Skip to content

content-lint: derive all four frontmatter term sets from the taxonomy - #190

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-185-derive-lint-slug-sets
Sep 3, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/issue-185-derive-lint-slug-sets

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #185

One file changed: scripts/content-lint.mjs. src/lib/term-data.ts already exported everything needed (RAW_TERMS, termSlugPath), so it is untouched, as are src/lib/terms.ts, src/lib/clusters.ts and src/content.config.ts.

What changed, and why it is two commits

4c08160 — derive VALID_TOPIC and VALID_AUDIENCE from RAW_TERMS. The script restated the taxonomy's topic and role slugs as two literal Sets. 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 that astro check and astro build accept, because the Zod enums in src/content.config.ts derive from the taxonomy and these two Sets 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 ./i18n and ./zhconvert, Vite-only). #182 removed that constraint. The module is now loaded once at module scope and both sets fall out of RAW_TERMS; readTermHubs consumes 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 — check solutions and industries against 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, leaving astro build to be the first (and least legible) objection. A non-list value is an error too, mirroring z.array(z.enum(...)).default([]): .default([]) substitutes for an absent field only, so solutions: 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.ts has always enforced these two fields, just later. So there is no content finding to report under ruling (2).

Error wording is unchanged — Invalid topic: … and Invalid 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):

  • M1 — topic term ablation-probe added to RAW_TERMS, and content/blog/ai-agent-workbench/index.mdx moved to topic: ablation-probe.
  • M2 — the same post, taxonomy left alone: a post carrying a term nothing declares.
  • M3 — industries: [] → industries: [not-an-industry], topic untouched.
  • M4 — solutions: [] → solutions: (null).
Leg State Command Predicted Actual
A1 M1 content-lint @ 435ff61 (base copy) FAIL, Invalid topic: ablation-probe FAIL exit 1 — ✗ content/blog/ai-agent-workbench/index.mdx: Invalid topic: ablation-probe
A2 M1 content-lint @ this branch PASS PASS exit 0 — ✓ content lint passed (335 files, 44 glossary terms checked)
B1 M1 astro check PASS PASS exit 0 — Result (135 files): 0 errors, 0 warnings, 0 hints
B2 M1 astro build PASS, hub emitted PASS exit 0 — 868 page(s) built (867 on the clean tree; the extra page is dist/en/blog/topics/ablation-probe/index.html, confirmed present)
C1 M2 content-lint @ this branch FAIL, Invalid topic: again FAIL exit 1 — same message, so the derivation rejects again the moment the term leaves the taxonomy
C2 M2 astro build FAIL at the Zod enum FAIL exit 1, but not where predicted — see below
C2c M2, cold store astro build FAIL at the Zod enum FAIL exit 1 — topic: Invalid enum value. Expected 'ai-agents' | 'app-building' | 'integration-data' | 'automation' | 'modernization' | 'governance' | 'customer-stories', received 'ablation-probe'
D1 M3 content-lint @ 435ff61 (base copy) PASS (the gap) PASS exit 0 — the bogus industry is invisible to the gate before this PR
D2 M3 content-lint @ this branch FAIL FAIL exit 1 — Invalid industry: not-an-industry
D3 M4 content-lint @ this branch FAIL FAIL exit 1 — solutions must be a list of terms, got: null
D4 M3 astro build FAIL FAIL exit 1 — InvalidContentEntryDataError from getEntryDataAndImages, i.e. D1's gap is a real one the build catches later

On 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') in termSlugPath, 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 at node_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: trap on absolute paths; the restore is git 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 one HEAD holds for that path, an empty hash counting as a failure rather than a pass:

restored ok: src/lib/term-data.ts 9095da4579cb6aac32109cb073aac44d3878aa3e == HEAD
restored ok: content/blog/ai-agent-workbench/index.mdx 74e9acaf72c044dc8087e8b517c208b4e7ceb3f1 == HEAD
restored ok: git status --porcelain empty

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:

Gate Exit Verdict line
pnpm content:lint 0 ✓ content lint passed (335 files, 44 glossary terms checked) · VERDICT command-exit 0 · held the lock 1s
pnpm content:lint --published 0 ✓ content lint passed (335 files, 44 glossary terms checked) · VERDICT command-exit 0 · held the lock 1s
pnpm check 0 Result (135 files): 0 errors, 0 warnings, 0 hints · VERDICT command-exit 0 · held the lock 9s
pnpm build 0 [build] 867 page(s) built in 38.13s · [build] Complete! · VERDICT command-exit 0 · held the lock 40s
pnpm seo:smoke 0 SEO smoke test passed (866 HTML pages checked) · VERDICT command-exit 0 · held the lock 2s

git status --porcelain is empty at 14a1ed1; git rev-parse --short HEAD from that run is 14a1ed1. No browser pass: this card changes no rendered output, and the build emits the same 867 pages as main.

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 no skip-changeset label to apply here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

`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

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — reviewed against the branch, with the seat's own ablation.

Gate farm re-run by the seat at 14a1ed1 through the shared lock, exits captured before any pipe:

Gate Exit Verdict line
pnpm content:lint 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm content:lint --published 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm check 0 - 0 hints
pnpm build 0 [build] 867 page(s) built in 39.71s · ✓ zh-Hant: generated 46, kept 0 hand-maintained, 5 with body links pointed at /zh-Hant/
pnpm seo:smoke 0 SEO smoke test passed (866 HTML pages checked)

git status --porcelain 0 lines. PR check run Build and deploy green at this head.

The two decisive legs, re-run independently. Not a re-read of your table — one worktree, HEAD switched between origin/main and this branch, each mutation anchored and confirmed on disk before measuring, restore proven by blob hash (74e9acaf… post, 9095da45… term-data, both == HEAD), tree 0 lines afterwards:

Leg origin/main 435ff61 This branch 14a1ed1
taxonomy declares ablation-probe, a post uses it exit 1 — ✗ …/ai-agent-workbench/index.mdx: Invalid topic: ablation-probe exit 0 — ✓ content lint passed
industries: [not-an-industry], topic untouched exit 0 — ✓ content lint passed exit 1 — ✗ …: Invalid industry: not-an-industry

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 solutions/industries gap was real, not theoretical: before this PR the gate simply did not look.

Reviewed beyond the ablation:

  • The literals are deleted, not shadowed. No residual Set, no agreement assertion. That was the specific thing the ruling asked for and the diff does it.
  • One module read at module scope, with readTermHubs consuming the same read rather than importing a second time — so there is one load and one source, not two of either.
  • The two-commit split holds. 4c08160 is the de-duplication, 14a1ed1 is the added check. They are genuinely different claims and separating them is what makes the second one reviewable.
  • Error wording preserved. Invalid topic: / Invalid audience: byte-identical; the two new messages follow their shape. That was an explicit constraint.
  • The null case is right and is not a ride-along. solutions: with nothing after it fails, because .default([]) substitutes for an absent field only — skipping it would have recreated the same lint/build disagreement in the opposite direction. Good catch, and the PR body says why rather than just doing it.
  • No existing post turned red, so ruling (2)'s stop-and-report branch correctly did not fire.

One correction to make before this is read by anyone else: the body's #189 should be #191. The stale-build-store finding is filed at #191; #189 is a locale-coverage card the seat filed seven minutes before your PR was created, on a completely unrelated subject. You drafted the reference before the issue had a number and the guess collided. Following the #163 precedent, the body is left alone rather than risking its tail through a full-body replace — this comment is the correction, and the squash message the seat writes will carry no reference at all.

On #191 itself — good find, correctly classified. The seat tried to reproduce it independently and got a confounded result: pnpm build is gen-zh-hant && content-lint --published && astro build, so a taxonomy mutation dies at the lint step and never reaches Astro's content layer. That is exactly the bound your issue draws, and it is the reason this is a finding and not queued work — neither CI (cold checkout) nor pnpm build (lint first, no cache, and after this PR reading the same source) is exposed. Only a bare astro build or astro dev. Your reading that the non-null assertions in BlogPost.astro are correct contract-first code and that the producer was skipped is right, and it is why the third option you sketch — termBySlugOrThrow naming the slug and the file — is the one worth keeping on the table.

Landing on main. #162 and #179 are unblocked; the seat holds them behind nothing now except capacity.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

content-lint hard-codes the topic and audience slug lists that it can now import

2 participants