diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 3fa9a68d7..cdedff5a8 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -17,28 +17,17 @@ "tests/bug-131-release-tarball-smoke-explicit-home.test.cjs :: integration-test-input", "tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in", "tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard", - "tests/bug-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product", - "tests/bug-214-writer-agents-write-truncation-contract.test.cjs :: source-text-is-the-product", - "tests/bug-2346-agent-read-loop-guards.test.cjs :: source-text-is-the-product", - "tests/bug-2419-project-researcher-agent.test.cjs :: source-text-is-the-product", - "tests/bug-2421-planner-grep-gate-hygiene.test.cjs :: source-text-is-the-product", "tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard", "tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product", - "tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product", "tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product", "tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product", "tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product", - "tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product", - "tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep", - "tests/bug-3290-intel-updater-layout-block.test.cjs :: source-text-is-the-product — agents/gsd-intel-updater.md IS", "tests/bug-33-settings-model-profile-adaptive.test.cjs :: source-text-is-the-product", "tests/bug-3384-secondary-defects.test.cjs :: source-text-is-the-product", - "tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product", "tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product", "tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product", "tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep", "tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product", - "tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs :: source-text-is-the-product", "tests/bug-3677-agent-colon-namespace-leak.test.cjs :: source-text-is-the-product", "tests/bug-3678-executor-commit-docs-respect.test.cjs :: source-text-is-the-product", "tests/bug-3683-command-colon-namespace-leak.test.cjs :: source-text-is-the-product", @@ -46,7 +35,6 @@ "tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product", "tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs :: source-text-is-the-product", "tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install", - "tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product", "tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product", "tests/bug-622-graphify-optional-graph-html.test.cjs :: source-text-is-the-product", "tests/bug-630-wave-cleanup-orchestrator-root.test.cjs :: source-text-is-the-product", @@ -91,7 +79,6 @@ "tests/edge-probe-spec-phase-contract.test.cjs :: runtime-contract-is-the-product — spec-phase.md Step 5.5 is the deployed workflow runtime contract under assertion", "tests/edit-phase.test.cjs :: source-text-is-the-product", "tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product", - "tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product", "tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product", "tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product", "tests/enh-48-cwd-drift-guard-e2e.test.cjs :: integration-test-input", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index da5445dd7..935b4f60e 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -6,37 +6,24 @@ "bug-21-state-md-template-frontmatter.test.cjs", "bug-211-launcher-home-fallback.test.cjs", "bug-2136-sh-hook-version.test.cjs", - "bug-214-phase-researcher-write-truncation-contract.test.cjs", - "bug-214-writer-agents-write-truncation-contract.test.cjs", "bug-2344-read-guard-claudecode-env.test.cjs", - "bug-2346-agent-read-loop-guards.test.cjs", - "bug-2351-intel-kilo-layout.test.cjs", - "bug-2419-project-researcher-agent.test.cjs", - "bug-2421-planner-grep-gate-hygiene.test.cjs", "bug-2451-context-monitor-over-report.test.cjs", "bug-2520-read-guard-hook-subprocess-env.test.cjs", "bug-2543-gsd-slash-namespace.test.cjs", "bug-2559-stale-search-year.test.cjs", "bug-260-worktree-path-guard.test.cjs", "bug-261-worktree-force-add-guard.test.cjs", - "bug-2686-review-fix-worktree.test.cjs", "bug-2772-gitmodules-path-intersection.test.cjs", "bug-2808-skill-hyphen-name.test.cjs", "bug-2839-review-fix-transactional-cleanup.test.cjs", "bug-2866-codex-strip-no-trailing-newline.test.cjs", "bug-2876-skill-frontmatter-quote.test.cjs", "bug-2916-handle-branching-default-base.test.cjs", - "bug-2990-code-fixer-worktree-branch.test.cjs", "bug-2995-post-install-script-paths.test.cjs", "bug-3019-help-passthrough.test.cjs", "bug-3054-stale-gsd-next-references.test.cjs", - "bug-3087-planner-directive-language.test.cjs", - "bug-3097-3099-executor-worktree-path-safety.test.cjs", - "bug-3290-intel-updater-layout-block.test.cjs", "bug-33-settings-model-profile-adaptive.test.cjs", - "bug-3321-verifier-runs-probes.test.cjs", "bug-3384-secondary-defects.test.cjs", - "bug-3430-planner-phase-contract.test.cjs", "bug-3442-shim-projection-drift-guard.test.cjs", "bug-3446-resume-continue-here-discovery.test.cjs", "bug-3491-nested-git-worktree.test.cjs", @@ -44,7 +31,6 @@ "bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs", "bug-3542-executor-git-stash-prohibition.test.cjs", "bug-3588-npm-audit-clean.test.cjs", - "bug-3605-stale-research-insert-phase-agent-refs.test.cjs", "bug-3668-workflow-runtime-resolution.test.cjs", "bug-3677-agent-colon-namespace-leak.test.cjs", "bug-3678-executor-commit-docs-respect.test.cjs", @@ -53,7 +39,6 @@ "bug-3689-resume-glob-nomatch.test.cjs", "bug-3810-no-gsd-sdk-runtime-refs.test.cjs", "bug-444-resolver-local-claude-install.test.cjs", - "bug-571-doc-writer-fix-mode-edit-only.test.cjs", "bug-619-codebase-drift-gate-shim.test.cjs", "bug-622-graphify-optional-graph-html.test.cjs", "bug-630-wave-cleanup-orchestrator-root.test.cjs", diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 01e84fd4b..c4334abca 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -21,15 +21,6 @@ ], "issue": "622" }, - "intel": { - "files": [ - "bug-2351-intel-kilo-layout.test.cjs", - "bug-3290-intel-updater-layout-block.test.cjs", - "intel-command-cutover.test.cjs", - "intel.test.cjs" - ], - "issue": "TBD" - }, "milestone": { "files": [ "milestone-archive.test.cjs", @@ -40,14 +31,6 @@ ], "issue": "TBD" }, - "phase": { - "files": [ - "bug-214-phase-researcher-write-truncation-contract.test.cjs", - "phase-dependency-levels.test.cjs", - "phase.test.cjs" - ], - "issue": "597" - }, "roadmap": { "files": [ "roadmap-mode-field.test.cjs", diff --git a/tests/agent-frontmatter.test.cjs b/tests/agent-frontmatter.test.cjs index d1b60e0d7..a5a0e283b 100644 --- a/tests/agent-frontmatter.test.cjs +++ b/tests/agent-frontmatter.test.cjs @@ -476,3 +476,1182 @@ describe('COMPAT: agents must not use runtime-specific frontmatter keys', () => }); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2346-agent-read-loop-guards.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2346-agent-read-loop-guards (consolidation epic #1969 B7 #1976)", () => { +/** + * Regression tests for bug #2346 + * + * Multiple GSD agents (gsd-ui-checker, gsd-planner) entered unbounded Read + * loops — re-reading the same file hundreds of times in a single run. Root + * cause: no explicit no-re-read rule or tool-budget cap in the agent prompts. + * gsd-pattern-mapper was fixed in #2312; this covers the remaining agents. + * + * Fix: add block to each affected agent with: + * 1. No-re-read constraint + * 2. Large-file strategy (Grep first, then targeted offset/limit Read) + * 3. Stop-on-sufficient-evidence rule (where applicable) + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +// allow-test-rule: source-text-is-the-product (see #2346) +// The block in agent .md files IS the fix — it is the AI instruction that +// prevents unbounded Read loops. There is no behavioral equivalent without a live LLM run. +describe('bug #2346: agent read loop guards', () => { + + describe('gsd-ui-checker', () => { + const agentPath = path.join(AGENTS_DIR, 'gsd-ui-checker.md'); + const content = fs.readFileSync(agentPath, 'utf-8'); + + test('agent file exists', () => { + assert.ok(fs.existsSync(agentPath), 'agents/gsd-ui-checker.md must exist'); + }); + + test('has block', () => { + assert.ok( + content.includes(''), + 'gsd-ui-checker.md must have a block to prevent unbounded read loops (#2346)' + ); + }); + + test('critical_rules contains no-re-read constraint', () => { + const rulesStart = content.indexOf(''); + const rulesEnd = content.indexOf('', rulesStart); + assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be complete'); + const rulesBlock = content.slice(rulesStart, rulesEnd); + assert.ok( + rulesBlock.includes('re-read') || rulesBlock.includes('re read'), + 'critical_rules must include a no-re-read rule' + ); + }); + + test('critical_rules appears before success_criteria', () => { + const rulesIdx = content.indexOf(''); + const successIdx = content.indexOf(''); + assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist'); + assert.ok( + rulesIdx < successIdx, + ' must appear before ' + ); + }); + }); + + describe('gsd-planner', () => { + const agentPath = path.join(AGENTS_DIR, 'gsd-planner.md'); + const content = fs.readFileSync(agentPath, 'utf-8'); + + test('agent file exists', () => { + assert.ok(fs.existsSync(agentPath), 'agents/gsd-planner.md must exist'); + }); + + test('has block', () => { + assert.ok( + content.includes(''), + 'gsd-planner.md must have a block to prevent unbounded read loops (#2346)' + ); + }); + + test('critical_rules contains no-re-read constraint', () => { + const rulesStart = content.indexOf(''); + const rulesEnd = content.indexOf('', rulesStart); + assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be complete'); + const rulesBlock = content.slice(rulesStart, rulesEnd); + assert.ok( + rulesBlock.includes('re-read') || rulesBlock.includes('re read'), + 'critical_rules must include a no-re-read rule' + ); + }); + + test('critical_rules appears before success_criteria', () => { + const rulesIdx = content.indexOf(''); + const successIdx = content.lastIndexOf(''); + assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist'); + assert.ok( + rulesIdx < successIdx, + ' must appear before ' + ); + }); + }); + +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3605-stale-research-insert-phase-agent-refs (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #3605) +// agents/*.md text IS the deployed contract — Claude Code, Codex, etc. load these +// files at runtime and surface their content to users. Testing for retired slash +// commands in this text is testing what real users will see. + +/** + * Bug #3605: Stale slash command references in 5 agent files + * + * After #3042 deleted /gsd-research-phase (replaced by + * /gsd-plan-phase --research-phase ) and v1.40.0 consolidated /gsd-insert-phase + * into /gsd-phase insert, six occurrences survived in agents/*.md because none of + * the consolidation passes (#3029, #3044, #3131) included agents/ in their per-name + * scrub scope. scripts/fix-slash-commands.cjs lists agents/ in SEARCH_DIRS but only + * runs the /gsd- → /gsd: namespace transform, not retired-name replacement. + * + * This guard fails when any retired command name reappears in agents/*.md. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +const RETIRED_COMMANDS = [ + '/gsd-research-phase', + '/gsd-insert-phase', + '/gsd-add-phase', + '/gsd-remove-phase', + '/gsd-analyze-dependencies', +]; + +// Not the shared listAgentFiles() helper: this returns ABSOLUTE paths (consumed +// by scanForRetired below as readFileSync targets), not stripped basenames. +function listAgentFiles() { + return fs + .readdirSync(AGENTS_DIR) + .filter((name) => name.endsWith('.md')) + .map((name) => path.join(AGENTS_DIR, name)); +} + +function scanForRetired(filePath) { + const text = fs.readFileSync(filePath, 'utf-8'); + const lines = text.split(/\r?\n/); + const hits = []; + for (let i = 0; i < lines.length; i++) { + for (const cmd of RETIRED_COMMANDS) { + const idx = lines[i].indexOf(cmd); + if (idx === -1) continue; + const next = lines[i].charCodeAt(idx + cmd.length); + // Only count if the match is a real invocation, not a prefix of a longer name. + // The next char must be a non-name char (anything outside [A-Za-z0-9-_]). + const isWordBoundary = + Number.isNaN(next) || + !((next >= 48 && next <= 57) || // 0-9 + (next >= 65 && next <= 90) || // A-Z + (next >= 97 && next <= 122) || // a-z + next === 45 || // - + next === 95); // _ + if (!isWordBoundary) continue; + hits.push({ line: i + 1, cmd, text: lines[i].trim() }); + } + } + return hits; +} + +describe('bug #3605: agent contracts must not reference retired slash commands', () => { + const agentFiles = listAgentFiles(); + + test('at least one agent file is scanned (smoke)', () => { + assert.ok(agentFiles.length > 0, 'expected agents/*.md to exist'); + }); + + for (const file of agentFiles) { + const rel = path.relative(path.join(__dirname, '..'), file); + test(`${rel} contains no retired slash commands`, () => { + const hits = scanForRetired(file); + assert.deepEqual( + hits, + [], + `${rel} contains retired command references:\n` + + hits.map((h) => ` line ${h.line}: ${h.cmd} — ${h.text}`).join('\n'), + ); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2427-sycophancy-hardening.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2427-sycophancy-hardening (consolidation epic #1969 B7 #1976)", () => { +'use strict'; + +/** + * Tests for #2427 — prompt-level sycophancy hardening of audit-class agents. + * Verifies the four required changes are present in each agent file: + * 1. Third-person framing (no "You are a GSD X" opening in ) + * 2. FORCE adversarial stance block + * 3. Explicit failure modes list + * 4. BLOCKER/WARNING classification requirement + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const AGENTS_DIR = path.join(__dirname, '../agents'); + +const AUDIT_AGENTS = [ + 'gsd-plan-checker.md', + 'gsd-code-reviewer.md', + 'gsd-security-auditor.md', + 'gsd-verifier.md', + 'gsd-eval-auditor.md', + 'gsd-nyquist-auditor.md', + 'gsd-ui-auditor.md', + 'gsd-integration-checker.md', + 'gsd-doc-verifier.md', +]; + +function readAgent(agentsDir, filename) { + return fs.readFileSync(path.join(agentsDir, filename), 'utf-8'); +} + +function extractRole(content) { + const match = content.match(/([\s\S]*?)<\/role>/); + return match ? match[1] : ''; +} + +describe('enh-2427 — sycophancy hardening: audit-class agents', () => { + + for (const filename of AUDIT_AGENTS) { + const label = filename.replace('.md', ''); + + describe(label, () => { + let content; + let role; + + test('file is readable', () => { + content = readAgent(AGENTS_DIR, filename); + role = extractRole(content); + assert.ok(content.length > 0, `${filename} should not be empty`); + }); + + test('(1) third-person framing — does not open with "You are a GSD"', () => { + content = content || readAgent(AGENTS_DIR, filename); + role = role || extractRole(content); + const firstSentence = role.trim().slice(0, 80); + assert.ok( + !firstSentence.startsWith('You are a GSD'), + `${filename}: must not open with "You are a GSD" — use third-person submission framing. Got: "${firstSentence}"` + ); + }); + + test('(2) FORCE adversarial stance — block present', () => { + content = content || readAgent(AGENTS_DIR, filename); + assert.ok( + content.includes(''), + `${filename}: must contain block` + ); + assert.ok( + content.includes('FORCE stance'), + `${filename}: must contain "FORCE stance"` + ); + }); + + test('(3) explicit failure modes list present', () => { + content = content || readAgent(AGENTS_DIR, filename); + assert.ok( + content.includes('failure modes'), + `${filename}: must contain "failure modes" section in ` + ); + }); + + test('(4) BLOCKER/WARNING classification requirement present', () => { + content = content || readAgent(AGENTS_DIR, filename); + assert.ok( + content.includes('**BLOCKER**'), + `${filename}: must define BLOCKER classification in ` + ); + assert.ok( + content.includes('**WARNING**'), + `${filename}: must define WARNING classification in ` + ); + }); + }); + } + +}); +// sdk/prompts/agents/ was removed in 377a6d2 — SDK now loads installed agents directly. + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-571-doc-writer-fix-mode-edit-only (consolidation epic #1969 B7 #1976)", () => { +/** + * Regression tests for bug #571 + * + * gsd-doc-writer in fix mode used the Write tool (whole-file replace) instead + * of the Edit tool (surgical replacement) when correcting specific failing + * claims. When the target doc was generated but not yet committed, Write could + * truncate the file to a single line with no git recovery path. + * + * Fix 1 (agent): Add Edit to the tools frontmatter and rewrite fix_mode + * instructions to mandate Edit and explicitly forbid Write on existing files. + * Fix 2 (workflow): Add a post-fix line-count guard in fix_loop that detects + * >90% shrinkage and restores the file from existing_content. + */ + +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #571) +// Agent .md files are the installed AI agents — their frontmatter and body IS +// what the runtime loads. Checking text content IS checking the deployed contract. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +const AGENT_PATH = path.join(AGENTS_DIR, 'gsd-doc-writer.md'); +const WORKFLOW_PATH = path.join(WORKFLOWS_DIR, 'docs-update.md'); + +// ─── Agent fix: Edit in tools frontmatter ──────────────────────────────────── + +describe('bug #571: gsd-doc-writer agent', () => { + const content = fs.readFileSync(AGENT_PATH, 'utf-8'); + + test('agent file exists', () => { + assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-doc-writer.md must exist'); + }); + + test('tools frontmatter includes Edit', () => { + const toolsMatch = content.match(/^tools:\s*(.+)$/m); + assert.ok(toolsMatch, 'gsd-doc-writer.md must have a tools: frontmatter line'); + assert.ok( + toolsMatch[1].includes('Edit'), + 'tools: frontmatter must include Edit so fix mode can make surgical replacements (#571)' + ); + }); + + // ─── fix_mode instructions ──────────────────────────────────────────────── + + describe('fix_mode block', () => { + const fixStart = content.indexOf(''); + const fixEnd = content.indexOf('', fixStart); + assert.ok(fixStart !== -1 && fixEnd !== -1, ' block must be present and complete'); + const fixBlock = content.slice(fixStart, fixEnd); + + test('fix_mode mandates Edit for corrections', () => { + assert.ok( + fixBlock.includes('Edit'), + 'fix_mode must instruct the agent to use the Edit tool for surgical corrections (#571)' + ); + }); + + test('fix_mode explicitly forbids Write on existing files', () => { + assert.ok( + fixBlock.includes('NEVER use the Write tool') || fixBlock.includes('NEVER call Write'), + 'fix_mode must explicitly forbid Write on existing files — Write replaces the whole file (#571)' + ); + }); + + test('fix_mode mentions unrecoverable data loss risk of Write', () => { + assert.ok( + fixBlock.includes('untracked') || fixBlock.includes('context window') || fixBlock.includes('permanently destroyed'), + 'fix_mode must explain WHY Write is forbidden — unrecoverable data loss for untracked files (#571)' + ); + }); + }); + + // ─── critical_rules ─────────────────────────────────────────────────────── + + describe('critical_rules block', () => { + const rulesStart = content.indexOf(''); + const rulesEnd = content.indexOf('', rulesStart); + assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be present and complete'); + const rulesBlock = content.slice(rulesStart, rulesEnd); + + test('critical_rules forbids Write in fix mode', () => { + assert.ok( + rulesBlock.includes('fix mode') && (rulesBlock.includes('NEVER call Write') || rulesBlock.includes('NEVER use the Write')), + 'critical_rules must explicitly forbid Write in fix mode (#571)' + ); + }); + + test('critical_rules Edit rule appears before success_criteria', () => { + const rulesIdx = content.indexOf(''); + const successIdx = content.indexOf(''); + assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both and must exist'); + assert.ok( + rulesIdx < successIdx, + ' must appear before (#571)' + ); + }); + }); +}); + +// ─── Workflow fix: post-fix truncation guard in fix_loop ───────────────────── + +describe('bug #571: docs-update workflow fix_loop', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + + test('workflow file exists', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/docs-update.md must exist'); + }); + + describe('fix_loop step', () => { + const loopStart = content.indexOf(''); + const loopEnd = content.indexOf('', loopStart); + assert.ok(loopStart !== -1 && loopEnd !== -1, 'fix_loop step must be present and complete'); + const loopBlock = content.slice(loopStart, loopEnd); + + test('fix_loop captures pre-fix line count', () => { + assert.ok( + loopBlock.includes('PRE_FIX_LINES') || loopBlock.includes('pre-fix line'), + 'fix_loop must capture the pre-fix line count to detect truncation (#571)' + ); + }); + + test('fix_loop checks post-fix line count', () => { + assert.ok( + loopBlock.includes('POST_FIX_LINES') || loopBlock.includes('post-fix line'), + 'fix_loop must check the post-fix line count to detect truncation (#571)' + ); + }); + + test('fix_loop restores file on truncation detection', () => { + assert.ok( + loopBlock.includes('Restore') || loopBlock.includes('restore'), + 'fix_loop must restore the file from existing_content when truncation is detected (#571)' + ); + }); + + test('fix_loop truncation threshold is >90% shrinkage', () => { + assert.ok( + loopBlock.includes('90%') || loopBlock.includes('10%'), + 'fix_loop must use a >90% shrinkage threshold (10% of original) to detect truncation (#571)' + ); + }); + + test('fix_loop logs a WARNING on truncation', () => { + assert.ok( + loopBlock.includes('WARNING') || loopBlock.includes('corrupted'), + 'fix_loop must log a WARNING when truncation is detected and restored (#571)' + ); + }); + + // Structural ordering: PRE check → fix agent runs → POST check → restore + // These ensure the guard is wired in the right sequence, not just present. + test('PRE_FIX_LINES is captured before POST_FIX_LINES (correct ordering)', () => { + const preIdx = loopBlock.indexOf('PRE_FIX_LINES'); + const postIdx = loopBlock.indexOf('POST_FIX_LINES'); + assert.ok(preIdx !== -1 && postIdx !== -1, 'both PRE_FIX_LINES and POST_FIX_LINES must be present (#571)'); + assert.ok( + preIdx < postIdx, + 'PRE_FIX_LINES must appear before POST_FIX_LINES — pre-capture must happen before post-check (#571)' + ); + }); + + test('restore instruction appears after POST_FIX_LINES check (correct ordering)', () => { + const postIdx = loopBlock.indexOf('POST_FIX_LINES'); + // Find the restore instruction — it follows the threshold comparison + const restoreIdx = loopBlock.indexOf('existing_content', postIdx); + assert.ok( + restoreIdx !== -1 && restoreIdx > postIdx, + 'restore-from-existing_content instruction must appear after the POST_FIX_LINES check (#571)' + ); + }); + + test('fix_loop doc path is quoted in shell snippets', () => { + // Unquoted paths break on filenames with spaces or shell metacharacters. + // Verify the bash snippets use quoted "{doc_path}" not bare {doc_path}. + assert.ok( + loopBlock.includes('< "{doc_path}"') || loopBlock.includes("<\"{doc_path}\""), + 'shell redirections must quote {doc_path} to handle paths with spaces (#571)' + ); + }); + + test('corrupted doc is still re-verified (not silently skipped)', () => { + // The restored doc must be included in step 2 re-verification so its + // failures are counted and reported. It should only be excluded from + // receiving another fix attempt, not from verification. + const restoreIdx = loopBlock.indexOf('existing_content', loopBlock.indexOf('POST_FIX_LINES')); + const reVerifyIdx = loopBlock.indexOf('re-verify', restoreIdx); + assert.ok( + reVerifyIdx !== -1, + 'fix_loop must include re-verification after truncation restore (corrupted docs still have failures) (#571)' + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-214-writer-agents-write-truncation-contract.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-214-writer-agents-write-truncation-contract (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #214) +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); + +// Every agent that writes a large file in a single Write call must carry the +// same truncation-resilient write contract added for bug #214. OpenCode shares +// OUTPUT_TOKEN_MAX=32000 with the thinking budget (upstream opencode#18108), so +// an oversized single `write` tool call is truncated mid-payload, yielding +// `JSON Parse error: Expected '}'`, and OpenCode then doom-loops. gsd-phase-researcher +// is locked by its own bug-214 test; this locks the other large-file writers. +const WRITER_AGENTS = [ + 'gsd-research-synthesizer', + 'gsd-planner', + 'gsd-executor', + 'gsd-domain-researcher', + 'gsd-project-researcher', + 'gsd-ui-researcher', +]; + +function readAgent(name) { + return fs.readFileSync(path.join(REPO_ROOT, 'agents', `${name}.md`), 'utf8'); +} + +describe('bug #214: large-file writer agents must survive write-tool truncation', () => { + for (const name of WRITER_AGENTS) { + describe(name, () => { + const prompt = readAgent(name); + + test('keeps single-Write as the default path', () => { + assert.match( + prompt, + /in a single `Write` call/i, + `${name}: must keep single-Write as the default (no regression for non-truncating runtimes).` + ); + }); + + test('names the truncation failure mode', () => { + assert.match(prompt, /truncat/i, `${name}: must name the truncation failure mode.`); + }); + + test('instructs incremental construction on large files', () => { + assert.match( + prompt, + /incrementa/i, + `${name}: must instruct incremental construction on large files.` + ); + }); + + test('defines the continuation sentinel', () => { + assert.match( + prompt, + //, + `${name}: must define the continuation sentinel for incremental writes.` + ); + }); + + test('forbids identical retry of the oversized write (doom-loop guard)', () => { + assert.match( + prompt, + /do NOT retry the same oversized call/i, + `${name}: must forbid identical retry of the oversized write.` + ); + }); + + test('requires Read before Edit', () => { + assert.match( + prompt, + /`Read` the file, then `Edit`/i, + `${name}: must require Read before Edit (OpenCode edit requires a prior Read).` + ); + }); + + test('instructs removing the sentinel on the final section', () => { + assert.match( + prompt, + /no trailing sentinel/i, + `${name}: must instruct removing the sentinel on the final section.` + ); + }); + + test('forbids silent fallback to returning content', () => { + assert.match( + prompt, + /do NOT silently fall back to returning content/i, + `${name}: must forbid silent fallback to returning content.` + ); + }); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-214-phase-researcher-write-truncation-contract.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-214-phase-researcher-write-truncation-contract (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #214) +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const RESEARCHER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-phase-researcher.md'); + +function readResearcherPrompt() { + return fs.readFileSync(RESEARCHER_PATH, 'utf8'); +} + +describe('bug #214: phase researcher must survive OpenCode write-tool truncation', () => { + test('Step 6 documents the large-file / truncation fallback write contract', () => { + const prompt = readResearcherPrompt(); + + assert.match( + prompt, + /truncat/i, + 'Step 6 must name the truncation failure mode.' + ); + assert.match( + prompt, + /incrementa/i, + 'Step 6 must instruct incremental construction on large files.' + ); + assert.match( + prompt, + //, + 'Step 6 must define the continuation sentinel for incremental writes.' + ); + assert.match( + prompt, + /do NOT retry the same oversized call/i, + 'Step 6 must forbid identical retry of the oversized write (doom-loop guard).' + ); + assert.match( + prompt, + /do NOT silently fall back to returning content/i, + 'Step 6 must forbid silent fallback to returning content.' + ); + assert.match( + prompt, + /`Read` the file, then `Edit`/i, + 'Step 6 must require Read before Edit (OpenCode edit requires a prior Read).' + ); + assert.match( + prompt, + /no trailing sentinel/i, + 'Step 6 must instruct removing the sentinel on the final section.' + ); + assert.match( + prompt, + /write the whole file in a single `Write` call/i, + 'Step 6 must keep single-Write as the default path (no regression for non-truncating runtimes).' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2990-code-fixer-worktree-branch.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2990-code-fixer-worktree-branch (consolidation epic #1969 B7 #1976)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2990) +// agents/gsd-code-fixer.md is the deployed agent definition the runtime +// loads. Parsing its bash code blocks into structured invocation records +// (extractCleanupGitInvocations + the recovery-block parsers below) IS +// testing the runtime contract — what command sequence the agent +// actually documents and executes. The .match() calls extract typed +// fields from a known-shape product file, then assertions go against +// those typed fields, not against the raw markdown text. + +// Consolidation #1969: scope GSD_TEST_MODE to this folded block so it does not +// leak into sibling folded suites in the shared file (was process-isolated when +// standalone). This unit suite only parses agent markdown, but keep the flag set +// for its own duration to preserve the origin behaviour, and restore it after. +const { before: __gtmBefore, after: __gtmAfter } = require('node:test'); +const __savedGsdTestMode = process.env.GSD_TEST_MODE; +__gtmBefore(() => { process.env.GSD_TEST_MODE = '1'; }); +__gtmAfter(() => { if (__savedGsdTestMode === undefined) delete process.env.GSD_TEST_MODE; else process.env.GSD_TEST_MODE = __savedGsdTestMode; }); + +/** + * Bug #2990: gsd-code-fixer worktree setup fails when current branch + * is already checked out in the main repo. + * + * The original agent definition called `git worktree add "$wt" "$branch"`, + * where `$branch` was the user's currently-checked-out branch. Git refuses + * to check out the same branch in two worktrees by default, so the setup + * failed before the agent could do any work. + * + * Fix: create a NEW branch `gsd-reviewfix/${padded_phase}-$$` and attach + * the worktree to it via `git worktree add -b "$reviewfix_branch" "$wt" + * "$branch"`. The cleanup tail then fast-forwards `$branch` to + * `$reviewfix_branch` so the user's branch captures the agent's commits. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-code-fixer.md'); + +function parseWorktreeAddInvocations(markdown) { + // Pull `git worktree add ...` calls and classify each into structured + // records: hasNewBranchFlag (uses -b $reviewfix_branch) vs attachesToBareBranch + // ($wt $branch). Skip occurrences inside markdown inline code (backticks) + // or bash comments -- those are documentation citations of the OLD broken + // pattern, not executable instructions. + const invocations = []; + const lines = markdown.split('\n'); + for (const line of lines) { + const idx = line.indexOf('git worktree add'); + if (idx === -1) continue; + // Skip if inside backticks: the substring up to the match has an odd + // number of backticks, the call is inside an inline code span. + const before = line.slice(0, idx); + const backticksBefore = (before.match(/`/g) || []).length; + if (backticksBefore % 2 === 1) continue; + // Skip if the line is a bash comment (after stripping leading whitespace). + if (line.trimStart().startsWith('#')) continue; + const argstr = line.slice(idx + 'git worktree add'.length).trim(); + invocations.push({ + raw: argstr, + hasNewBranchFlag: /(?:^|\s)-b\s+["']?\$reviewfix_branch["']?/.test(argstr), + attachesToBareBranch: /^["']?\$wt["']?\s+["']?\$branch["']?\b/.test(argstr), + }); + } + return invocations; +} + +describe('Bug #2990: gsd-code-fixer worktree attaches to a NEW branch, not the user-checked-out one', () => { + const md = fs.readFileSync(AGENT_PATH, 'utf-8'); + const invocations = parseWorktreeAddInvocations(md); + + test('sanity: at least one git-worktree-add invocation exists in the agent definition', () => { + assert.ok(invocations.length > 0, + 'expected gsd-code-fixer.md to document at least one git worktree add invocation'); + }); + + test('every git-worktree-add invocation uses -b $reviewfix_branch (not bare $branch)', () => { + const violations = invocations.filter(inv => inv.attachesToBareBranch); + assert.deepEqual( + violations.map(v => v.raw), + [], + `worktree-add invocations attaching to bare $branch (#2990): ${JSON.stringify(violations.map(v => v.raw), null, 2)}`, + ); + }); + + test('the canonical setup invocation uses -b "$reviewfix_branch" "$wt" "$branch"', () => { + const setupInvocations = invocations.filter(inv => inv.hasNewBranchFlag); + assert.ok(setupInvocations.length >= 1, + `expected at least one git-worktree-add invocation with -b "$reviewfix_branch" -- found: ${JSON.stringify(invocations.map(i => i.raw), null, 2)}`); + }); +}); + +/** + * Extract the cleanup-tail bash block from the agent .md, then parse it into + * an ordered array of `git ...` invocation records. Per-record assertions go + * against the structured records, not the raw markdown text. Anchor on the + * "Cleanup tail" header to scope to the right block (the file has multiple + * fenced bash blocks; we only want the cleanup one). + */ +function extractCleanupGitInvocations(markdown) { + // Find the cleanup tail header and the fenced bash block that follows. + const headerIdx = markdown.indexOf('**Cleanup tail (transactional'); + if (headerIdx === -1) return null; + const fenceStart = markdown.indexOf('```bash', headerIdx); + if (fenceStart === -1) return null; + const fenceEnd = markdown.indexOf('```', fenceStart + '```bash'.length); + if (fenceEnd === -1) return null; + const block = markdown.slice(fenceStart + '```bash'.length, fenceEnd); + + // Tokenize each non-comment, non-blank line into structured records. + const lines = block.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); + const records = []; + for (const line of lines) { + // Skip occurrences inside backticks (these would be inline-code + // citations of the OLD pattern, not executable). The cleanup fenced + // block is bash, but inline backticks can still appear inside echo + // strings — guard anyway. + const ticksBefore = (line.match(/`/g) || []).length; + if (ticksBefore && ticksBefore % 2 === 1) continue; + if (!line.includes('git ') && !line.startsWith('git ')) continue; + records.push({ + raw: line, + // Strip leading `git -C "..."`/`git -C $main_repo` so the verb-only + // form stays comparable across direct and -C invocations. + verb: (() => { + const m = line.match(/^git\s+(?:-C\s+\S+\s+)?(\S+)/); + return m ? m[1] : null; + })(), + // Did this line target the temp reviewfix branch by variable name? + targetsReviewfixBranch: /\$reviewfix_branch\b/.test(line) || /"\$reviewfix_branch"/.test(line), + // Is this the merge step? Captures the flag too. + isMergeFfOnly: /\bmerge\s+--ff-only\b/.test(line), + // Is this the branch-delete step? + isBranchDelete: /\bbranch\s+-D\b/.test(line), + }); + } + return records; +} + +describe('Bug #2990: cleanup tail fast-forwards $branch and deletes the temp branch on success', () => { + const md = fs.readFileSync(AGENT_PATH, 'utf-8'); + const records = extractCleanupGitInvocations(md); + + test('cleanup tail bash block exists and is parseable', () => { + assert.notEqual(records, null, 'expected to find a "Cleanup tail" bash block in agents/gsd-code-fixer.md'); + assert.ok(records.length > 0, 'expected at least one git invocation in the cleanup tail'); + }); + + test('cleanup contains exactly one merge --ff-only against $reviewfix_branch', () => { + const merges = records.filter(r => r.isMergeFfOnly); + assert.equal(merges.length, 1, `expected exactly 1 ff-only merge, got ${merges.length}: ${JSON.stringify(merges, null, 2)}`); + assert.equal(merges[0].targetsReviewfixBranch, true, 'merge --ff-only must target $reviewfix_branch'); + }); + + test('cleanup contains exactly one git branch -D for $reviewfix_branch', () => { + const deletes = records.filter(r => r.isBranchDelete); + assert.equal(deletes.length, 1, `expected exactly 1 branch -D, got ${deletes.length}`); + assert.equal(deletes[0].targetsReviewfixBranch, true, 'branch -D must target $reviewfix_branch'); + }); + + test('merge --ff-only precedes branch -D in the cleanup ordering', () => { + const mergeIdx = records.findIndex(r => r.isMergeFfOnly); + const deleteIdx = records.findIndex(r => r.isBranchDelete); + assert.ok(mergeIdx >= 0 && deleteIdx >= 0); + assert.ok(mergeIdx < deleteIdx, + `merge must run before branch delete (merge=${mergeIdx}, delete=${deleteIdx}); otherwise commits could be lost on merge failure`); + }); + + test('recovery sentinel JSON shape records reviewfix_branch alongside worktree_path', () => { + // Find the writeFileSync call that constructs the sentinel JSON. + // Parse the JSON.stringify argument list to extract the field names. + const match = md.match(/fs\.writeFileSync\(sentinelPath,\s*JSON\.stringify\(\{([^}]+)\}/); + assert.notEqual(match, null, 'expected JSON.stringify({...}) inside the sentinel write'); + const fields = match[1].split(',').map(s => s.trim().split(':')[0].trim()).filter(Boolean); + assert.ok(fields.includes('reviewfix_branch'), + `recovery sentinel must record reviewfix_branch alongside worktree_path; fields=${JSON.stringify(fields)}`); + assert.ok(fields.includes('worktree_path'), + `recovery sentinel must record worktree_path; fields=${JSON.stringify(fields)}`); + }); +}); + +describe('Bug #2990 (#3001 CR): recovery code reads reviewfix_branch from sentinel and deletes the orphan branch', () => { + const md = fs.readFileSync(AGENT_PATH, 'utf-8'); + + test('recovery node script extracts reviewfix_branch from parsed sentinel', () => { + // Find the recovery `node -e '...'` block (NOT the sentinel-write one). + // Anchor on "recovery sentinel from a prior interrupted run". + const headerIdx = md.indexOf('Detected pre-existing recovery sentinel'); + assert.notEqual(headerIdx, -1); + const nodeStart = md.indexOf("node -e '", headerIdx); + assert.notEqual(nodeStart, -1); + const nodeEnd = md.indexOf("' \"$sentinel\"", nodeStart); + assert.notEqual(nodeEnd, -1); + const nodeBlock = md.slice(nodeStart, nodeEnd); + // Both fields must be referenced by parsed.. + assert.ok(nodeBlock.includes('parsed.reviewfix_branch'), + 'recovery node script must extract parsed.reviewfix_branch from the sentinel'); + assert.ok(nodeBlock.includes('parsed.worktree_path'), + 'recovery node script must extract parsed.worktree_path from the sentinel'); + }); + + test('recovery shell deletes the orphan reviewfix branch when present', () => { + // The recovery block (between sentinel detection and `rm -f "$sentinel"`) + // must call `git branch -D "$prior_branch"` (best-effort, with || true). + const sentinelIdx = md.indexOf('Detected pre-existing recovery sentinel'); + const rmIdx = md.indexOf('rm -f "$sentinel"', sentinelIdx); + assert.notEqual(rmIdx, -1); + const recoveryBlock = md.slice(sentinelIdx, rmIdx); + assert.ok(/git\s+branch\s+-D\s+"\$prior_branch"/.test(recoveryBlock), + `recovery block must contain \`git branch -D "$prior_branch"\`; got: ${recoveryBlock.slice(0, 500)}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2686-review-fix-worktree.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2686-review-fix-worktree (consolidation epic #1969 B7 #1976)", () => { +/** + * Regression test for bug #2686 + * + * The gsd-code-fixer agent (spawned by /gsd-code-review-fix) operated directly + * against the main working tree. When it ran concurrently with a foreground + * session both processes raced for HEAD, the index, and on-disk files. The + * foreground session's next commit could land on the wrong branch (whichever + * branch the agent last checked out). + * + * Fix: the agent's working instructions must include `git worktree add` as the + * FIRST git operation, run ALL subsequent git operations inside that worktree + * path, and call `git worktree remove` for cleanup when done. + * + * This mirrors the pattern already used by every other per-issue GSD agent at + * /private/tmp/sv-. + */ + +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2686) +// The gsd-code-fixer agent's working instructions ARE the product — Claude +// executes them literally at runtime. Testing the text content tests the +// deployed contract: if the instruction is absent, the isolation guarantee +// is absent. + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +describe('bug-2686: review-fix agent worktree isolation', () => { + let agentContent; + + before(() => { + const agentPath = path.join(__dirname, '..', 'agents', 'gsd-code-fixer.md'); + assert.ok(fs.existsSync(agentPath), 'agents/gsd-code-fixer.md must exist'); + agentContent = fs.readFileSync(agentPath, 'utf-8'); + }); + + test('agent instructions include git worktree add before any branch-switching checkout or commit', () => { + const worktreePos = agentContent.indexOf('git worktree add'); + + assert.ok( + worktreePos !== -1, + 'gsd-code-fixer.md must include a "git worktree add" instruction to isolate operations from the main working tree (#2686)' + ); + + // `git checkout -- {file}` is a file-restore within the worktree — safe, not a branch switch. + // The dangerous operation is `git checkout ` (no leading --). + // Find the first branch-switching checkout (pattern: "git checkout " NOT followed by "--"). + const branchCheckoutMatch = /git checkout (?!--)/.exec(agentContent); + if (branchCheckoutMatch) { + const branchCheckoutPos = branchCheckoutMatch.index; + assert.ok( + worktreePos < branchCheckoutPos, + 'git worktree add must appear before any branch-switching git checkout in the agent instructions' + ); + } + + // commit command must come after worktree setup — the fixer may use + // either `git commit` directly or `gsd-sdk query commit` + const commitMatch = /(?:git commit|gsd-sdk query commit)/.exec(agentContent); + if (commitMatch) { + const commitPos = commitMatch.index; + assert.ok( + worktreePos < commitPos, + 'git worktree add must appear before any commit command in the agent instructions' + ); + } + }); + + test('agent instructions include worktree cleanup after completion', () => { + assert.ok( + agentContent.includes('git worktree remove') || agentContent.includes('worktree remove'), + 'gsd-code-fixer.md must include worktree cleanup (git worktree remove) to avoid leaking tmp directories (#2686)' + ); + }); + + test('agent instructions use a /tmp path for the worktree', () => { + // Require either a literal /tmp/sv- path or a variable assignment to /tmp/sv- + // (e.g. `wt=$(mktemp -d "/tmp/sv-..."`). Bare `$wt` or `wt=` references + // without a /tmp/sv- assignment are not sufficient. + const hasTmpWorktreePath = + /\/tmp\/sv-/.test(agentContent) || + /\bwt\s*=\s*["']?\/tmp\/sv-/.test(agentContent); + assert.ok( + hasTmpWorktreePath, + 'gsd-code-fixer.md must define a worktree variable at a /tmp/sv-... path, consistent with other GSD agents (#2686)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2500-codebase-mapper-arch-rich-format (consolidation epic #1969 B7 #1976)", () => { +/** + * Enhancement #2500: gsd-codebase-mapper (arch focus) rich architecture output + * + * The codebase/ARCHITECTURE.md produced by gsd-codebase-mapper was a sparse + * structural inventory — file listings and module relationships. After a major + * refactor, research/ARCHITECTURE.md (created at /gsd-new-project) goes stale + * with no refresh command. This enhancement enriches the codebase mapper's + * arch-focus template to match the richness of the research version: + * - ASCII system overview diagram + * - Data flow traces with numbered steps and code references + * - Component responsibility table (component → responsibility → file) + * - Critical architectural constraints + * - Anti-patterns specific to the codebase + * - marker at top (maintainer request) + * + * The agent's template text IS what the runtime executes, so testing + * the template content directly tests the deployed contract. + */ + +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2500) +// The gsd-codebase-mapper ARCHITECTURE.md template is the instruction set +// executed by the LLM at runtime. Testing its text content tests whether the +// deployed agent will produce rich architecture docs as required by #2500. + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-codebase-mapper.md'); + +describe('enh-2500: gsd-codebase-mapper arch focus — rich architecture output', () => { + let agentContent; + let archTemplate; + + before(() => { + assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-codebase-mapper.md must exist'); + agentContent = fs.readFileSync(AGENT_PATH, 'utf-8'); + + // Isolate the ARCHITECTURE.md template section from the agent file. + // End boundary is the STRUCTURE.md Template heading that immediately follows it. + const archStart = agentContent.indexOf('## ARCHITECTURE.md Template (arch focus)'); + assert.ok(archStart !== -1, 'agent must contain an ARCHITECTURE.md Template (arch focus) section'); + + const archEnd = agentContent.indexOf('## STRUCTURE.md Template (arch focus)', archStart + 1); + archTemplate = archEnd !== -1 + ? agentContent.slice(archStart, archEnd) + : agentContent.slice(archStart); + }); + + test('template includes a refreshed date marker', () => { + assert.ok( + archTemplate.includes(' marker so users can see when the doc was last generated (#2500 maintainer requirement)' + ); + }); + + test('template includes an ASCII system overview diagram', () => { + // ASCII diagrams use box-drawing characters or at minimum ┌/└/│/─ or +/|/- + const hasAsciiDiagram = + archTemplate.includes('┌') || + archTemplate.includes('└') || + archTemplate.includes('│') || + archTemplate.includes('+--') || + archTemplate.includes('+-') || + archTemplate.includes('→') || + archTemplate.includes('↓') || + archTemplate.includes('↑'); + + assert.ok( + hasAsciiDiagram, + 'ARCHITECTURE.md template must include an ASCII system overview diagram (box-drawing characters or flow arrows) as required by #2500' + ); + }); + + test('template includes System Overview section header', () => { + assert.ok( + archTemplate.includes('System Overview') || archTemplate.includes('system overview'), + 'ARCHITECTURE.md template must include a "System Overview" section for the ASCII diagram (#2500)' + ); + }); + + test('template includes a component responsibility table with required columns', () => { + // Must have a markdown table with component, responsibility, and file columns + const hasComponentCol = + archTemplate.includes('Component') || archTemplate.includes('component'); + const hasResponsibilityCol = + archTemplate.includes('Responsibility') || archTemplate.includes('responsibility'); + const hasFileCol = + archTemplate.includes('File') || archTemplate.includes('file'); + + assert.ok( + hasComponentCol && hasResponsibilityCol && hasFileCol, + 'ARCHITECTURE.md template must include a component responsibility table with Component, Responsibility, and File columns (#2500)' + ); + }); + + test('template includes data flow traces with numbered steps', () => { + const hasPrimaryRequestPath = /###\s+Primary Request Path/i.test(archTemplate); + // [^\n]+ + \r?\n is CRLF-tolerant: .+ doesn't match \r in JS regex by + // default, so \r before the literal \n in CRLF content kills the match. + const hasThreeNumberedSteps = /^\s*1\.[^\n]+\r?\n\s*2\.[^\n]+\r?\n\s*3\./m.test(archTemplate); + const hasFileLineRefs = /\(`\[.*:(?:line|\d+)\]`\)/.test(archTemplate); + + assert.ok( + hasPrimaryRequestPath && hasThreeNumberedSteps && hasFileLineRefs, + 'ARCHITECTURE.md template must include a "Primary Request Path" section with numbered steps and file:line references (#2500)' + ); + }); + + test('template includes architectural constraints section', () => { + const hasConstraints = + /##\s+Architectural Constraints/i.test(archTemplate) && + /\bThreading\b/.test(archTemplate) && + /\bGlobal state\b/i.test(archTemplate) && + /\bCircular imports\b/i.test(archTemplate); + + assert.ok( + hasConstraints, + 'ARCHITECTURE.md template must include an "Architectural Constraints" section with Threading, Global state, and Circular imports categories (#2500)' + ); + }); + + test('template includes anti-patterns section', () => { + assert.ok( + archTemplate.includes('Anti-pattern') || + archTemplate.includes('Anti-Pattern') || + archTemplate.includes('anti-pattern'), + 'ARCHITECTURE.md template must include an anti-patterns section specific to the codebase (#2500)' + ); + }); +}); + }); +} diff --git a/tests/bug-214-phase-researcher-write-truncation-contract.test.cjs b/tests/bug-214-phase-researcher-write-truncation-contract.test.cjs deleted file mode 100644 index 4d3f26279..000000000 --- a/tests/bug-214-phase-researcher-write-truncation-contract.test.cjs +++ /dev/null @@ -1,61 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const RESEARCHER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-phase-researcher.md'); - -function readResearcherPrompt() { - return fs.readFileSync(RESEARCHER_PATH, 'utf8'); -} - -describe('bug #214: phase researcher must survive OpenCode write-tool truncation', () => { - test('Step 6 documents the large-file / truncation fallback write contract', () => { - const prompt = readResearcherPrompt(); - - assert.match( - prompt, - /truncat/i, - 'Step 6 must name the truncation failure mode.' - ); - assert.match( - prompt, - /incrementa/i, - 'Step 6 must instruct incremental construction on large files.' - ); - assert.match( - prompt, - //, - 'Step 6 must define the continuation sentinel for incremental writes.' - ); - assert.match( - prompt, - /do NOT retry the same oversized call/i, - 'Step 6 must forbid identical retry of the oversized write (doom-loop guard).' - ); - assert.match( - prompt, - /do NOT silently fall back to returning content/i, - 'Step 6 must forbid silent fallback to returning content.' - ); - assert.match( - prompt, - /`Read` the file, then `Edit`/i, - 'Step 6 must require Read before Edit (OpenCode edit requires a prior Read).' - ); - assert.match( - prompt, - /no trailing sentinel/i, - 'Step 6 must instruct removing the sentinel on the final section.' - ); - assert.match( - prompt, - /write the whole file in a single `Write` call/i, - 'Step 6 must keep single-Write as the default path (no regression for non-truncating runtimes).' - ); - }); -}); diff --git a/tests/bug-214-writer-agents-write-truncation-contract.test.cjs b/tests/bug-214-writer-agents-write-truncation-contract.test.cjs deleted file mode 100644 index 59df4f6f1..000000000 --- a/tests/bug-214-writer-agents-write-truncation-contract.test.cjs +++ /dev/null @@ -1,96 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.resolve(__dirname, '..'); - -// Every agent that writes a large file in a single Write call must carry the -// same truncation-resilient write contract added for bug #214. OpenCode shares -// OUTPUT_TOKEN_MAX=32000 with the thinking budget (upstream opencode#18108), so -// an oversized single `write` tool call is truncated mid-payload, yielding -// `JSON Parse error: Expected '}'`, and OpenCode then doom-loops. gsd-phase-researcher -// is locked by its own bug-214 test; this locks the other large-file writers. -const WRITER_AGENTS = [ - 'gsd-research-synthesizer', - 'gsd-planner', - 'gsd-executor', - 'gsd-domain-researcher', - 'gsd-project-researcher', - 'gsd-ui-researcher', -]; - -function readAgent(name) { - return fs.readFileSync(path.join(REPO_ROOT, 'agents', `${name}.md`), 'utf8'); -} - -describe('bug #214: large-file writer agents must survive write-tool truncation', () => { - for (const name of WRITER_AGENTS) { - describe(name, () => { - const prompt = readAgent(name); - - test('keeps single-Write as the default path', () => { - assert.match( - prompt, - /in a single `Write` call/i, - `${name}: must keep single-Write as the default (no regression for non-truncating runtimes).` - ); - }); - - test('names the truncation failure mode', () => { - assert.match(prompt, /truncat/i, `${name}: must name the truncation failure mode.`); - }); - - test('instructs incremental construction on large files', () => { - assert.match( - prompt, - /incrementa/i, - `${name}: must instruct incremental construction on large files.` - ); - }); - - test('defines the continuation sentinel', () => { - assert.match( - prompt, - //, - `${name}: must define the continuation sentinel for incremental writes.` - ); - }); - - test('forbids identical retry of the oversized write (doom-loop guard)', () => { - assert.match( - prompt, - /do NOT retry the same oversized call/i, - `${name}: must forbid identical retry of the oversized write.` - ); - }); - - test('requires Read before Edit', () => { - assert.match( - prompt, - /`Read` the file, then `Edit`/i, - `${name}: must require Read before Edit (OpenCode edit requires a prior Read).` - ); - }); - - test('instructs removing the sentinel on the final section', () => { - assert.match( - prompt, - /no trailing sentinel/i, - `${name}: must instruct removing the sentinel on the final section.` - ); - }); - - test('forbids silent fallback to returning content', () => { - assert.match( - prompt, - /do NOT silently fall back to returning content/i, - `${name}: must forbid silent fallback to returning content.` - ); - }); - }); - } -}); diff --git a/tests/bug-2346-agent-read-loop-guards.test.cjs b/tests/bug-2346-agent-read-loop-guards.test.cjs deleted file mode 100644 index 303f5b66b..000000000 --- a/tests/bug-2346-agent-read-loop-guards.test.cjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Regression tests for bug #2346 - * - * Multiple GSD agents (gsd-ui-checker, gsd-planner) entered unbounded Read - * loops — re-reading the same file hundreds of times in a single run. Root - * cause: no explicit no-re-read rule or tool-budget cap in the agent prompts. - * gsd-pattern-mapper was fixed in #2312; this covers the remaining agents. - * - * Fix: add block to each affected agent with: - * 1. No-re-read constraint - * 2. Large-file strategy (Grep first, then targeted offset/limit Read) - * 3. Stop-on-sufficient-evidence rule (where applicable) - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -// allow-test-rule: source-text-is-the-product -// The block in agent .md files IS the fix — it is the AI instruction that -// prevents unbounded Read loops. There is no behavioral equivalent without a live LLM run. -describe('bug #2346: agent read loop guards', () => { - - describe('gsd-ui-checker', () => { - const agentPath = path.join(AGENTS_DIR, 'gsd-ui-checker.md'); - const content = fs.readFileSync(agentPath, 'utf-8'); - - test('agent file exists', () => { - assert.ok(fs.existsSync(agentPath), 'agents/gsd-ui-checker.md must exist'); - }); - - test('has block', () => { - assert.ok( - content.includes(''), - 'gsd-ui-checker.md must have a block to prevent unbounded read loops (#2346)' - ); - }); - - test('critical_rules contains no-re-read constraint', () => { - const rulesStart = content.indexOf(''); - const rulesEnd = content.indexOf('', rulesStart); - assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be complete'); - const rulesBlock = content.slice(rulesStart, rulesEnd); - assert.ok( - rulesBlock.includes('re-read') || rulesBlock.includes('re read'), - 'critical_rules must include a no-re-read rule' - ); - }); - - test('critical_rules appears before success_criteria', () => { - const rulesIdx = content.indexOf(''); - const successIdx = content.indexOf(''); - assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist'); - assert.ok( - rulesIdx < successIdx, - ' must appear before ' - ); - }); - }); - - describe('gsd-planner', () => { - const agentPath = path.join(AGENTS_DIR, 'gsd-planner.md'); - const content = fs.readFileSync(agentPath, 'utf-8'); - - test('agent file exists', () => { - assert.ok(fs.existsSync(agentPath), 'agents/gsd-planner.md must exist'); - }); - - test('has block', () => { - assert.ok( - content.includes(''), - 'gsd-planner.md must have a block to prevent unbounded read loops (#2346)' - ); - }); - - test('critical_rules contains no-re-read constraint', () => { - const rulesStart = content.indexOf(''); - const rulesEnd = content.indexOf('', rulesStart); - assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be complete'); - const rulesBlock = content.slice(rulesStart, rulesEnd); - assert.ok( - rulesBlock.includes('re-read') || rulesBlock.includes('re read'), - 'critical_rules must include a no-re-read rule' - ); - }); - - test('critical_rules appears before success_criteria', () => { - const rulesIdx = content.indexOf(''); - const successIdx = content.lastIndexOf(''); - assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both sections must exist'); - assert.ok( - rulesIdx < successIdx, - ' must appear before ' - ); - }); - }); - -}); diff --git a/tests/bug-2351-intel-kilo-layout.test.cjs b/tests/bug-2351-intel-kilo-layout.test.cjs deleted file mode 100644 index c305329a9..000000000 --- a/tests/bug-2351-intel-kilo-layout.test.cjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Regression test for bug #2351 - * - * gsd-intel-updater used hardcoded canonical paths (`agents/*.md`, - * `commands/gsd/*.md`, `hooks/*.js`, etc.) that assumed the standard - * `.claude/` runtime layout. Under a `.kilo` install, the runtime root is - * `.kilo/`, and the command directory is `command/` (not `commands/gsd/`). - * Globs against the old paths returned no results, producing semantically - * empty intel files (`"entries": {}`). - * - * Fix: add runtime layout detection and a mapping table so the agent - * resolves paths against the correct root. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-intel-updater.md'); - -describe('bug #2351: intel updater kilo layout support', () => { - let content; - - test('agent file exists', () => { - assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-intel-updater.md must exist'); - content = fs.readFileSync(AGENT_PATH, 'utf-8'); - }); - - test('scope section includes layout detection step', () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - const hasDetection = - content.includes('ls -d .kilo') || - content.includes('Runtime layout detection') || - content.includes('detected layout') || - content.includes('layout detection'); - assert.ok( - hasDetection, - 'gsd-intel-updater.md must instruct the agent to detect the runtime layout ' + - '(.kilo vs .claude) before resolving canonical paths (#2351)' - ); - }); - - test('scope section maps .kilo/agents path', () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - assert.ok( - content.includes('.kilo/agents'), - 'scope section must include the .kilo/agents/*.md path so agent count is correct under kilo layout' - ); - }); - - test('scope section maps .kilo/command path (not commands/gsd)', () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - assert.ok( - content.includes('.kilo/command'), - 'scope section must include .kilo/command path — kilo uses "command/" not "commands/gsd/"' - ); - }); - - test('scope section maps .kilo/hooks path', () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - assert.ok( - content.includes('.kilo/hooks'), - 'scope section must include .kilo/hooks path for hook file counts' - ); - }); - - test('scope section retains standard layout paths for .claude installs', () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - assert.ok( - content.includes('agents/*.md') || content.includes('Standard `.claude` layout'), - 'scope section must still document the standard .claude layout paths for non-kilo installs' - ); - }); -}); diff --git a/tests/bug-2419-project-researcher-agent.test.cjs b/tests/bug-2419-project-researcher-agent.test.cjs deleted file mode 100644 index 7df888328..000000000 --- a/tests/bug-2419-project-researcher-agent.test.cjs +++ /dev/null @@ -1,112 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Reads .md/.json/.yml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -/** - * Bug #2419: gsd-project-researcher agent type not found - * - * When gsd-new-project spawns gsd-project-researcher subagents, it fails with - * "agent type not found" if the user has a local-only install (agents in - * .claude/agents/ of a different project, not the global ~/.claude/agents/). - * - * Fix: new-project.md and new-milestone.md must parse agents_installed from - * the init JSON and warn the user (rather than silently failing) when agents - * are missing. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const NEW_PROJECT_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-project.md'); -const NEW_MILESTONE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-milestone.md'); -const AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -describe('gsd-project-researcher agent registration (#2419)', () => { - test('gsd-project-researcher.md exists in agents source dir', () => { - const agentFile = path.join(AGENTS_DIR, 'gsd-project-researcher.md'); - assert.ok( - fs.existsSync(agentFile), - 'agents/gsd-project-researcher.md must exist in the source agents directory' - ); - }); - - test('gsd-project-researcher.md has correct name in frontmatter', () => { - const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-project-researcher.md'), 'utf-8'); - assert.ok( - content.includes('name: gsd-project-researcher'), - 'agents/gsd-project-researcher.md must have name: gsd-project-researcher in frontmatter' - ); - }); - - test('new-project.md parses agents_installed from init JSON', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok( - content.includes('agents_installed'), - 'new-project.md must parse agents_installed from the init JSON to detect missing agents' - ); - }); - - test('new-project.md warns user when agents_installed is false', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok( - content.includes('agents_installed') && content.includes('agent type not found') || - content.includes('agents_installed') && content.includes('missing') || - content.includes('agents_installed') && content.includes('not installed'), - 'new-project.md must warn the user when agents are not installed (agents_installed is false)' - ); - }); - - test('new-project.md reports required-agent and skill-payload diagnostics separately', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok(content.includes('required_agents_installed'), - 'new-project.md must parse required_agents_installed from init JSON'); - assert.ok(content.includes('missing_required_agents'), - 'new-project.md must report missing required new-project agents separately'); - assert.ok(content.includes('agent_skill_payloads_available'), - 'new-project.md must distinguish skill payload availability from agent definitions'); - assert.ok(content.includes('agents_dir'), - 'new-project.md must show which agents directory was checked'); - }); - - test('new-milestone.md parses agents_installed from init JSON', () => { - const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); - assert.ok( - content.includes('agents_installed'), - 'new-milestone.md must parse agents_installed from the init JSON to detect missing agents' - ); - }); - - test('new-milestone.md warns user when agents_installed is false', () => { - const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); - assert.ok( - content.includes('agents_installed') && ( - content.includes('agent type not found') || - content.includes('missing') || - content.includes('not installed') - ), - 'new-milestone.md must warn the user when agents are not installed (agents_installed is false)' - ); - }); - - test('new-project.md lists gsd-project-researcher in available_agent_types', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - const agentTypesMatch = content.match(/([\s\S]*?)<\/available_agent_types>/); - assert.ok(agentTypesMatch, 'new-project.md must have section'); - assert.ok( - agentTypesMatch[1].includes('gsd-project-researcher'), - 'new-project.md must list gsd-project-researcher' - ); - }); - - test('new-milestone.md lists gsd-project-researcher in available_agent_types', () => { - const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); - const agentTypesMatch = content.match(/([\s\S]*?)<\/available_agent_types>/); - assert.ok(agentTypesMatch, 'new-milestone.md must have section'); - assert.ok( - agentTypesMatch[1].includes('gsd-project-researcher'), - 'new-milestone.md must list gsd-project-researcher' - ); - }); -}); diff --git a/tests/bug-2421-planner-grep-gate-hygiene.test.cjs b/tests/bug-2421-planner-grep-gate-hygiene.test.cjs deleted file mode 100644 index cdf834735..000000000 --- a/tests/bug-2421-planner-grep-gate-hygiene.test.cjs +++ /dev/null @@ -1,74 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Bug #2421: gsd-planner emits grep-count acceptance gates that count comment text - * - * The planner must instruct agents to use comment-aware grep patterns in - * verify blocks. Without this, descriptive comments in file - * headers count against the gate and force authors to reword them — the - * "self-invalidating grep gate" anti-pattern. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const PLANNER_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); - -describe('gsd-planner grep gate hygiene (#2421)', () => { - test('gsd-planner.md exists in agents source dir', () => { - assert.ok(fs.existsSync(PLANNER_PATH), 'agents/gsd-planner.md must exist'); - }); - - test('gsd-planner.md contains Grep gate hygiene rule', () => { - const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); - assert.ok( - content.includes('Grep gate hygiene') || content.includes('grep gate hygiene'), - 'gsd-planner.md must contain a "Grep gate hygiene" rule to prevent self-invalidating grep gates' - ); - }); - - test('gsd-planner.md explains self-invalidating grep gate anti-pattern', () => { - const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); - assert.ok( - content.includes('self-invalidating'), - 'gsd-planner.md must describe the "self-invalidating" grep gate anti-pattern' - ); - }); - - test('gsd-planner.md provides comment-stripping grep example', () => { - const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); - // Must show a pattern that excludes comment lines (grep -v or grep -vE) - assert.ok( - content.includes('grep -v') || content.includes('grep -vE') || content.includes('-v '), - 'gsd-planner.md must provide a comment-stripping grep example (grep -v or grep -vE)' - ); - }); - - test('gsd-planner.md warns against bare zero-count grep gates on whole files', () => { - const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); - assert.ok( - content.includes('== 0') || content.includes('zero-count') || content.includes('zero count'), - 'gsd-planner.md must warn against bare zero-count grep gates without comment exclusion' - ); - }); - - test('gsd-planner.md grep gate hygiene rule appears after Nyquist Rule', () => { - const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); - const nyquistIdx = content.indexOf('Nyquist Rule'); - const grepGateIdx = content.indexOf('grep gate hygiene') !== -1 - ? content.indexOf('grep gate hygiene') - : content.indexOf('Grep gate hygiene'); - - assert.ok(nyquistIdx !== -1, 'Nyquist Rule must be present in gsd-planner.md'); - assert.ok(grepGateIdx !== -1, 'Grep gate hygiene must be present in gsd-planner.md'); - assert.ok( - grepGateIdx > nyquistIdx, - `Grep gate hygiene rule (at ${grepGateIdx}) must appear after Nyquist Rule (at ${nyquistIdx})` - ); - }); -}); diff --git a/tests/bug-2686-review-fix-worktree.test.cjs b/tests/bug-2686-review-fix-worktree.test.cjs deleted file mode 100644 index 0542e36dc..000000000 --- a/tests/bug-2686-review-fix-worktree.test.cjs +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Regression test for bug #2686 - * - * The gsd-code-fixer agent (spawned by /gsd-code-review-fix) operated directly - * against the main working tree. When it ran concurrently with a foreground - * session both processes raced for HEAD, the index, and on-disk files. The - * foreground session's next commit could land on the wrong branch (whichever - * branch the agent last checked out). - * - * Fix: the agent's working instructions must include `git worktree add` as the - * FIRST git operation, run ALL subsequent git operations inside that worktree - * path, and call `git worktree remove` for cleanup when done. - * - * This mirrors the pattern already used by every other per-issue GSD agent at - * /private/tmp/sv-. - */ - -'use strict'; - -// allow-test-rule: source-text-is-the-product -// The gsd-code-fixer agent's working instructions ARE the product — Claude -// executes them literally at runtime. Testing the text content tests the -// deployed contract: if the instruction is absent, the isolation guarantee -// is absent. - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -describe('bug-2686: review-fix agent worktree isolation', () => { - let agentContent; - - before(() => { - const agentPath = path.join(__dirname, '..', 'agents', 'gsd-code-fixer.md'); - assert.ok(fs.existsSync(agentPath), 'agents/gsd-code-fixer.md must exist'); - agentContent = fs.readFileSync(agentPath, 'utf-8'); - }); - - test('agent instructions include git worktree add before any branch-switching checkout or commit', () => { - const worktreePos = agentContent.indexOf('git worktree add'); - - assert.ok( - worktreePos !== -1, - 'gsd-code-fixer.md must include a "git worktree add" instruction to isolate operations from the main working tree (#2686)' - ); - - // `git checkout -- {file}` is a file-restore within the worktree — safe, not a branch switch. - // The dangerous operation is `git checkout ` (no leading --). - // Find the first branch-switching checkout (pattern: "git checkout " NOT followed by "--"). - const branchCheckoutMatch = /git checkout (?!--)/.exec(agentContent); - if (branchCheckoutMatch) { - const branchCheckoutPos = branchCheckoutMatch.index; - assert.ok( - worktreePos < branchCheckoutPos, - 'git worktree add must appear before any branch-switching git checkout in the agent instructions' - ); - } - - // commit command must come after worktree setup — the fixer may use - // either `git commit` directly or `gsd-sdk query commit` - const commitMatch = /(?:git commit|gsd-sdk query commit)/.exec(agentContent); - if (commitMatch) { - const commitPos = commitMatch.index; - assert.ok( - worktreePos < commitPos, - 'git worktree add must appear before any commit command in the agent instructions' - ); - } - }); - - test('agent instructions include worktree cleanup after completion', () => { - assert.ok( - agentContent.includes('git worktree remove') || agentContent.includes('worktree remove'), - 'gsd-code-fixer.md must include worktree cleanup (git worktree remove) to avoid leaking tmp directories (#2686)' - ); - }); - - test('agent instructions use a /tmp path for the worktree', () => { - // Require either a literal /tmp/sv- path or a variable assignment to /tmp/sv- - // (e.g. `wt=$(mktemp -d "/tmp/sv-..."`). Bare `$wt` or `wt=` references - // without a /tmp/sv- assignment are not sufficient. - const hasTmpWorktreePath = - /\/tmp\/sv-/.test(agentContent) || - /\bwt\s*=\s*["']?\/tmp\/sv-/.test(agentContent); - assert.ok( - hasTmpWorktreePath, - 'gsd-code-fixer.md must define a worktree variable at a /tmp/sv-... path, consistent with other GSD agents (#2686)' - ); - }); -}); diff --git a/tests/bug-2990-code-fixer-worktree-branch.test.cjs b/tests/bug-2990-code-fixer-worktree-branch.test.cjs deleted file mode 100644 index 6df439442..000000000 --- a/tests/bug-2990-code-fixer-worktree-branch.test.cjs +++ /dev/null @@ -1,208 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// agents/gsd-code-fixer.md is the deployed agent definition the runtime -// loads. Parsing its bash code blocks into structured invocation records -// (extractCleanupGitInvocations + the recovery-block parsers below) IS -// testing the runtime contract — what command sequence the agent -// actually documents and executes. The .match() calls extract typed -// fields from a known-shape product file, then assertions go against -// those typed fields, not against the raw markdown text. - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2990: gsd-code-fixer worktree setup fails when current branch - * is already checked out in the main repo. - * - * The original agent definition called `git worktree add "$wt" "$branch"`, - * where `$branch` was the user's currently-checked-out branch. Git refuses - * to check out the same branch in two worktrees by default, so the setup - * failed before the agent could do any work. - * - * Fix: create a NEW branch `gsd-reviewfix/${padded_phase}-$$` and attach - * the worktree to it via `git worktree add -b "$reviewfix_branch" "$wt" - * "$branch"`. The cleanup tail then fast-forwards `$branch` to - * `$reviewfix_branch` so the user's branch captures the agent's commits. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-code-fixer.md'); - -function parseWorktreeAddInvocations(markdown) { - // Pull `git worktree add ...` calls and classify each into structured - // records: hasNewBranchFlag (uses -b $reviewfix_branch) vs attachesToBareBranch - // ($wt $branch). Skip occurrences inside markdown inline code (backticks) - // or bash comments -- those are documentation citations of the OLD broken - // pattern, not executable instructions. - const invocations = []; - const lines = markdown.split('\n'); - for (const line of lines) { - const idx = line.indexOf('git worktree add'); - if (idx === -1) continue; - // Skip if inside backticks: the substring up to the match has an odd - // number of backticks, the call is inside an inline code span. - const before = line.slice(0, idx); - const backticksBefore = (before.match(/`/g) || []).length; - if (backticksBefore % 2 === 1) continue; - // Skip if the line is a bash comment (after stripping leading whitespace). - if (line.trimStart().startsWith('#')) continue; - const argstr = line.slice(idx + 'git worktree add'.length).trim(); - invocations.push({ - raw: argstr, - hasNewBranchFlag: /(?:^|\s)-b\s+["']?\$reviewfix_branch["']?/.test(argstr), - attachesToBareBranch: /^["']?\$wt["']?\s+["']?\$branch["']?\b/.test(argstr), - }); - } - return invocations; -} - -describe('Bug #2990: gsd-code-fixer worktree attaches to a NEW branch, not the user-checked-out one', () => { - const md = fs.readFileSync(AGENT_PATH, 'utf-8'); - const invocations = parseWorktreeAddInvocations(md); - - test('sanity: at least one git-worktree-add invocation exists in the agent definition', () => { - assert.ok(invocations.length > 0, - 'expected gsd-code-fixer.md to document at least one git worktree add invocation'); - }); - - test('every git-worktree-add invocation uses -b $reviewfix_branch (not bare $branch)', () => { - const violations = invocations.filter(inv => inv.attachesToBareBranch); - assert.deepEqual( - violations.map(v => v.raw), - [], - `worktree-add invocations attaching to bare $branch (#2990): ${JSON.stringify(violations.map(v => v.raw), null, 2)}`, - ); - }); - - test('the canonical setup invocation uses -b "$reviewfix_branch" "$wt" "$branch"', () => { - const setupInvocations = invocations.filter(inv => inv.hasNewBranchFlag); - assert.ok(setupInvocations.length >= 1, - `expected at least one git-worktree-add invocation with -b "$reviewfix_branch" -- found: ${JSON.stringify(invocations.map(i => i.raw), null, 2)}`); - }); -}); - -/** - * Extract the cleanup-tail bash block from the agent .md, then parse it into - * an ordered array of `git ...` invocation records. Per-record assertions go - * against the structured records, not the raw markdown text. Anchor on the - * "Cleanup tail" header to scope to the right block (the file has multiple - * fenced bash blocks; we only want the cleanup one). - */ -function extractCleanupGitInvocations(markdown) { - // Find the cleanup tail header and the fenced bash block that follows. - const headerIdx = markdown.indexOf('**Cleanup tail (transactional'); - if (headerIdx === -1) return null; - const fenceStart = markdown.indexOf('```bash', headerIdx); - if (fenceStart === -1) return null; - const fenceEnd = markdown.indexOf('```', fenceStart + '```bash'.length); - if (fenceEnd === -1) return null; - const block = markdown.slice(fenceStart + '```bash'.length, fenceEnd); - - // Tokenize each non-comment, non-blank line into structured records. - const lines = block.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); - const records = []; - for (const line of lines) { - // Skip occurrences inside backticks (these would be inline-code - // citations of the OLD pattern, not executable). The cleanup fenced - // block is bash, but inline backticks can still appear inside echo - // strings — guard anyway. - const ticksBefore = (line.match(/`/g) || []).length; - if (ticksBefore && ticksBefore % 2 === 1) continue; - if (!line.includes('git ') && !line.startsWith('git ')) continue; - records.push({ - raw: line, - // Strip leading `git -C "..."`/`git -C $main_repo` so the verb-only - // form stays comparable across direct and -C invocations. - verb: (() => { - const m = line.match(/^git\s+(?:-C\s+\S+\s+)?(\S+)/); - return m ? m[1] : null; - })(), - // Did this line target the temp reviewfix branch by variable name? - targetsReviewfixBranch: /\$reviewfix_branch\b/.test(line) || /"\$reviewfix_branch"/.test(line), - // Is this the merge step? Captures the flag too. - isMergeFfOnly: /\bmerge\s+--ff-only\b/.test(line), - // Is this the branch-delete step? - isBranchDelete: /\bbranch\s+-D\b/.test(line), - }); - } - return records; -} - -describe('Bug #2990: cleanup tail fast-forwards $branch and deletes the temp branch on success', () => { - const md = fs.readFileSync(AGENT_PATH, 'utf-8'); - const records = extractCleanupGitInvocations(md); - - test('cleanup tail bash block exists and is parseable', () => { - assert.notEqual(records, null, 'expected to find a "Cleanup tail" bash block in agents/gsd-code-fixer.md'); - assert.ok(records.length > 0, 'expected at least one git invocation in the cleanup tail'); - }); - - test('cleanup contains exactly one merge --ff-only against $reviewfix_branch', () => { - const merges = records.filter(r => r.isMergeFfOnly); - assert.equal(merges.length, 1, `expected exactly 1 ff-only merge, got ${merges.length}: ${JSON.stringify(merges, null, 2)}`); - assert.equal(merges[0].targetsReviewfixBranch, true, 'merge --ff-only must target $reviewfix_branch'); - }); - - test('cleanup contains exactly one git branch -D for $reviewfix_branch', () => { - const deletes = records.filter(r => r.isBranchDelete); - assert.equal(deletes.length, 1, `expected exactly 1 branch -D, got ${deletes.length}`); - assert.equal(deletes[0].targetsReviewfixBranch, true, 'branch -D must target $reviewfix_branch'); - }); - - test('merge --ff-only precedes branch -D in the cleanup ordering', () => { - const mergeIdx = records.findIndex(r => r.isMergeFfOnly); - const deleteIdx = records.findIndex(r => r.isBranchDelete); - assert.ok(mergeIdx >= 0 && deleteIdx >= 0); - assert.ok(mergeIdx < deleteIdx, - `merge must run before branch delete (merge=${mergeIdx}, delete=${deleteIdx}); otherwise commits could be lost on merge failure`); - }); - - test('recovery sentinel JSON shape records reviewfix_branch alongside worktree_path', () => { - // Find the writeFileSync call that constructs the sentinel JSON. - // Parse the JSON.stringify argument list to extract the field names. - const match = md.match(/fs\.writeFileSync\(sentinelPath,\s*JSON\.stringify\(\{([^}]+)\}/); - assert.notEqual(match, null, 'expected JSON.stringify({...}) inside the sentinel write'); - const fields = match[1].split(',').map(s => s.trim().split(':')[0].trim()).filter(Boolean); - assert.ok(fields.includes('reviewfix_branch'), - `recovery sentinel must record reviewfix_branch alongside worktree_path; fields=${JSON.stringify(fields)}`); - assert.ok(fields.includes('worktree_path'), - `recovery sentinel must record worktree_path; fields=${JSON.stringify(fields)}`); - }); -}); - -describe('Bug #2990 (#3001 CR): recovery code reads reviewfix_branch from sentinel and deletes the orphan branch', () => { - const md = fs.readFileSync(AGENT_PATH, 'utf-8'); - - test('recovery node script extracts reviewfix_branch from parsed sentinel', () => { - // Find the recovery `node -e '...'` block (NOT the sentinel-write one). - // Anchor on "recovery sentinel from a prior interrupted run". - const headerIdx = md.indexOf('Detected pre-existing recovery sentinel'); - assert.notEqual(headerIdx, -1); - const nodeStart = md.indexOf("node -e '", headerIdx); - assert.notEqual(nodeStart, -1); - const nodeEnd = md.indexOf("' \"$sentinel\"", nodeStart); - assert.notEqual(nodeEnd, -1); - const nodeBlock = md.slice(nodeStart, nodeEnd); - // Both fields must be referenced by parsed.. - assert.ok(nodeBlock.includes('parsed.reviewfix_branch'), - 'recovery node script must extract parsed.reviewfix_branch from the sentinel'); - assert.ok(nodeBlock.includes('parsed.worktree_path'), - 'recovery node script must extract parsed.worktree_path from the sentinel'); - }); - - test('recovery shell deletes the orphan reviewfix branch when present', () => { - // The recovery block (between sentinel detection and `rm -f "$sentinel"`) - // must call `git branch -D "$prior_branch"` (best-effort, with || true). - const sentinelIdx = md.indexOf('Detected pre-existing recovery sentinel'); - const rmIdx = md.indexOf('rm -f "$sentinel"', sentinelIdx); - assert.notEqual(rmIdx, -1); - const recoveryBlock = md.slice(sentinelIdx, rmIdx); - assert.ok(/git\s+branch\s+-D\s+"\$prior_branch"/.test(recoveryBlock), - `recovery block must contain \`git branch -D "$prior_branch"\`; got: ${recoveryBlock.slice(0, 500)}`); - }); -}); diff --git a/tests/bug-3087-planner-directive-language.test.cjs b/tests/bug-3087-planner-directive-language.test.cjs deleted file mode 100644 index a54e5ffea..000000000 --- a/tests/bug-3087-planner-directive-language.test.cjs +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -// Regression guard for bug #3087. -// -// Between v1.38.3 and v1.38.4, agents/gsd-planner.md had 10 instances of -// CRITICAL/MANDATORY/ALWAYS/MUST directive emphasis systematically removed. -// The change was undocumented and conflicts with the stated intent of PR #2489 -// (the sycophancy-hardening pass that shipped in the same release). This test -// enforces the restored directive language so the demotion cannot recur silently. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -let src; -try { - src = fs.readFileSync(path.join(ROOT, 'agents', 'gsd-planner.md'), 'utf8'); -} catch (err) { - throw new Error(`agents/gsd-planner.md not found — was the file renamed? (${err.message})`); -} - -const directives = [ - { desc: 'User Decision Fidelity heading is CRITICAL', pattern: /## CRITICAL: User Decision Fidelity/ }, - { desc: 'Never Simplify heading is CRITICAL', pattern: /## CRITICAL: Never Simplify User Decisions/ }, - { desc: 'Multi-Source Audit heading is MANDATORY', pattern: /## Multi-Source Coverage Audit \(MANDATORY in every plan set\)/ }, - { desc: 'Source audit uses "Audit ALL" imperative', pattern: /Audit ALL four source types before finalizing/ }, - { desc: 'Discovery is MANDATORY', pattern: /Discovery is MANDATORY unless/ }, - { desc: 'Split signals use ALWAYS', pattern: /\*\*ALWAYS split if:\*\*/ }, - { desc: 'requirements field doc uses MUST', pattern: /\*\*MUST\*\* list requirement IDs from ROADMAP/ }, - { desc: 'Step 0 has CRITICAL requirement ID directive', pattern: /\*\*CRITICAL:\*\* Every requirement ID MUST appear/ }, - { desc: 'Write tool directive uses ALWAYS', pattern: /\*\*ALWAYS use the Write tool to create files\*\*/ }, - { desc: 'File naming convention heading is CRITICAL', pattern: /\*\*CRITICAL — File naming convention \(enforced\):\*\*/ }, -]; - -for (const { desc, pattern } of directives) { - test(`gsd-planner.md: ${desc}`, () => { - assert.ok( - pattern.test(src), - `Directive enforcement missing from gsd-planner.md: "${desc}" — pattern ${pattern} not found. ` + - `This language was demoted in v1.38.4 (PR #2489) without documentation, conflicting with ` + - `the sycophancy-hardening intent of that release. See bug #3087.`, - ); - }); -} diff --git a/tests/bug-3097-3099-executor-worktree-path-safety.test.cjs b/tests/bug-3097-3099-executor-worktree-path-safety.test.cjs deleted file mode 100644 index ef9e99fdf..000000000 --- a/tests/bug-3097-3099-executor-worktree-path-safety.test.cjs +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; -// allow-test-rule: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep - -// Regression guards for bug #3097 and #3099. -// -// #3097: gsd-executor's worktree HEAD guard used `if [ -f .git ]` to detect -// worktree mode. After a Bash `cd` out of the worktree into the main repo, -// `.git` is a DIRECTORY (not a file), so the test is false and the entire -// HEAD safety block is silently skipped. Commits then land on whatever branch -// the main repo has checked out — not the per-agent worktree branch. -// -// #3099: Executor agents construct absolute paths from `pwd` captured in the -// orchestrator context (main repo root). Edit/Write calls using these paths -// resolve to the main repo, not the worktree. git commit from the worktree -// sees a clean tree; the work is silently lost or leaks to main. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const executorSrc = fs.readFileSync( - path.join(ROOT, 'agents', 'gsd-executor.md'), 'utf8', -); -const executePhaseSrc = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8', -); - -describe('bug #3097: cwd-drift sentinel in gsd-executor.md', () => { - test('task_commit_protocol has cwd-drift assertion step (0a)', () => { - const protocolIdx = executorSrc.indexOf(''); - const protocolEnd = executorSrc.indexOf(''); - assert.ok(protocolIdx !== -1 && protocolEnd !== -1, 'task_commit_protocol block not found'); - const protocol = executorSrc.slice(protocolIdx, protocolEnd); - assert.ok( - protocol.includes('cwd') || protocol.includes('drift') || protocol.includes('gsd-spawn-toplevel'), - 'task_commit_protocol missing cwd-drift assertion step — #3097 fix not applied', - ); - }); - - test('sentinel uses git rev-parse --git-dir to detect worktree', () => { - const protocolIdx = executorSrc.indexOf(''); - const protocolEnd = executorSrc.indexOf(''); - const protocol = executorSrc.slice(protocolIdx, protocolEnd); - assert.ok( - protocol.includes('rev-parse --git-dir') || protocol.includes('worktrees/'), - 'cwd-drift detection does not use git rev-parse --git-dir or .git/worktrees/ pattern', - ); - }); - - test('cwd-drift check precedes HEAD assertion', () => { - const protocolIdx = executorSrc.indexOf(''); - const protocolEnd = executorSrc.indexOf(''); - const protocol = executorSrc.slice(protocolIdx, protocolEnd); - const driftIdx = protocol.search(/cwd.drift|gsd-spawn-toplevel|drift.*assertion/i); - const headIdx = protocol.indexOf('Pre-commit HEAD safety assertion'); - assert.ok(driftIdx !== -1, 'cwd-drift assertion not found'); - assert.ok(headIdx !== -1, 'HEAD assertion not found'); - assert.ok(driftIdx < headIdx, 'cwd-drift assertion must precede HEAD assertion (step 0a before step 0)'); - }); -}); - -describe('bug #3099: absolute-path safety guidance in gsd-executor.md', () => { - test('task_commit_protocol documents absolute-path safety', () => { - const protocolIdx = executorSrc.indexOf(''); - const protocolEnd = executorSrc.indexOf(''); - const protocol = executorSrc.slice(protocolIdx, protocolEnd); - assert.ok( - (protocol.includes('absolute') || protocol.includes('absolute-path')) && - (protocol.includes('worktree') || protocol.includes('WT_ROOT')), - 'task_commit_protocol missing absolute-path safety guidance — #3099 fix not applied', - ); - }); - - test('execute-phase.md parallel_execution block references path safety', () => { - const parallelIdx = executePhaseSrc.indexOf(''); - assert.ok(parallelIdx !== -1, 'parallel_execution block not found in execute-phase.md'); - // Verify the worktree-path-safety.md reference is present in the execution_context - // (loaded via @ reference rather than inlined — the safe extract pattern) - assert.ok( - executePhaseSrc.includes('worktree-path-safety.md'), - 'execute-phase.md does not reference worktree-path-safety.md in execution_context', - ); - }); - - test('execute-phase prompt anchors subagent file paths to project_root before files_to_read (#280)', () => { - const filesIdx = executePhaseSrc.indexOf(''); - assert.ok(filesIdx !== -1, 'files_to_read block not found in execute-phase.md'); - const dispatchSnippet = executePhaseSrc.slice(filesIdx, filesIdx + 1800); - assert.ok( - dispatchSnippet.includes('PROJECT_ROOT=$(git rev-parse --show-toplevel'), - 'executor dispatch must compute PROJECT_ROOT in the prompt before file reads', - ); - assert.ok( - dispatchSnippet.includes('${PROJECT_ROOT}/'), - 'executor files_to_read paths must be anchored to ${PROJECT_ROOT}/', - ); - }); - - test('worktree-path-safety.md reference file exists', () => { - assert.ok( - fs.existsSync(path.join(ROOT, 'gsd-core', 'references', 'worktree-path-safety.md')), - 'gsd-core/references/worktree-path-safety.md does not exist', - ); - }); - - test('worktree-path-safety.md contains cwd-drift and absolute-path guards', () => { - const safetySrc = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'references', 'worktree-path-safety.md'), 'utf8', - ); - assert.ok(safetySrc.includes('gsd-spawn-toplevel') || safetySrc.includes('cwd-drift'), - 'worktree-path-safety.md missing cwd-drift sentinel content'); - assert.ok(safetySrc.includes('WT_ROOT') || safetySrc.includes('absolute'), - 'worktree-path-safety.md missing absolute-path guard content'); - }); -}); diff --git a/tests/bug-3290-intel-updater-layout-block.test.cjs b/tests/bug-3290-intel-updater-layout-block.test.cjs deleted file mode 100644 index 4496c2bf5..000000000 --- a/tests/bug-3290-intel-updater-layout-block.test.cjs +++ /dev/null @@ -1,187 +0,0 @@ -// allow-test-rule: source-text-is-the-product — agents/gsd-intel-updater.md IS -// the deployed agent instruction set. Asserting its text content tests the -// deployed behaviour contract, not internal implementation. - -'use strict'; - -/** - * Regression tests for bug #3290. - * - * The "Runtime layout detection" block in gsd-intel-updater.md ran - * unconditionally on every project analysed, emitting: - * - * Layout detection returned "unknown" — this project is not a GSD-system - * installation (no `.claude/gsd-core/` or `.kilo/` runtime root). - * - * for every ordinary (non-GSD-framework) user project. The verdict was already - * ignored by Steps 2-6 on non-GSD projects. The block was dead-but-noisy. - * - * Fix: gate the runtime bash detection on a positive "is-this-the-framework- - * repo" check (package.json name === "@opengsd/gsd-core") so it runs ONLY when - * analysing the GSD framework's own repo, OR remove the block entirely if no - * downstream consumers exist. - * - * Group A — gating contract: - * The unconditional bash detection invocation must be absent OR wrapped in a - * framework-repo guard. A bare `ls -d .kilo ... || echo "unknown"` with no - * surrounding gate is the defect signature. - * - * Group B — no orphan consumers: - * Confirm no other agent, command, or workflow file reads/consumes the layout- - * detection verdict emitted by this block. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const AGENT_PATH = path.join(ROOT, 'agents', 'gsd-intel-updater.md'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -/** Walk a directory recursively and return absolute paths of all .md files. */ -function walkMd(dir) { - const results = []; - if (!fs.existsSync(dir)) return results; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkMd(abs)); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - results.push(abs); - } - } - return results; -} - -// ─── Group A — gating contract ─────────────────────────────────────────────── - -describe('bug #3290 — Group A: layout-detection block must be gated or absent', () => { - let content; - - test('agent file exists', () => { - assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-intel-updater.md must exist'); - content = fs.readFileSync(AGENT_PATH, 'utf-8'); - }); - - test( - 'bare unconditional detection invocation is absent — ' + - 'the "ls -d .kilo ... || echo unknown" must not appear outside a framework-repo gate', - () => { - content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); - - // The defect signature: the bash block runs unconditionally. - // We look for the exact shell one-liner that emits the verdict. - const bareDetectionPattern = - /ls -d \.kilo\b.*\|\|.*echo "?unknown"?/; - - const hasBareDetection = bareDetectionPattern.test(content); - - if (!hasBareDetection) { - // Block is fully removed — option B — pass. - return; - } - - // Block is still present. Verify it is surrounded by a framework-repo gate. - // A valid gate checks package.json name or an equivalent positive signal - // that the current project IS the GSD framework's own repo. - const hasFrameworkGate = - content.includes('@opengsd/gsd-core') || - content.includes('is-this-the-framework') || - content.includes('framework repo') || - content.includes('Only run') || - /if.*package\.json.*gsd-core/i.test(content) || - /Only.*layout detection.*GSD framework/i.test(content) || - /Only.*layout detection.*framework/i.test(content); - - assert.ok( - hasFrameworkGate, - 'agents/gsd-intel-updater.md contains a bare unconditional layout-detection ' + - 'bash block (`ls -d .kilo ... || echo unknown`) with no surrounding ' + - 'framework-repo gate (#3290). ' + - 'Either remove the block entirely, or wrap it in a check like:\n' + - ' if [[ "$(jq -r \'.name // ""\' package.json 2>/dev/null)" == "@opengsd/gsd-core" ]]; then\n' + - ' # ... detection block ...\n' + - ' fi' - ); - } - ); -}); - -// ─── Group B — no orphan downstream consumers ──────────────────────────────── - -describe('bug #3290 — Group B: layout-detection verdict has no downstream consumers', () => { - const SOURCE_DIRS = [ - path.join(ROOT, 'agents'), - path.join(ROOT, 'commands', 'gsd'), - path.join(ROOT, 'gsd-core', 'workflows'), - ]; - - /** - * Lines that reference the three possible verdict values emitted by the - * detection block: "claude", "kilo", "unknown" — ONLY as the verdict output - * of the gsd-intel-updater layout detection (not general runtime references). - * - * We look for the specific phrase "Layout detection returned" which is the - * sentinel the noisy output line uses. - */ - test('no file contains "Layout detection returned" (the noisy verdict phrase)', () => { - const matches = []; - - for (const dir of SOURCE_DIRS) { - const files = walkMd(dir); - for (const file of files) { - const rel = path.relative(ROOT, file); - const src = fs.readFileSync(file, 'utf-8'); - if (src.includes('Layout detection returned')) { - // Collect matching lines for the error message - const lines = src.split(/\r?\n/) - .map((l, i) => ({ line: l, n: i + 1 })) - .filter(({ line }) => line.includes('Layout detection returned')); - matches.push({ rel, lines }); - } - } - } - - assert.strictEqual( - matches.length, - 0, - 'Expected zero files to contain "Layout detection returned" (the noisy verdict ' + - 'phrase from the gsd-intel-updater layout-detection block). Found:\n' + - matches.map(({ rel, lines }) => - ` ${rel}:\n${lines.map(({ n, line }) => ` L${n}: ${line.trim()}`).join('\n')}` - ).join('\n') - ); - }); - - test('no agent or workflow instructs reading the layout-detection verdict output', () => { - // The verdict was: echo "kilo" | echo "claude" | echo "unknown" - // If any file references "Layout detection returned unknown" as an instruction - // to consume, that would be a consumer. We verify none exist outside of - // the producing file (gsd-intel-updater.md). - const verdictConsumerPattern = /Layout detection returned.*(unknown|claude|kilo)/i; - const consumers = []; - - for (const dir of SOURCE_DIRS) { - const files = walkMd(dir); - for (const file of files) { - // Exclude the producer itself — it defines the message, not consumes it - if (path.basename(file) === 'gsd-intel-updater.md') continue; - const src = fs.readFileSync(file, 'utf-8'); - if (verdictConsumerPattern.test(src)) { - consumers.push(path.relative(ROOT, file)); - } - } - } - - assert.deepStrictEqual( - consumers, - [], - 'Expected no downstream consumer of the layout-detection verdict. Found:\n' + - consumers.map((f) => ` ${f}`).join('\n') + - '\nIf a consumer exists, use option A (gate) not option B (remove).' - ); - }); -}); diff --git a/tests/bug-3321-verifier-runs-probes.test.cjs b/tests/bug-3321-verifier-runs-probes.test.cjs deleted file mode 100644 index 6af5e4ff2..000000000 --- a/tests/bug-3321-verifier-runs-probes.test.cjs +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const VERIFIER_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-verifier.md'); - -function verifierProbeContract(content) { - const sectionStart = content.indexOf('## Step 7c: Probe Execution'); - const sectionEnd = content.indexOf('## Step 8:', sectionStart); - assert.notEqual(sectionStart, -1, 'verifier must define Step 7c'); - assert.notEqual(sectionEnd, -1, 'verifier must close Step 7c before Step 8'); - - const section = content.slice(sectionStart, sectionEnd); - const codeBlocks = [...section.matchAll(/```bash\r?\n([\s\S]*?)\r?\n```/g)].map((match) => match[1].split(/\r?\n/).join('\n')); - const executionSteps = [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map((match) => match[1]); - return { - title: 'Step 7c: Probe Execution', - conventionalDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('find scripts')) || null, - declaredDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('grep -R')) || null, - executionCommand: codeBlocks[1] || '', - executionSteps, - statusRows: [...section.matchAll(/^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|[^|]+\|\s*([^|]+)\|$/gm)] - .map((match) => ({ probe: match[1], command: match[2], statuses: match[3].trim() })), - summaryClaimsRejected: section.includes('SUMMARY.md probe pass claims are not evidence'), - }; -} - -describe('bug #3321: gsd-verifier runs probes instead of trusting SUMMARY claims', () => { - test('verifier prompt requires direct probe discovery and execution', () => { - const content = fs.readFileSync(VERIFIER_AGENT, 'utf8'); - const contract = verifierProbeContract(content); - - assert.equal(contract.title, 'Step 7c: Probe Execution'); - assert.equal(contract.conventionalDiscoveryCommand, "find scripts -path '*/tests/probe-*.sh' -type f 2>/dev/null | sort"); - assert.equal( - contract.declaredDiscoveryCommand, - "grep -R -n -E 'probe-[^[:space:]]+\\.sh|scripts/.*/tests/probe-.*\\.sh' \"$PHASE_DIR\"/*-PLAN.md \"$PHASE_DIR\"/*-SUMMARY.md 2>/dev/null", - ); - assert.deepEqual(contract.executionSteps, [ - 'Build the `PROBES` list from explicit PLAN declarations first; include conventional `scripts/*/tests/probe-*.sh` when the phase is a migration/tooling phase or the success criteria mention probes.', - 'For every documented probe path, if the file is missing or unreadable, mark `MISSING_PROBE` and set `status: gaps_found`. Do not require the executable bit because probes run through `bash "$probe"`.', - 'Run each probe from the built `PROBES` list (declared + conventional) from the repository root:', - 'Exit code 0 is PASS. Any non-zero exit is FAILED and must include stdout/stderr evidence in VERIFICATION.md.', - 'Do not substitute executor narration, SUMMARY.md PASS-marker counts, or a different dry-run driver command for the probe result.', - ]); - assert.equal(contract.executionCommand, 'for probe in "${PROBES[@]}"; do\n timeout 30s bash "$probe"\ndone'); - assert.deepEqual(contract.statusRows, [{ - probe: 'scripts/.../probe-name.sh', - command: 'bash "$probe"', - statuses: 'PASS / FAILED / MISSING_PROBE', - }]); - assert.equal(contract.summaryClaimsRejected, true); - }); -}); diff --git a/tests/bug-3430-planner-phase-contract.test.cjs b/tests/bug-3430-planner-phase-contract.test.cjs deleted file mode 100644 index fadca144c..000000000 --- a/tests/bug-3430-planner-phase-contract.test.cjs +++ /dev/null @@ -1,44 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Planner markdown is the deployed planning contract; these checks lock the -// exact canonical forms that downstream phase-plan-index accepts. - -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const PLANNER_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); - -function readPlanner() { - return fs.readFileSync(PLANNER_PATH, 'utf8'); -} - -test('#3430: planner SUMMARY instruction uses canonical padded phase/plan form', () => { - const content = readPlanner(); - assert.match( - content, - /Create `\.planning\/phases\/XX-name\/\{padded_phase\}-\{plan\}-SUMMARY\.md` when done/, - 'planner must instruct executors to write SUMMARY files in canonical padded-phase form' - ); - assert.doesNotMatch( - content, - /After completion, create `\.planning\/phases\/XX-name\/\{phase\}-\{plan\}-SUMMARY\.md`/, - 'planner must not instruct the broken {phase}-{plan}-SUMMARY.md form' - ); -}); - -test('#3430: planner depends_on docs show canonical in-phase plan ids', () => { - const content = readPlanner(); - assert.match( - content, - /depends_on:[^\n]*Use `01-01`\/`01-01-auth-hardening`/, - 'planner must document canonical depends_on examples that phase-plan-index resolves' - ); - assert.doesNotMatch( - content, - /depends_on:[^\n]*01-trust\/01/, - 'planner must not document phase-slug/plan-number depends_on examples as canonical' - ); -}); diff --git a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs b/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs deleted file mode 100644 index 5c9f19e35..000000000 --- a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs +++ /dev/null @@ -1,89 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// agents/*.md text IS the deployed contract — Claude Code, Codex, etc. load these -// files at runtime and surface their content to users. Testing for retired slash -// commands in this text is testing what real users will see. - -/** - * Bug #3605: Stale slash command references in 5 agent files - * - * After #3042 deleted /gsd-research-phase (replaced by - * /gsd-plan-phase --research-phase ) and v1.40.0 consolidated /gsd-insert-phase - * into /gsd-phase insert, six occurrences survived in agents/*.md because none of - * the consolidation passes (#3029, #3044, #3131) included agents/ in their per-name - * scrub scope. scripts/fix-slash-commands.cjs lists agents/ in SEARCH_DIRS but only - * runs the /gsd- → /gsd: namespace transform, not retired-name replacement. - * - * This guard fails when any retired command name reappears in agents/*.md. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -const RETIRED_COMMANDS = [ - '/gsd-research-phase', - '/gsd-insert-phase', - '/gsd-add-phase', - '/gsd-remove-phase', - '/gsd-analyze-dependencies', -]; - -// Not the shared listAgentFiles() helper: this returns ABSOLUTE paths (consumed -// by scanForRetired below as readFileSync targets), not stripped basenames. -function listAgentFiles() { - return fs - .readdirSync(AGENTS_DIR) - .filter((name) => name.endsWith('.md')) - .map((name) => path.join(AGENTS_DIR, name)); -} - -function scanForRetired(filePath) { - const text = fs.readFileSync(filePath, 'utf-8'); - const lines = text.split(/\r?\n/); - const hits = []; - for (let i = 0; i < lines.length; i++) { - for (const cmd of RETIRED_COMMANDS) { - const idx = lines[i].indexOf(cmd); - if (idx === -1) continue; - const next = lines[i].charCodeAt(idx + cmd.length); - // Only count if the match is a real invocation, not a prefix of a longer name. - // The next char must be a non-name char (anything outside [A-Za-z0-9-_]). - const isWordBoundary = - Number.isNaN(next) || - !((next >= 48 && next <= 57) || // 0-9 - (next >= 65 && next <= 90) || // A-Z - (next >= 97 && next <= 122) || // a-z - next === 45 || // - - next === 95); // _ - if (!isWordBoundary) continue; - hits.push({ line: i + 1, cmd, text: lines[i].trim() }); - } - } - return hits; -} - -describe('bug #3605: agent contracts must not reference retired slash commands', () => { - const agentFiles = listAgentFiles(); - - test('at least one agent file is scanned (smoke)', () => { - assert.ok(agentFiles.length > 0, 'expected agents/*.md to exist'); - }); - - for (const file of agentFiles) { - const rel = path.relative(path.join(__dirname, '..'), file); - test(`${rel} contains no retired slash commands`, () => { - const hits = scanForRetired(file); - assert.deepEqual( - hits, - [], - `${rel} contains retired command references:\n` + - hits.map((h) => ` line ${h.line}: ${h.cmd} — ${h.text}`).join('\n'), - ); - }); - } -}); diff --git a/tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs b/tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs deleted file mode 100644 index 317799a89..000000000 --- a/tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Regression tests for bug #571 - * - * gsd-doc-writer in fix mode used the Write tool (whole-file replace) instead - * of the Edit tool (surgical replacement) when correcting specific failing - * claims. When the target doc was generated but not yet committed, Write could - * truncate the file to a single line with no git recovery path. - * - * Fix 1 (agent): Add Edit to the tools frontmatter and rewrite fix_mode - * instructions to mandate Edit and explicitly forbid Write on existing files. - * Fix 2 (workflow): Add a post-fix line-count guard in fix_loop that detects - * >90% shrinkage and restores the file from existing_content. - */ - -'use strict'; - -// allow-test-rule: source-text-is-the-product -// Agent .md files are the installed AI agents — their frontmatter and body IS -// what the runtime loads. Checking text content IS checking the deployed contract. - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const AGENTS_DIR = path.join(__dirname, '..', 'agents'); -const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -const AGENT_PATH = path.join(AGENTS_DIR, 'gsd-doc-writer.md'); -const WORKFLOW_PATH = path.join(WORKFLOWS_DIR, 'docs-update.md'); - -// ─── Agent fix: Edit in tools frontmatter ──────────────────────────────────── - -describe('bug #571: gsd-doc-writer agent', () => { - const content = fs.readFileSync(AGENT_PATH, 'utf-8'); - - test('agent file exists', () => { - assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-doc-writer.md must exist'); - }); - - test('tools frontmatter includes Edit', () => { - const toolsMatch = content.match(/^tools:\s*(.+)$/m); - assert.ok(toolsMatch, 'gsd-doc-writer.md must have a tools: frontmatter line'); - assert.ok( - toolsMatch[1].includes('Edit'), - 'tools: frontmatter must include Edit so fix mode can make surgical replacements (#571)' - ); - }); - - // ─── fix_mode instructions ──────────────────────────────────────────────── - - describe('fix_mode block', () => { - const fixStart = content.indexOf(''); - const fixEnd = content.indexOf('', fixStart); - assert.ok(fixStart !== -1 && fixEnd !== -1, ' block must be present and complete'); - const fixBlock = content.slice(fixStart, fixEnd); - - test('fix_mode mandates Edit for corrections', () => { - assert.ok( - fixBlock.includes('Edit'), - 'fix_mode must instruct the agent to use the Edit tool for surgical corrections (#571)' - ); - }); - - test('fix_mode explicitly forbids Write on existing files', () => { - assert.ok( - fixBlock.includes('NEVER use the Write tool') || fixBlock.includes('NEVER call Write'), - 'fix_mode must explicitly forbid Write on existing files — Write replaces the whole file (#571)' - ); - }); - - test('fix_mode mentions unrecoverable data loss risk of Write', () => { - assert.ok( - fixBlock.includes('untracked') || fixBlock.includes('context window') || fixBlock.includes('permanently destroyed'), - 'fix_mode must explain WHY Write is forbidden — unrecoverable data loss for untracked files (#571)' - ); - }); - }); - - // ─── critical_rules ─────────────────────────────────────────────────────── - - describe('critical_rules block', () => { - const rulesStart = content.indexOf(''); - const rulesEnd = content.indexOf('', rulesStart); - assert.ok(rulesStart !== -1 && rulesEnd !== -1, ' block must be present and complete'); - const rulesBlock = content.slice(rulesStart, rulesEnd); - - test('critical_rules forbids Write in fix mode', () => { - assert.ok( - rulesBlock.includes('fix mode') && (rulesBlock.includes('NEVER call Write') || rulesBlock.includes('NEVER use the Write')), - 'critical_rules must explicitly forbid Write in fix mode (#571)' - ); - }); - - test('critical_rules Edit rule appears before success_criteria', () => { - const rulesIdx = content.indexOf(''); - const successIdx = content.indexOf(''); - assert.ok(rulesIdx !== -1 && successIdx !== -1, 'both and must exist'); - assert.ok( - rulesIdx < successIdx, - ' must appear before (#571)' - ); - }); - }); -}); - -// ─── Workflow fix: post-fix truncation guard in fix_loop ───────────────────── - -describe('bug #571: docs-update workflow fix_loop', () => { - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - - test('workflow file exists', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/docs-update.md must exist'); - }); - - describe('fix_loop step', () => { - const loopStart = content.indexOf(''); - const loopEnd = content.indexOf('', loopStart); - assert.ok(loopStart !== -1 && loopEnd !== -1, 'fix_loop step must be present and complete'); - const loopBlock = content.slice(loopStart, loopEnd); - - test('fix_loop captures pre-fix line count', () => { - assert.ok( - loopBlock.includes('PRE_FIX_LINES') || loopBlock.includes('pre-fix line'), - 'fix_loop must capture the pre-fix line count to detect truncation (#571)' - ); - }); - - test('fix_loop checks post-fix line count', () => { - assert.ok( - loopBlock.includes('POST_FIX_LINES') || loopBlock.includes('post-fix line'), - 'fix_loop must check the post-fix line count to detect truncation (#571)' - ); - }); - - test('fix_loop restores file on truncation detection', () => { - assert.ok( - loopBlock.includes('Restore') || loopBlock.includes('restore'), - 'fix_loop must restore the file from existing_content when truncation is detected (#571)' - ); - }); - - test('fix_loop truncation threshold is >90% shrinkage', () => { - assert.ok( - loopBlock.includes('90%') || loopBlock.includes('10%'), - 'fix_loop must use a >90% shrinkage threshold (10% of original) to detect truncation (#571)' - ); - }); - - test('fix_loop logs a WARNING on truncation', () => { - assert.ok( - loopBlock.includes('WARNING') || loopBlock.includes('corrupted'), - 'fix_loop must log a WARNING when truncation is detected and restored (#571)' - ); - }); - - // Structural ordering: PRE check → fix agent runs → POST check → restore - // These ensure the guard is wired in the right sequence, not just present. - test('PRE_FIX_LINES is captured before POST_FIX_LINES (correct ordering)', () => { - const preIdx = loopBlock.indexOf('PRE_FIX_LINES'); - const postIdx = loopBlock.indexOf('POST_FIX_LINES'); - assert.ok(preIdx !== -1 && postIdx !== -1, 'both PRE_FIX_LINES and POST_FIX_LINES must be present (#571)'); - assert.ok( - preIdx < postIdx, - 'PRE_FIX_LINES must appear before POST_FIX_LINES — pre-capture must happen before post-check (#571)' - ); - }); - - test('restore instruction appears after POST_FIX_LINES check (correct ordering)', () => { - const postIdx = loopBlock.indexOf('POST_FIX_LINES'); - // Find the restore instruction — it follows the threshold comparison - const restoreIdx = loopBlock.indexOf('existing_content', postIdx); - assert.ok( - restoreIdx !== -1 && restoreIdx > postIdx, - 'restore-from-existing_content instruction must appear after the POST_FIX_LINES check (#571)' - ); - }); - - test('fix_loop doc path is quoted in shell snippets', () => { - // Unquoted paths break on filenames with spaces or shell metacharacters. - // Verify the bash snippets use quoted "{doc_path}" not bare {doc_path}. - assert.ok( - loopBlock.includes('< "{doc_path}"') || loopBlock.includes("<\"{doc_path}\""), - 'shell redirections must quote {doc_path} to handle paths with spaces (#571)' - ); - }); - - test('corrupted doc is still re-verified (not silently skipped)', () => { - // The restored doc must be included in step 2 re-verification so its - // failures are counted and reported. It should only be excluded from - // receiving another fix attempt, not from verification. - const restoreIdx = loopBlock.indexOf('existing_content', loopBlock.indexOf('POST_FIX_LINES')); - const reVerifyIdx = loopBlock.indexOf('re-verify', restoreIdx); - assert.ok( - reVerifyIdx !== -1, - 'fix_loop must include re-verification after truncation restore (corrupted docs still have failures) (#571)' - ); - }); - }); -}); diff --git a/tests/enh-2427-sycophancy-hardening.test.cjs b/tests/enh-2427-sycophancy-hardening.test.cjs deleted file mode 100644 index 30a0f1914..000000000 --- a/tests/enh-2427-sycophancy-hardening.test.cjs +++ /dev/null @@ -1,100 +0,0 @@ -'use strict'; - -/** - * Tests for #2427 — prompt-level sycophancy hardening of audit-class agents. - * Verifies the four required changes are present in each agent file: - * 1. Third-person framing (no "You are a GSD X" opening in ) - * 2. FORCE adversarial stance block - * 3. Explicit failure modes list - * 4. BLOCKER/WARNING classification requirement - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const AGENTS_DIR = path.join(__dirname, '../agents'); - -const AUDIT_AGENTS = [ - 'gsd-plan-checker.md', - 'gsd-code-reviewer.md', - 'gsd-security-auditor.md', - 'gsd-verifier.md', - 'gsd-eval-auditor.md', - 'gsd-nyquist-auditor.md', - 'gsd-ui-auditor.md', - 'gsd-integration-checker.md', - 'gsd-doc-verifier.md', -]; - -function readAgent(agentsDir, filename) { - return fs.readFileSync(path.join(agentsDir, filename), 'utf-8'); -} - -function extractRole(content) { - const match = content.match(/([\s\S]*?)<\/role>/); - return match ? match[1] : ''; -} - -describe('enh-2427 — sycophancy hardening: audit-class agents', () => { - - for (const filename of AUDIT_AGENTS) { - const label = filename.replace('.md', ''); - - describe(label, () => { - let content; - let role; - - test('file is readable', () => { - content = readAgent(AGENTS_DIR, filename); - role = extractRole(content); - assert.ok(content.length > 0, `${filename} should not be empty`); - }); - - test('(1) third-person framing — does not open with "You are a GSD"', () => { - content = content || readAgent(AGENTS_DIR, filename); - role = role || extractRole(content); - const firstSentence = role.trim().slice(0, 80); - assert.ok( - !firstSentence.startsWith('You are a GSD'), - `${filename}: must not open with "You are a GSD" — use third-person submission framing. Got: "${firstSentence}"` - ); - }); - - test('(2) FORCE adversarial stance — block present', () => { - content = content || readAgent(AGENTS_DIR, filename); - assert.ok( - content.includes(''), - `${filename}: must contain block` - ); - assert.ok( - content.includes('FORCE stance'), - `${filename}: must contain "FORCE stance"` - ); - }); - - test('(3) explicit failure modes list present', () => { - content = content || readAgent(AGENTS_DIR, filename); - assert.ok( - content.includes('failure modes'), - `${filename}: must contain "failure modes" section in ` - ); - }); - - test('(4) BLOCKER/WARNING classification requirement present', () => { - content = content || readAgent(AGENTS_DIR, filename); - assert.ok( - content.includes('**BLOCKER**'), - `${filename}: must define BLOCKER classification in ` - ); - assert.ok( - content.includes('**WARNING**'), - `${filename}: must define WARNING classification in ` - ); - }); - }); - } - -}); -// sdk/prompts/agents/ was removed in 377a6d2 — SDK now loads installed agents directly. diff --git a/tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs b/tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs deleted file mode 100644 index 96ab5dc51..000000000 --- a/tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Enhancement #2500: gsd-codebase-mapper (arch focus) rich architecture output - * - * The codebase/ARCHITECTURE.md produced by gsd-codebase-mapper was a sparse - * structural inventory — file listings and module relationships. After a major - * refactor, research/ARCHITECTURE.md (created at /gsd-new-project) goes stale - * with no refresh command. This enhancement enriches the codebase mapper's - * arch-focus template to match the richness of the research version: - * - ASCII system overview diagram - * - Data flow traces with numbered steps and code references - * - Component responsibility table (component → responsibility → file) - * - Critical architectural constraints - * - Anti-patterns specific to the codebase - * - marker at top (maintainer request) - * - * The agent's template text IS what the runtime executes, so testing - * the template content directly tests the deployed contract. - */ - -'use strict'; - -// allow-test-rule: source-text-is-the-product -// The gsd-codebase-mapper ARCHITECTURE.md template is the instruction set -// executed by the LLM at runtime. Testing its text content tests whether the -// deployed agent will produce rich architecture docs as required by #2500. - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-codebase-mapper.md'); - -describe('enh-2500: gsd-codebase-mapper arch focus — rich architecture output', () => { - let agentContent; - let archTemplate; - - before(() => { - assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-codebase-mapper.md must exist'); - agentContent = fs.readFileSync(AGENT_PATH, 'utf-8'); - - // Isolate the ARCHITECTURE.md template section from the agent file. - // End boundary is the STRUCTURE.md Template heading that immediately follows it. - const archStart = agentContent.indexOf('## ARCHITECTURE.md Template (arch focus)'); - assert.ok(archStart !== -1, 'agent must contain an ARCHITECTURE.md Template (arch focus) section'); - - const archEnd = agentContent.indexOf('## STRUCTURE.md Template (arch focus)', archStart + 1); - archTemplate = archEnd !== -1 - ? agentContent.slice(archStart, archEnd) - : agentContent.slice(archStart); - }); - - test('template includes a refreshed date marker', () => { - assert.ok( - archTemplate.includes(' marker so users can see when the doc was last generated (#2500 maintainer requirement)' - ); - }); - - test('template includes an ASCII system overview diagram', () => { - // ASCII diagrams use box-drawing characters or at minimum ┌/└/│/─ or +/|/- - const hasAsciiDiagram = - archTemplate.includes('┌') || - archTemplate.includes('└') || - archTemplate.includes('│') || - archTemplate.includes('+--') || - archTemplate.includes('+-') || - archTemplate.includes('→') || - archTemplate.includes('↓') || - archTemplate.includes('↑'); - - assert.ok( - hasAsciiDiagram, - 'ARCHITECTURE.md template must include an ASCII system overview diagram (box-drawing characters or flow arrows) as required by #2500' - ); - }); - - test('template includes System Overview section header', () => { - assert.ok( - archTemplate.includes('System Overview') || archTemplate.includes('system overview'), - 'ARCHITECTURE.md template must include a "System Overview" section for the ASCII diagram (#2500)' - ); - }); - - test('template includes a component responsibility table with required columns', () => { - // Must have a markdown table with component, responsibility, and file columns - const hasComponentCol = - archTemplate.includes('Component') || archTemplate.includes('component'); - const hasResponsibilityCol = - archTemplate.includes('Responsibility') || archTemplate.includes('responsibility'); - const hasFileCol = - archTemplate.includes('File') || archTemplate.includes('file'); - - assert.ok( - hasComponentCol && hasResponsibilityCol && hasFileCol, - 'ARCHITECTURE.md template must include a component responsibility table with Component, Responsibility, and File columns (#2500)' - ); - }); - - test('template includes data flow traces with numbered steps', () => { - const hasPrimaryRequestPath = /###\s+Primary Request Path/i.test(archTemplate); - // [^\n]+ + \r?\n is CRLF-tolerant: .+ doesn't match \r in JS regex by - // default, so \r before the literal \n in CRLF content kills the match. - const hasThreeNumberedSteps = /^\s*1\.[^\n]+\r?\n\s*2\.[^\n]+\r?\n\s*3\./m.test(archTemplate); - const hasFileLineRefs = /\(`\[.*:(?:line|\d+)\]`\)/.test(archTemplate); - - assert.ok( - hasPrimaryRequestPath && hasThreeNumberedSteps && hasFileLineRefs, - 'ARCHITECTURE.md template must include a "Primary Request Path" section with numbered steps and file:line references (#2500)' - ); - }); - - test('template includes architectural constraints section', () => { - const hasConstraints = - /##\s+Architectural Constraints/i.test(archTemplate) && - /\bThreading\b/.test(archTemplate) && - /\bGlobal state\b/i.test(archTemplate) && - /\bCircular imports\b/i.test(archTemplate); - - assert.ok( - hasConstraints, - 'ARCHITECTURE.md template must include an "Architectural Constraints" section with Threading, Global state, and Circular imports categories (#2500)' - ); - }); - - test('template includes anti-patterns section', () => { - assert.ok( - archTemplate.includes('Anti-pattern') || - archTemplate.includes('Anti-Pattern') || - archTemplate.includes('anti-pattern'), - 'ARCHITECTURE.md template must include an anti-patterns section specific to the codebase (#2500)' - ); - }); -}); diff --git a/tests/executor-mvp-tdd-section.test.cjs b/tests/executor-mvp-tdd-section.test.cjs index 7dcde06b6..f1b022703 100644 --- a/tests/executor-mvp-tdd-section.test.cjs +++ b/tests/executor-mvp-tdd-section.test.cjs @@ -96,3 +96,130 @@ describe('gsd-executor — state.* calls use the named-only router form (#1863 r } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3097-3099-executor-worktree-path-safety.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3097-3099-executor-worktree-path-safety (consolidation epic #1969 B7 #1976)", () => { +'use strict'; +// allow-test-rule: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep (see #3097) + +// Regression guards for bug #3097 and #3099. +// +// #3097: gsd-executor's worktree HEAD guard used `if [ -f .git ]` to detect +// worktree mode. After a Bash `cd` out of the worktree into the main repo, +// `.git` is a DIRECTORY (not a file), so the test is false and the entire +// HEAD safety block is silently skipped. Commits then land on whatever branch +// the main repo has checked out — not the per-agent worktree branch. +// +// #3099: Executor agents construct absolute paths from `pwd` captured in the +// orchestrator context (main repo root). Edit/Write calls using these paths +// resolve to the main repo, not the worktree. git commit from the worktree +// sees a clean tree; the work is silently lost or leaks to main. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const executorSrc = fs.readFileSync( + path.join(ROOT, 'agents', 'gsd-executor.md'), 'utf8', +); +const executePhaseSrc = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8', +); + +describe('bug #3097: cwd-drift sentinel in gsd-executor.md', () => { + test('task_commit_protocol has cwd-drift assertion step (0a)', () => { + const protocolIdx = executorSrc.indexOf(''); + const protocolEnd = executorSrc.indexOf(''); + assert.ok(protocolIdx !== -1 && protocolEnd !== -1, 'task_commit_protocol block not found'); + const protocol = executorSrc.slice(protocolIdx, protocolEnd); + assert.ok( + protocol.includes('cwd') || protocol.includes('drift') || protocol.includes('gsd-spawn-toplevel'), + 'task_commit_protocol missing cwd-drift assertion step — #3097 fix not applied', + ); + }); + + test('sentinel uses git rev-parse --git-dir to detect worktree', () => { + const protocolIdx = executorSrc.indexOf(''); + const protocolEnd = executorSrc.indexOf(''); + const protocol = executorSrc.slice(protocolIdx, protocolEnd); + assert.ok( + protocol.includes('rev-parse --git-dir') || protocol.includes('worktrees/'), + 'cwd-drift detection does not use git rev-parse --git-dir or .git/worktrees/ pattern', + ); + }); + + test('cwd-drift check precedes HEAD assertion', () => { + const protocolIdx = executorSrc.indexOf(''); + const protocolEnd = executorSrc.indexOf(''); + const protocol = executorSrc.slice(protocolIdx, protocolEnd); + const driftIdx = protocol.search(/cwd.drift|gsd-spawn-toplevel|drift.*assertion/i); + const headIdx = protocol.indexOf('Pre-commit HEAD safety assertion'); + assert.ok(driftIdx !== -1, 'cwd-drift assertion not found'); + assert.ok(headIdx !== -1, 'HEAD assertion not found'); + assert.ok(driftIdx < headIdx, 'cwd-drift assertion must precede HEAD assertion (step 0a before step 0)'); + }); +}); + +describe('bug #3099: absolute-path safety guidance in gsd-executor.md', () => { + test('task_commit_protocol documents absolute-path safety', () => { + const protocolIdx = executorSrc.indexOf(''); + const protocolEnd = executorSrc.indexOf(''); + const protocol = executorSrc.slice(protocolIdx, protocolEnd); + assert.ok( + (protocol.includes('absolute') || protocol.includes('absolute-path')) && + (protocol.includes('worktree') || protocol.includes('WT_ROOT')), + 'task_commit_protocol missing absolute-path safety guidance — #3099 fix not applied', + ); + }); + + test('execute-phase.md parallel_execution block references path safety', () => { + const parallelIdx = executePhaseSrc.indexOf(''); + assert.ok(parallelIdx !== -1, 'parallel_execution block not found in execute-phase.md'); + // Verify the worktree-path-safety.md reference is present in the execution_context + // (loaded via @ reference rather than inlined — the safe extract pattern) + assert.ok( + executePhaseSrc.includes('worktree-path-safety.md'), + 'execute-phase.md does not reference worktree-path-safety.md in execution_context', + ); + }); + + test('execute-phase prompt anchors subagent file paths to project_root before files_to_read (#280)', () => { + const filesIdx = executePhaseSrc.indexOf(''); + assert.ok(filesIdx !== -1, 'files_to_read block not found in execute-phase.md'); + const dispatchSnippet = executePhaseSrc.slice(filesIdx, filesIdx + 1800); + assert.ok( + dispatchSnippet.includes('PROJECT_ROOT=$(git rev-parse --show-toplevel'), + 'executor dispatch must compute PROJECT_ROOT in the prompt before file reads', + ); + assert.ok( + dispatchSnippet.includes('${PROJECT_ROOT}/'), + 'executor files_to_read paths must be anchored to ${PROJECT_ROOT}/', + ); + }); + + test('worktree-path-safety.md reference file exists', () => { + assert.ok( + fs.existsSync(path.join(ROOT, 'gsd-core', 'references', 'worktree-path-safety.md')), + 'gsd-core/references/worktree-path-safety.md does not exist', + ); + }); + + test('worktree-path-safety.md contains cwd-drift and absolute-path guards', () => { + const safetySrc = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'references', 'worktree-path-safety.md'), 'utf8', + ); + assert.ok(safetySrc.includes('gsd-spawn-toplevel') || safetySrc.includes('cwd-drift'), + 'worktree-path-safety.md missing cwd-drift sentinel content'); + assert.ok(safetySrc.includes('WT_ROOT') || safetySrc.includes('absolute'), + 'worktree-path-safety.md missing absolute-path guard content'); + }); +}); + }); +} diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index b51130a55..43ab68929 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -1263,3 +1263,286 @@ describe('#3258: no stale /gsd-intel slash-command references in product source }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2351-intel-kilo-layout.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2351-intel-kilo-layout (consolidation epic #1969 B7 #1976)", () => { +/** + * Regression test for bug #2351 + * + * gsd-intel-updater used hardcoded canonical paths (`agents/*.md`, + * `commands/gsd/*.md`, `hooks/*.js`, etc.) that assumed the standard + * `.claude/` runtime layout. Under a `.kilo` install, the runtime root is + * `.kilo/`, and the command directory is `command/` (not `commands/gsd/`). + * Globs against the old paths returned no results, producing semantically + * empty intel files (`"entries": {}`). + * + * Fix: add runtime layout detection and a mapping table so the agent + * resolves paths against the correct root. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-intel-updater.md'); + +describe('bug #2351: intel updater kilo layout support', () => { + let content; + + test('agent file exists', () => { + assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-intel-updater.md must exist'); + content = fs.readFileSync(AGENT_PATH, 'utf-8'); + }); + + test('scope section includes layout detection step', () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + const hasDetection = + content.includes('ls -d .kilo') || + content.includes('Runtime layout detection') || + content.includes('detected layout') || + content.includes('layout detection'); + assert.ok( + hasDetection, + 'gsd-intel-updater.md must instruct the agent to detect the runtime layout ' + + '(.kilo vs .claude) before resolving canonical paths (#2351)' + ); + }); + + test('scope section maps .kilo/agents path', () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + assert.ok( + content.includes('.kilo/agents'), + 'scope section must include the .kilo/agents/*.md path so agent count is correct under kilo layout' + ); + }); + + test('scope section maps .kilo/command path (not commands/gsd)', () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + assert.ok( + content.includes('.kilo/command'), + 'scope section must include .kilo/command path — kilo uses "command/" not "commands/gsd/"' + ); + }); + + test('scope section maps .kilo/hooks path', () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + assert.ok( + content.includes('.kilo/hooks'), + 'scope section must include .kilo/hooks path for hook file counts' + ); + }); + + test('scope section retains standard layout paths for .claude installs', () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + assert.ok( + content.includes('agents/*.md') || content.includes('Standard `.claude` layout'), + 'scope section must still document the standard .claude layout paths for non-kilo installs' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3290-intel-updater-layout-block.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3290-intel-updater-layout-block (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product — agents/gsd-intel-updater.md IS (see #3290) +// the deployed agent instruction set. Asserting its text content tests the +// deployed behaviour contract, not internal implementation. + +'use strict'; + +/** + * Regression tests for bug #3290. + * + * The "Runtime layout detection" block in gsd-intel-updater.md ran + * unconditionally on every project analysed, emitting: + * + * Layout detection returned "unknown" — this project is not a GSD-system + * installation (no `.claude/gsd-core/` or `.kilo/` runtime root). + * + * for every ordinary (non-GSD-framework) user project. The verdict was already + * ignored by Steps 2-6 on non-GSD projects. The block was dead-but-noisy. + * + * Fix: gate the runtime bash detection on a positive "is-this-the-framework- + * repo" check (package.json name === "@opengsd/gsd-core") so it runs ONLY when + * analysing the GSD framework's own repo, OR remove the block entirely if no + * downstream consumers exist. + * + * Group A — gating contract: + * The unconditional bash detection invocation must be absent OR wrapped in a + * framework-repo guard. A bare `ls -d .kilo ... || echo "unknown"` with no + * surrounding gate is the defect signature. + * + * Group B — no orphan consumers: + * Confirm no other agent, command, or workflow file reads/consumes the layout- + * detection verdict emitted by this block. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const AGENT_PATH = path.join(ROOT, 'agents', 'gsd-intel-updater.md'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +/** Walk a directory recursively and return absolute paths of all .md files. */ +function walkMd(dir) { + const results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkMd(abs)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(abs); + } + } + return results; +} + +// ─── Group A — gating contract ─────────────────────────────────────────────── + +describe('bug #3290 — Group A: layout-detection block must be gated or absent', () => { + let content; + + test('agent file exists', () => { + assert.ok(fs.existsSync(AGENT_PATH), 'agents/gsd-intel-updater.md must exist'); + content = fs.readFileSync(AGENT_PATH, 'utf-8'); + }); + + test( + 'bare unconditional detection invocation is absent — ' + + 'the "ls -d .kilo ... || echo unknown" must not appear outside a framework-repo gate', + () => { + content = content || fs.readFileSync(AGENT_PATH, 'utf-8'); + + // The defect signature: the bash block runs unconditionally. + // We look for the exact shell one-liner that emits the verdict. + const bareDetectionPattern = + /ls -d \.kilo\b.*\|\|.*echo "?unknown"?/; + + const hasBareDetection = bareDetectionPattern.test(content); + + if (!hasBareDetection) { + // Block is fully removed — option B — pass. + return; + } + + // Block is still present. Verify it is surrounded by a framework-repo gate. + // A valid gate checks package.json name or an equivalent positive signal + // that the current project IS the GSD framework's own repo. + const hasFrameworkGate = + content.includes('@opengsd/gsd-core') || + content.includes('is-this-the-framework') || + content.includes('framework repo') || + content.includes('Only run') || + /if.*package\.json.*gsd-core/i.test(content) || + /Only.*layout detection.*GSD framework/i.test(content) || + /Only.*layout detection.*framework/i.test(content); + + assert.ok( + hasFrameworkGate, + 'agents/gsd-intel-updater.md contains a bare unconditional layout-detection ' + + 'bash block (`ls -d .kilo ... || echo unknown`) with no surrounding ' + + 'framework-repo gate (#3290). ' + + 'Either remove the block entirely, or wrap it in a check like:\n' + + ' if [[ "$(jq -r \'.name // ""\' package.json 2>/dev/null)" == "@opengsd/gsd-core" ]]; then\n' + + ' # ... detection block ...\n' + + ' fi' + ); + } + ); +}); + +// ─── Group B — no orphan downstream consumers ──────────────────────────────── + +describe('bug #3290 — Group B: layout-detection verdict has no downstream consumers', () => { + const SOURCE_DIRS = [ + path.join(ROOT, 'agents'), + path.join(ROOT, 'commands', 'gsd'), + path.join(ROOT, 'gsd-core', 'workflows'), + ]; + + /** + * Lines that reference the three possible verdict values emitted by the + * detection block: "claude", "kilo", "unknown" — ONLY as the verdict output + * of the gsd-intel-updater layout detection (not general runtime references). + * + * We look for the specific phrase "Layout detection returned" which is the + * sentinel the noisy output line uses. + */ + test('no file contains "Layout detection returned" (the noisy verdict phrase)', () => { + const matches = []; + + for (const dir of SOURCE_DIRS) { + const files = walkMd(dir); + for (const file of files) { + const rel = path.relative(ROOT, file); + const src = fs.readFileSync(file, 'utf-8'); + if (src.includes('Layout detection returned')) { + // Collect matching lines for the error message + const lines = src.split(/\r?\n/) + .map((l, i) => ({ line: l, n: i + 1 })) + .filter(({ line }) => line.includes('Layout detection returned')); + matches.push({ rel, lines }); + } + } + } + + assert.strictEqual( + matches.length, + 0, + 'Expected zero files to contain "Layout detection returned" (the noisy verdict ' + + 'phrase from the gsd-intel-updater layout-detection block). Found:\n' + + matches.map(({ rel, lines }) => + ` ${rel}:\n${lines.map(({ n, line }) => ` L${n}: ${line.trim()}`).join('\n')}` + ).join('\n') + ); + }); + + test('no agent or workflow instructs reading the layout-detection verdict output', () => { + // The verdict was: echo "kilo" | echo "claude" | echo "unknown" + // If any file references "Layout detection returned unknown" as an instruction + // to consume, that would be a consumer. We verify none exist outside of + // the producing file (gsd-intel-updater.md). + const verdictConsumerPattern = /Layout detection returned.*(unknown|claude|kilo)/i; + const consumers = []; + + for (const dir of SOURCE_DIRS) { + const files = walkMd(dir); + for (const file of files) { + // Exclude the producer itself — it defines the message, not consumes it + if (path.basename(file) === 'gsd-intel-updater.md') continue; + const src = fs.readFileSync(file, 'utf-8'); + if (verdictConsumerPattern.test(src)) { + consumers.push(path.relative(ROOT, file)); + } + } + } + + assert.deepStrictEqual( + consumers, + [], + 'Expected no downstream consumer of the layout-detection verdict. Found:\n' + + consumers.map((f) => ` ${f}`).join('\n') + + '\nIf a consumer exists, use option A (gate) not option B (remove).' + ); + }); +}); + }); +} diff --git a/tests/planner-language-regression.test.cjs b/tests/planner-language-regression.test.cjs index 6f33ca3c9..8dfadf1dc 100644 --- a/tests/planner-language-regression.test.cjs +++ b/tests/planner-language-regression.test.cjs @@ -489,3 +489,196 @@ describe('bug #3805: fast.md log_to_state must be schema-aware', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2421-planner-grep-gate-hygiene.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2421-planner-grep-gate-hygiene (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #2421) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Bug #2421: gsd-planner emits grep-count acceptance gates that count comment text + * + * The planner must instruct agents to use comment-aware grep patterns in + * verify blocks. Without this, descriptive comments in file + * headers count against the gate and force authors to reword them — the + * "self-invalidating grep gate" anti-pattern. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PLANNER_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); + +describe('gsd-planner grep gate hygiene (#2421)', () => { + test('gsd-planner.md exists in agents source dir', () => { + assert.ok(fs.existsSync(PLANNER_PATH), 'agents/gsd-planner.md must exist'); + }); + + test('gsd-planner.md contains Grep gate hygiene rule', () => { + const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); + assert.ok( + content.includes('Grep gate hygiene') || content.includes('grep gate hygiene'), + 'gsd-planner.md must contain a "Grep gate hygiene" rule to prevent self-invalidating grep gates' + ); + }); + + test('gsd-planner.md explains self-invalidating grep gate anti-pattern', () => { + const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); + assert.ok( + content.includes('self-invalidating'), + 'gsd-planner.md must describe the "self-invalidating" grep gate anti-pattern' + ); + }); + + test('gsd-planner.md provides comment-stripping grep example', () => { + const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); + // Must show a pattern that excludes comment lines (grep -v or grep -vE) + assert.ok( + content.includes('grep -v') || content.includes('grep -vE') || content.includes('-v '), + 'gsd-planner.md must provide a comment-stripping grep example (grep -v or grep -vE)' + ); + }); + + test('gsd-planner.md warns against bare zero-count grep gates on whole files', () => { + const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); + assert.ok( + content.includes('== 0') || content.includes('zero-count') || content.includes('zero count'), + 'gsd-planner.md must warn against bare zero-count grep gates without comment exclusion' + ); + }); + + test('gsd-planner.md grep gate hygiene rule appears after Nyquist Rule', () => { + const content = fs.readFileSync(PLANNER_PATH, 'utf-8'); + const nyquistIdx = content.indexOf('Nyquist Rule'); + const grepGateIdx = content.indexOf('grep gate hygiene') !== -1 + ? content.indexOf('grep gate hygiene') + : content.indexOf('Grep gate hygiene'); + + assert.ok(nyquistIdx !== -1, 'Nyquist Rule must be present in gsd-planner.md'); + assert.ok(grepGateIdx !== -1, 'Grep gate hygiene must be present in gsd-planner.md'); + assert.ok( + grepGateIdx > nyquistIdx, + `Grep gate hygiene rule (at ${grepGateIdx}) must appear after Nyquist Rule (at ${nyquistIdx})` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3087-planner-directive-language.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3087-planner-directive-language (consolidation epic #1969 B7 #1976)", () => { +'use strict'; + +// Regression guard for bug #3087. +// +// Between v1.38.3 and v1.38.4, agents/gsd-planner.md had 10 instances of +// CRITICAL/MANDATORY/ALWAYS/MUST directive emphasis systematically removed. +// The change was undocumented and conflicts with the stated intent of PR #2489 +// (the sycophancy-hardening pass that shipped in the same release). This test +// enforces the restored directive language so the demotion cannot recur silently. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +let src; +try { + src = fs.readFileSync(path.join(ROOT, 'agents', 'gsd-planner.md'), 'utf8'); +} catch (err) { + throw new Error(`agents/gsd-planner.md not found — was the file renamed? (${err.message})`); +} + +const directives = [ + { desc: 'User Decision Fidelity heading is CRITICAL', pattern: /## CRITICAL: User Decision Fidelity/ }, + { desc: 'Never Simplify heading is CRITICAL', pattern: /## CRITICAL: Never Simplify User Decisions/ }, + { desc: 'Multi-Source Audit heading is MANDATORY', pattern: /## Multi-Source Coverage Audit \(MANDATORY in every plan set\)/ }, + { desc: 'Source audit uses "Audit ALL" imperative', pattern: /Audit ALL four source types before finalizing/ }, + { desc: 'Discovery is MANDATORY', pattern: /Discovery is MANDATORY unless/ }, + { desc: 'Split signals use ALWAYS', pattern: /\*\*ALWAYS split if:\*\*/ }, + { desc: 'requirements field doc uses MUST', pattern: /\*\*MUST\*\* list requirement IDs from ROADMAP/ }, + { desc: 'Step 0 has CRITICAL requirement ID directive', pattern: /\*\*CRITICAL:\*\* Every requirement ID MUST appear/ }, + { desc: 'Write tool directive uses ALWAYS', pattern: /\*\*ALWAYS use the Write tool to create files\*\*/ }, + { desc: 'File naming convention heading is CRITICAL', pattern: /\*\*CRITICAL — File naming convention \(enforced\):\*\*/ }, +]; + +for (const { desc, pattern } of directives) { + test(`gsd-planner.md: ${desc}`, () => { + assert.ok( + pattern.test(src), + `Directive enforcement missing from gsd-planner.md: "${desc}" — pattern ${pattern} not found. ` + + `This language was demoted in v1.38.4 (PR #2489) without documentation, conflicting with ` + + `the sycophancy-hardening intent of that release. See bug #3087.`, + ); + }); +} + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3430-planner-phase-contract.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3430-planner-phase-contract (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #3430) +// Planner markdown is the deployed planning contract; these checks lock the +// exact canonical forms that downstream phase-plan-index accepts. + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PLANNER_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); + +function readPlanner() { + return fs.readFileSync(PLANNER_PATH, 'utf8'); +} + +test('#3430: planner SUMMARY instruction uses canonical padded phase/plan form', () => { + const content = readPlanner(); + assert.match( + content, + /Create `\.planning\/phases\/XX-name\/\{padded_phase\}-\{plan\}-SUMMARY\.md` when done/, + 'planner must instruct executors to write SUMMARY files in canonical padded-phase form' + ); + assert.doesNotMatch( + content, + /After completion, create `\.planning\/phases\/XX-name\/\{phase\}-\{plan\}-SUMMARY\.md`/, + 'planner must not instruct the broken {phase}-{plan}-SUMMARY.md form' + ); +}); + +test('#3430: planner depends_on docs show canonical in-phase plan ids', () => { + const content = readPlanner(); + assert.match( + content, + /depends_on:[^\n]*Use `01-01`\/`01-01-auth-hardening`/, + 'planner must document canonical depends_on examples that phase-plan-index resolves' + ); + assert.doesNotMatch( + content, + /depends_on:[^\n]*01-trust\/01/, + 'planner must not document phase-slug/plan-number depends_on examples as canonical' + ); +}); + }); +} diff --git a/tests/research-agent-profiles.test.cjs b/tests/research-agent-profiles.test.cjs index 6bee6dcfd..ecd698f58 100644 --- a/tests/research-agent-profiles.test.cjs +++ b/tests/research-agent-profiles.test.cjs @@ -311,3 +311,124 @@ describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2419-project-researcher-agent.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2419-project-researcher-agent (consolidation epic #1969 B7 #1976)", () => { +// allow-test-rule: source-text-is-the-product (see #2419) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Bug #2419: gsd-project-researcher agent type not found + * + * When gsd-new-project spawns gsd-project-researcher subagents, it fails with + * "agent type not found" if the user has a local-only install (agents in + * .claude/agents/ of a different project, not the global ~/.claude/agents/). + * + * Fix: new-project.md and new-milestone.md must parse agents_installed from + * the init JSON and warn the user (rather than silently failing) when agents + * are missing. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const NEW_PROJECT_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-project.md'); +const NEW_MILESTONE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-milestone.md'); +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +describe('gsd-project-researcher agent registration (#2419)', () => { + test('gsd-project-researcher.md exists in agents source dir', () => { + const agentFile = path.join(AGENTS_DIR, 'gsd-project-researcher.md'); + assert.ok( + fs.existsSync(agentFile), + 'agents/gsd-project-researcher.md must exist in the source agents directory' + ); + }); + + test('gsd-project-researcher.md has correct name in frontmatter', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-project-researcher.md'), 'utf-8'); + assert.ok( + content.includes('name: gsd-project-researcher'), + 'agents/gsd-project-researcher.md must have name: gsd-project-researcher in frontmatter' + ); + }); + + test('new-project.md parses agents_installed from init JSON', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok( + content.includes('agents_installed'), + 'new-project.md must parse agents_installed from the init JSON to detect missing agents' + ); + }); + + test('new-project.md warns user when agents_installed is false', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok( + content.includes('agents_installed') && content.includes('agent type not found') || + content.includes('agents_installed') && content.includes('missing') || + content.includes('agents_installed') && content.includes('not installed'), + 'new-project.md must warn the user when agents are not installed (agents_installed is false)' + ); + }); + + test('new-project.md reports required-agent and skill-payload diagnostics separately', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok(content.includes('required_agents_installed'), + 'new-project.md must parse required_agents_installed from init JSON'); + assert.ok(content.includes('missing_required_agents'), + 'new-project.md must report missing required new-project agents separately'); + assert.ok(content.includes('agent_skill_payloads_available'), + 'new-project.md must distinguish skill payload availability from agent definitions'); + assert.ok(content.includes('agents_dir'), + 'new-project.md must show which agents directory was checked'); + }); + + test('new-milestone.md parses agents_installed from init JSON', () => { + const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); + assert.ok( + content.includes('agents_installed'), + 'new-milestone.md must parse agents_installed from the init JSON to detect missing agents' + ); + }); + + test('new-milestone.md warns user when agents_installed is false', () => { + const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); + assert.ok( + content.includes('agents_installed') && ( + content.includes('agent type not found') || + content.includes('missing') || + content.includes('not installed') + ), + 'new-milestone.md must warn the user when agents are not installed (agents_installed is false)' + ); + }); + + test('new-project.md lists gsd-project-researcher in available_agent_types', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + const agentTypesMatch = content.match(/([\s\S]*?)<\/available_agent_types>/); + assert.ok(agentTypesMatch, 'new-project.md must have section'); + assert.ok( + agentTypesMatch[1].includes('gsd-project-researcher'), + 'new-project.md must list gsd-project-researcher' + ); + }); + + test('new-milestone.md lists gsd-project-researcher in available_agent_types', () => { + const content = fs.readFileSync(NEW_MILESTONE_PATH, 'utf-8'); + const agentTypesMatch = content.match(/([\s\S]*?)<\/available_agent_types>/); + assert.ok(agentTypesMatch, 'new-milestone.md must have section'); + assert.ok( + agentTypesMatch[1].includes('gsd-project-researcher'), + 'new-milestone.md must list gsd-project-researcher' + ); + }); +}); + }); +} diff --git a/tests/verifier-behavior-unverified.test.cjs b/tests/verifier-behavior-unverified.test.cjs index b62b56561..52ed4cd3a 100644 --- a/tests/verifier-behavior-unverified.test.cjs +++ b/tests/verifier-behavior-unverified.test.cjs @@ -127,3 +127,71 @@ test('shipped workflow flags behavior-unverified truths even on infrastructure p test('standalone template per-truth guideline respects gaps_found precedence', () => { assert.match(standalone, /becomes `human_needed`[\s\S]{0,80}?gaps_found/i); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3321-verifier-runs-probes.test.cjs — consolidation epic #1969 (B7 #1976) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3321-verifier-runs-probes (consolidation epic #1969 B7 #1976)", () => { +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const VERIFIER_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-verifier.md'); + +function verifierProbeContract(content) { + const sectionStart = content.indexOf('## Step 7c: Probe Execution'); + const sectionEnd = content.indexOf('## Step 8:', sectionStart); + assert.notEqual(sectionStart, -1, 'verifier must define Step 7c'); + assert.notEqual(sectionEnd, -1, 'verifier must close Step 7c before Step 8'); + + const section = content.slice(sectionStart, sectionEnd); + const codeBlocks = [...section.matchAll(/```bash\r?\n([\s\S]*?)\r?\n```/g)].map((match) => match[1].split(/\r?\n/).join('\n')); + const executionSteps = [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map((match) => match[1]); + return { + title: 'Step 7c: Probe Execution', + conventionalDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('find scripts')) || null, + declaredDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('grep -R')) || null, + executionCommand: codeBlocks[1] || '', + executionSteps, + statusRows: [...section.matchAll(/^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|[^|]+\|\s*([^|]+)\|$/gm)] + .map((match) => ({ probe: match[1], command: match[2], statuses: match[3].trim() })), + summaryClaimsRejected: section.includes('SUMMARY.md probe pass claims are not evidence'), + }; +} + +describe('bug #3321: gsd-verifier runs probes instead of trusting SUMMARY claims', () => { + test('verifier prompt requires direct probe discovery and execution', () => { + const content = fs.readFileSync(VERIFIER_AGENT, 'utf8'); + const contract = verifierProbeContract(content); + + assert.equal(contract.title, 'Step 7c: Probe Execution'); + assert.equal(contract.conventionalDiscoveryCommand, "find scripts -path '*/tests/probe-*.sh' -type f 2>/dev/null | sort"); + assert.equal( + contract.declaredDiscoveryCommand, + "grep -R -n -E 'probe-[^[:space:]]+\\.sh|scripts/.*/tests/probe-.*\\.sh' \"$PHASE_DIR\"/*-PLAN.md \"$PHASE_DIR\"/*-SUMMARY.md 2>/dev/null", + ); + assert.deepEqual(contract.executionSteps, [ + 'Build the `PROBES` list from explicit PLAN declarations first; include conventional `scripts/*/tests/probe-*.sh` when the phase is a migration/tooling phase or the success criteria mention probes.', + 'For every documented probe path, if the file is missing or unreadable, mark `MISSING_PROBE` and set `status: gaps_found`. Do not require the executable bit because probes run through `bash "$probe"`.', + 'Run each probe from the built `PROBES` list (declared + conventional) from the repository root:', + 'Exit code 0 is PASS. Any non-zero exit is FAILED and must include stdout/stderr evidence in VERIFICATION.md.', + 'Do not substitute executor narration, SUMMARY.md PASS-marker counts, or a different dry-run driver command for the probe result.', + ]); + assert.equal(contract.executionCommand, 'for probe in "${PROBES[@]}"; do\n timeout 30s bash "$probe"\ndone'); + assert.deepEqual(contract.statusRows, [{ + probe: 'scripts/.../probe-name.sh', + command: 'bash "$probe"', + statuses: 'PASS / FAILED / MISSING_PROBE', + }]); + assert.equal(contract.summaryClaimsRejected, true); + }); +}); + }); +}