From 41d1d5b74bb632505238befcc9ef79ee31605397 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 02:33:18 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-783):=20complete=20Step=203=20=E2=80=94?= =?UTF-8?q?=20shared=20write-scope=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-783 Co-authored-by: Fusion --- .../core/src/file-scope-classification.ts | 181 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/store.ts | 77 +------- packages/engine/src/triage.ts | 16 +- 4 files changed, 187 insertions(+), 88 deletions(-) create mode 100644 packages/core/src/file-scope-classification.ts diff --git a/packages/core/src/file-scope-classification.ts b/packages/core/src/file-scope-classification.ts new file mode 100644 index 0000000000..adf4f8fc7d --- /dev/null +++ b/packages/core/src/file-scope-classification.ts @@ -0,0 +1,181 @@ +export type FileScopeClassificationReason = + | "included-write-scope" + | "invalid-entry" + | "duplicate-entry" + | "read-only-context" + | "forbidden-or-non-goal" + | "wrong-worktree-safeguard" + | "route-or-action" + | "fusion-metadata-evidence" + | "generated-lock" + | "conditional-changeset"; + +export interface FileScopeClassificationEntry { + token: string; + included: boolean; + reason: FileScopeClassificationReason; + line: string; +} + +export interface FileScopeClassificationResult { + entries: FileScopeClassificationEntry[]; + effectiveWriteScope: string[]; +} + +const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ + "makefile", + "dockerfile", + "justfile", + "license", + "readme", + "changelog", + "agents.md", + "project.yml", + "package.json", + "pnpm-lock.yaml", +]); + +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; +const CONDITIONAL_CONTEXT_RE = /\b(conditional|only if|if .*changes|if .*changed|expected if|required only if|unless)\b/i; +const ROUTE_OR_ACTION_RE = /^(?:\/[A-Za-z0-9:_*?./-]+|fn_task_[A-Za-z0-9_]+|review|merge|retry|archive)$/i; + +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 function extractFileScopeTokens(content: string): string[] { + const section = extractFileScopeSection(content); + if (!section) return []; + return extractBacktickedTokens(section); +} + +export function extractEffectiveWriteScopeFromPrompt(content: string): string[] { + return classifyFileScopeFromPrompt(content).effectiveWriteScope; +} + +export function classifyFileScopeFromPrompt(content: string): FileScopeClassificationResult { + const section = extractFileScopeSection(content); + if (!section) return { entries: [], effectiveWriteScope: [] }; + + const entries: FileScopeClassificationEntry[] = []; + const effectiveWriteScope: string[] = []; + const seen = new Set(); + let context: "include" | "exclude" | "conditional" = "include"; + + for (const rawLine of section.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const lower = line.toLowerCase(); + + if (INCLUDE_CONTEXT_RE.test(line) && !EXCLUDE_CONTEXT_RE.test(line) && !CONDITIONAL_CONTEXT_RE.test(line)) { + context = "include"; + } + if (EXCLUDE_CONTEXT_RE.test(line)) { + context = lower.includes("wrong-worktree") || lower.includes("wrong worktree") ? "exclude" : "exclude"; + } + if (CONDITIONAL_CONTEXT_RE.test(line)) { + context = "conditional"; + } + + const tokens = extractBacktickedTokens(line); + for (const rawToken of tokens) { + const token = rawToken.trim(); + const reason = classifyToken(token, line, context); + if (reason !== "included-write-scope") { + entries.push({ token, included: false, reason, line }); + continue; + } + if (seen.has(token)) { + entries.push({ token, included: false, reason: "duplicate-entry", line }); + continue; + } + seen.add(token); + effectiveWriteScope.push(token); + entries.push({ token, included: true, reason, line }); + } + } + + return { entries, effectiveWriteScope }; +} + +function classifyToken( + token: string, + line: string, + context: "include" | "exclude" | "conditional", +): FileScopeClassificationReason { + if (ROUTE_OR_ACTION_RE.test(token)) return "route-or-action"; + if (!isValidFileScopeEntry(token)) return "invalid-entry"; + + const lowerToken = token.toLowerCase(); + const lowerLine = line.toLowerCase(); + if (lowerToken.startsWith(".fusion/") || lowerToken === ".fusion") return "fusion-metadata-evidence"; + if (/^packages\/[^/]+\/package\.resolved$/i.test(token) || /^packages\/\*\/package\.resolved$/i.test(token)) { + return "generated-lock"; + } + if (lowerToken.startsWith(".changeset/") && (context === "conditional" || CONDITIONAL_CONTEXT_RE.test(line))) { + return "conditional-changeset"; + } + if (context === "conditional") return "read-only-context"; + if (context === "exclude") { + if (lowerLine.includes("wrong-worktree") || lowerLine.includes("wrong worktree") || lowerLine.includes("safeguard")) { + return "wrong-worktree-safeguard"; + } + if (lowerLine.includes("forbidden") || lowerLine.includes("non-goal") || lowerLine.includes("do not edit") || lowerLine.includes("do not modify")) { + return "forbidden-or-non-goal"; + } + return "read-only-context"; + } + if (GENERATED_CONTEXT_RE.test(line) && lowerToken.endsWith("package.resolved")) return "generated-lock"; + return "included-write-scope"; +} + +function extractFileScopeSection(content: string): string | null { + const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m); + if (!headingMatch) return null; + const startIdx = headingMatch.index! + headingMatch[0].length; + const rest = content.slice(startIdx); + const nextHeading = rest.search(/\n##?\s/); + return nextHeading === -1 ? rest : rest.slice(0, nextHeading); +} + +function extractBacktickedTokens(text: string): string[] { + return Array.from(text.matchAll(/`([^`]+)`/g), (match) => match[1]?.trim() ?? "").filter(Boolean); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a16c53537..398067b035 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { redactSecrets } from "./redact-secrets.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; export * from "./frontend-ux-policy.js"; +export * from "./file-scope-classification.js"; export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 64d26fca25..529f074e77 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -18,6 +18,7 @@ import { import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-ir.js"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; +import { extractEffectiveWriteScopeFromPrompt, extractFileScopeTokens, isValidFileScopeEntry } from "./file-scope-classification.js"; function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick | undefined): boolean { /* @@ -1093,80 +1094,9 @@ export class InvalidFileScopeError extends Error { } } -const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ - "makefile", - "dockerfile", - "justfile", - "license", - "readme", - "changelog", - "agents.md", -]); - -// `parseStepHeadings` (the `### Step N:` parser, step-inversion U1) was extracted -// into `step-parsers.ts` as the `step-headings` built-in parser (U12, KTD-12). -// It is re-exported here for back-compat with callers/tests that import it from -// `store.ts`. `parseStepsFromPrompt` below delegates through the registry. +export { isValidFileScopeEntry } from "./file-scope-classification.js"; export { parseStepHeadings } from "./step-parsers.js"; -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; -} - -function extractFileScopeTokens(content: string): string[] { - const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m); - if (!headingMatch) return []; - - const startIdx = headingMatch.index! + headingMatch[0].length; - const rest = content.slice(startIdx); - const nextHeading = rest.search(/\n##?\s/); - const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); - const tokens: string[] = []; - const backtickRegex = /`([^`]+)`/g; - let match; - while ((match = backtickRegex.exec(section)) !== null) { - tokens.push(match[1]); - } - - return tokens; -} - function validateFileScopeInPromptContent(prompt: string): { valid: string[]; invalid: string[] } { const tokens = extractFileScopeTokens(prompt); const valid: string[] = []; @@ -10834,8 +10764,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const content = await readFile(promptPath, "utf-8"); - const paths = extractFileScopeTokens(content); - return paths.filter((path) => isValidFileScopeEntry(path)); + return extractEffectiveWriteScopeFromPrompt(content); } private makeSyntheticDeleteRunId(taskId: string): string { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 3e7eee5d9f..ad02b27e75 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -27,6 +27,7 @@ import { findNearDuplicates, isNearDuplicateCanonicalInactive, applyFrontendUxCriteria, + extractEffectiveWriteScopeFromPrompt, MAX_TASK_LIST_TEXT_CHARS, type NearDuplicateCandidate, } from "@fusion/core"; @@ -2450,20 +2451,7 @@ export class TriageProcessor { } function parseFileScopeFromPrompt(text: string): string[] { - const match = text.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=^##\s+|$)/m); - if (!match) return []; - const entries: string[] = []; - for (const rawLine of match[1].split("\n")) { - const trimmed = rawLine.trim(); - if (!trimmed.startsWith("-")) continue; - const line = trimmed.replace(/^-+\s*/, "").replace(/`/g, "").trim(); - if (!line || /^out of scope/i.test(line)) break; - const pathOnly = line.split(" ")[0]?.trim(); - if (!pathOnly) continue; - entries.push(pathOnly); - if (entries.length >= 50) break; - } - return entries; + return extractEffectiveWriteScopeFromPrompt(text); } function extractPromptDeclaredTitle(prompt: string, taskId: string): string | null {