Skip to content

ci: fail the build when an llms body carries a numeric character reference or a malformed link target - #285

Merged
hotlong merged 2 commits into
mainfrom
claude/pm-dispatch-objectos-ju9td1
Sep 24, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/pm-dispatch-objectos-ju9td1

Conversation

@hotlong

@hotlong hotlong commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #282

Notation. AMP stands for one literal ampersand. This tracker's body sanitizer decodes HTML numeric character references, including inside code fences, so AMP#x2A; below means the six characters ampersand, hash, x, 2, A, semicolon. The gate's own output uses the same convention, because it lands in a markdown step summary.

What changed

.github/scripts/check-locale-surface.mjs now asserts #197's outcome on the built bytes of both consumers of page.data.getText('processed'): apps/docs/.next/server/app/llms-full.txt.body and every .body file under llms.mdx/.

rule fires on
numeric-character-reference any decimal or hex numeric reference (the only form mdast-util-to-markdown's encoder emits)
malformed-link-target a ]( opener whose target does not close with a literal ) before whitespace
llms-page-body-missing a page with an English source that has no llms.mdx body. The content-tree oracle this gate already builds says which bodies must exist (79 today), so a half-written directory cannot read as clean
no-link-targets a consumer where no link target could be read, which would leave the malformed-target rule measuring nothing
artifact-missing (existing rule) now also fires when the llms.mdx directory was never built

.github/workflows/ci.yml: no new step. The existing Locale surface step already runs this script right after pnpm turbo run build and before Package the Worker…. Its comment block gains one paragraph saying it now guards #197. The size-gate comment block is untouched.

Why a rule in this script and not a new one

  • File surface. A new .github/scripts/check-*.mjs with a --self-test mode must be registered in tools/ci-scripts/run-self-tests.mjs, because that runner fails on a self-test it does not list. That file is outside the declared surface (.github/scripts/ and ci.yml).
  • The oracle is already here. This script derives the English page set from content/docs/. That is what turns "79 bodies were read" into a checked claim, not a count someone happened to print.
  • It already reads the same body (llms-full.txt.body) at the right point in the job, and it already has the fixture, RULES-coverage and per-artifact machinery.
  • Cost: the script is no longer only about locale composition. To limit that, the encoding results print under their own heading (llms bodies are markdown, not HTML), with a per-consumer table and a line naming the fumadocs-core version that apps/docs resolves beside the version the patch is pinned to. A red on a bump therefore names its cause, even though the step is called Locale surface.

Negative control: both shapes, and the log says which ran

  1. Live, on every gate run. Before the real bodies are judged, the [finding] llms-full.txt emits HTML numeric entities into a plain-text file, and two of them break the markdown link they sit in #197 shape ([AI Builder](/docs/build/ai-builderAMP#x29; — …) goes through the same encodingFindings path once per consumer. Both rules must fire for both consumers, or negative-control-passed fails the run. The green log line on this branch reads: "Live negative control: the [finding] llms-full.txt emits HTML numeric entities into a plain-text file, and two of them break the markdown link they sit in #197 shape (a link target whose ) is encoded as AMP#x29;) was fed through this scan for both consumers and fired numeric-character-reference and malformed-link-target for each — the scan below can go red, so its result is a measurement."
  2. NOT MEASURED. On an unbuilt tree (a detached worktree at c22c02e with no .next), the gate exits 1 with artifact-missing ×4 and prints NOT MEASURED — not built rows for both consumers.

--self-test (run in CI by pnpm turbo run test) grows to 28 cases over 14 rules. It adds one red case per new rule and consumer, a green case for ampersands and parentheses in URLs, exact-count arithmetic for the scan, and a check that each of the four (consumer × rule) pairs has a fixture that turns it red. It also blinds the scan to show the live control can fire.

Measurements: the card's red and green states, on real builds

Built with NEXT_PRIVATE_STANDALONE=true pnpm turbo run build --force (as in CI), read off .next. An independent scratch counter gives the same figures as the gate.

tree fumadocs-core / patch references (full / mdx) malformed targets (full / mdx) gate exit
d725081, before #197's pin 16.8.12, unpatched 67 / 67 (AMP#x2A; ×58, AMP#x60; ×5, AMP#x29; ×4) 4 of 661 / 4 of 661 1
cb0c146 (this branch's base; the branch changes no build input) 16.8.12, patched 0 / 0 0 of 661 / 0 of 661 0
#239 head a69ef4d, built as-is 16.15.2, unpatched 67 / 67 4 of 650 / 4 of 650 1
cb0c146 + #239's version change, pin deleted 16.15.2, unpatched 67 / 67 4 of 661 / 4 of 661 1

The d725081 figures match the card exactly, including the byte count PR #281 recorded (695176). #239's head is a pre-#281 tree, which is why it has 650 targets. That count matches the decision comment #281 quotes.

The PM's mechanism assumption about the pin was partly wrong

The claim was: on a version change the patch silently stops applying and pnpm install still exits 0. Measured with pnpm 10.28.2 and #239's change applied to cb0c146:

  • pnpm install --no-frozen-lockfile and --lockfile-only both exit 1 with ERR_PNPM_UNUSED_PATCH The following patches were not used: fumadocs-core@16.8.12. A bump is loud wherever the lockfile is regenerated.
  • The first remedy that error suggests is to delete the pin. After that, install exits 0 with 16.15.2 unpatched, and the defect is back at full count (last row above). This gate turns that red.
  • A lockfile regenerated with unused patches allowed is accepted by CI's pnpm install --frozen-lockfile with exit 0 and no warning. The frozen install does not check for an unused patch.

The gate is still needed. The inaccurate paragraph in the patch header is filed as #284 (under patches/, outside this surface).

Ablation: the live control fails the real gate

On the committed tree, one mutation at a time, each confirmed on disk by grep -c of the anchor (1 → 0) and of the injected text (0 → 1), with a trap restore from HEAD. The restore was proven by blob hash equal to the HEAD blob and an empty git diff HEAD, not by an exit code:

mutation gate on the green build --self-test
NUMERIC_REFERENCE → /(?!)/g exit 1: Live negative control: FAILED — llms-full.txt:numeric-character-reference, llms.mdx:numeric-character-reference did not fire on the #197 shape. exit 1, 7 failures
malformed check → if (false) exit 1: same line, for malformed-link-target exit 1, 7 failures

Gates run on 76dca92 (the head of this PR)

Each exit code was captured before any pipe. The quoted line is the gate's own verdict.

  • node .github/scripts/check-locale-surface.mjs --self-test: exit 0, ✓ self-test: 28 case(s) over 14 rule(s) and 3 artifact(s) — …
  • node .github/scripts/check-locale-surface.mjs on the cb0c146 build: exit 0, ✓ every advertised URL has a source file … and neither llms consumer carries a numeric character reference or a malformed link target
  • the same script run against the d725081 build: exit 1, ✗ locale surface: 35 finding(s)
  • pnpm turbo run test --force: exit 0, ✓ 7 self-test(s) passed
  • node scripts/pm/check-half-states.mjs --self-test: exit 0, ✓ check-half-states self-test: 1551 cases pass.
  • node apps/docs/scripts/gen-zh-hant.mjs --check: exit 0, ✓ zh-Hant: 73 generated file(s) match …
  • node .github/scripts/check-node-floor.mjs --self-test: exit 0
  • ci.yml parses as YAML; no control bytes in either changed file.

Not run locally: the Worker packaging, the size weigh-in and the preview smoke check. This PR changes no build input, and CI runs all three.

Scope limits, stated in the script header

  • The whole body is scanned, code fences included. All 661 targets are outside a fence and no English page carries a reference today. Tracking fences across the concatenated body would let one page's unclosed fence hide every later page from the scan.
  • A titled link ([a](url "t")) would read as malformed. None exist today. Supporting one is a deliberate change to the rule.

Not touched

apps/docs/, content/docs/, patches/, root package.json, pnpm-lock.yaml, tools/ci-scripts/, and the size-gate comment block in ci.yml. #239 is not pushed to, commented on or merged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

Generated by Claude Code


Generated by Claude Code

…rence or a malformed link target

#197's fix is a fumadocs-core patch pinned to one exact version, and nothing
asserted its outcome. The locale-surface gate now scans the built
`llms-full.txt` body and every per-page `llms.mdx` body for HTML numeric
character references and for markdown link targets that do not close with a
literal `)`, runs a live negative control on the #197 shape before every
scan, and checks the `llms.mdx` body set against the content-tree oracle so a
directory that was never written cannot read as clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…were built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
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.

Nothing asserts /llms-full.txt is free of HTML numeric character references, so #197's fix regresses silently on the next fumadocs bump

2 participants