Merge branch 'next' into kimi-runtime-support

This commit is contained in:
Colin Johnson
2026-06-08 23:04:37 -04:00
committed by GitHub
117 changed files with 6892 additions and 608 deletions

View File

@@ -0,0 +1,329 @@
'use strict';
/**
* Regression test for bug #891: gsd_run launcher must probe non-Claude
* runtime homes before emitting the hard error.
*
* The last-resort $HOME/.claude/gsd-core branch is Claude Code-specific.
* Every non-Claude runtime (Hermes, Cursor, Codex, Copilot, Windsurf, …)
* installs gsd-core into a *different* directory that the shim never tried,
* causing a false-positive fatal ERROR on all non-Claude runtimes when
* RUNTIME_DIR is not set and gsd-tools is not on PATH.
*
* Asserts:
* (A) Snippet contains all expected non-Claude runtime home probes (structural).
* (B) HERMES_HOME behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on
* PATH, a stub at ${HERMES_HOME}/gsd-core/bin/gsd-tools.cjs is invoked.
* (C) Default Hermes path behavioral: stub at $HOME/.hermes/gsd-core/bin/
* gsd-tools.cjs is invoked when HERMES_HOME is not set.
* (D) Resolution order: non-Claude homes are probed BEFORE the hard error,
* and AFTER the $HOME/.claude branch.
* (E) Propagation: all workflow .md files using gsd_run contain each probe
* (sync-runtime-launcher.cjs was re-run after editing the snippet).
*/
// allow-test-rule: structural/behavioral regression for non-Claude runtime-home
// fallback arms in the gsd_run launcher snippet -- asserts literal substring
// presence for each runtime-home probe and exercises the bash resolution paths
// via execFileSync; there is no typed IR for "snippet contains arm X".
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
// Every non-Claude runtime home probe the snippet must contain.
// Key: runtime name (for diagnostics). Value: the substring that must appear
// in the snippet (the env-var-with-default expansion that probes that runtime's
// gsd-core install location). Mirrors src/runtime-homes.cts getGlobalConfigDir().
const EXPECTED_RUNTIME_PROBES = {
hermes: '.hermes}/gsd-core/bin/',
cursor: '.cursor}/gsd-core/bin/',
codex: '.codex}/gsd-core/bin/',
gemini: '.gemini}/gsd-core/bin/',
copilot: '.copilot}/gsd-core/bin/',
windsurf: '.codeium/windsurf}/gsd-core/bin/',
augment: '.augment}/gsd-core/bin/',
trae: '.trae}/gsd-core/bin/',
qwen: '.qwen}/gsd-core/bin/',
codebuddy: '.codebuddy}/gsd-core/bin/',
cline: '.cline}/gsd-core/bin/',
grok: '.agents}/gsd-core/bin/',
antigravity: '.gemini/antigravity}/gsd-core/bin/',
opencode: 'opencode}/gsd-core/bin/',
kilo: 'kilo}/gsd-core/bin/',
};
/**
* Collect all workflow .md files recursively.
*/
function collectWorkflowFiles() {
const results = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(full);
}
}
}
walk(WORKFLOWS_DIR);
return results;
}
/**
* Extract all bash/sh/shell fenced blocks from markdown content.
*/
function extractShellBlocks(content) {
const allLines = content.split('\n');
const blocks = [];
let inBlock = false;
let blockLang = null;
let blockLines = [];
let blockIndent = '';
let closingPattern = null;
for (let i = 0; i < allLines.length; i++) {
const line = allLines[i];
if (!inBlock) {
const fenceOpen = line.match(/^(\s*)```(\w+)?\s*$/);
if (fenceOpen) {
inBlock = true;
blockIndent = fenceOpen[1];
blockLang = (fenceOpen[2] || '').toLowerCase();
blockLines = [];
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
continue;
}
} else {
if (closingPattern.test(line)) {
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
blocks.push({ lines: blockLines });
}
inBlock = false;
blockLang = null;
blockLines = [];
blockIndent = '';
closingPattern = null;
continue;
}
blockLines.push(line);
}
}
return blocks;
}
/**
* Build a PATH with no gsd-tools binary so the PATH fallback branch is skipped.
* Returns the isolated PATH string (system paths minus any dir containing gsd-tools).
*/
function buildIsolatedPath() {
return (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
catch { return true; }
})
.join(path.delimiter);
}
describe('bug-891: non-Claude runtime home fallback arms', () => {
// ── (A) Structural: snippet contains all expected non-Claude probes ───────
test('(A) snippet contains all non-Claude runtime home probes', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const missing = [];
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
if (!snippetContent.includes(probe)) {
missing.push(`${runtime}: expected snippet to contain "${probe}"`);
}
}
assert.deepStrictEqual(
missing,
[],
`_runtime-launcher.snippet.sh is missing fallback probes for non-Claude runtimes:\n` +
missing.join('\n') +
`\n\nAdd elif arms for each runtime home (e.g. "\${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/...")` +
` before the hard-error else. Current snippet:\n${snippetContent.trim()}`,
);
});
// ── (A2) Structural: probes appear AFTER .claude arm but BEFORE hard error ─
test('(A2) non-Claude probes appear after .claude/gsd-core arm and before hard error', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
const errorPos = snippetContent.indexOf('exit 1');
assert.ok(claudePos !== -1, 'Snippet must still contain .claude/gsd-core/bin/ arm (regression guard)');
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 (hard-error guard)');
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
const probePos = snippetContent.indexOf(probe);
assert.ok(
probePos !== -1,
`Snippet must contain probe for ${runtime} ("${probe}")`,
);
assert.ok(
probePos < errorPos,
`${runtime} probe must appear before "exit 1" in snippet (found at ${probePos}, exit 1 at ${errorPos})`,
);
}
});
// ── (B) Behavioral: HERMES_HOME stub is resolved ──────────────────────────
test('(B) gsd_run resolves ${HERMES_HOME}/gsd-core/bin/ stub when set and local+PATH both miss', () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-b-'));
const fakeHermesHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-hermes-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt-'));
try {
const hermesBinDir = path.join(fakeHermesHome, 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Export HOME to an isolated temp dir (no .claude install there) so the
// $HOME/.claude arm is skipped and we fall through to the HERMES_HOME arm.
const scriptContent =
`unset GSD_TOOLS\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HERMES_HOME=${JSON.stringify(fakeHermesHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-home.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: buildIsolatedPath(), HOME: fakeHome, HERMES_HOME: fakeHermesHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into hermes gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('HERMES_HOME_STUB:ping,test'),
`Expected stub output "HERMES_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeHermesHome);
cleanup(fakeRuntime);
}
});
// ── (C) Behavioral: default .hermes path used when HERMES_HOME not set ────
test('(C) gsd_run resolves $HOME/.hermes/gsd-core/bin/ stub when HERMES_HOME is unset', () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt2-'));
try {
const hermesBinDir = path.join(fakeHome, '.hermes', 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_DEFAULT_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS HERMES_HOME\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run status\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-default.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: buildIsolatedPath(), HOME: fakeHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('.hermes/gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into .hermes/gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('HERMES_DEFAULT_STUB:status'),
`Expected stub output "HERMES_DEFAULT_STUB:status", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
}
});
// ── (D) Resolution order: claude < hermes < hard-error ───────────────────
test('(D) resolution order: .claude probe comes before hermes probe, hermes before hard error', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
const hermesPos = snippetContent.indexOf('.hermes}/gsd-core/bin/');
const errorPos = snippetContent.indexOf('exit 1');
assert.ok(claudePos !== -1, 'Snippet must contain .claude/gsd-core/bin/ arm');
assert.ok(hermesPos !== -1, 'Snippet must contain .hermes}/gsd-core/bin/ arm');
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 hard-error');
assert.ok(
claudePos < hermesPos,
`Expected .claude probe (at ${claudePos}) before .hermes probe (at ${hermesPos})`,
);
assert.ok(
hermesPos < errorPos,
`Expected .hermes probe (at ${hermesPos}) before exit 1 (at ${errorPos})`,
);
});
// ── (E) Propagation: workflow .md files using gsd_run contain hermes probe ─
test('(E) all workflow .md files using gsd_run contain the hermes runtime home probe', () => {
const HERMES_PROBE = '.hermes}/gsd-core/bin/';
const files = collectWorkflowFiles();
assert.ok(files.length > 0, 'expected at least one workflow .md file');
const missing = [];
for (const f of files) {
const content = fs.readFileSync(f, 'utf8');
const blocks = extractShellBlocks(content);
const allBlockLines = blocks.flatMap((b) => b.lines);
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
if (!fileHasGsdRun) continue;
const allContent = allBlockLines.join('\n');
if (!allContent.includes(HERMES_PROBE)) {
missing.push(path.relative(WORKFLOWS_DIR, f));
}
}
assert.deepStrictEqual(
missing,
[],
`These workflow files use gsd_run but are missing the hermes runtime home probe ("${HERMES_PROBE}"). ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate:\n` +
missing.join('\n'),
);
});
});

View File

@@ -0,0 +1,290 @@
/**
* Regression test for #892: checklist-style roadmap phases (`- [x] **Phase NN: name**`)
* were silently skipped by `buildRoadmapPhaseVariants()`, causing W007 false positives
* on every on-disk phase dir when the project uses the checklist ROADMAP format.
*
* Covers:
* A. Unit-level: `buildRoadmapPhaseVariants()` in validate.cts recognises both
* checked (`- [x]`) and unchecked (`- [ ]`) checklist items.
* B. Integration: `validate health` emits NO W007 for a checklist-only roadmap
* whose phase dirs all appear in the checklist.
* C. Integration: `validate consistency` emits NO "exists on disk but not in
* ROADMAP.md" warning for the same checklist-only roadmap.
*
* Requirements: BUG-892
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const {
buildRoadmapPhaseVariants,
} = require('../gsd-core/bin/lib/validate.cjs');
// ─── A: Unit-level ────────────────────────────────────────────────────────────
describe('buildRoadmapPhaseVariants — checklist format support (#892)', () => {
test('matches checked checklist item `- [x] **Phase 01: name**`', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('01') || roadmapPhases.has('1'),
`roadmapPhases should contain a variant of "01", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('matches checked checklist item with uppercase X `- [X] **Phase 02: foo**`', () => {
const content = [
'# Roadmap',
'',
'- [X] **Phase 02: feature-work**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('02') || roadmapPhases.has('2'),
`roadmapPhases should contain a variant of "02", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('matches unchecked checklist item `- [ ] **Phase 03: name**`', () => {
// unchecked items are also phases — they just have not been started
const content = [
'# Roadmap',
'',
'- [ ] **Phase 03: future-work**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('03') || roadmapPhases.has('3'),
`roadmapPhases should contain a variant of "03", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('collects all phases from a pure checklist roadmap (no ## headings)', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: alpha**',
'- [x] **Phase 02: beta**',
'- [ ] **Phase 03: gamma**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
const has = (id) => roadmapPhases.has(id) || roadmapPhases.has(id.replace(/^0+/, '')) || roadmapPhases.has(String(parseInt(id, 10)).padStart(2, '0'));
assert.ok(has('01'), 'should contain phase 01');
assert.ok(has('02'), 'should contain phase 02');
assert.ok(has('03'), 'should contain phase 03');
});
test('populates roadmapPhaseVariants with padding-normalised forms for checklist phases', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: something**',
].join('\n');
const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content);
// phaseVariants() adds both '1' and '01' forms
assert.ok(
roadmapPhaseVariants.has('1') || roadmapPhaseVariants.has('01'),
`roadmapPhaseVariants should contain at least one padding form, got: ${JSON.stringify([...roadmapPhaseVariants])}`
);
});
test('mixed roadmap (headings + checklist) collects phases from both styles', () => {
const content = [
'# Roadmap',
'',
'## Phase 1: heading-style',
'',
'- [x] **Phase 02: checklist-style**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('1') || roadmapPhases.has('01'),
'should contain heading-style phase 1'
);
assert.ok(
roadmapPhases.has('02') || roadmapPhases.has('2'),
'should contain checklist-style phase 02'
);
});
});
// ─── B: validate health — no W007 for checklist-only roadmaps ────────────────
describe('validate health — checklist-style roadmap phases must not emit W007 (#892)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no W007 when phase dirs match checked checklist entries in ROADMAP.md', () => {
// Write PROJECT.md, STATE.md, config.json, and a checklist-only ROADMAP.md
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// Checklist-only ROADMAP: no ## Phase headings, only checklist items
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
'- [x] **Phase 02: feature-work**',
'',
].join('\n')
);
// Create matching phase directories on disk
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w007s = output.warnings.filter((w) => w.code === 'W007');
assert.strictEqual(
w007s.length,
0,
`W007 must not fire for phases whose dirs are listed in a checklist-style ROADMAP.md, got: ${JSON.stringify(w007s)}`
);
});
test('W007 still fires when a phase dir is genuinely absent from a checklist roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// ROADMAP only lists phase 01, not phase 99
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: known-phase**',
'',
].join('\n')
);
// Phase 01 dir is present but also an orphan phase 99 that is NOT in ROADMAP
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-known-phase'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w007s = output.warnings.filter((w) => w.code === 'W007');
assert.ok(
w007s.length > 0,
`W007 must still fire for a phase dir genuinely not listed in ROADMAP.md, got warnings: ${JSON.stringify(output.warnings)}`
);
// Should only flag phase 99, not phase 01
assert.ok(
w007s.some((w) => w.message.includes('99')),
`W007 should reference orphan phase 99, got: ${JSON.stringify(w007s)}`
);
assert.ok(
!w007s.some((w) => w.message.includes('01') || w.message.includes('1')),
`W007 must NOT flag phase 01 which is in the checklist, got: ${JSON.stringify(w007s)}`
);
});
});
// ─── C: validate consistency — no false positive for checklist-only roadmaps ─
describe('validate consistency — checklist-style roadmap phases must not emit false warnings (#892)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no "exists on disk but not in ROADMAP.md" warning for checklist-matched phases', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// Checklist-only ROADMAP
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
'- [x] **Phase 02: feature-work**',
'',
].join('\n')
);
// Create matching phase directories
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// No "exists on disk but not in ROADMAP" warnings for checklist-listed phases
const diskNotInRoadmapWarnings = (output.warnings || []).filter(
(w) => typeof w === 'string'
? w.includes('exists on disk but not in ROADMAP')
: (w.message || '').includes('exists on disk but not in ROADMAP')
);
assert.strictEqual(
diskNotInRoadmapWarnings.length,
0,
`No "exists on disk but not in ROADMAP.md" warnings should fire for checklist-listed phases, got: ${JSON.stringify(diskNotInRoadmapWarnings)}`
);
});
});

View File

@@ -0,0 +1,321 @@
'use strict';
/**
* Regression guard for bug #905.
*
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
* frontmatter when the body-derived value was missing/unknown. The scalars
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
* silently stripped whenever `buildStateFrontmatter` could not extract them from
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
* annotations.
*
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
* that all four scalars survive a `writeStateMd` / `state sync` call when the
* body no longer carries the annotation but the existing frontmatter does.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
* annotations that `buildStateFrontmatter` uses to re-derive them.
*
* This is the exact scenario that triggered the bug: the body has already lost
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
* frontmatter still holds the ground-truth values. A subsequent `state sync`
* (or any `writeStateMd` call) must not strip them.
*/
function buildStateMdWithoutBodyAnnotations(opts) {
const {
currentPhase = 3,
currentPhaseName = 'Implementation',
currentPlan = 2,
progressPercent = 42,
} = opts || {};
return [
'---',
'gsd_state_version: 1.0',
`current_phase: ${currentPhase}`,
`current_phase_name: ${currentPhaseName}`,
`current_plan: ${currentPlan}`,
'status: executing',
'progress:',
` total_phases: 5`,
` completed_phases: 2`,
` total_plans: 10`,
` completed_plans: 4`,
` percent: ${progressPercent}`,
'---',
'',
'# GSD State',
'',
'## Configuration',
// Intentionally omitting "Current Phase:", "Current Phase Name:",
// "Current Plan:" body annotations to reproduce the bug scenario.
'Status: Executing',
'Last Activity: 2026-01-01',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- Use Node 22',
'',
].join('\n');
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'3',
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase_name,
'Implementation',
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
);
});
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_plan,
'2',
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
);
});
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
// That path runs syncStateFrontmatter and then explicitly re-applies the
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
// progress values must survive even though the phases dir is empty.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
// Add body annotation for Last Activity so state update can find and replace it
const initial = fs.readFileSync(statePath, 'utf8');
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
// → curated block survives via cmdStateJson read-path fallback.
assert.strictEqual(
fm.progress.completed_plans,
4,
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
);
});
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
// with resync:true), confirming the fix covers every write path.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'7',
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('body annotation beats existing frontmatter when both are present', () => {
// When the body DOES carry the annotation, the derived value wins — we must
// not accidentally lock stale frontmatter in place.
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
// which rebuilds from the body and would return body-derived values regardless
// of what syncStateFrontmatter wrote to disk).
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Frontmatter says phase 3; body says phase 5. Body should win.
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 3',
'current_phase_name: Old Phase',
'current_plan: 1',
'status: executing',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 5',
'Current Phase Name: New Phase',
'Current Plan: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
// Assert on raw file: body-derived values must be written to frontmatter,
// not the stale existing values. This guards against a fallback that locks
// in stale data even when buildStateFrontmatter successfully derived values.
const writtenContent = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(writtenContent);
assert.strictEqual(
rawFm.current_phase,
'5',
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
);
assert.strictEqual(
rawFm.current_phase_name,
'New Phase',
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
);
assert.strictEqual(
rawFm.current_plan,
'2',
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
);
});
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
// Without a phases dir, buildStateFrontmatter returns no progress block at all
// (the existsSync guard at line ~927 short-circuits the disk scan). The
// existing frontmatter's progress must then survive the writeStateMd call.
// Use createTempDir (no phases dir) and set up .planning/ manually.
const dir = createTempDir('gsd-905-nophasesdir-');
try {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const statePath = path.join(dir, '.planning', 'STATE.md');
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 2',
'status: executing',
'progress:',
' total_phases: 4',
' completed_phases: 1',
' total_plans: 8',
' completed_plans: 3',
' percent: 38',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
dir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
// Assert on the raw frontmatter file — cmdStateJson would apply
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
// The progress block must be present in the written frontmatter.
// parseFrontmatter returns flat keys, so check the presence indicator.
assert.ok(
written.includes('progress:'),
'progress block must be preserved in raw frontmatter when disk has no phases dir',
);
// percent: 38 should survive (no disk scan to overwrite it)
assert.ok(
written.includes('percent: 38'),
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
);
} finally {
cleanup(dir);
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -538,6 +538,35 @@ describe('init plan-phase zero-padded phase number (bug #2391)', () => {
assert.strictEqual(out03.phase_req_ids, out3.phase_req_ids,
'phase_req_ids must be identical regardless of padding');
});
// ── #904: branch_name must use normalized (stripped + zero-padded) phase number ──
// When project_code is set (e.g. "CK") the phase directory is prefixed:
// "CK-01-foundation". extractPhaseToken returns "CK-01" as phase_number.
// branch_name must call normalizePhaseName so it strips the prefix and zero-pads,
// producing "gsd/phase-01-foundation" rather than "gsd/phase-CK-01-foundation".
test('branch_name uses normalized phase number when project_code prefixes phase dir (#904)', () => {
seedPhase(tmpDir, 'CK-01-foundation', {
'CK-01-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
project_code: 'CK',
git: {
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
},
}, null, 2)
);
const result = runGsdTools('init execute-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// branch_name must use the normalized phase number, not the raw "CK-01" token
assert.strictEqual(output.branch_name, 'gsd/phase-01-foundation',
'branch_name must use normalized phase number (strip project_code prefix, zero-pad), not raw phase_number');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,702 @@
'use strict';
/**
* loop-host-contract.test.cjs — behavioral tests for gen-loop-host-contract.cjs.
*
* ADR-894 phase 3a-impl-2.
* Uses node:test + node:assert/strict.
* NO source-grep: tests use in-memory fixtures and real workflow files.
*/
const { describe, test } = 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');
const {
parseLoopHostBlock,
crossCheckRoles,
assertPointsCoverage,
buildContract,
serializeContract,
normalizeLineEndings,
STEP_WORKFLOWS,
CANONICAL_POINTS,
EXPECTED_POINTS_BY_STEP,
ROLE_TO_AGENT,
} = require('../scripts/gen-loop-host-contract.cjs');
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
const ROOT = path.resolve(__dirname, '..');
const CONTRACT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'loop-host-contract.cjs');
// ─── Helper: write a temporary workflows directory ────────────────────────────
function makeTempWorkflowsDir(files) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lhc-test-'));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(tmpDir, name), content, 'utf8');
}
return tmpDir;
}
// ─── Minimal valid workflow content templates ─────────────────────────────────
function makeWorkflow(step, points, roles, produces, consumes, extraContent) {
const pointsList = points.join(', ');
const rolesList = roles.join(', ');
const producesList = produces.join(', ');
const consumesList = consumes.join(', ');
return (
'<!-- gsd:loop-host\n' +
'step: ' + step + '\n' +
'points: ' + pointsList + '\n' +
'agent-roles: ' + rolesList + '\n' +
'produces: ' + producesList + '\n' +
'consumes: ' + consumesList + '\n' +
'-->\n' +
(extraContent || '')
);
}
// Minimal valid set of 5 workflows matching the canonical contract
function makeValidWorkflowFiles() {
return {
'discuss-phase.md': makeWorkflow('discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], []),
'plan-phase.md': makeWorkflow(
'plan', ['plan:pre', 'plan:post'],
['researcher', 'planner', 'checker'],
['PLAN.md'], ['CONTEXT.md'],
// Cross-check content: must contain the agent names
'gsd-phase-researcher gsd-planner gsd-plan-checker',
),
'execute-phase.md': makeWorkflow(
'execute', ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'],
['executor', 'verifier'],
['SUMMARY.md'], ['PLAN.md'],
'gsd-executor gsd-verifier',
),
'verify-work.md': makeWorkflow('verify', ['verify:pre', 'verify:post'], ['orchestrator'], ['UAT.md'], ['SUMMARY.md']),
'ship.md': makeWorkflow('ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md']),
};
}
// ─── 1. parseLoopHostBlock ────────────────────────────────────────────────────
describe('parseLoopHostBlock', () => {
test('parses a valid block correctly', () => {
const content = makeWorkflow(
'plan', ['plan:pre', 'plan:post'],
['researcher', 'planner', 'checker'],
['PLAN.md'], ['CONTEXT.md'],
);
const result = parseLoopHostBlock(content, 'plan-phase.md');
assert.strictEqual(result.step, 'plan');
assert.deepEqual(result.points, ['plan:pre', 'plan:post']);
assert.deepEqual(result.agentRoles, ['researcher', 'planner', 'checker']);
assert.deepEqual(result.coreArtifacts.produces, ['PLAN.md']);
assert.deepEqual(result.coreArtifacts.consumes, ['CONTEXT.md']);
});
test('parses empty produces field as empty array', () => {
const content = makeWorkflow(
'ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md'],
);
const result = parseLoopHostBlock(content, 'ship.md');
assert.deepEqual(result.coreArtifacts.produces, []);
assert.deepEqual(result.coreArtifacts.consumes, ['UAT.md']);
});
test('parses empty consumes field as empty array', () => {
const content = makeWorkflow(
'discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], [],
);
const result = parseLoopHostBlock(content, 'discuss-phase.md');
assert.deepEqual(result.coreArtifacts.consumes, []);
assert.deepEqual(result.coreArtifacts.produces, ['CONTEXT.md']);
});
test('throws when block is missing', () => {
assert.throws(
() => parseLoopHostBlock('no block here\n<purpose>hello</purpose>', 'test.md'),
/missing.*gsd:loop-host/,
);
});
test('throws when step field is missing from block', () => {
const content =
'<!-- gsd:loop-host\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: orchestrator\n' +
'produces:\n' +
'consumes:\n' +
'-->\n';
assert.throws(
() => parseLoopHostBlock(content, 'test.md'),
/missing required field "step"/,
);
});
test('throws when points field is empty', () => {
const content =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points:\n' +
'agent-roles: orchestrator\n' +
'produces:\n' +
'consumes:\n' +
'-->\n';
assert.throws(
() => parseLoopHostBlock(content, 'test.md'),
/"points" must have at least one value/,
);
});
test('parses multi-value fields with spaces correctly', () => {
const content = makeWorkflow(
'execute',
['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'],
['executor', 'verifier'],
['SUMMARY.md'], ['PLAN.md'],
);
const result = parseLoopHostBlock(content, 'execute-phase.md');
assert.deepEqual(result.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']);
assert.deepEqual(result.agentRoles, ['executor', 'verifier']);
});
});
// ─── 2. crossCheckRoles ───────────────────────────────────────────────────────
describe('crossCheckRoles', () => {
test('passes for orchestrator-only roles (no agent file needed)', () => {
const errors = crossCheckRoles('anything', ['orchestrator'], 'discuss-phase.md');
assert.deepEqual(errors, []);
});
test('passes when agent name is present in content', () => {
const content = 'Agent(subagent_type="gsd-planner") Agent(subagent_type="gsd-phase-researcher") gsd-plan-checker';
const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md');
assert.deepEqual(errors, []);
});
test('fails when declared role has no agent reference in content', () => {
const content = 'gsd-phase-researcher gsd-plan-checker'; // planner missing
const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md');
assert.strictEqual(errors.length, 1, 'expected exactly 1 error for missing planner');
assert.match(errors[0], /planner.*gsd-planner/);
});
test('fails when declared role is unknown (not in ROLE_TO_AGENT)', () => {
const content = 'gsd-executor gsd-verifier';
const errors = crossCheckRoles(content, ['executor', 'nonexistent-role'], 'execute-phase.md');
assert.ok(errors.some((e) => e.includes('nonexistent-role') && e.includes('ROLE_TO_AGENT')));
});
});
// ─── 3. assertPointsCoverage ─────────────────────────────────────────────────
describe('assertPointsCoverage', () => {
test('passes when all 12 canonical points are covered', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre', 'discuss:post'] },
{ step: 'plan', points: ['plan:pre', 'plan:post'] },
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.deepEqual(errors, []);
});
test('fails when a canonical point is missing', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post
{ step: 'plan', points: ['plan:pre', 'plan:post'] },
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.some((e) => e.includes('discuss:post') && e.includes('not declared')));
});
test('fails when an unknown point is declared', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre', 'discuss:post', 'discuss:extra'] },
{ step: 'plan', points: ['plan:pre', 'plan:post'] },
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.some((e) => e.includes('discuss:extra') && e.includes('not in the canonical')));
});
test('fails when a point is declared twice', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre', 'discuss:post'] },
{ step: 'plan', points: ['plan:pre', 'plan:post', 'discuss:pre'] }, // duplicate
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.some((e) => e.includes('discuss:pre') && e.includes('more than once')));
});
});
// ─── 4. buildContract — from real workflows ───────────────────────────────────
describe('buildContract from real workflows', () => {
test('produces a contract matching the inline LOOP_HOST_CONTRACT shape', () => {
const contract = buildContract(); // reads real gsd-core/workflows/
// Must be an array of 5 entries
assert.strictEqual(contract.length, 5, 'contract must have 5 step entries');
// Each entry must have step, points, agentRoles, coreArtifacts
for (const entry of contract) {
assert.ok(typeof entry.step === 'string', 'entry.step must be a string');
assert.ok(Array.isArray(entry.points), 'entry.points must be an array');
assert.ok(Array.isArray(entry.agentRoles), 'entry.agentRoles must be an array');
assert.ok(typeof entry.coreArtifacts === 'object', 'entry.coreArtifacts must be an object');
assert.ok(Array.isArray(entry.coreArtifacts.produces), 'entry.coreArtifacts.produces must be an array');
assert.ok(Array.isArray(entry.coreArtifacts.consumes), 'entry.coreArtifacts.consumes must be an array');
}
// Verify exact match with the committed loop-host-contract.cjs
assert.deepEqual(contract, LOOP_HOST_CONTRACT, 'built contract must match committed LOOP_HOST_CONTRACT');
});
test('covers exactly the 12 canonical points', () => {
const contract = buildContract();
const allPoints = contract.flatMap((e) => e.points);
assert.strictEqual(allPoints.length, 12, 'must cover exactly 12 points');
const pointSet = new Set(allPoints);
assert.strictEqual(pointSet.size, 12, 'all 12 points must be distinct');
for (const p of CANONICAL_POINTS) {
assert.ok(pointSet.has(p), 'canonical point "' + p + '" must be declared');
}
});
test('discuss step has orchestrator role and produces CONTEXT.md', () => {
const contract = buildContract();
const discuss = contract.find((e) => e.step === 'discuss');
assert.ok(discuss, 'discuss step must be present');
assert.deepEqual(discuss.agentRoles, ['orchestrator']);
assert.deepEqual(discuss.coreArtifacts.produces, ['CONTEXT.md']);
assert.deepEqual(discuss.coreArtifacts.consumes, []);
});
test('plan step has researcher/planner/checker roles and produces PLAN.md', () => {
const contract = buildContract();
const plan = contract.find((e) => e.step === 'plan');
assert.ok(plan, 'plan step must be present');
assert.deepEqual(plan.agentRoles, ['researcher', 'planner', 'checker']);
assert.deepEqual(plan.coreArtifacts.produces, ['PLAN.md']);
assert.deepEqual(plan.coreArtifacts.consumes, ['CONTEXT.md']);
});
test('execute step has executor/verifier roles and 4 points', () => {
const contract = buildContract();
const execute = contract.find((e) => e.step === 'execute');
assert.ok(execute, 'execute step must be present');
assert.deepEqual(execute.agentRoles, ['executor', 'verifier']);
assert.deepEqual(execute.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']);
assert.deepEqual(execute.coreArtifacts.produces, ['SUMMARY.md']);
assert.deepEqual(execute.coreArtifacts.consumes, ['PLAN.md']);
});
test('verify step has orchestrator role and produces UAT.md', () => {
const contract = buildContract();
const verify = contract.find((e) => e.step === 'verify');
assert.ok(verify, 'verify step must be present');
assert.deepEqual(verify.agentRoles, ['orchestrator']);
assert.deepEqual(verify.coreArtifacts.produces, ['UAT.md']);
assert.deepEqual(verify.coreArtifacts.consumes, ['SUMMARY.md']);
});
test('ship step has orchestrator role and empty produces', () => {
const contract = buildContract();
const ship = contract.find((e) => e.step === 'ship');
assert.ok(ship, 'ship step must be present');
assert.deepEqual(ship.agentRoles, ['orchestrator']);
assert.deepEqual(ship.coreArtifacts.produces, []);
assert.deepEqual(ship.coreArtifacts.consumes, ['UAT.md']);
});
});
// ─── 5. cross-check rejects nonexistent agent-role ───────────────────────────
describe('buildContract cross-check drift guard', () => {
test('rejects a block declaring a nonexistent agent-role', () => {
// Build a temporary workflows dir where plan-phase.md declares a role
// that has no corresponding agent reference in the file content.
const files = makeValidWorkflowFiles();
// Override plan-phase.md to declare a "phantom" role with no agent reference
files['plan-phase.md'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker, phantom\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\n' +
// Include real agents but NOT the phantom role's agent (phantom is not in ROLE_TO_AGENT)
'gsd-phase-researcher gsd-planner gsd-plan-checker\n';
const tmpDir = makeTempWorkflowsDir(files);
try {
assert.throws(
() => buildContract(tmpDir),
/ROLE_TO_AGENT|no entry/,
);
} finally {
cleanup(tmpDir);
}
});
test('rejects a block declaring a role whose agent is absent from the workflow', () => {
// plan-phase.md declares 'researcher' but does NOT mention gsd-phase-researcher
const files = makeValidWorkflowFiles();
files['plan-phase.md'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\n' +
// Only planner and checker present, researcher's agent is absent
'gsd-planner gsd-plan-checker\n';
const tmpDir = makeTempWorkflowsDir(files);
try {
assert.throws(
() => buildContract(tmpDir),
/gsd-phase-researcher.*not referenced|researcher.*gsd-phase-researcher/,
);
} finally {
cleanup(tmpDir);
}
});
});
// ─── 6. --check: CRLF-agnostic + committed-file staleness guard ──────────────
describe('normalizeLineEndings and committed-file staleness', () => {
test('normalizeLineEndings strips CR characters', () => {
const crlf = 'line1\r\nline2\r\nline3';
const lf = 'line1\nline2\nline3';
assert.strictEqual(normalizeLineEndings(crlf), lf);
assert.strictEqual(normalizeLineEndings(lf), lf);
});
test('committed loop-host-contract.cjs is up to date (--check passes)', () => {
// Build the live contract from the real workflows
const contract = buildContract();
const live = serializeContract(contract);
// Read the committed file
const committed = fs.readFileSync(CONTRACT_PATH, 'utf8');
// FIX 4: Full-content comparison — no generated-by-line stripping needed
// because the serializer has no nondeterministic content (no timestamp).
assert.strictEqual(
normalizeLineEndings(committed),
normalizeLineEndings(live),
'committed loop-host-contract.cjs is stale — run: node scripts/gen-loop-host-contract.cjs --write',
);
});
});
// ─── 7. STEP_WORKFLOWS and CANONICAL_POINTS exported constants ────────────────
describe('module exports', () => {
test('STEP_WORKFLOWS has 5 entries in pipeline order', () => {
assert.strictEqual(STEP_WORKFLOWS.length, 5);
assert.strictEqual(STEP_WORKFLOWS[0].step, 'discuss');
assert.strictEqual(STEP_WORKFLOWS[1].step, 'plan');
assert.strictEqual(STEP_WORKFLOWS[2].step, 'execute');
assert.strictEqual(STEP_WORKFLOWS[3].step, 'verify');
assert.strictEqual(STEP_WORKFLOWS[4].step, 'ship');
});
test('CANONICAL_POINTS has exactly 12 entries', () => {
assert.strictEqual(CANONICAL_POINTS.length, 12);
});
test('ROLE_TO_AGENT covers all non-orchestrator roles', () => {
// All non-orchestrator roles from the real contract
const allRoles = new Set(
LOOP_HOST_CONTRACT.flatMap((e) => e.agentRoles).filter((r) => r !== 'orchestrator'),
);
for (const role of allRoles) {
assert.ok(
ROLE_TO_AGENT[role] !== undefined,
'ROLE_TO_AGENT must cover non-orchestrator role "' + role + '"',
);
}
});
test('EXPECTED_POINTS_BY_STEP covers all 5 steps', () => {
assert.ok(EXPECTED_POINTS_BY_STEP, 'EXPECTED_POINTS_BY_STEP must be exported');
assert.strictEqual(Object.keys(EXPECTED_POINTS_BY_STEP).length, 5);
assert.ok(Array.isArray(EXPECTED_POINTS_BY_STEP.execute));
assert.strictEqual(EXPECTED_POINTS_BY_STEP.execute.length, 4);
});
});
// ─── 8. Regression: FIX 1 — per-step point ownership ────────────────────────
describe('assertPointsCoverage per-step ownership (FIX 1)', () => {
test('fails when two steps swap a point (discuss declares plan:pre, plan declares discuss:pre)', () => {
const entries = [
{ step: 'discuss', points: ['discuss:post', 'plan:pre'] }, // wrong: has plan:pre instead of discuss:pre
{ step: 'plan', points: ['discuss:pre', 'plan:post'] }, // wrong: has discuss:pre instead of plan:pre
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.length > 0, 'expected per-step ownership errors');
const combined = errors.join('\n');
// Both steps should be named in the errors
assert.ok(combined.includes('discuss'), 'error must mention discuss step');
assert.ok(combined.includes('plan'), 'error must mention plan step');
});
test('fails when a step is missing one of its own points', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post
{ step: 'plan', points: ['plan:pre', 'plan:post'] },
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.length > 0, 'expected ownership error for missing point');
assert.ok(
errors.some((e) => e.includes('discuss') && e.includes('expected')),
'error must name the step and expected points',
);
});
test('fails when a step has an extra point beyond its own', () => {
const entries = [
{ step: 'discuss', points: ['discuss:pre', 'discuss:post', 'plan:pre'] }, // extra: plan:pre
{ step: 'plan', points: ['plan:post'] }, // missing: plan:pre
{ step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] },
{ step: 'verify', points: ['verify:pre', 'verify:post'] },
{ step: 'ship', points: ['ship:pre', 'ship:post'] },
];
const errors = assertPointsCoverage(entries);
assert.ok(errors.length > 0, 'expected ownership errors for extra and missing points');
});
test('buildContract with swapped points across steps throws a per-step ownership error', () => {
const files = makeValidWorkflowFiles();
// discuss declares plan:pre instead of discuss:pre, plan declares discuss:pre instead of plan:pre
files['discuss-phase.md'] = makeWorkflow('discuss', ['discuss:post', 'plan:pre'], ['orchestrator'], ['CONTEXT.md'], []);
files['plan-phase.md'] = makeWorkflow(
'plan', ['discuss:pre', 'plan:post'],
['researcher', 'planner', 'checker'],
['PLAN.md'], ['CONTEXT.md'],
'gsd-phase-researcher gsd-planner gsd-plan-checker',
);
const tmpDir = makeTempWorkflowsDir(files);
let cleaned = false;
try {
assert.throws(
() => buildContract(tmpDir),
/step.*discuss.*expected|step.*plan.*expected/,
);
} finally {
if (!cleaned) {
cleanup(tmpDir);
cleaned = true;
}
}
});
});
// ─── 9. Regression: FIX 2 — multiple blocks + duplicate keys ─────────────────
describe('parseLoopHostBlock multiple-block and duplicate-key detection (FIX 2)', () => {
test('throws when a file has two gsd:loop-host marker blocks', () => {
const block =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\n';
const content = block + '\nSome prose.\n\n' + block;
assert.throws(
() => parseLoopHostBlock(content, 'discuss-phase.md'),
/expected exactly one gsd:loop-host marker block, found 2/,
);
});
test('throws when a block has a duplicate "points" key', () => {
const content =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'points: discuss:pre\n' + // duplicate
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\n';
assert.throws(
() => parseLoopHostBlock(content, 'discuss-phase.md'),
/duplicate key 'points' in gsd:loop-host marker/,
);
});
test('throws when a block has a duplicate "step" key', () => {
const content =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'step: plan\n' + // duplicate
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\n';
assert.throws(
() => parseLoopHostBlock(content, 'discuss-phase.md'),
/duplicate key 'step' in gsd:loop-host marker/,
);
});
test('buildContract with a two-block file throws with "found 2" error', () => {
const files = makeValidWorkflowFiles();
const singleBlock =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\n';
files['discuss-phase.md'] = singleBlock + '\nDoc example:\n\n' + singleBlock;
const tmpDir = makeTempWorkflowsDir(files);
try {
assert.throws(
() => buildContract(tmpDir),
/found 2/,
);
} finally {
cleanup(tmpDir);
}
});
});
// ─── 10. Regression: FIX 3 — word-boundary agent cross-check ─────────────────
describe('crossCheckRoles word-boundary match (FIX 3)', () => {
test('gsd-plan-checker-v2 does NOT satisfy required gsd-plan-checker reference', () => {
// Content has gsd-plan-checker-v2 but NOT bare gsd-plan-checker
const content = 'Agent("gsd-phase-researcher") Agent("gsd-planner") gsd-plan-checker-v2';
const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md');
assert.strictEqual(errors.length, 1, 'expected exactly 1 error for checker missing bare reference');
assert.match(errors[0], /gsd-plan-checker/);
});
test('gsd-plan-checker (bare) still satisfies the checker role', () => {
const content = 'Agent("gsd-phase-researcher") Agent("gsd-planner") gsd-plan-checker something-else';
const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md');
assert.deepEqual(errors, []);
});
test('gsd-plan-checker immediately followed by newline satisfies the checker role', () => {
const content = 'gsd-phase-researcher\ngsd-planner\ngsd-plan-checker\n';
const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md');
assert.deepEqual(errors, []);
});
test('buildContract rejects workflow referencing only -v2 agent variant', () => {
const files = makeValidWorkflowFiles();
// plan-phase.md refers to gsd-plan-checker-v2 but not gsd-plan-checker
files['plan-phase.md'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\n' +
'gsd-phase-researcher gsd-planner gsd-plan-checker-v2\n';
const tmpDir = makeTempWorkflowsDir(files);
try {
assert.throws(
() => buildContract(tmpDir),
/gsd-plan-checker.*not referenced|checker.*gsd-plan-checker/,
);
} finally {
cleanup(tmpDir);
}
});
});
// ─── 11. Regression: FIX 4 — --check detects header/body tampering ───────────
describe('--check full-content comparison (FIX 4)', () => {
test('committed file with tampered header is detected as stale', () => {
const contract = buildContract();
const live = serializeContract(contract);
// Tamper: replace the DO-NOT-EDIT line with something else
const tampered = live.replace(
' * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write',
' * TAMPERED HEADER LINE',
);
assert.notStrictEqual(
normalizeLineEndings(tampered),
normalizeLineEndings(live),
'tampered content must differ from live content (staleness detected)',
);
});
test('committed file with tampered body JSON is detected as stale', () => {
const contract = buildContract();
const live = serializeContract(contract);
// Tamper: add a phantom step name
const tampered = live.replace('"step": "discuss"', '"step": "discuss-tampered"');
assert.notStrictEqual(
normalizeLineEndings(tampered),
normalizeLineEndings(live),
'tampered body must differ from live content (staleness detected)',
);
});
test('un-tampered committed file passes full-content comparison', () => {
const contract = buildContract();
const live = serializeContract(contract);
const committed = fs.readFileSync(CONTRACT_PATH, 'utf8');
assert.strictEqual(
normalizeLineEndings(committed),
normalizeLineEndings(live),
'committed file must match live serialization exactly (full-content comparison)',
);
});
});
// ─── 12. Regression: FIX 5 — temp-dir cleanup in existing cross-check tests ──
// (cleanup is handled via try/finally in each test above that creates temp dirs;
// this suite documents and verifies the makeTempWorkflowsDir helper itself)
describe('temp-dir lifecycle', () => {
test('makeTempWorkflowsDir creates a directory that can be cleaned up', () => {
const files = { 'dummy.md': '<!-- gsd:loop-host\nstep: discuss\n-->' };
const tmpDir = makeTempWorkflowsDir(files);
assert.ok(fs.existsSync(tmpDir), 'temp dir must exist after creation');
cleanup(tmpDir);
assert.ok(!fs.existsSync(tmpDir), 'temp dir must not exist after cleanup');
});
});

View File

@@ -0,0 +1,683 @@
'use strict';
/**
* Tests for model-resolver.cjs (ADR-857 phase 2f / #888).
*
* Covers:
* - resolveModelInternal: model resolution across tiers + profile overrides
* - resolveGranularityInternal + assertValidGranularityOverride
* - resolveEffortInternal / resolveFastModeInternal
* - resolveEffortForTier / nextEffort
* - resolveModelForTier (dynamic routing)
* - resolveModelPolicy (#49 provider-neutral presets)
* - resolveTierEntry (#2517 runtime-aware tier resolution)
* - shim identity: core.X === modelResolver.X for all 13 public symbols
* - ADVERSARIAL: unknown agent types, invalid granularity/effort overrides,
* runtime override edge cases
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
// ─── modules under test ───────────────────────────────────────────────────────
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
const coreModule = require('../gsd-core/bin/lib/core.cjs');
const {
resolveTierEntry,
resolveModelPolicy,
resolveModelInternal,
VALID_GRANULARITIES,
resolveGranularityInternal,
assertValidGranularityOverride,
resolveModelForTier,
VALID_EFFORTS,
EFFORT_SET,
nextEffort,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
} = modelResolver;
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeTempProject(prefix = 'gsd-model-resolver-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function writeConfig(tmpDir, obj) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
// ─── shim identity ────────────────────────────────────────────────────────────
describe('model-resolver shim identity', () => {
test('core.resolveTierEntry === modelResolver.resolveTierEntry', () => {
assert.strictEqual(coreModule.resolveTierEntry, modelResolver.resolveTierEntry);
});
test('core.resolveModelPolicy === modelResolver.resolveModelPolicy', () => {
assert.strictEqual(coreModule.resolveModelPolicy, modelResolver.resolveModelPolicy);
});
test('core.resolveModelInternal === modelResolver.resolveModelInternal', () => {
assert.strictEqual(coreModule.resolveModelInternal, modelResolver.resolveModelInternal);
});
test('core.VALID_GRANULARITIES === modelResolver.VALID_GRANULARITIES (same Set object)', () => {
assert.strictEqual(coreModule.VALID_GRANULARITIES, modelResolver.VALID_GRANULARITIES);
});
test('core.resolveGranularityInternal === modelResolver.resolveGranularityInternal', () => {
assert.strictEqual(coreModule.resolveGranularityInternal, modelResolver.resolveGranularityInternal);
});
test('core.assertValidGranularityOverride === modelResolver.assertValidGranularityOverride', () => {
assert.strictEqual(coreModule.assertValidGranularityOverride, modelResolver.assertValidGranularityOverride);
});
test('core.resolveModelForTier === modelResolver.resolveModelForTier', () => {
assert.strictEqual(coreModule.resolveModelForTier, modelResolver.resolveModelForTier);
});
test('core.VALID_EFFORTS === modelResolver.VALID_EFFORTS (same Array object)', () => {
assert.strictEqual(coreModule.VALID_EFFORTS, modelResolver.VALID_EFFORTS);
});
test('core.EFFORT_SET === modelResolver.EFFORT_SET (same Set object)', () => {
assert.strictEqual(coreModule.EFFORT_SET, modelResolver.EFFORT_SET);
});
test('core.nextEffort === modelResolver.nextEffort', () => {
assert.strictEqual(coreModule.nextEffort, modelResolver.nextEffort);
});
test('core.resolveEffortInternal === modelResolver.resolveEffortInternal', () => {
assert.strictEqual(coreModule.resolveEffortInternal, modelResolver.resolveEffortInternal);
});
test('core.resolveFastModeInternal === modelResolver.resolveFastModeInternal', () => {
assert.strictEqual(coreModule.resolveFastModeInternal, modelResolver.resolveFastModeInternal);
});
test('core.resolveEffortForTier === modelResolver.resolveEffortForTier', () => {
assert.strictEqual(coreModule.resolveEffortForTier, modelResolver.resolveEffortForTier);
});
});
// ─── resolveModelInternal ─────────────────────────────────────────────────────
describe('resolveModelInternal', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('no config -> balanced profile -> gsd-planner resolves to a string', () => {
const model = resolveModelInternal(tmpDir, 'gsd-planner');
assert.ok(typeof model === 'string' && model.length > 0, `Expected non-empty string, got: ${JSON.stringify(model)}`);
});
test('model_overrides takes precedence over everything else', () => {
writeConfig(tmpDir, { model_overrides: { 'gsd-planner': 'my-custom-model' } });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'my-custom-model');
});
test('model_profile=quality -> opus-class model for gsd-planner', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
const model = resolveModelInternal(tmpDir, 'gsd-planner');
// quality profile must resolve to a non-empty model string
assert.ok(typeof model === 'string' && model.length > 0);
});
test('model_profile=budget -> haiku-class model for gsd-planner', () => {
writeConfig(tmpDir, { model_profile: 'budget' });
const model = resolveModelInternal(tmpDir, 'gsd-planner');
assert.ok(typeof model === 'string' && model.length > 0);
});
test('resolve_model_ids=omit -> returns empty string', () => {
writeConfig(tmpDir, { resolve_model_ids: 'omit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
});
test('unknown agent type, no config -> returns a non-empty string (fallback)', () => {
const model = resolveModelInternal(tmpDir, 'completely-unknown-agent-xyz');
assert.ok(typeof model === 'string' && model.length > 0);
});
test('model_profile=inherit -> returns "inherit"', () => {
writeConfig(tmpDir, { model_profile: 'inherit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
});
test('models config per phase type overrides profile tier', () => {
writeConfig(tmpDir, { models: { planning: 'opus' } });
// gsd-planner maps to planning phase type; config says opus
// with no resolve_model_ids, should return 'opus'
const model = resolveModelInternal(tmpDir, 'gsd-planner');
assert.strictEqual(model, 'opus');
});
test('models with invalid tier value falls through to profile', () => {
writeConfig(tmpDir, { models: { planning: 'not-a-valid-tier' } });
// invalid tier value -> falls back to profile resolution
const model = resolveModelInternal(tmpDir, 'gsd-planner');
assert.ok(typeof model === 'string' && model.length > 0);
});
test('runtime non-claude + model_profile_overrides for runtime tier', () => {
writeConfig(tmpDir, {
runtime: 'codex',
model_profile_overrides: {
codex: { haiku: 'codex-mini', sonnet: 'codex', opus: 'codex-full' },
},
});
// gsd-codebase-mapper is light tier -> haiku in balanced profile
const model = resolveModelInternal(tmpDir, 'gsd-codebase-mapper');
assert.ok(typeof model === 'string' && model.length > 0);
});
});
// ─── resolveGranularityInternal ───────────────────────────────────────────────
describe('resolveGranularityInternal', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('no config, no override -> returns "standard"', () => {
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'standard');
});
test('valid override wins over config', () => {
writeConfig(tmpDir, { granularity: 'fine' });
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'coarse'), 'coarse');
});
test('invalid override ignored, falls through to config', () => {
writeConfig(tmpDir, { granularity: 'fine' });
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'ultradetailed'), 'fine');
});
test('null override falls through to config', () => {
writeConfig(tmpDir, { granularity: 'coarse' });
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', null), 'coarse');
});
test('per-phase-type granularity beats global granularity', () => {
writeConfig(tmpDir, {
granularity: 'coarse',
granularities: { planning: 'fine' },
});
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'fine');
});
test('planning.granularity nested config used as fallback', () => {
writeConfig(tmpDir, { planning: { granularity: 'coarse' } });
assert.strictEqual(resolveGranularityInternal(tmpDir, null), 'coarse');
});
test('VALID_GRANULARITIES contains exactly coarse, standard, fine', () => {
assert.ok(VALID_GRANULARITIES instanceof Set);
assert.ok(VALID_GRANULARITIES.has('coarse'));
assert.ok(VALID_GRANULARITIES.has('standard'));
assert.ok(VALID_GRANULARITIES.has('fine'));
assert.strictEqual(VALID_GRANULARITIES.size, 3);
});
});
// ─── assertValidGranularityOverride ───────────────────────────────────────────
describe('assertValidGranularityOverride', () => {
test('undefined -> no-op (no throw)', () => {
assert.doesNotThrow(() =>
assertValidGranularityOverride(undefined, (msg) => { throw new Error(msg); })
);
});
test('null -> no-op (no throw)', () => {
assert.doesNotThrow(() =>
assertValidGranularityOverride(null, (msg) => { throw new Error(msg); })
);
});
test('empty string -> no-op (no throw)', () => {
assert.doesNotThrow(() =>
assertValidGranularityOverride('', (msg) => { throw new Error(msg); })
);
});
test('valid value "coarse" -> no-op (no throw)', () => {
assert.doesNotThrow(() =>
assertValidGranularityOverride('coarse', (msg) => { throw new Error(msg); })
);
});
test('invalid value -> calls fail with descriptive message', () => {
let caught = null;
// fail is called with the message; we capture it by throwing so the test can inspect
assert.throws(
() => assertValidGranularityOverride('megafine', (msg) => { caught = msg; throw new Error(msg); }),
(err) => {
assert.ok(err.message.includes('megafine'), `error message should include the invalid value: ${err.message}`);
assert.ok(err.message.includes('coarse') && err.message.includes('standard') && err.message.includes('fine'),
`error message should list valid values: ${err.message}`);
return true;
}
);
assert.ok(caught !== null, 'fail should have been called');
});
});
// ─── resolveEffortInternal ────────────────────────────────────────────────────
describe('resolveEffortInternal', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('no config -> gsd-planner (heavy) defaults to "xhigh" via tier default', () => {
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'xhigh');
});
test('invocation override beats everything', () => {
writeConfig(tmpDir, { effort: { agent_overrides: { 'gsd-planner': 'low' } } });
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'minimal' }), 'minimal');
});
test('agent_overrides beats routing_tier_defaults', () => {
writeConfig(tmpDir, {
effort: {
routing_tier_defaults: { heavy: 'medium' },
agent_overrides: { 'gsd-planner': 'low' },
},
});
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'low');
});
test('effort.default is final fallback when no tier default matches', () => {
writeConfig(tmpDir, { effort: { default: 'minimal' } });
assert.strictEqual(resolveEffortInternal(tmpDir, 'completely-unknown-agent-xyz'), 'minimal');
});
test('VALID_EFFORTS and EFFORT_SET are consistent', () => {
assert.ok(Array.isArray(VALID_EFFORTS));
assert.ok(EFFORT_SET instanceof Set);
assert.strictEqual(EFFORT_SET.size, VALID_EFFORTS.length);
for (const e of VALID_EFFORTS) {
assert.ok(EFFORT_SET.has(e), `EFFORT_SET missing: ${e}`);
}
});
});
// ─── nextEffort ────────────────────────────────────────────────────────────────
describe('nextEffort', () => {
test('minimal -> low', () => {
assert.strictEqual(nextEffort('minimal'), 'low');
});
test('max -> max (clamp at ceiling)', () => {
assert.strictEqual(nextEffort('max'), 'max');
});
test('high -> xhigh', () => {
assert.strictEqual(nextEffort('high'), 'xhigh');
});
test('unknown effort -> null', () => {
assert.strictEqual(nextEffort('turbo'), null);
});
});
// ─── resolveFastModeInternal ──────────────────────────────────────────────────
describe('resolveFastModeInternal', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('no config -> defaults to false', () => {
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false);
});
test('opts.override=true beats config', () => {
writeConfig(tmpDir, { fast_mode: { agent_overrides: { 'gsd-planner': false } } });
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner', { override: true }), true);
});
test('fast_mode.enabled=true sets default for all agents', () => {
writeConfig(tmpDir, { fast_mode: { enabled: true } });
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), true);
});
test('agent_overrides beats enabled', () => {
writeConfig(tmpDir, {
fast_mode: { enabled: true, agent_overrides: { 'gsd-planner': false } },
});
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false);
});
test('unknown agent with no config -> false', () => {
assert.strictEqual(resolveFastModeInternal(tmpDir, 'unknown-agent-xyz'), false);
});
});
// ─── resolveEffortForTier ─────────────────────────────────────────────────────
describe('resolveEffortForTier', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('dynamic_routing disabled -> attempt has no effect', () => {
const base = resolveEffortForTier(tmpDir, 'gsd-planner', 0);
const at1 = resolveEffortForTier(tmpDir, 'gsd-planner', 1);
assert.strictEqual(base, at1);
});
test('dynamic_routing enabled + escalate_on_failure=true + attempt=1 -> one step up', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
escalate_on_failure: true,
max_escalations: 2,
},
effort: { routing_tier_defaults: { light: 'low' } },
});
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0), 'low');
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1), 'medium');
});
test('escalation clamps at "max"', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
escalate_on_failure: true,
max_escalations: 99,
},
effort: { default: 'xhigh' },
});
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-planner', 99), 'max');
});
});
// ─── resolveModelForTier ──────────────────────────────────────────────────────
describe('resolveModelForTier', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('no dynamic_routing -> falls back to resolveModelInternal', () => {
const fromForTier = resolveModelForTier(tmpDir, 'gsd-planner');
const fromInternal = resolveModelInternal(tmpDir, 'gsd-planner');
assert.strictEqual(fromForTier, fromInternal);
});
test('model_overrides wins before dynamic routing logic', () => {
writeConfig(tmpDir, {
model_overrides: { 'gsd-planner': 'override-model' },
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
escalate_on_failure: true,
max_escalations: 2,
},
});
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-planner'), 'override-model');
});
test('dynamic_routing + tier_models + attempt=0 -> default tier model', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' },
escalate_on_failure: true,
max_escalations: 2,
},
});
// gsd-codebase-mapper is 'light' tier
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 0), 'haiku-custom');
});
test('dynamic_routing + attempt=1 escalates tier', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' },
escalate_on_failure: true,
max_escalations: 2,
},
});
// gsd-codebase-mapper light -> attempt=1 -> standard
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 1), 'sonnet-custom');
});
});
// ─── resolveModelPolicy ───────────────────────────────────────────────────────
describe('resolveModelPolicy (#49)', () => {
test('null policy -> null', () => {
assert.strictEqual(resolveModelPolicy(null, 'sonnet'), null);
});
test('no provider -> null', () => {
assert.strictEqual(resolveModelPolicy({ budget: 'medium' }, 'sonnet'), null);
});
test('generic provider: tier=opus -> reads policy.high', () => {
const result = resolveModelPolicy(
{ provider: 'generic', high: 'my-high-model', medium: 'my-medium', low: 'my-low' },
'opus'
);
assert.strictEqual(result, 'my-high-model');
});
test('generic provider: tier=sonnet -> reads policy.medium', () => {
const result = resolveModelPolicy(
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
'sonnet'
);
assert.strictEqual(result, 'med');
});
test('generic provider: tier=haiku -> reads policy.low', () => {
const result = resolveModelPolicy(
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
'haiku'
);
assert.strictEqual(result, 'lo');
});
test('custom provider same as generic', () => {
const result = resolveModelPolicy(
{ provider: 'custom', medium: 'custom-sonnet' },
'sonnet'
);
assert.strictEqual(result, 'custom-sonnet');
});
test('runtime_tiers override takes precedence over provider', () => {
const result = resolveModelPolicy(
{
provider: 'generic',
high: 'generic-hi',
medium: 'generic-med',
low: 'generic-lo',
runtime: 'codex',
runtime_tiers: { codex: { sonnet: 'codex-sonnet-override' } },
},
'sonnet'
);
assert.strictEqual(result, 'codex-sonnet-override');
});
test('unknown tier for generic -> null', () => {
const result = resolveModelPolicy(
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
'unknown-tier'
);
assert.strictEqual(result, null);
});
});
// ─── resolveTierEntry ────────────────────────────────────────────────────────
describe('resolveTierEntry (#2517)', () => {
test('null runtime -> null', () => {
assert.strictEqual(resolveTierEntry({ runtime: null, tier: 'sonnet', overrides: null }), null);
});
test('null tier -> null', () => {
assert.strictEqual(resolveTierEntry({ runtime: 'codex', tier: null, overrides: null }), null);
});
test('unknown runtime + unknown tier, no overrides -> null', () => {
assert.strictEqual(resolveTierEntry({
runtime: 'totally-unknown-runtime-xyz',
tier: 'totally-unknown-tier',
overrides: null,
}), null);
});
test('user override as string expands to { model: string }', () => {
const entry = resolveTierEntry({
runtime: 'codex',
tier: 'sonnet',
overrides: { codex: { sonnet: 'my-custom-codex-model' } },
});
assert.ok(entry !== null);
assert.strictEqual(entry.model, 'my-custom-codex-model');
});
test('user override as object merged with builtin', () => {
const entry = resolveTierEntry({
runtime: 'codex',
tier: 'sonnet',
overrides: { codex: { sonnet: { model: 'user-model', extra: 'value' } } },
});
assert.ok(entry !== null);
assert.strictEqual(entry.model, 'user-model');
assert.strictEqual(entry['extra'], 'value');
});
});
// ─── ADVERSARIAL ─────────────────────────────────────────────────────────────
describe('ADVERSARIAL: edge cases', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('resolveModelInternal: unknown agent + model_profile=quality -> "opus" fallback', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
const model = resolveModelInternal(tmpDir, 'completely-unknown-agent');
assert.strictEqual(model, 'opus');
});
test('resolveModelInternal: unknown agent + model_profile=budget -> "haiku" fallback', () => {
writeConfig(tmpDir, { model_profile: 'budget' });
assert.strictEqual(resolveModelInternal(tmpDir, 'unknown-agent'), 'haiku');
});
test('resolveGranularityInternal: empty override "" is treated as no override', () => {
writeConfig(tmpDir, { granularity: 'fine' });
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', ''), 'fine');
});
test('assertValidGranularityOverride: "ultrawide" is invalid -> fail called', () => {
let errorMsg = null;
assert.throws(
() => assertValidGranularityOverride('ultrawide', (msg) => { errorMsg = msg; throw new Error(msg); }),
(err) => {
assert.ok(err.message.includes('ultrawide'), `error should mention the invalid value: ${err.message}`);
return true;
}
);
assert.ok(errorMsg !== null, 'fail should have been called');
assert.ok(errorMsg.includes('ultrawide'), `error message should include 'ultrawide': ${errorMsg}`);
});
test('resolveEffortInternal: invalid override "turbo" falls through to tier default', () => {
const result = resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'turbo' });
// gsd-planner is heavy -> tier default xhigh
assert.strictEqual(result, 'xhigh');
});
test('resolveFastModeInternal: string "true" override is not accepted (must be boolean)', () => {
const result = resolveFastModeInternal(tmpDir, 'gsd-planner', { override: 'true' });
// string is not boolean -> falls through to default false
assert.strictEqual(result, false);
});
test('resolveEffortInternal: effort block is non-object string -> uses tier default', () => {
writeConfig(tmpDir, { effort: 'bad-value' });
const result = resolveEffortInternal(tmpDir, 'gsd-planner');
assert.ok(EFFORT_SET.has(result), `Expected valid effort, got: ${result}`);
});
test('resolveModelForTier: unknown agent with dynamic routing -> resolveModelInternal fallback', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
escalate_on_failure: true,
max_escalations: 1,
},
});
// unknown agent has no defaultTier -> falls back to resolveModelInternal
const fromForTier = resolveModelForTier(tmpDir, 'unknown-agent-xyz');
const fromInternal = resolveModelInternal(tmpDir, 'unknown-agent-xyz');
assert.strictEqual(fromForTier, fromInternal);
});
test('resolveTierEntry: runtime override with non-string, non-object value -> no model set', () => {
const entry = resolveTierEntry({
runtime: 'codex',
tier: 'sonnet',
overrides: { codex: { sonnet: 42 } },
});
// numeric 42 is neither string nor object -> treated as truthy userEntry=42 (not expanded)
// result will have whatever builtins exist + the override
// Key requirement: does not crash
assert.ok(entry !== null || entry === null, 'should not throw');
});
test('resolveModelPolicy: non-object policy -> null', () => {
assert.strictEqual(resolveModelPolicy('string-policy', 'sonnet'), null);
});
test('resolveModelPolicy: null tier -> null', () => {
assert.strictEqual(resolveModelPolicy({ provider: 'generic', medium: 'sonnet' }, null), null);
});
test('resolveEffortForTier: max_escalations=0 caps escalation', () => {
writeConfig(tmpDir, {
dynamic_routing: {
enabled: true,
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
escalate_on_failure: true,
max_escalations: 0,
},
effort: { routing_tier_defaults: { light: 'low' } },
});
const at0 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0);
const at1 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1);
// max_escalations=0 means no escalation allowed even at attempt=1
assert.strictEqual(at0, at1);
});
});

View File

@@ -81,35 +81,39 @@ const GRACE = 3000;
// current high-water mark within GRACE (#597 tighten-only ratchet).
// XL high-water mark is execute-phase.md — note that under LINES it was
// plan-phase; bytes genuinely re-rank the tier, which is the point of #717.
// actualMax=87005 (execute-phase); slack=2995 ≤ GRACE.
const XL_BUDGET = 90000;
// LARGE high-water mark is docs-update.md. actualMax=51184; slack=2816 ≤ GRACE.
const LARGE_BUDGET = 54000;
// DEFAULT high-water mark is settings-advanced.md. actualMax=35183; slack=2817 ≤ GRACE.
const DEFAULT_BUDGET = 38000;
// actualMax=91161 (execute-phase, #891 launcher shim expansion — added 17 runtime home arms);
// slack=1839 ≤ GRACE. plan-phase.md=88120, new-project.md=58110; both well under ceiling.
const XL_BUDGET = 93000;
// LARGE high-water mark is docs-update.md. actualMax=54410 (#891 launcher shim expansion);
// slack=1590 ≤ GRACE. quick.md=45710, autonomous.md=38030.
const LARGE_BUDGET = 56000;
// DEFAULT high-water mark is settings-advanced.md. actualMax=38409 (#891 launcher shim expansion);
// slack=1591 ≤ GRACE.
const DEFAULT_BUDGET = 40000;
// Top-level orchestrators that own end-to-end multi-phase rubrics.
// Grandfathered at current sizes — see PR #2551 for the progressive-disclosure
// pattern that future shrinks should follow. Byte counts noted for reference.
const XL_WORKFLOWS = new Set([
'execute-phase', // 87005 bytes (tier high-water mark)
'execute-phase', // 91161 bytes (tier high-water mark; grew in #891 launcher shim expansion)
'plan-phase', // 85068 bytes
'new-project', // 55850 bytes
]);
// Multi-step planners and bigger feature workflows. Grandfathered.
// Byte counts updated in #891 (launcher shim expanded with 17 runtime home arms).
const LARGE_WORKFLOWS = new Set([
'docs-update', // 51184 bytes (tier high-water mark)
'autonomous', // 32655
'complete-milestone', // 26284
'verify-work', // 26896
'transition', // 18201
'discuss-phase-assumptions', // 23398
'progress', // 23061
'new-milestone', // 26582
'update', // 19334
'quick', // 42484
'code-review', // 25500
'docs-update', // 54410 bytes (tier high-water mark)
'autonomous', // 38030
'complete-milestone', // 29510
'verify-work', // 30122
'transition', // 21427
'discuss-phase-assumptions', // 26624
'progress', // 26287
'new-milestone', // 29808
'update', // 20766
'quick', // 45710
'code-review', // 28726
]);
const ALL_WORKFLOWS = fs.readdirSync(WORKFLOWS_DIR)
@@ -191,7 +195,9 @@ describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => {
// headroom). This is the headline metric of the refactor — every other
// workflow above its tier is grandfathered and may shrink later via the
// same pattern.
const DISCUSS_PHASE_TARGET = 30000;
// Target raised from 30000 to 32000 in #891 (launcher shim expansion added 17 runtime home arms,
// adding ~960 bytes to the preamble; the thin-dispatcher intent is preserved — actual=30935).
const DISCUSS_PHASE_TARGET = 32000;
test(`discuss-phase.md is under ${DISCUSS_PHASE_TARGET} bytes (issue #2551 target)`, () => {
const filePath = path.join(WORKFLOWS_DIR, 'discuss-phase.md');
const bytes = byteCount(filePath);