diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e00d87c452..42f5f2c76b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2284,7 +2284,7 @@ jobs: # commands and the gate reds (3/2) — which is exactly what it did here. # Both existing collectors in this file carry self-tests ONLY, for the # same reason. Keep the sweep out of the collector block. - - name: ADR symbol-anchor gate self-tests + - name: Symbol-anchor gate self-tests run: | failed="" run_self_test() { @@ -2299,9 +2299,10 @@ jobs: } run_self_test node scripts/symbol-anchors.mjs --self-test run_self_test node scripts/check-adr-symbol-anchors.mjs --self-test + run_self_test node scripts/check-scripts-symbol-anchors.mjs --self-test if [ -n "$failed" ]; then echo "" - echo "ADR symbol-anchor self-tests — the following FAILED:" + echo "Symbol-anchor self-tests — the following FAILED:" printf "%s" "$failed" exit 1 fi @@ -2309,6 +2310,14 @@ jobs: - name: ADR symbol anchors resolve (no line numbers survive) run: node scripts/check-adr-symbol-anchors.mjs + # The SECOND corpus (#15765), and it is a `defineCorpus` registration over + # `scripts/**` comment prose — ⛔ not a second resolver. Its own sweep is a + # separate step for the same `check-step-collectors` reason stated above: + # a production sweep inside the collector block re-invokes the stub the + # harness plants and the count comes out 4/3. + - name: scripts/** symbol anchors resolve (no line citations on tracked targets survive) + run: node scripts/check-scripts-symbol-anchors.mjs + # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) # is validated by `pnpm check:platform-checklist`, but by MAINTAINER # DECISION it is NOT wired into CI — it runs on a periodic manual cadence diff --git a/package.json b/package.json index f9dcd829a1..1d3317671e 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", "check:adr-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", + "check:scripts-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs", "check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs", diff --git a/scripts/audits/14423-databaseloader-cost-probe.mjs b/scripts/audits/14423-databaseloader-cost-probe.mjs index 5367343140..f3a5822d7c 100644 --- a/scripts/audits/14423-databaseloader-cost-probe.mjs +++ b/scripts/audits/14423-databaseloader-cost-probe.mjs @@ -2,9 +2,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // #14423 step 1 (census) — empirical confirmation that `DatabaseLoader.loadManyKeyed()` -// costs NOTHING extra over `loadMany()` (same query, same cache — `readTypeRows()`, -// `packages/metadata/src/loaders/database-loader.ts:936`), while a `listNames()` + -// per-name `load()` read costs a SEPARATE query (`list()`, `:1062`) plus one query +// costs NOTHING extra over `loadMany()` (same query, same cache — +// `packages/metadata/src/loaders/database-loader.ts#readTypeRows`, `#list`), while +// a `listNames()` + per-name `load()` read costs a SEPARATE query plus one query // PER NAME (`load()`'s `_findOne`, via `baseFilter(type,name)`) — a real N+1 against // `loadMany`'s single query. MEASUREMENT ONLY — ships nothing. diff --git a/scripts/audits/14423-unbound-declarations-before-count.mjs b/scripts/audits/14423-unbound-declarations-before-count.mjs index 86ffdcd2ae..28873a123c 100644 --- a/scripts/audits/14423-unbound-declarations-before-count.mjs +++ b/scripts/audits/14423-unbound-declarations-before-count.mjs @@ -9,7 +9,7 @@ * (`runActionGovernanceInventory`, `collectEngineActionDeclarations`, * `reconcileActionRegistrations` — all exported from its public entry) and * drives it exactly as `ObjectQLPlugin.runGovernanceInventory` - * (`packages/objectql/src/plugin.ts:2475`) does, over a controlled standalone + * (`packages/objectql/src/plugin.ts#runGovernanceInventory`) does, over a controlled standalone * source that isolates the ONE population the identity fix can move: * standalone `action` declarations whose BODY carries no `name` (the C2/C6 * shape) and are otherwise `type: 'script'` with no `body` and no bound diff --git a/scripts/check-agent-test-spelling.mjs b/scripts/check-agent-test-spelling.mjs index b028549f9f..d165eebeec 100644 --- a/scripts/check-agent-test-spelling.mjs +++ b/scripts/check-agent-test-spelling.mjs @@ -80,11 +80,16 @@ * printing. FIVE of the six are forms a naive bare-`--` selector would have RED, * and every one of them is correct as written: * - * .github/workflows/ci.yml:1122 pnpm turbo run test --filter=@objectstack/dogfood -- --shard=N/3 - * .github/workflows/lint.yml:2443 pnpm dev -- --fresh (quoted in this gate's own wiring comment) - * AGENTS.md:126 pnpm dev -- --fresh -p - * AGENTS.md:133 pnpm dev:crm -- --fresh -p 38421 - * AGENTS.md:388 pnpm dev -- --fresh -p + * ⚠️ The rows name the FILE, never a line in it. The line each sat on when this + * was measured is kept beside it as data, because that is a dated reading and + * not a pointer -- and a pointer written `file:NNN` rots silently, which is the + * whole finding of #15765. `--list` re-derives the live position on every run. + * + * `.github/workflows/ci.yml` (was line 1122) pnpm turbo run test --filter=@objectstack/dogfood -- --shard=N/3 + * `.github/workflows/lint.yml` (was line 2443) pnpm dev -- --fresh (quoted in this gate's own wiring comment) + * `AGENTS.md` (was line 126) pnpm dev -- --fresh -p + * `AGENTS.md` (was line 133) pnpm dev:crm -- --fresh -p 38421 + * `AGENTS.md` (was line 388) pnpm dev -- --fresh -p * * The sixth is prose inside `scripts/check-examples-live-imports.mjs`, cleared * because its command word is a `check:` script. diff --git a/scripts/check-doc-anchors.mjs b/scripts/check-doc-anchors.mjs index f3fa9c6b7f..8ccfd1c4dc 100644 --- a/scripts/check-doc-anchors.mjs +++ b/scripts/check-doc-anchors.mjs @@ -81,10 +81,14 @@ // 200 internal fragment links across `content/**`, of which 4 pointed at // headings that do not exist: // -// content/docs/automation/flows.mdx:240 #notify -// content/docs/concepts/metadata-lifecycle.mdx:77 #overlay-whitelist -// content/docs/permissions/authentication.mdx:75 /docs/deployment/cli#os-login--json-is-ndjson--the-one-exception -// content/docs/protocol/kernel/http-protocol.mdx:764 /docs/api/client-sdk#clientdata--crud-operations +// The FRAGMENT is the durable identifier of each row, so the file is named as a +// file-level anchor and the line it sat on at measurement time is data beside +// it — a dated reading, not a pointer (#15765). +// +// `content/docs/automation/flows.mdx` (was line 240) #notify +// `content/docs/concepts/metadata-lifecycle.mdx` (was line 77) #overlay-whitelist +// `content/docs/permissions/authentication.mdx` (was line 75) /docs/deployment/cli#os-login--json-is-ndjson--the-one-exception +// `content/docs/protocol/kernel/http-protocol.mdx` (was line 764) /docs/api/client-sdk#clientdata--crud-operations // // Three of the four are the exact shape the card predicted — an anchor written // from the heading a reader SEES, one dash off from the heading the slugger diff --git a/scripts/check-init-service-contract.mjs b/scripts/check-init-service-contract.mjs index 8fd09e47d6..cfd16b4eca 100644 --- a/scripts/check-init-service-contract.mjs +++ b/scripts/check-init-service-contract.mjs @@ -788,7 +788,8 @@ function selfTest() { } `; - // 13. #4772 VERBATIM, pre-fix (`f2eb85007^`, packages/plugins/plugin-auth/src/auth-plugin.ts:346): + // 13. #4772 VERBATIM, pre-fix — `packages/plugins/plugin-auth/src/auth-plugin.ts` + // as it stood at `f2eb85007^` (line 346 there, a dated reading, not a pointer): // init() resolves the workspace-provided `cache` through `getServiceAsync`, // via an optional call on a cast `ctx`, inside a best-effort try/catch, and // the plugin's declarations cover `data`/`manifest`/objectql — never `cache`. diff --git a/scripts/check-org-identifier.mjs b/scripts/check-org-identifier.mjs index 2c868bfb45..1a3916654e 100644 --- a/scripts/check-org-identifier.mjs +++ b/scripts/check-org-identifier.mjs @@ -203,10 +203,13 @@ // ... of them wrong today ............................................ 0 // the same literal in the SCANNED (non-test) population ............... 0 // -// packages/spec/src/data/hook.test.ts:619 #3290 absence pin -// packages/plugins/plugin-audit/src/audit-writers.test.ts:1666 absence pin -// packages/plugins/plugin-audit/src/comment-access-hooks.test.ts:529 #9691 -// packages/services/service-storage/src/attachment-access-hooks.test.ts:750 +// (the file, never a line in it — the recipe above re-derives the position on +// every run, and a `file:NNN` pointer written here would rot silently, #15765) +// +// `packages/spec/src/data/hook.test.ts` #3290 absence pin +// `packages/plugins/plugin-audit/src/audit-writers.test.ts` absence pin +// `packages/plugins/plugin-audit/src/comment-access-hooks.test.ts` #9691 +// `packages/services/service-storage/src/attachment-access-hooks.test.ts` // // (The recipe reports 5 matches across those 4 files: `hook.test.ts` carries a // second one whose `tenantId` sits inside a COMMENT in the literal's body.) diff --git a/scripts/check-overlay-whitelist-table.mjs b/scripts/check-overlay-whitelist-table.mjs index ae6f2f5f18..85b6d7890e 100644 --- a/scripts/check-overlay-whitelist-table.mjs +++ b/scripts/check-overlay-whitelist-table.mjs @@ -162,9 +162,9 @@ * ## Scope: this table only, deliberately * * Sibling pages state the same facts correctly in PROSE, not in a machine-shaped - * type→flag table: `content/docs/permissions/authorization.mdx:236` + * type→flag table: `content/docs/permissions/authorization.mdx` * ("`permission` declares `allowOrgOverride: false`"), - * `content/docs/automation/jobs.mdx:42` (a job-vs-flow comparison table whose + * `content/docs/automation/jobs.mdx` (a job-vs-flow comparison table whose * flag mention sits inside an English cell), `content/docs/ai/agents.mdx`, * `content/docs/permissions/capabilities.mdx`, * `content/docs/references/system/email-template.mdx`. diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index 84b77fa22f..3c7fff880e 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -46,7 +46,8 @@ // `use`. A recipe nobody opts into is dead text a runner may still replay. // Both directions together are what the trap vocabulary beside them has // always had — used implies documented, documented implies used. -// - every SYMBOL ANCHOR (`path/to/file.ts#symbolName`) resolves: the cited +// - every SYMBOL ANCHOR (`/.ts#`, the spelling +// `scripts/symbol-anchors.mjs#ANCHOR_GRAMMAR` defines) resolves: the cited // file is in the tree and the cited symbol is still in it, comments masked // — a symbol surviving only in a docblock is ABSENT. A shrink-never floor // per family file keeps the population from being emptied one deleted diff --git a/scripts/check-published-list-mirrors.mjs b/scripts/check-published-list-mirrors.mjs index 2eae7ad178..439074151a 100644 --- a/scripts/check-published-list-mirrors.mjs +++ b/scripts/check-published-list-mirrors.mjs @@ -257,8 +257,11 @@ const REPO_ROOT = resolve(HERE, '..'); * * A row is located STRUCTURALLY -- by its heading and the fence inside that * heading's section -- never by line number. Line numbers are the first thing - * to rot here: this card was filed against `AGENTS.md:96-106` and the block sat - * at `:92-104` by the time it was implemented, three days later. + * to rot here: this card was filed against lines 96-106 of `AGENTS.md` and the + * block sat at 92-104 by the time it was implemented, three days later. ⚠️ Those + * four numbers are the EVIDENCE and every digit is kept; what is not kept is the + * `file:NNN` spelling, because a dated rot record written in anchor form reads + * as a live pointer to the next person (#15765). */ export const MIRRORS = [ { diff --git a/scripts/check-published-readme-links.mjs b/scripts/check-published-readme-links.mjs index c4cc6052ec..86576417d8 100644 --- a/scripts/check-published-readme-links.mjs +++ b/scripts/check-published-readme-links.mjs @@ -468,7 +468,7 @@ export function resolveDocsPage(contentRoot, pathname) { * `file` is the repo-root-relative posix path of the document, so the base is * its own directory -- the same base GitHub uses to resolve a relative href in * a rendered blob. Fragment and query are stripped before resolving - * (`../a.md#L10` targets `../a.md`) and the path is percent-decoded, because + * (`../.md#L10` targets `../.md`) and the path is percent-decoded, because * `%20` in an href is a space in a filename. * * Four outcomes rather than two. `escapes` is separated from `missing` because diff --git a/scripts/check-scripts-symbol-anchors.mjs b/scripts/check-scripts-symbol-anchors.mjs new file mode 100644 index 0000000000..dd894c7121 --- /dev/null +++ b/scripts/check-scripts-symbol-anchors.mjs @@ -0,0 +1,472 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-scripts-symbol-anchors (#15765) -- the `scripts/**` registration of the + * shared symbol-anchor resolver. + * + * node scripts/check-scripts-symbol-anchors.mjs + * node scripts/check-scripts-symbol-anchors.mjs --list + * node scripts/check-scripts-symbol-anchors.mjs --self-test + * + * ⚠️ THE MECHANISM IS NOT HERE. The grammar, the extractor, the comment-prose + * projection and the resolution rule all live in `scripts/symbol-anchors.mjs`, + * whose header is authoritative and worth reading before this one. This file is + * a `defineCorpus` call, a population declaration and an exit contract -- the + * same deliberately thin shape as `scripts/check-adr-symbol-anchors.mjs`, and + * ⛔ NOT a second resolver. The 2026-09-01 ruling on #13556 is explicit that a + * corpus joins by registration: 「与 #13788 已裁方向同构,**共享同一个 + * resolver**,⛔ 不造第二套」. + * + * ## The measured failure (#15765) + * + * `docs/adr/**` was the first corpus because its rot was censused: 243 of 337 + * live line anchors broken, 72.1%, a one-way LOWER bound. Gate headers under + * `scripts/**` were outside every corpus, in BOTH directions -- a `path:NNN` + * there was neither resolved nor refused -- and they are read more often than + * an ADR, because a gate header is where the next fixer starts. + * + * The rot rate there was demonstrated rather than argued. One citation in + * `scripts/check-react-page-adapter-contract.mjs` was written as + * `packages/client/src/index.ts` at one line, the #15094 census found that + * declaration at another, and triage read a third the SAME DAY: a cross-file + * line citation that moved twice inside one day, unnoticed, because nothing + * checked it. A second citation three lines below it had drifted off its line + * too, and was found only because a human read the paragraph. + * + * ## The census this gate was registered against + * + * `CENSUS_15765` below, measured on `5315098df` over the 216 tracked `.mjs` + * files under `scripts/`. The instrument matters more than the number, because + * three different instruments gave three different answers on the same tree: + * + * 251 raw `extractAnchors` over the whole file -- includes CODE, so it + * counts a gate's own + * fixtures + * 128 through `scripts/symbol-anchors.mjs#commentProse` -- comment prose only + * 32 ...and the cited path names a TRACKED FILE -- what this gate judges + * + * The card reported 27 across 14 files and triage 22 with a coarser regex; + * neither binds, and the third row above reproduces the card's instrument to + * within one citation (28 across 14 files if a bare `AGENTS.md` is required to + * be directory-qualified; 32 across 15 without that extra rule, which is the + * predicate this gate uses because a tracked file is a tracked file). + * + * ## What this corpus judges, and what it declines to + * + * `judgeUntrackedLineAnchors: false`. The 96 citations that name no tracked + * file are 66 bare filenames inside census tables (`engine.ts` at some line -- + * an abbreviation no resolver can bind to one of this tree's several files of + * that name), 22 continuations inheriting no path, 11 directory-qualified + * illustrations or sibling-repo paths, and 1 tilde form. They are a real defect + * class and are recorded as a follow-up, exactly as the 1,056 bare paths under + * `checkBarePaths` were for `docs/adr/**` -- but this gate cannot tell their + * author how to fix them, and a gate whose only remedy is "stop writing that" + * is the permanently-red gate this repo retired. + * + * `checkBarePaths: false`, for the same reason and on a measurement: judging + * every bare path code span in this corpus produces 1,617 findings, nearly all + * of them abbreviated spellings inside prose (`turbo.json` written as itself, + * `react-pages.mdx` for a page named in full two paragraphs up). That is the + * same call `docs/adr/**` made at 1,056. + * + * ⭐ What it DOES judge is the whole of the grammar otherwise: a `path:NNN` + * naming a tracked file is REFUSED, a symbol anchor must have a declaration + * site in the file it names, and a file-level anchor with a `#fragment` must + * name a file the tree really has. + * + * ## What a red means, and how to clear it + * + * [line-anchor] A `path:NNN` naming a tracked file. Cite the symbol + * instead -- `path#symbolName` -- or drop to a file-level + * `path`. Both stay checked; a line number does not. + * ⚠️ If the number is a DATED RECORD rather than a + * pointer -- a census row, a rot-rate example -- the + * repair is to keep every digit and stop writing it in + * ANCHOR FORM: name the file as a file-level anchor and + * put the number beside it as data. Nothing is lost and + * the record stops reading as a live claim. + * [unresolved-symbol] The file is there, the symbol is not. + * [unresolved-path] No tracked file at that path. In this corpus that is + * usually a PLACEHOLDER written path-shaped; the idiom + * `scripts/symbol-anchors.mjs#ANCHOR_GRAMMAR` already uses + * -- angle-bracket words, deliberately not path-shaped -- + * is the fix. + * [bad-exemption] An `anchor-exempt` marker naming no valid class. + * + * ⛔ MAINTAINER-ONLY: adding an `anchor-exempt` marker. Unchanged here. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { ANCHOR_GRAMMAR, commentProse, defineCorpus, sweepCorpus } from './symbol-anchors.mjs'; + +/* ── The declared path population (#13519 / check-declared-population-live) ─── + * These literals ARE the population this gate reads, and the self-test holds + * them against the corpus registration below, so a moved surface reddens here + * rather than turning this gate silently inert. */ +export const SCRIPTS_DIR = 'scripts'; +export const ROOT_DIR_WATCH_HINTS = ['scripts/**']; + +/* The census this gate was registered against, kept as data so the report can + * state what the surface was when the corpus joined. ⛔ Historical + * measurements, not a budget to spend. */ +export const CENSUS_15765 = { + measuredOn: '5315098dfb55d19ea79bd27c28b3864f32f8fad9', + mjsFilesTotal: 216, + rawLineCitations: 251, + commentProseLineCitations: 128, + trackedTargetLineCitations: 32, + trackedTargetFiles: 15, + barePathFindingsIfJudged: 1617, +}; + +/** + * ⚠️ DATED ALLOWANCES -- a file another lane's LIVE pull request holds. + * + * This is not an exemption and it is not a softening of the grammar: the + * citation is still a finding, still printed, still counted. What the row buys + * is that it does not FAIL the build while a PR that has already been reviewed + * against that file is open, because a comment-only edit landing underneath it + * is how two lanes hand each other a conflict. + * + * ⛔ A row is EXACT IN BOTH DIRECTIONS and the self-test holds it there: + * + * - it must name a file that STILL carries a judged finding, so the day the + * citation is migrated the row goes stale and reds until it is DELETED. An + * allowance nobody can retire is an exemption wearing a date. + * - it must name the PR that lifts it, so the follow-up has an owner. + * + * ⛔ This is NOT a route to green for a file you simply do not want to edit. + * The only admissible reason is the hot-file serial queue. + */ +export const HELD_FILE_ALLOWANCES = Object.freeze([ + Object.freeze({ + file: 'scripts/check-react-page-adapter-contract.mjs', + dated: '2026-09-05', + heldBy: 'PR #15770 (#15599)', + why: 'The citation is the WORKED EXAMPLE of the rot this whole gate exists for, and its own ' + + 'paragraph declares every number in it a DATED ROT RECORD that must not be repaired. ' + + 'The file is also held by a live PR of this lane, so it is not this card\'s to edit. ' + + 'Both halves are resolved by the same follow-up: that paragraph names the file and the ' + + 'numbers as data rather than in anchor form, on the PR that lands after #15770.', + }), + Object.freeze({ + file: 'scripts/check-adr-0087-registration.mjs', + dated: '2026-09-05', + heldBy: 'PR #15724 (#15627)', + why: 'A one-line docblock on `scripts/check-adr-0087-registration.mjs#parseSymbolRef` writes its ' + + 'placeholder path-shaped, so a resolver reads the illustration as an anchor into a file this ' + + 'tree does not have. The repair is the angle-bracket spelling `/.ts#` that ' + + '`scripts/symbol-anchors.mjs#ANCHOR_GRAMMAR` already uses, and it is one line — but the file ' + + 'is held by a governed draft awaiting a human merge, and a comment-only edit landing under a ' + + 'reviewed governed PR is exactly the conflict this queue exists to prevent.', + }), +]); + +export const CORPUS = defineCorpus({ + id: 'scripts', + label: 'scripts/** (gate headers and audit scripts, comment prose only)', + docRoots: [SCRIPTS_DIR], + docPattern: /\.mjs$/, + docProjection: commentProse, + judgeUntrackedLineAnchors: false, + crossRepos: { objectui: { checkoutEnv: 'OBJECTUI_CHECKOUT' } }, +}); + +/** Split a sweep's findings into the three dispositions this gate reports. */ +export function triage(findings, allowances = HELD_FILE_ALLOWANCES) { + const allowed = new Map(allowances.map((a) => [a.file, a])); + const hard = []; + const soft = []; + const excused = []; + for (const f of findings) { + if (f.soft) soft.push(f); + else if (allowed.has(f.doc)) excused.push({ ...f, allowance: allowed.get(f.doc) }); + else hard.push(f); + } + return { hard, soft, excused }; +} + +export function runCheck(root = process.cwd()) { + const { findings, counts } = sweepCorpus(CORPUS, root); + const { hard, soft, excused } = triage(findings); + + if (counts.anchors === 0) { + console.error('❌ check-scripts-symbol-anchors: the sweep found ZERO anchors in scripts/** — the extractor is broken, not the corpus clean.'); + process.exit(1); + } + + for (const f of soft) console.log(`ℹ️ [${f.kind}] ${f.doc}:${f.line} ${f.raw}\n ${f.detail}`); + for (const f of excused) { + console.log( + `⏳ [${f.kind}] ${f.doc}:${f.line} ${f.raw}\n ${f.detail}\n` + + ` ALLOWED, dated ${f.allowance.dated} — held by ${f.allowance.heldBy}. Delete the row when the citation goes.`, + ); + } + + if (hard.length > 0) { + console.error(`❌ check-scripts-symbol-anchors: ${hard.length} finding(s) across ${counts.docs} scripts.\n`); + for (const f of hard) console.error(` [${f.kind}] ${f.doc}:${f.line} ${f.raw}\n ${f.detail}`); + console.error(`\nThe anchor grammar:\n ${ANCHOR_GRAMMAR}`); + console.error('\n⛔ MAINTAINER-ONLY: an `anchor-exempt` marker is not the remedy — fix the anchor.'); + process.exit(1); + } + + console.log( + `✅ check-scripts-symbol-anchors: ${counts.anchors} anchors across ${counts.docs} scripts resolve — ` + + `${counts.symbol} symbol (${counts.declaration} declaration, ${counts.literal} literal), ` + + `${counts.fileLevel} file-level, ${counts.crossRepo} cross-repo, ${counts.exempt} exempt, ` + + `${counts.continuation} continuation. 0 line anchors on tracked targets survive ` + + `(${counts.unresolvableLineCitation} citations name no tracked file and are not judged; ` + + `${excused.length} dated allowance finding(s)).`, + ); +} + +function list(root = process.cwd()) { + const { findings, counts } = sweepCorpus(CORPUS, root); + console.log(JSON.stringify({ counts, findings, allowances: HELD_FILE_ALLOWANCES }, null, 2)); +} + +/* ─────────────────────────────── self-test ─────────────────────────────── */ + +function assert(cond, msg) { if (!cond) { console.error(`❌ check-scripts-symbol-anchors --self-test: ${msg}`); process.exit(1); } } + +// ── The self-test's own battery roster and floor (#13489) ────────────────── +// +// A module-level `assert()` that exits on the first failure used to be this +// shape's ONLY success condition, so "every case held" and "the cases never +// ran" printed the same line. What is pinned is the registered NAMES, not a +// number: the floor requires the OPENED set to equal the DECLARED set with +// each battery at or above its own count. +// +// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 +// keeps a total "right" the moment a sibling grows. +// +// The count is a FLOOR, not an equality — adding cases is ordinary work and must +// not red. A battery BELOW its floor means cases stopped running; the remedy is +// to find what stopped registering. +const SELF_TEST_BATTERIES = Object.freeze({ + 'check-scripts-symbol-anchors self-test': 34, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 1; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-scripts-symbol-anchors self-test reached its verdict'; + +export function selfTest() { + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { openBattery = name; }; + const registerCase = () => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + }; + battery('check-scripts-symbol-anchors self-test'); + const check = (cond, message) => { registerCase(); assert(cond, message); }; + + // 1. ⭐ A synthetic corpus carrying one of EVERY finding class this + // registration can produce, plus the healthy forms and the two shapes + // this corpus deliberately declines to judge — so "no findings" is told + // apart from "the rule stopped matching", and "declines" is told apart + // from "never saw it". + const tmp = mkdtempSync(join(tmpdir(), 'check-scripts-symbol-anchors-')); + try { + const write = (rel, body) => { mkdirSync(dirname(join(tmp, rel)), { recursive: true }); writeFileSync(join(tmp, rel), body); }; + write('src/thing.ts', 'export function realSymbol() {}\nconst names = ["sys_thing"];\n// commentOnlySymbol is only named here\n'); + write('scripts/good.mjs', [ + '// A symbol anchor `src/thing.ts#realSymbol` resolves.', + '/* A data identifier `src/thing.ts#sys_thing` resolves as a literal. */', + '/**', + ' * A file-level anchor `src/thing.ts` resolves, and a continuation', + ' * `src/thing.ts#realSymbol` then `#sys_thing`.', + ' */', + "const fixture = 'src/thing.ts:42'; // ⛔ CODE: a fixture, not a citation", + 'export const x = 1;', + ].join('\n')); + write('scripts/bad.mjs', [ + '// A survived line anchor `src/thing.ts:42` must be found.', + '/* A range `src/thing.ts:10-20` must be found. */', + '// An en-dash range `src/thing.ts:30–40` must be found.', + '// A missing symbol `src/thing.ts#noSuchSymbol` must be found.', + '// A comment-only symbol `src/thing.ts#commentOnlySymbol` must be found.', + '// A gone file `src/vanished.ts#whatever` must be found.', + '// A bogus exemption `src/thing.ts:99` must be found.', + ].join('\n')); + // A SECOND failing script, so "an allowance covers only the file it names" + // is provoked against a corpus that still has something left to fail on. + write('scripts/also-bad.mjs', '// Another survived line anchor `src/thing.ts:77` must be found.'); + write('scripts/declined.mjs', [ + '// An untracked target `src/never-existed.ts:7` is NOT judged here.', + '// A bare filename `thing.ts:8` is NOT judged here.', + '// A bare path code span `some/abbreviated/spelling.ts` is NOT judged here.', + ].join('\n')); + write('scripts/exempt.mjs', '// An excused anchor `src/gone.ts:7` is silent.'); + // the sweep reads `git ls-files`, so the fixture needs to be a repo + execFileSync('git', ['init', '-q'], { cwd: tmp }); + execFileSync('git', ['add', '-A'], { cwd: tmp }); + + const { findings, counts } = sweepCorpus(CORPUS, tmp); + const kinds = findings.map((f) => f.kind); + const count = (k) => kinds.filter((x) => x === k).length; + + check(count('line-anchor') === 4, `4 line anchors (plain, hyphen range, EN DASH range, second file) must be found, got ${count('line-anchor')}`); + check(count('unresolved-symbol') === 2, `2 unresolved symbols must be found, got ${count('unresolved-symbol')}`); + check(count('bad-exemption') === 1, `an invalid exemption class must be a finding, got ${count('bad-exemption')}`); + check(count('unresolved-path') === 1, `a vanished target must be a finding, got ${count('unresolved-path')}`); + check(counts.exempt === 1, 'a valid exemption must be honoured exactly once'); + check(!findings.some((f) => f.doc.includes('good.mjs')), 'the healthy script must produce no findings'); + check(counts.declaration >= 1 && counts.literal >= 1, 'both resolution classes must be exercised by the fixture'); + // ⭐ The projection, at CORPUS level rather than in the core's unit cases: + // a citation living in a string literal is a gate's own fixture and must + // not be a finding against the gate that wrote it. + check(!findings.some((f) => f.raw.includes('thing.ts:42') && f.doc.includes('good.mjs')), + 'a citation inside a STRING LITERAL is code, not a doc citation — the corpus must sweep comment prose only'); + // ...and the DECLINED shapes are declined, not missed: they were seen and + // counted, which is the difference between a scope call and a blind spot. + check(!findings.some((f) => f.doc.includes('declined.mjs')), + 'a citation naming no tracked file must not be a finding under judgeUntrackedLineAnchors: false'); + check(counts.unresolvableLineCitation === 2, + `both declined citations must be SEEN and counted, got ${counts.unresolvableLineCitation}`); + + // 2. The allowance mechanism, in both directions, against the fixture. + const fake = [{ file: 'scripts/bad.mjs', dated: '2026-01-01', heldBy: 'PR #1', why: 'fixture' }]; + const t = triage(findings, fake); + check(t.excused.length > 0, 'an allowance row must move that file\'s findings out of the failing set'); + check(t.excused.every((f) => f.doc === 'scripts/bad.mjs'), 'an allowance must cover ONLY the file it names'); + check(t.hard.every((f) => f.doc !== 'scripts/bad.mjs'), 'an allowed file must not also fail'); + check(t.hard.length > 0, 'an allowance on one file must not excuse the others — this corpus still reds'); + const none = triage(findings, []); + check(none.hard.length > t.hard.length, 'REMOVING the row must put those findings back — the row is load-bearing, not decorative'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // 3. The population guard: the declaration is read by ANOTHER tool + // (dispatch-gates / check-declared-population-live), so a wrong entry runs + // perfectly green here and shows up only as a dev who was never told this + // gate reads their surface. + check(ROOT_DIR_WATCH_HINTS.every((h) => h.startsWith(SCRIPTS_DIR)), 'every watch hint must be under the declared scripts dir'); + check(existsSync(SCRIPTS_DIR), `the declared population must reach the tree: ${SCRIPTS_DIR}`); + check(CORPUS.docRoots.includes(SCRIPTS_DIR), 'the corpus must sweep the population this gate declares'); + check( + ROOT_DIR_WATCH_HINTS.every((h) => CORPUS.docRoots.includes(h.replace(/\/\*+$/, ''))), + `the declared hints must name the roots the corpus sweeps: ${ROOT_DIR_WATCH_HINTS.join(', ')} vs ${CORPUS.docRoots.join(', ')}`, + ); + + // 4. ⭐ The live corpus is not empty, and the projection really is wired. + // This is the ONLY thing separating a clean tree from an extractor that + // silently matches nothing. + const live = sweepCorpus(CORPUS); + check(live.counts.anchors > 1000, `the live scripts corpus must yield its anchors, got ${live.counts.anchors}`); + check(live.counts.symbol > 0, 'the live corpus must contain resolved SYMBOL anchors'); + check(CORPUS.docProjection === commentProse, 'the corpus must sweep COMMENT PROSE — a raw .mjs corpus judges every gate\'s own fixtures'); + + // 5. ⛔ Every allowance row is EXACT IN BOTH DIRECTIONS against the LIVE + // tree. A row whose file no longer carries a judged finding has done its + // job and must be DELETED; leaving it is how a dated allowance becomes a + // permanent exemption nobody re-reads. + const liveExcused = triage(live.findings).excused; + for (const row of HELD_FILE_ALLOWANCES) { + check( + liveExcused.some((f) => f.doc === row.file), + `the allowance for \`${row.file}\` is STALE — that file carries no judged finding any more. ` + + 'Delete the row; it has been lifted.', + ); + check(/^\d{4}-\d{2}-\d{2}$/.test(row.dated), `the allowance for \`${row.file}\` must carry an ISO date, got \`${row.dated}\``); + check(/#\d+/.test(row.heldBy), `the allowance for \`${row.file}\` must name the PR that lifts it, got \`${row.heldBy}\``); + check(row.file.startsWith(`${SCRIPTS_DIR}/`), `an allowance may only name a file in this corpus, got \`${row.file}\``); + } + + // 6. The gate is wired to run. A gate nothing invokes is this repo's most + // carded defect class, and renaming a step silently detaches it. + const workflow = readFileSync('.github/workflows/lint.yml', 'utf8'); + check(workflow.includes('node scripts/check-scripts-symbol-anchors.mjs'), 'lint.yml must invoke this gate'); + check(workflow.includes('node scripts/check-scripts-symbol-anchors.mjs --self-test'), 'lint.yml must invoke this gate\'s --self-test'); + + // 7. The census declaration is intact, and its three instruments stay + // ordered — the ordering IS the reading (raw counts code, comment prose + // does not, and only some of those name a file this tree has). + check(CENSUS_15765.rawLineCitations > CENSUS_15765.commentProseLineCitations, 'the raw instrument must count MORE than the comment-prose one'); + check(CENSUS_15765.commentProseLineCitations > CENSUS_15765.trackedTargetLineCitations, 'the comment-prose instrument must count more than the tracked-target one'); + + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── + const floorMessages = []; + const floorFailure = (message) => { floorMessages.push(message); }; + const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); + let floorBreached = false; + if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { + floorBreached = true; + floorFailure( + `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batterySeen) { + if (declaredBatteries.includes(name)) continue; + floorBreached = true; + floorFailure( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declaredBatteries) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorBreached = true; + floorFailure( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (floorBreached) { + floorFailure( + 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + assert(!floorBreached, floorMessages.join('\n ')); + + console.log( + '✅ check-scripts-symbol-anchors --self-test: every finding class provoked, comment-prose projection ' + + `wired, declined shapes counted not missed, allowance rows exact both ways, population live (${live.counts.anchors} live anchors)`, + ); + + return SELF_TEST_VERDICT; +} + +if (isEntrypoint(import.meta.url)) { + // The `if` body is BRACED so the trailing `else if` cannot re-bind to the + // inner refusal; the `else` arms stay unbraced, per the landed + // `scripts/pm/check-label-desc-cap.mjs` precedent. + if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-scripts-symbol-anchors self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else if (process.argv.includes('--list')) list(); + else runCheck(); +} diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs index f006a9463c..d32d4a2435 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -408,7 +408,8 @@ export const PROSE_COUNTS = [ * ## The precedent the ruling rests on * * The sibling `isSystem` census gate already carries this split, and its docblock - * (`scripts/check-system-context-census.mjs:77-84`) gives the reason: + * (`scripts/check-system-context-census.mjs#UNENFORCED_TEXT_COUNTS`, the paragraph + * beside it) gives the reason: * * > *"The split is not a tolerance. Nothing about the CONTRACT stopped being * > checked ... What stopped being checked is a set of numbers about a diff --git a/scripts/check-turbo-task-graph.mjs b/scripts/check-turbo-task-graph.mjs index 7cc53c2e6a..ebc1799c3b 100644 --- a/scripts/check-turbo-task-graph.mjs +++ b/scripts/check-turbo-task-graph.mjs @@ -14,8 +14,8 @@ * before this gate NOTHING in the repo judged it structurally except one narrow * limb: `check:cross-package-test-inputs`' Layer B, which asks only whether * `#test` exists and carries the globs for the packages listed in - * `scripts/cross-package-test-inputs.mjs`. Any other task -- `#typecheck`, - * `#build`, a package the table does not name -- was read by no gate at all. + * `scripts/cross-package-test-inputs.mjs`. Any other task -- `#typecheck`, + * `#build`, a package the table does not name -- was read by no gate at all. * * That would be tolerable if turbo refused a wrong entry. It does not. Measured * on turbo 2.10.10, on a three-file fixture workspace, each case run to a real diff --git a/scripts/docs-audit/check-docs-transcript-drift.mjs b/scripts/docs-audit/check-docs-transcript-drift.mjs index bcfbea5bb0..b5f966cf71 100644 --- a/scripts/docs-audit/check-docs-transcript-drift.mjs +++ b/scripts/docs-audit/check-docs-transcript-drift.mjs @@ -16,7 +16,7 @@ * `content/docs` teaches with pasted CLI output. Some of those lines quote a * number the CLI computes at runtime from a registry: * - * packages/cli/src/commands/validate.ts:245 + * `packages/cli/src/commands/validate.ts` * const registered = authoringRulesFor('validate'); * printStep(`Running author-time rules (${registered.length})...`); * diff --git a/scripts/eslint-fatal-guard.mjs b/scripts/eslint-fatal-guard.mjs index c1abe63231..4dbd3f0b9d 100644 --- a/scripts/eslint-fatal-guard.mjs +++ b/scripts/eslint-fatal-guard.mjs @@ -144,7 +144,7 @@ // held a raw `eslint.lintFiles([file])` since #10449: // // $ checkGuardAdoption(repoRoot) → [] -// scripts/eslint-stack-headroom.mjs:212 → eslint.lintFiles([file]) +// `scripts/eslint-stack-headroom.mjs` → eslint.lintFiles([file]) // imported by check-slot-lookup-ratchet.mjs, check-query-options-…-mjs // // Nothing was mis-measured by it — `canaryParseFailures()` hands its results diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index a580c5cdeb..157c322e4e 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -1316,7 +1316,7 @@ function isAwaited(node) { * * Only `foo(...)` and `this.foo(...)` may resolve to a same-file body. Measured * cost of the looser rule, which resolved any dotted path by its LAST segment: - * `packages/services/service-job/src/db-job-adapter.ts:139` calls + * `packages/services/service-job/src/db-job-adapter.ts#cancel` calls * `this.cron.cancel(name)` — the CronJobAdapter's method — and the loose rule * walked into the file's OWN `cancel()` method, reached `setActive()`'s * `engine.update(...)` two frames down, and reported a cron-registry cleanup as diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 90d391af06..3a0e5d53c2 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -2995,8 +2995,9 @@ export function h17TriggerFileCandidates(text) { * checkout. A token the oracle does not recognise is DROPPED in silence: the * measured decoys are `Field` and `FIXTURE_CAPTURED_NEGATED` (backticked * identifiers sitting inside real trigger clauses on #8656 and #8662) and - * `scripts/check-type-check-coverage.mjs:1679` (a real path with a line suffix - * — tracked as a file, NOT as that token, so the suffix form correctly fails). + * `scripts/check-type-check-coverage.mjs` carrying a line suffix of 1679 (a real + * path with a line suffix — tracked as a file, NOT as that token, so the suffix + * form correctly fails). * Each of those is a row this index would otherwise have rendered wrong. * * @param {string[]} texts card body plus every hold-comment body @@ -8031,7 +8032,7 @@ export function h43GovernedReviewRequestGap(pr, governed, approvers, reviewed = // // ## The rule, and the gap that is NOT in the rule // -// `.claude/skills/pm-dispatch/SKILL.md:164` carries a maintainer ruling of +// `.claude/skills/pm-dispatch/SKILL.md` carries a maintainer ruling of // 2026-09-02 (「其他同意」): 「板面/树/队列读数自带取数时刻:写进认领、派发令、 // 复核、轮次报告、座位贴段落的读数恒带 UTC 时间戳(形如 `2026-03T00:21Z`),树读 // 数另带 ref 或 tip;无时间戳的读数是格式错误不是现值,读者按未取处理」. diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index cf58c06142..845c161781 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2695,8 +2695,9 @@ export const ROOT_WALK_RESIDUE_LEDGER = [ [ 'scripts/symbol-anchors.mjs --self-test', 'a shared grammar-and-extractor LIBRARY, invoked by CI only as its own self-test; its `git ls-files` runs over a ' - + 'corpus its caller passes in. The corpus walk it lends is exercised by check-adr-symbol-anchors.mjs, which ' - + 'declares ROOT_DIR_WATCH_HINTS = [docs/adr/**] and is placed by that.', + + 'corpus its caller passes in. The corpus walk it lends is exercised by its registrations — ' + + 'check-adr-symbol-anchors.mjs, which declares ROOT_DIR_WATCH_HINTS = [docs/adr/**], and ' + + 'check-scripts-symbol-anchors.mjs, which declares [scripts/**] — and each is placed by its own.', ], ]; @@ -3233,10 +3234,10 @@ const IDENTIFIER_TOKEN = /[A-Za-z_$][\w$]*/g; * once, THREE self-test ENTRY POINTS in the `scripts/` tree carry one TODAY, so * this repairs live files rather than guarding against a future spelling: * - * scripts/check-test-completeness.mjs:576 - * scripts/measure-position-name-fold-census.mjs:689 + * `scripts/check-test-completeness.mjs#selfTest` + * `scripts/measure-position-name-fold-census.mjs#selfTest` * both `function selfTest({ quiet = false } = {})` - * scripts/workspace-enumerator.mjs:328 + * `scripts/workspace-enumerator.mjs#selfTest` * `export function selfTest({ root = null } = {})` * * Measured at 6193e576d — bytes `maskSelfTests` changes in each file, this @@ -3857,8 +3858,8 @@ export function resolveModuleRelativeHint(literal, scriptPath, { root = ROOT } = * no hint at all. That is right for `'./invoked-as.mjs'` and `'./package.json'` * and wrong for these two, which are unambiguous subtree declarations: * - * packages/spec/scripts/build-docs.ts:61 path.resolve(__dirname, '../src') - * packages/spec/scripts/build-skill-references.ts:35 path.resolve(__dirname, '../src') + * `packages/spec/scripts/build-docs.ts#SRC_DIR` path.resolve(__dirname, '../src') + * `packages/spec/scripts/build-skill-references.ts#SPEC_SRC` path.resolve(__dirname, '../src') * * Checked at the declaration site rather than assumed, which is the provenance * criterion this file prices: `build-docs.ts` does `fs.readdirSync(SRC_DIR)` @@ -4839,7 +4840,7 @@ export function collapseHint(hint) { * shape the idioms above use: `packages/**` → `packages`, `packages/spec/src/**` * → `packages/spec/src`, `packages/client*` → `packages/client`. Each names a * subtree root the tree really has, and `ROOT_DIR_WATCH_HINTS` depends on - * exactly that reduction (`scripts/check-published-files.mjs:215-248` declares + * exactly that reduction (`scripts/check-published-files.mjs` declares * the workspace globs VERBATIM so "the glob collapse reduces each back to the * root it names", justified there at 91.3%). * diff --git a/scripts/symbol-anchors.mjs b/scripts/symbol-anchors.mjs index e5d1377e75..ff836fccbd 100644 --- a/scripts/symbol-anchors.mjs +++ b/scripts/symbol-anchors.mjs @@ -70,15 +70,21 @@ * * An anchor is written inside a markdown code span. Three forms, and no others: * - * `path/to/file.ts#symbolName` SYMBOL anchor -- the default - * `path/to/file.ts` FILE-LEVEL anchor - * `repo:path/to/file.ts#symbol` CROSS-REPO anchor (e.g. `objectui:...`) + * `/.ts#` SYMBOL anchor -- the default + * `/.ts` FILE-LEVEL anchor + * `:/.ts#` CROSS-REPO anchor (e.g. `objectui:...`) + * + * ⚠️ Those placeholders are deliberately NOT path-shaped, and that is the same + * discipline the `dispatch-gates: no-path-population` note further down states + * for `ANCHOR_GRAMMAR`: an illustration written as a real-looking path is read + * by a resolver as an anchor, and then reported as pointing at a file this tree + * does not have. A gate header that teaches the grammar must not fail it. * * plus a continuation form, so a sentence naming several symbols in one file * does not repeat the path -- it inherits the path from the anchor before it on - * the SAME line: + * the SAME line. This module's own exports, so the example is itself checked: * - * (`packages/objectql/src/engine.ts#registerApp`, `#installPackage`) + * (`scripts/symbol-anchors.mjs#extractAnchors`, `#defineCorpus`) * * Rules the forms exist to satisfy: * @@ -160,6 +166,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { isEntrypoint } from './invoked-as.mjs'; +import { blank, scanSource } from './js-comment-mask.mjs'; /* The grammar, as a string, so a gate can print it in its own failure text * instead of restating it and drifting from this file. */ @@ -427,6 +434,45 @@ export function extractAnchors(markdown) { return { anchors, lineAnchors }; } +/* ──────────────────── document-side: the doc PROJECTION ────────────────── */ + +/** + * The COMMENT PROSE of a JavaScript-shaped source, with every other character + * blanked to a space and every newline kept -- so a finding's LINE NUMBER is + * still the line number in the real file. + * + * ## Why a corpus needs this at all (#15765, measured) + * + * `extractAnchors` is line-based and knows nothing about syntax: hand it a + * `.mjs` file and it reads comment prose EXACTLY as it reads a paragraph of an + * ADR -- that half needed no change and the self-test pins it per comment form. + * What it also reads is the CODE, and that is the half that made a raw `.mjs` + * corpus unusable. Measured over the 216 tracked `scripts/**` `.mjs` files on + * `5315098df`: 251 live line citations raw against 128 through this projection. + * The 123 that vanish are not rot -- they are a gate's own self-test FIXTURES, + * string literals like `'p.ts:2'` and `'content/docs/other.mdx:1'` written to + * provoke that gate's own line-reporting. Judging those would red a gate for + * testing itself, which is the fabrication direction + * `scripts/js-comment-mask.mjs` exists to close. + * + * So the separator is the shared one, never a private stripper: this asks + * `scripts/js-comment-mask.mjs#scanSource` the same question every other + * source-scanning gate asks it, and projects the answer the other way round + * from `scripts/js-comment-mask.mjs#maskComments` -- that one keeps the code + * and blanks the prose; a doc corpus wants the prose and blanks the code. + * + * ⚠️ BLANK, NEVER DELETE, and it is `scripts/js-comment-mask.mjs#blank` that + * guarantees it: a projection that dropped the code lines would report every + * finding against a line number that does not exist in the file the author + * opens. + */ +export function commentProse(source) { + const { comment } = scanSource(source); + const code = new Uint8Array(comment.length); + for (let i = 0; i < comment.length; i += 1) code[i] = comment[i] ? 0 : 1; + return blank(source, code); +} + /* ───────────────────────── corpus registration ─────────────────────────── */ /** @@ -436,10 +482,19 @@ export function extractAnchors(markdown) { * lives above and is shared. */ export function defineCorpus(spec) { - const { id, label, docRoots, docPattern = /\.mdx?$/, crossRepos = {}, checkBarePaths = false } = spec; + const { + id, label, docRoots, docPattern = /\.mdx?$/, crossRepos = {}, checkBarePaths = false, + docProjection = null, judgeUntrackedLineAnchors = true, + } = spec; if (!id || !label) throw new Error('defineCorpus: `id` and `label` are required'); if (!Array.isArray(docRoots) || docRoots.length === 0) throw new Error('defineCorpus: `docRoots` must be a non-empty array'); - return { id, label, docRoots, docPattern, crossRepos, checkBarePaths }; + /* A projection that is present but not callable would be silently skipped by + * an `if (corpus.docProjection)` guard, and the corpus would sweep raw source + * while its registration reads as though it did not. Refused loudly instead. */ + if (docProjection !== null && typeof docProjection !== 'function') { + throw new Error('defineCorpus: `docProjection` must be a function or null'); + } + return { id, label, docRoots, docPattern, crossRepos, checkBarePaths, docProjection, judgeUntrackedLineAnchors }; } function walk(dir, pattern, out = []) { @@ -480,7 +535,7 @@ function trackedFiles(root) { export function sweepCorpus(corpus, root = process.cwd()) { const tracked = trackedFiles(root); const findings = []; - const counts = { docs: 0, anchors: 0, symbol: 0, fileLevel: 0, declaration: 0, literal: 0, crossRepo: 0, exempt: 0, continuation: 0 }; + const counts = { docs: 0, anchors: 0, symbol: 0, fileLevel: 0, declaration: 0, literal: 0, crossRepo: 0, exempt: 0, continuation: 0, unresolvableLineCitation: 0 }; const sourceCache = new Map(); const readTarget = (p) => { if (!sourceCache.has(p)) sourceCache.set(p, readFileSync(join(root, p), 'utf8')); @@ -491,10 +546,35 @@ export function sweepCorpus(corpus, root = process.cwd()) { for (const abs of docs) { const rel = relative(root, abs); counts.docs += 1; - const { anchors, lineAnchors } = extractAnchors(readFileSync(abs, 'utf8')); + const rawDoc = readFileSync(abs, 'utf8'); + const { anchors, lineAnchors } = extractAnchors(corpus.docProjection ? corpus.docProjection(rawDoc) : rawDoc); for (const la of lineAnchors) { if (la.exempt && EXEMPT_CLASSES.includes(la.exempt)) { counts.exempt += 1; continue; } + /* A corpus may decline to judge a citation that names NO FILE IN THIS + * TREE, and `docs/adr/**` leaves this ON while a `scripts/**` gate-header + * corpus turns it OFF. That is the same call `checkBarePaths` makes one + * paragraph down, on the same evidence shape, and it is a SCOPE + * declaration, never a softening of the grammar: a citation this gate + * cannot resolve either way is one it cannot tell an author how to fix, + * and a gate whose only remedy is "stop writing that" is the + * permanently-red gate this repo retired. + * + * Measured on `5315098df` over `scripts/**` `.mjs` comment prose: 128 + * live citations in all, of which 32 name a tracked file and 96 do not -- + * 66 bare filenames (`engine.ts:9407`, an abbreviation inside a census + * table that no resolver can bind to one of this tree's several + * `engine.ts`), 22 continuations inheriting no path of their own, 11 + * directory-qualified paths that are illustrations or sibling-repo files + * (`path/to/file.ts:1234`, `src/github.sh:68-91`), and 1 tilde form. The + * 96 are a real defect class and are recorded as a follow-up, exactly as + * the 1,056 bare paths under `checkBarePaths` were -- but they are not + * the cross-file rot #15765 measured, and folding them in would bury this + * gate's signal under a cleanup nobody ruled on. */ + if (!corpus.judgeUntrackedLineAnchors && !tracked.has(la.path ?? '')) { + counts.unresolvableLineCitation += 1; + continue; + } /* A marker whose CLASS is unrecognised is its own finding, never a * silent pass and never an ordinary line anchor: a typo in the class * must not be a quiet way to switch this gate off, and it must not be @@ -599,7 +679,7 @@ function assert(cond, msg) { if (!cond) { console.error(`❌ symbol-anchors --se // not red. A battery BELOW its floor means cases stopped running; the remedy is // to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ - 'symbol-anchors self-test': 51, + 'symbol-anchors self-test': 63, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -757,6 +837,54 @@ export function selfTest() { try { defineCorpus({ id: 'x', label: 'x', docRoots: [] }); } catch { threw = true; } check(threw, 'defineCorpus must refuse an empty docRoots'); + // 9. ⭐ The doc PROJECTION (#15765). A `.mjs` corpus hands `extractAnchors` + // comment prose and nothing else, so each comment FORM is provoked by + // name, and the negative -- a citation living in a STRING LITERAL, which + // is a gate's own test fixture and not a doc citation -- is provoked + // beside them. A projection that kept the code would red a gate for + // testing itself; one that dropped the code lines would report every + // finding against a line number the author cannot open. + const mjs = [ + 'import { x } from "y";', // 1 code + '// A line comment cites packages/a/line.ts:11 here.', // 2 `//` + '/* A block comment cites packages/a/block.ts:22 here. */', // 3 `/* */` + '/**', // 4 docblock + ' * A docblock cites packages/a/doc.ts:33 and anchors', // 5 + ' * `packages/a/doc.ts#realThing` properly.', // 6 + ' */', // 7 + "const fixture = 'packages/a/string.ts:44';", // 8 ⛔ negative + 'const tpl = `packages/a/template.ts:55`;', // 9 ⛔ negative + ].join('\n'); + const projected = commentProse(mjs); + const pj = extractAnchors(projected); + const pjRaw = pj.lineAnchors.map((l) => l.raw).join(' '); + check(pj.lineAnchors.some((l) => l.raw === 'packages/a/line.ts:11'), `a citation in a \`//\` comment must be read, got: ${pjRaw}`); + check(pj.lineAnchors.some((l) => l.raw === 'packages/a/block.ts:22'), `a citation in a \`/* */\` block comment must be read, got: ${pjRaw}`); + check(pj.lineAnchors.some((l) => l.raw === 'packages/a/doc.ts:33'), `a citation in a docblock must be read, got: ${pjRaw}`); + check(pj.anchors.some((a) => a.symbol === 'realThing' && a.path === 'packages/a/doc.ts'), 'a SYMBOL anchor written in a comment must be extracted, not only the rot'); + // ⛔ The negatives, one per literal form. A gate's fixtures are code. + check(!pj.lineAnchors.some((l) => l.raw.includes('string.ts')), `a citation inside a STRING LITERAL is not a doc citation, got: ${pjRaw}`); + check(!pj.lineAnchors.some((l) => l.raw.includes('template.ts')), `a citation inside a TEMPLATE literal is not a doc citation, got: ${pjRaw}`); + // ...and the raw source proves the projection is what makes the difference: + // without it the extractor reads the fixtures too, which is the whole reason + // a `.mjs` corpus needs one. + const unprojected = extractAnchors(mjs).lineAnchors.map((l) => l.raw); + check(unprojected.some((r) => r.includes('string.ts')) && unprojected.some((r) => r.includes('template.ts')), + 'without the projection the extractor DOES read string fixtures — the control that makes the two negatives above mean something'); + // LINE NUMBERS SURVIVE. Blanking, not deleting: the docblock citation is on + // source line 5 and must be reported there. + check(pj.lineAnchors.find((l) => l.raw === 'packages/a/doc.ts:33')?.line === 5, + 'the projection must preserve line numbers — a finding is reported against the line the author opens'); + check(projected.split('\n').length === mjs.split('\n').length, 'the projection must preserve the line COUNT'); + + // 10. The corpus knobs the projection comes with. + check(defineCorpus({ id: 'x', label: 'x', docRoots: ['a'] }).judgeUntrackedLineAnchors === true, + 'judging every line citation is the DEFAULT — an existing corpus must not be narrowed by adding this option'); + check(defineCorpus({ id: 'x', label: 'x', docRoots: ['a'] }).docProjection === null, 'no projection is the default'); + let projThrew = false; + try { defineCorpus({ id: 'x', label: 'x', docRoots: ['a'], docProjection: 'commentProse' }); } catch { projThrew = true; } + check(projThrew, 'defineCorpus must refuse a docProjection that is not callable — a skipped projection sweeps raw source while reading as though it did not'); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so