OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / fff49ad

fix: a facet ending at a block boundary was dropped — found by the CLI
normalise() mapped only KEPT characters, so a facet whose end landed on a character 5 deleted (the newlines a block separator is emitted from) had map[end] = -1 and the offset conversion discarded the whole facet. 'a #p2p' produced a tag facet; 'a #p2p\n\nmore' produced none. Filling the gap forward then made the facet SWALLOW the separator - byteEnd 8 where 6 was correct. Both wrong in opposite directions, which is the tell that one map cannot serve both ends: startMap a deleted start moves FORWARD to the next kept character endMap a deleted end stays BEHIND, just past the last kept one Two vectors pin it (43 total). No existing vector had a facet at the end of a non-final block and the fuzzer only generates single-block input, so nothing covered it - the CLI found it on its first real file, which is what library 9 says the CLI is for. Adds lint() + the bin/ shell: strip and lint work, project and explain exit 3 rather than pretending. 33 tests, 2281 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fff49ad8401d20af8b903476badd49445490b589
by plankatron <fleeky@gmail.com> · 2026-09-06T00:52:48.000Z

Changed files (6) +384 -8

bin/favela-markdown.js +79 -0
……@@ -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
10import { readFileSync } from 'node:fs'
11import process from 'node:process'
12import { strip } from '../lib/strip.js'
13import { lint } from '../lib/lint.js'
14import { parse } from '../lib/record.js'
15
16const 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
28const SEV = { error: '⛔', warn: '⚠ ', info: ' ·' }
29
30function 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
40function flag (argv, name) {
41 const i = argv.indexOf(name)
42 return i >= 0 ? argv[i + 1] : null
43}
44
45const [cmd, ...rest] = process.argv.slice(2)
46const file = rest.find((a) => !a.startsWith('--')) || null
47
48switch (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}
lib/lint.js +121 -0
……@@ -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
14import { parse } from './record.js'
15import { strip } from './strip.js'
16import { 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.
20const KNOWN_DIRECTIVES = ['spoiler', 'cw', 'details']
21
22const DIRECTIVE_RE = /^:::([A-Za-z][\w-]*)/gm
23const BARE_MENTION_RE = /(^|\s)@[A-Za-z0-9][\w.-]*/g
24const NON_HTTP_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|favela:\/\/|did:)[^)]+\)/g
25
26const 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 */
36export 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
121export default { lint }
lib/strip.js +41 -7
……@@ -403,20 +403,54 @@ function normalise (text) {
403403 }
404404 map[text.length] = out.length
405405
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
406434 // §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.
408436 const lead = out.length - out.replace(/^\s+/, '').length
409437 if (lead > 0) {
410438 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 }
412443 }
413444 const trimmed = out.replace(/\s+$/, '')
414445 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 }
416450 out = trimmed
417451 }
418452
419 return { text: out, map }
453 return { text: out, startMap, endMap }
420454}
421455
422456const byteLen = (s) => {
……@@ -463,13 +497,13 @@ export function strip (source) {
463497 return { text: '', facets: [] }
464498 }
465499
466 const { text, map } = normalise(doc.text)
500 const { text, startMap, endMap } = normalise(doc.text)
467501
468502 // §6.4 — offsets are computed against the FINAL text, and only now converted to bytes.
469503 const facets = []
470504 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]
473507 if (s === undefined || e === undefined || s < 0 || e < 0 || e <= s) continue // §6.3
474508 facets.push({
475509 index: { byteStart: byteLen(text.slice(0, s)), byteEnd: byteLen(text.slice(0, e)) },
package.json +4 -1
……@@ -16,6 +16,9 @@
1616 "micromark-extension-gfm-table": "^2.1.1"
1717 },
1818 "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"
2023 }
2124}
spec/vectors/markdown-strip.json +28 -0
……@@ -378,6 +378,34 @@
378378 "value": "did:plc:xyz"
379379 }
380380 ]
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 ]
381409 }
382410 ]
383411}
test/lint.test.js +111 -0
……@@ -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
11import test from 'brittle'
12import { lint } from '../lib/lint.js'
13
14const codes = (r) => r.issues.map((i) => i.code).sort()
15const bySeverity = (r, s) => r.issues.filter((i) => i.severity === s).map((i) => i.code)
16
17const GOOD = `---
18{
19 "createdAt": "2026-08-05T13:38:23.294Z",
20 "langs": [
21 "en"
22 ]
23}
24---
25
26hello **world** #p2p
27`
28
29test('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
35test('⛔ 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
42test('⛔ 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
48test('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
53test('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
62test('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
69test('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
75test('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
80test('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
85test('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![a cat](favela://blob/abc)\n')
88 t.ok(r.ok, 'an image-only post is valid')
89 t.ok(bySeverity(r, 'info').includes('empty-text'))
90})
91
92test('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
103test('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})