* test(#4409): prove the fold shadows two module-scope helpers Failing-first regression coverage for #4409. tests/runtime-launcher-parity.test.cjs declares extractShellBlocks twice. The module-scope copy (line 220) splits on /\r?\n/ and pushes {index, lang, lines}; the folded copy (line 1315) splits on '\n' and pushes {lines} only. The fold opens with a bare `{` at line 1226, so the inner declaration shadows the outer one for everything inside it -- and on a CRLF checkout every extracted line keeps a trailing \r. collectWorkflowFiles is duplicated the same way and is currently byte-identical, i.e. latent rather than active. Every row walks an AST via espree, a declared devDependency. None reads a .cjs and calls .includes(): that is local/no-source-grep's exact shape, and it is also the wrong instrument -- "how many declarations exist" is a construct count, and a regex would match the name inside a comment or a string literal too. Row 2 pins the rest of the corpus as an EXACT SORTED LIST of the 26 fold-shadowed helpers that remain in 15 other files, measured with this same walker rather than guessed. A bare count was rejected: `27 !== 26` names no offender and costs a round-trip to diagnose. Row 3 keeps that baseline from rotting into strings that match nothing. Row 4 is the behavioural half -- the defect a Windows user actually hits -- and asserts the surviving extractShellBlocks splits on a regex that tolerates \r, not on the bare '\n' literal. Red round: rows 1, 2 and 4 fail; row 3 passes, since every baseline entry is real today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4409): delete the fold's shadow copies of two helpers Both folded copies are removed so the fold resolves the module-scope pair. Deletion, not repair: patching the folded split to /\r?\n/ would leave two definitions and the next edit could diverge them again, and the issue asks for one definition per helper. Verified safe before deleting, not assumed: - the fold's WORKFLOWS_DIR and SNIPPET_FILE are byte-identical to the module-scope ones, and escapeRegex resolves to the same gsd-core/bin/lib/pattern.cjs, so the surviving functions close over the same values the folded copies did; - the fold has exactly ONE call site (test (E)) and it consumes blocks only as blocks.flatMap(b => b.lines). The module-scope extractor returns {index, lang, lines}, a SUPERSET of the folded {lines}, so no caller is starved. The deepStrictEqual 11 lines below that call compares `missing`, an array of path strings -- not block objects. Also removes the fold's now-dead `escapeRegex` re-require. It was used only by the folded extractShellBlocks; lint:ci caught it as an unused binding. The module-scope require stays -- the surviving extractor needs it. Applied by AST range rather than line numbers so the deletion boundaries are exact. Not fixed here, and not a deferral: the same walker finds 28 fold-shadowed helpers across 16 test files, of which 16 DIVERGE from their module-scope twin. The 15 diverged pairs outside this file are the same defect class, but the tracker already drew this boundary -- #4337 removed three runBashFile shadows "only because that PR had to edit all four identically" and deliberately left these two to #4409. A diverged shadow also cannot be deleted mechanically the way these could: its fold's tests were written against its own copy. The new suite pins all 26 remaining as an exact list so none can be forgotten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4409): close five holes both reviewers found in the new suite Review round. Every change is to the new suite; the fix itself is untouched. 1. The corpus walk was NON-RECURSIVE, so 64 files under tests/{dispatch, qa,observability,health-diagnostic-rules,helpers} were invisible to the baseline guard. Now recursive: 1013 files parsed, up from 949. The baseline is unchanged at 26 -- coverage widened without moving the pin. 2. `catch { continue }` silently exempted any file espree could not parse, so the guard could go quietly blind on exactly the file that broke. Parse failures now propagate, and row 2 asserts the scanned count. 3. Only FunctionDeclaration was detected, so a `const helper = () => {}` shadow was invisible. Arrow and function-expression bindings now count too. Zero such shadows exist today; the hole was future-facing. 4. The old row 3 was VACUOUS -- subsumed by row 2, which already fails on a stale baseline entry because the lists stop matching. Deleted; its message folded into row 2. 5. The CRLF row inspected only declarations[0]. On the pre-fix tree that is the MODULE-SCOPE copy, which was already correct -- so only the length===1 assertion went red and the row never actually saw the bug. It now checks EVERY declaration, and pins the split to the one applied to that declaration's own content parameter rather than to whichever `.split()` appears first in the body. Also removes the `allow-test-rule: source-text-is-the-product` marker. It was inert: the readFileSync result feeds espree.parse, never a text method, so local/no-source-grep has no candidate site to suppress. Proven by running the rule through the Linter API with suppression neutralized -- 0 messages either way. A marker naming an exemption that does not exist is misleading. The divergence figure in the header comment now states its normalization. Both reviewers were right and measured different things: over the 26 remaining shadows, whitespace-only normalization gives 16 diverged / 10 identical; stripping comments as well gives 15 / 11. Exactly one pair differs only in its comments. Control re-run against the pre-fix subject file with the new logic: 3 of 3 rows fail, 3 of 3 pass after. Every row is now load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
235 lines
9.5 KiB
JavaScript
235 lines
9.5 KiB
JavaScript
// Structural invariant over the test corpus itself: a helper declared at module
|
|
// scope must not be re-declared inside a program-level fold block, because the
|
|
// inner declaration shadows the outer one for everything in that fold and the
|
|
// two copies then drift independently (#4409, same class as #4205/#4337).
|
|
//
|
|
// Every assertion walks an AST. None reads a .cjs and calls .includes(): that is
|
|
// `local/no-source-grep`'s trigger shape, and it is also the wrong instrument —
|
|
// "how many declarations exist" is a construct count, and a regex would match the
|
|
// name inside a comment or a string literal too.
|
|
|
|
'use strict';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const espree = require('espree');
|
|
|
|
const TESTS_DIR = __dirname;
|
|
const SUBJECT = 'runtime-launcher-parity.test.cjs';
|
|
|
|
// The fold-shadowed helpers that remain elsewhere in tests/, measured with this
|
|
// same walker — not guessed. They are leftovers of the #1969 fold consolidation
|
|
// and are NOT this issue's scope: most of them DIVERGE from their module-scope
|
|
// twin (16 of 26 comparing whitespace-normalized bodies; 15 if comments are
|
|
// stripped too — one pair differs only in its comments), and a diverged shadow
|
|
// cannot be deleted mechanically the way this issue's could, because its fold's
|
|
// tests were written against its own copy.
|
|
//
|
|
// An exact sorted list, deliberately not a count: `27 !== 26` names no offender
|
|
// and costs a CI round-trip to diagnose.
|
|
const KNOWN_FOLD_SHADOWS = [
|
|
'capability-registry.test.cjs::makeTempCapDir',
|
|
'codex-config-agents.test.cjs::readHooksSessionStartCommands',
|
|
'codex-config-agents.test.cjs::runCodexInstall',
|
|
'config-loader.test.cjs::writeConfig',
|
|
'config.test.cjs::readConfig',
|
|
'config.test.cjs::readConfig',
|
|
'graphify-command-cutover.test.cjs::assertTypedError',
|
|
'graphify-command-cutover.test.cjs::makeGraphifyMock',
|
|
'graphify-command-cutover.test.cjs::runJsonErrors',
|
|
'health-validation.test.cjs::writeMinimalRoadmap',
|
|
'installer-migrations.test.cjs::userHook',
|
|
'model-profiles.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'model-resolver.test.cjs::writeConfig',
|
|
'read-guard.test.cjs::runHook',
|
|
'reapply-patches.test.cjs::parseFrontmatterField',
|
|
'runtime-homes-descriptor-drive.test.cjs::withEnv',
|
|
'skill-frontmatter-contract.test.cjs::read',
|
|
'state-prune.test.cjs::writeStateMd',
|
|
'update-custom-backup.test.cjs::sha256',
|
|
'update-custom-backup.test.cjs::sha256',
|
|
'update-custom-backup.test.cjs::writeManifest',
|
|
];
|
|
|
|
const FUNCTION_INITIALIZERS = new Set(['FunctionExpression', 'ArrowFunctionExpression']);
|
|
|
|
function walk(node, visit) {
|
|
if (!node || typeof node.type !== 'string') return;
|
|
visit(node);
|
|
for (const key of Object.keys(node)) {
|
|
if (key === 'loc' || key === 'range') continue;
|
|
const value = node[key];
|
|
if (Array.isArray(value)) value.forEach((child) => walk(child, visit));
|
|
else if (value && typeof value.type === 'string') walk(value, visit);
|
|
}
|
|
}
|
|
|
|
/** Recursive: tests/ has subdirectories (dispatch/, qa/, helpers/, …) that a
|
|
* flat readdir would silently exclude from the corpus guard. */
|
|
function allTestFiles(dir = TESTS_DIR, rel = '') {
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const abs = path.join(dir, entry.name);
|
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) out.push(...allTestFiles(abs, relPath));
|
|
else if (entry.name.endsWith('.cjs')) out.push(relPath);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
function parseTestFile(relPath) {
|
|
const src = fs.readFileSync(path.join(TESTS_DIR, relPath), 'utf8');
|
|
return espree.parse(src, { ecmaVersion: 2024, sourceType: 'script', loc: true });
|
|
}
|
|
|
|
/**
|
|
* Names a statement declares as a callable: a `function foo()` declaration AND a
|
|
* `const foo = () => {}` / `= function () {}` binding. Arrow-form helpers shadow
|
|
* exactly the same way; detecting only FunctionDeclaration would leave the guard
|
|
* blind to half the shapes a future fold could use.
|
|
*/
|
|
function declaredCallables(node) {
|
|
const found = new Map();
|
|
if (node.type === 'FunctionDeclaration' && node.id) {
|
|
found.set(node.id.name, node.loc.start.line);
|
|
}
|
|
if (node.type === 'VariableDeclaration') {
|
|
for (const d of node.declarations) {
|
|
if (d.id.type === 'Identifier' && d.init && FUNCTION_INITIALIZERS.has(d.init.type)) {
|
|
found.set(d.id.name, d.loc.start.line);
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Callables declared anywhere inside a PROGRAM-LEVEL bare block whose name
|
|
* collides with a module-scope callable in the same file. The bare `{` is the
|
|
* fold marker (`// Folded from … consolidation epic #1969`); the declarations sit
|
|
* deeper, inside the arrow passed to `__foldDescribe`, hence a descendant walk.
|
|
*/
|
|
function foldShadowedIn(relPath) {
|
|
const ast = parseTestFile(relPath);
|
|
const moduleScope = new Map();
|
|
for (const node of ast.body) {
|
|
for (const [name, line] of declaredCallables(node)) {
|
|
if (!moduleScope.has(name)) moduleScope.set(name, line);
|
|
}
|
|
}
|
|
const shadows = [];
|
|
if (moduleScope.size === 0) return shadows;
|
|
for (const node of ast.body) {
|
|
if (node.type !== 'BlockStatement') continue;
|
|
walk(node, (d) => {
|
|
for (const [name, line] of declaredCallables(d)) {
|
|
if (!moduleScope.has(name)) continue;
|
|
shadows.push({
|
|
key: `${relPath}::${name}`,
|
|
name,
|
|
moduleLine: moduleScope.get(name),
|
|
foldLine: line,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return shadows;
|
|
}
|
|
|
|
/** Scans the whole corpus. Parse failures are RAISED, never skipped: a silent
|
|
* `continue` would let the guard go quietly blind on the file that broke. */
|
|
function scanCorpus() {
|
|
const shadows = [];
|
|
const files = allTestFiles();
|
|
for (const relPath of files) {
|
|
shadows.push(...foldShadowedIn(relPath));
|
|
}
|
|
return { shadows, scanned: files.length };
|
|
}
|
|
|
|
describe('fold-shadowed test helpers (#4409)', () => {
|
|
// ROW 1 — the reported defect, asserted at the identity level.
|
|
test(`${SUBJECT} declares each helper exactly once`, () => {
|
|
const shadows = foldShadowedIn(SUBJECT).map(
|
|
(s) => `${s.name} (module@${s.moduleLine}, shadowed in fold@${s.foldLine})`,
|
|
);
|
|
assert.deepEqual(
|
|
shadows,
|
|
[],
|
|
`${SUBJECT} re-declares a module-scope helper inside its fold. The inner copy wins for ` +
|
|
'everything in that fold, so the two drift independently — which is how extractShellBlocks ' +
|
|
'came to split on "\\n" while its module-scope twin split on /\\r?\\n/ (#4409).\n ' +
|
|
shadows.join('\n '),
|
|
);
|
|
});
|
|
|
|
// ROW 2 — the rest of the corpus, pinned as an exact sorted list. A stale
|
|
// baseline entry fails here too (the lists stop matching), so there is no
|
|
// separate staleness row to pass vacuously.
|
|
test('the fold-shadowed set across tests/ matches the recorded baseline exactly', () => {
|
|
const { shadows, scanned } = scanCorpus();
|
|
assert.ok(scanned > 900, `expected the full corpus, scanned only ${scanned} files`);
|
|
assert.deepEqual(
|
|
shadows.map((s) => s.key).sort(),
|
|
[...KNOWN_FOLD_SHADOWS].sort(),
|
|
'The set of fold-shadowed helpers changed. If you REMOVED one, delete its line from ' +
|
|
'KNOWN_FOLD_SHADOWS — the baseline is meant to shrink. If you ADDED one, do not add it ' +
|
|
'here: declare the helper once at module scope instead (#4409).',
|
|
);
|
|
});
|
|
|
|
// ROW 3 — the behavioural half: the defect a Windows user actually hits.
|
|
test(`every extractShellBlocks in ${SUBJECT} splits lines CRLF-safely`, () => {
|
|
const ast = parseTestFile(SUBJECT);
|
|
const declarations = [];
|
|
walk(ast, (n) => {
|
|
if (n.type === 'FunctionDeclaration' && n.id && n.id.name === 'extractShellBlocks') {
|
|
declarations.push(n);
|
|
}
|
|
});
|
|
assert.equal(declarations.length, 1, 'exactly one extractShellBlocks may exist in this file');
|
|
|
|
// Check EVERY declaration, not just the first: on the pre-fix tree the
|
|
// module-scope copy came first and was already correct, so inspecting only
|
|
// declarations[0] would have missed the folded copy that carried the bug.
|
|
for (const declaration of declarations) {
|
|
const param = declaration.params[0];
|
|
assert.equal(param?.type, 'Identifier', 'extractShellBlocks takes a content parameter');
|
|
|
|
// Pin the split to the one applied TO THAT PARAMETER, rather than taking
|
|
// whichever `.split()` appears first in the body.
|
|
let splitArg = null;
|
|
walk(declaration, (n) => {
|
|
if (
|
|
splitArg === null &&
|
|
n.type === 'CallExpression' &&
|
|
n.callee.type === 'MemberExpression' &&
|
|
n.callee.property.name === 'split' &&
|
|
n.callee.object.type === 'Identifier' &&
|
|
n.callee.object.name === param.name
|
|
) {
|
|
splitArg = n.arguments[0];
|
|
}
|
|
});
|
|
assert.ok(splitArg, `extractShellBlocks must split its "${param.name}" parameter into lines`);
|
|
assert.ok(
|
|
splitArg.regex,
|
|
`extractShellBlocks splits on ${JSON.stringify(splitArg.value)} — a bare "\\n" leaves a ` +
|
|
'trailing \\r on every line of a CRLF checkout (core.autocrlf=true on Windows). Use /\\r?\\n/.',
|
|
);
|
|
assert.match(
|
|
splitArg.regex.pattern,
|
|
/\\r\?\\n/,
|
|
'the line split must tolerate a carriage return (#4409)',
|
|
);
|
|
}
|
|
});
|
|
});
|