diff --git a/CHANGELOG.md b/CHANGELOG.md index c1256e7d..730f7086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,40 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **#592: lock in the #586/#587 shell primitive guardrails mechanically.** + `check:arch` now rejects three regrowth shapes the six-copy-pasted-overlays + problem #586 fixed: (1) a new `Document.body.append`/`.appendChild` call + outside an exact, reviewed baseline snapshot of sanctioned lifecycle/ + primitive scopes (`shell-body-mount`); (2) a new `position: fixed` CSS + declaration in `src/styles.css` outside the current selector/at-rule + snapshot (`shell-fixed-position`, a focused CSS lexical scanner — no CSS + parser dependency); (3) a new global capture-phase Escape `keydown` + lifecycle outside `SurfaceLifecycle` and its exact documented exceptions/ + non-panel gesture exclusions (`shell-capture-escape`). The two source-level + rules share one real-TypeScript-parser batch + (`findShellGuardrailSourceContractViolations`, + `build/lib/check-legacy-owners.mjs`), reusing this repo's established + `withParsedSources`/`walkTree`/`SyntaxKind` idiom — no new parser + dependency. Root-cause circuit breaker (post-merge): three code-review + passes each found a variant of one root cause — a hand-rolled, Map-based + scope/alias-resolution layer re-deriving JS/TS name-binding semantics by + hand. Restructured to resolve every identifier-binding question (a + Document/Window alias, an `addEventListener` handler/capture-options + alias) through the REAL TypeScript checker `withParsedSources` already + had available (`checker.getSymbolAtLocation` against the identifier's + resolved declaration) instead — same-function block shadowing, for-loop- + header shadowing, and correct reversion after a shadow's scope ends are + now real binder behavior, not custom scope-tracking code; the retired + helpers (`scopeOwnerOf`/`scopeChain`/`buildGlobalAliasMap`/ + `buildFunctionDeclMap`/`buildCaptureAliasMap`) are gone. Candidate + discovery (the AST shapes for `.appendChild`/`.append`/`addEventListener`/ + Escape comparisons) and the CSS `shell-fixed-position` scanner are + unchanged; the three rules' policy and diagnostic shapes are unchanged. + Also closes the inherited #586/#593-phase-1 finding: an + independent `tests/unit/resize-handle-thickness-contract.test.js` proves + `src/ui/app-shell.ts`'s `HANDLE_PX` and `src/styles.css`'s + `.col-resize`/`.inspector-resize` width cannot drift unnoticed. Enforcement- + only — no runtime UI/DOM/CSS behavior changes. - **#630 Phase 8 (final phase — closes #630): make `@altinity/clickhouse-http` independently buildable/packable/typecheckable in isolation, and retire the `@clickhouse/client-web` vendor-comparison spike.** Claims A17/A18. diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index c351001b..1360e948 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -100,6 +100,9 @@ import { retiredClientSpikeScriptNames, findDynamicImportUsages, mightContainDynamicImport, + findShellGuardrailSourceContractViolations, + findShellFixedPositionViolations, + findShellFixedPositionMissingBaselineViolations, } from './lib/check-legacy-owners.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -886,6 +889,63 @@ for (const file of collectFiles(path.join(repoRoot, 'src'))) { } } +// Issue #592 — shell primitive guardrails: lock in what #586 ("one overlay- +// lifecycle implementation") and #587 ("a registry-driven panel model") +// established, so the six-copy-pasted-overlays problem #586 fixed cannot +// silently regrow. Two rules share ONE real-TypeScript-parser batch over the +// whole scanned `src/**` tree (`findShellGuardrailSourceContractViolations`, +// `build/lib/check-legacy-owners.mjs` — Architecture decision 4: never one +// parser process per rule or per file — including its own complete-tree +// reverse-baseline half, folded in below via `completeTree: true` rather +// than a second call opening a second batch, #592 review pass 2, ChatGPT PR +// #672 pass 2 P2); a third is a focused CSS lexical scanner over +// `src/styles.css` alone (`findShellFixedPositionViolations` — Architecture +// decision 2: no CSS parser dependency). `lineOfOffset` converts +// each analyzer's raw AST/lexer byte offset into the 1-based line number this +// gate's own diagnostics use everywhere else. +function lineOfOffset(source, pos) { + return source.slice(0, pos).split('\n').length; +} +{ + const shellGuardedFiles = collectFiles(path.join(repoRoot, 'src')); + const shellSources = shellGuardedFiles.map((file) => { + const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); + checkedFiles += 1; + return { filename: relFile, source: guardedFileSources.get(file) ?? fs.readFileSync(file, 'utf8') }; + }); + const bySource = new Map(shellSources.map((s) => [s.filename, s.source])); + // `completeTree: true` folds the complete-tree reverse-baseline half (PR + // #672 review pass 1 follow-up, ChatGPT) into this SAME shared parser + // batch — meaningful only against the real, complete `src/**` tree, which + // `shellSources` (built from `collectFiles`'s live disk walk) always is + // here. #592 review pass 2 (ChatGPT PR #672 pass 2 P2): this used to be a + // separate call to `findShellGuardrailMissingBaselineViolations` over the + // identical `shellSources`, which opened its OWN second parser batch — + // see `findShellGuardrailSourceContractViolations`'s own doc comment for + // why that violated this file's "ONE shared parser batch" contract. + for (const v of findShellGuardrailSourceContractViolations(shellSources, { completeTree: true })) { + const line = lineOfOffset(bySource.get(v.filename) ?? '', v.pos); + violations.push(`${v.filename}:${line} → ${v.rule}: ${v.detail}`); + } + + const stylesPath = path.join(repoRoot, 'src/styles.css'); + if (fs.existsSync(stylesPath)) { + checkedFiles += 1; + const cssSource = fs.readFileSync(stylesPath, 'utf8'); + for (const v of findShellFixedPositionViolations(cssSource, 'src/styles.css')) { + const line = lineOfOffset(cssSource, v.pos); + violations.push(`${v.filename}:${line} → ${v.rule}: ${v.detail}`); + } + // The reverse half of the #672 P1 fix — an approved fixed-position + // fingerprint that disappeared from the CSS entirely. Meaningful only + // against the real, complete stylesheet (see the function's own doc + // comment), which this gate always reads from disk. + for (const v of findShellFixedPositionMissingBaselineViolations(cssSource, 'src/styles.css')) { + violations.push(`${v.filename}:${lineOfOffset(cssSource, v.pos)} → ${v.rule}: ${v.detail}`); + } + } +} + if (violations.length) { console.error('check-boundaries: architecture violations:'); for (const line of violations) console.error(` ${line}`); diff --git a/build/lib/check-legacy-owners.d.mts b/build/lib/check-legacy-owners.d.mts index 249e5215..c4acc3bd 100644 --- a/build/lib/check-legacy-owners.d.mts +++ b/build/lib/check-legacy-owners.d.mts @@ -40,13 +40,22 @@ export type SurfaceLifecycleRule = | 'surface-current-workspace-null' | 'surface-retirement-ordering'; +/** The #592 shell-primitive-guardrail rule codes + * `findShellGuardrailSourceContractViolations` (`shell-body-mount` / + * `shell-capture-escape`) and `findShellFixedPositionViolations` + * (`shell-fixed-position`) may report. */ +export type ShellGuardrailRule = + | 'shell-body-mount' + | 'shell-capture-escape' + | 'shell-fixed-position'; + /** One reported source-contract violation — a plain, JSON-serializable DTO. * `pos` is the offending AST node's own `getStart(sourceFile)` (or `0` for a * whole-file "the required construct is entirely absent" finding, which * names no single node): a stable, deterministic identity, never a * line/column and never required in a user-facing diagnostic. */ export interface SourceContractViolation { - readonly rule: SidePanelRule | SurfaceLifecycleRule; + readonly rule: SidePanelRule | SurfaceLifecycleRule | ShellGuardrailRule; readonly filename: string; readonly pos: number; readonly detail: string; @@ -95,3 +104,124 @@ export function findSurfaceLifecycleSourceContractViolations( sources: readonly SurfaceLifecycleSourceEntry[], options: SurfaceLifecycleOptions, ): SourceContractViolation[]; + +/** One (filename, raw source) entry in a #592 shell-guardrail batch — + * structurally identical to `SurfaceLifecycleSourceEntry` (both are just + * "a repo-relative filename plus that file's complete, unmodified text"), + * named separately so `findShellGuardrailSourceContractViolations`'s own + * signature documents its own #592 contract rather than borrowing a #590- + * named type. */ +export interface ShellGuardrailSourceEntry { + readonly filename: string; + readonly source: string; +} + +/** Options for `findShellGuardrailSourceContractViolations`. */ +export interface ShellGuardrailSourceContractOptions { + /** Fold `findShellGuardrailMissingBaselineViolations`'s own complete-tree + * reverse-baseline check into this call's SAME shared parser batch, + * instead of a caller opening a second batch to get it separately (#592 + * review pass 2, ChatGPT PR #672 pass 2 P2). Only pass `true` when + * `sources` really is the complete scanned tree — see + * `findShellGuardrailMissingBaselineViolations`'s own doc comment for why. */ + readonly completeTree?: boolean; +} + +/** + * The #592 shell-primitive-guardrail source contract (`shell-body-mount` + + * `shell-capture-escape`), real-TypeScript-parser-backed, over ONE shared + * parser batch for the complete `sources` set (never one parser process per + * rule or per file). `options.completeTree: true` additionally folds in the + * complete-tree reverse-baseline violations + * `findShellGuardrailMissingBaselineViolations` would otherwise report + * separately, over this SAME batch. + */ +export function findShellGuardrailSourceContractViolations( + sources: readonly ShellGuardrailSourceEntry[], + options?: ShellGuardrailSourceContractOptions, +): SourceContractViolation[]; + +/** + * The complete-tree REVERSE half of the #592 shell-guardrail source + * contract (`shell-body-mount` + `shell-capture-escape`) — deliberately + * SEPARATE from `findShellGuardrailSourceContractViolations`, for the same + * reason `findShellFixedPositionMissingBaselineViolations` is separate from + * `findShellFixedPositionViolations` (see that pair's own doc comments): the + * forward check's own softened reverse pass (`declaredScopeKeys`) exists + * ONLY to keep it safe for this suite's many minimal single-scope synthetic + * fixtures, and cannot distinguish a genuinely complete file with an + * approved function/scope deleted from a fixture that never declared that + * scope to begin with. This export assumes `sources` IS the complete + * scanned tree (its only real callers are `build/check-boundaries.mjs`'s + * live `collectFiles(src/)` batch — directly, and via + * `findShellGuardrailSourceContractViolations`'s own `completeTree: true` + * mode, #592 review pass 2) and reports, without that softening, every + * `SHELL_BODY_MOUNT_POLICY`/`SHELL_CAPTURE_ESCAPE_POLICY` entry whose + * approved occurrence count is not met in `sources` — covering a whole + * approved FILE missing from `sources` entirely, a whole approved + * function/scope deleted (or renamed) from a still-present file, and a + * dropped occurrence count within a still-present scope, uniformly (PR #672 + * review pass 1 follow-up, ChatGPT). This standalone export still opens its + * own parser batch when called directly (kept for every existing caller + * with no batch already open); the production `check:arch` wiring instead + * reaches this identical logic through `findShellGuardrailSourceContractViolations`'s + * `completeTree` mode, so the production path never opens two. + */ +export function findShellGuardrailMissingBaselineViolations( + sources: readonly ShellGuardrailSourceEntry[], +): SourceContractViolation[]; + +/** One `position: fixed` (optionally `!important`) CSS declaration found by + * `scanFixedPositionDeclarations` — `selector` is the enclosing rule's own + * normalized (whitespace-collapsed, comma-list-normalized) prelude; `atRule` + * is the FULL chain of enclosing AT-RULE ancestors' normalized preludes, + * outermost first, joined with `' > '` (e.g. `'@media (max-width: 768px)'`, + * or `'@supports (display: grid) > @media (max-width: 768px)'` for a rule + * nested under both), or `null` when no at-rule ancestor exists; `nested` is + * `true` when the declaration's own rule sits inside another PLAIN style + * rule (real CSS nesting — a genuinely different, never-approved effective + * selector, e.g. a descendant selector — ChatGPT PR #672 pass 1); `pos` is + * the declaration's own offset into the scanned CSS text (the first + * non-whitespace, non-comment character). */ +export interface FixedPositionDeclaration { + readonly selector: string; + readonly atRule: string | null; + readonly nested: boolean; + readonly pos: number; +} + +/** + * The focused CSS lexical scanner (Architecture decision 2, #592) — no CSS + * parser dependency. Skips CSS block comments, respects quoted strings and + * escapes, tracks brace nesting, and normalizes whitespace/comma-selector- + * lists deterministically; see the `.mjs` implementation's own doc comment + * for the full contract. + */ +export function scanFixedPositionDeclarations(source: string): FixedPositionDeclaration[]; + +/** + * The `shell-fixed-position` guard's FORWARD half: every + * `scanFixedPositionDeclarations` result in `cssSource` beyond its exact + * `(selector, atRule)` fingerprint's approved COUNT (never a mere membership + * check — a duplicate of an approved fingerprint is flagged too, PR #672 + * review pass 1). A `nested` declaration (real CSS nesting under another + * plain style rule) is unconditionally flagged instead — never compared by + * fingerprint at all (PR #672 review pass 1 follow-up, ChatGPT). + */ +export function findShellFixedPositionViolations( + cssSource: string, + filename: string, +): SourceContractViolation[]; + +/** + * The `shell-fixed-position` guard's REVERSE half (PR #672 review pass 1): + * every frozen `SHELL_FIXED_POSITION_POLICY` fingerprint with ZERO matching + * occurrences in `cssSource` — meaningful only against the real, complete + * `src/styles.css` (see the `.mjs` implementation's own doc comment for why + * this is a separate export from `findShellFixedPositionViolations` rather + * than folded into it). + */ +export function findShellFixedPositionMissingBaselineViolations( + cssSource: string, + filename: string, +): SourceContractViolation[]; diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 344b958e..552faccc 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -177,6 +177,20 @@ export const PHASE8_NARROW_RULE_D_EXCEPTIONS = Object.freeze({ // never collide, unlike the single-file wrapper's now-removed // basename-only scheme, which relied on there only ever being one entry to // namespace at all. +// Issue #592 addendum (Architecture decision 6, post-PR-#672-review) — this +// batch primitive also hands back each file's real TypeScript `checker: +// Checker` (via its `Project`, `snapshot.getDefaultProjectForFile(virtualPath) +// .checker`), alongside the `SourceFile` it always returned. Every prior +// caller's callback still destructures/ignores whatever it likes from a +// two-argument call — `fn(sourceFiles)` (the #630/#642/#587/#590 callers +// above/below, none of which need name-binding resolution) keeps working +// unchanged because JS simply drops an extra call argument a callback's own +// signature never names. The `checkers` map exists so #592's own +// `findShellGuardrailSourceContractViolations` (below) can ask the REAL +// TypeScript binder "what does this identifier resolve to" instead of +// re-deriving JS/TS scope semantics by hand — the root-cause fix for three +// review passes' worth of hand-rolled-scope-walker defects (see this file's +// `## Issue #592` section header comment for the retrospective). function withParsedSources(entries, fn) { const virtualPaths = new Map(); // filename -> virtualPath const files = {}; @@ -189,18 +203,19 @@ function withParsedSources(entries, fn) { try { const snapshot = api.updateSnapshot({ openFiles: [...virtualPaths.values()] }); const sourceFiles = new Map(); // filename -> SourceFile + const checkers = new Map(); // filename -> Checker for (const [filename, virtualPath] of virtualPaths) { - const sourceFile = snapshot - .getDefaultProjectForFile(virtualPath) - ?.program.getSourceFile(virtualPath); + const project = snapshot.getDefaultProjectForFile(virtualPath); + const sourceFile = project?.program.getSourceFile(virtualPath); if (!sourceFile) { // Fail loud, never silently-clean: an unparseable probe must not read // as "no violations". throw new Error(`check-legacy-owners: could not parse ${filename}`); } sourceFiles.set(filename, sourceFile); + checkers.set(filename, project.checker); } - return fn(sourceFiles); + return fn(sourceFiles, checkers); } finally { api.close(); // always reap the native child process, on every return path } @@ -210,9 +225,15 @@ function withParsedSources(entries, fn) { // TypeScript parser and hand back its root AST node — the original #630 // single-source entry point, now a thin one-entry wrapper over // `withParsedSources` above. Every existing caller's behavior (including the -// thrown-on-unparseable-source contract) is unchanged. +// thrown-on-unparseable-source contract) is unchanged; `fn`'s optional SECOND +// parameter (the file's own `Checker`, #592 addendum) is available to any +// future single-source caller that wants it, but no current caller of this +// wrapper does. function withParsedSource(source, filename, fn) { - return withParsedSources([{ source, filename }], (sourceFiles) => fn(sourceFiles.get(filename))); + return withParsedSources( + [{ source, filename }], + (sourceFiles, checkers) => fn(sourceFiles.get(filename), checkers.get(filename)), + ); } /** @@ -1964,3 +1985,2085 @@ export function findSurfaceLifecycleSourceContractViolations(sources, { appFile, return violations; }); } + +// ── Issue #592 — shell primitive guardrails ────────────────────────────────── +// +// Lock in what #586 ("one overlay-lifecycle implementation") and #587 ("a +// registry-driven panel model") established, mechanically, so the +// six-copy-pasted-overlays problem #586 fixed cannot silently regrow the same +// way it grew the first time. Three independent guards: +// 1. `shell-body-mount` — a new `Document.body.append`/`.appendChild` call +// outside the exact frozen #592 baseline snapshot (`SHELL_BODY_MOUNT_ +// POLICY` below), parser-backed (`findShellGuardrailSourceContract +// Violations`, this section); +// 2. `shell-fixed-position` — a new `position: fixed` CSS declaration in +// `src/styles.css` outside the exact frozen selector/at-rule snapshot +// (`SHELL_FIXED_POSITION_POLICY`), a focused CSS lexical scanner (no CSS +// parser dependency — `scanFixedPositionDeclarations`, below); +// 3. `shell-capture-escape` — a new global capture-phase Escape `keydown` +// lifecycle outside `SurfaceLifecycle` and the exact frozen exception/ +// non-panel-gesture snapshot (`SHELL_CAPTURE_ESCAPE_POLICY`), sharing the +// SAME parser batch as guard 1 (`findShellGuardrailSourceContract +// Violations` runs both over one `withParsedSources` call, never one +// parser process per rule). +// +// Every fingerprint below was generated by running the analyzers below over +// the CURRENT tree (post-#586/#587, pre-#592) and manually reviewing every +// resulting occurrence — never guessed from the issue body. In particular, +// `src/ui/dashboard-chart-interaction.ts`'s `beginSelection` capture-Escape +// listener (chart range-selection cancellation, not a panel close) and +// `src/ui/toast.ts`/`src/ui/app.ts`'s non-panel body mounts (export progress, +// the temporary download anchor) are real, reviewed occurrences the issue's +// own attachment underestimated — see the plan's "Verified repository +// baseline" section. Deleting an exception below must SHRINK this table, not +// leave the entry present with a stale rationale. +// +// Root-cause circuit breaker (Architecture decision 6, added post-PR-#672): +// three formal code-review passes on the original implementation +// (`570e089`/`ad4f71f`/`e76c8a7`) each found and fixed a real defect in a +// hand-rolled, Map-based scope-resolution layer this section used to carry +// (`scopeOwnerOf`/`scopeChain`/`declarationScopeOwnerOf`/ +// `buildGlobalAliasMap`/`buildFunctionDeclMap`/`buildCaptureAliasMap`/a +// body-alias scope map) — flat file-wide maps (pass 1), then same-function +// block shadowing not modeled (pass 2), then `var`/for-loop-header +// declaration-kind-unaware scoping plus object-literal evaluation order (pass +// 3). All three were variants of ONE root cause: re-deriving JavaScript/ +// TypeScript binding semantics by hand instead of asking the real TypeScript +// binder. `withParsedSources`'s `Project` (above) already exposes a real +// `checker: Checker` with genuine binder symbol resolution +// (`getSymbolAtLocation`) that answers "what does this identifier resolve +// to" correctly and for free — same-function block shadowing, for-loop-header +// shadowing, sibling-scope non-pollution, and correct reversion to an outer +// binding once a shadow's scope ends are all real TypeScript-binder behavior, +// not something this module needs to model itself. Every place below that +// used to answer that question through the hand-rolled maps now resolves it +// through `checker.getSymbolAtLocation` against the identifier's real +// declaration instead — the maps and their scope-chain-walking machinery are +// deleted outright, not patched again. Candidate discovery (the AST shapes +// for `.appendChild`/`.append`/`addEventListener`/Escape-comparison) and the +// capture-options constant evaluator's own real object-literal evaluation- +// order logic are UNCHANGED — neither is a name-binding question, so neither +// is this addendum's concern. + +/** Every transparent cast/assertion wrapper a body-mount/capture-escape + * receiver or handler argument may sit behind — reused verbatim from the + * #643 null-equivalent unwrap above (`ParenthesizedExpression`/ + * `AsExpression`/`SatisfiesExpression`/`NonNullExpression`/ + * `TypeAssertionExpression`): the same "transparent wrapper" concept, not + * specific to a null RHS. */ +const unwrapCastWrappers = unwrapNullEquivalentWrappers; + +/** The scope-name of one `FUNCTION_LIKE_KINDS` node: its own declared name + * (`function openMenu() {}`), or — for an anonymous function/arrow — the + * name of the binding it is assigned to (`const onKey = (e) => {}`) or the + * property it is assigned as (`{ mount: (ctx) => {} }`, `{ mount(ctx) {} }`), + * or the literal placeholder `''` when neither applies (e.g. an + * arrow passed directly as a call argument, `withDocument(doc, () => {})`). + */ +function scopeNameFor(fnLikeNode) { + if (fnLikeNode.name && fnLikeNode.name.kind === SyntaxKind.Identifier) return fnLikeNode.name.text; + const p = fnLikeNode.parent; + if (p) { + if (p.kind === SyntaxKind.VariableDeclaration && p.name && p.name.kind === SyntaxKind.Identifier) return p.name.text; + if (p.kind === SyntaxKind.PropertyAssignment && p.name) { + if (p.name.kind === SyntaxKind.Identifier) return p.name.text; + if (p.name.kind === SyntaxKind.StringLiteral) return p.name.text; + } + } + return ''; +} + +/** The FULL chain of enclosing named-or-placeholder scopes for `node`, from + * outermost to innermost (e.g. `['createApp', 'showExportProgress']`, + * `['openPipelineFullscreen', 'mount']`) — a PATH, not just the nearest + * name, so two differently-named outer functions that both happen to + * contain a same-named inner callback (`mount`, ``) still key + * distinctly. This is what keeps a body-mount/capture-escape exception from + * becoming filename-wide authorization: the policy tables below match on + * (filename, this whole path), never on filename alone. */ +function enclosingScopePath(node) { + const names = []; + let current = node.parent; + while (current) { + if (FUNCTION_LIKE_KINDS.has(current.kind)) names.push(scopeNameFor(current)); + current = current.parent; + } + names.reverse(); + return names.length ? names : ['']; +} + +/** The nearest enclosing `FUNCTION_LIKE_KINDS` ancestor node itself (not just + * its name) — used by the SurfaceLifecycle-composition check, which must + * walk that scope's own subtree for a companion `openSurfaceLifecycle(...)` + * call. `null` when `node` sits at module top level (no enclosing function + * at all — not a real occurrence for either #592 guard today, but handled + * rather than assumed impossible). Deliberately FUNCTION-granular, not + * block-granular — `bodyMountCandidates`'s `scopeNode` and the + * `enclosingScopePath`/`fullScopePathOf`/`declaredScopeKeys` scope-PATH + * concept both need "the whole named function" for POLICY matching, never a + * narrower nested block; real lexical (block-level) name-BINDING resolution + * is a different question, answered by the real TypeScript checker + * (`checker.getSymbolAtLocation`, see the #592 addendum section header + * comment above and `resolveGlobalKind`/`resolveHandlerNode`/ + * `resolveCaptureFlag`/`resolvesToDocumentBody` below), not by a hand-rolled + * block-scope walk. */ +function innermostScopeNode(node) { + let current = node.parent; + while (current) { + if (FUNCTION_LIKE_KINDS.has(current.kind)) return current; + current = current.parent; + } + return null; +} + +/** `key.join(' > ')` — the one join convention every #592 scope-path + * comparison (candidate generation AND the frozen policy tables) shares, so + * a separator mismatch can never silently make a real exception fail to + * match its own policy entry. */ +function scopeKey(scopePath) { + return scopePath.join(' > '); +} + +/** The full scope-path key for a `FUNCTION_LIKE_KINDS` node ITSELF — its own + * enclosing chain (`enclosingScopePath`'s ancestors) PLUS its own name + * (`scopeNameFor`) — e.g. `createAnchoredPopovers`'s nested `open` arrow + * yields `['createAnchoredPopovers', 'open']`, distinct from + * `enclosingScopePath(node)` (which never includes `node` itself, only what + * encloses it). Never carries the `` sentinel: `fnLikeNode` is + * always itself a real function-like node, so the walk always has at least + * one name. */ +function fullScopePathOf(fnLikeNode) { + const names = [scopeNameFor(fnLikeNode)]; + let current = fnLikeNode.parent; + while (current) { + if (FUNCTION_LIKE_KINDS.has(current.kind)) names.push(scopeNameFor(current)); + current = current.parent; + } + names.reverse(); + return names; +} + +/** Every scope-path KEY (`scopeKey(fullScopePathOf(...))`) actually declared + * somewhere in `sourceFile` — used only to ask "does this policy scope path + * even exist in what was scanned", which is what makes the #672 P1 + * missing-baseline-entry check (`shellBodyMountViolations`/ + * `shellCaptureEscapeViolations`'s own SOFTENED reverse pass) safe against + * this test suite's own established convention of a MINIMAL synthetic + * fixture reproducing just ONE of a real file's several approved scopes + * under that file's real name (`shell-guardrails-arch.test.ts`'s own header + * comment): a sibling policy entry whose scope was never even part of the + * scanned source is correctly treated as "not this call's concern" rather + * than "a disappeared baseline occurrence" — the missing-entry check only + * fires for a scope that is ACTUALLY PRESENT in the tree (so a genuine drop + * from N approved occurrences to fewer, within a scope that still exists, + * is still caught). + * + * This deliberate softening means a policy entry whose ENTIRE enclosing + * scope (or whole owning FILE) has also been deleted from production code + * is, BY DESIGN, invisible to `shellBodyMountViolations`/ + * `shellCaptureEscapeViolations`'s own softened reverse pass — a real + * function-like scope either is or isn't declared in whatever was parsed, + * and a genuinely complete file with an approved function deleted is + * structurally indistinguishable, by THIS mechanism alone, from a partial + * fixture that never declared it. That coarser question — is the batch + * handed to this module the COMPLETE scanned tree, so an absent scope/file + * really means "deleted" rather than "not this fixture's concern" — is + * answered by a deliberately SEPARATE, stricter export instead: + * `findShellGuardrailMissingBaselineViolations` (PR #672 review pass 1 + * follow-up, ChatGPT), which never consults `declaredScopeKeys` at all and + * is meaningful only against something the caller already knows is + * complete — exactly the same separation `findShellFixedPositionViolations` + * and `findShellFixedPositionMissingBaselineViolations` already establish + * for the CSS guard, for the identical reason. */ +function declaredScopeKeys(sourceFile) { + const keys = new Set(); + walkTree(sourceFile, (node) => { + if (FUNCTION_LIKE_KINDS.has(node.kind)) keys.add(scopeKey(fullScopePathOf(node))); + }); + return keys; +} + +/** True for a real call anywhere inside `scopeNode`'s subtree whose callee's + * own terminal (last) identifier segment is exactly `name` — e.g. + * `hasCallNamed(scope, 'openSurfaceLifecycle')` matches both + * `openSurfaceLifecycle(...)` and `x.openSurfaceLifecycle(...)` (the latter + * never occurs in practice for this name, but `terminalNames` doesn't care). + * Backs the one #592 body-mount exception (`results.ts`'s cell-detail + * overlay) whose permission is conditioned on retaining its SurfaceLifecycle + * composition, not just its body-mount shape. */ +function hasCallNamed(scopeNode, name) { + if (!scopeNode) return false; + let found = false; + walkTree(scopeNode, (node) => { + if (found) return; + if (node.kind !== SyntaxKind.CallExpression) return; + const names = terminalNames(node.expression, 1); + if (names.length === 1 && names[0] === name) found = true; + }); + return found; +} + +/** Every `TypeReferenceNode` name reachable from `typeNode` through a union/ + * intersection/parenthesized type — e.g. `Document`, `Document | null`, + * `(Document)`. Used only to recognize a parameter/variable declared WITH a + * `: Document` / `: Window` annotation (`childDoc: Document`, `mainDoc: + * Document`) as a document/window alias — see `classifyGlobalDeclaration` + * below. Purely syntactic (reads the type annotation's own AST text), so it + * works identically whether or not the checker can fully resolve the named + * type — including a `Document`/`Window` ambient global whose OWN + * declaration lives in `lib.dom.d.ts` (`resolveGlobalKind` below resolves + * the bare identifiers `document`/`window` through the exact same real- + * binder-then-inspect-the-declaration path as any local alias, since the + * real TypeScript checker resolves them to `declare var document: Document` + * / `declare var window: Window & typeof globalThis` either way). */ +function typeNamesOf(typeNode) { + const names = []; + const walk = (t) => { + if (!t) return; + if (t.kind === SyntaxKind.TypeReference && t.typeName && t.typeName.kind === SyntaxKind.Identifier) { + names.push(t.typeName.text); + } + if (t.kind === SyntaxKind.UnionType || t.kind === SyntaxKind.IntersectionType) { + for (const sub of t.types) walk(sub); + } + if (t.kind === SyntaxKind.ParenthesizedType) walk(t.type); + }; + walk(typeNode); + return names; +} + +/** Every ultimate type NAME `typeNode` structurally resolves to — the same + * union/intersection/parenthesized walk `typeNamesOf` performs, PLUS (#592 + * review, ChatGPT PR #672 pass 1) resolving each `TypeReference` through the + * REAL checker (`checker.getSymbolAtLocation` on the type name) to see + * whether it names a local `type X = ...` ALIAS declaration — if so, this + * recurses into the alias's OWN type annotation instead of stopping at the + * alias's bare name, so `type Doc = Document; function f(doc: Doc)` + * recognizes `doc` as a `Document` exactly like a direct `: Document` + * annotation would. A reference that does NOT resolve to a type alias (an + * interface, an ambient global like `Document`/`Window` itself, an + * unresolvable name) contributes its own literal name instead — unchanged + * from `typeNamesOf`'s behavior. `seen` (keyed by the alias's own + * declaration node) guards against infinite recursion on a + * self-/mutually-referential alias chain — real TypeScript itself already + * rejects a directly circular type alias at compile time, so this is pure + * defense-in-depth, never expected to trigger against real, valid source. + * Deliberately still NOT general type inference (Architecture decision 6's + * own non-goal): this only follows a NAMED alias's own declared type, never + * computes a structural/inferred type for an arbitrary expression. */ +function resolvedTypeNames(typeNode, checker, seen) { + const names = []; + const walk = (t) => { + if (!t) return; + if (t.kind === SyntaxKind.UnionType || t.kind === SyntaxKind.IntersectionType) { + for (const sub of t.types) walk(sub); + return; + } + if (t.kind === SyntaxKind.ParenthesizedType) { walk(t.type); return; } + if (t.kind !== SyntaxKind.TypeReference || !t.typeName || t.typeName.kind !== SyntaxKind.Identifier) return; + const symbol = checker.getSymbolAtLocation(t.typeName); + const handle = symbol?.declarations?.[0]; + const aliasDecl = handle?.resolve?.(); + if (aliasDecl && aliasDecl.kind === SyntaxKind.TypeAliasDeclaration && !seen.has(aliasDecl)) { + seen.add(aliasDecl); + walk(aliasDecl.type); + return; + } + names.push(t.typeName.text); + }; + walk(typeNode); + return names; +} + +/** The SOURCE property name a `BindingElement` destructures — its own + * `propertyName`'s literal spelling when present (a rename: a plain + * identifier, a string/no-substitution-template literal, or a computed key + * resolving to either — mirrors `staticPropertyKeyName`'s object-literal- + * member recognition below, applied here to a `BindingElement`'s own + * `propertyName` instead), or else the binding's own LOCAL identifier name + * when `propertyName` is absent (a NON-renamed destructuring, where the + * local name IS the source key). `undefined` only when genuinely + * unresolvable (an unresolved computed `propertyName`, or a nested + * destructuring pattern as the local name) — never silently treated as "not + * a match" for the WRONG reason. #592 review pass (ChatGPT PR #672 pass 1): + * the prior check recognized a `propertyName` only when its OWN kind was + * `Identifier`, so a QUOTED rename (`const { 'body': host } = document`) + * fell through to reading the LOCAL name `host` instead of the real source + * key `body` — silently wrong whenever the local name differs from the real + * source property, and simply missed the match whenever it doesn't happen + * to coincide. */ +function bindingElementSourceKeyName(declNode) { + const propertyName = declNode.propertyName; + if (propertyName) { + if (propertyName.kind === SyntaxKind.Identifier) return propertyName.text; + if ( + propertyName.kind === SyntaxKind.StringLiteral || propertyName.kind === SyntaxKind.NoSubstitutionTemplateLiteral + ) return propertyName.text; + if (propertyName.kind === SyntaxKind.ComputedPropertyName) { + const inner = unwrapCastWrappers(propertyName.expression); + if (inner && (inner.kind === SyntaxKind.StringLiteral || inner.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + return inner.text; + } + } + return undefined; + } + return declNode.name && declNode.name.kind === SyntaxKind.Identifier ? declNode.name.text : undefined; +} + +/** `openInDetachedTab`'s `mount()` callback destructures its one parameter — + * `({ doc, bar, body, close, closeBtn }: MountCtx) => {...}` — and `doc` is + * a real `Document` (`MountCtx.doc`, `src/ui/detached-view.ts`), but it's + * bound via PLAIN (non-renamed) destructuring of a parameter whose OWN type + * annotation names `MountCtx`, not `Document` directly — a shape + * `classifyGlobalDeclaration` below's other rules can't see on their own + * (its Parameter/VariableDeclaration rule only looks at a plain-IDENTIFIER + * binding's own type; its BindingElement rule only recognizes a RENAMED + * `{ document: doc }` form). Resolving `MountCtx`'s OWN member types would + * need full type inference across a module graph (`MountCtx` is frequently + * imported cross-file — see `explain-graph.ts`/`results.ts`), which this + * check deliberately does not attempt (Architecture decision 6's own + * non-goal: the checker answers "what does this name resolve to", not + * general type checking) — so `MountCtx` is named explicitly here rather + * than inferred, the one #592-reviewed real shape, confirmed at its three + * real call sites (`explain-graph.ts` ×2, `results.ts` ×1). */ +const MOUNT_CTX_TYPE_NAME = 'MountCtx'; + +/** + * Classify a real BINDING declaration node (what `checker.getSymbolAtLocation` + * resolved an identifier reference TO) as denoting a `Document`, a `Window`, + * or neither — the Architecture-decision-6 replacement for the #592 review + * passes' hand-rolled `buildGlobalAliasMap`: instead of pre-walking the whole + * file into a scope-keyed alias table, this inspects ONE already-resolved + * declaration node directly, purely structurally: + * - a destructuring rename whose source key is `document`/`window` + * (`const { document: doc } = opts` — `menu.ts`'s real shape; a QUOTED + * spelling — `const { 'document': doc } = opts` — is recognized + * identically, via `bindingElementSourceKeyName`'s own literal-form + * recognition, #592 review pass/ChatGPT PR #672 pass 1); + * - a plain (non-renamed) destructuring of a `doc` property from a + * parameter/variable whose OWN type annotation names `MountCtx` (see + * `MOUNT_CTX_TYPE_NAME` above); + * - a `Parameter` or `VariableDeclaration` whose declared TYPE resolves + * (`resolvedTypeNames`, following any local `type X = ...` ALIAS chain + * through the real checker — #592 review pass/ChatGPT PR #672 pass 1) to + * `Document`/`Window` (`childDoc: Document`, `mainDoc: Document`, or + * `type Doc = Document; function f(doc: Doc)`) — this ALSO covers the + * bare globals `document`/`window` themselves: the real TypeScript + * checker resolves each to its own ambient `declare var document: + * Document` / `declare var window: Window & typeof globalThis` + * declaration in `lib.dom.d.ts`, which has exactly this shape, so no + * separate bare-identifier special case is needed; + * - a `VariableDeclaration` with NO type annotation: classified through its + * own initializer, recursively, via `resolveGlobalKind` below (`const doc + * = document.body.ownerDocument` and similar chains) — AND (#592 review + * pass 3) through every LATER whole-binding reassignment too, when the + * binding is a genuine `let`/`var` (`laterAssignmentResolvesToGlobalKind` + * below): a `let`/`var` binding can be reassigned anywhere in its scope, + * so classifying it from its initializer ALONE previously missed `let + * doc = window; doc = document; doc.body.appendChild(panel);` entirely — + * `doc` classified as `'window'` from its stale initializer, even though + * the value actually in effect at the real `.body` receiver use is + * `document`. This mirrors the write-awareness + * `resolvesToDocumentBody`/`laterAssignmentResolvesToDocumentBody` already + * apply to the body-ALIAS resolver, applied here to the Document/Window + * RECEIVER resolver itself. `seen` (keyed by the declaration node already + * being resolved) guards against an alias-reassignment CYCLE + * (`let a = x; let b = a; a = b;`) causing unbounded recursion — the same + * defense-in-depth precedent `resolvedTypeNames` already establishes for + * a type-alias cycle. + * Every one of these is answered by inspecting real AST structure the + * checker's binder already led us to — no scope-chain walk, alias map, or + * declaration-kind (`var` vs `let`/`const`) tracking of any kind: the checker + * already resolved WHICH declaration this identifier means, respecting real + * block/function scoping, hoisting, and shadowing for free. + * + * @param {object} declNode + * @param {object} checker the file's real TypeScript `Checker` — used for the + * type-alias-chain resolution (`resolvedTypeNames`) and the no-type- + * annotation initializer branch's recursive call + * @param {object} [sourceFile] the file `declNode` was parsed from — required + * to detect a later reassignment of a `let`/`var` binding; every real + * caller has one, so only synthetic/legacy call sites without one skip + * that specific check (falling back to the initializer-only classification) + * @param {Set} [seen] declaration nodes already being resolved on + * this call's recursion path — cycle guard, see above + * @returns {'document'|'window'|null} + */ +function classifyGlobalDeclaration(declNode, checker, sourceFile, seen = new Set()) { + if (declNode.kind === SyntaxKind.BindingElement && declNode.propertyName) { + const propName = bindingElementSourceKeyName(declNode); + if (propName === 'document') return 'document'; + if (propName === 'window') return 'window'; + } + if ( + declNode.kind === SyntaxKind.BindingElement && !declNode.propertyName + && declNode.name && declNode.name.kind === SyntaxKind.Identifier && declNode.name.text === 'doc' + ) { + const pattern = declNode.parent; + const owner = pattern && pattern.parent; + if (owner && owner.type && typeNamesOf(owner.type).includes(MOUNT_CTX_TYPE_NAME)) return 'document'; + } + if ((declNode.kind === SyntaxKind.Parameter || declNode.kind === SyntaxKind.VariableDeclaration) && declNode.type) { + const names = resolvedTypeNames(declNode.type, checker, new Set()); + if (names.includes('Document')) return 'document'; + if (names.includes('Window')) return 'window'; + } + if (declNode.kind === SyntaxKind.VariableDeclaration && !declNode.type && declNode.initializer) { + if (seen.has(declNode)) return null; + seen.add(declNode); + const initKind = resolveGlobalKind(declNode.initializer, checker, sourceFile, seen); + if (isConstVariableDeclaration(declNode) || !sourceFile) return initKind; + if (initKind === 'document' || laterAssignmentResolvesToGlobalKind(sourceFile, declNode, checker, 'document', seen)) { + return 'document'; + } + if (initKind === 'window' || laterAssignmentResolvesToGlobalKind(sourceFile, declNode, checker, 'window', seen)) { + return 'window'; + } + return null; + } + return null; +} + +/** + * Structurally resolve whether `node` denotes a `Document` (`'document'`), a + * `Window` (`'window'`), or neither (`null`) — covering, per the plan's own + * candidate-recognition list: the bare globals `document`/`window` and every + * simple alias of either (resolved through the REAL TypeScript checker — + * `checker.getSymbolAtLocation` against the identifier's own declaration, + * then `classifyGlobalDeclaration` above — never a hand-rolled scope-chain + * lookup); a member access chain ending in `.document`/`.window` regardless + * of receiver (`window.document`, `opts.document`, `deps.document`, + * `env.document` all qualify — the plan is explicit that ANY receiver + * counts, since the exact `opts.document` shape is what + * `dashboard-chart-interaction.ts`'s `beginSelection` needs); the + * bracket-property spelling (`doc['body']['appendChild']`'s own receiver + * chain uses the SAME check on `doc`, but a literal `x['document']` also + * resolves here for symmetry); and `||`/`??` (either operand) or `&&` (the + * right operand only — `a && a.document` evaluates to `a.document`, or a + * falsy `a`, so only the right side is ever the actual receiver at runtime) + * short-circuit forms, plus a ternary's either branch. Transparent cast/ + * assertion wrappers are unwrapped first. Every other shape (a call, a + * non-literal computed member, an unresolvable identifier) returns `null` — + * the caller treats `null` as "not a recognized global", never as a silent + * pass for a DIFFERENT reason. + * + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @param {object} [sourceFile] the file `node` was parsed from — threaded + * through to `classifyGlobalDeclaration` so a `let`/`var` receiver alias's + * LATER reassignment (not only its initializer) is considered; every real + * caller has one, so only synthetic/legacy call sites without one fall back + * to initializer-only classification + * @param {Set} [seen] declaration nodes already being resolved on + * this call's recursion path — cycle guard, threaded through every + * recursive call below so sibling `||`/`??`/ternary branches and + * `classifyGlobalDeclaration`'s own later-assignment walk all share one + * cycle-safe view + * @returns {'document'|'window'|null} + */ +function resolveGlobalKind(node, checker, sourceFile, seen = new Set()) { + const expr = unwrapCastWrappers(node); + if (!expr) return null; + if (expr.kind === SyntaxKind.Identifier) { + const symbol = checker.getSymbolAtLocation(expr); + if (!symbol) return null; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + const declNode = handle?.resolve(); + return declNode ? classifyGlobalDeclaration(declNode, checker, sourceFile, seen) : null; + } + if (expr.kind === SyntaxKind.PropertyAccessExpression) { + if (expr.name.text === 'document') return 'document'; + if (expr.name.text === 'window') return 'window'; + return null; + } + if (expr.kind === SyntaxKind.ElementAccessExpression) { + const arg = expr.argumentExpression; + if (arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + if (arg.text === 'document') return 'document'; + if (arg.text === 'window') return 'window'; + } + return null; + } + if (expr.kind === SyntaxKind.BinaryExpression) { + const op = expr.operatorToken.kind; + if (op === SyntaxKind.BarBarToken || op === SyntaxKind.QuestionQuestionToken) { + return resolveGlobalKind(expr.left, checker, sourceFile, seen) ?? resolveGlobalKind(expr.right, checker, sourceFile, seen); + } + if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, checker, sourceFile, seen); + return null; + } + if (expr.kind === SyntaxKind.ConditionalExpression) { + return resolveGlobalKind(expr.whenTrue, checker, sourceFile, seen) ?? resolveGlobalKind(expr.whenFalse, checker, sourceFile, seen); + } + return null; +} + +/** True when `declNode` (a non-`const` `let`/`var` `VariableDeclaration`, + * already known to have no type annotation — `classifyGlobalDeclaration`'s + * own precondition) is EVER whole-binding reassigned (` = + * ;`) anywhere in `sourceFile` to a value that itself resolves + * (`resolveGlobalKind`) to exactly `kind` ('document' or 'window') — the + * Document/Window RECEIVER-classification analogue of + * `laterAssignmentResolvesToDocumentBody` above, applied to + * `classifyGlobalDeclaration`'s untyped-variable branch (#592 review pass + * 3): `let doc = window; doc = document; doc.body.appendChild(panel);` + * previously classified `doc` from its stale `window` initializer alone, so + * this exact reassignment — the value actually in effect at the real + * receiver use — was invisible to both `shell-body-mount` (`doc.body`) and + * `shell-capture-escape` (`doc.addEventListener(...)`). Deliberately no + * control-flow/ordering analysis, matching every other later-assignment walk + * in this module (`laterAssignmentResolvesToDocumentBody`, + * `hasCapturePropertyMutation`) — ANY such reassignment anywhere is + * conservatively enough to attribute `kind` to the whole binding. + * + * @param {object} sourceFile + * @param {object} declNode the non-const `VariableDeclaration` binding + * @param {object} checker the file's real TypeScript `Checker` + * @param {'document'|'window'} kind + * @param {Set} seen cycle guard, threaded from the caller + * @returns {boolean} + */ +function laterAssignmentResolvesToGlobalKind(sourceFile, declNode, checker, kind, seen) { + let found = false; + walkTree(sourceFile, (node) => { + if (found) return; + if (node.kind !== SyntaxKind.BinaryExpression || node.operatorToken.kind !== SyntaxKind.EqualsToken) return; + const target = node.left; + if (target.kind !== SyntaxKind.Identifier) return; + const symbol = checker.getSymbolAtLocation(target); + if (!symbol) return; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + if (handle?.resolve() !== declNode) return; + if (resolveGlobalKind(node.right, checker, sourceFile, seen) === kind) found = true; + }); + return found; +} + +/** True when `declNode` (a `VariableDeclaration`) sits in a genuine `const` + * `VariableDeclarationList` — a binding that can never be reassigned after + * its own initialization, so trusting that initializer for EVERY later + * reference to the same binding is sound. A `let`/`var` binding can be + * reassigned anywhere in its scope, so a reference resolved to one is NOT + * safely reducible to "whatever its initializer says" (#592 review pass, + * ChatGPT PR #672 pass 1): `resolveHandlerNode`/`resolveCaptureFlag` below + * previously trusted ANY resolved `VariableDeclaration`'s initializer + * regardless of const-ness, so `let opts = { capture: false }; opts = { + * capture: true };` (or the analogous bare-boolean/handler-function form) + * silently escaped detection — the LATER value, the one actually in effect + * at the real `addEventListener` call, was never even considered. Both + * callers already have a documented fail-closed contract for "cannot be + * resolved" (return `null`, which the caller always treats as a violation + * — never as "assume clean"), so refusing to trust a non-`const` alias's + * initializer here is a strict IMPROVEMENT in detection, never a + * regression: it can only turn a previously-missed case into a correctly + * flagged "uncheckable" one, never the reverse. */ +function isConstVariableDeclaration(declNode) { + const list = declNode.parent; + return !!list && list.kind === SyntaxKind.VariableDeclarationList && (list.flags & NodeFlags.Const) !== 0; +} + +/** + * Resolve an `addEventListener` handler argument to the `FUNCTION_LIKE_KINDS` + * node it actually runs — an inline arrow/function expression directly, or a + * plain `Identifier` resolved through the REAL TypeScript checker + * (`checker.getSymbolAtLocation` against the identifier's own declaration — + * the Architecture-decision-6 replacement for the #592 review passes' + * hand-rolled `buildFunctionDeclMap`/lexical-scope-chain lookup): a + * `FunctionDeclaration` declaration resolves directly; a `VariableDeclaration` + * whose initializer is an arrow/function expression resolves to that + * initializer; anything else (a `Parameter`, a `BindingElement`, a + * `VariableDeclaration` with a non-function initializer, …) is unresolved. + * `null` for anything else at all (a member access, a call, a conditional, an + * identifier the checker can't bind, …) — the plan's own fail-closed + * requirement: "if a global capture keydown handler cannot be statically + * resolved, report it as uncheckable rather than treating it as non-Escape", + * so the caller must treat `null` as an unconditional violation, never as + * "assume clean". Because the checker's own binder resolves EACH reference to + * its correct governing declaration (respecting real block/function scoping + * and shadowing), there is no "nearest-preceding-by-source-position" heuristic + * needed here either — the checker already answers "which declaration does + * THIS specific reference mean". A `VariableDeclaration` alias must ALSO be a + * genuine `const` (`isConstVariableDeclaration` above, #592 review pass, + * ChatGPT PR #672 pass 1) before its initializer is trusted: a `let` + * reassigned to a DIFFERENT function after its declaration (`let onKey = + * () => {}; onKey = (e) => { if (e.key === 'Escape') close(); };`) would + * otherwise resolve to the STALE initial function — exactly the "cannot be + * statically resolved" case this function's own contract already requires + * failing closed on, not a silent resolution to the wrong handler. + * + * @param {object} handlerArg + * @param {object} checker the file's real TypeScript `Checker` + * @returns {object | null} + */ +function resolveHandlerNode(handlerArg, checker) { + const expr = unwrapCastWrappers(handlerArg); + if (!expr) return null; + if (FUNCTION_LIKE_KINDS.has(expr.kind)) return expr; + if (expr.kind !== SyntaxKind.Identifier) return null; + const symbol = checker.getSymbolAtLocation(expr); + if (!symbol) return null; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + const declNode = handle?.resolve(); + if (!declNode) return null; + if (FUNCTION_LIKE_KINDS.has(declNode.kind)) return declNode; + if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer && isConstVariableDeclaration(declNode)) { + const init = unwrapCastWrappers(declNode.initializer); + if (init && FUNCTION_LIKE_KINDS.has(init.kind)) return init; + } + return null; +} + +/** Best-effort static name of an object-literal member's key — a plain + * `Identifier` (`{ capture: … }`, and — since a `ShorthandPropertyAssignment`'s + * `.name` IS both the key AND the value reference — also `{ capture }`), a + * string/no-substitution-template-literal key (`{ 'capture': … }`), a + * numeric-literal key (never legally spells `capture`, but still a real + * static name, not an unresolvable one), or a computed key whose expression + * resolves (after unwrapping cast wrappers) to one of those same literal + * kinds (`{ ['capture']: … }`). Returns `undefined` — deliberately distinct + * from any resolved string — only when the key's own name genuinely cannot + * be determined (a computed key with a non-literal expression), so a caller + * can fail closed on "this might be the key I'm looking for" instead of + * silently treating it as "definitely isn't". */ +function staticPropertyKeyName(member) { + const name = member.name; + if (!name) return undefined; + if (name.kind === SyntaxKind.ComputedPropertyName) { + const inner = unwrapCastWrappers(name.expression); + if (inner && (inner.kind === SyntaxKind.StringLiteral || inner.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + return inner.text; + } + return undefined; + } + return typeof name.text === 'string' ? name.text : undefined; +} + +/** Resolve an `addEventListener` OPTIONS object literal's own `capture` + * member to `true`/`false`, or `null` when unresolvable, respecting REAL + * object-literal property EVALUATION ORDER (#592 review pass 3): a real JS + * object literal evaluates its properties left to right, and a LATER + * property or spread always overrides an EARLIER same-key value — so this + * walks `node.properties` in source order and tracks only the LAST + * capture-affecting event, never "the first/any explicit `capture` member + * found", which is what let a later `SpreadAssignment` or unresolvable key + * silently fail to override an earlier explicit `capture: false` (e.g. + * `{ capture: false, ...{ capture: true } }`, which really runs + * capture-phase). Each property is one of two effects on the running + * result: a KNOWN effect (an explicit `capture` member — plain identifier, + * string/computed-string-literal key, or `ShorthandPropertyAssignment` + * shorthand — resolved recursively through `resolveCaptureFlag`, so a + * shorthand `{ capture }` reusing an in-scope boolean alias resolves + * exactly like `{ capture: someAlias }` would) that OVERWRITES whatever the + * running result was, or an UNKNOWN effect (a `SpreadAssignment`, a + * `capture` key whose own static name can't be determined at all — + * `staticPropertyKeyName` returns `undefined`, since it MIGHT be the very + * `capture` key being looked for — or a `capture` key that exists only as a + * method/accessor, never a plain boolean value) that conservatively makes + * the running result unresolvable, since its real contents can't be proven + * NOT to (re)define `capture`. A property whose static key resolves to + * anything OTHER than `capture` has no effect on `capture` at all and is + * skipped, exactly like before. The FINAL running result — after the last + * property is processed — is this function's answer: no capture-affecting + * property/spread was ever seen at all is provably `false` (the DOM + * default), matching `addEventListener`'s own spec default; #592 review + * pass 2 already fixed the narrower "recognize every `capture` key + * spelling" gap this order-aware walk preserves. + * + * This evaluation-order logic is UNCHANGED by Architecture decision 6 (#592 + * addendum) — real object-literal property/spread evaluation order is not a + * name-binding question, so the real TypeScript checker has no bearing on it. + * Only the recursive `resolveCaptureFlag` call below (for resolving an + * explicit `capture` VALUE that turns out to itself be an identifier alias) + * now goes through the checker instead of a hand-rolled alias map. + * + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @param {object} [sourceFile] the file `node` was parsed from — threaded + * through to `resolveCaptureFlag` so a nested identifier alias can itself + * be checked for a later property mutation + * @returns {boolean | null} + */ +function resolveObjectCaptureLiteral(node, checker, sourceFile) { + let lastKnownValueNode = null; // meaningful only while `lastEventKnown === true` + let lastEventKnown = null; // null: no capture-affecting property seen yet; true: known; false: unknown/override-capable + for (const p of node.properties) { + if (p.kind === SyntaxKind.SpreadAssignment) { lastEventKnown = false; continue; } + const keyName = staticPropertyKeyName(p); + if (keyName === undefined) { lastEventKnown = false; continue; } + if (keyName !== 'capture') continue; + if (p.kind === SyntaxKind.PropertyAssignment) { lastKnownValueNode = p.initializer; lastEventKnown = true; } + else if (p.kind === SyntaxKind.ShorthandPropertyAssignment) { lastKnownValueNode = p.name; lastEventKnown = true; } + else lastEventKnown = false; // a method/get/set named `capture` — never a plain boolean + } + if (lastEventKnown === true) return resolveCaptureFlag(lastKnownValueNode, checker, sourceFile); + return lastEventKnown === false ? null : false; +} + +/** True when `rExpr` (already unwrapped) is an `Identifier` whose + * declaration chain resolves to `declNode` itself — directly, or through + * any number of hops of a PLAIN identifier-initialized alias (`const alias + * = opts;`), `let`/`var` included (a receiver need not itself be `const` to + * still reference the exact same runtime object at the moment of a write — + * only `declNode`'s OWN const-ness matters for whether ITS literal is + * trustworthy at all, which `resolveCaptureFlag` already gates before ever + * calling `hasCapturePropertyMutation`) — #592 review pass 3: the prior + * inline check here only ever compared the write's OWN receiver identifier + * against `declNode` directly, so `const alias = opts; alias.capture = + * true;` resolved `alias` to ITS OWN, distinct declaration and never + * reached `declNode` at all, silently trusting a stale snapshot even though + * `alias` and `opts` reference the identical object. `seen` guards against + * an alias cycle (the same defense-in-depth precedent `resolvedTypeNames` + * and the write-aware Document/Window resolvers above already establish). */ +function receiverAliasesDecl(rExpr, checker, declNode, seen) { + if (!rExpr || rExpr.kind !== SyntaxKind.Identifier) return false; + const sym = checker.getSymbolAtLocation(rExpr); + if (!sym) return false; + const handle = sym.valueDeclaration ?? sym.declarations[0]; + const resolved = handle?.resolve(); + if (!resolved) return false; + if (resolved === declNode) return true; + if (seen.has(resolved)) return false; + seen.add(resolved); + if (resolved.kind !== SyntaxKind.VariableDeclaration || !resolved.initializer) return false; + return receiverAliasesDecl(unwrapCastWrappers(resolved.initializer), checker, declNode, seen); +} + +/** True when ANY `.capture = …` / `['capture'] = …` assignment — + * a plain `=`, or ANY compound assignment operator (`||=`, `&&=`, `??=`, + * and every arithmetic/bitwise compound form — `SyntaxKind.FirstAssignment` + * .. `SyntaxKind.LastAssignment` is the real TypeScript AST's own contiguous + * range covering every assignment-operator token, #592 review pass 3) — + * exists anywhere in `sourceFile` whose OWN receiver resolves (via the real + * checker, `receiverAliasesDecl` above — directly, or through a plain alias + * of the same object) to the exact same binding as `declNode` — i.e. + * whether a property write elsewhere can change what + * `resolveObjectCaptureLiteral`'s snapshot of the object literal's OWN + * properties would otherwise "provably" answer (#592 review pass 2, ChatGPT + * PR #672 pass 2 P1): `const opts = { capture: false }; opts.capture = + * true; document.addEventListener(..., opts)` previously trusted the + * literal's OWN properties forever, even though the value actually in + * effect at the real `addEventListener` call had already changed. #592 + * review pass 3 closed two further escapes of the identical shape: a + * compound operator (`opts.capture ||= true`) was invisible because the + * operator check accepted only a plain `=`, and an ALIAS-mediated write + * (`const alias = opts; alias.capture = true;`) was invisible because the + * receiver-resolution check compared only the write's own receiver + * identifier, never following a plain alias back to `declNode`. This + * deliberately does NOT attempt real control-flow/ordering analysis — + * unlike `resolveObjectCaptureLiteral`'s own property-evaluation-order walk + * (sound because object-literal property order is a real, unconditional JS + * semantic), a later statement's execution order is not, once + * branches/loops exist, and the PR's Architecture decision 6 addendum + * frames that as out of this restructuring's scope — so ANY such write + * anywhere fails this const object's own properties CLOSED (this resolver + * returns `true`, the caller's existing "cannot resolve" contract), rather + * than risk trusting a stale snapshot. + * + * @param {object} sourceFile + * @param {object} declNode the const `VariableDeclaration` binding the object literal + * @param {object} checker the file's real TypeScript `Checker` + * @returns {boolean} + */ +function hasCapturePropertyMutation(sourceFile, declNode, checker) { + let found = false; + walkTree(sourceFile, (node) => { + if (found) return; + if (node.kind !== SyntaxKind.BinaryExpression) return; + const op = node.operatorToken.kind; + if (op < SyntaxKind.FirstAssignment || op > SyntaxKind.LastAssignment) return; + const target = node.left; + let receiver = null; + let propName; + if (target.kind === SyntaxKind.PropertyAccessExpression) { + receiver = target.expression; + propName = target.name.text; + } else if (target.kind === SyntaxKind.ElementAccessExpression) { + const arg = unwrapCastWrappers(target.argumentExpression); + if (arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + receiver = target.expression; + propName = arg.text; + } + } + if (propName !== 'capture' || !receiver) return; + const rExpr = unwrapCastWrappers(receiver); + if (receiverAliasesDecl(rExpr, checker, declNode, new Set())) found = true; + }); + return found; +} + +/** + * Resolve an `addEventListener` THIRD argument to `true` (capture), `false` + * (non-capture — proven), or `null` (cannot prove non-capture — the plan's + * own fail-closed requirement: "for a keydown listener whose options cannot + * be resolved enough to prove it is non-capture, fail closed rather than + * silently assuming capture: false"). Covers a bare boolean literal, an + * options object literal (`resolveObjectCaptureLiteral`), and a simple local + * const alias of either form — resolved through the REAL TypeScript checker + * (`checker.getSymbolAtLocation` against the identifier's own + * `VariableDeclaration`, then recursing into its initializer) — the + * Architecture-decision-6 replacement for the #592 review passes' + * hand-rolled `buildCaptureAliasMap`/lexical-scope-chain lookup: a same-named + * alias in an unrelated sibling/nested scope, or a same-named `var`/ + * for-header `let` binding, can never satisfy a lookup for a DIFFERENT real + * scope's own reference, because the checker's binder already resolved + * WHICH declaration this specific reference means. Every other shape (a + * member access, a call, a conditional, an unresolved identifier, a + * resolved declaration that isn't a plain `VariableDeclaration` with an + * initializer — e.g. a destructured `BindingElement`, never supported here + * either — OR (#592 review pass, ChatGPT PR #672 pass 1) a `VariableDeclaration` + * that is NOT a genuine `const` — `isConstVariableDeclaration` above) is + * `null`: a `let`/`var` alias CAN be reassigned anywhere in its scope + * (`let opts = { capture: false }; opts = { capture: true };`), so trusting + * only its own initializer would silently miss the value actually in effect + * at the real `addEventListener` call — exactly the "cannot prove + * non-capture" case this function's own contract already requires failing + * closed on, never a silent resolution to a stale value. + * + * A `ShorthandPropertyAssignment`'s own name node (`{ capture }`'s `capture`) + * is a special case the real checker itself distinguishes: plain + * `checker.getSymbolAtLocation` on that identifier resolves to the object + * LITERAL's own property symbol (an object-literal member named `capture`), + * not the outer variable it shorthand-references — `checker + * .getShorthandAssignmentValueSymbol` is the dedicated API for "what value + * does this shorthand property actually reference", so this function calls + * that instead whenever `node` is itself a shorthand property's name. + * + * A resolved `const` binding whose object literal is itself later mutated + * via a `.capture = …`/`['capture'] = …` property write anywhere in + * `sourceFile` (`hasCapturePropertyMutation`, #592 review pass 2, ChatGPT PR + * #672 pass 2 P1) is ALSO `null` — a property mutation reachable after + * construction makes the literal's own properties exactly as untrustworthy + * as a whole-binding `let`/`var` reassignment, even though the binding + * itself is `const`. + * + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @param {object} [sourceFile] the file `node` was parsed from — required to + * detect a later property mutation of a resolved const object literal; + * every real caller has one, so only synthetic/legacy call sites without + * one skip that specific check + * @returns {boolean | null} + */ +function resolveCaptureFlag(node, checker, sourceFile) { + const expr = unwrapCastWrappers(node); + if (!expr) return null; + if (expr.kind === SyntaxKind.TrueKeyword) return true; + if (expr.kind === SyntaxKind.FalseKeyword) return false; + if (expr.kind === SyntaxKind.ObjectLiteralExpression) return resolveObjectCaptureLiteral(expr, checker, sourceFile); + if (expr.kind !== SyntaxKind.Identifier) return null; + const isShorthandName = expr.parent + && expr.parent.kind === SyntaxKind.ShorthandPropertyAssignment && expr.parent.name === expr; + const symbol = isShorthandName + ? checker.getShorthandAssignmentValueSymbol(expr.parent) + : checker.getSymbolAtLocation(expr); + if (!symbol) return null; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + const declNode = handle?.resolve(); + if ( + !declNode || declNode.kind !== SyntaxKind.VariableDeclaration || !declNode.initializer + || !isConstVariableDeclaration(declNode) + ) return null; + if (sourceFile && hasCapturePropertyMutation(sourceFile, declNode, checker)) return null; + return resolveCaptureFlag(declNode.initializer, checker, sourceFile); +} + +/** The complete decoded string value `node` structurally resolves to, or + * `undefined` when it cannot be reduced to one concrete literal at all — + * a plain string/no-substitution-template literal directly, or a `const` + * identifier alias (`const ESC = 'Escape';`) resolved through the REAL + * TypeScript checker, recursively (a further alias of that alias resolves + * the same way, one more hop). `undefined` is deliberately distinct from + * any resolved string (including `''`) — a caller uses it to fail closed + * on "cannot prove this ISN'T the Escape literal" rather than silently + * treating an unresolvable comparison as definitely not Escape. Never + * trusts a `let`/`var` binding's initializer (`isConstVariableDeclaration`, + * same #592 review-pass rule every other alias resolver in this module + * already applies): a reassignable alias's initializer is not reliably + * the value in effect at any later reference. + * + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @returns {string | undefined} + */ +function resolveStringLiteralValue(node, checker) { + const expr = unwrapCastWrappers(node); + if (!expr) return undefined; + if (expr.kind === SyntaxKind.StringLiteral || expr.kind === SyntaxKind.NoSubstitutionTemplateLiteral) { + return expr.text; + } + if (expr.kind !== SyntaxKind.Identifier) return undefined; + const symbol = checker.getSymbolAtLocation(expr); + if (!symbol) return undefined; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + const declNode = handle?.resolve(); + if ( + !declNode || declNode.kind !== SyntaxKind.VariableDeclaration || !declNode.initializer + || !isConstVariableDeclaration(declNode) + ) return undefined; + return resolveStringLiteralValue(declNode.initializer, checker); +} + +/** Classify one `===`/`!==` Escape-testing comparison candidate: + * `'irrelevant'` when neither operand's terminal name is `key`/`code` at + * all (this comparison isn't testing a keyboard event property, so it's + * outside `containsEscapeSemantics`'s concern regardless of the other + * operand); otherwise the OTHER operand is resolved + * (`resolveStringLiteralValue`) to `'escape'` (resolves to exactly + * `'Escape'`), `'not-escape'` (resolves to some other concrete literal — + * provably not Escape), or `'ambiguous'` (cannot be resolved to any + * concrete literal at all — a non-`const` alias, an unresolvable + * identifier, or any other non-literal expression) — #592 review pass 2 + * (ChatGPT PR #672 pass 2 P1): a constant alias (`const ESC = 'Escape'; if + * (e.key === ESC) …`) previously required the LITERAL string `'Escape'` + * directly in the comparison, so an aliased comparison was misclassified + * `'irrelevant'`-equivalent (silently dropped as `'clean'`) rather than + * recognized or, failing that, treated as ambiguous. */ +function classifyEscapeComparison(node, checker) { + const leftNames = terminalNames(node.left, 1); + const rightNames = terminalNames(node.right, 1); + const leftIsKeyOrCode = leftNames.length === 1 && (leftNames[0] === 'key' || leftNames[0] === 'code'); + const rightIsKeyOrCode = rightNames.length === 1 && (rightNames[0] === 'key' || rightNames[0] === 'code'); + let candidate = null; + if (leftIsKeyOrCode && !rightIsKeyOrCode) candidate = node.right; + else if (rightIsKeyOrCode && !leftIsKeyOrCode) candidate = node.left; + if (!candidate) return 'irrelevant'; + const resolved = resolveStringLiteralValue(candidate, checker); + if (resolved === undefined) return 'ambiguous'; + return resolved === 'Escape' ? 'escape' : 'not-escape'; +} + +/** + * True when `fnLikeNode`'s body contains real Escape-testing control flow — + * per the plan's own recognition list: `event.key === 'Escape'` / `'Escape' + * === event.key` (either operand order, `===` or `!==`, any quote style, and + * a `const` identifier alias of the literal — `resolveStringLiteralValue` + * resolves a plain string/no-substitution-template literal or such an alias + * identically), the analogous `event.code` forms, and `switch (event.key) { + * case 'Escape': … }` (the case expression resolved the same alias-aware + * way). A generic + * capture keydown handler with NO Escape-specific branch (an + * activity/highlight listener, e.g. `dashboard.ts`'s + * `noteInteraction`/`clear`) contains none of these and is correctly + * classified clean — not governed by the #592 lifecycle rule at all, + * structurally, before any policy table is even consulted. A comparison or + * `case` value that cannot be resolved to any concrete literal at all + * (`resolveStringLiteralValue` returns `undefined` — e.g. a non-`const` + * alias, or any other unresolvable expression) fails CLOSED as if it were a + * real Escape comparison, exactly like every other unresolved shape this + * module's #592 guards already treat as "cannot prove this ISN'T the + * governed case" rather than silently assuming clean. + * + * @param {object} fnLikeNode + * @param {object} checker the file's real TypeScript `Checker` + * @returns {boolean} + */ +function containsEscapeSemantics(fnLikeNode, checker) { + let found = false; + const scanRoot = fnLikeNode.body ?? fnLikeNode; // a concise arrow body is an expression, not a Block + walkTree(scanRoot, (node) => { + if (found) return; + if ( + node.kind === SyntaxKind.BinaryExpression + && (node.operatorToken.kind === SyntaxKind.EqualsEqualsEqualsToken + || node.operatorToken.kind === SyntaxKind.ExclamationEqualsEqualsToken) + ) { + const outcome = classifyEscapeComparison(node, checker); + if (outcome === 'escape' || outcome === 'ambiguous') found = true; + } + if (node.kind === SyntaxKind.SwitchStatement) { + const names = terminalNames(node.expression, 1); + if (names.length === 1 && (names[0] === 'key' || names[0] === 'code')) { + for (const clause of node.caseBlock.clauses) { + if (clause.kind !== SyntaxKind.CaseClause) continue; + const resolved = resolveStringLiteralValue(clause.expression, checker); + if (resolved === undefined || resolved === 'Escape') { found = true; break; } + } + } + } + }); + return found; +} + +// ── Guard 1: `shell-body-mount` ────────────────────────────────────────────── + +/** + * The frozen #592 baseline: every (file, scope-path) that may mount directly + * onto a recognized `Document.body`, and exactly how many occurrences. Built + * by running `bodyMountCandidates` over the current tree and reviewing every + * result (see this section's header comment) — never guessed. `requiresLifecycle: + * true` additionally requires the SAME scope to retain a companion + * `openSurfaceLifecycle(...)` call (`hasCallNamed`); losing that call while + * keeping the body mount fails even though the mount's own shape/count is + * unchanged (`results.ts`'s cell-detail overlay is the one entry that needs + * this — it exists ONLY because #586 explicitly keeps this one non-docked + * overlay branch outside the docked-inspector migration, but still requires + * it be built on the shared `SurfaceLifecycle` primitive, never a bespoke + * one). + */ +const SHELL_BODY_MOUNT_POLICY = Object.freeze([ + { filename: 'src/ui/shortcuts.ts', scopePath: ['openShortcuts'], count: 1, + category: 'existing distinct primitive: shortcuts modal' }, + { filename: 'src/ui/menu.ts', scopePath: ['openMenu'], count: 2, + category: 'existing menu primitive: overlay + menu' }, + { filename: 'src/ui/dialog-shell.ts', scopePath: ['openDialogShell'], count: 1, + category: 'explicitly distinct dialog primitive' }, + { filename: 'src/ui/toast.ts', scopePath: ['flashToast'], count: 1, + category: 'acceptable transient toast' }, + { filename: 'src/ui/popover.ts', scopePath: ['openAnchoredDialog'], count: 2, + category: 'explicitly distinct popover primitive: anchored-dialog family (overlay + dialog)' }, + { filename: 'src/ui/popover.ts', scopePath: ['createAnchoredPopovers', 'open'], count: 1, + category: 'explicitly distinct popover primitive: anchored-popover family' }, + { filename: 'src/ui/results.ts', scopePath: ['openCellDetail', ''], count: 1, + category: 'SurfaceLifecycle-backed: current cell-detail overlay branch', requiresLifecycle: true }, + { filename: 'src/ui/detached-view.ts', scopePath: ['openAsTab', ''], count: 1, + category: 'detached/fullscreen primitive: real-tab (child-document) mount, outside #586\'s docked-inspector migration' }, + { filename: 'src/ui/detached-view.ts', scopePath: ['openAsOverlay', ''], count: 1, + category: 'detached/fullscreen primitive: popup-blocked (main-document) fallback, outside #586\'s docked-inspector migration' }, + { filename: 'src/ui/app.ts', scopePath: ['createApp', 'showExportProgress'], count: 1, + category: 'non-panel utility: existing transient export-progress surface' }, + { filename: 'src/ui/app.ts', scopePath: ['createApp', 'downloadFile'], count: 1, + category: 'non-panel utility: temporary download anchor, not a shell surface' }, +]); + +/** + * Structurally resolve whether `node` denotes a `Document.body` — the + * Architecture-decision-6 replacement for the #592 review passes' + * hand-rolled `bodyAliasMap` (a pre-walked, scope-keyed alias table): rather + * than pre-registering every body alias in the file up front, this resolves + * ONE receiver expression on demand, recursively, through the REAL + * TypeScript checker wherever an identifier reference is involved. Covers, + * per the plan's candidate list: a direct `.body` access on a recognized + * Document (`document.body`, `doc.body`, `deps.document.body`, …, via + * `resolveGlobalKind`); the bracket-property spelling (`doc['body']`); a + * propagated alias — `const body = childDoc.body; body.appendChild(...)` — + * resolved by looking at the alias's OWN `VariableDeclaration` initializer + * (found via `checker.getSymbolAtLocation`, never a hand-rolled scope-chain + * lookup) and recursing; a FURTHER alias of that alias (the same recursion, + * one more hop); and a destructuring alias of `Document.body` — `const { + * body } = document;` or the renamed `const { body: host } = document;` — + * resolved by inspecting the `BindingElement`'s own destructuring pattern + * and its owning declaration's initializer. Never gated by a raw + * `source.includes(...)` prefilter — see this section's header comment on + * why a text prefilter is unsound for this check (the repo's own recorded + * recurring failure mode). A `let`/`var` binding's initializer is not the + * only value it can ever hold: `laterAssignmentResolvesToDocumentBody` + * (#592 review pass 2, ChatGPT PR #672 pass 2 P1) also checks every + * whole-binding reassignment (`body = document.body;`) anywhere in + * `sourceFile` — a real Document-body mount reached only through a LATER + * reassignment (`let body = document.createElement('div'); body = + * document.body; body.appendChild(panel)`) previously resolved `false` + * (the initializer's own, stale value) and escaped this guard entirely. + * + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @param {object} [sourceFile] the file `node` was parsed from — required to + * detect a later reassignment of a `let`/`var` binding; every real caller + * has one, so only synthetic/legacy call sites without one skip that + * specific check + * @param {Set} [seen] declaration nodes already being resolved on + * this call's recursion path (#592 review pass 3) — an alias-reassignment + * CYCLE (`let a = createElement(...); let b = a; a = b; a.appendChild(...)`) + * would otherwise recurse `resolvesToDocumentBody` <-> `laterAssignmentRes- + * olvesToDocumentBody` forever: resolving `a`'s later assignment `a = b` + * resolves `b`, whose own initializer is `a` again, which (being non-const) + * re-triggers `a`'s later-assignment walk — the identical call, with no + * memoization, repeating indefinitely. Re-entering an already-in-progress + * declaration returns `false` for THAT path (the same defense-in-depth + * precedent `resolvedTypeNames` already establishes for a type-alias + * cycle) — a genuine `document.body` assignment reached through a + * DIFFERENT, non-cyclic later assignment on the same binding is still found, + * since `laterAssignmentResolvesToDocumentBody` visits every whole-binding + * reassignment in the file, not only the cyclic one. + * @returns {boolean} + */ +function resolvesToDocumentBody(node, checker, sourceFile, seen = new Set()) { + const expr = unwrapCastWrappers(node); + if (!expr) return false; + if (expr.kind === SyntaxKind.PropertyAccessExpression && expr.name.text === 'body') { + return resolveGlobalKind(expr.expression, checker, sourceFile) === 'document'; + } + if (expr.kind === SyntaxKind.ElementAccessExpression) { + const arg = expr.argumentExpression; + if ( + arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral) + && arg.text === 'body' + ) { + return resolveGlobalKind(expr.expression, checker, sourceFile) === 'document'; + } + return false; + } + if (expr.kind !== SyntaxKind.Identifier) return false; + const symbol = checker.getSymbolAtLocation(expr); + if (!symbol) return false; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + const declNode = handle?.resolve(); + if (!declNode) return false; + if (declNode.kind === SyntaxKind.BindingElement) { + // #592 review pass/ChatGPT PR #672 pass 1: `bindingElementSourceKeyName` + // recognizes a QUOTED rename (`const { 'body': host } = document`) + // identically to the unquoted form — the prior inline check here only + // recognized a plain-Identifier `propertyName`, so a quoted source key + // fell through to reading the LOCAL name (`host`) instead. + const propName = bindingElementSourceKeyName(declNode); + if (propName !== 'body') return false; + const pattern = declNode.parent; // ObjectBindingPattern + const owner = pattern && pattern.parent; // VariableDeclaration + return !!(owner && owner.initializer && resolveGlobalKind(owner.initializer, checker, sourceFile) === 'document'); + } + if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer) { + if (seen.has(declNode)) return false; // cycle guard — see @param seen above + seen.add(declNode); + if (resolvesToDocumentBody(declNode.initializer, checker, sourceFile, seen)) return true; + if (!isConstVariableDeclaration(declNode) && sourceFile + && laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker, seen)) return true; + return false; + } + return false; +} + +/** True when a `let`/`var` binding (`declNode`, already known non-`const`) + * is EVER whole-binding reassigned (` = ;`) anywhere in + * `sourceFile` to a value that ITSELF structurally resolves to + * `Document.body`. Deliberately no control-flow/ordering analysis — unlike + * `resolveObjectCaptureLiteral`'s own property-evaluation-order walk + * (sound because object-literal property order is a real, unconditional JS + * semantic), a later STATEMENT's execution order relative to the mount + * call site is not, once branches/loops exist — ANY such reassignment + * anywhere is conservatively enough to flag the whole binding as a + * possible mount, matching this guard's own established bias: a + * manually-reviewed false positive is far cheaper than a silently escaped + * real mount. This is deliberately narrow (it only fires for a binding + * reassigned to something that ITSELF resolves to `Document.body`), so an + * ordinary `let container = createDiv(); container.appendChild(row);` — + * never reassigned to `document.body` — is entirely unaffected. `seen` is + * the SAME cycle-guard `resolvesToDocumentBody` threads to it (#592 review + * pass 3) — this function recurses back into `resolvesToDocumentBody` for + * each candidate right-hand side, so both must share one cycle-safe view of + * which declarations are already being resolved on this path. */ +function laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker, seen) { + let found = false; + walkTree(sourceFile, (node) => { + if (found) return; + if (node.kind !== SyntaxKind.BinaryExpression || node.operatorToken.kind !== SyntaxKind.EqualsToken) return; + const target = node.left; + if (target.kind !== SyntaxKind.Identifier) return; + const symbol = checker.getSymbolAtLocation(target); + if (!symbol) return; + const handle = symbol.valueDeclaration ?? symbol.declarations[0]; + if (handle?.resolve() !== declNode) return; + if (resolvesToDocumentBody(node.right, checker, sourceFile, seen)) found = true; + }); + return found; +} + +/** + * Every real `.appendChild(...)`/`.append(...)` call in `sourceFile` whose + * receiver structurally resolves to a recognized `Document.body` + * (`resolvesToDocumentBody` above) — syntactic call-shape discovery + * (unchanged by Architecture decision 6: recognizing `.appendChild`/`.append` + * call SHAPES is not a name-binding question), receiver CLASSIFICATION + * delegated entirely to the real checker. + * + * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` + * @returns {{node: object, api: 'appendChild'|'append', scopePath: string[], scopeNode: object|null, pos: number}[]} + */ +function bodyMountCandidates(sourceFile, checker) { + const candidates = []; + walkTree(sourceFile, (node) => { + if (node.kind !== SyntaxKind.CallExpression) return; + const callee = node.expression; + let apiName = null; + let receiver = null; + if (callee.kind === SyntaxKind.PropertyAccessExpression) { + apiName = callee.name.text; + receiver = callee.expression; + } else if (callee.kind === SyntaxKind.ElementAccessExpression) { + const arg = callee.argumentExpression; + if (arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + apiName = arg.text; + receiver = callee.expression; + } + } + if (apiName !== 'appendChild' && apiName !== 'append') return; + if (!receiver) return; + if (!resolvesToDocumentBody(receiver, checker, sourceFile)) return; + candidates.push({ + node, api: apiName, scopePath: enclosingScopePath(node), scopeNode: innermostScopeNode(node), + pos: node.getStart(sourceFile), + }); + }); + return candidates; +} + +/** Apply `SHELL_BODY_MOUNT_POLICY` to `bodyMountCandidates(sourceFile)`'s + * result: group by (filename, scope path), sort each group by source + * position, and flag every occurrence beyond the approved count (or every + * occurrence at all, for a scope with no policy entry) — plus every + * occurrence in an entry whose `requiresLifecycle` composition is missing. + * Flagging the EXCESS occurrences specifically (not the whole group) means + * the first N approved mounts stay clean while a genuinely new (N+1)th one + * is pinpointed. A SECOND pass then walks every `SHELL_BODY_MOUNT_POLICY` + * entry for THIS file and flags the ones whose approved count is no longer + * matched by the current tree — the reviewed #672 P1: the excess-only loop + * above only ever visits a scope that still has at least one candidate, so + * a scope whose LAST occurrence disappeared (or whose count dropped below + * its frozen baseline) would otherwise produce zero violations, comparing + * the policy and the discovered candidates as an exact multiset in only one + * direction. */ +function shellBodyMountViolations(sourceFile, filename, checker) { + const byScope = new Map(); + for (const c of bodyMountCandidates(sourceFile, checker)) { + const key = scopeKey(c.scopePath); + const list = byScope.get(key) ?? []; + list.push(c); + byScope.set(key, list); + } + const violations = []; + for (const [key, list] of byScope) { + list.sort((a, b) => a.pos - b.pos); + const entry = SHELL_BODY_MOUNT_POLICY.find((e) => e.filename === filename && scopeKey(e.scopePath) === key); + const allowedCount = entry ? entry.count : 0; + const lifecycleOk = !entry?.requiresLifecycle || hasCallNamed(list[0].scopeNode, 'openSurfaceLifecycle'); + for (let idx = 0; idx < list.length; idx++) { + if (idx < allowedCount && lifecycleOk) continue; + const reason = !entry + ? 'no #592 body-mount policy entry exists for this scope' + : !lifecycleOk + ? 'its SurfaceLifecycle composition (openSurfaceLifecycle(...)) is missing from this scope' + : `this scope already has its approved ${allowedCount} occurrence(s)`; + violations.push(makeViolation( + 'shell-body-mount', filename, list[idx].pos, + `Document-body .${list[idx].api}(...) in scope "${key}" is not on the approved #592 body-mount snapshot (${reason}) — ` + + 'use the docked inspectorHost + SurfaceLifecycle, an established dialog/popover primitive, or deliberately update the documented exception snapshot', + )); + } + } + const declaredScopes = declaredScopeKeys(sourceFile); + for (const entry of SHELL_BODY_MOUNT_POLICY) { + if (entry.filename !== filename) continue; + const key = scopeKey(entry.scopePath); + if (!declaredScopes.has(key)) continue; // scope not part of what was scanned — see declaredScopeKeys + const actualCount = (byScope.get(key) ?? []).length; + if (actualCount >= entry.count) continue; + violations.push(makeViolation( + 'shell-body-mount', filename, 0, + `the approved #592 body-mount snapshot expects ${entry.count} Document-body mount(s) in scope "${key}" ` + + `(${entry.category}), but only ${actualCount} remain — deliberately update the reviewed baseline if this ` + + 'mount was intentionally removed, or restore it if this is unintended drift', + )); + } + return violations; +} + +// ── Guard 3: `shell-capture-escape` ────────────────────────────────────────── + +/** + * The frozen #592 baseline for every global capture-phase `keydown` listener + * with real Escape semantics: `src/ui/surface-lifecycle.ts`'s + * `openSurfaceLifecycle` is the canonical shared owner; five further entries + * are existing, DISTINCT panel/overlay lifecycles #586 deliberately keeps + * separate (dialog-shell, both popover families, `results.ts`'s Data Pane, + * both `explain-graph.ts` detail surfaces, `menu.ts`); three further entries + * are non-panel GESTURE cancellation (`dashboard-tile-gestures.ts`'s grid- + * resize and tile-drag Escape handlers, `dashboard-chart-interaction.ts`'s + * chart range-selection cancellation) — a different semantic category from a + * panel-close lifecycle, kept in the same table only because both need the + * identical exact-fingerprint/count enforcement shape. None of these + * categories authorizes a SECOND listener beside it (count-bounded, per + * scope) or a listener ANYWHERE ELSE in the same file (scope-path-bounded, + * never filename-bounded). + */ +const SHELL_CAPTURE_ESCAPE_POLICY = Object.freeze([ + { filename: 'src/ui/surface-lifecycle.ts', scopePath: ['openSurfaceLifecycle'], count: 1, + category: 'canonical shared SurfaceLifecycle owner' }, + { filename: 'src/ui/dialog-shell.ts', scopePath: ['openDialogShell'], count: 1, + category: 'existing distinct panel/overlay exception: dialog-shell' }, + { filename: 'src/ui/popover.ts', scopePath: ['openAnchoredDialog'], count: 1, + category: 'existing distinct panel/overlay exception: popover anchored-dialog family' }, + { filename: 'src/ui/popover.ts', scopePath: ['createAnchoredPopovers', 'open'], count: 1, + category: 'existing distinct panel/overlay exception: popover anchored-popover family' }, + { filename: 'src/ui/results.ts', scopePath: ['expandDataPane', 'mount'], count: 1, + category: 'existing distinct panel/overlay exception: Data Pane detail path (not consolidated by #586)' }, + { filename: 'src/ui/explain-graph.ts', scopePath: ['openPipelineFullscreen', 'mount'], count: 1, + category: 'existing distinct panel/overlay exception: EXPLAIN pipeline detail surface' }, + { filename: 'src/ui/explain-graph.ts', scopePath: ['openSchemaView', 'mount'], count: 1, + category: 'existing distinct panel/overlay exception: schema-lineage detail surface' }, + { filename: 'src/ui/menu.ts', scopePath: ['openMenu'], count: 1, + category: 'existing distinct panel/overlay exception: menu primitive' }, + { filename: 'src/ui/dashboard-tile-gestures.ts', scopePath: ['createTileGestureController', 'wireGridResize', ''], count: 1, + category: 'non-panel gesture cancellation: grid-resize Escape cancel' }, + { filename: 'src/ui/dashboard-tile-gestures.ts', scopePath: ['createTileGestureController', 'wireTileDrag', 'onPointerDown'], count: 1, + category: 'non-panel gesture cancellation: tile-drag reorder Escape cancel' }, + { filename: 'src/ui/dashboard-chart-interaction.ts', scopePath: ['createDashboardChartInteractionController', 'beginSelection'], count: 1, + category: 'non-panel gesture cancellation: chart range-selection Escape cancel' }, +]); + +/** + * Every global capture-phase `keydown` `addEventListener` call in + * `sourceFile`, classified — per the plan's candidate-listener/Escape- + * recognition/fail-closed requirements: + * - the CALL itself may be spelled either `.addEventListener(...)` + * (`PropertyAccessExpression`) or the bracket-property equivalent + * `['addEventListener'](...)` (`ElementAccessExpression` with a + * literal `'addEventListener'` argument, #592 review pass 3 — the + * identical bracket-spelling recognition `bodyMountCandidates` already + * applies to its own `.appendChild`/`.append` callee, previously never + * extended to this candidate discovery, so this exact spelling escaped + * as "not a candidate at all", not even fail-closed); + * - the EVENT NAME (first argument) must resolve (`resolveStringLiteralValue` + * — a literal directly, or a `const` alias of one, #592 review pass 3: + * previously only a literal directly in the call qualified, so `const + * EVT = 'keydown'; el.addEventListener(EVT, ...)` was invisible here even + * though the analogous alias resolution already applies to the Escape- + * literal COMPARISON inside the handler body) to exactly `'keydown'`; + * - the receiver must resolve to a recognized Document/Window + * (`resolveGlobalKind`) — anything else is not a candidate at all; + * - a MISSING third argument is provably non-capture (bubble phase) — + * not a candidate; + * - a third argument that resolves (`resolveCaptureFlag`) to exactly + * `false` is provably non-capture — not a candidate; + * - a third argument that cannot be resolved enough to PROVE non-capture + * (an unrecognized shape) is `'uncheckable-options'` — always a + * violation, fail-closed, never silently treated as non-capture; + * - once capture is proven `true`, the handler argument must resolve + * (`resolveHandlerNode`) to a real function; an unresolved handler is + * `'uncheckable-handler'` — always a violation, fail-closed; + * - a resolved handler with real Escape semantics (`containsEscapeSemantics`) + * is `'escape'`; without any is `'clean'` (a generic capture keydown + * activity/highlight listener, e.g. `dashboard.ts`'s + * `noteInteraction`/`clear` — structurally excluded here, before any + * policy table is consulted, exactly as the plan requires). + * + * Receiver/options/handler resolution is delegated entirely to the real + * TypeScript checker (`resolveGlobalKind`/`resolveCaptureFlag`/ + * `resolveHandlerNode`, Architecture decision 6) — this function's own job + * stays purely syntactic call-shape discovery (`addEventListener('keydown', + * …)` call sites) and dispatch, unchanged. + * + * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` + * @returns {{kind: 'escape'|'clean'|'uncheckable-handler'|'uncheckable-options', scopePath: string[], pos: number}[]} + */ +function captureEscapeCandidates(sourceFile, checker) { + const out = []; + walkTree(sourceFile, (node) => { + if (node.kind !== SyntaxKind.CallExpression) return; + const callee = node.expression; + let apiName = null; + let receiverExpr = null; + if (callee.kind === SyntaxKind.PropertyAccessExpression) { + apiName = callee.name.text; + receiverExpr = callee.expression; + } else if (callee.kind === SyntaxKind.ElementAccessExpression) { + const arg = callee.argumentExpression; + if (arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral)) { + apiName = arg.text; + receiverExpr = callee.expression; + } + } + if (apiName !== 'addEventListener' || !receiverExpr) return; + const args = node.arguments; + if (args.length < 2) return; + if (resolveStringLiteralValue(args[0], checker) !== 'keydown') return; + if (!resolveGlobalKind(receiverExpr, checker, sourceFile)) return; // not Document/Window — not a candidate + const pos = node.getStart(sourceFile); + const scopePath = enclosingScopePath(node); + const third = args[2]; + if (!third) return; // no options at all — provably non-capture (bubble phase) + const captureFlag = resolveCaptureFlag(third, checker, sourceFile); + if (captureFlag === false) return; // provably non-capture + if (captureFlag === null) { out.push({ kind: 'uncheckable-options', scopePath, pos }); return; } + const handlerNode = resolveHandlerNode(args[1], checker); + if (!handlerNode) { out.push({ kind: 'uncheckable-handler', scopePath, pos }); return; } + out.push({ kind: containsEscapeSemantics(handlerNode, checker) ? 'escape' : 'clean', scopePath, pos }); + }); + return out; +} + +/** Apply `SHELL_CAPTURE_ESCAPE_POLICY` to `captureEscapeCandidates(sourceFile)`'s + * result: every `'uncheckable-*'` candidate is an unconditional violation + * (fail-closed, never eligible for a policy match); every `'clean'` + * candidate is dropped (no Escape semantics — outside this rule entirely); + * every `'escape'` candidate is grouped by scope path and compared against + * the frozen policy the same excess-occurrence way `shellBodyMountViolations` + * compares body mounts — plus the same SECOND, reverse pass over every + * `SHELL_CAPTURE_ESCAPE_POLICY` entry for this file, flagging any whose + * approved count is no longer matched (the reviewed #672 P1: a disappeared + * frozen Escape listener is exactly as much a drift from the baseline as an + * added one, and the excess-only loop below can never see a scope that lost + * its last occurrence). */ +function shellCaptureEscapeViolations(sourceFile, filename, checker) { + const byScope = new Map(); + const violations = []; + for (const c of captureEscapeCandidates(sourceFile, checker)) { + if (c.kind === 'uncheckable-handler') { + violations.push(makeViolation( + 'shell-capture-escape', filename, c.pos, + 'a global capture-phase keydown handler could not be statically resolved to a real function — treat as a ' + + 'potential Escape lifecycle: use SurfaceLifecycle, or make the handler statically resolvable', + )); + continue; + } + if (c.kind === 'uncheckable-options') { + violations.push(makeViolation( + 'shell-capture-escape', filename, c.pos, + "a global keydown listener's capture option could not be proven non-capture — treat as a potential Escape " + + 'lifecycle: use SurfaceLifecycle, or make the options resolvable', + )); + continue; + } + if (c.kind === 'clean') continue; + const key = scopeKey(c.scopePath); + const list = byScope.get(key) ?? []; + list.push(c); + byScope.set(key, list); + } + for (const [key, list] of byScope) { + list.sort((a, b) => a.pos - b.pos); + const entry = SHELL_CAPTURE_ESCAPE_POLICY.find((e) => e.filename === filename && scopeKey(e.scopePath) === key); + const allowedCount = entry ? entry.count : 0; + for (let idx = 0; idx < list.length; idx++) { + if (idx < allowedCount) continue; + violations.push(makeViolation( + 'shell-capture-escape', filename, list[idx].pos, + `a global capture-phase Escape keydown listener in scope "${key}" is not on the approved #592 lifecycle/` + + 'exception snapshot — use SurfaceLifecycle for a shell/panel lifecycle, or deliberately register a ' + + 'narrow documented exception/non-panel gesture exclusion where that is genuinely the architecture', + )); + } + } + const declaredScopes = declaredScopeKeys(sourceFile); + for (const entry of SHELL_CAPTURE_ESCAPE_POLICY) { + if (entry.filename !== filename) continue; + const key = scopeKey(entry.scopePath); + if (!declaredScopes.has(key)) continue; // scope not part of what was scanned — see declaredScopeKeys + const actualCount = (byScope.get(key) ?? []).length; + if (actualCount >= entry.count) continue; + violations.push(makeViolation( + 'shell-capture-escape', filename, 0, + `the approved #592 capture-Escape snapshot expects ${entry.count} listener(s) in scope "${key}" ` + + `(${entry.category}), but only ${actualCount} remain — deliberately update the reviewed baseline if this ` + + 'listener was intentionally removed, or restore it if this is unintended drift', + )); + } + return violations; +} + +/** + * Issue #592 — the batched body-mount + capture-Escape source contract, real- + * parser-backed, over ONE shared `withParsedSources` batch for the complete + * `sources` set (never one parser process per rule or per file — Architecture + * decision 4). Returns `shell-body-mount` and `shell-capture-escape` + * violations together. Each file's real TypeScript `checker` (also produced + * by that same one batch — Architecture decision 6, #592 addendum) is handed + * to both guards so identifier-binding questions ("what does this receiver/ + * handler/options identifier resolve to") are answered by the real binder, + * never a hand-rolled scope walk. + * + * `options.completeTree: true` (#592 review pass 2, ChatGPT PR #672 pass 2 + * P2) additionally folds the STRICT (complete-tree) reverse-baseline half + * (`shellGuardrailStrictReverseViolations` below) into this SAME batch, + * instead of a caller opening a SECOND `withParsedSources` batch (a second + * native TypeScript-parser child process) to get it — the production + * `check:arch` wiring in `build/check-boundaries.mjs` previously called this + * function AND `findShellGuardrailMissingBaselineViolations` separately over + * the identical `sources`, each spinning up its own batch, directly + * contradicting this function's own "ONE shared parser batch" contract. + * Only pass `true` when `sources` really is the complete scanned tree (the + * same precondition `findShellGuardrailMissingBaselineViolations` already + * documents) — a partial/synthetic fixture batch must never set this. + * + * @param {readonly {filename: string, source: string}[]} sources + * @param {{completeTree?: boolean}} [options] + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellGuardrailSourceContractViolations(sources, options) { + const completeTree = !!options?.completeTree; + return withParsedSources(sources, (sourceFiles, checkers) => { + const violations = []; + for (const [filename, sourceFile] of sourceFiles) { + const checker = checkers.get(filename); + violations.push(...shellBodyMountViolations(sourceFile, filename, checker)); + violations.push(...shellCaptureEscapeViolations(sourceFile, filename, checker)); + } + if (completeTree) { + // Both reverse-baseline halves — the parser-independent whole-file + // absence check and the parser-dependent per-scope count check — fold + // into this SAME batch; neither opens (or needs) a second one. + violations.push(...shellGuardrailMissingFileViolations(sources)); + violations.push(...shellGuardrailStrictReverseViolations(sourceFiles, checkers)); + } + return violations; + }); +} + +/** The STRICT (complete-tree) count of every `SHELL_BODY_MOUNT_POLICY` entry + * belonging to `filename`, against `sourceFile`'s real `bodyMountCandidates` + * — unlike `shellBodyMountViolations`'s own softened reverse pass, this + * NEVER consults `declaredScopeKeys` first: a genuinely deleted approved + * function-like scope simply contributes zero occurrences here, exactly + * like a real disappeared mount within a still-present scope does, because + * this function's only callers (`shellGuardrailStrictReverseViolations` + * below, reached either through `findShellGuardrailSourceContractViolations`'s + * own `completeTree` mode or through `findShellGuardrailMissingBaselineViolations`) + * already guarantee `sourceFile` is the complete real file, never a + * partial synthetic fixture. */ +function shellBodyMountMissingBaselineViolationsStrict(sourceFile, filename, checker) { + const counts = new Map(); + for (const c of bodyMountCandidates(sourceFile, checker)) { + const key = scopeKey(c.scopePath); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const violations = []; + for (const entry of SHELL_BODY_MOUNT_POLICY) { + if (entry.filename !== filename) continue; + const key = scopeKey(entry.scopePath); + const actualCount = counts.get(key) ?? 0; + if (actualCount >= entry.count) continue; + violations.push(makeViolation( + 'shell-body-mount', filename, 0, + `the approved #592 body-mount snapshot expects ${entry.count} Document-body mount(s) in scope "${key}" ` + + `(${entry.category}), but only ${actualCount} remain in the complete scanned tree — deliberately update ` + + 'the reviewed baseline if this mount was intentionally removed, or restore it if this is unintended drift', + )); + } + return violations; +} + +/** `shellBodyMountMissingBaselineViolationsStrict`'s exact counterpart for + * `SHELL_CAPTURE_ESCAPE_POLICY` — counts only `'escape'`-classified + * candidates (an `'uncheckable-*'`/`'clean'` candidate is a DIFFERENT + * question this reverse pass never re-litigates; the forward pass already + * owns those). */ +function shellCaptureEscapeMissingBaselineViolationsStrict(sourceFile, filename, checker) { + const counts = new Map(); + for (const c of captureEscapeCandidates(sourceFile, checker)) { + if (c.kind !== 'escape') continue; + const key = scopeKey(c.scopePath); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const violations = []; + for (const entry of SHELL_CAPTURE_ESCAPE_POLICY) { + if (entry.filename !== filename) continue; + const key = scopeKey(entry.scopePath); + const actualCount = counts.get(key) ?? 0; + if (actualCount >= entry.count) continue; + violations.push(makeViolation( + 'shell-capture-escape', filename, 0, + `the approved #592 capture-Escape snapshot expects ${entry.count} listener(s) in scope "${key}" ` + + `(${entry.category}), but only ${actualCount} remain in the complete scanned tree — deliberately update ` + + 'the reviewed baseline if this listener was intentionally removed, or restore it if this is unintended ' + + 'drift', + )); + } + return violations; +} + +/** The parser-DEPENDENT half of the complete-tree reverse-baseline check — + * `shellBodyMountMissingBaselineViolationsStrict`/ + * `shellCaptureEscapeMissingBaselineViolationsStrict` for every file + * already present in an EXISTING parsed batch (`sourceFiles`/`checkers`, + * exactly the shape `withParsedSources`'s own callback receives) — factored + * out so a caller that already has a parsed batch open (either + * `findShellGuardrailSourceContractViolations`'s own `completeTree` mode, + * folding this into its ONE shared batch, or + * `findShellGuardrailMissingBaselineViolations`'s own standalone batch + * below) runs this reverse half WITHOUT opening a second one (#592 review + * pass 2, ChatGPT PR #672 pass 2 P2; Architecture decision 4). + * + * @param {Map} sourceFiles filename -> parsed `SourceFile` + * @param {Map} checkers filename -> that file's real `Checker` + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +function shellGuardrailStrictReverseViolations(sourceFiles, checkers) { + const out = []; + for (const [filename, sourceFile] of sourceFiles) { + const checker = checkers.get(filename); + out.push(...shellBodyMountMissingBaselineViolationsStrict(sourceFile, filename, checker)); + out.push(...shellCaptureEscapeMissingBaselineViolationsStrict(sourceFile, filename, checker)); + } + return out; +} + +/** The parser-INDEPENDENT half of the complete-tree reverse-baseline check — + * every `SHELL_BODY_MOUNT_POLICY`/`SHELL_CAPTURE_ESCAPE_POLICY` entry whose + * OWN approved `filename` has no matching entry in `sources` at all (a + * whole approved FILE deleted outright). A whole-file absence needs no AST + * at all, so this half never needs (and never opens) a parser batch — + * unlike `shellGuardrailStrictReverseViolations` above, which does. + * + * @param {readonly {filename: string, source: string}[]} sources + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +function shellGuardrailMissingFileViolations(sources) { + const present = new Set(sources.map((s) => s.filename)); + const violations = []; + for (const entry of SHELL_BODY_MOUNT_POLICY) { + if (present.has(entry.filename)) continue; + violations.push(makeViolation( + 'shell-body-mount', entry.filename, 0, + `the approved #592 body-mount snapshot expects ${entry.count} Document-body mount(s) in scope ` + + `"${scopeKey(entry.scopePath)}" (${entry.category}), but ${entry.filename} is not part of the scanned ` + + 'tree at all — deliberately update the reviewed baseline if this file was intentionally removed, or ' + + 'restore it if this is unintended drift', + )); + } + for (const entry of SHELL_CAPTURE_ESCAPE_POLICY) { + if (present.has(entry.filename)) continue; + violations.push(makeViolation( + 'shell-capture-escape', entry.filename, 0, + `the approved #592 capture-Escape snapshot expects ${entry.count} listener(s) in scope ` + + `"${scopeKey(entry.scopePath)}" (${entry.category}), but ${entry.filename} is not part of the scanned ` + + 'tree at all — deliberately update the reviewed baseline if this file was intentionally removed, or ' + + 'restore it if this is unintended drift', + )); + } + return violations; +} + +/** + * The complete-tree REVERSE half of the #592 shell-guardrail source + * contract — deliberately SEPARATE from `findShellGuardrailSourceContractViolations`, + * for the exact reason `findShellFixedPositionMissingBaselineViolations` is + * a separate export from `findShellFixedPositionViolations` (see that + * pair's own doc comments): `declaredScopeKeys`'s own softening — "a scope + * not part of what was scanned is not this call's concern" — exists ONLY to + * keep the forward check's reverse pass safe for this suite's many minimal + * single-scope synthetic fixtures (a fixture reproducing just ONE of a real + * file's several approved scopes, under that file's real name). It CANNOT + * tell a genuinely complete file with an approved function/scope deleted + * apart from a fixture that never declared that scope to begin with — both + * simply lack a function-like node at that scope path — so a real approved + * function's outright deletion (or an approved FILE's outright deletion) + * produced zero violations from the forward check's own reverse pass alone + * (PR #672 review pass 1 follow-up, ChatGPT): the forward check's own + * per-file loop (`findShellGuardrailSourceContractViolations` above) never + * even iterates a filename `sources` doesn't contain, and its softened + * reverse pass skips a scope that no longer parses out of a still-present + * file exactly like it skips one that was never in scope at all. + * + * This export assumes `sources` IS the complete scanned tree (its only real + * callers are `build/check-boundaries.mjs`'s live `collectFiles(src/)` batch + * — directly, and via `findShellGuardrailSourceContractViolations`'s own + * `completeTree: true` mode, #592 review pass 2 — which reads every file + * under `src/**` from disk) and reports, WITHOUT that softening: + * - every `SHELL_BODY_MOUNT_POLICY`/`SHELL_CAPTURE_ESCAPE_POLICY` entry + * whose OWN `filename` has no matching entry in `sources` at all — a + * whole approved FILE deleted outright; + * - every remaining entry whose approved scope's real occurrence count is + * below its frozen baseline — covering BOTH a dropped count within a + * still-present function AND an approved function deleted (or renamed) + * outright, uniformly: a deleted function-like node simply has no + * scope-path key in the real tree at all, so it counts as zero + * occurrences exactly like a real disappeared mount/listener, with no + * "declared at all" softening asked first. + * + * This standalone export still opens its OWN `withParsedSources` batch when + * `sources` needs parsing at all (kept for every existing direct caller — + * this suite's own tests, and any future caller with no parsed batch + * already open); `build/check-boundaries.mjs`'s production wiring instead + * reaches the identical parser-dependent logic through + * `findShellGuardrailSourceContractViolations`'s `completeTree` mode, over + * that call's own already-open batch (#592 review pass 2, ChatGPT PR #672 + * pass 2 P2) — never both, so the production path never opens two. + * + * @param {readonly {filename: string, source: string}[]} sources + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellGuardrailMissingBaselineViolations(sources) { + const violations = shellGuardrailMissingFileViolations(sources); + const neededFilenames = new Set([ + ...SHELL_BODY_MOUNT_POLICY.map((e) => e.filename), + ...SHELL_CAPTURE_ESCAPE_POLICY.map((e) => e.filename), + ]); + const toParse = sources.filter((s) => neededFilenames.has(s.filename)); + if (toParse.length === 0) return violations; + return violations.concat(withParsedSources( + toParse, + (sourceFiles, checkers) => shellGuardrailStrictReverseViolations(sourceFiles, checkers), + )); +} + +// ── Guard 2: `shell-fixed-position` (focused CSS lexical scanner) ─────────── +// +// No CSS parser dependency (Architecture decision 2) — a small hand-written +// lexer that skips `/* … */` comments, respects quoted strings and escape +// sequences, tracks brace nesting via an explicit frame stack (one frame per +// rule/at-rule, so a `position: fixed` declaration is always associated with +// its OWN enclosing selector list and the FULL chain of enclosing at-rules, +// never a sibling's), and normalizes whitespace/comma-selector-lists deterministically +// so the SAME logical selector always produces the SAME policy key regardless +// of incidental source formatting. + +/** Collapse every run of whitespace (including comment text collapsed to a + * single space by the scanner below) to one space, and trim. */ +function normalizeCssText(text) { + return text.replace(/\s+/g, ' ').trim(); +} + +/** Decode real CSS identifier ESCAPE SEQUENCES (#592 review pass 3) — a + * backslash followed by 1-6 hex digits (optionally consuming ONE trailing + * whitespace character that terminates the hex run, per the CSS spec) is + * that Unicode code point; a backslash followed by any other single + * character is that literal character. `scanFixedPositionDeclarations`'s + * main scan loop deliberately copies a backslash escape into its buffer + * VERBATIM (never decoding it) purely so an escaped delimiter char — `\;`, + * `\{`, `\}` — can never be mistaken for real CSS structure; it was never + * claiming the escaped TEXT itself was already normalized. Without this + * decode step, valid CSS like `\70osition: fixed;` (property) or + * `position: \66ixed;` (value) — both real, spec-legal escapes that every + * real CSS engine parses as plain `position: fixed` — stayed textually + * distinct from `'position'`/`'fixed'` and silently bypassed + * `processDeclaration`'s exact string comparisons. Applied ONLY to the + * already-colon-split property/value text right before comparison, never + * to the raw buffer used for colon/brace/semicolon SPLITTING itself (a + * decoded escape could change the text's length, which must never disturb + * where a declaration was actually delimited). */ +function decodeCssEscapes(text) { + return text.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([\s\S])/g, (_m, hex, literal) => { + if (hex !== undefined) { + const code = Number.parseInt(hex, 16); + return Number.isNaN(code) ? '' : String.fromCodePoint(code); + } + return literal ?? ''; + }); +} + +/** `normalizeCssText`, plus deterministic `,`-separated selector-list + * spacing (`', '` between each selector) regardless of the source's own + * comma spacing — so `.a,.b` and `.a, .b` produce the identical policy key, + * and appending a new selector to an existing approved group still changes + * the key (the plan's own point: a comma-separated selector list is ONE + * exact normalized policy key, so growing the list is a reviewable change). */ +function normalizeSelectorList(text) { + return normalizeCssText(text).replace(/\s*,\s*/g, ', '); +} + +/** The offset of the first real (non-whitespace, non-`/* … *\/`-comment) + * character at or after `from` in the ORIGINAL `source` text — used to + * report an accurate declaration offset even though the scanner's internal + * buffer collapses comments to a single space (which would otherwise + * misalign a naive "trim the buffered text" offset against the real file). */ +function firstMeaningfulCssOffset(source, from) { + let i = from; + const n = source.length; + while (i < n) { + const c = source[i]; + if (/\s/.test(c)) { i++; continue; } + if (c === '/' && source[i + 1] === '*') { + i += 2; + while (i < n && !(source[i] === '*' && source[i + 1] === '/')) i++; + i += 2; + continue; + } + break; + } + return i; +} + +/** + * Scan `source` (a complete CSS stylesheet) for every real `position: fixed` + * (optionally `!important`) declaration, associating each with its own + * enclosing rule's normalized selector list (`normalizeSelectorList`) and the + * FULL chain of enclosing at-rules' normalized preludes (`normalizeCssText`), + * outermost first, joined with `' > '` — or `null` when the declaration sits + * at the stylesheet's top level with no enclosing at-rule at all (e.g. NOT + * inside `@media`). #592 review pass 2: the prior implementation stopped at + * the NEAREST enclosing at-rule only, so wrapping an already-approved rule in + * an ADDITIONAL outer at-rule (`@supports (display: grid) { @media (...) { + * .inspector-host { position: fixed; } } }`) produced the identical + * fingerprint as the unwrapped rule — a real, behavior-changing structural + * edit (the rule now only applies when `@supports` also matches) was + * completely invisible to both `findShellFixedPositionViolations` and its + * missing-baseline reverse pass. A declaration sitting directly inside an + * at-rule with no intervening rule block (e.g. hypothetical `@page` content) + * is not reported — this rule only governs SELECTOR-scoped declarations, + * matching its own "associates a real position: fixed declaration with its + * rule prelude" contract. Comments/strings/escapes never contribute a + * phantom brace/semicolon/colon, so lexical trickery can't hide or spoof a + * declaration (see this section's own header comment) — and (#592 review + * pass 3) a real CSS identifier escape (`\70osition: fixed;`, + * `position: \66ixed;`) can't hide one either: `processDeclaration` decodes + * the property/value text (`decodeCssEscapes`) before comparing, so an + * escaped spelling that real CSS parses identically to `position`/`fixed` + * is recognized identically here too. `nested` is `true` when the + * declaration's own rule sits inside another plain STYLE rule — real CSS + * nesting (`.wrapper { .auth-host { position: fixed; } }` compiles to the + * descendant selector `.wrapper .auth-host`, a genuinely different, + * never-approved selector context) — as opposed to only at-rule ancestors + * (#592 review pass, ChatGPT PR #672 pass 1): the at-chain builder above only + * ever recorded 'at'-kind ancestor frames, silently skipping over any + * enclosing 'rule'-kind frame instead of folding it into the fingerprint or + * rejecting it, so a nested plain rule fingerprinted identically to its + * unwrapped, already-approved counterpart. `processDeclaration` also + * searches OUTWARD past any number of intervening 'at' frames for the + * nearest enclosing 'rule' frame (#592 review pass 2, ChatGPT PR #672 pass + * 2 P1) rather than only ever inspecting the single innermost frame — a bare + * declaration directly inside an at-rule nested in a style rule (`.rogue { + * @media (...) { position: fixed; } }`, real CSS nesting: the declaration + * inherits `.rogue` as its selector) previously produced zero candidates at + * all, not merely an unflagged one. + * + * @param {string} source + * @returns {{selector: string, atRule: string | null, nested: boolean, pos: number}[]} + */ +export function scanFixedPositionDeclarations(source) { + const n = source.length; + let i = 0; + const frames = []; // { kind: 'rule'|'at', prelude: string } + const results = []; + let buf = ''; + let segStart = 0; + + function readString(quote) { + let s = source[i]; i++; + while (i < n) { + const c = source[i]; + if (c === '\\') { s += c + (source[i + 1] ?? ''); i += 2; continue; } + s += c; i++; + if (c === quote) break; + } + return s; + } + + function processDeclaration(raw) { + const trimmed = raw.trim(); + if (!trimmed) return; + // Colon-split on the RAW (still-escaped) text — decoding first could + // shift where the real declaration boundary sits; only the two SIDES + // are decoded, right before the property-name/value comparisons below. + const colonIdx = trimmed.indexOf(':'); + if (colonIdx === -1) return; + const prop = decodeCssEscapes(trimmed.slice(0, colonIdx)).trim(); + const value = decodeCssEscapes(trimmed.slice(colonIdx + 1)).trim(); + if (prop.toLowerCase() !== 'position') return; + if (!/^fixed(\s*!\s*important)?$/i.test(normalizeCssText(value))) return; + // Find the NEAREST enclosing 'rule' frame, searching outward through any + // number of 'at' frames in between (#592 review pass 2, ChatGPT PR #672 + // pass 2 P1) — a bare declaration can sit directly inside an at-rule that + // is itself nested inside a style rule (`.rogue { @media (...) { + // position: fixed; } }`, real, browser-supported CSS nesting: the + // declaration inherits `.rogue` as its selector). The prior version only + // ever inspected the SINGLE innermost frame and bailed the instant it + // wasn't a 'rule' frame, so this exact shape produced zero candidates — + // not merely unflagged, entirely invisible to the guard. + let ruleIdx = -1; + const atChain = []; + for (let k = frames.length - 1; k >= 0; k--) { + if (frames[k].kind === 'at') { atChain.push(frames[k].prelude); continue; } + ruleIdx = k; + break; + } + if (ruleIdx === -1) return; // no enclosing rule at all — out of this rule's scope + const innermost = frames[ruleIdx]; + let nested = false; + for (let k = ruleIdx - 1; k >= 0; k--) { + if (frames[k].kind === 'at') atChain.push(frames[k].prelude); + else nested = true; // an enclosing 'rule'-kind frame, at ANY depth — real CSS nesting + } + atChain.reverse(); // outermost first + const atRule = atChain.length ? atChain.join(' > ') : null; + results.push({ selector: innermost.prelude, atRule, nested, pos: firstMeaningfulCssOffset(source, segStart) }); + } + + while (i < n) { + const c = source[i]; + if (c === '/' && source[i + 1] === '*') { + i += 2; + while (i < n && !(source[i] === '*' && source[i + 1] === '/')) i++; + i += 2; + buf += ' '; + continue; + } + if (c === '"' || c === "'") { buf += readString(c); continue; } + if (c === '\\') { buf += c + (source[i + 1] ?? ''); i += 2; continue; } + if (c === '{') { + const isAt = normalizeCssText(buf).startsWith('@'); + const prelude = isAt ? normalizeCssText(buf) : normalizeSelectorList(buf); + frames.push({ kind: isAt ? 'at' : 'rule', prelude }); + buf = ''; i++; segStart = i; + continue; + } + if (c === '}') { + processDeclaration(buf); + frames.pop(); + buf = ''; i++; segStart = i; + continue; + } + if (c === ';') { + processDeclaration(buf); + buf = ''; i++; segStart = i; + continue; + } + buf += c; i++; + } + processDeclaration(buf); // a trailing declaration with no closing `;`/`}` (malformed, defensive) + return results; +} + +/** The frozen #592 baseline: the exact current `position: fixed` selector/ + * at-rule snapshot of `src/styles.css`, generated by running + * `scanFixedPositionDeclarations` over the current file and reviewing every + * result (never hard-coding the issue attachment's approximate count) — + * authentication recovery, both file-menu overlay/dialog rules, the share + * toast, the export-progress banner, the shortcuts modal, the fullscreen + * pipeline-graph overlay, the whole variable/popover family (the combobox + * list + its "clear recent" footer, the multiselect popover, the shared + * anchored-dialog overlay, the time-range popover), the cell-detail overlay, + * and the narrow-viewport `.inspector-host` rule (deliberate post-#586 + * mobile behavior, under its own `@media (max-width: 768px)` context). */ +const SHELL_FIXED_POSITION_POLICY = Object.freeze([ + { selector: '.auth-host', atRule: null }, + { selector: '.fm-overlay', atRule: null }, + { selector: '.fm-dialog-backdrop', atRule: null }, + { selector: '.share-toast', atRule: null }, + { selector: '.export-progress', atRule: null }, + { selector: '.modal-backdrop', atRule: null }, + { selector: '.graph-overlay', atRule: null }, + { selector: '.var-combo-list', atRule: null }, + { selector: '.var-combo-footer', atRule: null }, + { selector: '.ms-popover', atRule: null }, + { selector: '.popover-overlay', atRule: null }, + { selector: '.trf-popover', atRule: null }, + { selector: '.cell-detail-overlay', atRule: null }, + { selector: '.inspector-host', atRule: '@media (max-width: 768px)' }, +]); + +/** The one fingerprint convention every `(selector, atRule)` comparison in + * this guard shares — `JSON.stringify([selector, atRule])`, so `null` + * (no enclosing at-rule) and the empty string are never conflated with each + * other, and no separator character or escaping scheme has to be invented + * (unlike a hand-joined string key, which risks exactly the kind of + * accidental-separator collision this guard exists to rule out). */ +function fixedPositionKey(selector, atRule) { + return JSON.stringify([selector, atRule]); +} + +/** + * The `shell-fixed-position` guard's FORWARD half: + * `scanFixedPositionDeclarations(cssSource)` grouped by exact + * `(selector, atRule)` fingerprint, compared against + * `SHELL_FIXED_POSITION_POLICY` by COUNT, not mere membership — the + * reviewed #672 P1: the prior implementation was a `.some(...)` membership + * check with no count at all, so a SECOND declaration reusing an already- + * approved fingerprint silently passed. Every declaration beyond a + * fingerprint's approved count (1, today, for every entry — a duplicate of + * an approved snapshot row is exactly the same un-reviewed regrowth risk a + * brand-new selector is) is flagged. + * + * The REVERSE direction (an approved fingerprint that disappeared from the + * CSS entirely) is `findShellFixedPositionMissingBaselineViolations` — a + * deliberately separate export; see its own doc comment for why folding it + * in here would break this suite's many minimal single-selector fixtures. + * + * A `nested` declaration (real CSS nesting under another plain style rule — + * `scanFixedPositionDeclarations`'s own doc comment) is NEVER compared + * against `SHELL_FIXED_POSITION_POLICY` by fingerprint at all — it is + * unconditionally flagged (#592 review pass, ChatGPT PR #672 pass 1): its + * `(selector, atRule)` fingerprint can otherwise be textually IDENTICAL to + * an already-approved top-level entry's (`.wrapper { .auth-host { position: + * fixed; } }` fingerprints as bare `.auth-host`/`atRule: null`, exactly like + * the approved, unwrapped baseline row), even though the rule's REAL + * effective selector changed (`.wrapper .auth-host`, a descendant + * selector) — a genuinely reviewable structural edit a fingerprint + * comparison alone can never distinguish from the unwrapped baseline. + * + * @param {string} cssSource + * @param {string} filename repo-relative, forward-slash separated (report only) + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellFixedPositionViolations(cssSource, filename) { + const violations = []; + const byKey = new Map(); // fingerprint -> decl[] + for (const decl of scanFixedPositionDeclarations(cssSource)) { + if (decl.nested) { + violations.push(makeViolation( + 'shell-fixed-position', filename, decl.pos, + `position: fixed on selector "${decl.selector}"${decl.atRule ? ` inside ${decl.atRule}` : ''} is nested ` + + 'inside another plain style rule (real CSS nesting changes its effective selector context, e.g. to a ' + + 'descendant selector) — this is never an approved #592 fixed-position shape; hoist the declaration out ' + + 'to a top-level (or purely at-rule-scoped) rule, or deliberately extend the reviewed fixed-position ' + + 'snapshot for a legitimate nested overlay', + )); + continue; + } + const key = fixedPositionKey(decl.selector, decl.atRule); + const list = byKey.get(key) ?? []; + list.push(decl); + byKey.set(key, list); + } + for (const [key, list] of byKey) { + const allowedCount = SHELL_FIXED_POSITION_POLICY.filter( + (p) => fixedPositionKey(p.selector, p.atRule) === key, + ).length; + for (let idx = 0; idx < list.length; idx++) { + if (idx < allowedCount) continue; + const decl = list[idx]; + const reason = allowedCount === 0 + ? 'is not on the approved #592 fixed-position snapshot' + : `duplicates an already-approved #592 fixed-position snapshot entry (approved count: ${allowedCount})`; + violations.push(makeViolation( + 'shell-fixed-position', filename, decl.pos, + `position: fixed on selector "${decl.selector}"${decl.atRule ? ` inside ${decl.atRule}` : ''} ${reason} — ` + + 'use shell/docked composition where appropriate, or deliberately extend the reviewed fixed-position ' + + 'snapshot for a legitimate overlay', + )); + } + } + return violations; +} + +/** + * The REVERSE half of the #672 P1 fixed-position fix: every + * `SHELL_FIXED_POSITION_POLICY` entry with ZERO matching occurrences in + * `cssSource` — a frozen approved fingerprint that has disappeared entirely, + * leaving stale permission for its silent, un-reviewed reintroduction. + * + * Deliberately a SEPARATE export from `findShellFixedPositionViolations`, + * unlike the TS body-mount/capture-escape guards' own reverse pass (which + * can safely stay INSIDE their single exported check, because a real + * function-like scope either is or isn't declared in whatever was parsed — + * `declaredScopeKeys` lets that check tell a partial synthetic fixture + * apart from the real file). CSS carries no equivalent structural signal: a + * `cssSource` naming only `.auth-host` could be the real, complete + * `src/styles.css` missing its other 13 entries, or it could be one of this + * suite's own many deliberately minimal single-selector fixtures — nothing + * about the string itself distinguishes the two. Folding this check into + * `findShellFixedPositionViolations` would make EVERY existing minimal CSS + * fixture in this test suite report 13+ false "missing" violations. This + * function is therefore meaningful only against something the caller + * already knows is the complete stylesheet — the real `check:arch` gate + * (`build/check-boundaries.mjs`) and the live-tree baseline test are its + * only two real callers, both scanning the actual, complete + * `src/styles.css`. + * + * @param {string} cssSource + * @param {string} filename repo-relative, forward-slash separated (report only) + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellFixedPositionMissingBaselineViolations(cssSource, filename) { + // A `nested` declaration's fingerprint never counts as "still present" for + // an approved baseline entry (#592 review pass, ChatGPT PR #672 pass 1): + // `findShellFixedPositionViolations` already unconditionally flags it on + // its own, and its REAL effective selector is no longer the approved + // top-level one (a descendant selector under its new enclosing rule) — + // wrapping the approved rule in a new enclosing style rule must report the + // ORIGINAL fingerprint missing, exactly like an outright removal would. + const found = new Set( + scanFixedPositionDeclarations(cssSource).filter((d) => !d.nested).map((d) => fixedPositionKey(d.selector, d.atRule)), + ); + const violations = []; + for (const entry of SHELL_FIXED_POSITION_POLICY) { + const key = fixedPositionKey(entry.selector, entry.atRule); + if (found.has(key)) continue; + violations.push(makeViolation( + 'shell-fixed-position', filename, 0, + `the approved #592 fixed-position snapshot expects a position: fixed declaration on selector ` + + `"${entry.selector}"${entry.atRule ? ` inside ${entry.atRule}` : ''}, but none remain in ${filename} — ` + + 'deliberately update the reviewed baseline if this was intentionally removed, or restore it if this is ' + + 'unintended drift', + )); + } + return violations; +} diff --git a/tests/unit/resize-handle-thickness-contract.test.js b/tests/unit/resize-handle-thickness-contract.test.js new file mode 100644 index 00000000..9bdb8248 --- /dev/null +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -0,0 +1,485 @@ +// Issue #592 (inherited from #586/#593 phase 1, filed against this issue's own +// "Extra acceptance") — an independent JS↔CSS drift contract, the same +// precedent `typography-contract.test.js` already establishes for +// `FONT_BYTE_BUDGET`/the type ramp: a layout constant JS reserves space for +// and CSS separately declares must not drift unnoticed. +// +// `src/ui/app-shell.ts`'s dock-aware width ceiling reserves space for the +// resize handle(s) beside the docked inspector: +// reservedPx: state.sidebarPx + HANDLE_PX * 2 // src/ui/app-shell.ts +// const HANDLE_PX = 7; // src/ui/app-shell.ts +// The real handle width is declared independently in CSS: +// .col-resize, .inspector-resize { width: 7px; } // src/styles.css +// Nothing links the two at compile time or runtime — a CSS-only edit to the +// handle width would leave `reservedPx` wrong, silently narrowing the centre +// surface below `CENTRE_MIN_PX`, with no test failing (happy-dom evaluates no +// CSS layout, and the e2e assertions are inequalities, not exact geometry). +// +// This is a TEST-ONLY, enforcement-only addition per #592's non-goals: it +// reads both production files as plain text and asserts agreement; it never +// changes `HANDLE_PX`'s runtime ownership or the CSS declaration itself. +// +// Deliberately independent extraction: `extractHandlePxValues` and +// `extractSharedResizeWidthPx` never share a helper or a source read with each +// other — the whole point is to catch disagreement between two independently +// declared values, so "expected" and "actual" must never be derived through +// the same code path (a bug in a shared extractor would silently make both +// sides agree with each other while disagreeing with reality). +// +// Stays `.js` (not `.ts`) for the same reason as `typography-contract.test.js` +// and `schema-build.test.js`: it reads repo files through `node:fs`, and the +// project carries no `@types/node` (ADR-0002's deliberate deferral). + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(process.cwd()); +const APP_SHELL_PATH = 'src/ui/app-shell.ts'; +const STYLES_PATH = 'src/styles.css'; + +const realAppShellSource = () => readFileSync(resolve(root, APP_SHELL_PATH), 'utf8'); +const realStylesSource = () => readFileSync(resolve(root, STYLES_PATH), 'utf8'); + +/** Every `const HANDLE_PX = ;` declaration found in `jsSource`, in + * source order — comments stripped first (block AND line), so a comment + * merely mentioning the declaration can never be mistaken for a real one. + * Zero, one, or many: the caller decides what count is valid — this + * extractor itself never assumes there is exactly one. */ +function extractHandlePxValues(jsSource) { + const stripped = jsSource + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:"'`])\/\/.*$/gm, '$1'); + return [...stripped.matchAll(/\bconst\s+HANDLE_PX\s*=\s*(-?\d+(?:\.\d+)?)\s*;/g)].map((m) => Number(m[1])); +} + +/** Every flat (non-nested) CSS rule in `cssSource` as `{ selectors, body }` — + * comments stripped first. Deliberately naive (no brace-nesting/at-rule + * awareness, unlike `scanFixedPositionDeclarations`'s general CSS lexer in + * `build/lib/check-legacy-owners.mjs`): this helper exists ONLY to find the + * one specific top-level `.col-resize, .inspector-resize { … }` rule this + * contract cares about, matching this repo's existing narrow, regex-based + * `typography-contract.test.js` precedent rather than reusing the general + * architecture-guard scanner for an unrelated, independent test. */ +function flatCssRules(cssSource) { + const stripped = cssSource.replace(/\/\*[\s\S]*?\*\//g, ' '); + return [...stripped.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selectors: m[1].split(',').map((s) => s.trim()).filter(Boolean), + body: m[2], + })); +} + +/** Decode real CSS identifier ESCAPE SEQUENCES — a backslash followed by 1-6 + * hex digits (optionally consuming one trailing whitespace character that + * terminates the hex run, per the CSS spec) is that Unicode code point; a + * backslash followed by any other single character is that literal + * character. Mirrors the identically-specified `decodeCssEscapes` in + * `build/lib/check-legacy-owners.mjs` (added there for the general + * `shell-fixed-position` CSS scanner, #592 review pass 3) — duplicated here + * rather than imported: that module exports no such helper, and this file's + * own established precedent (`flatCssRules`'s own doc comment above) is to + * duplicate the ONE specific need rather than reuse the general + * architecture-guard scanner for an unrelated, independent test. Applied + * ONLY to an already-split selector token or property/value text right + * before a comparison, never to the raw buffer used for brace/comma/colon + * SPLITTING — a decoded escape could change the text's length or introduce + * a real delimiter character, which must never disturb where a rule or + * declaration was actually delimited (#592 review pass 3: without this, + * real, browser-equivalent CSS like `.inspector-resize { \77idth: 8px; }` + * — `\77` = `w` — stays textually distinct from `'width'` and silently + * escapes this contract's own width comparison). */ +function decodeCssIdentifierEscapes(text) { + return text.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([\s\S])/g, (_m, hex, literal) => { + if (hex !== undefined) { + const code = Number.parseInt(hex, 16); + return Number.isNaN(code) ? '' : String.fromCodePoint(code); + } + return literal ?? ''; + }); +} + +/** True when `selector` (one already-trimmed token from a comma-split + * selector LIST — never the whole list) TARGETS `className`'s OWN box — a + * bare `.col-resize`, a COMPOUND selector (`.inspector-resize.dragging`, + * class order either way, including a pseudo-CLASS like `:hover`, which + * still styles the same element's own box), or a DESCENDANT/combinator + * selector (`.shell .inspector-resize`, `.shell > .inspector-resize`). + * #592 review pass 3: the prior check was `selectors.includes('.col- + * resize')` — exact string-list membership — so a compound or descendant + * selector naming the SAME class was invisible to the extractor below even + * though it can still win the real cascade for that class. Matched with a + * negative lookahead for another identifier/hyphen character immediately + * after the class name, so `.col-resize` never false-matches a DIFFERENT, + * longer class that merely starts with the same text (`.col-resized`, + * `.col-resize-handle`). Deliberately EXCLUDES any selector containing a + * pseudo-ELEMENT (`::before`/`::after`, real occurrences in + * `src/styles.css` today, e.g. `.col-resize::before`, + * `.col-resize:hover::before, .col-resize.dragging::before`): a + * pseudo-element is an entirely separate generated box with its own + * independent `width` — styling it is not an override of the handle + * element's OWN width, so it is correctly out of this contract's scope + * (matching how the prior exact-match check already, if incidentally, + * never matched any of these either). `selector` is decoded + * (`decodeCssIdentifierEscapes`) before either check (#592 review pass 3), + * so an escaped class-name spelling is recognized identically to its + * unescaped form. */ +function selectorTargetsResizeHandleClass(selector, className) { + const decoded = decodeCssIdentifierEscapes(selector); + if (decoded.includes('::')) return false; + return new RegExp(`\\.${className}(?![\\w-])`).test(decoded); +} + +/** Every `width: ` declaration's own numeric-px value, declared by ANY + * flat rule whose selector list names `.col-resize` and/or `.inspector- + * resize` — together (the rule that governs both classes' shared width), + * alone (a more-specific, later-declared, or media-query-scoped override + * that could still win the real cascade for just one of the two classes + * even though it never mentions the other — the P1 gap `flatCssRules`'s own + * brace-agnostic regex already sees through one level of `@media { … }` + * nesting for: an inner flat rule is matched on its own, the outer at-rule + * prelude is simply skipped as unmatched text), OR as part of a COMPOUND/ + * DESCENDANT selector naming either class (`selectorTargetsResizeHandleClass`, + * the pass-3 fix — a bare-class-list membership check alone missed + * `.inspector-resize.dragging { width: 8px; }` and `.shell .inspector-resize + * { width: 8px; }` entirely, so either override silently escaped this + * contract). EVERY matching `width:` declaration contributes exactly one + * entry — a real numeric px value (`7`, `8px !important` → `8`, the + * `!important` suffix never changes the browser's real geometry so it never + * changes what this contract extracts either) when the declared value IS a + * plain `px` (optionally `!important`), or `NaN` for anything else + * the browser's cascade could still render as a real width but this + * contract cannot statically reduce to one comparable number — `calc(...)`, + * a custom-property `var(...)`, or any other non-literal value (P1 follow- + * up, ChatGPT PR #672 pass 1: the prior regex silently SKIPPED any `width:` + * value that wasn't already an exact `px;` token, so a real, + * differently-valued `!important`/`calc()`/`var()` override on either + * resize class was invisible to this extractor — the SAME class of + * "silently skip instead of fail closed" bug the descendant/compound- + * selector fix above (pass 3) already closed for the SELECTOR side of this + * contract, just still open on the VALUE side). Order-independent; the + * contract below requires EXACTLY one declaration overall, so ANY second + * `width:` declaration on either class — standalone, compound, descendant, + * media-scoped, `!important`, or a non-literal value — makes the count 2+ + * and the contract fails closed (`css-ambiguous`) instead of silently + * reading only the grouped rule's own value while the browser's real + * cascade could render a completely different pixel width; a SOLE + * `calc()`/`var()` declaration (no clean numeric sibling at all) is a + * single `NaN` entry, which the exact-equality contract below can never + * treat as a match for the real `HANDLE_PX` either. The value-side regex's + * terminator matches a trailing `;` OR the end of the rule body itself + * (`(?:;|$)`, P1 follow-up, ChatGPT PR #672 review pass 2): a real CSS + * engine terminates a rule's LAST declaration at the closing `}` even with + * no trailing `;` (`.inspector-resize { width: 8px }`), but the prior + * regex required a literal `;` — so that declaration contributed ZERO + * entries, not even the NaN fail-closed entry a non-literal value gets, + * silently passing the contract even though the real cascade renders that + * width. Each declaration's PROPERTY NAME (the text before its own `:`) is + * decoded (`decodeCssIdentifierEscapes`) before comparing to `'width'` + * (#592 review pass 3): a real CSS identifier escape in the property name + * (`\77idth: 8px;` — `\77` = `w`, a spec-legal spelling every real browser + * parses identically to `width: 8px;`) previously stayed textually + * distinct from `'width'` and silently skipped this extractor entirely — + * the same "lexical trickery hides a declaration" gap `decodeCssEscapes` + * already closes for the general `shell-fixed-position` CSS scanner in + * `build/lib/check-legacy-owners.mjs`, never applied to this INDEPENDENT + * extractor. Declarations are found by splitting the rule body on `;` + * (rather than the previous single `width\s*:` regex) so the property-name + * text is available on its own for decoding before the comparison — the + * VALUE side's own decode-then-match shape (numeric px / `!important` / + * fail-closed `NaN`) is unchanged. */ +function extractSharedResizeWidthPx(cssSource) { + const values = []; + for (const rule of flatCssRules(cssSource)) { + const targets = rule.selectors.some( + (s) => selectorTargetsResizeHandleClass(s, 'col-resize') || selectorTargetsResizeHandleClass(s, 'inspector-resize'), + ); + if (!targets) continue; + for (const decl of rule.body.split(';')) { + const colonIdx = decl.indexOf(':'); + if (colonIdx === -1) continue; + const prop = decodeCssIdentifierEscapes(decl.slice(0, colonIdx)).trim().toLowerCase(); + if (prop !== 'width') continue; + const rawValue = decodeCssIdentifierEscapes(decl.slice(colonIdx + 1)).trim(); + const numeric = /^(-?\d+(?:\.\d+)?)px(?:\s*!\s*important)?$/i.exec(rawValue); + values.push(numeric ? Number(numeric[1]) : NaN); + } + } + return values; +} + +/** The full contract, independently computed from both extractors: valid + * only when the JS side names EXACTLY one `HANDLE_PX`, the CSS side names + * EXACTLY one shared `.col-resize`/`.inspector-resize` width, and the two + * numbers are equal. Every other combination (missing, duplicated, or + * simply disagreeing) is invalid, with a `reason` a test can assert on. */ +function resizeHandleContractStatus(jsSource, cssSource) { + const jsValues = extractHandlePxValues(jsSource); + const cssValues = extractSharedResizeWidthPx(cssSource); + if (jsValues.length !== 1) return { ok: false, reason: 'js-ambiguous', jsValues, cssValues }; + if (cssValues.length !== 1) return { ok: false, reason: 'css-ambiguous', jsValues, cssValues }; + if (jsValues[0] !== cssValues[0]) return { ok: false, reason: 'mismatch', jsValues, cssValues }; + return { ok: true, value: jsValues[0] }; +} + +describe('#592 resize-handle thickness contract (real production files)', () => { + it('exactly one HANDLE_PX declaration exists in app-shell.ts', () => { + expect(extractHandlePxValues(realAppShellSource())).toHaveLength(1); + }); + + it('.col-resize and .inspector-resize are governed by exactly one shared width declaration', () => { + expect(extractSharedResizeWidthPx(realStylesSource())).toHaveLength(1); + }); + + it('the CSS shared width exactly equals HANDLE_PX', () => { + const status = resizeHandleContractStatus(realAppShellSource(), realStylesSource()); + expect(status.ok).toBe(true); + expect(status.value).toBe(extractHandlePxValues(realAppShellSource())[0]); + }); +}); + +describe('#592 resize-handle thickness contract sabotage (synthetic — independent of the real files)', () => { + const CLEAN_JS = 'const HANDLE_PX = 7;\n'; + const CLEAN_CSS = '.col-resize, .inspector-resize { width: 7px; cursor: col-resize; }\n'; + + it('the clean baseline pair is valid (sanity check on the extractors themselves)', () => { + expect(resizeHandleContractStatus(CLEAN_JS, CLEAN_CSS)).toMatchObject({ ok: true, value: 7 }); + }); + + it('JS changes to 8 while CSS remains 7px: fails', () => { + const status = resizeHandleContractStatus('const HANDLE_PX = 8;\n', CLEAN_CSS); + expect(status).toMatchObject({ ok: false, reason: 'mismatch', jsValues: [8], cssValues: [7] }); + }); + + it('CSS changes to 8px while JS remains 7: fails', () => { + const status = resizeHandleContractStatus(CLEAN_JS, '.col-resize, .inspector-resize { width: 8px; }\n'); + expect(status).toMatchObject({ ok: false, reason: 'mismatch', jsValues: [7], cssValues: [8] }); + }); + + it('.col-resize and .inspector-resize stop sharing the intended declaration: fails', () => { + // Two INDEPENDENT single-class rules, not one shared rule — the P1 fix + // (extraction is now OR-based, not AND-based) means each is now its own + // "could target either resize class" width declaration, so this is + // still `css-ambiguous` (length !== 1), just via [7, 7] rather than the + // old AND-only extractor's `[]` (which only ever looked at the combined + // rule and never saw either standalone declaration at all). + const css = '.col-resize { width: 7px; }\n.inspector-resize { width: 7px; }\n'; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 7] }); + }); + + it('the JS constant is missing entirely: fails', () => { + const status = resizeHandleContractStatus('const OTHER = 7;\n', CLEAN_CSS); + expect(status).toMatchObject({ ok: false, reason: 'js-ambiguous', jsValues: [] }); + }); + + it('the JS constant is duplicated: fails', () => { + const js = 'const HANDLE_PX = 7;\nfunction f() { const HANDLE_PX = 9; return HANDLE_PX; }\n'; + const status = resizeHandleContractStatus(js, CLEAN_CSS); + expect(status).toMatchObject({ ok: false, reason: 'js-ambiguous', jsValues: [7, 9] }); + }); + + it('the CSS width is missing from the shared rule: fails', () => { + const css = '.col-resize, .inspector-resize { cursor: col-resize; }\n'; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [] }); + }); + + it('the CSS width is ambiguous (declared twice in the same shared rule): fails', () => { + const css = '.col-resize, .inspector-resize { width: 7px; width: 9px; }\n'; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 9] }); + }); + + // P1 (accepted, PR #672 review pass 1): a standalone or media-scoped + // single-class override rule used to be invisible to + // `extractSharedResizeWidthPx` (it only looked at rules naming BOTH + // classes together), so this exact drift — the browser renders the + // inspector handle at a DIFFERENT width than `HANDLE_PX` reserves — passed + // the contract silently. + + it('a later standalone .inspector-resize override with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.inspector-resize { width: 8px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 8] }); + }); + + it('a standalone .col-resize override with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.col-resize { width: 9px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 9] }); + }); + + it('a media-query-scoped .inspector-resize override with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}@media (max-width: 768px) {\n .inspector-resize { width: 10px; }\n}\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 10] }); + }); + + it('a standalone override that happens to repeat the SAME width still fails (no single-source-of-truth exception)', () => { + const css = `${CLEAN_CSS}.inspector-resize { width: 7px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 7] }); + }); + + // P1 follow-up (accepted, ChatGPT PR #672 review pass 1): the value-side + // regex required an EXACT `px;` token with nothing else between + // the number and the semicolon, so a `!important` suffix or a non-literal + // value (`calc(...)`, `var(...)`) was silently SKIPPED rather than counted + // — the real browser cascade still applies these declarations (an + // `!important` value WINS over a normal one; `calc()`/`var()` compute to + // some real pixel width), but the extractor never even saw them, so the + // contract stayed green while a real, differently-valued override sat + // right there in the CSS. + + it('a later standalone !important override with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.inspector-resize { width: 8px !important; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 8] }); + }); + + it('an !important override with no space before the bang is still counted', () => { + const css = `${CLEAN_CSS}.col-resize { width: 9px!important; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 9] }); + }); + + it('a calc() override is counted as an unconvertible (NaN) value, not silently skipped', () => { + const css = `${CLEAN_CSS}.inspector-resize { width: calc(7px + 1px); }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status.ok).toBe(false); + expect(status.reason).toBe('css-ambiguous'); + expect(status.cssValues).toHaveLength(2); + expect(status.cssValues[0]).toBe(7); + expect(Number.isNaN(status.cssValues[1])).toBe(true); + }); + + it('a var(--custom-property) override is counted as an unconvertible (NaN) value, not silently skipped', () => { + const css = `${CLEAN_CSS}.col-resize { width: var(--handle-width); }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status.ok).toBe(false); + expect(status.reason).toBe('css-ambiguous'); + expect(status.cssValues).toHaveLength(2); + expect(status.cssValues[0]).toBe(7); + expect(Number.isNaN(status.cssValues[1])).toBe(true); + }); + + it('a later standalone override with NO trailing semicolon (end-of-rule-body terminator) is still counted', () => { + // P1 follow-up (ChatGPT PR #672 review pass 2): the value-side regex + // required a literal trailing `;`, but a real CSS engine terminates the + // LAST declaration in a rule at the closing `}` just as validly — this + // declaration must not silently contribute zero entries. + const css = `${CLEAN_CSS}.inspector-resize { width: 8px }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 8] }); + }); + + it('a SOLE var(...) declaration (no clean numeric sibling) is a single unconvertible NaN value, never a false match', () => { + const css = '.col-resize, .inspector-resize { width: var(--handle-width); }\n'; + const values = extractSharedResizeWidthPx(css); + expect(values).toHaveLength(1); + expect(Number.isNaN(values[0])).toBe(true); + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status.ok).toBe(false); // NaN !== 7 either way — never a silent pass + }); + + it('a comment-only mention of HANDLE_PX does not count as a declaration', () => { + const js = '// const HANDLE_PX = 7; (old value)\n/* const HANDLE_PX = 9; */\n'; + expect(extractHandlePxValues(js)).toEqual([]); + }); + + it('a comment-only mention of the shared width rule does not count as a declaration', () => { + const css = '/* .col-resize, .inspector-resize { width: 7px; } */\n'; + expect(extractSharedResizeWidthPx(css)).toEqual([]); + }); + + // #592 review pass 3: a COMPOUND selector (two classes on the same + // element, `.inspector-resize.dragging`) or a DESCENDANT selector + // (`.shell .inspector-resize`) still targets `.inspector-resize` — and can + // still win the real cascade for it — but `selectors.includes('.inspector- + // resize')`'s exact-string-list membership check made both invisible to + // the extractor entirely, so a `width` override written either way never + // even reached the `cssValues.length !== 1` gate. + + it('a compound-selector override (.inspector-resize.dragging) with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.inspector-resize.dragging { width: 8px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 8] }); + }); + + it('a compound-selector override with the classes in the opposite order (.dragging.inspector-resize) fails', () => { + const css = `${CLEAN_CSS}.dragging.inspector-resize { width: 9px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 9] }); + }); + + it('a descendant-selector override (.shell .inspector-resize) with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.shell .inspector-resize { width: 10px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 10] }); + }); + + it('a compound-selector override on .col-resize (.col-resize.active) with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.col-resize.active { width: 11px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 11] }); + }); + + it('a same-prefix but DIFFERENT class name is never mistaken for a match (.col-resized)', () => { + // Sanity check on the negative-lookahead boundary: `.col-resized` must + // never be treated as targeting `.col-resize`. + const css = `${CLEAN_CSS}.col-resized { width: 99px; }\n`; + expect(extractSharedResizeWidthPx(css)).toEqual([7]); + }); + + it('a pseudo-CLASS compound override (.inspector-resize:hover) with a DIFFERENT width fails', () => { + // Unlike a pseudo-ELEMENT (below), `:hover` still styles the SAME + // element's own box — a real override this contract must catch. + const css = `${CLEAN_CSS}.inspector-resize:hover { width: 12px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 12] }); + }); + + it('a pseudo-ELEMENT selector (.col-resize::before) is never mistaken for the handle\'s own width', () => { + // Real shape from src/styles.css: `.col-resize::before { width: 1px; … }` + // styles the decorative `::before` pseudo-element — an entirely separate + // generated box with its own independent width, not an override of the + // handle element's OWN width, so it correctly stays out of scope. + const css = `${CLEAN_CSS}.col-resize::before { width: 1px; }\n`; + expect(extractSharedResizeWidthPx(css)).toEqual([7]); + }); + + it('a compound-then-pseudo-element selector (.col-resize.dragging::before) is never mistaken for the handle\'s own width', () => { + const css = `${CLEAN_CSS}.col-resize.dragging::before { width: 1px; }\n`; + expect(extractSharedResizeWidthPx(css)).toEqual([7]); + }); + + // #592 review pass 3: real CSS identifier ESCAPE SEQUENCES in the + // PROPERTY NAME — `\77idth` (a hex escape: `\77` = `w`) or `\width` (a + // single-character escape: `\w` = literal `w`) — are both, per the CSS + // spec, exactly equivalent to plain `width` in every real browser, but the + // prior regex matched only the literal text `width` and silently skipped + // either escaped spelling entirely, leaving a real, differently-valued + // override invisible to this contract. + + it('an escaped property name using a hex CSS identifier escape (\\77idth) with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.inspector-resize { \\77idth: 8px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 8] }); + }); + + it('an escaped property name using a single-character CSS identifier escape (\\width) with a DIFFERENT width fails', () => { + const css = `${CLEAN_CSS}.col-resize { \\width: 9px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 9] }); + }); + + it('an escaped selector class name (.inspector-re\\size, equivalent to .inspector-resize) with a DIFFERENT width fails', () => { + // `\s` is a single-character escape (`s` is not a hex digit), decoding + // to literal `s` — unlike `\e` (which real CSS parses as the HEX escape + // for code point U+000E, since `e` IS a valid hex digit), so `s` is + // deliberately the escaped character here. + const css = `${CLEAN_CSS}.inspector-re\\size { width: 10px; }\n`; + const status = resizeHandleContractStatus(CLEAN_JS, css); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 10] }); + }); +}); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts new file mode 100644 index 00000000..3a66f86a --- /dev/null +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -0,0 +1,1762 @@ +// Issue #592 — lock in what #586 ("one overlay-lifecycle implementation") and +// #587 ("a registry-driven panel model") established, mechanically, so the +// six-copy-pasted-overlays problem #586 fixed cannot silently regrow the same +// way it grew the first time. Three real-parser/lexer-backed checks, sharing +// this repo's established idiom (`findSurfaceLifecycleSourceContractViolations`, +// #643): real TypeScript AST for the two source-level rules +// (`findShellGuardrailSourceContractViolations`, one shared parser batch for +// BOTH — Architecture decision 4), and a focused CSS lexical scanner (no CSS +// parser dependency — `scanFixedPositionDeclarations`/ +// `findShellFixedPositionViolations`, Architecture decision 2) for the CSS +// rule. This file follows `surface-lifecycle-arch.test.ts`'s own sibling +// shape: a live-tree zero-violation baseline PLUS synthetic fixtures/sabotage +// for every case the plan's own Test Matrix lists — never guessed, every +// fingerprint below traces to a real, reviewed occurrence in the current tree +// (see `build/lib/check-legacy-owners.mjs`'s own `SHELL_BODY_MOUNT_POLICY`/ +// `SHELL_CAPTURE_ESCAPE_POLICY`/`SHELL_FIXED_POSITION_POLICY`). +// +// Stays `.ts` (not `.js`) for the same reason `surface-lifecycle-arch.test.ts` +// and `side-panel-source-contract.test.ts` do: it consumes the strict +// `.d.mts` declaration boundary over the `.mjs` implementation, so a caller +// error is caught by `tsc --noEmit`, not just at runtime. + +import { beforeAll, describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + findShellGuardrailSourceContractViolations, + findShellGuardrailMissingBaselineViolations, + findShellFixedPositionViolations, + findShellFixedPositionMissingBaselineViolations, + scanFixedPositionDeclarations, +} from '../../build/lib/check-legacy-owners.mjs'; +import type { + SourceContractViolation, ShellGuardrailSourceEntry, FixedPositionDeclaration, +} from '../../build/lib/check-legacy-owners.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); // tests/unit +const root = join(here, '..', '..'); // repo root +const srcDir = join(root, 'src'); + +function listSourceFiles(): string[] { + return readdirSync(srcDir, { recursive: true }) + .filter((rel) => /\.(ts|js)$/.test(rel)) + // Generated code is never hand-edited and can't legally reference these + // production constructs anyway — excluded for signal, not correctness + // (matches surface-lifecycle-arch.test.ts's own exclusion). + .filter((rel) => !rel.startsWith('generated' + '/') && !rel.includes(`${'generated'}/`)) + .map((rel) => 'src/' + rel.split('\\').join('/')); +} + +function rulesOf(vs: SourceContractViolation[]): string[] { + return vs.map((v) => v.rule); +} + +// ── Live-tree baseline ─────────────────────────────────────────────────────── +// Mandatory proof that the policy tables and production `check:arch` operate +// on the SAME baseline: zero #592 violations over the complete current +// scanned tree. + +describe('#592 shell guardrails: live-tree baseline', () => { + let tsViolations: SourceContractViolation[]; + let missingBaselineViolations: SourceContractViolation[]; + let cssViolations: SourceContractViolation[]; + let sources: ShellGuardrailSourceEntry[]; + + beforeAll(() => { + const files = listSourceFiles(); + sources = files.map((relPath) => ({ + filename: relPath, + source: readFileSync(join(root, relPath), 'utf8'), + })); + tsViolations = findShellGuardrailSourceContractViolations(sources); + missingBaselineViolations = findShellGuardrailMissingBaselineViolations(sources); + const css = readFileSync(join(root, 'src/styles.css'), 'utf8'); + cssViolations = findShellFixedPositionViolations(css, 'src/styles.css'); + }, 10000); + + it('no shell-body-mount violation exists anywhere in the current tree', () => { + expect(tsViolations.filter((v) => v.rule === 'shell-body-mount')).toEqual([]); + }); + + it('no shell-capture-escape violation exists anywhere in the current tree', () => { + expect(tsViolations.filter((v) => v.rule === 'shell-capture-escape')).toEqual([]); + }); + + it('the complete-tree reverse check also reports zero violations (sanity check)', () => { + expect(missingBaselineViolations).toEqual([]); + }); + + it('every #592-scanned filename listed by listSourceFiles() actually exists in the batch (sanity check on the sanity check)', () => { + // Otherwise the previous assertion could pass vacuously (an empty + // `sources` array trivially has zero "missing" violations too). + expect(sources.length).toBeGreaterThan(0); + }); + + it('no shell-fixed-position violation exists in src/styles.css', () => { + expect(cssViolations).toEqual([]); + }); +}); + +// ── Shared synthetic-fixture scope builder ────────────────────────────────── +// Every #592 policy entry keys on (filename, scope PATH) — see +// `enclosingScopePath`'s own doc comment in check-legacy-owners.mjs. Real +// scope names are one of three shapes: a plain named function +// (`function openMenu() { … }`), the literal placeholder `'mount'` (an +// object-literal `mount:` property, matching every real `openInDetachedTab` +// caller), or the literal placeholder `''` (an IIFE arrow, matching +// `withDocument(doc, () => { … })`'s real shape). `wrapScope` nests `inner` +// through exactly that chain, outermost first, so a fixture reproduces the +// SAME scope path the real occurrence has — never a hand-guessed approximation. + +function wrapScope(names: readonly string[], inner: string): string { + let code = inner; + for (let i = names.length - 1; i >= 0; i--) { + const name = names[i]; + if (name === '') { + code = `(() => {\n${code}\n})();`; + } else if (name === 'mount') { + code = `const _m = { mount: (ctx) => {\n${code}\n} };`; + } else { + code = `function ${name}() {\n${code}\n}`; + } + } + return code; +} + +function shellViolations(sources: ShellGuardrailSourceEntry[]): SourceContractViolation[] { + return findShellGuardrailSourceContractViolations(sources); +} + +function bodyMountRulesFor(filename: string, scopePath: readonly string[], body: string): string[] { + const source = wrapScope(scopePath, body); + return rulesOf(shellViolations([{ filename, source }]).filter((v) => v.rule === 'shell-body-mount')); +} + +/** Prefix `body` with `const = document;` — every fixture below that + * addresses a Document through a bare local name (`doc`, `d`, `childDoc`, + * `mainDoc`) must first ESTABLISH that alias in its own synthetic snippet + * (a fixture has no surrounding real file supplying a typed parameter), or + * the analyzer correctly does NOT recognize the receiver as a Document at + * all — which would make a positive case pass, and a sabotage case fail, + * for the WRONG reason (no candidate detected) rather than the intended one + * (a candidate detected and correctly classified). `deps.document`/ + * `opts.document`/bare `document`/`window` receivers need no such prefix: + * they resolve structurally, with no alias declaration required. + */ +function withDocAlias(alias: string, body: string): string { + return `const ${alias} = document;\n${body}`; +} + +// ── Body-mount positive cases ──────────────────────────────────────────────── +// Every current sanctioned shape (`SHELL_BODY_MOUNT_POLICY`'s own 11 entries), +// each reproduced as a minimal synthetic fixture under its real filename. + +describe('#592 shell-body-mount: positive characterization (sanctioned current shapes)', () => { + const cases: Array<[string, string, readonly string[], string]> = [ + ['SurfaceLifecycle-backed results overlay (openCellDetail)', 'src/ui/results.ts', ['openCellDetail', ''], + withDocAlias('doc', "openSurfaceLifecycle({ document: doc }); doc.body.appendChild(backdrop);")], + ['dialog-shell', 'src/ui/dialog-shell.ts', ['openDialogShell'], withDocAlias('doc', 'doc.body.appendChild(backdrop);')], + ['popover anchored-dialog family', 'src/ui/popover.ts', ['openAnchoredDialog'], + withDocAlias('d', 'd.body.appendChild(overlay); d.body.appendChild(dialog);')], + ['popover anchored-popover family', 'src/ui/popover.ts', ['createAnchoredPopovers', 'open'], + 'deps.document.body.appendChild(node);'], + ['toast', 'src/ui/toast.ts', ['flashToast'], withDocAlias('doc', 'doc.body.appendChild(el);')], + ['detached child-document mount', 'src/ui/detached-view.ts', ['openAsTab', ''], + withDocAlias('childDoc', 'childDoc.body.appendChild(panel);')], + ['detached main-document fallback', 'src/ui/detached-view.ts', ['openAsOverlay', ''], + withDocAlias('mainDoc', 'mainDoc.body.appendChild(backdrop);')], + ['menu', 'src/ui/menu.ts', ['openMenu'], + withDocAlias('doc', 'doc.body.appendChild(overlay); doc.body.appendChild(menu);')], + ['shortcuts modal', 'src/ui/shortcuts.ts', ['openShortcuts'], withDocAlias('doc', 'doc.body.appendChild(backdrop);')], + ['export-progress surface', 'src/ui/app.ts', ['createApp', 'showExportProgress'], + withDocAlias('doc', 'doc.body.appendChild(el);')], + ['temporary download-anchor utility', 'src/ui/app.ts', ['createApp', 'downloadFile'], + withDocAlias('doc', 'doc.body.appendChild(a);')], + ]; + for (const [label, filename, scopePath, body] of cases) { + it(`${label} passes`, () => { + expect(bodyMountRulesFor(filename, scopePath, body)).toEqual([]); + }); + } + + it('comments/string literals containing body-append text remain clean', () => { + const found = bodyMountRulesFor('src/ui/_lookalike-mount.ts', ['openLookalikeMount'], [ + "// doc.body.appendChild(fake);", + "const s = 'doc.body.appendChild(fake)';", + 'const n = 1;', + ].join('\n')); + expect(found).toEqual([]); + }); +}); + +// ── Body-mount sabotage cases ───────────────────────────────────────────── + +describe('#592 shell-body-mount: sabotage (each must fail)', () => { + const NEW_FILE = 'src/ui/_sabotage-body-mount.ts'; + + const receiverCases: Array<[string, string]> = [ + ['document.body.appendChild(panel)', 'document.body.appendChild(panel);'], + ['document.body.append(panel)', 'document.body.append(panel);'], + ["doc.body.appendChild(panel) (doc: Document parameter)", 'function f(doc: Document) { doc.body.appendChild(panel); }'], + ['window.document.body.appendChild(panel)', 'window.document.body.appendChild(panel);'], + ['childDoc.body.appendChild(panel) (childDoc: Document parameter)', + 'function f(childDoc: Document) { childDoc.body.appendChild(panel); }'], + ['mainDoc.body.appendChild(panel) (mainDoc: Document parameter)', + 'function f(mainDoc: Document) { mainDoc.body.appendChild(panel); }'], + ['deps.document.body.appendChild(panel)', 'deps.document.body.appendChild(panel);'], + ["bracket-property spelling doc['body']['appendChild'](panel)", + "function f(doc: Document) { doc['body']['appendChild'](panel); }"], + ['const body = childDoc.body; body.appendChild(panel)', + 'function f(childDoc: Document) { const body = childDoc.body; body.appendChild(panel); }'], + ['simple propagated body alias (const b = body; b.appendChild(panel))', + 'function f(childDoc: Document) { const body = childDoc.body; const b = body; b.appendChild(panel); }'], + // ChatGPT PR #672 review pass 2 P1: `resolvesToDocumentBody` previously + // only ever looked at a `let`/`var` binding's OWN initializer — a LATER + // whole-binding reassignment to `document.body` (the value actually in + // effect at the real `.appendChild` call) was invisible, so this real + // mount silently escaped the guard entirely. + ['let body REASSIGNED to document.body after declaration (not the stale initializer)', + "let body = document.createElement('div'); body = document.body; body.appendChild(panel);"], + ]; + for (const [label, body] of receiverCases) { + it(`${label} fails`, () => { + const source = `function openRogue() {\n${body}\n}`; + const found = shellViolations([{ filename: NEW_FILE, source }]).filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + } + + // Negative control on the write-aware `let`/`var` resolution above: an + // ORDINARY `let`-bound element receiver that is NEVER reassigned to + // `document.body` must stay entirely unaffected — proving the fix isn't + // simply "treat every non-const .appendChild/.append receiver as a mount". + it('an ordinary let-bound container (never reassigned to document.body) stays clean', () => { + const source = "function openRogue() { let container = document.createElement('div'); container.appendChild(panel); }"; + const found = shellViolations([{ filename: NEW_FILE, source }]).filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); + + it('a second mount inside an otherwise approved scope fails (only the excess one)', () => { + const found = bodyMountRulesFor('src/ui/toast.ts', ['flashToast'], + withDocAlias('doc', 'doc.body.appendChild(el); doc.body.appendChild(doc.createElement("div"));')); + expect(found).toEqual(['shell-body-mount']); + }); + + it('a new mount elsewhere in an approved FILE fails (exceptions are not filename-wide)', () => { + const found = bodyMountRulesFor('src/ui/toast.ts', ['someOtherFunction'], withDocAlias('doc', 'doc.body.appendChild(el);')); + expect(found).toEqual(['shell-body-mount']); + }); + + it('removing openSurfaceLifecycle(...) from a mount whose permission depends on it fails', () => { + // Same fingerprint as the SurfaceLifecycle-backed positive case above, + // but WITHOUT the openSurfaceLifecycle(...) call in the same scope. + const found = bodyMountRulesFor('src/ui/results.ts', ['openCellDetail', ''], + withDocAlias('doc', 'doc.body.appendChild(backdrop);')); + expect(found).toEqual(['shell-body-mount']); + }); +}); + +// ── Body-mount missing-baseline-entry sabotage (P1, PR #672 review pass 1) ── +// The prior implementation only ever flagged EXCESS occurrences in a scope +// that still had at least one candidate — a scope whose approved mount +// disappeared ENTIRELY produced zero violations, comparing the policy and +// the discovered candidates as an allowlist in only one direction. + +describe('#592 shell-body-mount: missing-baseline-entry sabotage (each must fail)', () => { + it('an approved scope that loses its ONLY frozen body-mount occurrence fails', () => { + // src/ui/toast.ts's flashToast is approved for exactly 1 body mount — + // remove it entirely (an empty scope body) rather than adding an excess. + const found = bodyMountRulesFor('src/ui/toast.ts', ['flashToast'], ''); + expect(found).toEqual(['shell-body-mount']); + }); + + it('an approved scope that drops from 2 approved occurrences to 1 fails', () => { + // src/ui/menu.ts's openMenu is approved for exactly 2 — keep only 1. + const found = bodyMountRulesFor('src/ui/menu.ts', ['openMenu'], withDocAlias('doc', 'doc.body.appendChild(overlay);')); + expect(found).toEqual(['shell-body-mount']); + }); +}); + +// ── Body-mount same-file scope-shadowing sabotage (P1, PR #672 review pass 1) ── +// The alias resolvers used to collect bindings by bare identifier across the +// ENTIRE source file (one flat last-write-wins Map), not lexically — a later +// sibling/nested same-named binding in an unrelated scope could silently +// erase an earlier one, hiding a real violation. These fixtures put TWO +// scopes with the SAME local names in ONE file (`shellViolations` operates +// per-file, so `bodyMountRulesFor`'s single-scope wrapper can't reproduce +// this — the source is built by hand instead). + +describe('#592 shell-body-mount: same-file scope-shadowing sabotage (each must fail)', () => { + it('a later sibling doc: Window function does not erase an earlier doc: Document body mount', () => { + const source = [ + 'function sneaky(doc: Document) { doc.body.appendChild(panel); }', + 'function unrelated(doc: Window) { }', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-shadow-doc-a.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('the same fixture with declaration order reversed still detects the violation', () => { + // Proves the fix is genuinely scope-aware, not an artifact of which + // declaration happens to come last in source order. + const source = [ + 'function unrelated(doc: Window) { }', + 'function sneaky(doc: Document) { doc.body.appendChild(panel); }', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-shadow-doc-b.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Body-mount nested block-local shadowing sabotage (review pass 2) ─────── +// One level finer than the #672 P1 same-FUNCTION shadowing fix above: a +// block-local `let`/`const` (an `if`/loop/bare `{ }` body, never a nested +// function) used to collapse into the SAME per-function alias bucket as an +// outer parameter of the same name, so the block-local shadow could silently +// erase (or be silently erased by) a real occurrence anywhere else in that +// same function — regardless of whether the real occurrence is inside or +// outside the shadow's own block. + +describe('#592 shell-body-mount: nested block-local shadowing sabotage (each must fail)', () => { + it('a block-local doc: Window shadow does not hide an outer doc: Document body mount AFTER the block', () => { + const source = [ + 'function openThing(doc: Document) {', + ' if (c) {', + ' const doc: Window = getPopup();', + ' doc.close();', + ' }', + ' doc.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-block-shadow-doc-a.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a block-local doc: Window shadow does not hide an outer doc: Document body mount BEFORE the block', () => { + const source = [ + 'function openThing(doc: Document) {', + ' doc.body.appendChild(panel);', + ' if (c) {', + ' const doc: Window = getPopup();', + ' doc.close();', + ' }', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-block-shadow-doc-b.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('the block-local doc: Window shadow correctly applies to a mount made INSIDE its own block', () => { + // Proves the fix is genuinely block-scoped, not merely "ignore inner + // shadows": a body-mount attempt through the SAME name INSIDE the block + // resolves against the block-local Window, not the outer Document, and + // is correctly NOT flagged. + const source = [ + 'function openThing(doc: Document) {', + ' if (c) {', + ' const doc: Window = getPopup();', + ' doc.body.appendChild(panel);', + ' }', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-block-shadow-doc-c.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); +}); + +// ── Body-mount declaration-kind-aware scoping sabotage (review pass 3) ───── +// Every table keyed through `scopeOwnerOf` used to assign EVERY binding to +// the nearest Block/CaseBlock/function regardless of `var` vs `let`/`const` +// — so a `var` bound inside a nested block (real JS: function-scoped, not +// block-scoped) could disappear from analysis for a later use OUTSIDE that +// block, and a `let`/`const` bound in a `for`/`for-in`/`for-of` HEADER (real +// JS: scoped to the whole loop construct, never the same scope as code +// physically outside it) could clobber a same-named outer binding in the +// SAME enclosing scope map. + +describe('#592 shell-body-mount: declaration-kind-aware scoping sabotage (each must fail)', () => { + it('a var bound ONLY inside a nested block is still visible for a mount AFTER the block (var is function-scoped)', () => { + const source = [ + 'function openRogue() {', + ' if (c) {', + ' var d = document;', + ' }', + ' d.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-var-block-escape.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a for-header let does not clobber an outer const Document alias in the same enclosing scope', () => { + const source = [ + 'function openRogue() {', + ' const doc = document;', + ' for (let doc = window; false; ) { }', + ' doc.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-for-header-let-leak.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a for-header var still leaks to the enclosing function scope (real var hoisting) and is still caught', () => { + const source = [ + 'function openRogue() {', + ' for (var d = document; false; ) { }', + ' d.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-for-header-var-leak.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Body-mount destructuring alias sabotage (review pass 3) ──────────────── +// `const { body } = document;` and the renamed `const { body: host } = +// document;` are direct `Document.body` mounts exactly like the plain- +// identifier `const body = document.body;` form above — `bodyAliasMap`'s own +// alias-building walk used to recognize only a plain-`Identifier` binding +// name, so a destructuring alias of either shape registered no candidate at +// all and bypassed the guard entirely. + +describe('#592 shell-body-mount: destructuring alias sabotage (each must fail)', () => { + it('const { body } = document; body.appendChild(panel) fails', () => { + const source = 'function openRogue() { const { body } = document; body.appendChild(panel); }'; + const found = shellViolations([{ filename: 'src/ui/_sabotage-destructure-body.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('const { body: host } = document; host.append(panel) (renamed) fails', () => { + const source = 'function openRogue() { const { body: host } = document; host.append(panel); }'; + const found = shellViolations([{ filename: 'src/ui/_sabotage-destructure-body-renamed.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + // ChatGPT PR #672 review pass 1: the prior check only recognized a + // destructuring rename whose OWN `propertyName` was a plain `Identifier` + // (`{ body: host }`) — a QUOTED source key (`{ 'body': host }`, valid, + // equivalent JS/TS syntax) fell through to reading the LOCAL binding name + // (`host`) instead of the real source property (`body`), so this exact + // shape silently bypassed the guard. + it("const { 'body': host } = document; host.appendChild(panel) (quoted rename) fails", () => { + const source = "function openRogue() { const { 'body': host } = document; host.appendChild(panel); }"; + const found = shellViolations([{ filename: 'src/ui/_sabotage-destructure-body-quoted.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it("const { ['body']: host } = document; host.appendChild(panel) (computed string-literal rename) fails", () => { + const source = "function openRogue() { const { ['body']: host } = document; host.appendChild(panel); }"; + const found = shellViolations([{ filename: 'src/ui/_sabotage-destructure-body-computed.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + // The identical quoted-rename gap also applied to the OTHER destructuring + // recognition branch — resolving a Document/Window alias itself (not just + // its `.body`) through a renamed `{ document: doc }`/`{ window: win }` + // destructure — since both go through the SAME `bindingElementSourceKeyName` + // helper. + it("const { 'document': doc } = opts; doc.body.appendChild(panel) (quoted document-alias rename) fails", () => { + const source = 'function openRogue() { const { \'document\': doc } = opts; doc.body.appendChild(panel); }'; + const found = shellViolations([{ filename: 'src/ui/_sabotage-quoted-document-rename.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Body-mount type-alias sabotage (ChatGPT PR #672 review pass 1) ───────── +// `type Doc = Document; function f(doc: Doc) { ... }` names a real +// `Document` exactly like a direct `: Document` annotation would — the +// prior `typeNamesOf`-only check read only the type annotation's own +// LITERAL name (`'Doc'`), never resolving it through the checker to see +// that it's a local alias FOR `Document`, so this exact shape silently +// bypassed the guard. + +describe('#592 shell-body-mount: type-alias sabotage (each must fail)', () => { + it('a local type alias for Document (type Doc = Document) is recognized as a Document parameter', () => { + const source = [ + 'type Doc = Document;', + 'function openRogue(doc: Doc) { doc.body.appendChild(panel); }', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-type-alias-doc.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a local type alias for Window (type Win = Window) is recognized as a candidate addEventListener receiver', () => { + const source = [ + 'type Win = Window;', + "function openRogue(win: Win) { const onKey = (e) => { if (e.key === 'Escape') close(); }; " + + "win.addEventListener('keydown', onKey, true); }", + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-type-alias-win.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a CHAIN of two type aliases (type A = Document; type Doc = A;) is still resolved to Document', () => { + const source = [ + 'type A = Document;', + 'type Doc = A;', + 'function openRogue(doc: Doc) { doc.body.appendChild(panel); }', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-type-alias-chain.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('an unrelated type alias (type Doc = string) is never mistaken for a Document alias', () => { + const source = [ + 'type Doc = string;', + 'function openRogue(doc: Doc) { doc.body.appendChild(panel); }', // not a real Document — `.body` is nonsense on a string, but the check is purely structural + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_not-a-document-type-alias.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); + + it('a direct : Document annotation still works unchanged (no regression from the alias-chain resolver)', () => { + const source = 'function openRogue(doc: Document) { doc.body.appendChild(panel); }'; + const found = shellViolations([{ filename: 'src/ui/_sabotage-direct-document-still-works.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Capture-Escape positive cases ─────────────────────────────────────────── + +function captureEscapeRulesFor(filename: string, scopePath: readonly string[], body: string): string[] { + const source = wrapScope(scopePath, body); + return rulesOf(shellViolations([{ filename, source }]).filter((v) => v.rule === 'shell-capture-escape')); +} + +describe('#592 shell-capture-escape: positive characterization (sanctioned current shapes)', () => { + const cases: Array<[string, string, readonly string[], string]> = [ + ['canonical SurfaceLifecycle', 'src/ui/surface-lifecycle.ts', ['openSurfaceLifecycle'], + withDocAlias('doc', "const onKeyDown = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKeyDown, true);")], + ['dialog exception', 'src/ui/dialog-shell.ts', ['openDialogShell'], + withDocAlias('doc', "const onKey = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey, true);")], + ['popover exception (anchored-dialog family)', 'src/ui/popover.ts', ['openAnchoredDialog'], + withDocAlias('d', "const onKeyDown = (e) => { if (e.key === 'Escape') close(); }; d.addEventListener('keydown', onKeyDown, true);")], + ['popover exception (anchored-popover family)', 'src/ui/popover.ts', ['createAnchoredPopovers', 'open'], + "const onKey = (e) => { if (e.key === 'Escape') close(); }; deps.document.addEventListener('keydown', onKey, true);"], + ['results.ts distinct Data Pane handler', 'src/ui/results.ts', ['expandDataPane', 'mount'], + withDocAlias('doc', "const onKey = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey, true);")], + ['explain-graph.ts pipeline handler', 'src/ui/explain-graph.ts', ['openPipelineFullscreen', 'mount'], + withDocAlias('doc', "const onKey = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey, true);")], + ['explain-graph.ts schema handler', 'src/ui/explain-graph.ts', ['openSchemaView', 'mount'], + withDocAlias('doc', "const onKey = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey, true);")], + ['menu.ts', 'src/ui/menu.ts', ['openMenu'], + withDocAlias('doc', "const onKey = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey, true);")], + ['dashboard-tile-gestures.ts grid-resize Escape cancellation', 'src/ui/dashboard-tile-gestures.ts', + ['createTileGestureController', 'wireGridResize', ''], + "const onKey = (ev) => { if (ev.key === 'Escape') cancel(); }; deps.document.addEventListener('keydown', onKey, true);"], + ['dashboard-tile-gestures.ts tile-drag Escape cancellation', 'src/ui/dashboard-tile-gestures.ts', + ['createTileGestureController', 'wireTileDrag', 'onPointerDown'], + "const onKey = (ev) => { if (ev.key === 'Escape') cleanup(); }; deps.document.addEventListener('keydown', onKey, true);"], + ['dashboard-chart-interaction.ts beginSelection chart-selection Escape cancellation', + 'src/ui/dashboard-chart-interaction.ts', ['createDashboardChartInteractionController', 'beginSelection'], + "const onKey = (event) => { if (event.key === 'Escape') cancel(); }; opts.document.addEventListener('keydown', onKey, true);"], + ]; + for (const [label, filename, scopePath, body] of cases) { + it(`${label} passes`, () => { + expect(captureEscapeRulesFor(filename, scopePath, body)).toEqual([]); + }); + } + + it('a generic capture keydown handler with no Escape branch stays clean', () => { + const found = captureEscapeRulesFor('src/ui/dashboard.ts', ['renderDashboard'], + withDocAlias('doc', "const noteInteraction = () => { userInteracted = true; }; doc.addEventListener('keydown', noteInteraction, true);")); + expect(found).toEqual([]); + }); + + // Negative control on the const-alias Escape-value resolution + // (`resolveStringLiteralValue`/`classifyEscapeComparison`): a const alias + // that resolves to a DIFFERENT concrete literal is provably not Escape — + // this must stay clean, proving the alias fix isn't simply "treat every + // key/code comparison as Escape". + it("a const alias resolving to a DIFFERENT literal ('Enter') stays clean", () => { + const found = captureEscapeRulesFor('src/ui/_noncapture-other-alias.ts', ['openSomethingElseAgainStill'], + "const OTHER_KEY = 'Enter'; const onKey = (e) => { if (e.key === OTHER_KEY) submit(); }; " + + "document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual([]); + }); + + it('a non-capture Escape listener (no third argument) stays clean', () => { + const found = captureEscapeRulesFor('src/ui/_noncapture.ts', ['openSomething'], + "const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey);"); + expect(found).toEqual([]); + }); + + // Proves the shorthand-capture fix (below) resolves the ALIASED value, not + // merely "a capture key exists at all" — a shorthand reusing an in-scope + // `false` stays provably non-capture. + it('{ capture } shorthand reusing an in-scope false stays clean', () => { + const found = captureEscapeRulesFor('src/ui/_noncapture-shorthand.ts', ['openSomethingElse'], + "const onKey = (e) => { if (e.key === 'Escape') close(); }; const capture = false; document.addEventListener('keydown', onKey, { capture });"); + expect(found).toEqual([]); + }); + + // #592 review pass 3: `resolveObjectCaptureLiteral` respects real object- + // literal property EVALUATION ORDER — a LATER explicit `capture` property + // still correctly wins over an EARLIER one when nothing override-capable + // (a spread, an unresolved key) sits between them. + it('{ capture: true, ...{}, capture: false } (last explicit key still wins over an intervening empty spread) stays clean', () => { + const found = captureEscapeRulesFor('src/ui/_noncapture-last-explicit-wins.ts', ['openSomethingElseAgain'], + "const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey, { capture: true, ...{}, capture: false });"); + expect(found).toEqual([]); + }); + + it('comments/strings containing listener lookalikes stay clean', () => { + const found = captureEscapeRulesFor('src/ui/_lookalike.ts', ['openLookalike'], [ + "// document.addEventListener('keydown', onKey, true);", + "const s = \"document.addEventListener('keydown', onKey, true)\";", + 'const n = 1;', + ].join('\n')); + expect(found).toEqual([]); + }); +}); + +// ── Capture-Escape sabotage cases ─────────────────────────────────────────── + +describe('#592 shell-capture-escape: sabotage (each must fail)', () => { + const escapeHandler = "const onKey = (e) => { if (e.key === 'Escape') close(); };"; + + it("document.addEventListener('keydown', handler, true) with Escape semantics fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-a.ts', ['openRogueA'], + `${escapeHandler} document.addEventListener('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('window equivalent fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-b.ts', ['openRogueB'], + `${escapeHandler} window.addEventListener('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('opts.document equivalent OUTSIDE the exact approved chart-selection fingerprint fails', () => { + // Real filename/receiver shape, but a DIFFERENT scope than beginSelection. + const found = captureEscapeRulesFor('src/ui/dashboard-chart-interaction.ts', + ['createDashboardChartInteractionController', 'someOtherMethod'], + `${escapeHandler} opts.document.addEventListener('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("{ capture: true } fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-c.ts', ['openRogueC'], + `${escapeHandler} document.addEventListener('keydown', onKey, { capture: true });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a simple capture-options alias fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-d.ts', ['openRogueD'], + `${escapeHandler} const opts2 = { capture: true }; document.addEventListener('keydown', onKey, opts2);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + // ChatGPT PR #672 review pass 1 follow-up: `resolveCaptureFlag` used to + // trust ANY resolved `VariableDeclaration`'s initializer regardless of + // const-ness — a `let` alias reassigned AFTER its declaration (the value + // actually in effect at the real `addEventListener` call) was silently + // read as its STALE initial value instead, escaping detection entirely. + // These fail-closed to `uncheckable-options` now (the same violation shape + // "a simple capture-options alias fails" above exercises for a real + // `const` alias), never a silent "provably false" pass. + + it('a let capture-options object alias REASSIGNED to { capture: true } after declaration fails (not the stale initializer)', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-let-opts-reassign.ts', ['openRogueLetOptsReassign'], + `${escapeHandler} let opts = { capture: false }; opts = { capture: true }; ` + + "document.addEventListener('keydown', onKey, opts);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a let bare-boolean capture flag REASSIGNED to true after declaration fails (not the stale initializer)', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-let-bool-reassign.ts', ['openRogueLetBoolReassign'], + `${escapeHandler} let capture = false; capture = true; ` + + "document.addEventListener('keydown', onKey, capture);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a var capture-options alias (never a genuine const) fails, even with no reassignment at all', () => { + // The invariant is "genuine const", not "happens to be reassigned" — + // a `var`/`let` alias is NEVER trusted, regardless of whether THIS + // particular fixture reassigns it. + const found = captureEscapeRulesFor('src/ui/_sabotage-var-opts.ts', ['openRogueVarOpts'], + `${escapeHandler} var opts = { capture: true }; document.addEventListener('keydown', onKey, opts);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + // ChatGPT PR #672 review pass 2 P1: `resolveCaptureFlag` previously trusted + // a resolved CONST object literal's OWN properties forever, even when a + // LATER property write on that same object (still a `const` BINDING — the + // reference never changes, only the referenced object's OWN property does) + // changed the value actually in effect at the real `addEventListener` call. + it('a const capture-options object MUTATED via a later property write fails (not the original literal)', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-const-opts-mutate.ts', ['openRogueConstOptsMutate'], + `${escapeHandler} const opts = { capture: false }; opts.capture = true; ` + + "document.addEventListener('keydown', onKey, opts);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("a const capture-options object mutated via bracket property write (opts['capture'] = true) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-const-opts-mutate-bracket.ts', ['openRogueConstOptsMutateBracket'], + `${escapeHandler} const opts = { capture: false }; opts['capture'] = true; ` + + "document.addEventListener('keydown', onKey, opts);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + // #592 review pass 3: `hasCapturePropertyMutation` previously only ever + // recognized a PLAIN `=` write on the object literal's OWN receiver + // identifier — a compound assignment operator (`||=`, `&&=`, `??=`, …) was + // filtered out before the property name was even inspected, and a + // mutation through a plain ALIAS of the same object (`const alias = opts; + // alias.capture = true;`) compared only the write's own receiver against + // the original declaration, never following the alias back to it — both + // silently trusted the stale `{ capture: false }` snapshot even though the + // real object (shared by reference) had already been mutated to + // `capture: true` before the real `addEventListener` call. + + it('a const capture-options object mutated via a compound assignment operator (opts.capture ||= true) fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-const-opts-mutate-compound.ts', ['openRogueConstOptsMutateCompound'], + `${escapeHandler} const opts = { capture: false }; opts.capture ||= true; ` + + "document.addEventListener('keydown', onKey, opts);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a const capture-options object mutated through a plain alias (const alias = opts; alias.capture = true) fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-const-opts-mutate-alias.ts', ['openRogueConstOptsMutateAlias'], + `${escapeHandler} const opts = { capture: false }; const alias = opts; alias.capture = true; ` + + "document.addEventListener('keydown', onKey, opts);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + // The identical gap, for the HANDLER alias instead of the capture-options + // alias: `resolveHandlerNode` used to trust a resolved `VariableDeclaration` + // initializer regardless of const-ness too, so a `let` handler reassigned + // to a real Escape-testing function AFTER its (non-Escape) initial + // declaration resolved to the STALE, non-Escape initializer and was + // classified 'clean' instead of 'escape'. + + it('a let handler alias REASSIGNED to a real Escape handler after declaration fails (not the stale initializer)', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-let-handler-reassign.ts', ['openRogueLetHandlerReassign'], [ + 'let onKey = () => {};', + "onKey = (e) => { if (e.key === 'Escape') close(); };", + "document.addEventListener('keydown', onKey, true);", + ].join('\n')); + expect(found).toEqual(['shell-capture-escape']); + }); + + // #592 review pass 3: the prior implementation returned as soon as ANY + // explicit `capture` property was found, ignoring whether a LATER spread + // or unresolvable key could override it — real object-literal evaluation + // order means a later property/spread ALWAYS overrides an earlier same-key + // value, so `{ capture: false, ...{ capture: true } }` really runs + // capture-phase in the browser even though an earlier explicit property + // says `false`. + it('{ capture: false, ...{ capture: true } } (a later spread can override an earlier explicit false) fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-spread-override.ts', ['openRogueSpreadOverride'], + `${escapeHandler} document.addEventListener('keydown', onKey, { capture: false, ...{ capture: true } });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('{ capture: false, [k]: 1 } (an unresolved computed key AFTER an explicit false) fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-unresolved-key-after-false.ts', ['openRogueUnresolvedKeyAfterFalse'], + `${escapeHandler} document.addEventListener('keydown', onKey, { capture: false, [k]: 1 });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + // #592 review pass 2: `resolveObjectCaptureLiteral` only ever recognized a + // plain-identifier-keyed `capture` property; a string-literal key, a + // computed string-literal key, or shorthand each fell through to the + // "no capture key present" branch and resolved provably `false`, silently + // bypassing the guard for a real capture-phase Escape listener. + it("{ 'capture': true } (string-literal key) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-string-key.ts', ['openRogueStringKey'], + `${escapeHandler} document.addEventListener('keydown', onKey, { 'capture': true });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("{ ['capture']: true } (computed string-literal key) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-computed-key.ts', ['openRogueComputedKey'], + `${escapeHandler} document.addEventListener('keydown', onKey, { ['capture']: true });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('{ capture } (shorthand, reusing an in-scope boolean) fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-shorthand.ts', ['openRogueShorthand'], + `${escapeHandler} const capture = true; document.addEventListener('keydown', onKey, { capture });`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('an inline handler fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-e.ts', ['openRogueE'], + "document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a local named FUNCTION DECLARATION handler fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-f.ts', ['openRogueF'], + "function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('an aliased Document/Window target fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-g.ts', ['openRogueG'], + `${escapeHandler} const d = window; d.addEventListener('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("event.key !== 'Escape' guard fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-h.ts', ['openRogueH'], + "const onKey = (e) => { if (e.key !== 'Escape') return; close(); }; document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("event.code === 'Escape' fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-i.ts', ['openRogueI'], + "const onKey = (e) => { if (e.code === 'Escape') close(); }; document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("switch (event.key) with an Escape case fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-j.ts', ['openRogueJ'], [ + "const onKey = (e) => { switch (e.key) { case 'Escape': close(); break; default: break; } };", + "document.addEventListener('keydown', onKey, true);", + ].join('\n')); + expect(found).toEqual(['shell-capture-escape']); + }); + + // ChatGPT PR #672 review pass 2 P1: `containsEscapeSemantics` previously + // required the LITERAL string `'Escape'` directly in the comparison — a + // `const` alias of the same value was invisible, so the handler was + // misclassified `'clean'` (dropped, no policy check at all) instead of + // recognized as a real Escape listener. + it("a const alias of the Escape literal (const ESC = 'Escape'; e.key === ESC) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-escape-alias.ts', ['openRogueEscapeAlias'], + "const ESC = 'Escape'; const onKey = (e) => { if (e.key === ESC) close(); }; " + + "document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it("switch (event.key) with a const-alias Escape case (case ESC:) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-escape-alias-switch.ts', ['openRogueEscapeAliasSwitch'], [ + "const ESC = 'Escape';", + "const onKey = (e) => { switch (e.key) { case ESC: close(); break; default: break; } };", + "document.addEventListener('keydown', onKey, true);", + ].join('\n')); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('an Escape comparison against an unresolvable (non-const) alias fails closed', () => { + // `let`/`var` aliases (and any other unresolvable expression) cannot be + // proven NOT to be 'Escape' either — fail closed rather than silently + // treating the handler as clean. `expectedValue` (not `key`/`code`) + // deliberately avoids `terminalNames`' own `key`/`code` text match, so + // this exercises the CANDIDATE-side resolution, not the property-name + // detection on the other operand. + const found = captureEscapeRulesFor('src/ui/_sabotage-escape-alias-unresolved.ts', ['openRogueEscapeAliasUnresolved'], + "let expectedValue = getExpectedValue(); const onKey = (e) => { if (e.key === expectedValue) close(); }; " + + "document.addEventListener('keydown', onKey, true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('an unresolved global capture-keydown handler fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-k.ts', ['openRogueK'], + "document.addEventListener('keydown', getHandler(), true);"); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('unresolved capture options for an Escape keydown fails', () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-l.ts', ['openRogueL'], + `${escapeHandler} document.addEventListener('keydown', onKey, computeOptions());`); + expect(found).toEqual(['shell-capture-escape']); + }); + + // #592 review pass 3: candidate discovery required the CALL's own callee to + // be a plain `.`-property-access (`document.addEventListener(...)`) — the + // bracket-property equivalent (`document['addEventListener'](...)`) was + // discarded before the event-name/receiver were even inspected, exactly + // the SAME bracket spelling `shell-body-mount`'s own candidate discovery + // already recognizes for `.appendChild`/`.append`. + it("a bracket-spelled addEventListener callee (document['addEventListener'](...)) fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-bracket-addeventlistener.ts', ['openRogueBracketAddEventListener'], + `${escapeHandler} document['addEventListener']('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + // #592 review pass 3: the event-name argument required the LITERAL string + // `'keydown'` directly in the call — a `const` alias of that same literal + // (the analogous alias resolution `resolveStringLiteralValue` already + // applies to the Escape-literal COMPARISON inside the handler body) was + // never dereferenced here, so this call silently exited as "not a + // candidate at all". + it("a const alias of the 'keydown' event-name literal (const EVT = 'keydown') fails", () => { + const found = captureEscapeRulesFor('src/ui/_sabotage-keydown-alias.ts', ['openRogueKeydownAlias'], + `const EVT = 'keydown'; ${escapeHandler} document.addEventListener(EVT, onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a second listener inside results.ts (expandDataPane > mount) fails', () => { + const found = captureEscapeRulesFor('src/ui/results.ts', ['expandDataPane', 'mount'], withDocAlias('doc', + `${escapeHandler} doc.addEventListener('keydown', onKey, true); + const onKey2 = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey2, true);`)); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a second listener inside explain-graph.ts (openSchemaView > mount) fails', () => { + const found = captureEscapeRulesFor('src/ui/explain-graph.ts', ['openSchemaView', 'mount'], withDocAlias('doc', + `${escapeHandler} doc.addEventListener('keydown', onKey, true); + const onKey2 = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey2, true);`)); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a second listener inside menu.ts (openMenu) fails', () => { + const found = captureEscapeRulesFor('src/ui/menu.ts', ['openMenu'], withDocAlias('doc', + `${escapeHandler} doc.addEventListener('keydown', onKey, true); + const onKey2 = (e) => { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', onKey2, true);`)); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a second listener in dashboard-chart-interaction.ts (beginSelection) fails', () => { + const found = captureEscapeRulesFor('src/ui/dashboard-chart-interaction.ts', + ['createDashboardChartInteractionController', 'beginSelection'], + `${escapeHandler} opts.document.addEventListener('keydown', onKey, true); + const onKey2 = (e) => { if (e.key === 'Escape') cancel(); }; opts.document.addEventListener('keydown', onKey2, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); + + it('a third gesture listener in dashboard-tile-gestures.ts (a new scope) fails', () => { + const found = captureEscapeRulesFor('src/ui/dashboard-tile-gestures.ts', + ['createTileGestureController', 'wireSomeOtherGesture'], + `${escapeHandler} deps.document.addEventListener('keydown', onKey, true);`); + expect(found).toEqual(['shell-capture-escape']); + }); +}); + +// ── Capture-Escape missing-baseline-entry sabotage (P1, PR #672 review pass 1) ── +// Same reverse-direction gap as `shell-body-mount`'s: the excess-only loop +// never visits a scope with zero remaining candidates, so a disappeared +// frozen Escape listener produced no violation at all. + +describe('#592 shell-capture-escape: missing-baseline-entry sabotage (each must fail)', () => { + it('an approved scope that loses its ONLY frozen capture-Escape listener fails', () => { + // src/ui/menu.ts's openMenu is approved for exactly 1 capture-Escape + // listener — remove it entirely (an empty scope body). + const found = captureEscapeRulesFor('src/ui/menu.ts', ['openMenu'], ''); + expect(found).toEqual(['shell-capture-escape']); + }); +}); + +// ── Complete-tree missing-baseline strict reverse check sabotage (ChatGPT PR #672 +// review pass 1 follow-up) ────────────────────────────────────────────────── +// The softened forward-check reverse pass above (`declaredScopeKeys`) can +// only ever catch a dropped OCCURRENCE COUNT within a scope that is still +// present in whatever was scanned — it deliberately treats a scope/file +// entirely ABSENT from the scanned batch as "not this call's concern", +// which is exactly right for this suite's own minimal single-scope +// fixtures but leaves a real approved function (or a whole approved FILE) +// deleted outright silently unflagged. `findShellGuardrailMissingBaselineViolations` +// is the deliberately separate, stricter pass that assumes `sources` IS the +// complete tree and closes exactly that gap. + +describe('#592 shell-guardrail missing-baseline: complete-tree strict reverse check (each must fail)', () => { + it('deleting an approved function outright (whole-scope disappearance) is flagged — unlike the softened forward-check reverse pass', () => { + const sources: ShellGuardrailSourceEntry[] = [ + { filename: 'src/ui/toast.ts', source: 'export const unrelated = 1;\n' }, + ]; + // Sanity: this is exactly the confirmed blind spot — the softened + // forward-check reverse pass does NOT catch it, because + // `declaredScopeKeys` correctly (for ITS OWN purpose) treats a fixture + // that never declares `flashToast` at all as "not this call's concern". + expect(shellViolations(sources).filter((v) => v.rule === 'shell-body-mount')).toEqual([]); + const found = findShellGuardrailMissingBaselineViolations(sources) + .filter((v) => v.rule === 'shell-body-mount' && v.filename === 'src/ui/toast.ts'); + expect(found.length).toBeGreaterThan(0); + expect(found[0]!.detail).toContain('flashToast'); + }); + + it('an approved FILE missing from the batch entirely is flagged for every one of its policy entries', () => { + // src/ui/menu.ts owns entries in BOTH tables (shell-body-mount's + // openMenu, count 2; shell-capture-escape's openMenu, count 1) — + // omitting the file from the batch entirely must flag both, never + // silently skip either just because the file was never even parsed. + const sources: ShellGuardrailSourceEntry[] = [ + { filename: 'src/ui/_unrelated.ts', source: 'export const x = 1;\n' }, + ]; + const found = findShellGuardrailMissingBaselineViolations(sources); + const menuBodyMount = found.find((v) => v.rule === 'shell-body-mount' && v.filename === 'src/ui/menu.ts'); + const menuCaptureEscape = found.find((v) => v.rule === 'shell-capture-escape' && v.filename === 'src/ui/menu.ts'); + expect(menuBodyMount).toBeDefined(); + expect(menuCaptureEscape).toBeDefined(); + expect(menuBodyMount!.detail).toContain('not part of the scanned tree'); + }); + + it('renaming an approved function while KEEPING its mount call is a real bypass of NEITHER check — it is still caught by the forward check\'s own "no policy entry" branch', () => { + // Sanity/negative control per the finding's own caveat: pure renaming is + // NOT a full bypass — it is already flagged today, just via a DIFFERENT + // branch (a scope with no matching policy entry) than the missing- + // baseline gap this describe block targets. + const found = bodyMountRulesFor( + 'src/ui/toast.ts', ['flashToastRenamed'], withDocAlias('doc', 'doc.body.appendChild(el);'), + ); + expect(found).toEqual(['shell-body-mount']); + }); +}); + +// ── `completeTree` mode folds the strict reverse check into ONE shared batch +// (P2, ChatGPT PR #672 review pass 2) ─────────────────────────────────────── +// The production `check:arch` wiring used to call +// `findShellGuardrailSourceContractViolations` AND +// `findShellGuardrailMissingBaselineViolations` separately over the +// identical complete `src/**` tree — each opening its OWN real-TypeScript- +// parser batch, directly contradicting this module's own "ONE shared parser +// batch" architecture (decision 4). `completeTree: true` folds BOTH +// reverse-baseline halves (the parser-independent whole-file-absence check +// and the parser-dependent per-scope strict count check) into the SAME +// batch the forward check already opens. + +describe('#592 shell-guardrail source contract: completeTree mode', () => { + it('folds both reverse-baseline halves AND the forward check into ONE call, matching the union of running each separately', () => { + // src/ui/toast.ts is present but has an EXCESS mount (a real forward- + // check violation); every OTHER approved file (menu.ts, popover.ts, …) + // is entirely absent from this batch (real missing-baseline + // violations) — exercising both halves at once. + const toastSource = wrapScope( + ['flashToast'], + withDocAlias('doc', 'doc.body.appendChild(el); doc.body.appendChild(doc.createElement("div"));'), + ); + const sources: ShellGuardrailSourceEntry[] = [{ filename: 'src/ui/toast.ts', source: toastSource }]; + const key = (v: SourceContractViolation) => [v.rule, v.filename, v.pos, v.detail].join(''); + + const folded = findShellGuardrailSourceContractViolations(sources, { completeTree: true }); + const forwardOnly = findShellGuardrailSourceContractViolations(sources); + const missingBaselineOnly = findShellGuardrailMissingBaselineViolations(sources); + + expect(folded.map(key).sort()).toEqual([...forwardOnly, ...missingBaselineOnly].map(key).sort()); + // Sanity: both halves actually contributed something here, so the + // equality above isn't vacuously true (e.g. `completeTree` doing + // nothing at all against two already-empty result sets). + expect(forwardOnly.length).toBeGreaterThan(0); + expect(missingBaselineOnly.length).toBeGreaterThan(0); + }); + + it('omitting completeTree (or the options argument entirely) is unaffected — no reverse-baseline violations leak into the plain forward check', () => { + const sources: ShellGuardrailSourceEntry[] = [ + { filename: 'src/ui/_unrelated.ts', source: 'export const x = 1;\n' }, + ]; + expect(findShellGuardrailSourceContractViolations(sources)).toEqual([]); + expect(findShellGuardrailSourceContractViolations(sources, {})).toEqual([]); + expect(findShellGuardrailSourceContractViolations(sources, { completeTree: false })).toEqual([]); + }); +}); + +// ── Capture-Escape same-file scope-shadowing sabotage (P1, PR #672 review pass 1) ── +// Reproduces the reviewed capture-alias-overwrite and handler-shadowing +// cases: a same-named binding in an unrelated sibling or nested-below scope +// must never resolve (or de-resolve) a real scope's own capture options or +// handler. `shellViolations` is called directly (not through +// `captureEscapeRulesFor`) because these fixtures need TWO independent +// scopes in ONE file. + +describe('#592 shell-capture-escape: same-file scope-shadowing sabotage (each must fail)', () => { + it("a sibling scope's opts = false does not erase a real scope's { capture: true } alias", () => { + const source = [ + 'function real(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' const opts = { capture: true };', + " doc.addEventListener('keydown', onKey, opts);", + '}', + 'function other() {', + ' const opts = false;', + " document.addEventListener('click', () => {}, opts);", + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-shadow-capture.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); + + it("an unrelated nested helper's same-named local never resolves a real scope's addEventListener handler", () => { + // `unrelatedHelper`'s own `onKey` sits textually BETWEEN the real + // Escape-testing `onKey`'s declaration and its `addEventListener` use — + // a nearest-preceding-by-POSITION (not by lexical scope) resolver picks + // the wrong, non-Escape handler and the real violation goes undetected. + const source = [ + 'function real(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' function unrelatedHelper() {', + ' const onKey = (e) => { userInteracted = true; };', + ' }', + " doc.addEventListener('keydown', onKey, true);", + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-shadow-handler.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Capture-Escape nested block-local shadowing sabotage (review pass 2) ──── +// The same one-level-finer gap as `shell-body-mount`'s own block-shadowing +// sabotage above: a block-local (`if`/loop/bare `{ }`, never a nested +// function) alias or handler of the SAME name used to collapse into the same +// per-function bucket as a real scope's own capture-options alias or named +// handler, so the block-local shadow could silently overwrite (or be +// overwritten by) the real one — hiding the real violation entirely. + +describe('#592 shell-capture-escape: nested block-local shadowing sabotage (each must fail)', () => { + it("a block-local, unrelated capture-options alias does not erase a real scope's { capture: true } alias", () => { + const source = [ + 'function real(doc: Document) {', + ' const opts = { capture: true };', // the real listener's own alias + ' if (c) {', + ' const opts = false;', // block-local shadow, unrelated listener + " document.addEventListener('click', () => {}, opts);", + ' }', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + " doc.addEventListener('keydown', onKey, opts);", // must still resolve the OUTER alias + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-block-shadow-capture.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); + + it("a block-local, unrelated named handler does not erase a real scope's Escape-testing handler", () => { + const source = [ + 'function real(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", // the real handler + ' if (c) {', + ' const onKey = (e) => { userInteracted = true; };', // block-local shadow, unrelated + " document.addEventListener('click', onKey);", + ' }', + " doc.addEventListener('keydown', onKey, true);", // must still resolve the OUTER handler + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-block-shadow-handler.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Capture-Escape declaration-kind-aware scoping sabotage (review pass 3) ─ +// The identical for-loop-header fix `shell-body-mount`'s own scoping +// sabotage exercises above, reproduced against `buildCaptureAliasMap`'s +// capture-options alias table and `buildFunctionDeclMap`'s handler-name +// table — both keyed through the SAME shared `declarationScopeOwnerOf`/ +// `scopeOwnerOf` machinery, so both inherit the identical bug and fix. + +describe('#592 shell-capture-escape: declaration-kind-aware scoping sabotage (each must fail)', () => { + it('a for-header let capture-options alias does not clobber an outer real { capture: true } alias', () => { + const source = [ + 'function openRogue(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' const opts = { capture: true };', + ' for (let opts = false; false; ) { }', + " doc.addEventListener('keydown', onKey, opts);", + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-for-header-let-leak-capture.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a for-header let handler alias does not clobber (or get preferred over) the outer real Escape handler', () => { + // `resolveHandlerNode` picks the NEAREST-PRECEDING same-named + // declaration WITHIN one scope — if the for-header's own `onKey` wrongly + // shared the outer function's scope bucket, it would be nearer to the + // `addEventListener` call than the real outer handler and would win, + // resolving to a handler with no Escape branch and hiding the violation. + const source = [ + 'function openRogue(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' for (let onKey = () => { flag = true; }; false; ) { }', + " doc.addEventListener('keydown', onKey, true);", + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-for-header-let-leak-handler.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Mutable Document/Window global-alias write-awareness sabotage +// (review pass 3) ─────────────────────────────────────────────────────────── +// `resolveGlobalKind`/`classifyGlobalDeclaration` (the receiver resolver +// SHARED by both `shell-body-mount` and `shell-capture-escape`) previously +// classified an untyped `let`/`var` binding from its OWN initializer alone — +// a LATER whole-binding reassignment (the value actually in effect at the +// real `.body`/`.addEventListener` receiver use) was invisible, exactly the +// gap `laterAssignmentResolvesToDocumentBody` already closed for the body- +// ALIAS resolver, never applied to the Document/Window RECEIVER resolver +// itself. + +describe('#592 shell guardrails: mutable Document/Window global-alias write-awareness sabotage (review pass 3)', () => { + it('a let receiver REASSIGNED from Window to Document after declaration is a body-mount (not its stale Window initializer)', () => { + const source = [ + 'function openRogue() {', + ' let doc = window;', + ' doc = document;', + ' doc.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-mutable-global-alias.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); + + it('a let receiver REASSIGNED from a non-global value to Document after declaration is a capture-escape candidate', () => { + const source = [ + 'function openRogue() {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' let target = {};', + ' target = document;', + " target.addEventListener('keydown', onKey, true);", + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-mutable-global-alias-capture.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found.length).toBeGreaterThan(0); + }); + + // Negative control: proves the fix isn't simply "treat every reassigned + // let/var as Document/Window" — a binding whose initializer AND every + // later reassignment never resolve to either stays entirely unclassified. + it('an ordinary let-bound alias never pointing to Document/Window at all (initializer or any reassignment) stays clean', () => { + const source = [ + 'function openRogue() {', + ' let doc = {};', + ' doc = getSomethingElse();', + ' doc.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-mutable-global-alias-negative.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); +}); + +// ── Alias-cycle non-crash sabotage (review pass 3) ────────────────────────── +// `resolvesToDocumentBody`/`laterAssignmentResolvesToDocumentBody` previously +// recursed into each other with no visited-binding set: an alias-reassignment +// CYCLE (`a` assigned to `b`, `b` initialized from `a`) re-triggered the +// identical unresolved call forever — a real `RangeError: Maximum call stack +// size exceeded`, not merely a missed-detection false negative, independent +// of whether the cycle has any relation to `document.body` at all. + +describe('#592 shell-body-mount: alias-cycle non-crash sabotage (must not throw)', () => { + it('an alias-reassignment cycle with NO document.body relation at all does not crash the analyzer', () => { + const source = [ + 'function openRogue() {', + " let a = document.createElement('div');", + ' let b = a;', + ' a = b;', + ' a.appendChild(panel);', + '}', + ].join('\n'); + expect(() => shellViolations([{ filename: 'src/ui/_sabotage-alias-cycle.ts', source }])).not.toThrow(); + const found = shellViolations([{ filename: 'src/ui/_sabotage-alias-cycle.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); + + it('a real document.body mount reached through a DIFFERENT reassignment on a binding that ALSO has a cyclic alias is still detected', () => { + // Proves the cycle guard only suppresses the cyclic path itself — a + // genuine mount reached through a separate, non-cyclic reassignment on + // the SAME binding is not collaterally hidden by the cycle fix. + const source = [ + 'function openRogue() {', + " let a = document.createElement('div');", + ' let b = a;', + ' a = b;', + ' a = document.body;', + ' a.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_sabotage-alias-cycle-real-mount.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found.length).toBeGreaterThan(0); + }); +}); + +// ── Fixed-position positive cases ─────────────────────────────────────────── + +describe('#592 shell-fixed-position: positive characterization', () => { + it('a representative current root selector passes', () => { + const css = '.auth-host { position: fixed; inset: 0; z-index: 120; }'; + expect(findShellFixedPositionViolations(css, 'src/styles.css')).toEqual([]); + }); + + it('the mobile .inspector-host rule under its current media context passes', () => { + const css = '@media (max-width: 768px) {\n .inspector-host { position: fixed; inset: 0; }\n}\n'; + expect(findShellFixedPositionViolations(css, 'src/styles.css')).toEqual([]); + }); + + it('the same selector under a DIFFERENT at-rule context is a NEW key (fails)', () => { + const css = '@media (max-width: 999px) {\n .inspector-host { position: fixed; inset: 0; }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + }); + + it('comma-selector normalization is deterministic regardless of source spacing', () => { + const a = scanFixedPositionDeclarations('.a,.b{position:fixed;}'); + const b = scanFixedPositionDeclarations('.a , .b {\n position: fixed;\n}'); + expect(a).toHaveLength(1); + expect(b).toHaveLength(1); + expect(a[0]!.selector).toBe(b[0]!.selector); + expect(a[0]!.selector).toBe('.a, .b'); + }); + + it('comments/strings containing fake property text do not count', () => { + const css = '/* position: fixed; */\n.clean { color: red; }\n'; + expect(scanFixedPositionDeclarations(css)).toEqual([]); + }); + + it('multiline selectors/declarations parse correctly', () => { + const css = [ + '.multi-a,', + '.multi-b {', + ' position:', + ' fixed;', + ' inset: 0;', + '}', + ].join('\n'); + const found = scanFixedPositionDeclarations(css); + expect(found).toHaveLength(1); + expect(found[0]!.selector).toBe('.multi-a, .multi-b'); + }); + + // #592 review pass 2: the scanner used to record only the NEAREST + // enclosing at-rule, so a rule nested under TWO at-rules (`@supports` > + // `@media`) produced the identical fingerprint as being nested under the + // inner one alone. + it('records the FULL chain of nested at-rules, outermost first, joined with " > "', () => { + const css = '@supports (display: grid) {\n@media (max-width: 768px) {\n .x { position: fixed; }\n}\n}\n'; + const found = scanFixedPositionDeclarations(css); + expect(found).toHaveLength(1); + expect(found[0]!.atRule).toBe('@supports (display: grid) > @media (max-width: 768px)'); + }); + + it('a top-level (non-nested) declaration has nested: false', () => { + const found = scanFixedPositionDeclarations('.x { position: fixed; }'); + expect(found).toHaveLength(1); + expect(found[0]!.nested).toBe(false); + }); + + it('a declaration inside @media only (no enclosing plain rule) still has nested: false', () => { + const css = '@media (max-width: 768px) {\n .x { position: fixed; }\n}\n'; + const found = scanFixedPositionDeclarations(css); + expect(found).toHaveLength(1); + expect(found[0]!.nested).toBe(false); + }); + + // ChatGPT PR #672 review pass 2 P1: `processDeclaration` previously + // inspected ONLY the single innermost frame and bailed the instant it + // wasn't a 'rule' frame — a BARE declaration sitting directly inside an + // at-rule that is itself nested inside a style rule (real, browser- + // supported CSS nesting: the declaration inherits the outer selector) + // produced ZERO candidates at all, not merely an unflagged one. + it('a bare declaration nested inside @media inside a plain rule is associated with the outer selector', () => { + const css = '.rogue {\n @media (min-width: 0px) {\n position: fixed;\n }\n}\n'; + const found = scanFixedPositionDeclarations(css); + expect(found).toHaveLength(1); + expect(found[0]!.selector).toBe('.rogue'); + expect(found[0]!.atRule).toBe('@media (min-width: 0px)'); + expect(found[0]!.nested).toBe(false); + }); + + it('the same nesting shape under TWO at-rules records the full chain, outermost first', () => { + const css = '.rogue {\n @supports (display: grid) {\n @media (min-width: 0px) {\n position: fixed;\n }\n }\n}\n'; + const found = scanFixedPositionDeclarations(css); + expect(found).toHaveLength(1); + expect(found[0]!.selector).toBe('.rogue'); + expect(found[0]!.atRule).toBe('@supports (display: grid) > @media (min-width: 0px)'); + }); +}); + +// ── Fixed-position sabotage cases ─────────────────────────────────────────── + +describe('#592 shell-fixed-position: sabotage (each must fail)', () => { + it('a new root selector with position: fixed fails', () => { + const found = findShellFixedPositionViolations('.sabotage-root { position: fixed; }', 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + }); + + it('a new selector under @media fails', () => { + const css = '@media (max-width: 768px) {\n .sabotage-media { position: fixed; }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + }); + + it('a new selector under another nested at-rule fails', () => { + const css = '@supports (display: grid) {\n .sabotage-supports { position: fixed; }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + }); + + it('a new selector using a bare declaration nested inside @media inside a plain rule fails (not invisible)', () => { + const css = '.sabotage-rogue {\n @media (min-width: 0px) {\n position: fixed;\n }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + }); + + it('adding another selector to an approved selector group changes the key and fails', () => { + const css = '.auth-host, .sabotage-appended { position: fixed; inset: 0; }'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.detail).toContain('.auth-host, .sabotage-appended'); + }); + + it("a new selector with an approved-looking name (another '*-overlay') fails — no fuzzy name heuristic", () => { + const found = findShellFixedPositionViolations('.sabotage-overlay { position: fixed; }', 'src/styles.css'); + expect(found).toHaveLength(1); + }); + + // P1 (accepted, PR #672 review pass 1): the prior check was a `.some(...)` + // membership test with no count, so a duplicate of an already-approved + // fingerprint silently passed. + it('a duplicate of an already-approved selector/at-rule fails (count-based, not membership-only)', () => { + const css = '.auth-host { position: fixed; }\n.auth-host { position: fixed; }\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); // only the SECOND, excess occurrence + expect(found[0]!.rule).toBe('shell-fixed-position'); + expect(found[0]!.detail).toContain('duplicates'); + }); + + it('a THIRD occurrence of an already-approved-for-2 selector still only flags the excess one(s)', () => { + // .fm-overlay/.fm-dialog-backdrop etc. are each approved for exactly 1 — + // reuse .auth-host (also approved for exactly 1) three times: 2 excess. + const css = Array(3).fill('.auth-host { position: fixed; }').join('\n'); + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(2); + }); + + // #592 review pass 2: wrapping the APPROVED mobile `.inspector-host` rule + // in a brand-new OUTER at-rule is a real, behavior-changing structural + // edit (the rule now only applies when the outer at-rule also matches), + // but the prior nearest-only fingerprint made it indistinguishable from + // the unwrapped, already-approved baseline entry. + it('wrapping the approved mobile .inspector-host rule in an additional outer at-rule fails', () => { + const css = '@supports (display: grid) {\n@media (max-width: 768px) {\n .inspector-host { position: fixed; inset: 0; }\n}\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + expect(found[0]!.detail).toContain('.inspector-host'); + }); + + // #592 review pass 3: the scanner copied a backslash escape into its + // internal buffer VERBATIM (deliberately, so an escaped delimiter char + // could never be mistaken for real CSS structure) but never DECODED it + // before comparing the property/value text — so a real, spec-legal CSS + // identifier escape that every browser parses as plain `position`/`fixed` + // stayed textually distinct from those literal strings and bypassed the + // guard entirely. + it('an escaped property name (\\70osition, decodes to "position") with position: fixed fails', () => { + const css = '.sabotage-escaped-prop { \\70osition: fixed; }'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + }); + + it('an escaped value (\\66ixed, decodes to "fixed") on a real position property fails', () => { + const css = '.sabotage-escaped-value { position: \\66ixed; }'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.rule).toBe('shell-fixed-position'); + }); + + // ChatGPT PR #672 review pass 1: the at-chain builder only ever folded + // enclosing 'at'-kind (at-rule) frames into the fingerprint, silently + // skipping any enclosing 'rule'-kind (plain style rule) frame — so real + // CSS NESTING (`.wrapper { .auth-host { position: fixed; } }` compiles to + // the descendant selector `.wrapper .auth-host`, a genuinely different, + // never-approved effective selector) fingerprinted as bare `.auth-host` + // with no at-rule — byte-identical to the approved top-level baseline row + // — and was never flagged. + + it('a position: fixed declaration nested inside another plain style rule is unconditionally flagged, even reusing an approved-looking selector', () => { + const css = '.wrapper {\n .auth-host { position: fixed; }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); // NOT zero — proves it never silently matched the approved .auth-host baseline + expect(found[0]!.rule).toBe('shell-fixed-position'); + expect(found[0]!.detail).toContain('.auth-host'); + expect(found[0]!.detail).toContain('nested'); + }); + + it('a position: fixed declaration nested two levels deep inside plain style rules is still flagged', () => { + const css = '.outer {\n .inner {\n .deepest { position: fixed; }\n }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.detail).toContain('.deepest'); + }); + + it('a nested rule under an at-rule (both a plain-rule AND an at-rule ancestor) is still flagged', () => { + const css = '@media (max-width: 768px) {\n .wrapper {\n .nested-under-media { position: fixed; }\n }\n}\n'; + const found = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(found).toHaveLength(1); + expect(found[0]!.detail).toContain('.nested-under-media'); + }); +}); + +// ── Fixed-position missing-baseline-entry sabotage (P1, PR #672 review pass 1) ── +// The reverse direction: an approved fingerprint disappearing from the CSS +// entirely used to leave stale permission for its silent reintroduction, +// since nothing ever checked it. Meaningful only against the real, complete +// stylesheet (see `findShellFixedPositionMissingBaselineViolations`'s own +// doc comment) — these tests read the REAL `src/styles.css` and remove one +// approved rule from it, rather than using a small synthetic snippet. + +describe('#592 shell-fixed-position: missing-baseline-entry sabotage (each must fail)', () => { + const realStylesCss = readFileSync(join(root, 'src/styles.css'), 'utf8'); + + it('the real, complete stylesheet has zero missing-baseline violations (sanity check)', () => { + expect(findShellFixedPositionMissingBaselineViolations(realStylesCss, 'src/styles.css')).toEqual([]); + }); + + it('removing the one approved .auth-host rule from the real stylesheet is flagged as missing', () => { + const withoutAuthHost = realStylesCss.replace(/\.auth-host\s*\{[^}]*\}/, ''); + // Sanity: the removal actually happened (otherwise this test would prove + // nothing about the missing-entry check at all). + expect(withoutAuthHost).not.toContain('.auth-host {'); + const found = findShellFixedPositionMissingBaselineViolations(withoutAuthHost, 'src/styles.css'); + const authHostMissing = found.find((v) => v.detail.includes('.auth-host') && v.detail.includes('none remain')); + expect(authHostMissing).toBeDefined(); + expect(authHostMissing!.rule).toBe('shell-fixed-position'); + }); + + it('a completely empty stylesheet flags every approved entry as missing', () => { + const found = findShellFixedPositionMissingBaselineViolations('', 'src/styles.css'); + expect(found.length).toBeGreaterThan(1); + expect(found.every((v) => v.rule === 'shell-fixed-position')).toBe(true); + }); + + // #592 review pass 2: wrapping the real, unmodified mobile `.inspector-host` + // rule in an additional nested at-rule changes its full enclosing-at-rule + // CHAIN — a real structural/behavioral change (the rule now only applies + // when the new at-rule ALSO matches) — so the ORIGINAL baseline fingerprint + // (single `@media (max-width: 768px)`) must be reported missing, exactly + // like an outright removal. + it('wrapping the real .inspector-host rule in an additional nested at-rule flags the original fingerprint as missing', () => { + const inspectorHostRuleMatch = realStylesCss.match(/\.inspector-host\s*\{[^}]*position:\s*fixed[^}]*\}/); + expect(inspectorHostRuleMatch).not.toBeNull(); // sanity: the real mobile rule was found + const wrapped = realStylesCss.replace( + inspectorHostRuleMatch![0], + `@supports (display: grid) {\n${inspectorHostRuleMatch![0]}\n}`, + ); + expect(wrapped).not.toBe(realStylesCss); // sanity: the wrap actually happened + const missing = findShellFixedPositionMissingBaselineViolations(wrapped, 'src/styles.css') + .find((v) => v.detail.includes('.inspector-host') && v.detail.includes('none remain')); + expect(missing).toBeDefined(); + expect(missing!.rule).toBe('shell-fixed-position'); + }); + + // ChatGPT PR #672 review pass 1: wrapping the real, unmodified `.auth-host` + // rule in an additional enclosing PLAIN style rule (real CSS nesting) is a + // genuinely different, never-approved effective selector — the original + // un-nested fingerprint must be reported missing exactly like an outright + // removal, not silently treated as "still present" just because a nested + // declaration with the same bare selector text exists somewhere. + it('wrapping the real .auth-host rule in an additional enclosing plain rule flags the original fingerprint as missing too', () => { + const authHostMatch = realStylesCss.match(/\.auth-host\s*\{[^}]*\}/); + expect(authHostMatch).not.toBeNull(); // sanity: the real rule was found + const wrapped = realStylesCss.replace(authHostMatch![0], `.sabotage-wrapper {\n${authHostMatch![0]}\n}`); + expect(wrapped).not.toBe(realStylesCss); // sanity: the wrap actually happened + const missing = findShellFixedPositionMissingBaselineViolations(wrapped, 'src/styles.css') + .find((v) => v.detail.includes('.auth-host') && v.detail.includes('none remain')); + expect(missing).toBeDefined(); + expect(missing!.rule).toBe('shell-fixed-position'); + }); +}); + +// ── Diagnostic tests ───────────────────────────────────────────────────────── +// For at least one violation per rule: relative file, source line, rule id, +// offending selector/call/scope, and a concrete remediation are all present. + +function lineOf(source: string, pos: number): number { + return source.slice(0, pos).split('\n').length; +} + +describe('#592 diagnostics are actionable', () => { + it('shell-body-mount: file, line, rule, scope, and remediation are all present', () => { + const source = wrapScope(['openRogueDiag'], 'document.body.appendChild(panel);'); + const [v] = shellViolations([{ filename: 'src/ui/_diag-a.ts', source }]) + .filter((x) => x.rule === 'shell-body-mount'); + expect(v).toBeDefined(); + expect(v!.filename).toBe('src/ui/_diag-a.ts'); + expect(lineOf(source, v!.pos)).toBe(source.slice(0, source.indexOf('document.body')).split('\n').length); + expect(v!.rule).toBe('shell-body-mount'); + expect(v!.detail).toContain('openRogueDiag'); + expect(v!.detail).toMatch(/inspectorHost|SurfaceLifecycle|dialog\/popover|exception snapshot/); + }); + + it('shell-capture-escape: file, line, rule, scope, and remediation are all present', () => { + const source = wrapScope(['openRogueDiag2'], + "const onKey = (e) => { if (e.key === 'Escape') close(); };\ndocument.addEventListener('keydown', onKey, true);"); + const [v] = shellViolations([{ filename: 'src/ui/_diag-b.ts', source }]) + .filter((x) => x.rule === 'shell-capture-escape'); + expect(v).toBeDefined(); + expect(v!.filename).toBe('src/ui/_diag-b.ts'); + expect(lineOf(source, v!.pos)).toBe(source.slice(0, source.indexOf("document.addEventListener")).split('\n').length); + expect(v!.rule).toBe('shell-capture-escape'); + expect(v!.detail).toContain('openRogueDiag2'); + expect(v!.detail).toMatch(/SurfaceLifecycle|documented exception/); + }); + + it('shell-fixed-position: file, line, rule, selector, and remediation are all present', () => { + const css = '.header {}\n.sabotage-diag {\n position: fixed;\n}\n'; + const [v] = findShellFixedPositionViolations(css, 'src/styles.css'); + expect(v).toBeDefined(); + expect(v!.filename).toBe('src/styles.css'); + expect(lineOf(css, v!.pos)).toBe(3); + expect(v!.rule).toBe('shell-fixed-position'); + expect(v!.detail).toContain('.sabotage-diag'); + expect(v!.detail).toMatch(/docked composition|fixed-position snapshot/); + }); +}); + +// ── Sanity: unrelated files/rule shapes never contribute noise ───────────── + +describe('#592 shell guardrails: files with none of the governed shapes stay clean', () => { + it('a file with no Document-body mounts and no capture-Escape listeners is clean', () => { + const source = 'export function pureHelper(a: number, b: number): number { return a + b; }\n'; + expect(shellViolations([{ filename: 'src/core/_unrelated.ts', source }])).toEqual([]); + }); + + it('a plain "body" local HTMLElement variable is never mistaken for Document.body', () => { + const source = 'function f() { const body = document.createElement("div"); body.appendChild(child); }'; + const found = shellViolations([{ filename: 'src/ui/_not-document-body.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toEqual([]); + }); +}); + +// ── Architecture decision 6 (#592 addendum): checker-based identifier +// resolution ───────────────────────────────────────────────────────────── +// Direct proof — through the public entry point, over synthetic fixtures — +// that name-binding resolution for both guards now goes through the REAL +// TypeScript checker rather than the retired hand-rolled scope-tracking +// machinery (`scopeOwnerOf`/`scopeChain`/`buildGlobalAliasMap`/ +// `buildFunctionDeclMap`/`buildCaptureAliasMap`, all deleted). These mirror +// the coordinator's own pre-implementation spike: same-function block +// shadow, for-loop-header shadow, sibling-scope non-pollution, and correct +// reversion to the outer binding once a shadow's scope ends — checked here +// for BOTH the Document/Window alias question (`shell-body-mount`) and the +// handler/capture-options alias question (`shell-capture-escape`), asserting +// exact violation COUNTS (not just "something failed"), unlike the +// pre-existing sabotage tests above which only assert `length > 0`. + +describe('#592 Architecture decision 6: checker-based resolution (mirrors the pre-implementation spike)', () => { + it('Document/Window: a same-function block shadow classifies each occurrence against its own lexical scope', () => { + // Outer `doc: Document` parameter; an inner if-block shadows it with a + // real `doc: Window`, used there for a capture-Escape listener (Window is + // a recognized addEventListener receiver, so this IS a candidate); after + // the block, the SAME name reverts to the outer Document for a body + // mount. Real TypeScript binder semantics, not a hand-rolled scope walk. + const source = [ + 'function openRogueA6a(doc: Document) {', + ' if (cond) {', + ' const doc: Window = getPopup();', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + " doc.addEventListener('keydown', onKey, true);", + ' }', + ' doc.body.appendChild(panel);', + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_a6-block-shadow.ts', source }]); + expect(found.filter((v) => v.rule === 'shell-capture-escape')).toHaveLength(1); + expect(found.filter((v) => v.rule === 'shell-body-mount')).toHaveLength(1); + expect(found).toHaveLength(2); + }); + + it('Document: a for-loop-header shadow is isolated to the loop, with correct reversion to the outer alias on BOTH sides', () => { + const source = [ + 'function openRogueA6b() {', + ' const doc = document;', + ' doc.body.appendChild(before);', // outer, BEFORE the loop + ' for (let doc = window; false; ) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + " doc.addEventListener('keydown', onKey, true);", // loop-scoped Window + ' }', + ' doc.body.appendChild(after);', // outer, AFTER the loop + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_a6-for-header-shadow.ts', source }]); + expect(found.filter((v) => v.rule === 'shell-body-mount')).toHaveLength(2); + expect(found.filter((v) => v.rule === 'shell-capture-escape')).toHaveLength(1); + expect(found).toHaveLength(3); + }); + + it('Document/Window: sibling scopes reusing the same local alias name resolve independently (no cross-pollution)', () => { + // `fnA`'s `doc: Document` really is a body mount; `fnB`'s SAME bare name + // `doc: Window` is NOT — the checker resolves each reference to its own + // function's own parameter, never a sibling's, so `fnB`'s call is not + // even a `shell-body-mount` CANDIDATE at all (Window has no `.body` + // Document semantics), not merely "excused by policy". + const source = [ + 'function fnA(doc: Document) { doc.body.appendChild(panel); }', + 'function fnB(doc: Window) { doc.body.appendChild(panel); }', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_a6-sibling-scopes.ts', source }]) + .filter((v) => v.rule === 'shell-body-mount'); + expect(found).toHaveLength(1); + }); + + it('handler resolution: a block-local non-Escape handler shadow does not change the outer handler classification, and reverts correctly outside the block', () => { + const source = [ + 'function openRogueA6c(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", // outer real handler + ' if (cond) {', + ' const onKey = (e) => { flag = true; };', // block-local shadow, non-Escape + " document.addEventListener('click', onKey);", // no capture arg — not even a candidate + ' }', + " doc.addEventListener('keydown', onKey, true);", // must resolve to the OUTER handler + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_a6-handler-block-shadow.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found).toHaveLength(1); + }); + + it('capture-options alias: a for-loop-header shadow resolves independently inside vs. outside the loop', () => { + const source = [ + 'function openRogueA6d(doc: Document) {', + " const onKey = (e) => { if (e.key === 'Escape') close(); };", + ' const opts = { capture: true };', + ' for (let opts = false; false; ) {', + " document.addEventListener('click', () => {}, opts);", // inner shadow: opts=false, non-capture + ' }', + " doc.addEventListener('keydown', onKey, opts);", // outer: opts=true, must be flagged + '}', + ].join('\n'); + const found = shellViolations([{ filename: 'src/ui/_a6-capture-for-header-shadow.ts', source }]) + .filter((v) => v.rule === 'shell-capture-escape'); + expect(found).toHaveLength(1); + }); +}); + +// Typed-only compile-time proof the DTOs above are what the strict `.d.mts` +// boundary declares — never executed, just type-checked by `tsc --noEmit`. +function typeCheckOnly(): void { + const decl: FixedPositionDeclaration = { selector: '.x', atRule: null, nested: false, pos: 0 }; + const violation: SourceContractViolation = { rule: 'shell-body-mount', filename: 'x', pos: 0, detail: 'x' }; + const entry: ShellGuardrailSourceEntry = { filename: 'x', source: 'x' }; + void decl; void violation; void entry; +} +void typeCheckOnly;