Skip to content
Merged
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
196 changes: 195 additions & 1 deletion scripts/pm/check-half-states.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@

import process from 'node:process';
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { readFileSync, writeSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isEntrypoint } from '../invoked-as.mjs';

Expand Down Expand Up @@ -31642,6 +31642,90 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading.
t('#13544 control: a genuinely unreachable host still refuses', classifyTransportProbe({ token: 'proxy-injected', authed: { networkError: 'ECONNREFUSED' }, transport: describeTransportRoute({ env: { HTTPS_PROXY: 'http://127.0.0.1:1' } }) }).kind, 'host-unreachable');
t('#13544 control: …and the refusal exit code is still 3', EXIT_PREREQUISITE_NOT_MET, 3);

// -- CLI surface (#18369) --------------------------------------------------
//
// Two layers, because they fail separately: the PURE cases judge
// `refuseUnknownArgs` and `USAGE`; the SPAWNED ones below judge the ENTRY —
// which branch runs first, and whether anything reached the network before
// it. No in-process call can observe "no request was made", and the filed
// defect was exactly an ordering one.
t('#18369 the standing caller\'s own argv is honoured', refuseUnknownArgs(['--format=markdown', '--provenance=run 1 · commit abc']), null);
t('#18369 no argument at all is honoured (the default sweep)', refuseUnknownArgs([]), null);
t('#18369 --self-test is honoured', refuseUnknownArgs(['--self-test']), null);
t('#18369 --probe is honoured', refuseUnknownArgs(['--probe']), null);
t('#18369 --help/-h are honoured', refuseUnknownArgs(['--help']) === null && refuseUnknownArgs(['-h']) === null, true);
t('#18369 --provenance with an empty value is honoured', refuseUnknownArgs(['--provenance=']), null);
// ⚖️ The NAME is this layer's judgement; the VALUE stays `parseOutputOptions`'.
// A second format vocabulary here would be a second list to drift.
t('#18369 a bad --format VALUE is not this layer\'s refusal', refuseUnknownArgs(['--format=bogus']), null);
t('#18369 …and is still refused, by the layer that owns it', typeof parseOutputOptions(['--format=bogus']).error, 'string');
// The filer's positive control, and the card's headline case.
t('#18369 a flag that certainly does not exist is refused BY NAME', refuseUnknownArgs(['--totally-bogus-flag-xyz']), 'unknown option --totally-bogus-flag-xyz');
// The wrong-TARGET token: `--repo=` never existed here, was silently dropped,
// and the sweep then ran against the DEFAULT board.
t('#18369 a --repo= this tool never had is refused by name', refuseUnknownArgs(['--repo=objectstack-ai/objectui']), 'unknown option --repo');
t('#18369 a near-miss of a real option is refused by name', refuseUnknownArgs(['--formats=markdown']), 'unknown option --formats');
t('#18369 a value option spelled with a SPACE is refused, not silently defaulted', String(refuseUnknownArgs(['--format', 'markdown'])).startsWith('--format carries its value in the same token'), true);
t('#18369 a bare positional is refused and told where the board comes from', String(refuseUnknownArgs(['objectstack-ai/objectui'])).startsWith('unexpected argument "objectstack-ai/objectui"'), true);
// ⛔ Deliberate: the proxy flag is node's, read at process START, so after the
// script path it never did anything. Refusing it is how a caller finds out.
t('#18369 the proxy flag AFTER the script path is refused (it is node\'s, not ours)', refuseUnknownArgs([PROXY_FLAG]), `unknown option ${PROXY_FLAG}`);
t('#18369 the refusal names the FIRST offending token, not the last', refuseUnknownArgs(['--bogus-one', '--bogus-two']), 'unknown option --bogus-one');
// One roster, both halves: a flag documented and not honoured — or honoured
// and not documented — is a case here, never a code review.
t('#18369 USAGE documents every flag on the roster', CLI_FLAGS.filter((f) => !USAGE.includes(f)).join(','), '');
t('#18369 USAGE documents every value option on the roster', CLI_VALUE_OPTIONS.filter((o) => !USAGE.includes(`${o}=`)).join(','), '');
t('#18369 every format this tool renders is named in USAGE', OUTPUT_FORMATS.filter((f) => !USAGE.includes(f)).join(','), '');
t('#18369 USAGE names the env the board comes from, since no flag does', USAGE.includes('PM_SWEEP_REPO'), true);
t('#18369 USAGE opens with the word a caller greps for', USAGE.startsWith('usage:'), true);

// -- The entry, spawned — "zero requests" is not observable in-process ------
//
// `globalThis.fetch` is replaced in the child by one that exits 97, so a fetch
// ATTEMPT is a distinct exit code rather than a network result: offline and
// deterministic. ⛔ The proxy env is cleared for the child because
// `rearmThroughProxy` would else re-exec a GRANDCHILD without `--import`,
// where the trap is not installed — the case would then be answering about a
// process it never armed.
const FETCH_TRAP_EXIT = 97;
const FETCH_TRAP =
'data:text/javascript,globalThis.fetch=()=>{process.stderr.write("FETCH-ATTEMPTED\\n");' +
`process.exit(${FETCH_TRAP_EXIT})};`;
const runEntry = (args, env = {}) =>
spawnSync(process.execPath, ['--import', FETCH_TRAP, SELF_PATH, ...args], {
encoding: 'utf8',
env: {
...process.env,
HTTPS_PROXY: '', https_proxy: '', NODE_OPTIONS: '', NODE_USE_ENV_PROXY: '',
GITHUB_TOKEN: '', GH_TOKEN: '', ...env,
},
});
// ⭐ THE CONTROL, and it is what gives every case below it meaning: a trap
// never installed would let `--help` "pass" with the sweep deleted, or with
// this whole battery deleted. The default invocation must still reach the
// network, in an exit code nothing else in this file uses.
const trapped = runEntry([]);
t('#18369 control: the default sweep still reaches fetch, so the trap is armed', trapped.status, FETCH_TRAP_EXIT);
t('#18369 control: …and the trap, not the sweep, is what said so', String(trapped.stderr).includes('FETCH-ATTEMPTED'), true);
const helped = runEntry(['--help']);
t('#18369 --help exits 0', helped.status, 0);
t('#18369 --help issues ZERO requests', String(helped.stderr).includes('FETCH-ATTEMPTED'), false);
t('#18369 --help prints the usage block on STDOUT, whole and untruncated', helped.stdout, `${USAGE}\n`);
const shortHelp = runEntry(['-h']);
t('#18369 -h answers identically', `${shortHelp.status}:${shortHelp.stdout}`, `0:${helped.stdout}`);
// ⭐ Answered BEFORE the `SWEEP_REPO` guard on purpose: a caller asking what
// the flags are must get them on the box whose environment is broken, which
// is exactly the box where they ask.
const helpedBroken = runEntry(['--help'], { PM_SWEEP_REPO: 'not a repository' });
t('#18369 --help answers even when the sweep target is malformed', helpedBroken.status, 0);
t('#18369 …and still issues zero requests', String(helpedBroken.stderr).includes('FETCH-ATTEMPTED'), false);
const refused = runEntry(['--totally-bogus-flag-xyz']);
t('#18369 an unknown option exits 2 — this file\'s bad-usage code, not a fourth one', refused.status, 2);
t('#18369 an unknown option issues ZERO requests', String(refused.stderr).includes('FETCH-ATTEMPTED'), false);
t('#18369 an unknown option is refused by name, in git-history.mjs\'s spelling', String(refused.stderr).startsWith('check-half-states: unknown option --totally-bogus-flag-xyz'), true);
t('#18369 …and the refusal carries the usage block with it', String(refused.stderr).includes('usage:'), true);
t('#18369 the refusal goes to STDERR, leaving stdout empty', refused.stdout, '');

let failed = 0;
for (const [name, actual, expected] of cases) {
const ok = actual === expected;
Expand All @@ -31657,8 +31741,118 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading.
return SELF_TEST_VERDICT;
}

// ---------------------------------------------------------------------------
// CLI surface — the roster, the usage text and the refusal (#18369)
// ---------------------------------------------------------------------------
//
// argv was READ here but never VALIDATED: the only membership tests were
// `includes('--self-test')` / `includes('--probe')`, and `parseOutputOptions`
// matched two prefixes and dropped every other token silently. So `--help` and
// a flag that certainly does not exist behaved IDENTICALLY — both fell through
// into the full sweep, a multi-page, rate-limit-spending read of a live board,
// and no usage string existed in this file to print instead.
//
// The unanswered `--help` is the cheap half. The expensive half is the mistyped
// REAL flag: `--format markdown` with a space, or a `--repo=…` this tool has
// never had, was IGNORED, and the sweep then ran with the DEFAULT format
// against the DEFAULT board — a wrong-target reading indistinguishable from the
// run the caller believes they asked for. Same failure direction the
// `SWEEP_REPO` guard below already refuses to take, so argv gets the same
// answer: refused by name, exit 2, before any request.
//
// Three things to know before editing this block:
//
// · ONE roster feeds both the refusal and `USAGE`. A tool that refuses what
// its own usage documents is worse than one that documents nothing, and a
// second hand-typed list is how that drift arrives — `post-stamped.mjs`'s
// `KNOWN_FLAGS`/`KNOWN_OPTIONS` shape, reused rather than reinvented.
// · EXIT 2, not `git-history.mjs`'s 1. The refusal WORDING mirrors that file
// (`check-half-states: unknown option --foo`), but this file's exit
// vocabulary is pinned by its own header at 0/2/3 and all three of its
// pre-existing bad-usage exits are 2. A fourth code would make "bad usage"
// two numbers inside one tool.
// · `--use-env-proxy` is deliberately NOT on the roster (node's flag, read at
// process START) and neither is any positional — the board comes from
// `resolveSweepRepo`, so a bare `owner/name` here is precisely the
// wrong-target token this refusal exists to catch.

/** The bare flags honoured in this file's OWN argv. ⛔ The refusal and `USAGE` read no other list. */
export const CLI_FLAGS = Object.freeze(['--self-test', '--probe', '--help', '-h']);

/** The `--name=value` options. This owns the NAME; `parseOutputOptions` owns what a VALUE may be. */
export const CLI_VALUE_OPTIONS = Object.freeze(['--format', '--provenance']);

/** The usage block — stdout under `--help`, and beneath every refusal on stderr. */
export const USAGE = [
'usage:',
' node scripts/pm/check-half-states.mjs sweep the live board (report-only)',
' node scripts/pm/check-half-states.mjs --probe can a live sweep run HERE? (no sweep)',
' node scripts/pm/check-half-states.mjs --self-test verify the predicates offline (no network)',
' node scripts/pm/check-half-states.mjs --help this text, before any board read',
'',
` --format=FMT render the sweep as one of: ${OUTPUT_FORMATS.join(', ')} (default ${OUTPUT_FORMATS[0]})`,
' --provenance=TEXT stamp the caller\'s run identity into a --format=markdown body',
'',
'the board is named by the ENVIRONMENT — there is no --repo and no positional argument:',
' PM_SWEEP_REPO `owner/name` to sweep; else GITHUB_REPOSITORY, else the built-in default',
' PM_SWEEP_CLOSED_FLOOR YYYY-MM-DD floor for the closed-card pass',
' GITHUB_TOKEN/GH_TOKEN the credential the sweep reads with',
' NODE_OPTIONS=--use-env-proxy node reads its proxy flag at process START, so it goes there or',
' before the script path — never in this argv',
'',
'exit 0 swept or answered (report-only: 0 findings and 40 findings both exit 0)',
' 2 bad usage, or a sweep that could not run for an unclassified reason',
' 3 transport prerequisite not met — NOTHING was swept, which is not a clean board',
].join('\n');

/**
* Refuse an argv this tool does not honour, by name. Pure, so the self-test
* pins every refusal offline — and so the entry can answer before it has spent
* a request, a child process, or a page of somebody's rate limit.
*
* @param {string[]} argv
* @returns {string|null} the refusal sentence, or `null` when every token is honoured
*/
export function refuseUnknownArgs(argv) {
for (const arg of argv ?? []) {
if (CLI_FLAGS.includes(arg)) continue;
const named = /^(--[A-Za-z0-9][A-Za-z0-9-]*)=/.exec(arg);
if (named && CLI_VALUE_OPTIONS.includes(named[1])) continue;
// Spelled with a SPACE is the silent one: the old parser matched neither
// token, kept the default format, and swept anyway.
if (CLI_VALUE_OPTIONS.includes(arg)) {
return `${arg} carries its value in the same token — spell it \`${arg}=…\`, not \`${arg} …\``;
}
if (arg.startsWith('-')) return `unknown option ${named ? named[1] : arg}`;
return `unexpected argument ${JSON.stringify(arg)} — this tool takes options only; the board it sweeps comes from PM_SWEEP_REPO/GITHUB_REPOSITORY, never from a positional`;
}
return null;
}

const isMain = isEntrypoint(import.meta.url);
if (isMain) {
// ⚠️ argv is read INSIDE the guard, never at module scope: this file is
// imported by a dozen `scripts/pm/*` and `scripts/check-*` tools, several of
// which take a `--help` of their own, and an unguarded read would answer
// THEIR flag out of a module they imported for one predicate.
const argv = process.argv.slice(2);
// Answered FIRST — before the `SWEEP_REPO` / `CLOSED_FLOOR` guards below,
// before the proxy re-exec, before any request. Not merely "before the
// network": a caller asking what the flags ARE must get them on the box whose
// environment is broken, which is exactly the box where they ask.
// ⛔ `writeSync`, not `console.log`, because the next statement is
// `process.exit` and stdout to a PIPE is asynchronous on some platforms — the
// self-test reads this through one, and a truncated usage block would be a
// flaky case blaming the parser.
if (argv.includes('--help') || argv.includes('-h')) {
writeSync(1, `${USAGE}\n`);
process.exit(0);
}
const refusal = refuseUnknownArgs(argv);
if (refusal !== null) {
writeSync(2, `check-half-states: ${refusal}\n\n${USAGE}\n`);
process.exit(2);
}
// A malformed sweep target is bad usage (exit 2), refused BEFORE any request
// — including the probe's, whose second stage is a repo-scoped read of this
// very string. Silently falling back to the default would sweep a board
Expand Down
Loading