content-lint: derive all four frontmatter term sets from the taxonomy - #190
Conversation
`scripts/content-lint.mjs` restated `src/lib/term-data.ts`'s topic and role slugs as two literal Sets. They agreed, but they were two producers of one fact, and the drift they allowed is one-directional and lands on an author: add a term to the taxonomy, use it in a post, and this script rejects the post with `Invalid topic: …` while `astro check` and `astro build` accept it, because the Zod enums in `src/content.config.ts` derive from the taxonomy and these Sets did not. The message names the post; the file to edit was here. The literals existed because the script is plain Node and could not import `src/lib/terms.ts` (it value-imports extensionless `./i18n` and `./zhconvert`, which only Vite resolves). That constraint is gone: the taxonomy data now lives in `src/lib/term-data.ts`, which imports nothing, and this script already loaded it to resolve topic-hub links. So load it once, at module scope, and derive both Sets from `RAW_TERMS` — deleted, not kept beside the derivation as a cross-check, which would be the same duplication in a different hat. `readTermHubs` now consumes the same read instead of importing the module a second time, and the module-load failure message names both consumers. Error wording is unchanged: `Invalid topic: …` and `Invalid audience: …` are part of this script's output contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…onomy The script validated `topic` and `audience` and ignored the other two term fields entirely, so an author could write any string into `solutions` or `industries` and this gate stayed green — the same defect as checking against a stale list, only further from being noticed, since the first objection came from `astro build`. Now that the taxonomy is read here rather than restated, covering all four groups is the same derivation applied twice more. This is an added check, not part of the de-duplication, which is why it is its own commit. It turns no existing post red: all 335 published files pass, as they must — `src/content.config.ts` has always enforced these two fields via `z.array(z.enum(...))`, just later and less legibly than here. A non-list value is an error too, mirroring the schema: `.default([])` there substitutes for an absent field only, so `solutions:` with nothing after it (null) fails the build. Skipping it here would recreate the disagreement this card exists to remove, in the opposite direction. Wording follows the existing pair: `Invalid solution: …` / `Invalid industry: …`, naming the offending entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
|
ACCEPT — reviewed against the branch, with the seat's own ablation. Gate farm re-run by the seat at
The two decisive legs, re-run independently. Not a re-read of your table — one worktree, HEAD switched between
Four for four, in both directions. The first row is the defect the card was filed for, reproduced exactly: the gate rejecting a post the taxonomy and the build both accept, with a message pointing at the post while the file to edit was the script. The second row settles the question the ruling flagged — the Reviewed beyond the ablation:
One correction to make before this is read by anyone else: the body's On #191 itself — good find, correctly classified. The seat tried to reproduce it independently and got a confounded result: Landing on Generated by Claude Code |
Fixes #185
One file changed:
scripts/content-lint.mjs.src/lib/term-data.tsalready exported everything needed (RAW_TERMS,termSlugPath), so it is untouched, as aresrc/lib/terms.ts,src/lib/clusters.tsandsrc/content.config.ts.What changed, and why it is two commits
4c08160— deriveVALID_TOPICandVALID_AUDIENCEfromRAW_TERMS. The script restated the taxonomy's topic and role slugs as two literalSets. They agreed with the taxonomy, so nothing was red — but they were a second producer of one fact, and the drift they allowed runs one way and lands on an author: add a term, use it in a post, and this script rejects the post thatastro checkandastro buildaccept, because the Zod enums insrc/content.config.tsderive from the taxonomy and these twoSets did not. The message names the post; the file to edit was the script.The literals existed because the script is plain Node and could not import
src/lib/terms.ts(it value-imports extensionless./i18nand./zhconvert, Vite-only). #182 removed that constraint. The module is now loaded once at module scope and both sets fall out ofRAW_TERMS;readTermHubsconsumes that same read instead of importing a second time. The literals are deleted, not kept beside the derivation as a cross-check, and there is no assertion that the two agree — that would be the same duplication in a different hat.14a1ed1— checksolutionsandindustriesagainst the same source. This is an added check, not part of the de-duplication, which is why it is its own commit. The script ignored both fields entirely, so an author could write any string into them and this gate stayed green, leavingastro buildto be the first (and least legible) objection. A non-list value is an error too, mirroringz.array(z.enum(...)).default([]):.default([])substitutes for an absent field only, sosolutions:with nothing after it (null) fails the build — skipping it here would recreate the same disagreement in the opposite direction.It turns no existing post red. All 335 published files pass, as they must:
src/content.config.tshas always enforced these two fields, just later. So there is no content finding to report under ruling (2).Error wording is unchanged —
Invalid topic: …andInvalid audience: …are part of this script's output contract. The two new messages follow the same shape:Invalid solution: …,Invalid industry: ….Ablation
The tree is green today with both copies agreeing, so a green tree proves nothing. Every leg's direction was written down before it ran ("Predicted" below is transcribed from that note, not reconstructed after).
Mutations, all anchored and all refusing to be no-ops (the edit helper exits 3 unless its anchor occurs exactly once):
ablation-probeadded toRAW_TERMS, andcontent/blog/ai-agent-workbench/index.mdxmoved totopic: ablation-probe.industries: []→industries: [not-an-industry], topic untouched.solutions: []→solutions:(null).content-lint@435ff61(base copy)Invalid topic: ablation-probe✗ content/blog/ai-agent-workbench/index.mdx: Invalid topic: ablation-probecontent-lint@ this branch✓ content lint passed (335 files, 44 glossary terms checked)astro checkResult (135 files): 0 errors, 0 warnings, 0 hintsastro build868 page(s) built(867 on the clean tree; the extra page isdist/en/blog/topics/ablation-probe/index.html, confirmed present)content-lint@ this branchInvalid topic:againastro buildastro buildtopic: Invalid enum value. Expected 'ai-agents' | 'app-building' | 'integration-data' | 'automation' | 'modernization' | 'governance' | 'customer-stories', received 'ablation-probe'content-lint@435ff61(base copy)content-lint@ this branchInvalid industry: not-an-industrycontent-lint@ this branchsolutions must be a list of terms, got: nullastro buildInvalidContentEntryDataErrorfromgetEntryDataAndImages, i.e. D1's gap is a real one the build catches laterOn C2, reported rather than smoothed over. The leg failed in the predicted direction but at the wrong place: a render-time
Cannot read properties of undefined (reading 'group')intermSlugPath, not the schema. My first re-run cleared.astro/and reproduced the same crash — that removal was a no-op, because this project's content-layer store lives atnode_modules/.astro/data-store.json. Clearing the real one (C2c) produced the schema error as predicted. The mechanism is that the store is keyed on the entry file's own bytes, so an incremental build does not re-validate a post when only the schema source — the taxonomy — changed. CI is unaffected (fresh checkout, cold store); it is a local-build effect, recorded separately in #191 and not touched here.The three gates now agree on a post carrying a newly added term (A2/B1/B2 all pass), and they agree in the red direction too (C1/C2c both fail). That disagreement was the whole defect.
Restore discipline, every leg:
trapon absolute paths; the restore isgit checkout HEAD --followed by the absolute path, never the bare two-dash form, which restores from the index and hands the mutation straight back. Restore is proven per file by comparing the on-disk blob hash with the oneHEADholds for that path, an empty hash counting as a failure rather than a pass:Gates
All five re-run after the ablation, at final head
14a1ed1, tree clean, each through the shared verify lock, each exit code captured by redirecting to a file before any pipe. Verdict lines are the gates' own:pnpm content:lint✓ content lint passed (335 files, 44 glossary terms checked)·VERDICT command-exit 0 · held the lock 1spnpm content:lint --published✓ content lint passed (335 files, 44 glossary terms checked)·VERDICT command-exit 0 · held the lock 1spnpm checkResult (135 files): 0 errors, 0 warnings, 0 hints·VERDICT command-exit 0 · held the lock 9spnpm build[build] 867 page(s) built in 38.13s·[build] Complete!·VERDICT command-exit 0 · held the lock 40spnpm seo:smokeSEO smoke test passed (866 HTML pages checked)·VERDICT command-exit 0 · held the lock 2sgit status --porcelainis empty at14a1ed1;git rev-parse --short HEADfrom that run is14a1ed1. No browser pass: this card changes no rendered output, and the build emits the same 867 pages asmain.No changeset: this repository has no
.changeset/and its only workflow (.github/workflows/cloudflare-pages.yml) has no changeset step, so there is nothing to declare and noskip-changesetlabel to apply here.🤖 Generated with Claude Code
https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr