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

@@ -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 });