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:
126
packages/core/src/__tests__/file-scope-classification.test.ts
Normal file
126
packages/core/src/__tests__/file-scope-classification.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user