diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f1522dddeeb..23298400e83 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt new file mode 100644 index 00000000000..bddc84a2216 --- /dev/null +++ b/config/main-layout-writer-baseline.txt @@ -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 diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt new file mode 100644 index 00000000000..bb875d2d399 --- /dev/null +++ b/config/renderer-layout-writer-baseline.txt @@ -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 diff --git a/config/scripts/check-layout-writer-ratchets.mjs b/config/scripts/check-layout-writer-ratchets.mjs new file mode 100644 index 00000000000..0e8c6bf71cb --- /dev/null +++ b/config/scripts/check-layout-writer-ratchets.mjs @@ -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)) +} diff --git a/config/scripts/check-layout-writer-ratchets.test.mjs b/config/scripts/check-layout-writer-ratchets.test.mjs new file mode 100644 index 00000000000..126e3326012 --- /dev/null +++ b/config/scripts/check-layout-writer-ratchets.test.mjs @@ -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", + "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) + }) +}) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 5c250aba625..7d678ac5001 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -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], diff --git a/package.json b/package.json index d277f4045be..7c6d1068a33 100644 --- a/package.json +++ b/package.json @@ -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",