feat(FN-783): complete Step 3 — shared write-scope classification
Fusion-Task-Id: FN-783 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
181
packages/core/src/file-scope-classification.ts
Normal file
181
packages/core/src/file-scope-classification.ts
Normal file
@@ -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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Settings, "experimentalFeatures"> | 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user