From e896d892741e27fad77d6f817f4b3912cb88a2a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 03:30:38 +0000 Subject: [PATCH 1/3] refactor(docs-audit): hand the hand-written docs list to the audit workflow instead of inlining it The default scope moves out of the sandboxed workflow body into a generated, non-governed artifact the caller hands in as args.handwritten. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2 --- .claude/workflows/docs-accuracy-audit.js | 332 ++++------------ scripts/docs-audit/check-audit-scope.mjs | 462 +++++++++++++++++------ scripts/docs-audit/handwritten-docs.json | 214 +++++++++++ 3 files changed, 646 insertions(+), 362 deletions(-) create mode 100644 scripts/docs-audit/handwritten-docs.json diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index 1ccf73f209..111ac24f79 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -1,7 +1,7 @@ export const meta = { name: 'docs-accuracy-audit', - description: 'Audit + fix hand-written ObjectStack docs against actual implementation, with adversarial verification. Scope with args.docs; defaults to all hand-written docs.', - whenToUse: 'Periodic or change-scoped documentation accuracy verification. Pass args.docs = [paths] to scope (e.g. output of scripts/docs-audit/affected-docs.mjs); omit for a full audit of every hand-written doc.', + description: 'Audit + fix hand-written ObjectStack docs against actual implementation, with adversarial verification. Scope with args.docs, or hand in the whole set as args.handwritten.', + whenToUse: 'Periodic or change-scoped documentation accuracy verification. Pass args.docs = [paths] to scope (e.g. output of scripts/docs-audit/affected-docs.mjs). For a FULL audit pass args.handwritten = the `docs` array of scripts/docs-audit/handwritten-docs.json — this body has no filesystem, so the full set has to be handed in.', phases: [ { title: 'Scope Preflight', detail: 'resolve every doc path on disk; abort naming any that does not exist' }, { title: 'Audit & Fix', detail: 'one agent per doc: read, locate implementation, apply evidence-backed edits — except release-owned pages, which are reviewed read-only and produce findings to file as issues' }, @@ -9,222 +9,31 @@ export const meta = { ], } -// Default scope = every hand-written doc (content/docs/** minus references/). Callers -// normally pass a scoped subset via args.docs (e.g. only docs whose backing packages/ -// code changed). +// Default scope = every hand-written doc (content/docs/** minus references/). It is NOT +// inline here: it is HANDED IN as `args.handwritten` by whoever invokes this workflow. +// Callers normally pass a narrower `args.docs` instead (e.g. only the docs whose backing +// packages/ code changed). // -// The list is inline because it has to be: a workflow script runs in a `node:vm` -// context whose only globals are log/phase/console/budget/timers plus agent/parallel/ -// pipeline/workflow/args, with code generation disabled — no require, no import, no -// filesystem. It cannot enumerate content/docs/ itself, nor read a JSON artifact. +// The read happens OUTSIDE this body, and it has to. A workflow script runs in a +// `node:vm` context whose only globals are log/phase/console/budget/timers plus +// agent/parallel/pipeline/workflow/args, with code generation disabled — no require, no +// import, no filesystem. `args` is the ONE channel through which data reaches this body, +// so the file read is the caller's and the list arrives at invocation time. // -// So it is GENERATED instead: `node scripts/docs-audit/check-audit-scope.mjs --write` -// derives it from the filesystem, and the same script without --write is a CI gate -// (`pnpm check:docs-audit-scope`) that fails when the block and content/docs/ disagree -// in EITHER direction. It used to be hand-kept behind a "keep in sync" comment, and by -// #4851 had rotted both ways at once — 16 entries pointing at files that no longer -// existed (the whole renamed protocol/objectos → protocol/kernel directory among them) -// and 48 existing docs missing from it — with every full-audit run reporting green. -// -// GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write` -// derives this from the filesystem (every content/docs/**/*.mdx except -// references/, via `affected-docs.mjs --all`); the same script run without -// --write fails CI when the two disagree in either direction. See #4851: this -// list was hand-kept, 16 entries pointed at files that no longer existed and 48 -// existing docs were absent from it, and a "FULL audit" reported green over both. -const ALL_HANDWRITTEN = [ - "content/docs/ai/actions-as-tools.mdx", - "content/docs/ai/agents.mdx", - "content/docs/ai/connect-mcp.mdx", - "content/docs/ai/index.mdx", - "content/docs/ai/knowledge-rag.mdx", - "content/docs/ai/natural-language-queries.mdx", - "content/docs/ai/skills-reference.mdx", - "content/docs/ai/skills.mdx", - "content/docs/ai/tools.mdx", - "content/docs/api/client-sdk.mdx", - "content/docs/api/data-api.mdx", - "content/docs/api/data-flow.mdx", - "content/docs/api/declarative-endpoints.mdx", - "content/docs/api/environment-routing.mdx", - "content/docs/api/error-catalog.mdx", - "content/docs/api/error-handling-client.mdx", - "content/docs/api/error-handling-server.mdx", - "content/docs/api/index.mdx", - "content/docs/api/metadata-api.mdx", - "content/docs/api/plugin-endpoints.mdx", - "content/docs/api/wire-format.mdx", - "content/docs/automation/approvals.mdx", - "content/docs/automation/connectors.mdx", - "content/docs/automation/email-templates.mdx", - "content/docs/automation/flows.mdx", - "content/docs/automation/hook-bodies.mdx", - "content/docs/automation/hooks.mdx", - "content/docs/automation/index.mdx", - "content/docs/automation/jobs.mdx", - "content/docs/automation/webhooks.mdx", - "content/docs/automation/workflows.mdx", - "content/docs/build-without-code.mdx", - "content/docs/capabilities/ai.mdx", - "content/docs/capabilities/analytics.mdx", - "content/docs/capabilities/approvals.mdx", - "content/docs/capabilities/automation.mdx", - "content/docs/capabilities/data.mdx", - "content/docs/capabilities/forms.mdx", - "content/docs/capabilities/index.mdx", - "content/docs/capabilities/integrations.mdx", - "content/docs/capabilities/permissions.mdx", - "content/docs/capabilities/request-template.mdx", - "content/docs/capabilities/views.mdx", - "content/docs/concepts/architecture.mdx", - "content/docs/concepts/design-principles.mdx", - "content/docs/concepts/index.mdx", - "content/docs/concepts/metadata-driven.mdx", - "content/docs/concepts/metadata-lifecycle.mdx", - "content/docs/concepts/north-star.mdx", - "content/docs/data-modeling/analytics.mdx", - "content/docs/data-modeling/drivers.mdx", - "content/docs/data-modeling/external-datasources.mdx", - "content/docs/data-modeling/field-type-decision-tree.mdx", - "content/docs/data-modeling/field-types.mdx", - "content/docs/data-modeling/fields.mdx", - "content/docs/data-modeling/formulas.mdx", - "content/docs/data-modeling/import-mappings.mdx", - "content/docs/data-modeling/index.mdx", - "content/docs/data-modeling/indexing.mdx", - "content/docs/data-modeling/object-extensions.mdx", - "content/docs/data-modeling/objects.mdx", - "content/docs/data-modeling/queries.mdx", - "content/docs/data-modeling/relationships.mdx", - "content/docs/data-modeling/schema-design.mdx", - "content/docs/data-modeling/seed-data.mdx", - "content/docs/data-modeling/validation-rules.mdx", - "content/docs/data-modeling/validation.mdx", - "content/docs/deployment/backup-restore.mdx", - "content/docs/deployment/cli.mdx", - "content/docs/deployment/environment-variables.mdx", - "content/docs/deployment/index.mdx", - "content/docs/deployment/production-readiness.mdx", - "content/docs/deployment/publish-and-preview.mdx", - "content/docs/deployment/seed-tenancy-repair.mdx", - "content/docs/deployment/self-hosting.mdx", - "content/docs/deployment/single-project-mode.mdx", - "content/docs/deployment/tenancy-modes.mdx", - "content/docs/deployment/troubleshooting.mdx", - "content/docs/deployment/validating-metadata.mdx", - "content/docs/getting-started/build-with-claude-code.mdx", - "content/docs/getting-started/common-patterns.mdx", - "content/docs/getting-started/examples.mdx", - "content/docs/getting-started/glossary.mdx", - "content/docs/getting-started/how-ai-development-works.mdx", - "content/docs/getting-started/index.mdx", - "content/docs/getting-started/quick-reference.mdx", - "content/docs/getting-started/quick-start.mdx", - "content/docs/getting-started/your-first-project.mdx", - "content/docs/index.mdx", - "content/docs/kernel/architecture.mdx", - "content/docs/kernel/cluster.mdx", - "content/docs/kernel/contracts/auth-service.mdx", - "content/docs/kernel/contracts/cache-service.mdx", - "content/docs/kernel/contracts/data-engine.mdx", - "content/docs/kernel/contracts/index.mdx", - "content/docs/kernel/contracts/metadata-service.mdx", - "content/docs/kernel/contracts/storage-service.mdx", - "content/docs/kernel/events.mdx", - "content/docs/kernel/index.mdx", - "content/docs/kernel/runtime-services/audit-service.mdx", - "content/docs/kernel/runtime-services/data-service.mdx", - "content/docs/kernel/runtime-services/email-service.mdx", - "content/docs/kernel/runtime-services/examples.mdx", - "content/docs/kernel/runtime-services/index.mdx", - "content/docs/kernel/runtime-services/queue-service.mdx", - "content/docs/kernel/runtime-services/settings-service.mdx", - "content/docs/kernel/runtime-services/sharing-service.mdx", - "content/docs/kernel/runtime-services/sms-service.mdx", - "content/docs/kernel/runtime-services/storage-service.mdx", - "content/docs/kernel/runtime-services/versioning.mdx", - "content/docs/kernel/services-checklist.mdx", - "content/docs/kernel/services.mdx", - "content/docs/permissions/access-matrix.mdx", - "content/docs/permissions/access-recipes.mdx", - "content/docs/permissions/administrator-guide.mdx", - "content/docs/permissions/attachments-access.mdx", - "content/docs/permissions/authentication.mdx", - "content/docs/permissions/authorization.mdx", - "content/docs/permissions/capabilities.mdx", - "content/docs/permissions/delegated-administration.mdx", - "content/docs/permissions/explain.mdx", - "content/docs/permissions/field-level-security.mdx", - "content/docs/permissions/index.mdx", - "content/docs/permissions/permission-metadata.mdx", - "content/docs/permissions/permission-sets.mdx", - "content/docs/permissions/permissions-matrix.mdx", - "content/docs/permissions/positions.mdx", - "content/docs/permissions/profiles.mdx", - "content/docs/permissions/record-view-auditing.mdx", - "content/docs/permissions/rls.mdx", - "content/docs/permissions/sharing-rules.mdx", - "content/docs/permissions/sso.mdx", - "content/docs/permissions/system-context.mdx", - "content/docs/permissions/tenant-audit-census.mdx", - "content/docs/plugins/adding-a-metadata-type.mdx", - "content/docs/plugins/anatomy.mdx", - "content/docs/plugins/development.mdx", - "content/docs/plugins/index.mdx", - "content/docs/plugins/packages.mdx", - "content/docs/protocol/backward-compatibility.mdx", - "content/docs/protocol/diagram.mdx", - "content/docs/protocol/index.mdx", - "content/docs/protocol/kernel/config-resolution.mdx", - "content/docs/protocol/kernel/error-handling.mdx", - "content/docs/protocol/kernel/http-protocol.mdx", - "content/docs/protocol/kernel/i18n-standard.mdx", - "content/docs/protocol/kernel/index.mdx", - "content/docs/protocol/kernel/lifecycle.mdx", - "content/docs/protocol/kernel/metadata-service.mdx", - "content/docs/protocol/kernel/plugin-spec.mdx", - "content/docs/protocol/kernel/realtime-protocol.mdx", - "content/docs/protocol/knowledge.mdx", - "content/docs/protocol/objectql/index.mdx", - "content/docs/protocol/objectql/query-syntax.mdx", - "content/docs/protocol/objectql/schema.mdx", - "content/docs/protocol/objectql/security.mdx", - "content/docs/protocol/objectql/state-machine.mdx", - "content/docs/protocol/objectql/types.mdx", - "content/docs/protocol/objectui/actions.mdx", - "content/docs/protocol/objectui/concept.mdx", - "content/docs/protocol/objectui/index.mdx", - "content/docs/protocol/objectui/layout-dsl.mdx", - "content/docs/protocol/objectui/record-alert.mdx", - "content/docs/protocol/objectui/widget-contract.mdx", - "content/docs/releases/implementation-status.mdx", - "content/docs/releases/index.mdx", - "content/docs/releases/v12.mdx", - "content/docs/releases/v13.mdx", - "content/docs/releases/v14.mdx", - "content/docs/releases/v15.mdx", - "content/docs/releases/v16.mdx", - "content/docs/releases/v17.mdx", - "content/docs/releases/v9.mdx", - "content/docs/ui/actions.mdx", - "content/docs/ui/apps.mdx", - "content/docs/ui/audience-based-interfaces.mdx", - "content/docs/ui/create-vs-edit-form.mdx", - "content/docs/ui/dashboards.mdx", - "content/docs/ui/doc-pages.mdx", - "content/docs/ui/field-grouping-and-order.mdx", - "content/docs/ui/forms.mdx", - "content/docs/ui/index.mdx", - "content/docs/ui/pages.mdx", - "content/docs/ui/public-data-collection.mdx", - "content/docs/ui/react-pages.mdx", - "content/docs/ui/reports.mdx", - "content/docs/ui/setup-app.mdx", - "content/docs/ui/translations.mdx", - "content/docs/ui/views.mdx", - "content/docs/upgrading.mdx", -] -// +// THE single source is the artifact named below: one generated file, derived from the +// filesystem by `node scripts/docs-audit/check-audit-scope.mjs --write` and held equal to +// content/docs/ in BOTH directions by `pnpm check:docs-audit-scope`. It is deliberately +// not kept here — an audit's page list is routine bookkeeping, and keeping it in this tree +// made every PR that adds a documentation page edit a governed file for no other reason. +// +// Before it was generated at all it was hand-kept behind a "keep in sync" comment, and it +// rotted both ways at once: 16 entries pointing at files that no longer existed (a whole +// renamed directory among them) and 48 existing docs missing from it — while every +// full-audit run reported green, because a doc path that resolves to nothing produces an +// agent that reads nothing and reports "0 fixes". One generated source held equal to the +// filesystem in both directions is what keeps that from recurring, wherever it lives. +const SCOPE_SOURCE = 'scripts/docs-audit/handwritten-docs.json' +const SCOPE_ARG = 'handwritten' // --- Release-owned pages are IN SCOPE but READ-ONLY (#4920) ------------------- // @@ -242,13 +51,13 @@ const ALL_HANDWRITTEN = [ // // The ruling on #4920 was NOT to drop them from scope. Dropping them would leave // some of the most-read pages in the docs permanently unaudited, and would create a -// SECOND definition of "which docs does this workflow cover" alongside the generated -// block above — #4851 had just finished paying for what happens when one subject has -// two hand-kept lists. So the scope is unchanged and only the DELIVERABLE forks: +// SECOND definition of "which docs does this workflow cover" alongside the scope +// source above — the repo had just finished paying for what happens when one subject +// has two hand-kept lists. So the scope is unchanged and only the DELIVERABLE forks: // findings to file as issues, instead of edits written to disk. // // The fork has to be decidable inside the workflow VM (no filesystem, no require, no -// import — see the note on the generated block), which a path prefix is. And the +// import — see the note on the scope source above), which a path prefix is. And the // prefix is not a curation of the guardrail, it is the guardrail's own path column // copied verbatim, so there is still exactly one definition of "release-owned". // `scripts/docs-audit/check-audit-scope.mjs` anchors the two together and goes red if @@ -257,37 +66,56 @@ const ALL_HANDWRITTEN = [ const RELEASE_OWNED_PREFIX = 'content/docs/releases/' const isReleaseOwned = (doc) => doc.startsWith(RELEASE_OWNED_PREFIX) -// Scope resolution. Omitting `args` entirely is the legitimate "audit -// everything" invocation; supplying `args` but not a usable `args.docs` array -// is a CALLER BUG and must say so. +// Scope resolution. Two channels, and NEITHER has a default: `args.docs` is the +// caller's narrowing, `args.handwritten` is the full hand-written set handed in from +// the artifact named above. An invocation carrying neither is refused by name. // -// This used to be a single `?:` that fell back to ALL_HANDWRITTEN for both -// cases. The Workflow tool delivers `args` verbatim, so passing a JSON-encoded -// STRING instead of an object — an easy mistake, and one the tool's own docs -// warn about — left `args.docs` undefined, the ternary silently widened a -// 12-doc request to all 147, and the run burned ~294 agents on work nobody -// asked for. Nothing in the output said the scope had been ignored. Failing -// loudly costs one retry; failing quietly cost an hour. -if (args !== undefined && args !== null) { - if (typeof args === 'string') { - throw new Error( - '[docs-accuracy-audit] `args` arrived as a string, not an object — pass a real JSON ' + - 'value (e.g. {"docs": ["content/docs/a.mdx"]}), not a JSON-encoded string. ' + - 'Refusing to silently audit all ' + ALL_HANDWRITTEN.length + ' docs instead.', - ) - } - if (args.docs !== undefined && (!Array.isArray(args.docs) || args.docs.length === 0)) { +// There is deliberately no "omit args and audit everything" path any more. There +// cannot be one — this body has no filesystem, so with nothing handed in it has no +// idea what "everything" is, and the only shapes it could invent are a silent audit +// of nothing and a stale list. Refusing costs one retry and says exactly what to pass. +// +// Supplying `args` but not a usable array is a CALLER BUG and must say so. This used +// to be a single `?:` that fell back to the inline list for both cases. The Workflow +// tool delivers `args` verbatim, so passing a JSON-encoded STRING instead of an object +// — an easy mistake, and one the tool's own docs warn about — left `args.docs` +// undefined, the ternary silently widened a 12-doc request to the whole corpus, and +// the run burned ~294 agents on work nobody asked for. Nothing in the output said the +// scope had been ignored. Failing loudly costs one retry; failing quietly cost an hour. +const HOW_TO_PASS = + 'Read `' + SCOPE_SOURCE + '` OUTSIDE the workflow and pass its `docs` array as ' + + 'args.' + SCOPE_ARG + ' for a full audit, or pass args.docs = [paths] to scope the run.' + +if (typeof args === 'string') { + throw new Error( + '[docs-accuracy-audit] `args` arrived as a string, not an object — pass a real JSON ' + + 'value (e.g. {"docs": ["content/docs/a.mdx"]}), not a JSON-encoded string. ' + HOW_TO_PASS, + ) +} +for (const key of ['docs', SCOPE_ARG]) { + const value = args === undefined || args === null ? undefined : args[key] + if (value !== undefined && (!Array.isArray(value) || value.length === 0 || value.some((d) => typeof d !== 'string'))) { throw new Error( - '[docs-accuracy-audit] `args.docs` must be a non-empty array of doc paths; got ' + - JSON.stringify(args.docs) + '. Omit `args` entirely to audit all ' + - ALL_HANDWRITTEN.length + ' hand-written docs.', + '[docs-accuracy-audit] `args.' + key + '` must be a non-empty array of doc paths; got ' + + JSON.stringify(value) + '. ' + HOW_TO_PASS, ) } } -const DOCS = args && Array.isArray(args.docs) && args.docs.length ? args.docs : ALL_HANDWRITTEN + +const SCOPED = args && Array.isArray(args.docs) && args.docs.length ? args.docs : null +const HANDWRITTEN = args && Array.isArray(args[SCOPE_ARG]) && args[SCOPE_ARG].length ? args[SCOPE_ARG] : null +if (!SCOPED && !HANDWRITTEN) { + throw new Error( + '[docs-accuracy-audit] no scope was handed in: this invocation carries neither `args.docs` ' + + 'nor `args.' + SCOPE_ARG + '`. This body runs in a sandbox with no filesystem, so it cannot ' + + 'enumerate content/docs/ or read the list itself — the caller must hand it in. ' + HOW_TO_PASS, + ) +} + +const DOCS = SCOPED ?? HANDWRITTEN const WRITABLE_DOCS = DOCS.filter((d) => !isReleaseOwned(d)) const READONLY_DOCS = DOCS.filter(isReleaseOwned) -log(`scope: ${DOCS.length} doc(s)${DOCS === ALL_HANDWRITTEN ? ' — FULL audit (no args.docs given)' : ''}`) +log(`scope: ${DOCS.length} doc(s)${DOCS === HANDWRITTEN ? ` — FULL audit (whole hand-written set from ${SCOPE_SOURCE})` : ''}`) if (READONLY_DOCS.length) { log( ` of which ${READONLY_DOCS.length} release-owned page(s) under ${RELEASE_OWNED_PREFIX} — ` + @@ -297,10 +125,10 @@ if (READONLY_DOCS.length) { // --- Scope preflight: every path in scope must resolve to a real file --------- // -// `pnpm check:docs-audit-scope` already keeps ALL_HANDWRITTEN honest in CI, but it -// can only see the DEFAULT list. A caller-supplied `args.docs` — the normal way this -// workflow is invoked — is checked by nothing, and a bad path there fails exactly the -// way #4851's stale list did: the audit agent finds no file, reports `fixCount: 0`, +// `pnpm check:docs-audit-scope` already holds the scope source equal to content/docs/ +// in CI, but it can only see THAT list. A caller-supplied `args.docs` — the normal way +// this workflow is invoked — is checked by nothing, and a bad path there fails exactly +// the way a stale list did before the gate existed: the agent finds no file, reports `fixCount: 0`, // and the run summary shows a doc that was "audited clean". So resolve the scope // first and refuse to start if anything in it is missing. // @@ -369,10 +197,10 @@ if (preflight.missing.length) { `[docs-accuracy-audit] ${preflight.missing.length} of ${DOCS.length} doc path(s) in scope ` + 'do not exist:\n ' + preflight.missing.join('\n ') + '\n\nRefusing to run: an audit agent pointed at a non-existent file reads nothing and ' + - 'reports "0 fixes", so the run would report success over docs nobody looked at (#4851 — ' + - 'that is how #4781 and #4817 survived ~2 months of green full audits). If these came from ' + - 'args.docs, fix the caller; if they came from the default list, run ' + - '`node scripts/docs-audit/check-audit-scope.mjs --write`.', + 'reports "0 fixes", so the run would report success over docs nobody looked at — that is ' + + 'how two real accuracy defects survived ~2 months of green full audits. If these came from ' + + `args.docs, fix the caller; if they came from ${SCOPE_SOURCE}, that artifact is stale — run ` + + '`node scripts/docs-audit/check-audit-scope.mjs --write` and hand in the regenerated list.', ) } log(`preflight: all ${preflight.present.length} path(s) resolve (via \`${preflight.command}\`)`) diff --git a/scripts/docs-audit/check-audit-scope.mjs b/scripts/docs-audit/check-audit-scope.mjs index d784b2dd6b..47721ef476 100644 --- a/scripts/docs-audit/check-audit-scope.mjs +++ b/scripts/docs-audit/check-audit-scope.mjs @@ -8,20 +8,39 @@ // // Usage: // node scripts/docs-audit/check-audit-scope.mjs # verify; exit 1 naming every drifted entry -// node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the block in place -// node scripts/docs-audit/check-audit-scope.mjs --self-test # pin the parser/renderer/differ + the read-only routing +// node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the artifact from the filesystem +// node scripts/docs-audit/check-audit-scope.mjs --self-test # pin the parser/renderer/differ + the read-only routing + the injection contract // -// ## Why this exists (#4851) +// ## Where the list lives, and why it moved (#13591; maintainer 2026-09-01, 「同意」) // -// `.claude/workflows/docs-accuracy-audit.js` carries the full list of hand-written -// docs inline, as `ALL_HANDWRITTEN`. It has to: a workflow script runs inside a -// `node:vm` context whose only globals are `log/phase/console/budget/setTimeout/ -// clearTimeout` plus `agent/parallel/pipeline/workflow/args`, with `codeGeneration` -// disabled — no `require`, no `import`, no filesystem, no `eval`. The script -// therefore cannot enumerate `content/docs/**` itself, and cannot read the list -// from a JSON artifact either. +// THE single source is `scripts/docs-audit/handwritten-docs.json` — one generated +// file, derived from the filesystem here and consumed by two readers: this gate, and +// whoever invokes the `docs-accuracy-audit` workflow, who reads it and hands the list +// to the workflow body as `args.handwritten`. // -// So the list was hand-kept, with a comment asking the next author to "keep in sync +// It used to be inline in `.claude/workflows/docs-accuracy-audit.js`, as +// `ALL_HANDWRITTEN`, and the argument for that was real as far as it went: a workflow +// script runs inside a `node:vm` context whose only globals are `log/phase/console/ +// budget/setTimeout/clearTimeout` plus `agent/parallel/pipeline/workflow/args`, with +// `codeGeneration` disabled — no `require`, no `import`, no filesystem, no `eval`. The +// body can neither enumerate `content/docs/**` nor open a JSON file. +// +// What that argument missed is that the body does not have to do the reading. `args` +// IS an injection channel: the runner delivers it verbatim from the invocation, so the +// file read happens in the CALLER, outside the sandbox, and the list arrives as data. +// The sandbox stays exactly as FS-blind as before. +// +// The cost of the inline form was paid by every unrelated PR. `.claude/**` is a +// governed surface — human-merge-only, never armed, never queued — so adding one +// customer documentation page forced an edit to a governed file, through a bookkeeping +// list that merely happened to live there. Worse, it was invisible at dispatch time: +// nothing in such a card's file list showed a governed path until the gate ran. The +// maintainer ruled the list off that surface; the governed register itself is untouched, +// and narrowing it was considered and explicitly rejected. +// +// ## Why the list is generated at all (#4851) +// +// It was once hand-kept, with a comment asking the next author to "keep in sync // with `affected-docs.mjs --all`" — a promise nothing checked. It rotted, in BOTH // directions, and neither direction announced itself: // @@ -51,9 +70,23 @@ // rather than re-deriving it. One definition of "hand-written doc", one place to // change it; a second walk here would be the next thing to drift out of sync. // -// `--write` regenerates the inline block from that derivation, so the array is a -// generated artifact that happens to live inside a hand-written file. Hand-editing -// it is never necessary and this check will reject it. +// `--write` regenerates the artifact from that derivation, byte for byte. Hand-editing +// it is never necessary and this check will reject it — including a merely reordered +// list, because a hand-edited artifact has stopped being generated output whatever it +// happens to contain. +// +// ## The injection contract is checked by RUNNING the workflow, not by grepping it +// +// Moving the list out creates one failure mode that did not exist before: the workflow +// could stop reading what it is handed — an edit that renames the arg, reinstates a +// default, or drops the refusal — and this gate would still report the artifact in +// sync with `content/docs/` while every full audit ran over nothing, or over whatever +// stale shape the body invented. The artifact being correct says nothing about the body +// consuming it. So `checkScopeInjection` runs the real workflow against stub agents and +// observes what it does with an injected list, with a scoped list, and with neither; +// `--self-test` then mutates each of those behaviours out of an in-memory copy and +// requires the check to go red, because a guard nobody has seen fail is a guard nobody +// has tested (#4868). // // ## Release-owned pages: in scope, read-only (#4920) // @@ -116,8 +149,22 @@ const AGENTS_REL = 'AGENTS.md'; * a file that does not exist. The self-test pins both halves. */ export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; -const BEGIN = '// '; -const END = '// '; + +/** + * THE single source: the one hand-kept-nowhere, generated-here list of hand-written + * docs. Two readers — this gate, and the caller that hands it to the workflow body. + * Deliberately outside `.claude/**` and outside every other governed glob (see the + * header): an audit's page list is bookkeeping, and it must not make an unrelated + * docs PR human-merge-only. + */ +export const SCOPE_ARTIFACT_REL = 'scripts/docs-audit/handwritten-docs.json'; + +/** + * The `args` key the workflow body reads its full scope from. Named here because + * `checkScopeInjection` asserts against the REAL workflow through it: rename the arg + * in one place and the observed run stops matching, which is the point. + */ +export const SCOPE_ARG = 'handwritten'; /** * The release-owned boundary — the path column of AGENTS.md's RELEASE-OWNED guardrail @@ -129,85 +176,74 @@ export const isReleaseOwned = (doc) => doc.startsWith(RELEASE_OWNED_PREFIX); const args = process.argv.slice(2); -// --- block extraction / rendering ------------------------------------------- +// --- the artifact: parsing / rendering --------------------------------------- /** - * The generated block's body (everything strictly between the markers), or throw. + * The doc paths declared by the artifact, or throw. * - * A missing marker is a HARD failure, not a skip. This gate's whole subject is a - * list that quietly stopped matching reality; a version of it that shrugs when it - * cannot find that list would reproduce the defect one level up (#4690's shape: + * An unreadable or shapeless artifact is a HARD failure, not a skip. This gate's whole + * subject is a list that quietly stopped matching reality; a version of it that shrugs + * when it cannot find that list would reproduce the defect one level up (#4690's shape: * no manifest, exit 0). */ -export function extractBlock(source) { - const begin = source.indexOf(BEGIN); - const end = source.indexOf(END); - if (begin === -1 || end === -1 || end < begin) { - throw new Error( - `${WORKFLOW_REL}: could not find the generated scope block. Expected a region ` + - `delimited by "${BEGIN}" and "${END}". The audit workflow's default scope is ` + - `generated by this script; if the block was renamed or removed, restore it (or ` + - `update the markers here) — do NOT hand-maintain the list.`, - ); - } - return source.slice(begin + BEGIN.length, end); -} - -/** The doc paths declared inside the generated block. */ -export function parseBlock(source) { - const body = extractBlock(source); - // No doc path contains `]`, so "up to the first bracket" is unambiguous — and it - // parses the legacy single-line form too, so an old block is reported as drift - // rather than as a parse crash. - const m = body.match(/const ALL_HANDWRITTEN = (\[[^\]]*])/); - if (!m) { - throw new Error( - `${WORKFLOW_REL}: the generated scope block contains no \`const ALL_HANDWRITTEN = [...]\` ` + - `array literal. Regenerate it with \`node scripts/docs-audit/check-audit-scope.mjs --write\`.`, - ); - } - // The rendered form keeps a trailing comma on the last entry (smaller diffs when a - // doc is added); that is the one thing between it and plain JSON, so drop it. - const json = m[1].replace(/,(\s*])$/, '$1'); +export function parseArtifact(source) { let parsed; try { - parsed = JSON.parse(json); + parsed = JSON.parse(source); } catch (e) { throw new Error( - `${WORKFLOW_REL}: ALL_HANDWRITTEN is not a plain array of string literals (${e.message}). ` + - `It is generated output — regenerate with \`--write\` rather than hand-editing it.`, + `${SCOPE_ARTIFACT_REL}: not valid JSON (${e.message}). It is generated output — ` + + `regenerate it with \`node scripts/docs-audit/check-audit-scope.mjs --write\` rather ` + + `than hand-editing it.`, + ); + } + const docs = parsed?.docs; + if (!Array.isArray(docs) || docs.some((d) => typeof d !== 'string')) { + throw new Error( + `${SCOPE_ARTIFACT_REL}: no \`docs\` array of string paths. That array IS the audit ` + + `scope — the workflow is handed it as \`args.${SCOPE_ARG}\` and this gate holds it ` + + `equal to content/docs/. Regenerate with \`--write\`.`, ); } - return parsed; + return docs; } /** - * The exact text the block must contain for a given doc set. Byte-comparing against + * The exact bytes the artifact must contain for a given doc set. Byte-comparing against * this is what makes hand-edits — including a merely reordered list — visible. + * + * The `readme` block is part of the generated bytes on purpose: JSON carries no + * comments, and an artifact that cannot say what it is invites the next reader to treat + * it as a hand-kept list, which is the exact thing it exists to stop being. */ -export function renderBlock(docs) { - const entries = [...docs].sort(); - return [ - '', - '// GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`', - '// derives this from the filesystem (every content/docs/**/*.mdx except', - '// references/, via `affected-docs.mjs --all`); the same script run without', - '// --write fails CI when the two disagree in either direction. See #4851: this', - '// list was hand-kept, 16 entries pointed at files that no longer existed and 48', - '// existing docs were absent from it, and a "FULL audit" reported green over both.', - 'const ALL_HANDWRITTEN = [', - ...entries.map((d) => ` ${JSON.stringify(d)},`), - ']', - '', - ].join('\n'); -} - -/** Splice a freshly rendered block back into the workflow source. */ -export function replaceBlock(source, docs) { - const begin = source.indexOf(BEGIN); - const end = source.indexOf(END); - if (begin === -1 || end === -1 || end < begin) throw new Error('missing markers'); // extractBlock already explains - return source.slice(0, begin + BEGIN.length) + renderBlock(docs) + source.slice(end); +export function renderArtifact(docs) { + return `${JSON.stringify( + { + readme: [ + 'GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`', + 'derives this from the filesystem (every content/docs/**/*.mdx except references/,', + 'via `affected-docs.mjs --all`); the same script without --write is a CI gate that', + 'fails when this file and content/docs/ disagree in EITHER direction.', + '', + 'THIS FILE IS THE SINGLE SOURCE for "which docs are hand-written". Two consumers:', + ' 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no', + ' filesystem, so the CALLER reads this file and passes `docs` as args.handwritten.', + ' 2. scripts/docs-audit/check-audit-scope.mjs — the gate that keeps it honest.', + 'There is deliberately no second copy anywhere; one subject, one list.', + '', + 'It lives here, outside .claude/** and outside every other governed surface, by', + 'maintainer ruling (2026-09-01). Inline, it made every PR that added a documentation', + 'page a human-merge-only PR — a governed edit forced by bookkeeping, invisible until', + 'the gate ran. Moving it is NOT a narrowing of the governed register, which is', + 'unchanged; do not treat this file as precedent for relocating anything else.', + ], + generator: 'node scripts/docs-audit/check-audit-scope.mjs --write', + gate: 'pnpm check:docs-audit-scope', + docs: [...docs].sort(), + }, + null, + 2, + )}\n`; } // --- the comparison ---------------------------------------------------------- @@ -465,6 +501,103 @@ export async function checkReadOnlyRouting(source) { return problems; } +/** The two probe docs, as a list handed in the way a real full audit hands one in. */ +const INJECTION_PROBE = [PROBE_EDITABLE, PROBE_RELEASE]; + +/** + * The injection contract, observed on a real run. + * + * The artifact being in sync with `content/docs/` says NOTHING about the workflow body + * consuming it — that is the one new failure mode created by moving the list out, and + * it is silent by construction: a body that ignored `args.${SCOPE_ARG}` would leave this + * gate green while every full audit ran over the wrong set. Grepping the source cannot + * tell a working read from a dead one, so this observes behaviour: what a handed-in list + * is actually audited, what a run with nothing handed in does, and whether the narrowing + * channel still narrows. + * + * Returns a list of human-readable problems; empty means the contract holds. + */ +export async function checkScopeInjection(source) { + const problems = []; + const stub = ({ prompt, phase, schema }) => + phase === 'Scope Preflight' ? preflightResponse(prompt) : stubFor(schema, { doc: '' }); + const audits = (calls) => calls.filter((c) => c.phase === 'Audit & Fix'); + + // 1. The injected list IS the scope, and the run says which list it audited. + const injected = await runWorkflow(source, { workflowArgs: { [SCOPE_ARG]: INJECTION_PROBE }, respond: stub }); + if (injected.error) { + problems.push(`the workflow threw on a run whose scope was handed in as args.${SCOPE_ARG}: ${injected.error.message}`); + } else { + const seen = audits(injected.calls); + if (seen.length !== INJECTION_PROBE.length) { + problems.push( + `args.${SCOPE_ARG} carried ${INJECTION_PROBE.length} doc(s) but ${seen.length} reached an ` + + 'audit-phase agent — the body is not auditing what it was handed', + ); + } + if (!injected.logs.some((l) => l.includes('FULL audit') && l.includes(SCOPE_ARTIFACT_REL))) { + problems.push( + `a run scoped by args.${SCOPE_ARG} did not report itself as a FULL audit naming ` + + `${SCOPE_ARTIFACT_REL} — the summary has to say which list it audited, or a truncated ` + + 'list reads exactly like the whole corpus', + ); + } + } + + // 2. Nothing handed in must REFUSE, naming the artifact and the arg. A body that + // invents a default here is how the list silently rots back into existence. + for (const [label, workflowArgs] of [ + ['no args at all', undefined], + ['an args object carrying neither key', {}], + ]) { + const bare = await runWorkflow(source, { workflowArgs, respond: stub }); + if (!bare.error) { + problems.push( + `${label}: the workflow ran instead of refusing — with no list handed in it cannot know ` + + 'what "everything" is, so whatever it audited it would report as a result', + ); + continue; + } + if (!bare.error.message.includes(SCOPE_ARTIFACT_REL) || !bare.error.message.includes(`args.${SCOPE_ARG}`)) { + problems.push( + `${label}: the refusal names neither ${SCOPE_ARTIFACT_REL} nor args.${SCOPE_ARG}, so it does ` + + `not tell the caller what to pass — got: ${bare.error.message.slice(0, 160)}`, + ); + } + if (audits(bare.calls).length) problems.push(`${label}: audit-phase agents were spawned before the refusal`); + } + + // 3. A malformed injected list is a CALLER BUG, never an empty audit. + for (const [label, value] of [ + ['an empty array', []], + ['a bare string', PROBE_EDITABLE], + ['an array holding a non-string', [PROBE_EDITABLE, 7]], + ]) { + const bad = await runWorkflow(source, { workflowArgs: { [SCOPE_ARG]: value }, respond: stub }); + if (!bad.error) { + problems.push(`args.${SCOPE_ARG} = ${label} did not fail the run — an audit of nothing must never report success`); + } else if (!bad.error.message.includes(`args.${SCOPE_ARG}`)) { + // It failed, but not BY NAME. A body with no shape guard still dies somewhere + // downstream on a malformed list, with a message that sends the caller reading + // the audit instead of their own invocation. + problems.push( + `args.${SCOPE_ARG} = ${label} failed without naming args.${SCOPE_ARG} as the caller bug — ` + + `got: ${bad.error.message.slice(0, 160)}`, + ); + } + } + + // 4. The narrowing channel still narrows, and does not claim to be a full audit. + const scoped = await runWorkflow(source, { workflowArgs: { docs: [PROBE_EDITABLE] }, respond: stub }); + if (scoped.error) { + problems.push(`args.docs no longer scopes a run: ${scoped.error.message}`); + } else if (scoped.logs.some((l) => l.includes('FULL audit'))) { + problems.push('a run scoped by args.docs called itself a FULL audit — the two channels have collapsed into one'); + } + + return problems; +} + // --- main -------------------------------------------------------------------- // Exports bindings, so an import for those exports alone must run nothing (#10667). @@ -483,37 +616,62 @@ if (isEntrypoint(import.meta.url)) { } } +/** The artifact's bytes, or null when it does not exist yet (`--write` seeds it). */ +function readArtifact(artifactPath) { + try { + return readFileSync(artifactPath, 'utf8'); + } catch (e) { + if (e?.code === 'ENOENT') return null; + throw e; + } +} + async function main() { const workflowPath = join(REPO_ROOT, WORKFLOW_REL); + const artifactPath = join(REPO_ROOT, SCOPE_ARTIFACT_REL); const source = readFileSync(workflowPath, 'utf8'); const derived = deriveDocs(); if (args.includes('--write')) { - const before = parseBlock(source); - writeFileSync(workflowPath, replaceBlock(source, derived)); + const existing = readArtifact(artifactPath); + const before = existing === null ? [] : parseArtifact(existing); + writeFileSync(artifactPath, renderArtifact(derived)); const { dead, unlisted } = diffScope(before, derived); console.log( - `✓ regenerated ${WORKFLOW_REL} scope: ${derived.length} hand-written doc(s) ` + + `✓ regenerated ${SCOPE_ARTIFACT_REL}: ${derived.length} hand-written doc(s) ` + `(+${unlisted.length} added, -${dead.length} removed).`, ); return; } - const listed = parseBlock(source); + const raw = readArtifact(artifactPath); + if (raw === null) { + console.error( + `✗ ${SCOPE_ARTIFACT_REL} does not exist.\n\n` + + ` That file is THE single source for the audit's page scope — the workflow is handed\n` + + ` its \`docs\` array as \`args.${SCOPE_ARG}\`, and this gate holds it equal to content/docs/.\n` + + ` A missing subject is a RED result, never a pass by vacancy.\n\n` + + ` Fix: node scripts/docs-audit/check-audit-scope.mjs --write\n`, + ); + process.exit(1); + } + const listed = parseArtifact(raw); const { dead, unlisted, duplicates } = diffScope(listed, derived); - // Byte-compare too: correct paths in a hand-edited shape still means the block + // Byte-compare too: correct paths in a hand-edited shape still means the artifact // stopped being generated output, and the next `--write` would churn. - const blockDrift = extractBlock(source) !== renderBlock(derived); + const blockDrift = raw !== renderArtifact(derived); if (!dead.length && !unlisted.length && !duplicates.length && !blockDrift) { console.log( - `✓ docs-accuracy-audit scope is in sync with content/docs/: ${listed.length} hand-written doc(s).`, + `✓ docs-accuracy-audit scope is in sync with content/docs/: ${listed.length} hand-written doc(s) ` + + `(${SCOPE_ARTIFACT_REL}).`, ); await checkReleaseOwned(source, derived); + await checkInjection(source); return; } - console.error(`✗ ${WORKFLOW_REL}: ALL_HANDWRITTEN has drifted from content/docs/.\n`); + console.error(`✗ ${SCOPE_ARTIFACT_REL}: the hand-written doc list has drifted from content/docs/.\n`); if (dead.length) { console.error( ` ${dead.length} listed path(s) do not exist — an audit agent pointed at one reads\n` + @@ -536,8 +694,8 @@ async function main() { } if (!dead.length && !unlisted.length && !duplicates.length && blockDrift) { console.error( - ' The listed paths are correct but the block does not match its rendered form —\n' + - ' ordering, formatting or the header comment was hand-edited.\n', + ' The listed paths are correct but the file does not match its rendered form —\n' + + ' ordering, formatting or the readme block was hand-edited.\n', ); } console.error(' Fix: node scripts/docs-audit/check-audit-scope.mjs --write'); @@ -611,6 +769,29 @@ async function checkReleaseOwned(source, derived) { ); } +/** + * The other half of "one list, correctly consumed": the artifact is in sync, and the + * workflow really reads what it is handed. Neither implies the other. + */ +async function checkInjection(source) { + const problems = await checkScopeInjection(source); + if (problems.length) { + console.error(`✗ ${WORKFLOW_REL}: it does not consume the scope it is handed.\n`); + for (const p of problems) console.error(` - ${p}`); + console.error( + `\n Observed by running the workflow against stub agents. ${SCOPE_ARTIFACT_REL} is THE\n` + + ` single source; the body runs in a sandbox with no filesystem, so the caller reads that\n` + + ` file and hands the list in as \`args.${SCOPE_ARG}\`. An artifact in sync with content/docs/\n` + + ` proves nothing about a body that has stopped reading it.\n`, + ); + process.exit(1); + } + console.log( + `✓ scope injection is live: the workflow audits the list handed in as args.${SCOPE_ARG}, ` + + `and refuses an invocation that hands in no scope at all.`, + ); +} + // --- self-test --------------------------------------------------------------- /** @@ -644,32 +825,28 @@ async function selfTest() { }; const docs = ['content/docs/a.mdx', 'content/docs/b.mdx']; - const file = `header\n${BEGIN}${renderBlock(docs)}${END}\nfooter\n`; + const artifact = renderArtifact(docs); - check('parseBlock round-trips the rendered block', docs, parseBlock(file)); - check('renderBlock sorts', ['a', 'b'], parseBlock(`${BEGIN}${renderBlock(['b', 'a'])}${END}`)); + check('parseArtifact round-trips the rendered artifact', docs, parseArtifact(artifact)); + check('renderArtifact sorts', ['a', 'b'], parseArtifact(renderArtifact(['b', 'a']))); + check('renderArtifact is order-insensitive byte-for-byte', artifact, renderArtifact([...docs].reverse())); + // The artifact carries no comments (JSON), so the bytes have to say what it is — + // otherwise the next reader treats a generated file as a hand-kept one. check( - 'replaceBlock preserves the surrounding file', - ['header', 'footer', ''], - (() => { - const out = replaceBlock(file, ['content/docs/c.mdx']); - return [out.split('\n')[0], out.split('\n').at(-2), out.split('\n').at(-1)]; - })(), + 'the rendered artifact declares itself the single source and names its consumer arg', + [true, true, true], + [artifact.includes('SINGLE SOURCE'), artifact.includes(`args.${SCOPE_ARG}`), artifact.includes('--write')], ); - check('replaceBlock installs the new list', ['content/docs/c.mdx'], parseBlock(replaceBlock(file, ['content/docs/c.mdx']))); + check('…and ends with exactly one trailing newline', [true, false], [artifact.endsWith('}\n'), artifact.endsWith('\n\n')]); // A gate that cannot find its subject must fail, not pass. This is the exact // failure mode the gate exists to prevent, applied to the gate itself. - throws('missing markers throw', () => extractBlock('no markers here'), 'could not find the generated scope block'); + throws('unparseable JSON throws', () => parseArtifact('{ not json'), 'not valid JSON'); + throws('an artifact with no docs array throws', () => parseArtifact('{"readme":[]}'), 'no `docs` array of string paths'); throws( - 'a block with no array literal throws', - () => parseBlock(`${BEGIN}\n// nothing here\n${END}`), - 'no `const ALL_HANDWRITTEN = [...]` array literal', - ); - throws( - 'a hand-edited (non-JSON) array literal throws', - () => parseBlock(`${BEGIN}\nconst ALL_HANDWRITTEN = [\n 'a.mdx',\n]\n${END}`), - 'not a plain array of string literals', + 'a docs array holding a non-string throws', + () => parseArtifact('{"docs":["content/docs/a.mdx",7]}'), + 'no `docs` array of string paths', ); // Both directions of drift, each pinned on its own — #4851 asked only about @@ -727,18 +904,32 @@ async function selfTest() { throws( 'a workflow without the prefix constant throws', - () => parseReleaseOwnedPrefix('const ALL_HANDWRITTEN = []'), + () => parseReleaseOwnedPrefix('const SCOPE_SOURCE = ""'), 'no `const RELEASE_OWNED_PREFIX', ); const workflowSource = readFileSync(join(REPO_ROOT, WORKFLOW_REL), 'utf8'); + // The list left this file by ruling, and nothing may quietly bring it back: a second + // copy inside the sandbox body is the "one subject, two hand-kept lists" bill again, + // and this time it would also restore the governed-edit toll on every docs PR. + check( + 'the workflow body carries no inline copy of the list', + [false, false], + [/const ALL_HANDWRITTEN\s*=/.test(workflowSource), workflowSource.includes('generated:docs-audit-scope')], + ); + check( + 'the workflow names the artifact it is handed, and the arg it arrives in', + [true, true], + [workflowSource.includes(SCOPE_ARTIFACT_REL), workflowSource.includes(`const SCOPE_ARG = '${SCOPE_ARG}'`)], + ); + // (1) Still in scope. #4920's rejected option was deleting these pages from the // audit; that would show up right here, as an empty list. check( 'release pages are still IN the audit scope', true, - parseBlock(workflowSource).filter(isReleaseOwned).length > 0, + parseArtifact(readFileSync(join(REPO_ROOT, SCOPE_ARTIFACT_REL), 'utf8')).filter(isReleaseOwned).length > 0, ); // (2) …and routed read-only, observed on a real run of the workflow. @@ -768,6 +959,57 @@ async function selfTest() { } } + // --- the injection contract (#13591) --------------------------------------- + // + // Same discipline, one layer over: the list now arrives from outside, so the thing + // that can silently break is the BODY's consumption of it, not the list. Observed on + // the real workflow, then broken four ways in memory — each way is a shape a + // plausible future edit takes, and each must be seen to go red. + check('the workflow consumes the scope it is handed', [], await checkScopeInjection(workflowSource)); + + const injectionMutants = [ + // The read goes dead: the body is handed a list and ignores it. + ['the injected list is ignored', [ + ['args && Array.isArray(args[SCOPE_ARG]) && args[SCOPE_ARG].length ? args[SCOPE_ARG] : null', 'null'], + ]], + // The refusal is replaced by a silent default — an audit of nothing, reported as a + // result. This is the exact shape the loud-failure rule above exists to stop. + ['a silent default replaces the no-scope refusal', [ + ['if (!SCOPED && !HANDWRITTEN) {', 'if (false) {'], + ['const DOCS = SCOPED ?? HANDWRITTEN', 'const DOCS = SCOPED ?? HANDWRITTEN ?? []'], + ]], + // The summary stops saying WHICH list it audited: a truncated hand-in then reads + // exactly like the whole corpus. + ['the FULL-audit line stops naming the scope source', [ + [' — FULL audit (whole hand-written set from ${SCOPE_SOURCE})', ' — FULL audit'], + ]], + // The shape guard goes: a malformed hand-in dies somewhere downstream instead of + // naming the caller's own key. + ['the malformed-list guard is removed', [ + ["for (const key of ['docs', SCOPE_ARG]) {", 'for (const key of []) {'], + ]], + ]; + for (const [label, replacements] of injectionMutants) { + total++; + let mutated = workflowSource; + let missed = null; + for (const [from, to] of replacements) { + const next = mutated.replace(from, to); + if (next === mutated) { missed = from; break; } + mutated = next; + } + if (missed !== null) { + console.error(` ✗ injection mutation "${label}" did not apply (anchor not found: ${JSON.stringify(missed)}) — it cannot prove anything. Update the mutation to match the current source.`); + failed++; + continue; + } + const problems = await checkScopeInjection(mutated); + if (!problems.length) { + console.error(` ✗ injection mutation "${label}": checkScopeInjection stayed GREEN with the hand-in broken`); + failed++; + } + } + if (failed) { console.error(`\n✗ check-audit-scope self-test failed (${failed} case(s)).`); process.exit(1); diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json new file mode 100644 index 0000000000..dc81dcb245 --- /dev/null +++ b/scripts/docs-audit/handwritten-docs.json @@ -0,0 +1,214 @@ +{ + "readme": [ + "GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`", + "derives this from the filesystem (every content/docs/**/*.mdx except references/,", + "via `affected-docs.mjs --all`); the same script without --write is a CI gate that", + "fails when this file and content/docs/ disagree in EITHER direction.", + "", + "THIS FILE IS THE SINGLE SOURCE for \"which docs are hand-written\". Two consumers:", + " 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no", + " filesystem, so the CALLER reads this file and passes `docs` as args.handwritten.", + " 2. scripts/docs-audit/check-audit-scope.mjs — the gate that keeps it honest.", + "There is deliberately no second copy anywhere; one subject, one list.", + "", + "It lives here, outside .claude/** and outside every other governed surface, by", + "maintainer ruling (2026-09-01). Inline, it made every PR that added a documentation", + "page a human-merge-only PR — a governed edit forced by bookkeeping, invisible until", + "the gate ran. Moving it is NOT a narrowing of the governed register, which is", + "unchanged; do not treat this file as precedent for relocating anything else." + ], + "generator": "node scripts/docs-audit/check-audit-scope.mjs --write", + "gate": "pnpm check:docs-audit-scope", + "docs": [ + "content/docs/ai/actions-as-tools.mdx", + "content/docs/ai/agents.mdx", + "content/docs/ai/connect-mcp.mdx", + "content/docs/ai/index.mdx", + "content/docs/ai/knowledge-rag.mdx", + "content/docs/ai/natural-language-queries.mdx", + "content/docs/ai/skills-reference.mdx", + "content/docs/ai/skills.mdx", + "content/docs/ai/tools.mdx", + "content/docs/api/client-sdk.mdx", + "content/docs/api/data-api.mdx", + "content/docs/api/data-flow.mdx", + "content/docs/api/declarative-endpoints.mdx", + "content/docs/api/environment-routing.mdx", + "content/docs/api/error-catalog.mdx", + "content/docs/api/error-handling-client.mdx", + "content/docs/api/error-handling-server.mdx", + "content/docs/api/index.mdx", + "content/docs/api/metadata-api.mdx", + "content/docs/api/plugin-endpoints.mdx", + "content/docs/api/wire-format.mdx", + "content/docs/automation/approvals.mdx", + "content/docs/automation/connectors.mdx", + "content/docs/automation/email-templates.mdx", + "content/docs/automation/flows.mdx", + "content/docs/automation/hook-bodies.mdx", + "content/docs/automation/hooks.mdx", + "content/docs/automation/index.mdx", + "content/docs/automation/jobs.mdx", + "content/docs/automation/webhooks.mdx", + "content/docs/automation/workflows.mdx", + "content/docs/build-without-code.mdx", + "content/docs/capabilities/ai.mdx", + "content/docs/capabilities/analytics.mdx", + "content/docs/capabilities/approvals.mdx", + "content/docs/capabilities/automation.mdx", + "content/docs/capabilities/data.mdx", + "content/docs/capabilities/forms.mdx", + "content/docs/capabilities/index.mdx", + "content/docs/capabilities/integrations.mdx", + "content/docs/capabilities/permissions.mdx", + "content/docs/capabilities/request-template.mdx", + "content/docs/capabilities/views.mdx", + "content/docs/concepts/architecture.mdx", + "content/docs/concepts/design-principles.mdx", + "content/docs/concepts/index.mdx", + "content/docs/concepts/metadata-driven.mdx", + "content/docs/concepts/metadata-lifecycle.mdx", + "content/docs/concepts/north-star.mdx", + "content/docs/data-modeling/analytics.mdx", + "content/docs/data-modeling/drivers.mdx", + "content/docs/data-modeling/external-datasources.mdx", + "content/docs/data-modeling/field-type-decision-tree.mdx", + "content/docs/data-modeling/field-types.mdx", + "content/docs/data-modeling/fields.mdx", + "content/docs/data-modeling/formulas.mdx", + "content/docs/data-modeling/import-mappings.mdx", + "content/docs/data-modeling/index.mdx", + "content/docs/data-modeling/indexing.mdx", + "content/docs/data-modeling/object-extensions.mdx", + "content/docs/data-modeling/objects.mdx", + "content/docs/data-modeling/queries.mdx", + "content/docs/data-modeling/relationships.mdx", + "content/docs/data-modeling/schema-design.mdx", + "content/docs/data-modeling/seed-data.mdx", + "content/docs/data-modeling/validation-rules.mdx", + "content/docs/data-modeling/validation.mdx", + "content/docs/deployment/backup-restore.mdx", + "content/docs/deployment/cli.mdx", + "content/docs/deployment/environment-variables.mdx", + "content/docs/deployment/index.mdx", + "content/docs/deployment/production-readiness.mdx", + "content/docs/deployment/publish-and-preview.mdx", + "content/docs/deployment/seed-tenancy-repair.mdx", + "content/docs/deployment/self-hosting.mdx", + "content/docs/deployment/single-project-mode.mdx", + "content/docs/deployment/tenancy-modes.mdx", + "content/docs/deployment/troubleshooting.mdx", + "content/docs/deployment/validating-metadata.mdx", + "content/docs/getting-started/build-with-claude-code.mdx", + "content/docs/getting-started/common-patterns.mdx", + "content/docs/getting-started/examples.mdx", + "content/docs/getting-started/glossary.mdx", + "content/docs/getting-started/how-ai-development-works.mdx", + "content/docs/getting-started/index.mdx", + "content/docs/getting-started/quick-reference.mdx", + "content/docs/getting-started/quick-start.mdx", + "content/docs/getting-started/your-first-project.mdx", + "content/docs/index.mdx", + "content/docs/kernel/architecture.mdx", + "content/docs/kernel/cluster.mdx", + "content/docs/kernel/contracts/auth-service.mdx", + "content/docs/kernel/contracts/cache-service.mdx", + "content/docs/kernel/contracts/data-engine.mdx", + "content/docs/kernel/contracts/index.mdx", + "content/docs/kernel/contracts/metadata-service.mdx", + "content/docs/kernel/contracts/storage-service.mdx", + "content/docs/kernel/events.mdx", + "content/docs/kernel/index.mdx", + "content/docs/kernel/runtime-services/audit-service.mdx", + "content/docs/kernel/runtime-services/data-service.mdx", + "content/docs/kernel/runtime-services/email-service.mdx", + "content/docs/kernel/runtime-services/examples.mdx", + "content/docs/kernel/runtime-services/index.mdx", + "content/docs/kernel/runtime-services/queue-service.mdx", + "content/docs/kernel/runtime-services/settings-service.mdx", + "content/docs/kernel/runtime-services/sharing-service.mdx", + "content/docs/kernel/runtime-services/sms-service.mdx", + "content/docs/kernel/runtime-services/storage-service.mdx", + "content/docs/kernel/runtime-services/versioning.mdx", + "content/docs/kernel/services-checklist.mdx", + "content/docs/kernel/services.mdx", + "content/docs/permissions/access-matrix.mdx", + "content/docs/permissions/access-recipes.mdx", + "content/docs/permissions/administrator-guide.mdx", + "content/docs/permissions/attachments-access.mdx", + "content/docs/permissions/authentication.mdx", + "content/docs/permissions/authorization.mdx", + "content/docs/permissions/capabilities.mdx", + "content/docs/permissions/delegated-administration.mdx", + "content/docs/permissions/explain.mdx", + "content/docs/permissions/field-level-security.mdx", + "content/docs/permissions/index.mdx", + "content/docs/permissions/permission-metadata.mdx", + "content/docs/permissions/permission-sets.mdx", + "content/docs/permissions/permissions-matrix.mdx", + "content/docs/permissions/positions.mdx", + "content/docs/permissions/profiles.mdx", + "content/docs/permissions/record-view-auditing.mdx", + "content/docs/permissions/rls.mdx", + "content/docs/permissions/sharing-rules.mdx", + "content/docs/permissions/sso.mdx", + "content/docs/permissions/system-context.mdx", + "content/docs/permissions/tenant-audit-census.mdx", + "content/docs/plugins/adding-a-metadata-type.mdx", + "content/docs/plugins/anatomy.mdx", + "content/docs/plugins/development.mdx", + "content/docs/plugins/index.mdx", + "content/docs/plugins/packages.mdx", + "content/docs/protocol/backward-compatibility.mdx", + "content/docs/protocol/diagram.mdx", + "content/docs/protocol/index.mdx", + "content/docs/protocol/kernel/config-resolution.mdx", + "content/docs/protocol/kernel/error-handling.mdx", + "content/docs/protocol/kernel/http-protocol.mdx", + "content/docs/protocol/kernel/i18n-standard.mdx", + "content/docs/protocol/kernel/index.mdx", + "content/docs/protocol/kernel/lifecycle.mdx", + "content/docs/protocol/kernel/metadata-service.mdx", + "content/docs/protocol/kernel/plugin-spec.mdx", + "content/docs/protocol/kernel/realtime-protocol.mdx", + "content/docs/protocol/knowledge.mdx", + "content/docs/protocol/objectql/index.mdx", + "content/docs/protocol/objectql/query-syntax.mdx", + "content/docs/protocol/objectql/schema.mdx", + "content/docs/protocol/objectql/security.mdx", + "content/docs/protocol/objectql/state-machine.mdx", + "content/docs/protocol/objectql/types.mdx", + "content/docs/protocol/objectui/actions.mdx", + "content/docs/protocol/objectui/concept.mdx", + "content/docs/protocol/objectui/index.mdx", + "content/docs/protocol/objectui/layout-dsl.mdx", + "content/docs/protocol/objectui/record-alert.mdx", + "content/docs/protocol/objectui/widget-contract.mdx", + "content/docs/releases/implementation-status.mdx", + "content/docs/releases/index.mdx", + "content/docs/releases/v12.mdx", + "content/docs/releases/v13.mdx", + "content/docs/releases/v14.mdx", + "content/docs/releases/v15.mdx", + "content/docs/releases/v16.mdx", + "content/docs/releases/v17.mdx", + "content/docs/releases/v9.mdx", + "content/docs/ui/actions.mdx", + "content/docs/ui/apps.mdx", + "content/docs/ui/audience-based-interfaces.mdx", + "content/docs/ui/create-vs-edit-form.mdx", + "content/docs/ui/dashboards.mdx", + "content/docs/ui/doc-pages.mdx", + "content/docs/ui/field-grouping-and-order.mdx", + "content/docs/ui/forms.mdx", + "content/docs/ui/index.mdx", + "content/docs/ui/pages.mdx", + "content/docs/ui/public-data-collection.mdx", + "content/docs/ui/react-pages.mdx", + "content/docs/ui/reports.mdx", + "content/docs/ui/setup-app.mdx", + "content/docs/ui/translations.mdx", + "content/docs/ui/views.mdx", + "content/docs/upgrading.mdx" + ] +} From 97f42002ffca79ce3c16fea1c41f2a2ca487e46a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 03:35:17 +0000 Subject: [PATCH 2/3] chore(governed): retire the #9866 provenance row now that its subject left .claude/ Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2 --- scripts/pm/check-governed-merges.mjs | 433 +++++++++++---------------- 1 file changed, 177 insertions(+), 256 deletions(-) diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index a919024ec4..6c5ca01ba4 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -90,55 +90,64 @@ * `examples/AGENTS.md` (an example-tree file) and not the * `create-objectstack` template copy (product content). * - * ## The generated-artifact exception (#9866; maintainer 2026-08-20 + 2026-08-22) - * - * Exactly one file under `.claude/**` is not instruction-tree prose but a - * required gate's own `--write` artifact: `check:docs-audit-scope` keeps its - * generated `ALL_HANDWRITTEN` page-scope block inside - * `.claude/workflows/docs-accuracy-audit.js` (the workflow runs in a `node:vm` - * sandbox that cannot read files, so the list must live inline). Adding a docs - * page reddens that gate, the gate's own `--write` regenerates the block, and - * the block sat inside this fence — so EVERY page-adding docs PR crossed the - * governed surface (measured 5-for-5 on #9866), and three of the crossings - * merged with nobody recording them. A fence that fires on routine traffic - * trains every seat to read past it. - * - * The maintainer ruled for the provenance-aware exception — #10277's Option C - * = #9866's shape 2 — verbatim 「10277 同意 C」 (2026-08-20), re-confirmed over - * the same day's earlier relocate-the-artifact ruling: 「A:按方案 2(最新裁 - * 定)」 (2026-08-22). Four load-bearing constraints, each pinned by - * `--self-test`; drop one and the exception is a hole: - * - * 1. RECOMPUTE, never compare to a stored baseline. The pass criterion is - * "the file's diff byte-equals what - * `node scripts/docs-audit/check-audit-scope.mjs --write` produces on the - * tree under test" — concretely, the tree's copy of the file must equal - * the generator's `replaceBlock(baseFile, docsDerivedFromThisTree)`. Any - * stored or cached comparison can be constructed against. - * 2. BYTE-EXACT, no shape heuristics. There is no "only the ALL_HANDWRITTEN - * block changed, so allow it" — a reordered-but-equal list rejects too. - * 3. A NAMED single-file exception, not a class mechanism. - * `GENERATED_SURFACE_EXCEPTIONS` is structurally a list, seeded with this - * one entry by ruling; every other governed-surface judgment — the rest - * of `.claude/**` included — is character-for-character unchanged. - * 4. FAIL CLOSED, and the mixed-diff rule is untouched: provenance that - * cannot be recomputed (no base version, unreadable file, generator - * failure, empty derivation) keeps the path governed, and a hit on any - * OTHER governed path still forks the whole PR — 「混合 diff 一条命中即整 - * PR 分叉」. - * - * ### The generator co-edit is fenced out (#11084) — a NARROWING, not a widening - * - * Constraint 1 makes the recompute run the tree under test's OWN - * `scripts/docs-audit/**` (it must — the derivation has to reflect the PR's - * own docs). So a PR that edits the generator AND hand-edits the artifact in - * one diff could in principle construct a derivation whose recomputed splice - * byte-equals its hand-edited block: the tree would be certifying itself. - * Cheap fence, `--test` only: if the submitted path list contains ANY - * `scripts/docs-audit/**` path, the recompute is SKIPPED and the path stays - * governed with that reason stated. This only ever moves verdicts toward - * governed — the pure-regeneration path with an untouched generator lifts - * exactly as it did before, and no path that was governed becomes clear. + * ## The generated-artifact exception (#9866) is RETIRED — its subject moved out + * + * History worth keeping, because the shape recurs. Exactly one file under + * `.claude/**` was not instruction-tree prose but a required gate's own + * `--write` artifact: `check:docs-audit-scope` kept its generated + * `ALL_HANDWRITTEN` page-scope block inside + * `.claude/workflows/docs-accuracy-audit.js`, because the workflow runs in a + * `node:vm` sandbox that cannot read files. Adding a docs page reddened that + * gate, the gate's `--write` regenerated the block, and the block sat inside + * this fence — so EVERY page-adding docs PR crossed the governed surface + * (measured 5-for-5 on #9866), and three of the crossings merged with nobody + * recording them. A fence that fires on routine traffic trains every seat to + * read past it. + * + * The first remedy was a provenance-aware exception in this register — a row + * that lifted that one path when the tree's copy byte-equalled the generator's + * own recomputed splice (#10277 Option C, 「10277 同意 C」 2026-08-20, re-confirmed + * 「A:按方案 2(最新裁定)」 2026-08-22 over the same day's earlier + * relocate-the-artifact proposal). + * + * ⚖️ On 2026-09-01 the maintainer ruled the other way, verbatim 「同意」 on + * #13591's option 2: MOVE THE LIST OFF THE GOVERNED SURFACE. It now lives in + * `scripts/docs-audit/handwritten-docs.json`, and the workflow's caller hands it + * to the sandbox as `args.handwritten` — the read happens outside the vm, so the + * inline copy that made the exception necessary no longer exists. The newer + * ruling supersedes the older, so the row is GONE rather than dormant. Keeping + * it would have been actively unsafe: with no generated artifact left at that + * path, a row still naming it could only ever be asked to certify hand-authored + * instruction prose, which is precisely what the fence exists to stop. + * + * ⚠️ What did NOT change, in either direction: `GOVERNED_SURFACES` is + * character-for-character the same list — `.claude/**` included. #13591's option + * 3, carving `.claude/workflows/**` out of the register, was considered and + * explicitly REJECTED by the same ruling. Removing this row NARROWS what the + * exception machinery lifts; nothing that was governed became clear. + * + * ### The generator co-edit fence (#11084) — a NARROWING, not a widening + * + * A recompute has to run the tree under test's OWN generator (it must — the + * derivation has to reflect the PR's own content). So a PR that edits the + * generator AND hand-edits its artifact in one diff could in principle construct + * a derivation whose recomputed output byte-equals its hand edit: the tree would + * be certifying itself. Cheap fence, `--test` only: if the submitted path list + * contains ANY path under that row's own trusted generator tree, the recompute is + * SKIPPED and the path stays governed with that reason stated. This only ever + * moves verdicts toward governed — a pure regeneration with an untouched + * generator lifts exactly as before, and no path that was governed becomes clear. + * + * The remaining constraints, each pinned by `--self-test`; drop one and an + * exception row is a hole: + * + * 1. RECOMPUTE, never compare to a stored baseline. Any stored or cached + * comparison can be constructed against. + * 2. BYTE-EXACT, no shape heuristics — a reordered-but-equal artifact rejects. + * 3. Rows are NAMED and ruled, never a class mechanism. + * 4. FAIL CLOSED, and the mixed-diff rule is untouched: provenance that cannot + * be recomputed keeps the path governed, and a hit on any OTHER governed + * path still forks the whole PR — 「混合 diff 一条命中即整 PR 分叉」. * * The exception applies to the `--test` predicate only. The post-merge SWEEP * still lists a pure-regeneration merge: under-enumeration is the one @@ -677,15 +686,17 @@ export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**', 'CLAUDE.md/**']; * * Row fields: * `path` — an exact repo-relative path, for a generator that owns - * exactly one file the register can name (the #9866 row). + * exactly one file the register can name. No row uses this + * today; it is kept because `generatedExceptionFor` reads it + * and the shape is the cheaper one when it fits. * `candidate` — a NARROWING gate for a generator whose output set is * enumerated at run time: matching it earns a path the * question, never the answer. See the header — a candidate * cannot lift anything by itself, in either direction. * `verify` — the sink-generator recompute (#11705): run this package * script's `--check` on the tree under test and read the - * output manifest it declares. Absent ⇒ the #9866 splice - * recompute. + * output manifest it declares. REQUIRED: a row with no + * recompute cannot prove anything, so it fails closed. * `generator` — the command an operator runs to regenerate; rendered in * every verdict, lifted or not. * `trustedGeneratorPrefixes` — the tree whose co-edit fences this row @@ -693,15 +704,6 @@ export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**', 'CLAUDE.md/**']; * by it. */ export const GENERATED_SURFACE_EXCEPTIONS = Object.freeze([ - Object.freeze({ - id: 'docs-audit-scope', - ruling: '#9866', - path: '.claude/workflows/docs-accuracy-audit.js', - surfaceId: 'claude-tree', - generator: 'node scripts/docs-audit/check-audit-scope.mjs --write', - trustedGeneratorPrefixes: Object.freeze(['scripts/docs-audit/']), - what: "the docs-audit workflow's generated ALL_HANDWRITTEN scope block (a gate-owned --write artifact, not instruction-tree prose)", - }), Object.freeze({ id: 'spec-skill-refs', ruling: '#11705', @@ -739,16 +741,6 @@ export function generatedExceptionFor(path) { return GENERATED_SURFACE_EXCEPTIONS.find((e) => (e.path ? e.path === path : e.candidate.test(path))) ?? null; } -/** - * The generator tree the #9866 row's recompute TRUSTS: constraint 1 executes - * this directory's `affected-docs.mjs` and imports its `check-audit-scope.mjs` - * `replaceBlock`, both from the tree under test. A PR that edits anything - * here is editing the instrument the certificate is measured with. Kept as a - * named export because it is that row's own prefix; every row carries its own - * list in `trustedGeneratorPrefixes`. - */ -export const TRUSTED_GENERATOR_PREFIX = 'scripts/docs-audit/'; - /** * The #11084 fence, pure so `--self-test` pins both directions offline. Given * the PR's submitted path list and the register row under consideration, @@ -764,12 +756,24 @@ export const TRUSTED_GENERATOR_PREFIX = 'scripts/docs-audit/'; * rather than left to `startsWith` alone. * * PER ROW, not global: the fence names the instrument that measures THIS row. - * A docs PR that also edits the spec generator tree has not touched the - * docs-audit generator, and fencing it there would refuse a regeneration the - * ruling allows. + * A PR that edits some OTHER generator tree has not touched this row's, and + * fencing it there would refuse a regeneration the ruling allows. `entry` is + * therefore required — there is no "the" row to default to, and a default would + * silently measure one row's diff against another row's instrument. */ -export function generatorCoEditProvenance(paths, entry = GENERATED_SURFACE_EXCEPTIONS[0]) { - const prefixes = entry?.trustedGeneratorPrefixes ?? [TRUSTED_GENERATOR_PREFIX]; +export function generatorCoEditProvenance(paths, entry) { + const prefixes = entry?.trustedGeneratorPrefixes ?? []; + if (prefixes.length === 0) { + // No row, or a row that names no instrument tree: there is nothing to fence + // WITH, so the honest answer is not "no co-edit" (which would send the path on + // to be recomputed) but fail-closed — the one direction that cannot open a hole. + return { + pureRegeneration: false, + reason: + 'the register row names no trusted generator tree, so a generator co-edit cannot be ruled out — ' + + 'fail closed: the path stays governed', + }; + } const coEdited = (Array.isArray(paths) ? paths : []).filter( (p) => typeof p === 'string' && prefixes.some((prefix) => p.startsWith(prefix) || p.startsWith(`./${prefix}`)), ); @@ -783,49 +787,9 @@ export function generatorCoEditProvenance(paths, entry = GENERATED_SURFACE_EXCEP }; } -/** - * The byte-exact provenance verdict, pure so `--self-test` pins all four - * ruled cases offline. Inputs: the file at the PR's merge base - * (`baseSource`), the file on the tree under test (`prSource`), the doc set - * derived from that same tree (`derivedDocs`), and the generator's own - * `replaceBlock`. Pass ⟺ `prSource === replaceBlock(baseSource, derivedDocs)` - * — which is exactly "the PR's diff to this file equals the diff `--write` - * produces on the PR's own tree": the generated block must be the recomputed - * render (rejects in-block hand edits, reorderings included) AND everything - * outside the block must be untouched relative to base (rejects out-of-block - * hand edits, which `--write` itself would preserve and so cannot vouch for). - * Every non-pass answers with a stated reason; there is no heuristic branch. - */ -export function docsAuditRegenVerdict({ baseSource, prSource, derivedDocs, replaceBlock }) { - if (typeof prSource !== 'string') { - return { pureRegeneration: false, reason: 'the file could not be read from the tree under test — fail closed: the path stays governed' }; - } - if (typeof baseSource !== 'string') { - return { pureRegeneration: false, reason: 'no base version to diff against (file added, or unreadable at the merge base) — a whole new workflow file is not a regeneration; fail closed' }; - } - if (!Array.isArray(derivedDocs) || derivedDocs.length === 0) { - return { pureRegeneration: false, reason: 'the derived doc set is empty or unreadable — refusing to certify a regeneration of nothing (the generator itself refuses the same); fail closed' }; - } - let expected; - try { - expected = replaceBlock(baseSource, derivedDocs); - } catch (error) { - return { pureRegeneration: false, reason: `the generator could not splice the base file (${String(error?.message ?? error).split('\n')[0]}) — fail closed` }; - } - if (prSource === expected) { - return { pureRegeneration: true, reason: 'byte-equal to the generator output recomputed on this tree (no stored baseline consulted)' }; - } - return { - pureRegeneration: false, - reason: - 'differs from the generator output recomputed on this tree — byte-exact equality is the only pass, so a hand edit ' + - '(inside or outside the generated block, a reordering included) keeps the path governed', - }; -} - /** * Apply the exception register to a `--test` verdict. Pure: provenance - * arrives as a Map of path → `docsAuditRegenVerdict`-shaped result. A + * arrives as a Map of path → `{ pureRegeneration, reason }` result. A * registered path with verified pure-regeneration provenance is LIFTED from * the hit set (recorded under `exceptions`, never under `clearPaths` — it is * on the register, exempted, not absent); a registered path with failed or @@ -1043,10 +1007,10 @@ export function renderExceptionLines(verdict) { exceptions .map((e) => e.pureRegeneration - ? ` ℹ️ generated-surface exception (${e.ruling ?? '#9866'}): ${e.path} is a PURE REGENERATION —\n` + + ? ` ℹ️ generated-surface exception (${e.ruling ?? '(unruled row)'}): ${e.path} is a PURE REGENERATION —\n` + ` byte-equal to \`${e.generator}\` recomputed on THIS tree (never a stored baseline),\n` + ` so this path does not govern the PR by itself. Any other governed hit still forks the whole PR.` - : ` ⛔ generated-surface exception (${e.ruling ?? '#9866'}) did NOT lift ${e.path}:\n` + + : ` ⛔ generated-surface exception (${e.ruling ?? '(unruled row)'}) did NOT lift ${e.path}:\n` + ` ${e.reason}\n` + ` The path stays governed; a pure \`${e.generator}\` regeneration is the only thing the exception passes.`, ) @@ -1081,54 +1045,6 @@ export function renderTestVerdict(verdict) { ); } -/** - * The real recompute behind the exception, on the local tree at `root` (the - * tree under test — run the PR's own copy of this script from the PR's own - * checkout, or point `--root` at it). Constraint 1 in code: the expected - * bytes are produced HERE, from this tree's docs derivation spliced by the - * generator's own exported `replaceBlock`, never read from anywhere stored. - * Every failure path answers fail-closed with a stated reason. No network. - */ -export async function recomputeDocsAuditProvenance(root, exception, { baseRef = null } = {}) { - const failClosed = (reason) => ({ pureRegeneration: false, reason: `${reason} — fail closed: the path stays governed` }); - let replaceBlock; - try { - ({ replaceBlock } = await import('../docs-audit/check-audit-scope.mjs')); - if (typeof replaceBlock !== 'function') return failClosed('the generator module exports no replaceBlock'); - } catch (error) { - return failClosed(`could not load the generator module (${String(error?.message ?? error).split('\n')[0]})`); - } - let prSource; - try { - prSource = readFileSync(join(root, exception.path), 'utf8'); - } catch (error) { - return failClosed(`could not read ${exception.path} from the tree under test (${String(error?.message ?? error).split('\n')[0]})`); - } - let baseSource; - try { - // `baseRef` is how a merge-group build names its base (it has no reason to - // hold `origin/main`); a seat run resolves the merge base itself. - const base = baseRef ?? git(root, ['merge-base', 'origin/main', 'HEAD']).trim(); - baseSource = git(root, ['show', `${base}:${exception.path}`]); - } catch (error) { - return failClosed(`could not read the base version of ${exception.path} (${String(error?.message ?? error).split('\n')[0]})`); - } - let derivedDocs; - try { - derivedDocs = JSON.parse( - execFileSync(process.execPath, [join(root, 'scripts/docs-audit/affected-docs.mjs'), '--all', '--json'], { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'ignore'], - }), - ).docs; - } catch (error) { - return failClosed(`could not derive the doc set on this tree (${String(error?.message ?? error).split('\n')[0]})`); - } - return docsAuditRegenVerdict({ baseSource, prSource, derivedDocs, replaceBlock }); -} - /** * The #11705 verdict, pure so `--self-test` pins every branch offline. Inputs: * the hit `path`, its register `entry`, and `run` — what the generator answered @@ -1281,7 +1197,17 @@ export async function recomputeProvenanceFor(root, hitsByEntry, { allPaths = [], for (const path of paths) provenance.set(path, sinkGeneratorVerdict({ path, entry, run })); continue; } - for (const path of paths) provenance.set(path, await recomputeDocsAuditProvenance(root, entry, { baseRef })); + // A row with no recompute cannot prove anything about its paths. There is no + // "trust the row" branch here — that would lift a governed path on the strength + // of a register entry alone, which is the one thing no row is allowed to do. + for (const path of paths) { + provenance.set(path, { + pureRegeneration: false, + reason: + `the register row \`${entry.id}\` declares no recompute (\`verify\`), so nothing can prove this ` + + 'file matches its generator on the tree under test — fail closed: the path stays governed', + }); + } } return provenance; } @@ -3208,25 +3134,36 @@ async function selfTest() { assert(`--test-answers-governed-for-${surface.id}`, testVerdict([sample]).governed === true, sample); } - // ── the generated-artifact exception (#9866; rulings 2026-08-20 + -22) ─── + // ── the generated-artifact exception register (#9866 · #11084 · #11705) ── // // Safety-relevant merge-gate code: a bug here waves real instruction edits - // past the fence. The four ruled cases are pinned against the GENERATOR'S - // OWN exported render/splice functions, not imitations, so the fixtures are - // real `--write` output; if the generator ever renames its block markers, - // `replaceBlock` stops splicing these fixtures and the pure-regen case goes - // red HERE — loud, which is the point. - const { renderBlock: genRender, replaceBlock: genReplace } = await import('../docs-audit/check-audit-scope.mjs'); - // The register: every row is ruled, and the #9866 row is still the exact - // single file its ruling named. A row must also name a surface its own paths - // are actually governed by — an exception for an ungoverned path would be a - // register row nothing reads. + // past the fence. ⚖️ The #9866 docs-audit row RETIRED on 2026-09-01 (header + // section above): its subject — the audit workflow's inline page list — was + // moved off the governed surface by ruling, so there is no generated artifact + // under `.claude/**` for any row to certify, and a row still naming that path + // could only ever be asked to vouch for hand-authored instruction prose. The + // register's SEMANTICS are pinned exactly as before, against the rows that + // remain: the invariants, the lift / reject / fail-closed behaviour of + // `applyGeneratedExceptions`, the untouched mixed-diff rule, and the #11084 + // co-edit fence in both directions. + // + // The register: every row is ruled, every row can actually prove something, + // and every row names a surface its own paths are governed by — an exception + // for an ungoverned path would be a register row nothing reads. assert('every-register-row-cites-the-ruling-that-put-it-there', GENERATED_SURFACE_EXCEPTIONS.every((e) => typeof e.ruling === 'string' && /^#\d+$/.test(e.ruling)), JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.ruling]))); - assert('the-9866-row-is-still-the-exact-single-file-its-ruling-named', - GENERATED_SURFACE_EXCEPTIONS[0].path === '.claude/workflows/docs-accuracy-audit.js' && GENERATED_SURFACE_EXCEPTIONS[0].ruling === '#9866', - JSON.stringify(GENERATED_SURFACE_EXCEPTIONS[0])); + // The retirement, asserted rather than remembered: nothing may quietly + // reinstate a lift over the instruction tree. Both row shapes are checked, + // because a `candidate` regexp could re-admit the path without a `path` field. + assert('no-row-lifts-anything-under-.claude-the-retired-9866-row-stays-retired', + GENERATED_SURFACE_EXCEPTIONS.every((e) => + !(e.path ?? '').startsWith('.claude/') && + !(e.candidate?.test('.claude/workflows/docs-accuracy-audit.js') ?? false)), + JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.path ?? String(e.candidate)]))); + assert('the-audit-workflow-is-plainly-governed-again-with-no-provenance-consulted', + testVerdict(['.claude/workflows/docs-accuracy-audit.js']).governed === true && + generatedExceptionFor('.claude/workflows/docs-accuracy-audit.js') === null); assert('a-row-matches-either-an-exact-path-or-a-narrowing-candidate-never-neither-and-never-both', GENERATED_SURFACE_EXCEPTIONS.every((e) => (typeof e.path === 'string') !== (e.candidate instanceof RegExp)), JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, typeof e.path, String(e.candidate)]))); @@ -3239,72 +3176,44 @@ async function selfTest() { }), JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => e.path ?? REGISTER_SAMPLES[e.id]))); assert('every-row-names-its-generator-command-and-its-trusted-instrument-tree', GENERATED_SURFACE_EXCEPTIONS.every((e) => typeof e.generator === 'string' && e.generator !== '' && (e.trustedGeneratorPrefixes ?? []).length > 0)); - assert('the-exception-names-its-generator-command', - GENERATED_SURFACE_EXCEPTIONS[0].generator.includes('check-audit-scope.mjs --write')); - - const oldDocs = ['content/docs/a.mdx']; - const newDocs = ['content/docs/a.mdx', 'content/docs/new-page.mdx']; - const baseWorkflow = - `// header prose\n// ${genRender(oldDocs)}// \n` + - `const RELEASE_OWNED_PREFIX = 'content/docs/releases/'\n// footer\n`; - const pureRegen = genReplace(baseWorkflow, newDocs); - const regen = (prSource, over = {}) => - docsAuditRegenVerdict({ baseSource: baseWorkflow, prSource, derivedDocs: newDocs, replaceBlock: genReplace, ...over }); - assert('the-pure-regen-fixture-is-a-real-diff-not-a-no-op', pureRegen !== baseWorkflow && pureRegen.includes('new-page.mdx')); - // Ruled case 1: a pure regeneration passes. - const pureVerdict = regen(pureRegen); - assert('ruled-case-1-pure-regeneration-passes', pureVerdict.pureRegeneration === true, pureVerdict.reason); - assert('a-pass-says-it-recomputed-and-consulted-no-stored-baseline', /recomputed on this tree/.test(pureVerdict.reason) && /no stored baseline/.test(pureVerdict.reason), pureVerdict.reason); - // Ruled case 2: an in-block hand edit rejects. - const inBlockEdit = pureRegen.replace(' "content/docs/new-page.mdx",', ' "content/docs/new-page.mdx",\n "content/docs/sneaked-in.mdx",'); - assert('the-in-block-mutation-applied', inBlockEdit !== pureRegen); - assert('ruled-case-2-in-block-hand-edit-rejects', regen(inBlockEdit).pureRegeneration === false); - // Ruled case 3: an out-of-block edit rejects — `--write` itself would - // PRESERVE it, which is exactly why the compare runs from the base file. - const outOfBlockEdit = pureRegen.replace('// footer', '// footer, hand-edited'); - assert('the-out-of-block-mutation-applied', outOfBlockEdit !== pureRegen); - assert('ruled-case-3-out-of-block-edit-rejects', regen(outOfBlockEdit).pureRegeneration === false); - // Ruled case 4: mixed regeneration + hand edit rejects. - const mixedEdit = inBlockEdit.replace('// footer', '// footer, hand-edited'); - assert('ruled-case-4-mixed-regen-plus-hand-edit-rejects', regen(mixedEdit).pureRegeneration === false); - // Byte-exact means byte-exact: the same doc SET reordered is not a pass — - // the "only the block changed, and to equivalent content" heuristic is the - // shape the ruling forbids. - const reordered = pureRegen.replace( - ' "content/docs/a.mdx",\n "content/docs/new-page.mdx",', - ' "content/docs/new-page.mdx",\n "content/docs/a.mdx",', - ); - assert('the-reorder-mutation-applied', reordered !== pureRegen); - assert('an-equal-set-in-a-different-order-rejects-no-shape-heuristics', regen(reordered).pureRegeneration === false); - // Fail-closed inputs, each with its own stated reason. - assert('no-base-version-rejects-an-added-workflow-file-is-not-a-regeneration', regen(pureRegen, { baseSource: null }).pureRegeneration === false); - assert('an-unreadable-tree-file-rejects', regen(null).pureRegeneration === false); - assert('an-empty-derivation-rejects-like-the-generator-itself-refuses-it', regen(pureRegen, { derivedDocs: [] }).pureRegeneration === false); - assert('a-generator-splice-failure-rejects', regen(pureRegen, { baseSource: 'no markers here at all' }).pureRegeneration === false); - - // Applying the exception to a verdict — the fence semantics. - const wfPath = GENERATED_SURFACE_EXCEPTIONS[0].path; - const verified = new Map([[wfPath, { pureRegeneration: true, reason: 'byte-equal (fixture)' }]]); - const liftedVerdict = applyGeneratedExceptions(testVerdict(['content/docs/x.mdx', wfPath]), verified); + // A row with no recompute can prove nothing, so it must not exist: the + // dispatcher fails such a row closed, and this refuses it one level earlier. + assert('every-row-declares-the-recompute-that-proves-its-paths', + GENERATED_SURFACE_EXCEPTIONS.every((e) => e.verify && typeof e.verify.gate === 'string' && typeof e.verify.pkg === 'string'), + JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.verify ?? null]))); + { + const noVerify = Object.freeze({ id: 'fixture-row', ruling: '#0', trustedGeneratorPrefixes: Object.freeze(['x/']) }); + const answered = await recomputeProvenanceFor('/nonexistent', new Map([[noVerify, ['skills/x/references/_index.md']]])); + const only = [...answered.values()][0]; + assert('a-row-that-declares-no-recompute-fails-closed-rather-than-lifting', + only?.pureRegeneration === false && /declares no recompute/.test(only?.reason ?? ''), JSON.stringify(only)); + } + + // Applying the exception to a verdict — the fence semantics, on a real + // registered path. Provenance is a fixture here on purpose: these cases pin + // what the register DOES with a verdict, not how a verdict is reached. + const regPath = REGISTER_SAMPLES['spec-skill-refs']; + const verified = new Map([[regPath, { pureRegeneration: true, reason: 'byte-equal (fixture)' }]]); + const liftedVerdict = applyGeneratedExceptions(testVerdict(['content/docs/x.mdx', regPath]), verified); assert('a-verified-pure-regen-lifts-the-only-hit-and-the-pr-is-not-governed', liftedVerdict.governed === false && liftedVerdict.hitPaths.length === 0 && liftedVerdict.matched.length === 0, JSON.stringify(liftedVerdict.hitPaths)); assert('a-lifted-path-is-recorded-as-an-exception-never-as-a-clear-path', - !liftedVerdict.clearPaths.includes(wfPath) && liftedVerdict.exceptions.length === 1 && liftedVerdict.exceptions[0].pureRegeneration === true, JSON.stringify(liftedVerdict.exceptions)); - const rejectedVerdict = applyGeneratedExceptions(testVerdict([wfPath]), new Map([[wfPath, { pureRegeneration: false, reason: 'differs (fixture)' }]])); - assert('a-failed-provenance-keeps-the-path-governed', rejectedVerdict.governed === true && rejectedVerdict.hitPaths.join() === wfPath); - const absentProvenance = applyGeneratedExceptions(testVerdict([wfPath]), new Map()); + !liftedVerdict.clearPaths.includes(regPath) && liftedVerdict.exceptions.length === 1 && liftedVerdict.exceptions[0].pureRegeneration === true, JSON.stringify(liftedVerdict.exceptions)); + const rejectedVerdict = applyGeneratedExceptions(testVerdict([regPath]), new Map([[regPath, { pureRegeneration: false, reason: 'differs (fixture)' }]])); + assert('a-failed-provenance-keeps-the-path-governed', rejectedVerdict.governed === true && rejectedVerdict.hitPaths.join() === regPath); + const absentProvenance = applyGeneratedExceptions(testVerdict([regPath]), new Map()); assert('absent-provenance-fails-closed-never-open', absentProvenance.governed === true && /fail closed/.test(absentProvenance.exceptions[0].reason), JSON.stringify(absentProvenance.exceptions)); // The mixed-diff rule is untouched: one hit on any OTHER governed path // still forks the whole PR, provenance verified or not. - const mixedGoverned = applyGeneratedExceptions(testVerdict([wfPath, '.claude/skills/x/SKILL.md']), verified); + const mixedGoverned = applyGeneratedExceptions(testVerdict([regPath, '.claude/skills/x/SKILL.md']), verified); assert('one-hit-on-any-OTHER-governed-path-still-forks-the-whole-pr', mixedGoverned.governed === true && mixedGoverned.hitPaths.join() === '.claude/skills/x/SKILL.md', JSON.stringify(mixedGoverned.hitPaths)); assert('and-the-lifted-path-is-still-reported-as-lifted-on-a-mixed-diff', mixedGoverned.exceptions[0].pureRegeneration === true); - // A NAMED single file, not a class: a sibling workflow file never consults + // NAMED rows, not a class: a file the register does not cover never consults // provenance, even provenance that claims to be verified. const sibling = '.claude/workflows/some-other-workflow.js'; const siblingVerdict = applyGeneratedExceptions(testVerdict([sibling]), new Map([[sibling, { pureRegeneration: true, reason: 'x' }]])); - assert('a-sibling-workflow-file-is-not-excepted-single-file-not-a-class', + assert('an-unregistered-file-is-not-excepted-named-rows-not-a-class', siblingVerdict.governed === true && siblingVerdict.exceptions.length === 0, JSON.stringify(siblingVerdict.exceptions)); assert('a-verdict-that-never-consulted-the-exception-carries-no-exceptions-field', testVerdict(['AGENTS.md']).exceptions === undefined); @@ -3312,48 +3221,53 @@ async function selfTest() { // // The fence decides whether `--test` recomputes at all, so a bug in either // direction is load-bearing: too loose re-opens the self-certification, too - // tight would break the ruled pure-regeneration lift. The register's own - // generator command must live under the fenced prefix — if the generator - // ever relocates, the fence must follow, and this goes red first. - assert('the-fence-covers-the-directory-the-registered-generator-actually-runs-from', - GENERATED_SURFACE_EXCEPTIONS[0].generator.includes(TRUSTED_GENERATOR_PREFIX), GENERATED_SURFACE_EXCEPTIONS[0].generator); + // tight would break the ruled pure-regeneration lift. + const fenceRow = GENERATED_SURFACE_EXCEPTIONS.find((e) => e.id === 'spec-skill-refs'); + const fencePrefix = fenceRow.trustedGeneratorPrefixes[0]; // Direction A — co-edit: the generator is touched, so no recompute happens // and the artifact stays governed, with the stated reason. - const coEditPaths = [wfPath, 'scripts/docs-audit/affected-docs.mjs']; - const coEdit = generatorCoEditProvenance(coEditPaths); + const coEditPaths = [regPath, `${fencePrefix}build-skill-references.ts`]; + const coEdit = generatorCoEditProvenance(coEditPaths, fenceRow); assert('a-generator-co-edit-answers-fail-closed-before-any-recompute', coEdit !== null && coEdit.pureRegeneration === false, JSON.stringify(coEdit)); assert('the-co-edit-reason-states-the-ruled-words-and-names-the-co-edited-file', /modifies the generator this exception trusts — the path stays governed/.test(coEdit?.reason ?? '') && - (coEdit?.reason ?? '').includes('scripts/docs-audit/affected-docs.mjs'), String(coEdit?.reason)); + (coEdit?.reason ?? '').includes(`${fencePrefix}build-skill-references.ts`), String(coEdit?.reason)); // A neutered fence must fail LOUD and READABLE here, not crash the run: the // absent answer is itself the finding, so it is asserted, never dereferenced. - const coEditVerdict = applyGeneratedExceptions(testVerdict(coEditPaths), new Map(coEdit ? [[wfPath, coEdit]] : [])); + const coEditVerdict = applyGeneratedExceptions(testVerdict(coEditPaths), new Map(coEdit ? [[regPath, coEdit]] : [])); assert('a-generator-co-edit-keeps-the-artifact-governed-the-exception-does-not-lift', - coEditVerdict.governed === true && coEditVerdict.hitPaths.join() === wfPath && coEditVerdict.exceptions[0].pureRegeneration === false, + coEditVerdict.governed === true && coEditVerdict.hitPaths.join() === regPath && coEditVerdict.exceptions[0].pureRegeneration === false, JSON.stringify(coEditVerdict.hitPaths)); const coEditRender = renderTestVerdict(coEditVerdict); assert('the-co-edit-render-stays-GOVERNED-and-tells-the-seat-why-it-did-not-lift', coEditRender.includes('GOVERNED') && coEditRender.includes('did NOT lift') && coEditRender.includes('modifies the generator this exception trusts'), coEditRender); assert('the-dot-slash-spelling-of-a-generator-path-is-fenced-too-recognising-more-only-tightens', - generatorCoEditProvenance([wfPath, './scripts/docs-audit/check-audit-scope.mjs']) !== null); + generatorCoEditProvenance([regPath, `./${fencePrefix}build-skill-references.ts`], fenceRow) !== null); // Direction B — untouched generator: the fence abstains (`null`), the // recompute runs exactly as before, and a verified pure regeneration still // lifts. This is the narrowing's whole obligation: nothing else moves. - const regenOnlyPaths = [wfPath, 'content/docs/new-page.mdx']; + const regenOnlyPaths = [regPath, 'packages/spec/src/ui/responsive.zod.ts']; assert('an-untouched-generator-abstains-so-the-recompute-runs-exactly-as-before', - generatorCoEditProvenance(regenOnlyPaths) === null); + generatorCoEditProvenance(regenOnlyPaths, fenceRow) === null); const regenOnlyVerdict = applyGeneratedExceptions(testVerdict(regenOnlyPaths), verified); assert('a-pure-regen-with-an-untouched-generator-still-lifts-and-the-pr-is-not-governed', regenOnlyVerdict.governed === false && regenOnlyVerdict.hitPaths.length === 0 && regenOnlyVerdict.exceptions[0].pureRegeneration === true, JSON.stringify(regenOnlyVerdict)); assert('a-docs-only-pr-that-never-hits-the-register-is-untouched-by-the-fence', - generatorCoEditProvenance(['content/docs/a.mdx']) === null && testVerdict(['content/docs/a.mdx']).governed === false); + generatorCoEditProvenance(['content/docs/a.mdx'], fenceRow) === null && testVerdict(['content/docs/a.mdx']).governed === false); // A prefix on the real directory, not a substring: a sibling directory whose // name merely starts the same way is not the trusted generator. assert('a-near-miss-sibling-directory-is-not-mistaken-for-the-generator-tree', - generatorCoEditProvenance(['scripts/docs-auditing/other.mjs']) === null); + generatorCoEditProvenance([`${fencePrefix.replace(/\/$/, '')}-x/other.ts`], fenceRow) === null); + // A row that names no instrument tree cannot rule a co-edit out, so it must + // fail closed rather than abstain — abstaining would send it on to recompute. + { + const unfenced = generatorCoEditProvenance([regPath], { id: 'fixture', trustedGeneratorPrefixes: [] }); + assert('a-row-with-no-trusted-generator-tree-fails-closed-rather-than-abstaining', + unfenced !== null && unfenced.pureRegeneration === false && /names no trusted generator tree/.test(unfenced.reason), JSON.stringify(unfenced)); + } // ── the #11705 generator-owned rows inside `skills/**` ─────────────────── // @@ -3433,9 +3347,16 @@ async function selfTest() { assert('and-the-shared-output-sink-counts-as-the-instrument-too', generatorCoEditProvenance([genIndex, 'packages/spec/scripts/lib/generated-output.ts'], skillRefs) !== null); assert('editing-spec-SOURCE-is-not-a-generator-co-edit', generatorCoEditProvenance([genIndex, 'packages/spec/src/ui/view.zod.ts'], skillRefs) === null); - assert('the-fences-are-per-row-neither-tree-fences-the-other-rows-generator', - generatorCoEditProvenance(['packages/spec/scripts/build-skill-references.ts'], GENERATED_SURFACE_EXCEPTIONS[0]) === null && - generatorCoEditProvenance(['scripts/docs-audit/affected-docs.mjs'], skillRefs) === null); + // PER ROW, not global: a row is fenced by ITS OWN instrument tree and by no + // other. Pinned against a fixture row rather than a sibling register row, + // because the two live rows happen to share a prefix today — and a property + // that only holds while two rows differ is not the property being asserted. + { + const elsewhere = { id: 'fixture', trustedGeneratorPrefixes: ['tools/other-generator/'] }; + assert('the-fences-are-per-row-neither-tree-fences-the-other-rows-generator', + generatorCoEditProvenance(['packages/spec/scripts/build-skill-references.ts'], elsewhere) === null && + generatorCoEditProvenance(['tools/other-generator/gen.ts'], skillRefs) === null); + } // Applied to a verdict: the #11685 shape lifts, and the ruled limit holds. const specPr = applyGeneratedExceptions(testVerdict(['packages/spec/src/ui/responsive.zod.ts', genIndex]), new Map([[genIndex, genuine]])); @@ -3482,7 +3403,7 @@ async function selfTest() { // The words a seat reads. const liftedRender = renderTestVerdict(liftedVerdict); assert('a-lifted-render-names-the-exception-the-generator-and-the-recompute', - liftedRender.includes('PURE REGENERATION') && liftedRender.includes('check-audit-scope.mjs --write') && liftedRender.includes('THIS tree'), liftedRender); + liftedRender.includes('PURE REGENERATION') && liftedRender.includes(generatedExceptionFor(regPath).generator) && liftedRender.includes('THIS tree'), liftedRender); assert('a-lifted-render-still-warns-that-any-other-governed-hit-forks', liftedRender.includes('forks the whole PR'), liftedRender); const rejectedRender = renderTestVerdict(rejectedVerdict); assert('a-rejected-render-stays-GOVERNED-and-says-why-the-exception-did-not-lift', @@ -3494,7 +3415,7 @@ async function selfTest() { for (const failure of failures) console.error(` • ${failure}`); process.exit(1); } - console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the four ruled cases against the generator's own splice, byte-exactness, fail-closed inputs, the untouched mixed-diff rule, single-file-not-a-class, the #11084 generator co-edit fence in both directions, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes).\n ${liveNote}`); + console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes).\n ${liveNote}`); } /** The exit code `--test` would return for a path list — pinned without spawning. */ From 8a82109c4e402139bb7363ead46c3ae17d312c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 03:36:29 +0000 Subject: [PATCH 3/3] docs(docs-audit): update the README, CI comment and prose for the injected scope Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2 --- .github/workflows/lint.yml | 22 ++++++----- scripts/docs-audit/README.md | 47 +++++++++++++++++------ scripts/docs-audit/affected-docs.mjs | 2 +- scripts/pm/check-governed-merges.mjs | 8 ++-- scripts/pm/check-governed-queue-guard.mjs | 23 ++++++----- 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3dfae68f01..e8749c76d3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1871,17 +1871,19 @@ jobs: - name: Template version-time rewriter self-test run: pnpm check:template-version-sync - # #4851: the docs-accuracy-audit workflow carries its default scope inline + # #4851: the docs-accuracy-audit workflow's default scope is a generated list # (a workflow script runs in a vm with no filesystem, so it cannot enumerate - # content/docs/ itself). Hand-kept, that list rotted in BOTH directions — - # 16 entries pointing at files that no longer existed after the - # protocol/objectos → protocol/kernel rename, and 48 existing docs missing - # from it — while every "FULL audit" run reported green, which is how the - # accuracy defects in #4781 and #4817 survived ~2 months. This regenerates - # the list from the filesystem and fails when the two disagree either way. - # It lives in this job deliberately: the change that breaks the list is a - # docs rename, so a `packages/**` paths filter would blind the gate to - # exactly its own failure mode. + # content/docs/ itself — the caller hands the list in). Hand-kept, that list + # rotted in BOTH directions — 16 entries pointing at files that no longer + # existed after the protocol/objectos → protocol/kernel rename, and 48 + # existing docs missing from it — while every "FULL audit" run reported + # green, which is how the accuracy defects in #4781 and #4817 survived + # ~2 months. This derives the list from the filesystem and fails when it and + # scripts/docs-audit/handwritten-docs.json disagree either way; it also runs + # the workflow against stub agents to check the body still consumes what it + # is handed. It lives in this job deliberately: the change that breaks the + # list is a docs rename, so a `packages/**` paths filter would blind the gate + # to exactly its own failure mode. - name: Docs-audit scope is derived, not hand-kept run: pnpm check:docs-audit-scope diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index 553d4a6468..aeaf26c581 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -456,17 +456,33 @@ node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the list fr node scripts/docs-audit/check-audit-scope.mjs --self-test ``` -The `docs-accuracy-audit` workflow (part 3) carries its default scope **inline**, as -`ALL_HANDWRITTEN`. It has to: a workflow script runs inside a `node:vm` context whose -only globals are `log`/`phase`/`console`/`budget`/timers plus -`agent`/`parallel`/`pipeline`/`workflow`/`args`, with code generation disabled — no -`require`, no `import`, no filesystem. It can neither walk `content/docs/` nor read a -JSON artifact, so the list cannot be derived *at run time*. +**THE single source is `scripts/docs-audit/handwritten-docs.json`** — one generated file, +with two consumers: this gate, and whoever invokes the `docs-accuracy-audit` workflow +(part 3), who reads it and hands its `docs` array in as `args.handwritten`. -It is therefore derived at *generation* time instead: `--write` rewrites the block from +The workflow body cannot read it itself. A workflow script runs inside a `node:vm` +context whose only globals are `log`/`phase`/`console`/`budget`/timers plus +`agent`/`parallel`/`pipeline`/`workflow`/`args`, with code generation disabled — no +`require`, no `import`, no filesystem. It can neither walk `content/docs/` nor open a +JSON artifact. But it does not have to: `args` **is** an injection channel, delivered +verbatim from the invocation, so the read happens in the **caller**, outside the sandbox, +and the list arrives as data. + +The list used to live inline in the workflow body, as `ALL_HANDWRITTEN`. `.claude/**` is +a governed surface (human-merge-only, never armed, never queued), so adding one customer +documentation page forced a governed edit — through a bookkeeping list that merely +happened to live there, and invisibly: nothing in such a card's file list showed a +governed path until this gate ran. Maintainer ruling, 2026-09-01, verbatim 「同意」: move +it off. ⚠️ The governed register itself is **unchanged** — narrowing it was the option +that ruling explicitly rejected, and this file is no precedent for relocating anything +else out of `.claude/**`. + +The list is derived at *generation* time: `--write` rewrites the artifact from `affected-docs.mjs --all` (one definition of "hand-written doc", not two), and the plain -run is a CI gate in `lint.yml` that fails when the block and `content/docs/` disagree -**in either direction**. +run is a CI gate in `lint.yml` that fails when the artifact and `content/docs/` disagree +**in either direction**. It also runs the workflow against stub agents and checks that the +body still *consumes* what it is handed — an artifact in sync with `content/docs/` proves +nothing about a body that has stopped reading it, and that failure would be silent. Both directions matter, and only one had ever been noticed (#4851): @@ -642,15 +658,22 @@ a consumer, add it to that list.** A Claude Code multi-agent workflow (`.claude/workflows/docs-accuracy-audit.js`). For each doc: an agent reads it, locates the real implementation, and applies evidence-backed fixes in place; a second **adversarial verifier** re-checks every fix against the code and -repairs over-corrections. Scope it with `args.docs`; omit for a full audit. +repairs over-corrections. Scope it with `args.docs`; for a full audit hand in the whole +set as `args.handwritten`. ```js // scoped to the docs a code change touched: Workflow({ name: 'docs-accuracy-audit', args: { docs: [/* output of affected-docs.mjs */] } }) -// full audit of all hand-written docs: -Workflow({ name: 'docs-accuracy-audit' }) +// full audit of all hand-written docs — read the artifact first, OUTSIDE the sandbox: +// node -e "console.log(JSON.stringify(require('./scripts/docs-audit/handwritten-docs.json').docs))" +Workflow({ name: 'docs-accuracy-audit', args: { handwritten: [/* that array */] } }) ``` +⛔ There is no "omit `args` and audit everything" invocation, and there cannot be: the +body has no filesystem, so with nothing handed in it does not know what "everything" is. +It refuses by name rather than inventing a scope — the two shapes it could invent are a +silent audit of nothing and a stale list, and both report success. + It edits files in place (frontmatter preserved, no moves) and returns a per-doc log of fixes, verifier repairs, and residual items that couldn't be confirmed against code — **except** for `content/docs/releases/**`, which is reviewed read-only and returns diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index f1315ee838..e7dd5f517b 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -236,7 +236,7 @@ let diffBaseRef = null; * the audit scoping command still returns them and they keep getting audited. #4920 * considered excluding them and REJECTED it: the most-read pages in the docs would go * permanently unaudited and silently, and a second definition of "docs this tooling - * covers" would grow next to the generated block. What forks is the DELIVERABLE — the + * covers" would grow next to the generated scope artifact. What forks is the DELIVERABLE — the * drift comment tells the reader to file an issue instead of editing (#6893: a comment * listing `content/docs/releases/v17.mdx` next to editable pages steers a dev who * treats the list as a worklist straight into the one edit the repo forbids). diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 6c5ca01ba4..2afe608a24 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -607,11 +607,9 @@ * with no governed merges the sweep costs ZERO lookups. `--test` never * touches the network, and spends anything at all in exactly one case: a hit * on a `GENERATED_SURFACE_EXCEPTIONS` row recomputes that generator's output - * on the local tree under test (the #9866 row reads the file, `git - * merge-base`/`git show` for the base version and the docs derivation; a - * #11705 row runs that generator's own `--check` once, ~3 s, for every path it - * owns in the diff) — still zero API calls; every other `--test` run reads - * only the register in this file. + * on the local tree under test (a #11705 row runs that generator's own + * `--check` once, ~3 s, for every path it owns in the diff) — still zero API + * calls; every other `--test` run reads only the register in this file. */ import { execFileSync, spawnSync } from 'node:child_process'; diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 81e154c14d..5c7027634e 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -133,15 +133,20 @@ * * ## The generated-artifact exception is honoured, and that is not optional * - * `.claude/workflows/docs-accuracy-audit.js` holds a required gate's own - * `--write` artifact, so EVERY page-adding docs PR touches the governed - * surface (measured 5-for-5 on #9866). A guard that refuses those refuses - * routine traffic, which is the same poison one level down. The #10277 Option-C - * exception (maintainer 「10277 同意 C」) is therefore applied here through the - * register's own `applyGeneratedExceptions`, with provenance recomputed - * BYTE-EXACT against this build's own base sha — never a stored baseline, and - * fail-closed on every error path, exactly as the four ruled constraints - * require. + * A generator-owned file sitting inside a governed surface makes routine + * traffic cross the fence, and a guard that refuses routine traffic is the same + * poison one level down. So the register's provenance exception is applied here + * through its own `applyGeneratedExceptions`, with provenance recomputed on this + * build's own tree — never a stored baseline, and fail-closed on every error + * path, exactly as the ruled constraints require. + * + * ⚖️ The original case for this — `.claude/workflows/docs-accuracy-audit.js` + * holding a required gate's own `--write` artifact, so EVERY page-adding docs PR + * touched the governed surface (measured 5-for-5 on #9866) — is GONE, and by a + * different remedy: on 2026-09-01 the maintainer ruled that list off the governed + * surface entirely (#13591, verbatim 「同意」). Its register row retired with it. + * The mechanism stays because the #11705 rows still need it, and because the + * shape recurs; ⛔ its absence from a diff is not a reason to relax anything. * * NOTHING about the exception is decided here: membership is the register's * `generatedExceptionFor` and the recompute is its `recomputeProvenanceFor`,