chore(ratchet): layout-writer ratchets for main and renderer (#27431)

* chore(ratchet): layout-writer ratchets for main and renderer

Two per-file ratchets that may only go down: main-process callers of the
Store's layout setters plus direct edits of saved session partitions, and
renderer writes of the window's layout and per-view selection fields
(including computed-key and spread writes, counted where the key or object
is built). The frozen-mirror writers are exempt. Wired next to the
focus-read ratchet in lint and the PR workflow.

* chore(ratchet): count only membership writes of editor and browser entries

A .map over the existing openFiles or browser tab list keeps its entries
and order, so it is a content update (draft, git status, URL) and is no
longer counted by the renderer layout-writer ratchet.

* chore(ratchet): ignore variable keys and pass-through shorthands

A variable key such as exitCodes[ptyId] names no field, so only string
keys count. A shorthand key counts only in an object handed to set,
setState or a Store writer; elsewhere it passes a value on.

* chore(ratchet): prune renderer baseline after main moved
This commit is contained in:
Jinwoo Hong
2026-10-11 14:55:11 -04:00
committed by GitHub
parent 3f8d296b8d
commit e969881be5
7 changed files with 782 additions and 1 deletions
+4
View File
@@ -336,6 +336,10 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:focus-read-ratchet
- name: Enforce layout-writer ratchets
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:layout-writer-ratchets
- name: Check Node runtime pin
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:node-runtime-pin
+73
View File
@@ -0,0 +1,73 @@
# Main-process writers of saved workspace layout, per file: calls of the Store layout
# setters, plus assignments, deletes and object keys that edit session partitions directly.
# This is a RATCHET: counts may only go DOWN. Layout moves to one runtime owner; do not add
# writers. Prune after removing writers: pnpm check:layout-writer-ratchets --prune
1 src/main/ipc/pty/ipc/leaf-move.ts
2 src/main/ipc/pty/ipc/spawn-commit-persist.ts
1 src/main/ipc/pty/pane/pane-owner-replacement.ts
3 src/main/ipc/pty/pane/stable-owner.ts
2 src/main/ipc/pty/runtime/spawn-commit.ts
1 src/main/ipc/renderer-shutdown-checkpoint.ts
1 src/main/ipc/runtime-environment-removal-cleanup.ts
1 src/main/ipc/runtime-environment-session-reconcile.ts
3 src/main/ipc/session.ts
6 src/main/orca-profiles/profile-project-session-state.ts
4 src/main/orca-profiles/profile-project-source-removal.ts
2 src/main/orca-profiles/profile-project-state-file.ts
4 src/main/orca-profiles/profile-project-transfer-payload.ts
2 src/main/orca-profiles/profile-project-transfer.ts
9 src/main/orca-profiles/profile-session-owner-transfer.ts
3 src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts
1 src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts
1 src/main/persistence/loading-store/loaded-state-parsing.ts
1 src/main/persistence/loading-store/metadata-lineage-operations.ts
1 src/main/persistence/loading-store/mobile-tab-selection-persistence.ts
1 src/main/persistence/loading-store/normalize-loaded-profile-state.ts
10 src/main/persistence/loading-store/pty-binding-persistence.ts
10 src/main/persistence/loading-store/pty-binding-session-update.ts
11 src/main/persistence/loading-store/repo-lifecycle-operations.ts
10 src/main/persistence/loading-store/session-host-partitions.ts
9 src/main/persistence/loading-store/session-snapshot-operations.ts
3 src/main/persistence/loading-store/state-serialization-secret-handling.ts
4 src/main/persistence/loading-store/workspace-session-snapshot-publication.ts
4 src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts
2 src/main/persistence/loading-store/workspace-session-write-listeners.ts
2 src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts
3 src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts
2 src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts
3 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts
4 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-subtraction.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts
1 src/main/persistence/restoring-sessions/folder-workspace-operations.ts
5 src/main/persistence/restoring-sessions/session-owner-fields.ts
12 src/main/persistence/restoring-sessions/session-owner-removal.ts
2 src/main/persistence/restoring-sessions/workspace-pane-normalization.ts
4 src/main/persistence/terminal-topology/terminal-topology-membership.ts
1 src/main/persistence/tracking-repos/project-group-operations.ts
4 src/main/persistence/tracking-repos/repo-worktree-pruning.ts
3 src/main/persistence/tracking-repos/worktree-identity-migration.ts
1 src/main/runtime/client-hosted-browser-page-persistence.ts
1 src/main/runtime/mobile-session-editor-projection.ts
3 src/main/runtime/mobile-session-layout-projection.ts
2 src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
2 src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
2 src/main/runtime/orca-runtime-attach-window.ts
2 src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
1 src/main/runtime/orca-runtime-get-runtime-id.ts
2 src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
6 src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts
2 src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
1 src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
1 src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
3 src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
4 src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
2 src/main/runtime/runtime-pane-admission.ts
7 src/main/runtime/runtime-terminal-orphan-session-adoption.ts
1 src/main/runtime/runtime-workspace-session-controller.ts
4 src/main/runtime/workspace-session-worktree-id.ts
1 src/main/ssh/ssh-target-id-migration.ts
2 src/main/terminal-scrollback-snapshots.ts
+125
View File
@@ -0,0 +1,125 @@
# Renderer writes of the window layout and per-view selection fields, per file: assignments,
# deletes, object keys and string keys (computed-key writes). Same-name copies are reads.
# This is a RATCHET: counts may only GO DOWN. Layout comes from the runtime; send a command
# instead. Prune after removing writes: pnpm check:layout-writer-ratchets --prune
3 src/renderer/src/attention/notification-subject-owner.ts
1 src/renderer/src/components/activity/use-agent-pane-threads.ts
3 src/renderer/src/components/cmd-j/palette-live-status.tsx
2 src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts
1 src/renderer/src/components/sidebar/use-worktree-activity-status.ts
1 src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts
4 src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts
3 src/renderer/src/components/sidebar/worktree-list-lineage-store-state.ts
3 src/renderer/src/components/sidebar/worktree-list-pinned-store-state.ts
4 src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts
1 src/renderer/src/components/status-bar/workspace-space-decision-inputs.ts
2 src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts
3 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts
23 src/renderer/src/components/tab-group/tab-drag-preview-activation.ts
1 src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
1 src/renderer/src/components/terminal-pane/pty-connection-test-deps.ts
4 src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts
3 src/renderer/src/components/worktree-jump-palette-status-inputs.ts
9 src/renderer/src/lib/agent-background-session-test-state.ts
1 src/renderer/src/lib/agent-hibernation-coordinator.ts
4 src/renderer/src/lib/floating-workspace-terminal-actions.ts
1 src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts
4 src/renderer/src/lib/session-write-subscriber.ts
1 src/renderer/src/lib/workspace-session-browser-tabs.ts
7 src/renderer/src/lib/workspace-session-host-hydration.ts
7 src/renderer/src/lib/workspace-session-hydration-keys.ts
25 src/renderer/src/lib/workspace-session-patch.ts
3 src/renderer/src/lib/workspace-session-staged-browser-tabs.ts
17 src/renderer/src/lib/workspace-session.ts
21 src/renderer/src/lib/worktree-activation-created-agent-test-state.ts
3 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts
3 src/renderer/src/runtime/structured-agent-session-tab-retirement.ts
10 src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts
2 src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts
2 src/renderer/src/runtime/sync-runtime-graph/sync-key.ts
13 src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts
1 src/renderer/src/runtime/web-runtime-session-snapshot.ts
3 src/renderer/src/runtime/web-session-client-owned-tab-placement.ts
1 src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts
8 src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts
18 src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts
2 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts
5 src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts
4 src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts
1 src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts
2 src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts
1 src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts
2 src/renderer/src/runtime/web-session-terminal-orphan-topology.ts
3 src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.ts
9 src/renderer/src/store/repos/repo-removal.ts
10 src/renderer/src/store/slices/browser/browser-close-actions.ts
6 src/renderer/src/store/slices/browser/browser-hydration-actions.ts
5 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts
2 src/renderer/src/store/slices/browser/browser-page-state-actions.ts
6 src/renderer/src/store/slices/browser/browser-tab-actions.ts
4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts
2 src/renderer/src/store/slices/browser/create-browser-slice.ts
1 src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts
1 src/renderer/src/store/slices/editor/actions/chat-visual-tab-actions.ts
9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts
7 src/renderer/src/store/slices/editor/actions/close-file-action.ts
1 src/renderer/src/store/slices/editor/actions/git-status-actions.ts
5 src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts
3 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts
27 src/renderer/src/store/slices/editor/actions/open-combined-diff.ts
13 src/renderer/src/store/slices/editor/actions/open-conflict-file.ts
14 src/renderer/src/store/slices/editor/actions/open-conflict-review.ts
4 src/renderer/src/store/slices/editor/actions/open-file-apply.ts
5 src/renderer/src/store/slices/editor/actions/open-file-mutations.ts
7 src/renderer/src/store/slices/editor/actions/open-file-state.ts
26 src/renderer/src/store/slices/editor/actions/open-history-diff.ts
1 src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts
10 src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts
4 src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts
5 src/renderer/src/store/slices/editor/actions/restored-editor-owner-in-place.ts
10 src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts
3 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts
2 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts
1 src/renderer/src/store/slices/editor/file-ids/open-file-path-rekey.ts
4 src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts
2 src/renderer/src/store/slices/ssh-target-cleanup.ts
2 src/renderer/src/store/slices/tab-group-reference-repair.ts
4 src/renderer/src/store/slices/tab-group-state.ts
2 src/renderer/src/store/slices/tabs-hydration.ts
4 src/renderer/src/store/slices/tabs/create-tabs-slice.ts
17 src/renderer/src/store/slices/tabs/tabs-close-actions.ts
14 src/renderer/src/store/slices/tabs/tabs-create-actions.ts
11 src/renderer/src/store/slices/tabs/tabs-drop-actions.ts
4 src/renderer/src/store/slices/tabs/tabs-focus-actions.ts
13 src/renderer/src/store/slices/tabs/tabs-group-actions.ts
1 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts
8 src/renderer/src/store/slices/tabs/tabs-label-actions.ts
10 src/renderer/src/store/slices/tabs/tabs-move-actions.ts
9 src/renderer/src/store/slices/tabs/tabs-reconciliation.ts
1 src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts
4 src/renderer/src/store/slices/tabs/tabs-session-actions.ts
5 src/renderer/src/store/slices/tabs/tabs-surface.ts
6 src/renderer/src/store/slices/terminal-orphan-helpers.ts
4 src/renderer/src/store/slices/terminal-tab-title-batch.ts
6 src/renderer/src/store/slices/terminals.ts
5 src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts
7 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts
15 src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts
2 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
18 src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts
18 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts
5 src/renderer/src/store/terminals/terminal-layout-state.ts
2 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
3 src/renderer/src/store/terminals/terminal-pty-bindings.ts
2 src/renderer/src/store/terminals/terminal-pty-release.ts
1 src/renderer/src/store/terminals/terminal-shutdown-guards.ts
3 src/renderer/src/store/terminals/terminal-shutdown-state.ts
1 src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts
2 src/renderer/src/store/terminals/terminal-tab-attention.ts
6 src/renderer/src/store/terminals/terminal-tab-close.ts
10 src/renderer/src/store/terminals/terminal-tab-creation.ts
7 src/renderer/src/store/terminals/terminal-tab-navigation.ts
3 src/renderer/src/store/terminals/terminal-tab-presentation.ts
5 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts
2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts
2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts
@@ -0,0 +1,504 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import ts from 'typescript-api'
import {
diffCounts,
formatBaseline,
parseBaseline,
parseBaselineNotes
} from './check-focus-read-ratchet.mjs'
// Two ratchets for writers of workspace tab layout, which is moving to one owner in the runtime.
// Per-file counts may only go down. Deleted symbols are left to the compiler.
// - Main: callers of the Store's layout setters, plus writes that edit saved session partitions
// directly (the Store-internal session writers included).
// - Renderer: writes of the window's layout and per-view selection fields, including computed
// keys (counted at the string key) and spreads (counted where the spread object is built).
// Content updates of existing editor and browser entries are not membership and are not counted.
// Shorthand keys count only in an object handed to `set`/`setState` or a Store writer.
// Partition fields of the saved workspace session (census class 1).
const SESSION_PARTITION_FIELDS = [
'tabsByWorktree',
'terminalLayoutsByTabId',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'activeTabIdByWorktree',
'activeWorktreeId',
'openFilesByWorktree',
'browserTabsByWorktree',
'activeBrowserTabIdByWorktree',
'activeFileIdByWorktree',
'activeTabTypeByWorktree',
'tabBarOrderByWorktree',
'sleepingAgentSessionsByPaneKey',
'terminalPtyIncarnationsByPaneKey',
'defaultTerminalTabsAppliedByWorktreeId',
'lastVisitedAtByWorktreeId',
'browserUrlHistory'
]
const SESSION_FIELDS = ['workspaceSession', 'workspaceSessionsByHostId']
// Tab and pane-layout fields edited in place inside a partition (census class 1, third search).
const SESSION_TAB_FIELDS = [
'ptyId',
'ptyIdsByLeafId',
'customTitle',
'titlesByLeafId',
'buffersByLeafId',
'activeLeafId'
]
// Window layout fields of acceptance 1 and the per-view selection fields (census class 2).
const RENDERER_LAYOUT_FIELDS = new Set([
'tabsByWorktree',
'terminalLayoutsByTabId',
'tabBarOrderByWorktree',
'unifiedTabsByWorktree',
'groupsByWorktree',
'layoutByWorktree',
'openFiles',
'browserTabsByWorktree',
'activeGroupIdByWorktree',
'activeTabIdByWorktree',
'activeTabId',
'activeFileIdByWorktree',
'activeFileId',
'activeBrowserTabIdByWorktree',
'activeBrowserTabId',
'activeTabTypeByWorktree',
'activeTabType',
'ptyIdsByTabId'
])
export const RATCHETS = [
{
id: 'main',
label: 'main-process layout writer(s)',
scanRoot: 'src/main',
baselinePath: 'config/main-layout-writer-baseline.txt',
// Store layout setters and the Store-internal helpers that edit partitions (census class 1
// entry points). Every call, and every method value (`store.setWorkspaceSession.bind`), counts.
writerNames: new Set([
'setWorkspaceSession',
'patchWorkspaceSession',
'stageWorkspaceSessionBeforeUnload',
'setWorkspaceSessionForWorktree',
'setForWorktree',
'persistPtyBinding',
'retirePtyBinding',
'moveTerminalLeafToNewTab',
'admitTerminalPane',
'withdrawTerminalPane',
'removeWorkspaceSessionHost',
'removeWorkspaceSessionStateForWorktree',
'setHostWorkspaceSession',
'setLocalWorkspaceSession',
'applyPtyBinding',
'removeWorkspaceSessionOwner',
'removeWorkspaceSessionOwners',
'removeWorkspaceSessionOwnerEverywhere',
'removeWorkspaceSessionOwnersEverywhere',
'removeWorkspaceSessionOwnerInPartition',
'deleteScannedSessionFieldsForOwners',
'removeRepoFromWorkspaceSession',
'removeRepoFromHostWorkspaceSessions',
'applyPreparedOrcadMigrationWorkspaceSession',
'applyPayloadToTarget',
'removeSourceRepo',
'clearSshRemotePtyBindingsForTarget',
'clearSshRemotePtyBindingsForLeases'
]),
assignedFields: new Set([
...SESSION_FIELDS,
...SESSION_PARTITION_FIELDS,
...SESSION_TAB_FIELDS,
'mobileClientTabSelectionsByDeviceId'
]),
// These names also key unrelated main objects, so only the session itself counts as a key.
keyedFields: new Set(SESSION_FIELDS),
entryContentFields: new Set(),
allowed: () => false,
header: [
'# Main-process writers of saved workspace layout, per file: calls of the Store layout',
'# setters, plus assignments, deletes and object keys that edit session partitions directly.',
'# This is a RATCHET: counts may only go DOWN. Layout moves to one runtime owner; do not add',
'# writers. Prune after removing writers: pnpm check:layout-writer-ratchets --prune'
]
},
{
id: 'renderer',
label: 'renderer layout write(s)',
scanRoot: 'src/renderer/src',
baselinePath: 'config/renderer-layout-writer-baseline.txt',
writerNames: new Set(),
assignedFields: RENDERER_LAYOUT_FIELDS,
keyedFields: RENDERER_LAYOUT_FIELDS,
// Entries here also carry content (drafts, git status, URL). A `.map(…)` over the existing
// list keeps membership and order, so it is a content update and is not counted.
entryContentFields: new Set(['openFiles', 'browserTabsByWorktree']),
// The three writers the plan allows: applyFromRuntime, the frozen mirror's store patch and
// the frozen setters for remote-server workspaces.
allowed: (rel) =>
rel === 'src/renderer/src/store/workspace-layout-cache.ts' ||
rel === 'src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts' ||
rel.startsWith('src/renderer/src/runtime/remote-workspace-frozen/'),
header: [
'# Renderer writes of the window layout and per-view selection fields, per file: assignments,',
'# deletes, object keys and string keys (computed-key writes). Same-name copies are reads.',
'# This is a RATCHET: counts may only GO DOWN. Layout comes from the runtime; send a command',
'# instead. Prune after removing writes: pnpm check:layout-writer-ratchets --prune'
]
}
]
// Census test filter: test, fixture and harness files are not product writers.
export function isScannedPath(rel) {
return (
/\.(ts|tsx)$/.test(rel) &&
!/\.(test|spec)\.|\/tests?\/|__tests__|test-support|fixture|harness|e2e\//.test(rel)
)
}
const ASSIGNMENT_OPERATORS = new Set([
ts.SyntaxKind.EqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.AmpersandAmpersandEqualsToken
])
/** A string-literal key; a variable key (`exitCodes[ptyId]`) names no field. */
function literalKey(node) {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null
}
function staticName(node) {
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
return node.text
}
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text
}
if (ts.isComputedPropertyName(node)) {
return literalKey(node.expression)
}
return null
}
/** The field a write target (`a.b[c]!.d`) goes through, if any; root identifiers are locals. */
function targetedField(node, fields) {
let current = node
while (current) {
if (
ts.isParenthesizedExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current)
) {
current = current.expression
} else if (ts.isPropertyAccessExpression(current)) {
if (fields.has(current.name.text)) {
return current.name.text
}
current = current.expression
} else if (ts.isElementAccessExpression(current)) {
const key = literalKey(current.argumentExpression)
if (key !== null && fields.has(key)) {
return key
}
current = current.expression
} else {
return null
}
}
return null
}
function unwrap(node) {
let current = node
while (
ts.isParenthesizedExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current)
) {
current = current.expression
}
return current
}
/** An existing value read as-is: `s.openFiles`, `s.tabs[id] ?? []`. */
function isPlainRead(node) {
const current = unwrap(node)
if (ts.isIdentifier(current) || current.kind === ts.SyntaxKind.ThisKeyword) {
return true
}
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
return isPlainRead(current.expression)
}
if (
ts.isBinaryExpression(current) &&
(current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
current.operatorToken.kind === ts.SyntaxKind.BarBarToken)
) {
const right = unwrap(current.right)
const emptyLiteral =
(ts.isArrayLiteralExpression(right) && right.elements.length === 0) ||
(ts.isObjectLiteralExpression(right) && right.properties.length === 0)
return isPlainRead(current.left) && (emptyLiteral || isPlainRead(right))
}
return false
}
/** `list.map(…)`, or `{ ...map, [key]: list.map(…) }`: same entries in the same order. */
function keepsMembership(node) {
const value = unwrap(node)
if (ts.isCallExpression(value)) {
const callee = unwrap(value.expression)
return (
ts.isPropertyAccessExpression(callee) &&
callee.name.text === 'map' &&
isPlainRead(callee.expression)
)
}
if (ts.isObjectLiteralExpression(value)) {
return value.properties.every(
(property) =>
(ts.isSpreadAssignment(property) && isPlainRead(property.expression)) ||
(ts.isPropertyAssignment(property) && keepsMembership(property.initializer))
)
}
return false
}
const STORE_SET_CALLS = new Set(['set', 'setState'])
/** Whether an object literal is a store write: `set({ … })`, `set((s) => ({ … }))` or a writer call. */
function isPassedToWriter(objectLiteral, writerNames) {
let node = objectLiteral
for (;;) {
const parent = node.parent
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isSatisfiesExpression(parent) ||
ts.isConditionalExpression(parent)
) {
node = parent
} else if (ts.isArrowFunction(parent) && parent.body === node) {
node = parent
} else if (ts.isReturnStatement(parent)) {
let fn = parent.parent
while (fn && !ts.isFunctionLike(fn)) {
fn = fn.parent
}
if (!fn) {
return false
}
node = fn
} else {
break
}
}
const call = node.parent
if (!ts.isCallExpression(call) || !call.arguments.includes(node)) {
return false
}
const callee = unwrap(call.expression)
const name = ts.isIdentifier(callee)
? callee.text
: ts.isPropertyAccessExpression(callee)
? callee.name.text
: null
return name !== null && (STORE_SET_CALLS.has(name) || writerNames.has(name))
}
/** `{ tabsByWorktree: s.tabsByWorktree }` copies a value under its own name: a selector read. */
function isSameNameCopy(property, name) {
let value = property.initializer
while (ts.isParenthesizedExpression(value) || ts.isNonNullExpression(value)) {
value = value.expression
}
return ts.isPropertyAccessExpression(value) && value.name.text === name
}
/** Layout writes in one file under one ratchet's rules. */
export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') {
const names = [...ratchet.writerNames, ...ratchet.assignedFields]
if (!names.some((name) => sourceText.includes(name))) {
return 0
}
const sourceFile = ts.createSourceFile(
fileName,
sourceText,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
let count = 0
const visit = (node) => {
if (
(ts.isPropertyAccessExpression(node) && ratchet.writerNames.has(node.name.text)) ||
(ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
ratchet.writerNames.has(node.expression.text))
) {
count += 1
} else if (
ts.isBinaryExpression(node) &&
ASSIGNMENT_OPERATORS.has(node.operatorToken.kind) &&
targetedField(node.left, ratchet.assignedFields) !== null
) {
const field = targetedField(node.left, ratchet.assignedFields)
if (!(ratchet.entryContentFields.has(field) && keepsMembership(node.right))) {
count += 1
}
} else if (
ts.isDeleteExpression(node) &&
targetedField(node.expression, ratchet.assignedFields) !== null
) {
count += 1
} else if (
ts.isShorthandPropertyAssignment(node) &&
ratchet.keyedFields.has(node.name.text) &&
// A shorthand elsewhere passes a value on (`return { activeTabId, tabsByWorktree }`).
isPassedToWriter(node.parent, ratchet.writerNames)
) {
count += 1
} else if (ts.isPropertyAssignment(node)) {
const name = staticName(node.name)
if (
name !== null &&
ratchet.keyedFields.has(name) &&
!isSameNameCopy(node, name) &&
!(ratchet.entryContentFields.has(name) && keepsMembership(node.initializer))
) {
count += 1
}
} else if (
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) &&
ratchet.keyedFields.has(node.text) &&
// Object keys and element reads are judged above; `'tabsByWorktree'` elsewhere feeds a
// computed-key write (`patch[key] = …`), which cannot be resolved statically.
!(ts.isPropertyAssignment(node.parent) && node.parent.name === node) &&
!ts.isComputedPropertyName(node.parent) &&
!(ts.isElementAccessExpression(node.parent) && node.parent.argumentExpression === node) &&
!ts.isLiteralTypeNode(node.parent)
) {
count += 1
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return count
}
function trackedFiles(root, scanRoot) {
return execFileSync('git', ['ls-files', scanRoot], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter((rel) => rel && isScannedPath(rel))
}
export function collectCurrentCounts(ratchet, root = process.cwd()) {
const counts = new Map()
for (const rel of trackedFiles(root, ratchet.scanRoot)) {
if (ratchet.allowed(rel)) {
continue
}
let text
try {
text = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
// Deleted in the working tree but still tracked.
continue
}
const found = countLayoutWrites(text, ratchet, rel)
if (found > 0) {
counts.set(rel, found)
}
}
return counts
}
function total(counts) {
return [...counts.values()].reduce((sum, count) => sum + count, 0)
}
const ADVICE =
'Workspace layout is moving to one runtime owner; send a layout command instead of writing it here.'
export function main(root = process.cwd()) {
let failed = false
for (const ratchet of RATCHETS) {
const baselineFile = path.join(root, ratchet.baselinePath)
if (!fs.existsSync(baselineFile)) {
console.error(`::error::Missing ${ratchet.baselinePath}.`)
failed = true
continue
}
const current = collectCurrentCounts(ratchet, root)
const { grown, shrunk } = diffCounts(
current,
parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
)
for (const { file, now, allowed } of grown) {
console.error(
`::error file=${file}::${now} ${ratchet.label}, baseline allows ${allowed}. ${ADVICE}`
)
}
for (const { file, now, allowed } of shrunk) {
console.error(
`::error file=${file}::${now} ${ratchet.label}, baseline still allows ${allowed}. Run: pnpm check:layout-writer-ratchets --prune`
)
}
if (grown.length + shrunk.length > 0) {
failed = true
} else {
console.log(`Layout-writer ratchet (${ratchet.id}) OK — ${total(current)} ${ratchet.label}.`)
}
}
return failed ? 1 : 0
}
export function prune(root = process.cwd()) {
for (const ratchet of RATCHETS) {
const baselineFile = path.join(root, ratchet.baselinePath)
const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : ''
const current = collectCurrentCounts(ratchet, root)
// Lowers entries only; growth still has to be fixed in the code.
const pruned = new Map(
[...parseBaseline(text)].map(([file, allowed]) => [
file,
Math.min(allowed, current.get(file) ?? 0)
])
)
fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text)))
console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} ${ratchet.label}.`)
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
if (process.argv[2] === '--init') {
// One-time bootstrap of the baselines from today's tree.
for (const ratchet of RATCHETS) {
const counts = collectCurrentCounts(ratchet, root)
fs.writeFileSync(
path.join(root, ratchet.baselinePath),
formatBaseline(counts, ratchet.header)
)
}
} else if (process.argv[2] === '--prune') {
prune(root)
}
process.exit(main(root))
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { countLayoutWrites, isScannedPath, RATCHETS } from './check-layout-writer-ratchets.mjs'
const [mainRatchet, rendererRatchet] = RATCHETS
describe('main layout writers', () => {
it('counts Store setter calls, partition edits and session keys, not reads or types', () => {
const src = [
'store.setWorkspaceSession(next, hostId)',
'const save = store.persistPtyBinding.bind(store)',
'removeWorkspaceSessionOwners(state, ids)',
'this.state.workspaceSession = merged',
'session.tabsByWorktree[id] = tabs',
'delete partition.terminalLayoutsByTabId![tabId]',
'tab.ptyId = null',
'const next = { ...state, workspaceSession: merged }',
'const tabs = session.tabsByWorktree[id]',
'type T = { workspaceSession: Session }',
'const reply = { tabsByWorktree: tabs }',
'exitCodes[ptyId] = code',
'const byPty = { [ptyId]: code }'
].join('\n')
expect(countLayoutWrites(src, mainRatchet, 'a.ts')).toBe(8)
})
})
describe('renderer layout writes', () => {
it('counts assignments, object, shorthand and string keys, not same-name copies or types', () => {
const src = [
'set({ tabsByWorktree: next, activeTabId })',
'patch.tabsByWorktree![id] = tabs',
"const KEYS = ['groupsByWorktree', 'layoutByWorktree'] as const",
'patch[key] = value',
'const picked = useAppStore((s) => ({ openFiles: s.openFiles }))',
'const useTabs = () => ({ activeTabId, tabsByWorktree })',
"type Field = Pick<AppState, 'tabsByWorktree'>",
"const read = state['unifiedTabsByWorktree']",
'const { tabsByWorktree } = state'
].join('\n')
expect(countLayoutWrites(src, rendererRatchet, 'a.tsx')).toBe(5)
})
it('skips content updates of existing editor and browser entries, not membership changes', () => {
const src = [
'set({ openFiles: s.openFiles.map((f) => (f.id === id ? { ...f, isDirty: true } : f)) })',
'set({ browserTabsByWorktree: { ...s.browserTabsByWorktree, [wt]: (s.browserTabsByWorktree[wt] ?? []).map(withUrl) } })',
'set({ openFiles: [...s.openFiles, file] })',
'set({ openFiles: s.openFiles.filter((f) => f.id !== id) })',
'set({ tabsByWorktree: s.tabsByWorktree.map(rename) })'
].join('\n')
expect(countLayoutWrites(src, rendererRatchet, 'a.tsx')).toBe(3)
})
it('allows only the frozen mirror writers', () => {
expect(rendererRatchet.allowed('src/renderer/src/store/workspace-layout-cache.ts')).toBe(true)
expect(
rendererRatchet.allowed('src/renderer/src/runtime/remote-workspace-frozen/setters.ts')
).toBe(true)
expect(
rendererRatchet.allowed('src/renderer/src/store/slices/tabs/tabs-create-actions.ts')
).toBe(false)
})
})
describe('isScannedPath', () => {
it('skips tests, fixtures and harnesses', () => {
expect(isScannedPath('src/main/ipc/session.ts')).toBe(true)
expect(isScannedPath('src/main/ipc/session.test.ts')).toBe(false)
expect(isScannedPath('src/main/runtime/terminal-retirement-fixture.ts')).toBe(false)
expect(isScannedPath('src/main/ipc/session.md')).toBe(false)
})
})
@@ -224,6 +224,7 @@ it('pins every foreground and background step to its selected phase', () => {
['Enforce ts-nocheck ratchet', staticPhase],
['Enforce runtime Electron-import ratchet', staticPhase],
['Enforce focus-read ratchet', staticPhase],
['Enforce layout-writer ratchets', staticPhase],
['Check Node runtime pin', staticPhase],
['Boot orcad and round-trip a terminal', staticPhase],
['Verify the generated RPC params catalog', staticPhase],
+2 -1
View File
@@ -14,7 +14,7 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -40,6 +40,7 @@
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"check:focus-read-ratchet": "node config/scripts/check-focus-read-ratchet.mjs",
"check:layout-writer-ratchets": "node config/scripts/check-layout-writer-ratchets.mjs",
"check:readme-local-links": "node config/scripts/check-readme-local-links.mjs",
"check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs",
"check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs",