* test(#3780): regression tests for parallel ledger-writer loss * fix(#3780): serialize WINDOWS.md mutations on a cross-process ledger lock * fix(#3780): keep the ledger-unavailable degrade contract intact under the lock wrapper * chore(#3780): backfill changeset PR number (4681) --------- Co-authored-by: sim <sim@local>
1033 lines
46 KiB
JavaScript
1033 lines
46 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Refactor-trigger integration + e2e tests (issue #1953).
|
|
*
|
|
* Covers test matrix rows 54b and 55-92 — the git adapter, the CLI contract
|
|
* (`gsd-tools refactor <subcommand>`), disposition + ledger interplay, strict
|
|
* mode's broken-windows integration, and the loop/registry wiring. Rows 1-54
|
|
* (analyzer, evaluator, baseline persistence) live in
|
|
* tests/complexity-trigger.test.cjs — out of scope here.
|
|
*
|
|
* Spec sources (authoritative):
|
|
* - .gsd/phase/feat-1953-complexity-triggered-refactor/42-router-contract.md
|
|
* - .gsd/phase/feat-1953-complexity-triggered-refactor/50-test-matrix.md
|
|
* - .gsd/phase/feat-1953-complexity-triggered-refactor/40-design.md
|
|
*
|
|
* Automation split (per 50-test-matrix.md Step 1): rows 55-56/61-62/74-90 use
|
|
* the router's `_git`/`_windows`/`_core` injection seams in-process (no real
|
|
* git/broken-windows I/O, no temp-dir races); rows 57-60/63-73 exercise the
|
|
* real `gsd-tools` CLI subprocess via tests/helpers/process-seam.cjs, because
|
|
* they are the actual CLI-contract/subprocess-degrade surface.
|
|
*
|
|
* Hermetic: every fixture dir via createTempGitProject/createTempDir +
|
|
* t.after(cleanup); every fs/require mock via mock.method(...), restored via
|
|
* t.after(...mock.restore()). No shared module-level fixture state (row 91).
|
|
* Every gsd-tools subprocess invocation clears GSD_WORKSTREAM/GSD_PROJECT/
|
|
* GSD_SESSION_KEY so a developer's shell cannot redirect the fixture (row 92).
|
|
*/
|
|
|
|
const { describe, test, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const Module = require('node:module');
|
|
|
|
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
|
|
const {
|
|
VERDICT,
|
|
REASON,
|
|
PROPOSAL_SUFFIX,
|
|
DEFAULTS,
|
|
analyzeSource,
|
|
} = require('../gsd-core/bin/lib/complexity-trigger.cjs');
|
|
const gitBaseBranch = require('../gsd-core/bin/lib/git-base-branch.cjs');
|
|
const windowsModule = require('../gsd-core/bin/lib/broken-windows.cjs');
|
|
const lockModule = require('../gsd-core/bin/lib/capability-lock.cjs');
|
|
const { routeRefactorTriggerCommand } = require('../gsd-core/bin/lib/refactor-trigger-command-router.cjs');
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const { validateCapability, VALID_LOOP_POINTS } = require('../gsd-core/bin/lib/capability-validator.cjs');
|
|
const { resolveLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
|
const { ERROR_REASON } = require('../gsd-core/bin/lib/io.cjs');
|
|
const refactorTriggerCapability = require('../capabilities/refactor-trigger/capability.json');
|
|
|
|
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
const ROUTER_MODULE_PATH = require.resolve('../gsd-core/bin/lib/refactor-trigger-command-router.cjs');
|
|
|
|
// A `gsd-tools refactor` subprocess in this suite does module require plus one
|
|
// or two bounded (<=15s) git calls against a tiny mkdtemp repo — well over any
|
|
// observed duration for that class of call (mirrors the reasoning in
|
|
// tests/helpers/timeouts.cjs's own per-class constants; this call class is not
|
|
// one of the four shared there, so it keeps its own local constant per that
|
|
// module's documented convention).
|
|
const CLI_TIMEOUT_MS = 30000;
|
|
|
|
const FLAT_JS = ['function f() {', ' return 1;', '}', ''].join('\n');
|
|
const TRIGGERING_JS = [
|
|
'function f(x) {',
|
|
' if (x) {',
|
|
' return 1;',
|
|
' }',
|
|
' return 2;',
|
|
'}',
|
|
'',
|
|
].join('\n');
|
|
|
|
function noThrowError(label) {
|
|
return (message, reason) => {
|
|
throw new Error(`${label}: unexpected error() call — message=${message} reason=${reason}`);
|
|
};
|
|
}
|
|
|
|
function writeConfig(dir, cfg) {
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(cfg), 'utf8');
|
|
}
|
|
|
|
/** Creates `.planning/phases/01-feat/01-PLAN.md` and commits it — this commit
|
|
* becomes the phase-start anchor per the router contract's "Touched-file
|
|
* anchor" rule (the commit that ADDED the phase's PLAN.md). */
|
|
function seedPhaseAndAnchor(dir) {
|
|
const phaseDir = path.join(dir, '.planning', 'phases', '01-feat');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), ['# Plan', ''].join('\n'), 'utf8');
|
|
gitOrThrow(['add', '-A'], { cwd: dir });
|
|
gitOrThrow(['commit', '-m', 'plan'], { cwd: dir });
|
|
return phaseDir;
|
|
}
|
|
|
|
function commitFile(dir, relPath, content, message) {
|
|
const full = path.join(dir, relPath);
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, content, 'utf8');
|
|
gitOrThrow(['add', '-A'], { cwd: dir });
|
|
gitOrThrow(['commit', '-m', message], { cwd: dir });
|
|
}
|
|
|
|
function runCliOnce(args, cwd, envOverrides = {}) {
|
|
return runNode([GSD_TOOLS, ...args], {
|
|
cwd,
|
|
timeoutMs: CLI_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
// Row 92: clear ambient GSD_* env so a developer's shell cannot
|
|
// redirect the fixture.
|
|
GSD_WORKSTREAM: '',
|
|
GSD_PROJECT: '',
|
|
GSD_SESSION_KEY: '',
|
|
...envOverrides,
|
|
},
|
|
});
|
|
}
|
|
|
|
function assertExited(result, label) {
|
|
assert.strictEqual(result.outcome, OUTCOME.EXITED, `${label}: expected EXITED, got outcome=${result.outcome} stderr=${result.stderr}`);
|
|
}
|
|
|
|
function parseStdout(result) {
|
|
return JSON.parse(result.stdout.trim());
|
|
}
|
|
|
|
function parseStderr(result) {
|
|
return JSON.parse(result.stderr.trim());
|
|
}
|
|
|
|
/** Sets up a triggering fixture: strict per `strict`, threshold=1 (any single
|
|
* decision point triggers), jumpDelta=100 (never independently triggers). */
|
|
function setupTriggeringProject(prefix, strict) {
|
|
const dir = createTempGitProject(prefix);
|
|
writeConfig(dir, {
|
|
refactor: {
|
|
trigger_enabled: true,
|
|
trigger_strict: strict,
|
|
complexity_threshold: 1,
|
|
complexity_jump_delta: 100,
|
|
},
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
return dir;
|
|
}
|
|
|
|
// ─── Row 54b — router orchestration continues past an unreadable file ───────
|
|
|
|
describe('refactor-trigger router: continues analyzing after one unreadable touched file (row 54b)', () => {
|
|
test('continuesAnalyzingAfterUnreadableFile', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-54b-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true, complexity_threshold: 1, complexity_jump_delta: 100 } });
|
|
seedPhaseAndAnchor(dir);
|
|
const goodAbs = path.join(dir, 'good.js');
|
|
const badAbs = path.join(dir, 'bad.js');
|
|
fs.writeFileSync(goodAbs, TRIGGERING_JS, 'utf8');
|
|
fs.writeFileSync(badAbs, FLAT_JS, 'utf8');
|
|
gitOrThrow(['add', '-A'], { cwd: dir });
|
|
gitOrThrow(['commit', '-m', 'good and bad'], { cwd: dir });
|
|
|
|
const origReadFileSync = fs.readFileSync;
|
|
const readMock = mock.method(fs, 'readFileSync', (target, ...rest) => {
|
|
if (target === badAbs) {
|
|
const err = new Error('simulated denied read');
|
|
err.code = 'EACCES';
|
|
throw err;
|
|
}
|
|
return origReadFileSync.call(fs, target, ...rest);
|
|
});
|
|
t.after(() => readMock.mock.restore());
|
|
|
|
const outputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'evaluate', '--phase', '1', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('row54b'),
|
|
_core: { output: (v) => outputs.push(v) },
|
|
});
|
|
|
|
assert.strictEqual(outputs.length, 1);
|
|
const result = outputs[0];
|
|
assert.strictEqual(result.verdict, VERDICT.TRIGGERED);
|
|
assert.strictEqual(result.target.file, 'good.js');
|
|
assert.strictEqual(result.skipped.length, 1);
|
|
assert.strictEqual(result.skipped[0].file, 'bad.js');
|
|
assert.strictEqual(result.skipped[0].reason, REASON.REFACTOR_FILE_UNREADABLE);
|
|
});
|
|
});
|
|
|
|
// ─── Rows 55-62 — touched-file discovery (git adapter) ──────────────────────
|
|
|
|
describe('refactor-trigger: touched-file discovery (git adapter)', () => {
|
|
test('returnsTouchedPathsFromDiff', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-55-');
|
|
t.after(() => cleanup(dir));
|
|
const before = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
|
|
commitFile(dir, 'a.js', FLAT_JS, 'add a');
|
|
commitFile(dir, 'sub/b.js', FLAT_JS, 'add b');
|
|
commitFile(dir, 'c.cjs', FLAT_JS, 'add c');
|
|
|
|
const touched = gitBaseBranch.changedFilesSince(dir, before);
|
|
assert.ok(Array.isArray(touched));
|
|
assert.deepEqual([...touched].sort(), ['a.js', 'c.cjs', 'sub/b.js'].sort());
|
|
});
|
|
|
|
test('handlesEmptyTouchedSet', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-56-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true } });
|
|
seedPhaseAndAnchor(dir);
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assertExited(result, 'row56');
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.BELOW_THRESHOLD);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_NO_TOUCHED_FILES);
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', 'phases', '01-feat', `01${PROPOSAL_SUFFIX}`)), false);
|
|
});
|
|
|
|
test('degradesWhenNotAGitRepository', (t) => {
|
|
const dir = createTempDir('gsd-refactor-cli-57-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true } });
|
|
const phaseDir = path.join(dir, '.planning', 'phases', '01-feat');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), ['# Plan', ''].join('\n'), 'utf8');
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assertExited(result, 'row57');
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.SKIPPED);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_GIT_UNAVAILABLE);
|
|
assert.strictEqual(fs.existsSync(path.join(phaseDir, `01${PROPOSAL_SUFFIX}`)), false);
|
|
});
|
|
|
|
test('degradesWhenGitBinaryMissing', () => {
|
|
const enoentExecGit = () => ({
|
|
exitCode: 127,
|
|
stdout: '',
|
|
stderr: 'git: not found',
|
|
signal: null,
|
|
error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }),
|
|
timedOut: false,
|
|
});
|
|
assert.strictEqual(gitBaseBranch.changedFilesSince('/does-not-matter', 'HEAD~1', enoentExecGit), null);
|
|
assert.strictEqual(gitBaseBranch.phaseStartCommit('/does-not-matter', '.planning/phases/01-feat', enoentExecGit), null);
|
|
});
|
|
|
|
test('degradesWhenGitTimesOut', () => {
|
|
const timeoutExecGit = () => ({
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
|
|
timedOut: true,
|
|
});
|
|
assert.strictEqual(gitBaseBranch.changedFilesSince('/does-not-matter', 'HEAD~1', timeoutExecGit), null);
|
|
assert.strictEqual(gitBaseBranch.phaseStartCommit('/does-not-matter', '.planning/phases/01-feat', timeoutExecGit), null);
|
|
});
|
|
|
|
test('roundTripsHostilePathsViaNulSeparatedDiff', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-60-');
|
|
t.after(() => cleanup(dir));
|
|
const before = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
|
|
const spaceName = 'a file with spaces.js';
|
|
const unicodeName = 'café-日本.js';
|
|
const dashLeadingName = '--leading-dash.js';
|
|
commitFile(dir, spaceName, FLAT_JS, 'space name');
|
|
commitFile(dir, unicodeName, FLAT_JS, 'unicode name');
|
|
commitFile(dir, dashLeadingName, FLAT_JS, 'dash-leading name');
|
|
|
|
const touched = gitBaseBranch.changedFilesSince(dir, before);
|
|
assert.ok(Array.isArray(touched));
|
|
assert.deepEqual([...touched].sort(), [spaceName, unicodeName, dashLeadingName].sort());
|
|
});
|
|
|
|
test('doesNotInterpretFlagLikePathAsGitOption', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-61-');
|
|
t.after(() => cleanup(dir));
|
|
const before = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
|
|
const flagLikeName = '--upload-pack=evil.js';
|
|
commitFile(dir, flagLikeName, FLAT_JS, 'flag-like name');
|
|
|
|
const touched = gitBaseBranch.changedFilesSince(dir, before);
|
|
assert.ok(Array.isArray(touched));
|
|
assert.deepEqual(touched, [flagLikeName]);
|
|
});
|
|
|
|
test('refusesPathEscapingRepoRoot', (t) => {
|
|
const dir = createTempDir('gsd-refactor-cli-62-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true } });
|
|
const phaseDir = path.join(dir, '.planning', 'phases', '01-feat');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), ['# Plan', ''].join('\n'), 'utf8');
|
|
|
|
// A real, readable file OUTSIDE the project root that a traversal path
|
|
// could reach if the router failed to confine it.
|
|
const secretParent = createTempDir('gsd-refactor-cli-62-secret-');
|
|
t.after(() => cleanup(secretParent));
|
|
const secretFile = path.join(secretParent, 'escaped.js');
|
|
fs.writeFileSync(secretFile, TRIGGERING_JS, 'utf8');
|
|
const traversal = path.relative(dir, secretFile);
|
|
|
|
const readCalls = [];
|
|
const origReadFileSync = fs.readFileSync;
|
|
const readMock = mock.method(fs, 'readFileSync', (target, ...rest) => {
|
|
readCalls.push(target);
|
|
return origReadFileSync.call(fs, target, ...rest);
|
|
});
|
|
t.after(() => readMock.mock.restore());
|
|
|
|
const outputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'evaluate', '--phase', '1', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('row62'),
|
|
_git: {
|
|
phaseStartCommit: () => 'HEAD~1',
|
|
changedFilesSince: () => [traversal],
|
|
},
|
|
_core: { output: (v) => outputs.push(v) },
|
|
});
|
|
|
|
assert.strictEqual(outputs.length, 1);
|
|
const result = outputs[0];
|
|
assert.strictEqual(result.verdict, VERDICT.BELOW_THRESHOLD);
|
|
assert.strictEqual(result.skipped.length, 1);
|
|
assert.strictEqual(result.skipped[0].reason, REASON.REFACTOR_FILE_UNREADABLE);
|
|
assert.strictEqual(readCalls.includes(secretFile), false, 'the escaping path must never reach fs.readFileSync');
|
|
});
|
|
});
|
|
|
|
// ─── Rows 63-73 — CLI contract: `gsd-tools refactor` ─────────────────────────
|
|
|
|
describe('refactor-trigger: CLI contract — gsd-tools refactor', () => {
|
|
test('writesProposalArtifactForTriggeringPhase', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-63-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assertExited(result, 'row63');
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.TRIGGERED);
|
|
assert.strictEqual(parsed.artifact_written, true);
|
|
assert.strictEqual(typeof parsed.artifact_path, 'string');
|
|
assert.strictEqual(fs.statSync(parsed.artifact_path).isFile(), true);
|
|
});
|
|
|
|
test('writesNoArtifactBelowThreshold', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-64-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true, complexity_threshold: 1000, complexity_jump_delta: 1000 } });
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'flat.js', FLAT_JS, 'flat file');
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assertExited(result, 'row64');
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.BELOW_THRESHOLD);
|
|
assert.strictEqual(parsed.artifact_written, false);
|
|
assert.strictEqual(parsed.artifact_path, null);
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', 'phases', '01-feat', `01${PROPOSAL_SUFFIX}`)), false);
|
|
});
|
|
|
|
test('returnsDisabledResponseWhenCapabilityOff', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-65-');
|
|
t.after(() => cleanup(dir));
|
|
seedPhaseAndAnchor(dir);
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assertExited(result, 'row65');
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.disabled, true);
|
|
});
|
|
|
|
test('rejectsMissingPhaseArgument', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-66-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, 'row66');
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.ok, false);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_USAGE);
|
|
});
|
|
|
|
test('rejectsEmptyAndWhitespacePhase', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-67-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
for (const phaseValue of ['', ' ']) {
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', phaseValue, '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, `row67(${JSON.stringify(phaseValue)})`);
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
}
|
|
});
|
|
|
|
// Matrix row 68 names REFACTOR_USAGE as the expected reason; the router
|
|
// contract (42-router-contract.md "evaluate" step 2) and the shipped
|
|
// implementation both treat `--phase=`/`--phase==N` as PRESENT-BUT-INVALID
|
|
// (REFACTOR_INVALID_PHASE), reserving REFACTOR_USAGE for the flag being
|
|
// absent entirely. Asserted against the router contract + implementation,
|
|
// which agree with each other; the matrix wording is the stale one here —
|
|
// see the final report for this discrepancy.
|
|
test('rejectsMalformedPhaseAssignment', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-68-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
for (const arg of ['--phase=', '--phase==3']) {
|
|
const result = runCliOnce(['refactor', 'evaluate', arg, '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, `row68(${arg})`);
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
}
|
|
});
|
|
|
|
test('rejectsDuplicatePhaseFlag', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-69-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--phase', '2', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, 'row69');
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
});
|
|
|
|
test('rejectsFlagLikePhaseValue', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-70-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, 'row70');
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
});
|
|
|
|
test('reportsUnknownSubcommandWithAvailableList', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-71-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'frobnicate', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, 'row71');
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
});
|
|
|
|
test('rejectsOverlongAndUnicodePhaseValues', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-72-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
// Overlong: matches the positive-integer regex (all digits), so it clears
|
|
// arg validation but fails to resolve as a real phase directory ->
|
|
// REFACTOR_INVALID_PHASE via the resolve-phase-dir path (exit 0, typed
|
|
// verdict on stdout) rather than the arg-validation path.
|
|
const overlong = '9'.repeat(5000);
|
|
const overlongResult = runCliOnce(['refactor', 'evaluate', '--phase', overlong, '--raw'], dir);
|
|
assertExited(overlongResult, 'row72(overlong)');
|
|
assert.strictEqual(overlongResult.exitCode, 0);
|
|
const overlongParsed = parseStdout(overlongResult);
|
|
assert.strictEqual(overlongParsed.verdict, VERDICT.SKIPPED);
|
|
assert.strictEqual(overlongParsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
|
|
// Unicode: fails the arg-validation regex outright.
|
|
const unicodeResult = runCliOnce(['refactor', 'evaluate', '--phase', '一二三', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(unicodeResult, 'row72(unicode)');
|
|
assert.notStrictEqual(unicodeResult.exitCode, 0);
|
|
const unicodeParsed = parseStderr(unicodeResult);
|
|
assert.strictEqual(unicodeParsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
});
|
|
|
|
test('provesNoShellInterpolationOfPhaseValue', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-73-', false);
|
|
t.after(() => cleanup(dir));
|
|
const sentinel = path.join(dir, 'SENTINEL');
|
|
|
|
const payloads = [
|
|
`1;touch ${sentinel};`,
|
|
`$(touch ${sentinel})`,
|
|
'`touch ' + sentinel + '`',
|
|
];
|
|
for (const payload of payloads) {
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', payload, '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, `row73(${JSON.stringify(payload)})`);
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
assert.strictEqual(fs.existsSync(sentinel), false, `sentinel must not exist after payload ${JSON.stringify(payload)}`);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_INVALID_PHASE);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Rows 74-79 — disposition + ledger ───────────────────────────────────────
|
|
|
|
describe('refactor-trigger: disposition + ledger', () => {
|
|
test('recordsDeclinedRefactorAsDeviationWindow', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-74-', true);
|
|
t.after(() => cleanup(dir));
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
assert.strictEqual(parseStdout(evalResult).ledger_recorded, true);
|
|
|
|
const declineResult = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', 'not worth it', '--raw'], dir);
|
|
assertExited(declineResult, 'row74');
|
|
assert.strictEqual(declineResult.exitCode, 0);
|
|
const declineParsed = parseStdout(declineResult);
|
|
assert.strictEqual(declineParsed.status, 'declined');
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.entries.length, 1);
|
|
assert.strictEqual(ledger.entries[0].kind, 'deviation');
|
|
assert.strictEqual(ledger.entries[0].status, 'waived');
|
|
});
|
|
|
|
test('declinesGracefullyWithoutBrokenWindows', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-75-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true, trigger_strict: false, complexity_threshold: 1, complexity_jump_delta: 100 } });
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
|
|
const evalOutputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'evaluate', '--phase', '1', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('row75(evaluate)'),
|
|
_core: { output: (v) => evalOutputs.push(v) },
|
|
});
|
|
assert.strictEqual(evalOutputs[0].verdict, VERDICT.TRIGGERED);
|
|
|
|
// Simulate broken-windows genuinely absent: make the router's own
|
|
// `require('./broken-windows.cjs')` throw, exactly as the router contract
|
|
// documents ("absent or a throwing require is the documented degrade
|
|
// path"). Scoped to this module's own require calls only.
|
|
const origRequire = Module.prototype.require;
|
|
const requireMock = mock.method(Module.prototype, 'require', function mockedRequire(id) {
|
|
if (id === './broken-windows.cjs' && this.filename === ROUTER_MODULE_PATH) {
|
|
throw new Error('simulated: broken-windows capability not installed');
|
|
}
|
|
return origRequire.call(this, id);
|
|
});
|
|
t.after(() => requireMock.mock.restore());
|
|
|
|
const declineOutputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'decline', '--phase', '1', '--reason', 'not now', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('row75(decline)'),
|
|
_core: { output: (v) => declineOutputs.push(v) },
|
|
});
|
|
|
|
assert.strictEqual(declineOutputs.length, 1);
|
|
const declineResult = declineOutputs[0];
|
|
assert.strictEqual(declineResult.status, 'declined');
|
|
assert.strictEqual(declineResult.ledger_resolved, false);
|
|
assert.strictEqual(typeof declineResult.ledger_note, 'string');
|
|
assert.notStrictEqual(declineResult.ledger_note, '');
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)), false);
|
|
});
|
|
|
|
test('rejectsEmptyDeclineReason', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-76-', false);
|
|
t.after(() => cleanup(dir));
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
const artifactPath = parseStdout(evalResult).artifact_path;
|
|
|
|
for (const reason of ['', ' ']) {
|
|
const result = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', reason, '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, `row76(${JSON.stringify(reason)})`);
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
const parsed = parseStderr(result);
|
|
assert.strictEqual(parsed.reason, REASON.REFACTOR_DECLINE_REASON_EMPTY);
|
|
}
|
|
|
|
const { parseProposal } = require('../gsd-core/bin/lib/complexity-trigger.cjs');
|
|
const proposal = parseProposal(fs.readFileSync(artifactPath, 'utf8'));
|
|
assert.strictEqual(proposal.status, 'proposed', 'a rejected decline must not change the proposal status');
|
|
});
|
|
|
|
test('doesNotAppendDuplicateWindowOnRepeatedDecline', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-77-', true);
|
|
t.after(() => cleanup(dir));
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
|
|
const first = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', 'first', '--raw'], dir);
|
|
assert.strictEqual(first.exitCode, 0);
|
|
|
|
const second = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', 'second', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(second, 'row77(second)');
|
|
assert.notStrictEqual(second.exitCode, 0);
|
|
assert.strictEqual(parseStderr(second).reason, REASON.REFACTOR_ALREADY_DISPOSITIONED);
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.entries.length, 1);
|
|
});
|
|
|
|
test('rejectsDeclineWithNoProposal', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-78-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, { refactor: { trigger_enabled: true } });
|
|
seedPhaseAndAnchor(dir);
|
|
|
|
const result = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', 'x', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(result, 'row78');
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
assert.strictEqual(parseStderr(result).reason, REASON.REFACTOR_ARTIFACT_NOT_FOUND);
|
|
});
|
|
|
|
test('treatsAcceptAndDeclineAsMutuallyExclusive', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-79-', false);
|
|
t.after(() => cleanup(dir));
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
|
|
const acceptResult = runCliOnce(['refactor', 'accept', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(acceptResult.exitCode, 0);
|
|
assert.strictEqual(parseStdout(acceptResult).status, 'accepted');
|
|
|
|
const declineResult = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', 'x', '--raw'], dir, { GSD_JSON_ERRORS: '1' });
|
|
assertExited(declineResult, 'row79');
|
|
assert.notStrictEqual(declineResult.exitCode, 0);
|
|
assert.strictEqual(parseStderr(declineResult).reason, REASON.REFACTOR_ALREADY_DISPOSITIONED);
|
|
});
|
|
});
|
|
|
|
// ─── Rows 80-86 — strict mode -> broken-windows ledger ───────────────────────
|
|
|
|
describe('refactor-trigger router: locked-ledger degrade (#3780)', () => {
|
|
test('evaluate degrades ledger_recorded to false with a note when a writer holds the ledger lock', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-3780-lock-', true);
|
|
t.after(() => cleanup(dir));
|
|
const lockPath = path.join(dir, '.planning', '.WINDOWS.lock');
|
|
const handle = lockModule.acquireLock(lockPath, { maxAttempts: 1 });
|
|
assert.ok(handle, 'test setup: the test process must be able to acquire the ledger lock');
|
|
t.after(() => lockModule.releaseLock(handle));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0, 'evaluate must degrade, never fail, on lock contention');
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.artifact_written, true, 'the local proposal artifact is independent of the ledger');
|
|
assert.strictEqual(parsed.ledger_recorded, false);
|
|
assert.match(parsed.ledger_note, /ledger lock/, `note must name the contention: ${parsed.ledger_note}`);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)),
|
|
false,
|
|
'a degraded record must not write the ledger',
|
|
);
|
|
|
|
// After release the same evaluation records — the refusal was contention.
|
|
lockModule.releaseLock(handle);
|
|
const result2 = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result2.exitCode, 0);
|
|
assert.strictEqual(parseStdout(result2).ledger_recorded, true);
|
|
});
|
|
});
|
|
|
|
|
|
describe('refactor-trigger: strict mode -> broken-windows ledger', () => {
|
|
test('appendsNoWindowInAdvisoryMode', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-80-', false);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.TRIGGERED);
|
|
assert.strictEqual(parsed.artifact_written, true);
|
|
assert.strictEqual(parsed.ledger_recorded, undefined);
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)), false);
|
|
});
|
|
|
|
test('appendsNoWindowWhenNothingTriggered', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-81-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, {
|
|
refactor: { trigger_enabled: true, trigger_strict: true, complexity_threshold: 1000, complexity_jump_delta: 1000 },
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'flat.js', FLAT_JS, 'flat file');
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.BELOW_THRESHOLD);
|
|
assert.strictEqual(parsed.artifact_written, false);
|
|
assert.strictEqual(parsed.ledger_recorded, undefined, 'F1 regression guard: nothing triggered => zero windows, ever');
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)), false);
|
|
});
|
|
|
|
test('appendsDeviationWindowForUntriagedProposal', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-82-', true);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
assert.strictEqual(parseStdout(result).ledger_recorded, true);
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.open_count, 1);
|
|
assert.strictEqual(ledger.entries.length, 1);
|
|
const entry = ledger.entries[0];
|
|
assert.strictEqual(entry.kind, 'deviation');
|
|
assert.strictEqual(entry.status, 'open');
|
|
assert.strictEqual(entry.file, 'hot.js', 'dedup identity is the structured file field, not prose');
|
|
assert.strictEqual(entry.line, 1, 'dedup identity is the structured line field (target function start line)');
|
|
});
|
|
|
|
test('resolvesWindowOnAcceptRegardlessOfScore', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-83-', true);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
const evalParsed = parseStdout(evalResult);
|
|
assert.strictEqual(evalParsed.verdict, VERDICT.TRIGGERED);
|
|
const triggeringScore = evalParsed.target.score;
|
|
|
|
// Accept WITHOUT touching hot.js at all — its live score cannot have
|
|
// improved, and per config it is still strictly above the threshold.
|
|
const acceptResult = runCliOnce(['refactor', 'accept', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(acceptResult.exitCode, 0);
|
|
const acceptParsed = parseStdout(acceptResult);
|
|
assert.strictEqual(acceptParsed.reanchored_to, triggeringScore, 'the Goodhart guarantee: the score did not improve');
|
|
assert.ok(acceptParsed.reanchored_to > 1, 'score must still be above the configured threshold (1)');
|
|
assert.strictEqual(acceptParsed.ledger_resolved, true);
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.open_count, 0);
|
|
assert.strictEqual(ledger.fixed_count, 1);
|
|
});
|
|
|
|
test('waivesWindowWithReasonOnDecline', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-84-', true);
|
|
t.after(() => cleanup(dir));
|
|
const evalResult = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(evalResult.exitCode, 0);
|
|
|
|
const reasonText = 'deferred to next phase';
|
|
const declineResult = runCliOnce(['refactor', 'decline', '--phase', '1', '--reason', reasonText, '--raw'], dir);
|
|
assert.strictEqual(declineResult.exitCode, 0);
|
|
assert.strictEqual(parseStdout(declineResult).ledger_resolved, true);
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.entries.length, 1);
|
|
assert.strictEqual(ledger.entries[0].status, 'waived');
|
|
assert.strictEqual(ledger.entries[0].reason, reasonText);
|
|
});
|
|
|
|
test('doesNotDuplicateWindowOnReEvaluate', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-85-', true);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const first = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(first.exitCode, 0);
|
|
const second = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(second.exitCode, 0);
|
|
assert.strictEqual(parseStdout(second).ledger_recorded, true);
|
|
|
|
const ledgerPath = path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME);
|
|
const ledger = windowsModule.parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
|
|
assert.strictEqual(ledger.entries.length, 1);
|
|
assert.strictEqual(ledger.open_count, 1);
|
|
});
|
|
|
|
test('notesLedgerUnavailableWithoutBrokenWindows', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-86-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, {
|
|
refactor: { trigger_enabled: true, trigger_strict: true, complexity_threshold: 1, complexity_jump_delta: 100 },
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
|
|
const origRequire = Module.prototype.require;
|
|
const requireMock = mock.method(Module.prototype, 'require', function mockedRequire(id) {
|
|
if (id === './broken-windows.cjs' && this.filename === ROUTER_MODULE_PATH) {
|
|
throw new Error('simulated: broken-windows capability not installed');
|
|
}
|
|
return origRequire.call(this, id);
|
|
});
|
|
t.after(() => requireMock.mock.restore());
|
|
|
|
const outputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'evaluate', '--phase', '1', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('row86'),
|
|
_core: { output: (v) => outputs.push(v) },
|
|
});
|
|
|
|
assert.strictEqual(outputs.length, 1);
|
|
const result = outputs[0];
|
|
assert.strictEqual(result.verdict, VERDICT.TRIGGERED);
|
|
assert.strictEqual(result.artifact_written, true);
|
|
assert.strictEqual(result.ledger_recorded, false);
|
|
assert.strictEqual(result.ledger_note, 'broken-windows capability unavailable — proposal recorded locally only');
|
|
assert.strictEqual(fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)), false);
|
|
});
|
|
});
|
|
|
|
// ─── Strict-mode enforcement-gap warning (#1953) ─────────────────────────────
|
|
//
|
|
// `refactor.trigger_strict` only ever appends a deviation window to the
|
|
// broken-windows ledger; a ship only actually stops when the SEPARATE
|
|
// `workflow.windows_enforce` toggle is also on. These cases lock the typed
|
|
// `REFACTOR_STRICT_NOT_ENFORCING` warning that closes that expectation gap.
|
|
|
|
describe('refactor-trigger: strict-mode enforcement-gap warning', () => {
|
|
test('warnsWhenStrictOnAndWindowsEnforceOff', (t) => {
|
|
const dir = setupTriggeringProject('gsd-refactor-cli-swe-1-', true);
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.ledger_recorded, true, 'broken-windows must be present and recording for this case');
|
|
assert.ok(Array.isArray(parsed.warnings));
|
|
assert.strictEqual(parsed.warnings.length, 1);
|
|
assert.strictEqual(parsed.warnings[0].reason, REASON.REFACTOR_STRICT_NOT_ENFORCING);
|
|
assert.strictEqual(typeof parsed.warnings[0].message, 'string');
|
|
assert.notStrictEqual(parsed.warnings[0].message, '');
|
|
});
|
|
|
|
test('omitsWarningWhenStrictOnAndWindowsEnforceOn', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-swe-2-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, {
|
|
refactor: { trigger_enabled: true, trigger_strict: true, complexity_threshold: 1, complexity_jump_delta: 100 },
|
|
workflow: { windows_enforce: true },
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.ledger_recorded, true, 'broken-windows must be present and recording for this case');
|
|
assert.strictEqual(parsed.warnings, undefined);
|
|
});
|
|
|
|
test('warnsWhenStrictOnAndBrokenWindowsAbsent', (t) => {
|
|
const dir = createTempGitProject('gsd-refactor-cli-swe-3-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, {
|
|
refactor: { trigger_enabled: true, trigger_strict: true, complexity_threshold: 1, complexity_jump_delta: 100 },
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
|
|
// Simulate broken-windows genuinely absent, exactly as
|
|
// `notesLedgerUnavailableWithoutBrokenWindows` (row 86) does.
|
|
const origRequire = Module.prototype.require;
|
|
const requireMock = mock.method(Module.prototype, 'require', function mockedRequire(id) {
|
|
if (id === './broken-windows.cjs' && this.filename === ROUTER_MODULE_PATH) {
|
|
throw new Error('simulated: broken-windows capability not installed');
|
|
}
|
|
return origRequire.call(this, id);
|
|
});
|
|
t.after(() => requireMock.mock.restore());
|
|
|
|
const outputs = [];
|
|
routeRefactorTriggerCommand({
|
|
args: ['refactor', 'evaluate', '--phase', '1', '--raw'],
|
|
cwd: dir,
|
|
raw: true,
|
|
error: noThrowError('swe-3'),
|
|
_core: { output: (v) => outputs.push(v) },
|
|
});
|
|
|
|
assert.strictEqual(outputs.length, 1);
|
|
const result = outputs[0];
|
|
assert.strictEqual(result.ledger_recorded, false);
|
|
assert.ok(Array.isArray(result.warnings));
|
|
assert.strictEqual(result.warnings.length, 1);
|
|
assert.strictEqual(result.warnings[0].reason, REASON.REFACTOR_STRICT_NOT_ENFORCING);
|
|
assert.strictEqual(typeof result.warnings[0].message, 'string');
|
|
assert.notStrictEqual(result.warnings[0].message, '');
|
|
});
|
|
|
|
test('neverWarnsWhenStrictOffRegardlessOfWindowsEnforce', (t) => {
|
|
for (const windowsEnforce of [true, false]) {
|
|
const dir = createTempGitProject('gsd-refactor-cli-swe-4-');
|
|
t.after(() => cleanup(dir));
|
|
writeConfig(dir, {
|
|
refactor: { trigger_enabled: true, trigger_strict: false, complexity_threshold: 1, complexity_jump_delta: 100 },
|
|
workflow: { windows_enforce: windowsEnforce },
|
|
});
|
|
seedPhaseAndAnchor(dir);
|
|
commitFile(dir, 'hot.js', TRIGGERING_JS, 'hot file');
|
|
|
|
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
const parsed = parseStdout(result);
|
|
assert.strictEqual(parsed.verdict, VERDICT.TRIGGERED, `windowsEnforce=${windowsEnforce}: fixture must still trigger`);
|
|
assert.strictEqual(parsed.ledger_recorded, undefined, `windowsEnforce=${windowsEnforce}: strict is off`);
|
|
assert.strictEqual(parsed.warnings, undefined, `windowsEnforce=${windowsEnforce}: strict off must never warn`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Row 87 (independence) — registry declares zero ship:pre gates ──────────
|
|
|
|
describe('refactor-trigger: registry independence', () => {
|
|
test('declaresNoShipPreGate', () => {
|
|
const cap = registry.capabilities['refactor-trigger'];
|
|
assert.ok(cap, 'refactor-trigger must be present in the registry');
|
|
assert.deepEqual(cap.gates, []);
|
|
|
|
const shipPre = registry.byLoopPoint['ship:pre'];
|
|
assert.strictEqual(shipPre.gates.length, 2);
|
|
assert.deepEqual(shipPre.gates.map((g) => g.capId).sort(), ['broken-windows', 'security']);
|
|
});
|
|
});
|
|
|
|
// ─── Rows 87-90 — loop wiring + manifest validation ──────────────────────────
|
|
|
|
describe('refactor-trigger: loop wiring', () => {
|
|
test('rendersRefactorStepAtExecutePost', () => {
|
|
const resolved = resolveLoopHooks({
|
|
point: 'execute:post',
|
|
registry,
|
|
config: { refactor: { trigger_enabled: true } },
|
|
});
|
|
const step = resolved.activeHooks.find((h) => h.capId === 'refactor-trigger');
|
|
assert.ok(step, 'refactor-trigger step must be present when enabled');
|
|
assert.strictEqual(step.kind, 'step');
|
|
assert.deepEqual(step.ref, { command: 'refactor evaluate' });
|
|
assert.strictEqual(step.when, 'refactor.trigger_enabled');
|
|
assert.strictEqual(step.onError, 'skip');
|
|
});
|
|
|
|
test('omitsRefactorStepWhenDisabled', () => {
|
|
const resolved = resolveLoopHooks({
|
|
point: 'execute:post',
|
|
registry,
|
|
config: { refactor: { trigger_enabled: false } },
|
|
});
|
|
const step = resolved.activeHooks.find((h) => h.capId === 'refactor-trigger');
|
|
assert.strictEqual(step, undefined, 'refactor-trigger step must be absent when disabled');
|
|
});
|
|
|
|
test('preservesCodeReviewHookShapeAlongsideRefactorHook', () => {
|
|
const resolved = resolveLoopHooks({
|
|
point: 'execute:post',
|
|
registry,
|
|
config: { refactor: { trigger_enabled: true } },
|
|
});
|
|
assert.strictEqual(resolved.activeHooks.length, 2, 'both refactor-trigger and code-review must be present');
|
|
const codeReview = resolved.activeHooks.find((h) => h.capId === 'code-review');
|
|
assert.ok(codeReview, 'code-review step must still be present alongside refactor-trigger');
|
|
assert.deepEqual(codeReview, {
|
|
capId: 'code-review',
|
|
kind: 'step',
|
|
ref: { skill: 'code-review' },
|
|
when: 'workflow.code_review',
|
|
produces: ['REVIEW.md'],
|
|
consumes: ['SUMMARY.md'],
|
|
onError: 'skip',
|
|
supportsReviewerLanes: true,
|
|
});
|
|
});
|
|
|
|
test('validatesRefactorTriggerManifest', () => {
|
|
const errors = validateCapability(refactorTriggerCapability, 'refactor-trigger');
|
|
assert.deepEqual(errors, []);
|
|
|
|
const ownConfigKeys = [
|
|
'refactor.trigger_enabled',
|
|
'refactor.complexity_threshold',
|
|
'refactor.complexity_jump_delta',
|
|
'refactor.trigger_strict',
|
|
];
|
|
for (const key of ownConfigKeys) {
|
|
assert.strictEqual(registry.configKeys[key], 'refactor-trigger', `config key ${key} must be owned solely by refactor-trigger, no other capability`);
|
|
}
|
|
|
|
for (const step of refactorTriggerCapability.steps) {
|
|
assert.strictEqual(VALID_LOOP_POINTS.has(step.point), true, `step.point ${step.point} must be one of the closed 12 loop points`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Regression: handleEvaluate self-complexity (#3267) ────────────────────
|
|
|
|
describe('refactor-trigger-command-router: self-complexity', () => {
|
|
test('handleEvaluateStaysAtOrBelowThreshold', () => {
|
|
const routerPath = path.join(__dirname, '..', 'src', 'refactor-trigger-command-router.cts');
|
|
const source = fs.readFileSync(routerPath, 'utf8');
|
|
const result = analyzeSource(source);
|
|
assert.strictEqual(result.ok, true, 'router source must analyze cleanly');
|
|
const handleEvaluateFn = result.functions.find((f) => f.name === 'handleEvaluate');
|
|
assert.ok(handleEvaluateFn, 'handleEvaluate must still be found by the analyzer');
|
|
assert.ok(
|
|
handleEvaluateFn.score <= DEFAULTS.threshold,
|
|
`handleEvaluate scores ${handleEvaluateFn.score}, must be <= ${DEFAULTS.threshold} (refactor.complexity_threshold)`,
|
|
);
|
|
});
|
|
});
|