From 0742cf3493e328f1ded63c1fa7ffa7a38d055459 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 13:23:30 -0500 Subject: [PATCH 1/4] fix(gsd): coerce string arrays to objects in complete-slice/task tools (#3565) LLMs sometimes pass plain strings instead of the expected object shape for array fields like filesModified and requires, causing TypeBox validation to reject the input before the execute function runs. This adds Type.Union schemas to accept both formats and normalizes strings to objects with sensible defaults in the execute functions. Closes #3565 --- .../extensions/gsd/bootstrap/db-tools.ts | 99 +++++++++++++------ .../gsd/tests/complete-slice.test.ts | 42 ++++++++ 2 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 3f6f9d998..ac67ba546 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -704,8 +704,14 @@ export function registerDbTools(pi: ExtensionAPI): void { }; } try { + // Coerce string items to objects for verificationEvidence (#3541). + const coerced = { ...params }; + coerced.verificationEvidence = (params.verificationEvidence ?? []).map((v: any) => + typeof v === "string" ? { command: v, exitCode: 0, verdict: "pass", durationMs: 0 } : v, + ); + const { handleCompleteTask } = await import("../tools/complete-task.js"); - const result = await handleCompleteTask(params, process.cwd()); + const result = await handleCompleteTask(coerced, process.cwd()); if ("error" in result) { return { content: [{ type: "text" as const, text: `Error completing task: ${result.error}` }], @@ -761,12 +767,15 @@ export function registerDbTools(pi: ExtensionAPI): void { keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "List of key decisions made during this task" })), blockerDiscovered: Type.Optional(Type.Boolean({ description: "Whether a plan-invalidating blocker was discovered" })), verificationEvidence: Type.Optional(Type.Array( - Type.Object({ - command: Type.String({ description: "Verification command that was run" }), - exitCode: Type.Number({ description: "Exit code of the command" }), - verdict: Type.String({ description: "Pass/fail verdict (e.g. '✅ pass', '❌ fail')" }), - durationMs: Type.Number({ description: "Duration of the command in milliseconds" }), - }), + Type.Union([ + Type.Object({ + command: Type.String({ description: "Verification command that was run" }), + exitCode: Type.Number({ description: "Exit code of the command" }), + verdict: Type.String({ description: "Pass/fail verdict (e.g. '✅ pass', '❌ fail')" }), + durationMs: Type.Number({ description: "Duration of the command in milliseconds" }), + }), + Type.String({ description: "Fallback: verification summary string" }), + ]), { description: "Array of verification evidence entries" }, )), }), @@ -787,8 +796,27 @@ export function registerDbTools(pi: ExtensionAPI): void { }; } try { + // Coerce string items to objects for fields where LLMs sometimes pass + // plain strings instead of the expected { key, value } shape (#3541). + const coerced = { ...params }; + coerced.filesModified = (params.filesModified ?? []).map((f: any) => + typeof f === "string" ? { path: f, description: "" } : f, + ); + coerced.requires = (params.requires ?? []).map((r: any) => + typeof r === "string" ? { slice: r, provides: "" } : r, + ); + coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => + typeof r === "string" ? { id: r, how: "" } : r, + ); + coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => + typeof r === "string" ? { id: r, proof: "" } : r, + ); + coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => + typeof r === "string" ? { id: r, what: "" } : r, + ); + const { handleCompleteSlice } = await import("../tools/complete-slice.js"); - const result = await handleCompleteSlice(params, process.cwd()); + const result = await handleCompleteSlice(coerced, process.cwd()); if ("error" in result) { return { content: [{ type: "text" as const, text: `Error completing slice: ${result.error}` }], @@ -850,38 +878,53 @@ export function registerDbTools(pi: ExtensionAPI): void { drillDownPaths: Type.Optional(Type.Array(Type.String(), { description: "Paths to task summaries for drill-down" })), affects: Type.Optional(Type.Array(Type.String(), { description: "Downstream slices affected" })), requirementsAdvanced: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - how: Type.String({ description: "How it was advanced" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + how: Type.String({ description: "How it was advanced" }), + }), + Type.String({ description: "Fallback: 'ID — how' string" }), + ]), { description: "Requirements advanced by this slice" }, )), requirementsValidated: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - proof: Type.String({ description: "What proof validates it" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + proof: Type.String({ description: "What proof validates it" }), + }), + Type.String({ description: "Fallback: 'ID — proof' string" }), + ]), { description: "Requirements validated by this slice" }, )), requirementsInvalidated: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - what: Type.String({ description: "What changed" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + what: Type.String({ description: "What changed" }), + }), + Type.String({ description: "Fallback: 'ID — what' string" }), + ]), { description: "Requirements invalidated or re-scoped" }, )), filesModified: Type.Optional(Type.Array( - Type.Object({ - path: Type.String({ description: "File path" }), - description: Type.String({ description: "What changed" }), - }), + Type.Union([ + Type.Object({ + path: Type.String({ description: "File path" }), + description: Type.String({ description: "What changed" }), + }), + Type.String({ description: "Fallback: file path string" }), + ]), { description: "Files modified with descriptions" }, )), requires: Type.Optional(Type.Array( - Type.Object({ - slice: Type.String({ description: "Dependency slice ID" }), - provides: Type.String({ description: "What was consumed from it" }), - }), + Type.Union([ + Type.Object({ + slice: Type.String({ description: "Dependency slice ID" }), + provides: Type.String({ description: "What was consumed from it" }), + }), + Type.String({ description: "Fallback: slice ID string" }), + ]), { description: "Upstream slice dependencies consumed" }, )), }), diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index ed5073ff8..72273fdc9 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -406,6 +406,48 @@ console.log('\n=== complete-slice: handler with missing roadmap ==='); cleanup(dbPath); } +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler accepts string coercion for object arrays (#3541) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-slice: handler accepts string-coerced arrays (#3541) ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); + + const { basePath } = createTempProject(); + + // Set up DB state + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + + // Simulate LLM passing strings instead of objects — coerced before handler + const params = makeValidSliceParams(); + const coerced = { ...params }; + coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => + ({ path: f, description: '' }), + ); + coerced.requires = ['S00'].map((r: string) => + ({ slice: r, provides: '' }), + ); + coerced.requirementsAdvanced = ['R001'].map((r: string) => + ({ id: r, how: '' }), + ); + + const result = await handleCompleteSlice(coerced, basePath); + assertTrue(!('error' in result), 'handler should succeed with coerced string arrays'); + if (!('error' in result)) { + // Verify SUMMARY.md renders without crashing on coerced fields + const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); + assertMatch(summaryContent, /src\/foo\.ts/, 'summary should list coerced file path'); + assertMatch(summaryContent, /R001/, 'summary should list coerced requirement'); + } + + cleanupDir(basePath); + cleanup(dbPath); +} + // ═══════════════════════════════════════════════════════════════════════════ // complete-slice: step 13 specifies write tool for PROJECT.md (#2946) // ═══════════════════════════════════════════════════════════════════════════ From 6046a31c6f7623bd36eb2a770bb0087d93b488f9 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 13:30:09 -0500 Subject: [PATCH 2/4] fix(gsd): address Codex adversarial review findings for #3565 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verificationEvidence coercion now uses sentinel values (exitCode: -1, verdict: "unknown") instead of fabricating passing results - String coercion for requirements fields now parses "ID — detail" delimiter format to preserve semantic payload - Added regression tests for sentinel values and delimiter parsing Closes #3565 --- .../extensions/gsd/bootstrap/db-tools.ts | 47 ++++++++---- .../gsd/tests/complete-slice.test.ts | 73 ++++++++++++++++--- 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index ac67ba546..2926c1b29 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -707,7 +707,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // Coerce string items to objects for verificationEvidence (#3541). const coerced = { ...params }; coerced.verificationEvidence = (params.verificationEvidence ?? []).map((v: any) => - typeof v === "string" ? { command: v, exitCode: 0, verdict: "pass", durationMs: 0 } : v, + typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v, ); const { handleCompleteTask } = await import("../tools/complete-task.js"); @@ -798,22 +798,37 @@ export function registerDbTools(pi: ExtensionAPI): void { try { // Coerce string items to objects for fields where LLMs sometimes pass // plain strings instead of the expected { key, value } shape (#3541). + // Parses "key — value" or "key - value" format when possible. + const splitPair = (s: string): [string, string] => { + const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/); + return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""]; + }; const coerced = { ...params }; - coerced.filesModified = (params.filesModified ?? []).map((f: any) => - typeof f === "string" ? { path: f, description: "" } : f, - ); - coerced.requires = (params.requires ?? []).map((r: any) => - typeof r === "string" ? { slice: r, provides: "" } : r, - ); - coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => - typeof r === "string" ? { id: r, how: "" } : r, - ); - coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => - typeof r === "string" ? { id: r, proof: "" } : r, - ); - coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => - typeof r === "string" ? { id: r, what: "" } : r, - ); + coerced.filesModified = (params.filesModified ?? []).map((f: any) => { + if (typeof f !== "string") return f; + const [path, description] = splitPair(f); + return { path, description }; + }); + coerced.requires = (params.requires ?? []).map((r: any) => { + if (typeof r !== "string") return r; + const [slice, provides] = splitPair(r); + return { slice, provides }; + }); + coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => { + if (typeof r !== "string") return r; + const [id, how] = splitPair(r); + return { id, how }; + }); + coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => { + if (typeof r !== "string") return r; + const [id, proof] = splitPair(r); + return { id, proof }; + }); + coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => { + if (typeof r !== "string") return r; + const [id, what] = splitPair(r); + return { id, what }; + }); const { handleCompleteSlice } = await import("../tools/complete-slice.js"); const result = await handleCompleteSlice(coerced, process.cwd()); diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index 72273fdc9..061d3e44f 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -422,32 +422,81 @@ console.log('\n=== complete-slice: handler accepts string-coerced arrays (#3541) insertSlice({ id: 'S01', milestoneId: 'M001' }); insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); - // Simulate LLM passing strings instead of objects — coerced before handler + // Simulate the coercion logic from sliceCompleteExecute: parse "key — value" format + const splitPair = (s: string): [string, string] => { + const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/); + return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""]; + }; + const params = makeValidSliceParams(); const coerced = { ...params }; - coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => - ({ path: f, description: '' }), - ); - coerced.requires = ['S00'].map((r: string) => - ({ slice: r, provides: '' }), - ); - coerced.requirementsAdvanced = ['R001'].map((r: string) => - ({ id: r, how: '' }), - ); + + // Plain strings without delimiter — second field should be empty + coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => { + const [p, d] = splitPair(f); + return { path: p, description: d }; + }); + assertEq(coerced.filesModified[0].path, 'src/foo.ts', 'plain string: path preserved'); + assertEq(coerced.filesModified[0].description, '', 'plain string: description empty'); + + // Strings with "—" delimiter — should parse both parts + coerced.requirementsAdvanced = ['R001 — Handler validates task completion'].map((r: string) => { + const [id, how] = splitPair(r); + return { id, how }; + }); + assertEq(coerced.requirementsAdvanced[0].id, 'R001', 'delimited string: id parsed'); + assertEq(coerced.requirementsAdvanced[0].how, 'Handler validates task completion', 'delimited string: how parsed'); + + // Strings with "- " delimiter — should also parse + coerced.requirementsValidated = ['R002 - Tests pass'].map((r: string) => { + const [id, proof] = splitPair(r); + return { id, proof }; + }); + assertEq(coerced.requirementsValidated[0].id, 'R002', 'hyphen delimiter: id parsed'); + assertEq(coerced.requirementsValidated[0].proof, 'Tests pass', 'hyphen delimiter: proof parsed'); + + coerced.requires = ['S00'].map((r: string) => { + const [slice, provides] = splitPair(r); + return { slice, provides }; + }); + coerced.requirementsInvalidated = []; const result = await handleCompleteSlice(coerced, basePath); assertTrue(!('error' in result), 'handler should succeed with coerced string arrays'); if (!('error' in result)) { - // Verify SUMMARY.md renders without crashing on coerced fields const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); assertMatch(summaryContent, /src\/foo\.ts/, 'summary should list coerced file path'); - assertMatch(summaryContent, /R001/, 'summary should list coerced requirement'); + assertMatch(summaryContent, /R001/, 'summary should list coerced requirement id'); + assertMatch(summaryContent, /Handler validates task completion/, 'summary should list parsed requirement detail'); } cleanupDir(basePath); cleanup(dbPath); } +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: verificationEvidence coercion uses sentinel values (#3541) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-slice: verificationEvidence sentinel values (#3541) ==='); +{ + // Verify the coercion logic produces non-passing sentinel values + const coerce = (v: any) => + typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v; + + const coerced = coerce("npm test"); + assertEq(coerced.command, 'npm test', 'sentinel: command preserved'); + assertEq(coerced.exitCode, -1, 'sentinel: exitCode is -1, not 0'); + assertEq(coerced.verdict, 'unknown (coerced from string)', 'sentinel: verdict is unknown, not pass'); + assertEq(coerced.durationMs, 0, 'sentinel: durationMs is 0'); + + // Object inputs pass through unchanged + const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 }; + const passthrough = coerce(obj); + assertEq(passthrough.exitCode, 0, 'object passthrough: exitCode unchanged'); + assertEq(passthrough.verdict, 'pass', 'object passthrough: verdict unchanged'); +} + // ═══════════════════════════════════════════════════════════════════════════ // complete-slice: step 13 specifies write tool for PROJECT.md (#2946) // ═══════════════════════════════════════════════════════════════════════════ From e210b7efdf292792660d2b48babeb46a12625474 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 13:32:56 -0500 Subject: [PATCH 3/4] fix(gsd): follow CONTRIBUTING standards for #3565 - Move new coercion tests to standalone file using node:test + node:assert/strict (per CONTRIBUTING testing standards) - Remove tests from legacy complete-slice.test.ts to avoid mixing test frameworks in the same file --- .../complete-slice-string-coercion.test.ts | 212 ++++++++++++++++++ .../gsd/tests/complete-slice.test.ts | 91 -------- 2 files changed, 212 insertions(+), 91 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts diff --git a/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts new file mode 100644 index 000000000..a92abdf77 --- /dev/null +++ b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts @@ -0,0 +1,212 @@ +// GSD Extension — String coercion regression tests for complete-slice/task tools +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, +} from "../gsd-db.ts"; +import { handleCompleteSlice } from "../tools/complete-slice.ts"; +import type { CompleteSliceParams } from "../types.ts"; + +// ─── Helpers ───────────────────────────────────────────────────────────── + +/** + * The splitPair coercion logic extracted from db-tools.ts sliceCompleteExecute. + * Duplicated here so we can unit-test it directly. + */ +function splitPair(s: string): [string, string] { + const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/); + return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""]; +} + +function makeValidSliceParams(): CompleteSliceParams { + return { + sliceId: "S01", + milestoneId: "M001", + sliceTitle: "Test Slice", + oneLiner: "Implemented test slice", + narrative: "Built and tested.", + verification: "All tests pass.", + deviations: "None.", + knownLimitations: "None.", + followUps: "None.", + keyFiles: ["src/foo.ts"], + keyDecisions: ["D001"], + patternsEstablished: [], + observabilitySurfaces: [], + provides: ["test handler"], + requirementsSurfaced: [], + drillDownPaths: [], + affects: [], + requirementsAdvanced: [{ id: "R001", how: "Handler validates" }], + requirementsValidated: [], + requirementsInvalidated: [], + filesModified: [{ path: "src/foo.ts", description: "Handler" }], + requires: [], + uatContent: "## Smoke Test\n\nVerify all assertions pass.", + }; +} + +// ─── splitPair unit tests ──────────────────────────────────────────────── + +describe("splitPair coercion helper (#3565)", () => { + test("plain string without delimiter returns string + empty", () => { + const [a, b] = splitPair("src/foo.ts"); + assert.equal(a, "src/foo.ts"); + assert.equal(b, ""); + }); + + test("em-dash delimiter parses both parts", () => { + const [id, how] = splitPair("R001 — Handler validates task completion"); + assert.equal(id, "R001"); + assert.equal(how, "Handler validates task completion"); + }); + + test("hyphen delimiter parses both parts", () => { + const [id, proof] = splitPair("R002 - Tests pass"); + assert.equal(id, "R002"); + assert.equal(proof, "Tests pass"); + }); + + test("string with no space around hyphen is treated as plain", () => { + // e.g. a file path like "src/foo-bar.ts" should not split + const [a, b] = splitPair("src/foo-bar.ts"); + assert.equal(a, "src/foo-bar.ts"); + assert.equal(b, ""); + }); + + test("whitespace is trimmed from both parts", () => { + const [id, how] = splitPair(" R003 — Trimmed value "); + assert.equal(id, "R003"); + assert.equal(how, "Trimmed value"); + }); +}); + +// ─── verificationEvidence sentinel tests ───────────────────────────────── + +describe("verificationEvidence sentinel coercion (#3565)", () => { + function coerceEvidence(v: any) { + return typeof v === "string" + ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } + : v; + } + + test("string input produces non-passing sentinel", () => { + const result = coerceEvidence("npm test"); + assert.equal(result.command, "npm test"); + assert.equal(result.exitCode, -1); + assert.equal(result.verdict, "unknown (coerced from string)"); + assert.equal(result.durationMs, 0); + }); + + test("object input passes through unchanged", () => { + const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 }; + const result = coerceEvidence(obj); + assert.equal(result.exitCode, 0); + assert.equal(result.verdict, "pass"); + assert.equal(result.durationMs, 1234); + }); + + test("sentinel exitCode is not 0 (must not fabricate success)", () => { + const result = coerceEvidence("anything"); + assert.notEqual(result.exitCode, 0, "exitCode must not be 0 for coerced strings"); + assert.ok( + !result.verdict.includes("pass"), + "verdict must not contain 'pass' for coerced strings", + ); + }); +}); + +// ─── Handler integration with coerced params ───────────────────────────── + +describe("handleCompleteSlice with coerced string arrays (#3565)", () => { + let dbPath: string; + let basePath: string; + + beforeEach(() => { + dbPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-")), + "test.db", + ); + openDatabase(dbPath); + + basePath = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-handler-")); + const sliceDir = path.join(basePath, ".gsd", "milestones", "M001", "slices", "S01", "tasks"); + fs.mkdirSync(sliceDir, { recursive: true }); + + const roadmapPath = path.join(basePath, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + fs.writeFileSync( + roadmapPath, + [ + "# M001: Test Milestone", + "", + "## Slices", + "", + '- [ ] **S01: Test Slice** `risk:medium` `depends:[]`', + " - After this: basic functionality works", + ].join("\n"), + ); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" }); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + fs.rmSync(basePath, { recursive: true, force: true }); + }); + + test("handler succeeds with coerced filesModified and requirementsAdvanced", async () => { + const params = makeValidSliceParams(); + // Simulate coercion from plain strings + params.filesModified = ["src/foo.ts", "src/bar.ts"].map((f) => { + const [p, d] = splitPair(f); + return { path: p, description: d }; + }); + params.requirementsAdvanced = ["R001 — Handler validates task completion"].map((r) => { + const [id, how] = splitPair(r); + return { id, how }; + }); + + const result = await handleCompleteSlice(params, basePath); + assert.ok(!("error" in result), "handler should succeed"); + if (!("error" in result)) { + const summary = fs.readFileSync(result.summaryPath, "utf-8"); + assert.match(summary, /src\/foo\.ts/); + assert.match(summary, /R001/); + assert.match(summary, /Handler validates task completion/); + } + }); + + test("handler succeeds with coerced requires and requirementsValidated", async () => { + const params = makeValidSliceParams(); + params.requires = ["S00 — Provided base infrastructure"].map((r) => { + const [slice, provides] = splitPair(r); + return { slice, provides }; + }); + params.requirementsValidated = ["R002 - Tests pass"].map((r) => { + const [id, proof] = splitPair(r); + return { id, proof }; + }); + + const result = await handleCompleteSlice(params, basePath); + assert.ok(!("error" in result), "handler should succeed"); + if (!("error" in result)) { + const summary = fs.readFileSync(result.summaryPath, "utf-8"); + assert.match(summary, /S00/); + assert.match(summary, /Provided base infrastructure/); + assert.match(summary, /R002/); + assert.match(summary, /Tests pass/); + } + }); +}); diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index 061d3e44f..ed5073ff8 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -406,97 +406,6 @@ console.log('\n=== complete-slice: handler with missing roadmap ==='); cleanup(dbPath); } -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler accepts string coercion for object arrays (#3541) -// ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== complete-slice: handler accepts string-coerced arrays (#3541) ==='); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - const { basePath } = createTempProject(); - - // Set up DB state - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); - insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); - - // Simulate the coercion logic from sliceCompleteExecute: parse "key — value" format - const splitPair = (s: string): [string, string] => { - const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/); - return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""]; - }; - - const params = makeValidSliceParams(); - const coerced = { ...params }; - - // Plain strings without delimiter — second field should be empty - coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => { - const [p, d] = splitPair(f); - return { path: p, description: d }; - }); - assertEq(coerced.filesModified[0].path, 'src/foo.ts', 'plain string: path preserved'); - assertEq(coerced.filesModified[0].description, '', 'plain string: description empty'); - - // Strings with "—" delimiter — should parse both parts - coerced.requirementsAdvanced = ['R001 — Handler validates task completion'].map((r: string) => { - const [id, how] = splitPair(r); - return { id, how }; - }); - assertEq(coerced.requirementsAdvanced[0].id, 'R001', 'delimited string: id parsed'); - assertEq(coerced.requirementsAdvanced[0].how, 'Handler validates task completion', 'delimited string: how parsed'); - - // Strings with "- " delimiter — should also parse - coerced.requirementsValidated = ['R002 - Tests pass'].map((r: string) => { - const [id, proof] = splitPair(r); - return { id, proof }; - }); - assertEq(coerced.requirementsValidated[0].id, 'R002', 'hyphen delimiter: id parsed'); - assertEq(coerced.requirementsValidated[0].proof, 'Tests pass', 'hyphen delimiter: proof parsed'); - - coerced.requires = ['S00'].map((r: string) => { - const [slice, provides] = splitPair(r); - return { slice, provides }; - }); - coerced.requirementsInvalidated = []; - - const result = await handleCompleteSlice(coerced, basePath); - assertTrue(!('error' in result), 'handler should succeed with coerced string arrays'); - if (!('error' in result)) { - const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); - assertMatch(summaryContent, /src\/foo\.ts/, 'summary should list coerced file path'); - assertMatch(summaryContent, /R001/, 'summary should list coerced requirement id'); - assertMatch(summaryContent, /Handler validates task completion/, 'summary should list parsed requirement detail'); - } - - cleanupDir(basePath); - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: verificationEvidence coercion uses sentinel values (#3541) -// ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== complete-slice: verificationEvidence sentinel values (#3541) ==='); -{ - // Verify the coercion logic produces non-passing sentinel values - const coerce = (v: any) => - typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v; - - const coerced = coerce("npm test"); - assertEq(coerced.command, 'npm test', 'sentinel: command preserved'); - assertEq(coerced.exitCode, -1, 'sentinel: exitCode is -1, not 0'); - assertEq(coerced.verdict, 'unknown (coerced from string)', 'sentinel: verdict is unknown, not pass'); - assertEq(coerced.durationMs, 0, 'sentinel: durationMs is 0'); - - // Object inputs pass through unchanged - const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 }; - const passthrough = coerce(obj); - assertEq(passthrough.exitCode, 0, 'object passthrough: exitCode unchanged'); - assertEq(passthrough.verdict, 'pass', 'object passthrough: verdict unchanged'); -} - // ═══════════════════════════════════════════════════════════════════════════ // complete-slice: step 13 specifies write tool for PROJECT.md (#2946) // ═══════════════════════════════════════════════════════════════════════════ From 0b7764349ca76039ac90887c49b526c0e9be2bf7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 13:33:13 -0500 Subject: [PATCH 4/4] chore(gsd): remove copyright line from test file --- .../extensions/gsd/tests/complete-slice-string-coercion.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts index a92abdf77..229860ba9 100644 --- a/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts @@ -1,5 +1,4 @@ // GSD Extension — String coercion regression tests for complete-slice/task tools -// Copyright (c) 2026 Jeremy McSpadden import { describe, test, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict";