fix: block verifier pass on unresolved debt markers (#3343)

* fix: block verifier pass on unresolved debt markers

* chore: add changeset for verifier debt gate

* test: align verifier debt cleanup with standards

* fix: address coderabbit verifier debt findings

* fix: address follow-up coderabbit guard findings

* fix: tighten debt marker matching

* fix: ignore deleted files in debt scan

* docs: document debt scan path contract

* fix: harden debt scan path handling

* fix: tighten debt marker reference parsing

* fix: clarify debt scan failure logging

* fix: preserve verifier debt error contract
This commit is contained in:
Tom Boucher
2026-05-10 11:19:50 -04:00
committed by GitHub
parent 25fb81d01e
commit 0afcea0723
5 changed files with 609 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
type: Fixed
pr: 3343
---
**Phase verification no longer passes with unresolved `TBD`/`FIXME`/`XXX` markers** — the SDK phase runner now blocks advance after a nominal verifier pass when phase-modified source files contain untracked debt markers. Same-line issue/PR references and `DEF-*` IDs remain allowed for formal deferrals.
The debt scan covers literal source paths declared in phase plan `files_modified` frontmatter and task `files`; globs are not expanded, and undeclared files modified during execution are not scanned. Git-diff-based coverage would be a separate enhancement.

View File

@@ -422,8 +422,10 @@ grep -E "^\- \`" "$PHASE_DIR"/*-SUMMARY.md | sed 's/.*`\([^`]*\)`.*/\1/' | sort
Run anti-pattern detection on each file:
```bash
# TODO/FIXME/placeholder comments
grep -n -E "TODO|FIXME|XXX|HACK|PLACEHOLDER" "$file" 2>/dev/null
# Debt-marker comments
grep -n -E "TBD|FIXME|XXX" "$file" 2>/dev/null
# Warning-level cleanup comments
grep -n -E "TODO|HACK|PLACEHOLDER" "$file" 2>/dev/null
grep -n -E "placeholder|coming soon|will be here|not yet implemented|not available" "$file" -i 2>/dev/null
# Empty implementations
grep -n -E "return null|return \{\}|return \[\]|=> \{\}" "$file" 2>/dev/null
@@ -437,7 +439,9 @@ grep -n -B 2 -A 2 "console\.log" "$file" 2>/dev/null | grep -E "^\s*(const|funct
**Stub classification:** A grep match is a STUB only when the value flows to rendering or user-visible output AND no other code path populates it with real data. A test helper, type default, or initial state that gets overwritten by a fetch/store is NOT a stub. Check for data-fetching (useEffect, fetch, query, useSWR, useQuery, subscribe) that writes to the same variable before flagging.
Categorize: 🛑 Blocker (prevents goal) | ⚠️ Warning (incomplete) | ℹ️ Info (notable)
**Debt marker gate:** Any `TBD`, `FIXME`, or `XXX` marker in a file modified by this phase is a 🛑 BLOCKER unless the same line references formal follow-up work (`issue #123`, `PR #123`, `#123`, or `DEF-*`). Unreferenced markers mean completion is not auditable; set `status: gaps_found` and list each marker under `gaps`.
Categorize: 🛑 Blocker (prevents goal or unresolved debt marker) | ⚠️ Warning (incomplete) | ℹ️ Info (notable)
## Step 7b: Behavioral Spot-Checks

View File

@@ -329,7 +329,8 @@ Extract files modified in this phase from SUMMARY.md, scan each:
| Pattern | Search | Severity |
|---------|--------|----------|
| TODO/FIXME/XXX/HACK | `grep -n -E "TODO\|FIXME\|XXX\|HACK"` | ⚠️ Warning |
| TBD/FIXME/XXX without same-line `issue #123`, `PR #123`, `#123`, or `DEF-*` reference | `grep -n -e TBD -e FIXME -e XXX` | 🛑 Blocker |
| TODO/HACK | `grep -n -e TODO -e HACK` | ⚠️ Warning |
| Placeholder content | `grep -n -iE "placeholder\|coming soon\|will be here"` | 🛑 Blocker |
| Empty returns | `grep -n -E "return null\|return \{\}\|return \[\]\|=> \{\}"` | ⚠️ Warning |
| Log-only functions | Functions containing only console.log | ⚠️ Warning |

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PhaseRunner, PhaseRunnerError } from './phase-runner.js';
@@ -39,15 +39,20 @@ vi.mock('./plan-parser.js', () => ({
}));
import { runPhaseStepSession } from './session-runner.js';
import { parsePlanFile } from './plan-parser.js';
const mockRunPhaseStepSession = vi.mocked(runPhaseStepSession);
const mockParsePlanFile = vi.mocked(parsePlanFile);
// ─── Factory helpers ─────────────────────────────────────────────────────────
let defaultProjectDir = '/tmp/project';
const defaultPhaseDir = '.planning/phases/01-auth';
function makePhaseOp(overrides: Partial<PhaseOpInfo> = {}): PhaseOpInfo {
return {
phase_found: true,
phase_dir: '/tmp/project/.planning/phases/01-auth',
phase_dir: defaultPhaseDir,
phase_number: '1',
phase_name: 'Authentication',
phase_slug: 'auth',
@@ -60,8 +65,8 @@ function makePhaseOp(overrides: Partial<PhaseOpInfo> = {}): PhaseOpInfo {
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '/tmp/project/.planning/phases/01-auth/CONTEXT.md',
research_path: '/tmp/project/.planning/phases/01-auth/RESEARCH.md',
context_path: join(defaultProjectDir, defaultPhaseDir, 'CONTEXT.md'),
research_path: join(defaultProjectDir, defaultPhaseDir, 'RESEARCH.md'),
...overrides,
};
}
@@ -100,6 +105,27 @@ function makePlanInfo(overrides: Partial<PlanInfo> = {}): PlanInfo {
};
}
function makeParsedPlan(filesModified: string[] = []) {
return {
frontmatter: {
phase: '01-auth',
plan: '01',
type: 'execute',
wave: 1,
depends_on: [],
files_modified: filesModified,
autonomous: true,
requirements: [],
must_haves: { truths: [], artifacts: [], key_links: [] },
},
objective: 'Test plan objective',
execution_context: [],
context_refs: [],
tasks: [{ name: 'Test task', type: 'auto', files: [], read_first: [], action: 'do the thing', verify: 'check it', done: 'done', acceptance_criteria: [] }],
raw: '',
};
}
function makePlanIndex(planCount: number, overrides: Partial<PhasePlanIndex> = {}): PhasePlanIndex {
const plans: PlanInfo[] = [];
const waves: Record<string, string[]> = {};
@@ -136,7 +162,7 @@ function makeDeps(overrides: Partial<PhaseRunnerDeps> = {}): PhaseRunnerDeps {
const events: GSDEvent[] = [];
return {
projectDir: '/tmp/project',
projectDir: defaultProjectDir,
tools: {
initPhaseOp: vi.fn().mockResolvedValue(makePhaseOp()),
phaseComplete: vi.fn().mockResolvedValue(undefined),
@@ -183,9 +209,21 @@ function getEmittedEvents(deps: PhaseRunnerDeps): GSDEvent[] {
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('PhaseRunner', () => {
beforeEach(() => {
let tempProjectDirs: string[] = [];
beforeEach(async () => {
tempProjectDirs = [];
defaultProjectDir = await mkdtemp(join(tmpdir(), 'gsd-phase-runner-default-'));
tempProjectDirs.push(defaultProjectDir);
await mkdir(join(defaultProjectDir, defaultPhaseDir), { recursive: true });
await writeFile(join(defaultProjectDir, defaultPhaseDir, '01-PLAN.md'), '---\nfiles_modified: []\n---\n', 'utf-8');
vi.clearAllMocks();
mockRunPhaseStepSession.mockResolvedValue(makePlanResult());
mockParsePlanFile.mockResolvedValue(makeParsedPlan());
});
afterEach(async () => {
await Promise.all(tempProjectDirs.map((dir) => rm(dir, { recursive: true, force: true })));
});
// ─── Happy path ────────────────────────────────────────────────────────
@@ -728,6 +766,331 @@ Use TypeScript.`, 'utf-8');
expect(verifyStep?.error).toBe('verification_gaps_found');
});
it('keeps phase pending when changed phase files contain unresolved TBD/FIXME/XXX markers', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-architectural-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# TBD: wire retry handling before release\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.success).toBe(false);
expect(verifyStep?.error).toBe('verification_gaps_found');
});
it('allows changed-file debt markers when they reference tracked follow-up work', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-tracked-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME(issue #3322): preserve upstream retry behavior\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(true);
expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1');
expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance);
});
it('allows changed-file entries for files deleted by the phase', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-deleted-file-debt-scan-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/deleted.sh"]\n---\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/deleted.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(true);
expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1');
expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance);
});
it('allows dotted lowercase xxx placeholder text when scanning debt markers', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-lowercase-placeholder-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\napi_host="xxx.example.test"\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(true);
expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1');
expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance);
});
it('keeps phase pending when changed files contain lowercase debt markers', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-lowercase-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# fixme: wire retry handling before release\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
});
it('keeps phase pending when debt markers are followed by punctuation', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-punctuated-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME. remove before release\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
});
it('allows bare hash issue references when they read as references', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-bare-hash-ref-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME tracked in #3322\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(true);
expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1');
expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance);
});
it('does not treat quoted numeric fragments as debt references', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-hex-fragment-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\ncolor="#123" # FIXME temp styling\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
});
it('does not allow unrelated earlier issue text to satisfy a later debt marker', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-unrelated-debt-ref-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\nlabel="issue #123"; # FIXME temp styling\n', 'utf-8');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
});
it('reports one unresolved debt finding per line', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-duplicate-debt-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const sourceDir = join(projectDir, 'scripts', 'upstream');
await mkdir(phaseDir, { recursive: true });
await mkdir(sourceDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8');
await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# TBD TBD before release\n', 'utf-8');
const logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any;
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config, logger });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const blockCall = logger.warn.mock.calls.find(([message]: [string]) => message.includes('Verification blocked'));
expect(blockCall?.[1].findings).toHaveLength(1);
expect(blockCall?.[1].findings[0]).toMatchObject({
file: 'scripts/upstream/run.sh',
line: 2,
marker: 'TBD',
});
expect(blockCall?.[1].findings[0]).not.toHaveProperty('text');
});
it('keeps phase pending when a declared file resolves through a symlink outside the project', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-symlink-project-'));
const externalDir = await mkdtemp(join(tmpdir(), 'gsd-symlink-external-'));
tempProjectDirs.push(projectDir, externalDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["linked-outside/secret.sh"]\n---\n', 'utf-8');
await writeFile(join(externalDir, 'secret.sh'), '#!/usr/bin/env bash\necho safe\n', 'utf-8');
await symlink(externalDir, join(projectDir, 'linked-outside'), 'dir');
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
mockParsePlanFile.mockResolvedValue(makeParsedPlan(['linked-outside/secret.sh']));
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
});
it('does not advance when verification status cannot be checked', async () => {
const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ config });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
(deps.tools.exec as ReturnType<typeof vi.fn>).mockImplementation((cmd: string) => {
if (cmd === 'check.verification-status') return Promise.reject(new Error('status parser crashed'));
return Promise.resolve(undefined);
});
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.success).toBe(false);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(mockRunPhaseStepSession.mock.calls.filter((call) => call[1] === PhaseStepType.Plan)).toHaveLength(1);
expect(mockRunPhaseStepSession.mock.calls.filter((call) => call[1] === PhaseStepType.Execute)).toHaveLength(1);
});
it('keeps phase pending when plan files cannot be listed for the debt scan', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'gsd-debt-missing-plans-'));
tempProjectDirs.push(projectDir);
const phaseDir = join(projectDir, '.planning', 'phases', '01-auth');
const logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any;
const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 });
const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any });
const deps = makeDeps({ projectDir, config, logger });
(deps.tools.initPhaseOp as ReturnType<typeof vi.fn>).mockResolvedValue(phaseOp);
const runner = new PhaseRunner(deps);
const result = await runner.run('1');
expect(result.success).toBe(false);
expect(deps.tools.phaseComplete).not.toHaveBeenCalled();
expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance);
const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify);
expect(verifyStep?.success).toBe(false);
expect(verifyStep?.error).toBe('verification_gaps_found');
expect(logger.warn.mock.calls.some(([message]: [string]) => message.includes('unresolved architectural debt markers'))).toBe(false);
expect(logger.warn.mock.calls.some(([message]: [string]) => message.includes('architectural debt scan could not complete'))).toBe(true);
});
it('halts when verification review callback rejects', async () => {
const onVerificationReview = vi.fn().mockResolvedValue('reject');
const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 });

View File

@@ -27,8 +27,9 @@ import type { ContextEngine } from './context-engine.js';
import type { GSDLogger } from './logger.js';
import { runPhaseStepSession, runPlanSession } from './session-runner.js';
import { parsePlanFile } from './plan-parser.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { checkResearchGate } from './research-gate.js';
// ─── Error type ──────────────────────────────────────────────────────────────
@@ -47,7 +48,22 @@ export class PhaseRunnerError extends Error {
// ─── Verification result enum ────────────────────────────────────────────────
export type VerificationOutcome = 'passed' | 'human_needed' | 'gaps_found';
export type VerificationOutcome = 'passed' | 'human_needed' | 'gaps_found' | 'architectural_debt' | 'status_unreadable';
interface ArchitecturalDebtFinding {
file: string;
line: number;
marker: string;
text: string;
}
type ArchitecturalDebtCheckReason = 'markers_found' | 'scan_error';
interface ArchitecturalDebtCheck {
pass: boolean;
findings: ArchitecturalDebtFinding[];
reason?: ArchitecturalDebtCheckReason;
}
// ─── PhaseRunner deps interface ──────────────────────────────────────────────
@@ -889,6 +905,21 @@ export class PhaseRunner {
outcome = await this.parseVerificationOutcome(lastResult, phaseNumber);
if (outcome === 'passed') {
const debtCheck = await this.checkArchitecturalDebt(phaseNumber);
if (!debtCheck.pass) {
const message =
debtCheck.reason === 'scan_error'
? `Verification blocked because architectural debt scan could not complete for phase ${phaseNumber}`
: `Verification blocked by unresolved architectural debt markers in phase ${phaseNumber}`;
this.logger?.warn(message, {
phase: phaseNumber,
reason: debtCheck.reason,
findingCount: debtCheck.findings.length,
findings: debtCheck.findings.map(({ file, line, marker }) => ({ file, line, marker })),
});
outcome = 'architectural_debt';
break;
}
break;
}
@@ -929,6 +960,10 @@ export class PhaseRunner {
}
}
if (outcome === 'status_unreadable') {
break;
}
if (outcome === 'gaps_found') {
if (gapRetryCount < maxGapRetries) {
gapRetryCount++;
@@ -986,7 +1021,7 @@ export class PhaseRunner {
step: PhaseStepType.Verify,
success: verifySuccess,
durationMs,
...(!verifySuccess && { error: `verification_${outcome}` }),
...(!verifySuccess && { error: this.verificationErrorForOutcome(outcome) }),
});
return {
@@ -994,7 +1029,7 @@ export class PhaseRunner {
success: verifySuccess,
durationMs,
planResults: allPlanResults,
...(!verifySuccess && { error: `verification_${outcome}` }),
...(!verifySuccess && { error: this.verificationErrorForOutcome(outcome) }),
};
}
@@ -1149,9 +1184,191 @@ export class PhaseRunner {
this.logger?.warn(`Unknown verification status '${status}' for phase ${phaseNumber}, treating as gaps_found`);
return 'gaps_found';
} catch (err) {
// Can't parse VERIFICATION.md — fall back to session result
// Can't parse VERIFICATION.md — fail closed so a missing/broken status check never completes the phase.
this.logger?.warn(`Could not check verification status for phase ${phaseNumber}: ${err instanceof Error ? err.message : String(err)}`);
return 'passed';
return 'status_unreadable';
}
}
private verificationErrorForOutcome(outcome: VerificationOutcome): string {
if (outcome === 'status_unreadable' || outcome === 'architectural_debt') return 'verification_gaps_found';
return `verification_${outcome}`;
}
/**
* Block phase completion when source files changed by this phase still contain
* unresolved TBD/FIXME/XXX comments. Markers are allowed only when the same
* line references tracked follow-up work (issue/PR number or DEF-* id).
*
* The debt scan is intentionally scoped to literal source paths declared in
* phase plan frontmatter `files_modified` and task `files`. Glob patterns are
* not expanded, and files modified during execution but omitted from the plan
* are not scanned; git-diff-based coverage would be a separate enhancement.
*/
private async checkArchitecturalDebt(phaseNumber: string): Promise<ArchitecturalDebtCheck> {
let phaseOp: PhaseOpInfo;
try {
phaseOp = await this.tools.initPhaseOp(phaseNumber);
} catch (err) {
this.logger?.warn(`Could not initialize phase ${phaseNumber} for architectural debt check: ${err instanceof Error ? err.message : String(err)}`);
return { pass: false, findings: [], reason: 'scan_error' };
}
let planPaths: string[];
try {
planPaths = await this.listPhasePlanPaths(phaseOp.phase_dir);
} catch {
return { pass: false, findings: [], reason: 'scan_error' };
}
if (phaseOp.has_plans && planPaths.length === 0) {
this.logger?.warn(`No phase plans found for architectural debt check in phase ${phaseNumber}`);
return { pass: false, findings: [], reason: 'scan_error' };
}
const filesToScan = new Set<string>();
for (const planPath of planPaths) {
try {
const parsedPlan = await parsePlanFile(planPath);
for (const file of this.extractPlanFiles(parsedPlan)) {
if (this.shouldScanForArchitecturalDebt(file)) {
filesToScan.add(file);
}
}
} catch (err) {
this.logger?.warn(`Could not parse plan for architectural debt check (${planPath}): ${err instanceof Error ? err.message : String(err)}`);
return { pass: false, findings: [], reason: 'scan_error' };
}
}
const findings: ArchitecturalDebtFinding[] = [];
for (const file of filesToScan) {
const absolutePath = this.resolveProjectPath(file);
if (!absolutePath) {
findings.push({ file, line: 0, marker: 'path', text: 'File is outside the project root' });
continue;
}
try {
const content = await readFile(absolutePath, 'utf-8');
findings.push(...this.findUnresolvedDebtMarkers(file, content));
} catch (err) {
const code = typeof err === 'object' && err !== null && 'code' in err ? (err as { code?: unknown }).code : undefined;
if (code === 'ENOENT') {
continue;
}
findings.push({
file,
line: 0,
marker: 'read',
text: err instanceof Error ? err.message : String(err),
});
}
}
const hasDebtMarkers = findings.some(({ marker }) => marker !== 'path' && marker !== 'read');
return {
pass: findings.length === 0,
findings,
reason: findings.length === 0 ? undefined : hasDebtMarkers ? 'markers_found' : 'scan_error',
};
}
private async listPhasePlanPaths(phaseDir: string): Promise<string[]> {
const absolutePhaseDir = this.resolveProjectPath(phaseDir);
if (!absolutePhaseDir) {
const err = new Error(`Phase directory is outside the project root: ${phaseDir}`);
this.logger?.warn(err.message);
throw err;
}
try {
const entries = await readdir(absolutePhaseDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && (entry.name === 'PLAN.md' || entry.name.endsWith('-PLAN.md')))
.map((entry) => join(absolutePhaseDir, entry.name));
} catch (err) {
this.logger?.warn(`Could not list phase plans for architectural debt check (${phaseDir}): ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
private extractPlanFiles(parsedPlan: ParsedPlan): string[] {
const files = new Set<string>();
for (const file of parsedPlan.frontmatter.files_modified ?? []) {
files.add(file);
}
for (const task of parsedPlan.tasks ?? []) {
for (const file of task.files ?? []) {
files.add(file);
}
}
return [...files];
}
private shouldScanForArchitecturalDebt(file: string): boolean {
return !/\.(md|markdown)$/i.test(file);
}
private findUnresolvedDebtMarkers(file: string, content: string): ArchitecturalDebtFinding[] {
const findings: ArchitecturalDebtFinding[] = [];
const markerPattern = /(?:^|[^\w.])(TBD|FIXME|XXX)(?=\b(?:\.(?:\s|$)|[^\w.]|$))/i;
const lines = content.split(/\r?\n/);
lines.forEach((line, index) => {
const match = markerPattern.exec(line);
if (match) {
const markerSegment = line.slice(match.index);
if (this.hasFormalDebtReference(markerSegment)) return;
findings.push({
file,
line: index + 1,
marker: match[1].toUpperCase(),
text: line.trim(),
});
}
});
return findings;
}
private hasFormalDebtReference(line: string): boolean {
return /\bDEF-[A-Z0-9-]+\b/i.test(line) || /\b(?:issue|issues|pr|pull request)\s+#?\d+\b/i.test(line) || /(?:^|\s)#\d+\b/.test(line);
}
private resolveProjectPath(pathValue: string): string | undefined {
const root = this.realpathForBoundary(resolve(this.projectDir));
if (!root) return undefined;
const absolutePath = isAbsolute(pathValue) ? resolve(pathValue) : resolve(this.projectDir, pathValue);
const canonicalPath = this.realpathForBoundary(absolutePath);
if (!canonicalPath) return undefined;
const relativePath = relative(root, canonicalPath);
if (relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))) {
return canonicalPath;
}
return undefined;
}
private realpathForBoundary(pathValue: string): string | undefined {
const missingSegments: string[] = [];
let currentPath = pathValue;
while (true) {
try {
return join(realpathSync(currentPath), ...missingSegments.reverse());
} catch (err) {
const code = typeof err === 'object' && err !== null && 'code' in err ? (err as { code?: unknown }).code : undefined;
if (code !== 'ENOENT') return undefined;
const parent = dirname(currentPath);
if (parent === currentPath) return undefined;
missingSegments.push(basename(currentPath));
currentPath = parent;
}
}
}