diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 9bd194604..388722a02 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -113,6 +113,25 @@ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * Normalize a task-plan file reference that may include inline description text + * after the path, for example: + * "docs/file.md — explanation" + * "docs/file.md - explanation" + */ +export function normalizePlannedFileReference(value: string): string { + const trimmed = value.trim().replace(/`/g, ""); + const match = /^(.*?)(?:\s+(?:—|-)\s+)(.+)$/.exec(trimmed); + if (!match) return trimmed; + + const pathCandidate = match[1].trim(); + if (pathCandidate.includes("/") || pathCandidate.includes("\\") || pathCandidate.includes(".")) { + return pathCandidate; + } + + return trimmed; +} + /** Parse bullet list items from a text block. */ export function parseBullets(text: string): string[] { return text.split('\n') @@ -603,11 +622,11 @@ export function parseTaskPlanIO(content: string): { inputFiles: string[]; output let match: RegExpExecArray | null; backtickPathRegex.lastIndex = 0; while ((match = backtickPathRegex.exec(trimmed)) !== null) { - const candidate = match[1]; + const candidate = normalizePlannedFileReference(match[1]); // Filter out things that look like code tokens rather than file paths // (e.g. `true`, `false`, `npm run test`). A file path has at least one // dot or slash. - if (candidate.includes("/") || candidate.includes(".")) { + if (candidate.includes("/") || candidate.includes("\\") || candidate.includes(".")) { paths.push(candidate); } } diff --git a/src/resources/extensions/gsd/safety/file-change-validator.ts b/src/resources/extensions/gsd/safety/file-change-validator.ts index e2bb390ab..acc0dc927 100644 --- a/src/resources/extensions/gsd/safety/file-change-validator.ts +++ b/src/resources/extensions/gsd/safety/file-change-validator.ts @@ -10,6 +10,7 @@ */ import { execFileSync } from "node:child_process"; +import { normalizePlannedFileReference } from "../files.js"; import { logWarning } from "../workflow-logger.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -57,7 +58,9 @@ export function validateFileChanges( // Normalize expected paths (strip leading ./ or /) const normalizedExpected = new Set( - [...allExpected].map(f => f.replace(/^\.\//, "").replace(/^\//, "")), + [...allExpected].map((f) => + normalizePlannedFileReference(f).replace(/^\.\//, "").replace(/^\//, ""), + ), ); // Compute symmetric difference diff --git a/src/resources/extensions/gsd/tests/file-change-validator.test.ts b/src/resources/extensions/gsd/tests/file-change-validator.test.ts new file mode 100644 index 000000000..3e5df159b --- /dev/null +++ b/src/resources/extensions/gsd/tests/file-change-validator.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { validateFileChanges } from "../safety/file-change-validator.ts"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); +} + +test("validateFileChanges ignores inline descriptions in expected output paths", (t) => { + const base = mkdtempSync(join(tmpdir(), "gsd-file-change-validator-")); + t.after(() => rmSync(base, { recursive: true, force: true })); + + mkdirSync(join(base, "definitions"), { recursive: true }); + git(base, "init"); + git(base, "config", "user.email", "test@example.com"); + git(base, "config", "user.name", "Test User"); + + const target = join(base, "definitions", "ac-audit.md"); + writeFileSync(target, "initial\n"); + git(base, "add", "."); + git(base, "commit", "-m", "initial"); + + writeFileSync(target, "updated\n"); + git(base, "add", "."); + git(base, "commit", "-m", "update"); + + const audit = validateFileChanges( + base, + ["definitions/ac-audit.md — current state of AC CRM, tags, pipelines, automations"], + [], + ); + + assert.ok(audit, "audit should be produced when expected output exists"); + assert.deepEqual(audit.unexpectedFiles, []); + assert.deepEqual(audit.missingFiles, []); + assert.equal( + audit.violations.some((v) => v.severity === "warning"), + false, + "described expected output should not trigger unexpected-file warnings", + ); +}); diff --git a/src/resources/extensions/gsd/tests/reactive-graph.test.ts b/src/resources/extensions/gsd/tests/reactive-graph.test.ts index 8e16e28a5..6232dc6b0 100644 --- a/src/resources/extensions/gsd/tests/reactive-graph.test.ts +++ b/src/resources/extensions/gsd/tests/reactive-graph.test.ts @@ -101,6 +101,25 @@ test("parseTaskPlanIO handles multiple backtick tokens on one line", () => { assert.deepEqual(io.outputFiles, ["src/c.ts"]); }); +test("parseTaskPlanIO strips inline descriptions from backtick-wrapped file references", () => { + const content = `# T01: Described Paths + +## Inputs + +- \`src/config.ts — existing configuration\` +- \`src/flags.ts - feature flags\` + +## Expected Output + +- \`definitions/ac-audit.md — current state of AC CRM\` +- \`docs/runbook.md - update deployment notes\` +`; + + const io = parseTaskPlanIO(content); + assert.deepEqual(io.inputFiles, ["src/config.ts", "src/flags.ts"]); + assert.deepEqual(io.outputFiles, ["definitions/ac-audit.md", "docs/runbook.md"]); +}); + // ─── deriveTaskGraph ────────────────────────────────────────────────────── test("deriveTaskGraph: linear chain T01→T02→T03", () => {