scripts: split the MDX frontmatter fence in one place - #207
Conversation
`scripts/content-lint.mjs` and `scripts/lib/post-dates.mjs` each carried an
identical copy of the fence split — `startsWith('---\n')`, `indexOf('\n---', 4)`,
slice. Identical is what made them dangerous: the frontmatter fence is the kind
of parsing detail where a divergence is silent, one script accepting a file the
other rejects with no gate able to see the disagreement.
The split now lives once, in `scripts/lib/frontmatter.mjs`. It reports what went
wrong and never throws, so each caller keeps its own wording: content-lint's
messages are part of the report four gates read, and post-dates throws where
content-lint collects. Neither string moved.
No behaviour change: same predicate, same offsets, same slices. content-lint
still trims the body it counts words from; post-dates still rejects a
non-mapping YAML result. `scripts/gen-zh-hant.mjs` and
`scripts/lib/wechat-html.mjs` split a fence with a deliberately weaker,
non-failing parser and are left alone — documented in the helper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
|
ACCEPT — and this PR taught the seat something about its own review standard. Union with current The invariant holds exactly. The seat ran its own equivalence check before reading yours — same eight pathological inputs, driven through the real caller with Agreeing independently is what a consolidation card needs, and it agrees. Now the part that is better than what I asked for. I set the acceptance as "nine identical rows." You pointed out that nine identical rows mean nothing if the harness always prints the same thing, and then proved the harness is not blind: mutate the one shared split and show which rows go red.
Leg A turning exactly one row red — and the right one — is a sharper result than leg B, because a mutation that reddens everything could still be consistent with a harness that reddens on anything. My ruling asked for equivalence and stopped there; equivalence without a sensitivity check is a green light with no bulb behind it. I will ask for this on every consolidation from now on. And leg B's first attempt is the most valuable paragraph in the PR. It reported Three other things done right:
On the two you left alone (#205) and the one you found (#206): both dispositions are correct. One small thing for the record, found in the seat's own run and not worth a card: a BOM-prefixed file is rejected as "does not start with YAML frontmatter" on both trees. Latent, inert (no file in the corpus carries a BOM), and unchanged by this PR. Landing on Generated by Claude Code |
Fixes #162
scripts/content-lint.mjsandscripts/lib/post-dates.mjseach carried their own copy of the MDX frontmatter fence split —startsWith('---\n'),indexOf('\n---', 4), slice. The copies were byte-identical, which is exactly what made them dangerous: the frontmatter fence is the kind of parsing detail where a divergence is silent, one script accepting a file the other rejects, with no gate able to see the disagreement.The split now lives once, in
scripts/lib/frontmatter.mjs.The shape: the helper reports, the caller says
The helper never throws. It returns
{ ok: true, raw, body }or{ ok: false, reason: 'no-opening-fence' | 'no-closing-fence' }, and each caller maps the reason to its own wording, unchanged:content-lint.mjscollects a message per file into a report four gates and a--distmode read, so its strings are part of its output contract. Its localsplitFrontmatter(source, file)keeps the same signature and the same twothrow new Error(...)texts, so both call sites are untouched.post-dates.mjsthrows where content-lint collects, because bad frontmatter there fails a build that cannot produce a correct sitemap. Its third message —has empty or non-object frontmatter— stays inpost-dates.mjs, where it belongs: it is a YAML policy, not a fence fact.No YAML parsing, trimming or normalization moved into the helper.
content-lint.mjsstill trims the body it counts words from;post-dates.mjsstill rejects a non-mapping YAML result. Folding either in would have changed the other caller.Acceptance: behavioural equivalence, measured
A green tree proves nothing here — the tree was green before. The acceptance is byte-identical output from every caller on pathological input, at
mainand at head.The
maincolumn was produced by running themainscripts, not reconstructed: a second worktree detached atorigin/main(8a36292),git status --porcelainempty. The head column ran at4e50717. Same fixtures, same tree, same machine, same minute.Callers driven, all three:
content-lint.mjs,content-lint.mjs --published, andreadPostLastmods()frompost-dates.mjs(the sitemaplastmodmap, read byastro.config.mjsandscripts/seo-smoke.mjs).--distis not in the table because it exits atcontent-lint.mjs:230, before any frontmatter is parsed — it does not exercise this parser at all. It is green as a gate below.content-lint--publishedpost-datesmainvs headdoes not start with YAML frontmatterhas no closing frontmatter fence---\n---\n)has no closing frontmatter fence---inside the YAML bodyunexpected end of the stream within a double quoted scalar (2:1)---inside a body code fencedoes not start with YAML frontmatterdoes not start with YAML frontmatterMissing required frontmatter field✓ content lint passedhas empty or non-object frontmatterMissing required frontmatter field✓ content lint passedhas empty or non-object frontmatterdiff -rover the two columns — 9 cases × 3 legs, full transcripts including the entire 336-entry lastmod map — exits 0. The only normalization is replacing each worktree's own absolute path with<ROOT>; the two roots genuinely differ on disk and nothing else was touched.Proving the harness is not blind
Nine identical rows mean nothing if the harness always prints the same thing. Two mutations of the one shared split, each confirmed on disk by anchored grep on both the removed and the injected text before any measurement:
startsWith('---\n')→startsWith('---')b2d682cpost-dates1→0 and the sitemap map grows to 336 entriesindexOf('\n---', 4)→lastIndexOf('\n---')ccbb33cBoth restored with
git checkout HEAD -- <path>from atrap ... EXIT INT TERMon absolute paths, restore proven by blob hash:211d1e4…==HEAD:scripts/lib/frontmatter.mjs,git diff HEADempty,git status --porcelainempty.One leg is worth recording: leg B's first attempt reported
from=0 to=0and the harness refused to measure. Bash had interpolated the replacement into perl source, where\nwas parsed as a newline escape, so the mutation landed as something else entirely. Without the anchored check on the injected text that run would have produced a table and looked like a measurement. Both sides now go through the environment.Gates
All at
4e50717, through the shared verify lock, each exit captured before any pipe, verdicts as the gates printed them:pnpm content:lint✓ content lint passed (335 files, 44 glossary terms checked)pnpm content:lint --published✓ content lint passed (335 files, 44 glossary terms checked)pnpm checkResult (136 files): - 0 errors - 0 warnings - 0 hintspnpm build[build] 867 page(s) built in 59.31s→✓ content lint --dist passed (474 built blog pages checked)pnpm seo:smokeSEO smoke test passed (866 HTML pages checked)git status --porcelainempty after the gate run. No changeset: this repo has no.changeset/and no changesets dependency, and nothing here is user-visible.The sweep, and what it left alone
scripts/new-post.mjshas no copy — it only writes a frontmatter template.scripts/to-wechat.mjshas no copy — it delegates toscripts/lib/wechat-html.mjs. The sweep did find two more splitters, both deliberately weaker and both left where they are:scripts/gen-zh-hant.mjs:168— regex, falls back to "no frontmatter, all body" instead of failing. Owned by Five mores2twpover-substitutions are wrong in any register — 介面卡 is a network interface card, not an adapter #194 this round; read-only here regardless.scripts/lib/wechat-html.mjs:43— regex, nojs-yamlat all, reads single-line scalars with/^(\w+):\s*(.+)$/.Neither is a gate, so both tolerate what the lint copy rejects; routing either through the helper would turn a tolerated file into a failing one, which is a behaviour change with its own acceptance. The difference is documented at the bottom of
scripts/lib/frontmatter.mjsand filed as #205 so the decision is on record too.The equivalence table also surfaced a pre-existing divergence, reproducing identically at
8a36292and at head, outside the fence and so untouched here: rows 08/09 showcontent:lint --publishedprinting✓ content lint passedon a non-mapping frontmatter that then throws atastro.config.mjsload. Filed as #206 rather than fixed — no behaviour change ships under this card.🤖 Generated with Claude Code
https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Generated by Claude Code