Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions scripts/pm/dispatch-gates.data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,30 @@ export const CHANGE_KIND_ROWS = [
name: 'check:dispatcher-error-vocabulary',
why: 'it sweeps the non-test TypeScript sources under the package root for every site that stamps an error code, and reports each value the registered vocabulary (StandardErrorCode joined with ERROR_CODE_LEDGER) does not contain — so a code arriving through a quoted literal, a SCREAMING_SNAKE constant, a typeof reference to one, or a template moves it. This is the gate no path derivation can name: it computes its own population from a bare top-level root, which the bare-root ledger records as REFUSE-WIDE at 39% of the tracked tree, so it scores the same quiet silence for every card and #12843 paid a CI round trip for that silence. It needs NO build — a source scan, one pass, whole tree, and it names the file and line. Repair by REGISTERING the code where the vocabulary is declared, never by widening a consumer to tolerate it; reconciliation runs BOTH ways, so a table row whose site is gone fails too, and a pending-registration row whose code has since been registered fails as the discharge it is. ⚠ This lead is deliberately WIDE — it fires on a file that merely carries a code-shaped value, not only one that adds a new one — because the wasted run is one cheap gate and the miss is a CI round trip',
},
{
name: 'check:error-code-provenance',
why: "the vocabulary gate's population-disjoint sibling: it reads the SAME stamp sites in every package's non-test src, keeps only the codes the vocabulary DOES register, and fails when the stamping package's own owner key in the ADR-0112 ledger does not list the code and no PROVENANCE_WAIVERS entry names the owner that holds it instead. So a registered code stamped in a package that never stamped it before moves it, and so does a constant BOUND to one (a constant is read at its definition, whatever its name, even one that is only ever compared against). No path derivation names it: its only declared literals are the ledger file, so the derivation files it under the artifact rosters and scores it silent for every card, and PR #31214 paid a CI round trip for that silence on a scenarios-package constant. It needs NO build, being one source scan over the whole tree, and it names the package, the code and the site. Repair by listing the code under the stamping package's owner key, or by not stamping it (compare against the catalogue's spelling instead of holding it in a constant); a waiver is a recorded ruling, never a quick fix. ⚠ The lead is this entry's predicate, wide for the vocabulary gate's reason: at fe97d85b it reached all 162 files holding one of the gate's 513 registered-code sites",
},
],
},
{
kind: 'adds or edits a file carrying a CLI command-id literal outside the CLI package, or a CLI command module or manifest (judged from CONTENT — no path derivation can name this gate)',
matches: 'cli-command-literal',
gates: [
{
name: 'check:cli-command-ids',
why: "every quoted run that opens with a CLI binary name and a space (a command id such as the migrate-apply one, and just as much the Portuguese word for administrators after that binary name in a translated message) in non-test source outside the CLI package must resolve to a real oclif command id or topic; and every module the CLI emits under its commands directory must default-export a command class. So a new or edited literal anywhere in package source moves it, and so does renaming, adding or deleting a command module. No path derivation names it: the literal population is a `src/` path SEGMENT in any package, a shape no subtree spelling covers, and its only declared root is `scripts/**`, so it scored silent for package source and for the commands directory alike, and PR #30734 paid a CI round trip for that silence on a pt-BR string fragment. It needs NO build, about 22 seconds, and it names the file, the line and the literal. Repair by spelling the real command id (or, for prose that merely starts with the binary's word, rephrasing so the binary name is not the first thing in the quotes); the exemption ledger is for a checker's own fixtures and filed real defects, never a quick fix. Derived by CONTENT rather than for every diff: the gate is unconditional in CI but not cheap enough to name on a card that cannot move it",
},
],
},
{
kind: 'edits a file an ADR anchors by symbol, or moves or deletes a file an ADR anchors at all (read from the ADR corpus — no path derivation can name this gate)',
matches: 'adr-anchored-file',
gates: [
{
name: 'check:adr-symbol-anchors',
why: "every `path#Symbol` anchor in docs/adr/** must still resolve to a declaration (or a literal) of that name in that file, and every file-level anchor to a tracked file. So removing or renaming an anchored declaration reds it, and so does moving or deleting any anchored file, from a diff that never touches an ADR. No path derivation names it for those diffs: its declared population is docs/adr/** alone, and PR #30749 paid a CI round trip and a landing tier for that silence when it retired a constant ADR-0012 anchored. It needs NO build, about 7 seconds, and it names the record, the line and the anchor. Repair by re-anchoring the ADR where the mechanism now lives, or by keeping the symbol; ⚠ if the ADR SENTENCE has become untrue, that is its own card, and ⛔ an `anchor-exempt` marker is MAINTAINER-ONLY. ⚠ The symbol limb fires on any edit of a symbol-anchored file, not only one that removes the declaration, because the wasted run is one cheap gate and the miss is a CI round trip",
},
],
},
{
Expand Down
202 changes: 202 additions & 0 deletions scripts/pm/dispatch-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,12 @@ import { GOVERNED_REPOS, HUMAN_MERGE_LINE_THRESHOLD, SELF_REPO_ID, parseNumstat,
// published source and which are its tests — never respelled here (#19936). See
// SUSPECT_TIER_GLOBS for why this predicate and not one of the repo's others.
import { isTestPath } from '../check-undeclared-dep-imports.mjs';
// The ADR anchor gate's corpus registration and the shared extractor it sweeps
// with: the change kind that reads them (#30763) reads exactly the anchors that
// gate judges, never a respelling of them. Both modules are dependency-free, so
// this tool still runs in a checkout with no install.
import { CORPUS as ADR_ANCHOR_CORPUS } from '../check-adr-symbol-anchors.mjs';
import { extractAnchors } from '../symbol-anchors.mjs';
// The hand-written tables — ledgers, marker grammars, the change-kind roster, the tier globs — are
// DATA, and live beside this file; this engine loads them, matches paths against them and derives.
// Every table is re-exported under the name it always had, so no consumer moved.
Expand Down Expand Up @@ -10801,6 +10807,200 @@ export function guardsADurabilityCriticalCall(path, readSource = readTrackedSour
return new RegExp(`\\b(?:${names.map((n) => n.replace(/\$/g, '\\$')).join('|')})\\b`).test(masked);
}

/**
* Does this path carry a CLI command-id literal outside the CLI package, or sit
* on a CLI's own command surface? This is the content trigger for
* `check:cli-command-ids` (#30763), the fourth CONTENT predicate.
*
* ## Why no path derivation names this gate
*
* The gate judges two populations. Its literal duty reads every non-test JS/TS
* source outside each oclif package whose path has a `src` SEGMENT anywhere,
* plus the whole `scripts` root. Only the second is spelled as a root it can
* declare, so a card touching package source scored silent. Its command duty
* reads each CLI's commands directory, which it spells as a package-relative
* constant no hint can anchor, so a card renaming a command scored silent too.
* PR #30734 paid a CI round trip for the first silence: a pt-BR string fragment
* opening with the binary's word, in plugin-auth source.
*
* ## Why by content, and not for every diff
*
* The step is unconditional in lint.yml, so "always" would be true. It is not
* cheap: about 22 seconds at fe97d85b, against about 7 for the ADR anchor gate
* and 6 for the provenance gate. So this predicate reads what the gate reads:
*
* - LITERAL: a non-test JS/TS file outside every CLI package holding a quote
* followed by a declared binary name, a space and a lowercase word or digit.
* That is a net around the gate's own literal reader, never a copy of it:
* it skips no comment line and resolves no id, so a shape the gate adds
* later is already inside it.
* - SURFACE: any path under a CLI package's commands source directory, and
* the CLI manifest itself. Judged from the PATH, so a deleted or renamed
* command module still counts.
*
* ⛔ The gate's own reader is NOT imported here: its module loads `typescript`
* at import time, and this tool runs in checkouts with no install. The
* self-test holds the net against that reader instead, in a child process: the
* binary names must agree, and every file in which the gate's reader finds a
* literal must be inside this net. A narrowing that loses one fails there.
*
* Measured at fe97d85b over 7002 non-test JS/TS files outside the CLI package:
* the gate's population holds a literal in 390 of them, and this net reaches
* all 390 and 34 more (19 under the `scripts` root the path derivation already
* names, 15 test helpers and lint scripts outside the gate's population).
*
* ## What it cannot see
*
* A file with nothing on disk answers false on the literal limb, as in the
* three predicates above. Deleting a literal can only stale an exemption, and
* every exempted file sits under the `scripts` root the path derivation
* already matches.
*/
const OCLIF_COMMANDS_SOURCE = ['src', 'commands'].join('/');
const CLI_POPULATION_EXTENSION = /\.(?:ts|tsx|js|mjs|cjs)$/;
const CLI_POPULATION_TEST = /\.(?:test|spec)\.[tj]sx?$/;
const COMMENT_LINE_START = /^\s*(?:\/\/|\*|\/\*)/;

/**
* The binary names a CLI manifest declares for itself: `oclif.bin` plus every
* `bin` key. The gate's own rule as its header states it, read wide (no name
* shape filter); the self-test holds it equal to the gate's reader.
*/
export function cliBinaryNames(pkg) {
const bin = pkg?.oclif?.bin;
if (typeof bin !== 'string' || bin === '') return [];
const keys = pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin) ? Object.keys(pkg.bin) : [];
return [...new Set([bin, ...keys])];
}

let cliSurfaces = null;
/**
* Every CLI package in the tree as `{ dir, bins }`, read from the tracked
* manifests that declare `oclif.bin` (the gate's rule for what a CLI is),
* memoised. A manifest that does not parse is skipped, as the gate skips one.
*/
export function cliCommandSurfaces(files = null) {
if (!files && cliSurfaces) return cliSurfaces;
const surfaces = [];
for (const file of files ?? trackedFiles()) {
if (!/(?:^|\/)package\.json$/.test(file)) continue;
let pkg;
try {
pkg = JSON.parse(readFileSync(nodePath.join(ROOT, file), 'utf8'));
} catch {
continue;
}
const bins = cliBinaryNames(pkg);
if (bins.length > 0) surfaces.push({ dir: nodePath.posix.dirname(file), bins });
}
if (!files) cliSurfaces = surfaces;
return surfaces;
}

const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

export function carriesACliCommandLiteral(path, readSource = readTrackedSource, surfaces = cliCommandSurfaces) {
const rel = path.replace(/^\.\//, '');
const clis = surfaces();
const own = clis.find((c) => c.dir !== '.' && rel.startsWith(`${c.dir}/`));
if (own) return rel === `${own.dir}/package.json` || rel.startsWith(`${own.dir}/${OCLIF_COMMANDS_SOURCE}/`);
if (!CLI_POPULATION_EXTENSION.test(rel)) return false;
if (CLI_POPULATION_TEST.test(rel) || rel.includes('/__tests__/')) return false;
const bins = [...new Set(clis.flatMap((c) => c.bins))];
// An empty alternation would match a quote and a space anywhere, so no
// declared binary must answer false rather than reach every file.
if (bins.length === 0) return false;
const source = readSource(rel);
if (source === null || source === undefined) return false;
const literal = new RegExp(`['"\`](?:${bins.map(escapeRegExp).join('|')}) [a-z0-9]`);
if (!literal.test(source)) return false;
// A line that OPENS as a comment is prose the gate does not read, and jsdoc
// that cites a command in backticks is most of what the net would catch
// otherwise. A trailing comment on a code line stays in, as it does there.
return source.split('\n').some((line) => !COMMENT_LINE_START.test(line) && literal.test(line));
}

/**
* Would a diff on this path break an ADR anchor? This is the trigger for
* `check:adr-symbol-anchors` (#30763), read from the ADR corpus rather than
* from the path's own content.
*
* ## Why no path derivation names this gate
*
* The gate's declared population is the ADR directory, and that is all a card
* touching an ADR needs. But an anchor reds from the OTHER end too: a
* `path#Symbol` anchor reds when its file stops declaring the symbol, and any
* anchor reds when its file moves or goes. PR #30749 retired a constant an ADR
* anchored and learned of it in CI, because its diff touched no ADR.
*
* ## What it matches
*
* The anchors are extracted by the shared extractor, with the gate's own corpus
* registration (its roots, document pattern, projection and grammar options),
* so this reads exactly the anchors the gate sweeps. Cross-repo anchors are
* left out, as the gate leaves them out of its red verdicts.
*
* - SYMBOL: a path some `path#Symbol` anchor names. Any edit counts, not only
* one that removes the declaration: deciding that needs the old and new
* text, and the wasted run is one seven-second gate.
* - FILE: a path any anchor names that has nothing on disk. That is a move or
* a delete, the only way a file-level anchor can red.
*
* Measured at fe97d85b: 3204 anchors across 150 records, naming 292 files by
* symbol and 1281 more at file level only.
*/
let adrTargets = null;
export function adrAnchorTargets(corpus = ADR_ANCHOR_CORPUS, root = ROOT) {
if (corpus === ADR_ANCHOR_CORPUS && root === ROOT && adrTargets) return adrTargets;
const symbolFiles = new Set();
const anchoredFiles = new Set();
const docs = [];
const walkDocs = (rel) => {
let entries;
try {
entries = readdirSync(nodePath.join(root, rel), { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const child = `${rel}/${e.name}`;
if (e.isDirectory()) {
if (!(corpus.excludeDirs ?? []).includes(e.name)) walkDocs(child);
} else if (corpus.docPattern.test(e.name)) docs.push(child);
}
};
for (const docRoot of corpus.docRoots) walkDocs(docRoot);
if (docs.length === 0) {
throw new Error(
`dispatch-gates: the ADR anchor corpus (${corpus.docRoots.join(', ')}) yielded no document, so the anchored files ` +
'cannot be read. The corpus moved: re-point this reader with it',
);
}
for (const doc of docs) {
const raw = readFileSync(nodePath.join(root, doc), 'utf8');
const { anchors } = extractAnchors(corpus.docProjection ? corpus.docProjection(raw) : raw, {
unspannedAnchors: corpus.unspannedAnchors ?? false,
pathlessLineCitations: corpus.pathlessLineCitations ?? false,
tildeRangeCitations: corpus.tildeRangeCitations ?? false,
});
for (const a of anchors) {
if (a.repo || !a.path) continue;
anchoredFiles.add(a.path);
if (a.symbol) symbolFiles.add(a.path);
}
}
const targets = { symbolFiles, anchoredFiles, docs: docs.length };
if (corpus === ADR_ANCHOR_CORPUS && root === ROOT) adrTargets = targets;
return targets;
}

export function breaksAnAdrAnchor(path, targets = adrAnchorTargets, onDisk = (p) => existsSync(nodePath.join(ROOT, p))) {
const rel = path.replace(/^\.\//, '');
const { symbolFiles, anchoredFiles } = targets();
if (symbolFiles.has(rel)) return true;
return anchoredFiles.has(rel) && !onDisk(rel);
}

/**
* Is this path inside the ROOT package's tsc program — the population the
* root's `typecheck:root` script checks (until the root graduated, the
Expand Down Expand Up @@ -11117,6 +11317,8 @@ const CHANGE_KIND_PREDICATES = Object.freeze({
'error-code-literal': stampsAnErrorCodeLiteral,
'http-status-emit': emitsAnHttpStatus,
'durability-seam': guardsADurabilityCriticalCall,
'cli-command-literal': (path) => carriesACliCommandLiteral(path),
'adr-anchored-file': (path) => breaksAnAdrAnchor(path),
'ui-scripts-suite-input': (path) => readByUiScriptsSuite(path),
});

Expand Down
Loading
Loading