test(#1972): consolidate 94 command/module regression tests into subject suites
Fold 94 issue-named command/module regression files into the canonical test file that owns each subject-under-test, across 52 existing suites (state, config, frontmatter, roadmap-parser, capability-registry, shell-command-projection-dispatch, plan-phase-drift-guard, health-validation, runtime-converters, commands, etc.). Verbatim block-scoped describe wrappers; 881 subtests conserved 1:1. No new test files. Host-env pre-check (per B2): the only GSD_WORKSTREAM/GSD_PROJECT-touching destinations (intel, planning-workspace) clear those vars hermetically, so folded CLI tests are safe. Regenerates regression-name allowlist (222->162), ratchets file-count allowlist across 8 buckets (validate entry removed after dropping <=2), makes 34 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 34 stale ids). Repoints CONTEXT.md + ADR-0002/443/1235/3524 test-file references. lint:ci green. Part of epic #1969. Closes #1972. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -551,3 +551,375 @@ describe('graphify cutover: registry entries correct', () => {
|
||||
assert.strictEqual(cmd.router, 'routeGraphifyCommand');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-974-graphify-budget-missing-value.test.cjs — consolidation epic #1969 (B3 #1972)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-974-graphify-budget-missing-value (consolidation epic #1969 B3 #1972)", () => {
|
||||
'use strict';
|
||||
/**
|
||||
* bug-974-graphify-budget-missing-value.test.cjs
|
||||
*
|
||||
* Regression guard: `gsd-tools graphify query <term> --budget` with no value
|
||||
* following --budget must emit a USAGE error and exit non-zero.
|
||||
*
|
||||
* Bug: args[budgetIdx + 1] was undefined → parseInt(undefined, 10) → NaN.
|
||||
* NaN is falsy so the budget guard `if (options.budget)` silently skipped
|
||||
* trimming and the query ran unbounded with no warning. (#974)
|
||||
*
|
||||
* Test categories:
|
||||
* 1. BEHAVIORAL (recording mock) — direct routeGraphifyCommand call, no I/O
|
||||
* 2. SUBPROCESS — end-to-end via runGsdTools
|
||||
* 3. PROPERTY — budget arg parser: non-numeric/absent → usage error, valid int → passes
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
enableGraphify,
|
||||
writeGraphJson,
|
||||
SAMPLE_GRAPH,
|
||||
} = require('./helpers/graphify.cjs');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { routeGraphifyCommand } = require('../gsd-core/bin/lib/graphify-command-router.cjs');
|
||||
|
||||
// ─── Recording-mock helpers (mirrors graphify-command-cutover.test.cjs) ────────
|
||||
|
||||
function makeGraphifyMock() {
|
||||
const calls = [];
|
||||
function recorder(name, ...fnArgs) {
|
||||
const sentinel = { _mock: name, args: fnArgs };
|
||||
calls.push(sentinel);
|
||||
return sentinel;
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
mock: {
|
||||
graphifyQuery: (cwd, term, opts) => recorder('graphifyQuery', cwd, term, opts),
|
||||
graphifyStatus: (cwd) => recorder('graphifyStatus', cwd),
|
||||
graphifyDiff: (cwd) => recorder('graphifyDiff', cwd),
|
||||
graphifyBuild: (cwd) => recorder('graphifyBuild', cwd),
|
||||
writeSnapshot: (cwd) => recorder('writeSnapshot', cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeErrorRecorder() {
|
||||
const calls = [];
|
||||
const fn = (msg, reason) => calls.push({ msg, reason });
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
function runJsonErrors(args, tmpDir, env = {}) {
|
||||
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
|
||||
assert.strictEqual(result.success, false,
|
||||
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
|
||||
`stdout: ${result.output}\nstderr: ${result.error}`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
||||
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertTypedError(parsed, expectedReason, label) {
|
||||
assert.strictEqual(parsed.ok, false, `${label}: error object must have ok: false`);
|
||||
assert.strictEqual(parsed.reason, expectedReason,
|
||||
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
`${label}: message must be a non-empty string`);
|
||||
}
|
||||
|
||||
// ─── 1. BEHAVIORAL — direct routeGraphifyCommand (recording mock, no I/O) ─────
|
||||
|
||||
describe('bug #974: --budget missing value → usage error (unit, recording mock)', () => {
|
||||
const CWD = '/fake/cwd';
|
||||
const RAW = false;
|
||||
|
||||
// (a) --budget as last arg (no value following)
|
||||
test('(a) --budget last arg (missing value) → error(USAGE); graphifyQuery NOT called', () => {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget'],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 1,
|
||||
`error must be called exactly once; got ${errFn.calls.length} calls`);
|
||||
assert.strictEqual(errFn.calls[0].reason, 'usage',
|
||||
`reason must be 'usage'; got: ${errFn.calls[0].reason}`);
|
||||
assert.ok(
|
||||
errFn.calls[0].msg.includes('graphify query'),
|
||||
`usage message must mention "graphify query"; got: ${errFn.calls[0].msg}`,
|
||||
);
|
||||
assert.strictEqual(calls.length, 0,
|
||||
'graphifyQuery must NOT be called when --budget has no value');
|
||||
});
|
||||
|
||||
// (b) --budget with a non-numeric value
|
||||
test('(b) --budget foo (non-numeric value) → error(USAGE); graphifyQuery NOT called', () => {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget', 'foo'],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 1,
|
||||
`error must be called exactly once; got ${errFn.calls.length} calls`);
|
||||
assert.strictEqual(errFn.calls[0].reason, 'usage',
|
||||
`reason must be 'usage'; got: ${errFn.calls[0].reason}`);
|
||||
assert.strictEqual(calls.length, 0,
|
||||
'graphifyQuery must NOT be called when --budget value is non-numeric');
|
||||
});
|
||||
|
||||
// (c) --budget 0 → 0 is a valid integer, must NOT error
|
||||
test('(c) --budget 0 (explicit zero) → budget passed as 0, no error', () => {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget', '0'],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 0,
|
||||
`error must NOT be called for --budget 0; got: ${JSON.stringify(errFn.calls)}`);
|
||||
assert.strictEqual(calls.length, 1,
|
||||
'graphifyQuery MUST be called for --budget 0');
|
||||
assert.strictEqual(calls[0]._mock, 'graphifyQuery');
|
||||
assert.strictEqual(calls[0].args[2].budget, 0,
|
||||
`budget must be 0, got: ${calls[0].args[2].budget}`);
|
||||
});
|
||||
|
||||
// (d) valid --budget 500 → regression: still works after fix
|
||||
test('(d) --budget 500 (valid positive integer) → budget: 500, no error', () => {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget', '500'],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 0,
|
||||
`error must NOT be called for --budget 500; got: ${JSON.stringify(errFn.calls)}`);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0]._mock, 'graphifyQuery');
|
||||
assert.strictEqual(calls[0].args[2].budget, 500,
|
||||
`budget must be integer 500; got: ${calls[0].args[2].budget}`);
|
||||
assert.strictEqual(typeof calls[0].args[2].budget, 'number',
|
||||
'budget must be a number, not string');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── 2. SUBPROCESS — end-to-end via runGsdTools ───────────────────────────────
|
||||
|
||||
describe('bug #974: --budget missing value → usage error (subprocess)', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableGraphify(planningDir);
|
||||
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('subprocess: --budget last arg → non-zero exit', () => {
|
||||
const result = runGsdTools(
|
||||
['graphify', 'query', 'AuthService', '--budget'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.strictEqual(result.success, false,
|
||||
`--budget with no value must exit non-zero; stderr: ${result.error}`);
|
||||
});
|
||||
|
||||
test('subprocess: --budget last arg → stderr contains usage hint', () => {
|
||||
const result = runGsdTools(
|
||||
['graphify', 'query', 'AuthService', '--budget'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(
|
||||
result.error.includes('Usage') || result.error.includes('graphify query'),
|
||||
`stderr must contain usage hint; got: ${result.error}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('subprocess: --budget foo (non-numeric) → non-zero exit', () => {
|
||||
const result = runGsdTools(
|
||||
['graphify', 'query', 'AuthService', '--budget', 'foo'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.strictEqual(result.success, false,
|
||||
`--budget with non-numeric value must exit non-zero; stderr: ${result.error}`);
|
||||
});
|
||||
|
||||
test('subprocess: --budget last arg → GSD_JSON_ERRORS=1 → usage reason', () => {
|
||||
const parsed = runJsonErrors(
|
||||
['graphify', 'query', 'AuthService', '--budget'],
|
||||
tmpDir,
|
||||
);
|
||||
assertTypedError(parsed, 'usage', '--budget missing value json-error');
|
||||
assert.ok(
|
||||
parsed.message.includes('graphify query'),
|
||||
`message must include "graphify query"; got: ${parsed.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('subprocess: --budget foo → GSD_JSON_ERRORS=1 → usage reason', () => {
|
||||
const parsed = runJsonErrors(
|
||||
['graphify', 'query', 'AuthService', '--budget', 'foo'],
|
||||
tmpDir,
|
||||
);
|
||||
assertTypedError(parsed, 'usage', '--budget non-numeric json-error');
|
||||
});
|
||||
|
||||
// Regression: valid --budget still works after the fix
|
||||
test('subprocess: --budget 500 (valid) → success, term echoed', () => {
|
||||
const result = runGsdTools(
|
||||
['graphify', 'query', 'AuthService', '--budget', '500'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(result.success,
|
||||
`--budget 500 must still succeed after fix; error: ${result.error}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.term, 'AuthService',
|
||||
'term must be echoed in query response');
|
||||
assert.ok('nodes' in parsed, 'response must include nodes array');
|
||||
assert.ok('total_nodes' in parsed, 'response must include total_nodes field');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. PROPERTY — budget parse: non-numeric/absent → usage error, valid int → passes
|
||||
// Per RULESET.TESTS.property-based-testing: parsing/budget-limit modules
|
||||
// must include ≥1 fast-check property test.
|
||||
|
||||
describe('bug #974: budget arg parser property tests', () => {
|
||||
const CWD = '/fake/cwd';
|
||||
const RAW = false;
|
||||
|
||||
// Property (a): any non-numeric, non-parseable string for --budget → usage error
|
||||
//
|
||||
// The budget-VALUE generator is constrained to genuinely non-numeric values.
|
||||
// The .filter(v => Number.isNaN(parseInt(v, 10))) at the end is the authoritative
|
||||
// gate: it excludes any value that parseInt(v, 10) would accept (e.g. "+5", "-3",
|
||||
// " 7", "0x1a", "007"). This makes the property universally true rather than
|
||||
// relying on a runtime skip inside the property body.
|
||||
// The term is fixed as "myterm" — this property is about the BUDGET VALUE, not
|
||||
// the term.
|
||||
test('property: non-numeric --budget value always produces usage error', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
// Alphabetic-start strings (parseInt('abc') = NaN)
|
||||
fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_-]{0,20}$/),
|
||||
// Empty string (parseInt('') = NaN)
|
||||
fc.constant(''),
|
||||
// Strings starting with special chars — filtered below for safety
|
||||
fc.stringMatching(/^[!@#$%^&*()_+={}\][|\\:;"'<,>?/~`][^\s]{0,10}$/),
|
||||
).filter(v => Number.isNaN(parseInt(v, 10))),
|
||||
(badValue) => {
|
||||
const { mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget', badValue],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 1,
|
||||
`--budget "${badValue}" (NaN) must produce exactly one error call`);
|
||||
assert.strictEqual(errFn.calls[0].reason, 'usage',
|
||||
`--budget "${badValue}" error must have reason 'usage'; got: ${errFn.calls[0].reason}`);
|
||||
},
|
||||
),
|
||||
// Explicit seed + numRuns for CI determinism.
|
||||
{ seed: 1001, numRuns: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
// Example (b): --budget as last arg (missing value) → usage error, for fixed valid terms.
|
||||
// Previously a property fuzz over a validTerm generator; replaced with deterministic
|
||||
// examples because the regex `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,29}$/` admitted single
|
||||
// digits like "0" which are falsy and trigger the router's `if (!term)` guard instead
|
||||
// of the budget-missing-value path, causing a spurious test failure (seed 1004, term
|
||||
// "0"). These properties are about the BUDGET contract; the term is a fixed bystander.
|
||||
test('example: --budget as last arg always produces usage error (fixed terms)', () => {
|
||||
const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent'];
|
||||
for (const term of FIXED_TERMS) {
|
||||
const { mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', term, '--budget'],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 1,
|
||||
`--budget as last arg: term=${JSON.stringify(term)}: must produce exactly one error call; got ${errFn.calls.length}`);
|
||||
assert.strictEqual(errFn.calls[0].reason, 'usage',
|
||||
`--budget as last arg: term=${JSON.stringify(term)}: reason must be 'usage'; got ${errFn.calls[0].reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Property (c): valid positive integer strings → budget passed through as number, no error
|
||||
test('property: positive integer string for --budget is always accepted', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 10_000_000 }),
|
||||
(n) => {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', 'myterm', '--budget', String(n)],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 0,
|
||||
`--budget ${n} (valid positive integer) must not produce an error`);
|
||||
assert.strictEqual(calls.length, 1, 'graphifyQuery must be called');
|
||||
assert.strictEqual(calls[0].args[2].budget, n,
|
||||
`budget must equal ${n}; got: ${calls[0].args[2].budget}`);
|
||||
assert.strictEqual(typeof calls[0].args[2].budget, 'number',
|
||||
`budget must be a number, not string "${calls[0].args[2].budget}"`);
|
||||
},
|
||||
),
|
||||
// Explicit seed + numRuns for CI determinism.
|
||||
{ seed: 1003, numRuns: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
// Example (d): absence of --budget flag yields budget:null and no error, for fixed valid terms.
|
||||
// Previously a property fuzz over a validTerm generator; replaced with deterministic
|
||||
// examples for the same reason as example (b): single-digit terms like "0" are falsy
|
||||
// and trigger the router's term-missing usage error, not the budget-absent path.
|
||||
// These examples fix the term as clearly valid multi-char identifiers.
|
||||
test('example: absence of --budget flag yields budget:null and no error (fixed terms)', () => {
|
||||
const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent'];
|
||||
for (const term of FIXED_TERMS) {
|
||||
const { calls, mock } = makeGraphifyMock();
|
||||
const errFn = makeErrorRecorder();
|
||||
routeGraphifyCommand({
|
||||
args: ['graphify', 'query', term],
|
||||
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
|
||||
});
|
||||
assert.strictEqual(errFn.calls.length, 0,
|
||||
`no --budget flag must not produce an error for term "${term}"`);
|
||||
assert.strictEqual(calls.length, 1,
|
||||
`graphifyQuery must be called for term "${term}"; got calls.length=${calls.length}`);
|
||||
assert.strictEqual(calls[0].args[2].budget, null,
|
||||
`absent --budget must pass budget:null; got: ${calls[0].args[2].budget}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user