Merge branch 'next' into kimi-runtime-support
This commit is contained in:
329
tests/bug-891-non-claude-runtime-home-fallback.test.cjs
Normal file
329
tests/bug-891-non-claude-runtime-home-fallback.test.cjs
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
290
tests/bug-892-validate-checklist-roadmap-phases.test.cjs
Normal file
290
tests/bug-892-validate-checklist-roadmap-phases.test.cjs
Normal 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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
1242
tests/capability-registry.test.cjs
Normal file
1242
tests/capability-registry.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
702
tests/loop-host-contract.test.cjs
Normal file
702
tests/loop-host-contract.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
683
tests/model-resolver.test.cjs
Normal file
683
tests/model-resolver.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user