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
1143 lines
34 KiB
JavaScript
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();
|
|
}
|