diff --git a/scripts/check-entry-guard.mjs b/scripts/check-entry-guard.mjs index 7046f40d30..9fed65fe83 100644 --- a/scripts/check-entry-guard.mjs +++ b/scripts/check-entry-guard.mjs @@ -460,12 +460,7 @@ export function importUnsafeStatements(source) { const KNOWN_IMPORT_UNSAFE = new Set([ 'scripts/check-changeset-no-major.mjs', 'scripts/check-empty-changeset.mjs', - 'scripts/check-error-status-conformance.mjs', - 'scripts/check-query-options-erasure-ratchet.mjs', - 'scripts/check-release-page-status.mjs', - 'scripts/checklist-select.mjs', 'scripts/objectui-range.mjs', - 'scripts/pm/check-governed-prose.mjs', 'scripts/qa/qa-rollup.mjs', 'scripts/ts-parse.mjs', ]); diff --git a/scripts/check-error-status-conformance.mjs b/scripts/check-error-status-conformance.mjs index 028903e75b..dd3aa03d63 100644 --- a/scripts/check-error-status-conformance.mjs +++ b/scripts/check-error-status-conformance.mjs @@ -112,6 +112,7 @@ import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs'; import { maskComments } from './js-comment-mask.mjs'; import { join, relative } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; const SCAN_ROOT = 'packages'; const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage', 'build', 'fixtures']); @@ -1083,7 +1084,6 @@ function selfTest() { process.exit(0); } -if (process.argv.includes('--self-test')) selfTest(); // ─────────────────────────────────────────────────────────────────────────── // The real check @@ -1099,134 +1099,145 @@ function walk(dir, out) { } } -const update = process.argv.includes('--update'); - -const files = []; -walk(SCAN_ROOT, files); -const sources = new Map(); -for (const f of files.sort()) sources.set(relative('.', f).replace(/\\/g, '/'), readFileSync(f, 'utf8')); - -const errorsZod = readFileSync(ERRORS_ZOD, 'utf8'); -const members = parseStandardErrorCodes(errorsZod); -const index = buildConstantIndex(sources); -const derived = deriveRuntimeStatuses(sources, index); -for (const { code, status } of deriveDoorMap(errorsZod)) { - if (!derived.emitted.has(code)) derived.emitted.set(code, new Map()); - const perStatus = derived.emitted.get(code); - if (!perStatus.has(status)) perStatus.set(status, []); - if (!perStatus.get(status).includes(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`)) { - perStatus.get(status).push(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`); +function main() { + const update = process.argv.includes('--update'); + + const files = []; + walk(SCAN_ROOT, files); + const sources = new Map(); + for (const f of files.sort()) sources.set(relative('.', f).replace(/\\/g, '/'), readFileSync(f, 'utf8')); + + const errorsZod = readFileSync(ERRORS_ZOD, 'utf8'); + const members = parseStandardErrorCodes(errorsZod); + const index = buildConstantIndex(sources); + const derived = deriveRuntimeStatuses(sources, index); + for (const { code, status } of deriveDoorMap(errorsZod)) { + if (!derived.emitted.has(code)) derived.emitted.set(code, new Map()); + const perStatus = derived.emitted.get(code); + if (!perStatus.has(status)) perStatus.set(status, []); + if (!perStatus.get(status).includes(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`)) { + perStatus.get(status).push(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`); + } } -} -const doc = parseDocumentedStatuses({ - handling: readFileSync(DOC_HANDLING, 'utf8'), - catalog: readFileSync(DOC_CATALOG, 'utf8'), -}); -const { vocabulary, docPublishedBeyondStandard } = reconciledVocabulary({ members, ...doc }); -const result = reconcile({ vocabulary, emitted: derived.emitted, ...doc }); - -const baseline = existsSync(BASELINE_PATH) - ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) - : { unpinned: [] }; -const baselined = new Set(baseline.unpinned ?? []); -const newlyUnpinned = result.unpinned.filter((c) => !baselined.has(c)); -const nowPinnedFindings = nowPinned({ - baselined: [...baselined], unpinned: result.unpinned, vocabulary, documented: doc.documented, -}); - -if (update) { - writeFileSync( - BASELINE_PATH, - `${JSON.stringify({ - note: - 'StandardErrorCode members documented with an HTTP status that NO producer this gate can read ' - + 'declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a ' - + 'gate failure, and a row that becomes pinned must be removed. Regenerate with ' - + '`node scripts/check-error-status-conformance.mjs --update`.', - unpinned: result.unpinned, - }, null, 2)}\n`, - ); - console.log(`Baseline rewritten: ${result.unpinned.length} unpinned code(s).`); - process.exit(0); -} + const doc = parseDocumentedStatuses({ + handling: readFileSync(DOC_HANDLING, 'utf8'), + catalog: readFileSync(DOC_CATALOG, 'utf8'), + }); + const { vocabulary, docPublishedBeyondStandard } = reconciledVocabulary({ members, ...doc }); + const result = reconcile({ vocabulary, emitted: derived.emitted, ...doc }); -// The residual, as a SUBTRACTION from what the pages publish rather than a -// claim about it: codes the deriver found that are neither a `StandardErrorCode` -// member nor published with a status by a scanned page. Nothing here is a -// literal, so registering a ledger code moves no number that has to be edited. -const unreconciledLedger = [...derived.emitted.keys()].filter((c) => !vocabulary.includes(c)).sort(); - -// `--report` prints the whole derivation rather than only the disagreements. -// A finding is only as trustworthy as the evidence behind it, and "which -// producers did you actually see for this code?" is the first question anyone -// reading a failure asks. -if (process.argv.includes('--report')) { - for (const code of vocabulary) { - const runtime = derived.emitted.get(code); - if (!runtime) continue; - console.log(`${code}`); - for (const [status, where] of [...runtime].sort((a, b) => a[0] - b[0])) { - console.log(` ${status} ${where.join('\n ')}`); + const baseline = existsSync(BASELINE_PATH) + ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) + : { unpinned: [] }; + const baselined = new Set(baseline.unpinned ?? []); + const newlyUnpinned = result.unpinned.filter((c) => !baselined.has(c)); + const nowPinnedFindings = nowPinned({ + baselined: [...baselined], unpinned: result.unpinned, vocabulary, documented: doc.documented, + }); + + if (update) { + writeFileSync( + BASELINE_PATH, + `${JSON.stringify({ + note: + 'StandardErrorCode members documented with an HTTP status that NO producer this gate can read ' + + 'declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a ' + + 'gate failure, and a row that becomes pinned must be removed. Regenerate with ' + + '`node scripts/check-error-status-conformance.mjs --update`.', + unpinned: result.unpinned, + }, null, 2)}\n`, + ); + console.log(`Baseline rewritten: ${result.unpinned.length} unpinned code(s).`); + process.exit(0); + } + + // The residual, as a SUBTRACTION from what the pages publish rather than a + // claim about it: codes the deriver found that are neither a `StandardErrorCode` + // member nor published with a status by a scanned page. Nothing here is a + // literal, so registering a ledger code moves no number that has to be edited. + const unreconciledLedger = [...derived.emitted.keys()].filter((c) => !vocabulary.includes(c)).sort(); + + // `--report` prints the whole derivation rather than only the disagreements. + // A finding is only as trustworthy as the evidence behind it, and "which + // producers did you actually see for this code?" is the first question anyone + // reading a failure asks. + if (process.argv.includes('--report')) { + for (const code of vocabulary) { + const runtime = derived.emitted.get(code); + if (!runtime) continue; + console.log(`${code}`); + for (const [status, where] of [...runtime].sort((a, b) => a[0] - b[0])) { + console.log(` ${status} ${where.join('\n ')}`); + } } + console.log(`\nderived but NOT reconciled — no scanned page publishes a status for these ${unreconciledLedger.length}:`); + for (const c of unreconciledLedger) console.log(` ${c}`); } - console.log(`\nderived but NOT reconciled — no scanned page publishes a status for these ${unreconciledLedger.length}:`); - for (const c of unreconciledLedger) console.log(` ${c}`); -} -console.log('check:error-status-conformance — documented HTTP status ⇄ runtime-emitted status'); -console.log( - ` scope: ${vocabulary.length} code(s) reconciled = ${members.length} StandardErrorCode member(s) ` - + `+ ${docPublishedBeyondStandard.length} ledger code(s) a doc page publishes a status for` - + `${docPublishedBeyondStandard.length ? ` (${docPublishedBeyondStandard.join(', ')})` : ''}; ` - + `${sources.size} source files scanned; ${derived.sites} producer site(s) derived; ` - + `${unreconciledLedger.length} further ledger code(s) derived but NOT reconciled — no scanned page publishes ` - + 'a status for them, so there is nothing to reconcile them against.', -); -console.log( - ` reconciled: ${result.reconciledCodes} code(s) with a derived producer, ` - + `${result.reconciledPairs} (code, status) pair(s) matched against the docs.`, -); -console.log(` unpinned: ${result.unpinned.length} documented code(s) with no derivable producer (baselined: ${baselined.size}).`); -const ungraded = ungradedEntries(doc); -if (ungraded.length) { + console.log('check:error-status-conformance — documented HTTP status ⇄ runtime-emitted status'); console.log( - ` ungraded: ${ungraded.length} doc entr(y|ies) whose heading was read but for which no page publishes a ` - + 'status in a graded shape (reported, not failed — see `ungradedEntries`) —', + ` scope: ${vocabulary.length} code(s) reconciled = ${members.length} StandardErrorCode member(s) ` + + `+ ${docPublishedBeyondStandard.length} ledger code(s) a doc page publishes a status for` + + `${docPublishedBeyondStandard.length ? ` (${docPublishedBeyondStandard.join(', ')})` : ''}; ` + + `${sources.size} source files scanned; ${derived.sites} producer site(s) derived; ` + + `${unreconciledLedger.length} further ledger code(s) derived but NOT reconciled — no scanned page publishes ` + + 'a status for them, so there is nothing to reconcile them against.', ); - for (const e of ungraded) console.log(` ${e.code} ${e.where}`); -} -if (doc.unreadableHeadings.length) { - console.log(` unreadable: ${doc.unreadableHeadings.length} doc heading(s) naming a code in an unrecognised shape —`); - for (const u of doc.unreadableHeadings) console.log(` ${u.path}:${u.line} ${JSON.stringify(u.text)}`); -} -if (derived.unresolved.length) { - console.log(` unresolved: ${derived.unresolved.length} declaration(s) the deriver could not read —`); - for (const u of derived.unresolved) console.log(` ${u}`); -} - -if (result.reconciledPairs === 0) { - console.error( - '\n✗ the deriver matched ZERO (code, status) pairs. A green run with nothing reconciled is a blind run, ' - + 'not a clean one — the source anchors this gate reads have moved.\n', + console.log( + ` reconciled: ${result.reconciledCodes} code(s) with a derived producer, ` + + `${result.reconciledPairs} (code, status) pair(s) matched against the docs.`, ); - process.exit(1); -} + console.log(` unpinned: ${result.unpinned.length} documented code(s) with no derivable producer (baselined: ${baselined.size}).`); + const ungraded = ungradedEntries(doc); + if (ungraded.length) { + console.log( + ` ungraded: ${ungraded.length} doc entr(y|ies) whose heading was read but for which no page publishes a ` + + 'status in a graded shape (reported, not failed — see `ungradedEntries`) —', + ); + for (const e of ungraded) console.log(` ${e.code} ${e.where}`); + } + if (doc.unreadableHeadings.length) { + console.log(` unreadable: ${doc.unreadableHeadings.length} doc heading(s) naming a code in an unrecognised shape —`); + for (const u of doc.unreadableHeadings) console.log(` ${u.path}:${u.line} ${JSON.stringify(u.text)}`); + } + if (derived.unresolved.length) { + console.log(` unresolved: ${derived.unresolved.length} declaration(s) the deriver could not read —`); + for (const u of derived.unresolved) console.log(` ${u}`); + } -const failures = []; -for (const f of result.emittedNotDocumented) failures.push(emittedNotDocumentedMessage(f)); -for (const f of result.documentedNotReachable) failures.push(documentedNotReachableMessage(f)); -for (const u of doc.unreadableHeadings) failures.push(unreadableHeadingMessage(u)); -for (const c of newlyUnpinned) failures.push(newUnpinnedMessage(c)); -for (const f of nowPinnedFindings) { - failures.push(f.reason === 'producer' ? nowPinnedProducerMessage(f.code) : nowPinnedDocRemovedMessage(f.code)); -} + if (result.reconciledPairs === 0) { + console.error( + '\n✗ the deriver matched ZERO (code, status) pairs. A green run with nothing reconciled is a blind run, ' + + 'not a clean one — the source anchors this gate reads have moved.\n', + ); + process.exit(1); + } + + const failures = []; + for (const f of result.emittedNotDocumented) failures.push(emittedNotDocumentedMessage(f)); + for (const f of result.documentedNotReachable) failures.push(documentedNotReachableMessage(f)); + for (const u of doc.unreadableHeadings) failures.push(unreadableHeadingMessage(u)); + for (const c of newlyUnpinned) failures.push(newUnpinnedMessage(c)); + for (const f of nowPinnedFindings) { + failures.push(f.reason === 'producer' ? nowPinnedProducerMessage(f.code) : nowPinnedDocRemovedMessage(f.code)); + } -if (failures.length) { - console.error(''); - for (const f of failures) console.error(` ✗ ${f}`); - console.error(`\n✗ check:error-status-conformance — ${failures.length} finding(s).\n`); - process.exit(1); + if (failures.length) { + console.error(''); + for (const f of failures) console.error(` ✗ ${f}`); + console.error(`\n✗ check:error-status-conformance — ${failures.length} finding(s).\n`); + process.exit(1); + } + + console.log('\n✓ every derivable runtime status is documented, and every documented status is reachable.'); } -console.log('\n✓ every derivable runtime status is documented, and every documented status is reachable.'); +// Guarded: this module exports the whole derivation (parseStandardErrorCodes, +// deriveRuntimeStatuses, reconcile, and the message builders), and unguarded an +// import of any of them walked the scan root, read every source file and printed +// this gate's full report into the importer's stdout before returning a binding. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + main(); +} diff --git a/scripts/check-query-options-erasure-ratchet.mjs b/scripts/check-query-options-erasure-ratchet.mjs index 0a29446aca..a1f0b9d397 100644 --- a/scripts/check-query-options-erasure-ratchet.mjs +++ b/scripts/check-query-options-erasure-ratchet.mjs @@ -120,13 +120,24 @@ import { osThreadStackKb, stackRearmPlan, } from './eslint-stack-headroom.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; // This gate lints IN-PROCESS, so it does not inherit the `--stack-size` the // root `lint` script puts on ESLint's CLI entry, and this repo's deepest file // does not parse without it (#10449). Re-exec once, before any linting -- // including before `--self-test`, whose headroom assertion below is only a fact // about the gate if the self-test runs on the same stack the gate does. -ensureStackHeadroom(fileURLToPath(import.meta.url)); +// +// Guarded IN PLACE rather than moved into main(): the ordering above is the +// whole point of the call, and leaving it at its original position in module +// order is what makes that ordering checkable by reading. `rearmWithStackHeadroom` +// re-execs and then calls `process.exit(status)`, so on an import path this line +// replaced the IMPORTER's process with a fresh run of this gate -- the loudest +// entry in the KNOWN_IMPORT_UNSAFE ledger, and the reason a `main()` extraction +// alone would not have been enough here. +if (isEntrypoint(import.meta.url)) { + ensureStackHeadroom(fileURLToPath(import.meta.url)); +} const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); @@ -897,92 +908,100 @@ async function selfTest() { // --------------------------------------------------------------------------- // main -if (process.argv.includes('--self-test')) { - await selfTest(); - process.exit(0); -} +async function main() { + if (!eslintConfig.some(carriesRule)) { + console.error( + `check-query-options-erasure-ratchet: no config block carries \`${QUERY_OPTIONS_RULE_ID}\`.\n` + + 'The rule was renamed or removed without updating QUERY_OPTIONS_RULE_ID — refusing\n' + + 'to report "clean" for a rule that is no longer being measured.', + ); + process.exit(2); + } -if (!eslintConfig.some(carriesRule)) { - console.error( - `check-query-options-erasure-ratchet: no config block carries \`${QUERY_OPTIONS_RULE_ID}\`.\n` + - 'The rule was renamed or removed without updating QUERY_OPTIONS_RULE_ID — refusing\n' + - 'to report "clean" for a rule that is no longer being measured.', - ); - process.exit(2); -} + const update = process.argv.includes('--update'); + const baselineFile = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); + const baseline = baselineFile.nonTest ?? {}; + const testCeiling = baselineFile.testSurface?.sites; -const update = process.argv.includes('--update'); -const baselineFile = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); -const baseline = baselineFile.nonTest ?? {}; -const testCeiling = baselineFile.testSurface?.sites; + if (typeof testCeiling !== 'number' && !update) { + console.error( + `check-query-options-erasure-ratchet: ${BASELINE_PATH} has no numeric ` + + '`testSurface.sites`. Refusing to report clean with half the surface unmeasured.', + ); + process.exit(2); + } -if (typeof testCeiling !== 'number' && !update) { - console.error( - `check-query-options-erasure-ratchet: ${BASELINE_PATH} has no numeric ` + - '`testSurface.sites`. Refusing to report clean with half the surface unmeasured.', + // Two runs, one per population. The split is done by ESLint against the very + // globs the rule uses, so there is no second definition of "is this a test + // file" for the two halves to drift apart on. + const nonTest = sortKeys(await measure(new Set(Object.keys(baseline)))); + const everything = sortKeys(await measure(new Set([...Object.keys(baseline), ...QUERY_OPTIONS_TEST_GLOBS]))); + const testOnly = sortKeys( + Object.fromEntries(Object.entries(everything).filter(([file]) => !(file in nonTest))), ); - process.exit(2); -} + const testSites = sum(testOnly); + + if (update) { + const next = { + ...baselineFile, + nonTest, + testSurface: { ...(baselineFile.testSurface ?? {}), sites: testSites }, + }; + writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(next, null, 2) + '\n'); + console.log( + `query-options-erasure baseline updated: ${sum(nonTest)} non-test site(s) in ` + + `${Object.keys(nonTest).length} file(s); test surface ${testSites} site(s) in ` + + `${Object.keys(testOnly).length} file(s).`, + ); + process.exit(0); + } + + const monotonicity = baselineKeysAddedSinceMergeBase(Object.keys(baseline)); + const errors = diffRatchet({ + baseline, + current: nonTest, + testCeiling, + testSites, + addedBaselineKeys: monotonicity?.added ?? [], + }); + + if (errors.length > 0) { + console.error(`✗ query-options-erasure ratchet (${errors.length} problem(s)):\n`); + for (const e of errors) console.error(` • ${e}`); + console.error( + `\nUnswept: ${sum(nonTest)} non-test site(s) in ${Object.keys(nonTest).length} file(s), ` + + `plus ${testSites} in test code. Sweeping is a separate batch — part of the residual ` + + `needs a boundary type WRITTEN (objectql's \`hookContext.input.options\`, the metadata ` + + `loader's query bag), not the assertion deleted. See issue #4918.`, + ); + process.exit(1); + } -// Two runs, one per population. The split is done by ESLint against the very -// globs the rule uses, so there is no second definition of "is this a test -// file" for the two halves to drift apart on. -const nonTest = sortKeys(await measure(new Set(Object.keys(baseline)))); -const everything = sortKeys(await measure(new Set([...Object.keys(baseline), ...QUERY_OPTIONS_TEST_GLOBS]))); -const testOnly = sortKeys( - Object.fromEntries(Object.entries(everything).filter(([file]) => !(file in nonTest))), -); -const testSites = sum(testOnly); - -if (update) { - const next = { - ...baselineFile, - nonTest, - testSurface: { ...(baselineFile.testSurface ?? {}), sites: testSites }, - }; - writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(next, null, 2) + '\n'); console.log( - `query-options-erasure baseline updated: ${sum(nonTest)} non-test site(s) in ` + - `${Object.keys(nonTest).length} file(s); test surface ${testSites} site(s) in ` + - `${Object.keys(testOnly).length} file(s).`, + `✓ query-options-erasure ratchet holds: ${sum(nonTest)} unswept non-test site(s) in ` + + `${Object.keys(nonTest).length} file(s), none new, and every file measured parsed. ` + + `Every other non-test file under packages/ is covered by \`pnpm lint\`.`, ); - process.exit(0); -} - -const monotonicity = baselineKeysAddedSinceMergeBase(Object.keys(baseline)); -const errors = diffRatchet({ - baseline, - current: nonTest, - testCeiling, - testSites, - addedBaselineKeys: monotonicity?.added ?? [], -}); - -if (errors.length > 0) { - console.error(`✗ query-options-erasure ratchet (${errors.length} problem(s)):\n`); - for (const e of errors) console.error(` • ${e}`); - console.error( - `\nUnswept: ${sum(nonTest)} non-test site(s) in ${Object.keys(nonTest).length} file(s), ` + - `plus ${testSites} in test code. Sweeping is a separate batch — part of the residual ` + - `needs a boundary type WRITTEN (objectql's \`hookContext.input.options\`, the metadata ` + - `loader's query bag), not the assertion deleted. See issue #4918.`, + console.log( + ` test surface: ${testSites} site(s) in ${Object.keys(testOnly).length} file(s) — at the ` + + `ceiling, outside the blocking rule by the #4918 triage (a rejection test must be able ` + + `to build off-contract input).`, + ); + console.log( + monotonicity + ? ` baseline key set verified against ${monotonicity.base}: no files added.` + : ` NOT verified: could not read the baseline at the merge base with main (no git, ` + + `shallow clone, or the baseline is new here), so "no files added" is unchecked this run.`, ); - process.exit(1); } -console.log( - `✓ query-options-erasure ratchet holds: ${sum(nonTest)} unswept non-test site(s) in ` + - `${Object.keys(nonTest).length} file(s), none new, and every file measured parsed. ` + - `Every other non-test file under packages/ is covered by \`pnpm lint\`.`, -); -console.log( - ` test surface: ${testSites} site(s) in ${Object.keys(testOnly).length} file(s) — at the ` + - `ceiling, outside the blocking rule by the #4918 triage (a rejection test must be able ` + - `to build off-contract input).`, -); -console.log( - monotonicity - ? ` baseline key set verified against ${monotonicity.base}: no files added.` - : ` NOT verified: could not read the baseline at the merge base with main (no git, ` + - `shallow clone, or the baseline is new here), so "no files added" is unchecked this run.`, -); +// The dispatch, behind the same predicate. This module exports `diffRatchet`, +// `measure` and the baseline helpers; unguarded, importing one of them ran two +// full ESLint passes over packages/** inside the importer. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + await selfTest(); + process.exit(0); + } + await main(); +} diff --git a/scripts/check-release-page-status.mjs b/scripts/check-release-page-status.mjs index ef7d4f5d86..d2a8d5bf51 100644 --- a/scripts/check-release-page-status.mjs +++ b/scripts/check-release-page-status.mjs @@ -99,6 +99,7 @@ // shipped, and the two corrected ones that replaced them. import { execFileSync } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; +import { isEntrypoint } from './invoked-as.mjs'; const SPEC_CHANGELOG = 'packages/spec/CHANGELOG.md'; const SPEC_PKG = 'packages/spec/package.json'; @@ -837,51 +838,61 @@ function renderOk(checked, tagCorroborations) { // ── Run ────────────────────────────────────────────────────────────────────── -if (process.argv.includes('--self-test')) selfTest(); +function main() { -const changelogText = readFileSync(SPEC_CHANGELOG, 'utf8'); -const gaMajorList = gaMajors(changelogText); -const specVersion = JSON.parse(readFileSync(SPEC_PKG, 'utf8')).version; -const tagMajorList = readGaTagMajors(); + const changelogText = readFileSync(SPEC_CHANGELOG, 'utf8'); + const gaMajorList = gaMajors(changelogText); + const specVersion = JSON.parse(readFileSync(SPEC_PKG, 'utf8')).version; + const tagMajorList = readGaTagMajors(); -const problems = instrumentProblems({ gaMajorList, specVersion, tagMajorList }); + const problems = instrumentProblems({ gaMajorList, specVersion, tagMajorList }); -const checked = gaMajorList.filter(inScope); -if (problems.length === 0 && checked.length === 0) { - problems.push( - `no GA major at or above v${SCOPE_FLOOR_MAJOR} was found in ${SPEC_CHANGELOG}, so this gate ` - + 'checked ZERO pages. v16 and v17 have both shipped and majors only go up, so this cannot be a ' - + 'true reading — a gate that silently checks nothing is worse than no gate. Fix the parse.', - ); -} - -if (problems.length === 0) { - const indexText = existsSync(INDEX_PATH) ? readFileSync(INDEX_PATH, 'utf8') : null; - if (indexText === null) { - problems.push(`${INDEX_PATH} is missing — there is no releases index to check entries against.`); + const checked = gaMajorList.filter(inScope); + if (problems.length === 0 && checked.length === 0) { + problems.push( + `no GA major at or above v${SCOPE_FLOOR_MAJOR} was found in ${SPEC_CHANGELOG}, so this gate ` + + 'checked ZERO pages. v16 and v17 have both shipped and majors only go up, so this cannot be a ' + + 'true reading — a gate that silently checks nothing is worse than no gate. Fix the parse.', + ); } - for (const major of checked) { - const pagePath = `${RELEASES_DIR}/v${major}.mdx`; - if (!existsSync(pagePath)) { + + if (problems.length === 0) { + const indexText = existsSync(INDEX_PATH) ? readFileSync(INDEX_PATH, 'utf8') : null; + if (indexText === null) { + problems.push(`${INDEX_PATH} is missing — there is no releases index to check entries against.`); + } + for (const major of checked) { + const pagePath = `${RELEASES_DIR}/v${major}.mdx`; + if (!existsSync(pagePath)) { + problems.push( + `${pagePath} is missing — @objectstack/spec ${major}.x is GA but there is no release page to ` + + 'check. (check:release-notes is the gate that owns page existence; this one owns what the ' + + 'page SAYS.)', + ); + continue; + } problems.push( - `${pagePath} is missing — @objectstack/spec ${major}.x is GA but there is no release page to ` - + 'check. (check:release-notes is the gate that owns page existence; this one owns what the ' - + 'page SAYS.)', + ...pageStatusProblems(major, pagePath, statusBlockquote(readFileSync(pagePath, 'utf8'))), ); - continue; - } - problems.push( - ...pageStatusProblems(major, pagePath, statusBlockquote(readFileSync(pagePath, 'utf8'))), - ); - if (indexText !== null) { - problems.push(...indexStatusProblems(major, indexEntryLine(indexText, major))); + if (indexText !== null) { + problems.push(...indexStatusProblems(major, indexEntryLine(indexText, major))); + } } } -} -if (problems.length > 0) { - console.error(renderFailure(problems)); - process.exit(1); + if (problems.length > 0) { + console.error(renderFailure(problems)); + process.exit(1); + } + + console.log(renderOk(checked, tagMajorList.filter(inScope).length)); } -console.log(renderOk(checked, tagMajorList.filter(inScope).length)); +// Guarded: this module exports its predicates (`gaMajors`, `pageStatusProblems`, +// `indexStatusProblems` — check-release-section-coverage.mjs inherits the same +// scope floor from it), and unguarded the whole gate ran inside any importer, +// printing its verdict over theirs. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + main(); +} diff --git a/scripts/checklist-select.mjs b/scripts/checklist-select.mjs index 3466786ed9..e51849517c 100644 --- a/scripts/checklist-select.mjs +++ b/scripts/checklist-select.mjs @@ -36,6 +36,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = new URL('..', import.meta.url).pathname; const AREAS_DIR = join(ROOT, 'docs/qa/platform-checklist/areas'); @@ -117,7 +118,7 @@ function isBlocked(it) { } // ── self-test ──────────────────────────────────────────────────────────────── -if (process.argv.includes('--self-test')) { +function selfTest() { const FIX = [ { id: 'a.one', status: 'active', priority: 'P0', surface: 'browser', since: 'v16', source: ['packages/foo/bar.ts'] }, { id: 'a.two', status: 'active', priority: 'P1', surface: 'api', since: 'v16.1', source: ['#3358'], blocked: { by: 'fixture', ref: '#1' } }, @@ -153,39 +154,49 @@ if (process.argv.includes('--self-test')) { } // ── CLI ────────────────────────────────────────────────────────────────────── -const args = process.argv.slice(2); -const json = args.includes('--json'); -const includeBlocked = args.includes('--include-blocked'); -const selector = args.find((a) => !a.startsWith('--')); - -if (!selector) { - console.error('usage: node scripts/checklist-select.mjs [--json] [--include-blocked]'); - console.error(' selectors: | area: | capability: | priority:P0 | surface:api | since:vN | file: | all'); - process.exit(2); -} -if (!existsSync(AREAS_DIR)) { - console.error(`checklist-select: ${AREAS_DIR} not found`); - process.exit(1); -} +function main() { + const args = process.argv.slice(2); + const json = args.includes('--json'); + const includeBlocked = args.includes('--include-blocked'); + const selector = args.find((a) => !a.startsWith('--')); -const items = loadItems(); -const coverage = existsSync(COVERAGE) ? JSON.parse(readFileSync(COVERAGE, 'utf8')) : { metadataKinds: {} }; -let matched = selectItems(selector, items, coverage); -const droppedBlocked = includeBlocked ? [] : matched.filter(isBlocked); -if (!includeBlocked) matched = matched.filter((it) => !isBlocked(it)); + if (!selector) { + console.error('usage: node scripts/checklist-select.mjs [--json] [--include-blocked]'); + console.error(' selectors: | area: | capability: | priority:P0 | surface:api | since:vN | file: | all'); + process.exit(2); + } + if (!existsSync(AREAS_DIR)) { + console.error(`checklist-select: ${AREAS_DIR} not found`); + process.exit(1); + } -if (json) { - process.stdout.write(JSON.stringify(matched.map((it) => ({ id: it.id, priority: it.priority, surface: it.surface, since: it.since, revision: it.revision })), null, 2) + '\n'); -} + const items = loadItems(); + const coverage = existsSync(COVERAGE) ? JSON.parse(readFileSync(COVERAGE, 'utf8')) : { metadataKinds: {} }; + let matched = selectItems(selector, items, coverage); + const droppedBlocked = includeBlocked ? [] : matched.filter(isBlocked); + if (!includeBlocked) matched = matched.filter((it) => !isBlocked(it)); -console.error(`\nselector: ${selector} → ${matched.length} runnable item(s)${droppedBlocked.length ? ` (${droppedBlocked.length} blocked, hidden — pass --include-blocked)` : ''}\n`); -for (const it of matched) { - console.error(` ${it.priority} ${String(it.surface).padEnd(8)} ${it.id}${isBlocked(it) ? ' [BLOCKED]' : ''}`); -} -if (droppedBlocked.length) { - console.error(`\n hidden (blocked): ${droppedBlocked.map((i) => i.id).join(', ')}`); + if (json) { + process.stdout.write(JSON.stringify(matched.map((it) => ({ id: it.id, priority: it.priority, surface: it.surface, since: it.since, revision: it.revision })), null, 2) + '\n'); + } + + console.error(`\nselector: ${selector} → ${matched.length} runnable item(s)${droppedBlocked.length ? ` (${droppedBlocked.length} blocked, hidden — pass --include-blocked)` : ''}\n`); + for (const it of matched) { + console.error(` ${it.priority} ${String(it.surface).padEnd(8)} ${it.id}${isBlocked(it) ? ' [BLOCKED]' : ''}`); + } + if (droppedBlocked.length) { + console.error(`\n hidden (blocked): ${droppedBlocked.map((i) => i.id).join(', ')}`); + } + if (matched.length === 0) { + console.error(' (nothing matched — check the selector; try `all` or `area:`)'); + process.exit(1); + } } -if (matched.length === 0) { - console.error(' (nothing matched — check the selector; try `all` or `area:`)'); - process.exit(1); + +// The dispatch runs only when node ran THIS file. Imported for `selectItems` +// (the skill's front half is a pure resolver), the old top-level CLI printed a +// usage block to the importer's stderr and killed it with exit 2 mid-import. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + main(); } diff --git a/scripts/pm/check-governed-prose.mjs b/scripts/pm/check-governed-prose.mjs index 380f71047d..01deb5d901 100644 --- a/scripts/pm/check-governed-prose.mjs +++ b/scripts/pm/check-governed-prose.mjs @@ -77,17 +77,27 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; - -// `check-governed-merges.mjs` runs its OWN self-test at module scope when it -// sees `--self-test` in `process.argv` — it has no entry-point guard, and this -// is its first importer. Left alone, running THIS file's `--self-test` would -// also run the sibling's, and a failure there calls `process.exit(1)` before a -// single case of ours reports: our result would be masked by another script's -// name. So the flag is withheld for the duration of the import only. -const realArgv = process.argv; -process.argv = realArgv.filter((arg) => arg !== '--self-test'); +import { isEntrypoint } from '../invoked-as.mjs'; + +// A dynamic import, because this file used to have to reach the sibling with +// `--self-test` withheld from `process.argv`: `check-governed-merges.mjs` ran +// its own self-test at module scope, so running OUR `--self-test` also ran the +// sibling's, and a failure there called `process.exit(1)` before a single case +// of ours reported. +// +// That workaround is GONE, and this comment records why rather than preserving +// it: the sibling now guards BOTH of its module-scope triggers on +// `invokedDirectly = isEntrypoint(import.meta.url)` (its `main()` at :1201 and +// its self-test at :1614), so on this import path the flag cannot reach either +// one no matter what argv says. Measured rather than reasoned — importing the +// sibling with `--self-test` planted in `process.argv` runs no self-test, +// prints nothing and returns its 34 exports. +// +// Mutating `process.argv` was never free: it is process-global, so it edited +// the argv of whatever imported THIS file too, for the duration of the import. +// The static form stays a dynamic `import()` only because +// `scripts/pm/dispatch-gates.mjs` reads this edge. const { GOVERNED_SURFACES } = await import('./check-governed-merges.mjs'); -process.argv = realArgv; const REPO_ROOT = new URL('../../', import.meta.url); @@ -335,5 +345,7 @@ function selfTest() { return 0; } -const isSelfTest = process.argv.slice(2).includes('--self-test'); -process.exit(isSelfTest ? selfTest() : runGate()); +if (isEntrypoint(import.meta.url)) { + const isSelfTest = process.argv.slice(2).includes('--self-test'); + process.exit(isSelfTest ? selfTest() : runGate()); +}