refactor(test): replace try/finally with t.after() in gsd/tests (a-d) (#2395)
This commit is contained in:
parent
30775f4dcc
commit
2223298f76
23 changed files with 1857 additions and 2088 deletions
|
|
@ -130,119 +130,119 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", (
|
|||
|
||||
// ─── Integration: single milestone completes → merged to main ────────────────
|
||||
|
||||
test("single milestone worktree is merged to main when all complete (#962)", () => {
|
||||
test("single milestone worktree is merged to main when all complete (#962)", (t) => {
|
||||
const savedCwd = process.cwd();
|
||||
let tempDir = "";
|
||||
|
||||
try {
|
||||
tempDir = createTempRepo();
|
||||
|
||||
// Set up a single milestone
|
||||
createMilestoneArtifacts(tempDir, "M001");
|
||||
run("git add .", tempDir);
|
||||
run('git commit -m "add milestone"', tempDir);
|
||||
|
||||
// Create worktree and simulate work
|
||||
const wt = createAutoWorktree(tempDir, "M001");
|
||||
assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree");
|
||||
|
||||
writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n");
|
||||
run("git add .", wt);
|
||||
run('git commit -m "feat(M001): add feature"', wt);
|
||||
|
||||
// Simulate the fix: merge before stopping (what the "all complete" path now does)
|
||||
const roadmapPath = join(
|
||||
tempDir,
|
||||
".gsd",
|
||||
"milestones",
|
||||
"M001",
|
||||
"M001-ROADMAP.md",
|
||||
);
|
||||
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
||||
const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent);
|
||||
|
||||
// Verify work is on main
|
||||
assert.ok(
|
||||
existsSync(join(tempDir, "feature.ts")),
|
||||
"feature.ts should be on main after merge",
|
||||
);
|
||||
assert.equal(process.cwd(), tempDir, "cwd restored to project root");
|
||||
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree");
|
||||
assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared");
|
||||
|
||||
// Verify milestone branch was cleaned up
|
||||
const branches = run("git branch", tempDir);
|
||||
assert.ok(
|
||||
!branches.includes("milestone/M001"),
|
||||
"milestone branch should be deleted",
|
||||
);
|
||||
|
||||
// Verify squash commit on main
|
||||
const log = run("git log --oneline -3", tempDir);
|
||||
assert.ok(
|
||||
log.includes("M001"),
|
||||
"squash commit on main should reference M001",
|
||||
);
|
||||
|
||||
assert.ok(mergeResult.commitMessage.length > 0, "commit message returned");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(savedCwd);
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tempDir = createTempRepo();
|
||||
|
||||
// Set up a single milestone
|
||||
createMilestoneArtifacts(tempDir, "M001");
|
||||
run("git add .", tempDir);
|
||||
run('git commit -m "add milestone"', tempDir);
|
||||
|
||||
// Create worktree and simulate work
|
||||
const wt = createAutoWorktree(tempDir, "M001");
|
||||
assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree");
|
||||
|
||||
writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n");
|
||||
run("git add .", wt);
|
||||
run('git commit -m "feat(M001): add feature"', wt);
|
||||
|
||||
// Simulate the fix: merge before stopping (what the "all complete" path now does)
|
||||
const roadmapPath = join(
|
||||
tempDir,
|
||||
".gsd",
|
||||
"milestones",
|
||||
"M001",
|
||||
"M001-ROADMAP.md",
|
||||
);
|
||||
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
||||
const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent);
|
||||
|
||||
// Verify work is on main
|
||||
assert.ok(
|
||||
existsSync(join(tempDir, "feature.ts")),
|
||||
"feature.ts should be on main after merge",
|
||||
);
|
||||
assert.equal(process.cwd(), tempDir, "cwd restored to project root");
|
||||
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree");
|
||||
assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared");
|
||||
|
||||
// Verify milestone branch was cleaned up
|
||||
const branches = run("git branch", tempDir);
|
||||
assert.ok(
|
||||
!branches.includes("milestone/M001"),
|
||||
"milestone branch should be deleted",
|
||||
);
|
||||
|
||||
// Verify squash commit on main
|
||||
const log = run("git log --oneline -3", tempDir);
|
||||
assert.ok(
|
||||
log.includes("M001"),
|
||||
"squash commit on main should reference M001",
|
||||
);
|
||||
|
||||
assert.ok(mergeResult.commitMessage.length > 0, "commit message returned");
|
||||
});
|
||||
|
||||
// ─── Integration: last of multiple milestones completes → merged ─────────────
|
||||
|
||||
test("last milestone worktree is merged when it's the final one (#962)", () => {
|
||||
test("last milestone worktree is merged when it's the final one (#962)", (t) => {
|
||||
const savedCwd = process.cwd();
|
||||
let tempDir = "";
|
||||
|
||||
try {
|
||||
tempDir = createTempRepo();
|
||||
|
||||
// Set up two milestones
|
||||
createMilestoneArtifacts(tempDir, "M001");
|
||||
createMilestoneArtifacts(tempDir, "M002");
|
||||
run("git add .", tempDir);
|
||||
run('git commit -m "add milestones"', tempDir);
|
||||
|
||||
// Complete M001 first (merge it)
|
||||
const wt1 = createAutoWorktree(tempDir, "M001");
|
||||
writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n");
|
||||
run("git add .", wt1);
|
||||
run('git commit -m "feat(M001): m001 work"', wt1);
|
||||
const roadmap1 = readFileSync(
|
||||
join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
"utf-8",
|
||||
);
|
||||
mergeMilestoneToMain(tempDir, "M001", roadmap1);
|
||||
|
||||
// Now complete M002 (the LAST milestone — this is the #962 scenario)
|
||||
const wt2 = createAutoWorktree(tempDir, "M002");
|
||||
writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n");
|
||||
run("git add .", wt2);
|
||||
run('git commit -m "feat(M002): m002 work"', wt2);
|
||||
const roadmap2 = readFileSync(
|
||||
join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"),
|
||||
"utf-8",
|
||||
);
|
||||
mergeMilestoneToMain(tempDir, "M002", roadmap2);
|
||||
|
||||
// Both features should now be on main
|
||||
assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main");
|
||||
assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main");
|
||||
assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge");
|
||||
|
||||
// Both milestone branches should be cleaned up
|
||||
const branches = run("git branch", tempDir);
|
||||
assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted");
|
||||
assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(savedCwd);
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tempDir = createTempRepo();
|
||||
|
||||
// Set up two milestones
|
||||
createMilestoneArtifacts(tempDir, "M001");
|
||||
createMilestoneArtifacts(tempDir, "M002");
|
||||
run("git add .", tempDir);
|
||||
run('git commit -m "add milestones"', tempDir);
|
||||
|
||||
// Complete M001 first (merge it)
|
||||
const wt1 = createAutoWorktree(tempDir, "M001");
|
||||
writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n");
|
||||
run("git add .", wt1);
|
||||
run('git commit -m "feat(M001): m001 work"', wt1);
|
||||
const roadmap1 = readFileSync(
|
||||
join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
"utf-8",
|
||||
);
|
||||
mergeMilestoneToMain(tempDir, "M001", roadmap1);
|
||||
|
||||
// Now complete M002 (the LAST milestone — this is the #962 scenario)
|
||||
const wt2 = createAutoWorktree(tempDir, "M002");
|
||||
writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n");
|
||||
run("git add .", wt2);
|
||||
run('git commit -m "feat(M002): m002 work"', wt2);
|
||||
const roadmap2 = readFileSync(
|
||||
join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"),
|
||||
"utf-8",
|
||||
);
|
||||
mergeMilestoneToMain(tempDir, "M002", roadmap2);
|
||||
|
||||
// Both features should now be on main
|
||||
assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main");
|
||||
assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main");
|
||||
assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge");
|
||||
|
||||
// Both milestone branches should be cleaned up
|
||||
const branches = run("git branch", tempDir);
|
||||
assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted");
|
||||
assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,26 +110,24 @@ test("clearLock is safe when no lock file exists", () => {
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("bootstrap cleanup releases session lock artifacts", () => {
|
||||
test("bootstrap cleanup releases session lock artifacts", (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
||||
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
||||
|
||||
try {
|
||||
const result = acquireSessionLock(dir);
|
||||
assert.equal(result.acquired, true, "session lock should be acquired");
|
||||
assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held");
|
||||
if (properLockfileAvailable) {
|
||||
assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held");
|
||||
}
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
releaseSessionLock(dir);
|
||||
clearLock(dir);
|
||||
|
||||
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup");
|
||||
assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
const result = acquireSessionLock(dir);
|
||||
assert.equal(result.acquired, true, "session lock should be acquired");
|
||||
assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held");
|
||||
if (properLockfileAvailable) {
|
||||
assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held");
|
||||
}
|
||||
|
||||
releaseSessionLock(dir);
|
||||
clearLock(dir);
|
||||
|
||||
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup");
|
||||
assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup");
|
||||
});
|
||||
|
||||
// ─── isLockProcessAlive detects live vs dead PIDs ────────────────────────
|
||||
|
|
|
|||
|
|
@ -51,93 +51,79 @@ function cleanup(base: string): void {
|
|||
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
||||
}
|
||||
|
||||
test("resolveMilestonePath returns null for missing milestone", () => {
|
||||
test("resolveMilestonePath returns null for missing milestone", (t) => {
|
||||
const base = makeTmpBase();
|
||||
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
|
||||
try {
|
||||
const result = resolveMilestonePath(base, "M999");
|
||||
assert.equal(result, null, "should return null for non-existent milestone");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const result = resolveMilestonePath(base, "M999");
|
||||
assert.equal(result, null, "should return null for non-existent milestone");
|
||||
});
|
||||
|
||||
test("resolveMilestonePath returns path for existing milestone", () => {
|
||||
test("resolveMilestonePath returns path for existing milestone", (t) => {
|
||||
const base = makeTmpBase();
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
try {
|
||||
const result = resolveMilestonePath(base, "M001");
|
||||
assert.ok(result, "should return a path for existing milestone");
|
||||
assert.ok(result.includes("M001"), "path should contain the milestone ID");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const result = resolveMilestonePath(base, "M001");
|
||||
assert.ok(result, "should return a path for existing milestone");
|
||||
assert.ok(result.includes("M001"), "path should contain the milestone ID");
|
||||
});
|
||||
|
||||
test("resolveMilestoneFile returns null when no SUMMARY exists", () => {
|
||||
test("resolveMilestoneFile returns null when no SUMMARY exists", (t) => {
|
||||
const base = makeTmpBase();
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
try {
|
||||
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
assert.equal(result, null, "should return null when no SUMMARY file");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
assert.equal(result, null, "should return null when no SUMMARY file");
|
||||
});
|
||||
|
||||
test("resolveMilestoneFile returns path when SUMMARY exists (completed)", () => {
|
||||
test("resolveMilestoneFile returns path when SUMMARY exists (completed)", (t) => {
|
||||
const base = makeTmpBase();
|
||||
const mDir = join(base, ".gsd", "milestones", "M001");
|
||||
mkdirSync(mDir, { recursive: true });
|
||||
writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone.");
|
||||
try {
|
||||
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
assert.ok(result, "should return a path when SUMMARY exists");
|
||||
assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
assert.ok(result, "should return a path when SUMMARY exists");
|
||||
assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY");
|
||||
});
|
||||
|
||||
// ─── Combined validation logic (mirrors auto.ts resume guard) ───────────────
|
||||
|
||||
test("stale milestone: missing dir means paused session should be discarded", () => {
|
||||
test("stale milestone: missing dir means paused session should be discarded", (t) => {
|
||||
const base = makeTmpBase();
|
||||
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
|
||||
try {
|
||||
const mDir = resolveMilestonePath(base, "M999");
|
||||
const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY");
|
||||
const isStale = !mDir || !!summaryFile;
|
||||
assert.ok(isStale, "milestone that doesn't exist should be detected as stale");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const mDir = resolveMilestonePath(base, "M999");
|
||||
const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY");
|
||||
const isStale = !mDir || !!summaryFile;
|
||||
assert.ok(isStale, "milestone that doesn't exist should be detected as stale");
|
||||
});
|
||||
|
||||
test("stale milestone: completed (has SUMMARY) means paused session should be discarded", () => {
|
||||
test("stale milestone: completed (has SUMMARY) means paused session should be discarded", (t) => {
|
||||
const base = makeTmpBase();
|
||||
const mDir = join(base, ".gsd", "milestones", "M001");
|
||||
mkdirSync(mDir, { recursive: true });
|
||||
writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone.");
|
||||
try {
|
||||
const dir = resolveMilestonePath(base, "M001");
|
||||
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
const isStale = !dir || !!summaryFile;
|
||||
assert.ok(isStale, "milestone with SUMMARY should be detected as stale");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const dir = resolveMilestonePath(base, "M001");
|
||||
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
const isStale = !dir || !!summaryFile;
|
||||
assert.ok(isStale, "milestone with SUMMARY should be detected as stale");
|
||||
});
|
||||
|
||||
test("valid milestone: exists and has no SUMMARY means paused session is valid", () => {
|
||||
test("valid milestone: exists and has no SUMMARY means paused session is valid", (t) => {
|
||||
const base = makeTmpBase();
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
try {
|
||||
const dir = resolveMilestonePath(base, "M001");
|
||||
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
const isStale = !dir || !!summaryFile;
|
||||
assert.ok(!isStale, "active milestone should not be detected as stale");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const dir = resolveMilestonePath(base, "M001");
|
||||
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
|
||||
const isStale = !dir || !!summaryFile;
|
||||
assert.ok(!isStale, "active milestone should not be detected as stale");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|||
|
||||
import { runGSDDoctor, selectDoctorScope, filterDoctorIssues } from "../doctor.js";
|
||||
|
||||
test("auto-preflight scopes to active milestone, ignoring historical", async () => {
|
||||
test("auto-preflight scopes to active milestone, ignoring historical", async (t) => {
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
|
||||
|
|
@ -23,18 +23,16 @@ test("auto-preflight scopes to active milestone, ignoring historical", async ()
|
|||
writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`);
|
||||
writeFileSync(join(gsd, "milestones", "M009", "slices", "S01", "S01-PLAN.md"), `# S01: Active Slice\n\n**Goal:** Active\n**Demo:** Active\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Active Task** \`est:5m\`\n todo\n`);
|
||||
|
||||
try {
|
||||
const scope = await selectDoctorScope(tmpBase);
|
||||
assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone");
|
||||
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
|
||||
|
||||
const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope });
|
||||
const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false });
|
||||
assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope");
|
||||
const scope = await selectDoctorScope(tmpBase);
|
||||
assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone");
|
||||
|
||||
const historicalReport = await runGSDDoctor(tmpBase, { fix: false });
|
||||
const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning");
|
||||
assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings");
|
||||
} finally {
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
}
|
||||
const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope });
|
||||
const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false });
|
||||
assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope");
|
||||
|
||||
const historicalReport = await runGSDDoctor(tmpBase, { fix: false });
|
||||
const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning");
|
||||
assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings");
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -43,31 +43,36 @@ function makeNoUICtx(cwd: string) {
|
|||
|
||||
// ─── Scenario 1: No manifest exists ──────────────────────────────────────────
|
||||
|
||||
test('secrets gate: no manifest exists — getManifestStatus returns null', async () => {
|
||||
test('secrets gate: no manifest exists — getManifestStatus returns null', async (t) => {
|
||||
const tmp = makeTempDir('gate-no-manifest');
|
||||
try {
|
||||
// No .gsd directory at all
|
||||
const result = await getManifestStatus(tmp, 'M001');
|
||||
assert.strictEqual(result, null, 'should return null when no manifest file exists');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// No .gsd directory at all
|
||||
const result = await getManifestStatus(tmp, 'M001');
|
||||
assert.strictEqual(result, null, 'should return null when no manifest file exists');
|
||||
});
|
||||
|
||||
// ─── Scenario 2: Pending keys exist ─────────────────────────────────────────
|
||||
|
||||
test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => {
|
||||
test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async (t) => {
|
||||
const tmp = makeTempDir('gate-pending');
|
||||
const savedA = process.env.GSD_GATE_TEST_EXISTING;
|
||||
try {
|
||||
// Simulate one key already in env
|
||||
process.env.GSD_GATE_TEST_EXISTING = 'already-here';
|
||||
|
||||
// Ensure pending keys are NOT in env
|
||||
t.after(() => {
|
||||
delete process.env.GSD_GATE_TEST_EXISTING;
|
||||
if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
|
||||
delete process.env.GSD_GATE_TEST_PEND_A;
|
||||
delete process.env.GSD_GATE_TEST_PEND_B;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
writeManifest(tmp, `# Secrets Manifest
|
||||
// Simulate one key already in env
|
||||
process.env.GSD_GATE_TEST_EXISTING = 'already-here';
|
||||
|
||||
// Ensure pending keys are NOT in env
|
||||
delete process.env.GSD_GATE_TEST_PEND_A;
|
||||
delete process.env.GSD_GATE_TEST_PEND_B;
|
||||
|
||||
writeManifest(tmp, `# Secrets Manifest
|
||||
|
||||
**Milestone:** M001
|
||||
**Generated:** 2025-06-20T10:00:00Z
|
||||
|
|
@ -97,62 +102,60 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up
|
|||
1. Already in env
|
||||
`);
|
||||
|
||||
// (a) Verify getManifestStatus shows pending keys
|
||||
const status = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(status, null, 'manifest should exist');
|
||||
assert.ok(status!.pending.length > 0, 'should have pending keys');
|
||||
assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys');
|
||||
assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys');
|
||||
// (a) Verify getManifestStatus shows pending keys
|
||||
const status = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(status, null, 'manifest should exist');
|
||||
assert.ok(status!.pending.length > 0, 'should have pending keys');
|
||||
assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys');
|
||||
assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys');
|
||||
|
||||
// (b) Call collectSecretsFromManifest with no-UI context
|
||||
// With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
|
||||
const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
|
||||
// (b) Call collectSecretsFromManifest with no-UI context
|
||||
// With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
|
||||
const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
|
||||
|
||||
// (c) Verify return shape
|
||||
assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
|
||||
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
|
||||
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
|
||||
assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
|
||||
// (c) Verify return shape
|
||||
assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
|
||||
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
|
||||
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
|
||||
assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
|
||||
|
||||
// (d) Verify manifest on disk was updated — pending entries that went through
|
||||
// collection are now "skipped". The existing-in-env entry retains its manifest
|
||||
// status ("pending") because collectSecretsFromManifest only updates entries
|
||||
// that flow through collectOneSecret. At runtime, getManifestStatus overrides
|
||||
// env-present entries to "existing" regardless of manifest status.
|
||||
const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
|
||||
const updatedContent = readFileSync(manifestPath, 'utf8');
|
||||
assert.ok(
|
||||
updatedContent.includes('**Status:** skipped'),
|
||||
'formerly-pending entries should now have status "skipped" in the manifest file',
|
||||
);
|
||||
// Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
|
||||
const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
|
||||
assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
|
||||
const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
|
||||
assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
|
||||
// (d) Verify manifest on disk was updated — pending entries that went through
|
||||
// collection are now "skipped". The existing-in-env entry retains its manifest
|
||||
// status ("pending") because collectSecretsFromManifest only updates entries
|
||||
// that flow through collectOneSecret. At runtime, getManifestStatus overrides
|
||||
// env-present entries to "existing" regardless of manifest status.
|
||||
const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
|
||||
const updatedContent = readFileSync(manifestPath, 'utf8');
|
||||
assert.ok(
|
||||
updatedContent.includes('**Status:** skipped'),
|
||||
'formerly-pending entries should now have status "skipped" in the manifest file',
|
||||
);
|
||||
// Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
|
||||
const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
|
||||
assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
|
||||
const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
|
||||
assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
|
||||
|
||||
// (e) Verify getManifestStatus now shows no pending
|
||||
const statusAfter = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(statusAfter, null);
|
||||
assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
|
||||
} finally {
|
||||
delete process.env.GSD_GATE_TEST_EXISTING;
|
||||
if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
|
||||
delete process.env.GSD_GATE_TEST_PEND_A;
|
||||
delete process.env.GSD_GATE_TEST_PEND_B;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
// (e) Verify getManifestStatus now shows no pending
|
||||
const statusAfter = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(statusAfter, null);
|
||||
assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
|
||||
});
|
||||
|
||||
// ─── Scenario 3: No pending keys — all collected or in env ──────────────────
|
||||
|
||||
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => {
|
||||
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async (t) => {
|
||||
const tmp = makeTempDir('gate-no-pending');
|
||||
const savedKey = process.env.GSD_GATE_TEST_ENVKEY;
|
||||
try {
|
||||
process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
|
||||
t.after(() => {
|
||||
delete process.env.GSD_GATE_TEST_ENVKEY;
|
||||
if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
writeManifest(tmp, `# Secrets Manifest
|
||||
process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
|
||||
|
||||
writeManifest(tmp, `# Secrets Manifest
|
||||
|
||||
**Milestone:** M001
|
||||
**Generated:** 2025-06-20T10:00:00Z
|
||||
|
|
@ -182,15 +185,10 @@ test('secrets gate: no pending keys — getManifestStatus shows pending.length =
|
|||
1. In env already
|
||||
`);
|
||||
|
||||
const result = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(result, null, 'manifest should exist');
|
||||
assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
|
||||
assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
|
||||
assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
|
||||
assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
|
||||
} finally {
|
||||
delete process.env.GSD_GATE_TEST_ENVKEY;
|
||||
if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const result = await getManifestStatus(tmp, 'M001');
|
||||
assert.notStrictEqual(result, null, 'manifest should exist');
|
||||
assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
|
||||
assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
|
||||
assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
|
||||
assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,176 +36,156 @@ function makeTempDir(prefix: string): string {
|
|||
|
||||
// ─── appendCapture ────────────────────────────────────────────────────────────
|
||||
|
||||
test("captures: appendCapture creates CAPTURES.md on first call", () => {
|
||||
test("captures: appendCapture creates CAPTURES.md on first call", (t) => {
|
||||
const tmp = makeTempDir("cap-create");
|
||||
try {
|
||||
const id = appendCapture(tmp, "first thought");
|
||||
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
|
||||
assert.ok(
|
||||
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
|
||||
"CAPTURES.md should exist",
|
||||
);
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes("# Captures"), "should have header");
|
||||
assert.ok(content.includes(`### ${id}`), "should have entry heading");
|
||||
assert.ok(
|
||||
content.includes("**Text:** first thought"),
|
||||
"should have text field",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Status:** pending"),
|
||||
"should have pending status",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const id = appendCapture(tmp, "first thought");
|
||||
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
|
||||
assert.ok(
|
||||
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
|
||||
"CAPTURES.md should exist",
|
||||
);
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes("# Captures"), "should have header");
|
||||
assert.ok(content.includes(`### ${id}`), "should have entry heading");
|
||||
assert.ok(
|
||||
content.includes("**Text:** first thought"),
|
||||
"should have text field",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Status:** pending"),
|
||||
"should have pending status",
|
||||
);
|
||||
});
|
||||
|
||||
test("captures: appendCapture appends to existing file", () => {
|
||||
test("captures: appendCapture appends to existing file", (t) => {
|
||||
const tmp = makeTempDir("cap-append");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "thought one");
|
||||
const id2 = appendCapture(tmp, "thought two");
|
||||
assert.notStrictEqual(id1, id2, "IDs should be unique");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes(`### ${id1}`), "should have first entry");
|
||||
assert.ok(content.includes(`### ${id2}`), "should have second entry");
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought one"),
|
||||
"should have first text",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought two"),
|
||||
"should have second text",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const id1 = appendCapture(tmp, "thought one");
|
||||
const id2 = appendCapture(tmp, "thought two");
|
||||
assert.notStrictEqual(id1, id2, "IDs should be unique");
|
||||
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes(`### ${id1}`), "should have first entry");
|
||||
assert.ok(content.includes(`### ${id2}`), "should have second entry");
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought one"),
|
||||
"should have first text",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought two"),
|
||||
"should have second text",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── loadAllCaptures / loadPendingCaptures ────────────────────────────────────
|
||||
|
||||
test("captures: loadAllCaptures parses entries correctly", () => {
|
||||
test("captures: loadAllCaptures parses entries correctly", (t) => {
|
||||
const tmp = makeTempDir("cap-load");
|
||||
try {
|
||||
appendCapture(tmp, "alpha");
|
||||
appendCapture(tmp, "beta");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "should have 2 entries");
|
||||
assert.strictEqual(all[0].text, "alpha");
|
||||
assert.strictEqual(all[1].text, "beta");
|
||||
assert.strictEqual(all[0].status, "pending");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
appendCapture(tmp, "alpha");
|
||||
appendCapture(tmp, "beta");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "should have 2 entries");
|
||||
assert.strictEqual(all[0].text, "alpha");
|
||||
assert.strictEqual(all[1].text, "beta");
|
||||
assert.strictEqual(all[0].status, "pending");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
});
|
||||
|
||||
test("captures: loadAllCaptures returns empty array when no file", () => {
|
||||
test("captures: loadAllCaptures returns empty array when no file", (t) => {
|
||||
const tmp = makeTempDir("cap-nofile");
|
||||
try {
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 0);
|
||||
});
|
||||
|
||||
test("captures: loadPendingCaptures filters resolved entries", () => {
|
||||
test("captures: loadPendingCaptures filters resolved entries", (t) => {
|
||||
const tmp = makeTempDir("cap-pending");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "pending one");
|
||||
appendCapture(tmp, "pending two");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
||||
const id1 = appendCapture(tmp, "pending one");
|
||||
appendCapture(tmp, "pending two");
|
||||
|
||||
const pending = loadPendingCaptures(tmp);
|
||||
assert.strictEqual(pending.length, 1, "should have 1 pending");
|
||||
assert.strictEqual(pending[0].text, "pending two");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
||||
|
||||
const pending = loadPendingCaptures(tmp);
|
||||
assert.strictEqual(pending.length, 1, "should have 1 pending");
|
||||
assert.strictEqual(pending[0].text, "pending two");
|
||||
});
|
||||
|
||||
test("captures: loadAllCaptures preserves resolved entries", () => {
|
||||
test("captures: loadAllCaptures preserves resolved entries", (t) => {
|
||||
const tmp = makeTempDir("cap-all-resolved");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "pending one");
|
||||
appendCapture(tmp, "pending two");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
||||
const id1 = appendCapture(tmp, "pending one");
|
||||
appendCapture(tmp, "pending two");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "all should still have 2");
|
||||
assert.strictEqual(all[0].status, "resolved");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "all should still have 2");
|
||||
assert.strictEqual(all[0].status, "resolved");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
});
|
||||
|
||||
// ─── hasPendingCaptures ───────────────────────────────────────────────────────
|
||||
|
||||
test("captures: hasPendingCaptures returns false when no file", () => {
|
||||
test("captures: hasPendingCaptures returns false when no file", (t) => {
|
||||
const tmp = makeTempDir("cap-has-nofile");
|
||||
try {
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
});
|
||||
|
||||
test("captures: hasPendingCaptures returns true with pending entries", () => {
|
||||
test("captures: hasPendingCaptures returns true with pending entries", (t) => {
|
||||
const tmp = makeTempDir("cap-has-true");
|
||||
try {
|
||||
appendCapture(tmp, "something");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), true);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
appendCapture(tmp, "something");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), true);
|
||||
});
|
||||
|
||||
test("captures: hasPendingCaptures returns false when all resolved", () => {
|
||||
test("captures: hasPendingCaptures returns false when all resolved", (t) => {
|
||||
const tmp = makeTempDir("cap-has-false");
|
||||
try {
|
||||
const id = appendCapture(tmp, "will resolve");
|
||||
markCaptureResolved(tmp, id, "note", "done", "resolved it");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const id = appendCapture(tmp, "will resolve");
|
||||
markCaptureResolved(tmp, id, "note", "done", "resolved it");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
});
|
||||
|
||||
// ─── markCaptureResolved ──────────────────────────────────────────────────────
|
||||
|
||||
test("captures: markCaptureResolved updates entry in place", () => {
|
||||
test("captures: markCaptureResolved updates entry in place", (t) => {
|
||||
const tmp = makeTempDir("cap-resolve");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "keep pending");
|
||||
const id2 = appendCapture(tmp, "will resolve");
|
||||
appendCapture(tmp, "also pending");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
|
||||
const id1 = appendCapture(tmp, "keep pending");
|
||||
const id2 = appendCapture(tmp, "will resolve");
|
||||
appendCapture(tmp, "also pending");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 3, "should still have 3 entries");
|
||||
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
|
||||
|
||||
const resolved = all.find((c) => c.id === id2)!;
|
||||
assert.strictEqual(resolved.status, "resolved");
|
||||
assert.strictEqual(resolved.classification, "quick-task");
|
||||
assert.strictEqual(resolved.resolution, "executed inline");
|
||||
assert.strictEqual(resolved.rationale, "small fix");
|
||||
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 3, "should still have 3 entries");
|
||||
|
||||
// Others should be unaffected
|
||||
const kept = all.find((c) => c.id === id1)!;
|
||||
assert.strictEqual(kept.status, "pending");
|
||||
assert.strictEqual(kept.classification, undefined);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const resolved = all.find((c) => c.id === id2)!;
|
||||
assert.strictEqual(resolved.status, "resolved");
|
||||
assert.strictEqual(resolved.classification, "quick-task");
|
||||
assert.strictEqual(resolved.resolution, "executed inline");
|
||||
assert.strictEqual(resolved.rationale, "small fix");
|
||||
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
|
||||
|
||||
// Others should be unaffected
|
||||
const kept = all.find((c) => c.id === id1)!;
|
||||
assert.strictEqual(kept.status, "pending");
|
||||
assert.strictEqual(kept.classification, undefined);
|
||||
});
|
||||
|
||||
// ─── resolveCapturesPath ──────────────────────────────────────────────────────
|
||||
|
|
@ -371,58 +351,50 @@ test("triage: parseTriageOutput handles all five classification types", () => {
|
|||
|
||||
// ─── Edge Cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("captures: appendCapture handles special characters in text", () => {
|
||||
test("captures: appendCapture handles special characters in text", (t) => {
|
||||
const tmp = makeTempDir("cap-special");
|
||||
try {
|
||||
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
|
||||
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
|
||||
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
|
||||
});
|
||||
|
||||
test("captures: markCaptureResolved is no-op for non-existent ID", () => {
|
||||
test("captures: markCaptureResolved is no-op for non-existent ID", (t) => {
|
||||
const tmp = makeTempDir("cap-noop");
|
||||
try {
|
||||
appendCapture(tmp, "real capture");
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
appendCapture(tmp, "real capture");
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
|
||||
});
|
||||
|
||||
test("captures: markCaptureResolved is no-op when no file exists", () => {
|
||||
test("captures: markCaptureResolved is no-op when no file exists", (t) => {
|
||||
const tmp = makeTempDir("cap-nofile-resolve");
|
||||
try {
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
|
||||
});
|
||||
|
||||
test("captures: re-resolving a capture overwrites previous resolution", () => {
|
||||
test("captures: re-resolving a capture overwrites previous resolution", (t) => {
|
||||
const tmp = makeTempDir("cap-reresolve");
|
||||
try {
|
||||
const id = appendCapture(tmp, "will re-resolve");
|
||||
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
|
||||
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
|
||||
assert.strictEqual(all[0].resolution, "second resolution");
|
||||
assert.strictEqual(all[0].rationale, "second rationale");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const id = appendCapture(tmp, "will re-resolve");
|
||||
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
|
||||
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
|
||||
assert.strictEqual(all[0].resolution, "second resolution");
|
||||
assert.strictEqual(all[0].rationale, "second rationale");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
* `/plugin marketplace add ...` source model.
|
||||
*/
|
||||
|
||||
|
||||
import { describe, it, before, after, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
|
|
@ -306,45 +305,45 @@ describe(
|
|||
});
|
||||
});
|
||||
|
||||
it('should not persist marketplace agent directories into package sources', async () => {
|
||||
it('should not persist marketplace agent directories into package sources', async (t) => {
|
||||
const isolatedAgentDir = join(tempDir, '.gsd', 'agent');
|
||||
const settingsPath = join(isolatedAgentDir, 'settings.json');
|
||||
rmSync(isolatedAgentDir, { recursive: true, force: true });
|
||||
process.env.GSD_CODING_AGENT_DIR = isolatedAgentDir;
|
||||
|
||||
try {
|
||||
mkdirSync(isolatedAgentDir, { recursive: true });
|
||||
const tempSettings: Record<string, unknown> = { packages: [] };
|
||||
writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2));
|
||||
|
||||
const { ctx } = createMockContext([
|
||||
'Plugins only',
|
||||
'Yes - discover plugins and select components',
|
||||
'Import all components',
|
||||
'Yes, continue',
|
||||
]);
|
||||
|
||||
const readPrefs = () => ({ ...prefs });
|
||||
const writePrefs = async (p: Record<string, unknown>) => {
|
||||
Object.assign(prefs, p);
|
||||
};
|
||||
|
||||
await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs);
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] };
|
||||
const packageEntries = Array.isArray(settings.packages) ? settings.packages : [];
|
||||
const hasAgentsDirPackage = packageEntries.some((entry) => {
|
||||
const source = typeof entry === 'string'
|
||||
? entry
|
||||
: (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined);
|
||||
return typeof source === 'string' && source.endsWith('/agents');
|
||||
});
|
||||
|
||||
assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources');
|
||||
} finally {
|
||||
t.after(() => {
|
||||
delete process.env.GSD_CODING_AGENT_DIR;
|
||||
rmSync(isolatedAgentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
mkdirSync(isolatedAgentDir, { recursive: true });
|
||||
const tempSettings: Record<string, unknown> = { packages: [] };
|
||||
writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2));
|
||||
|
||||
const { ctx } = createMockContext([
|
||||
'Plugins only',
|
||||
'Yes - discover plugins and select components',
|
||||
'Import all components',
|
||||
'Yes, continue',
|
||||
]);
|
||||
|
||||
const readPrefs = () => ({ ...prefs });
|
||||
const writePrefs = async (p: Record<string, unknown>) => {
|
||||
Object.assign(prefs, p);
|
||||
};
|
||||
|
||||
await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs);
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] };
|
||||
const packageEntries = Array.isArray(settings.packages) ? settings.packages : [];
|
||||
const hasAgentsDirPackage = packageEntries.some((entry) => {
|
||||
const source = typeof entry === 'string'
|
||||
? entry
|
||||
: (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined);
|
||||
return typeof source === 'string' && source.endsWith('/agents');
|
||||
});
|
||||
|
||||
assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,142 +91,140 @@ async function loadGuidanceExport(): Promise<{ collectOneSecretWithGuidance: Fun
|
|||
|
||||
// ─── collectSecretsFromManifest: categorization ───────────────────────────────
|
||||
|
||||
test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async () => {
|
||||
test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async (t) => {
|
||||
const { collectSecretsFromManifest } = await loadOrchestrator();
|
||||
|
||||
const tmp = makeTempDir("manifest-collect");
|
||||
const savedA = process.env.EXISTING_KEY_A;
|
||||
try {
|
||||
process.env.EXISTING_KEY_A = "already-set";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "EXISTING_KEY_A", status: "pending" },
|
||||
{ key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] },
|
||||
{ key: "SKIPPED_KEY_C", status: "skipped" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary screen dismiss
|
||||
return "mock-secret-value"; // collect pending key
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// EXISTING_KEY_A should be in existingSkipped (it's in process.env)
|
||||
assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"),
|
||||
"EXISTING_KEY_A should be in existingSkipped");
|
||||
|
||||
// PENDING_KEY_B should have been collected (applied)
|
||||
assert.ok(result.applied.includes("PENDING_KEY_B"),
|
||||
"PENDING_KEY_B should be in applied");
|
||||
|
||||
// SKIPPED_KEY_C should remain skipped
|
||||
assert.ok(result.skipped.includes("SKIPPED_KEY_C"),
|
||||
"SKIPPED_KEY_C should be in skipped");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
delete process.env.EXISTING_KEY_A;
|
||||
if (savedA !== undefined) process.env.EXISTING_KEY_A = savedA;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
process.env.EXISTING_KEY_A = "already-set";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "EXISTING_KEY_A", status: "pending" },
|
||||
{ key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] },
|
||||
{ key: "SKIPPED_KEY_C", status: "skipped" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary screen dismiss
|
||||
return "mock-secret-value"; // collect pending key
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// EXISTING_KEY_A should be in existingSkipped (it's in process.env)
|
||||
assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"),
|
||||
"EXISTING_KEY_A should be in existingSkipped");
|
||||
|
||||
// PENDING_KEY_B should have been collected (applied)
|
||||
assert.ok(result.applied.includes("PENDING_KEY_B"),
|
||||
"PENDING_KEY_B should be in applied");
|
||||
|
||||
// SKIPPED_KEY_C should remain skipped
|
||||
assert.ok(result.skipped.includes("SKIPPED_KEY_C"),
|
||||
"SKIPPED_KEY_C should be in skipped");
|
||||
});
|
||||
|
||||
test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async () => {
|
||||
test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async (t) => {
|
||||
const { collectSecretsFromManifest } = await loadOrchestrator();
|
||||
|
||||
const tmp = makeTempDir("manifest-collect-skip");
|
||||
const savedA = process.env.ALREADY_SET_KEY;
|
||||
try {
|
||||
process.env.ALREADY_SET_KEY = "present";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "ALREADY_SET_KEY", status: "pending" },
|
||||
{ key: "NEEDS_COLLECTION", status: "pending" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
const collectedKeyNames: string[] = [];
|
||||
let summaryShown = false;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (factory: any) => {
|
||||
// Intercept the factory to check what key is being collected
|
||||
if (!summaryShown) {
|
||||
summaryShown = true;
|
||||
return null; // dismiss summary
|
||||
}
|
||||
collectedKeyNames.push("prompted");
|
||||
return "mock-value";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should
|
||||
assert.ok(!result.applied.includes("ALREADY_SET_KEY"),
|
||||
"ALREADY_SET_KEY should not be in applied (it was auto-skipped)");
|
||||
assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"),
|
||||
"ALREADY_SET_KEY should be in existingSkipped");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
delete process.env.ALREADY_SET_KEY;
|
||||
if (savedA !== undefined) process.env.ALREADY_SET_KEY = savedA;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
process.env.ALREADY_SET_KEY = "present";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "ALREADY_SET_KEY", status: "pending" },
|
||||
{ key: "NEEDS_COLLECTION", status: "pending" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
const collectedKeyNames: string[] = [];
|
||||
let summaryShown = false;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (factory: any) => {
|
||||
// Intercept the factory to check what key is being collected
|
||||
if (!summaryShown) {
|
||||
summaryShown = true;
|
||||
return null; // dismiss summary
|
||||
}
|
||||
collectedKeyNames.push("prompted");
|
||||
return "mock-value";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should
|
||||
assert.ok(!result.applied.includes("ALREADY_SET_KEY"),
|
||||
"ALREADY_SET_KEY should not be in applied (it was auto-skipped)");
|
||||
assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"),
|
||||
"ALREADY_SET_KEY should be in existingSkipped");
|
||||
});
|
||||
|
||||
test("collectSecretsFromManifest: manifest statuses are updated after collection", async () => {
|
||||
test("collectSecretsFromManifest: manifest statuses are updated after collection", async (t) => {
|
||||
const { collectSecretsFromManifest } = await loadOrchestrator();
|
||||
|
||||
const tmp = makeTempDir("manifest-update");
|
||||
try {
|
||||
const manifest = makeManifest([
|
||||
{ key: "KEY_TO_COLLECT", status: "pending" },
|
||||
{ key: "KEY_TO_SKIP", status: "pending" },
|
||||
]);
|
||||
const manifestPath = await writeManifestFile(tmp, manifest);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary screen dismiss
|
||||
if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT
|
||||
return null; // KEY_TO_SKIP — user skips
|
||||
},
|
||||
const manifest = makeManifest([
|
||||
{ key: "KEY_TO_COLLECT", status: "pending" },
|
||||
{ key: "KEY_TO_SKIP", status: "pending" },
|
||||
]);
|
||||
const manifestPath = await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary screen dismiss
|
||||
if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT
|
||||
return null; // KEY_TO_SKIP — user skips
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// Read back the manifest file and verify statuses were updated
|
||||
const { parseSecretsManifest } = await loadFilesExports();
|
||||
const updatedContent = readFileSync(manifestPath, "utf8");
|
||||
const updatedManifest = parseSecretsManifest(updatedContent);
|
||||
// Read back the manifest file and verify statuses were updated
|
||||
const { parseSecretsManifest } = await loadFilesExports();
|
||||
const updatedContent = readFileSync(manifestPath, "utf8");
|
||||
const updatedManifest = parseSecretsManifest(updatedContent);
|
||||
|
||||
const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT");
|
||||
const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP");
|
||||
const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT");
|
||||
const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP");
|
||||
|
||||
assert.equal(keyToCollect?.status, "collected",
|
||||
"KEY_TO_COLLECT should have status 'collected' after providing a value");
|
||||
assert.equal(keyToSkip?.status, "skipped",
|
||||
"KEY_TO_SKIP should have status 'skipped' after user skipped it");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(keyToCollect?.status, "collected",
|
||||
"KEY_TO_COLLECT should have status 'collected' after providing a value");
|
||||
assert.equal(keyToSkip?.status, "skipped",
|
||||
"KEY_TO_SKIP should have status 'skipped' after user skipped it");
|
||||
});
|
||||
|
||||
// ─── showSecretsSummary: render output ────────────────────────────────────────
|
||||
|
|
@ -423,47 +421,47 @@ test("collectOneSecret: no guidance provided — render output has no guidance s
|
|||
|
||||
// ─── collectSecretsFromManifest: returns structured result ────────────────────
|
||||
|
||||
test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async () => {
|
||||
test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async (t) => {
|
||||
const { collectSecretsFromManifest } = await loadOrchestrator();
|
||||
|
||||
const tmp = makeTempDir("manifest-result");
|
||||
const savedKey = process.env.RESULT_TEST_EXISTING;
|
||||
try {
|
||||
process.env.RESULT_TEST_EXISTING = "already-here";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "RESULT_TEST_EXISTING", status: "pending" },
|
||||
{ key: "RESULT_TEST_NEW", status: "pending" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary dismiss
|
||||
return "secret-value"; // collect the pending key
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// Verify result shape
|
||||
assert.ok(Array.isArray(result.applied), "result should have applied array");
|
||||
assert.ok(Array.isArray(result.skipped), "result should have skipped array");
|
||||
assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array");
|
||||
|
||||
assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"),
|
||||
"existing key should be in existingSkipped");
|
||||
assert.ok(result.applied.includes("RESULT_TEST_NEW"),
|
||||
"collected key should be in applied");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
delete process.env.RESULT_TEST_EXISTING;
|
||||
if (savedKey !== undefined) process.env.RESULT_TEST_EXISTING = savedKey;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
process.env.RESULT_TEST_EXISTING = "already-here";
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "RESULT_TEST_EXISTING", status: "pending" },
|
||||
{ key: "RESULT_TEST_NEW", status: "pending" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary dismiss
|
||||
return "secret-value"; // collect the pending key
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
// Verify result shape
|
||||
assert.ok(Array.isArray(result.applied), "result should have applied array");
|
||||
assert.ok(Array.isArray(result.skipped), "result should have skipped array");
|
||||
assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array");
|
||||
|
||||
assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"),
|
||||
"existing key should be in existingSkipped");
|
||||
assert.ok(result.applied.includes("RESULT_TEST_NEW"),
|
||||
"collected key should be in applied");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,40 +7,40 @@ import fs from "node:fs";
|
|||
import { handleInspect } from "../commands-inspect.ts";
|
||||
import { closeDatabase, openDatabase } from "../gsd-db.ts";
|
||||
|
||||
test("/gsd inspect opens existing database when it was not yet opened in session", async () => {
|
||||
test("/gsd inspect opens existing database when it was not yet opened in session", async (t) => {
|
||||
closeDatabase();
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-inspect-db-"));
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
try {
|
||||
const gsdDir = path.join(tmp, ".gsd");
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
const dbPath = path.join(gsdDir, "gsd.db");
|
||||
|
||||
assert.equal(openDatabase(dbPath), true);
|
||||
closeDatabase();
|
||||
|
||||
process.chdir(tmp);
|
||||
|
||||
const notifications: Array<{ message: string; level: string }> = [];
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify(message: string, level: string) {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await handleInspect(ctx);
|
||||
|
||||
assert.equal(notifications.length, 1);
|
||||
assert.equal(notifications[0].level, "info");
|
||||
assert.match(notifications[0].message, /=== GSD Database Inspect ===/);
|
||||
assert.doesNotMatch(notifications[0].message, /No GSD database available/);
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(prevCwd);
|
||||
closeDatabase();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const gsdDir = path.join(tmp, ".gsd");
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
const dbPath = path.join(gsdDir, "gsd.db");
|
||||
|
||||
assert.equal(openDatabase(dbPath), true);
|
||||
closeDatabase();
|
||||
|
||||
process.chdir(tmp);
|
||||
|
||||
const notifications: Array<{ message: string; level: string }> = [];
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify(message: string, level: string) {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await handleInspect(ctx);
|
||||
|
||||
assert.equal(notifications.length, 1);
|
||||
assert.equal(notifications[0].level, "info");
|
||||
assert.match(notifications[0].message, /=== GSD Database Inspect ===/);
|
||||
assert.doesNotMatch(notifications[0].message, /No GSD database available/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,22 +42,22 @@ function writeDebugLog(dir: string, name: string, entries: Record<string, unknow
|
|||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("logs shows empty state message when no logs exist", async () => {
|
||||
test("logs shows empty state message when no logs exist", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
await handleLogs("", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
assert.ok(ctx.notifications[0].msg.includes("No logs found"));
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
assert.ok(ctx.notifications[0].msg.includes("No logs found"));
|
||||
});
|
||||
|
||||
test("logs lists activity logs", async () => {
|
||||
test("logs lists activity logs", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -71,21 +71,21 @@ test("logs lists activity logs", async () => {
|
|||
{ role: "assistant", content: "Completing slice S01" },
|
||||
]);
|
||||
|
||||
try {
|
||||
await handleLogs("", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
|
||||
assert.ok(msg.includes("execute-task"), "should show unit type");
|
||||
assert.ok(msg.includes("complete-slice"), "should show second log");
|
||||
assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
|
||||
assert.ok(msg.includes("execute-task"), "should show unit type");
|
||||
assert.ok(msg.includes("complete-slice"), "should show second log");
|
||||
assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
|
||||
});
|
||||
|
||||
test("logs <N> shows activity log details", async () => {
|
||||
test("logs <N> shows activity log details", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -99,40 +99,40 @@ test("logs <N> shows activity log details", async () => {
|
|||
{ role: "assistant", content: "I ran the tests and wrote a file" },
|
||||
]);
|
||||
|
||||
try {
|
||||
await handleLogs("1", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Activity Log #1"), "should show log number");
|
||||
assert.ok(msg.includes("execute-task"), "should show unit type");
|
||||
assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
|
||||
assert.ok(msg.includes("Errors: 1"), "should count errors");
|
||||
assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
|
||||
assert.ok(msg.includes("npm test"), "should show commands run");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("1", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Activity Log #1"), "should show log number");
|
||||
assert.ok(msg.includes("execute-task"), "should show unit type");
|
||||
assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
|
||||
assert.ok(msg.includes("Errors: 1"), "should count errors");
|
||||
assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
|
||||
assert.ok(msg.includes("npm test"), "should show commands run");
|
||||
});
|
||||
|
||||
test("logs <N> shows not found for invalid seq", async () => {
|
||||
test("logs <N> shows not found for invalid seq", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
|
||||
try {
|
||||
await handleLogs("999", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
assert.ok(ctx.notifications[0].msg.includes("not found"));
|
||||
assert.equal(ctx.notifications[0].level, "warning");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("999", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
assert.ok(ctx.notifications[0].msg.includes("not found"));
|
||||
assert.equal(ctx.notifications[0].level, "warning");
|
||||
});
|
||||
|
||||
test("logs debug lists debug logs", async () => {
|
||||
test("logs debug lists debug logs", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -143,19 +143,19 @@ test("logs debug lists debug logs", async () => {
|
|||
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
|
||||
]);
|
||||
|
||||
try {
|
||||
await handleLogs("debug", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
|
||||
assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("debug", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
|
||||
assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
|
||||
});
|
||||
|
||||
test("logs debug <N> shows debug log summary", async () => {
|
||||
test("logs debug <N> shows debug log summary", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -167,21 +167,21 @@ test("logs debug <N> shows debug log summary", async () => {
|
|||
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
|
||||
]);
|
||||
|
||||
try {
|
||||
await handleLogs("debug 1", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Debug Log:"), "should show debug log header");
|
||||
assert.ok(msg.includes("Events: 3"), "should count events");
|
||||
assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
|
||||
assert.ok(msg.includes("dispatch-error"), "should show errors");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("debug 1", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Debug Log:"), "should show debug log header");
|
||||
assert.ok(msg.includes("Events: 3"), "should count events");
|
||||
assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
|
||||
assert.ok(msg.includes("dispatch-error"), "should show errors");
|
||||
});
|
||||
|
||||
test("logs tail shows recent activity summaries", async () => {
|
||||
test("logs tail shows recent activity summaries", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -195,20 +195,20 @@ test("logs tail shows recent activity summaries", async () => {
|
|||
{ role: "toolResult", toolCallId: "1", toolName: "bash", isError: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
await handleLogs("tail 2", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
|
||||
assert.ok(msg.includes("#1"), "should show first log");
|
||||
assert.ok(msg.includes("#2"), "should show second log");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("tail 2", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
const msg = ctx.notifications[0].msg;
|
||||
assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
|
||||
assert.ok(msg.includes("#1"), "should show first log");
|
||||
assert.ok(msg.includes("#2"), "should show second log");
|
||||
});
|
||||
|
||||
test("logs clear removes old logs", async () => {
|
||||
test("logs clear removes old logs", async (t) => {
|
||||
const dir = createTestDir();
|
||||
const ctx = createMockCtx();
|
||||
const origCwd = process.cwd();
|
||||
|
|
@ -225,17 +225,17 @@ test("logs clear removes old logs", async () => {
|
|||
writeActivityLog(dir, i, "execute-task", `M001/S01/T0${i}`, [{ type: "toolCall" }]);
|
||||
}
|
||||
|
||||
try {
|
||||
await handleLogs("clear", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
// Old log should be removed, recent ones kept
|
||||
assert.ok(!existsSync(oldFile), "old log should be removed");
|
||||
assert.ok(
|
||||
existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
|
||||
"most recent log should be kept",
|
||||
);
|
||||
} finally {
|
||||
t.after(() => {
|
||||
process.chdir(origCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await handleLogs("clear", ctx as any);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
// Old log should be removed, recent ones kept
|
||||
assert.ok(!existsSync(oldFile), "old log should be removed");
|
||||
assert.ok(
|
||||
existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
|
||||
"most recent log should be kept",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ describe("continue-here", () => {
|
|||
});
|
||||
|
||||
describe("continueHereFired runtime record field", () => {
|
||||
it("AutoUnitRuntimeRecord includes continueHereFired with default false", async () => {
|
||||
it("AutoUnitRuntimeRecord includes continueHereFired with default false", async (t) => {
|
||||
// Import writeUnitRuntimeRecord to verify the field is present and defaults
|
||||
const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js");
|
||||
const fs = await import("node:fs");
|
||||
|
|
@ -171,87 +171,83 @@ describe("continue-here", () => {
|
|||
|
||||
// Use a temp directory as basePath
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-test-"));
|
||||
try {
|
||||
const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
});
|
||||
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
assert.equal(record.continueHereFired, false, "default continueHereFired should be false");
|
||||
const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
});
|
||||
|
||||
// Verify it persists to disk
|
||||
const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
assert.ok(read, "record should be readable");
|
||||
assert.equal(read!.continueHereFired, false);
|
||||
assert.equal(record.continueHereFired, false, "default continueHereFired should be false");
|
||||
|
||||
// Update to true
|
||||
const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
|
||||
continueHereFired: true,
|
||||
});
|
||||
assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true");
|
||||
// Verify it persists to disk
|
||||
const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
assert.ok(read, "record should be readable");
|
||||
assert.equal(read!.continueHereFired, false);
|
||||
|
||||
// Verify persistence
|
||||
const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true");
|
||||
// Update to true
|
||||
const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
|
||||
continueHereFired: true,
|
||||
});
|
||||
assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true");
|
||||
|
||||
// Clean up
|
||||
clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
// Verify persistence
|
||||
const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true");
|
||||
|
||||
// Clean up
|
||||
clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context-pressure monitor integration", () => {
|
||||
it("should fire wrap-up when context >= threshold and mark continueHereFired", async () => {
|
||||
it("should fire wrap-up when context >= threshold and mark continueHereFired", async (t) => {
|
||||
const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-monitor-"));
|
||||
try {
|
||||
// Simulate the monitor's one-shot logic:
|
||||
// 1. Write initial runtime record (continueHereFired=false)
|
||||
const startedAt = Date.now();
|
||||
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
});
|
||||
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
const budget = computeBudgets(128_000);
|
||||
const threshold = budget.continueThresholdPercent;
|
||||
// Simulate the monitor's one-shot logic:
|
||||
// 1. Write initial runtime record (continueHereFired=false)
|
||||
const startedAt = Date.now();
|
||||
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
});
|
||||
|
||||
// Simulate the monitor poll: context at 75% (above threshold)
|
||||
const contextPercent = 75;
|
||||
const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
assert.ok(runtime, "runtime record should exist");
|
||||
assert.equal(runtime!.continueHereFired, false, "initially false");
|
||||
const budget = computeBudgets(128_000);
|
||||
const threshold = budget.continueThresholdPercent;
|
||||
|
||||
// Check: should fire
|
||||
const shouldFire = !runtime!.continueHereFired
|
||||
&& contextPercent >= threshold;
|
||||
assert.ok(shouldFire, "should fire when context >= threshold and not yet fired");
|
||||
// Simulate the monitor poll: context at 75% (above threshold)
|
||||
const contextPercent = 75;
|
||||
const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
assert.ok(runtime, "runtime record should exist");
|
||||
assert.equal(runtime!.continueHereFired, false, "initially false");
|
||||
|
||||
// Mark as fired (what the monitor does)
|
||||
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
|
||||
continueHereFired: true,
|
||||
});
|
||||
// Check: should fire
|
||||
const shouldFire = !runtime!.continueHereFired
|
||||
&& contextPercent >= threshold;
|
||||
assert.ok(shouldFire, "should fire when context >= threshold and not yet fired");
|
||||
|
||||
// Verify one-shot: second poll should NOT fire
|
||||
const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
assert.ok(runtime2, "runtime record should still exist");
|
||||
assert.equal(runtime2!.continueHereFired, true, "should be marked as fired");
|
||||
// Mark as fired (what the monitor does)
|
||||
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
|
||||
continueHereFired: true,
|
||||
});
|
||||
|
||||
const shouldFireAgain = !runtime2!.continueHereFired
|
||||
&& contextPercent >= threshold;
|
||||
assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard");
|
||||
// Verify one-shot: second poll should NOT fire
|
||||
const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
assert.ok(runtime2, "runtime record should still exist");
|
||||
assert.equal(runtime2!.continueHereFired, true, "should be marked as fired");
|
||||
|
||||
// Clean up
|
||||
clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
const shouldFireAgain = !runtime2!.continueHereFired
|
||||
&& contextPercent >= threshold;
|
||||
assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard");
|
||||
|
||||
// Clean up
|
||||
clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
|
||||
});
|
||||
|
||||
it("should not fire when context is below threshold", () => {
|
||||
|
|
|
|||
|
|
@ -26,53 +26,45 @@ function cleanup(base: string): void {
|
|||
|
||||
// ─── writeLock / readCrashLock ────────────────────────────────────────────
|
||||
|
||||
test("writeLock creates lock file and readCrashLock reads it", () => {
|
||||
test("writeLock creates lock file and readCrashLock reads it", (t) => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl");
|
||||
const lock = readCrashLock(base);
|
||||
assert.ok(lock, "lock should exist");
|
||||
assert.equal(lock!.unitType, "execute-task");
|
||||
assert.equal(lock!.unitId, "M001/S01/T01");
|
||||
assert.equal(lock!.completedUnits, 3);
|
||||
assert.equal(lock!.sessionFile, "/tmp/session.jsonl");
|
||||
assert.equal(lock!.pid, process.pid);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl");
|
||||
const lock = readCrashLock(base);
|
||||
assert.ok(lock, "lock should exist");
|
||||
assert.equal(lock!.unitType, "execute-task");
|
||||
assert.equal(lock!.unitId, "M001/S01/T01");
|
||||
assert.equal(lock!.completedUnits, 3);
|
||||
assert.equal(lock!.sessionFile, "/tmp/session.jsonl");
|
||||
assert.equal(lock!.pid, process.pid);
|
||||
});
|
||||
|
||||
test("readCrashLock returns null when no lock exists", () => {
|
||||
test("readCrashLock returns null when no lock exists", (t) => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
const lock = readCrashLock(base);
|
||||
assert.equal(lock, null);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
const lock = readCrashLock(base);
|
||||
assert.equal(lock, null);
|
||||
});
|
||||
|
||||
// ─── clearLock ────────────────────────────────────────────────────────────
|
||||
|
||||
test("clearLock removes existing lock file", () => {
|
||||
test("clearLock removes existing lock file", (t) => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
writeLock(base, "plan-slice", "M001/S01", 0);
|
||||
assert.ok(readCrashLock(base), "lock should exist before clear");
|
||||
clearLock(base);
|
||||
assert.equal(readCrashLock(base), null, "lock should be gone after clear");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
writeLock(base, "plan-slice", "M001/S01", 0);
|
||||
assert.ok(readCrashLock(base), "lock should exist before clear");
|
||||
clearLock(base);
|
||||
assert.equal(readCrashLock(base), null, "lock should be gone after clear");
|
||||
});
|
||||
|
||||
test("clearLock is safe when no lock exists", () => {
|
||||
test("clearLock is safe when no lock exists", (t) => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
assert.doesNotThrow(() => clearLock(base));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
t.after(() => cleanup(base));
|
||||
|
||||
assert.doesNotThrow(() => clearLock(base));
|
||||
});
|
||||
|
||||
// ─── isLockProcessAlive ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -63,35 +63,33 @@ steps:
|
|||
|
||||
// ─── loadDefinition: valid YAML ──────────────────────────────────────────
|
||||
|
||||
test("loadDefinition: valid 3-step YAML returns correct structure", () => {
|
||||
test("loadDefinition: valid 3-step YAML returns correct structure", (t) => {
|
||||
const dir = writeDefYaml(VALID_3STEP_YAML);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
assert.equal(def.version, 1);
|
||||
assert.equal(def.name, "test-workflow");
|
||||
assert.equal(def.description, "A test workflow");
|
||||
assert.deepEqual(def.params, { topic: "AI" });
|
||||
assert.equal(def.steps.length, 3);
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
|
||||
// Step 1: research
|
||||
assert.equal(def.steps[0].id, "research");
|
||||
assert.equal(def.steps[0].name, "Research the topic");
|
||||
assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md");
|
||||
assert.deepEqual(def.steps[0].requires, []);
|
||||
assert.deepEqual(def.steps[0].produces, ["research.md"]);
|
||||
assert.equal(def.version, 1);
|
||||
assert.equal(def.name, "test-workflow");
|
||||
assert.equal(def.description, "A test workflow");
|
||||
assert.deepEqual(def.params, { topic: "AI" });
|
||||
assert.equal(def.steps.length, 3);
|
||||
|
||||
// Step 2: outline — depends on research
|
||||
assert.equal(def.steps[1].id, "outline");
|
||||
assert.deepEqual(def.steps[1].requires, ["research"]);
|
||||
// Step 1: research
|
||||
assert.equal(def.steps[0].id, "research");
|
||||
assert.equal(def.steps[0].name, "Research the topic");
|
||||
assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md");
|
||||
assert.deepEqual(def.steps[0].requires, []);
|
||||
assert.deepEqual(def.steps[0].produces, ["research.md"]);
|
||||
|
||||
// Step 3: draft — depends on outline
|
||||
assert.equal(def.steps[2].id, "draft");
|
||||
assert.deepEqual(def.steps[2].requires, ["outline"]);
|
||||
assert.deepEqual(def.steps[2].produces, ["draft.md"]);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
// Step 2: outline — depends on research
|
||||
assert.equal(def.steps[1].id, "outline");
|
||||
assert.deepEqual(def.steps[1].requires, ["research"]);
|
||||
|
||||
// Step 3: draft — depends on outline
|
||||
assert.equal(def.steps[2].id, "draft");
|
||||
assert.deepEqual(def.steps[2].requires, ["outline"]);
|
||||
assert.deepEqual(def.steps[2].produces, ["draft.md"]);
|
||||
});
|
||||
|
||||
// ─── validateDefinition: rejection cases ─────────────────────────────────
|
||||
|
|
@ -223,23 +221,21 @@ test("validateDefinition: missing step name → error", () => {
|
|||
|
||||
// ─── loadDefinition: error cases ─────────────────────────────────────────
|
||||
|
||||
test("loadDefinition: missing file → descriptive error", () => {
|
||||
test("loadDefinition: missing file → descriptive error", (t) => {
|
||||
const dir = makeTmpDir();
|
||||
try {
|
||||
assert.throws(
|
||||
() => loadDefinition(dir, "nonexistent"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("not found"));
|
||||
assert.ok(err.message.includes("nonexistent.yaml"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
assert.throws(
|
||||
() => loadDefinition(dir, "nonexistent"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("not found"));
|
||||
assert.ok(err.message.includes("nonexistent.yaml"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefinition: invalid YAML schema → descriptive error", () => {
|
||||
test("loadDefinition: invalid YAML schema → descriptive error", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 2
|
||||
name: "bad"
|
||||
|
|
@ -248,23 +244,21 @@ steps:
|
|||
name: "A"
|
||||
prompt: "do A"
|
||||
`);
|
||||
try {
|
||||
assert.throws(
|
||||
() => loadDefinition(dir, "test-workflow"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Invalid workflow definition"));
|
||||
assert.ok(err.message.includes("Unsupported version"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
assert.throws(
|
||||
() => loadDefinition(dir, "test-workflow"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Invalid workflow definition"));
|
||||
assert.ok(err.message.includes("Unsupported version"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── loadDefinition: snake_case → camelCase conversion ───────────────────
|
||||
|
||||
test("loadDefinition: depends_on in YAML maps to requires in TypeScript", () => {
|
||||
test("loadDefinition: depends_on in YAML maps to requires in TypeScript", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 1
|
||||
name: "dep-test"
|
||||
|
|
@ -277,15 +271,13 @@ steps:
|
|||
prompt: "do second"
|
||||
depends_on: [first]
|
||||
`);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[1].requires, ["first"]);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[1].requires, ["first"]);
|
||||
});
|
||||
|
||||
test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", () => {
|
||||
test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 1
|
||||
name: "ctx-test"
|
||||
|
|
@ -298,12 +290,10 @@ steps:
|
|||
prompt: "do second"
|
||||
context_from: [first]
|
||||
`);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[1].contextFrom, ["first"]);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[1].contextFrom, ["first"]);
|
||||
});
|
||||
|
||||
// ─── validateDefinition: iterate field validation ────────────────────────
|
||||
|
|
@ -725,7 +715,7 @@ test("validateDefinition: valid minimal step (no requires/produces) → accepted
|
|||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test("loadDefinition: loads without params field → params is undefined", () => {
|
||||
test("loadDefinition: loads without params field → params is undefined", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 1
|
||||
name: "no-params"
|
||||
|
|
@ -734,15 +724,13 @@ steps:
|
|||
name: "A"
|
||||
prompt: "do A"
|
||||
`);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.equal(def.params, undefined);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.equal(def.params, undefined);
|
||||
});
|
||||
|
||||
test("loadDefinition: loads without description → description is undefined", () => {
|
||||
test("loadDefinition: loads without description → description is undefined", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 1
|
||||
name: "no-desc"
|
||||
|
|
@ -751,15 +739,13 @@ steps:
|
|||
name: "A"
|
||||
prompt: "do A"
|
||||
`);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.equal(def.description, undefined);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.equal(def.description, undefined);
|
||||
});
|
||||
|
||||
test("loadDefinition: step with no requires/produces defaults to empty arrays", () => {
|
||||
test("loadDefinition: step with no requires/produces defaults to empty arrays", (t) => {
|
||||
const dir = writeDefYaml(`
|
||||
version: 1
|
||||
name: "defaults"
|
||||
|
|
@ -768,11 +754,9 @@ steps:
|
|||
name: "A"
|
||||
prompt: "do A"
|
||||
`);
|
||||
try {
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[0].requires, []);
|
||||
assert.deepEqual(def.steps[0].produces, []);
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
||||
}
|
||||
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
|
||||
|
||||
const def = loadDefinition(dir, "test-workflow");
|
||||
assert.deepEqual(def.steps[0].requires, []);
|
||||
assert.deepEqual(def.steps[0].produces, []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,361 +38,315 @@ function cleanup(dir: string): void {
|
|||
|
||||
// ─── detectProjectState ─────────────────────────────────────────────────────────
|
||||
|
||||
test("detectProjectState: empty directory returns state=none", () => {
|
||||
test("detectProjectState: empty directory returns state=none", (t) => {
|
||||
const dir = makeTempDir("empty");
|
||||
try {
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "none");
|
||||
assert.equal(result.v1, undefined);
|
||||
assert.equal(result.v2, undefined);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "none");
|
||||
assert.equal(result.v1, undefined);
|
||||
assert.equal(result.v2, undefined);
|
||||
});
|
||||
|
||||
test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", () => {
|
||||
test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", (t) => {
|
||||
const dir = makeTempDir("v2-gsd");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd");
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.milestoneCount, 1);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd");
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.milestoneCount, 1);
|
||||
});
|
||||
|
||||
test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", () => {
|
||||
test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", (t) => {
|
||||
const dir = makeTempDir("v2-empty");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd-empty");
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.milestoneCount, 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd-empty");
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.milestoneCount, 0);
|
||||
});
|
||||
|
||||
test("detectProjectState: directory with .planning/ returns v1-planning", () => {
|
||||
test("detectProjectState: directory with .planning/ returns v1-planning", (t) => {
|
||||
const dir = makeTempDir("v1-planning");
|
||||
try {
|
||||
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
|
||||
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8");
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v1-planning");
|
||||
assert.ok(result.v1);
|
||||
assert.equal(result.v1!.hasRoadmap, true);
|
||||
assert.equal(result.v1!.hasPhasesDir, true);
|
||||
assert.equal(result.v1!.phaseCount, 1);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
|
||||
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8");
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v1-planning");
|
||||
assert.ok(result.v1);
|
||||
assert.equal(result.v1!.hasRoadmap, true);
|
||||
assert.equal(result.v1!.hasPhasesDir, true);
|
||||
assert.equal(result.v1!.phaseCount, 1);
|
||||
});
|
||||
|
||||
test("detectProjectState: v2 takes priority over v1 when both exist", () => {
|
||||
test("detectProjectState: v2 takes priority over v1 when both exist", (t) => {
|
||||
const dir = makeTempDir("both");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning"), { recursive: true });
|
||||
const result = detectProjectState(dir);
|
||||
assert.equal(result.state, "v2-gsd");
|
||||
});
|
||||
|
||||
test("detectProjectState: detects preferences in .gsd/", () => {
|
||||
test("detectProjectState: detects preferences in .gsd/", (t) => {
|
||||
const dir = makeTempDir("prefs");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
|
||||
writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8");
|
||||
const result = detectProjectState(dir);
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.hasPreferences, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
|
||||
writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8");
|
||||
const result = detectProjectState(dir);
|
||||
assert.ok(result.v2);
|
||||
assert.equal(result.v2!.hasPreferences, true);
|
||||
});
|
||||
|
||||
// ─── detectV1Planning ───────────────────────────────────────────────────────────
|
||||
|
||||
test("detectV1Planning: returns null for missing .planning/", () => {
|
||||
test("detectV1Planning: returns null for missing .planning/", (t) => {
|
||||
const dir = makeTempDir("no-v1");
|
||||
try {
|
||||
assert.equal(detectV1Planning(dir), null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
assert.equal(detectV1Planning(dir), null);
|
||||
});
|
||||
|
||||
test("detectV1Planning: returns null when .planning is a file", () => {
|
||||
test("detectV1Planning: returns null when .planning is a file", (t) => {
|
||||
const dir = makeTempDir("v1-file");
|
||||
try {
|
||||
writeFileSync(join(dir, ".planning"), "not a directory", "utf-8");
|
||||
assert.equal(detectV1Planning(dir), null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, ".planning"), "not a directory", "utf-8");
|
||||
assert.equal(detectV1Planning(dir), null);
|
||||
});
|
||||
|
||||
test("detectV1Planning: detects phases directory with multiple phases", () => {
|
||||
test("detectV1Planning: detects phases directory with multiple phases", (t) => {
|
||||
const dir = makeTempDir("v1-phases");
|
||||
try {
|
||||
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true });
|
||||
const result = detectV1Planning(dir);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.phaseCount, 3);
|
||||
assert.equal(result!.hasPhasesDir, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true });
|
||||
mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true });
|
||||
const result = detectV1Planning(dir);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.phaseCount, 3);
|
||||
assert.equal(result!.hasPhasesDir, true);
|
||||
});
|
||||
|
||||
test("detectV1Planning: detects ROADMAP.md", () => {
|
||||
test("detectV1Planning: detects ROADMAP.md", (t) => {
|
||||
const dir = makeTempDir("v1-roadmap");
|
||||
try {
|
||||
mkdirSync(join(dir, ".planning"), { recursive: true });
|
||||
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8");
|
||||
const result = detectV1Planning(dir);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.hasRoadmap, true);
|
||||
assert.equal(result!.hasPhasesDir, false);
|
||||
assert.equal(result!.phaseCount, 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".planning"), { recursive: true });
|
||||
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8");
|
||||
const result = detectV1Planning(dir);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.hasRoadmap, true);
|
||||
assert.equal(result!.hasPhasesDir, false);
|
||||
assert.equal(result!.phaseCount, 0);
|
||||
});
|
||||
|
||||
// ─── detectProjectSignals ───────────────────────────────────────────────────────
|
||||
|
||||
test("detectProjectSignals: empty directory", () => {
|
||||
test("detectProjectSignals: empty directory", (t) => {
|
||||
const dir = makeTempDir("signals-empty");
|
||||
try {
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.deepEqual(signals.detectedFiles, []);
|
||||
assert.equal(signals.isGitRepo, false);
|
||||
assert.equal(signals.isMonorepo, false);
|
||||
assert.equal(signals.primaryLanguage, undefined);
|
||||
assert.equal(signals.hasCI, false);
|
||||
assert.equal(signals.hasTests, false);
|
||||
assert.deepEqual(signals.verificationCommands, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.deepEqual(signals.detectedFiles, []);
|
||||
assert.equal(signals.isGitRepo, false);
|
||||
assert.equal(signals.isMonorepo, false);
|
||||
assert.equal(signals.primaryLanguage, undefined);
|
||||
assert.equal(signals.hasCI, false);
|
||||
assert.equal(signals.hasTests, false);
|
||||
assert.deepEqual(signals.verificationCommands, []);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Node.js project", () => {
|
||||
test("detectProjectSignals: Node.js project", (t) => {
|
||||
const dir = makeTempDir("signals-node");
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test-project",
|
||||
scripts: {
|
||||
test: "jest",
|
||||
build: "tsc",
|
||||
lint: "eslint .",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8");
|
||||
mkdirSync(join(dir, ".git"), { recursive: true });
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("package.json"));
|
||||
assert.equal(signals.primaryLanguage, "javascript/typescript");
|
||||
assert.equal(signals.isGitRepo, true);
|
||||
assert.equal(signals.packageManager, "npm");
|
||||
assert.ok(signals.verificationCommands.includes("npm test"));
|
||||
assert.ok(signals.verificationCommands.some(c => c.includes("build")));
|
||||
assert.ok(signals.verificationCommands.some(c => c.includes("lint")));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test-project",
|
||||
scripts: {
|
||||
test: "jest",
|
||||
build: "tsc",
|
||||
lint: "eslint .",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8");
|
||||
mkdirSync(join(dir, ".git"), { recursive: true });
|
||||
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("package.json"));
|
||||
assert.equal(signals.primaryLanguage, "javascript/typescript");
|
||||
assert.equal(signals.isGitRepo, true);
|
||||
assert.equal(signals.packageManager, "npm");
|
||||
assert.ok(signals.verificationCommands.includes("npm test"));
|
||||
assert.ok(signals.verificationCommands.some(c => c.includes("build")));
|
||||
assert.ok(signals.verificationCommands.some(c => c.includes("lint")));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Rust project", () => {
|
||||
test("detectProjectSignals: Rust project", (t) => {
|
||||
const dir = makeTempDir("signals-rust");
|
||||
try {
|
||||
writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Cargo.toml"));
|
||||
assert.equal(signals.primaryLanguage, "rust");
|
||||
assert.ok(signals.verificationCommands.includes("cargo test"));
|
||||
assert.ok(signals.verificationCommands.includes("cargo clippy"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Cargo.toml"));
|
||||
assert.equal(signals.primaryLanguage, "rust");
|
||||
assert.ok(signals.verificationCommands.includes("cargo test"));
|
||||
assert.ok(signals.verificationCommands.includes("cargo clippy"));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Go project", () => {
|
||||
test("detectProjectSignals: Go project", (t) => {
|
||||
const dir = makeTempDir("signals-go");
|
||||
try {
|
||||
writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("go.mod"));
|
||||
assert.equal(signals.primaryLanguage, "go");
|
||||
assert.ok(signals.verificationCommands.includes("go test ./..."));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("go.mod"));
|
||||
assert.equal(signals.primaryLanguage, "go");
|
||||
assert.ok(signals.verificationCommands.includes("go test ./..."));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Python project", () => {
|
||||
test("detectProjectSignals: Python project", (t) => {
|
||||
const dir = makeTempDir("signals-python");
|
||||
try {
|
||||
writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("pyproject.toml"));
|
||||
assert.equal(signals.primaryLanguage, "python");
|
||||
assert.ok(signals.verificationCommands.includes("pytest"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("pyproject.toml"));
|
||||
assert.equal(signals.primaryLanguage, "python");
|
||||
assert.ok(signals.verificationCommands.includes("pytest"));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: monorepo detection via workspaces", () => {
|
||||
test("detectProjectSignals: monorepo detection via workspaces", (t) => {
|
||||
const dir = makeTempDir("signals-monorepo");
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "mono", workspaces: ["packages/*"] }),
|
||||
"utf-8",
|
||||
);
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.isMonorepo, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "mono", workspaces: ["packages/*"] }),
|
||||
"utf-8",
|
||||
);
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.isMonorepo, true);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: monorepo detection via turbo.json", () => {
|
||||
test("detectProjectSignals: monorepo detection via turbo.json", (t) => {
|
||||
const dir = makeTempDir("signals-turbo");
|
||||
try {
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8");
|
||||
writeFileSync(join(dir, "turbo.json"), "{}", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.isMonorepo, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8");
|
||||
writeFileSync(join(dir, "turbo.json"), "{}", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.isMonorepo, true);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: CI detection", () => {
|
||||
test("detectProjectSignals: CI detection", (t) => {
|
||||
const dir = makeTempDir("signals-ci");
|
||||
try {
|
||||
mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.hasCI, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.hasCI, true);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: test detection via jest config", () => {
|
||||
test("detectProjectSignals: test detection via jest config", (t) => {
|
||||
const dir = makeTempDir("signals-tests");
|
||||
try {
|
||||
writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.hasTests, true);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.equal(signals.hasTests, true);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: package manager detection", () => {
|
||||
test("detectProjectSignals: package manager detection", (t) => {
|
||||
const dir1 = makeTempDir("pm-pnpm");
|
||||
const dir2 = makeTempDir("pm-yarn");
|
||||
const dir3 = makeTempDir("pm-bun");
|
||||
try {
|
||||
writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8");
|
||||
writeFileSync(join(dir1, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir1).packageManager, "pnpm");
|
||||
|
||||
writeFileSync(join(dir2, "yarn.lock"), "", "utf-8");
|
||||
writeFileSync(join(dir2, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir2).packageManager, "yarn");
|
||||
|
||||
writeFileSync(join(dir3, "bun.lockb"), "", "utf-8");
|
||||
writeFileSync(join(dir3, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir3).packageManager, "bun");
|
||||
} finally {
|
||||
t.after(() => {
|
||||
cleanup(dir1);
|
||||
cleanup(dir2);
|
||||
cleanup(dir3);
|
||||
}
|
||||
});
|
||||
|
||||
writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8");
|
||||
writeFileSync(join(dir1, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir1).packageManager, "pnpm");
|
||||
|
||||
writeFileSync(join(dir2, "yarn.lock"), "", "utf-8");
|
||||
writeFileSync(join(dir2, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir2).packageManager, "yarn");
|
||||
|
||||
writeFileSync(join(dir3, "bun.lockb"), "", "utf-8");
|
||||
writeFileSync(join(dir3, "package.json"), "{}", "utf-8");
|
||||
assert.equal(detectProjectSignals(dir3).packageManager, "bun");
|
||||
});
|
||||
|
||||
test("detectProjectSignals: skips default npm test script", () => {
|
||||
test("detectProjectSignals: skips default npm test script", (t) => {
|
||||
const dir = makeTempDir("signals-default-test");
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
scripts: { test: 'echo "Error: no test specified" && exit 1' },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const signals = detectProjectSignals(dir);
|
||||
// Should NOT include the default npm test script
|
||||
assert.equal(
|
||||
signals.verificationCommands.some(c => c.includes("test")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
scripts: { test: 'echo "Error: no test specified" && exit 1' },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const signals = detectProjectSignals(dir);
|
||||
// Should NOT include the default npm test script
|
||||
assert.equal(
|
||||
signals.verificationCommands.some(c => c.includes("test")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProjectSignals: pnpm uses pnpm commands", () => {
|
||||
test("detectProjectSignals: pnpm uses pnpm commands", (t) => {
|
||||
const dir = makeTempDir("signals-pnpm-cmds");
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
scripts: { test: "vitest", build: "tsc" },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.verificationCommands.includes("pnpm test"));
|
||||
assert.ok(signals.verificationCommands.includes("pnpm run build"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
scripts: { test: "vitest", build: "tsc" },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.verificationCommands.includes("pnpm test"));
|
||||
assert.ok(signals.verificationCommands.includes("pnpm run build"));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Ruby project with rspec", () => {
|
||||
test("detectProjectSignals: Ruby project with rspec", (t) => {
|
||||
const dir = makeTempDir("signals-ruby");
|
||||
try {
|
||||
writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8");
|
||||
mkdirSync(join(dir, "spec"), { recursive: true });
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Gemfile"));
|
||||
assert.equal(signals.primaryLanguage, "ruby");
|
||||
assert.ok(signals.verificationCommands.includes("bundle exec rspec"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8");
|
||||
mkdirSync(join(dir, "spec"), { recursive: true });
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Gemfile"));
|
||||
assert.equal(signals.primaryLanguage, "ruby");
|
||||
assert.ok(signals.verificationCommands.includes("bundle exec rspec"));
|
||||
});
|
||||
|
||||
test("detectProjectSignals: Makefile with test target", () => {
|
||||
test("detectProjectSignals: Makefile with test target", (t) => {
|
||||
const dir = makeTempDir("signals-make");
|
||||
try {
|
||||
writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Makefile"));
|
||||
assert.ok(signals.verificationCommands.includes("make test"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8");
|
||||
const signals = detectProjectSignals(dir);
|
||||
assert.ok(signals.detectedFiles.includes("Makefile"));
|
||||
assert.ok(signals.verificationCommands.includes("make test"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ describe("DevWorkflowEngine", () => {
|
|||
assert.equal(engine.engineId, "dev");
|
||||
});
|
||||
|
||||
test("deriveState returns EngineState with expected fields", async () => {
|
||||
test("deriveState returns EngineState with expected fields", async (t) => {
|
||||
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
||||
const engine = new DevWorkflowEngine();
|
||||
|
||||
|
|
@ -81,31 +81,29 @@ describe("DevWorkflowEngine", () => {
|
|||
const tempDir = mkdtempSync(join(tmpdir(), "gsd-engine-test-"));
|
||||
mkdirSync(join(tempDir, ".gsd", "milestones"), { recursive: true });
|
||||
|
||||
try {
|
||||
const state = await engine.deriveState(tempDir);
|
||||
t.after(() => rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
assert.equal(typeof state.phase, "string", "phase should be a string");
|
||||
assert.ok(
|
||||
"currentMilestoneId" in state,
|
||||
"state should have currentMilestoneId",
|
||||
);
|
||||
assert.ok(
|
||||
"activeSliceId" in state,
|
||||
"state should have activeSliceId",
|
||||
);
|
||||
assert.ok(
|
||||
"activeTaskId" in state,
|
||||
"state should have activeTaskId",
|
||||
);
|
||||
assert.equal(
|
||||
typeof state.isComplete,
|
||||
"boolean",
|
||||
"isComplete should be boolean",
|
||||
);
|
||||
assert.ok("raw" in state, "state should have raw field");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const state = await engine.deriveState(tempDir);
|
||||
|
||||
assert.equal(typeof state.phase, "string", "phase should be a string");
|
||||
assert.ok(
|
||||
"currentMilestoneId" in state,
|
||||
"state should have currentMilestoneId",
|
||||
);
|
||||
assert.ok(
|
||||
"activeSliceId" in state,
|
||||
"state should have activeSliceId",
|
||||
);
|
||||
assert.ok(
|
||||
"activeTaskId" in state,
|
||||
"state should have activeTaskId",
|
||||
);
|
||||
assert.equal(
|
||||
typeof state.isComplete,
|
||||
"boolean",
|
||||
"isComplete should be boolean",
|
||||
);
|
||||
assert.ok("raw" in state, "state should have raw field");
|
||||
});
|
||||
|
||||
test("reconcile returns continue for non-complete state", async () => {
|
||||
|
|
@ -280,16 +278,14 @@ describe("Kill switch (GSD_ENGINE_BYPASS)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async () => {
|
||||
test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async (t) => {
|
||||
const { resolveEngine } = await import("../engine-resolver.ts");
|
||||
process.env.GSD_ENGINE_BYPASS = "1";
|
||||
try {
|
||||
// resolveEngine should still resolve normally — bypass is checked in autoLoop
|
||||
const { engine } = resolveEngine({ activeEngineId: null });
|
||||
assert.ok(engine, "should return an engine even with bypass set");
|
||||
} finally {
|
||||
delete process.env.GSD_ENGINE_BYPASS;
|
||||
}
|
||||
t.after(() => delete process.env.GSD_ENGINE_BYPASS);
|
||||
|
||||
// resolveEngine should still resolve normally — bypass is checked in autoLoop
|
||||
const { engine } = resolveEngine({ activeEngineId: null });
|
||||
assert.ok(engine, "should return an engine even with bypass set");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,215 +20,199 @@ function teardownRepo(repo: string): void {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test("dispatch guard blocks when prior milestone has incomplete slices", () => {
|
||||
test("dispatch guard blocks when prior milestone has incomplete slices", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
// Seed DB: M002 with S01 complete, S02 pending
|
||||
insertMilestone({ id: "M002", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
|
||||
// M003 with two pending slices
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
// Seed DB: M002 with S01 complete, S02 pending
|
||||
insertMilestone({ id: "M002", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
|
||||
// Need ROADMAP files for milestone discovery (findMilestoneIds reads disk)
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
// M003 with two pending slices
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"),
|
||||
"Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.",
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
// Need ROADMAP files for milestone discovery (findMilestoneIds reads disk)
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"),
|
||||
"Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard blocks later slice in same milestone when earlier incomplete", () => {
|
||||
test("dispatch guard blocks later slice in same milestone when earlier incomplete", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M002", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
insertMilestone({ id: "M002", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"),
|
||||
"Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.",
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"),
|
||||
"Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard allows dispatch when all earlier slices complete", () => {
|
||||
test("dispatch guard allows dispatch when all earlier slices complete", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
insertMilestone({ id: "M003", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null);
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
|
||||
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null);
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null);
|
||||
});
|
||||
|
||||
test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", () => {
|
||||
test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", (t) => {
|
||||
// S05 depends on S06, but S05 appears first positionally.
|
||||
// Old behavior: S06 blocked because S05 (positionally earlier) is incomplete.
|
||||
// Fixed behavior: S06 has no unmet dependencies, so it can dispatch.
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 });
|
||||
insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 });
|
||||
insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 });
|
||||
insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 });
|
||||
insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 });
|
||||
|
||||
// S06 depends only on S04 (complete) — should be unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"),
|
||||
null,
|
||||
);
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
|
||||
// S05 depends on S04 (complete) and S06 (incomplete) — should be blocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"),
|
||||
"Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.",
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
// S06 depends only on S04 (complete) — should be unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"),
|
||||
null,
|
||||
);
|
||||
|
||||
// S05 depends on S04 (complete) and S06 (incomplete) — should be blocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"),
|
||||
"Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard falls back to positional ordering when no dependencies declared", () => {
|
||||
test("dispatch guard falls back to positional ordering when no dependencies declared", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 });
|
||||
|
||||
// S03 has no dependencies — positional fallback blocks on S02
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
|
||||
"Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.",
|
||||
);
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
|
||||
// S02 has no dependencies — positional fallback: S01 is done, so unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
// S03 has no dependencies — positional fallback blocks on S02
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
|
||||
"Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.",
|
||||
);
|
||||
|
||||
// S02 has no dependencies — positional fallback: S01 is done, so unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard allows slice with all declared dependencies complete", () => {
|
||||
test("dispatch guard allows slice with all declared dependencies complete", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 });
|
||||
|
||||
// S03 depends on S01 (done) and S02 (done) — unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
|
||||
null,
|
||||
);
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
|
||||
// S04 depends only on S01 (done) — unblocked even though S03 is incomplete
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
// S03 depends on S01 (done) and S02 (done) — unblocked
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
|
||||
null,
|
||||
);
|
||||
|
||||
// S04 depends only on S01 (done) — unblocked even though S03 is incomplete
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", () => {
|
||||
test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
// M001 is complete (has SUMMARY) but has unchecked remediation slices in DB
|
||||
insertMilestone({ id: "M001", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
|
||||
|
||||
insertMilestone({ id: "M002", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 });
|
||||
// M001 is complete (has SUMMARY) but has unchecked remediation slices in DB
|
||||
insertMilestone({ id: "M001", title: "Previous" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 });
|
||||
insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 });
|
||||
insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 });
|
||||
|
||||
// M001 SUMMARY on disk triggers skip
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"),
|
||||
"---\nstatus: complete\n---\n# M001 Summary\nDone.\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
insertMilestone({ id: "M002", title: "Current" });
|
||||
insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 });
|
||||
|
||||
// M001 has SUMMARY — should be skipped, not block M002/S01
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
// M001 SUMMARY on disk triggers skip
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"),
|
||||
"---\nstatus: complete\n---\n# M001 Summary\nDone.\n");
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
|
||||
|
||||
// M001 has SUMMARY — should be skipped, not block M002/S01
|
||||
assert.equal(
|
||||
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch guard works without git repo", () => {
|
||||
test("dispatch guard works without git repo", (t) => {
|
||||
const repo = setupRepo();
|
||||
try {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
t.after(() => teardownRepo(repo));
|
||||
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
insertMilestone({ id: "M001", title: "Test" });
|
||||
insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 });
|
||||
insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
|
||||
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null);
|
||||
} finally {
|
||||
teardownRepo(repo);
|
||||
}
|
||||
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
|
||||
|
||||
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,62 +71,56 @@ function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: strin
|
|||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async () => {
|
||||
test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async (t) => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-"));
|
||||
try {
|
||||
// Slice plan exists with tasks, but tasks/ directory is empty
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const ctx = makeContext(tmp);
|
||||
const result = await resolveDispatch(ctx);
|
||||
// Slice plan exists with tasks, but tasks/ directory is empty
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
|
||||
assert.equal(result.action, "dispatch", "should dispatch, not stop");
|
||||
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
|
||||
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
||||
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
|
||||
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const ctx = makeContext(tmp);
|
||||
const result = await resolveDispatch(ctx);
|
||||
|
||||
assert.equal(result.action, "dispatch", "should dispatch, not stop");
|
||||
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
|
||||
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
||||
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
|
||||
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
||||
});
|
||||
|
||||
test("dispatch: present task plan proceeds to execute-task normally", async () => {
|
||||
test("dispatch: present task plan proceeds to execute-task normally", async (t) => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-ok-"));
|
||||
try {
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const ctx = makeContext(tmp);
|
||||
const result = await resolveDispatch(ctx);
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
|
||||
|
||||
assert.equal(result.action, "dispatch");
|
||||
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
|
||||
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
||||
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
|
||||
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const ctx = makeContext(tmp);
|
||||
const result = await resolveDispatch(ctx);
|
||||
|
||||
assert.equal(result.action, "dispatch");
|
||||
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
|
||||
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
||||
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
|
||||
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
||||
});
|
||||
|
||||
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async () => {
|
||||
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => {
|
||||
// Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write).
|
||||
// Dispatch should still re-dispatch plan-slice, not hard-stop.
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-loop-"));
|
||||
try {
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const ctx = makeContext(tmp);
|
||||
const r1 = await resolveDispatch(ctx);
|
||||
assert.equal(r1.action, "dispatch");
|
||||
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice");
|
||||
scaffoldSlicePlan(tmp, "M002", "S03");
|
||||
|
||||
// Still no task plan written — dispatch again
|
||||
const r2 = await resolveDispatch(ctx);
|
||||
assert.equal(r2.action, "dispatch");
|
||||
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
|
||||
"should keep dispatching plan-slice until task plans appear");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const ctx = makeContext(tmp);
|
||||
const r1 = await resolveDispatch(ctx);
|
||||
assert.equal(r1.action, "dispatch");
|
||||
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice");
|
||||
|
||||
// Still no task plan written — dispatch again
|
||||
const r2 = await resolveDispatch(ctx);
|
||||
assert.equal(r2.action, "dispatch");
|
||||
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
|
||||
"should keep dispatching plan-slice until task plans appear");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function createFixture(): string {
|
|||
return base;
|
||||
}
|
||||
|
||||
test("dispatch uat targets last completed slice, not activeSlice (#1693)", async () => {
|
||||
test("dispatch uat targets last completed slice, not activeSlice (#1693)", async (t) => {
|
||||
const base = createFixture();
|
||||
invalidateStateCache();
|
||||
|
||||
|
|
@ -88,31 +88,29 @@ test("dispatch uat targets last completed slice, not activeSlice (#1693)", async
|
|||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
|
||||
// Should have dispatched (sendMessage called)
|
||||
assert.ok(sentPrompt, "sendMessage should have been called with a prompt");
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
|
||||
// The dispatch notification should reference M001/S01 (completed), not M001/S02 (active)
|
||||
const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching"));
|
||||
assert.ok(dispatchNotification, "dispatch notification should be present");
|
||||
assert.match(
|
||||
dispatchNotification.message,
|
||||
/M001\/S01/,
|
||||
"dispatch should target completed slice S01, not active slice S02",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
dispatchNotification.message,
|
||||
/M001\/S02/,
|
||||
"dispatch should NOT target active (next incomplete) slice S02",
|
||||
);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
// Should have dispatched (sendMessage called)
|
||||
assert.ok(sentPrompt, "sendMessage should have been called with a prompt");
|
||||
|
||||
// The dispatch notification should reference M001/S01 (completed), not M001/S02 (active)
|
||||
const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching"));
|
||||
assert.ok(dispatchNotification, "dispatch notification should be present");
|
||||
assert.match(
|
||||
dispatchNotification.message,
|
||||
/M001\/S01/,
|
||||
"dispatch should target completed slice S01, not active slice S02",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
dispatchNotification.message,
|
||||
/M001\/S02/,
|
||||
"dispatch should NOT target active (next incomplete) slice S02",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch uat warns when no completed slices exist", async () => {
|
||||
test("dispatch uat warns when no completed slices exist", async (t) => {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-dispatch-uat-none-"));
|
||||
invalidateStateCache();
|
||||
|
||||
|
|
@ -164,13 +162,11 @@ test("dispatch uat warns when no completed slices exist", async () => {
|
|||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
|
||||
const warning = notifications.find(n => n.level === "warning");
|
||||
assert.ok(warning, "should show a warning notification");
|
||||
assert.match(warning.message, /no completed slices/, "warning should mention no completed slices");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
|
||||
const warning = notifications.find(n => n.level === "warning");
|
||||
assert.ok(warning, "should show a warning notification");
|
||||
assert.match(warning.message, /no completed slices/, "warning should mention no completed slices");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,35 +56,33 @@ Done.
|
|||
`);
|
||||
}
|
||||
|
||||
test("doctor does not report any reconciliation issue codes", async () => {
|
||||
test("doctor does not report any reconciliation issue codes", async (t) => {
|
||||
const tmp = makeTmp("no-reconciliation");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
buildScaffold(tmp);
|
||||
|
||||
const REMOVED_CODES = [
|
||||
"task_done_missing_summary",
|
||||
"task_summary_without_done_checkbox",
|
||||
"all_tasks_done_missing_slice_summary",
|
||||
"all_tasks_done_missing_slice_uat",
|
||||
"all_tasks_done_roadmap_not_checked",
|
||||
"slice_checked_missing_summary",
|
||||
"slice_checked_missing_uat",
|
||||
];
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
const REMOVED_CODES = [
|
||||
"task_done_missing_summary",
|
||||
"task_summary_without_done_checkbox",
|
||||
"all_tasks_done_missing_slice_summary",
|
||||
"all_tasks_done_missing_slice_uat",
|
||||
"all_tasks_done_roadmap_not_checked",
|
||||
"slice_checked_missing_summary",
|
||||
"slice_checked_missing_uat",
|
||||
];
|
||||
|
||||
// No summary or UAT stubs should be created
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
|
||||
|
||||
const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
|
||||
assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
|
||||
// No summary or UAT stubs should be created
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
|
||||
|
||||
const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
|
||||
assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { runGSDDoctor } from "../doctor.js";
|
||||
|
||||
test("doctor fix=true sanitizes em-dash in milestone title", async () => {
|
||||
test("doctor fix=true sanitizes em-dash in milestone title", async (t) => {
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
const mDir = join(gsd, "milestones", "M001");
|
||||
|
|
@ -34,33 +34,31 @@ test("doctor fix=true sanitizes em-dash in milestone title", async () => {
|
|||
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Initial Setup\n\n## Tasks\n- [ ] **T01: Scaffold** \`est:15m\`\n`);
|
||||
writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Scaffold\n");
|
||||
|
||||
try {
|
||||
// Run doctor with fix=true
|
||||
const report = await runGSDDoctor(tmpBase, { fix: true });
|
||||
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
|
||||
|
||||
// The em-dash should have been replaced
|
||||
const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
|
||||
const h1 = fixed.split("\n").find(l => l.startsWith("# "))!;
|
||||
assert.ok(h1, "H1 line should exist");
|
||||
assert.ok(!h1.includes("\u2014"), "em-dash should be replaced");
|
||||
assert.ok(!h1.includes("\u2013"), "en-dash should be replaced");
|
||||
assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement");
|
||||
// Run doctor with fix=true
|
||||
const report = await runGSDDoctor(tmpBase, { fix: true });
|
||||
|
||||
// Should have recorded the fix
|
||||
assert.ok(
|
||||
report.fixesApplied.some(f => f.includes("sanitized")),
|
||||
`fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`,
|
||||
);
|
||||
// The em-dash should have been replaced
|
||||
const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
|
||||
const h1 = fixed.split("\n").find(l => l.startsWith("# "))!;
|
||||
assert.ok(h1, "H1 line should exist");
|
||||
assert.ok(!h1.includes("\u2014"), "em-dash should be replaced");
|
||||
assert.ok(!h1.includes("\u2013"), "en-dash should be replaced");
|
||||
assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement");
|
||||
|
||||
// The issue should NOT appear in the report (it was fixed)
|
||||
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001");
|
||||
assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list");
|
||||
} finally {
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
}
|
||||
// Should have recorded the fix
|
||||
assert.ok(
|
||||
report.fixesApplied.some(f => f.includes("sanitized")),
|
||||
`fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`,
|
||||
);
|
||||
|
||||
// The issue should NOT appear in the report (it was fixed)
|
||||
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001");
|
||||
assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list");
|
||||
});
|
||||
|
||||
test("doctor fix=false still reports delimiter_in_title as warning", async () => {
|
||||
test("doctor fix=false still reports delimiter_in_title as warning", async (t) => {
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-nf-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
const mDir = join(gsd, "milestones", "M001");
|
||||
|
|
@ -72,16 +70,14 @@ test("doctor fix=false still reports delimiter_in_title as warning", async () =>
|
|||
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Setup\n\n## Tasks\n- [ ] **T01: Init** \`est:10m\`\n`);
|
||||
writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Init\n");
|
||||
|
||||
try {
|
||||
const report = await runGSDDoctor(tmpBase, { fix: false });
|
||||
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title");
|
||||
assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false");
|
||||
assert.equal(delimIssues[0].severity, "warning");
|
||||
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
|
||||
|
||||
// File should be unchanged
|
||||
const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
|
||||
assert.ok(content.includes("\u2014"), "file should not be modified when fix=false");
|
||||
} finally {
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
}
|
||||
const report = await runGSDDoctor(tmpBase, { fix: false });
|
||||
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title");
|
||||
assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false");
|
||||
assert.equal(delimIssues[0].severity, "warning");
|
||||
|
||||
// File should be unchanged
|
||||
const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
|
||||
assert.ok(content.includes("\u2014"), "file should not be modified when fix=false");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,56 +76,53 @@ const REMOVED_CODES = [
|
|||
"slice_checked_missing_uat",
|
||||
];
|
||||
|
||||
test("fixLevel:task — no reconciliation issue codes are reported", async () => {
|
||||
test("fixLevel:task — no reconciliation issue codes are reported", async (t) => {
|
||||
const tmp = makeTmp("task-level");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
buildScaffold(tmp);
|
||||
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fixLevel:all — no reconciliation issue codes are reported", async () => {
|
||||
test("fixLevel:all — no reconciliation issue codes are reported", async (t) => {
|
||||
const tmp = makeTmp("all-level");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
buildScaffold(tmp);
|
||||
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
|
||||
// Summary and UAT stubs should NOT be created (no reconciliation)
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
|
||||
|
||||
// Roadmap should remain unchecked (no reconciliation)
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const codes = report.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
|
||||
// Summary and UAT stubs should NOT be created (no reconciliation)
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
|
||||
|
||||
// Roadmap should remain unchecked (no reconciliation)
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked");
|
||||
});
|
||||
|
||||
test("fixLevel:all — delimiter_in_title still fixable", async () => {
|
||||
test("fixLevel:all — delimiter_in_title still fixable", async (t) => {
|
||||
const tmp = makeTmp("delimiter-fix");
|
||||
try {
|
||||
const gsd = join(tmp, ".gsd");
|
||||
const m = join(gsd, "milestones", "M001");
|
||||
const s = join(m, "slices", "S01", "tasks");
|
||||
mkdirSync(s, { recursive: true });
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// Roadmap with em dash in milestone title (should still be fixable)
|
||||
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core
|
||||
const gsd = join(tmp, ".gsd");
|
||||
const m = join(gsd, "milestones", "M001");
|
||||
const s = join(m, "slices", "S01", "tasks");
|
||||
mkdirSync(s, { recursive: true });
|
||||
|
||||
// Roadmap with em dash in milestone title (should still be fixable)
|
||||
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core
|
||||
|
||||
## Slices
|
||||
|
||||
|
|
@ -133,7 +130,7 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => {
|
|||
> Demo
|
||||
`);
|
||||
|
||||
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
|
||||
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
|
||||
|
||||
**Goal:** test
|
||||
|
||||
|
|
@ -142,13 +139,10 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => {
|
|||
- [ ] **T01: Do stuff** \`est:5m\`
|
||||
`);
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
|
||||
const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title");
|
||||
// The milestone-level delimiter is auto-fixed, but the report may or may not include it
|
||||
// depending on whether it was fixed successfully. Just verify it ran without crashing.
|
||||
assert.ok(report.issues !== undefined, "doctor produces a report");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title");
|
||||
// The milestone-level delimiter is auto-fixed, but the report may or may not include it
|
||||
// depending on whether it was fixed successfully. Just verify it ran without crashing.
|
||||
assert.ok(report.issues !== undefined, "doctor produces a report");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,72 +58,66 @@ Done.
|
|||
`);
|
||||
}
|
||||
|
||||
test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => {
|
||||
test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => {
|
||||
const tmp = makeTmp("no-roadmap-toggle");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
buildScaffold(tmp);
|
||||
|
||||
// Roadmap must remain unchecked — doctor no longer touches checkboxes
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(
|
||||
roadmapContent.includes("- [ ] **S01"),
|
||||
"roadmap should remain unchecked — doctor no longer toggles checkboxes"
|
||||
);
|
||||
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
|
||||
// No summary or UAT stubs created
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
// Roadmap must remain unchecked — doctor no longer touches checkboxes
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(
|
||||
roadmapContent.includes("- [ ] **S01"),
|
||||
"roadmap should remain unchecked — doctor no longer toggles checkboxes"
|
||||
);
|
||||
|
||||
// No summary or UAT stubs created
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
|
||||
});
|
||||
|
||||
test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => {
|
||||
test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => {
|
||||
const tmp = makeTmp("all-no-toggle");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
buildScaffold(tmp);
|
||||
|
||||
// Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(
|
||||
roadmapContent.includes("- [ ] **S01"),
|
||||
"roadmap should remain unchecked — reconciliation removed"
|
||||
);
|
||||
const report = await runGSDDoctor(tmp, { fix: true });
|
||||
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
// Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes
|
||||
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
|
||||
assert.ok(
|
||||
roadmapContent.includes("- [ ] **S01"),
|
||||
"roadmap should remain unchecked — reconciliation removed"
|
||||
);
|
||||
|
||||
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
|
||||
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
|
||||
});
|
||||
|
||||
test("consecutive doctor runs produce no reconciliation codes", async () => {
|
||||
test("consecutive doctor runs produce no reconciliation codes", async (t) => {
|
||||
const tmp = makeTmp("consecutive-clean");
|
||||
try {
|
||||
buildScaffold(tmp);
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
buildScaffold(tmp);
|
||||
|
||||
const REMOVED_CODES = [
|
||||
"task_done_missing_summary",
|
||||
"task_summary_without_done_checkbox",
|
||||
"all_tasks_done_missing_slice_summary",
|
||||
"all_tasks_done_missing_slice_uat",
|
||||
"all_tasks_done_roadmap_not_checked",
|
||||
"slice_checked_missing_summary",
|
||||
"slice_checked_missing_uat",
|
||||
];
|
||||
await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
|
||||
|
||||
const codes = report2.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const REMOVED_CODES = [
|
||||
"task_done_missing_summary",
|
||||
"task_summary_without_done_checkbox",
|
||||
"all_tasks_done_missing_slice_summary",
|
||||
"all_tasks_done_missing_slice_uat",
|
||||
"all_tasks_done_roadmap_not_checked",
|
||||
"slice_checked_missing_summary",
|
||||
"slice_checked_missing_uat",
|
||||
];
|
||||
|
||||
const codes = report2.issues.map(i => i.code);
|
||||
for (const removed of REMOVED_CODES) {
|
||||
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue