From a10d237a550c936ee0f7b2aa447dc53ac2bda412 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 12:09:11 +0000 Subject: [PATCH] feat(ci): differential gate for newly added cross-file line citations, and retire two stored line-number ledgers (objectui#8875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling C on objectui#8875, clauses 2, 3 and 5. Clause 1 (the convention text in AGENTS.md) is a governed surface and lands as a separate, human-merged PR. Clause 2 — a DIFFERENTIAL gate. `scripts/check-new-cross-file-line-citations.mjs` reports the cross-file line-address citations a pull request ADDS, against its merge base. The 540 existing citations are not its denominator and it sweeps none of them. An absolute count was refused on a measurement: PR #8887's line shifts flipped one citation from `drifted` to `resolves` by accident, moving the tree-wide false count 540 -> 539, an unearned green that belonged to nobody. It ships report-only (`ENFORCEMENT`), wired into its own workflow so it can see the markdown-, docs- and changeset-only pull requests `lint.yml` short-circuits. Its four synthetic controls are fatal in both modes — a differential gate that reports zero through a broken differ is indistinguishable from a clean branch. `unresolvable` (chiefly regenerated `dist/*.d.ts`) is a third verdict and is never counted false. Clause 3 — the two stored ledgers stop storing line numbers. `UNGATED_EXAMPLES` is re-keyed from `path:line symbol` to `path symbol #ordinal`, and the zh-only family citations in the designer-table parity gate become `{ file, anchor }` with the anchor asserted present in the file exactly once. Behaviour-preserving on today's tree, proven by before/after runs: the doc-example gate's whole report is byte-identical, and 89 of 89 ledger rows map onto the new keys with unchanged values. Clause 5 — the firing control the ruling asked to keep is intact, but not at the address the ruling gives. Reported on the card. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .github/workflows/line-citation-gate.yml | 79 +++ content/docs/guide/ci-cd-pipeline.md | 73 +++ package.json | 1 + .../__tests__/check-doc-example-types.test.ts | 61 +- .../check-i18n-designer-table-parity.test.ts | 40 +- ...heck-new-cross-file-line-citations.test.ts | 428 +++++++++++++ scripts/check-doc-example-types.mjs | 224 ++++--- scripts/check-i18n-designer-table-parity.mjs | 30 +- .../check-new-cross-file-line-citations.mjs | 595 ++++++++++++++++++ scripts/cross-file-line-citation-census.mjs | 19 +- scripts/dependabot-merge-gate.mjs | 2 + 11 files changed, 1445 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/line-citation-gate.yml create mode 100644 scripts/__tests__/check-new-cross-file-line-citations.test.ts create mode 100644 scripts/check-new-cross-file-line-citations.mjs diff --git a/.github/workflows/line-citation-gate.yml b/.github/workflows/line-citation-gate.yml new file mode 100644 index 0000000000..dbaf98110b --- /dev/null +++ b/.github/workflows/line-citation-gate.yml @@ -0,0 +1,79 @@ +name: Line Citation Gate + +# The DIFFERENTIAL cross-file line-address citation gate (objectui#8875, ruling +# clause 2). It reads what THIS pull request ADDED against its base — never a +# tree-wide total. An absolute count was explicitly refused: PR #8887's line +# shifts flipped one citation from `drifted` to `resolves` by accident, moving +# the tree-wide false count 540 -> 539, and that unearned green belonged to +# nobody. The 540 existing citations are not this gate's denominator. +# +# ── REPORT-ONLY, and the workflow does not decide that ─────────────────────── +# The enforcement state lives in the script (`ENFORCEMENT`), not here, so there +# is one place to read it and one place to flip it. Report-only means findings +# exit 0; a failed CONTROL still exits 1 in both modes, because a differential +# gate reporting zero through a broken differ is indistinguishable from a clean +# branch — the failure this card names one level up. +# +# ── Why its own workflow rather than a step in `lint.yml` ──────────────────── +# `lint.yml` decides inside the job whether the change "needs a full run", and +# its exclusion list skips every expensive step on a markdown-only, docs-only or +# changeset-only change. Those are precisely the changes that carry citations: +# the ruling names 注释、消息串、文档、changeset、脚本 in that order. A gate +# that cannot see the pull request shape most likely to trip it rebuilds the +# hole it exists to close — the same conclusion `shell-escape-residue.yml`, +# `docs-links.yml`, `control-bytes.yml` and `changeset-presence.yml` each +# reached in their own headers. One gate, one home. +# +# Hence no `paths` and no `paths-ignore`, deliberately. +# +# ── Why `pull_request` only ────────────────────────────────────────────────── +# This gate needs a BASE to be differential at all, and only a pull request has +# one. It is therefore ⛔ not requirable while it is report-only, and +# `scripts/dependabot-merge-gate.mjs` classifies it in `NOT_A_GATE` with that +# reason. When `ENFORCEMENT` flips to `blocking` this workflow owes a +# `merge_group` leg before the context may be required — a required check that +# does not report on a queue build stalls the queue until the ruleset's +# 60-minute timeout fails it (objectui#3523). +# +# It needs no install and no build: a checkout plus one `node` call over this +# branch's own diff. Keep it that way; the import graph is node builtins plus +# repo-relative modules only, which `pre-install-import-graph.yml` enforces. + +on: + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: line-citation-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + line-citation-gate: + name: Line Citation Gate + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + # `fetch-depth: 0` is load-bearing, not caution: the gate resolves the + # merge base with the target branch, and a shallow clone cannot. When it + # cannot, the gate exits 2 naming PREREQUISITE NOT MET rather than + # printing a zero — "the diff could not be computed" must never be + # indistinguishable from "nothing was added" (objectstack#4928). + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + - name: Report cross-file line-address citations this pull request adds + env: + CITATION_GATE_BASE: origin/${{ github.event.pull_request.base.ref }} + run: node scripts/check-new-cross-file-line-citations.mjs diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 7a6c502e84..f953e9e982 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -41,6 +41,7 @@ one has its own section below. | `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve | | `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | | `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | +| `line-citation-gate.yml` | Line Citation Gate | PR to `main`, `develop` — **no path filter**; manual | No — **report-only** while it beds in; it exits 0 whatever it finds, and exits 1 only when one of its own synthetic controls fails. It declares no `merge_group` trigger, so it cannot be a required context in its current state | | `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there; on the pull request itself it is deliberately green and prints an early warning | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `lockfile-integrity.yml` | Lockfile Integrity Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own two files; manual | No — **deliberately not a blocking context** ([#8326](https://github.com/objectstack-ai/objectui/issues/8326)); it names the packages and the Dependabot merge gate classifies it `NOT_A_GATE` | @@ -1597,6 +1598,78 @@ workflow runs as its own first step because a rotted predicate must redden rathe governed diff through, and the wiring is pinned by `scripts/__tests__/check-governed-queue-guard.test.ts`. +## Line Citations (`line-citation-gate.yml`) + +**Triggers:** Pull requests to `main`/`develop`, with **no path filter**, plus manual dispatch. It +appears in the checks list as **Line Citation Gate**. + +**What it runs:** `node scripts/check-new-cross-file-line-citations.mjs` — one `node` call over the +pull request's own diff. No install, no build. + +**Report-only.** This step **exits 0 regardless of what it finds**. It is not a required context, it +declares no `merge_group` trigger, and `scripts/dependabot-merge-gate.mjs` classifies it +`NOT_A_GATE` for that reason. The one thing that does make it exit 1 is a failure of its own +synthetic controls — a differential gate reporting zero through a broken differ is indistinguishable +from a clean branch, so the instrument is checked on every run. + +### What it reads, and the word that decides its shape + +The maintainer ruled the class on 2026-09-10, verbatim: 「跨文件的「某文件第几行」引用, +这种完全没必要吧,是否应该避免」. A cross-file line address points somewhere the reader is not +looking, and nothing tells them when it moves. Five spellings are read, written here with `NNN` +standing in for the digits **on purpose** — a real address in this paragraph would be one more +citation for the gate to report, which is the shape of the problem rather than a description of it: + +| spelling | example, digits elided | +|---|---| +| the dominant form | `NAME.ts:NNN` | +| the GitHub permalink form | `NAME.ts#LNNN` | +| the address written first | `line NNN of NAME.ts` | +| the address written second | `NAME.ts line NNN` | +| the **continuation** form, which carries no filename at all | a bare `:NNN` beside an address written on a neighbouring line | + +The continuation form is the one no basename-anchored probe can see, and it is why a one-syntax +count is not a reading. Measured on this tree: 1,267 such citations, 540 of them already false. + +The gate is **differential**. It reads only what a pull request **adds**, against its merge base with +the target branch. The 540 existing citations are **not** its denominator and it does **not** sweep +them in: shifting an already-false address by a hunk delta moves a wrong pointer to a differently +wrong place while making the diff look diligent. + +An absolute count was refused on a measurement rather than on taste. PR #8887's line shifts flipped +one citation from `drifted` to `resolves` by accident, moving the tree-wide false count 540 → 539 — +an unearned green that belonged to nobody, and one a total-reading gate would have scored as +progress. + +### The three verdicts, and the one that must not collapse + +Every added citation is a finding — the convention is that the address is not written, not that it +is written accurately — but each carries the verdict a reader would reach by following it: + +| verdict | meaning | +|---|---| +| `false` | the cited line does not carry what the citing prose says it does | +| `resolving` | it does, today | +| `unresolvable` | nothing can ever decide it — chiefly citations into regenerated `dist/*.d.ts`, which are untracked and rebuilt, plus bare basenames that name several tracked files at once | + +`unresolvable` is **never** counted as `false`. Calling an undecidable citation wrong is an +assertion, and the split is pinned by a synthetic control so it cannot quietly regress. + +### What flips it to blocking + +`ENFORCEMENT` in the script is the whole switch, and its test reads the landed value, so the flip +cannot happen without the pin moving with it. It flips once the gate reads zero new citations across +the in-flight population and the convention text has landed in `AGENTS.md` — an author failed by a +rule is owed a document to be failed against. When it does flip, this workflow owes a `merge_group` +leg before the context may be required: a required check that never reports on a queue build stalls +the queue until the ruleset's 60-minute timeout fails it. + +### The related report + +`pnpm census:cross-file-line-citations` is the tree-wide census the differential gate was derived +from. It runs in no workflow, prints the whole population with its per-directory split, and is the +right instrument for asking how large the existing class is — never for deciding a pull request. + ## Lockfile Integrity (`lockfile-integrity.yml`) **Triggers:** Pull requests to `main`/`develop` that touch `pnpm-lock.yaml`, diff --git a/package.json b/package.json index 2a28ad5697..1d1fd9d231 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "census:body-dialect": "node scripts/body-dialect-census.mjs", "census:tsconfig-test-parity": "node scripts/tsconfig-test-parity-census.mjs", "census:cross-file-line-citations": "node scripts/cross-file-line-citation-census.mjs", + "check:new-line-citations": "node scripts/check-new-cross-file-line-citations.mjs", "check:control-bytes": "node scripts/check-control-bytes.mjs", "check:action-ref-convention": "node scripts/check-action-ref-convention.mjs", "check:published-dist": "node scripts/check-published-dist-tooling.mjs", diff --git a/scripts/__tests__/check-doc-example-types.test.ts b/scripts/__tests__/check-doc-example-types.test.ts index 648aacebfc..84f12194df 100644 --- a/scripts/__tests__/check-doc-example-types.test.ts +++ b/scripts/__tests__/check-doc-example-types.test.ts @@ -339,12 +339,71 @@ describe('the real ledger', () => { expect(row.card, key).toMatch(/^objectui#\d+$/); } }); + + /** + * objectui#8875 clause 3. The key used to be `path:line symbol`, and the line + * number in it was not decoration — it was a STORED literal compared for + * equality, so an edit anywhere above a documented symbol invalidated every + * row below it in that file and reddened `main` on a branch that had not + * touched a single example. PR #8895 added three imports to + * `packages/types/src/objectql.ts` and did exactly that; objectui#8614 is the + * same failure one card earlier. + * + * The maintainer ruled the class on 2026-09-10 — 跨文件的「某文件第几行」引用, + * 这种完全没必要吧,是否应该避免 — and the repair the ruling names is to stop + * storing the number, ⛔ not to recompute it after every shift. So the shape + * is pinned in BOTH directions: the key generator may not produce one, and the + * ledger may not carry one. + */ + it('keys carry no line address, in either direction', () => { + const LINE_ADDRESS = /\.[A-Za-z]+:\d+/; + + for (const key of Object.keys(UNGATED_EXAMPLES)) { + expect( + key, + `${key} embeds a line address. objectui#8875 clause 3 retired that key shape: a stored ` + + `line number is a snapshot of a moving quantity, so an unrelated edit above the block ` + + `invalidates the row and reddens a branch that changed nothing. Key by ` + + `\`path symbol #ordinal\` — see \`ledgerKey\`.`, + ).not.toMatch(LINE_ADDRESS); + expect(key, `${key} is not in the \`path symbol #ordinal\` shape`).toMatch(/ #\d+$/); + } + + // The generator, not only today's ledger: a ledger cleaned by hand while the + // generator still emits addresses would go red on the next collected block + // instead of here. + for (const block of census.blocks) { + expect(ledgerKey(block), 'ledgerKey emitted a line address').not.toMatch(LINE_ADDRESS); + } + + // Anti-vacuity. A regex that matched nothing would pass both loops above on + // an empty tree, so it is shown FIRING on the shape it is written to reject. + // + // ⛔ ASSEMBLED, not written out. A literal address here would itself be a + // cross-file line citation, and the differential gate landed alongside this + // change would report it as newly added — correctly. A control for a shape + // does not need to be an instance of the thing the shape names. + const RETIRED_KEY_SHAPE = ['packages/types/src/objectql.ts', ':', '1618', ' ObjectFormSchema'].join(''); + expect(RETIRED_KEY_SHAPE).toMatch(LINE_ADDRESS); + }); + + it('the ordinal discriminates the symbols that document more than one example', () => { + // The ordinal is not ceremony: five symbols in this tree carry several + // `@example` blocks, and `path symbol` alone would collapse them onto one + // key — silently, by making several rows the same row. Keys are checked for + // uniqueness against the block count so that collapse cannot happen quietly. + const generated = census.blocks.map((b) => ledgerKey(b)); + expect(new Set(generated).size).toBe(census.blocks.length); + expect(new Set(census.blocks.map((b) => `${b.file} ${b.symbol}`)).size).toBeLessThan( + census.blocks.length, + ); + }); }); // ── the card's own acceptance criterion ────────────────────────────────────── describe('objectui#7974 — the defect this gate was filed for', () => { - const key = 'packages/mobile/src/useSpecGesture.ts:69 useSpecGesture'; + const key = 'packages/mobile/src/useSpecGesture.ts useSpecGesture #1'; it('its example is IN the compiled tier — the gate reaches the block the card named', () => { const census = exampleCensus({ root: repoRoot }); diff --git a/scripts/__tests__/check-i18n-designer-table-parity.test.ts b/scripts/__tests__/check-i18n-designer-table-parity.test.ts index b63585ee55..950af86ee7 100644 --- a/scripts/__tests__/check-i18n-designer-table-parity.test.ts +++ b/scripts/__tests__/check-i18n-designer-table-parity.test.ts @@ -192,15 +192,47 @@ describe('the documented zh-only families', () => { for (const family of ZH_ONLY_FAMILIES) { expect(family.prefix, 'a family must be a prefix').toMatch(/\.$/); expect(family.reason.trim().length, `${family.prefix} has no reason`).toBeGreaterThan(20); - expect(family.citation, `${family.prefix} has no citation`).toMatch(/^[\w./-]+:\d+(-\d+)?$/); + expect(family.citation, `${family.prefix} has no citation`).toBeTruthy(); + expect(family.citation.file, `${family.prefix} cites no file`).toMatch(/^[\w./-]+$/); + expect( + family.citation.anchor.trim().length, + `${family.prefix} cites no anchor text`, + ).toBeGreaterThan(8); + // objectui#8875 clause 3: the citation used to be `path:line`, a STORED + // line number pointing into another file. It was one unrelated edit away + // from naming the wrong line, and nothing here ever followed it, so the + // drift would have been silent. An anchor is text, so it can be CHECKED — + // which the next assertion does. + expect( + `${family.citation.file} ${family.citation.anchor}`, + `${family.prefix} still cites a line address`, + ).not.toMatch(/\.[A-Za-z]+:\d+/); } }); - it('every citation names a file that exists in this repo', () => { + it('every citation resolves: the file exists and the anchor text is in it, exactly once', () => { for (const family of ZH_ONLY_FAMILIES) { - const file = family.citation.slice(0, family.citation.lastIndexOf(':')); - expect(fs.existsSync(path.join(repoRoot, file)), `${family.citation} names no file`).toBe(true); + const full = path.join(repoRoot, family.citation.file); + expect(fs.existsSync(full), `${family.citation.file} names no file`).toBe(true); + + const source = fs.readFileSync(full, 'utf8'); + const occurrences = source.split(family.citation.anchor).length - 1; + expect( + occurrences, + `${family.prefix} cites "${family.citation.anchor}" in ${family.citation.file}, which ` + + `contains it ${occurrences} time(s). A citation that locates nothing — or several ` + + `things — is the failure objectui#8875 retired the line numbers for; it is not fixed ` + + `by writing a number back.`, + ).toBe(1); } + + // Anti-vacuity: the same reader, on a string that is deliberately absent, + // must return zero. Without it a broken read would agree with every anchor. + const control = fs.readFileSync( + path.join(repoRoot, ZH_ONLY_FAMILIES[0].citation.file), + 'utf8', + ); + expect(control.includes('zzz-this-anchor-does-not-exist')).toBe(false); }); it('every family still subtracts at least one real key', () => { diff --git a/scripts/__tests__/check-new-cross-file-line-citations.test.ts b/scripts/__tests__/check-new-cross-file-line-citations.test.ts new file mode 100644 index 0000000000..ef28efb748 --- /dev/null +++ b/scripts/__tests__/check-new-cross-file-line-citations.test.ts @@ -0,0 +1,428 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + ENFORCEMENT, + EXIT_CODES, + SELF_FILES, + SYNTHETIC_CASES, + citationKey, + classify, + evaluateSyntheticCases, + newCitationsIn, +} from '../check-new-cross-file-line-citations.mjs'; +import { judge } from '../cross-file-line-citation-census.mjs'; + +/** + * objectui#8875 clause 2 — the DIFFERENTIAL cross-file line-address citation + * gate. + * + * The maintainer ruled the class on 2026-09-10, verbatim and untranslated: + * 跨文件的「某文件第几行」引用, 这种完全没必要吧,是否应该避免. The mechanism + * half is a gate that reds on what a pull request ADDS, and the load-bearing + * word is DIFFERENTIAL — an absolute count was refused on a measurement, not a + * preference: PR #8887's line shifts flipped one citation from `drifted` to + * `resolves` by accident and moved the tree-wide false count 540 -> 539, an + * unearned green a total-reading gate would have scored as progress. + * + * ⛔ THE ONE THING THESE TESTS EXIST TO STOP is a gate that cannot fail. A + * differential gate reporting zero because its differ is broken is + * indistinguishable from a clean branch — which is this card's own subject one + * level up. So the firing direction is asserted first, and every non-firing + * assertion below is paired with a firing one on the same code path. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const gatePath = path.join(repoRoot, 'scripts/check-new-cross-file-line-citations.mjs'); +const workflowPath = path.join(repoRoot, '.github/workflows/line-citation-gate.yml'); +const docPath = path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md'); + +/** Judges a hit against the real tree, the way the gate's own run does. */ +function judgeAgainstTree(hit: { citedWritten: string; citedLine: number; file: string; anchors: string[] }) { + const tracked = execFileSync('git', ['ls-files', '-z'], { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + .split('\0') + .filter(Boolean); + const index = new Map(); + for (const p of tracked) { + const name = path.basename(p); + if (!index.has(name)) index.set(name, []); + (index.get(name) as string[]).push(p); + } + return judge(hit, repoRoot, index, new Map()); +} + +const NOTES = 'packages/example/src/notes.ts'; + +describe('the differ FIRES — the direction that makes this a gate at all', () => { + it('reports a cross-file address the head text added', () => { + const added = newCitationsIn({ + relPath: NOTES, + baseText: '// the action vocabulary is declared by `ActionDef`\n', + headText: '// declared at packages/core/src/actions/ActionRunner.ts:112\n', + }); + expect(added).toHaveLength(1); + expect(added[0].citedWritten).toBe('packages/core/src/actions/ActionRunner.ts'); + expect(added[0].citedLine).toBe(112); + }); + + /** + * The four spellings the card measured, keyed by syntax name. + * + * ⛔ The fixtures live HERE and not in the `each` table on purpose: an address + * in a case table reaches the test name through the title interpolation, and + * `object-ui/no-line-address-in-test-name` refuses that at `error`. That rule + * is objectui#8047's mechanization of the very convention this gate extends, + * so working around it here would be the card contradicting itself in its own + * test file. The table carries the syntax NAME; the address stays data. + */ + const SYNTAX_FIXTURES: Record = { + colon: 'see packages/core/src/actions/ActionRunner.ts:112', + permalink: 'see packages/core/src/actions/ActionRunner.ts#L112', + 'line-before-name': 'see line 112 of packages/core/src/actions/ActionRunner.ts', + 'name-before-line': 'see packages/core/src/actions/ActionRunner.ts line 112', + }; + + it.each(Object.keys(SYNTAX_FIXTURES))('reads the %s syntax the card measured', (syntax) => { + // The card's own evidence is that a single-syntax probe under-reads this + // class by construction: the filing seat's first probe was `path:line` only + // and missed a `#L` permalink among its own 73 hits. + const added = newCitationsIn({ + relPath: NOTES, + baseText: '', + headText: `// ${SYNTAX_FIXTURES[syntax]}\n`, + }); + expect(added).toHaveLength(1); + expect(added[0].citedLine).toBe(112); + expect(added[0].syntax).toBe(syntax); + }); + + it('reads the CONTINUATION address, which carries no filename at all', () => { + // `packages/types/src/crud.ts` writes an address and then a bare `:NNN` + // beside it. No basename-anchored probe can match the second one, and the + // ruling names it explicitly alongside the four syntaxes. + const added = newCitationsIn({ + relPath: NOTES, + baseText: '', + headText: '// see packages/core/src/actions/ActionRunner.ts:1787 and :1793\n', + }); + expect(added).toHaveLength(2); + expect(added.map((a) => a.citedLine)).toEqual([1787, 1793]); + expect(added[1].syntax).toBe('continuation'); + expect(added[1].citedWritten).toBe('packages/core/src/actions/ActionRunner.ts'); + }); + + it('a RE-ADDRESSED citation is new — moving the number is not a repair', () => { + // Clause 4 repairs an existing address by converting it to a content + // anchor, never by moving the number to a different number. So an edited + // address is a fresh one, and the gate says so. + const added = newCitationsIn({ + relPath: NOTES, + baseText: '// see packages/core/src/actions/ActionRunner.ts:112\n', + headText: '// see packages/core/src/actions/ActionRunner.ts:113\n', + }); + expect(added).toHaveLength(1); + expect(added[0].citedLine).toBe(113); + }); + + it('a file with no base blob at all has every citation in it reported', () => { + const added = newCitationsIn({ + relPath: NOTES, + baseText: null as unknown as string, + headText: '// a packages/core/src/actions/ActionRunner.ts:112\n// b packages/types/src/crud.ts:57\n', + }); + expect(added).toHaveLength(2); + }); +}); + +describe('the differ does NOT fire — and each case is paired with the firing one above', () => { + it('is blind to a citation that only MOVED down the citing file', () => { + // The anti-absolute-count control. Adding two imports above an existing + // citation must report nothing: if it did, the instrument would carry the + // positional fragility it exists to measure. + const citation = '// see packages/core/src/actions/ActionRunner.ts:112\n'; + const added = newCitationsIn({ + relPath: NOTES, + baseText: citation, + headText: `import { x } from './x';\nimport { y } from './y';\n\n${citation}`, + }); + expect(added).toEqual([]); + }); + + it('is blind to prose re-wrapped around an unchanged citation', () => { + const added = newCitationsIn({ + relPath: NOTES, + baseText: '// the action vocabulary lives at packages/core/src/actions/ActionRunner.ts:112\n', + headText: '// the action vocabulary lives\n// at packages/core/src/actions/ActionRunner.ts:112\n', + }); + expect(added).toEqual([]); + }); + + it('counts a SECOND copy of an existing citation, because the comparison is a multiset', () => { + // Paired with the two above: "unchanged is invisible" must not degrade into + // "any citation already somewhere in the file is forgiven". + const citation = '// see packages/core/src/actions/ActionRunner.ts:112\n'; + const added = newCitationsIn({ relPath: NOTES, baseText: citation, headText: citation + citation }); + expect(added).toHaveLength(1); + }); + + it('keeps the citing line number out of the identity, and the cited one in', () => { + const key = citationKey({ syntax: 'colon', citedWritten: 'a/b.ts', citedLine: 12, line: 99 } as never); + expect(key).toBe('colon|a/b.ts|12'); + expect(key).not.toContain('99'); + }); +}); + +describe('released CHANGELOG sections stay carved out', () => { + it('does not report a citation added under a released version heading', () => { + // Clause 4: a changelog entry is a dated record of what was true at that + // release. Re-addressing it would make it false AS HISTORY, so the gate must + // not ask an author to. + const head = + '# pkg\n\n## 17.6.0\n\n- fixed per packages/core/src/actions/ActionRunner.ts:112\n'; + expect(newCitationsIn({ relPath: 'packages/pkg/CHANGELOG.md', baseText: '', headText: head })).toEqual([]); + }); + + it('DOES report one added above the first released heading — the carve-out is not the whole file', () => { + const head = + '# pkg\n\n- unreleased, per packages/core/src/actions/ActionRunner.ts:112\n\n## 17.6.0\n\n- old\n'; + expect( + newCitationsIn({ relPath: 'packages/pkg/CHANGELOG.md', baseText: '', headText: head }), + ).toHaveLength(1); + }); +}); + +describe('`unresolvable` is a third answer and never collapses into `false`', () => { + it('records a citation into a regenerated build artifact as unresolvable', () => { + // The ruling: 指向重生成文件(`dist/*.d.ts`)的记 unresolvable,不计. Those + // files are untracked and rebuilt, so no instrument can ever decide them — + // calling one false would be an assertion. An earlier census reported this + // correctly and the split must not regress. + const added = newCitationsIn({ + relPath: NOTES, + baseText: '', + headText: '// the emitted shape is at packages/types/dist/overlay.d.ts:334\n', + }); + expect(added).toHaveLength(1); + const verdict = judgeAgainstTree(added[0] as never).verdict; + expect(classify(verdict)).toBe('unresolvable'); + expect(classify(verdict)).not.toBe('false'); + }); + + it('still reports it as ADDED — unresolvable is a verdict, not an exemption', () => { + const added = newCitationsIn({ + relPath: NOTES, + baseText: '', + headText: '// packages/types/dist/overlay.d.ts:334\n', + }); + expect(added).toHaveLength(1); + }); + + it('classifies the three buckets apart', () => { + expect(classify('resolves')).toBe('resolving'); + expect(classify('drifted')).toBe('false'); + expect(classify('out-of-range')).toBe('false'); + expect(classify('non-substantive')).toBe('false'); + expect(classify('no-such-file')).toBe('unresolvable'); + expect(classify('ambiguous-basename')).toBe('unresolvable'); + expect(classify('anchor-absent')).toBe('unresolvable'); + }); +}); + +describe("the gate's own synthetic controls", () => { + it('all pass on this tree', () => { + const results = evaluateSyntheticCases(); + const failed = results.filter((r) => !r.ok); + expect(failed.map((r) => `${r.id}: ${r.detail}`)).toEqual([]); + expect(results.length).toBe(SYNTHETIC_CASES.length); + }); + + it('the FIRING control is synthetic, and it has to be', () => { + // A differential gate is blind to citations that already exist in the tree — + // that is the point of it — so the tree's own known-wrong citation cannot + // serve as this gate's firing control the way it serves the census's. + // objectui#8875 clause 5 asks for a known-wrong citation to be KEPT as the + // firing control; the one it names by line address has moved, and the + // census still holds it BY CONTENT. This gate needs its own, and a fixture + // is the only shape that can be new on every run. + const firing = SYNTHETIC_CASES.find((c) => c.id === 'fires'); + expect(firing).toBeDefined(); + expect(firing?.baseText).not.toContain(':112'); + expect(firing?.headText).toContain(':112'); + }); + + it('a broken differ fails the controls instead of reporting a clean branch', () => { + // The controls are evaluated through the real differ, so this asserts the + // wiring rather than the fixtures: hand `evaluateSyntheticCases` a judge + // that lies and the same-file case, which is the only one that consults it, + // must fail rather than pass. + const results = evaluateSyntheticCases(() => ({ verdict: 'resolves' }) as never); + expect(results.find((r) => r.id === 'same-file-is-not-in-the-population')?.ok).toBe(false); + }); +}); + +describe('the enforcement state is declared once, and this pin states what landed', () => { + it('ships REPORT-ONLY', () => { + // objectui#8875 clause 2: report-only 起步,零新增后翻阻断. Flipping this to + // `blocking` changes whether a pull request can be merged, so it is a + // decision and not a tidy-up — this assertion is what makes the flip + // visible in a diff instead of arriving as a one-word edit. + expect(ENFORCEMENT).toBe('report-only'); + }); + + it('exits 0 on findings under report-only and 1 under --strict, so it CAN fail', () => { + // Run twice over a real fixture through the real CLI, since exit codes are + // the only thing CI reads. A gate whose failing path is never exercised is + // one nobody has shown can fail. + const scratch = fs.mkdtempSync(path.join(repoRoot, '.tmp-citation-gate-')); + try { + execFileSync('git', ['init', '-q'], { cwd: scratch }); + execFileSync('git', ['config', 'user.email', 'gate@example.invalid'], { cwd: scratch }); + execFileSync('git', ['config', 'user.name', 'gate'], { cwd: scratch }); + fs.writeFileSync(path.join(scratch, 'seed.md'), 'nothing here\n'); + execFileSync('git', ['add', '-A'], { cwd: scratch }); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: scratch }); + const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: scratch, encoding: 'utf8' }).trim(); + fs.writeFileSync( + path.join(scratch, 'seed.md'), + 'see packages/core/src/actions/ActionRunner.ts:112\n', + ); + + const run = (args: string[]) => { + const res = execFileSync('node', [gatePath, '--base', base, '--json', ...args], { + cwd: scratch, + encoding: 'utf8', + env: { ...process.env, CITATION_GATE_BASE: base }, + // The gate exits non-zero on purpose in the strict arm; capture it. + }); + return res; + }; + + const reportOnly = JSON.parse(run([])); + expect(reportOnly.newCitations).toBe(1); + expect(reportOnly.enforcement).toBe('report-only'); + + let strictStatus = 0; + try { + run(['--strict']); + } catch (error) { + strictStatus = (error as { status: number }).status; + } + expect(strictStatus, 'the gate must exit 1 on a new citation under --strict').toBe( + EXIT_CODES.newCitations, + ); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +describe('a base it cannot resolve is PREREQUISITE NOT MET, never a green', () => { + it('exits 2 and says so', () => { + // objectstack#4928 named the direction: a swallowed diff failure is + // indistinguishable from a clean tree, and it fails towards "nothing to + // report" — the most reassuring wrong answer available. + let status = 0; + let output: string; + try { + output = execFileSync('node', [gatePath, '--base', 'refs/heads/no-such-base-ref-8875'], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const e = error as { status: number; stderr: string }; + status = e.status; + output = e.stderr; + } + expect(status).toBe(EXIT_CODES.couldNotRun); + expect(output).toContain('PREREQUISITE NOT MET'); + }); +}); + +describe('the instrument does not count itself', () => { + it('carves out both citation readers and both their tests', () => { + for (const file of SELF_FILES) { + expect(fs.existsSync(path.join(repoRoot, file)), `${file} is carved out but does not exist`).toBe( + true, + ); + } + expect(SELF_FILES.has('scripts/check-new-cross-file-line-citations.mjs')).toBe(true); + expect(SELF_FILES.has('scripts/__tests__/check-new-cross-file-line-citations.test.ts')).toBe(true); + }); + + it('reads the census rather than keeping a second copy of it', () => { + // Two readers over one population is how the two answers start disagreeing. + // The census is the measured one; this gate adds the base comparison and + // nothing else. + const source = fs.readFileSync(gatePath, 'utf8'); + expect(source).toContain("from './cross-file-line-citation-census.mjs'"); + expect(source).not.toMatch(/const RE_COLON\s*=/); + }); +}); + +describe('wiring — a gate nothing runs is not a gate', () => { + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + it('runs the gate in its own workflow', () => { + expect(workflow).toContain('node scripts/check-new-cross-file-line-citations.mjs'); + }); + + it('checks out deep enough to have a merge base', () => { + expect(workflow).toContain('fetch-depth: 0'); + }); + + it('filters no pull request at the trigger', () => { + // The population is comments, message strings, documentation and changesets. + // A path filter on any of those is the hole the gate exists to close, and + // `lint.yml`'s in-job short-circuit excludes exactly them, which is why this + // is its own workflow. + const trigger = workflow.slice(workflow.indexOf('\non:'), workflow.indexOf('\nconcurrency:')); + expect(trigger).toContain('pull_request'); + expect(trigger).not.toMatch(/^\s*paths(-ignore)?:/m); + }); + + it('is written up on the page objectui#3653 pins, and declared report-only there', () => { + const doc = fs.readFileSync(docPath, 'utf8'); + expect(doc).toContain('node scripts/check-new-cross-file-line-citations.mjs'); + + const at = doc.indexOf('node scripts/check-new-cross-file-line-citations.mjs'); + const start = doc.lastIndexOf('\n\n', at); + const end = doc.indexOf('\n\n', at); + // Whitespace-normalised: the page hard-wraps, so a sentence that must be + // present is split across lines in the source and matches nothing verbatim. + const around = doc + .slice(start, end === -1 ? doc.length : end + 400) + .toLowerCase() + .replace(/\s+/g, ' '); + expect(around, 'a step the page does not declare report-only is a guardrail readers believe in') + .toContain('report-only'); + expect(around).toContain('exits 0 regardless of what it finds'); + }); + + it('has a heading naming the workflow file, which the inventory pin also requires', () => { + const doc = fs.readFileSync(docPath, 'utf8'); + const headings = doc.split('\n').filter((line) => /^#{1,6}\s/.test(line)); + expect(headings.some((h) => h.includes('line-citation-gate.yml'))).toBe(true); + }); + + it('is classified by the Dependabot merge gate, with its reason', () => { + // An unclassified blocking check is one a Dependabot merge would be let + // past (objectui#6135); an unclassified NON-blocking one fails that gate's + // partition test instead, which is where a new context is meant to be + // noticed. + const gate = fs.readFileSync(path.join(repoRoot, 'scripts/dependabot-merge-gate.mjs'), 'utf8'); + expect(gate).toContain("'Line Citation Gate':"); + }); + + it('the workflow job name matches the classified context name', () => { + expect(workflow).toMatch(/^\s*name: Line Citation Gate$/m); + }); +}); diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index cb5f9c4da7..4230f63274 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -169,7 +169,7 @@ * * ## The ledger, and what makes it shrink-only * - * `UNGATED_EXAMPLES` is keyed by `path:line symbol` and each row carries the + * `UNGATED_EXAMPLES` is keyed by `path symbol #ordinal` and each row carries the * diagnostic CODES the example currently produces, a written reason, and the card * that owns it. Four verdicts, and only the first is silent: * @@ -435,12 +435,23 @@ export function exampleCensus({ root = repoRoot } = {}) { const inSources = tags.filter((t) => !t.tooling); const exported = inSources.filter((t) => t.exported && t.symbol !== null); const blocks = []; + /** + * `file symbol -> how many of its blocks have been seen`, which becomes each + * block's ORDINAL. `exported` is walked in source order per file, so the + * ordinal of a block is its position among that symbol's own examples and + * moves only when one of THEM is added or removed (objectui#8875 clause 3). + */ + const ordinals = new Map(); for (const tag of exported) { for (const fence of tag.fences) { if (!TS_FENCE_LANGUAGES.has(fence.language)) continue; + const pair = `${tag.file} ${tag.symbol}`; + const ordinal = (ordinals.get(pair) ?? 0) + 1; + ordinals.set(pair, ordinal); blocks.push({ file: tag.file, line: tag.line, + ordinal, symbol: tag.symbol, package: tag.package, language: fence.language, @@ -473,9 +484,11 @@ export function exampleCensus({ root = repoRoot } = {}) { * The declared debt: examples that do not compile today, each with the codes it * produces, a written reason, and the card that owns it. * - * Keys are `path:line symbol`. The line is the `@example` TAG's line, which is - * where a reader looking for the block starts; it moves when the file moves, and - * a row whose key no longer resolves is reported as stale rather than ignored. + * Keys are `path symbol #ordinal` — see `ledgerKey` for why they carry ⛔ NO + * line number and what the ordinal is. A row whose key no longer resolves is + * reported as stale rather than ignored, so a symbol that stops documenting an + * example still reddens; what no longer reddens is an edit somewhere else in + * the same file. * * ⛔ A row is not a place to park a defect. Every row here is a claim that the * example is a FRAGMENT (it references context its reader supplies) or that a @@ -485,175 +498,175 @@ export function exampleCensus({ root = repoRoot } = {}) { * @type {Record} */ export const UNGATED_EXAMPLES = { - 'packages/auth/src/AuthGuard.tsx:36 AuthGuard': { + 'packages/auth/src/AuthGuard.tsx AuthGuard #1': { card: null, codes: [2657], reason: 'two sibling JSX elements with no wrapper: the block is a render-body excerpt, not a module', }, - 'packages/auth/src/AuthProvider.tsx:142 AuthProvider': { + 'packages/auth/src/AuthProvider.tsx AuthProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, which the example never declares', }, - 'packages/auth/src/AuthProvider.tsx:149 AuthProvider': { + 'packages/auth/src/AuthProvider.tsx AuthProvider #2': { card: null, codes: [2304], reason: 'usage fragment: references `App`, which the example never declares', }, - 'packages/auth/src/AuthProvider.tsx:155 AuthProvider': { + 'packages/auth/src/AuthProvider.tsx AuthProvider #3': { card: null, codes: [2304], reason: 'usage fragment: references `App`, which the example never declares', }, - 'packages/auth/src/AuthShell.tsx:66 AuthShell': { + 'packages/auth/src/AuthShell.tsx AuthShell #1': { card: null, codes: [2304, 2552], reason: 'usage fragment: references `LoginForm`, `navigate`, which the example never declares', }, - 'packages/auth/src/createAuthClient.ts:270 createAuthClient': { + 'packages/auth/src/createAuthClient.ts createAuthClient #1': { card: null, codes: [18004], reason: 'shorthand `{ email, password }` stands for credentials the caller supplies; the example never declares them', }, - 'packages/auth/src/ForgotPasswordForm.tsx:107 ForgotPasswordForm': { + 'packages/auth/src/ForgotPasswordForm.tsx ForgotPasswordForm #1': { card: null, codes: [2304], reason: 'usage fragment: references `setShowSuccess`, which the example never declares', }, - 'packages/auth/src/LoginForm.tsx:126 LoginForm': { + 'packages/auth/src/LoginForm.tsx LoginForm #1': { card: null, codes: [2552], reason: 'usage fragment: references `navigate`, which the example never declares', }, - 'packages/auth/src/RegisterForm.tsx:102 RegisterForm': { + 'packages/auth/src/RegisterForm.tsx RegisterForm #1': { card: null, codes: [2552], reason: 'usage fragment: references `navigate`, which the example never declares', }, - 'packages/auth/src/useAuth.ts:16 useAuth': { + 'packages/auth/src/useAuth.ts useAuth #1': { card: null, codes: [18047], reason: 'guards on `isAuthenticated`, which strict null checking cannot correlate with `user` being non-null', }, - 'packages/auth/src/UserMenu.tsx:31 UserMenu': { + 'packages/auth/src/UserMenu.tsx UserMenu #1': { card: null, codes: [2552], reason: 'usage fragment: references `navigate`, which the example never declares', }, - 'packages/components/src/notifications/NotificationAlerts.tsx:58 NotificationAlerts': { + 'packages/components/src/notifications/NotificationAlerts.tsx NotificationAlerts #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, `NotificationProvider`, which the example never declares', }, - 'packages/components/src/notifications/NotificationBanners.tsx:38 NotificationBanners': { + 'packages/components/src/notifications/NotificationBanners.tsx NotificationBanners #1': { card: null, codes: [2304], reason: 'usage fragment: references `Outlet`, which the example never declares', }, - 'packages/components/src/notifications/NotificationInline.tsx:43 NotificationInline': { + 'packages/components/src/notifications/NotificationInline.tsx NotificationInline #1': { card: null, codes: [2304], reason: 'usage fragment: references `notify`, which the example never declares', }, - 'packages/components/src/notifications/NotificationSnackbar.tsx:43 NotificationSnackbar': { + 'packages/components/src/notifications/NotificationSnackbar.tsx NotificationSnackbar #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, `NotificationProvider`, which the example never declares', }, - 'packages/core/src/actions/TransactionManager.ts:129 TransactionManager': { + 'packages/core/src/actions/TransactionManager.ts TransactionManager #1': { card: null, codes: [2304], reason: 'usage fragment: references `actionExecutor`, `createOrderAction`, `manager`, `sendNotificationAction`, `updateInventoryAction`, which the example never declares', }, - 'packages/core/src/actions/TransactionManager.ts:244 TransactionManager': { + 'packages/core/src/actions/TransactionManager.ts TransactionManager #2': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, `manager`, which the example never declares', }, - 'packages/core/src/actions/TransactionManager.ts:324 TransactionManager': { + 'packages/core/src/actions/TransactionManager.ts TransactionManager #3': { card: null, codes: [2304, 7006], reason: 'usage fragment: references `items`, `manager`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/core/src/adapters/resolveDataSource.ts:36 resolveDataSource': { + 'packages/core/src/adapters/resolveDataSource.ts resolveDataSource #1': { card: null, codes: [2304, 18047], reason: 'usage fragment: references `contextDataSource`, which the example never declares, so what depends on it is judged unbound', }, - 'packages/core/src/data-scope/DataScopeManager.ts:68 DataScopeManager': { + 'packages/core/src/data-scope/DataScopeManager.ts DataScopeManager #1': { card: null, codes: [2304], reason: 'usage fragment: references `myDataSource`, which the example never declares', }, - 'packages/core/src/data-scope/ViewDataProvider.ts:139 ViewDataProvider': { + 'packages/core/src/data-scope/ViewDataProvider.ts ViewDataProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `myFetcher`, which the example never declares', }, - 'packages/core/src/evaluator/ExpressionEvaluator.ts:264 ExpressionEvaluator': { + 'packages/core/src/evaluator/ExpressionEvaluator.ts ExpressionEvaluator #2': { card: null, codes: [2304], reason: 'usage fragment: references `evaluator`, which the example never declares', }, - 'packages/core/src/evaluator/ExpressionEvaluator.ts:334 ExpressionEvaluator': { + 'packages/core/src/evaluator/ExpressionEvaluator.ts ExpressionEvaluator #3': { card: null, codes: [2304], reason: 'usage fragment: references `evaluator`, which the example never declares', }, - 'packages/core/src/evaluator/ExpressionEvaluator.ts:534 ExpressionEvaluator': { + 'packages/core/src/evaluator/ExpressionEvaluator.ts ExpressionEvaluator #4': { card: null, codes: [2304], reason: 'usage fragment: references `fmt`, which the example never declares', }, - 'packages/core/src/registry/WidgetRegistry.ts:41 WidgetRegistry': { + 'packages/core/src/registry/WidgetRegistry.ts WidgetRegistry #1': { card: null, codes: [2304], reason: 'usage fragment: references `registry`, which the example never declares', }, - 'packages/core/src/utils/debug.ts:109 debugLog': { + 'packages/core/src/utils/debug.ts debugLog #1': { card: null, codes: [7017], reason: 'sets a debug flag on `globalThis`, which has no index signature under strict mode', }, - 'packages/core/src/utils/freeze-schema.ts:144 defineSystemView': { + 'packages/core/src/utils/freeze-schema.ts defineSystemView #1': { card: null, codes: [2339], reason: 'demonstrates that the returned view is frozen by showing a `push` the readonly type rejects — the diagnostic IS the lesson', }, - 'packages/core/src/utils/record-source.ts:140 resolveRecordSourceConfig': { + 'packages/core/src/utils/record-source.ts resolveRecordSourceConfig #1': { card: null, codes: [2304], reason: 'usage fragment: references `resolveRecordSourceObjectName`, `schema`, `useMemo`, which the example never declares', }, - 'packages/core/src/utils/record-source.ts:69 resolveRecordSourceObjectName': { + 'packages/core/src/utils/record-source.ts resolveRecordSourceObjectName #1': { card: null, codes: [2304], reason: @@ -665,103 +678,103 @@ export const UNGATED_EXAMPLES = { // above these two JSDoc blocks in schema-validator.ts). Both VERDICTS are // unchanged — only the line half of each key moved, and both were re-derived // from the file rather than arithmetic on the old numbers. - 'packages/core/src/validation/schema-validator.ts:550 assertValidSchema': { + 'packages/core/src/validation/schema-validator.ts assertValidSchema #1': { card: null, codes: [2304, 18046], reason: 'usage fragment: references `schema`, which the example never declares, so what depends on it is judged unbound', }, - 'packages/core/src/validation/schema-validator.ts:575 isValidSchema': { + 'packages/core/src/validation/schema-validator.ts isValidSchema #1': { card: null, codes: [2304], reason: 'usage fragment: references `data`, `renderSchema`, which the example never declares', }, - 'packages/data-objectstack/src/index.ts:6562 createObjectStackAdapter': { + 'packages/data-objectstack/src/index.ts createObjectStackAdapter #1': { card: null, codes: [2591], reason: 'usage fragment: references `process`, which the example never declares, so what depends on it is judged unbound', }, - 'packages/i18n/src/provider.tsx:370 I18nProviderProps': { + 'packages/i18n/src/provider.tsx I18nProviderProps #1': { card: null, codes: [2304, 7006], reason: 'usage fragment: references `App`, `I18nProvider`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/i18n/src/provider.tsx:393 I18nProviderProps': { + 'packages/i18n/src/provider.tsx I18nProviderProps #2': { card: null, codes: [2304], reason: 'usage fragment: references `App`, `I18nProvider`, `loadLanguage`, `loadLocales`, which the example never declares', }, - 'packages/i18n/src/provider.tsx:428 I18nProvider': { + 'packages/i18n/src/provider.tsx I18nProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, which the example never declares', }, - 'packages/i18n/src/useObjectLabel.ts:88 useObjectLabel': { + 'packages/i18n/src/useObjectLabel.ts useObjectLabel #1': { card: null, codes: [2304], reason: 'usage fragment: references `objectDef`, which the example never declares', }, - 'packages/i18n/src/utils/spec-formatters.ts:64 resolvePlural': { + 'packages/i18n/src/utils/spec-formatters.ts resolvePlural #1': { card: null, codes: [2304], reason: 'annotates with `SpecPluralRule`, a type the example does not import', }, - 'packages/layout/src/AppSchemaRenderer.tsx:485 AppSchemaRenderer': { + 'packages/layout/src/AppSchemaRenderer.tsx AppSchemaRenderer #1': { card: null, codes: [2304], reason: 'usage fragment: references `Outlet`, `appJson`, `can`, `evaluateVisibility`, `evaluator`, which the example never declares', }, - 'packages/layout/src/NavigationRenderer.tsx:1243 NavigationRenderer': { + 'packages/layout/src/NavigationRenderer.tsx NavigationRenderer #1': { card: null, codes: [2304], reason: 'usage fragment: references `appSchema`, `can`, `evaluateVisibility`, `evaluator`, `saveOrder`, `searchTerm`, `updatePin`, which the example never declares', }, - 'packages/layout/src/ResponsiveGrid.tsx:119 ResponsiveGrid': { + 'packages/layout/src/ResponsiveGrid.tsx ResponsiveGrid #1': { card: null, codes: [2304], reason: 'usage fragment: references `Card`, which the example never declares', }, - 'packages/mobile/src/useGesture.ts:29 useGesture': { + 'packages/mobile/src/useGesture.ts useGesture #1': { card: null, codes: [1108], reason: 'a hook-body excerpt: its `return` sits outside any function, so the block is a fragment by shape', }, - 'packages/mobile/src/useSpecGesture.ts:69 useSpecGesture': { + 'packages/mobile/src/useSpecGesture.ts useSpecGesture #1': { card: 'objectui#7974', codes: [1108, 2322], reason: 'the scalar `swipe.direction` this example passes is rejected by the declared `SpecSwipeDirection[]` (TS2322). objectui#7974 owns BOTH halves — the example and the lenient cast that hides it — and is on another lane. Delete this row when that card lands; the block also returns outside a function (TS1108), a hook-body excerpt', }, - 'packages/mobile/src/useTouchTarget.ts:33 useTouchTarget': { + 'packages/mobile/src/useTouchTarget.ts useTouchTarget #1': { card: null, codes: [1108], reason: 'a hook-body excerpt: its `return` sits outside any function, so the block is a fragment by shape', }, - 'packages/plugin-designer/src/EditorModeToggle.tsx:46 EditorModeToggle': { + 'packages/plugin-designer/src/EditorModeToggle.tsx EditorModeToggle #1': { card: null, codes: [2304], reason: 'usage fragment: references `mode`, `setMode`, which the example never declares', }, - 'packages/plugin-designer/src/hooks/useDesignerHistory.ts:24 useDesignerHistory': { + 'packages/plugin-designer/src/hooks/useDesignerHistory.ts useDesignerHistory #1': { card: null, codes: [2304], reason: 'usage fragment: references `DesignerState`, `initialState`, `newState`, which the example never declares', }, - 'packages/plugin-form/src/FormSection.tsx:109 FormSectionContainer': { + 'packages/plugin-form/src/FormSection.tsx FormSectionContainer #1': { card: null, codes: [2304], reason: @@ -769,259 +782,259 @@ export const UNGATED_EXAMPLES = { }, // Line moved 123 -> 124 (objectui#8738 route 1: one new import line added // above this JSDoc block, in ObjectForm.tsx, for `warnUnresolvedTopLevelField`). - 'packages/plugin-form/src/ObjectForm.tsx:124 ObjectForm': { + 'packages/plugin-form/src/ObjectForm.tsx ObjectForm #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-form/src/TabbedForm.tsx:220 TabbedForm': { + 'packages/plugin-form/src/TabbedForm.tsx TabbedForm #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-form/src/WizardForm.tsx:361 WizardForm': { + 'packages/plugin-form/src/WizardForm.tsx WizardForm #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-grid/src/VirtualGrid.tsx:49 VirtualGrid': { + 'packages/plugin-grid/src/VirtualGrid.tsx VirtualGrid #1': { card: null, codes: [2304], reason: 'usage fragment: references `items`, which the example never declares', }, - 'packages/plugin-list/src/ListView.tsx:808 ListViewHandle': { + 'packages/plugin-list/src/ListView.tsx ListViewHandle #1': { card: null, codes: [2304, 2686], reason: 'names the `React` UMD global, which a module-shaped block may not reach without an import', }, - 'packages/plugin-report/src/LiveReportExporter.ts:150 exportExcelWithFormulas': { + 'packages/plugin-report/src/LiveReportExporter.ts exportExcelWithFormulas #1': { card: null, codes: [2304], reason: 'usage fragment: references `data`, `report`, which the example never declares', }, - 'packages/plugin-report/src/LiveReportExporter.ts:234 createScheduleTrigger': { + 'packages/plugin-report/src/LiveReportExporter.ts createScheduleTrigger #1': { card: null, codes: [1109], reason: 'the block is a prose-and-code mixture that does not parse as TSX in isolation', }, - 'packages/plugin-report/src/LiveReportExporter.ts:88 exportWithLiveData': { + 'packages/plugin-report/src/LiveReportExporter.ts exportWithLiveData #1': { card: null, codes: [2304], reason: 'usage fragment: references `myAdapter`, `report`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:618 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx ObjectView #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:632 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx ObjectView #2': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:649 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx ObjectView #3': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/react/src/context/ActionContext.tsx:72 ActionProvider': { + 'packages/react/src/context/ActionContext.tsx ActionProvider #1': { card: null, codes: [2304, 18004], reason: 'shorthand `{ user }` stands for context the caller supplies; the example never declares it', }, - 'packages/react/src/context/DndContext.tsx:128 DndProvider': { + 'packages/react/src/context/DndContext.tsx DndProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `KanbanBoard`, `handleDrop`, which the example never declares', }, - 'packages/react/src/context/NotificationContext.tsx:377 NotificationProvider': { + 'packages/react/src/context/NotificationContext.tsx NotificationProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, `NotificationAlerts`, `NotificationBanners`, `NotificationSnackbar`, `toast`, which the example never declares', }, - 'packages/react/src/context/ThemeContext.tsx:120 ThemeProvider': { + 'packages/react/src/context/ThemeContext.tsx ThemeProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `App`, `myTheme`, which the example never declares', }, - 'packages/react/src/element-data-source/ElementDataSourceGate.tsx:182 useElementDataSourceSchema': { + 'packages/react/src/element-data-source/ElementDataSourceGate.tsx useElementDataSourceSchema #1': { card: null, codes: [1108, 2304], reason: 'usage fragment: references `schema`, which the example never declares, so what depends on it is judged unbound', }, - 'packages/react/src/hooks/useActionRunner.ts:42 useActionRunner': { + 'packages/react/src/hooks/useActionRunner.ts useActionRunner #1': { card: null, codes: [2304], reason: 'usage fragment: references `formData`, `toast`, which the example never declares', }, - 'packages/react/src/hooks/useClientNotifications.ts:103 useClientNotifications': { + 'packages/react/src/hooks/useClientNotifications.ts useClientNotifications #1': { card: null, codes: [2304], reason: 'usage fragment: references `Button`, which the example never declares', }, - 'packages/react/src/hooks/useCrudShortcuts.ts:37 useCrudShortcuts': { + 'packages/react/src/hooks/useCrudShortcuts.ts useCrudShortcuts #1': { card: null, codes: [2304], reason: 'usage fragment: references `closeDialog`, `deleteSelected`, `openCreateDialog`, `saveRecord`, which the example never declares', }, - 'packages/react/src/hooks/useDataRefresh.ts:24 useDataRefresh': { + 'packages/react/src/hooks/useDataRefresh.ts useDataRefresh #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, `objectName`, `params`, `schema`, `setData`, `useEffect`, which the example never declares', }, - 'packages/react/src/hooks/useDebugMode.ts:34 useDebugMode': { + 'packages/react/src/hooks/useDebugMode.ts useDebugMode #1': { card: null, codes: [2304], reason: 'usage fragment: references `DebugPanel`, which the example never declares', }, - 'packages/react/src/hooks/useDensityMode.ts:77 useDensityMode': { + 'packages/react/src/hooks/useDensityMode.ts useDensityMode #1': { card: null, codes: [2304], reason: 'usage fragment: references `activeView`, `dataSource`, `obj`, `vid`, which the example never declares', }, - 'packages/react/src/hooks/useDiscovery.ts:88 useDiscovery': { + 'packages/react/src/hooks/useDiscovery.ts useDiscovery #1': { card: null, codes: [2304], reason: 'usage fragment: references `AuthProvider`, `LoadingScreen`, which the example never declares', }, - 'packages/react/src/hooks/useDynamicApp.ts:58 useDynamicApp': { + 'packages/react/src/hooks/useDynamicApp.ts useDynamicApp #1': { card: null, codes: [2304, 2307, 2693], reason: 'imports \'../config/app.json\', a sibling file the reader\'s own project supplies, and names `Console` as a value', }, - 'packages/react/src/hooks/useElementDataSource.ts:125 useElementDataSource': { + 'packages/react/src/hooks/useElementDataSource.ts useElementDataSource #1': { card: null, codes: [1108, 2304], reason: 'usage fragment: references `adapter`, `schema`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/react/src/hooks/useETagCache.ts:174 useETagCache': { + 'packages/react/src/hooks/useETagCache.ts useETagCache #1': { card: null, codes: [2304], reason: 'usage fragment: references `User`, `setUser`, `useEffect`, which the example never declares', }, - 'packages/react/src/hooks/useExpression.ts:165 useExpression': { + 'packages/react/src/hooks/useExpression.ts useExpression #1': { card: null, codes: [18004], reason: 'shorthand `{ data, user }` stands for the scope the caller supplies; the example never declares it', }, - 'packages/react/src/hooks/useKeyboardShortcuts.ts:34 useKeyboardShortcuts': { + 'packages/react/src/hooks/useKeyboardShortcuts.ts useKeyboardShortcuts #1': { card: null, codes: [2304], reason: 'usage fragment: references `closeModal`, `createNew`, `openSearch`, which the example never declares', }, - 'packages/react/src/hooks/useNavigationOverlay.ts:211 useNavigationOverlay': { + 'packages/react/src/hooks/useNavigationOverlay.ts useNavigationOverlay #1': { card: null, codes: [1003, 1382], reason: 'the block is a prose-and-code mixture that does not parse as TSX in isolation', }, - 'packages/react/src/hooks/useOffline.ts:239 useOffline': { + 'packages/react/src/hooks/useOffline.ts useOffline #1': { card: null, codes: [2304], reason: 'usage fragment: references `Banner`, which the example never declares', }, - 'packages/react/src/hooks/usePageVariables.tsx:249 usePageVariableBinding': { + 'packages/react/src/hooks/usePageVariables.tsx usePageVariableBinding #1': { card: null, codes: [2304], reason: 'usage fragment: references `record`, `schema`, which the example never declares', }, - 'packages/react/src/hooks/usePageVariables.tsx:98 PageVariablesProvider': { + 'packages/react/src/hooks/usePageVariables.tsx PageVariablesProvider #1': { card: null, codes: [2304], reason: 'usage fragment: references `MyComponents`, which the example never declares', }, - 'packages/react/src/hooks/usePerformance.ts:139 usePerformance': { + 'packages/react/src/hooks/usePerformance.ts usePerformance #1': { card: null, codes: [2304, 2345], reason: 'usage fragment: references `NormalList`, `VirtualList`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/react/src/hooks/usePerformanceBudget.ts:131 usePerformanceBudget': { + 'packages/react/src/hooks/usePerformanceBudget.ts usePerformanceBudget #1': { card: null, codes: [2304], reason: 'usage fragment: references `Dashboard`, `analytics`, which the example never declares', }, - 'packages/react/src/hooks/useSchemaPersistence.ts:212 useSchemaPersistence': { + 'packages/react/src/hooks/useSchemaPersistence.ts useSchemaPersistence #1': { card: null, codes: [2304, 2451, 7006], reason: 'usage fragment: references `SchemaPersistenceAdapter`, `pageSchema`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/react/src/hooks/useSettledSchema.ts:112 useSettledSchema': { + 'packages/react/src/hooks/useSettledSchema.ts useSettledSchema #1': { card: null, codes: [2304], reason: 'usage fragment: references `dataConfig`, `resolveRecordSourceObjectName`, `schema`, which the example never declares', }, - 'packages/react/src/hooks/useViewData.ts:72 useViewData': { + 'packages/react/src/hooks/useViewData.ts useViewData #1': { card: null, codes: [2304, 7031], reason: 'usage fragment: references `ErrorMessage`, `Spinner`, `Table`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/react/src/hooks/useViewSharing.ts:53 useViewSharing': { + 'packages/react/src/hooks/useViewSharing.ts useViewSharing #1': { card: null, codes: [2304], reason: 'usage fragment: references `currentFilters`, `currentSort`, `initialViews`, which the example never declares', }, - 'packages/types/src/data.ts:263 GlobalSearchHit': { + 'packages/types/src/data.ts GlobalSearchHit #1': { card: null, codes: [2304, 7006], reason: 'usage fragment: references `DataSource`, `User`, `buildQuery`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/types/src/data.ts:740 DataSource': { + 'packages/types/src/data.ts DataSource #1': { card: null, codes: [2304, 7006], reason: 'usage fragment: references `dataSource`, `refreshList`, which the example never declares, so what depends on them is judged unbound', }, - 'packages/types/src/icon-key-migration.ts:122 migrateIconNodeKeys': { + 'packages/types/src/icon-key-migration.ts migrateIconNodeKeys #1': { card: null, codes: [2304], reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1618 ObjectFormSchema': { + 'packages/types/src/objectql.ts ObjectFormSchema #1': { card: null, codes: [1005, 1109], reason: 'the block is a prose-and-code mixture that does not parse as TSX in isolation', }, - 'packages/types/src/plugin-scope.ts:227 AppMetadataPlugin': { + 'packages/types/src/plugin-scope.ts AppMetadataPlugin #1': { card: null, codes: [1128], reason: @@ -1141,9 +1154,30 @@ export function preludeFor(block, injectableFrom) { : ''; } -/** `path:line symbol` — the ledger key for one block. */ +/** + * `path symbol #ordinal` — the ledger key for one block. + * + * ⛔ NO LINE NUMBER. It used to be `path:line symbol`, and the line was part of + * the key, so an edit ANYWHERE ABOVE a documented symbol invalidated every row + * below it in that file. That is not hypothetical: PR #8895 added three import + * lines to `packages/types/src/objectql.ts`, every collected block moved down by + * three, and this gate reddened on `main` naming a row whose example had not + * changed at all. objectui#8614 is the same failure one card earlier. + * + * The maintainer ruled the class on 2026-09-10 — 跨文件的「某文件第几行」引用, + * 这种完全没必要吧,是否应该避免 — and objectui#8875's clause 3 applies it here: + * a stored line number is a snapshot of a moving quantity, and the repair is to + * STOP STORING ONE, not to recompute it after every shift. + * + * The ordinal is the block's position among the examples of THAT symbol in THAT + * file (see `exampleCensus`). 114 of this tree's 124 blocks are the only example + * on their symbol and carry `#1`; the ordinal exists for the five symbols that + * document more than one. It moves only when a sibling example on the same + * symbol is added or removed — an editorial act on the very block a row + * describes — never when unrelated lines shift above it. + */ export function ledgerKey(block) { - return `${block.file}:${block.line} ${block.symbol}`; + return `${block.file} ${block.symbol} #${block.ordinal}`; } /** diff --git a/scripts/check-i18n-designer-table-parity.mjs b/scripts/check-i18n-designer-table-parity.mjs index c019e179b8..2f64df9458 100644 --- a/scripts/check-i18n-designer-table-parity.mjs +++ b/scripts/check-i18n-designer-table-parity.mjs @@ -128,6 +128,16 @@ export const DESIGNER_PAIR_CONSTS = DESIGNER_TABLE_PAIRS.flatMap((pair) => [pair * family is one-sided is a fact about a consumer, and a claim about a consumer * that names no consumer is an assertion. * + * ⛔ A citation is `{ file, anchor }` and the anchor is TEXT, ⛔ never a line + * number (objectui#8875 clause 3, on the maintainer's ruling of 2026-09-10: + * 跨文件的「某文件第几行」引用, 这种完全没必要吧,是否应该避免). These three + * addresses were stored literals pointing INTO another file, so every one of + * them was one unrelated edit away from naming the wrong line — silently, since + * nothing here ever followed them. An anchor is checked: this gate's test + * asserts the text is actually present in the file, which a line number never + * could be. ⚠️ The anchor must be a string that occurs exactly once, or the + * citation stops locating anything. + * * These prefixes decide only what a REPORT prints. They can never make a * failing gate pass, which is why a prefix is safe here and would not be in an * exemption ledger — a prefix ledger swallows an entire family's future @@ -140,14 +150,20 @@ export const ZH_ONLY_FAMILIES = [ reason: 'zh-only by design: for English the flow palette falls back to the engine descriptor’s own ' + 'server-authoritative name/description, so an `en` row would OVERRIDE the server', - citation: 'packages/app-shell/src/views/metadata-admin/i18n.ts:2692-2695', + citation: { + file: 'packages/app-shell/src/views/metadata-admin/i18n.ts', + anchor: 'resolved by translateNodeLabel/Hint', + }, }, { prefix: 'engine.enum.type.', reason: 'zh-only by construction: translateEnumOption returns the raw value before it ever touches the ' + 'table for any non-zh locale (`if (!isZhLocale(locale)) return value;`), so an `en` row is unreachable', - citation: 'packages/app-shell/src/views/metadata-admin/i18n.ts:4516', + citation: { + file: 'packages/app-shell/src/views/metadata-admin/i18n.ts', + anchor: 'export function translateEnumOption', + }, }, { prefix: 'engine.packages.form.help.', @@ -155,7 +171,10 @@ export const ZH_ONLY_FAMILIES = [ 'zh-only on purpose: with no `en` row tOptional returns undefined, helpText drops out, and the row ' + 'falls back to ManifestSchema’s .describe() in @objectstack/spec — an `en` row would copy that ' + 'text into a second producer, against AGENTS.md #0.1', - citation: 'packages/app-shell/src/views/metadata-admin/package-schema.ts:88-93', + citation: { + file: 'packages/app-shell/src/views/metadata-admin/package-schema.ts', + anchor: 'en-US deliberately has NO entry', + }, }, ]; @@ -306,7 +325,10 @@ if (isEntrypoint(import.meta.url)) { // to a reader without handing anyone a lever that can turn the gate off. console.log('\nzh-only keys, by documented family (report only — these never affect the exit code):'); for (const family of ZH_ONLY_FAMILIES) { - console.log(` ${result.familyCounts[family.prefix]} ${family.prefix} ${family.reason} [${family.citation}]`); + console.log( + ` ${result.familyCounts[family.prefix]} ${family.prefix} ${family.reason} ` + + `[${family.citation.file} -> ${family.citation.anchor}]`, + ); } if (result.unexplainedZhOnly.length === 0) { console.log(' 0 no zh-only key falls outside the families above.'); diff --git a/scripts/check-new-cross-file-line-citations.mjs b/scripts/check-new-cross-file-line-citations.mjs new file mode 100644 index 0000000000..2bb921a795 --- /dev/null +++ b/scripts/check-new-cross-file-line-citations.mjs @@ -0,0 +1,595 @@ +#!/usr/bin/env node +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The DIFFERENTIAL cross-file line-address citation gate (objectui#8875). + * + * Run: node scripts/check-new-cross-file-line-citations.mjs + * node scripts/check-new-cross-file-line-citations.mjs --base + * node scripts/check-new-cross-file-line-citations.mjs --strict + * node scripts/check-new-cross-file-line-citations.mjs --json + * + * Exit: 0 = no new citation, or new citations while ENFORCEMENT is + * `report-only` + * 1 = new citations under blocking enforcement, OR one of this gate's + * own controls failed + * 2 = PREREQUISITE NOT MET -- the base could not be resolved, so this + * run measured nothing. Never a silent zero. + * + * ## The ruling this implements, and the one word that decides its shape + * + * The maintainer ruled on 2026-09-10, verbatim and untranslated: + * + * 跨文件的「某文件第几行」引用, 这种完全没必要吧,是否应该避免 + * + * The direction taken from it (objectui#8875, comment 5617612419) has five + * clauses. Clause 2 is this file, and its load-bearing word is DIFFERENTIAL: + * + * > 相对 base 新增的跨文件 `path:line` 形引用(卡面测过的四种语法 + 无文件名 + * > 的续写地址)即红;存量 540 条不作为门的分母,⛔ 不搭车清扫。指向重生成 + * > 文件(`dist/*.d.ts`)的记 unresolvable,不计。 + * + * ⛔ AN ABSOLUTE COUNT WAS EXPLICITLY REFUSED, and not on taste. It was refused + * on a measurement: PR #8887's line shifts flipped one citation from `drifted` + * to `resolves` BY ACCIDENT, moving the tree-wide false count 540 -> 539, and + * the executing seat had to name that in its own pull request body as an + * UNEARNED GREEN that belonged to nobody. A gate reading an absolute number + * scores that as progress. So this gate never reads a total: it reads what THIS + * BRANCH ADDED, and the existing citations are ⛔ not its denominator and ⛔ not + * swept in by it. + * + * ## What is NEW, and why identity is content-shaped rather than positional + * + * A citation is identified, WITHIN ONE CITING FILE, by + * + * syntax | the written path | the cited line number + * + * and compared as a MULTISET between the base blob and the head text of that + * same file. The citing file's OWN line number is deliberately not part of the + * identity, and neither is the prose around it: + * + * - if it were, re-indenting a file, re-wrapping a paragraph or adding an + * import above would report every citation below as newly added. That is + * the positional fragility this whole card is about, rebuilt inside the + * instrument meant to measure it. + * - a citation whose CITED line number changes (`:112` edited to `:113`) IS + * new, and that is correct rather than a side effect: clause 4 repairs an + * existing address by converting it to a content anchor, never by moving + * the number to a different number. + * + * Renames are followed (`git diff -M`), so moving a file does not report its + * citations as freshly written. + * + * ## The population, inherited rather than re-implemented + * + * The scanner, the five syntaxes, the released-CHANGELOG carve-out, the + * same-file exclusion and the four verdicts all come from + * `cross-file-line-citation-census.mjs` BY IMPORT. This repository treats a + * second copy of a reader as a defect: two readers over one population is how + * the two answers start disagreeing, and the census is already the measured + * one. This file adds exactly one thing to it -- the base comparison. + * + * ⇒ everything the census excludes, this gate excludes: + * + * - SAME-FILE citations. objectui#8047's carve-out reasoning ("a human reads + * them beside the code they annotate") still holds there, and the ruling + * says so in as many words: 同文件行号照 objectui#8047 的既有豁免. + * - RELEASED CHANGELOG SECTIONS. A changelog entry is a dated record of what + * was true at that release; clause 4 says they are ⛔ never re-addressed. + * - citations reaching a TEST NAME, which objectui#8047's ESLint rule owns at + * `error`. Counted and printed separately so an overlap is visible rather + * than silent. + * + * ## `unresolvable` is a THIRD answer, and it must never collapse into `false` + * + * The ruling: 指向重生成文件(`dist/*.d.ts`)的记 unresolvable,不计. A citation + * into a build artifact is untracked and regenerated -- no instrument can ever + * decide it, so calling it false would be an assertion, and calling it true + * would be worse. The census reports that class under `no-such-file` and keeps + * it out of its false count; this gate keeps the same three-way split and + * asserts it in `SYNTHETIC_CASES` below, because an earlier census reported it + * correctly and a regression here would be invisible. + * + * ⚠️ `unresolvable` is a VERDICT, not an exemption. A newly written citation is + * a finding whatever it resolves to -- the convention is that the address is + * not written, not that it is written accurately. The verdict column says what + * a reader would find if they followed it. + * + * ## Enforcement: report-only first, and what flips it + * + * The ruling: report-only 起步,零新增后翻阻断. `ENFORCEMENT` below is the whole + * switch, and flipping it is a one-line change plus the pin in this gate's test + * that reads it. It ships `report-only` so that pull requests already in flight + * are not failed by a rule whose convention text (clause 1, `AGENTS.md`) has + * not landed yet -- that document is a governed surface and is a separate, + * human-merged pull request. + * + * ⛔ Report-only does NOT mean "cannot fail". A control failure exits 1 in + * either mode: a differential gate that reports zero because its differ is + * broken is indistinguishable from a clean branch, which is the exact failure + * this card exists to name one level up. Hence `SYNTHETIC_CASES`. + * + * ## The controls, printed on every run, fatal in both modes + * + * The firing control cannot be a citation that already exists in the tree: a + * differential gate is blind to those by construction, which is the point of + * it. It is therefore SYNTHETIC -- a base text and a head text handed to the + * same differ the real run uses. Four cases, each pinning a different way this + * gate could be wrong: + * + * FIRES a cross-file address added to the head text is reported. + * DOES NOT FIRE the same citation present in both, with the citing prose + * moved down the file, is NOT reported. This is the + * anti-absolute-count control and the reason the gate exists in + * this shape. + * UNRESOLVABLE an added citation into `packages/types/dist/*.d.ts` is + * reported NEW with verdict `unresolvable`, never `false`. + * SAME-FILE an added citation into the citing file itself is not in the + * population at all. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { + FALSE_VERDICTS, + SELF_FILES, + UNJUDGED_VERDICTS, + anchorsFor, + judge, + scanFile, +} from './cross-file-line-citation-census.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +/** + * `report-only` | `blocking`. THE flip, and the only one. + * + * ⚠️ Changing this value changes whether a pull request can be merged, so it is + * a decision and not a tidy-up. `check-new-cross-file-line-citations.test.ts` + * reads it and states the landed state in its own assertion, so the flip cannot + * happen without the pin moving with it, and the row in + * `content/docs/guide/ci-cd-pipeline.md` says report-only in prose. + * + * WHAT FLIPS IT (objectui#8875, ruling clause 2): once this gate has read ZERO + * new citations across the in-flight population -- i.e. it is no longer + * reporting findings that belong to branches written before the convention -- + * and clause 1's convention text has landed in `AGENTS.md`, so an author who is + * failed by it has a document to be failed against. + */ +export const ENFORCEMENT = 'report-only'; + +/** + * The instrument may not count itself. ⛔ Re-exported from the census, ⛔ never + * re-declared: one population, one carve-out list. A second copy drifts, and a + * file carved out of one reader while counted by the other makes the two + * disagree with nothing anywhere reporting it. + */ +export { SELF_FILES }; + +/** Extensions this gate reads, kept in step with the census's own whitelist. */ +const SCANNED_EXT = new Set([ + 'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', + 'json', 'jsonc', 'md', 'mdx', 'yml', 'yaml', 'txt', + 'css', 'scss', 'html', 'vue', 'svelte', 'sh', 'py', 'toml', +]); + +/** Generated or vendored text nothing authors by hand. */ +const SKIP_FILES = new Set(['pnpm-lock.yaml', 'skills-lock.json']); + +export const EXIT_CODES = { ok: 0, newCitations: 1, controlFailed: 1, couldNotRun: 2 }; + +/** + * The identity of one citation inside one citing file. ⛔ Never includes the + * citing line number -- see the header on why positional identity would rebuild + * the defect inside the instrument. + */ +export function citationKey(hit) { + return `${hit.syntax}|${hit.citedWritten}|${hit.citedLine}`; +} + +/** + * The multiset difference for ONE file: the hits present in `headText` beyond + * those already present in `baseText`. + * + * Pure, so the synthetic controls and the real run go through the same code. + * A file absent from the base (added, untracked, renamed from nothing) is + * handed `''` and every hit in it is new. + */ +export function newCitationsIn({ relPath, baseText, headText }) { + const head = scanFile(relPath, headText); + const base = scanFile(relPath, baseText ?? ''); + + const budget = new Map(); + for (const hit of base.hits) { + const key = citationKey(hit); + budget.set(key, (budget.get(key) ?? 0) + 1); + } + + const added = []; + for (const hit of head.hits) { + const key = citationKey(hit); + const left = budget.get(key) ?? 0; + if (left > 0) { + budget.set(key, left - 1); + continue; + } + added.push({ ...hit, anchors: anchorsFor(head.lines, hit.line - 1, hit.citedWritten) }); + } + return added; +} + +/** + * The verdict a reader would reach by following one citation, plus the class it + * is reported under. Three classes, and the third is the one that must never + * collapse into the second. + */ +export function classify(verdict) { + if (verdict === 'resolves') return 'resolving'; + if (FALSE_VERDICTS.has(verdict)) return 'false'; + if (UNJUDGED_VERDICTS.has(verdict)) return 'unresolvable'; + return 'unresolvable'; +} + +/** + * The four synthetic control cases. Each is a base text and a head text for one + * notional citing file; the differ that judges them is the one the real run + * uses, so a differ that stopped working fails here before it reports a clean + * branch. + */ +export const SYNTHETIC_CASES = [ + { + id: 'fires', + why: 'a cross-file address added by the head text must be reported', + relPath: 'packages/example/src/notes.ts', + baseText: '// the action vocabulary is declared by `ActionDef`\n', + headText: + '// the action vocabulary is declared at packages/core/src/actions/ActionRunner.ts:112\n', + want: (added) => added.length === 1 && added[0].citedLine === 112, + describe: (added) => added.map((a) => `${a.citedWritten}:${a.citedLine}`).join(', ') || '(none)', + }, + { + id: 'does-not-fire-on-a-moved-citing-line', + why: + 'the same citation, with its citing prose pushed down the file, is NOT new -- this is the ' + + 'control that separates a differential gate from the absolute count the ruling refused', + relPath: 'packages/example/src/notes.ts', + baseText: '// see packages/core/src/actions/ActionRunner.ts:112\n', + headText: + "import { x } from './x';\nimport { y } from './y';\n\n// see packages/core/src/actions/ActionRunner.ts:112\n", + want: (added) => added.length === 0, + describe: (added) => added.map((a) => `${a.citedWritten}:${a.citedLine}`).join(', ') || '(none)', + }, + { + id: 'unresolvable-is-not-false', + why: + 'a citation into a regenerated build artifact is reported NEW with verdict `unresolvable`; ' + + 'the ruling records that class and ⛔ never counts it false', + relPath: 'packages/example/src/notes.ts', + baseText: '', + headText: '// the emitted shape is at packages/types/dist/overlay.d.ts:334\n', + want: (added) => added.length === 1 && added[0].citedWritten.includes('dist/'), + describe: (added) => added.map((a) => `${a.citedWritten}:${a.citedLine}`).join(', ') || '(none)', + }, + { + id: 'same-file-is-not-in-the-population', + why: + "a citation into the citing file itself keeps objectui#8047's carve-out and is out of scope", + relPath: 'packages/example/src/notes.ts', + baseText: '', + headText: '// see notes.ts:12 for the other half\n', + want: (added, verdicts) => added.length === 1 && verdicts[0] === 'same-file', + describe: (added, verdicts) => verdicts.join(', ') || '(none)', + }, +]; + +/** + * The judge the controls run under: HERMETIC, over an index that contains only + * the cases' own notional file. + * + * ⚠️ Deliberately not the live tree. A control that resolves against + * `git ls-files` is a control that rots when the tree moves, and it answers a + * different question in a scratch repository than it does here — which would + * make this gate report "instrument broken" wherever it is not run from its own + * checkout. These four cases are about the DIFFER and the CLASSIFIER, so they + * are judged against a fixed index and depend on nothing outside this file. + */ +export function hermeticJudge(hit) { + const index = new Map([['notes.ts', ['packages/example/src/notes.ts']]]); + return judge(hit, '/nonexistent-control-root', index, new Map()); +} + +/** + * Runs every synthetic case through the real differ. `judgeFor` is injectable so + * a test can show that a judge which lies fails the controls rather than passing + * them; the real run uses the hermetic one. + */ +export function evaluateSyntheticCases(judgeFor = hermeticJudge) { + return SYNTHETIC_CASES.map((c) => { + const added = newCitationsIn({ relPath: c.relPath, baseText: c.baseText, headText: c.headText }); + const verdicts = added.map((hit) => judgeFor(hit).verdict); + let ok = false; + let detail = ''; + try { + ok = c.want(added, verdicts) === true; + detail = c.describe(added, verdicts); + } catch (error) { + ok = false; + detail = `threw: ${error instanceof Error ? error.message : String(error)}`; + } + return { id: c.id, why: c.why, ok, detail }; + }); +} + +/** Every tracked path at HEAD, as git sees them. */ +function trackedFiles(root) { + const out = execFileSync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + return out.split('\0').filter(Boolean); +} + +/** + * The base this branch is measured against, and the merge base with it. + * + * ⚠️ Resolution FAILS LOUDLY. A gate that cannot compute its base has measured + * nothing, and "nothing found" is the one answer it must never print + * (objectstack#4928 named the direction: a swallowed diff failure is + * indistinguishable from a clean tree). + */ +export function resolveBase(root, argv, env) { + const flagAt = argv.indexOf('--base'); + const explicit = + flagAt >= 0 && argv[flagAt + 1] + ? argv[flagAt + 1] + : env.CITATION_GATE_BASE || + (env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}` : '') || + 'origin/main'; + try { + const mergeBase = execFileSync('git', ['merge-base', explicit, 'HEAD'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + return { ref: explicit, mergeBase, error: null }; + } catch (error) { + return { + ref: explicit, + mergeBase: null, + error: error instanceof Error ? error.message.split('\n')[0] : String(error), + }; + } +} + +/** + * `head path -> base path` for everything this branch touched, plus the set of + * paths with no base blob at all. + * + * The diff is taken against the WORKING TREE, not `HEAD`: this gate is most + * useful before a commit exists, and a reading that ignored uncommitted edits + * would tell an author their new citation is fine right up until CI disagrees. + * Untracked files are collected separately for the same reason. + */ +export function changedPaths(root, mergeBase) { + const status = execFileSync( + 'git', + ['diff', '--name-status', '-M', '--diff-filter=ACMRT', mergeBase, '--'], + { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, + ); + const map = new Map(); + for (const line of status.split('\n')) { + if (!line.trim()) continue; + const parts = line.split('\t'); + const code = parts[0]; + if (code.startsWith('R') && parts.length >= 3) map.set(parts[2], parts[1]); + else if (code.startsWith('A')) map.set(parts[1], null); + else map.set(parts[1], parts[1]); + } + const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + .split('\0') + .filter(Boolean); + for (const path of untracked) if (!map.has(path)) map.set(path, null); + return map; +} + +/** The blob at `rev:path`, or `null` when the path did not exist there. */ +function blobAt(root, rev, path) { + if (path === null) return null; + try { + return execFileSync('git', ['show', `${rev}:${path}`], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } +} + +function scannable(relPath) { + const ext = relPath.includes('.') ? relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase() : ''; + if (!SCANNED_EXT.has(ext)) return false; + if (SKIP_FILES.has(basename(relPath))) return false; + if (SELF_FILES.has(relPath)) return false; + return true; +} + +function main(argv, env = process.env) { + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const head = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + const asJson = argv.includes('--json'); + const blocking = argv.includes('--strict') || ENFORCEMENT === 'blocking'; + + const base = resolveBase(root, argv, env); + if (base.mergeBase === null) { + console.error( + `PREREQUISITE NOT MET -- no merge base between \`${base.ref}\` and HEAD (${base.error}).`, + ); + console.error( + ' This run measured NOTHING. It is not a green, and it is not a red about any citation.', + ); + console.error( + ' In CI the checkout needs `fetch-depth: 0`; locally, `git fetch origin main` first, or', + ); + console.error(' name a base explicitly with `--base `.'); + return EXIT_CODES.couldNotRun; + } + + const index = new Map(); + for (const path of trackedFiles(root)) { + const name = basename(path); + if (!index.has(name)) index.set(name, []); + index.get(name).push(path); + } + const fileCache = new Map(); + const judgeFor = (hit) => judge(hit, root, index, fileCache); + + const controls = evaluateSyntheticCases(); + + const touched = changedPaths(root, base.mergeBase); + const scanned = []; + const added = []; + for (const [headPath, basePath] of touched) { + if (!scannable(headPath)) continue; + if (!existsSync(join(root, headPath))) continue; + let headText; + try { + headText = readFileSync(join(root, headPath), 'utf8'); + } catch { + continue; + } + scanned.push(headPath); + const baseText = blobAt(root, base.mergeBase, basePath); + for (const hit of newCitationsIn({ relPath: headPath, baseText, headText })) { + added.push({ ...hit, ...judgeFor(hit) }); + } + } + + const sameFile = added.filter((row) => row.verdict === 'same-file'); + const population = added.filter((row) => row.verdict !== 'same-file'); + const inTestName = population.filter((row) => row.inTestName); + const findings = population.filter((row) => !row.inTestName); + const byClass = { false: 0, resolving: 0, unresolvable: 0 }; + for (const row of findings) byClass[classify(row.verdict)] += 1; + + const failedControls = controls.filter((c) => !c.ok); + + if (asJson) { + console.log( + JSON.stringify( + { + head, + base: base.ref, + mergeBase: base.mergeBase, + enforcement: blocking ? 'blocking' : ENFORCEMENT, + scannedFiles: scanned.length, + newCitations: findings.length, + byClass, + excludedSameFile: sameFile.length, + excludedTestName: inTestName.length, + controls, + rows: findings.map((row) => ({ + file: row.file, + line: row.line, + syntax: row.syntax, + cited: `${row.citedWritten}:${row.citedLine}`, + verdict: row.verdict, + class: classify(row.verdict), + text: row.text, + })), + }, + null, + 2, + ), + ); + } else { + console.log( + `# New cross-file line-address citations -- objectui#8875 clause 2 (HEAD ${head})\n`, + ); + console.log(`Base : ${base.ref}`); + console.log(`Merge base : ${base.mergeBase}`); + console.log(`Enforcement : ${blocking ? 'blocking' : ENFORCEMENT}`); + console.log(`Files compared : ${scanned.length} (this branch's own diff, not the tree)\n`); + + console.log('## Controls -- synthetic, and fatal in BOTH modes\n'); + for (const control of controls) { + console.log(`${control.ok ? 'PASS' : 'FAIL'} ${control.id}: ${control.detail}`); + console.log(` why: ${control.why}`); + } + console.log(''); + + console.log('## The number\n'); + console.log(`Cross-file line-address citations ADDED by this branch : ${findings.length}`); + console.log(` of which FALSE against today's tree : ${byClass.false}`); + console.log(` of which resolve to their content : ${byClass.resolving}`); + console.log(` of which are UNRESOLVABLE (regenerated / ambiguous) : ${byClass.unresolvable}`); + console.log(`Excluded, added same-file citations (objectui#8047) : ${sameFile.length}`); + console.log(`Excluded, added citations reaching a test name : ${inTestName.length}`); + console.log( + '\n⛔ The existing citations in this tree are NOT this gate\'s denominator and are ⛔ not', + ); + console.log( + ' swept in by it: shifting an already-false address by a hunk delta moves a wrong', + ); + console.log(' pointer to a differently wrong place while making the diff look diligent.\n'); + + if (findings.length > 0) { + console.log('## What this branch added\n'); + for (const row of findings) { + console.log( + ` ${row.file}:${row.line} adds ${row.citedWritten}:${row.citedLine} ` + + `[${row.syntax}] -> ${classify(row.verdict)} (${row.verdict})`, + ); + console.log(` ${row.text}`); + } + console.log(''); + console.log('Cite the thing by CONTENT instead -- the symbol name, the anchor text, the'); + console.log('test name. A cross-file line address is not read beside the code it annotates,'); + console.log('nothing puts the cited line in front of the reader, and nothing tells them it'); + console.log('moved (objectui#7853, objectui#8875).\n'); + } + } + + if (failedControls.length > 0) { + console.error( + `VERDICT new-cross-file-line-citations: ${failedControls.length} CONTROL(S) FAILED -- ` + + 'this run is NOT a reading -> exit 1', + ); + return EXIT_CODES.controlFailed; + } + if (findings.length > 0 && blocking) { + console.error( + `VERDICT new-cross-file-line-citations: ${findings.length} new citation(s), enforcement ` + + 'blocking -> exit 1', + ); + return EXIT_CODES.newCitations; + } + // Every VERDICT line goes to stderr, so `--json` leaves stdout parseable and a + // caller never has to choose between reading the machine answer and reading + // the one line that says which of the exit codes this was. + console.error( + `VERDICT new-cross-file-line-citations: ${findings.length} new citation(s), enforcement ` + + `${blocking ? 'blocking' : ENFORCEMENT} -> exit 0`, + ); + return EXIT_CODES.ok; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/cross-file-line-citation-census.mjs b/scripts/cross-file-line-citation-census.mjs index 1e3bb1cd64..e4a3c3c977 100644 --- a/scripts/cross-file-line-citation-census.mjs +++ b/scripts/cross-file-line-citation-census.mjs @@ -230,10 +230,23 @@ const SCANNED_EXT = new Set([ /** Generated or vendored text nothing authors by hand. */ const SKIP_FILES = new Set(['pnpm-lock.yaml', 'skills-lock.json']); -/** The instrument may not count itself: both files carry addresses as fixtures. */ -const SELF_FILES = new Set([ +/** + * The instrument may not count itself: every file here carries addresses as + * FIXTURE DATA -- controls, worked examples, the shapes a syntax must and must + * not match. Counting them would be the instrument reading itself. + * + * ⚠️ It is one list for BOTH readers. The differential gate + * (`check-new-cross-file-line-citations.mjs`, objectui#8875 clause 2) imports + * this set rather than keeping a second copy: two carve-out lists over one + * population drift, and the direction they drift in is silent -- a file carved + * out of one reader and counted by the other makes the two answers disagree + * with no error anywhere. + */ +export const SELF_FILES = new Set([ 'scripts/cross-file-line-citation-census.mjs', 'scripts/__tests__/cross-file-line-citation-census.test.ts', + 'scripts/check-new-cross-file-line-citations.mjs', + 'scripts/__tests__/check-new-cross-file-line-citations.test.ts', ]); /** @@ -729,7 +742,7 @@ function main(argv) { console.log(''); console.log(`Excluded, same-file citations (#8047's carve-out still holds) : ${sameFile.length}`); console.log(`Excluded, released CHANGELOG sections (dated records) : ${carvedOut.length}`); - console.log(`Excluded, this census and its own test (fixture addresses) : ${selfCarved}`); + console.log(`Excluded, the two citation readers and their tests (fixtures): ${selfCarved}`); console.log(`Reaching a test name (objectui#8047's rule owns these) : ${inTestName.length}`); console.log(" ^ this zero is a reading only because the classifier controls above fired."); console.log(''); diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 52a7f5294b..6db96a58fc 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -242,6 +242,8 @@ export const NOT_A_GATE = Object.freeze({ "lockfile-integrity.yml (objectui#8326) reports a lockfile DELTA — an `@objectstack/*` identity moving backward, or a workspace-declared dependency gaining a physical copy. ⛔ It is deliberately NOT a blocking context: enrolling it changes what stops the merge queue, which is a maintainer decision the #8326 dispatch reserved rather than took, and its pull request writes up the cost as input (measured: it would have blocked 3 of the 40 most recent lockfile-changing commits on `main`, each for a real duplication of a runtime-declared package). Its pull_request trigger is also path-filtered to `pnpm-lock.yaml` and its own two files, so it cannot be REQUIRED under #3523's rule while that filter stands — promoting it means removing the filter as well.", 'Live half-state sweep': 'half-state-patrol.yml is REPORT-ONLY by ruling (objectui#5791): a completed sweep exits 0 whether it found 0 half-states or 40, and the job gates no branch and blocks no queue. It goes red only when the sweep could not RUN — the patrol reporting its own death, which is a fact about the patrol, not a verdict on the pull request. Its pull_request trigger is also path-filtered to the sweeper and the workflow, so a Dependabot bump never produces this check at all.', + 'Line Citation Gate': + 'line-citation-gate.yml is REPORT-ONLY by ruling (objectui#8875, clause 2): it prints the cross-file line-address citations a pull request ADDED against its base and exits 0 whatever it finds, so requiring it would enrol a check that cannot say no. It goes red only when one of its own synthetic controls fails — the differ reporting its own death, which is a fact about the instrument and not a verdict on the pull request. It also declares NO `merge_group` trigger, because it needs a base to be differential at all and only a pull request has one; under #3523 a required context that never reports on a queue build stalls the queue until the ruleset timeout fails it, so promoting this one means giving it a queue leg first. That promotion is the flip condition the ruling states, and a maintainer decision, ⛔ not a tidy-up.', }); /**