Merge remote-tracking branch 'origin/next' into feat/1279-fail-first-prover

# Conflicts:
#	docs/adr/550-spec-phase-probe-contract.md
#	gsd-core/workflows/verify-phase.md
#	tests/workflow-size-baseline.json
This commit is contained in:
Dave
2026-06-16 00:32:22 -04:00
31 changed files with 1934 additions and 166 deletions

View File

@@ -19,6 +19,7 @@ const { cleanup } = require('./helpers.cjs');
const {
resolveCapabilityState,
isCapabilityActive,
_isSafePropKey,
_loadInstalledSkillsManifest,
_resolveManifest,
@@ -31,7 +32,7 @@ const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
/**
* Build a minimal synthetic registry for a single capability with the given
* skills, steps, gates, contributions, and configSchema entries.
* skills, steps, gates, contributions, configSchema, and optional activationKey.
*/
function makeRegistry({
id = 'test-cap',
@@ -41,18 +42,23 @@ function makeRegistry({
gates = [],
contributions = [],
configSchema = {},
activationKey = undefined,
} = {}) {
const capEntry = {
id,
tier,
skills,
steps,
gates,
contributions,
config: {},
};
if (activationKey !== undefined) {
capEntry.activationKey = activationKey;
}
return {
capabilities: {
[id]: {
id,
tier,
skills,
steps,
gates,
contributions,
config: {},
},
[id]: capEntry,
},
configSchema,
};
@@ -535,6 +541,64 @@ describe('resolveCapabilityState — hook activation details', () => {
assert.strictEqual(hook.when, 42, 'original non-string when value must be preserved');
assert.strictEqual(hook.active, false, 'non-string when → inactive');
});
// ── configActivation cascade to hooks ────────────────────────────────────────
// Bug: a config-disabled capability (active=false) with an unconditional hook
// (no `when`, configured=true) was wrongly yielding hook.active=true because
// the hook loop used `enabled && configured` instead of `active && configured`.
// Fix: hook.active = active(capability) && configured.
test('config-disabled capability with unconditional hook → hook.active=false, configured=true', () => {
// The capability activationKey resolves false → capability active=false.
// The hook has no `when` → configured=true (unconditional).
// Before the fix: hook.active = enabled(true) && configured(true) = true ← BUG
// After the fix: hook.active = active(false) && configured(true) = false ← correct
const registry = makeRegistry({
id: 'test-cap',
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
steps: [{ point: 'plan:pre', ref: { skill: 'my-skill' } }], // no `when` → unconditional
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: false } }, // capability's configActivation = false
});
const cap = result.capabilities[0];
assert.ok(cap, 'capability must be present');
assert.strictEqual(cap.enabled, true, 'enabled stays true: installed && surfaced');
assert.strictEqual(cap.active, false, 'capability active=false: configActivation off');
const hook = cap.hooks.find((h) => h.kind === 'step');
assert.ok(hook, 'unconditional step hook must be present');
assert.strictEqual(hook.when, undefined, 'hook has no when field (unconditional)');
assert.strictEqual(hook.configured, true, 'hook configured=true: hook own gate is open');
assert.strictEqual(hook.active, false, 'hook active=false: capability configActivation cascades to hook');
});
test('config-ENABLED capability with unconditional hook → hook.active=true (control)', () => {
// Same setup as above but activationKey resolves true → capability active=true.
// hook.active should follow configured as before.
const registry = makeRegistry({
id: 'test-cap',
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
steps: [{ point: 'plan:pre', ref: { skill: 'my-skill' } }], // no `when`
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: true } }, // capability's configActivation = true
});
const cap = result.capabilities[0];
assert.ok(cap, 'capability must be present');
assert.strictEqual(cap.active, true, 'capability active=true: configActivation on');
const hook = cap.hooks.find((h) => h.kind === 'step');
assert.ok(hook, 'unconditional step hook must be present');
assert.strictEqual(hook.configured, true, 'hook configured=true');
assert.strictEqual(hook.active, true, 'hook active=true: both capability and hook gate open');
});
});
// ─── resolveCapabilityState — determinism ─────────────────────────────────────
@@ -1177,3 +1241,351 @@ describe('regressions: installed-runtime capability surface (#1160)', () => {
});
});
// ─── resolveCapabilityState — per-capability active field ────────────────────
describe('resolveCapabilityState — per-capability active (Phase 2)', () => {
// Boundary: installed && surfaced && config-enabled → active=true
test('active=true when installed && surfaced && activationKey resolves true', () => {
const registry = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
configSchema: { 'myfeature.enabled': { default: false } },
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: true } },
});
const cap = result.capabilities[0];
assert.ok(cap, 'capability must be present');
assert.strictEqual(cap.installed, true);
assert.strictEqual(cap.surfaced, true);
assert.strictEqual(cap.enabled, true, 'enabled = installed && surfaced (unchanged)');
assert.strictEqual(cap.active, true, 'active = enabled && config-enabled');
});
// Boundary: installed && surfaced && config-DISABLED → active=false (the key case)
test('active=false when installed && surfaced but activationKey resolves false', () => {
const registry = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
configSchema: { 'myfeature.enabled': { default: false } },
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: false } },
});
const cap = result.capabilities[0];
assert.ok(cap);
assert.strictEqual(cap.enabled, true, 'enabled still true: installed && surfaced unchanged');
assert.strictEqual(cap.active, false, 'active=false: config gate is off');
});
// Boundary: config-enabled but NOT surfaced → active=false
test('active=false when config-enabled but not surfaced (enabled=false)', () => {
const registry = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(), // NOT surfaced
config: { myfeature: { enabled: true } },
});
const cap = result.capabilities[0];
assert.ok(cap);
assert.strictEqual(cap.surfaced, false);
assert.strictEqual(cap.enabled, false, 'enabled=false: not surfaced');
assert.strictEqual(cap.active, false, 'active=false: enabled is false');
});
// Boundary: NOT installed → active=false regardless of config
test('active=false when not installed (installed=false)', () => {
const registry = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
});
const result = resolveCapabilityState({
registry,
installedSkills: new Set(), // NOT installed
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: true } },
});
const cap = result.capabilities[0];
assert.ok(cap);
assert.strictEqual(cap.installed, false);
assert.strictEqual(cap.enabled, false);
assert.strictEqual(cap.active, false, 'active=false: not installed');
});
// Boundary: no activationKey → active === enabled (no config gate)
test('active=enabled when capability has no activationKey', () => {
// No activationKey: configActivation defaults to true so active = enabled
const registry = makeRegistry({
skills: ['my-skill'],
// No activationKey
});
// installed && surfaced → enabled=true → active=true
const resultEnabled = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: {},
});
const capEnabled = resultEnabled.capabilities[0];
assert.ok(capEnabled);
assert.strictEqual(capEnabled.enabled, true);
assert.strictEqual(capEnabled.active, true, 'active=true when no activationKey and enabled');
// NOT surfaced → enabled=false → active=false
const resultDisabled = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(), // not surfaced
config: {},
});
const capDisabled = resultDisabled.capabilities[0];
assert.ok(capDisabled);
assert.strictEqual(capDisabled.enabled, false);
assert.strictEqual(capDisabled.active, false, 'active=false when no activationKey and not enabled');
});
// enabled field semantics unchanged: still installed && surfaced only
test('enabled stays installed && surfaced regardless of activationKey', () => {
const registry = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
});
// installed && surfaced but config disables it
const result = resolveCapabilityState({
registry,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: { myfeature: { enabled: false } },
});
const cap = result.capabilities[0];
assert.ok(cap);
// enabled must still be true (installed && surfaced — config doesn't affect it)
assert.strictEqual(cap.enabled, true, 'enabled = installed && surfaced (config does not affect enabled)');
assert.strictEqual(cap.active, false, 'active reflects config gate');
});
// activationKey defaults to schema default when key absent from config
test('active uses schema default when activationKey not in config', () => {
// schema default = true → active=true when enabled
const registryDefaultTrue = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
configSchema: { 'myfeature.enabled': { default: true } },
});
const resultTrue = resolveCapabilityState({
registry: registryDefaultTrue,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: {}, // no explicit key
});
assert.strictEqual(resultTrue.capabilities[0].active, true, 'active=true when schema default=true');
// schema default = false → active=false when enabled
const registryDefaultFalse = makeRegistry({
skills: ['my-skill'],
activationKey: 'myfeature.enabled',
configSchema: { 'myfeature.enabled': { default: false } },
});
const resultFalse = resolveCapabilityState({
registry: registryDefaultFalse,
installedSkills: new Set(['my-skill']),
surfacedSkills: new Set(['my-skill']),
config: {}, // no explicit key
});
assert.strictEqual(resultFalse.capabilities[0].active, false, 'active=false when schema default=false');
});
});
// ─── isCapabilityActive convenience predicate ─────────────────────────────────
describe('isCapabilityActive — convenience predicate (Phase 2)', () => {
// isCapabilityActive delegates to resolveCapabilityRuntimeState which does I/O.
// We test it via the CLI+tmpDir path used by the existing e2e tests above, and
// a pure-function boundary test that forces a known-missing capability id.
test('returns false for unknown capId (not in registry)', () => {
// We can't easily inject the registry into resolveCapabilityRuntimeState
// without a cwd that has been set up. Instead, probe a capability id that
// is guaranteed to never appear in the real registry.
// We use a non-existent cwd so resolveCapabilityRuntimeState degrades
// gracefully (installedSkills/surfacedSkills → empty → all disabled) but
// still returns a capabilities array from the real registry.
// Any truly-unknown capId must return false regardless.
const nonExistentCwd = path.join(os.tmpdir(), 'cap-active-nonexistent-' + Date.now());
const result = isCapabilityActive('__definitely_not_a_capability__', nonExistentCwd);
assert.strictEqual(result, false, 'unknown capId must return false');
});
test('isCapabilityActive returns same value as the entry active field (e2e)', () => {
// Use a non-existent cwd so resolveCapabilityRuntimeState degrades gracefully.
// resolveCapabilityRuntimeState and isCapabilityActive both resolve against
// the SAME runtime environment: we compare isCapabilityActive(capId, cwd)
// to the matching entry's .active from resolveCapabilityRuntimeState(cwd, undefined)
// called on the identical cwd. This guarantees a real equality check — not typeof.
//
// We need resolveCapabilityRuntimeState for the comparison; require it here
// since it is exported from the same module.
const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs');
const nonExistentCwd = path.join(os.tmpdir(), 'cap-active-eq-' + Date.now());
// Pick a capability that exists in the real registry ('ui' is always present).
const capId = 'ui';
// resolveCapabilityRuntimeState resolves state from the real environment.
const runtimeResult = resolveCapabilityRuntimeState(nonExistentCwd, undefined);
const entry = runtimeResult.capabilities.find((c) => c.id === capId);
assert.ok(entry, `'${capId}' must be present in the real registry`);
// isCapabilityActive must return exactly the same boolean as the resolved entry.
const actual = isCapabilityActive(capId, nonExistentCwd);
assert.strictEqual(
actual,
entry.active,
`isCapabilityActive('${capId}') must equal entry.active=${entry.active} from resolveCapabilityRuntimeState`,
);
// Also verify the already-covered false case for unknown capId:
assert.strictEqual(isCapabilityActive('__no_such_cap__', nonExistentCwd), false);
});
});
// ─── Cross-runtime runtime detection (HIGH fix — GSD_RUNTIME → config.runtime → 'claude') ──────
describe('isCapabilityActive cross-runtime detection (GSD_RUNTIME → config.runtime → claude)', () => {
// Verifies the HIGH bug fix: when GSD_RUNTIME='codex', resolveCapabilityRuntimeState
// must consult the CODEX config dir (via CODEX_HOME), not ~/.claude.
//
// Fixture layout:
// CODEX_HOME → tmpCodexDir/ ← .gsd-surface.json: full profile, graphify SURFACED
// CLAUDE_CONFIG_DIR → tmpClaudeDir/ ← .gsd-surface.json: full profile, graphify NOT surfaced
// GSD_RUNTIME=codex
// project cwd → tmpProjectDir/ ← config.json: graphify.enabled=true
// (config leg must pass; SURFACE leg is what we are isolating)
//
// Expected: isCapabilityActive('graphify', cwd) === true
// (codex dir has graphify surfaced, so the codex surface should win)
//
// Pre-fix (hardcoded 'claude'): would read tmpClaudeDir → graphify NOT surfaced → false (BUG).
// Post-fix (detects 'codex' via GSD_RUNTIME): reads tmpCodexDir → surfaced → true (CORRECT).
let tmpCodexDir;
let tmpClaudeDir;
let tmpProjectDir;
let prevGsdRuntime;
let prevCodexHome;
let prevClaudeConfigDir;
let prevGsdWorkstream;
let prevGsdProject;
before(() => {
tmpCodexDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-codex-cfg-'));
tmpClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-claude-cfg-'));
tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-xrt-project-'));
// Codex config dir: full profile + graphify SURFACED (disabledClusters empty)
fs.writeFileSync(
path.join(tmpCodexDir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
// Claude config dir: full profile + graphify NOT surfaced
fs.writeFileSync(
path.join(tmpClaudeDir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: ['graphify'], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
// Project: config with graphify.enabled=true so the config leg passes.
// The SURFACE leg (install+surfaced) is what we are testing — it must read
// the CODEX config dir (via CODEX_HOME), not the CLAUDE config dir.
fs.mkdirSync(path.join(tmpProjectDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpProjectDir, '.planning', 'config.json'),
JSON.stringify({ graphify: { enabled: true } }),
'utf8',
);
});
after(() => {
cleanup(tmpCodexDir);
cleanup(tmpClaudeDir);
cleanup(tmpProjectDir);
});
test('GSD_RUNTIME=codex → resolver consults CODEX_HOME, not CLAUDE_CONFIG_DIR (HIGH cross-runtime fix)', () => {
prevGsdRuntime = process.env.GSD_RUNTIME;
prevCodexHome = process.env.CODEX_HOME;
prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
prevGsdWorkstream = process.env.GSD_WORKSTREAM;
prevGsdProject = process.env.GSD_PROJECT;
try {
process.env.GSD_RUNTIME = 'codex';
process.env.CODEX_HOME = tmpCodexDir; // codex dir: graphify SURFACED
process.env.CLAUDE_CONFIG_DIR = tmpClaudeDir; // claude dir: graphify NOT surfaced
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
// With GSD_RUNTIME=codex, the resolver must look at CODEX_HOME (graphify surfaced)
// and return true. Pre-fix it would look at CLAUDE_CONFIG_DIR (not surfaced) → false.
const active = isCapabilityActive('graphify', tmpProjectDir);
assert.strictEqual(
active,
true,
'isCapabilityActive must return true when GSD_RUNTIME=codex and graphify is surfaced in CODEX_HOME — ' +
'the pre-fix code hardcoded getGlobalConfigDir("claude") regardless of GSD_RUNTIME, returning false (BUG)',
);
// Also verify: if we swap surfaces (codex NOT surfaced, claude surfaced), still uses codex dir → false
fs.writeFileSync(
path.join(tmpCodexDir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: ['graphify'], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
fs.writeFileSync(
path.join(tmpClaudeDir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
const activeSwapped = isCapabilityActive('graphify', tmpProjectDir);
assert.strictEqual(
activeSwapped,
false,
'isCapabilityActive must return false when GSD_RUNTIME=codex and graphify is NOT surfaced in CODEX_HOME — ' +
'even though claude dir has graphify surfaced, the codex dir is the authoritative surface',
);
} finally {
// Restore env vars
if (prevGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = prevGsdRuntime;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
if (prevGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prevGsdWorkstream;
if (prevGsdProject === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = prevGsdProject;
// Restore codex dir surface fixture for cleanup consistency
fs.writeFileSync(
path.join(tmpCodexDir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
}
});
});

View File

@@ -6,6 +6,7 @@
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createTempProject, cleanup } = require('./helpers.cjs');
@@ -26,6 +27,55 @@ const {
SAMPLE_GRAPH,
} = require('./helpers/graphify.cjs');
// ─── Shared fixture: surfaced-config-dir ─────────────────────────────────────
//
// Positive-path tests (graphifyQuery, graphifyDiff, graceful-degradation) call
// enableGraphify() (config leg only) and assert non-disabled outcomes. With
// the tri-state gate (isCapabilityActive), those outcomes ALSO require graphify
// to be installed+surfaced in the runtime config dir. Without this fixture the
// tests are ambient-dependent: they pass only on machines where the ambient
// ~/.claude has graphify surfaced.
//
// Fix: before each positive-path test, point CLAUDE_CONFIG_DIR at a tmp dir
// with a full-profile .gsd-surface.json (graphify surfaced), and clear
// GSD_RUNTIME / GSD_WORKSTREAM / GSD_PROJECT for hermeticity.
/** Create a tmp config dir with graphify surfaced (full profile, no disabled clusters). */
function makeSurfacedConfigDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-graphify-qry-cfg-'));
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
return dir;
}
/**
* Save the env vars the surfaced-config fixture overrides.
* Returns an object whose .restore() returns env to its original state.
*/
function saveSurfacedEnv() {
const saved = {
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
GSD_PROJECT: process.env.GSD_PROJECT,
};
return {
restore() {
if (saved.GSD_RUNTIME === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = saved.GSD_RUNTIME;
if (saved.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
if (saved.GSD_WORKSTREAM === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
if (saved.GSD_PROJECT === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = saved.GSD_PROJECT;
},
};
}
// ─── query describe ───────────────────────────────────────────────────────────
describe('query', () => {
@@ -192,13 +242,25 @@ describe('query', () => {
describe('graphifyQuery', () => {
let tmpDir;
let planningDir;
// Surfaced-config-dir fixture: makes positive-path tests deterministic.
// See module-level comment for rationale.
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -259,13 +321,25 @@ describe('query', () => {
describe('graphifyDiff', () => {
let tmpDir;
let planningDir;
// Surfaced-config-dir fixture: makes positive-path tests deterministic.
// See module-level comment for rationale.
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -379,13 +453,25 @@ describe('query', () => {
describe('graceful degradation (AGENT-03)', () => {
let tmpDir;
let planningDir;
// Surfaced-config-dir fixture: makes positive-path tests deterministic.
// See module-level comment for rationale.
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});

View File

@@ -11,20 +11,21 @@
/**
* Tests for gsd-core/bin/lib/graphify.cjs
*
* Covers: config gate on/off (TEST-03), graceful degradation (TEST-04),
* subprocess helper (FOUND-04), presence detection (FOUND-02),
* version checking (FOUND-03), and disabled response (FOUND-01).
* Covers: tri-state gate (TEST-03 — isCapabilityActive cutover, Phase 3),
* graceful degradation (TEST-04), subprocess helper (FOUND-04),
* presence detection (FOUND-02), version checking (FOUND-03),
* and disabled response (FOUND-01).
*/
const { describe, test, beforeEach, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const childProcess = require('child_process');
const { createTempProject, cleanup } = require('./helpers.cjs');
const { createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
const {
isGraphifyEnabled,
disabledResponse,
execGraphify,
GRAPHIFY_REASON,
@@ -42,10 +43,87 @@ const {
SAMPLE_GRAPH,
} = require('./helpers/graphify.cjs');
// ─── Shared fixture: surfaced-config-dir ─────────────────────────────────────
//
// Positive-path tests (graphifyStatus + graphifyBuild) call enableGraphify()
// (config leg only) and assert non-disabled outcomes. With the tri-state gate
// (isCapabilityActive), a non-disabled outcome ALSO requires graphify to be
// installed+surfaced in the runtime config dir. Without this fixture those
// tests were ambient-dependent: they passed only on machines where graphify
// happened to be surfaced in the real ~/.claude.
//
// Fix: before each positive-path test, point CLAUDE_CONFIG_DIR at a tmp dir
// containing a full-profile .gsd-surface.json with graphify surfaced, and
// clear GSD_RUNTIME / GSD_WORKSTREAM / GSD_PROJECT for hermeticity.
// An EMPTY tmp config dir (no .gsd-surface.json) also works — the resolver
// defaults to 'full' profile → all surfaced — but we write the file explicitly
// so the fixture intent is visible and independent of default-resolution logic.
/** Create a tmp config dir with graphify surfaced (full profile, no disabled clusters). */
function makeSurfacedConfigDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-graphify-surface-cfg-'));
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
return dir;
}
/**
* Save the env vars that the surfaced-config fixture overrides.
* Returns an object whose .restore() method returns the env to its original state.
*/
function saveSurfacedEnv() {
const saved = {
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
GSD_PROJECT: process.env.GSD_PROJECT,
};
return {
restore() {
if (saved.GSD_RUNTIME === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = saved.GSD_RUNTIME;
if (saved.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
if (saved.GSD_WORKSTREAM === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
if (saved.GSD_PROJECT === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = saved.GSD_PROJECT;
},
};
}
// ─── status describe ─────────────────────────────────────────────────────────
// Require capability-state to assert gate parity in regression tests below.
const { isCapabilityActive } = require('../gsd-core/bin/lib/capability-state.cjs');
describe('status', () => {
describe('isGraphifyEnabled', () => {
// ─── Tri-state gate (Phase 3 cutover from isGraphifyEnabled → isCapabilityActive) ──
//
// The old config-only gate (isGraphifyEnabled) checked ONLY graphify.enabled in
// config.json. The new tri-state gate (isCapabilityActive) requires the capability
// to be installed AND surfaced AND config-enabled.
//
// FAIL-FIRST PROOF (what would fail against the OLD isGraphifyEnabled code):
// Scenario: graphify installed+surfaced on the runtime, graphify.enabled=true in config.
// Old code: isGraphifyEnabled(planningDir) → true → status returns non-disabled.
// New code: isCapabilityActive('graphify', cwd) → depends on surface+install.
// The "gate-parity" test below would FAIL on old code because graphifyStatus used
// isGraphifyEnabled (config-only), which diverges from isCapabilityActive when
// the surface/install dimension differs from config. Specifically:
// - On a machine where graphify is NOT surfaced but config-enabled:
// OLD: isGraphifyEnabled=true → not disabled (BUG)
// NEW: isCapabilityActive=false → disabled (CORRECT)
// - The "returns disabled when graphify.enabled is false" test STILL PASSES under
// old code (config check is a subset of the new check) — it's the POSITIVE case
// that breaks.
//
// With the NEW gate (isCapabilityActive), graphify commands delegate entirely to
// isCapabilityActive, so graphifyStatus outcome === isCapabilityActive outcome.
describe('tri-state graphify gate (isCapabilityActive cutover)', () => {
let tmpDir;
let planningDir;
@@ -58,44 +136,210 @@ describe('status', () => {
cleanup(tmpDir);
});
test('returns false when no config.json exists', () => {
// Remove config.json if createTempProject wrote one
const configPath = path.join(planningDir, 'config.json');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
assert.strictEqual(isGraphifyEnabled(planningDir), false);
// REGRESSION (Phase 3): graphify gate outcome must exactly match isCapabilityActive.
// Old gate (isGraphifyEnabled) was config-only; new gate is tri-state.
// This test would fail on old code in any environment where isCapabilityActive
// disagrees with the config-only check (e.g., surfaced+installed but config-absent).
test('graphifyStatus gate outcome matches isCapabilityActive (regression — Phase 3)', () => {
// No graphify.enabled in config: isCapabilityActive resolves from install+surface.
const capabilityActive = isCapabilityActive('graphify', tmpDir);
const result = graphifyStatus(tmpDir);
if (capabilityActive) {
// Surface+install active → command must proceed (not disabled).
assert.ok(
!result.disabled,
'graphifyStatus must not return disabled when isCapabilityActive=true',
);
} else {
// Not active → command must return disabled.
assert.strictEqual(
result.disabled,
true,
'graphifyStatus must return disabled when isCapabilityActive=false',
);
}
});
test('returns false when graphify key is not set', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
'utf8'
);
assert.strictEqual(isGraphifyEnabled(planningDir), false);
});
test('returns false when graphify.enabled is false', () => {
// Config-disabled → isCapabilityActive returns false → command disabled (preserved).
test('graphifyStatus returns disabled when graphify.enabled is false', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ graphify: { enabled: false } }),
'utf8'
);
assert.strictEqual(isGraphifyEnabled(planningDir), false);
const result = graphifyStatus(tmpDir);
assert.strictEqual(result.disabled, true,
'graphifyStatus must return disabled when graphify.enabled=false');
});
test('returns true when graphify.enabled is true', () => {
// Config-enabled and capability active → command proceeds (not disabled).
test('graphifyStatus is not disabled when config-enabled and isCapabilityActive=true', () => {
enableGraphify(planningDir);
assert.strictEqual(isGraphifyEnabled(planningDir), true);
const capabilityActive = isCapabilityActive('graphify', tmpDir);
// Only assert when the capability is truly active in this environment.
// On a machine without graphify surfaced, isCapabilityActive=false even with
// config-enabled — this is the correct new behavior.
if (capabilityActive) {
const result = graphifyStatus(tmpDir);
assert.ok(!result.disabled,
'graphifyStatus must not return disabled when graphify is fully active');
}
// When capabilityActive=false (not surfaced), disabled is the correct outcome.
// No assertion needed — the regression test above covers it.
});
test('returns false when config.json is malformed', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
'not json',
'utf8'
);
assert.strictEqual(isGraphifyEnabled(planningDir), false);
// ── TEST-03-HERMETIC ────────────────────────────────────────────────────────
//
// FAIL-FIRST PROOF (what would fail against the OLD isGraphifyEnabled code):
// Scenario: graphify installed (full profile → '*' sentinel) + config-enabled=true,
// but graphify NOT surfaced (disabled cluster in .gsd-surface.json).
//
// OLD gate (isGraphifyEnabled): checks ONLY graphify.enabled in config.json.
// → graphify.enabled=true → isGraphifyEnabled=true → graphifyStatus NOT disabled → BUG.
//
// NEW gate (isCapabilityActive): requires installed AND surfaced AND config-enabled.
// → installed=true, surfaced=false → isCapabilityActive=false → graphifyStatus disabled → CORRECT.
//
// Fixture layout:
// CLAUDE_CONFIG_DIR → tmpConfigDir/
// .gsd-surface.json → full profile, disabledClusters:["graphify"]
// (no .gsd-profile → defaults to 'full' → installedSkills='*' → installed=true)
// tmpProjectDir/
// .planning/config.json → {"graphify":{"enabled":true}}
//
// The test is HERMETIC: it controls all three tri-state dimensions via fixture
// files and the CLAUDE_CONFIG_DIR env var, so the outcome is independent of
// any real ~/.claude configuration in the host environment.
describe('hermetic: graphify installed + config-enabled but NOT surfaced → disabled', () => {
let tmpConfigDir;
let tmpProjectDir;
let prevClaudeConfigDir;
let prevGsdWorkstream;
let prevGsdProject;
beforeEach(() => {
tmpConfigDir = createTempDir('gsd-graphify-surface-test-config-');
tmpProjectDir = createTempDir('gsd-graphify-surface-test-project-');
// Fixture: .gsd-surface.json — full profile with graphify cluster disabled.
// The 'graphify' key in disabledClusters maps to ["graphify"] via
// capability-registry.cjs capabilityClusters, removing the 'graphify' skill
// stem from the surfaced set. All other skills remain surfaced.
// No .gsd-profile written → readActiveProfile returns null → defaults to 'full'
// → resolveProfile returns '*' sentinel → installedSkills='*' → installed=true.
const surfaceState = {
baseProfile: 'full',
disabledClusters: ['graphify'],
explicitAdds: [],
explicitRemoves: [],
};
fs.writeFileSync(
path.join(tmpConfigDir, '.gsd-surface.json'),
JSON.stringify(surfaceState, null, 2) + '\n',
'utf8',
);
// Fixture: project config — graphify.enabled=true.
// This is the config dimension that the OLD gate (isGraphifyEnabled) would
// have returned true for, causing the BUG. The new gate ignores config when
// installed && surfaced is false.
const planningDirForFixture = path.join(tmpProjectDir, '.planning');
fs.mkdirSync(planningDirForFixture, { recursive: true });
fs.writeFileSync(
path.join(planningDirForFixture, 'config.json'),
JSON.stringify({ graphify: { enabled: true } }),
'utf8',
);
// Save and override env vars for hermeticity.
// CLAUDE_CONFIG_DIR controls which config dir getGlobalConfigDir('claude') resolves.
prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
prevGsdWorkstream = process.env.GSD_WORKSTREAM;
prevGsdProject = process.env.GSD_PROJECT;
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
// Clear GSD_WORKSTREAM/GSD_PROJECT — ambient values redirect planningDir()
// causing STATE.md reads from an unrelated location (hermeticity regression #872).
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
// Restore env vars before cleanup so that cleanup() rmSync calls use
// the original env (no silent planningDir redirection from leftover vars).
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
if (prevGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prevGsdWorkstream;
if (prevGsdProject === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = prevGsdProject;
cleanup(tmpConfigDir);
cleanup(tmpProjectDir);
});
// Negative case: installed + NOT surfaced + config-enabled → NOT active, command disabled.
// This is the KEY BUG-FIX branch. OLD isGraphifyEnabled would return true here (BUG).
// NEW isCapabilityActive returns false (CORRECT), forcing graphifyStatus to return disabled.
test('isCapabilityActive returns false when installed + NOT surfaced + config-enabled=true (TEST-03-HERMETIC)', () => {
const active = isCapabilityActive('graphify', tmpProjectDir);
assert.strictEqual(
active,
false,
'isCapabilityActive must return false when graphify is installed and config-enabled but NOT surfaced — ' +
'OLD isGraphifyEnabled would return true here (config-only check) which is the bug this test guards against',
);
});
test('graphifyStatus returns disabled when installed + NOT surfaced + config-enabled=true (TEST-03-HERMETIC)', () => {
// Old isGraphifyEnabled(planningDir) → true (graphify.enabled=true in config) → NOT disabled (BUG).
// New isCapabilityActive('graphify', cwd) → false (not surfaced) → disabled (CORRECT).
const result = graphifyStatus(tmpProjectDir);
assert.strictEqual(
result.disabled,
true,
'graphifyStatus must return disabled when graphify is installed and config-enabled but NOT surfaced — ' +
'surface state must gate the command regardless of config-enabled value',
);
assert.ok(
typeof result.message === 'string' && result.message.length > 0,
'disabled response must include a non-empty message with enable instructions',
);
});
// Positive control: same config-dir but now WITH graphify surfaced.
// This confirms the fixture itself is sound — the two tests above must see
// divergent outcomes from the same project config, controlled only by surface state.
test('graphifyStatus is NOT disabled when installed + SURFACED + config-enabled=true (positive control)', () => {
// Re-write .gsd-surface.json with graphify surfaced (disabledClusters empty).
const surfaceStateOn = {
baseProfile: 'full',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
};
fs.writeFileSync(
path.join(tmpConfigDir, '.gsd-surface.json'),
JSON.stringify(surfaceStateOn, null, 2) + '\n',
'utf8',
);
const active = isCapabilityActive('graphify', tmpProjectDir);
assert.strictEqual(
active,
true,
'isCapabilityActive must return true when graphify is installed, surfaced, and config-enabled=true',
);
const result = graphifyStatus(tmpProjectDir);
assert.strictEqual(
result.disabled,
undefined,
'graphifyStatus must NOT return disabled when graphify is installed, surfaced, and config-enabled=true — ' +
'got disabled:' + JSON.stringify(result.disabled),
);
});
});
// ── end TEST-03-HERMETIC ────────────────────────────────────────────────────
});
describe('disabledResponse', () => {
@@ -109,13 +353,28 @@ describe('status', () => {
describe('graphifyStatus', () => {
let tmpDir;
let planningDir;
// Surfaced-config-dir fixture: makes positive-path tests deterministic by
// ensuring graphify is surfaced in the runtime config dir. Without this,
// tests depending on enableGraphify() pass only on machines where the
// ambient ~/.claude has graphify surfaced (ambient-dependent = flaky).
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
// Set up hermetic surfaced config dir: graphify surfaced, runtime=claude.
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME; // use 'claude' default
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -451,14 +710,27 @@ describe('build', () => {
describe('graphifyBuild', () => {
let tmpDir;
let planningDir;
// Surfaced-config-dir fixture: makes positive-path tests deterministic.
// See graphifyStatus describe block for the rationale.
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableGraphify(planningDir);
// Set up hermetic surfaced config dir: graphify surfaced, runtime=claude.
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
mock.restoreAll();
});

View File

@@ -100,7 +100,7 @@ function enableIntel(tmpDir) {
const config = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
// isIntelEnabled() requires the NESTED form { intel: { enabled: true } }.
// isIntelCapabilityActive() / isCapabilityActive('intel', cwd) requires the NESTED form { intel: { enabled: true } }.
// A flat dotted key like config['intel.enabled'] = true is NOT recognised.
config.intel = { ...(config.intel ?? {}), enabled: true };
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');

View File

@@ -12,7 +12,7 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const {
intelQuery,
@@ -24,10 +24,12 @@ const {
intelExtractExports,
intelApiSurface,
ensureIntelDir,
isIntelEnabled,
isIntelCapabilityActive,
INTEL_FILES,
} = require('../gsd-core/bin/lib/intel.cjs');
const { isCapabilityActive } = require('../gsd-core/bin/lib/capability-state.cjs');
// ─── Helpers ────────────────────────────────────────────────────────────────
function enableIntel(planningDir) {
@@ -55,6 +57,51 @@ function _writeIntelMd(planningDir, filename, content) {
fs.writeFileSync(path.join(intelPath, filename), content, 'utf8');
}
// ─── Surfaced-config-dir fixture ──────────────────────────────────────────────
//
// Positive-path tests (intelQuery, intelStatus, etc.) call isCapabilityActive
// via the tri-state gate — they need the capability to be surfaced.
// Without this fixture those tests are ambient-dependent (pass only on machines
// where intel is surfaced in the real ~/.claude).
//
// Fix: point CLAUDE_CONFIG_DIR at a tmp dir containing a full-profile
// .gsd-surface.json (no disabled clusters) so intel is surfaced deterministically.
// An EMPTY tmp config dir also works (defaults to 'full' profile → all surfaced)
// but we write the file explicitly for visible intent.
/** Create a tmp config dir with intel (and all caps) surfaced — full profile. */
function makeSurfacedConfigDir() {
const dir = createTempDir('gsd-intel-surface-cfg-');
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
return dir;
}
/** Save env vars touched by the surfaced-config fixture; returns .restore(). */
function saveSurfacedEnv() {
const saved = {
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
GSD_PROJECT: process.env.GSD_PROJECT,
};
return {
restore() {
if (saved.GSD_RUNTIME === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = saved.GSD_RUNTIME;
if (saved.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
if (saved.GSD_WORKSTREAM === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
if (saved.GSD_PROJECT === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = saved.GSD_PROJECT;
},
};
}
// ─── Disabled gating ────────────────────────────────────────────────────────
describe('intel disabled gating', () => {
@@ -70,22 +117,44 @@ describe('intel disabled gating', () => {
cleanup(tmpDir);
});
test('isIntelEnabled returns false when no config.json exists', () => {
assert.strictEqual(isIntelEnabled(planningDir), false);
// isIntelEnabled was removed in Phase 4 (tri-state cutover). These tests now
// verify the gating via the public command API (intelQuery) and the exported
// isIntelCapabilityActive helper which delegates to isCapabilityActive.
test('isIntelCapabilityActive returns false when no config.json exists', () => {
// No CLAUDE_CONFIG_DIR → defaults to real ~/.claude; no intel.enabled in config.
// In a hermetic test environment (empty tmpDir), the capability is not surfaced
// via the real config dir — but isCapabilityActive returns false by construction
// when the activationKey (intel.enabled) is absent/false regardless of surface,
// because: active = enabled && configActivation; configActivation=false when key absent.
// This test is surface-agnostic for the "no config" branch.
assert.strictEqual(isIntelCapabilityActive(planningDir), false);
});
test('isIntelEnabled returns false when intel.enabled is not set', () => {
test('isIntelCapabilityActive returns false when intel.enabled is not set', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
'utf8'
);
assert.strictEqual(isIntelEnabled(planningDir), false);
assert.strictEqual(isIntelCapabilityActive(planningDir), false);
});
test('isIntelEnabled returns true when intel.enabled is true', () => {
// NOTE: intel has `skills: []` (empty), so installed and surfaced are vacuously true.
// For intel, active = configActivation (the intel.enabled config key).
// This test verifies that isIntelCapabilityActive delegates to isCapabilityActive('intel', cwd)
// and that the function returns a boolean without throwing, regardless of the ambient
// CLAUDE_CONFIG_DIR. The config dimension is probed hermetically in the section below.
test('isIntelCapabilityActive delegates to isCapabilityActive and returns a boolean (config=true, ambient surface)', () => {
// Write config.intel.enabled=true (config dimension ON). Since intel has no skills,
// surface is vacuously true — so this call returns true when the config is written
// and the capability system resolves correctly.
enableIntel(planningDir);
assert.strictEqual(isIntelEnabled(planningDir), true);
// We cannot assert the exact value without full hermetic control of all three
// tri-state dimensions (see hermetic section below for that), but we assert that:
// 1. isIntelCapabilityActive delegates correctly (does not throw)
// 2. it returns a boolean (not undefined/null/object)
const result = isIntelCapabilityActive(planningDir);
assert.strictEqual(typeof result, 'boolean', 'isIntelCapabilityActive must return a boolean');
});
test('intelQuery returns disabled response when intel is off', () => {
@@ -110,6 +179,121 @@ describe('intel disabled gating', () => {
});
});
// ─── Tri-state gate hermetic regression tests ────────────────────────────────
//
// Intel capability has `skills: []` (empty) — so `installed` and `surfaced` are
// VACUOUSLY TRUE. For intel, `active = configActivation` where configActivation
// resolves the `activationKey` ("intel.enabled") via the config. This is a meaningful
// tri-state improvement because the old `isIntelEnabled` read config.json directly
// (synchronous file read, not wired through `loadConfig`), while the new gate goes
// through the full `resolveCapabilityRuntimeState` path.
//
// FAIL-FIRST PROOF (what would fail against the OLD isIntelEnabled code):
// Scenario: intel installed (skills=[]) + config has intel.enabled=true, BUT the
// surface has intel NOT surfaced via disabledClusters.
//
// However: intel has skills:[], so disabledClusters:['intel'] has no effect
// (no skills to remove from the surfaced set). Intel is always vacuously surfaced.
//
// The CORRECT regression for intel's tri-state cutover is:
// intel.enabled=true in config → isCapabilityActive=true → command NOT disabled
// intel.enabled=false (or absent) → isCapabilityActive=false → command disabled
// AND that the gate now goes through the shared resolver (not a direct config read).
//
// FAIL-FIRST SCENARIO: OLD isIntelEnabled read config.json at the planningDir path
// via platformReadSync. NEW isCapabilityActive uses resolveCapabilityRuntimeState
// which goes through loadConfig (multi-layer resolution). A test that sets
// intel.enabled=true in config then calls intelStatus would:
// OLD: isIntelEnabled → reads .planning/config.json → true → NOT disabled.
// NEW: isCapabilityActive → resolveCapabilityRuntimeState → configActivation=true → active=true → NOT disabled.
// Both return the same, so the regression test focuses on the config-absent/false case
// where the gate correctly returns disabled (proving the delegation path works).
describe('intel tri-state gate hermetic regression (isCapabilityActive cutover)', () => {
let tmpConfigDir;
let tmpProjectDir;
let prevClaudeConfigDir;
let prevGsdWorkstream;
let prevGsdProject;
beforeEach(() => {
tmpConfigDir = createTempDir('gsd-intel-tristate-cfg-');
tmpProjectDir = createTempProject('gsd-intel-tristate-proj-');
prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
prevGsdWorkstream = process.env.GSD_WORKSTREAM;
prevGsdProject = process.env.GSD_PROJECT;
// Empty CLAUDE_CONFIG_DIR (no .gsd-surface.json) → defaults to 'full' profile.
// Intel has skills:[] so it is vacuously installed+surfaced+enabled.
// Active = configActivation = intel.enabled in config.
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
if (prevGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prevGsdWorkstream;
if (prevGsdProject === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = prevGsdProject;
cleanup(tmpConfigDir);
cleanup(tmpProjectDir);
});
// NEGATIVE CASE: config has NO intel.enabled (absent → defaults to false via activationKey).
// OLD gate: isIntelEnabled reads config.json → no intel key → returns false → disabled.
// NEW gate: isCapabilityActive → configActivation=false (intel.enabled default=false) → active=false → disabled.
// Both return disabled. The test PROVES the gate is wired through isCapabilityActive and
// the command intelStatus returns disabled — regression guard against losing the delegation.
test('intelStatus returns disabled when intel.enabled is absent in config (hermetic tristate negative)', () => {
// No config.json in planningDir — intel.enabled defaults to false.
// OLD isIntelEnabled: reads .planning/config.json → not found → false → disabled.
// NEW isCapabilityActive: intel.enabled default=false → configActivation=false → active=false → disabled.
const planningDir = path.join(tmpProjectDir, '.planning');
const result = intelStatus(planningDir);
assert.strictEqual(
result.disabled,
true,
'intelStatus must return disabled when intel.enabled is not set — ' +
'both old and new gate must return disabled here; this is the regression guard for the delegation path',
);
assert.ok(
typeof result.message === 'string' && result.message.length > 0,
'disabled response must include a non-empty message',
);
});
// POSITIVE CONTROL: intel.enabled=true in config → isCapabilityActive=true → NOT disabled.
// This is the primary pass case that proves the NEW gate honours config-enabled.
// OLD gate (isIntelEnabled) returns true. NEW gate (isCapabilityActive) also returns true.
// The test confirms the behaviour is preserved after cutover.
test('intelStatus NOT disabled when intel.enabled=true in config (hermetic tristate positive control)', () => {
const planningDir = path.join(tmpProjectDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ intel: { enabled: true } }),
'utf8',
);
const active = isCapabilityActive('intel', tmpProjectDir);
assert.strictEqual(
active,
true,
'isCapabilityActive must return true when intel.enabled=true and intel is vacuously installed+surfaced',
);
const result = intelStatus(planningDir);
assert.ok(
!result.disabled,
'intelStatus must NOT return disabled when intel.enabled=true (positive control)',
);
});
});
// ─── ensureIntelDir ─────────────────────────────────────────────────────────
describe('ensureIntelDir', () => {
@@ -143,14 +327,26 @@ describe('ensureIntelDir', () => {
describe('intelQuery', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableIntel(planningDir);
// Harden: ensure intel is surfaced (tri-state gate requires install+surface+config).
// Empty CLAUDE_CONFIG_DIR defaults to 'full' profile → all caps surfaced.
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -233,14 +429,24 @@ describe('intelQuery', () => {
describe('intelStatus', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableIntel(planningDir);
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -280,14 +486,24 @@ describe('intelStatus', () => {
describe('intelDiff', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableIntel(planningDir);
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -332,14 +548,24 @@ describe('intelDiff', () => {
describe('intelSnapshot', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableIntel(planningDir);
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -363,14 +589,24 @@ describe('intelSnapshot', () => {
describe('intelValidate', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableIntel(planningDir);
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -665,12 +901,24 @@ describe('intelExtractExports', () => {
describe('gsd-tools intel subcommands', () => {
let tmpDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
// Set up surfaced config dir for positive-path CLI tests (subprocess inherits env).
// Negative-path tests (disabled) still work because intel.enabled is not set by default.
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});
@@ -753,13 +1001,23 @@ describe('gsd-tools intel subcommands', () => {
describe('intelApiSurface', () => {
let tmpDir;
let planningDir;
let surfacedConfigDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
surfacedConfigDir = makeSurfacedConfigDir();
savedEnv = saveSurfacedEnv();
delete process.env.GSD_RUNTIME;
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
});
afterEach(() => {
savedEnv.restore();
cleanup(surfacedConfigDir);
cleanup(tmpDir);
});

View File

@@ -237,7 +237,7 @@ describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', (
assert.strictEqual(resolvedB.activeHooks.length, 0, 'explicit config=false must override schema default=true');
});
it('[bva] discuss:post with synthetic capability, capabilityStatesById enabled=false → hook absent even when config=true', () => {
it('[bva] discuss:post with synthetic capability, capabilityStatesById active=false → hook absent even when config=true', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'discuss:post',
when: 'workflow.testcap_on',
@@ -247,9 +247,10 @@ describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', (
point: 'discuss:post',
registry: reg,
config: { workflow: { testcap_on: true } },
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
// Phase 4: resolver gates on `active` (not `enabled`); pass active:false to suppress.
capabilityStatesById: new Map([['future-cap', { enabled: false, active: false }]]),
});
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById enabled=false must suppress hook even when config=true');
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById active=false must suppress hook even when config=true');
});
it('[negative] discuss:post with invalid point name "discuss:past" exits non-zero and lists valid points', () => {
@@ -372,7 +373,8 @@ describe('execute:pre — real registry empty-resolution + synthetic resolver me
point: 'execute:pre',
registry: syntheticReg,
config: {},
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
// Phase 4: resolver gates on `active` (not `enabled`); pass active:false to suppress.
capabilityStatesById: new Map([['future-cap', { enabled: false, active: false }]]),
});
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById disabled must filter unconditional hook');
});

View File

@@ -315,11 +315,15 @@ describe('verify:post — per-key BVA: each false excludes only that single step
// ─── 5. Surface-disable via capabilityStatesById (pure resolver) ──────────────
describe('verify:post — surface-disable: capabilityStatesById filters hooks', () => {
test('[negative] ui disabled via capabilityStatesById→enabled:false excludes ui step; nyquist+security remain', () => {
// Phase 4 note: the resolver now gates on `active` (not `enabled`), so
// capabilityStatesById entries must carry active:false to suppress a hook.
// Real CapabilityStateEntry objects from resolveCapabilityRuntimeState carry both
// enabled and active; fixtures here mirror that shape.
test('[negative] ui disabled via capabilityStatesById→active:false excludes ui step; nyquist+security remain', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: true }],
['security', { enabled: true }],
['ui', { enabled: false }],
['nyquist', { enabled: true, active: true }],
['security', { enabled: true, active: true }],
['ui', { enabled: false, active: false }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',
@@ -340,9 +344,9 @@ describe('verify:post — surface-disable: capabilityStatesById filters hooks',
test('[negative] security disabled via capabilityStatesById excludes security step; nyquist+ui remain', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: true }],
['security', { enabled: false }],
['ui', { enabled: true }],
['nyquist', { enabled: true, active: true }],
['security', { enabled: false, active: false }],
['ui', { enabled: true, active: true }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',
@@ -363,9 +367,9 @@ describe('verify:post — surface-disable: capabilityStatesById filters hooks',
test('[empty-resolution] all three disabled via capabilityStatesById returns empty activeHooks with valid envelope', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: false }],
['security', { enabled: false }],
['ui', { enabled: false }],
['nyquist', { enabled: false, active: false }],
['security', { enabled: false, active: false }],
['ui', { enabled: false, active: false }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',

View File

@@ -963,3 +963,78 @@ describe('--active-cap flag (loop render-hooks)', () => {
);
});
});
// ─── Phase 4 regression: loop-resolver gates on state.active (not state.enabled) ─────
//
// FAIL-FIRST PROOF (what would fail against the OLD state.enabled check):
// Scenario: capability has activationKey set; it is installed + surfaced (enabled=true)
// but the activationKey resolves to false (active=false). The capability has a hook
// WITHOUT a `when` guard (unconditional). With old state.enabled check:
// state.enabled=true → state.enabled !== false → true → hook IS rendered (BUG).
// With new state.active check:
// state.active=false → state.active === true → false → hook NOT rendered (CORRECT).
//
// This test uses resolveLoopHooks directly with a synthetic registry and a
// capabilityStatesById map that models the above scenario: enabled=true, active=false.
// It would FAIL against the OLD `state.enabled !== false` code and PASS against the
// NEW `state.active === true` code.
describe('Phase 4 regression: capabilityStatesById gates on active (not enabled) for config-disabled capability', () => {
test('[regression] enabled=true active=false + no `when` guard → hook NOT rendered (state.active gate)', () => {
// Fail-first: with OLD `state.enabled !== false`, enabled=true → hook IS rendered (BUG).
// With NEW `state.active === true`, active=false → hook NOT rendered (CORRECT).
const registry = makeRegistry({
point: 'plan:pre',
steps: [{ capId: 'test-cap', ref: { skill: 'gsd-test-skill' } }],
// No `when` → unconditional hook (no per-hook config gate to fall back on)
});
const capabilityStatesById = new Map([
// enabled=true (installed+surfaced), active=false (activationKey resolved to false)
// This models a capability like intel/graphify that is surfaced but config-disabled.
['test-cap', { enabled: true, active: false }],
]);
const result = resolveLoopHooks({
point: 'plan:pre',
registry,
config: {}, // config doesn't matter — capability already resolved active=false
capabilityStatesById,
});
assert.strictEqual(
result.activeHooks.length,
0,
'Hook must NOT be rendered when state.active=false, even if enabled=true and no `when` guard — ' +
'OLD state.enabled check would include this hook (BUG: enabled=true passes enabled!==false); ' +
'NEW state.active check correctly suppresses it (active=false fails active===true)',
);
});
test('[positive control] enabled=true active=true + no `when` guard → hook IS rendered', () => {
// Confirms the fixture is sound: same hook, same registry, but active=true → rendered.
// This test must PASS against BOTH old and new code (it's the unbroken branch).
const registry = makeRegistry({
point: 'plan:pre',
steps: [{ capId: 'test-cap', ref: { skill: 'gsd-test-skill' } }],
});
const capabilityStatesById = new Map([
['test-cap', { enabled: true, active: true }],
]);
const result = resolveLoopHooks({
point: 'plan:pre',
registry,
config: {},
capabilityStatesById,
});
assert.strictEqual(
result.activeHooks.length,
1,
'Hook MUST be rendered when state.active=true and no `when` guard (positive control)',
);
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
});
});

View File

@@ -28,6 +28,11 @@ const fc = require('./helpers/fast-check-setup.cjs');
const BUILT_SCRIPT = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs');
const pc = require(BUILT_SCRIPT);
// #1278: the check-descriptor deterministic-locate round-trip crosses three modules — probe-core's
// projector, the shared flat parser, and the enforcement read-back adapter. Require all three here so
// the property exercises the real end-to-end chain (not a stubbed seam).
const fm = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'frontmatter.cjs'));
const enforce = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'prohibition-enforcement.cjs'));
// The same representative validators bundle the edge adapter injects (see
// tests/probe-core.test.cjs) — exercises the generic engine independent of any one probe.
@@ -165,3 +170,102 @@ describe('probe-core property: orphan rejection is stable', () => {
);
});
});
// ─── #1278: the check-descriptor deterministic-locate round-trip (property-based) ────────────────
// trek-e re-review (RULESET.TESTS.property-based-testing): the projectProhibitions -> render ->
// parseMustHavesBlock -> descriptorFromProjection chain is a bijective/transformation contract. The
// example suite (tests/prohibition-probe.schema.test.cjs CHK-03 A/B/C) pins three hand-picked rows;
// these properties pin the invariant across the FULL input domain — including the parseMustHavesBlock
// numeric-coercion case (a /^\d+$/ scalar parses back as a number; descriptorFromProjection
// String()-normalizes it) and the under-specified fail-closed cases. The "stable" contract is
// expressed at the descriptorFromProjection reconstruction layer, because the raw parse step is
// intentionally lossy for numeric scalars (the shared parser coerces; #1278 does not change it).
// Mirror of the schema test's renderProhibitionsDoc: flat scalar continuation KVs, emitted only when
// present (src/frontmatter.cts:344 reads them back as scalar `key: value` lines).
function renderProhibitionsDoc(entries) {
const lines = ['---', 'phase: 01-x', 'plan: 01', 'must_haves:', ' prohibitions:'];
for (const e of entries) {
lines.push(` - statement: "${e.statement}"`);
lines.push(` status: ${e.status}`);
if (e.verification !== undefined) lines.push(` verification: ${e.verification}`);
if (e.reason !== undefined) lines.push(` reason: "${e.reason}"`);
if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`);
if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`);
if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`);
}
lines.push('---', '', 'Body.', '');
return lines.join('\n');
}
const BASE_TIER = Object.freeze({
requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test',
resolution: null, reason: null, statement: 'MUST NOT do the forbidden thing',
});
const KIND_ARB = fc.constantFrom('node-test', 'lint-rule');
// Path-like scalar that is NEVER pure-digit (so the flat parser does not numeric-coerce it) — models
// realistic targets / rule-ids. The renderer is unquoted, so the charset excludes whitespace, quotes
// and colons that the flat continuation-KV regex would not round-trip.
const PATH_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-'.split('');
const pathScalarArb = fc.array(fc.constantFrom(...PATH_CHARS), { minLength: 1, maxLength: 24 })
.map((chars) => chars.join(''))
.filter((s) => /\D/.test(s)); // ≥1 non-digit → stays a string through parseMustHavesBlock
// Canonical integer string — exercises the numeric-coercion path (render `key: 12345` -> parse coerces
// to NUMBER -> descriptorFromProjection String()-normalizes back). Capped well under MAX_SAFE_INTEGER,
// no leading zeros, so the integer round-trips exactly.
const numericScalarArb = fc.nat({ max: 9999999 }).map(String);
const targetArb = fc.oneof(pathScalarArb, numericScalarArb);
// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule.
const wellFormedArb = KIND_ARB.chain((kind) =>
fc.record({ target: targetArb, rule: pathScalarArb }).map(({ target, rule }) => {
const item = { ...BASE_TIER, check_kind: kind, check_target: target };
if (kind === 'lint-rule') item.check_rule = rule;
return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined };
}),
);
describe('probe-core property: #1278 check-descriptor round-trip is deterministic across the full string domain', () => {
test('a well-formed descriptor survives project -> render -> parse -> descriptorFromProjection (incl. numeric coercion); target/rule reconstruct as strings', () => {
fc.assert(
fc.property(wellFormedArb, ({ item, kind, target, rule }) => {
const projected = pc.projectProhibitions([item]);
if (projected[0].check_kind !== kind) return false; // projector emits the descriptor
const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions');
const d = enforce.descriptorFromProjection(reparsed[0]);
if (!d || d.kind !== kind) return false;
// target is string-normalized even when parseMustHavesBlock numerically coerced it.
if (typeof d.target !== 'string' || d.target !== target) return false;
if (kind === 'lint-rule') {
return typeof d.rule === 'string' && d.rule === rule;
}
return !('rule' in d); // a node-test descriptor never carries a rule
}),
);
});
});
// Under-specified / invalid projected descriptors: the deterministic-locate contract is fail-CLOSED —
// the adapter + the producer's existing locate guard must NEVER green and ALWAYS flag, even when the
// (injected) runner would report a pass.
const malformedArb = fc.oneof(
fc.constant({ ...BASE_TIER }), // absent descriptor (no check_*)
KIND_ARB.map((kind) => ({ ...BASE_TIER, check_kind: kind })), // valid kind, NO target
pathScalarArb.map((t) => ({ ...BASE_TIER, check_kind: 'lint-rule', check_target: t })), // lint-rule, NO rule
fc.record({ k: fc.constantFrom('shell-script', 'bash', 'python', 'exec', ''), t: targetArb })
.map(({ k, t }) => ({ ...BASE_TIER, check_kind: k, check_target: t })), // unknown kind
);
describe('probe-core property: #1278 under-specified descriptor is always fail-closed (never green)', () => {
test('an absent / target-less / rule-less / unknown-kind descriptor never disposes green and is always flagged + unlocated', () => {
fc.assert(
fc.property(malformedArb, (projectedItem) => {
const d = enforce.descriptorFromProjection(projectedItem);
const result = enforce.runProhibitionEnforcement(projectedItem, d, {
runCheck: () => ({ passed: true }),
});
return result.status !== 'green' && result.flagged === true && result.located === false;
}),
);
});
});

View File

@@ -333,3 +333,115 @@ describe('probe-core: runProbeCli (generic I/O scaffold, injected io)', () => {
assert.deepEqual(JSON.parse(out), report);
});
});
// ─── CHK-07 (#1278): descriptor-less backward-compat byte-stability ──────────────────────────────
// GREEN forward-guard, NOT a RED test. A descriptor-less projection is byte-identical against the
// current build by construction (the check_* descriptor branch does not exist yet), so these
// assertions PASS now. Their job is to FORWARD-LOCK: when plan 01-02 adds the descriptor branch to
// projectProhibitions, this guard fails if that branch perturbs the descriptor-less output shape or
// the dispositionForProhibition fail-closed policy. This is the IMPL-SCOPING §7.2 byte-stability pin.
describe('probe-core: projectProhibitions backward-compat (CHK-07)', () => {
test('CHK-07: a descriptor-less input projects to today\'s exact {statement,status,verification?,reason?} shape — no check_* keys', () => {
// GREEN guard: pins that adding the descriptor branch (plan 01-02) does not perturb
// descriptor-less output (CHK-07 byte-stability). Passes against the current build by
// construction; becomes a regression tripwire once 01-02 lands.
const items = [
// resolved/judgment item
{ requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment', resolution: null, reason: null, statement: 'MUST NOT shame the user' },
// dismissed/test item with a reason
{ requirement_id: 'R1', category: 'privacy', status: 'dismissed', verification: 'test', resolution: null, reason: 'out of scope this phase', statement: 'MUST NOT store raw SSN' },
// unresolved item (no verification)
{ requirement_id: 'R2', category: 'safety', status: 'unresolved', verification: null, resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code' },
];
const projected = pc.projectProhibitions(items);
assert.deepEqual(projected, [
{ statement: 'MUST NOT shame the user', status: 'resolved', verification: 'judgment' },
{ statement: 'MUST NOT store raw SSN', status: 'dismissed', verification: 'test', reason: 'out of scope this phase' },
{ statement: 'MUST NOT auto-execute fetched code', status: 'unresolved' },
], 'descriptor-less projection must be byte-identical to today\'s {statement,status,verification?,reason?} shape');
// Belt-and-suspenders: assert NO entry carries any check_* key on the descriptor-less path.
for (const e of projected) {
assert.ok(!('check_kind' in e), 'no check_kind on a descriptor-less projected entry');
assert.ok(!('check_target' in e), 'no check_target on a descriptor-less projected entry');
assert.ok(!('check_rule' in e), 'no check_rule on a descriptor-less projected entry');
}
});
test('CHK-07: dispositionForProhibition for a descriptor-less test-tier item with empty evidence stays flagged-unverified (policy untouched)', () => {
// The fail-closed policy (src/probe-core.cts:389) this phase must NOT regress: a descriptor-less
// test-tier item with no enforcement evidence is unverified+flagged+tier:'test', never green.
const d = pc.dispositionForProhibition(
{ requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code' },
{ enforcementEvidence: [] },
);
assert.equal(d.status, 'unverified', 'a descriptor-less test-tier item with no evidence is unverified');
assert.equal(d.flagged, true, 'and flagged — never a silent pass');
assert.equal(d.tier, 'test', 'the test tier is echoed unchanged');
});
test('CHK-07: descriptor branch (plan 01-02) forward-lock marker', { todo: 'plan 01-02 adds the check_* descriptor branch to projectProhibitions; this GREEN guard forward-locks that it does not perturb the descriptor-less path' }, () => {
// Intentional t.todo marker so a reader knows the GREEN guard above is deliberate (forward-locking
// the plan-01-02 descriptor branch), not an accidental no-op.
});
});
// ─── CHK-02 (#1278): projectProhibitions emits the flat-scalar descriptor for well-formed items ────
// Unit-layer pin (the parser round-trip lives in tests/prohibition-probe.schema.test.cjs CHK-03). The
// projection only emits check_* when the descriptor is well-formed (valid kind + non-empty target;
// check_rule only on a lint-rule that carries one); anything under that bar emits NO check_* keys.
describe('probe-core: projectProhibitions descriptor projection (CHK-02)', () => {
test('CHK-02: a well-formed node-test descriptor projects check_kind/check_target (no check_rule)', () => {
const projected = pc.projectProhibitions([
{ status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code',
check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs' },
]);
assert.equal(projected[0].check_kind, 'node-test');
assert.equal(projected[0].check_target, 'tests/no-autoexec.test.cjs');
assert.ok(!('check_rule' in projected[0]), 'a node-test descriptor never projects check_rule');
});
test('CHK-02: a lint-rule descriptor with a rule projects all three check_* scalars', () => {
const projected = pc.projectProhibitions([
{ status: 'resolved', verification: 'test', statement: 'MUST NOT read source files in tests',
check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep' },
]);
assert.equal(projected[0].check_kind, 'lint-rule');
assert.equal(projected[0].check_target, 'src/');
assert.equal(projected[0].check_rule, 'local/no-source-grep');
});
test('CHK-02: a lint-rule descriptor WITHOUT a rule leaves check_rule absent (fails closed downstream)', () => {
const projected = pc.projectProhibitions([
{ status: 'resolved', verification: 'test', statement: 'MUST NOT read source files in tests',
check_kind: 'lint-rule', check_target: 'src/' },
]);
assert.equal(projected[0].check_kind, 'lint-rule');
assert.equal(projected[0].check_target, 'src/');
assert.ok(!('check_rule' in projected[0]), 'a lint-rule with no rule projects check_rule absent');
});
test('CHK-02: an under-specified descriptor (kind but empty/missing target) emits NO check_* keys', () => {
const projected = pc.projectProhibitions([
// valid kind but empty target -> below the well-formedness bar -> descriptor projects absent
{ status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing',
check_kind: 'node-test', check_target: ' ' },
// unknown kind -> descriptor projects absent
{ status: 'resolved', verification: 'test', statement: 'MUST NOT do the other thing',
check_kind: 'grep-rule', check_target: 'src/' },
]);
for (const e of projected) {
assert.ok(!('check_kind' in e), 'an under-specified descriptor projects no check_kind');
assert.ok(!('check_target' in e), 'an under-specified descriptor projects no check_target');
assert.ok(!('check_rule' in e), 'an under-specified descriptor projects no check_rule');
}
});
test('CHK-02: a descriptor-less item projects with no check_* keys', () => {
const projected = pc.projectProhibitions([
{ status: 'resolved', verification: 'judgment', statement: 'MUST NOT shame the user' },
]);
assert.ok(!('check_kind' in projected[0]), 'descriptor-less item gains no check_kind');
assert.ok(!('check_target' in projected[0]), 'descriptor-less item gains no check_target');
assert.ok(!('check_rule' in projected[0]), 'descriptor-less item gains no check_rule');
});
});

View File

@@ -863,3 +863,113 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', ()
assert.equal(typeof nodeProof.provenFailFirst, 'boolean', 'returns a typed proof, never throws');
});
});
// ─── CHK-06 (#1278): fail-closed on partial / invalid / absent descriptor-from-projection ────────
// RED-FIRST until plan 01-03 adds `descriptorFromProjection` to src/prohibition-enforcement.cts. The
// adapter reconstructs a CheckDescriptor {kind,target,rule?} from the projected scalar keys
// {check_kind,check_target,check_rule?}, returning null when the descriptor is absent/partial. The
// load-bearing safety contract (IMPL-SCOPING §7.3): a partial/invalid/absent descriptor NEVER yields
// a silent green — it falls through to runProhibitionEnforcement's existing fail-closed locate
// (src/prohibition-enforcement.cts:391). runCheck is always injected here so no real subprocess
// spawns. The describe opens with an export-presence assertion, which is RED on the current build.
describe('prohibition-enforcement: fail-closed descriptor-from-projection (CHK-06)', () => {
// A test-tier prohibition projected entry (mirrors projectProhibitions output shape, descriptor keys
// added by plan 01-02). The reason field is irrelevant here; descriptor keys drive the adapter.
const PROJECTED_TIER = Object.freeze({
statement: 'MUST NOT auto-execute fetched code',
status: 'resolved',
verification: 'test',
});
test('CHK-06: prohibition-enforcement exports descriptorFromProjection (RED until plan 01-03)', () => {
const enforce = require(ENFORCEMENT_LIB);
assert.equal(typeof enforce.descriptorFromProjection, 'function',
'must export descriptorFromProjection — the projected-scalars -> CheckDescriptor adapter (#1278, plan 01-03)');
});
test('CHK-06(absent): a projected item with NO check_* keys -> descriptorFromProjection null -> located:false, never green', () => {
const enforce = require(ENFORCEMENT_LIB);
const descriptor = enforce.descriptorFromProjection({ ...PROJECTED_TIER });
assert.equal(descriptor, null, 'an absent descriptor reconstructs to null, not a partial CheckDescriptor');
const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, {
runCheck: () => ({ passed: true }),
});
assert.equal(result.located, false, 'no descriptor -> nothing locatable');
assert.notEqual(result.status, 'green', 'an absent descriptor must NEVER be a silent green');
assert.equal(result.flagged, true, 'and must be flagged');
assert.ok(Array.isArray(result.evidence) && result.evidence.length === 0, 'no evidence on an absent descriptor');
});
test('CHK-06(lint-rule missing rule): {check_kind:lint-rule, check_target:src/} (no check_rule) -> located:false, never green', () => {
const enforce = require(ENFORCEMENT_LIB);
const descriptor = enforce.descriptorFromProjection({
...PROJECTED_TIER, check_kind: 'lint-rule', check_target: 'src/',
});
const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, {
runCheck: () => ({ passed: true }),
});
assert.equal(result.located, false, 'an under-specified lint-rule (no rule id) is not locatable (validRule guard, :390)');
assert.notEqual(result.status, 'green', 'a lint-rule missing its rule id must NEVER green');
assert.equal(result.flagged, true);
});
test('CHK-06(unknown kind): {check_kind:shell-script} -> validKind false -> located:false, never green', () => {
const enforce = require(ENFORCEMENT_LIB);
const descriptor = enforce.descriptorFromProjection({
...PROJECTED_TIER, check_kind: 'shell-script', check_target: 'x',
});
const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, {
runCheck: () => ({ passed: true }),
});
assert.equal(result.located, false, 'an unknown kind is not a valid wired check (validKind guard, :388)');
assert.notEqual(result.status, 'green', 'an unknown check kind must NEVER green');
assert.equal(result.flagged, true);
});
test('CHK-06(well-formed but runCheck reports non-pass): located:true, never green (no false green)', () => {
const enforce = require(ENFORCEMENT_LIB);
const descriptor = enforce.descriptorFromProjection({
...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs',
});
// A complete node-test descriptor; failFirst is caller-attested at verify time (#1279), not sourced
// from the projection, so attest it here. The injected runCheck reports a non-pass.
const result = enforce.runProhibitionEnforcement(
PROJECTED_TIER,
descriptor ? { ...descriptor, failFirst: true } : descriptor,
{ runCheck: () => ({ passed: false }) },
);
assert.equal(result.located, true, 'a well-formed descriptor IS located even though the run did not pass');
assert.notEqual(result.status, 'green', 'a located check that does not genuinely pass must NEVER green');
assert.equal(result.flagged, true);
});
test('CHK-06(MD-01 numeric coercion): a numeric-looking check_target reconstructs as a STRING (parseMustHavesBlock coerces ^\\d+$ to number) -> located, no type-lie / silent un-locate', () => {
const enforce = require(ENFORCEMENT_LIB);
// The shared parseMustHavesBlock (src/frontmatter.cts) coerces a /^\d+$/ scalar value to a NUMBER on
// round-trip, so a numeric-looking check_target arrives at the adapter as a number. The adapter must
// String()-coerce it (not cast `as string` over a number), so the descriptor is honestly typed AND a
// numeric-looking target still locates instead of silently un-locating (the round-trip is lossless
// across the full string domain — closes review finding MD-01/LW-01).
const descriptor = enforce.descriptorFromProjection({
...PROJECTED_TIER, check_kind: 'node-test', check_target: 12345,
});
assert.equal(typeof descriptor.target, 'string',
'a numeric-coerced check_target must reconstruct as a string, never a number behind an `as string` cast');
assert.equal(descriptor.target, '12345');
const result = enforce.runProhibitionEnforcement(
PROJECTED_TIER,
{ ...descriptor, failFirst: true },
{ runCheck: () => ({ passed: true }) },
);
assert.equal(result.located, true,
'a numeric-looking but valid target locates after String() coercion — no silent un-locate');
});
test('CHK-06(LW-02 stray rule): a check_rule on a node-test descriptor is dropped (rule belongs to lint-rule only)', () => {
const enforce = require(ENFORCEMENT_LIB);
const descriptor = enforce.descriptorFromProjection({
...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/x.test.cjs', check_rule: 'local/no-source-grep',
});
assert.equal(descriptor.rule, undefined, 'a node-test descriptor carries no rule even if a stray check_rule is present');
});
});

View File

@@ -150,6 +150,12 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip
lines.push(` status: ${e.status}`);
if (e.verification !== undefined) lines.push(` verification: ${e.verification}`);
if (e.reason !== undefined) lines.push(` reason: "${e.reason}"`);
// CHK-03 (#1278): the flat scalar descriptor keys render as continuation KVs under the list
// item, exactly how src/frontmatter.cts:344 reads them back. Only emitted when present, so a
// descriptor-less entry renders identically to before (no `check_*` lines).
if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`);
if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`);
if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`);
}
lines.push('---', '', 'Body.', '');
return lines.join('\n');
@@ -178,4 +184,87 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip
assert.deepEqual(pc.projectProhibitions(null), [], 'null -> [] (documented fail-soft)');
assert.deepEqual(pc.projectProhibitions(undefined), [], 'undefined -> [] (documented fail-soft)');
});
// ─── CHK-03 (#1278): the check_* flat-scalar descriptor round-trips ──────────────────────────
// RED-FIRST until plan 01-02 teaches projectProhibitions to emit check_kind/check_target/
// check_rule. The DEFECT.GENERATIVE-FIX parity property pinned here is exactly the one a nested
// `check: {}` object would FAIL: the shared flat parser (src/frontmatter.cts:344) only reads
// scalar continuation KVs, so the flat representation is the ONLY one that survives
// project -> write -> parse intact. The non-droppable RED trigger in each case is a
// `check_kind`-presence assertion on the projected entry — without it the deep-equal would pass
// vacuously against the current (pre-projection) build, where there are no descriptor keys.
test('CHK-03(A): a node-test descriptor projects + round-trips with check_kind/check_target', () => {
const pc = require(PROBE_CORE_LIB);
const fm = require(FRONTMATTER_LIB);
const items = [
{
requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test',
resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code',
check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs',
},
];
const projected = pc.projectProhibitions(items);
// HARD, non-droppable RED trigger: the projected entry MUST carry check_kind. This fails against
// the current build (projectProhibitions strips check_*) and makes the parity non-vacuous.
assert.ok(projected[0].check_kind,
'CHK-03 RED trigger: projectProhibitions must emit check_kind on a descriptor-carrying entry');
assert.equal(projected[0].check_kind, 'node-test');
assert.equal(projected[0].check_target, 'tests/no-autoexec.test.cjs');
assert.ok(!('check_rule' in projected[0]), 'check_rule is absent for a node-test descriptor');
const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions');
assert.deepEqual(reparsed, projected,
'a node-test descriptor must survive project -> write -> parseMustHavesBlock unchanged (check_* intact)');
});
test('CHK-03(B): a lint-rule descriptor round-trips with all three check_* scalars', () => {
const pc = require(PROBE_CORE_LIB);
const fm = require(FRONTMATTER_LIB);
const items = [
{
requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test',
resolution: null, reason: null, statement: 'MUST NOT read source files in tests',
check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep',
},
];
const projected = pc.projectProhibitions(items);
assert.ok(projected[0].check_kind,
'CHK-03 RED trigger: projectProhibitions must emit check_kind on a lint-rule descriptor entry');
assert.equal(projected[0].check_kind, 'lint-rule');
assert.equal(projected[0].check_target, 'src/');
assert.equal(projected[0].check_rule, 'local/no-source-grep');
const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions');
assert.deepEqual(reparsed, projected,
'a lint-rule descriptor must survive the writer<->reader bijection with check_kind/target/rule intact');
});
test('CHK-03(C): a mixed list — descriptor test-tier, descriptor-less judgment, dismissed — all round-trip; the descriptor-less item gains NO check_* keys', () => {
const pc = require(PROBE_CORE_LIB);
const fm = require(FRONTMATTER_LIB);
const items = [
{
requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test',
resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code',
check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs',
},
{
requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment',
resolution: null, reason: null, statement: 'MUST NOT shame the user',
},
{
requirement_id: 'R2', category: 'privacy', status: 'dismissed', verification: 'test',
resolution: null, reason: 'out of scope this phase', statement: 'MUST NOT store raw SSN',
},
];
const projected = pc.projectProhibitions(items);
// Non-droppable RED trigger: the descriptor-carrying entry exposes check_kind.
assert.ok(projected[0].check_kind,
'CHK-03 RED trigger: the test-tier descriptor entry must carry check_kind');
// The descriptor-less judgment item must NOT gain any check_* key.
assert.ok(!('check_kind' in projected[1]), 'a descriptor-less item gains no check_kind');
assert.ok(!('check_target' in projected[1]), 'a descriptor-less item gains no check_target');
assert.ok(!('check_rule' in projected[1]), 'a descriptor-less item gains no check_rule');
const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions');
assert.deepEqual(reparsed, projected,
'a mixed list survives the writer<->reader bijection; descriptor presence/absence is preserved per item');
});
});

View File

@@ -72,7 +72,7 @@
"ship.md": 24388,
"sketch-wrap-up.md": 14223,
"sketch.md": 19960,
"spec-phase.md": 28438,
"spec-phase.md": 30343,
"spike-wrap-up.md": 15092,
"spike.md": 24517,
"stats.md": 6718,
@@ -85,6 +85,6 @@
"undo.md": 10431,
"update.md": 21053,
"validate-phase.md": 10745,
"verify-phase.md": 35812,
"verify-phase.md": 37074,
"verify-work.md": 31157
}