Clarify that legacy prerebase settings are inert on the production merge path. - Mark retained prerebase configuration and audit events as legacy-only. - Add a static validator and tests preventing new prerebase callers. - Update merge architecture, testing, and settings documentation. Files changed: AGENTS.md | 2 +- docs/architecture.md | 3 +- docs/settings-reference.md | 6 +- docs/testing.md | 2 +- package.json | 6 +- packages/core/src/types/settings/settings-scope.ts | 32 +++-- .../src/errors/transient-merge-error-classifier.ts | 12 +- packages/engine/src/merge/merger-auto-prerebase.ts | 12 +- packages/engine/src/util/run-audit.ts | 2 + scripts/__tests__/check-prerebase-inert.test.mjs | 73 +++++++++++ scripts/__tests__/run-static-gate-checks.test.mjs | 1 + scripts/__tests__/verify-fast.test.mjs | 1 + scripts/check-prerebase-inert.mjs | 146 +++++++++++++++++++++ scripts/lib/source-projection.mjs | 87 ++++++++++++ 14 files changed, 359 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-8898 Fusion-Task-Lineage: 9cfd836d-17c2-44a0-a076-56fef0917935 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
147 lines
9.7 KiB
JavaScript
147 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
FNXC:MergerUnification 2026-08-09-12:20:
|
|
Master-plan U0 makes runAiMerge the only production merge path. This tracked-file
|
|
validator protects the retained-but-inert prerebase surface from a new live caller.
|
|
Its exact self-exclusion is necessary because its regex literals are executable code;
|
|
no other script is excluded.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath, URL } from "node:url";
|
|
import { resolve } from "node:path";
|
|
import { maskSource } from "./lib/source-projection.mjs";
|
|
|
|
export const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
export const SELF_EXCLUDED_PATHS = Object.freeze(["scripts/check-prerebase-inert.mjs", "scripts/lib/source-projection.mjs"]);
|
|
export const EXEMPT_PATHS = Object.freeze(["packages/engine/src/merger.ts", "packages/engine/src/index.ts"]);
|
|
const EXCLUDED_SEGMENTS = new Set(["node_modules", "dist", ".git", ".fusion", ".worktrees", ".next", ".turbo", "coverage", "screenshots", "docs", "__tests__", "__mocks__", "__fixtures__", "__snapshots__", "fixtures", "e2e"]);
|
|
const EXTENSION = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/;
|
|
const EXCLUDED_FILE = /(?:\.(?:test|spec)\.[^.]+$|\.d\.ts$|(?:^|\.)vitest(?:\.|$)|(?:^|\.)[^/]*config\.[^.]+$)/;
|
|
const PREBASE_IDS = /\b(?:decideAutoPrerebase|probeDivergence|runAutoPrerebase)\b/;
|
|
const LEGACY = /\baiMergeTask\b/;
|
|
const SPECIFIER = String.raw`(?:[^"'\\]|\\.)*merger-auto-prerebase(?:\.js|\.ts)?`;
|
|
const MODULE_BINDING = new RegExp(String.raw`(?:\bimport\s+(?:[^"';]*?\s+from\s*)?|\bexport\s+(?:[^"';]*?\s+from\s*)?|\bimport\s*\(|\brequire\s*\()\s*(["'])${SPECIFIER}\1`, "g");
|
|
const QUOTED_ACCESS = /(?:\[\s*|\b(?:Reflect\.get|Object\.getOwnPropertyDescriptor)\s*\([^,]+,\s*)(["'])aiMergeTask\1\s*(?:\]|\))/g;
|
|
|
|
export function isCorpusPath(path) {
|
|
const segments = path.split("/");
|
|
if (SELF_EXCLUDED_PATHS.includes(path) || segments.some((segment) => EXCLUDED_SEGMENTS.has(segment))) return false;
|
|
const name = segments.at(-1) ?? "";
|
|
return EXTENSION.test(name) && !EXCLUDED_FILE.test(name);
|
|
}
|
|
export function partitionCorpus(paths) {
|
|
return { exempt: paths.filter((path) => EXEMPT_PATHS.includes(path)), general: paths.filter((path) => !EXEMPT_PATHS.includes(path)) };
|
|
}
|
|
export function detectLegacyBindings(code) {
|
|
const rules = [];
|
|
if (/\b(?:import|export)\s*\{[^}]*\baiMergeTask\b[^}]*\}/s.test(code)) rules.push("aiMergeTask binding");
|
|
if (/\{[^}]*\baiMergeTask\b(?:\s*:\s*[A-Za-z_$][\w$]*)?[^}]*\}\s*=/s.test(code)) rules.push("aiMergeTask destructuring");
|
|
if (/(?:\.|\?\.)\s*aiMergeTask\b/.test(code)) rules.push("aiMergeTask member access");
|
|
if (/\baiMergeTask\s*(?:<[^;<>()]*>)?\s*\(/s.test(code)) rules.push("aiMergeTask call");
|
|
if (LEGACY.test(code)) rules.push("aiMergeTask binding");
|
|
return [...new Set(rules)];
|
|
}
|
|
export function detectQuotedAccess(specifierProjection) {
|
|
const codeProjection = maskSource(specifierProjection, { blankStrings: true });
|
|
QUOTED_ACCESS.lastIndex = 0;
|
|
// FNXC:MergerUnification 2026-08-09-12:20: Specifier projection retains
|
|
// strings, so also require the access opener at this offset to survive in
|
|
// code. This keeps scanner data strings from masquerading as an access.
|
|
for (let match; (match = QUOTED_ACCESS.exec(specifierProjection));) {
|
|
const codeAtMatch = codeProjection.slice(match.index);
|
|
if (/^(?:\[|Reflect\.get\b|Object\.getOwnPropertyDescriptor\b)/.test(codeAtMatch)) return ["quoted aiMergeTask identifier"];
|
|
}
|
|
return [];
|
|
}
|
|
export function detectsPrerebaseSpecifier(specifierProjection) {
|
|
const codeProjection = maskSource(specifierProjection, { blankStrings: true });
|
|
MODULE_BINDING.lastIndex = 0;
|
|
// The retained-string projection alone cannot distinguish an import-shaped
|
|
// data string from real syntax. At the same offset, the binding keyword must
|
|
// survive the code-only projection.
|
|
for (let match; (match = MODULE_BINDING.exec(specifierProjection));) {
|
|
if (/^(?:import|export|require)\b/.test(codeProjection.slice(match.index))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function occurrences(code) { return [...code.matchAll(/\baiMergeTask\b/g)]; }
|
|
export function checkExemption(path, code, specifier, source = code) {
|
|
// FNXC:MergerUnification 2026-08-09-12:20: The historical merger source has
|
|
// regex syntax predating the lightweight projection. Count comment-free code
|
|
// occurrences here rather than granting either exempt file a whole-file pass.
|
|
const commentFree = source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (match) => match.replace(/[^\n]/g, " "));
|
|
const found = occurrences(commentFree);
|
|
if (path === "packages/engine/src/merger.ts") {
|
|
if (found.length !== 1) return `${path}: per-occurrence exemption expected exactly one code occurrence`;
|
|
const at = found[0].index;
|
|
return /export\s+(?:async\s+)?function\s+aiMergeTask\s*(?:<[^>]*>)?\s*\(/.test(commentFree.slice(Math.max(0, at - 40), at + 160))
|
|
? null : `${path}: per-occurrence exemption expected declaration-shaped occurrence on code-only projection`;
|
|
}
|
|
if (path === "packages/engine/src/index.ts") {
|
|
if (found.length !== 1) return `${path}: per-occurrence exemption expected exactly one code occurrence`;
|
|
const at = found[0].index;
|
|
const window = specifier.slice(Math.max(0, at - 80), at + 240);
|
|
return /export\s*\{[\s\S]*\baiMergeTask\b[\s\S]*\}\s*from\s*["'][^"']*\/merger\.js["']/.test(window)
|
|
? null : `${path}: per-occurrence exemption re-export shape mismatch (specifier-preserving projection)`;
|
|
}
|
|
return `${path}: unknown exemption path`;
|
|
}
|
|
export function listAllTrackedFiles(root = repoRoot) {
|
|
const result = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
if (result.status !== 0) throw new Error(result.stderr?.trim() || "git ls-files failed");
|
|
return result.stdout.split("\n").filter(Boolean);
|
|
}
|
|
export function listTrackedFiles(root = repoRoot) { return listAllTrackedFiles(root).filter(isCorpusPath); }
|
|
/** Compute the required source-root set difference; generic segments must not hide source. */
|
|
export function protectedCorpusHoles(allPaths = listAllTrackedFiles(), corpus = listTrackedFiles()) {
|
|
const protectedPath = /^(?:packages\/[^/]+\/src\/|packages\/dashboard\/app\/|plugins\/[^/]+\/src\/)/;
|
|
const scanned = new Set(corpus);
|
|
return allPaths.filter((path) => protectedPath.test(path) && isCorpusPath(path) && !scanned.has(path));
|
|
}
|
|
export function checkDocs(root = repoRoot) {
|
|
const content = readFileSync(resolve(root, "docs/settings-reference.md"), "utf8");
|
|
return ["prerebaseAutoEnabled", "prerebaseHotFiles", "prerebaseDivergenceThreshold"].flatMap((setting) => {
|
|
const row = content.split("\n").find((line) => line.startsWith(`| \`${setting}\` |`));
|
|
if (!row) return [`docs contract: missing ${setting} row`];
|
|
const failures = [];
|
|
if (row.includes("Stage 1/2")) failures.push("stale Stage 1/2 wording");
|
|
if (!row.includes("**Legacy and inert (master-plan U0)**")) failures.push("missing inert marker");
|
|
if (!row.includes("runAiMerge")) failures.push("missing runAiMerge");
|
|
return failures.map((failure) => `docs contract: ${setting}: ${failure}`);
|
|
});
|
|
}
|
|
export function scanSources(paths = listTrackedFiles(), root = repoRoot) {
|
|
const failures = [], identifierPaths = new Set(), specifierPaths = new Set();
|
|
const holes = protectedCorpusHoles(undefined, paths);
|
|
if (holes.length) failures.push(`corpus no-hole: excluded protected sources ${holes.join(", ")}`);
|
|
const { exempt, general } = partitionCorpus(paths);
|
|
if (exempt.length !== EXEMPT_PATHS.length || new Set([...exempt, ...general]).size !== paths.length) failures.push("layer routing: exempt/general corpus partition mismatch");
|
|
for (const path of paths) {
|
|
const source = readFileSync(resolve(root, path), "utf8");
|
|
const hasToken = /decideAutoPrerebase|probeDivergence|runAutoPrerebase|aiMergeTask|merger-auto-prerebase/.test(source);
|
|
const mustCheckExemption = EXEMPT_PATHS.includes(path);
|
|
if (!hasToken && !mustCheckExemption) continue;
|
|
const code = maskSource(source, { blankStrings: true });
|
|
const specifier = maskSource(source, { blankStrings: false });
|
|
if (PREBASE_IDS.test(code)) identifierPaths.add(path);
|
|
if (detectsPrerebaseSpecifier(specifier)) specifierPaths.add(path);
|
|
if (mustCheckExemption) {
|
|
const failure = checkExemption(path, code, specifier, source); if (failure) failures.push(failure);
|
|
} else {
|
|
for (const rule of detectLegacyBindings(code)) failures.push(`${path}: ${rule}`);
|
|
for (const rule of detectQuotedAccess(specifier)) failures.push(`${path}: ${rule}`);
|
|
}
|
|
}
|
|
const expectedIds = ["packages/engine/src/merge/merger-auto-prerebase.ts", "packages/engine/src/merger.ts"];
|
|
const expectedSpecifiers = ["packages/engine/src/merger.ts"];
|
|
if (JSON.stringify([...identifierPaths].sort()) !== JSON.stringify(expectedIds)) failures.push(`identifier reachability: expected ${expectedIds.join(", ")}, got ${[...identifierPaths].sort().join(", ")}`);
|
|
if (JSON.stringify([...specifierPaths].sort()) !== JSON.stringify(expectedSpecifiers)) failures.push(`module specifier: expected ${expectedSpecifiers.join(", ")}, got ${[...specifierPaths].sort().join(", ")}`);
|
|
return failures;
|
|
}
|
|
export function formatFailureMessage(matches) {
|
|
return ["[check-prerebase-inert] retained legacy prerebase contract violated.", "Re-wiring prerebase or aiMergeTask by identifier, import, alias, or package path requires updating docs/settings-reference.md, AGENTS.md item 10, and docs/architecture.md.", ...matches].join("\n");
|
|
}
|
|
export function main() { const failures = [...checkDocs(), ...scanSources()]; if (!failures.length) return 0; console.error(formatFailureMessage(failures)); return 1; }
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();
|