Merge pull request #3737 from mastertyko/fix/3736-safety-expected-output-paths

fix(gsd): normalize described expected output paths
This commit is contained in:
Jeremy McSpadden 2026-04-08 22:18:56 -05:00 committed by GitHub
commit d8574e5669
4 changed files with 94 additions and 3 deletions

View file

@ -132,6 +132,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')
@ -622,11 +641,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);
}
}

View file

@ -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

View file

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

View file

@ -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", () => {