| … | … | @@ -0,0 +1,79 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | // favela-markdown — the CLI. library.md §9. | |
| 3 | // | |
| 4 | // "Every projection defect so far has been diagnosed by reading daemon logs. This makes it a | |
| 5 | // one-liner against a file, offline." | |
| 6 | // | |
| 7 | // ⚠ This file MAY use Node builtins; lib/ may not (§3 — the peer imports lib/ under Bare, and | |
| 8 | // nothing imports bin/). Keep the boundary: all logic lives in lib/, this is argv and printing. | |
| 9 | ||
| 10 | import { readFileSync } from 'node:fs' | |
| 11 | import process from 'node:process' | |
| 12 | import { strip } from '../lib/strip.js' | |
| 13 | import { lint } from '../lib/lint.js' | |
| 14 | import { parse } from '../lib/record.js' | |
| 15 | ||
| 16 | const USAGE = `favela-markdown — the record codec, offline | |
| 17 | ||
| 18 | favela-markdown strip <file> text + facets | |
| 19 | favela-markdown lint <file> [--room KIND] frontmatter, reach, directives, mentions | |
| 20 | favela-markdown project <file> --target T not implemented — needs a projection target | |
| 21 | favela-markdown explain <file> not implemented — needs a projection target | |
| 22 | ||
| 23 | --room KIND public | followers | members | dm | |
| 24 | Without it, lint makes NO claim about reach: the room is what sets the | |
| 25 | ceiling, and a file on disk does not say which room it belongs to. | |
| 26 | ` | |
| 27 | ||
| 28 | const SEV = { error: '⛔', warn: '⚠ ', info: ' ·' } | |
| 29 | ||
| 30 | function read (file) { | |
| 31 | if (!file) { process.stderr.write('missing <file>\n\n' + USAGE); process.exit(2) } | |
| 32 | try { | |
| 33 | return readFileSync(file === '-' ? 0 : file, 'utf8') | |
| 34 | } catch (err) { | |
| 35 | process.stderr.write('cannot read ' + file + ': ' + err.message + '\n') | |
| 36 | process.exit(2) | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | function flag (argv, name) { | |
| 41 | const i = argv.indexOf(name) | |
| 42 | return i >= 0 ? argv[i + 1] : null | |
| 43 | } | |
| 44 | ||
| 45 | const [cmd, ...rest] = process.argv.slice(2) | |
| 46 | const file = rest.find((a) => !a.startsWith('--')) || null | |
| 47 | ||
| 48 | switch (cmd) { | |
| 49 | case 'strip': { | |
| 50 | const rec = parse(read(file)) | |
| 51 | const { text, facets } = strip(rec.body) | |
| 52 | process.stdout.write(JSON.stringify({ text, facets }, null, 2) + '\n') | |
| 53 | break | |
| 54 | } | |
| 55 | ||
| 56 | case 'lint': { | |
| 57 | const r = lint(read(file), { roomType: flag(rest, '--room') || undefined }) | |
| 58 | for (const i of r.issues) { | |
| 59 | process.stdout.write(`${SEV[i.severity]} ${i.code} ${i.message}\n ${i.spec}\n`) | |
| 60 | } | |
| 61 | if (!r.issues.length) process.stdout.write('· clean\n') | |
| 62 | process.stdout.write( | |
| 63 | `\n${r.ok ? 'ok' : 'NOT OK'} · ${r.publishable ? 'publishable' : 'NOT publishable'}` + | |
| 64 | (flag(rest, '--room') ? '' : ' · reach unchecked (pass --room to check it)') + '\n') | |
| 65 | process.exit(r.ok ? 0 : 1) | |
| 66 | } | |
| 67 | ||
| 68 | case 'project': | |
| 69 | case 'explain': | |
| 70 | // Honest rather than a stub that prints nothing: these need a projection target, and | |
| 71 | // project()/fromAS2()/fromBsky() are not written yet. | |
| 72 | process.stderr.write(`'${cmd}' is not implemented — it needs project(), which needs a target.\n`) | |
| 73 | process.exit(3) | |
| 74 | break | |
| 75 | ||
| 76 | default: | |
| 77 | process.stdout.write(USAGE) | |
| 78 | process.exit(cmd ? 2 : 0) | |
| 79 | } |
| … | … | @@ -0,0 +1,121 @@ |
| 1 | // lint — library.md §9. "Every projection defect so far has been diagnosed by reading daemon logs. | |
| 2 | // This makes it a one-liner against a file, offline." | |
| 3 | // | |
| 4 | // The logic lives here rather than in bin/ for two reasons: it is the part worth testing, and lib/ | |
| 5 | // must stay free of Node builtins so the peer can import it under Bare (§3). bin/ is a thin shell. | |
| 6 | // | |
| 7 | // SEVERITY IS PART OF THE CONTRACT, not decoration: | |
| 8 | // error — the record cannot be published. §6.2 fails closed; publishing anyway is a privacy or | |
| 9 | // correctness bug, not a stylistic one. | |
| 10 | // warn — it publishes, and something is lost. §5.3's honesty requirement made mechanical. | |
| 11 | // info — a fact the author should know that costs nothing. | |
| 12 | // Conflating them is how "it lints clean" comes to mean nothing. | |
| 13 | ||
| 14 | import { parse } from './record.js' | |
| 15 | import { strip } from './strip.js' | |
| 16 | import { maxReach, NETWORKS } from './reach.js' | |
| 17 | ||
| 18 | // §5 of library.md pins the directive set. An unknown NAME is legal — §4.8 yields its contents — so | |
| 19 | // this is info, not an error. The set is pinned so that "did you mean :::spoiler" is answerable. | |
| 20 | const KNOWN_DIRECTIVES = ['spoiler', 'cw', 'details'] | |
| 21 | ||
| 22 | const DIRECTIVE_RE = /^:::([A-Za-z][\w-]*)/gm | |
| 23 | const BARE_MENTION_RE = /(^|\s)@[A-Za-z0-9][\w.-]*/g | |
| 24 | const NON_HTTP_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|favela:\/\/|did:)[^)]+\)/g | |
| 25 | ||
| 26 | const issue = (code, severity, spec, message, extra) => | |
| 27 | Object.assign({ code, severity, spec, message }, extra || {}) | |
| 28 | ||
| 29 | /** | |
| 30 | * lint(source, { roomType } = {}) -> { ok, publishable, issues, text, facets } | |
| 31 | * | |
| 32 | * TOTAL — never throws. `roomType` is optional: without it no claim is made about reach, because | |
| 33 | * the room is what determines the ceiling (SOCIAL-MODEL §4) and a CLI reading a loose file cannot | |
| 34 | * know which room it belongs to. Guessing would be worse than declining. | |
| 35 | */ | |
| 36 | export function lint (source, opts = {}) { | |
| 37 | const issues = [] | |
| 38 | const src = String(source == null ? '' : source) | |
| 39 | ||
| 40 | const rec = parse(src) | |
| 41 | for (const e of rec.errors) { | |
| 42 | issues.push(issue(e.code, 'error', 'library §6.2', e.message)) | |
| 43 | } | |
| 44 | ||
| 45 | const fm = rec.frontmatter || {} | |
| 46 | ||
| 47 | // reach: unknown networks are named rather than dropped. A silently ignored network is | |
| 48 | // indistinguishable from one that failed to deliver (record-vocabulary §5.3). | |
| 49 | const reach = Array.isArray(fm.reach) ? fm.reach : [] | |
| 50 | for (const n of reach) { | |
| 51 | if (!NETWORKS.includes(n)) { | |
| 52 | issues.push(issue('unknown-network', 'error', 'record-vocabulary §5.1', | |
| 53 | `reach names "${n}", which is not a network this build knows`, { network: n })) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | // The ceiling is the ROOM's, so this is only checkable when the caller supplies one. | |
| 58 | if (typeof opts.roomType === 'string') { | |
| 59 | const ceiling = maxReach({ roomType: opts.roomType, verb: opts.verb || 'post' }) | |
| 60 | for (const n of reach) { | |
| 61 | if (NETWORKS.includes(n) && !ceiling.has(n)) { | |
| 62 | issues.push(issue('reach-exceeds-room', 'error', 'library §6.1', | |
| 63 | `reach names "${n}" but a ${opts.roomType} room cannot reach it`, { network: n })) | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | let text = '' | |
| 69 | let facets = [] | |
| 70 | try { | |
| 71 | const s = strip(rec.body) | |
| 72 | text = s.text | |
| 73 | facets = s.facets | |
| 74 | } catch (err) { | |
| 75 | issues.push(issue('strip-failed', 'error', 'strip §2', 'strip threw, which it must never do: ' + err.message)) | |
| 76 | } | |
| 77 | ||
| 78 | // §5 — the spoiler asymmetry, which is the thesis in one worked example: the SAME directive is | |
| 79 | // lossless to the fediverse (summary + sensitive) and lossy to bsky (revealed as inner text), and | |
| 80 | // today nothing tells the author. | |
| 81 | const names = [] | |
| 82 | let m | |
| 83 | DIRECTIVE_RE.lastIndex = 0 | |
| 84 | while ((m = DIRECTIVE_RE.exec(rec.body)) !== null) names.push(m[1]) | |
| 85 | for (const name of names) { | |
| 86 | if (name === 'spoiler' || name === 'cw') { | |
| 87 | issues.push(issue('spoiler-revealed-on-bsky', 'warn', 'library §5', | |
| 88 | `:::${name} projects as its inner text on bsky — the post arrives un-concealed there`)) | |
| 89 | } else if (!KNOWN_DIRECTIVES.includes(name)) { | |
| 90 | issues.push(issue('unknown-directive', 'info', 'strip §4.8', | |
| 91 | `:::${name} is not in the pinned set — its contents are kept and the fence is dropped`, | |
| 92 | { name })) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | // §3.4 — a mention resolves or is omitted, never guessed. A bare @name is plain text, which is | |
| 97 | // correct and also probably not what the author meant. | |
| 98 | BARE_MENTION_RE.lastIndex = 0 | |
| 99 | if (BARE_MENTION_RE.test(rec.body)) { | |
| 100 | issues.push(issue('unresolved-mention', 'info', 'strip §3.4', | |
| 101 | 'a bare @name is plain text with no facet — a mention needs a favela:// target')) | |
| 102 | } | |
| 103 | ||
| 104 | // §3.2 — a non-http target emits the label as plain text with NO facet. Silently. | |
| 105 | NON_HTTP_LINK_RE.lastIndex = 0 | |
| 106 | if (NON_HTTP_LINK_RE.test(rec.body)) { | |
| 107 | issues.push(issue('link-refused', 'warn', 'strip §3.2', | |
| 108 | 'a link target that is not http/https/favela/did emits its label as plain text, with no link')) | |
| 109 | } | |
| 110 | ||
| 111 | // §3.5 — an image-only post strips to empty text, and that is VALID. Worth saying, not flagging. | |
| 112 | if (text.length === 0 && rec.body.trim().length > 0) { | |
| 113 | issues.push(issue('empty-text', 'info', 'strip §3.5', | |
| 114 | 'this record strips to empty text — valid for an image-only post')) | |
| 115 | } | |
| 116 | ||
| 117 | const ok = !issues.some((i) => i.severity === 'error') | |
| 118 | return { ok, publishable: rec.publishable, issues, text, facets } | |
| 119 | } | |
| 120 | ||
| 121 | export default { lint } |
| … | … | @@ -403,20 +403,54 @@ function normalise (text) { |
| 403 | 403 | } |
| 404 | 404 | map[text.length] = out.length |
| 405 | 405 | |
| 406 | // ⛔ TWO maps, because a deleted character means OPPOSITE things at each end of a range. | |
| 407 | // | |
| 408 | // Only KEPT characters were mapped above; every character §5 deleted — the newlines a block | |
| 409 | // separator is emitted from, trailing whitespace, collapsed blank lines — still has an index a | |
| 410 | // facet range may land on. A facet ending exactly at a block boundary has its `end` on such a | |
| 411 | // character. Leaving it unmapped DROPPED the facet; mapping it forward to the next kept character | |
| 412 | // made the facet SWALLOW the separator (byteEnd 8 where 6 was correct). Both were wrong, in | |
| 413 | // opposite directions, which is the tell that one map cannot serve both ends. | |
| 414 | // | |
| 415 | // startMap a deleted start moves FORWARD to the next kept character — the range begins at | |
| 416 | // real content rather than in a gap. | |
| 417 | // endMap a deleted end stays BEHIND, just past the last kept character before it — the | |
| 418 | // range ends where its content ended, not where the next one begins. | |
| 419 | // | |
| 420 | // Caught by `facet-at-end-of-block`: no vector had a facet at the end of a non-final block, and | |
| 421 | // the fuzzer only generates single-block input. The CLI found it on its first real file. | |
| 422 | const startMap = new Array(text.length + 1) | |
| 423 | const endMap = new Array(text.length + 1) | |
| 424 | ||
| 425 | startMap[text.length] = out.length | |
| 426 | for (let i = text.length - 1; i >= 0; i--) { | |
| 427 | startMap[i] = map[i] >= 0 ? map[i] : startMap[i + 1] | |
| 428 | } | |
| 429 | endMap[0] = 0 | |
| 430 | for (let i = 1; i <= text.length; i++) { | |
| 431 | endMap[i] = map[i - 1] >= 0 ? map[i - 1] + 1 : endMap[i - 1] | |
| 432 | } | |
| 433 | ||
| 406 | 434 | // §5.4 — leading whitespace of the first kept line is not removed by the per-line pass above |
| 407 | // (only trailing is), so trim the assembled result and shift the map to match. | |
| 435 | // (only trailing is), so trim the assembled result and shift both maps to match. | |
| 408 | 436 | const lead = out.length - out.replace(/^\s+/, '').length |
| 409 | 437 | if (lead > 0) { |
| 410 | 438 | out = out.slice(lead) |
| 411 | for (let i = 0; i < map.length; i++) if (map[i] >= 0) map[i] = Math.max(0, map[i] - lead) | |
| 439 | for (let i = 0; i < startMap.length; i++) { | |
| 440 | startMap[i] = Math.max(0, startMap[i] - lead) | |
| 441 | endMap[i] = Math.max(0, endMap[i] - lead) | |
| 442 | } | |
| 412 | 443 | } |
| 413 | 444 | const trimmed = out.replace(/\s+$/, '') |
| 414 | 445 | if (trimmed.length !== out.length) { |
| 415 | for (let i = 0; i < map.length; i++) if (map[i] > trimmed.length) map[i] = trimmed.length | |
| 446 | for (let i = 0; i < startMap.length; i++) { | |
| 447 | if (startMap[i] > trimmed.length) startMap[i] = trimmed.length | |
| 448 | if (endMap[i] > trimmed.length) endMap[i] = trimmed.length | |
| 449 | } | |
| 416 | 450 | out = trimmed |
| 417 | 451 | } |
| 418 | 452 | |
| 419 | return { text: out, map } | |
| 453 | return { text: out, startMap, endMap } | |
| 420 | 454 | } |
| 421 | 455 | |
| 422 | 456 | const byteLen = (s) => { |
| … | … | @@ -463,13 +497,13 @@ export function strip (source) { |
| 463 | 497 | return { text: '', facets: [] } |
| 464 | 498 | } |
| 465 | 499 | |
| 466 | const { text, map } = normalise(doc.text) | |
| 500 | const { text, startMap, endMap } = normalise(doc.text) | |
| 467 | 501 | |
| 468 | 502 | // §6.4 — offsets are computed against the FINAL text, and only now converted to bytes. |
| 469 | 503 | const facets = [] |
| 470 | 504 | for (const m of doc.marks) { |
| 471 | const s = map[m.start] | |
| 472 | const e = map[m.end] | |
| 505 | const s = startMap[m.start] | |
| 506 | const e = endMap[m.end] | |
| 473 | 507 | if (s === undefined || e === undefined || s < 0 || e < 0 || e <= s) continue // §6.3 |
| 474 | 508 | facets.push({ |
| 475 | 509 | index: { byteStart: byteLen(text.slice(0, s)), byteEnd: byteLen(text.slice(0, e)) }, |
| … | … | @@ -16,6 +16,9 @@ |
| 16 | 16 | "micromark-extension-gfm-table": "^2.1.1" |
| 17 | 17 | }, |
| 18 | 18 | "scripts": { |
| 19 | "test": "node test/strip.test.js && node test/record.test.js && node test/reach.test.js && node test/strip-fuzz.test.js" | |
| 19 | "test": "node test/strip.test.js && node test/record.test.js && node test/reach.test.js && node test/lint.test.js && node test/strip-fuzz.test.js" | |
| 20 | }, | |
| 21 | "bin": { | |
| 22 | "favela-markdown": "bin/favela-markdown.js" | |
| 20 | 23 | } |
| 21 | 24 | } |
| … | … | @@ -378,6 +378,34 @@ |
| 378 | 378 | "value": "did:plc:xyz" |
| 379 | 379 | } |
| 380 | 380 | ] |
| 381 | }, | |
| 382 | { | |
| 383 | "name": "facet-at-end-of-block", | |
| 384 | "why": "§6.4 offsets map through §5 normalisation — a facet ending at a BLOCK BOUNDARY must survive it", | |
| 385 | "source": "a #p2p\n\nmore", | |
| 386 | "text": "a #p2p\n\nmore", | |
| 387 | "facets": [ | |
| 388 | { | |
| 389 | "byteStart": 2, | |
| 390 | "byteEnd": 6, | |
| 391 | "type": "tag", | |
| 392 | "value": "p2p" | |
| 393 | } | |
| 394 | ] | |
| 395 | }, | |
| 396 | { | |
| 397 | "name": "link-at-end-of-block", | |
| 398 | "why": "§6.4 the same for a link facet — the normalisation index map must cover dropped characters", | |
| 399 | "source": "see [here](https://x.com)\n\nnext", | |
| 400 | "text": "see here\n\nnext", | |
| 401 | "facets": [ | |
| 402 | { | |
| 403 | "byteStart": 4, | |
| 404 | "byteEnd": 8, | |
| 405 | "type": "link", | |
| 406 | "value": "https://x.com" | |
| 407 | } | |
| 408 | ] | |
| 381 | 409 | } |
| 382 | 410 | ] |
| 383 | 411 | } |
| … | … | @@ -0,0 +1,111 @@ |
| 1 | // lint — library.md §9. "Every projection defect so far has been diagnosed by reading daemon logs. | |
| 2 | // This makes it a one-liner against a file, offline." | |
| 3 | // | |
| 4 | // The lint LOGIC lives in lib/ and the CLI is a thin argv shell over it, so the thing worth testing | |
| 5 | // is testable and the portable half stays free of Node builtins (§3). | |
| 6 | // | |
| 7 | // Severity matters and is asserted: an `error` means the record cannot be published (§6.2 fails | |
| 8 | // closed), a `warn` means it publishes but loses something, and `info` is a fact worth surfacing. | |
| 9 | // Conflating them is how "it lints clean" comes to mean nothing. | |
| 10 | ||
| 11 | import test from 'brittle' | |
| 12 | import { lint } from '../lib/lint.js' | |
| 13 | ||
| 14 | const codes = (r) => r.issues.map((i) => i.code).sort() | |
| 15 | const bySeverity = (r, s) => r.issues.filter((i) => i.severity === s).map((i) => i.code) | |
| 16 | ||
| 17 | const GOOD = `--- | |
| 18 | { | |
| 19 | "createdAt": "2026-08-05T13:38:23.294Z", | |
| 20 | "langs": [ | |
| 21 | "en" | |
| 22 | ] | |
| 23 | } | |
| 24 | --- | |
| 25 | ||
| 26 | hello **world** #p2p | |
| 27 | ` | |
| 28 | ||
| 29 | test('a well-formed record lints clean', (t) => { | |
| 30 | const r = lint(GOOD) | |
| 31 | t.ok(r.ok, 'ok') | |
| 32 | t.alike(bySeverity(r, 'error'), [], 'no errors') | |
| 33 | }) | |
| 34 | ||
| 35 | test('⛔ malformed frontmatter is an ERROR and blocks publication (§6.2)', (t) => { | |
| 36 | const r = lint('---\n{ "createdAt": oops }\n---\n\nbody\n') | |
| 37 | t.absent(r.ok) | |
| 38 | t.ok(bySeverity(r, 'error').includes('frontmatter-invalid-json')) | |
| 39 | t.ok(r.publishable === false, 'and the record is not publishable') | |
| 40 | }) | |
| 41 | ||
| 42 | test('⛔ a missing createdAt is an ERROR — nothing may be published without it', (t) => { | |
| 43 | const r = lint('---\n{}\n---\n\nbody\n') | |
| 44 | t.absent(r.ok) | |
| 45 | t.ok(bySeverity(r, 'error').includes('no-created-at')) | |
| 46 | }) | |
| 47 | ||
| 48 | test('an unknown network in reach is an ERROR, not a silent drop', (t) => { | |
| 49 | const r = lint('---\n{\n "createdAt": "T",\n "reach": ["bsky", "myspace"]\n}\n---\n\nbody\n') | |
| 50 | t.ok(bySeverity(r, 'error').includes('unknown-network'), 'names the network rather than ignoring it') | |
| 51 | }) | |
| 52 | ||
| 53 | test('reach is reported against the room ceiling only when the room is KNOWN', (t) => { | |
| 54 | const src = '---\n{\n "createdAt": "T",\n "reach": ["bsky"]\n}\n---\n\nbody\n' | |
| 55 | t.alike(bySeverity(lint(src), 'error'), [], 'without a room, no claim is made — the CLI cannot know') | |
| 56 | ||
| 57 | const withRoom = lint(src, { roomType: 'followers' }) | |
| 58 | t.ok(bySeverity(withRoom, 'error').includes('reach-exceeds-room'), | |
| 59 | '⛔ a followers room can never reach bsky, and saying so is the whole point') | |
| 60 | }) | |
| 61 | ||
| 62 | test('a spoiler is a WARN — it arrives un-spoilered on bsky (§5, the thesis in one example)', (t) => { | |
| 63 | const r = lint('---\n{\n "createdAt": "T"\n}\n---\n\n:::spoiler\nthe ending\n:::\n') | |
| 64 | t.ok(bySeverity(r, 'warn').includes('spoiler-revealed-on-bsky'), | |
| 65 | 'lossless to the fediverse, lossy to bsky, and nothing else tells the author') | |
| 66 | t.ok(r.ok, 'a warning does not block publication') | |
| 67 | }) | |
| 68 | ||
| 69 | test('an unknown directive name is INFO — it still yields its contents (§4.8)', (t) => { | |
| 70 | const r = lint('---\n{\n "createdAt": "T"\n}\n---\n\n:::wat\nbody\n:::\n') | |
| 71 | t.ok(bySeverity(r, 'info').includes('unknown-directive')) | |
| 72 | t.ok(r.ok) | |
| 73 | }) | |
| 74 | ||
| 75 | test('a bare @mention is INFO — it resolves or is omitted, never guessed (§3.4)', (t) => { | |
| 76 | const r = lint('---\n{\n "createdAt": "T"\n}\n---\n\nhi @alice\n') | |
| 77 | t.ok(bySeverity(r, 'info').includes('unresolved-mention')) | |
| 78 | }) | |
| 79 | ||
| 80 | test('a non-http link target is a WARN — the label survives, the link does not (§3.2)', (t) => { | |
| 81 | const r = lint('---\n{\n "createdAt": "T"\n}\n---\n\n[x](javascript:alert(1))\n') | |
| 82 | t.ok(bySeverity(r, 'warn').includes('link-refused')) | |
| 83 | }) | |
| 84 | ||
| 85 | test('an empty body with no embed is INFO, not an error (§3.5)', (t) => { | |
| 86 | // "A post whose entire source is one image strips to empty text, which is valid." | |
| 87 | const r = lint('---\n{\n "createdAt": "T"\n}\n---\n\n\n') | |
| 88 | t.ok(r.ok, 'an image-only post is valid') | |
| 89 | t.ok(bySeverity(r, 'info').includes('empty-text')) | |
| 90 | }) | |
| 91 | ||
| 92 | test('lint is TOTAL — never throws', (t) => { | |
| 93 | for (const s of ['', '---', '---\n---', 'no frontmatter', '---\n[]\n---\n', null, undefined]) { | |
| 94 | try { | |
| 95 | const r = lint(s) | |
| 96 | t.ok(Array.isArray(r.issues) && typeof r.ok === 'boolean', 'total on ' + JSON.stringify(s)) | |
| 97 | } catch (err) { | |
| 98 | t.fail('threw on ' + JSON.stringify(s) + ': ' + err.message) | |
| 99 | } | |
| 100 | } | |
| 101 | }) | |
| 102 | ||
| 103 | test('every issue carries a code, a severity and a spec reference', (t) => { | |
| 104 | const r = lint('---\n{ bad }\n---\n\n:::wat\nx\n:::\n\nhi @bob\n') | |
| 105 | t.ok(r.issues.length > 0) | |
| 106 | for (const i of r.issues) { | |
| 107 | t.ok(typeof i.code === 'string' && i.code.length > 0, 'code') | |
| 108 | t.ok(['error', 'warn', 'info'].includes(i.severity), 'severity is one of three') | |
| 109 | t.ok(typeof i.spec === 'string' && i.spec.length > 0, i.code + ' cites the spec section it enforces') | |
| 110 | } | |
| 111 | }) |