From 02e8874aa9574eb779eb478443cb5e192fc0db38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:29:13 +0000 Subject: [PATCH 1/4] wip(pm): widening-tell gate, self-test green Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX --- scripts/pm/check-widening-tells.mjs | 976 ++++++++++++++++++++++++++++ 1 file changed, 976 insertions(+) create mode 100644 scripts/pm/check-widening-tells.mjs diff --git a/scripts/pm/check-widening-tells.mjs b/scripts/pm/check-widening-tells.mjs new file mode 100644 index 0000000000..40decab931 --- /dev/null +++ b/scripts/pm/check-widening-tells.mjs @@ -0,0 +1,976 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-widening-tells — the MECHANICAL half of the directional clause-② + * ruling (#16349, decision batch #62): a diff that ADDS a key, an arm, an + * export or a registration while its card's claim reads `Clause-②: no` is + * refused at enqueue, with the file:line of the tell (#16448). + * + * node scripts/pm/check-widening-tells.mjs --self-test + * node scripts/pm/check-widening-tells.mjs --declaration no --diff /tmp/pr.diff + * node scripts/pm/check-widening-tells.mjs --declaration no --files /tmp/files.json + * git diff origin/main...HEAD | node scripts/pm/check-widening-tells.mjs --declaration no --diff - + * + * ## Why this file exists at all + * + * Clause ② used to be judged as a two-sided question ("does this card touch + * the contract?"), and #16349 made it DIRECTIONAL: widening the accept set or + * the public surface triggers the contract-review tier; pulling code back to + * the declared contract does not. The maintainer's condition on that + * relaxation was explicit — **the direction claim becomes checkable instead of + * trusted**. A direction nobody can check is a self-declaration, and a + * self-declaration that only ever loosens the tier is the one shape the whole + * clause-② chain is written against. + * + * `dispatch-gates.mjs`'s `SUSPECT_TIER_GLOBS` already says where the check + * belongs, in its own words: "whichever tier is dispatched, the PR's ACTUAL + * diff passes the clause-② enqueue gate before the card may enqueue — the diff + * is a fact; the card's semantics were a prediction. The gate itself lives in + * the PM skill (入队与落地); this output only points at it." Until this file, + * that gate was a human reading. This is it, mechanized. + * + * ## The four tells, and what each one is a tell OF + * + * A TELL, never a proof (#16448 states this as a prohibition, so it is stated + * here as one too). Each tell is a syntactic shape that a WIDENING diff + * normally has and a narrowing diff normally does not: + * + * T1 a new key on a Zod object schema on the contract source surface — the + * accept set gains a spelling an author may now write. + * T2 a new member of a closed set: `z.enum([…])`, `z.union([…])`, + * `z.discriminatedUnion(…)`, or a `CORE_PLUGIN_TYPES`-shaped `as const` + * array — the accept set gains a VALUE. + * T3 a new row in a published entry point's export listing + * (`packages/spec/api-surface/*.json` and its signatures sibling) — the + * PUBLIC SURFACE grows, which ADR-0059's backward-compatibility gate + * already treats as the breadth half of a contract change. + * T4 a new registration in a registry / catalog — the error-code ledger, + * the dispatcher vocabulary, the metadata form registry. A registration + * widens what the runtime will ACCEPT without any schema file moving. + * + * ## What it deliberately does NOT do + * + * **It does not judge narrowings.** A removal-only diff with `Clause-②: no` + * passes, and so does a tightened `.refine(…)`: a tell is an ADDITION shape and + * this file reads added lines only. That is the ruling's own direction, not a + * gap — a narrowing claim that is wrong is a different card's problem. + * + * **It never blocks a `yes`.** `Clause-②: yes` already routes to contract + * review, so a tell on top of it adds nothing to decide. Refusing a `yes` would + * make the honest declaration the expensive one, which is how a gate teaches + * people to declare `no`. + * + * **False positives are the accepted cost; false negatives are the ruling's.** + * #16448 fixes both directions: "False positives are acceptable (the author + * re-declares or explains); false negatives are the cost the ruling accepted." + * So a fixture whose added line merely LOOKS like a schema key is refused, and + * the remedy is one word in the claim comment — never a weakened rule here. + * + * **It writes nothing and hangs no label.** Same call `check-clause2-carriers` + * and `check-half-states` make: a checker that hung `needs:contract-review` + * would be issuing the review verdict, which is 自查放行. ⛔ No new label and no + * new claim-line syntax exist because of this file — #16448 forbids both, and + * the reader it uses is the sibling's existing `Clause-②:` reader. + * + * ## Where the surfaces come from — imported, never hand-copied + * + * The contract SOURCE surface is `SUSPECT_TIER_GLOBS`, imported from + * `dispatch-gates.mjs`. It is declared once there and a second spelling here + * would be the hand-copied register `check:pm-governed-prose` exists to stop + * one family over — and the two would then disagree about the same path on the + * day one of them moved. + * + * The PUBLISHED surface is derived from `REGEN_ARTIFACTS` in + * `scripts/regen-artifacts.mjs` — the rows whose `check` is + * `check:api-surface`. Same reason: that table already owns the answer to + * "which committed files ARE the published export listing", it is guarded by + * its own gate, and a shard added there (#5837 sharded this surface once + * already) reaches this gate without an edit. + * + * `REGISTRATION_SURFACES` below is the ONE table this file declares itself, + * because no register in the tree carries it: "which files are closed-vocabulary + * registries" is not a question `regen-artifacts` or the tier globs answer. It + * is kept from rotting the same way `MANDATORY_TIER_GLOBS` is — every row must + * name a path that EXISTS in this tree, asserted in `--self-test`, which CI + * runs. A renamed registry leaves dead data that guards nothing while reading + * as protection, and that is the incident class itself. + * + * ⚠️ Measured 2026-09-07, and recorded because the card names it: the + * "renderer registry" of #16448's tell list has NO implementation in this repo + * — `RendererRegistry` appears only in ADR-0012's notification-platform table. + * The nearest live shape is `METADATA_FORM_REGISTRY` + * (`packages/spec/src/system/metadata-form-registry.ts`), whose own docblock + * calls it the "canonical registry of FormView layouts" consumed by "the + * generic SchemaForm renderer", so that is the row declared. ⛔ A row for a + * path that does not exist was NOT written: a declared-but-absent glob is the + * dead data the existence guard exists against. + * + * ## The objectui mirror, and why its row is repo-keyed + * + * #16448 scopes T1/T2 to `packages/spec/src/**` "(or the objectui mirror + * equivalents when run there)". objectui's mirror is `packages/types/src/zod/**` + * (measured 2026-09-07: `app.zod.ts`, `blocks.zod.ts`, `form.zod.ts`, + * `navigation.zod.ts`, `theme.zod.ts` all live there). This script ships only + * in THIS repo's `scripts/pm/`, so that glob would be dead data here — hence + * the `repo` key: a surface row applies to the repo it names, the existence + * guard only runs over rows applicable to the local tree, and porting the gate + * is a data edit rather than a rewrite. ⛔ The mirror row is not a claim that + * anything runs this gate in objectui today; nothing does. + * + * ## Exit codes — one register, shared with the sibling + * + * 0 no tell, or the declaration is not `no` (a `yes` is never blocked here, + * and an UNREADABLE declaration is `check-clause2-carriers`'s C2 row, not + * this file's verdict to issue). + * 1 bad usage — the input could not be formed, so nothing was judged. + * 2 INCOMPLETE — a file on a tell surface arrived with no patch to read + * (binary, truncated by the API, or a document that omitted it). An + * unread diff is NOT a clean diff (#4690), and this is the one exit that + * must never be mistaken for 0. + * 4 REFUSED — a widening tell with `Clause-②: no`. + * + * The values are pinned equal to `check-clause2-carriers`'s in THAT file's + * self-test (it imports this one, so the pin is written on the importing side + * and no cycle is created): a seat reading `$?` reads one table, not two. + * + * ## The caller + * + * `check-clause2-carriers.mjs --pair N` — the enqueue-path predicate a seat + * runs before it may hand a pair to the queue (`references/contract-review.md`, + * landing pre-check ②). That call pays ONE extra request, and only for a pair + * whose declaration reads `no`; the report-only board SWEEP deliberately does + * not pay it (a 29-PR sweep already costs about GitHub's whole documented + * anonymous hourly budget, and a board fact is not a fact about whichever PR + * runs CI next). + * + * The `check:pm-widening-tells` step in `lint.yml` runs the SELF-TEST only, for + * the reason its two neighbours record: this gate's verdict is about ONE pair's + * diff, and failing an unrelated PR's CI over it would punish the wrong actor. + */ + +// dispatch-gates: no-path-population -- this gate reads no file in the tree as its input: it judges a PR's DIFF, supplied by its caller from the API or from `git diff`, so no card's file surface can predict it. The path globs in its module body are patterns matched against diff filenames, never files it opens (#16448) + +import process from 'node:process'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; +import { SUSPECT_TIER_GLOBS, hintCovers } from './dispatch-gates.mjs'; +import { REGEN_ARTIFACTS } from '../regen-artifacts.mjs'; + +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); + +// -- The self-test's own battery roster and floor --------------------------- +// +// Same instrument the sibling carries, for the same reason: `failed.length === 0` +// alone cannot tell "every case held" from "the cases never ran". Every section +// opens with `battery('')`, every assertion is attributed to the battery +// most recently opened, and the floor requires the OPENED set to equal the +// DECLARED set with each battery at or above its own count. +// +// The counts are a FLOOR, not an equality — adding cases is ordinary work and +// must not red. A battery BELOW its floor means cases stopped running. +const SELF_TEST_BATTERIES = Object.freeze({ + 'the patch reader: added lines, and the line numbers they carry': 17, + 'the unified-diff splitter, for the local `git diff` path': 11, + 'the surfaces, imported rather than restated': 11, + 'T1 — a new key on a Zod object schema': 14, + 'T2 — a new member of a closed set': 13, + 'T3 — a new row in a published entry point': 8, + 'T4 — a new registration in a registry': 10, + '#16448 acceptance: the four positive controls, each with its file:line': 8, + '#16448 acceptance: the negative controls a widening gate must let through': 10, + 'the refusal sentence, and the two prohibitions it must keep': 8, + 'the exit register is distinct in every direction it must be': 6, + 'the declared registry rows still exist in this tree': 4, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 12; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 1; +export const EXIT_INCOMPLETE = 2; +/** A widening tell was found on a diff whose card declares `Clause-②: no`. */ +export const EXIT_REFUSED = 4; + +/** + * The sentence #16448 fixes, quoted from the card and NOT paraphrased. + * + * It is a constant because the whole point of the refusal is that the author + * knows the two ways out of it — re-declare, or explain — without reading this + * file. A row renders it once; ⛔ never a second wording per tell. + */ +export const REFUSAL_SENTENCE = + 'a widening tell with `Clause-②: no` — re-declare `yes` or explain in the claim why this ' + + 'addition does not widen'; + +// --------------------------------------------------------------------------- +// The surfaces +// --------------------------------------------------------------------------- + +/** This repo, as a surface row names it. Rows with no `repo` mean this one. */ +export const THIS_REPO = 'objectstack-ai/objectstack'; + +/** + * The contract SOURCE surface — T1 and T2 live here. + * + * Built from `SUSPECT_TIER_GLOBS`, imported. That table's own docblock calls + * `packages/spec/src/**` "the contract surface (error-code ledger, *.zod.ts + * contract schemas) — the normal landing zone of a clause-② card", which is + * exactly the population these two tells want, and it is declared THERE. + * + * The objectui mirror is repo-keyed; see the header for why it is declared but + * inert in this tree. + */ +export const CONTRACT_SOURCE_SURFACES = Object.freeze([ + ...SUSPECT_TIER_GLOBS.map((g) => Object.freeze({ glob: g.glob, repo: THIS_REPO, why: g.why, imported: 'SUSPECT_TIER_GLOBS' })), + Object.freeze({ + glob: 'packages/types/src/zod/**', + repo: 'objectstack-ai/objectui', + why: "objectui's mirror of the contract schemas (#16448: \"or the objectui mirror equivalents when run there\")", + imported: null, + }), +]); + +/** + * The PUBLISHED export surface — T3. + * + * Derived from the generated-artifact register, so a new shard reaches this + * gate without an edit here. `check:api-surface` is the discriminator because + * it is what the register itself uses to name this artifact family. + */ +export const PUBLISHED_SURFACES = Object.freeze( + REGEN_ARTIFACTS.filter((row) => row.check === 'check:api-surface').map((row) => + Object.freeze({ glob: row.path, repo: THIS_REPO, why: `the ${row.check} artifact family`, imported: 'REGEN_ARTIFACTS' }), + ), +); + +/** + * The REGISTRY surface — T4. The one table this file declares itself. + * + * Every row must name a path that EXISTS in this tree (asserted in + * `--self-test`); see the header for why a row for #16448's "renderer registry" + * was not written, and what was written in its place. + */ +export const REGISTRATION_SURFACES = Object.freeze([ + Object.freeze({ + glob: 'packages/spec/src/api/error-code-ledger.zod.ts', + repo: THIS_REPO, + why: 'ERROR_CODE_LEDGER — a registered code is one `ApiErrorSchema.code` accepts, so a row here widens the wire vocabulary (ADR-0112 D3)', + imported: null, + }), + Object.freeze({ + glob: 'packages/runtime/src/dispatcher-error-vocabulary.ts', + repo: THIS_REPO, + why: "the dispatcher vocabulary's declaration half — the classification ledger check:dispatcher-error-vocabulary reconciles against the scan", + imported: null, + }), + Object.freeze({ + glob: 'packages/spec/src/system/metadata-form-registry.ts', + repo: THIS_REPO, + why: 'METADATA_FORM_REGISTRY — the canonical FormView registry the generic SchemaForm renderer reads; the live shape nearest #16448\'s "renderer registry"', + imported: null, + }), +]); + +/** Every declared surface, in one list, so a caller can render the whole set. */ +export const ALL_SURFACES = Object.freeze([ + ...CONTRACT_SOURCE_SURFACES, + ...PUBLISHED_SURFACES, + ...REGISTRATION_SURFACES, +]); + +/** + * Do any of these surface rows cover `filename`, for a run against `repo`? + * + * A row whose `repo` is not the run's repo is INERT — not a miss to be + * explained, simply not this repo's surface. `hintCovers` is the sibling + * family's one path matcher (imported), so a glob gets segment-boundary + * semantics rather than string-prefix ones for free. + */ +export function surfaceCovers(surfaces, filename, repo = THIS_REPO) { + if (typeof filename !== 'string' || filename === '') return false; + return surfaces.some((s) => (s.repo == null || s.repo === repo) && hintCovers(s.glob, filename)); +} + +/** A source file the tells read — ⛔ never a test, which declares no contract. */ +export function isContractSourceFile(filename) { + return /\.(?:ts|mts|cts)$/.test(filename) && !/\.(?:test|spec|pin\.test)\.[cm]?ts$/.test(filename); +} + +// --------------------------------------------------------------------------- +// Reading a patch +// --------------------------------------------------------------------------- + +const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/; + +/** + * The ADDED lines of one patch, each with its line number in the NEW file. + * + * The line number is the whole reason this is not a `split('\n').filter()`: the + * card requires a file:line on every refusal, and a refusal an author cannot + * navigate to is a refusal they will argue with rather than fix. + * + * ⛔ Removed lines advance nothing and context lines advance by one — getting + * that backwards produces plausible numbers that point at the wrong line, which + * is worse than no number at all. Both directions are pinned in `--self-test`. + * + * @param {string|null|undefined} patch — a unified diff body, hunk headers + * included. GitHub's `/pulls/N/files` `patch` field is exactly this shape. + * @returns {{ line: number, text: string }[]} + */ +export function addedLines(patch) { + const out = []; + if (typeof patch !== 'string' || patch === '') return out; + let lineNo = 0; + let inHunk = false; + for (const raw of patch.split('\n')) { + const header = HUNK_HEADER.exec(raw); + if (header) { + lineNo = Number(header[1]); + inHunk = true; + continue; + } + // The file headers of a full `git diff` — `+++` must be tested BEFORE the + // `+` branch below, or every patch reports a phantom addition at line 1. + if (raw.startsWith('+++') || raw.startsWith('---') || raw.startsWith('diff --git')) continue; + if (raw.startsWith('\\')) continue; // "\ No newline at end of file" + if (!inHunk) continue; + if (raw.startsWith('+')) { + out.push({ line: lineNo, text: raw.slice(1) }); + lineNo += 1; + continue; + } + if (raw.startsWith('-')) continue; // removed: consumes no new-file line + lineNo += 1; // context (a leading space, and the empty trailing line) + } + return out; +} + +const DIFF_GIT = /^diff --git a\/(.+?) b\/(.+)$/; + +/** + * Split a whole `git diff` into the per-file rows this gate judges. + * + * This is the LOCAL read path — `git diff ...HEAD` — and it exists + * so a seat with no API budget can run the same predicate on the same bytes. + * The row shape is GitHub's (`filename`, `status`, `patch`) so nothing + * downstream can tell the two paths apart, which is what stops them drifting. + * + * A file with a `diff --git` header and no hunk yields `patch: null` — a + * binary or mode-only change, which is UNREAD rather than empty. The caller + * decides whether an unread file on a tell surface is a gap; ⛔ this function + * never turns one into a clean reading. + */ +export function splitUnifiedDiff(text) { + const rows = []; + if (typeof text !== 'string' || text.trim() === '') return rows; + let current = null; + const flush = () => { + if (!current) return; + const body = current.body.join('\n'); + const patch = HUNK_HEADER.test(body) || /\n@@ /.test(`\n${body}`) ? body : null; + // `additions` is carried so the local path answers "did this file add + // anything" in the SAME field the API path answers it in — the gap + // accounting below reads one field, not one per input path. + rows.push({ filename: current.filename, status: current.status, patch, additions: addedLines(patch).length }); + current = null; + }; + for (const raw of text.split('\n')) { + const head = DIFF_GIT.exec(raw); + if (head) { + flush(); + current = { filename: head[2], status: 'modified', body: [] }; + continue; + } + if (!current) continue; + if (raw.startsWith('new file mode')) current.status = 'added'; + else if (raw.startsWith('deleted file mode')) current.status = 'removed'; + else if (raw.startsWith('rename to ')) { + current.status = 'renamed'; + current.filename = raw.slice('rename to '.length).trim(); + } + current.body.push(raw); + } + flush(); + return rows; +} + +// --------------------------------------------------------------------------- +// The four tells +// --------------------------------------------------------------------------- + +/** A line that is prose inside the code — a tell never fires on a comment. */ +const COMMENT_LINE = /^[ \t]*(?:\/\/|\/\*|\*|#)/; + +/** + * T1 — a property whose value is a SCHEMA. + * + * Calibrated against the real tree rather than guessed (measured 2026-09-07 over + * `packages/spec/src/**`): 8,102 property lines take a `z.` value, and the + * whole non-`z.` schema vocabulary beneath them is `retiredKey(` (235), + * `I18nLabelSchema` and its `*Schema` siblings (≈300), `strictObject(` (46) and + * `lazySchema(`. Requiring a schema-shaped VALUE is what keeps the tell off the + * 1,655 `x: true` / 1,170 `x: string` lines that are object literals and type + * annotations, not accept-set members. + * + * ⛔ It does NOT verify the property sits inside a `z.object({` block. A hunk + * is a fragment — block state cannot be recovered from one honestly — and a + * reader that guessed would fail SILENTLY in the direction that matters. The + * card's own boundary applies: a tell, not a proof. + */ +const SCHEMA_PROPERTY = /^[ \t]*(?:'[^']+'|"[^"]+"|\[[^\]]+\]|[A-Za-z_$][\w$]*)[ \t]*\??[ \t]*:[ \t]*(?:z\.|lazySchema\(|strictObject\(|retiredKey\(|[A-Za-z_$][\w$]*Schema\b)/; + +/** T2 — a closed set DECLARED or re-written on one line. */ +const CLOSED_SET_OPENER = /z\.(?:enum|union|discriminatedUnion|literal)\(/; + +/** T2 — a bare string element of a multi-line `z.enum([…])` or `as const` array. */ +const BARE_STRING_ELEMENT = /^[ \t]*(?:'[^']*'|"[^"]*")[ \t]*,?[ \t]*(?:\/\/.*)?$/; + +/** T2 — a bare schema arm of a multi-line `z.union([…])`. */ +const BARE_SCHEMA_ARM = /^[ \t]*[A-Za-z_$][\w$]*Schema[ \t]*,[ \t]*(?:\/\/.*)?$/; + +/** T3 — a row of a published export listing: every entry is a JSON string. */ +const JSON_STRING_ROW = /^[ \t]*"/; + +/** + * T4 — a registration. + * + * Two shapes, because the three declared registries write entries two ways + * (measured 2026-09-07): a BARE element of a list — `'WORKFLOW_STEP_FAILED',` + * in `ERROR_CODE_LEDGER` — and a keyed member — `'@objectstack/rest': [` + * opening an owner's list, `code: 'X',` inside a dispatcher-vocabulary row, + * `workflow: workflowForm,` in the form registry. A structural line (`]`, + * `},`, `});`) matches neither, and comments are already excluded upstream. + */ +const REGISTRATION_ROW = + /^[ \t]*(?:'[^']*'|"[^"]*")[ \t]*,[ \t]*(?:\/\/.*)?$|^[ \t]*(?:'[^']+'|"[^"]+"|[A-Za-z_$][\w$]*)[ \t]*:[ \t]*\S/; + +/** + * Every tell one file's added lines carry. + * + * @param {{ filename?: string, status?: string, patch?: string|null }} file + * @param {{ repo?: string }} [opts] + * @returns {{ tell: string, file: string, line: number, text: string, why: string }[]} + */ +export function tellsInFile(file, { repo = THIS_REPO } = {}) { + const filename = String(file?.filename ?? ''); + if (filename === '') return []; + if (file?.status === 'removed') return []; // a deleted file adds nothing. + const rows = []; + const added = addedLines(file?.patch); + const onContractSource = surfaceCovers(CONTRACT_SOURCE_SURFACES, filename, repo) && isContractSourceFile(filename); + const onPublished = surfaceCovers(PUBLISHED_SURFACES, filename, repo); + const onRegistry = surfaceCovers(REGISTRATION_SURFACES, filename, repo); + for (const { line, text } of added) { + if (COMMENT_LINE.test(text)) continue; + const at = { file: filename, line, text: text.trim().slice(0, 160) }; + // A DECLARED registry is read as a registry first. Its files also sit on + // the contract source surface (two of the three live under + // `packages/spec/src/**`), and a ledger code read as "a member of a closed + // set" would be true but less useful than the reading that names the + // register it was added to. One line is one row, never one per surface. + if (onRegistry && REGISTRATION_ROW.test(text)) { + rows.push({ tell: 'T4', ...at, why: 'a new registration in a registry / catalog — what the runtime accepts grows with no schema file moving' }); + continue; + } + if (onContractSource && SCHEMA_PROPERTY.test(text)) { + rows.push({ tell: 'T1', ...at, why: 'a new key on a Zod object schema — the accept set gains a spelling an author may now write' }); + continue; + } + if (onContractSource && (CLOSED_SET_OPENER.test(text) || BARE_STRING_ELEMENT.test(text) || BARE_SCHEMA_ARM.test(text))) { + rows.push({ tell: 'T2', ...at, why: 'a new member of a closed set (z.enum / union / an `as const` array) — the accept set gains a value' }); + continue; + } + if (onPublished && JSON_STRING_ROW.test(text)) { + rows.push({ tell: 'T3', ...at, why: 'a new row in a published entry point\'s export listing — the public surface grows (ADR-0059)' }); + continue; + } + } + return rows; +} + +/** + * A file this gate had to read and could not. + * + * Only a file ON a tell surface owes a patch: an unread `README.md` decides + * nothing here, and reporting it would bury the readings that matter. A file + * that IS on a surface and arrived with no patch is a gap, because "no added + * line matched" and "no line was read" are the two states #4690 is about. + */ +export function unreadFiles(files, { repo = THIS_REPO } = {}) { + const gaps = []; + for (const file of files ?? []) { + const filename = String(file?.filename ?? ''); + if (filename === '' || file?.status === 'removed') continue; + // A file that ADDED NOTHING owes no patch. `additions: 0` is how both + // input paths spell a pure rename and a mode-only change, and reporting + // those as unread would bury the gaps that are real (a truncated patch on + // a file that did add lines) under one every rename produces. + if (typeof file?.additions === 'number' && file.additions === 0) continue; + if (file?.status === 'renamed' && (file?.patch == null || file.patch === '')) continue; + const onSurface = + (surfaceCovers(CONTRACT_SOURCE_SURFACES, filename, repo) && isContractSourceFile(filename)) || + surfaceCovers(PUBLISHED_SURFACES, filename, repo) || + surfaceCovers(REGISTRATION_SURFACES, filename, repo); + if (!onSurface) continue; + if (typeof file?.patch === 'string' && file.patch !== '') continue; + gaps.push(filename); + } + return gaps; +} + +/** Every tell in a whole changed-file listing, in file order. */ +export function wideningTells(files, { repo = THIS_REPO } = {}) { + const rows = []; + for (const file of files ?? []) rows.push(...tellsInFile(file, { repo })); + return rows; +} + +/** + * The verdict: a declaration plus a diff. + * + * @param {{ declaration: 'yes'|'no'|null|undefined, + * files: object[]|null, repo?: string }} input + * @returns {{ state: 'not-applicable'|'unreadable'|'incomplete'|'refused'|'clean', + * rows: object[], gaps: string[], text: string|null }} + */ +export function wideningRefusal({ declaration, files, repo = THIS_REPO } = {}) { + // A `yes` is never blocked here, and an unreadable declaration is the + // sibling's C2 row — issuing a verdict on it from this file would be a second + // reader of the same limb, which is the drift this family punishes. + if (declaration !== 'no') { + return { state: 'not-applicable', rows: [], gaps: [], text: null }; + } + if (!Array.isArray(files)) { + return { + state: 'unreadable', + rows: [], + gaps: [], + text: + 'the changed-file listing could not be read, so this diff is UNJUDGED for widening tells. ' + + '⛔ An unread diff is not a narrow diff.', + }; + } + const gaps = unreadFiles(files, { repo }); + const rows = wideningTells(files, { repo }); + if (rows.length > 0) { + const where = rows.map((r) => `${r.file}:${r.line}`).join(', '); + return { + state: 'refused', + rows, + gaps, + text: `${REFUSAL_SENTENCE} — ${rows.length} tell(s): ${where}`, + }; + } + if (gaps.length > 0) { + return { + state: 'incomplete', + rows, + gaps, + text: + `${gaps.length} file(s) on a tell surface arrived with no patch to read (${gaps.join(', ')}), ` + + 'so this diff is UNJUDGED for widening tells rather than clear of them.', + }; + } + return { state: 'clean', rows: [], gaps: [], text: null }; +} + +/** The exit code one verdict maps to — one place, so no caller re-derives it. */ +export function exitForRefusal(verdict) { + if (verdict?.state === 'refused') return EXIT_REFUSED; + if (verdict?.state === 'incomplete' || verdict?.state === 'unreadable') return EXIT_INCOMPLETE; + return EXIT_OK; +} + +/** The rows a caller prints, one line each, file:line first. */ +export function refusalLines(verdict) { + return (verdict?.rows ?? []).map((r) => `${r.tell} ${r.file}:${r.line} — ${r.why}\n + ${r.text}`); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function readInput(source) { + if (source === '-') return readFileSync(0, 'utf8'); + return readFileSync(source, 'utf8'); +} + +function argValue(argv, flag) { + const i = argv.indexOf(flag); + if (i === -1) return null; + const v = argv[i + 1]; + return typeof v === 'string' && !v.startsWith('--') ? v : ''; +} + +function main(argv) { + if (argv.includes('--self-test')) { + const code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-widening-tells self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + return 1; + } + return code; + } + + const declaration = argValue(argv, '--declaration'); + if (declaration === null || declaration === '') { + console.error( + 'check-widening-tells: --declaration is required — this gate is a predicate about a ' + + 'DIFF AND a claim, and reading only one of them decides nothing. ⛔ Silence is not a clearance.', + ); + return EXIT_USAGE; + } + if (declaration !== 'yes' && declaration !== 'no') { + console.error( + `check-widening-tells: --declaration ${JSON.stringify(declaration)} is neither \`yes\` nor \`no\`. ` + + 'Those two spellings are the whole set the clause-② reader recognises.', + ); + return EXIT_USAGE; + } + + const diffArg = argValue(argv, '--diff'); + const filesArg = argValue(argv, '--files'); + if ((diffArg === null) === (filesArg === null)) { + console.error( + 'check-widening-tells: name exactly one input — `--diff ` (a `git diff` body) or ' + + '`--files ` (the /pulls/N/files rows). Two inputs would be half one diff and half another.', + ); + return EXIT_USAGE; + } + + let files; + try { + if (diffArg !== null) { + if (diffArg === '') throw new Error('--diff needs a file path, or `-` for stdin'); + files = splitUnifiedDiff(readInput(diffArg)); + } else { + if (filesArg === '') throw new Error('--files needs a file path, or `-` for stdin'); + const doc = JSON.parse(readInput(filesArg)); + files = Array.isArray(doc) ? doc : Array.isArray(doc?.files) ? doc.files : null; + if (!files) throw new Error('--files needs a JSON array of /pulls/N/files rows, or an object carrying one as `files`'); + } + } catch (err) { + console.error(`check-widening-tells: ${err.message}. ⛔ Not a reading of a narrow diff.`); + return EXIT_USAGE; + } + + const verdict = wideningRefusal({ declaration, files }); + if (verdict.state === 'not-applicable') { + console.log( + `✓ check-widening-tells: the claim declares \`Clause-②: ${declaration}\`, which this gate never ` + + 'blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing.', + ); + return EXIT_OK; + } + if (verdict.state === 'clean') { + console.log( + `✓ check-widening-tells: ${files.length} changed file(s) read, no widening tell on any declared ` + + 'surface. ⚠️ A tell is not a proof and its absence is not one either — false negatives are the ' + + 'cost the #16349 ruling accepted.', + ); + return EXIT_OK; + } + for (const line of refusalLines(verdict)) console.error(`✗ ${line}`); + console.error(`check-widening-tells: ${verdict.text}`); + return exitForRefusal(verdict); +} + +// --------------------------------------------------------------------------- +// Self-test — offline, and the fixtures are the shapes measured in the tree +// --------------------------------------------------------------------------- + +/** A patch body from added lines starting at `start`, the shape the API sends. */ +const patchOf = (start, ...lines) => [`@@ -${start},0 +${start},${lines.length} @@`, ...lines].join('\n'); + +const FILE_SCHEMA_KEY = { + filename: 'packages/spec/src/kernel/manifest.zod.ts', + status: 'modified', + patch: patchOf(44, '+ telemetry: z.array(z.string()).optional()', ' ', '- stale: z.string(),'), +}; +const FILE_ENUM_MEMBER = { + filename: 'packages/spec/src/kernel/plugin.zod.ts', + status: 'modified', + patch: patchOf(95, "+ 'workflow', // Business: long-running orchestration"), +}; +const FILE_API_SURFACE = { + filename: 'packages/spec/api-surface/kernel.json', + status: 'modified', + patch: patchOf(14, '+ "WorkflowPluginSchema (const)",'), +}; +const FILE_REGISTRY = { + filename: 'packages/spec/src/api/error-code-ledger.zod.ts', + status: 'modified', + patch: patchOf(140, "+ 'WORKFLOW_STEP_FAILED',"), +}; + +let selfTestReachedVerdict = false; + +export function selfTest() { + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const registerCase = () => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + }; + const cases = []; + const t = (name, ok, detail) => { + registerCase(); + cases.push({ name, ok: Boolean(ok), detail }); + }; + const says = (s, frag) => typeof s === 'string' && s.includes(frag); + const tells = (file) => tellsInFile(file); + const at = (file) => tells(file).map((r) => `${r.file}:${r.line}`); + + // -- the patch reader ------------------------------------------------------ + battery('the patch reader: added lines, and the line numbers they carry'); + t('an empty patch reads as no added lines, never as an added line', addedLines('').length === 0 && addedLines(null).length === 0 && addedLines(undefined).length === 0); + t('one added line carries the hunk header\'s start', addedLines('@@ -1,0 +7,1 @@\n+alpha')[0]?.line === 7); + t('…and its text, with the `+` stripped', addedLines('@@ -1,0 +7,1 @@\n+alpha')[0]?.text === 'alpha'); + t('a context line advances the number by one', addedLines('@@ -1,2 +7,2 @@\n ctx\n+beta')[0]?.line === 8); + t('⛔ a REMOVED line advances nothing — the classic off-by-one that points at the wrong line', addedLines('@@ -1,2 +7,1 @@\n-gone\n+beta')[0]?.line === 7); + t('two additions after a removal keep counting from the same base', JSON.stringify(addedLines('@@ -1,3 +7,2 @@\n-gone\n+b1\n+b2').map((r) => r.line)) === '[7,8]'); + t('a second hunk RESETS to its own header rather than continuing', addedLines('@@ -1,1 +7,1 @@\n+a\n@@ -40,1 +60,1 @@\n+b')[1]?.line === 60); + t('⛔ the `+++` file header is not an added line', addedLines('--- a/x\n+++ b/x\n@@ -1,0 +3,1 @@\n+real').length === 1); + t('…and the one line it would have fabricated is the real one, at the right number', addedLines('--- a/x\n+++ b/x\n@@ -1,0 +3,1 @@\n+real')[0]?.line === 3); + t('"\\ No newline at end of file" is not an added line', addedLines('@@ -1,1 +1,1 @@\n+x\n\\ No newline at end of file').length === 1); + t('a line before any hunk header is ignored — there is no number to give it', addedLines('+orphan').length === 0); + t('an added EMPTY line is still an added line', addedLines('@@ -1,0 +5,1 @@\n+')[0]?.text === ''); + t('a hunk header with no comma on the new side still reads', addedLines('@@ -1 +9 @@\n+solo')[0]?.line === 9); + t('a removal-only patch yields nothing to judge', addedLines('@@ -1,2 +1,0 @@\n-a\n-b').length === 0); + t('the trailing empty split element does not fabricate a line', addedLines('@@ -1,1 +1,1 @@\n+a\n').length === 1); + t('a `diff --git` header line inside the body is skipped', addedLines('diff --git a/x b/x\n@@ -1,0 +2,1 @@\n+z').length === 1); + t('mixed context/add/remove keeps every number right', JSON.stringify(addedLines('@@ -1,4 +10,4 @@\n ctx\n-old\n+new\n ctx2\n+tail').map((r) => r.line)) === '[11,13]'); + + // -- the unified-diff splitter -------------------------------------------- + battery('the unified-diff splitter, for the local `git diff` path'); + const twoFiles = [ + 'diff --git a/packages/spec/src/a.zod.ts b/packages/spec/src/a.zod.ts', + 'index 111..222 100644', + '--- a/packages/spec/src/a.zod.ts', + '+++ b/packages/spec/src/a.zod.ts', + '@@ -1,0 +5,1 @@', + '+ extra: z.string(),', + 'diff --git a/README.md b/README.md', + '--- a/README.md', + '+++ b/README.md', + '@@ -1,0 +1,1 @@', + '+prose', + ].join('\n'); + t('two files split into two rows', splitUnifiedDiff(twoFiles).length === 2); + t('…named by their b-side path', splitUnifiedDiff(twoFiles)[0]?.filename === 'packages/spec/src/a.zod.ts'); + t('…each carrying its own hunk', addedLines(splitUnifiedDiff(twoFiles)[1]?.patch)[0]?.text === 'prose'); + t('an empty diff is no rows, never one row with nothing in it', splitUnifiedDiff('').length === 0 && splitUnifiedDiff(' ').length === 0); + t('a new file is marked `added`', splitUnifiedDiff('diff --git a/x b/x\nnew file mode 100644\n@@ -0,0 +1,1 @@\n+a')[0]?.status === 'added'); + t('a deleted file is marked `removed`', splitUnifiedDiff('diff --git a/x b/x\ndeleted file mode 100644\n@@ -1,1 +0,0 @@\n-a')[0]?.status === 'removed'); + t('a rename takes the NEW name, which is the path a tell must be reported at', splitUnifiedDiff('diff --git a/x b/y\nsimilarity index 98%\nrename from x\nrename to y\n')[0]?.filename === 'y'); + t('a binary change yields `patch: null` — UNREAD, not empty', splitUnifiedDiff('diff --git a/i.png b/i.png\nBinary files a/i.png and b/i.png differ')[0]?.patch === null); + t('…and null is what `unreadFiles` counts as a gap when it is on a surface', unreadFiles([{ filename: 'packages/spec/api-surface/kernel.json', patch: null }]).length === 1); + t('⛔ a file OFF every surface with no patch is not a gap — it decides nothing here', unreadFiles([{ filename: 'README.md', patch: null }]).length === 0); + t('the local path and the API path produce the same verdict on the same bytes', JSON.stringify(wideningTells(splitUnifiedDiff(twoFiles)).map((r) => r.tell)) === '["T1"]'); + + // -- the surfaces --------------------------------------------------------- + battery('the surfaces, imported rather than restated'); + t('the contract source surface is IMPORTED from SUSPECT_TIER_GLOBS, not spelled here', CONTRACT_SOURCE_SURFACES.some((s) => s.imported === 'SUSPECT_TIER_GLOBS')); + t('…and it covers exactly what that table declares', SUSPECT_TIER_GLOBS.every((g) => CONTRACT_SOURCE_SURFACES.some((s) => s.glob === g.glob))); + t('the published surface is DERIVED from REGEN_ARTIFACTS', PUBLISHED_SURFACES.length > 0 && PUBLISHED_SURFACES.every((s) => s.imported === 'REGEN_ARTIFACTS')); + t('…so both api-surface artifacts reach it without a literal here', surfaceCovers(PUBLISHED_SURFACES, 'packages/spec/api-surface/kernel.json') && surfaceCovers(PUBLISHED_SURFACES, 'packages/spec/api-surface-signatures.json')); + t('a spec source file is on the contract surface', surfaceCovers(CONTRACT_SOURCE_SURFACES, 'packages/spec/src/kernel/plugin.zod.ts')); + t('⛔ a sibling directory that merely shares a prefix is not', !surfaceCovers(CONTRACT_SOURCE_SURFACES, 'packages/spec/src-legacy/plugin.zod.ts')); + t('an api-surface file is NOT on the contract source surface — the two tells stay apart', !surfaceCovers(CONTRACT_SOURCE_SURFACES, 'packages/spec/api-surface/kernel.json')); + t("the objectui mirror row is INERT in this repo's run", !surfaceCovers(CONTRACT_SOURCE_SURFACES, 'packages/types/src/zod/app.zod.ts', THIS_REPO)); + t('…and live when the run names objectui', surfaceCovers(CONTRACT_SOURCE_SURFACES, 'packages/types/src/zod/app.zod.ts', 'objectstack-ai/objectui')); + t('a test file on the contract surface is not a contract source file', !isContractSourceFile('packages/spec/src/kernel/plugin.zod.test.ts') && !isContractSourceFile('packages/spec/src/x.spec.ts')); + t('…and a plain source file is', isContractSourceFile('packages/spec/src/kernel/plugin.zod.ts')); + + // -- T1 -------------------------------------------------------------------- + battery('T1 — a new key on a Zod object schema'); + t('a new `key: z.…` line is a tell', tells(FILE_SCHEMA_KEY)[0]?.tell === 'T1'); + t('…reported at its file:line', at(FILE_SCHEMA_KEY)[0] === 'packages/spec/src/kernel/manifest.zod.ts:44'); + t('an optional-marked key reads too', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ slug?: z.string(),') }).length === 1); + t('a quoted key reads too — a dotted hook name is a real spelling in this tree', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ 'record.beforeInsert': z.array(z.string()),") }).length === 1); + t('a `*Schema` value reads — the measured non-`z.` vocabulary', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ label: I18nLabelSchema.optional(),') })[0]?.tell === 'T1'); + t('`retiredKey(` reads — 235 lines in the tree take it', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ legacy: retiredKey('legacy'),") })[0]?.tell === 'T1'); + t('`strictObject(` reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ nested: strictObject({ a: z.string() }),') })[0]?.tell === 'T1'); + t('`lazySchema(` reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ deep: lazySchema(() => z.string()),') })[0]?.tell === 'T1'); + t('⛔ an object-literal boolean is NOT a schema key — 1,655 such lines exist and none is an accept-set member', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ enabled: true,') }).length === 0); + t('⛔ nor a TypeScript type annotation', tells({ filename: 'packages/spec/src/a.ts', patch: patchOf(3, '+ name: string;') }).length === 0); + t('⛔ nor a key added in a COMMENT', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ // future: z.string() — not yet') }).length === 0); + t('⛔ nor the same line in a TEST file', tells({ filename: 'packages/spec/src/a.zod.test.ts', patch: patchOf(3, '+ extra: z.string(),') }).length === 0); + t('⛔ nor the same line OUTSIDE the contract surface', tells({ filename: 'packages/runtime/src/a.ts', patch: patchOf(3, '+ extra: z.string(),') }).length === 0); + t('a file whose only change is a REMOVED key yields no tell', tells({ filename: 'packages/spec/src/a.zod.ts', patch: '@@ -3,1 +3,0 @@\n- gone: z.string(),' }).length === 0); + + // -- T2 -------------------------------------------------------------------- + battery('T2 — a new member of a closed set'); + t('a bare string element is a tell', tells(FILE_ENUM_MEMBER)[0]?.tell === 'T2'); + t('…reported at its file:line', at(FILE_ENUM_MEMBER)[0] === 'packages/spec/src/kernel/plugin.zod.ts:95'); + t('…with the trailing comment stripped off the quoted text', says(tells(FILE_ENUM_MEMBER)[0]?.text, "'workflow'")); + t('a double-quoted element reads too', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ "workflow",') })[0]?.tell === 'T2'); + t('a re-written one-line `z.enum([…])` reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+export const K = z.enum(['a', 'b', 'c']);") })[0]?.tell === 'T2'); + t('a `z.union([` opener reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+const U = z.union([') })[0]?.tell === 'T2'); + t('a `z.discriminatedUnion(` opener reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+const D = z.discriminatedUnion('kind', [") })[0]?.tell === 'T2'); + t('a bare union ARM reads', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, '+ WorkflowSchema,') })[0]?.tell === 'T2'); + t('⛔ a bare string in a COMMENT does not', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ // 'workflow',") }).length === 0); + t('⛔ nor a bare string outside the contract surface', tells({ filename: 'apps/docs/x.ts', patch: patchOf(3, "+ 'workflow',") }).length === 0); + t('⛔ a REMOVED member is not a tell — the ruling is directional', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -3,1 +3,0 @@\n- 'legacy'," }).length === 0); + t('T1 wins over T2 on a line that could read as both, so one line is never two rows', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ kind: z.enum(['a']),") }).length === 1); + t('…and the row it produces is the key reading', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ kind: z.enum(['a']),") })[0]?.tell === 'T1'); + + // -- T3 -------------------------------------------------------------------- + battery('T3 — a new row in a published entry point'); + t('a new export row is a tell', tells(FILE_API_SURFACE)[0]?.tell === 'T3'); + t('…reported at its file:line', at(FILE_API_SURFACE)[0] === 'packages/spec/api-surface/kernel.json:14'); + t('the signatures sibling is on the surface too', tells({ filename: 'packages/spec/api-surface-signatures.json', patch: patchOf(4, '+ "defineWorkflow": "sha256:0000000000000000",') })[0]?.tell === 'T3'); + t('⛔ a removed row is not a tell', tells({ filename: 'packages/spec/api-surface/kernel.json', patch: '@@ -14,1 +14,0 @@\n- "Gone (const)",' }).length === 0); + t('⛔ a JSON file elsewhere is not on this surface', tells({ filename: 'packages/spec/package.json', patch: patchOf(4, '+ "./workflow": "./dist/workflow.js",') }).length === 0); + t('⛔ nor a non-string structural line inside the listing', tells({ filename: 'packages/spec/api-surface/kernel.json', patch: patchOf(4, '+ ]') }).length === 0); + t('an unread api-surface file is a GAP, not a clean reading', unreadFiles([{ filename: 'packages/spec/api-surface/kernel.json', patch: undefined }])[0] === 'packages/spec/api-surface/kernel.json'); + t('a DELETED api-surface file adds nothing and owes no patch', unreadFiles([{ filename: 'packages/spec/api-surface/kernel.json', status: 'removed', patch: null }]).length === 0); + + // -- T4 -------------------------------------------------------------------- + battery('T4 — a new registration in a registry'); + t('a new ledger code is a tell', tells(FILE_REGISTRY)[0]?.tell === 'T4'); + t('…reported at its file:line', at(FILE_REGISTRY)[0] === 'packages/spec/src/api/error-code-ledger.zod.ts:140'); + t('a new OWNER key opening a list is a tell', tells({ filename: 'packages/spec/src/api/error-code-ledger.zod.ts', patch: patchOf(140, "+ '@objectstack/workflow': [") })[0]?.tell === 'T4'); + t('the dispatcher vocabulary is on the registry surface, outside packages/spec', tells({ filename: 'packages/runtime/src/dispatcher-error-vocabulary.ts', patch: patchOf(300, "+ code: 'WORKFLOW_STEP_FAILED',") })[0]?.tell === 'T4'); + t('the metadata form registry is on it too', tells({ filename: 'packages/spec/src/system/metadata-form-registry.ts', patch: patchOf(70, '+ workflow: workflowForm,') }).length === 1); + t('⛔ a comment in a registry is not a registration', tells({ filename: 'packages/runtime/src/dispatcher-error-vocabulary.ts', patch: patchOf(300, "+ // 'WORKFLOW_STEP_FAILED' is pending") }).length === 0); + t('⛔ a removed registration is not a tell', tells({ filename: 'packages/spec/src/api/error-code-ledger.zod.ts', patch: "@@ -140,1 +140,0 @@\n- 'GONE'," }).length === 0); + t('⛔ a runtime file that is NOT a declared registry is off the surface', tells({ filename: 'packages/runtime/src/other.ts', patch: patchOf(300, "+ code: 'WORKFLOW_STEP_FAILED',") }).length === 0); + t('a registry file inside packages/spec reports ONE row, not one per overlapping surface', tells(FILE_REGISTRY).length === 1); + t('…because the ledger line is read by the registry tell, which the contract-source tells do not claim', tells(FILE_REGISTRY)[0]?.tell === 'T4'); + + // -- acceptance: the positive controls ------------------------------------ + battery('#16448 acceptance: the four positive controls, each with its file:line'); + const positives = [FILE_SCHEMA_KEY, FILE_ENUM_MEMBER, FILE_API_SURFACE, FILE_REGISTRY]; + const refusedAll = wideningRefusal({ declaration: 'no', files: positives }); + t('all four tells fire on one diff', refusedAll.rows.length === 4); + t('…one of each kind, none collapsed into another', JSON.stringify(refusedAll.rows.map((r) => r.tell).sort()) === '["T1","T2","T3","T4"]'); + t('the verdict is REFUSED', refusedAll.state === 'refused'); + t('…which maps to the adverse exit', exitForRefusal(refusedAll) === EXIT_REFUSED); + t('the refusal carries the card\'s sentence verbatim', says(refusedAll.text, REFUSAL_SENTENCE)); + t('…and every tell\'s file:line', ['packages/spec/src/kernel/manifest.zod.ts:44', 'packages/spec/src/kernel/plugin.zod.ts:95', 'packages/spec/api-surface/kernel.json:14', 'packages/spec/src/api/error-code-ledger.zod.ts:140'].every((p) => says(refusedAll.text, p))); + t('each printed row leads with its file:line', refusalLines(refusedAll).every((l) => /^T[1-4] [^ ]+:\d+ — /.test(l))); + t('…and quotes the added line so the reader need not open the file', refusalLines(refusedAll).every((l) => l.includes('\n + '))); + + // -- acceptance: the negative controls ------------------------------------ + battery('#16448 acceptance: the negative controls a widening gate must let through'); + const yesVerdict = wideningRefusal({ declaration: 'yes', files: positives }); + t('the SAME four diffs with `yes` are not blocked', yesVerdict.state === 'not-applicable'); + t('…and exit 0', exitForRefusal(yesVerdict) === EXIT_OK); + t('…with no rows computed at all — a `yes` is never even judged here', yesVerdict.rows.length === 0); + const removalOnly = [ + { filename: 'packages/spec/src/kernel/plugin.zod.ts', status: 'modified', patch: "@@ -95,2 +95,0 @@\n- 'legacy',\n- 'deprecated'," }, + { filename: 'packages/spec/api-surface/kernel.json', status: 'modified', patch: '@@ -14,1 +14,0 @@\n- "LegacySchema (const)",' }, + { filename: 'packages/spec/src/api/error-code-ledger.zod.ts', status: 'modified', patch: "@@ -140,1 +140,0 @@\n- 'GONE'," }, + ]; + const removalVerdict = wideningRefusal({ declaration: 'no', files: removalOnly }); + t('a removal-only diff with `no` PASSES — the ruling is directional', removalVerdict.state === 'clean'); + t('…and exits 0', exitForRefusal(removalVerdict) === EXIT_OK); + t('a tightened refine with `no` passes', wideningRefusal({ declaration: 'no', files: [{ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(9, '+ .refine((v) => v.length < 10, { message: "too long" })') }] }).state === 'clean'); + t('a pure rename with `no` passes', wideningRefusal({ declaration: 'no', files: splitUnifiedDiff('diff --git a/packages/spec/src/a.zod.ts b/packages/spec/src/b.zod.ts\nrename from packages/spec/src/a.zod.ts\nrename to packages/spec/src/b.zod.ts\n') }).state === 'clean'); + t('a docs-only diff with `no` passes', wideningRefusal({ declaration: 'no', files: [{ filename: 'content/docs/x.mdx', patch: patchOf(1, '+ newKey: z.string(),') }] }).state === 'clean'); + t('an absent declaration is NOT this gate\'s verdict to issue — that is the sibling\'s C2 row', wideningRefusal({ declaration: null, files: positives }).state === 'not-applicable'); + t('…and neither is a malformed one', wideningRefusal({ declaration: 'YES', files: positives }).state === 'not-applicable'); + + // -- the sentence and the prohibitions ------------------------------------ + battery('the refusal sentence, and the two prohibitions it must keep'); + t('the sentence names both ways out', says(REFUSAL_SENTENCE, 're-declare `yes`') && says(REFUSAL_SENTENCE, 'explain in the claim')); + t('…and quotes the declaration in the spelling the reader uses', says(REFUSAL_SENTENCE, '`Clause-②: no`')); + t('⛔ no label name appears anywhere in this file\'s outputs — a checker that hung one would be issuing the verdict', !says(REFUSAL_SENTENCE, 'needs:') && refusalLines(refusedAll).every((l) => !l.includes('needs:'))); + t('⛔ no new claim-line syntax is invented: the two values are the sibling\'s two', wideningRefusal({ declaration: 'maybe', files: positives }).state === 'not-applicable'); + t('a tell is reported as a tell — its `why` says what it is evidence OF', refusedAll.rows.every((r) => typeof r.why === 'string' && r.why.length > 20)); + t('the quoted line is CAPPED, so one row cannot swamp the report', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, `+ k: z.string() // ${'x'.repeat(400)}`) })[0]?.text.length <= 160); + t('a clean verdict carries no text to mistake for a finding', removalVerdict.text === null); + t('a not-applicable verdict likewise', yesVerdict.text === null); + + // -- the exit register ---------------------------------------------------- + battery('the exit register is distinct in every direction it must be'); + t('the four codes are four distinct values', new Set([EXIT_OK, EXIT_USAGE, EXIT_INCOMPLETE, EXIT_REFUSED]).size === 4); + t('REFUSED is never 0 — silence is what this file exists against', EXIT_REFUSED !== EXIT_OK); + t('INCOMPLETE is never REFUSED — an unread diff is not a widening one', EXIT_INCOMPLETE !== EXIT_REFUSED); + const unread = wideningRefusal({ declaration: 'no', files: null }); + t('an unreadable listing is INCOMPLETE, never clean', unread.state === 'unreadable' && exitForRefusal(unread) === EXIT_INCOMPLETE); + const gapped = wideningRefusal({ declaration: 'no', files: [{ filename: 'packages/spec/api-surface/kernel.json', patch: null }] }); + t('a surface file with no patch is INCOMPLETE, never clean', gapped.state === 'incomplete' && exitForRefusal(gapped) === EXIT_INCOMPLETE); + t('⛔ but a REFUSAL outranks a gap — a tell that was read is a fact, whatever else was not', wideningRefusal({ declaration: 'no', files: [...positives, { filename: 'packages/spec/api-surface/ui.json', patch: null }] }).state === 'refused'); + + // -- the declared rows still exist ---------------------------------------- + battery('the declared registry rows still exist in this tree'); + const localRows = REGISTRATION_SURFACES.filter((s) => s.repo === THIS_REPO); + t('every declared registry row names a path in THIS tree — a renamed registry must red here, not go quiet', localRows.every((s) => existsSync(new URL(s.glob, `file://${ROOT}`))), localRows.filter((s) => !existsSync(new URL(s.glob, `file://${ROOT}`))).map((s) => s.glob).join(', ')); + t('the table is not empty — an empty register guards nothing while reading as protection', localRows.length >= 3); + t('every surface row carries a `why`, so a reader can tell what it is protecting', ALL_SURFACES.every((s) => typeof s.why === 'string' && s.why.length > 10)); + t('every surface row names the repo it applies to', ALL_SURFACES.every((s) => typeof s.repo === 'string' && s.repo.includes('/'))); + + // -- the floor ------------------------------------------------------------- + const floorFailures = []; + const floorFailure = (text) => { + floorFailures.push(text); + console.error(` ✗ ${text}`); + }; + const declared = new Set(Object.keys(SELF_TEST_BATTERIES)); + const opened = new Set(batterySeen.keys()); + if (declared.size < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `the battery roster declares ${declared.size} batteries, below its pinned floor of ` + + `${SELF_TEST_BATTERY_FLOOR} — deleting an entry silences its floor exactly as effectively as zeroing it.`, + ); + } + for (const name of opened) { + if (!declared.has(name)) floorFailure(`self-test battery "${name}" ran but is NOT declared in the roster.`); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorFailure( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (floorFailures.length > 0) { + console.error( + '✗ check-widening-tells self-test: the battery floor is breached — cases STOPPED RUNNING. ' + + 'Find what stopped registering (an early return, a deleted block, a guard that now skips).', + ); + return 1; + } + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-widening-tells self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ check-widening-tells self-test: ${cases.length} cases pass (the patch reader with its ` + + 'line-number directions, the unified-diff splitter, the three imported/declared surfaces, the ' + + "four tells, #16448's four positive controls each with its file:line, its negative controls — " + + 'the same diffs with `yes`, and a removal-only diff with `no` — and the exit register).', + ); + + selfTestReachedVerdict = true; + return 0; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} From dabcb7e66480172fd2cb77f93bd51b8da0027478 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:32:40 +0000 Subject: [PATCH 2/4] wip(pm): wire C5 into --pair Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX --- .github/workflows/lint.yml | 13 ++ package.json | 1 + scripts/pm/check-clause2-carriers.mjs | 277 ++++++++++++++++++++++++-- 3 files changed, 274 insertions(+), 17 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 130c893dba..e16af58dcf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -975,6 +975,19 @@ jobs: - name: Clause-② carrier checker self-test run: pnpm check:pm-clause2-carriers + # Widening-tell gate self-test (#16448) — the mechanical half of the + # directional clause-② ruling (#16349), and the same split as every step + # around it: the LIVE predicate is `--pair`'s C5 row, a verdict about ONE + # pair's diff, so failing an unrelated PR's CI over it would punish the + # wrong actor. Only the offline self-test runs here. + # + # This gate has no whole-tree production run at all — its input is a DIFF + # supplied by its caller, never a file in the tree — so the `--self-test` + # invocation is the only instrument its matching rules have, which is + # exactly the family `check:self-test-wired` requires this step for. + - name: Widening-tell gate self-test + run: pnpm check:pm-widening-tells + # Governed-merges audit self-test (#9495) — same family, same split as # the half-state sweeper above: the live sweep enumerates the governed # merges since a date/ref for the PM round report — report-only by diff --git a/package.json b/package.json index b84bdca13c..6c672f8b99 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:pm-clause2-carriers": "node scripts/pm/check-clause2-carriers.mjs --self-test", + "check:pm-widening-tells": "node scripts/pm/check-widening-tells.mjs --self-test", "check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 751b4a6e4f..71d7d6053c 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -17,6 +17,12 @@ * node scripts/pm/check-clause2-carriers.mjs --pair 13910 --pair-json pair.json * node scripts/pm/check-clause2-carriers.mjs --pair 13910 --pair-json - * + * Since #16448 `--pair` also carries C5 — the WIDENING TELL row: a diff that + * adds a schema key, a closed-set member, a published export or a registry + * entry while its card declares `Clause-②: no`. The tells themselves live in + * `check-widening-tells.mjs` (imported, with its own self-test); this file + * supplies the declaration and the diff and joins them. + * * ## The gate, and the three limbs it is supposed to stand on * * `scripts/pm/ensure-pm-labels.sh` states the gate in its own words: a PR whose @@ -77,9 +83,22 @@ * reading is "a HINT, never a verdict" because clause ② is judged from a card's * CONTENT. A second declaration of that surface here would be a hand copy of a * register — the exact drift `check:pm-governed-prose` exists to stop one - * family over — and it would cost a changed-file listing per pair for a reading - * that decides nothing this file reports. Every row below is derivable from the - * declaration and the two carriers alone. + * family over. C1–C4 are all derivable from the declaration and the two + * carriers alone, and none of them costs a changed-file listing. + * + * ⭐ C5 (#16448) is the one row that reads a DIFF, and it is still not limb ①. + * Limb ① asks "does this card's file surface suggest the contract tier?" and + * answers with a hint; C5 asks "does this diff have the SHAPE of a widening, + * while its card declared `no`?" and answers about the pair. The tells, the + * surfaces and the refusal sentence all live in `check-widening-tells.mjs`, + * which IMPORTS `SUSPECT_TIER_GLOBS` rather than restating it — so the contract + * surface is still declared exactly once in this tree. The maintainer's + * condition on the directional clause-② ruling (#16349) was that the direction + * claim become checkable instead of trusted, and this row is that condition; + * `SUSPECT_TIER_GLOBS`'s own docblock had already promised the reading ("the + * PR's ACTUAL diff passes the clause-② enqueue gate before the card may + * enqueue — the diff is a fact; the card's semantics were a prediction") and + * pointed at a gate that, until #16448, was a human. * * **It relaxes no spelling.** `Clause-②: yes` / `Clause-②: no` are the only two * readings that count, and prose is not one of them. #12409 measured where the @@ -193,6 +212,11 @@ * "events": { "13476": the `/issues/N/events` rows } — optional * "commits": { "HEAD-SHA": { commit: { committer: { date } } } } * — optional + * "files": { "13910": the `/pulls/N/files` rows } — optional, + * keyed by PR NUMBER (not card), because that is what + * the endpoint is keyed by. Owed only by a pair whose + * card declares `Clause-②: no`; omitting it there + * reads `null` → C5 UNJUDGED, never a narrow diff. * } * * ⛔ A key the document does not carry reads `null`, which is UNJUDGED @@ -211,10 +235,20 @@ * the PR delivers (the card, its comment thread). A C3 candidate adds its two * carriers' event streams (one page each on this board) and — only once both * read cleared — one commit: ≤5 reads for a candidate pair, 2 for every other. - * The sweep pays the listing once and the same per-pair cost for every pair it - * derives. ⇒ a `--pair` run costs 3–6 requests, while a 29-PR sweep costs about - * 60 — which is exactly GitHub's documented anonymous hourly budget, one more - * reason the run prints the remaining count instead of assuming it. + * A pair whose card declares `Clause-②: no` adds ONE more — its changed-file + * listing, for C5 (#16448). The sweep pays the listing once and the same + * per-pair cost for every pair it derives. ⇒ a `--pair` run costs 3–7 requests, + * while a 29-PR sweep costs about 60 — which is exactly GitHub's documented + * anonymous hourly budget, one more reason the run prints the remaining count + * instead of assuming it. + * + * ⭐ That last read is `--pair` ONLY, and the asymmetry is deliberate. Paying it + * per sweep pair would push a routine sweep past the anonymous budget it + * already sits on, and a widening tell on somebody else's pair is a board fact + * rather than a verdict about the PR that happens to run CI next — the same + * call the sweep/`--pair` split already makes for every other row here. In a + * sweep `pair.files` is therefore `undefined`, which no row reads; `null` means + * a read that WAS owed came back short, and that is UNJUDGED. * * ## Exit codes — the refusal to read as clean, in one table * @@ -245,9 +279,14 @@ * ⚠️ 0 is not "the review passed"; the PASS reading is human and * is precondition ① of the landing check, not this exit code. * 2 also the answer when a C3 candidate's event stream or head commit could - * not be read: an unread stream is not a never-hung gate, so it is UNJUDGED - * rather than either verdict. - * 4 they do not. Deliberately NOT 3: a verdict about the PAIR must be + * not be read, or when a `Clause-②: no` pair's changed-file listing could + * not be: an unread stream is not a never-hung gate and an unread diff is + * not a narrow one, so both are UNJUDGED rather than either verdict. + * 4 they do not — or, since #16448, the declaration reads `no` while the + * diff carries a widening tell (row C5). One exit code with several + * adverse reasons is the shape this table already had: the ROW says which, + * and the exit says only "a verdict about this pair, adverse". + * Deliberately NOT 3: a verdict about the PAIR must be * impossible to confuse with "the environment could not answer", so a * seat reading `$?` cannot turn a refusal into a clearance. And ⛔ never * 0-with-a-message: silence is what this whole file exists against. @@ -280,6 +319,15 @@ import { proxyRearmPlan, resolveSweepRepo, } from './check-half-states.mjs'; +import { + EXIT_INCOMPLETE as WT_EXIT_INCOMPLETE, + EXIT_OK as WT_EXIT_OK, + EXIT_REFUSED as WT_EXIT_REFUSED, + EXIT_USAGE as WT_EXIT_USAGE, + REFUSAL_SENTENCE, + refusalLines, + wideningRefusal, +} from './check-widening-tells.mjs'; // dispatch-gates: no-path-population -- this gate reads no file in the tree at all; its whole input is the GitHub API (PRs, their labels, and the claim comments on their cards), so no card's file surface can predict it and the honest derivation is a repo-wide undetermined one (#13519) @@ -309,7 +357,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the #13910 specimen, end to end': 2, 'pairing, derived from the same relation H8/H31 read': 3, 'the three read paths: ordered, offline-capable, and named in every refusal': 24, - 'the exit register is distinct in every direction it must be': 3, + 'C5: the direction claim checked against the diff (#16448)': 16, + 'the exit register is distinct in every direction it must be': 6, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1106,6 +1155,88 @@ export function c4VerdictSelfReview(pair) { ); } +// --------------------------------------------------------------------------- +// C5 -- the direction claim, checked against the diff (#16448 / #16349) +// --------------------------------------------------------------------------- + +/** + * Does this pair owe a changed-file listing? + * + * ⭐ ONLY a card that DECLARED `no`. The whole point of the #16349 ruling is + * that `no` is the reading which BUYS a lower tier, so `no` is the reading that + * must be checkable; a `yes` already routes to contract review and a tell on + * top of it decides nothing. Every other declaration state (`missing`, + * `absent`, `malformed`, `misplaced`, `unreadable`) is C2's row and not this + * one's: two readers of the same limb is the drift this file was written to + * avoid one family over. + * + * It is exported and used by BOTH the fetch and the UNJUDGED accounting, the + * way `needsGateHistory` is, so the set that owes a listing and the set that + * gets one cannot drift apart -- and a pair that owes nothing can never be + * reported as missing a read the reader was never going to make. + */ +export function needsWideningRead(pair) { + const d = cardDeclaration(pair?.cardComments ?? null); + return d.state === 'declared' && d.value === 'no'; +} + +/** + * The widening verdict for one pair -- the sibling gate, given this pair's + * declaration and diff. + * + * The tells, the surfaces and the refusal sentence all live in + * `check-widening-tells.mjs`; this function is the JOIN and nothing else, so + * the shape of a tell is stated once in the tree. + */ +export function pairWidening(pair, repo) { + if (!needsWideningRead(pair)) return { state: 'not-applicable', rows: [], gaps: [], text: null }; + return wideningRefusal({ declaration: 'no', files: pair?.files ?? null, repo }); +} + +/** + * C5 -- a widening tell on a diff whose card declares `Clause-②: no`. + * + * The #16349 ruling made clause ② DIRECTIONAL on the maintainer's explicit + * condition that the direction claim become checkable instead of trusted. This + * row is that condition: `SUSPECT_TIER_GLOBS`'s own docblock already promised + * that "whichever tier is dispatched, the PR's ACTUAL diff passes the clause-② + * enqueue gate before the card may enqueue -- the diff is a fact; the card's + * semantics were a prediction", and until #16448 that gate was a human reading. + * + * ⚠️ A TELL, never a proof, in BOTH directions: a false positive costs the + * author one word in the claim comment, and a false negative is the cost the + * ruling accepted when it took the directional reading. So this row never + * asserts that the diff widens -- it asserts that the diff has the SHAPE of one + * that does, while the claim says it does not, and names the file:line so the + * author can answer with the file open. + */ +export function c5WideningTell(pair, repo) { + const v = pairWidening(pair, repo); + if (v.state !== 'refused') return null; + const head = `card #${pair?.card} (delivering open PR #${pair?.pr}${pair?.draft ? ' (draft)' : ''})`; + return ( + `${head} declares \`Clause-②: no\` while its diff carries ${v.rows.length} widening tell(s) -- ` + + `${REFUSAL_SENTENCE}. ${v.rows.map((r) => `${r.file}:${r.line} (${r.tell})`).join(', ')}. ` + + 'Neither reading is overturned here: the declaration stands as written and the diff stands as ' + + `pushed, and they disagree. ${NEVER_WRITES}` + ); +} + +/** + * C5's own #4690 half -- a diff this file could not READ is not a narrow diff. + * + * Reached only for a pair that owes the listing, so a `yes` pair and a pair + * with no declaration can never be reported as missing a read nobody owed. + */ +export function wideningUnjudged(pair, repo) { + const v = pairWidening(pair, repo); + if (v.state !== 'unreadable' && v.state !== 'incomplete') return null; + return ( + `pair PR #${pair?.pr} / card #${pair?.card} declares \`Clause-②: no\`, and its diff is UNJUDGED ` + + `for widening tells: ${v.text}` + ); +} + /** Every row for one pair, in reporting order. */ export function pairRows(pair) { const rows = []; @@ -1482,7 +1613,39 @@ async function readHeadCommitDate(repo, sha) { } /** - * The five reads `gather` performs, named once. + * The PAGE CAP on one PR's changed-file listing. + * + * Three pages is 300 files, past anything this board's PRs produce, and the + * files that matter here are a handful of contract sources. A PR that exceeds + * it is answered `null` -> UNJUDGED, never clean: a listing read short does not + * merely lose detail, it loses the very file whose added key is the tell. + */ +export const FILE_PAGE_CAP = 3; + +/** + * One PR's changed files, with their patches, paged to exhaustion -- or `null`. + * + * Owed by the widening-tell reading ALONE, and only for a pair whose card + * declares `Clause-②: no` (`needsWideningRead`, the same predicate the + * UNJUDGED accounting reads), so no pair can owe a request the live reader was + * never going to make. + * + * ⛔ Never a partial array, for the reason `readCarrierEvents` states one + * function over: a caller cannot tell a short read from a narrow diff. + */ +async function readPullFiles(repo, number) { + const out = []; + for (let page = 1; page <= FILE_PAGE_CAP; page++) { + const batch = await restOrNull(`/repos/${repo}/pulls/${number}/files?per_page=100&page=${page}`); + if (!Array.isArray(batch)) return null; + out.push(...batch); + if (batch.length < 100) return out; + } + return null; // cap hit: the tail is unread, so the diff is unread. +} + +/** + * The six reads `gather` performs, named once. * * A reader implements every one of them and may answer with a value OR a * promise of one (`gather` awaits either), which is what lets the offline @@ -1496,6 +1659,7 @@ export const READER_METHODS = Object.freeze([ 'readCardComments', 'readCarrierEvents', 'readHeadCommitDate', + 'readPullFiles', ]); /** Paths (i) and (ii): the network, down the ladder `rest` implements. */ @@ -1507,6 +1671,7 @@ const NETWORK_READER = Object.freeze({ readCardComments: (repo, n) => restOrNull(`/repos/${repo}/issues/${n}/comments?per_page=100`), readCarrierEvents: (repo, n) => readCarrierEvents(repo, n), readHeadCommitDate: (repo, sha) => readHeadCommitDate(repo, sha), + readPullFiles: (repo, n) => readPullFiles(repo, n), }); /** A resource the document does not carry — `null`, i.e. UNJUDGED (#4690). */ @@ -1559,6 +1724,10 @@ export function pairJsonReader(doc, { source = 'the --pair-json document' } = {} const commit = fromDocument(doc.commits, sha); return serve(commit?.commit?.committer?.date ?? null); }, + readPullFiles: (_repo, n) => { + const rows = fromDocument(doc.files, n); + return serve(Array.isArray(rows) ? rows : null); + }, }); } @@ -1584,7 +1753,7 @@ export function pairJsonRepoConflict(docRepo, repo) { * sweep's cost at one PR listing plus two reads per pair, plus — for the C3 * candidates ALONE — their two event streams and one head commit. */ -async function gather(repo, prFilter = null, reader = NETWORK_READER) { +async function gather(repo, prFilter = null, reader = NETWORK_READER, { readFiles = false } = {}) { const pulls = (await reader.listOpenPulls(repo)).filter((pr) => (prFilter ? pr.number === prFilter : true)); const pairs = []; for (const pr of pulls) { @@ -1623,6 +1792,23 @@ async function gather(repo, prFilter = null, reader = NETWORK_READER) { pair.headCommittedAt = await reader.readHeadCommitDate(repo, pair.headSha); } } + + // Third pass -- the changed-file listing, for the widening reading (#16448). + // + // ⭐ `--pair` ONLY, and only for the pairs that declared `no`. The report-only + // SWEEP deliberately does not pay for it: a 29-PR sweep already costs about + // GitHub's whole documented anonymous hourly budget, one row per pair here + // would push it past that, and a widening tell on somebody else's pair is a + // board fact rather than a verdict about the PR that happens to run next -- + // the same call the sweep/`--pair` split already makes everywhere else in + // this file. `pair.files` is therefore `undefined` in a sweep, which no row + // reads, and `null` only when a read that WAS owed came back short. + if (readFiles) { + for (const pair of pairs) { + if (!needsWideningRead(pair)) continue; + pair.files = await reader.readPullFiles(repo, pair.pr); + } + } return { pulls, pairs }; } @@ -1655,21 +1841,38 @@ function renderSweep({ repo, pulls, pairs }, { json = false } = {}) { return unjudged.length > 0 ? EXIT_INCOMPLETE : EXIT_OK; } -function renderPair(pair) { +function renderPair(pair, repo) { const gap = pairUnjudged(pair); if (gap) { console.error(`✗ check-clause2-carriers --pair: ${gap}`); return EXIT_INCOMPLETE; } const rows = pairRows(pair); + const widening = pairWidening(pair, repo); + const wideningRow = c5WideningTell(pair, repo); + if (wideningRow) rows.push({ code: 'C5', text: wideningRow }); + const wideningGap = wideningUnjudged(pair, repo); if (rows.length === 0) { + if (wideningGap) { + console.error(`✗ check-clause2-carriers --pair: ${wideningGap}`); + return EXIT_INCOMPLETE; + } console.log( `✓ check-clause2-carriers: PR #${pair.pr} / card #${pair.card} — the clause-② declaration is ` + - 'readable in the fixed spelling and both carriers agree.', + 'readable in the fixed spelling and both carriers agree' + + (widening.state === 'clean' + ? ', and its diff carries no widening tell. ⚠️ A tell is not a proof and its absence is not one either.' + : '.'), ); return EXIT_OK; } for (const row of rows) console.error(`✗ ${row.code} — ${row.text}`); + // The file:line list, one per line, so an author can paste it into an editor. + for (const line of refusalLines(widening)) console.error(` ${line}`); + // An adverse row OUTRANKS a gap -- a tell that WAS read is a fact about this + // pair whatever else could not be read -- but the gap is still printed, or a + // reader would take the rows below for the whole reading. + if (wideningGap) console.error(`⚠️ ${wideningGap}`); console.error( `check-clause2-carriers: PR #${pair.pr} / card #${pair.card} is NOT clause-② legible ` + `(exit ${EXIT_PAIR_ADVERSE}). ⛔ This is a verdict about this pair, not about the environment.`, @@ -2151,11 +2354,51 @@ export function selfTest() { t('…and refuses to state a budget it did not see, rather than implying plenty', says(renderRateNote(null), 'UNKNOWN')); // -- the exit register is distinct in every direction it must be ----------- + // -- C5: the direction claim, checked against the diff (#16448) ----------- + // + // The tells themselves are the SIBLING's, with its own 120-case self-test; + // what is pinned here is the JOIN — which pairs owe a diff read, what a pair + // that owes none reports, and that a tell reaches the exit register. + battery('C5: the direction claim checked against the diff (#16448)'); + const WIDENS = [{ + filename: 'packages/spec/src/kernel/plugin.zod.ts', + status: 'modified', + patch: "@@ -95,0 +95,1 @@\n+ 'workflow',", + }]; + const NARROWS = [{ + filename: 'packages/spec/src/kernel/plugin.zod.ts', + status: 'modified', + patch: "@@ -95,1 +95,0 @@\n- 'legacy',", + }]; + const declaring = (value, files) => ({ pr: 13910, card: 13476, draft: false, cardLabels: [], prLabels: [], cardComments: [CLAIM(`Clause-②: ${value}`)], files }); + t('only a card that DECLARED `no` owes a changed-file listing', needsWideningRead(declaring('no', null)) === true); + t('⛔ a `yes` owes none — it already routes to contract review', needsWideningRead(declaring('yes', null)) === false); + t('⛔ a card with a claim comment but NO declaration owes none — that is C2\'s row, not C5\'s', needsWideningRead({ cardComments: [CLAIM('Domain: `domain:engine`')] }) === false); + t('⛔ an UNREADABLE thread owes none — a second reader of the same limb is the drift this file avoids', needsWideningRead({ cardComments: null }) === false); + t('a widening tell on a `no` pair is a C5 row', typeof c5WideningTell(declaring('no', WIDENS), 'objectstack-ai/objectstack') === 'string'); + t('…naming the file:line', says(c5WideningTell(declaring('no', WIDENS), 'objectstack-ai/objectstack'), 'packages/spec/src/kernel/plugin.zod.ts:95')); + t('…and carrying the card\'s own refusal sentence, unparaphrased', says(c5WideningTell(declaring('no', WIDENS), 'objectstack-ai/objectstack'), REFUSAL_SENTENCE)); + t('…and the never-writes boundary every other row carries', says(c5WideningTell(declaring('no', WIDENS), 'objectstack-ai/objectstack'), '自查放行')); + t('⛔ the SAME diff with `yes` is not a row — a tell never blocks the honest declaration', c5WideningTell(declaring('yes', WIDENS), 'objectstack-ai/objectstack') === null); + t('⛔ a removal-only diff with `no` is not a row — the ruling is directional', c5WideningTell(declaring('no', NARROWS), 'objectstack-ai/objectstack') === null); + t('a `no` pair whose listing could NOT be read is UNJUDGED, never clean', typeof wideningUnjudged(declaring('no', null), 'objectstack-ai/objectstack') === 'string'); + t('…and it is not also a row — unread is not adverse', c5WideningTell(declaring('no', null), 'objectstack-ai/objectstack') === null); + t('⛔ a `yes` pair with no listing is NOT unjudged — it never owed one', wideningUnjudged(declaring('yes', null), 'objectstack-ai/objectstack') === null); + t('a sweep pair (files never fetched) that declared `no` reads as owing the listing', needsWideningRead({ cardComments: [CLAIM('Clause-②: no')] }) === true); + t('the reader roster carries the sixth read, so both readers must implement it', READER_METHODS.includes('readPullFiles')); + t('…and the offline document serves it from its own `files` bag', typeof pairJsonReader({ pulls: [], files: { 13910: [] } }).readPullFiles === 'function'); + battery('the exit register is distinct in every direction it must be'); const codes = [EXIT_OK, EXIT_USAGE, EXIT_INCOMPLETE, EXIT_PREREQUISITE_NOT_MET, EXIT_PAIR_ADVERSE]; t('every exit code is distinct — a verdict can never be read as an environment complaint', new Set(codes).size === codes.length, JSON.stringify(codes)); t('the adverse-pair code is NOT the prerequisite code', EXIT_PAIR_ADVERSE !== EXIT_PREREQUISITE_NOT_MET); t('the prerequisite code is the sibling\'s, imported rather than re-picked', EXIT_PREREQUISITE_NOT_MET === 3); + // The widening gate is a second file with its own exits; a seat reading `$?` + // must read ONE table. The pin is written HERE, on the importing side, so the + // two modules stay acyclic. + t('the widening gate\'s REFUSED is this file\'s adverse-pair code', WT_EXIT_REFUSED === EXIT_PAIR_ADVERSE); + t('…its INCOMPLETE is this file\'s INCOMPLETE', WT_EXIT_INCOMPLETE === EXIT_INCOMPLETE); + t('…and its OK and USAGE agree too', WT_EXIT_OK === EXIT_OK && WT_EXIT_USAGE === EXIT_USAGE); // -- The floor: every declared battery RAN, and ran its cases (#13489) ----- // @@ -2306,7 +2549,7 @@ async function main(argv) { let swept = 0; try { - const { pulls, pairs } = await gather(repo, only, reader); + const { pulls, pairs } = await gather(repo, only, reader, { readFiles: only !== null }); swept = pairs.length; if (only !== null) { if (pairs.length === 0) { @@ -2320,7 +2563,7 @@ async function main(argv) { } let worst = EXIT_OK; for (const p of pairs) { - const code = renderPair(p); + const code = renderPair(p, repo); if (code !== EXIT_OK) worst = code === EXIT_INCOMPLETE && worst === EXIT_PAIR_ADVERSE ? worst : code; } return worst; From 9f33c3a3242f5d37c220fd4a1daf8db8a462f620 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:31:39 +0000 Subject: [PATCH 3/4] fix(pm): a binary change to a tell surface is UNREAD, not narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `splitUnifiedDiff` wrote `additions: addedLines(patch).length` for every row, and `addedLines(null)` is empty — so a BINARY change stamped a count nobody took. `unreadFiles` skips a row that added nothing, so a binary edit to `packages/spec/api-surface/*.json` arriving through the local path reported `state: 'clean'`: the gate's own contract, declared and not enforced, inside the gate. The three states are now told apart by what the diff SAYS — a hunk gives the count, a `Binary files`/`GIT binary patch` marker gives `null` (UNKNOWN), and neither gives a real `0` (a mode-only change or a pure rename adds no line). `addedNothing` interprets both input paths in one place, so a MISSING count can never become a zero; GitHub's own `additions: 0` on a binary row is kept, because that reading was taken by something that can see the blob. The self-test pinned the two halves separately and never composed them, which is why it stayed green. Seven composed cases now drive splitUnifiedDiff into wideningRefusal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX --- scripts/pm/check-widening-tells.mjs | 137 ++++++++++++++++++++++++---- 1 file changed, 119 insertions(+), 18 deletions(-) diff --git a/scripts/pm/check-widening-tells.mjs b/scripts/pm/check-widening-tells.mjs index 40decab931..d951ee5bea 100644 --- a/scripts/pm/check-widening-tells.mjs +++ b/scripts/pm/check-widening-tells.mjs @@ -67,6 +67,15 @@ * So a fixture whose added line merely LOOKS like a schema key is refused, and * the remedy is one word in the claim comment — never a weakened rule here. * + * **An unread diff is not a narrow diff.** A file on a tell surface whose + * content this gate could not read is reported as a GAP (exit 2), never folded + * into the clean verdict — and the counting that decides it distinguishes a + * count that was TAKEN from a count that is MISSING (`addedNothing`). The + * failure this rule is written against is not hypothetical: the local diff + * splitter briefly stamped `additions: 0` on binary rows, which made a binary + * edit to a published-surface file read as clean through the very gate whose + * contract this is. + * * **It writes nothing and hangs no label.** Same call `check-clause2-carriers` * and `check-half-states` make: a checker that hung `needs:contract-review` * would be issuing the review verdict, which is 自查放行. ⛔ No new label and no @@ -172,7 +181,8 @@ const ROOT = fileURLToPath(new URL('../..', import.meta.url)); // must not red. A battery BELOW its floor means cases stopped running. const SELF_TEST_BATTERIES = Object.freeze({ 'the patch reader: added lines, and the line numbers they carry': 17, - 'the unified-diff splitter, for the local `git diff` path': 11, + 'the unified-diff splitter, for the local `git diff` path': 15, + 'the local path composed: an unread diff is not a narrow diff': 7, 'the surfaces, imported rather than restated': 11, 'T1 — a new key on a Zod object schema': 14, 'T2 — a new member of a closed set': 13, @@ -187,7 +197,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 12; +const SELF_TEST_BATTERY_FLOOR = 13; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -356,6 +366,18 @@ export function addedLines(patch) { const DIFF_GIT = /^diff --git a\/(.+?) b\/(.+)$/; +/** + * How `git diff` says "there is no text hunk because the content is BINARY". + * + * Both spellings, because both reach this reader: the default one-line + * `Binary files a/x and b/x differ`, and the `GIT binary patch` block a + * `--binary` diff emits instead. A file with neither marker AND no hunk is a + * mode-only change or a pure rename — those really do add nothing, and telling + * them apart from a binary is the whole point of reading the marker rather than + * inferring from the missing hunk. + */ +const BINARY_MARKER = /^(?:Binary files .* differ|GIT binary patch)$/m; + /** * Split a whole `git diff` into the per-file rows this gate judges. * @@ -364,10 +386,27 @@ const DIFF_GIT = /^diff --git a\/(.+?) b\/(.+)$/; * The row shape is GitHub's (`filename`, `status`, `patch`) so nothing * downstream can tell the two paths apart, which is what stops them drifting. * - * A file with a `diff --git` header and no hunk yields `patch: null` — a - * binary or mode-only change, which is UNREAD rather than empty. The caller - * decides whether an unread file on a tell surface is a gap; ⛔ this function - * never turns one into a clean reading. + * A file with a `diff --git` header and no hunk yields `patch: null`. Whether + * that is UNREAD or genuinely EMPTY is read off the diff itself, never guessed: + * a `Binary files … differ` / `GIT binary patch` marker means git could not show + * the content, so `additions` is `null` (UNKNOWN) and the caller reports a gap + * on a tell surface; no marker and no hunk means a mode-only change or a pure + * rename, which really did add nothing, so `additions` is `0` and the row is + * clean. ⛔ This function never turns an unread file into a clean reading — and + * the way it used to was by stamping `addedLines(null).length` on every row, + * which wrote `0` for a binary change and let a binary edit to + * `api-surface/*.json` pass as narrow. + * + * ## Why the two input paths differ here, and why that is not drift + * + * On the API path GitHub sends `additions: 0` for a binary row, and this file + * KEEPS it: that zero is GitHub's own reading of its own object store, taken by + * something that can see the blob. The local path has strictly less + * information — `git diff` refused to show the content, and nothing downstream + * can recover it — so it answers `null`. Same field, two producers, two + * genuinely different states of knowledge; the asymmetry is *information + * available*, not two readers drifting apart. `addedNothing` is where both are + * interpreted, once. */ export function splitUnifiedDiff(text) { const rows = []; @@ -376,11 +415,28 @@ export function splitUnifiedDiff(text) { const flush = () => { if (!current) return; const body = current.body.join('\n'); - const patch = HUNK_HEADER.test(body) || /\n@@ /.test(`\n${body}`) ? body : null; + const hasHunk = HUNK_HEADER.test(body) || /\n@@ /.test(`\n${body}`); + const patch = hasHunk ? body : null; // `additions` is carried so the local path answers "did this file add // anything" in the SAME field the API path answers it in — the gap // accounting below reads one field, not one per input path. - rows.push({ filename: current.filename, status: current.status, patch, additions: addedLines(patch).length }); + // + // ⛔ And it is a COUNT THAT WAS TAKEN, never a default. `addedLines(null)` + // returns an empty array, so writing `addedLines(patch).length` for every + // row would stamp `0` on a BINARY file — a number nobody counted — and the + // gap accounting, which skips a row that added nothing, would then read a + // binary change to a tell surface as CLEAN. That is the exact + // declared-but-not-enforced shape this gate exists against, inside the gate + // itself. So the three cases are told apart by what the diff SAYS: + // + // a hunk -> the count, taken from the hunk + // a binary marker -> `null`, i.e. UNKNOWN — git did not show the content, + // so this reader cannot say whether anything was added + // neither -> `0`, a real reading: a mode-only change or a pure + // rename adds no line, and git says so by emitting + // no hunk AND no binary marker + const additions = hasHunk ? addedLines(patch).length : BINARY_MARKER.test(body) ? null : 0; + rows.push({ filename: current.filename, status: current.status, patch, additions }); current = null; }; for (const raw of text.split('\n')) { @@ -497,31 +553,50 @@ export function tellsInFile(file, { repo = THIS_REPO } = {}) { return rows; } +/** + * Did this row add NOTHING — as a fact this reader can point at? + * + * ⭐ The asymmetry is the safety property, and it is the same one the sibling's + * `--pair-json` reader has: a MISSING count is not a zero. `additions: 0` is a + * reading somebody took — GitHub's own on the API path, this file's hunk count + * on the local one — and a row that added nothing owes no patch, so skipping it + * keeps every rename and mode-only change out of the gap list. `null` or an + * absent field is the ABSENCE of that reading, and an absent reading can never + * be turned into "nothing was added" here. + * + * The one inference this function does make is narrow and named: a row whose + * status is `renamed`, which carries NO count at all and NO patch, is a pure + * rename. That shape only reaches this file from a hand-assembled document — + * both real input paths carry a count — and a pure rename genuinely adds + * nothing. + */ +export function addedNothing(file) { + if (typeof file?.additions === 'number') return file.additions === 0; + if (file?.additions != null) return false; // a non-number count is no count. + return file?.status === 'renamed' && (file?.patch == null || file.patch === ''); +} + /** * A file this gate had to read and could not. * * Only a file ON a tell surface owes a patch: an unread `README.md` decides * nothing here, and reporting it would bury the readings that matter. A file - * that IS on a surface and arrived with no patch is a gap, because "no added - * line matched" and "no line was read" are the two states #4690 is about. + * that IS on a surface, added something (or might have), and arrived with no + * patch is a gap — because "no added line matched" and "no line was read" are + * the two states this whole family keeps apart. */ export function unreadFiles(files, { repo = THIS_REPO } = {}) { const gaps = []; for (const file of files ?? []) { const filename = String(file?.filename ?? ''); if (filename === '' || file?.status === 'removed') continue; - // A file that ADDED NOTHING owes no patch. `additions: 0` is how both - // input paths spell a pure rename and a mode-only change, and reporting - // those as unread would bury the gaps that are real (a truncated patch on - // a file that did add lines) under one every rename produces. - if (typeof file?.additions === 'number' && file.additions === 0) continue; - if (file?.status === 'renamed' && (file?.patch == null || file.patch === '')) continue; + if (addedNothing(file)) continue; + if (typeof file?.patch === 'string' && file.patch !== '') continue; const onSurface = (surfaceCovers(CONTRACT_SOURCE_SURFACES, filename, repo) && isContractSourceFile(filename)) || surfaceCovers(PUBLISHED_SURFACES, filename, repo) || surfaceCovers(REGISTRATION_SURFACES, filename, repo); if (!onSurface) continue; - if (typeof file?.patch === 'string' && file.patch !== '') continue; gaps.push(filename); } return gaps; @@ -783,6 +858,31 @@ export function selfTest() { t('…and null is what `unreadFiles` counts as a gap when it is on a surface', unreadFiles([{ filename: 'packages/spec/api-surface/kernel.json', patch: null }]).length === 1); t('⛔ a file OFF every surface with no patch is not a gap — it decides nothing here', unreadFiles([{ filename: 'README.md', patch: null }]).length === 0); t('the local path and the API path produce the same verdict on the same bytes', JSON.stringify(wideningTells(splitUnifiedDiff(twoFiles)).map((r) => r.tell)) === '["T1"]'); + // `additions` is a COUNT THAT WAS TAKEN. The three states below are told + // apart by what the diff SAYS, and conflating them is what let a binary + // change to a tell surface read as clean. + t('⛔ a binary row carries additions `null` — UNKNOWN, never a fabricated 0', splitUnifiedDiff('diff --git a/i.png b/i.png\nBinary files a/i.png and b/i.png differ')[0]?.additions === null); + t('…and the `GIT binary patch` spelling reads as UNKNOWN too', splitUnifiedDiff('diff --git a/i.png b/i.png\nGIT binary patch\nliteral 0\nHcmV?d00001')[0]?.additions === null); + t('a MODE-ONLY change carries a real 0 — no hunk AND no binary marker is git saying nothing was added', splitUnifiedDiff('diff --git a/x b/x\nold mode 100644\nnew mode 100755')[0]?.additions === 0); + t('a pure rename carries a real 0 for the same reason', splitUnifiedDiff('diff --git a/x b/y\nsimilarity index 100%\nrename from x\nrename to y')[0]?.additions === 0); + + // -- the local path composed, end to end ---------------------------------- + // + // The two halves below were each pinned separately before, and the defect + // lived exactly between them: `patch: null` was asserted on one fixture and + // "null is a gap" on a DIFFERENT, hand-built row that carried no `additions` + // at all — so nothing drove a real binary row through `unreadFiles`. These + // cases compose the actual functions, in the order a caller calls them. + battery('the local path composed: an unread diff is not a narrow diff'); + const composed = (diff) => wideningRefusal({ declaration: 'no', files: splitUnifiedDiff(diff) }); + const BINARY_ON_SURFACE = 'diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json\nindex 111..222 100644\nBinary files a/packages/spec/api-surface/kernel.json and b/packages/spec/api-surface/kernel.json differ'; + t('⭐ a BINARY change to a tell surface reads INCOMPLETE, never clean', composed(BINARY_ON_SURFACE).state === 'incomplete'); + t('…and maps to exit 2, the one exit that must never be mistaken for 0', exitForRefusal(composed(BINARY_ON_SURFACE)) === EXIT_INCOMPLETE); + t('…naming the file whose content could not be read', composed(BINARY_ON_SURFACE).gaps[0] === 'packages/spec/api-surface/kernel.json'); + t('⛔ but a binary change OFF every surface is clean — it decides nothing here', composed('diff --git a/docs/logo.png b/docs/logo.png\nBinary files a/docs/logo.png and b/docs/logo.png differ').state === 'clean'); + t('a MODE-ONLY change to a tell surface is clean — it really did add nothing', composed('diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json\nold mode 100644\nnew mode 100755').state === 'clean'); + t('addedNothing: a MISSING count is never a zero', addedNothing({ filename: 'x', additions: null }) === false && addedNothing({ filename: 'x' }) === false); + t('…while a count that WAS taken is one', addedNothing({ filename: 'x', additions: 0 }) === true && addedNothing({ filename: 'x', additions: 3 }) === false); // -- the surfaces --------------------------------------------------------- battery('the surfaces, imported rather than restated'); @@ -964,7 +1064,8 @@ export function selfTest() { `✓ check-widening-tells self-test: ${cases.length} cases pass (the patch reader with its ` + 'line-number directions, the unified-diff splitter, the three imported/declared surfaces, the ' + "four tells, #16448's four positive controls each with its file:line, its negative controls — " + - 'the same diffs with `yes`, and a removal-only diff with `no` — and the exit register).', + 'the same diffs with `yes`, and a removal-only diff with `no` — the local path composed end ' + + 'to end so a binary change to a tell surface cannot read as clean — and the exit register).', ); selfTestReachedVerdict = true; From dc4437aa1298ffe08dcfc01a9822f66fd7306818 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:46:11 +0000 Subject: [PATCH 4/4] fix(pm): drop the no-path-population marker the widening-tell gate cannot honour The gate's CI command is its own --self-test, so on the marker's own second listed cause the declaration read true. dispatch-gates' live guard refuses it anyway the moment a family NAMES paths, and this one names 59: 9 from its module body (the registry table, the objectui mirror glob, two repo slugs, four fixture filenames) and 50 inherited from the two registers it imports on purpose, SUSPECT_TIER_GLOBS and REGEN_ARTIFACTS. Getting those out of a scanned position would mean hand-copying two registers this file imports precisely so it can never disagree with them - the drift check:pm-governed-prose exists to stop, and this gate's strongest property. So the marker goes and the derivation stands. The header now records what the derivation says, which part of it is right (packages/spec/src/**, api-surface/**, the three registries - the surfaces this gate polices) and which part is noise inherited from REGEN_ARTIFACTS, and why one cheap self-test in a MATCHED column is the smaller error than a marker sitting above a live population. The case that catches this sits at ~1534 of dispatch-gates' self-test and needs well over 540s to reach, which is why three local runs missed it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX --- scripts/pm/check-widening-tells.mjs | 42 ++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/pm/check-widening-tells.mjs b/scripts/pm/check-widening-tells.mjs index d951ee5bea..e362bf9731 100644 --- a/scripts/pm/check-widening-tells.mjs +++ b/scripts/pm/check-widening-tells.mjs @@ -158,7 +158,47 @@ * diff, and failing an unrelated PR's CI over it would punish the wrong actor. */ -// dispatch-gates: no-path-population -- this gate reads no file in the tree as its input: it judges a PR's DIFF, supplied by its caller from the API or from `git diff`, so no card's file surface can predict it. The path globs in its module body are patterns matched against diff filenames, never files it opens (#16448) +// ⛔ NO `dispatch-gates: no-path-population` MARKER HERE — deliberately, and the +// reasoning is worth the paragraph because the marker LOOKS right. +// +// This gate's CI command is its own `--self-test`, which is the second of the +// three causes that marker's docblock lists ("the derivation NEED NOT place it"). +// On that reading the declaration is true: no card's file surface should +// schedule this command, because running it says nothing about that card's diff. +// +// But the marker is refused by `dispatch-gates`'s live guard the moment a family +// NAMES paths, and this one names 59 of them (measured on this tree): 9 from its +// own module body — the registry table, the objectui mirror glob, the two repo +// slugs and four fixture filenames — and 50 inherited from the two registers it +// imports ON PURPOSE, `SUSPECT_TIER_GLOBS` and `REGEN_ARTIFACTS`. That guard's +// own text gives the fork: "If the literals are the real population, delete the +// marker and let the matched column do its job; if they are artifacts rather +// than a population, the marker stands and the literals do not belong in a +// scanned position." +// +// Both halves of getting them out of scanned positions cost more than they buy: +// the 9 would have to become segment predicates instead of paths, and the 50 +// would have to become a HAND COPY of two registers this file imports precisely +// so it can never disagree with them — which is the drift +// `check:pm-governed-prose` exists to stop one family over, and the single +// strongest property this gate has. So the marker goes and the derivation +// stands. +// +// What the derivation now says, and why it is not wrong: a card touching any of +// those 59 paths gets `pnpm check:pm-widening-tells` in its MATCHED column. For +// `packages/spec/src/**`, `packages/spec/api-surface/**` and the three +// registries that is exactly right — they are the surfaces this gate polices, +// and a dev editing one is the dev whose claim it will judge. For the tail +// inherited from `REGEN_ARTIFACTS` (`*/test-typecheck-debt.json`, +// `content/docs/references/**`, and the rest) it is noise, and the cost of that +// noise is bounded and small: the command is an offline self-test that runs in +// about a second and whose green means "the tells still work". An +// over-matched gate pastes one cheap command into a prompt; the alternative was +// a marker sitting above a live population, which is the rot direction +// `dispatch-gates` measured and refused. ⛔ Do not re-add the marker without +// first removing the imports — a green local run is not evidence, because the +// case that catches this sits at ~1534 of the self-test's assertions and needs +// well over 540s to reach (#16448 patch round 2). import process from 'node:process'; import { existsSync, readFileSync } from 'node:fs';