Files
tutti/tools/scripts/check-agent-gui-degradation.mjs
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1143 lines
34 KiB
JavaScript

import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync
} from "node:fs";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
analyzeAgentGuiPresentationCss,
countInlineCompositorHints,
countPresentationSchedulers,
hasPresentationWorkReason,
isInlineCompositorHintLine,
isPresentationSchedulerLine,
validatePresentationHintReasons
} from "./agent-gui-presentation-performance-policy.mjs";
// Degradation ratchet for the agent GUI refactor
// (docs/architecture/agent-gui-refactor-plan.md, sections 4.1 step 0 and 5.2).
//
// Modes:
// - default: measure metrics and compare against the committed baseline.
// Any metric increase fails. Any decrease also fails until the baseline is
// updated in the same change, so refactor wins stay locked.
// - --update-baseline: rewrite the baseline JSON from current measurements.
// - --staged: incremental checks on staged added lines only (pre-commit).
//
// The metric definitions in this script are the authoritative counting rules;
// numbers quoted in architecture documents are illustrative only.
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const defaultWorkspaceRoot = join(scriptDirectory, "..", "..");
export const scanRootPrefixes = [
"packages/agent/gui/",
"packages/agent/activity-core/"
];
const renderBoundaryFiles = [
"apps/desktop/src/renderer/src/features/workspace-agent/ui/DesktopAgentGUIWorkbenchBody.tsx"
];
const stagedScanPrefixes = [...scanRootPrefixes, ...renderBoundaryFiles];
const goExemptionRootPrefix = "packages/agent/daemon/";
export const businessFileLineLimit = 800;
export const componentMemoLimit = 5;
export const knownProviderIds = [
"claude-code",
"codex",
"cursor",
"nexight",
"openclaw",
"opencode",
"tutti-agent"
];
// Files allowed to call useSyncExternalStore directly. Once the engine binding
// (useEngineSelector) lands, it becomes the only entry here.
export const subscriptionBindingFilePatterns = [
/packages\/agent\/(?:gui|activity-core)\/.*useEngineSelector\.tsx?$/
];
// Timer-free zones: engine, reducer, and selector code must express timing as
// state-machine transitions instead of setTimeout/setInterval.
export const timerForbiddenPathPatterns = [
/packages\/agent\/activity-core\/src\/engine\//,
/\.reducer\.tsx?$/,
/(?:^|\/)selectors?\.tsx?$/
];
const ignoredPathFragments = [
"/.turbo/",
"/dist/",
"/generated/",
"/node_modules/",
"/out/"
];
const ignoredFilenamePatterns = [
/\.d\.ts$/,
/\.spec\.[cm]?[tj]sx?$/,
/\.test\.[cm]?[tj]sx?$/,
/vitest\./
];
const providerLiteralComparisonPattern = () =>
/\bprovider\s*(?:===|!==)\s*["'`]/g;
const providerCaseClausePattern = () =>
new RegExp(`\\bcase\\s+["'\`](?:${knownProviderIds.join("|")})["'\`]`, "g");
const timerCallPattern = () => /\bset(?:Timeout|Interval)\s*\(/g;
const timerReasonCommentPattern = /(?:\/\/|\/\*|\*)\s*timing:/;
const useSyncExternalStorePattern = () => /\buseSyncExternalStore\b/g;
const moduleMutableGlobalPattern = () => /^(?:export\s+)?(?:let|var)\s/gm;
const renderMirrorRefNamePattern =
/(?:\w*(?:Projection|Cache|Props|WorkspaceId|Locked)Ref|(?!drag)\w*StateRef|handle(?!d)\w+Ref)/i;
export function isScannedSourceFile(relativePath) {
if (!/\.[cm]?[tj]sx?$/.test(relativePath)) {
return false;
}
const normalized = `/${relativePath}`;
if (ignoredPathFragments.some((fragment) => normalized.includes(fragment))) {
return false;
}
return !ignoredFilenamePatterns.some((pattern) => pattern.test(relativePath));
}
export function isScannedPresentationCssFile(relativePath) {
return (
relativePath.startsWith("packages/agent/gui/") &&
relativePath.endsWith(".css")
);
}
export function isInScanRoots(relativePath) {
return scanRootPrefixes.some((prefix) => relativePath.startsWith(prefix));
}
export function isComponentModule(relativePath) {
return (
relativePath.endsWith(".tsx") &&
!/(?:^|\/)use[A-Z][^/]*\.tsx$/.test(relativePath)
);
}
function isInStagedScanRoots(relativePath) {
return stagedScanPrefixes.some((prefix) => relativePath.startsWith(prefix));
}
export function isSubscriptionBindingFile(relativePath) {
return subscriptionBindingFilePatterns.some((pattern) =>
pattern.test(relativePath)
);
}
export function isTimerForbiddenFile(relativePath) {
return timerForbiddenPathPatterns.some((pattern) =>
pattern.test(relativePath)
);
}
export function countMatches(source, pattern) {
let count = 0;
for (const _match of source.matchAll(pattern)) {
count += 1;
}
return count;
}
export function countLines(source) {
if (source.length === 0) {
return 0;
}
const newlineCount = countMatches(source, /\n/g);
return source.endsWith("\n") ? newlineCount : newlineCount + 1;
}
export function countEffects(source) {
return countMatches(source, /\buse(?:Layout)?Effect\s*\(/g);
}
export function countMemoization(source) {
return countMatches(source, /\buse(?:Memo|Callback)\s*\(|\bmemo\s*\(/g);
}
export function countRenderMirrorRefs(source) {
const namedMirrorCount = countMatches(
source,
new RegExp(
`\\b(?:const|let)\\s+${renderMirrorRefNamePattern.source}\\b[\\s\\S]{0,160}?\\buseRef\\b`,
"gi"
)
);
const wholeInputMirrorCount = countMatches(
source,
/\b(?:const|let)\s+inputRef\s*=\s*useRef\s*\(\s*input\s*\)/g
);
return namedMirrorCount + wholeInputMirrorCount;
}
export function countProviderBranches(source) {
return (
countMatches(source, providerLiteralComparisonPattern()) +
countMatches(source, providerCaseClausePattern())
);
}
export function countTimerCalls(source) {
return countMatches(source, timerCallPattern());
}
export function importsValtio(source) {
return /from\s+["']valtio(?:\/|["'])/.test(source);
}
export function importsZustand(source) {
return /from\s+["']zustand(?:\/|["'])/.test(source);
}
export function countStoreCreations(source) {
let count = 0;
if (importsValtio(source)) {
count += countMatches(source, /\bproxy(?:Map|Set)?\s*[<(]/g);
}
if (importsZustand(source)) {
count += countMatches(source, /\bcreate(?:Store)?\s*[<(]/g);
}
return count;
}
export function countModuleMutableGlobals(source) {
return countMatches(source, moduleMutableGlobalPattern());
}
export function findSwallowedCatchRanges(source) {
const ranges = [];
for (const catchIndex of findCatchKeywordIndexes(source)) {
const range = readCatchBlockRange(source, catchIndex);
if (!range) {
continue;
}
const body = source.slice(range.bodyStart + 1, range.bodyEnd);
if (stripCommentsAndWhitespace(body).length === 0) {
ranges.push({
endLine: lineNumberAt(source, range.bodyEnd),
startLine: lineNumberAt(source, catchIndex)
});
}
}
return ranges;
}
function findCatchKeywordIndexes(source) {
const indexes = [];
let cursor = 0;
while (cursor < source.length) {
const character = source[cursor];
if (character === '"' || character === "'" || character === "`") {
cursor = skipStringLiteral(source, cursor);
continue;
}
if (character === "/" && source[cursor + 1] === "/") {
const lineEnd = source.indexOf("\n", cursor);
cursor = lineEnd === -1 ? source.length : lineEnd + 1;
continue;
}
if (character === "/" && source[cursor + 1] === "*") {
const commentEnd = source.indexOf("*/", cursor + 2);
cursor = commentEnd === -1 ? source.length : commentEnd + 2;
continue;
}
if (
character === "c" &&
source.startsWith("catch", cursor) &&
!/[\w$]/.test(source[cursor - 1] ?? "") &&
!/[\w$]/.test(source[cursor + 5] ?? "")
) {
indexes.push(cursor);
cursor += 5;
continue;
}
cursor += 1;
}
return indexes;
}
export function countSwallowedCatches(source) {
return findSwallowedCatchRanges(source).length;
}
function readCatchBlockRange(source, catchIndex) {
let cursor = catchIndex + "catch".length;
cursor = skipWhitespaceAndComments(source, cursor);
if (source[cursor] === "(") {
const closeParen = findMatchingDelimiter(source, cursor, "(", ")");
if (closeParen === -1) {
return null;
}
cursor = skipWhitespaceAndComments(source, closeParen + 1);
}
if (source[cursor] !== "{") {
return null;
}
const bodyEnd = findMatchingDelimiter(source, cursor, "{", "}");
if (bodyEnd === -1) {
return null;
}
return { bodyEnd, bodyStart: cursor };
}
function skipWhitespaceAndComments(source, startIndex) {
let cursor = startIndex;
while (cursor < source.length) {
const character = source[cursor];
if (/\s/.test(character)) {
cursor += 1;
continue;
}
if (character === "/" && source[cursor + 1] === "/") {
const lineEnd = source.indexOf("\n", cursor);
cursor = lineEnd === -1 ? source.length : lineEnd + 1;
continue;
}
if (character === "/" && source[cursor + 1] === "*") {
const commentEnd = source.indexOf("*/", cursor + 2);
cursor = commentEnd === -1 ? source.length : commentEnd + 2;
continue;
}
break;
}
return cursor;
}
function findMatchingDelimiter(source, openIndex, openChar, closeChar) {
let depth = 0;
let cursor = openIndex;
while (cursor < source.length) {
const character = source[cursor];
if (character === '"' || character === "'" || character === "`") {
cursor = skipStringLiteral(source, cursor);
continue;
}
if (character === "/" && source[cursor + 1] === "/") {
const lineEnd = source.indexOf("\n", cursor);
cursor = lineEnd === -1 ? source.length : lineEnd + 1;
continue;
}
if (character === "/" && source[cursor + 1] === "*") {
const commentEnd = source.indexOf("*/", cursor + 2);
cursor = commentEnd === -1 ? source.length : commentEnd + 2;
continue;
}
if (character === openChar) {
depth += 1;
} else if (character === closeChar) {
depth -= 1;
if (depth === 0) {
return cursor;
}
}
cursor += 1;
}
return -1;
}
function skipStringLiteral(source, quoteIndex) {
const quote = source[quoteIndex];
let cursor = quoteIndex + 1;
while (cursor < source.length) {
const character = source[cursor];
if (character === "\\") {
cursor += 2;
continue;
}
if (character === quote) {
return cursor + 1;
}
if (quote !== "`" && character === "\n") {
return cursor + 1;
}
cursor += 1;
}
return cursor;
}
function stripCommentsAndWhitespace(body) {
return body
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "")
.replace(/\s+/g, "");
}
function lineNumberAt(source, index) {
return source.slice(0, index).split("\n").length;
}
export function measureFileMetrics(relativePath, source, identityExemptFiles) {
const providerBranches = countProviderBranches(source);
const isIdentityExempt = identityExemptFiles.includes(relativePath);
return {
effectCount: countEffects(source),
identityProviderBranches: isIdentityExempt ? providerBranches : 0,
lineCount: countLines(source),
memoCount: countMemoization(source),
moduleMutableGlobals: countModuleMutableGlobals(source),
inlineCompositorHints: countInlineCompositorHints(source),
presentationSchedulerCalls: countPresentationSchedulers(source),
providerBranches: isIdentityExempt ? 0 : providerBranches,
renderMirrorRefs: countRenderMirrorRefs(source),
setTimeoutCount: countTimerCalls(source),
storeCreations: countStoreCreations(source),
swallowedCatches: countSwallowedCatches(source),
useSyncExternalStoreCount: isSubscriptionBindingFile(relativePath)
? 0
: countMatches(source, useSyncExternalStorePattern())
};
}
export function aggregateMetrics(fileMetricsByPath) {
const metrics = {
componentMemoOverages: {},
effectCount: 0,
fileLines: {},
goFileLengthExemptions: 0,
identityProviderBranches: 0,
inlineCompositorHints: {},
moduleMutableGlobals: 0,
overlayStores: 0,
providerBranches: 0,
presentationHints: {},
presentationSchedulerCalls: {},
renderMirrorRefs: 0,
setTimeoutCount: 0,
swallowedCatch: 0,
useSyncExternalStoreCount: 0
};
const sortedPaths = Array.from(Object.keys(fileMetricsByPath)).sort();
for (const path of sortedPaths) {
const file = fileMetricsByPath[path];
if (file.lineCount > businessFileLineLimit) {
metrics.fileLines[path] = file.lineCount;
}
if (isComponentModule(path) && file.memoCount > componentMemoLimit) {
metrics.componentMemoOverages[path] = file.memoCount;
}
// Effects move with a vertical module when a monolith is decomposed, so
// their package-wide total remains neutral. Memoization is different:
// only component modules have a budget; read/controller hooks may own
// stable projections without consuming the view budget.
metrics.effectCount += file.effectCount;
metrics.identityProviderBranches += file.identityProviderBranches;
if (file.inlineCompositorHints > 0) {
metrics.inlineCompositorHints[path] = file.inlineCompositorHints;
}
metrics.moduleMutableGlobals += file.moduleMutableGlobals;
metrics.overlayStores += file.storeCreations;
metrics.providerBranches += file.providerBranches;
if (file.presentationSchedulerCalls > 0) {
metrics.presentationSchedulerCalls[path] =
file.presentationSchedulerCalls;
}
metrics.renderMirrorRefs += file.renderMirrorRefs;
metrics.setTimeoutCount += file.setTimeoutCount;
metrics.swallowedCatch += file.swallowedCatches;
metrics.useSyncExternalStoreCount += file.useSyncExternalStoreCount;
}
return metrics;
}
export function compareWithBaseline(currentMetrics, baselineMetrics) {
const regressions = [];
const improvements = [];
for (const key of Object.keys(currentMetrics)) {
const current = currentMetrics[key];
const baseline = baselineMetrics[key];
if (typeof current === "number") {
const baselineValue = typeof baseline === "number" ? baseline : 0;
if (current > baselineValue) {
regressions.push(
`${key}: ${baselineValue} -> ${current} (must not increase)`
);
} else if (current < baselineValue) {
improvements.push(`${key}: ${baselineValue} -> ${current}`);
}
continue;
}
const baselineMap =
baseline && typeof baseline === "object" ? baseline : {};
for (const [path, value] of Object.entries(current)) {
const baselineValue = baselineMap[path];
if (typeof baselineValue !== "number") {
if (key === "fileLines") {
regressions.push(
`${key}: ${path} has ${value} lines and is not in the baseline (limit ${businessFileLineLimit})`
);
} else {
regressions.push(`${key}: ${path} 0 -> ${value} (must not increase)`);
}
continue;
}
if (value > baselineValue) {
regressions.push(
`${key}: ${path} ${baselineValue} -> ${value} (must not increase)`
);
} else if (value < baselineValue) {
improvements.push(`${key}: ${path} ${baselineValue} -> ${value}`);
}
}
for (const [path, baselineValue] of Object.entries(baselineMap)) {
if (!(path in current)) {
improvements.push(`${key}: ${path} ${baselineValue} -> gone`);
}
}
}
return { improvements, regressions };
}
export function parseStagedAddedLines(diffOutput) {
const addedLinesByFile = new Map();
let currentFile = null;
let nextAddedLine = null;
for (const line of diffOutput.split("\n")) {
if (line.startsWith("+++ ")) {
const path = line.slice(4).trim();
currentFile = path.startsWith("b/") ? path.slice(2) : path;
if (currentFile === "/dev/null") {
currentFile = null;
}
continue;
}
const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
if (hunkMatch) {
nextAddedLine = Number(hunkMatch[1]);
continue;
}
if (currentFile === null || nextAddedLine === null) {
continue;
}
if (line.startsWith("+")) {
if (!addedLinesByFile.has(currentFile)) {
addedLinesByFile.set(currentFile, []);
}
addedLinesByFile.get(currentFile).push({
content: line.slice(1),
line: nextAddedLine
});
nextAddedLine += 1;
} else if (!line.startsWith("-") && !line.startsWith("\\")) {
nextAddedLine += 1;
}
}
return addedLinesByFile;
}
export function checkStagedFile({
relativePath,
stagedContent,
addedLines,
identityExemptFiles
}) {
const violations = [];
const contentLines = stagedContent.split("\n");
const swallowedRanges = findSwallowedCatchRanges(stagedContent);
const timerForbidden = isTimerForbiddenFile(relativePath);
const isComponentFile = isComponentModule(relativePath);
const identityExempt = identityExemptFiles.includes(relativePath);
const bindingFile = isSubscriptionBindingFile(relativePath);
const usesValtio = importsValtio(stagedContent);
const usesZustand = importsZustand(stagedContent);
for (const added of addedLines) {
const { content, line } = added;
if (
new RegExp(
`\\b(?:const|let)\\s+${renderMirrorRefNamePattern.source}\\b`,
"i"
).test(content) ||
/\b(?:const|let)\s+inputRef\s*=\s*useRef\s*\(\s*input\s*\)/.test(content)
) {
violations.push({
line,
message:
"new render-time ref mirrors/caches are forbidden; move business state to the engine/controller, stabilize projections in selectors/read hooks, and reserve refs for imperative external lifecycles",
rule: "no-render-ref-mirror"
});
}
if (
isComponentFile &&
countMemoization(stagedContent) > componentMemoLimit &&
/\buse(?:Memo|Callback)\s*\(|\bmemo\s*\(/.test(content)
) {
violations.push({
line,
message: `component memoization budget is ${componentMemoLimit}; move stable projections to engine selectors/read hooks instead of adding leaf caches`,
rule: "component-memo-budget"
});
}
if (timerCallPattern().test(content)) {
if (timerForbidden) {
violations.push({
line,
message:
"timers are forbidden in engine/reducer/selector code; model timing as an expiry intent instead",
rule: "no-sync-timer"
});
} else if (!hasTimerReasonComment(contentLines, line)) {
violations.push({
line,
message:
"new timers must carry a `// timing: <reason>` comment on the same or previous line",
rule: "no-sync-timer"
});
}
}
if (
isPresentationSchedulerLine(content) &&
!hasPresentationWorkReason(contentLines, line)
) {
violations.push({
line,
message:
"new requestAnimationFrame/requestIdleCallback/ResizeObserver work must carry a `// presentation-work: <visible/active/lifetime reason>` comment on the same or previous line",
rule: "presentation-scheduler-lifecycle"
});
}
if (
isInlineCompositorHintLine(content) &&
!hasPresentationWorkReason(contentLines, line)
) {
violations.push({
line,
message:
"new inline willChange/translateZ/translate3d hints must carry a `// presentation-work: <bounded lifetime reason>` comment on the same or previous line",
rule: "inline-compositor-lifecycle"
});
}
if (
!identityExempt &&
(providerLiteralComparisonPattern().test(content) ||
providerCaseClausePattern().test(content))
) {
violations.push({
line,
message:
"new provider behavior branches are frozen; consume daemon capability/catalog contracts instead (identity-display files are exempt via the baseline list)",
rule: "provider-branch-freeze"
});
}
if (
isComponentFile &&
((usesValtio && /\bproxy(?:Map|Set)?\s*[<(]/.test(content)) ||
(usesZustand && /\bcreate(?:Store)?\s*[<(]/.test(content)))
) {
violations.push({
line,
message:
"component files must not create valtio/zustand stores; shared state belongs to the workspace engine",
rule: "no-store-in-view"
});
}
if (!bindingFile && useSyncExternalStorePattern().test(content)) {
violations.push({
line,
message:
"components must not call useSyncExternalStore directly; subscribe through the single engine binding (useEngineSelector)",
rule: "single-subscription-binding"
});
}
// Column-0 anchoring keeps function-local declarations out of scope.
if (/^(?:export\s+)?(?:let|var)\s/.test(content)) {
violations.push({
line,
message:
"new module-level mutable globals are forbidden in published agent packages; inject instances explicitly",
rule: "no-module-mutable-global"
});
}
}
const addedLineNumbers = new Set(addedLines.map((added) => added.line));
for (const range of swallowedRanges) {
for (let line = range.startLine; line <= range.endLine; line += 1) {
if (addedLineNumbers.has(line)) {
violations.push({
line: range.startLine,
message:
"catch blocks must report diagnostics or rethrow; silently swallowed errors are forbidden",
rule: "no-swallowed-catch"
});
break;
}
}
}
return violations;
}
export function checkStagedPresentationCssFile({
relativePath,
stagedContent,
addedLines,
presentationHintReasons
}) {
const report = analyzeAgentGuiPresentationCss(relativePath, stagedContent);
const addedLineNumbers = new Set(addedLines.map((added) => added.line));
const violations = report.violations
.filter(({ line }) => addedLineNumbers.has(line))
.map((violation) => ({ ...violation }));
for (const hint of report.hints) {
if (
!addedLineNumbers.has(hint.line) ||
presentationHintReasons[hint.fingerprint]?.trim()
) {
continue;
}
violations.push({
line: hint.line,
message:
"new persistent compositor hints and infinite animations require an exact `presentationHintReasons` entry in tools/degradation-baseline/agent-gui.json",
rule: "presentation-hint-lifecycle"
});
}
for (const required of report.missingRequiredDeclarations) {
violations.push({
line: 1,
message: `required AgentGUI pruning declaration is missing: ${required.selector} { ${required.property}: ${required.value}; }`,
rule: "presentation-pruning-contract"
});
}
return violations;
}
function hasTimerReasonComment(contentLines, lineNumber) {
const line = contentLines[lineNumber - 1] ?? "";
const previousLine = contentLines[lineNumber - 2] ?? "";
return (
timerReasonCommentPattern.test(line) ||
timerReasonCommentPattern.test(previousLine)
);
}
export function collectMetrics({ workspaceRoot, identityExemptFiles }) {
const fileMetricsByPath = {};
const presentationHints = {};
for (const rootPrefix of scanRootPrefixes) {
const absoluteRoot = join(workspaceRoot, rootPrefix);
if (!existsSync(absoluteRoot)) {
continue;
}
for (const filePath of walk(absoluteRoot)) {
const relativePath = toPosixPath(relative(workspaceRoot, filePath));
if (isScannedPresentationCssFile(relativePath)) {
const source = readFileSync(filePath, "utf8");
for (const hint of analyzeAgentGuiPresentationCss(relativePath, source)
.hints) {
presentationHints[hint.fingerprint] =
(presentationHints[hint.fingerprint] ?? 0) + 1;
}
continue;
}
if (!isScannedSourceFile(relativePath)) {
continue;
}
const source = readFileSync(filePath, "utf8");
fileMetricsByPath[relativePath] = measureFileMetrics(
relativePath,
source,
identityExemptFiles
);
}
}
const metrics = aggregateMetrics(fileMetricsByPath);
metrics.presentationHints = presentationHints;
for (const relativePath of renderBoundaryFiles) {
const absolutePath = join(workspaceRoot, relativePath);
if (!existsSync(absolutePath)) continue;
metrics.renderMirrorRefs += countRenderMirrorRefs(
readFileSync(absolutePath, "utf8")
);
}
metrics.goFileLengthExemptions = countGoFileLengthExemptions(workspaceRoot);
return metrics;
}
function collectPresentationCssViolations(workspaceRoot) {
const violations = [];
for (const rootPrefix of scanRootPrefixes) {
const absoluteRoot = join(workspaceRoot, rootPrefix);
if (!existsSync(absoluteRoot)) {
continue;
}
for (const filePath of walk(absoluteRoot)) {
const relativePath = toPosixPath(relative(workspaceRoot, filePath));
if (!isScannedPresentationCssFile(relativePath)) {
continue;
}
const report = analyzeAgentGuiPresentationCss(
relativePath,
readFileSync(filePath, "utf8")
);
for (const violation of report.violations) {
violations.push({ file: relativePath, ...violation });
}
for (const required of report.missingRequiredDeclarations) {
violations.push({
file: relativePath,
line: 1,
message: `required AgentGUI pruning declaration is missing: ${required.selector} { ${required.property}: ${required.value}; }`,
rule: "presentation-pruning-contract"
});
}
}
}
return violations;
}
function countGoFileLengthExemptions(workspaceRoot) {
const absoluteRoot = join(workspaceRoot, goExemptionRootPrefix);
if (!existsSync(absoluteRoot)) {
return 0;
}
let count = 0;
for (const filePath of walk(absoluteRoot)) {
if (!filePath.endsWith(".go")) {
continue;
}
const source = readFileSync(filePath, "utf8");
count += countMatches(source, /revive:disable:file-length-limit/g);
}
return count;
}
function* walk(directory) {
for (const entry of readdirSync(directory)) {
if (entry === "node_modules" || entry === "dist" || entry === ".turbo") {
continue;
}
const filePath = join(directory, entry);
const stat = statSync(filePath);
if (stat.isDirectory()) {
yield* walk(filePath);
continue;
}
yield filePath;
}
}
function toPosixPath(path) {
return path.split(sep).join("/");
}
export function readBaseline(baselinePath) {
if (!existsSync(baselinePath)) {
return null;
}
return JSON.parse(readFileSync(baselinePath, "utf8"));
}
export function findStaleIdentityExemptFiles(
workspaceRoot,
identityExemptFiles
) {
return identityExemptFiles.filter(
(relativePath) => !existsSync(join(workspaceRoot, relativePath))
);
}
function reportStaleIdentityExemptFiles(workspaceRoot, identityExemptFiles) {
const staleFiles = findStaleIdentityExemptFiles(
workspaceRoot,
identityExemptFiles
);
if (staleFiles.length === 0) {
return false;
}
console.error(
"agent-gui degradation baseline has stale identity exemptions:"
);
for (const file of staleFiles) {
console.error(`- ${file}`);
}
console.error("\nRemove exemptions when their files are removed or renamed.");
return true;
}
function writeBaseline(baselinePath, baseline) {
mkdirSync(dirname(baselinePath), { recursive: true });
writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
}
function runFullMode({ workspaceRoot, baselinePath, updateBaseline }) {
const existingBaseline = readBaseline(baselinePath);
const identityExemptFiles = existingBaseline?.identityExemptFiles ?? [];
if (reportStaleIdentityExemptFiles(workspaceRoot, identityExemptFiles)) {
return 1;
}
if (!existingBaseline && !updateBaseline) {
console.error(
"agent-gui degradation baseline is missing. Generate it with:\n" +
" node tools/scripts/check-agent-gui-degradation.mjs --update-baseline"
);
return 1;
}
const metrics = collectMetrics({ identityExemptFiles, workspaceRoot });
const cssViolations = collectPresentationCssViolations(workspaceRoot);
if (cssViolations.length > 0) {
console.error("agent-gui presentation CSS policy failed:");
for (const violation of cssViolations) {
console.error(
`- [${violation.rule}] ${violation.file}:${violation.line} ${violation.message}`
);
}
return 1;
}
const presentationHintReasons =
existingBaseline?.presentationHintReasons ?? {};
const hintReasonState = validatePresentationHintReasons(
Object.keys(metrics.presentationHints).map((fingerprint) => ({
fingerprint
})),
presentationHintReasons
);
if (hintReasonState.missing.length > 0 || hintReasonState.stale.length > 0) {
console.error(
"agent-gui presentation hint lifecycle reasons are out of date:"
);
for (const fingerprint of hintReasonState.missing) {
console.error(`- missing reason: ${fingerprint}`);
}
for (const fingerprint of hintReasonState.stale) {
console.error(`- stale reason: ${fingerprint}`);
}
console.error(
"\nAdd or remove exact `presentationHintReasons` entries in tools/degradation-baseline/agent-gui.json, then update the baseline."
);
return 1;
}
if (updateBaseline) {
writeBaseline(baselinePath, {
identityExemptFiles,
presentationHintReasons,
metrics
});
console.log(
`agent-gui degradation baseline written to ${toPosixPath(
relative(workspaceRoot, baselinePath)
)}`
);
return 0;
}
const { improvements, regressions } = compareWithBaseline(
metrics,
existingBaseline.metrics ?? {}
);
if (regressions.length > 0) {
console.error("agent-gui degradation check failed (metrics increased):");
for (const regression of regressions) {
console.error(`- ${regression}`);
}
console.error(
"\nFix the regression at its source instead of raising the baseline." +
"\nFor render churn: move business state to the engine/controller, keep stable projections in selectors/read hooks, and do not hide unstable inputs behind component refs or leaf memoization." +
"\nSee docs/architecture/agent-gui-refactor-plan.md section 5.2."
);
return 1;
}
if (improvements.length > 0) {
console.error(
"agent-gui degradation metrics improved; lock the win by updating the baseline in this change:"
);
for (const improvement of improvements) {
console.error(`- ${improvement}`);
}
console.error(
"\nRun: node tools/scripts/check-agent-gui-degradation.mjs --update-baseline"
);
return 1;
}
console.log("agent-gui degradation check passed");
return 0;
}
function runStagedMode({ workspaceRoot, baselinePath }) {
const existingBaseline = readStagedBaseline(workspaceRoot, baselinePath);
const identityExemptFiles = existingBaseline?.identityExemptFiles ?? [];
const presentationHintReasons =
existingBaseline?.presentationHintReasons ?? {};
if (reportStaleIdentityExemptFiles(workspaceRoot, identityExemptFiles)) {
return 1;
}
const mergeHead = readMergeHead(workspaceRoot);
const diffOutput = execFileSync("git", stagedDiffArgs(mergeHead), {
cwd: workspaceRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024
});
const addedLinesByFile = parseStagedAddedLines(diffOutput);
const violations = [];
for (const [relativePath, addedLines] of addedLinesByFile) {
if (
!isScannedSourceFile(relativePath) &&
!isScannedPresentationCssFile(relativePath)
) {
continue;
}
if (!isInStagedScanRoots(relativePath)) {
continue;
}
let stagedContent;
try {
stagedContent = execFileSync("git", ["show", `:${relativePath}`], {
cwd: workspaceRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024
});
} catch {
continue;
}
if (isScannedPresentationCssFile(relativePath)) {
for (const violation of checkStagedPresentationCssFile({
addedLines,
presentationHintReasons,
relativePath,
stagedContent
})) {
violations.push({ file: relativePath, ...violation });
}
continue;
}
for (const violation of checkStagedFile({
addedLines,
identityExemptFiles,
relativePath,
stagedContent
})) {
violations.push({ file: relativePath, ...violation });
}
}
if (violations.length > 0) {
console.error("agent-gui degradation staged check failed:");
for (const violation of violations) {
console.error(
`- [${violation.rule}] ${violation.file}:${violation.line} ${violation.message}`
);
}
console.error(
"\nSee docs/architecture/agent-gui-refactor-plan.md sections 5.2 and 5.3."
);
return 1;
}
console.log("agent-gui degradation staged check passed");
return 0;
}
function readStagedBaseline(workspaceRoot, baselinePath) {
const relativePath = toPosixPath(relative(workspaceRoot, baselinePath));
if (relativePath === ".." || relativePath.startsWith("../")) {
return readBaseline(baselinePath);
}
try {
return JSON.parse(
execFileSync("git", ["show", `:${relativePath}`], {
cwd: workspaceRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"]
})
);
} catch {
return readBaseline(baselinePath);
}
}
export function stagedDiffArgs(mergeHead = null) {
return [
"diff",
"--cached",
...(mergeHead ? [mergeHead] : []),
"-U0",
"--no-color",
"--diff-filter=ACMR",
"--",
...stagedScanPrefixes
];
}
function readMergeHead(workspaceRoot) {
try {
return execFileSync("git", ["rev-parse", "--verify", "MERGE_HEAD"], {
cwd: workspaceRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return null;
}
}
function main() {
const workspaceRoot =
process.env.TUTTI_WORKSPACE_ROOT ?? defaultWorkspaceRoot;
const baselinePath =
process.env.TUTTI_AGENT_GUI_DEGRADATION_BASELINE ??
join(workspaceRoot, "tools/degradation-baseline/agent-gui.json");
const staged = process.argv.includes("--staged");
const updateBaseline = process.argv.includes("--update-baseline");
if (staged) {
process.exitCode = runStagedMode({ baselinePath, workspaceRoot });
return;
}
process.exitCode = runFullMode({
baselinePath,
updateBaseline,
workspaceRoot
});
}
const isDirectExecution =
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectExecution) {
main();
}