fix(core): accept root-level files in File Scope validation

Root paths with extensions (global.json, Directory.Packages.props, MyApp.slnx)
were rejected because isValidFileScopeEntry required a slash, which failed GitHub
imports whose issue bodies declare those paths and dropped them from effective
write scope. Share one validator between create/update and classification.
This commit is contained in:
gsxdsm
2026-07-21 17:50:25 -07:00
parent a38524dd54
commit d393168771
3 changed files with 142 additions and 51 deletions

View File

@@ -0,0 +1,126 @@
/**
* FNXC:FileScopeClassification 2026-07-21-12:00:
* Regression for root-level File Scope files with extensions. GitHub issue import
* embeds the issue body into PROMPT.md; when that body declares `## File Scope`
* with paths like global.json / Directory.Packages.props / MyApp.slnx, createTask
* must not throw InvalidFileScopeError, and extractEffectiveWriteScopeFromPrompt
* must keep all four write targets (not only nested src/... entries).
*/
import { describe, expect, it } from "vitest";
import {
extractEffectiveWriteScopeFromPrompt,
isValidFileScopeEntry,
} from "../file-scope-classification.js";
import {
isValidFileScopeEntry as storeIsValidFileScopeEntry,
validateFileScopeInPromptContent,
} from "../task-store/file-scope.js";
describe("isValidFileScopeEntry", () => {
it("accepts root-level repo files with letter-leading extensions", () => {
const roots = [
"global.json",
"Directory.Packages.props",
"MyApp.slnx",
"MyApp.sln",
"tsconfig.json",
"package.json",
"pnpm-lock.yaml",
"README.md",
".env",
"AGENTS.md",
];
for (const path of roots) {
expect(isValidFileScopeEntry(path), path).toBe(true);
expect(storeIsValidFileScopeEntry(path), `store:${path}`).toBe(true);
}
});
it("accepts nested files, globs, and known extensionless roots", () => {
const samples = [
"src/MyApp/Program.cs",
"packages/core/src/store.ts",
"packages/engine/src/**/*.ts",
"packages/dashboard/app/**",
"Makefile",
"Dockerfile",
"foo/Dockerfile",
];
for (const path of samples) {
expect(isValidFileScopeEntry(path), path).toBe(true);
}
});
it("rejects git refs, absolute paths, bare identifiers, and version-like tokens", () => {
const rejects = [
"origin/main",
"upstream/main",
"refs/heads/main",
"https://example.com/repo",
"git@github.com:org/repo.git",
"ssh://git@host/repo",
"feature/fn-123",
"abc1234",
"deadbeef",
"main",
"todo",
"v1.2.3",
"/abs/path.ts",
"../escape.ts",
"packages/../secret.ts",
"",
" ",
];
for (const path of rejects) {
expect(isValidFileScopeEntry(path), JSON.stringify(path)).toBe(false);
}
});
it("keeps create/update validation and classification on the same function", () => {
expect(storeIsValidFileScopeEntry).toBe(isValidFileScopeEntry);
});
});
describe("extractEffectiveWriteScopeFromPrompt / validateFileScopeInPromptContent", () => {
const prompt = `# Task: FN-8459
## File Scope
- \`global.json\`
- \`Directory.Packages.props\`
- \`MyApp.slnx\`
- \`src/MyApp/Program.cs\`
## Steps
- [ ] Implement
`;
it("includes all four FN-8459-style File Scope entries in effective write scope", () => {
expect(extractEffectiveWriteScopeFromPrompt(prompt)).toEqual([
"global.json",
"Directory.Packages.props",
"MyApp.slnx",
"src/MyApp/Program.cs",
]);
});
it("passes create/update File Scope validation for root-level extension paths", () => {
const { valid, invalid } = validateFileScopeInPromptContent(prompt);
expect(invalid).toEqual([]);
expect(valid).toEqual([
"global.json",
"Directory.Packages.props",
"MyApp.slnx",
"src/MyApp/Program.cs",
]);
});
it("still rejects git-ref tokens inside File Scope on create/update validation", () => {
const bad = `## File Scope
- \`packages/core/src/store.ts\`
- \`origin/main\`
`;
const { valid, invalid } = validateFileScopeInPromptContent(bad);
expect(valid).toEqual(["packages/core/src/store.ts"]);
expect(invalid).toEqual(["origin/main"]);
});
});

View File

@@ -25,6 +25,9 @@ export interface FileScopeClassificationResult {
/*
FNXC:FileScopeClassification 2026-06-25-04:34:
Task File Scope is operator intent, not every path-like token in PROMPT.md. Keep this classifier conservative so read-only evidence, wrong-worktree safeguards, generated locks, route names, and conditional changesets do not create false write-scope leases or file-scope merge guards.
FNXC:FileScopeClassification 2026-07-21-12:00:
Root-level repo files with letter-leading extensions (global.json, Directory.Packages.props, MyApp.slnx, tsconfig.json, .env) are valid File Scope entries. Requiring a slash rejected them, which failed GitHub imports whose issue bodies declare those paths and dropped them from extractEffectiveWriteScopeFromPrompt. Still reject bare identifiers (main, todo) and version-like tokens (v1.2.3) via the letter-leading final-extension rule. Extensionless well-known roots (Makefile, Dockerfile) stay on the explicit allowlist.
*/
const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([
"makefile",
@@ -39,6 +42,9 @@ const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([
"pnpm-lock.yaml",
]);
/** Final segment ends with a letter-leading extension (.json, .props, .slnx, .env). */
const FILE_EXTENSION_RE = /\.[A-Za-z][A-Za-z0-9]*$/;
const INCLUDE_CONTEXT_RE = /\b(expected|touched|touch|modify|modified|write|writes|implementation|must update|artifacts?|files? changed|source paths?)\b/i;
const EXCLUDE_CONTEXT_RE = /\b(forbidden|non-goals?|out of scope|do not edit|do not modify|must not edit|must not modify|do not hand-edit|hand-edit|read-only|context to read|evidence only|metadata|wrong[- ]worktree|safeguards?)\b/i;
const GENERATED_CONTEXT_RE = /\b(generated|lockfiles?|locks?)\b/i;
@@ -67,7 +73,6 @@ export function isValidFileScopeEntry(token: string): boolean {
const segments = trimmed.split("/");
const lastSegment = segments[segments.length - 1] ?? "";
const hasSlash = trimmed.includes("/");
const hasDotInLastSegment = lastSegment.includes(".");
if (KNOWN_FILE_SCOPE_ROOT_FILES.has(lastSegment.toLowerCase())) {
@@ -78,7 +83,8 @@ export function isValidFileScopeEntry(token: string): boolean {
return true;
}
if (hasSlash && hasDotInLastSegment) {
// Nested (`src/MyApp/Program.cs`) or root-level (`global.json`, `Directory.Packages.props`) files.
if (hasDotInLastSegment && FILE_EXTENSION_RE.test(lastSegment)) {
return true;
}

View File

@@ -5,56 +5,15 @@
* Extracted from the monolithic packages/core/src/store.ts (U5 decomposition).
* Pure behavior-invariant move: function bodies are byte-identical to their
* pre-extraction form. store.ts re-imports these helpers.
*
* FNXC:FileScopeClassification 2026-07-21-12:00:
* isValidFileScopeEntry is owned by file-scope-classification.ts so create/update
* validation and extractEffectiveWriteScopeFromPrompt cannot drift. Root-level
* files with extensions (global.json, Directory.Packages.props, MyApp.slnx) must
* pass both paths — GitHub issue bodies that declare them were failing import.
*/
const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([
"makefile",
"dockerfile",
"justfile",
"license",
"readme",
"changelog",
"agents.md",
]);
export function isValidFileScopeEntry(token: string): boolean {
const trimmed = token.trim();
if (!trimmed) return false;
const lower = trimmed.toLowerCase();
if (
lower.startsWith("origin/")
|| lower.startsWith("upstream/")
|| lower.startsWith("refs/")
|| /^https?:\/\//i.test(trimmed)
|| /^git@/i.test(trimmed)
|| /^ssh:\/\//i.test(trimmed)
|| /^[a-z]+\/fn-\d+$/i.test(trimmed)
|| /^[a-f0-9]{7,}$/i.test(trimmed)
|| trimmed.includes("..")
|| trimmed.startsWith("/")
) {
return false;
}
const segments = trimmed.split("/");
const lastSegment = segments[segments.length - 1];
const hasSlash = trimmed.includes("/");
const hasDotInLastSegment = lastSegment.includes(".");
if (KNOWN_FILE_SCOPE_ROOT_FILES.has(lastSegment.toLowerCase())) {
return true;
}
if (trimmed.includes("**") || trimmed.endsWith("/*") || (lastSegment.includes("*") && hasDotInLastSegment)) {
return true;
}
if (hasSlash && hasDotInLastSegment) {
return true;
}
return false;
}
export { isValidFileScopeEntry } from "../file-scope-classification.js";
import { isValidFileScopeEntry } from "../file-scope-classification.js";
export function extractFileScopeTokens(content: string): string[] {
const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m);