Merge branch 'next' into feat/1279-fail-first-prover
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
"gsd-pattern-mapper.md": 12487,
|
||||
"gsd-phase-researcher.md": 40638,
|
||||
"gsd-plan-checker.md": 42003,
|
||||
"gsd-planner.md": 48892,
|
||||
"gsd-planner.md": 49216,
|
||||
"gsd-project-researcher.md": 22014,
|
||||
"gsd-research-synthesizer.md": 13653,
|
||||
"gsd-roadmapper.md": 21781,
|
||||
|
||||
@@ -147,3 +147,103 @@ describe('bug #641 — --files-from with bare suite token', () => {
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for issue #1329:
|
||||
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
|
||||
// list that included tests/core.test.cjs — a file deleted in #1291. Every
|
||||
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
|
||||
// path into .ci-selected-tests.txt and crashed run-tests with
|
||||
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
|
||||
// the fallback at write time, fall back to the 'unit' suite sentinel when
|
||||
// nothing survives, and guard the FALLBACK constant against disk reality.
|
||||
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
|
||||
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
|
||||
require('../scripts/ci-prepare-test-scope.cjs');
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
|
||||
// the test files on disk are two surfaces that must stay in sync. This fails
|
||||
// the instant a refactor deletes a file still named in FALLBACK — which is
|
||||
// precisely what #1291 did and CI did not catch.
|
||||
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
|
||||
for (const entry of FALLBACK) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(entry);
|
||||
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
|
||||
assert.ok(
|
||||
isSentinel || exists,
|
||||
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1329-fallback-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
|
||||
// Create all but the last FALLBACK file under a controlled root, simulating
|
||||
// a since-deleted test (the #1329 mechanism), independent of which files
|
||||
// FALLBACK happens to name today.
|
||||
const present = FALLBACK.slice(0, -1);
|
||||
const absent = FALLBACK[FALLBACK.length - 1];
|
||||
for (const f of present) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
|
||||
const lines = resolveSelection({ scope: 'targeted', targeted: '', windows: '', root: tmpDir });
|
||||
|
||||
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
|
||||
for (const f of present) {
|
||||
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
|
||||
// tmpDir/tests exists but contains none of the FALLBACK files.
|
||||
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
|
||||
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
|
||||
});
|
||||
|
||||
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
|
||||
// The detected list is already filtered by affected-tests-lib and may carry
|
||||
// a suite sentinel; ci-prepare-test-scope must not touch it.
|
||||
const lines = resolveSelection({
|
||||
scope: 'targeted',
|
||||
targeted: 'tests/does-not-exist.test.cjs unit',
|
||||
windows: '',
|
||||
root: tmpDir,
|
||||
});
|
||||
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
|
||||
});
|
||||
|
||||
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
|
||||
// Run the real script (subprocess) with empty detection inside an isolated
|
||||
// root that holds the FALLBACK files, then verify every line it wrote into
|
||||
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
|
||||
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
|
||||
for (const f of FALLBACK) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
const prep = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
|
||||
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
|
||||
|
||||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||||
for (const line of selected.split('\n').filter(Boolean)) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(line);
|
||||
assert.ok(
|
||||
isSentinel || fs.existsSync(path.join(tmpDir, line)),
|
||||
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
|
||||
});
|
||||
});
|
||||
|
||||
596
tests/capability-precedence-parity.test.cjs
Normal file
596
tests/capability-precedence-parity.test.cjs
Normal file
@@ -0,0 +1,596 @@
|
||||
'use strict';
|
||||
/**
|
||||
* capability-precedence-parity.test.cjs — DEFECT.GENERATIVE-FIX parity gate
|
||||
*
|
||||
* Proves that the config-key four-level precedence walk has a single owner
|
||||
* (capability-activation.cjs) and that loop-resolver.cjs re-exports the same
|
||||
* functions rather than maintaining local copies.
|
||||
*
|
||||
* Two contracts enforced:
|
||||
* (a) Identity: loop-resolver's _resolveActivationValue, _getNestedConfigValue,
|
||||
* and _readRawConfigKey are the SAME function objects as
|
||||
* capability-activation's exports. Fails the instant a local copy is
|
||||
* re-introduced.
|
||||
* (b) Behavioral matrix: across a fixture matrix hitting each precedence level
|
||||
* (loadConfig result, workstream config.json, root config.json, registry
|
||||
* default, absent, falsy values, prototype-pollution key), asserts that
|
||||
* _resolveActivationValue(key,config,cwd,registry) ===
|
||||
* (resolveConfigKey(key,{config,cwd,registry}).found ?
|
||||
* Boolean(resolveConfigKey(key,{config,cwd,registry}).value) : false).
|
||||
* Level-2/3 cases use real tmpdir .planning/config.json fixtures.
|
||||
*
|
||||
* RULESET: RULESET.TESTS.no-source-grep — no readFileSync + .includes() on source.
|
||||
* RULESET: RULESET.TESTS.boundary-coverage — covers limit-1/limit/limit+1 (falsy boundary).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ── Module paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
|
||||
const capabilityActivation = require(path.join(LIB, 'capability-activation.cjs'));
|
||||
const loopResolver = require(path.join(LIB, 'loop-resolver.cjs'));
|
||||
|
||||
// ─── (a) Identity guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('capability-precedence-parity: identity guard — loop-resolver re-exports same function objects', () => {
|
||||
test('_resolveActivationValue is the same function in both modules', () => {
|
||||
assert.strictEqual(
|
||||
loopResolver._resolveActivationValue,
|
||||
capabilityActivation._resolveActivationValue,
|
||||
'_resolveActivationValue: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||||
);
|
||||
});
|
||||
|
||||
test('_getNestedConfigValue is the same function in both modules', () => {
|
||||
assert.strictEqual(
|
||||
loopResolver._getNestedConfigValue,
|
||||
capabilityActivation._getNestedConfigValue,
|
||||
'_getNestedConfigValue: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||||
);
|
||||
});
|
||||
|
||||
test('_readRawConfigKey is the same function in both modules', () => {
|
||||
assert.strictEqual(
|
||||
loopResolver._readRawConfigKey,
|
||||
capabilityActivation._readRawConfigKey,
|
||||
'_readRawConfigKey: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (b) Behavioral matrix ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal registry with a configSchema entry for a dotted key.
|
||||
* def=undefined means no default (absent). def=<value> provides a default.
|
||||
*/
|
||||
function makeRegistry(dotKey, def) {
|
||||
const registry = {};
|
||||
if (def !== undefined) {
|
||||
registry.configSchema = {
|
||||
[dotKey]: { default: def },
|
||||
};
|
||||
} else {
|
||||
registry.configSchema = {};
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: call resolveConfigKey and assert it equals
|
||||
* _resolveActivationValue(key,config,cwd,registry) on the boolean coercion.
|
||||
*/
|
||||
function assertParity(key, config, cwd, registry, label) {
|
||||
const { _resolveActivationValue, resolveConfigKey } = capabilityActivation;
|
||||
const boolResult = _resolveActivationValue(key, config, cwd, registry);
|
||||
const rawResult = resolveConfigKey(key, { config, cwd, registry });
|
||||
const expectedBool = rawResult.found ? Boolean(rawResult.value) : false;
|
||||
assert.strictEqual(
|
||||
boolResult,
|
||||
expectedBool,
|
||||
`parity check failed for ${label}: _resolveActivationValue=${boolResult} but resolveConfigKey gives found=${rawResult.found} value=${JSON.stringify(rawResult.value)} → expectedBool=${expectedBool}`,
|
||||
);
|
||||
return { boolResult, rawResult };
|
||||
}
|
||||
|
||||
describe('capability-precedence-parity: behavioral matrix', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
// Create a hermetic tmpdir for file-based fixtures (levels 2+3).
|
||||
// Clear env vars that redirect planningDir/planningRoot.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parity-test-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Remove the tmpdir (helpers.cleanup carries the Windows-EBUSY retry budget).
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Env vars that planningDir/planningRoot read — save/restore around each test.
|
||||
function withCleanEnv(fn) {
|
||||
const saved = {
|
||||
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
|
||||
GSD_PROJECT: process.env.GSD_PROJECT,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
};
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (saved.GSD_WORKSTREAM !== undefined) process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
if (saved.GSD_PROJECT !== undefined) process.env.GSD_PROJECT = saved.GSD_PROJECT;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (saved.CLAUDE_CONFIG_DIR !== undefined) process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Level 1: loadConfig result (config arg has the key) ─────────────────────
|
||||
|
||||
test('level-1 truthy: config arg has key=true → resolved true, parity holds', () => {
|
||||
const key = 'feature.enabled';
|
||||
const config = { feature: { enabled: true } };
|
||||
const registry = makeRegistry(key, undefined);
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 truthy');
|
||||
assert.strictEqual(boolResult, true);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, true);
|
||||
});
|
||||
|
||||
test('level-1 falsy (false): config arg has key=false → resolved false, parity holds', () => {
|
||||
// BVA: falsy value false — boundary case
|
||||
const key = 'feature.enabled';
|
||||
const config = { feature: { enabled: false } };
|
||||
const registry = makeRegistry(key, true); // default true but level-1 wins
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 falsy=false');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, false); // raw value preserved
|
||||
});
|
||||
|
||||
test('level-1 falsy (0): config arg has key=0 → resolved false, parity holds', () => {
|
||||
// BVA: falsy value 0 — boundary case
|
||||
const key = 'feature.level';
|
||||
const config = { feature: { level: 0 } };
|
||||
const registry = makeRegistry(key, 2); // default 2 but level-1 wins
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 falsy=0');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, 0); // raw value 0 preserved
|
||||
});
|
||||
|
||||
test('level-1 truthy (nonzero): config arg has key=2 → resolved true, parity holds', () => {
|
||||
// BVA: truthy numeric — boundary-adjacent to 0
|
||||
const key = 'feature.level';
|
||||
const config = { feature: { level: 2 } };
|
||||
const registry = makeRegistry(key, undefined);
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 truthy=2');
|
||||
assert.strictEqual(boolResult, true);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, 2);
|
||||
});
|
||||
|
||||
// ── Level 2: workstream config.json ─────────────────────────────────────────
|
||||
|
||||
test('level-2: workstream config.json has key → resolved, level-1 empty, parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
// Set up a .planning/config.json in tmpDir
|
||||
const planningDir = path.join(tmpDir, 'ws-l2', '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ graphify: { enabled: true } }),
|
||||
'utf8',
|
||||
);
|
||||
const key = 'graphify.enabled';
|
||||
const config = {}; // level-1 miss
|
||||
const registry = makeRegistry(key, undefined);
|
||||
const cwd = path.join(tmpDir, 'ws-l2');
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-2 workstream');
|
||||
assert.strictEqual(boolResult, true);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('level-2 falsy: workstream config.json has key=false → false, parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
const planningDir = path.join(tmpDir, 'ws-l2-false', '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ graphify: { enabled: false } }),
|
||||
'utf8',
|
||||
);
|
||||
const key = 'graphify.enabled';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, true); // default true, but level-2 wins
|
||||
const cwd = path.join(tmpDir, 'ws-l2-false');
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-2 falsy');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Level 3: root config.json (only when paths differ) ──────────────────────
|
||||
|
||||
// Without GSD_WORKSTREAM, planningDir === planningRoot, so levels 2 and 3 point
|
||||
// to the same file. Level-3 distinction is only exercisable by setting GSD_WORKSTREAM.
|
||||
// We test the "paths same → no double-read" path (covered by level-2 test above)
|
||||
// and the "paths differ" path via GSD_WORKSTREAM.
|
||||
test('level-3: root config.json read when workstream path differs, parity holds', () => {
|
||||
// Use GSD_WORKSTREAM to force planningDir to differ from planningRoot.
|
||||
withCleanEnv(() => {
|
||||
const projectRoot = path.join(tmpDir, 'ws-l3-project');
|
||||
// Root planning dir = projectRoot/.planning
|
||||
const rootPlanningDir = path.join(projectRoot, '.planning');
|
||||
// Workstream planning dir = projectRoot/.planning/workstreams/mystream
|
||||
const wsName = 'mystream';
|
||||
const wsPlanningDir = path.join(rootPlanningDir, 'workstreams', wsName);
|
||||
fs.mkdirSync(rootPlanningDir, { recursive: true });
|
||||
fs.mkdirSync(wsPlanningDir, { recursive: true });
|
||||
|
||||
// Write ONLY the root config.json (no ws config.json)
|
||||
fs.writeFileSync(
|
||||
path.join(rootPlanningDir, 'config.json'),
|
||||
JSON.stringify({ intel: { enabled: true } }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Activate workstream so planningDir → wsPlanningDir, planningRoot → rootPlanningDir
|
||||
process.env.GSD_WORKSTREAM = wsName;
|
||||
|
||||
const key = 'intel.enabled';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, undefined);
|
||||
const { boolResult, rawResult } = assertParity(key, config, projectRoot, registry, 'level-3 root config');
|
||||
// ws config.json absent → level-2 miss; root config.json present → level-3 hit
|
||||
assert.strictEqual(boolResult, true);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Level 4: registry configSchema default ───────────────────────────────────
|
||||
|
||||
test('level-4: registry default=true, no config or file → true, parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
const key = 'workflow.some_feature';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, true);
|
||||
// cwd points to an empty dir — no .planning/config.json files
|
||||
const cwd = path.join(tmpDir, 'l4-default-true');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=true');
|
||||
assert.strictEqual(boolResult, true);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('level-4: registry default=false → false (BVA: falsy default), parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
const key = 'workflow.some_feature';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, false);
|
||||
const cwd = path.join(tmpDir, 'l4-default-false');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=false');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, false); // raw false preserved
|
||||
});
|
||||
});
|
||||
|
||||
test('level-4: registry default=0 → false (BVA: numeric zero default), parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
const key = 'feature.level';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, 0);
|
||||
const cwd = path.join(tmpDir, 'l4-default-0');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=0');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, 0); // raw 0 preserved
|
||||
});
|
||||
});
|
||||
|
||||
// ── Level 5: absent ──────────────────────────────────────────────────────────
|
||||
|
||||
test('level-5: key absent everywhere → false, found=false, parity holds', () => {
|
||||
withCleanEnv(() => {
|
||||
const key = 'nonexistent.key';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, undefined); // no default
|
||||
const cwd = path.join(tmpDir, 'l5-absent');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-5 absent');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, false);
|
||||
assert.strictEqual(rawResult.value, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('level-5: cwd=undefined, absent key → false, found=false, parity holds', () => {
|
||||
const key = 'nonexistent.key';
|
||||
const config = {};
|
||||
const registry = makeRegistry(key, undefined);
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-5 absent cwd=undefined');
|
||||
assert.strictEqual(boolResult, false);
|
||||
assert.strictEqual(rawResult.found, false);
|
||||
});
|
||||
|
||||
// ── Prototype-pollution guard (nested-traversal sink, levels 1–3) ──────────────
|
||||
//
|
||||
// The guard protects the NESTED config-object traversal (_getNestedConfigValue) —
|
||||
// the only prototype-pollution sink. A `__proto__`/`constructor`/`prototype`
|
||||
// SEGMENT encountered mid-path is rejected → found=false. Level 4 is a flat
|
||||
// single-key lookup of the whole dotted string against registry.configSchema (no
|
||||
// nested traversal, not a pollution sink), so we deliberately seed NO level-4
|
||||
// default here — otherwise that flat lookup would legitimately match the literal
|
||||
// dotted key and the segment guard would not be the thing under test.
|
||||
|
||||
test('prototype-pollution: __proto__ mid-path segment → found=false, boolResult=false, parity holds', () => {
|
||||
const key = 'a.__proto__.polluted';
|
||||
const config = { a: { real: 1 } }; // 'a' resolves, then the '__proto__' segment must be rejected
|
||||
const registry = makeRegistry(key, undefined); // no level-4 default
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'proto-pollution __proto__ mid-path');
|
||||
assert.strictEqual(rawResult.found, false, '__proto__ path segment must be rejected by the nested-traversal guard');
|
||||
assert.strictEqual(boolResult, false);
|
||||
});
|
||||
|
||||
test('prototype-pollution: constructor mid-path segment → found=false, boolResult=false, parity holds', () => {
|
||||
const key = 'a.constructor.polluted';
|
||||
const config = { a: { real: 1 } };
|
||||
const registry = makeRegistry(key, undefined); // no level-4 default
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'proto-pollution constructor mid-path');
|
||||
assert.strictEqual(rawResult.found, false, 'constructor path segment must be rejected by the nested-traversal guard');
|
||||
assert.strictEqual(boolResult, false);
|
||||
});
|
||||
|
||||
// ── Level-1 precedence over level-4 ─────────────────────────────────────────
|
||||
|
||||
test('level-1 wins over level-4 default: config=false beats registry default=true', () => {
|
||||
// BVA: this is the critical boundary — level-1 falsy beats level-4 truthy
|
||||
const key = 'graphify.enabled';
|
||||
const config = { graphify: { enabled: false } };
|
||||
const registry = makeRegistry(key, true);
|
||||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 false beats level-4 true default');
|
||||
assert.strictEqual(boolResult, false, 'config value false must win over registry default true');
|
||||
assert.strictEqual(rawResult.found, true);
|
||||
assert.strictEqual(rawResult.value, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) resolveConfigKey identity guard (FIX 2 — loop-resolver re-exports resolveConfigKey) ────
|
||||
//
|
||||
// resolveLoopHooks.resolveConfigValues uses resolveConfigKey internally to build
|
||||
// hook configValues. Exporting resolveConfigKey from loop-resolver and asserting
|
||||
// identity here ensures that if someone ever introduces a local copy, this test
|
||||
// will catch it immediately (same mechanism as the _resolveActivationValue guard
|
||||
// above, applied to the raw-value consumer).
|
||||
|
||||
describe('capability-precedence-parity: identity guard — loop-resolver.resolveConfigKey is same fn as capability-activation.resolveConfigKey', () => {
|
||||
test('resolveConfigKey is the same function in both modules', () => {
|
||||
assert.ok(
|
||||
typeof loopResolver.resolveConfigKey === 'function',
|
||||
'loop-resolver must export resolveConfigKey',
|
||||
);
|
||||
assert.strictEqual(
|
||||
loopResolver.resolveConfigKey,
|
||||
capabilityActivation.resolveConfigKey,
|
||||
'resolveConfigKey: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (d) Behavioral: loop-resolver resolveConfigValues uses the shared engine ────────────────────
|
||||
//
|
||||
// Proves that resolveLoopHooks's resolveConfigValues closure (the RAW-value consumer)
|
||||
// produces the SAME result as calling capabilityActivation.resolveConfigKey directly
|
||||
// for the same (dotKey, config, cwd, registry) arguments.
|
||||
//
|
||||
// This test FAILS if resolveConfigValues is ever replaced with a divergent copy that
|
||||
// does not delegate to resolveConfigKey — fulfilling the DEFECT.GENERATIVE-FIX mandate.
|
||||
//
|
||||
// Coverage: three cases required:
|
||||
// (1) Level-1 config hit — key present in config arg
|
||||
// (2) Level-4 registry default — key absent from config, present in schema default
|
||||
// (3) Absent key — key absent everywhere (omitted from resolved output)
|
||||
|
||||
describe('capability-precedence-parity: behavioral — loop-resolver resolveConfigValues uses shared resolveConfigKey engine', () => {
|
||||
const { resolveLoopHooks } = loopResolver;
|
||||
const { resolveConfigKey } = capabilityActivation;
|
||||
const { CANONICAL_POINTS_FALLBACK } = loopResolver;
|
||||
|
||||
/**
|
||||
* Build a minimal registry that has:
|
||||
* - one contribution hook at the given point with configValues map
|
||||
* - one capability that owns that hook (capId = 'test-cap', active=true implied)
|
||||
* - configSchema entries for level-4 defaults
|
||||
*/
|
||||
function makeLoopRegistry({ point, configValues, schemaDefaults = {} }) {
|
||||
const byLoopPoint = {};
|
||||
for (const p of CANONICAL_POINTS_FALLBACK) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
byLoopPoint[point].contributions = [
|
||||
{
|
||||
capId: 'test-cap',
|
||||
into: 'test-section',
|
||||
fragment: { inline: 'test fragment' },
|
||||
configValues,
|
||||
},
|
||||
];
|
||||
const configSchema = {};
|
||||
for (const [dotKey, def] of Object.entries(schemaDefaults)) {
|
||||
configSchema[dotKey] = { default: def };
|
||||
}
|
||||
return { byLoopPoint, configSchema };
|
||||
}
|
||||
|
||||
/** capabilityStatesById with test-cap active=true */
|
||||
function activeCapMap() {
|
||||
return new Map([['test-cap', { enabled: true, active: true }]]);
|
||||
}
|
||||
|
||||
test('(1) level-1 config hit: resolveConfigValues matches resolveConfigKey for present key', () => {
|
||||
// Hook declares configValues: { secLevel: 'security.asvs_level' }
|
||||
// Config has security.asvs_level=2 → level-1 hit
|
||||
const dotKey = 'security.asvs_level';
|
||||
const alias = 'secLevel';
|
||||
const config = { security: { asvs_level: 2 } };
|
||||
const registry = makeLoopRegistry({
|
||||
point: 'plan:pre',
|
||||
configValues: { [alias]: dotKey },
|
||||
});
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry,
|
||||
config,
|
||||
cwd: undefined,
|
||||
capabilityStatesById: activeCapMap(),
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||||
const hook = resolved.activeHooks[0];
|
||||
assert.ok(hook.configValues, 'configValues must be present on the resolved hook');
|
||||
|
||||
// resolveConfigKey direct result for the same args
|
||||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||||
assert.strictEqual(directResult.found, true, 'direct resolveConfigKey must find the level-1 key');
|
||||
assert.strictEqual(
|
||||
hook.configValues[alias],
|
||||
directResult.value,
|
||||
`resolved hook configValues[${alias}] must equal resolveConfigKey(...).value for level-1 hit (both=${JSON.stringify(directResult.value)})`,
|
||||
);
|
||||
assert.strictEqual(hook.configValues[alias], 2, 'level-1 numeric value 2 must be preserved (not coerced to boolean)');
|
||||
});
|
||||
|
||||
test('(2) level-4 registry default hit: resolveConfigValues matches resolveConfigKey for schema default', () => {
|
||||
// Hook declares configValues: { blockOn: 'security.block_on' }
|
||||
// Config is empty; schema default = 'medium'
|
||||
const dotKey = 'security.block_on';
|
||||
const alias = 'blockOn';
|
||||
const schemaDefault = 'medium';
|
||||
const config = {};
|
||||
const registry = makeLoopRegistry({
|
||||
point: 'execute:pre',
|
||||
configValues: { [alias]: dotKey },
|
||||
schemaDefaults: { [dotKey]: schemaDefault },
|
||||
});
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'execute:pre',
|
||||
registry,
|
||||
config,
|
||||
cwd: undefined,
|
||||
capabilityStatesById: activeCapMap(),
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||||
const hook = resolved.activeHooks[0];
|
||||
assert.ok(hook.configValues, 'configValues must be present on the resolved hook (schema default found)');
|
||||
|
||||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||||
assert.strictEqual(directResult.found, true, 'direct resolveConfigKey must find the level-4 schema default');
|
||||
assert.strictEqual(
|
||||
hook.configValues[alias],
|
||||
directResult.value,
|
||||
`resolved hook configValues[${alias}] must equal resolveConfigKey(...).value for level-4 hit (both=${JSON.stringify(directResult.value)})`,
|
||||
);
|
||||
assert.strictEqual(hook.configValues[alias], 'medium', 'level-4 string default must be preserved as raw string');
|
||||
});
|
||||
|
||||
test('(3a) mixed hook: one resolvable alias + one absent alias → configValues present, contains only the resolvable alias', () => {
|
||||
// Hook declares configValues: { present: 'feature.on', absent: 'nope.missing' }
|
||||
// Config has feature.on=true (level-1 hit); no registry default for nope.missing.
|
||||
// Expected: resolved hook.configValues is defined, has 'present'=true, does NOT have 'absent'.
|
||||
const presentDotKey = 'feature.on';
|
||||
const absentDotKey = 'nope.missing';
|
||||
const config = { feature: { on: true } };
|
||||
const registry = makeLoopRegistry({
|
||||
point: 'verify:post',
|
||||
configValues: { present: presentDotKey, absent: absentDotKey },
|
||||
// No schema default for absentDotKey — it must be absent everywhere
|
||||
});
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry,
|
||||
config,
|
||||
cwd: undefined,
|
||||
capabilityStatesById: activeCapMap(),
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||||
const hook = resolved.activeHooks[0];
|
||||
|
||||
// The 'present' alias must resolve via level-1 config hit
|
||||
assert.ok(hook.configValues !== undefined, 'configValues must be defined (at least one alias resolved)');
|
||||
assert.strictEqual(
|
||||
hook.configValues['present'],
|
||||
true,
|
||||
"hook.configValues['present'] must equal true (level-1 hit for feature.on)",
|
||||
);
|
||||
|
||||
// The 'absent' alias must NOT appear in configValues at all (omit-when-absent contract)
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(hook.configValues, 'absent'),
|
||||
"Absent key 'nope.missing' must not appear in hook.configValues (found=false → omit, not include as undefined)",
|
||||
);
|
||||
});
|
||||
|
||||
test('(3b) all-absent hook: all configValues aliases absent everywhere → hook.configValues === undefined', () => {
|
||||
// Hook declares configValues: { missingAlias: 'does.not.exist' }
|
||||
// Key absent from config, no schema default, no config files.
|
||||
// resolveConfigValues must return undefined (empty resolved map → omitted entirely).
|
||||
const dotKey = 'does.not.exist';
|
||||
const alias = 'missingAlias';
|
||||
const config = {};
|
||||
const registry = makeLoopRegistry({
|
||||
point: 'ship:pre',
|
||||
configValues: { [alias]: dotKey },
|
||||
// No schema default for this key
|
||||
});
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'ship:pre',
|
||||
registry,
|
||||
config,
|
||||
cwd: undefined,
|
||||
capabilityStatesById: activeCapMap(),
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||||
const hook = resolved.activeHooks[0];
|
||||
|
||||
// resolveConfigKey direct result must confirm absent
|
||||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||||
assert.strictEqual(directResult.found, false, 'resolveConfigKey must return found=false for absent key');
|
||||
assert.strictEqual(directResult.value, undefined);
|
||||
|
||||
// When ALL aliases are absent, resolveConfigValues returns undefined → hook.configValues must be undefined
|
||||
assert.strictEqual(
|
||||
hook.configValues,
|
||||
undefined,
|
||||
'hook.configValues must be undefined when all aliases are absent (omit-when-empty contract)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1461,6 +1461,136 @@ describe('isCapabilityActive — convenience predicate (Phase 2)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityRuntimeState configOverride (FIX 1 — single config snapshot) ──────────────
|
||||
|
||||
describe('resolveCapabilityRuntimeState configOverride — honors caller snapshot instead of reading disk', () => {
|
||||
// These tests exercise the optional third parameter `configOverride`.
|
||||
// When provided, the resolver MUST use that config object for capability
|
||||
// activation (active = enabled && configActivation) rather than calling
|
||||
// loadConfig(cwd) internally. This guarantees that cmdLoopRenderHooks and
|
||||
// the resolver use the exact same config snapshot (TOCTOU elimination).
|
||||
//
|
||||
// Strategy: build a synthetic registry with `activationKey: 'myfeature.enabled'`
|
||||
// and use a tmpdir whose on-disk .planning/config.json carries the OPPOSITE
|
||||
// value from the configOverride. The test asserts that the returned capability
|
||||
// `active` matches the override, not the on-disk file.
|
||||
|
||||
const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
let tmpDir;
|
||||
let savedEnv;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-override-'));
|
||||
// Save env vars that affect planningDir / config resolution
|
||||
savedEnv = {
|
||||
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
|
||||
GSD_PROJECT: process.env.GSD_PROJECT,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
};
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
// Write on-disk config with myfeature.enabled=false (override will say true)
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ myfeature: { enabled: false } }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Restore env vars
|
||||
if (savedEnv.GSD_WORKSTREAM !== undefined) process.env.GSD_WORKSTREAM = savedEnv.GSD_WORKSTREAM;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
if (savedEnv.GSD_PROJECT !== undefined) process.env.GSD_PROJECT = savedEnv.GSD_PROJECT;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (savedEnv.CLAUDE_CONFIG_DIR !== undefined) process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('configOverride=undefined (default) reads on-disk config → active=false', () => {
|
||||
// Baseline: without override, loadConfig reads the on-disk file which has
|
||||
// myfeature.enabled=false, so the capability is inactive.
|
||||
// NOTE: resolveCapabilityRuntimeState does real I/O (profile/surface), which
|
||||
// degrades gracefully on a tmpDir that has no surface marker. We only assert
|
||||
// on the `active` field which is determined by the config activation leg.
|
||||
// Use a simple registry with no skills so installed=surfaced=enabled=true
|
||||
// (vacuously) and active depends solely on configActivation.
|
||||
//
|
||||
// Because resolveCapabilityRuntimeState always uses the real capability-registry.cjs,
|
||||
// we cannot inject the synthetic registry into it. Instead we verify the
|
||||
// config-override leg by comparing the two code paths (with and without override)
|
||||
// against each other on a known config key that the real registry uses.
|
||||
//
|
||||
// We use the real 'graphify' capability whose activationKey='graphify.enabled'.
|
||||
// On-disk: graphify.enabled=false → active=false.
|
||||
// Override: { graphify: { enabled: true } } → active=true.
|
||||
//
|
||||
// Set up a separate tmpdir with graphify.enabled=false on disk.
|
||||
const tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-override-baseline-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpCwd, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpCwd, '.planning', 'config.json'),
|
||||
JSON.stringify({ graphify: { enabled: false } }),
|
||||
'utf8',
|
||||
);
|
||||
// No override → reads on-disk config → graphify active=false
|
||||
const result = resolveCapabilityRuntimeState(tmpCwd, undefined);
|
||||
const graphifyEntry = result.capabilities.find((c) => c.id === 'graphify');
|
||||
assert.ok(graphifyEntry, 'graphify capability must be present in the real registry');
|
||||
assert.strictEqual(
|
||||
graphifyEntry.active,
|
||||
false,
|
||||
'Without configOverride, on-disk graphify.enabled=false must produce active=false',
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpCwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('configOverride honors caller value — overrides on-disk config (active flips from false to true)', () => {
|
||||
// On-disk: graphify.enabled=false (set up in before()).
|
||||
// configOverride: { graphify: { enabled: true } } ← caller wins.
|
||||
// Expected: graphify active=true (override, not disk).
|
||||
//
|
||||
// The tmpDir from before() has graphify.enabled=false on disk. We pass
|
||||
// configOverride with graphify.enabled=true. The returned active must be true.
|
||||
const override = { graphify: { enabled: true } };
|
||||
const result = resolveCapabilityRuntimeState(tmpDir, undefined, override);
|
||||
const graphifyEntry = result.capabilities.find((c) => c.id === 'graphify');
|
||||
assert.ok(graphifyEntry, 'graphify capability must be present in the real registry');
|
||||
assert.strictEqual(
|
||||
graphifyEntry.active,
|
||||
true,
|
||||
'configOverride { graphify: { enabled: true } } must produce active=true even though on-disk config has graphify.enabled=false',
|
||||
);
|
||||
});
|
||||
|
||||
test('configOverride — non-existent cwd, override with enabled=true → active=true regardless of absent disk file', () => {
|
||||
// When cwd points to a non-existent directory, loadConfig(cwd) would return {}
|
||||
// (or throw/fallback to {}). With configOverride, the caller's value is used
|
||||
// for level-1 directly. Levels 2+3 raw reads on a non-existent cwd produce
|
||||
// no-hits. So: override={graphify:{enabled:true}} → level-1 hit → active=true.
|
||||
// This is a clean test that avoids any interaction with the on-disk fixture in tmpDir.
|
||||
const nonExistentCwd = path.join(os.tmpdir(), 'cap-override-nonexistent-' + Date.now());
|
||||
const override = { graphify: { enabled: true } };
|
||||
const result = resolveCapabilityRuntimeState(nonExistentCwd, undefined, override);
|
||||
const graphifyEntry = result.capabilities.find((c) => c.id === 'graphify');
|
||||
assert.ok(graphifyEntry, 'graphify capability must be present in the real registry');
|
||||
assert.strictEqual(
|
||||
graphifyEntry.active,
|
||||
true,
|
||||
'configOverride { graphify: { enabled: true } } must produce active=true even with non-existent cwd (level-1 hit from override)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-runtime runtime detection (HIGH fix — GSD_RUNTIME → config.runtime → 'claude') ──────
|
||||
|
||||
describe('isCapabilityActive cross-runtime detection (GSD_RUNTIME → config.runtime → claude)', () => {
|
||||
|
||||
823
tests/enh-968-region-scoped-negative-grep.test.cjs
Normal file
823
tests/enh-968-region-scoped-negative-grep.test.cjs
Normal file
@@ -0,0 +1,823 @@
|
||||
// allow-test-rule: source-text-is-the-product #968
|
||||
// Enhancement #968: region-scoped negative gate detector + guidance docs.
|
||||
// Tests the pure function scanFileWideNegativeGateConflict exported from
|
||||
// verify.cjs, plus CLI integration and doc-contract assertions.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
// Build path to built verify.cjs
|
||||
const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs');
|
||||
|
||||
// Build paths to doc files
|
||||
const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||
const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md');
|
||||
// PLAN_MD_REF removed — was unused (doc-contract cases test PLANNER_MD and ANTIPATTERNS_MD only)
|
||||
|
||||
// ─── Fixture helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal two-task plan fixture.
|
||||
* taskA: file=app/page.py, gateText=the verify/acceptance_criteria block, actionText=action block
|
||||
* taskB: file=app/page.py (default) or otherFile, action text
|
||||
* allowlistMarker: optional HTML comment to insert at the top
|
||||
*/
|
||||
function makeTwoTaskPlan({
|
||||
taskAFile = 'app/page.py',
|
||||
taskAGate = '! grep -Eq \'await .*refresh\' app/page.py',
|
||||
taskAAction = 'Refactor the factory to be synchronous.',
|
||||
taskBFile = 'app/page.py',
|
||||
taskBAction = 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
allowlistMarker = '',
|
||||
} = {}) {
|
||||
const lines = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
`files_modified: [${taskAFile}, ${taskBFile}]`,
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# Test Plan',
|
||||
'',
|
||||
];
|
||||
|
||||
if (allowlistMarker) {
|
||||
lines.push(allowlistMarker, '');
|
||||
}
|
||||
|
||||
// Task A: the one with the negative gate
|
||||
lines.push('<task>');
|
||||
lines.push('<name>Task A: factory refactor</name>');
|
||||
lines.push(`<files>${taskAFile}</files>`);
|
||||
lines.push(`<action>${taskAAction}</action>`);
|
||||
lines.push(`<verify><automated>${taskAGate}</automated></verify>`);
|
||||
lines.push('<done>Factory is synchronous.</done>');
|
||||
lines.push('</task>');
|
||||
lines.push('');
|
||||
|
||||
// Task B: the sibling that requires the construct
|
||||
lines.push('<task>');
|
||||
lines.push('<name>Task B: reindex handler</name>');
|
||||
lines.push(`<files>${taskBFile}</files>`);
|
||||
lines.push(`<action>${taskBAction}</action>`);
|
||||
lines.push('<verify><automated>npm test</automated></verify>');
|
||||
lines.push('<done>Handler is in place.</done>');
|
||||
lines.push('</task>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single-task plan (no sibling).
|
||||
*/
|
||||
function makeSingleTaskPlan({
|
||||
taskFile = 'app/page.py',
|
||||
taskGate = '! grep -Eq \'await .*refresh\' app/page.py',
|
||||
taskAction = 'Refactor the factory to be synchronous.',
|
||||
} = {}) {
|
||||
return [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
`files_modified: [${taskFile}]`,
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# Test Plan',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task A: factory refactor</name>',
|
||||
`<files>${taskFile}</files>`,
|
||||
`<action>${taskAction}</action>`,
|
||||
`<verify><automated>${taskGate}</automated></verify>`,
|
||||
'<done>Factory is synchronous.</done>',
|
||||
'</task>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Group 1: pure-function unit tests ────────────────────────────────────────
|
||||
|
||||
describe('scanFileWideNegativeGateConflict — pure unit tests', () => {
|
||||
let scan;
|
||||
|
||||
before(() => {
|
||||
const verify = require(VERIFY_CJS);
|
||||
scan = verify.scanFileWideNegativeGateConflict;
|
||||
assert.ok(typeof scan === 'function', 'scanFileWideNegativeGateConflict must be exported');
|
||||
});
|
||||
|
||||
// Case 1: basic WARN path — Task A bans PAT file-wide, Task B requires it in same file
|
||||
test('case 1 — file-wide ban + sibling requires → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }');
|
||||
assert.ok(
|
||||
result.warnings.length >= 1,
|
||||
`expected at least 1 warning, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('Region-scope conflict (#968)'),
|
||||
`warning must mention Region-scope conflict (#968), got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('await .*refresh'),
|
||||
`warning must mention the PAT, got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('app/page.py'),
|
||||
`warning must mention the file, got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 2: region-scoped via sed → NO warn
|
||||
test('case 2 — region-scoped via sed pipe → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sed-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 2b: region-scoped via awk → NO warn
|
||||
test('case 2b — region-scoped via awk pipe → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`awk-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 3: single task file-wide ban, no sibling → NO warn
|
||||
test('case 3 — single task, no sibling → NO warn', () => {
|
||||
const content = makeSingleTaskPlan({
|
||||
taskGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`single task (no sibling) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 4: sibling requires PAT but lists a different file → NO warn
|
||||
test('case 4 — sibling lists different file → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'app/page.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBFile: 'app/other.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sibling with different file must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 5: sibling lists same file but action lacks PAT → NO warn
|
||||
test('case 5 — sibling lists same file but action lacks PAT → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls bridge.sync() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sibling with no PAT in action must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 6: positive grep (no !) + sibling → NO warn (positive requirement, not a ban)
|
||||
test('case 6 — positive grep (no !) + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -q 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`positive grep must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 7: inverted grep -v with ! + sibling → NO warn
|
||||
test('case 7 — inverted grep -vq with ! + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -vq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`inverted grep (-v) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 8: allowlist marker present → NO warn
|
||||
test('case 8 — allowlist marker suppresses warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
allowlistMarker: '<!-- planner-region-allow: await .*refresh -->',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`allowlist marker must suppress warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 9: one task both bans and requires same PAT in same file (no second task) → NO warn
|
||||
test('case 9 — one task bans and requires PAT (no sibling) → NO warn', () => {
|
||||
const content = makeSingleTaskPlan({
|
||||
taskGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskAction: 'Refactor to avoid await refresh, but note that bridge.refresh() is used later.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`single task (no sibling B) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 10: count form `grep -c 'PAT' FILE == 0` + sibling → WARN
|
||||
test('case 10 — count form (grep -c PAT FILE == 0) + sibling → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -c 'await .*refresh' app/page.py == 0",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`count form (grep -c ... == 0) must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 11: bracket form `[ $(grep -c PAT FILE) -eq 0 ]` + sibling → WARN
|
||||
test('case 11 — bracket form ([ $(grep -c PAT FILE) -eq 0 ]) + sibling → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "[ $(grep -c 'await .*refresh' app/page.py) -eq 0 ]",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`bracket form must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 12: CRLF variant of case 1 → WARN
|
||||
test('case 12 — CRLF line endings → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const crlfContent = content.split('\n').join('\r\n');
|
||||
const result = scan(crlfContent);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`CRLF content must still warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 13: backslash line-continuation variant → WARN
|
||||
test('case 13 — backslash line continuation → WARN', () => {
|
||||
// Build manually to control exact line continuation
|
||||
const lines = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
'files_modified: [app/page.py]',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task A: factory refactor</name>',
|
||||
'<files>app/page.py</files>',
|
||||
'<action>Refactor the factory to be synchronous.</action>',
|
||||
// Gate split across lines with backslash continuation
|
||||
"<verify><automated>! grep -Eq 'await .*refresh' \\\napp/page.py</automated></verify>",
|
||||
'<done>Factory is synchronous.</done>',
|
||||
'</task>',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task B: reindex handler</name>',
|
||||
'<files>app/page.py</files>',
|
||||
'<action>Add a post-reindex handler that calls await bridge.refresh() to repopulate state.</action>',
|
||||
'<verify><automated>npm test</automated></verify>',
|
||||
'<done>Handler is in place.</done>',
|
||||
'</task>',
|
||||
].join('\n');
|
||||
const result = scan(lines);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`backslash continuation must still warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 14: mixed line with positive gate AND a negative gate, sibling → WARN (on the negative only)
|
||||
test('case 14 — mixed positive+negative on one segment + sibling → WARN for negative', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -c 'X' app/page.py == 1 && ! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`mixed line with negative gate + sibling must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 15: glob file arg `app/*.py` → NO warn (unresolvable path)
|
||||
test('case 15 — glob file arg → NO warn (unresolvable)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/*.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`glob file arg must not warn (unresolvable), got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 16: invalid-regex PAT literal fallback → WARN, no exception
|
||||
test('case 16 — invalid-regex PAT → literal fallback, WARN, no exception', () => {
|
||||
// "await (refresh" — unbalanced paren, invalid regex
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await (refresh' app/page.py",
|
||||
taskBAction: 'The handler calls await (refresh on bridge to repopulate state.',
|
||||
});
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
}, 'scan must not throw on invalid regex PAT');
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`invalid-regex PAT with literal match must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 17: ReDoS-ish PAT (catastrophic backtracking) → no hang, no false warn.
|
||||
// The sibling action is 5000 'a's — classic ReDoS trigger if we call new RegExp('(a+)+$').
|
||||
// Proof-of-no-hang: the test runner's own timeout catches it; a hanging test fails here.
|
||||
// No timing assertion (flaky) — the linear patternRequiredIn implementation is microsecond-fast.
|
||||
test('case 17 — catastrophic ReDoS pattern is instant, no hang, no false warn', () => {
|
||||
const longAs = 'a'.repeat(5000);
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq '(a+)+$' app/page.py",
|
||||
taskBAction: `Reindex handler that processes ${longAs} records and calls bridge.refresh().`,
|
||||
});
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
}, 'scan must not throw on ReDoS-ish PAT');
|
||||
// The literal '(a+)+$' is not present in the action text as a substring → no warn.
|
||||
// (If new RegExp were used, this test would hang before reaching this assertion.)
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`catastrophic PAT '(a+)+$' not literally in action — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(Array.isArray(result.warnings), 'valid result shape');
|
||||
});
|
||||
|
||||
// Case 23 (mutation-catching): cat producer = file-wide → WARN; sed producer = region-scoped → NO warn
|
||||
test('case 23a — cat pipe: ! cat app/page.py | grep -Eq PAT + sibling → WARN (file-wide via cat)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! cat app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`cat-piped grep is file-wide — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(result.valid !== false, 'valid must remain true even when #968 warns');
|
||||
});
|
||||
|
||||
test('case 23b — sed pipe: ! sed -n "12,40p" app/page.py | grep -Eq PAT + sibling → NO warn (region-scoped)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sed-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 24: awk region → NO warn
|
||||
test('case 24 — awk region pipe: ! awk \'/^def make_page/,/^def /\' app/page.py | grep -Eq PAT + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`awk-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 25: basename non-over-match — different dirs, same basename → NO warn
|
||||
test('case 25 — basename non-over-match: different dirs same filename → NO warn', () => {
|
||||
// Task A bans on apps/web/config.py; Task B lists apps/admin/config.py
|
||||
// Same basename "config.py" but different dirs → must NOT warn
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'apps/web/config.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' apps/web/config.py",
|
||||
taskBFile: 'apps/admin/config.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`different dirs (apps/web/config.py vs apps/admin/config.py) — same basename but must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 26: extensionless known file (Dockerfile) recognized via knownFiles → WARN
|
||||
test('case 26 — extensionless known file (Dockerfile) via knownFiles → WARN', () => {
|
||||
// Task A has ! grep -Eq 'FROM scratch' Dockerfile
|
||||
// Dockerfile has no extension, so looksLikePath would miss it — but knownFiles should catch it
|
||||
// Task B lists Dockerfile in <files> and action requires 'FROM scratch'
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'Dockerfile',
|
||||
taskAGate: "! grep -Eq 'FROM scratch' Dockerfile",
|
||||
taskBFile: 'Dockerfile',
|
||||
taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`Dockerfile (extensionless, known via <files>) should be recognized — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(result.valid !== false, 'valid must remain true');
|
||||
});
|
||||
|
||||
// Case 27: wildcard semantic match — patternRequiredIn handles .* correctly
|
||||
test('case 27 — wildcard semantic match: "await .*refresh" (gate) warns when action has "await bridge.refresh()"', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`patternRequiredIn must match "await .*refresh" against "await bridge.refresh()" — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 4b: same-file positive control — sibling lists the SAME banned file + requires PAT → WARN
|
||||
// Paired with case 4: proves the no-warn in case 4 is due to the file mismatch, not a dead detector.
|
||||
test('case 4b — same-file positive control: sibling lists same file → WARN (proves case 4 no-warn is file-mismatch)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'app/page.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBFile: 'app/page.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`same-file sibling must warn — proves case 4's no-warn is due to file mismatch, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 7b: non-inverted positive control — without -v the ban IS detected → WARN
|
||||
// Paired with case 7: proves the -v skip is what suppresses case 7.
|
||||
test('case 7b — non-inverted positive control: ! grep -q (no -v) + sibling → WARN (proves case 7 no-warn is -v skip)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -q 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`non-inverted ! grep -q must warn — proves the -v flag is what suppresses case 7, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 25b: basename-fallback positive — bare unqualified filename matches sibling's qualified path → WARN
|
||||
// Paired with case 25: proves the bare-name basename fallback at src ~line 525 actually fires.
|
||||
// Case 25 only proves qualified paths don't over-match; this proves the bare fallback does fire.
|
||||
test('case 25b — basename-fallback positive: bare gate file matches sibling qualified path → WARN (proves basename fallback fires)', () => {
|
||||
// Task A gate uses bare "config.py" (no directory prefix — unqualified).
|
||||
// Task B lists "apps/admin/config.py" (qualified). basename("apps/admin/config.py") === "config.py".
|
||||
// The basename fallback (line 525) should match → WARN.
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'config.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' config.py",
|
||||
taskBFile: 'apps/admin/config.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`bare gate file "config.py" must match sibling "apps/admin/config.py" via basename fallback — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 28: anchored pattern warns after ^ strip — proves anchor stripping works
|
||||
// Gate: ! grep -Eq '^FROM scratch' Dockerfile
|
||||
// Sibling B lists Dockerfile, action requires 'FROM scratch' (no anchor in prose).
|
||||
// Without anchor stripping, "^FROM scratch" would be treated as containing metacharacters
|
||||
// and fall back to literal-substring: "^FROM scratch" not in B's prose → no warn.
|
||||
// With anchor stripping, "FROM scratch" is the effective literal → found in B's prose → WARN.
|
||||
test('case 28 — anchored pattern warns: ! grep -Eq \'^FROM scratch\' Dockerfile + sibling → WARN (proves ^ strip)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'Dockerfile',
|
||||
taskAGate: "! grep -Eq '^FROM scratch' Dockerfile",
|
||||
taskBFile: 'Dockerfile',
|
||||
taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`anchored pattern "^FROM scratch" must warn after ^ is stripped — "FROM scratch" is in sibling action, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 29: alternation falls back conservatively — documents the known limitation.
|
||||
// Gate: ! grep -Eq 'debug|trace' src/logger.ts
|
||||
// Sibling B lists src/logger.ts, action says "remove debug calls" (contains "debug" but NOT "debug|trace").
|
||||
// patternRequiredIn sees unhandled `|` in joined frags → literal-substring fallback on raw pattern.
|
||||
// "debug|trace" is NOT literally in B's prose → conservative NO warn.
|
||||
// This is intentional: false-negative is the safe direction for a warn-only advisory.
|
||||
test('case 29 — alternation conservative fallback: "debug|trace" → NO warn (documents alternation limitation)', () => {
|
||||
// NOTE: This is intended conservative behavior, not a bug.
|
||||
// patternRequiredIn falls back to literal-substring for patterns containing `|` (alternation),
|
||||
// because safely expanding alternation without new RegExp would require a mini-parser.
|
||||
// The literal "debug|trace" is not present verbatim in the action, so no warn fires.
|
||||
// A planner who writes `debug|trace` gets no advisory — acceptable, since a false-negative
|
||||
// is always safer than a false-positive for a warn-only gate.
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'src/logger.ts',
|
||||
taskAGate: "! grep -Eq 'debug|trace' src/logger.ts",
|
||||
taskBFile: 'src/logger.ts',
|
||||
taskBAction: 'Remove debug calls from the logger module to reduce noise.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`alternation pattern "debug|trace" must conservatively NOT warn — literal "debug|trace" not in action, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 18: empty content → no crash, no #968 warn
|
||||
test('case 18 — empty content → no crash', () => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan('');
|
||||
});
|
||||
assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }');
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
'empty content must produce no #968 warn',
|
||||
);
|
||||
});
|
||||
|
||||
// Case 18b: no-task plan → no crash
|
||||
test('case 18b — no-task plan → no crash', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
'files_modified: []',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# No tasks here',
|
||||
].join('\n');
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
});
|
||||
assert.strictEqual(result.warnings.filter(w => w.includes('#968')).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 2: end-to-end via runGsdTools ──────────────────────────────────────
|
||||
|
||||
describe('scanFileWideNegativeGateConflict — end-to-end via verify plan-structure', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Case 19: integration — valid stays true despite warning (warn-only)
|
||||
test('case 19 — integration: valid===true despite #968 warning', () => {
|
||||
const planContent = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(planDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
||||
|
||||
const result = runGsdTools(
|
||||
'verify plan-structure .planning/phases/01-test/01-01-PLAN.md',
|
||||
tmpDir,
|
||||
);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
parsed.valid,
|
||||
true,
|
||||
`#968 is warn-only: valid must be true, got: ${JSON.stringify(parsed)}`,
|
||||
);
|
||||
assert.ok(
|
||||
parsed.warnings.some(w => w.includes('#968')),
|
||||
`must have a #968 warning, got: ${JSON.stringify(parsed.warnings)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 3: doc-contract ────────────────────────────────────────────────────
|
||||
|
||||
describe('doc-contract: guidance prose is in place', () => {
|
||||
// Case 20: gsd-planner.md has the new guidance
|
||||
test('case 20 — gsd-planner.md contains Region-scoped negative gates + reference', () => {
|
||||
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('Region-scoped negative gates'),
|
||||
'gsd-planner.md must include "Region-scoped negative gates"',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('planner-antipatterns.md'),
|
||||
'gsd-planner.md must reference planner-antipatterns.md',
|
||||
);
|
||||
});
|
||||
|
||||
// Case 21: planner-antipatterns.md has the new section
|
||||
test('case 21 — planner-antipatterns.md has ## Region-Scoped Negative Gates + examples', () => {
|
||||
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('## Region-Scoped Negative Gates'),
|
||||
'planner-antipatterns.md must include "## Region-Scoped Negative Gates"',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('await .*refresh'),
|
||||
'planner-antipatterns.md must include the worked example pattern "await .*refresh"',
|
||||
);
|
||||
// Verify sed or awk region example is present
|
||||
const hasSedOrAwk = content.includes('sed -n') || content.includes('awk ');
|
||||
assert.ok(
|
||||
hasSedOrAwk,
|
||||
'planner-antipatterns.md must include sed-n or awk region example',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 4: AC3 executable proof ───────────────────────────────────────────
|
||||
|
||||
describe('AC3: executable proof — file-wide ban vs region-scoped simultaneously satisfiable', () => {
|
||||
test('case 22 — grep/sed proof: both gates simultaneously satisfiable', () => {
|
||||
// Check if grep and sed are available
|
||||
const grepAvail = spawnSync('grep', ['--version']).status === 0;
|
||||
const sedAvail = spawnSync('sed', ['--version']).status === 0 ||
|
||||
spawnSync('sed', ['-n', '1p', '/dev/null']).status === 0;
|
||||
|
||||
if (!grepAvail || !sedAvail) {
|
||||
// Skip gracefully if tools are unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
// Write a temp Python file with:
|
||||
// def make_page(): — no await refresh
|
||||
// async def reindex_handler(): — awaits bridge.refresh()
|
||||
const tmpFile = path.join(os.tmpdir(), `gsd-968-proof-${process.pid}.py`);
|
||||
const pyContent = [
|
||||
'def make_page():',
|
||||
' """Synchronous factory — must not block on a refresh."""',
|
||||
' return {"title": "My Page"}',
|
||||
'',
|
||||
'',
|
||||
'async def reindex_handler():',
|
||||
' """Post-reindex callback — must await bridge.refresh() to repopulate state."""',
|
||||
' await bridge.refresh()',
|
||||
' return True',
|
||||
].join('\n');
|
||||
fs.writeFileSync(tmpFile, pyContent);
|
||||
|
||||
try {
|
||||
// (a) File-wide: grep -Eq 'await .*refresh' <file> — should EXIT 0 (pattern found)
|
||||
// This means a file-wide ban (! grep -Eq ...) WOULD FAIL
|
||||
const fileWide = spawnSync('grep', ['-Eq', 'await .*refresh', tmpFile]);
|
||||
assert.strictEqual(
|
||||
fileWide.status,
|
||||
0,
|
||||
'grep file-wide should find the pattern (exits 0) — proving the file-wide ban would fail',
|
||||
);
|
||||
|
||||
// (b) Region-scoped (make_page only): sed extracts lines 1-3, piped to grep → pattern NOT found
|
||||
// The factory region is clean: ban PASSES
|
||||
const makePageLines = spawnSync('sed', ['-n', '1,3p', tmpFile]);
|
||||
assert.strictEqual(makePageLines.status, 0, 'sed should succeed');
|
||||
const makePageRegion = makePageLines.stdout.toString();
|
||||
|
||||
// Write to a temp file and grep it
|
||||
const regionFile = path.join(os.tmpdir(), `gsd-968-region-${process.pid}.py`);
|
||||
fs.writeFileSync(regionFile, makePageRegion);
|
||||
try {
|
||||
const regionBan = spawnSync('grep', ['-Eq', 'await .*refresh', regionFile]);
|
||||
assert.strictEqual(
|
||||
regionBan.status,
|
||||
1,
|
||||
'grep in make_page region should NOT find pattern (exits 1) — ban PASSES in factory region',
|
||||
);
|
||||
|
||||
// (c) Region-scoped (reindex_handler): grep should FIND the pattern → requirement met
|
||||
const reindexLines = spawnSync('sed', ['-n', '6,9p', tmpFile]);
|
||||
const reindexRegion = reindexLines.stdout.toString();
|
||||
const reindexFile = path.join(os.tmpdir(), `gsd-968-reindex-${process.pid}.py`);
|
||||
fs.writeFileSync(reindexFile, reindexRegion);
|
||||
try {
|
||||
const reindexCheck = spawnSync('grep', ['-Eq', 'await .*refresh', reindexFile]);
|
||||
assert.strictEqual(
|
||||
reindexCheck.status,
|
||||
0,
|
||||
'grep in reindex_handler region MUST find pattern (exits 0) — requirement met',
|
||||
);
|
||||
} finally {
|
||||
try { fs.unlinkSync(reindexFile); } catch { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(regionFile); } catch { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -325,6 +325,142 @@ describe('init commands', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_req_ids, null);
|
||||
});
|
||||
|
||||
test('init plan-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '11-second-active-phase'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
'milestone: v0.4.0',
|
||||
'current_phase: 11',
|
||||
'---',
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap: Example',
|
||||
'',
|
||||
'## Milestones',
|
||||
'',
|
||||
'- ✅ **v0.3.0 Foundations** - Phases 1-9 (shipped 2026-01-01)',
|
||||
'- 🚧 **v0.4.0 Feature Work** - Phases 10-11 (in progress)',
|
||||
'',
|
||||
'## Phases',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01</summary>',
|
||||
'',
|
||||
'- [x] **Phase 1: Bootstrap**',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'### 🚧 v0.4.0 Feature Work (Active)',
|
||||
'',
|
||||
'**Milestone Goal:** Deliver the feature set.',
|
||||
'',
|
||||
'- [ ] **Phase 10: First Active Phase**',
|
||||
'- [ ] **Phase 11: Second Active Phase**',
|
||||
'',
|
||||
'### 📋 v0.5+ (Planned)',
|
||||
'',
|
||||
'## Phase Details',
|
||||
'',
|
||||
'### Phase 10: First Active Phase',
|
||||
'**Goal**: Build the first piece.',
|
||||
'**Requirements**: REQ-01',
|
||||
'',
|
||||
'### Phase 11: Second Active Phase',
|
||||
'**Goal**: Build the second piece.',
|
||||
'**Requirements**: REQ-02, REQ-03',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('init plan-phase 11', tmpDir);
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_found, true);
|
||||
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
|
||||
});
|
||||
|
||||
test('init execute-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
|
||||
seedPhase(tmpDir, '11-second-active-phase', {
|
||||
'11-01-PLAN.md': '# Plan',
|
||||
});
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
'milestone: v0.4.0',
|
||||
'current_phase: 11',
|
||||
'---',
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap: Example',
|
||||
'',
|
||||
'## Phases',
|
||||
'',
|
||||
'### 🚧 v0.4.0 Feature Work (Active)',
|
||||
'',
|
||||
'- [ ] **Phase 10: First Active Phase**',
|
||||
'- [ ] **Phase 11: Second Active Phase**',
|
||||
'',
|
||||
'### 📋 v0.5+ (Planned)',
|
||||
'',
|
||||
'## Phase Details',
|
||||
'',
|
||||
'### Phase 10: First Active Phase',
|
||||
'**Goal**: Build the first piece.',
|
||||
'**Requirements**: REQ-01',
|
||||
'',
|
||||
'### Phase 11: Second Active Phase',
|
||||
'**Goal**: Build the second piece.',
|
||||
'**Requirements**: REQ-02, REQ-03',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('init execute-phase 11', tmpDir);
|
||||
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_found, true);
|
||||
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
|
||||
});
|
||||
|
||||
test('init phase-op resolves a details-summary milestone phase from later flat Phase Details', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'milestone: v1.11',
|
||||
'current_phase: 86',
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phases',
|
||||
'<details open>',
|
||||
'<summary>🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS</summary>',
|
||||
'',
|
||||
'- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
|
||||
'- [ ] **Phase 87: Other Work** — Later phase',
|
||||
'</details>',
|
||||
'',
|
||||
'## Phase Details',
|
||||
'',
|
||||
'### Phase 86: Details Block Regression',
|
||||
'**Goal**: Resolve phase details after collapsed milestone block',
|
||||
'**Requirements**: DATA-01',
|
||||
'',
|
||||
'### Phase 87: Other Work',
|
||||
'**Goal**: Not relevant',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('init phase-op 86', tmpDir);
|
||||
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_found, true);
|
||||
assert.strictEqual(output.phase_name, 'Details Block Regression');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,7 @@ const ROOT = path.resolve(__dirname, '..');
|
||||
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
||||
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
||||
@@ -235,9 +236,9 @@ describe('B: hooks/hooks.json', () => {
|
||||
// or changes `name` to an invalid form goes red immediately.
|
||||
//
|
||||
// C2 (OPPORTUNISTIC) — When the `claude` binary IS on PATH, also run
|
||||
// `claude plugin validate . --strict` as an end-to-end smoke test.
|
||||
// This tier provides defence-in-depth for schema changes Claude Code
|
||||
// may introduce that the fixture hasn't yet captured.
|
||||
// `claude plugin validate <temp-plugin-root> --strict` as an end-to-end
|
||||
// smoke test. This tier provides defence-in-depth for schema changes
|
||||
// Claude Code may introduce that the fixture hasn't yet captured.
|
||||
//
|
||||
describe('C: plugin.json schema validation', () => {
|
||||
|
||||
@@ -366,19 +367,29 @@ describe('C: plugin.json schema validation', () => {
|
||||
})();
|
||||
|
||||
test(
|
||||
'C2: claude plugin validate . --strict exits 0 (opportunistic — skip when claude not on PATH)',
|
||||
'C2: claude plugin validate --strict exits 0 (opportunistic — skip when claude not on PATH)',
|
||||
{ skip: !claudeAvailable ? 'claude binary not on PATH' : false },
|
||||
() => {
|
||||
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plugin-validate-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, '.claude-plugin'), { recursive: true });
|
||||
fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json'));
|
||||
fs.symlinkSync(path.join(ROOT, 'commands'), path.join(pluginRoot, 'commands'), 'dir');
|
||||
fs.symlinkSync(path.join(ROOT, 'hooks'), path.join(pluginRoot, 'hooks'), 'dir');
|
||||
|
||||
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`claude plugin validate ${pluginRoot} --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(pluginRoot);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user