Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
/**
|
|
* Convergence gate for #4109: which hooks are still called in the render bodies
|
|
* of the shell components that sit above the whole tree.
|
|
*
|
|
* In React the position in the tree IS the scope of the state. A hook called in
|
|
* the shell's render body has the whole tree as its scope; the same hook called
|
|
* in a provider has its readers as its scope. `AppShellContent` today carries
|
|
* 536 hooks above the entire tree, so a session switch produces ~19 commits,
|
|
* ~14 of them full-tree renders, of which 96-99% is recoverable work.
|
|
*
|
|
* The fix is to move call sites, one feature at a time. That is slow, and a
|
|
* checklist cannot tell whether it is progressing — extracting a feature into
|
|
* its own slice does not change the scope of any state, and Session Navigation
|
|
* proves it: a complete `model / controller / ui / ports` slice whose
|
|
* controller is still invoked from this render body.
|
|
*
|
|
* A call site does have a definition of done, so this gate counts them. The
|
|
* inventory below is EXACT, not a ceiling: growing past an entry fails, and so
|
|
* does falling below one. Only failing on growth would let the counts drift
|
|
* quietly downward and record no progress, which is the one thing this gate
|
|
* exists to make visible. The cost is real — a change that removes an effect
|
|
* has to edit this file — and that edit IS the record.
|
|
*
|
|
* Deliberately NOT a `--write` mode. Regenerating the inventory on demand would
|
|
* let a new hook be accepted by rerunning a command, and the friction of
|
|
* editing it by hand is the point. This is the opposite shape from
|
|
* `check-astryx-surface-inventory.mjs`, whose invariant is "regenerate and
|
|
* compare bytes, never a hand-written list". That is right for an inventory
|
|
* that only has to describe the tree; this one has to RESIST it.
|
|
*
|
|
* Two shapes of migration, because the failure message cannot tell them apart:
|
|
* when nothing in the shell body reads the feature, the call site moves into a
|
|
* provider and React bails out of `children` (the mention catalog, #4109) — the
|
|
* entry disappears. When the shell body DOES read it, moving the call site
|
|
* still leaves a selector read behind, and the honest outcome is a smaller
|
|
* entry rather than none. "Call it from the provider" is not advice to follow
|
|
* blindly: cross-feature intent the shell issues must stay an explicit command,
|
|
* never an implicit subscription bought to lower a number.
|
|
*
|
|
* The count is unweighted, so it measures migration progress and not render
|
|
* cost: a subscription that fires five times per session switch and a hook that
|
|
* runs once at mount both count as one. Falling counts do not by themselves
|
|
* demonstrate a faster switch.
|
|
*
|
|
* Both `AppShell` and `AppShellContent` are counted. `AppShell` wraps the
|
|
* content in the root providers, so a hook hoisted one level up has if anything
|
|
* a WIDER scope — an inventory that watched only the inner component would
|
|
* accept that move as progress.
|
|
*
|
|
* The counting is textual, because TypeScript 7 exposes no stable parser to
|
|
* JavaScript (only `typescript/unstable/*`), and a gate that goes red whenever
|
|
* a dependency moves is its own kind of noise. Textual counting is therefore
|
|
* written to fail closed: `stripNonCode` blanks comments, strings and regex
|
|
* literals in place, the body is delimited by brace balancing over that blanked
|
|
* text rather than by a `\n}\n` guess, and a hook name is only counted where a
|
|
* call actually follows it — including through an explicit type argument, which
|
|
* an earlier version of this gate silently skipped over.
|
|
*
|
|
* Purely derived hooks (`useMemo`, `useCallback`, `useRef`, `useId`) are
|
|
* ignored: they hold no state and subscribe to nothing, so they cannot widen
|
|
* the scope of a render. Everything else counts, including React's own
|
|
* `useState` / `useEffect`, because a custom hook is only a name for them.
|
|
*
|
|
* Run: npm run check:app-shell-hooks
|
|
* Fix: move the call site into the feature's provider, then delete its entry.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const root = join(fileURLToPath(new URL('..', import.meta.url)));
|
|
const shellFile = 'apps/desktop/src/renderer/app-shell.tsx';
|
|
|
|
/** Hooks that hold no state and subscribe to nothing. */
|
|
const DERIVED_HOOKS = new Set(['useMemo', 'useCallback', 'useRef', 'useId']);
|
|
|
|
/**
|
|
* Hooks still called in each shell component's render body, with the number of
|
|
* call sites. This inventory may only shrink. Each entry is one scope that is
|
|
* still the whole tree rather than its readers.
|
|
*/
|
|
export const ALLOWED = {
|
|
AppShell: {
|
|
useState: 2,
|
|
useSystemUiLocale: 1,
|
|
},
|
|
AppShellContent: {
|
|
useActiveExecutionBoundary: 1,
|
|
useActiveSessionEvents: 1,
|
|
useAppShellBootstrapSubscriptions: 1,
|
|
useAppShellComposerQuotes: 1,
|
|
useAppShellHostEffects: 1,
|
|
useAppShellNavRefSync: 1,
|
|
useAppShellPersistenceEffects: 1,
|
|
useAppShellProjectContext: 1,
|
|
useAppShellSessionUiReads: 1,
|
|
useAppShellSessionWorkspace: 1,
|
|
useAppShellTurnPresentation: 1,
|
|
useCommandPalette: 1,
|
|
useComposerAttachments: 1,
|
|
useEffect: 14,
|
|
useKeyboardHelp: 1,
|
|
useLayoutEffect: 2,
|
|
useModuleHubController: 1,
|
|
useNewTaskChoice: 1,
|
|
useOnboardingSnapshot: 1,
|
|
usePlanModeState: 1,
|
|
useSessionEventHealthPolling: 1,
|
|
// Replaces `useSessionNavigationController`, which is now called inside
|
|
// `SessionNavigationProvider`. The entry shrinks rather than disappearing,
|
|
// because the shell body does read the rail: the command palette lists the
|
|
// same visible sessions, the titlebar shows the linked parent, and the
|
|
// frame publishes the rail's width as `--maka-sidenav-width`. What is left
|
|
// holds nothing — three `useMemo`s over the catalog the shell already reads
|
|
// and one subscription to the rail's geometry — where the controller it
|
|
// replaces put three `useState`, four effects and a `useStableActions`
|
|
// facade on this fiber.
|
|
useSessionNavigationReads: 1,
|
|
useSessionCollaborationDialog: 1,
|
|
useSessionSettingIntent: 2,
|
|
useSettingsModal: 1,
|
|
useShellAppearance: 1,
|
|
useShellChatModel: 1,
|
|
useShellConnections: 3,
|
|
useShellLiveTurn: 1,
|
|
useShellMemoryPill: 1,
|
|
useShellResume: 1,
|
|
useShellRunUpdates: 1,
|
|
useShellSearch: 1,
|
|
useStableActions: 7,
|
|
useState: 15,
|
|
useTaskEntryController: 1,
|
|
useTaskSubmissionReadiness: 1,
|
|
useToast: 1,
|
|
// The last of the three `useKeyedPendingRegistry` call sites this entry
|
|
// replaces: #4113 moved the other two onto the session UI store, which is
|
|
// already an external store, so their scope left this fiber entirely. This
|
|
// one stays because the shell body reads `keys` to build the turn footer's
|
|
// disabled mask.
|
|
useTurnActionRegistry: 1,
|
|
useWorkbarController: 1,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* After these, a `/` opens a regex literal; anywhere else it is division.
|
|
*
|
|
* `<` and `>` are deliberately absent. They would qualify in expression
|
|
* position, but in TSX they are far more often a tag: treating the `/` of
|
|
* `</div>` as the start of a regex swallows everything up to the next slash,
|
|
* which is exactly the silent under-count this gate must not have.
|
|
*/
|
|
const REGEX_MAY_FOLLOW = new Set([
|
|
'(',
|
|
',',
|
|
'=',
|
|
':',
|
|
'[',
|
|
'!',
|
|
'&',
|
|
'|',
|
|
'?',
|
|
'{',
|
|
'}',
|
|
';',
|
|
'+',
|
|
'-',
|
|
'*',
|
|
'%',
|
|
'^',
|
|
'~',
|
|
'\n',
|
|
]);
|
|
|
|
/**
|
|
* Blanks comments, string bodies and regex literals, replacing each with spaces
|
|
* so that offsets and line numbers still line up with the original.
|
|
*
|
|
* Prose naming a hook is not a call site, and the shell's comments name hooks
|
|
* at length. The two cases worth spelling out are the ones that break a naive
|
|
* scanner: an apostrophe in JSX text (`<span>don't</span>`) is not a string
|
|
* delimiter, and a regex literal may contain quotes (`/['"]/`) that would
|
|
* otherwise swallow the rest of the file. Both would UNDER-count, which is the
|
|
* dangerous direction for a gate whose only product is a number.
|
|
*/
|
|
export function stripNonCode(source) {
|
|
const out = [...source];
|
|
let index = 0;
|
|
let lastMeaningful = '\n';
|
|
const blank = (from, to) => {
|
|
for (let i = from; i < to && i < out.length; i += 1) {
|
|
if (out[i] !== '\n') out[i] = ' ';
|
|
}
|
|
};
|
|
while (index < source.length) {
|
|
const char = source[index];
|
|
const next = source[index + 1];
|
|
if (char === '/' && next === '/') {
|
|
const end = source.indexOf('\n', index);
|
|
const stop = end === -1 ? source.length : end;
|
|
blank(index, stop);
|
|
index = stop;
|
|
continue;
|
|
}
|
|
if (char === '/' && next === '*') {
|
|
const end = source.indexOf('*/', index + 2);
|
|
const stop = end === -1 ? source.length : end + 2;
|
|
blank(index, stop);
|
|
index = stop;
|
|
continue;
|
|
}
|
|
if (char === '/' && REGEX_MAY_FOLLOW.has(lastMeaningful)) {
|
|
let scan = index + 1;
|
|
let inClass = false;
|
|
while (scan < source.length) {
|
|
const c = source[scan];
|
|
if (c === '\\') scan += 2;
|
|
else if (c === '[') (inClass = true), (scan += 1);
|
|
else if (c === ']') (inClass = false), (scan += 1);
|
|
else if (c === '\n') break;
|
|
else if (c === '/' && !inClass) break;
|
|
else scan += 1;
|
|
}
|
|
// An unterminated candidate was division after all; leave it alone.
|
|
if (scan < source.length && source[scan] === '/') {
|
|
blank(index, scan + 1);
|
|
lastMeaningful = '/';
|
|
index = scan + 1;
|
|
continue;
|
|
}
|
|
}
|
|
if (char === "'" || char === '"' || char === '`') {
|
|
// In JSX text an apostrophe follows a word or a closing bracket, and no
|
|
// JavaScript expression can put a string literal there.
|
|
if (char === "'" && /[A-Za-z0-9_$>)\]}]/.test(lastMeaningful)) {
|
|
lastMeaningful = char;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
let scan = index + 1;
|
|
while (scan < source.length && source[scan] !== char) {
|
|
scan += source[scan] === '\\' ? 2 : 1;
|
|
}
|
|
blank(index + 1, scan);
|
|
lastMeaningful = char;
|
|
index = Math.min(scan + 1, source.length);
|
|
continue;
|
|
}
|
|
if (!/\s/.test(char) || char === '\n') lastMeaningful = char;
|
|
index += 1;
|
|
}
|
|
return out.join('');
|
|
}
|
|
|
|
/**
|
|
* The body of a top-level component, delimited by brace balancing over text
|
|
* whose strings and comments are already blanked. A `\n}\n` guess would stop at
|
|
* the first brace that formatting happened to put in column zero.
|
|
*/
|
|
export function findComponentBody(blanked, name) {
|
|
const declaration = new RegExp(
|
|
String.raw`^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+${name}\b|^(?:export\s+)?(?:default\s+)?const\s+${name}\s*(?::[^=]+)?=`,
|
|
'm',
|
|
);
|
|
const match = declaration.exec(blanked);
|
|
if (match === null) return null;
|
|
// Skip the parameter list. `function AppShellContent({ ... })` opens a brace
|
|
// for its destructured parameter, and balancing from there returns the
|
|
// parameter object rather than the body.
|
|
const paren = blanked.indexOf('(', match.index);
|
|
if (paren === -1) return null;
|
|
let parens = 0;
|
|
let afterParams = -1;
|
|
for (let i = paren; i < blanked.length; i += 1) {
|
|
if (blanked[i] === '(') parens += 1;
|
|
else if (blanked[i] === ')') {
|
|
parens -= 1;
|
|
if (parens === 0) {
|
|
afterParams = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (afterParams === -1) return null;
|
|
const open = blanked.indexOf('{', afterParams);
|
|
if (open === -1) return null;
|
|
let depth = 0;
|
|
for (let i = open; i < blanked.length; i += 1) {
|
|
if (blanked[i] === '{') depth += 1;
|
|
else if (blanked[i] === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) return blanked.slice(open, i + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `useX(`, `React.useX(`, `useX<T>(`, `useX\n (` and the bare `use(` of React
|
|
* 19 all call a hook; `ReturnType<typeof useX>` does not, and neither does
|
|
* `shellCopy.useSkillPrompt(name)` — the shell really does call that one.
|
|
*
|
|
* That last case is why the qualifier is the literal `React.` and not any
|
|
* identifier: a general `<name>.useX(` rule would count a locale copy function
|
|
* as a hook. The narrow rule leaves one hole, `import * as X from 'react'`,
|
|
* which `assertCountableReactImport` closes by failing rather than by guessing.
|
|
*/
|
|
// One level of nesting is enough for `useX<Record<string, number>>(...)`;
|
|
// deeper generics in a hook call do not occur here and would only cost a
|
|
// miscount in the safe direction (a failure, not a silent pass).
|
|
const TYPE_ARGUMENTS = String.raw`<(?:[^<>()]|<[^<>()]*>)*>`;
|
|
const HOOK_CALL = new RegExp(
|
|
String.raw`(?<![.\w$])(?:React\.)?(use(?:[A-Z][A-Za-z0-9]*)?)(?=\s*(?:${TYPE_ARGUMENTS}\s*)?\()`,
|
|
'g',
|
|
);
|
|
|
|
/**
|
|
* A namespace import of React would let `X.useState(...)` past a scanner that
|
|
* only knows the name `React`, and the whole point of this gate is that it
|
|
* cannot be widened by accident. It has no way to resolve the alias, so it
|
|
* refuses the file instead of under-counting it.
|
|
*/
|
|
export function assertCountableReactImport(source) {
|
|
const namespaceImport = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]react['"]/.exec(
|
|
source,
|
|
);
|
|
if (!namespaceImport) return null;
|
|
return (
|
|
`imports React as the namespace \`${namespaceImport[1]}\`.\n` +
|
|
' This gate counts hook calls textually and knows only the name `React`,\n' +
|
|
' so `' +
|
|
namespaceImport[1] +
|
|
'.useState(...)` would be invisible to it.\n' +
|
|
' Use named imports, or teach the scanner the alias.'
|
|
);
|
|
}
|
|
|
|
export function countHooks(body) {
|
|
const counts = {};
|
|
for (const match of body.matchAll(HOOK_CALL)) {
|
|
// `React.useState` is the same call site as `useState`, and the inventory
|
|
// names the hook, not the syntax that reached it.
|
|
const name = match[1];
|
|
if (DERIVED_HOOKS.has(name)) continue;
|
|
counts[name] = (counts[name] ?? 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export function compareToInventory(counts, allowed) {
|
|
const added = [];
|
|
const grown = [];
|
|
const stale = [];
|
|
for (const [name, count] of Object.entries(counts)) {
|
|
const budget = allowed[name];
|
|
if (budget === undefined) added.push(name);
|
|
else if (count > budget) grown.push({ name, budget, count });
|
|
}
|
|
for (const [name, budget] of Object.entries(allowed)) {
|
|
const count = counts[name] ?? 0;
|
|
if (count < budget) stale.push({ name, budget, count });
|
|
}
|
|
return { added, grown, stale };
|
|
}
|
|
|
|
function main() {
|
|
const source = readFileSync(join(root, shellFile), 'utf8');
|
|
// Against the raw source: `stripNonCode` blanks the `'react'` specifier.
|
|
const uncountableImport = assertCountableReactImport(source);
|
|
if (uncountableImport) {
|
|
console.error(`${shellFile}: ${uncountableImport}`);
|
|
process.exit(1);
|
|
}
|
|
const blanked = stripNonCode(source);
|
|
const failures = [];
|
|
let hooks = 0;
|
|
let callSites = 0;
|
|
|
|
for (const [component, allowed] of Object.entries(ALLOWED)) {
|
|
const body = findComponentBody(blanked, component);
|
|
if (body === null) {
|
|
console.error(`${shellFile}: could not find the render body of ${component}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const counts = countHooks(body);
|
|
hooks += Object.keys(counts).length;
|
|
callSites += Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
const { added, grown, stale } = compareToInventory(counts, allowed);
|
|
|
|
for (const name of added) {
|
|
failures.push(
|
|
`${shellFile}: ${name} is a new hook in ${component}'s render body.\n` +
|
|
' Its state would be scoped to the whole tree. Call it from the feature\n' +
|
|
' provider instead; add it to the inventory only if it genuinely belongs\n' +
|
|
' to the shell, and say why in the pull request (#4109).',
|
|
);
|
|
}
|
|
for (const { name, budget, count } of grown) {
|
|
failures.push(
|
|
`${shellFile}: ${name} has ${count} call sites in ${component}, inventory allows ${budget}.`,
|
|
);
|
|
}
|
|
for (const { name, budget, count } of stale) {
|
|
// Deliberately not phrased as an instruction to lower the number. A drop
|
|
// is usually a migration, but it is also what a mis-count looks like, and
|
|
// a gate that tells you to shrink its own inventory can be talked into
|
|
// emptying itself.
|
|
failures.push(
|
|
`${shellFile}: ${name} has ${count} call sites in ${component}, inventory says ${budget}.\n` +
|
|
' If a migration moved it, update the entry in scripts/check-app-shell-hooks.mjs.\n' +
|
|
' If you did not move it, the gate is miscounting — fix the gate, not the number.',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (failures.length === 0) {
|
|
console.log(
|
|
`app-shell hook scope: ok (${hooks} hooks, ${callSites} call sites across ${Object.keys(ALLOWED).join(' + ')})`,
|
|
);
|
|
return;
|
|
}
|
|
for (const failure of failures) console.error(failure);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|