From 4fb90382b735321d137c30649a854c7c49bb847e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:24:31 +0000 Subject: [PATCH] fix(tooling): derive the prerequisite gate name by stripping the module extension, not only `.mjs` The refusal frame prints two different strings: the gate's NAME in the headline and the PATH the reader runs. The name was derived with a `.mjs`-only strip written when every importer was `.mjs`, so the first TypeScript importer (`scripts/check-exported-any-returns.mts`) printed its extension in the one message a reader is meant to quote back. The extension comes off, and the rule is now stated rather than left as a regex: `GATE_MODULE_EXTENSIONS` declares the two module extensions this repo's gate corpus uses, and `gateNameOf` removes whichever one the file has. The test that settles the direction is what the reader has to type, and it does not point at the extension either way -- the runnable string is `command`, which keeps the real path with its real extension, and the way a reader re-runs the gate is its package script (`check:exported-any-returns`), which has no extension at all. The PATH half is deliberately unmoved: stripping it would hand the reader a file that does not exist. Five self-test cases pin the pair on one fixture -- a `.mts` gate and a `.mjs` sibling differing only in extension -- so neither half can drift without a red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- scripts/import-prerequisite.mjs | 79 ++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/scripts/import-prerequisite.mjs b/scripts/import-prerequisite.mjs index 2b53b6693e..ef5f4df360 100644 --- a/scripts/import-prerequisite.mjs +++ b/scripts/import-prerequisite.mjs @@ -875,12 +875,61 @@ export function reportPrerequisiteNotMet(importerUrl, verdict, measures) { process.exit(EXIT_PREREQUISITE_NOT_MET); } +/** + * The module extensions a gate in this repo is written in. The corpus is + * exactly these two, and `check-ratchet-remedy-authority.mjs` already declares + * the same pair for the same reason. Declared as a list rather than inlined as + * a character class so the rule below reads as a rule, and so widening it is an + * edit someone has to mean. + */ +const GATE_MODULE_EXTENSIONS = ['.mjs', '.mts']; + +/** + * The gate's NAME: its basename with the module extension removed, whichever of + * the two it is. + * + * The identifier in the headline and the command the reader runs are two + * different strings -- the block below says so, and this is the half that is a + * NAME. The extension comes off for `.mts` as it always has for `.mjs`, and the + * test that settles it is what the reader actually has to type: + * + * - The reader never types this identifier. The runnable string is `command`, + * built by `importerCommandPath`, which keeps the real path WITH its real + * extension (`node scripts/check-exported-any-returns.mts`) and is already + * correct for both spellings. + * - What the reader types to RE-RUN the gate is its package script -- + * `pnpm --filter @objectstack/client check:exported-any-returns` -- which + * carries no extension at all. A `.mts` gate runs through `tsx`, not `node`, + * so its file extension is not the reader's entry point either. + * + * So an extension here is not information the reader needs; it only appeared + * because a strip written when every gate was `.mjs` stopped matching the file + * family that arrived later. The rule is stated here rather than left as a + * regex because the other defensible reading -- keep the extension and call the + * `.mjs` gates the inconsistent ones -- would change what EVERY importer prints, + * which is a different change from this one. + * + * ⛔ Not a path: a basename, always. `prerequisiteNotMetText`'s own comment and + * the self-test below both hold that boundary, and it is unchanged here. + * + * The `X.mjs` + `X.d.mts` pairs in this tree are NOT a name collision waiting + * to happen: a `.d.mts` is a type declaration beside its implementation, holds + * no runtime code and is never executed, so it never imports this module and + * never reaches this function. ⛔ Do not add a `.d` case for it — pinning an + * input that cannot occur is the phantom check AGENTS.md warns about. + */ +function gateNameOf(importerUrl) { + const base = fileURLToPath(importerUrl).split('/').pop(); + const ext = GATE_MODULE_EXTENSIONS.find((e) => base.endsWith(e)); + return ext ? base.slice(0, -ext.length) : base; +} + /** * The text `reportPrerequisiteNotMet` prints, as a value — so the self-test can * assert on the advisory without spawning a process or stubbing `process.exit`. */ function prerequisiteNotMetText(importerUrl, verdict, measures) { - const gate = fileURLToPath(importerUrl).split('/').pop().replace(/\.mjs$/, ''); + const gate = gateNameOf(importerUrl); // The path to RUN and the name to CALL IT BY are two different strings, and // only the first moves. ⛔ The `/tmp/${gate}.log` sink below keeps the // BASENAME on purpose: a repo-relative path there would spell @@ -1197,6 +1246,34 @@ export function selfTest() { advisoryFor(lintGate).includes('> /tmp/check-doc-formula-expressions.log 2>&1') && !advisoryFor(lintGate).includes('/tmp/packages/lint')); + // (g) The module extension comes off for `.mts` exactly as it always did + // for `.mjs`. Pinned as a PAIR on the same fixture — one `.mts` gate and + // one `.mjs` gate differing only in extension — because a single + // observation cannot tell "the extension was stripped" from "there was + // never one to strip", which is the discrimination this card turned on. + const mtsGate = join(wt, 'scripts', 'check-fixture-any-returns.mts'); + const mjsSibling = join(wt, 'scripts', 'check-fixture-any-returns.mjs'); + t('a `.mts` importer is named WITHOUT its extension', + advisoryFor(mtsGate).includes('\ncheck-fixture-any-returns: PREREQUISITE NOT MET') + && !advisoryFor(mtsGate).includes('check-fixture-any-returns.mts: PREREQUISITE'), + advisoryFor(mtsGate)); + t('CONTROL: its `.mjs` sibling prints the SAME name, so the identifier is the gate\'s, not the file\'s', + advisoryFor(mjsSibling).includes('\ncheck-fixture-any-returns: PREREQUISITE NOT MET') + && !advisoryFor(mjsSibling).includes('check-fixture-any-returns.mjs: PREREQUISITE'), + advisoryFor(mjsSibling)); + + // ⛔ The half that must NOT move with it. The command is a PATH: strip its + // extension and the reader is handed a file that does not exist — the same + // defect this module already fixed once, relocated one token to the right. + t('⛔ but the COMMAND still carries the real `.mts` extension — a path, not a name', + advisoryFor(mtsGate).includes('`node scripts/check-fixture-any-returns.mts > '), + advisoryFor(mtsGate)); + t('and the `.mjs` sibling\'s command keeps ITS real extension too', + advisoryFor(mjsSibling).includes('`node scripts/check-fixture-any-returns.mjs > ')); + t('the /tmp log sink follows the NAME, so it carries no extension either', + advisoryFor(mtsGate).includes('> /tmp/check-fixture-any-returns.log 2>&1'), + advisoryFor(mtsGate)); + // ── the CLOSURE: every declared prerequisite, in one command ──────────── // // The acceptance this card was ruled on: what is pinned is the printed