From d393168771acce8ce9bb592af17518019c83ea99 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 21 Jul 2026 17:50:25 -0700 Subject: [PATCH] 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. --- .../file-scope-classification.test.ts | 126 ++++++++++++++++++ .../core/src/file-scope-classification.ts | 10 +- packages/core/src/task-store/file-scope.ts | 57 ++------ 3 files changed, 142 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/__tests__/file-scope-classification.test.ts diff --git a/packages/core/src/__tests__/file-scope-classification.test.ts b/packages/core/src/__tests__/file-scope-classification.test.ts new file mode 100644 index 0000000000..8bd1645bfa --- /dev/null +++ b/packages/core/src/__tests__/file-scope-classification.test.ts @@ -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"]); + }); +}); diff --git a/packages/core/src/file-scope-classification.ts b/packages/core/src/file-scope-classification.ts index 9054b082c7..c9e9049c4d 100644 --- a/packages/core/src/file-scope-classification.ts +++ b/packages/core/src/file-scope-classification.ts @@ -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; } diff --git a/packages/core/src/task-store/file-scope.ts b/packages/core/src/task-store/file-scope.ts index e86364f80a..6b8a0754e0 100644 --- a/packages/core/src/task-store/file-scope.ts +++ b/packages/core/src/task-store/file-scope.ts @@ -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);