From 36ef21c23350d55a22ce28f7e6bd7fe0538dc397 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 08:47:27 +0200 Subject: [PATCH 1/8] build(#592): lock in #586/#587 shell primitive guardrails Add three mechanical check:arch guards so the six-copy-pasted-overlays problem #586 fixed cannot silently regrow: - shell-body-mount: a new Document.body.append/.appendChild call outside an exact, reviewed baseline of sanctioned lifecycle/primitive scopes. - shell-fixed-position: a new position: fixed CSS declaration in src/styles.css outside the current selector/at-rule snapshot, via a focused CSS lexical scanner (no CSS parser dependency). - shell-capture-escape: a new global capture-phase Escape keydown lifecycle outside SurfaceLifecycle and its exact documented exceptions/non-panel gesture exclusions. The two source-level rules share one real-TypeScript-parser batch (findShellGuardrailSourceContractViolations, build/lib/check-legacy- owners.mjs), reusing this repo's existing withParsedSources/walkTree/ SyntaxKind idiom -- no new parser dependency. Every fingerprint in the frozen policy tables was generated by running the analyzers over the live tree and reviewing each occurrence, including two the issue's own attachment underestimated (dashboard-chart-interaction.ts's beginSelection chart-selection Escape cancellation, and app.ts's export-progress/download- anchor body mounts). Also closes the inherited #586/#593-phase-1 finding: an independent tests/unit/resize-handle-thickness-contract.test.js proves app-shell.ts's HANDLE_PX and styles.css's .col-resize/.inspector-resize width cannot drift unnoticed. Enforcement-only -- no runtime UI/DOM/CSS behavior changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- CHANGELOG.md | 19 + build/check-boundaries.mjs | 40 + build/lib/check-legacy-owners.d.mts | 64 +- build/lib/check-legacy-owners.mjs | 955 ++++++++++++++++++ .../resize-handle-thickness-contract.test.js | 174 ++++ tests/unit/shell-guardrails-arch.test.ts | 569 +++++++++++ 6 files changed, 1820 insertions(+), 1 deletion(-) create mode 100644 tests/unit/resize-handle-thickness-contract.test.js create mode 100644 tests/unit/shell-guardrails-arch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1256e7d..43ffd0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ 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. 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..4244d039 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -100,6 +100,8 @@ import { retiredClientSpikeScriptNames, findDynamicImportUsages, mightContainDynamicImport, + findShellGuardrailSourceContractViolations, + findShellFixedPositionViolations, } from './lib/check-legacy-owners.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -886,6 +888,44 @@ 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); 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])); + for (const v of findShellGuardrailSourceContractViolations(shellSources)) { + 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}`); + } + } +} + 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..feb677e5 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,56 @@ 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; +} + +/** + * 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). + */ +export function findShellGuardrailSourceContractViolations( + 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 nearest enclosing at-rule's normalized prelude (e.g. + * `'@media (max-width: 768px)'`), or `null` when the declaration sits at the + * stylesheet's top level; `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 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: every `scanFixedPositionDeclarations` + * result in `cssSource` whose exact `(selector, atRule)` pair is outside the + * frozen #592 baseline snapshot. + */ +export function findShellFixedPositionViolations( + cssSource: string, + filename: string, +): SourceContractViolation[]; diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 344b958e..db255560 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -1964,3 +1964,958 @@ 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. + +/** 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). */ +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(' > '); +} + +/** 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 `buildGlobalAliasMap`. */ +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; +} + +/** + * 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`; a simple + * alias already in `aliasMap` (built by `buildGlobalAliasMap`); 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 {Map} aliasMap + * @returns {'document'|'window'|null} + */ +function resolveGlobalKind(node, aliasMap) { + const expr = unwrapCastWrappers(node); + if (!expr) return null; + if (expr.kind === SyntaxKind.Identifier) { + if (expr.text === 'document') return 'document'; + if (expr.text === 'window') return 'window'; + return aliasMap.get(expr.text) ?? 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, aliasMap) ?? resolveGlobalKind(expr.right, aliasMap); + } + if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, aliasMap); + return null; + } + if (expr.kind === SyntaxKind.ConditionalExpression) { + return resolveGlobalKind(expr.whenTrue, aliasMap) ?? resolveGlobalKind(expr.whenFalse, aliasMap); + } + return null; +} + +/** + * Build the per-file `name -> 'document'|'window'` alias map: every + * `Parameter`/`VariableDeclaration` whose declared TYPE names `Document`/ + * `Window` (`childDoc: Document`, `mainDoc: Document`), every destructuring + * rename whose `propertyName` is `document`/`window` (`const { document: doc + * } = opts` — `menu.ts`'s real shape), and every `VariableDeclaration` whose + * INITIALIZER resolves via `resolveGlobalKind` against the map built so far. + * A single forward walk over the whole file suffices for every real + * occurrence in this codebase (parameters are visited before the statements + * that reference them by `forEachChild`'s own declaration order, and no + * alias here is ever referenced before its own declaration) — this is a + * bounded architecture-guard heuristic, not a general dataflow engine; see + * this module's own header comment on accepted-risk scope. + * + * @param {object} sourceFile + * @returns {Map} + */ +function buildGlobalAliasMap(sourceFile) { + const aliasMap = new Map(); + walkTree(sourceFile, (node) => { + if (node.kind === SyntaxKind.Parameter && node.name && node.name.kind === SyntaxKind.Identifier && node.type) { + const names = typeNamesOf(node.type); + if (names.includes('Document')) aliasMap.set(node.name.text, 'document'); + else if (names.includes('Window')) aliasMap.set(node.name.text, 'window'); + } + if ( + node.kind === SyntaxKind.BindingElement && node.propertyName + && node.propertyName.kind === SyntaxKind.Identifier && node.name.kind === SyntaxKind.Identifier + ) { + if (node.propertyName.text === 'document') aliasMap.set(node.name.text, 'document'); + else if (node.propertyName.text === 'window') aliasMap.set(node.name.text, 'window'); + } + if (node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier) { + let kind = null; + if (node.type) { + const names = typeNamesOf(node.type); + if (names.includes('Document')) kind = 'document'; + else if (names.includes('Window')) kind = 'window'; + } + if (!kind && node.initializer) kind = resolveGlobalKind(node.initializer, aliasMap); + if (kind) aliasMap.set(node.name.text, kind); + } + }); + return aliasMap; +} + +/** Every `name -> [{node, pos}]` binding of a `FunctionDeclaration` or a + * `const name = (…) => {}` / `const name = function (…) {}` in `sourceFile` + * — used to resolve a plain-identifier `addEventListener` handler argument + * (`doc.addEventListener('keydown', onKey, true)`) back to the function it + * names. Multiple same-named entries are kept (never overwritten) so + * `resolveHandlerNode` can pick the one nearest-preceding a given use. */ +function buildFunctionDeclMap(sourceFile) { + const map = new Map(); + const add = (name, node) => { + const list = map.get(name) ?? []; + list.push({ node, pos: node.getStart(sourceFile) }); + map.set(name, list); + }; + walkTree(sourceFile, (node) => { + if (node.kind === SyntaxKind.FunctionDeclaration && node.name) add(node.name.text, node); + if ( + node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier + && node.initializer + ) { + const init = unwrapCastWrappers(node.initializer); + if (init && (init.kind === SyntaxKind.ArrowFunction || init.kind === SyntaxKind.FunctionExpression)) { + add(node.name.text, init); + } + } + }); + return map; +} + +/** + * 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 to the NEAREST PRECEDING (by source position) + * declaration of that name in `funcDeclMap` (`buildFunctionDeclMap`). `null` + * for anything else (a member access, a call, a conditional, …) — 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". + * + * @param {object} handlerArg + * @param {Map} funcDeclMap + * @returns {object | null} + */ +function resolveHandlerNode(handlerArg, funcDeclMap) { + const expr = unwrapCastWrappers(handlerArg); + if (!expr) return null; + if (FUNCTION_LIKE_KINDS.has(expr.kind)) return expr; + if (expr.kind === SyntaxKind.Identifier) { + const entries = funcDeclMap.get(expr.text); + if (!entries || entries.length === 0) return null; + const pos = expr.getStart(); + let best = null; + for (const e of entries) { if (e.pos <= pos && (!best || e.pos > best.pos)) best = e; } + return best ? best.node : entries[0].node; + } + return null; +} + +/** Resolve an `addEventListener` OPTIONS object literal's own `capture` + * member to `true`/`false`, or `null` when unresolvable — a `SpreadAssignment` + * anywhere in the object (its full shape can't be proven), or an explicit + * `capture` property whose value isn't a plain boolean literal. An object + * literal with NO explicit `capture` key and no spread is provably `false` + * (the DOM default), matching `addEventListener`'s own spec default. */ +function resolveObjectCaptureLiteral(node) { + let hasSpread = false; + let captureProp = null; + for (const p of node.properties) { + if (p.kind === SyntaxKind.SpreadAssignment) { hasSpread = true; continue; } + if ( + p.kind === SyntaxKind.PropertyAssignment && p.name && p.name.kind === SyntaxKind.Identifier + && p.name.text === 'capture' + ) { + captureProp = p; + } + } + if (captureProp) { + const v = unwrapCastWrappers(captureProp.initializer); + if (v && v.kind === SyntaxKind.TrueKeyword) return true; + if (v && v.kind === SyntaxKind.FalseKeyword) return false; + return null; + } + return hasSpread ? null : false; +} + +/** Every `name -> true|false|null` binding of a `const name = true` / `const + * name = false` / `const name = { capture: … }` (via + * `resolveObjectCaptureLiteral`) in `sourceFile` — backs the plan's "simple + * local const aliases of either form" requirement for the THIRD + * `addEventListener` argument. */ +function buildCaptureAliasMap(sourceFile) { + const map = new Map(); + walkTree(sourceFile, (node) => { + if ( + node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier + && node.initializer + ) { + const init = unwrapCastWrappers(node.initializer); + if (!init) return; + if (init.kind === SyntaxKind.TrueKeyword) map.set(node.name.text, true); + else if (init.kind === SyntaxKind.FalseKeyword) map.set(node.name.text, false); + else if (init.kind === SyntaxKind.ObjectLiteralExpression) map.set(node.name.text, resolveObjectCaptureLiteral(init)); + } + }); + return map; +} + +/** + * 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; every other shape (a member access, a call, a + * conditional, an unresolved identifier) is `null`. + * + * @param {object} node + * @param {Map} captureAliasMap + * @returns {boolean | null} + */ +function resolveCaptureFlag(node, captureAliasMap) { + 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); + if (expr.kind === SyntaxKind.Identifier) return captureAliasMap.has(expr.text) ? captureAliasMap.get(expr.text) : null; + return null; +} + +/** The one Escape literal every semantic check below compares against — + * `event.key`/`event.code` forms alike (the plan does not distinguish + * between the two KeyboardEvent properties, only requires either to be + * recognized). */ +const ESCAPE_LITERAL_SET = new Set(['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 — + * `exactLiteralMatch` already normalizes string vs. no-substitution-template + * literals to the same decoded `.text`), the analogous `event.code` forms, and + * `switch (event.key) { case 'Escape': … }`. 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. + * + * @param {object} fnLikeNode + * @returns {boolean} + */ +function containsEscapeSemantics(fnLikeNode) { + 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 leftIsEscape = exactLiteralMatch(node.left, ESCAPE_LITERAL_SET); + const rightIsEscape = exactLiteralMatch(node.right, ESCAPE_LITERAL_SET); + const other = leftIsEscape ? node.right : (rightIsEscape ? node.left : null); + if (other) { + const names = terminalNames(other, 1); + if (names.length === 1 && (names[0] === 'key' || names[0] === 'code')) 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 && exactLiteralMatch(clause.expression, ESCAPE_LITERAL_SET)) { + 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' }, +]); + +/** + * Every real `.appendChild(...)`/`.append(...)` call in `sourceFile` whose + * receiver structurally resolves to a recognized `Document.body` — covering, + * per the plan's candidate list: `document.body.appendChild(...)`, + * `doc.body.appendChild(...)`, `mainDoc.body.appendChild(...)`, + * `childDoc.body.appendChild(...)`, `deps.document.body.appendChild(...)`, + * `window.document.body.appendChild(...)`, the bracket-property spelling + * (`doc['body']['appendChild'](...)`), a propagated body alias (`const body = + * childDoc.body; body.appendChild(...)`), and a further simple alias of that + * body binding. 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). + * + * @param {object} sourceFile + * @returns {{node: object, api: 'appendChild'|'append', scopePath: string[], scopeNode: object|null, pos: number}[]} + */ +function bodyMountCandidates(sourceFile) { + const aliasMap = buildGlobalAliasMap(sourceFile); + const bodyAliasNames = new Set(); + walkTree(sourceFile, (node) => { + if ( + node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier + && node.initializer + ) { + const init = unwrapCastWrappers(node.initializer); + if ( + init && init.kind === SyntaxKind.PropertyAccessExpression && init.name.text === 'body' + && resolveGlobalKind(init.expression, aliasMap) === 'document' + ) { + bodyAliasNames.add(node.name.text); + } + if (init && init.kind === SyntaxKind.Identifier && bodyAliasNames.has(init.text)) { + bodyAliasNames.add(node.name.text); + } + } + }); + 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; + const recv = unwrapCastWrappers(receiver); + if (!recv) return; + let isBody = false; + if (recv.kind === SyntaxKind.Identifier && bodyAliasNames.has(recv.text)) { + isBody = true; + } else if (recv.kind === SyntaxKind.PropertyAccessExpression && recv.name.text === 'body' + && resolveGlobalKind(recv.expression, aliasMap) === 'document') { + isBody = true; + } else if (recv.kind === SyntaxKind.ElementAccessExpression) { + const argN = recv.argumentExpression; + if ( + argN && (argN.kind === SyntaxKind.StringLiteral || argN.kind === SyntaxKind.NoSubstitutionTemplateLiteral) + && argN.text === 'body' && resolveGlobalKind(recv.expression, aliasMap) === 'document' + ) { + isBody = true; + } + } + if (!isBody) 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. */ +function shellBodyMountViolations(sourceFile, filename) { + const byScope = new Map(); + for (const c of bodyMountCandidates(sourceFile)) { + 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', + )); + } + } + 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 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). + * + * @param {object} sourceFile + * @returns {{kind: 'escape'|'clean'|'uncheckable-handler'|'uncheckable-options', scopePath: string[], pos: number}[]} + */ +function captureEscapeCandidates(sourceFile) { + const aliasMap = buildGlobalAliasMap(sourceFile); + const funcDeclMap = buildFunctionDeclMap(sourceFile); + const captureAliasMap = buildCaptureAliasMap(sourceFile); + const out = []; + walkTree(sourceFile, (node) => { + if (node.kind !== SyntaxKind.CallExpression) return; + const callee = node.expression; + if (callee.kind !== SyntaxKind.PropertyAccessExpression || callee.name.text !== 'addEventListener') return; + const args = node.arguments; + if (args.length < 2) return; + const evtArg = unwrapCastWrappers(args[0]); + if ( + !evtArg || (evtArg.kind !== SyntaxKind.StringLiteral && evtArg.kind !== SyntaxKind.NoSubstitutionTemplateLiteral) + || evtArg.text !== 'keydown' + ) return; + if (!resolveGlobalKind(callee.expression, aliasMap)) 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, captureAliasMap); + if (captureFlag === false) return; // provably non-capture + if (captureFlag === null) { out.push({ kind: 'uncheckable-options', scopePath, pos }); return; } + const handlerNode = resolveHandlerNode(args[1], funcDeclMap); + if (!handlerNode) { out.push({ kind: 'uncheckable-handler', scopePath, pos }); return; } + out.push({ kind: containsEscapeSemantics(handlerNode) ? '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. */ +function shellCaptureEscapeViolations(sourceFile, filename) { + const byScope = new Map(); + const violations = []; + for (const c of captureEscapeCandidates(sourceFile)) { + 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', + )); + } + } + 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. + * + * @param {readonly {filename: string, source: string}[]} sources + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellGuardrailSourceContractViolations(sources) { + return withParsedSources(sources, (sourceFiles) => { + const violations = []; + for (const [filename, sourceFile] of sourceFiles) { + violations.push(...shellBodyMountViolations(sourceFile, filename)); + violations.push(...shellCaptureEscapeViolations(sourceFile, filename)); + } + return violations; + }); +} + +// ── 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 nearest enclosing at-rule, 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(); +} + +/** `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 + * nearest enclosing at-rule's normalized prelude (`normalizeCssText`, or + * `null` when the declaration sits at the stylesheet's top level with no + * enclosing at-rule — e.g. NOT inside `@media`). 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). + * + * @param {string} source + * @returns {{selector: string, atRule: string | null, 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; + const colonIdx = trimmed.indexOf(':'); + if (colonIdx === -1) return; + const prop = trimmed.slice(0, colonIdx).trim(); + const value = trimmed.slice(colonIdx + 1).trim(); + if (prop.toLowerCase() !== 'position') return; + if (!/^fixed(\s*!\s*important)?$/i.test(normalizeCssText(value))) return; + const innermost = frames[frames.length - 1]; + if (!innermost || innermost.kind !== 'rule') return; // no selector context — out of this rule's scope + let atRule = null; + for (let k = frames.length - 2; k >= 0; k--) { + if (frames[k].kind === 'at') { atRule = frames[k].prelude; break; } + } + results.push({ selector: innermost.prelude, atRule, 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 `shell-fixed-position` guard: every `scanFixedPositionDeclarations` + * result in `cssSource` whose exact `(selector, atRule)` pair is not on + * `SHELL_FIXED_POSITION_POLICY`. + * + * @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 = []; + for (const decl of scanFixedPositionDeclarations(cssSource)) { + const approved = SHELL_FIXED_POSITION_POLICY.some( + (p) => p.selector === decl.selector && p.atRule === decl.atRule, + ); + if (approved) continue; + violations.push(makeViolation( + 'shell-fixed-position', filename, decl.pos, + `position: fixed on selector "${decl.selector}"${decl.atRule ? ` inside ${decl.atRule}` : ''} is not on the ` + + 'approved #592 fixed-position snapshot — use shell/docked composition where appropriate, or deliberately ' + + 'extend the reviewed fixed-position snapshot for a legitimate overlay', + )); + } + 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..3f395236 --- /dev/null +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -0,0 +1,174 @@ +// 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], + })); +} + +/** Every `width: px` value declared by a rule whose selector list + * contains BOTH `.col-resize` AND `.inspector-resize` together (order- + * independent; additional selectors in the same group, e.g. `.row-resize`, + * are allowed) — i.e. the rule that governs both classes' shared width, not + * just any rule that happens to mention either class alone. Zero, one, or + * many, across however many matching rule groups exist: the caller decides + * what count is valid. */ +function extractSharedResizeWidthPx(cssSource) { + const values = []; + for (const rule of flatCssRules(cssSource)) { + if (!rule.selectors.includes('.col-resize') || !rule.selectors.includes('.inspector-resize')) continue; + for (const m of rule.body.matchAll(/\bwidth\s*:\s*(-?\d+(?:\.\d+)?)px\s*;/g)) values.push(Number(m[1])); + } + 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', () => { + 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: [] }); + }); + + 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] }); + }); + + 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([]); + }); +}); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts new file mode 100644 index 00000000..4ac59b94 --- /dev/null +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -0,0 +1,569 @@ +// 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, + findShellFixedPositionViolations, + 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 cssViolations: SourceContractViolation[]; + + beforeAll(() => { + const files = listSourceFiles(); + const sources: ShellGuardrailSourceEntry[] = files.map((relPath) => ({ + filename: relPath, + source: readFileSync(join(root, relPath), 'utf8'), + })); + tsViolations = findShellGuardrailSourceContractViolations(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('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); }'], + ]; + 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); + }); + } + + 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']); + }); +}); + +// ── 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([]); + }); + + 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([]); + }); + + 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']); + }); + + 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']); + }); + + 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']); + }); + + 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']); + }); +}); + +// ── 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'); + }); +}); + +// ── 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('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); + }); +}); + +// ── 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([]); + }); +}); + +// 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, 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; From 570e08940998c27c2585107b3c9cc3515dd0ac07 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 09:47:48 +0200 Subject: [PATCH 2/8] fix(#592): address review pass 1 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildGlobalAliasMap/buildFunctionDeclMap/buildCaptureAliasMap now key every binding by its own declaring scope (a new scopeOwnerOf/scopeChain/ lookupInScopeChain lexical-resolution layer) instead of one flat file-wide Map. A later sibling `doc: Window` no longer overwrites an earlier `doc: Document`, a sibling scope's `opts = false` no longer erases a real scope's `{ capture: true }` alias, and a same-named nested helper no longer resolves in place of the real addEventListener handler. - Fixing the scoping exposed a real (previously accidental) detection gap: `openInDetachedTab`'s `mount(({ doc, ... }: MountCtx) => ...)` destructures `doc: Document` from a named interface type, a shape none of the alias rules modeled directly — it only worked before via an unrelated same-name binding's file-wide leak. Added an explicit, narrowly-scoped MountCtx recognition rule so explain-graph.ts's two real capture-Escape listeners resolve on their own merits. - shellBodyMountViolations/shellCaptureEscapeViolations now also compare the frozen policy against the tree in the missing direction: any approved scope whose occurrence count dropped below its baseline is flagged, not just excess occurrences. Gated on declaredScopeKeys (does this scope even exist in what was scanned) so this suite's many single-scope synthetic fixtures for multi-entry files (popover.ts, app.ts, detached-view.ts, explain-graph.ts, dashboard-tile-gestures.ts) aren't misread as "missing". - findShellFixedPositionViolations is now count-based (a duplicate of an approved selector/at-rule is flagged, not just a brand-new one). The reverse direction (an approved fingerprint that vanished from the CSS entirely) is a new, separate export, findShellFixedPositionMissingBaseline Violations — kept separate because CSS has no structural way to tell a partial test fixture from the real, complete stylesheet; wired into check:arch against the real src/styles.css. - extractSharedResizeWidthPx now also catches a standalone or media-scoped single-class override of .col-resize/.inspector-resize, not just the shared grouped rule. - Added same-file scope-shadowing sabotage fixtures (doc alias, capture options, handler), missing-baseline-entry sabotage for both TS guards, duplicate-approved-CSS and missing-baseline-CSS sabotage (against the real styles.css), and CSS-override sabotage for the resize-handle contract. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/check-boundaries.mjs | 8 + build/lib/check-legacy-owners.d.mts | 21 +- build/lib/check-legacy-owners.mjs | 430 +++++++++++++++--- .../resize-handle-thickness-contract.test.js | 64 ++- tests/unit/shell-guardrails-arch.test.ts | 167 +++++++ 5 files changed, 603 insertions(+), 87 deletions(-) diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index 4244d039..877dcc53 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -102,6 +102,7 @@ import { mightContainDynamicImport, findShellGuardrailSourceContractViolations, findShellFixedPositionViolations, + findShellFixedPositionMissingBaselineViolations, } from './lib/check-legacy-owners.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -923,6 +924,13 @@ function lineOfOffset(source, pos) { 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}`); + } } } diff --git a/build/lib/check-legacy-owners.d.mts b/build/lib/check-legacy-owners.d.mts index feb677e5..9b32a357 100644 --- a/build/lib/check-legacy-owners.d.mts +++ b/build/lib/check-legacy-owners.d.mts @@ -149,11 +149,26 @@ export interface FixedPositionDeclaration { export function scanFixedPositionDeclarations(source: string): FixedPositionDeclaration[]; /** - * The `shell-fixed-position` guard: every `scanFixedPositionDeclarations` - * result in `cssSource` whose exact `(selector, atRule)` pair is outside the - * frozen #592 baseline snapshot. + * 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). */ 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 db255560..ae4ea6a0 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2067,6 +2067,50 @@ 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 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). A policy entry whose ENTIRE enclosing scope has also been + * deleted from production code is a coarser change a rename/typecheck + * failure elsewhere in the pipeline would surface — deliberately out of + * this narrower check's scope. */ +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 @@ -2087,6 +2131,52 @@ function hasCallNamed(scopeNode, name) { return found; } +/** The scope that directly owns a BINDING introduced at `node`, or that a + * REFERENCE at `node` resolves outward from: the nearest enclosing + * `FUNCTION_LIKE_KINDS` ancestor (`innermostScopeNode`), or `sourceFile` + * itself when `node` sits at module top level. Unlike `innermostScopeNode` + * (whose only existing caller wants `null` to mean "no enclosing scope at + * all"), this always returns a real, stable map key, so every scoped alias/ + * handler/capture table below has one uniform module-scope sentinel instead + * of a null special case. */ +function scopeOwnerOf(node, sourceFile) { + return innermostScopeNode(node) ?? sourceFile; +} + +/** The full lexical scope chain for `node`, innermost first, ending at + * `sourceFile` (module scope) — every scope a name reference at `node` can + * actually resolve through, mirroring real JS/TS function-scope shadowing. + * A binding declared in a sibling scope, or in a scope nested BELOW `node` + * (a helper function declared inside the scope currently being resolved), + * is never a member of this chain, so it can never satisfy a lookup for + * `node` — the fix for the P1 "collects bindings by bare identifier across + * the entire source file" finding: every alias/handler/capture table below + * is now keyed `scope -> Map` and resolved through this chain, + * never through one flat file-wide `Map`. */ +function scopeChain(node, sourceFile) { + const chain = []; + let scope = scopeOwnerOf(node, sourceFile); + for (;;) { + chain.push(scope); + if (scope === sourceFile) return chain; + scope = scopeOwnerOf(scope, sourceFile); + } +} + +/** Resolve `node` (an `Identifier`) through `scopedMap` (`scope -> + * Map`) by walking `scopeChain(node, sourceFile)` innermost + * first and returning the first scope's binding for `node.text` — i.e. the + * nearest LEXICALLY VISIBLE declaration, never a same-named binding from an + * unrelated scope. `undefined` when no scope on the chain binds that name at + * all (the caller's own fail-closed handling decides what that means). */ +function lookupInScopeChain(scopedMap, node, sourceFile) { + for (const scope of scopeChain(node, sourceFile)) { + const local = scopedMap.get(scope); + if (local && local.has(node.text)) return local.get(node.text); + } + return undefined; +} + /** 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 @@ -2129,16 +2219,17 @@ function typeNamesOf(typeNode) { * a recognized global", never as a silent pass for a DIFFERENT reason. * * @param {object} node - * @param {Map} aliasMap + * @param {Map>} aliasMap scope -> name -> kind + * @param {object} sourceFile * @returns {'document'|'window'|null} */ -function resolveGlobalKind(node, aliasMap) { +function resolveGlobalKind(node, aliasMap, sourceFile) { const expr = unwrapCastWrappers(node); if (!expr) return null; if (expr.kind === SyntaxKind.Identifier) { if (expr.text === 'document') return 'document'; if (expr.text === 'window') return 'window'; - return aliasMap.get(expr.text) ?? null; + return lookupInScopeChain(aliasMap, expr, sourceFile) ?? null; } if (expr.kind === SyntaxKind.PropertyAccessExpression) { if (expr.name.text === 'document') return 'document'; @@ -2156,48 +2247,89 @@ function resolveGlobalKind(node, aliasMap) { if (expr.kind === SyntaxKind.BinaryExpression) { const op = expr.operatorToken.kind; if (op === SyntaxKind.BarBarToken || op === SyntaxKind.QuestionQuestionToken) { - return resolveGlobalKind(expr.left, aliasMap) ?? resolveGlobalKind(expr.right, aliasMap); + return resolveGlobalKind(expr.left, aliasMap, sourceFile) ?? resolveGlobalKind(expr.right, aliasMap, sourceFile); } - if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, aliasMap); + if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, aliasMap, sourceFile); return null; } if (expr.kind === SyntaxKind.ConditionalExpression) { - return resolveGlobalKind(expr.whenTrue, aliasMap) ?? resolveGlobalKind(expr.whenFalse, aliasMap); + return resolveGlobalKind(expr.whenTrue, aliasMap, sourceFile) ?? resolveGlobalKind(expr.whenFalse, aliasMap, sourceFile); } return null; } +/** `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 none of + * `buildGlobalAliasMap`'s other rules can see on their own (its Parameter + * rule only looks at a plain-IDENTIFIER parameter's own type; its + * BindingElement rule only recognizes a RENAMED `{ document: doc }` form). + * This module has no real type checker (`typescript/unstable/sync` is + * parse-only — see the module header), 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'; + /** - * Build the per-file `name -> 'document'|'window'` alias map: every + * Build the per-file `scope -> name -> 'document'|'window'` alias map: every * `Parameter`/`VariableDeclaration` whose declared TYPE names `Document`/ * `Window` (`childDoc: Document`, `mainDoc: Document`), every destructuring * rename whose `propertyName` is `document`/`window` (`const { document: doc * } = opts` — `menu.ts`'s real shape), and every `VariableDeclaration` whose - * INITIALIZER resolves via `resolveGlobalKind` against the map built so far. - * A single forward walk over the whole file suffices for every real - * occurrence in this codebase (parameters are visited before the statements - * that reference them by `forEachChild`'s own declaration order, and no - * alias here is ever referenced before its own declaration) — this is a - * bounded architecture-guard heuristic, not a general dataflow engine; see - * this module's own header comment on accepted-risk scope. + * INITIALIZER resolves via `resolveGlobalKind` against the map built so far, + * each recorded under its OWN declaring scope (`scopeOwnerOf`) rather than + * one flat file-wide key. A single forward walk over the whole file still + * suffices for every real occurrence in this codebase (parameters are + * visited before the statements that reference them by `forEachChild`'s own + * declaration order, and no alias here is ever referenced before its own + * declaration) — this is a bounded architecture-guard heuristic, not a + * general dataflow engine; see this module's own header comment on + * accepted-risk scope. Per-scope keying is what makes that heuristic sound + * under same-file shadowing: a later sibling `doc: Window` in an unrelated + * function no longer overwrites an earlier `doc: Document` bound in a + * different scope (the reviewed #672 P1 — same bare name, different scopes, + * used to collapse to one file-wide last-write-wins entry). * * @param {object} sourceFile - * @returns {Map} + * @returns {Map>} */ function buildGlobalAliasMap(sourceFile) { - const aliasMap = new Map(); + const aliasMap = new Map(); // scope -> Map + const setAlias = (scope, name, kind) => { + let local = aliasMap.get(scope); + if (!local) { local = new Map(); aliasMap.set(scope, local); } + local.set(name, kind); + }; walkTree(sourceFile, (node) => { if (node.kind === SyntaxKind.Parameter && node.name && node.name.kind === SyntaxKind.Identifier && node.type) { const names = typeNamesOf(node.type); - if (names.includes('Document')) aliasMap.set(node.name.text, 'document'); - else if (names.includes('Window')) aliasMap.set(node.name.text, 'window'); + const scope = scopeOwnerOf(node, sourceFile); + if (names.includes('Document')) setAlias(scope, node.name.text, 'document'); + else if (names.includes('Window')) setAlias(scope, node.name.text, 'window'); + } + if ( + node.kind === SyntaxKind.Parameter && node.name && node.name.kind === SyntaxKind.ObjectBindingPattern && node.type + && typeNamesOf(node.type).includes(MOUNT_CTX_TYPE_NAME) + ) { + const scope = scopeOwnerOf(node, sourceFile); + for (const el of node.name.elements) { + if ( + el.kind === SyntaxKind.BindingElement && !el.propertyName && el.name && el.name.kind === SyntaxKind.Identifier + && el.name.text === 'doc' + ) { + setAlias(scope, el.name.text, 'document'); + } + } } if ( node.kind === SyntaxKind.BindingElement && node.propertyName && node.propertyName.kind === SyntaxKind.Identifier && node.name.kind === SyntaxKind.Identifier ) { - if (node.propertyName.text === 'document') aliasMap.set(node.name.text, 'document'); - else if (node.propertyName.text === 'window') aliasMap.set(node.name.text, 'window'); + const scope = scopeOwnerOf(node, sourceFile); + if (node.propertyName.text === 'document') setAlias(scope, node.name.text, 'document'); + else if (node.propertyName.text === 'window') setAlias(scope, node.name.text, 'window'); } if (node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier) { let kind = null; @@ -2206,35 +2338,44 @@ function buildGlobalAliasMap(sourceFile) { if (names.includes('Document')) kind = 'document'; else if (names.includes('Window')) kind = 'window'; } - if (!kind && node.initializer) kind = resolveGlobalKind(node.initializer, aliasMap); - if (kind) aliasMap.set(node.name.text, kind); + if (!kind && node.initializer) kind = resolveGlobalKind(node.initializer, aliasMap, sourceFile); + if (kind) setAlias(scopeOwnerOf(node, sourceFile), node.name.text, kind); } }); return aliasMap; } -/** Every `name -> [{node, pos}]` binding of a `FunctionDeclaration` or a - * `const name = (…) => {}` / `const name = function (…) {}` in `sourceFile` - * — used to resolve a plain-identifier `addEventListener` handler argument - * (`doc.addEventListener('keydown', onKey, true)`) back to the function it - * names. Multiple same-named entries are kept (never overwritten) so - * `resolveHandlerNode` can pick the one nearest-preceding a given use. */ +/** Every `scope -> name -> [{node, pos}]` binding of a `FunctionDeclaration` + * or a `const name = (…) => {}` / `const name = function (…) {}` in + * `sourceFile`, keyed by the declaration's OWN declaring scope + * (`scopeOwnerOf`) rather than one flat file-wide name — used to resolve a + * plain-identifier `addEventListener` handler argument (`doc. + * addEventListener('keydown', onKey, true)`) back to the function it names + * through `resolveHandlerNode`'s lexical scope-chain lookup, never through a + * same-named declaration in an unrelated sibling or nested-below scope (the + * reviewed #672 P1 handler-shadowing case). Multiple same-named entries + * WITHIN one scope are kept (never overwritten) so `resolveHandlerNode` can + * pick the one nearest-preceding a given use inside that scope. */ function buildFunctionDeclMap(sourceFile) { - const map = new Map(); - const add = (name, node) => { - const list = map.get(name) ?? []; + const map = new Map(); // scope -> Map + const add = (scope, name, node) => { + let local = map.get(scope); + if (!local) { local = new Map(); map.set(scope, local); } + const list = local.get(name) ?? []; list.push({ node, pos: node.getStart(sourceFile) }); - map.set(name, list); + local.set(name, list); }; walkTree(sourceFile, (node) => { - if (node.kind === SyntaxKind.FunctionDeclaration && node.name) add(node.name.text, node); + if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { + add(scopeOwnerOf(node, sourceFile), node.name.text, node); + } if ( node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier && node.initializer ) { const init = unwrapCastWrappers(node.initializer); if (init && (init.kind === SyntaxKind.ArrowFunction || init.kind === SyntaxKind.FunctionExpression)) { - add(node.name.text, init); + add(scopeOwnerOf(node, sourceFile), node.name.text, init); } } }); @@ -2244,24 +2385,27 @@ function buildFunctionDeclMap(sourceFile) { /** * 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 to the NEAREST PRECEDING (by source position) - * declaration of that name in `funcDeclMap` (`buildFunctionDeclMap`). `null` - * for anything else (a member access, a call, a conditional, …) — 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". + * plain `Identifier` resolved through `sourceFile`'s lexical scope chain + * (`lookupInScopeChain`) to the NEAREST enclosing scope that declares that + * name in `funcDeclMap` (`buildFunctionDeclMap`), then the NEAREST PRECEDING + * (by source position) declaration of that name within THAT scope. `null` + * for anything else (a member access, a call, a conditional, an identifier no + * scope on the chain binds, …) — 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". * * @param {object} handlerArg - * @param {Map} funcDeclMap + * @param {Map>} funcDeclMap + * @param {object} sourceFile * @returns {object | null} */ -function resolveHandlerNode(handlerArg, funcDeclMap) { +function resolveHandlerNode(handlerArg, funcDeclMap, sourceFile) { const expr = unwrapCastWrappers(handlerArg); if (!expr) return null; if (FUNCTION_LIKE_KINDS.has(expr.kind)) return expr; if (expr.kind === SyntaxKind.Identifier) { - const entries = funcDeclMap.get(expr.text); + const entries = lookupInScopeChain(funcDeclMap, expr, sourceFile); if (!entries || entries.length === 0) return null; const pos = expr.getStart(); let best = null; @@ -2298,13 +2442,22 @@ function resolveObjectCaptureLiteral(node) { return hasSpread ? null : false; } -/** Every `name -> true|false|null` binding of a `const name = true` / `const - * name = false` / `const name = { capture: … }` (via - * `resolveObjectCaptureLiteral`) in `sourceFile` — backs the plan's "simple - * local const aliases of either form" requirement for the THIRD - * `addEventListener` argument. */ +/** Every `scope -> name -> true|false|null` binding of a `const name = true` + * / `const name = false` / `const name = { capture: … }` (via + * `resolveObjectCaptureLiteral`) in `sourceFile`, keyed by the declaration's + * own declaring scope (`scopeOwnerOf`) — backs the plan's "simple local + * const aliases of either form" requirement for the THIRD + * `addEventListener` argument, resolved through `resolveCaptureFlag`'s + * lexical scope-chain lookup so a same-named alias in an unrelated sibling + * scope (the reviewed #672 P1 capture-alias-overwrite case) can never + * satisfy a different scope's lookup. */ function buildCaptureAliasMap(sourceFile) { - const map = new Map(); + const map = new Map(); // scope -> Map + const setAlias = (scope, name, value) => { + let local = map.get(scope); + if (!local) { local = new Map(); map.set(scope, local); } + local.set(name, value); + }; walkTree(sourceFile, (node) => { if ( node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier @@ -2312,9 +2465,12 @@ function buildCaptureAliasMap(sourceFile) { ) { const init = unwrapCastWrappers(node.initializer); if (!init) return; - if (init.kind === SyntaxKind.TrueKeyword) map.set(node.name.text, true); - else if (init.kind === SyntaxKind.FalseKeyword) map.set(node.name.text, false); - else if (init.kind === SyntaxKind.ObjectLiteralExpression) map.set(node.name.text, resolveObjectCaptureLiteral(init)); + const scope = scopeOwnerOf(node, sourceFile); + if (init.kind === SyntaxKind.TrueKeyword) setAlias(scope, node.name.text, true); + else if (init.kind === SyntaxKind.FalseKeyword) setAlias(scope, node.name.text, false); + else if (init.kind === SyntaxKind.ObjectLiteralExpression) { + setAlias(scope, node.name.text, resolveObjectCaptureLiteral(init)); + } } }); return map; @@ -2331,16 +2487,20 @@ function buildCaptureAliasMap(sourceFile) { * conditional, an unresolved identifier) is `null`. * * @param {object} node - * @param {Map} captureAliasMap + * @param {Map>} captureAliasMap + * @param {object} sourceFile * @returns {boolean | null} */ -function resolveCaptureFlag(node, captureAliasMap) { +function resolveCaptureFlag(node, captureAliasMap, 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); - if (expr.kind === SyntaxKind.Identifier) return captureAliasMap.has(expr.text) ? captureAliasMap.get(expr.text) : null; + if (expr.kind === SyntaxKind.Identifier) { + const found = lookupInScopeChain(captureAliasMap, expr, sourceFile); + return found === undefined ? null : found; + } return null; } @@ -2466,7 +2626,7 @@ function bodyMountCandidates(sourceFile) { const init = unwrapCastWrappers(node.initializer); if ( init && init.kind === SyntaxKind.PropertyAccessExpression && init.name.text === 'body' - && resolveGlobalKind(init.expression, aliasMap) === 'document' + && resolveGlobalKind(init.expression, aliasMap, sourceFile) === 'document' ) { bodyAliasNames.add(node.name.text); } @@ -2499,13 +2659,13 @@ function bodyMountCandidates(sourceFile) { if (recv.kind === SyntaxKind.Identifier && bodyAliasNames.has(recv.text)) { isBody = true; } else if (recv.kind === SyntaxKind.PropertyAccessExpression && recv.name.text === 'body' - && resolveGlobalKind(recv.expression, aliasMap) === 'document') { + && resolveGlobalKind(recv.expression, aliasMap, sourceFile) === 'document') { isBody = true; } else if (recv.kind === SyntaxKind.ElementAccessExpression) { const argN = recv.argumentExpression; if ( argN && (argN.kind === SyntaxKind.StringLiteral || argN.kind === SyntaxKind.NoSubstitutionTemplateLiteral) - && argN.text === 'body' && resolveGlobalKind(recv.expression, aliasMap) === 'document' + && argN.text === 'body' && resolveGlobalKind(recv.expression, aliasMap, sourceFile) === 'document' ) { isBody = true; } @@ -2526,7 +2686,14 @@ function bodyMountCandidates(sourceFile) { * 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. */ + * 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) { const byScope = new Map(); for (const c of bodyMountCandidates(sourceFile)) { @@ -2555,6 +2722,20 @@ function shellBodyMountViolations(sourceFile, filename) { )); } } + 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; } @@ -2642,15 +2823,15 @@ function captureEscapeCandidates(sourceFile) { !evtArg || (evtArg.kind !== SyntaxKind.StringLiteral && evtArg.kind !== SyntaxKind.NoSubstitutionTemplateLiteral) || evtArg.text !== 'keydown' ) return; - if (!resolveGlobalKind(callee.expression, aliasMap)) return; // not Document/Window — not a candidate + if (!resolveGlobalKind(callee.expression, aliasMap, 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, captureAliasMap); + const captureFlag = resolveCaptureFlag(third, captureAliasMap, sourceFile); if (captureFlag === false) return; // provably non-capture if (captureFlag === null) { out.push({ kind: 'uncheckable-options', scopePath, pos }); return; } - const handlerNode = resolveHandlerNode(args[1], funcDeclMap); + const handlerNode = resolveHandlerNode(args[1], funcDeclMap, sourceFile); if (!handlerNode) { out.push({ kind: 'uncheckable-handler', scopePath, pos }); return; } out.push({ kind: containsEscapeSemantics(handlerNode) ? 'escape' : 'clean', scopePath, pos }); }); @@ -2663,7 +2844,12 @@ function captureEscapeCandidates(sourceFile) { * 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. */ + * 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) { const byScope = new Map(); const violations = []; @@ -2704,6 +2890,20 @@ function shellCaptureEscapeViolations(sourceFile, filename) { )); } } + 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; } @@ -2894,10 +3094,32 @@ const SHELL_FIXED_POSITION_POLICY = Object.freeze([ { 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: every `scanFixedPositionDeclarations` - * result in `cssSource` whose exact `(selector, atRule)` pair is not on - * `SHELL_FIXED_POSITION_POLICY`. + * 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. * * @param {string} cssSource * @param {string} filename repo-relative, forward-slash separated (report only) @@ -2905,16 +3127,74 @@ const SHELL_FIXED_POSITION_POLICY = Object.freeze([ */ export function findShellFixedPositionViolations(cssSource, filename) { const violations = []; + const byKey = new Map(); // fingerprint -> decl[] for (const decl of scanFixedPositionDeclarations(cssSource)) { - const approved = SHELL_FIXED_POSITION_POLICY.some( - (p) => p.selector === decl.selector && p.atRule === decl.atRule, - ); - if (approved) 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) { + const found = new Set(scanFixedPositionDeclarations(cssSource).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, decl.pos, - `position: fixed on selector "${decl.selector}"${decl.atRule ? ` inside ${decl.atRule}` : ''} is not on the ` - + 'approved #592 fixed-position snapshot — use shell/docked composition where appropriate, or deliberately ' - + 'extend the reviewed fixed-position snapshot for a legitimate overlay', + '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 index 3f395236..c375bdd9 100644 --- a/tests/unit/resize-handle-thickness-contract.test.js +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -69,17 +69,26 @@ function flatCssRules(cssSource) { })); } -/** Every `width: px` value declared by a rule whose selector list - * contains BOTH `.col-resize` AND `.inspector-resize` together (order- - * independent; additional selectors in the same group, e.g. `.row-resize`, - * are allowed) — i.e. the rule that governs both classes' shared width, not - * just any rule that happens to mention either class alone. Zero, one, or - * many, across however many matching rule groups exist: the caller decides - * what count is valid. */ +/** Every `width: 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) OR 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). Order-independent; additional selectors in the same + * group, e.g. `.row-resize`, are allowed. Zero, one, or many, across however + * many matching rule groups exist: the caller decides what count is valid — + * and the contract below requires EXACTLY one, so ANY standalone or + * media-scoped override of either class's `width` 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. */ function extractSharedResizeWidthPx(cssSource) { const values = []; for (const rule of flatCssRules(cssSource)) { - if (!rule.selectors.includes('.col-resize') || !rule.selectors.includes('.inspector-resize')) continue; + if (!rule.selectors.includes('.col-resize') && !rule.selectors.includes('.inspector-resize')) continue; for (const m of rule.body.matchAll(/\bwidth\s*:\s*(-?\d+(?:\.\d+)?)px\s*;/g)) values.push(Number(m[1])); } return values; @@ -134,9 +143,15 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ }); 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: [] }); + expect(status).toMatchObject({ ok: false, reason: 'css-ambiguous', cssValues: [7, 7] }); }); it('the JS constant is missing entirely: fails', () => { @@ -162,6 +177,37 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ 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] }); + }); + 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([]); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index 4ac59b94..4fee528b 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -27,6 +27,7 @@ import { dirname, join } from 'node:path'; import { findShellGuardrailSourceContractViolations, findShellFixedPositionViolations, + findShellFixedPositionMissingBaselineViolations, scanFixedPositionDeclarations, } from '../../build/lib/check-legacy-owners.mjs'; import type { @@ -226,6 +227,60 @@ describe('#592 shell-body-mount: sabotage (each must fail)', () => { }); }); +// ── 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); + }); +}); + // ── Capture-Escape positive cases ─────────────────────────────────────────── function captureEscapeRulesFor(filename: string, scopePath: readonly string[], body: string): string[] { @@ -413,6 +468,66 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { }); }); +// ── 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']); + }); +}); + +// ── 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); + }); +}); + // ── Fixed-position positive cases ─────────────────────────────────────────── describe('#592 shell-fixed-position: positive characterization', () => { @@ -494,6 +609,58 @@ describe('#592 shell-fixed-position: sabotage (each must fail)', () => { 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); + }); +}); + +// ── 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); + }); }); // ── Diagnostic tests ───────────────────────────────────────────────────────── From ad4f71f74d92a8a6cfc216539649d52eb5961022 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 10:23:25 +0200 Subject: [PATCH 3/8] fix(#592): address review pass 2 findings Fix four ChatGPT PR-review findings on the #592 shell guardrails: - scopeOwnerOf/innermostScopeNode collapsed same-function block-local let/const shadows (if/loop/bare-block bindings) into one flat per-function bucket, letting a block-local shadow of a Document/Window alias, capture-options alias, or named handler silently hide (or be hidden by) a real occurrence elsewhere in the same function. Added innermostLexicalScopeNode/isBlockScopeNode for real block-scoped resolution in scopeOwnerOf (used by every alias/handler/capture table), while innermostScopeNode stays function-only for the SurfaceLifecycle-composition and scope-PATH callers that need it. bodyMountCandidates' own bodyAliasNames Set was also file-global and is now a scoped scope->Map resolved the same way. - resolveObjectCaptureLiteral only recognized an identifier-named `capture` PropertyAssignment, so `{'capture': true}`, `{['capture']: true}`, and shorthand `{ capture }` fell through to the no-key branch and resolved provably false instead of failing closed. Added staticPropertyKeyName to resolve string/computed-string-literal/ shorthand keys and recurse through resolveCaptureFlag for the shorthand value reference; any other unresolvable key now fails closed to null. - The CSS fixed-position scanner recorded only the nearest enclosing at-rule, so wrapping an already-approved rule in an additional outer at-rule produced the identical fingerprint. scanFixedPositionDeclarations now records the full chain of enclosing at-rules, outermost first, joined with ' > '. Tests added for nested block-local shadowing (body-mount and capture-escape, both directions), the three capture-key shapes, and the nested-at-rule-chain fingerprint (forward + missing-baseline). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/lib/check-legacy-owners.d.mts | 11 +- build/lib/check-legacy-owners.mjs | 211 +++++++++++++++++------ tests/unit/shell-guardrails-arch.test.ts | 178 +++++++++++++++++++ 3 files changed, 347 insertions(+), 53 deletions(-) diff --git a/build/lib/check-legacy-owners.d.mts b/build/lib/check-legacy-owners.d.mts index 9b32a357..d0738dc3 100644 --- a/build/lib/check-legacy-owners.d.mts +++ b/build/lib/check-legacy-owners.d.mts @@ -129,10 +129,13 @@ export function findShellGuardrailSourceContractViolations( /** 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 nearest enclosing at-rule's normalized prelude (e.g. - * `'@media (max-width: 768px)'`), or `null` when the declaration sits at the - * stylesheet's top level; `pos` is the declaration's own offset into the - * scanned CSS text (the first non-whitespace, non-comment character). */ + * is the FULL chain of enclosing at-rules' 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 the declaration sits at the + * stylesheet's top level with no enclosing at-rule at all; `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; diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index ae4ea6a0..f461e851 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2049,7 +2049,12 @@ function enclosingScopePath(node) { * 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). */ + * 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", never a narrower nested + * block, so this stays a distinct function from `innermostLexicalScopeNode` + * below rather than being generalized in place. */ function innermostScopeNode(node) { let current = node.parent; while (current) { @@ -2059,6 +2064,45 @@ function innermostScopeNode(node) { return null; } +/** True for a real lexical block-scope boundary — every bare `Block` + * (a function body, an `if`/`else` arm, a loop body, a `try`/`catch`/ + * `finally` block, or a standalone `{ }`) or `CaseBlock` (a `switch`'s + * whole clause list — matching real JS: every `case` in ONE switch shares a + * SINGLE lexical scope, there is no separate scope per `case`). Broader + * than `collectOrderingScopes`'s narrower ordering-only list (which + * deliberately omits `else`/`do`/`try`/a bare standalone block to match the + * retired textual opener) — this models real `let`/`const` shadowing + * wherever it actually occurs, not just the constructs one old regex + * happened to recognize. */ +function isBlockScopeNode(node) { + return node.kind === SyntaxKind.Block || node.kind === SyntaxKind.CaseBlock; +} + +/** The nearest enclosing LEXICAL scope boundary for `node` — a + * `FUNCTION_LIKE_KINDS` ancestor OR a bare block-scope node + * (`isBlockScopeNode`), whichever is nearer. This is the fix for the #592 + * review-pass-2 finding one level finer than #672 P1's own same-FUNCTION + * shadowing fix: a block-local `let`/`const` (`if (c) { const doc: Window = + * …; … } doc.body.appendChild(...)`) must shadow an outer function-scoped + * binding of the same name ONLY inside that block, exactly like real JS/TS + * lexical scoping — never collapse into the one flat per-FUNCTION bucket + * `innermostScopeNode` deliberately keeps for its own two callers (see that + * function's own doc comment on why they need the coarser granularity). + * `null` when `node` sits at module top level, same contract as + * `innermostScopeNode`. Used ONLY by `scopeOwnerOf`/`scopeChain` — every + * alias/handler/capture table keyed through those two (`buildGlobalAliasMap`, + * `buildFunctionDeclMap`, `buildCaptureAliasMap`, and `bodyMountCandidates`'s + * own `bodyAliasMap`) inherits real block-scoping from this one change. */ +function innermostLexicalScopeNode(node) { + let current = node.parent; + while (current) { + if (FUNCTION_LIKE_KINDS.has(current.kind)) return current; + if (isBlockScopeNode(current)) 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 @@ -2132,15 +2176,20 @@ function hasCallNamed(scopeNode, name) { } /** The scope that directly owns a BINDING introduced at `node`, or that a - * REFERENCE at `node` resolves outward from: the nearest enclosing - * `FUNCTION_LIKE_KINDS` ancestor (`innermostScopeNode`), or `sourceFile` - * itself when `node` sits at module top level. Unlike `innermostScopeNode` - * (whose only existing caller wants `null` to mean "no enclosing scope at - * all"), this always returns a real, stable map key, so every scoped alias/ - * handler/capture table below has one uniform module-scope sentinel instead - * of a null special case. */ + * REFERENCE at `node` resolves outward from: the nearest enclosing LEXICAL + * scope boundary (`innermostLexicalScopeNode` — a `FUNCTION_LIKE_KINDS` + * ancestor OR a bare block), or `sourceFile` itself when `node` sits at + * module top level. Deliberately `innermostLexicalScopeNode`, never the + * function-only `innermostScopeNode` — every alias/handler/capture table + * keyed through this function needs real block-scoped shadowing (the #592 + * review-pass-2 fix), while `innermostScopeNode`'s own two callers + * (`bodyMountCandidates`'s `scopeNode`, and the scope-PATH helpers) still + * need the coarser function granularity and call it directly instead. + * Always returns a real, stable map key (never `null`), so every scoped + * alias/handler/capture table below has one uniform module-scope sentinel + * instead of a null special case. */ function scopeOwnerOf(node, sourceFile) { - return innermostScopeNode(node) ?? sourceFile; + return innermostLexicalScopeNode(node) ?? sourceFile; } /** The full lexical scope chain for `node`, innermost first, ending at @@ -2415,31 +2464,73 @@ function resolveHandlerNode(handlerArg, funcDeclMap, sourceFile) { 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 — a `SpreadAssignment` - * anywhere in the object (its full shape can't be proven), or an explicit - * `capture` property whose value isn't a plain boolean literal. An object - * literal with NO explicit `capture` key and no spread is provably `false` - * (the DOM default), matching `addEventListener`'s own spec default. */ -function resolveObjectCaptureLiteral(node) { + * anywhere in the object (its full shape can't be proven), an explicit + * `capture` member (however its key is spelled — plain identifier, string/ + * computed-string-literal key, or `ShorthandPropertyAssignment` shorthand) + * whose VALUE isn't provably boolean (resolved recursively through + * `resolveCaptureFlag`, so a shorthand `{ capture }` reusing an in-scope + * boolean alias resolves exactly like `{ capture: someAlias }` would), or a + * `capture` key that exists only as a method/accessor (never a plain + * boolean value). An object literal with NO explicit `capture` key at all + * (every member's own static name resolves and none of them is `capture`) + * and no spread is provably `false` (the DOM default), matching + * `addEventListener`'s own spec default. #592 review pass 2: the prior + * implementation only ever recognized a plain-identifier-keyed + * `PropertyAssignment`, so a string-literal key (`{'capture': true}`), a + * computed string-literal key (`{['capture']: true}`), or shorthand + * (`{ capture }`) fell through to the "no capture key" branch and resolved + * `false` — provably non-capture — even though each is a REAL `capture` + * member. Any member whose own static key name is unresolvable + * (`staticPropertyKeyName` returns `undefined`) now also fails closed to + * `null`, since it might be the very `capture` key being looked for. + * + * @param {object} node + * @param {Map>} captureAliasMap + * @param {object} sourceFile + * @returns {boolean | null} + */ +function resolveObjectCaptureLiteral(node, captureAliasMap, sourceFile) { let hasSpread = false; - let captureProp = null; + let captureValueNode = null; + let hasUnresolvableCaptureKey = false; for (const p of node.properties) { if (p.kind === SyntaxKind.SpreadAssignment) { hasSpread = true; continue; } - if ( - p.kind === SyntaxKind.PropertyAssignment && p.name && p.name.kind === SyntaxKind.Identifier - && p.name.text === 'capture' - ) { - captureProp = p; - } - } - if (captureProp) { - const v = unwrapCastWrappers(captureProp.initializer); - if (v && v.kind === SyntaxKind.TrueKeyword) return true; - if (v && v.kind === SyntaxKind.FalseKeyword) return false; - return null; + const keyName = staticPropertyKeyName(p); + if (keyName === undefined) { hasUnresolvableCaptureKey = true; continue; } + if (keyName !== 'capture') continue; + if (p.kind === SyntaxKind.PropertyAssignment) captureValueNode = p.initializer; + else if (p.kind === SyntaxKind.ShorthandPropertyAssignment) captureValueNode = p.name; + else hasUnresolvableCaptureKey = true; // a method/get/set named `capture` — never a plain boolean } - return hasSpread ? null : false; + if (captureValueNode) return resolveCaptureFlag(captureValueNode, captureAliasMap, sourceFile); + return (hasSpread || hasUnresolvableCaptureKey) ? null : false; } /** Every `scope -> name -> true|false|null` binding of a `const name = true` @@ -2469,7 +2560,7 @@ function buildCaptureAliasMap(sourceFile) { if (init.kind === SyntaxKind.TrueKeyword) setAlias(scope, node.name.text, true); else if (init.kind === SyntaxKind.FalseKeyword) setAlias(scope, node.name.text, false); else if (init.kind === SyntaxKind.ObjectLiteralExpression) { - setAlias(scope, node.name.text, resolveObjectCaptureLiteral(init)); + setAlias(scope, node.name.text, resolveObjectCaptureLiteral(init, map, sourceFile)); } } }); @@ -2496,7 +2587,7 @@ function resolveCaptureFlag(node, captureAliasMap, sourceFile) { 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); + if (expr.kind === SyntaxKind.ObjectLiteralExpression) return resolveObjectCaptureLiteral(expr, captureAliasMap, sourceFile); if (expr.kind === SyntaxKind.Identifier) { const found = lookupInScopeChain(captureAliasMap, expr, sourceFile); return found === undefined ? null : found; @@ -2610,28 +2701,40 @@ const SHELL_BODY_MOUNT_POLICY = Object.freeze([ * childDoc.body; body.appendChild(...)`), and a further simple alias of that * body binding. 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). + * check (the repo's own recorded recurring failure mode). The body-alias + * table (`bodyAliasMap`) is keyed `scope -> Map` and resolved + * through `lookupInScopeChain`, exactly like `buildGlobalAliasMap`/ + * `buildFunctionDeclMap`/`buildCaptureAliasMap` — #592 review pass 2: this + * used to be one flat file-wide `Set`, so a block-local `const body + * = …` unrelated to Document.body could still satisfy (or a block-local + * shadow could still starve) a lookup anywhere else in the file. * * @param {object} sourceFile * @returns {{node: object, api: 'appendChild'|'append', scopePath: string[], scopeNode: object|null, pos: number}[]} */ function bodyMountCandidates(sourceFile) { const aliasMap = buildGlobalAliasMap(sourceFile); - const bodyAliasNames = new Set(); + const bodyAliasMap = new Map(); // scope -> Map + const setBodyAlias = (scope, name) => { + let local = bodyAliasMap.get(scope); + if (!local) { local = new Map(); bodyAliasMap.set(scope, local); } + local.set(name, true); + }; walkTree(sourceFile, (node) => { if ( node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier && node.initializer ) { const init = unwrapCastWrappers(node.initializer); + const scope = scopeOwnerOf(node, sourceFile); if ( init && init.kind === SyntaxKind.PropertyAccessExpression && init.name.text === 'body' && resolveGlobalKind(init.expression, aliasMap, sourceFile) === 'document' ) { - bodyAliasNames.add(node.name.text); + setBodyAlias(scope, node.name.text); } - if (init && init.kind === SyntaxKind.Identifier && bodyAliasNames.has(init.text)) { - bodyAliasNames.add(node.name.text); + if (init && init.kind === SyntaxKind.Identifier && lookupInScopeChain(bodyAliasMap, init, sourceFile) !== undefined) { + setBodyAlias(scope, node.name.text); } } }); @@ -2656,7 +2759,7 @@ function bodyMountCandidates(sourceFile) { const recv = unwrapCastWrappers(receiver); if (!recv) return; let isBody = false; - if (recv.kind === SyntaxKind.Identifier && bodyAliasNames.has(recv.text)) { + if (recv.kind === SyntaxKind.Identifier && lookupInScopeChain(bodyAliasMap, recv, sourceFile) !== undefined) { isBody = true; } else if (recv.kind === SyntaxKind.PropertyAccessExpression && recv.name.text === 'body' && resolveGlobalKind(recv.expression, aliasMap, sourceFile) === 'document') { @@ -2934,8 +3037,8 @@ export function findShellGuardrailSourceContractViolations(sources) { // 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 nearest enclosing at-rule, never a -// sibling's), and normalizes whitespace/comma-selector-lists deterministically +// 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. @@ -2981,15 +3084,23 @@ function firstMeaningfulCssOffset(source, from) { * 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 - * nearest enclosing at-rule's normalized prelude (`normalizeCssText`, or - * `null` when the declaration sits at the stylesheet's top level with no - * enclosing at-rule — e.g. NOT inside `@media`). 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). + * 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). * * @param {string} source * @returns {{selector: string, atRule: string | null, pos: number}[]} @@ -3024,10 +3135,12 @@ export function scanFixedPositionDeclarations(source) { if (!/^fixed(\s*!\s*important)?$/i.test(normalizeCssText(value))) return; const innermost = frames[frames.length - 1]; if (!innermost || innermost.kind !== 'rule') return; // no selector context — out of this rule's scope - let atRule = null; + const atChain = []; for (let k = frames.length - 2; k >= 0; k--) { - if (frames[k].kind === 'at') { atRule = frames[k].prelude; break; } + if (frames[k].kind === 'at') atChain.push(frames[k].prelude); } + atChain.reverse(); // outermost first + const atRule = atChain.length ? atChain.join(' > ') : null; results.push({ selector: innermost.prelude, atRule, pos: firstMeaningfulCssOffset(source, segStart) }); } diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index 4fee528b..e0308dd6 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -281,6 +281,65 @@ describe('#592 shell-body-mount: same-file scope-shadowing sabotage (each must f }); }); +// ── 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([]); + }); +}); + // ── Capture-Escape positive cases ─────────────────────────────────────────── function captureEscapeRulesFor(filename: string, scopePath: readonly string[], body: string): string[] { @@ -334,6 +393,15 @@ describe('#592 shell-capture-escape: positive characterization (sanctioned curre 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([]); + }); + it('comments/strings containing listener lookalikes stay clean', () => { const found = captureEscapeRulesFor('src/ui/_lookalike.ts', ['openLookalike'], [ "// document.addEventListener('keydown', onKey, true);", @@ -381,6 +449,29 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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);"); @@ -528,6 +619,49 @@ describe('#592 shell-capture-escape: same-file scope-shadowing sabotage (each mu }); }); +// ── 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); + }); +}); + // ── Fixed-position positive cases ─────────────────────────────────────────── describe('#592 shell-fixed-position: positive characterization', () => { @@ -575,6 +709,17 @@ describe('#592 shell-fixed-position: positive characterization', () => { 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)'); + }); }); // ── Fixed-position sabotage cases ─────────────────────────────────────────── @@ -628,6 +773,19 @@ describe('#592 shell-fixed-position: sabotage (each must fail)', () => { 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'); + }); }); // ── Fixed-position missing-baseline-entry sabotage (P1, PR #672 review pass 1) ── @@ -661,6 +819,26 @@ describe('#592 shell-fixed-position: missing-baseline-entry sabotage (each must 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'); + }); }); // ── Diagnostic tests ───────────────────────────────────────────────────────── From e76c8a7de3078ba9f066fe589dc29401f9d0df58 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 11:02:01 +0200 Subject: [PATCH 4/8] fix(#592): address review pass 3 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Made every #592 scope-resolution table (buildGlobalAliasMap, the handler- alias table, buildCaptureAliasMap, and bodyMountCandidates' body-alias table) declaration-kind-aware via new declarationScopeOwnerOf/ varDeclarationScope helpers: a `var` bound inside a nested block (or a for-loop header) is now correctly function-scoped instead of vanishing from analysis outside that block, and a `let`/`const` bound in a for/for-in/for-of HEADER now gets its own loop-construct scope (isBlockScopeNode now recognizes those three statement kinds) instead of clobbering a same-named outer binding in the same enclosing scope map. - resolveObjectCaptureLiteral now respects real object-literal property evaluation order: it tracks only the LAST capture-affecting event across node.properties, so a spread or unresolvable key that comes AFTER an explicit `capture` property correctly makes the result unresolvable (`{ capture: false, ...{ capture: true } }` no longer resolves to the earlier `false` and silently escapes the capture-Escape guard). - bodyMountCandidates now also recognizes a destructuring alias of Document.body (`const { body } = document`, and the renamed `const { body: host } = document`) as a direct body mount, matching the existing plain-identifier alias handling. - scanFixedPositionDeclarations now decodes real CSS identifier escapes (decodeCssEscapes) before comparing property/value text, so spec-legal escaped spellings like `\70osition: fixed;` or `position: \66ixed;` are recognized exactly like the literal `position: fixed` they decode to. - resize-handle-thickness-contract.test.js's extractSharedResizeWidthPx now recognizes the .col-resize/.inspector-resize classes inside compound (`.inspector-resize.dragging`) and descendant (`.shell .inspector-resize`) selectors, not just an exact selector-list membership match — while still excluding pseudo-element selectors (`::before`/`::after`), which style an unrelated generated box. - Added sabotage/positive fixtures for every case above across shell-guardrails-arch.test.ts and resize-handle-thickness-contract.test.js. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/lib/check-legacy-owners.mjs | 299 +++++++++++++----- .../resize-handle-thickness-contract.test.js | 116 ++++++- tests/unit/shell-guardrails-arch.test.ts | 166 ++++++++++ 3 files changed, 494 insertions(+), 87 deletions(-) diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index f461e851..904db351 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2066,16 +2066,25 @@ function innermostScopeNode(node) { /** True for a real lexical block-scope boundary — every bare `Block` * (a function body, an `if`/`else` arm, a loop body, a `try`/`catch`/ - * `finally` block, or a standalone `{ }`) or `CaseBlock` (a `switch`'s + * `finally` block, or a standalone `{ }`), `CaseBlock` (a `switch`'s * whole clause list — matching real JS: every `case` in ONE switch shares a - * SINGLE lexical scope, there is no separate scope per `case`). Broader - * than `collectOrderingScopes`'s narrower ordering-only list (which - * deliberately omits `else`/`do`/`try`/a bare standalone block to match the - * retired textual opener) — this models real `let`/`const` shadowing - * wherever it actually occurs, not just the constructs one old regex - * happened to recognize. */ + * SINGLE lexical scope, there is no separate scope per `case`), or a + * `for`/`for-in`/`for-of` STATEMENT itself (#592 review pass 3: a `let`/ + * `const` bound in a loop HEADER — `for (let i = 0; …)` — is scoped to the + * whole loop construct, including its body, but its declaration node sits + * in the header, one level ABOVE the loop's own body `Block` — so without + * the loop statement being its own boundary here, that header binding would + * resolve to whatever scope encloses the ENTIRE loop, colliding with a + * same-named outer binding exactly like the fixed #592 review-pass-2 block- + * shadowing bug, just one level up). Broader than `collectOrderingScopes`'s + * narrower ordering-only list (which deliberately omits `else`/`do`/`try`/a + * bare standalone block to match the retired textual opener) — this models + * real `let`/`const` shadowing wherever it actually occurs, not just the + * constructs one old regex happened to recognize. */ function isBlockScopeNode(node) { - return node.kind === SyntaxKind.Block || node.kind === SyntaxKind.CaseBlock; + return node.kind === SyntaxKind.Block || node.kind === SyntaxKind.CaseBlock + || node.kind === SyntaxKind.ForStatement || node.kind === SyntaxKind.ForInStatement + || node.kind === SyntaxKind.ForOfStatement; } /** The nearest enclosing LEXICAL scope boundary for `node` — a @@ -2192,6 +2201,63 @@ function scopeOwnerOf(node, sourceFile) { return innermostLexicalScopeNode(node) ?? sourceFile; } +/** The scope that owns a `var` BINDING at `node` — real JS `var` hoisting is + * FUNCTION-scoped (module-scoped at top level), never block- or loop- + * scoped, so a `var` bound inside a nested `if`/loop/bare block, or inside a + * `for`/`for-in`/`for-of` HEADER, is still owned by the nearest enclosing + * `FUNCTION_LIKE_KINDS` node — never the block or loop it happens to sit + * inside. Deliberately built on `innermostScopeNode` (the function-only + * walk), never `innermostLexicalScopeNode` — the same "coarser granularity" + * contract `innermostScopeNode`'s own doc comment already describes for its + * other two callers now applies here too. */ +function varDeclarationScope(node, sourceFile) { + return innermostScopeNode(node) ?? sourceFile; +} + +/** The nearest `VariableDeclarationList` ancestor that actually OWNS the + * binding at `node` (a `VariableDeclaration`, or a `BindingElement` nested + * inside one's destructuring pattern) — stopping the walk at the first + * `FUNCTION_LIKE_KINDS` ancestor encountered FIRST, which means a + * `Parameter`'s own destructuring pattern (which has no + * `VariableDeclarationList` of its own at all — a parameter is never + * `var`/`let`/`const`) correctly returns `null` rather than a wrong, unrelated + * outer declaration list several scopes up the real function-nesting chain. */ +function owningDeclarationList(node) { + let current = node.parent; + while (current) { + if (current.kind === SyntaxKind.VariableDeclarationList) return current; + if (FUNCTION_LIKE_KINDS.has(current.kind)) return null; + current = current.parent; + } + return null; +} + +/** True when `declarationList` (a real `VariableDeclarationList`) is a `var` + * list — neither `NodeFlags.Let` nor `NodeFlags.Const` set, the same + * flag-based test `nonConstDeclarationKeyword` already uses for the + * unrelated surface-protected-declaration rule above. */ +function isVarDeclarationList(declarationList) { + return (declarationList.flags & (NodeFlags.Let | NodeFlags.Const)) === 0; +} + +/** Declaration-KIND-aware `scopeOwnerOf`, for a real BINDING node + * (a `VariableDeclaration`, or a `BindingElement` nested inside one) — + * the #592 review-pass-3 fix: every alias/handler/capture/body-alias table + * below that registers a `var`/`let`/`const` binding must use THIS, never + * plain `scopeOwnerOf`, so a `var` gets real function-scoping + * (`varDeclarationScope`) instead of being silently treated as block- or + * loop-scoped like a `let`/`const` would be. A binding with no owning + * `VariableDeclarationList` at all (`owningDeclarationList` returns `null` + * — a `Parameter`'s own destructuring) falls through to plain + * `scopeOwnerOf`, which already gives every `Parameter` the right answer + * (the function itself — nothing block- or loop-scoped ever sits between a + * parameter and its own function). */ +function declarationScopeOwnerOf(node, sourceFile) { + const list = owningDeclarationList(node); + if (list && isVarDeclarationList(list)) return varDeclarationScope(node, sourceFile); + return scopeOwnerOf(node, sourceFile); +} + /** The full lexical scope chain for `node`, innermost first, ending at * `sourceFile` (module scope) — every scope a name reference at `node` can * actually resolve through, mirroring real JS/TS function-scope shadowing. @@ -2328,18 +2394,25 @@ const MOUNT_CTX_TYPE_NAME = 'MountCtx'; * rename whose `propertyName` is `document`/`window` (`const { document: doc * } = opts` — `menu.ts`'s real shape), and every `VariableDeclaration` whose * INITIALIZER resolves via `resolveGlobalKind` against the map built so far, - * each recorded under its OWN declaring scope (`scopeOwnerOf`) rather than - * one flat file-wide key. A single forward walk over the whole file still - * suffices for every real occurrence in this codebase (parameters are - * visited before the statements that reference them by `forEachChild`'s own - * declaration order, and no alias here is ever referenced before its own - * declaration) — this is a bounded architecture-guard heuristic, not a - * general dataflow engine; see this module's own header comment on - * accepted-risk scope. Per-scope keying is what makes that heuristic sound - * under same-file shadowing: a later sibling `doc: Window` in an unrelated - * function no longer overwrites an earlier `doc: Document` bound in a - * different scope (the reviewed #672 P1 — same bare name, different scopes, - * used to collapse to one file-wide last-write-wins entry). + * each `BindingElement`/`VariableDeclaration` recorded under its OWN + * declaration-KIND-aware declaring scope (`declarationScopeOwnerOf` — #592 + * review pass 3: `var` is function-scoped, `let`/`const` including a for- + * loop-header binding is genuinely lexically scoped; a `Parameter` has no + * declaration-list at all and keeps plain `scopeOwnerOf`, which already + * gives it the right, function-level answer) rather than one flat file-wide + * key. A single forward walk over the whole file still suffices for every + * real occurrence in this codebase (parameters are visited before the + * statements that reference them by `forEachChild`'s own declaration order, + * and no alias here is ever referenced before its own declaration) — this is + * a bounded architecture-guard heuristic, not a general dataflow engine; see + * this module's own header comment on accepted-risk scope. Per-scope keying + * is what makes that heuristic sound under same-file shadowing: a later + * sibling `doc: Window` in an unrelated function no longer overwrites an + * earlier `doc: Document` bound in a different scope (the reviewed #672 P1 + * — same bare name, different scopes, used to collapse to one file-wide + * last-write-wins entry), and a loop-header `let doc = window` no longer + * clobbers an outer `const doc: Document` bound in the SAME enclosing + * function/block (the #592 review-pass-3 fix). * * @param {object} sourceFile * @returns {Map>} @@ -2376,7 +2449,7 @@ function buildGlobalAliasMap(sourceFile) { node.kind === SyntaxKind.BindingElement && node.propertyName && node.propertyName.kind === SyntaxKind.Identifier && node.name.kind === SyntaxKind.Identifier ) { - const scope = scopeOwnerOf(node, sourceFile); + const scope = declarationScopeOwnerOf(node, sourceFile); if (node.propertyName.text === 'document') setAlias(scope, node.name.text, 'document'); else if (node.propertyName.text === 'window') setAlias(scope, node.name.text, 'window'); } @@ -2388,17 +2461,21 @@ function buildGlobalAliasMap(sourceFile) { else if (names.includes('Window')) kind = 'window'; } if (!kind && node.initializer) kind = resolveGlobalKind(node.initializer, aliasMap, sourceFile); - if (kind) setAlias(scopeOwnerOf(node, sourceFile), node.name.text, kind); + if (kind) setAlias(declarationScopeOwnerOf(node, sourceFile), node.name.text, kind); } }); return aliasMap; } /** Every `scope -> name -> [{node, pos}]` binding of a `FunctionDeclaration` - * or a `const name = (…) => {}` / `const name = function (…) {}` in - * `sourceFile`, keyed by the declaration's OWN declaring scope - * (`scopeOwnerOf`) rather than one flat file-wide name — used to resolve a - * plain-identifier `addEventListener` handler argument (`doc. + * (always block-scoped like `let` in this module's strict-mode ES-module + * source, so plain `scopeOwnerOf` is already correct for it) or a + * `const`/`let`/`var name = (…) => {}` / `… = function (…) {}` (declaration- + * KIND-aware `declarationScopeOwnerOf` — #592 review pass 3, same `var`-is- + * function-scoped fix as `buildGlobalAliasMap`'s) in `sourceFile`, keyed by + * the declaration's OWN declaring scope rather than one flat file-wide + * name — used to resolve a plain-identifier `addEventListener` handler + * argument (`doc. * addEventListener('keydown', onKey, true)`) back to the function it names * through `resolveHandlerNode`'s lexical scope-chain lookup, never through a * same-named declaration in an unrelated sibling or nested-below scope (the @@ -2424,7 +2501,7 @@ function buildFunctionDeclMap(sourceFile) { ) { const init = unwrapCastWrappers(node.initializer); if (init && (init.kind === SyntaxKind.ArrowFunction || init.kind === SyntaxKind.FunctionExpression)) { - add(scopeOwnerOf(node, sourceFile), node.name.text, init); + add(declarationScopeOwnerOf(node, sourceFile), node.name.text, init); } } }); @@ -2490,26 +2567,35 @@ function staticPropertyKeyName(member) { } /** Resolve an `addEventListener` OPTIONS object literal's own `capture` - * member to `true`/`false`, or `null` when unresolvable — a `SpreadAssignment` - * anywhere in the object (its full shape can't be proven), an explicit - * `capture` member (however its key is spelled — plain identifier, string/ - * computed-string-literal key, or `ShorthandPropertyAssignment` shorthand) - * whose VALUE isn't provably boolean (resolved recursively through - * `resolveCaptureFlag`, so a shorthand `{ capture }` reusing an in-scope - * boolean alias resolves exactly like `{ capture: someAlias }` would), or a - * `capture` key that exists only as a method/accessor (never a plain - * boolean value). An object literal with NO explicit `capture` key at all - * (every member's own static name resolves and none of them is `capture`) - * and no spread is provably `false` (the DOM default), matching - * `addEventListener`'s own spec default. #592 review pass 2: the prior - * implementation only ever recognized a plain-identifier-keyed - * `PropertyAssignment`, so a string-literal key (`{'capture': true}`), a - * computed string-literal key (`{['capture']: true}`), or shorthand - * (`{ capture }`) fell through to the "no capture key" branch and resolved - * `false` — provably non-capture — even though each is a REAL `capture` - * member. Any member whose own static key name is unresolvable - * (`staticPropertyKeyName` returns `undefined`) now also fails closed to - * `null`, since it might be the very `capture` key being looked for. + * 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. * * @param {object} node * @param {Map>} captureAliasMap @@ -2517,31 +2603,32 @@ function staticPropertyKeyName(member) { * @returns {boolean | null} */ function resolveObjectCaptureLiteral(node, captureAliasMap, sourceFile) { - let hasSpread = false; - let captureValueNode = null; - let hasUnresolvableCaptureKey = false; + 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) { hasSpread = true; continue; } + if (p.kind === SyntaxKind.SpreadAssignment) { lastEventKnown = false; continue; } const keyName = staticPropertyKeyName(p); - if (keyName === undefined) { hasUnresolvableCaptureKey = true; continue; } + if (keyName === undefined) { lastEventKnown = false; continue; } if (keyName !== 'capture') continue; - if (p.kind === SyntaxKind.PropertyAssignment) captureValueNode = p.initializer; - else if (p.kind === SyntaxKind.ShorthandPropertyAssignment) captureValueNode = p.name; - else hasUnresolvableCaptureKey = true; // a method/get/set named `capture` — never a plain boolean + 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 (captureValueNode) return resolveCaptureFlag(captureValueNode, captureAliasMap, sourceFile); - return (hasSpread || hasUnresolvableCaptureKey) ? null : false; + if (lastEventKnown === true) return resolveCaptureFlag(lastKnownValueNode, captureAliasMap, sourceFile); + return lastEventKnown === false ? null : false; } -/** Every `scope -> name -> true|false|null` binding of a `const name = true` - * / `const name = false` / `const name = { capture: … }` (via +/** Every `scope -> name -> true|false|null` binding of a `const`/`let`/`var + * name = true` / `= false` / `= { capture: … }` (via * `resolveObjectCaptureLiteral`) in `sourceFile`, keyed by the declaration's - * own declaring scope (`scopeOwnerOf`) — backs the plan's "simple local - * const aliases of either form" requirement for the THIRD - * `addEventListener` argument, resolved through `resolveCaptureFlag`'s - * lexical scope-chain lookup so a same-named alias in an unrelated sibling - * scope (the reviewed #672 P1 capture-alias-overwrite case) can never - * satisfy a different scope's lookup. */ + * own declaration-KIND-aware declaring scope (`declarationScopeOwnerOf` — + * #592 review pass 3) — backs the plan's "simple local const aliases of + * either form" requirement for the THIRD `addEventListener` argument, + * resolved through `resolveCaptureFlag`'s lexical scope-chain lookup so a + * same-named alias in an unrelated sibling scope (the reviewed #672 P1 + * capture-alias-overwrite case), OR a same-named `var`/for-header `let` + * binding that would otherwise land in the wrong scope bucket (the #592 + * review-pass-3 fix), can never satisfy a different scope's lookup. */ function buildCaptureAliasMap(sourceFile) { const map = new Map(); // scope -> Map const setAlias = (scope, name, value) => { @@ -2556,7 +2643,7 @@ function buildCaptureAliasMap(sourceFile) { ) { const init = unwrapCastWrappers(node.initializer); if (!init) return; - const scope = scopeOwnerOf(node, sourceFile); + const scope = declarationScopeOwnerOf(node, sourceFile); if (init.kind === SyntaxKind.TrueKeyword) setAlias(scope, node.name.text, true); else if (init.kind === SyntaxKind.FalseKeyword) setAlias(scope, node.name.text, false); else if (init.kind === SyntaxKind.ObjectLiteralExpression) { @@ -2698,16 +2785,25 @@ const SHELL_BODY_MOUNT_POLICY = Object.freeze([ * `childDoc.body.appendChild(...)`, `deps.document.body.appendChild(...)`, * `window.document.body.appendChild(...)`, the bracket-property spelling * (`doc['body']['appendChild'](...)`), a propagated body alias (`const body = - * childDoc.body; body.appendChild(...)`), and a further simple alias of that - * body binding. 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). The body-alias - * table (`bodyAliasMap`) is keyed `scope -> Map` and resolved - * through `lookupInScopeChain`, exactly like `buildGlobalAliasMap`/ + * childDoc.body; body.appendChild(...)`), a further simple alias of that + * body binding, and (#592 review pass 3) a destructuring alias of + * `Document.body` — `const { body } = document;` or the renamed + * `const { body: host } = document;` — which is a DIRECT `Document.body` + * mount exactly like the plain-identifier `const body = document.body;` + * form, not a shape this table can afford to leave unrecognized. 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). The body-alias table + * (`bodyAliasMap`) is keyed `scope -> Map` and resolved through + * `lookupInScopeChain`, exactly like `buildGlobalAliasMap`/ * `buildFunctionDeclMap`/`buildCaptureAliasMap` — #592 review pass 2: this * used to be one flat file-wide `Set`, so a block-local `const body * = …` unrelated to Document.body could still satisfy (or a block-local - * shadow could still starve) a lookup anywhere else in the file. + * shadow could still starve) a lookup anywhere else in the file — and #592 + * review pass 3: every registration now goes through the declaration-KIND- + * aware `declarationScopeOwnerOf`, so a `var body = …` alias declared inside + * a nested block is still visible for the rest of its enclosing FUNCTION + * (real `var` hoisting), not just inside that block. * * @param {object} sourceFile * @returns {{node: object, api: 'appendChild'|'append', scopePath: string[], scopeNode: object|null, pos: number}[]} @@ -2726,7 +2822,7 @@ function bodyMountCandidates(sourceFile) { && node.initializer ) { const init = unwrapCastWrappers(node.initializer); - const scope = scopeOwnerOf(node, sourceFile); + const scope = declarationScopeOwnerOf(node, sourceFile); if ( init && init.kind === SyntaxKind.PropertyAccessExpression && init.name.text === 'body' && resolveGlobalKind(init.expression, aliasMap, sourceFile) === 'document' @@ -2737,6 +2833,19 @@ function bodyMountCandidates(sourceFile) { setBodyAlias(scope, node.name.text); } } + if ( + node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.ObjectBindingPattern + && node.initializer && resolveGlobalKind(node.initializer, aliasMap, sourceFile) === 'document' + ) { + const scope = declarationScopeOwnerOf(node, sourceFile); + for (const el of node.name.elements) { + if (el.kind !== SyntaxKind.BindingElement || !el.name || el.name.kind !== SyntaxKind.Identifier) continue; + const propName = el.propertyName && el.propertyName.kind === SyntaxKind.Identifier + ? el.propertyName.text + : el.name.text; + if (propName === 'body') setBodyAlias(scope, el.name.text); + } + } }); const candidates = []; walkTree(sourceFile, (node) => { @@ -3048,6 +3157,34 @@ 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, @@ -3100,7 +3237,12 @@ function firstMeaningfulCssOffset(source, from) { * 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). + * 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. * * @param {string} source * @returns {{selector: string, atRule: string | null, pos: number}[]} @@ -3127,10 +3269,13 @@ export function scanFixedPositionDeclarations(source) { 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 = trimmed.slice(0, colonIdx).trim(); - const value = trimmed.slice(colonIdx + 1).trim(); + 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; const innermost = frames[frames.length - 1]; diff --git a/tests/unit/resize-handle-thickness-contract.test.js b/tests/unit/resize-handle-thickness-contract.test.js index c375bdd9..b4b5a8ad 100644 --- a/tests/unit/resize-handle-thickness-contract.test.js +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -69,26 +69,61 @@ function flatCssRules(cssSource) { })); } +/** 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). */ +function selectorTargetsResizeHandleClass(selector, className) { + if (selector.includes('::')) return false; + return new RegExp(`\\.${className}(?![\\w-])`).test(selector); +} + /** Every `width: 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) OR alone (a more-specific, later- + * 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). Order-independent; additional selectors in the same - * group, e.g. `.row-resize`, are allowed. Zero, one, or many, across however - * many matching rule groups exist: the caller decides what count is valid — - * and the contract below requires EXACTLY one, so ANY standalone or - * media-scoped override of either class's `width` 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. */ + * 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). + * Order-independent; additional selectors in the same group, e.g. + * `.row-resize`, are allowed. Zero, one, or many, across however many + * matching rule groups exist: the caller decides what count is valid — and + * the contract below requires EXACTLY one, so ANY standalone, compound, + * descendant, or media-scoped override of either class's `width` 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. */ function extractSharedResizeWidthPx(cssSource) { const values = []; for (const rule of flatCssRules(cssSource)) { - if (!rule.selectors.includes('.col-resize') && !rule.selectors.includes('.inspector-resize')) continue; + const targets = rule.selectors.some( + (s) => selectorTargetsResizeHandleClass(s, 'col-resize') || selectorTargetsResizeHandleClass(s, 'inspector-resize'), + ); + if (!targets) continue; for (const m of rule.body.matchAll(/\bwidth\s*:\s*(-?\d+(?:\.\d+)?)px\s*;/g)) values.push(Number(m[1])); } return values; @@ -217,4 +252,65 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ 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]); + }); }); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index e0308dd6..c29a16c4 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -340,6 +340,81 @@ describe('#592 shell-body-mount: nested block-local shadowing sabotage (each mus }); }); +// ── 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); + }); +}); + // ── Capture-Escape positive cases ─────────────────────────────────────────── function captureEscapeRulesFor(filename: string, scopePath: readonly string[], body: string): string[] { @@ -402,6 +477,16 @@ describe('#592 shell-capture-escape: positive characterization (sanctioned curre 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);", @@ -449,6 +534,25 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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 @@ -662,6 +766,47 @@ describe('#592 shell-capture-escape: nested block-local shadowing sabotage (each }); }); +// ── 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); + }); +}); + // ── Fixed-position positive cases ─────────────────────────────────────────── describe('#592 shell-fixed-position: positive characterization', () => { @@ -786,6 +931,27 @@ describe('#592 shell-fixed-position: sabotage (each must fail)', () => { 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'); + }); }); // ── Fixed-position missing-baseline-entry sabotage (P1, PR #672 review pass 1) ── From 0c3c6d489132092ffc2cac014e7bfd6f41c8b61c Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 11:58:08 +0200 Subject: [PATCH 5/8] refactor(#592): resolve shell-guardrail identifier bindings via the real TypeScript checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause circuit breaker (per-issue-cycle.md): three formal ChatGPT code-review passes (570e089, ad4f71f, e76c8a7) each found and fixed a real defect in build/lib/check-legacy-owners.mjs's hand-rolled, Map-based scope-resolution layer (scopeOwnerOf/scopeChain/declarationScopeOwnerOf/ buildGlobalAliasMap/buildFunctionDeclMap/buildCaptureAliasMap/a body-alias scope map) backing the shell-body-mount and shell-capture-escape guards — flat file-wide maps (pass 1), same-function block shadowing not modeled (pass 2), 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 underlying Project (already constructed for every batch, previously discarded down to just its SourceFile) exposes a real checker: Checker with genuine binder symbol resolution (checker.getSymbolAtLocation). withParsedSources/withParsedSource now hand that checker to their callback alongside the SourceFile, and every place that answered "what does this identifier resolve to" now resolves it through the checker against the identifier's real declaration instead: - resolveGlobalKind (Document/Window classification) + a new classifyGlobalDeclaration inspect the resolved declaration's own type annotation, covering bare document/window (which resolve to their own lib.dom.d.ts ambient declarations — no bare-identifier special case needed), typed parameters/variables, destructuring renames, and the narrow MountCtx exception. - resolveHandlerNode resolves an addEventListener handler identifier to its real FunctionDeclaration/arrow-or-function-expression-initializer declaration. - resolveCaptureFlag resolves a capture-options identifier through its real VariableDeclaration initializer, including the ShorthandPropertyAssignment special case (getShorthandAssignmentValueSymbol) the checker itself requires. - resolvesToDocumentBody replaces the body-alias map entirely, resolving a receiver on demand instead of pre-walking the whole file. Deleted outright: scopeOwnerOf, scopeChain, declarationScopeOwnerOf, varDeclarationScope, owningDeclarationList, isVarDeclarationList, innermostLexicalScopeNode, isBlockScopeNode, lookupInScopeChain, buildGlobalAliasMap, buildFunctionDeclMap, buildCaptureAliasMap. Candidate discovery (the .appendChild/.append/addEventListener/Escape-comparison AST shapes) and the capture-options evaluator's own real object-literal evaluation-order logic are unchanged; the CSS shell-fixed-position scanner is untouched. No policy fingerprint, diagnostic shape, or public export changed. Added resolver-level tests mirroring the pre-implementation spike (same- function block shadow, for-loop-header shadow, sibling-scope non-pollution, correct reversion after a shadow's scope ends) for both the Document/Window and handler/capture-alias questions. All existing sabotage fixtures from the three prior review-fix commits still pass, now for the real structural reason. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- CHANGELOG.md | 17 +- build/lib/check-legacy-owners.mjs | 841 ++++++++++------------- tests/unit/shell-guardrails-arch.test.ts | 104 +++ 3 files changed, 468 insertions(+), 494 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ffd0ee..730f7086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,22 @@ auto-generated per-PR notes; this file is the curated, human-readable history. (`findShellGuardrailSourceContractViolations`, `build/lib/check-legacy-owners.mjs`), reusing this repo's established `withParsedSources`/`walkTree`/`SyntaxKind` idiom — no new parser - dependency. Also closes the inherited #586/#593-phase-1 finding: an + 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- diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 904db351..95850af8 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)), + ); } /** @@ -1996,6 +2017,33 @@ export function findSurfaceLifecycleSourceContractViolations(sources, { appFile, // 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 @@ -2052,9 +2100,13 @@ function enclosingScopePath(node) { * 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", never a narrower nested - * block, so this stays a distinct function from `innermostLexicalScopeNode` - * below rather than being generalized in place. */ + * 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) { @@ -2064,54 +2116,6 @@ function innermostScopeNode(node) { return null; } -/** True for a real lexical block-scope boundary — every bare `Block` - * (a function body, an `if`/`else` arm, a loop body, a `try`/`catch`/ - * `finally` block, or a standalone `{ }`), `CaseBlock` (a `switch`'s - * whole clause list — matching real JS: every `case` in ONE switch shares a - * SINGLE lexical scope, there is no separate scope per `case`), or a - * `for`/`for-in`/`for-of` STATEMENT itself (#592 review pass 3: a `let`/ - * `const` bound in a loop HEADER — `for (let i = 0; …)` — is scoped to the - * whole loop construct, including its body, but its declaration node sits - * in the header, one level ABOVE the loop's own body `Block` — so without - * the loop statement being its own boundary here, that header binding would - * resolve to whatever scope encloses the ENTIRE loop, colliding with a - * same-named outer binding exactly like the fixed #592 review-pass-2 block- - * shadowing bug, just one level up). Broader than `collectOrderingScopes`'s - * narrower ordering-only list (which deliberately omits `else`/`do`/`try`/a - * bare standalone block to match the retired textual opener) — this models - * real `let`/`const` shadowing wherever it actually occurs, not just the - * constructs one old regex happened to recognize. */ -function isBlockScopeNode(node) { - return node.kind === SyntaxKind.Block || node.kind === SyntaxKind.CaseBlock - || node.kind === SyntaxKind.ForStatement || node.kind === SyntaxKind.ForInStatement - || node.kind === SyntaxKind.ForOfStatement; -} - -/** The nearest enclosing LEXICAL scope boundary for `node` — a - * `FUNCTION_LIKE_KINDS` ancestor OR a bare block-scope node - * (`isBlockScopeNode`), whichever is nearer. This is the fix for the #592 - * review-pass-2 finding one level finer than #672 P1's own same-FUNCTION - * shadowing fix: a block-local `let`/`const` (`if (c) { const doc: Window = - * …; … } doc.body.appendChild(...)`) must shadow an outer function-scoped - * binding of the same name ONLY inside that block, exactly like real JS/TS - * lexical scoping — never collapse into the one flat per-FUNCTION bucket - * `innermostScopeNode` deliberately keeps for its own two callers (see that - * function's own doc comment on why they need the coarser granularity). - * `null` when `node` sits at module top level, same contract as - * `innermostScopeNode`. Used ONLY by `scopeOwnerOf`/`scopeChain` — every - * alias/handler/capture table keyed through those two (`buildGlobalAliasMap`, - * `buildFunctionDeclMap`, `buildCaptureAliasMap`, and `bodyMountCandidates`'s - * own `bodyAliasMap`) inherits real block-scoping from this one change. */ -function innermostLexicalScopeNode(node) { - let current = node.parent; - while (current) { - if (FUNCTION_LIKE_KINDS.has(current.kind)) return current; - if (isBlockScopeNode(current)) 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 @@ -2184,119 +2188,19 @@ function hasCallNamed(scopeNode, name) { return found; } -/** The scope that directly owns a BINDING introduced at `node`, or that a - * REFERENCE at `node` resolves outward from: the nearest enclosing LEXICAL - * scope boundary (`innermostLexicalScopeNode` — a `FUNCTION_LIKE_KINDS` - * ancestor OR a bare block), or `sourceFile` itself when `node` sits at - * module top level. Deliberately `innermostLexicalScopeNode`, never the - * function-only `innermostScopeNode` — every alias/handler/capture table - * keyed through this function needs real block-scoped shadowing (the #592 - * review-pass-2 fix), while `innermostScopeNode`'s own two callers - * (`bodyMountCandidates`'s `scopeNode`, and the scope-PATH helpers) still - * need the coarser function granularity and call it directly instead. - * Always returns a real, stable map key (never `null`), so every scoped - * alias/handler/capture table below has one uniform module-scope sentinel - * instead of a null special case. */ -function scopeOwnerOf(node, sourceFile) { - return innermostLexicalScopeNode(node) ?? sourceFile; -} - -/** The scope that owns a `var` BINDING at `node` — real JS `var` hoisting is - * FUNCTION-scoped (module-scoped at top level), never block- or loop- - * scoped, so a `var` bound inside a nested `if`/loop/bare block, or inside a - * `for`/`for-in`/`for-of` HEADER, is still owned by the nearest enclosing - * `FUNCTION_LIKE_KINDS` node — never the block or loop it happens to sit - * inside. Deliberately built on `innermostScopeNode` (the function-only - * walk), never `innermostLexicalScopeNode` — the same "coarser granularity" - * contract `innermostScopeNode`'s own doc comment already describes for its - * other two callers now applies here too. */ -function varDeclarationScope(node, sourceFile) { - return innermostScopeNode(node) ?? sourceFile; -} - -/** The nearest `VariableDeclarationList` ancestor that actually OWNS the - * binding at `node` (a `VariableDeclaration`, or a `BindingElement` nested - * inside one's destructuring pattern) — stopping the walk at the first - * `FUNCTION_LIKE_KINDS` ancestor encountered FIRST, which means a - * `Parameter`'s own destructuring pattern (which has no - * `VariableDeclarationList` of its own at all — a parameter is never - * `var`/`let`/`const`) correctly returns `null` rather than a wrong, unrelated - * outer declaration list several scopes up the real function-nesting chain. */ -function owningDeclarationList(node) { - let current = node.parent; - while (current) { - if (current.kind === SyntaxKind.VariableDeclarationList) return current; - if (FUNCTION_LIKE_KINDS.has(current.kind)) return null; - current = current.parent; - } - return null; -} - -/** True when `declarationList` (a real `VariableDeclarationList`) is a `var` - * list — neither `NodeFlags.Let` nor `NodeFlags.Const` set, the same - * flag-based test `nonConstDeclarationKeyword` already uses for the - * unrelated surface-protected-declaration rule above. */ -function isVarDeclarationList(declarationList) { - return (declarationList.flags & (NodeFlags.Let | NodeFlags.Const)) === 0; -} - -/** Declaration-KIND-aware `scopeOwnerOf`, for a real BINDING node - * (a `VariableDeclaration`, or a `BindingElement` nested inside one) — - * the #592 review-pass-3 fix: every alias/handler/capture/body-alias table - * below that registers a `var`/`let`/`const` binding must use THIS, never - * plain `scopeOwnerOf`, so a `var` gets real function-scoping - * (`varDeclarationScope`) instead of being silently treated as block- or - * loop-scoped like a `let`/`const` would be. A binding with no owning - * `VariableDeclarationList` at all (`owningDeclarationList` returns `null` - * — a `Parameter`'s own destructuring) falls through to plain - * `scopeOwnerOf`, which already gives every `Parameter` the right answer - * (the function itself — nothing block- or loop-scoped ever sits between a - * parameter and its own function). */ -function declarationScopeOwnerOf(node, sourceFile) { - const list = owningDeclarationList(node); - if (list && isVarDeclarationList(list)) return varDeclarationScope(node, sourceFile); - return scopeOwnerOf(node, sourceFile); -} - -/** The full lexical scope chain for `node`, innermost first, ending at - * `sourceFile` (module scope) — every scope a name reference at `node` can - * actually resolve through, mirroring real JS/TS function-scope shadowing. - * A binding declared in a sibling scope, or in a scope nested BELOW `node` - * (a helper function declared inside the scope currently being resolved), - * is never a member of this chain, so it can never satisfy a lookup for - * `node` — the fix for the P1 "collects bindings by bare identifier across - * the entire source file" finding: every alias/handler/capture table below - * is now keyed `scope -> Map` and resolved through this chain, - * never through one flat file-wide `Map`. */ -function scopeChain(node, sourceFile) { - const chain = []; - let scope = scopeOwnerOf(node, sourceFile); - for (;;) { - chain.push(scope); - if (scope === sourceFile) return chain; - scope = scopeOwnerOf(scope, sourceFile); - } -} - -/** Resolve `node` (an `Identifier`) through `scopedMap` (`scope -> - * Map`) by walking `scopeChain(node, sourceFile)` innermost - * first and returning the first scope's binding for `node.text` — i.e. the - * nearest LEXICALLY VISIBLE declaration, never a same-named binding from an - * unrelated scope. `undefined` when no scope on the chain binds that name at - * all (the caller's own fail-closed handling decides what that means). */ -function lookupInScopeChain(scopedMap, node, sourceFile) { - for (const scope of scopeChain(node, sourceFile)) { - const local = scopedMap.get(scope); - if (local && local.has(node.text)) return local.get(node.text); - } - return undefined; -} - /** 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 `buildGlobalAliasMap`. */ + * 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) => { @@ -2313,38 +2217,120 @@ function typeNamesOf(typeNode) { return names; } +/** `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 `propertyName` is `document`/`window` + * (`const { document: doc } = opts` — `menu.ts`'s real shape); + * - 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 names + * `Document`/`Window` (`childDoc: Document`, `mainDoc: Document`) — 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). + * 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` — needed only + * for the no-type-annotation initializer branch's recursive call + * @returns {'document'|'window'|null} + */ +function classifyGlobalDeclaration(declNode, checker) { + if ( + declNode.kind === SyntaxKind.BindingElement && declNode.propertyName + && declNode.propertyName.kind === SyntaxKind.Identifier + ) { + if (declNode.propertyName.text === 'document') return 'document'; + if (declNode.propertyName.text === '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 = typeNamesOf(declNode.type); + if (names.includes('Document')) return 'document'; + if (names.includes('Window')) return 'window'; + } + if (declNode.kind === SyntaxKind.VariableDeclaration && !declNode.type && declNode.initializer) { + return resolveGlobalKind(declNode.initializer, checker); + } + 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`; a simple - * alias already in `aliasMap` (built by `buildGlobalAliasMap`); 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. + * 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 {Map>} aliasMap scope -> name -> kind - * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` * @returns {'document'|'window'|null} */ -function resolveGlobalKind(node, aliasMap, sourceFile) { +function resolveGlobalKind(node, checker) { const expr = unwrapCastWrappers(node); if (!expr) return null; if (expr.kind === SyntaxKind.Identifier) { - if (expr.text === 'document') return 'document'; - if (expr.text === 'window') return 'window'; - return lookupInScopeChain(aliasMap, expr, sourceFile) ?? null; + 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) : null; } if (expr.kind === SyntaxKind.PropertyAccessExpression) { if (expr.name.text === 'document') return 'document'; @@ -2362,181 +2348,57 @@ function resolveGlobalKind(node, aliasMap, sourceFile) { if (expr.kind === SyntaxKind.BinaryExpression) { const op = expr.operatorToken.kind; if (op === SyntaxKind.BarBarToken || op === SyntaxKind.QuestionQuestionToken) { - return resolveGlobalKind(expr.left, aliasMap, sourceFile) ?? resolveGlobalKind(expr.right, aliasMap, sourceFile); + return resolveGlobalKind(expr.left, checker) ?? resolveGlobalKind(expr.right, checker); } - if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, aliasMap, sourceFile); + if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, checker); return null; } if (expr.kind === SyntaxKind.ConditionalExpression) { - return resolveGlobalKind(expr.whenTrue, aliasMap, sourceFile) ?? resolveGlobalKind(expr.whenFalse, aliasMap, sourceFile); + return resolveGlobalKind(expr.whenTrue, checker) ?? resolveGlobalKind(expr.whenFalse, checker); } return null; } -/** `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 none of - * `buildGlobalAliasMap`'s other rules can see on their own (its Parameter - * rule only looks at a plain-IDENTIFIER parameter's own type; its - * BindingElement rule only recognizes a RENAMED `{ document: doc }` form). - * This module has no real type checker (`typescript/unstable/sync` is - * parse-only — see the module header), 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'; - -/** - * Build the per-file `scope -> name -> 'document'|'window'` alias map: every - * `Parameter`/`VariableDeclaration` whose declared TYPE names `Document`/ - * `Window` (`childDoc: Document`, `mainDoc: Document`), every destructuring - * rename whose `propertyName` is `document`/`window` (`const { document: doc - * } = opts` — `menu.ts`'s real shape), and every `VariableDeclaration` whose - * INITIALIZER resolves via `resolveGlobalKind` against the map built so far, - * each `BindingElement`/`VariableDeclaration` recorded under its OWN - * declaration-KIND-aware declaring scope (`declarationScopeOwnerOf` — #592 - * review pass 3: `var` is function-scoped, `let`/`const` including a for- - * loop-header binding is genuinely lexically scoped; a `Parameter` has no - * declaration-list at all and keeps plain `scopeOwnerOf`, which already - * gives it the right, function-level answer) rather than one flat file-wide - * key. A single forward walk over the whole file still suffices for every - * real occurrence in this codebase (parameters are visited before the - * statements that reference them by `forEachChild`'s own declaration order, - * and no alias here is ever referenced before its own declaration) — this is - * a bounded architecture-guard heuristic, not a general dataflow engine; see - * this module's own header comment on accepted-risk scope. Per-scope keying - * is what makes that heuristic sound under same-file shadowing: a later - * sibling `doc: Window` in an unrelated function no longer overwrites an - * earlier `doc: Document` bound in a different scope (the reviewed #672 P1 - * — same bare name, different scopes, used to collapse to one file-wide - * last-write-wins entry), and a loop-header `let doc = window` no longer - * clobbers an outer `const doc: Document` bound in the SAME enclosing - * function/block (the #592 review-pass-3 fix). - * - * @param {object} sourceFile - * @returns {Map>} - */ -function buildGlobalAliasMap(sourceFile) { - const aliasMap = new Map(); // scope -> Map - const setAlias = (scope, name, kind) => { - let local = aliasMap.get(scope); - if (!local) { local = new Map(); aliasMap.set(scope, local); } - local.set(name, kind); - }; - walkTree(sourceFile, (node) => { - if (node.kind === SyntaxKind.Parameter && node.name && node.name.kind === SyntaxKind.Identifier && node.type) { - const names = typeNamesOf(node.type); - const scope = scopeOwnerOf(node, sourceFile); - if (names.includes('Document')) setAlias(scope, node.name.text, 'document'); - else if (names.includes('Window')) setAlias(scope, node.name.text, 'window'); - } - if ( - node.kind === SyntaxKind.Parameter && node.name && node.name.kind === SyntaxKind.ObjectBindingPattern && node.type - && typeNamesOf(node.type).includes(MOUNT_CTX_TYPE_NAME) - ) { - const scope = scopeOwnerOf(node, sourceFile); - for (const el of node.name.elements) { - if ( - el.kind === SyntaxKind.BindingElement && !el.propertyName && el.name && el.name.kind === SyntaxKind.Identifier - && el.name.text === 'doc' - ) { - setAlias(scope, el.name.text, 'document'); - } - } - } - if ( - node.kind === SyntaxKind.BindingElement && node.propertyName - && node.propertyName.kind === SyntaxKind.Identifier && node.name.kind === SyntaxKind.Identifier - ) { - const scope = declarationScopeOwnerOf(node, sourceFile); - if (node.propertyName.text === 'document') setAlias(scope, node.name.text, 'document'); - else if (node.propertyName.text === 'window') setAlias(scope, node.name.text, 'window'); - } - if (node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier) { - let kind = null; - if (node.type) { - const names = typeNamesOf(node.type); - if (names.includes('Document')) kind = 'document'; - else if (names.includes('Window')) kind = 'window'; - } - if (!kind && node.initializer) kind = resolveGlobalKind(node.initializer, aliasMap, sourceFile); - if (kind) setAlias(declarationScopeOwnerOf(node, sourceFile), node.name.text, kind); - } - }); - return aliasMap; -} - -/** Every `scope -> name -> [{node, pos}]` binding of a `FunctionDeclaration` - * (always block-scoped like `let` in this module's strict-mode ES-module - * source, so plain `scopeOwnerOf` is already correct for it) or a - * `const`/`let`/`var name = (…) => {}` / `… = function (…) {}` (declaration- - * KIND-aware `declarationScopeOwnerOf` — #592 review pass 3, same `var`-is- - * function-scoped fix as `buildGlobalAliasMap`'s) in `sourceFile`, keyed by - * the declaration's OWN declaring scope rather than one flat file-wide - * name — used to resolve a plain-identifier `addEventListener` handler - * argument (`doc. - * addEventListener('keydown', onKey, true)`) back to the function it names - * through `resolveHandlerNode`'s lexical scope-chain lookup, never through a - * same-named declaration in an unrelated sibling or nested-below scope (the - * reviewed #672 P1 handler-shadowing case). Multiple same-named entries - * WITHIN one scope are kept (never overwritten) so `resolveHandlerNode` can - * pick the one nearest-preceding a given use inside that scope. */ -function buildFunctionDeclMap(sourceFile) { - const map = new Map(); // scope -> Map - const add = (scope, name, node) => { - let local = map.get(scope); - if (!local) { local = new Map(); map.set(scope, local); } - const list = local.get(name) ?? []; - list.push({ node, pos: node.getStart(sourceFile) }); - local.set(name, list); - }; - walkTree(sourceFile, (node) => { - if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { - add(scopeOwnerOf(node, sourceFile), node.name.text, node); - } - if ( - node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier - && node.initializer - ) { - const init = unwrapCastWrappers(node.initializer); - if (init && (init.kind === SyntaxKind.ArrowFunction || init.kind === SyntaxKind.FunctionExpression)) { - add(declarationScopeOwnerOf(node, sourceFile), node.name.text, init); - } - } - }); - return map; -} - /** * 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 `sourceFile`'s lexical scope chain - * (`lookupInScopeChain`) to the NEAREST enclosing scope that declares that - * name in `funcDeclMap` (`buildFunctionDeclMap`), then the NEAREST PRECEDING - * (by source position) declaration of that name within THAT scope. `null` - * for anything else (a member access, a call, a conditional, an identifier no - * scope on the chain binds, …) — 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". + * 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". * * @param {object} handlerArg - * @param {Map>} funcDeclMap - * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` * @returns {object | null} */ -function resolveHandlerNode(handlerArg, funcDeclMap, sourceFile) { +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) { - const entries = lookupInScopeChain(funcDeclMap, expr, sourceFile); - if (!entries || entries.length === 0) return null; - const pos = expr.getStart(); - let best = null; - for (const e of entries) { if (e.pos <= pos && (!best || e.pos > best.pos)) best = e; } - return best ? best.node : entries[0].node; + 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) { + const init = unwrapCastWrappers(declNode.initializer); + if (init && FUNCTION_LIKE_KINDS.has(init.kind)) return init; } return null; } @@ -2597,12 +2459,18 @@ function staticPropertyKeyName(member) { * 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 {Map>} captureAliasMap - * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` * @returns {boolean | null} */ -function resolveObjectCaptureLiteral(node, captureAliasMap, sourceFile) { +function resolveObjectCaptureLiteral(node, checker) { 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) { @@ -2614,46 +2482,10 @@ function resolveObjectCaptureLiteral(node, captureAliasMap, sourceFile) { 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, captureAliasMap, sourceFile); + if (lastEventKnown === true) return resolveCaptureFlag(lastKnownValueNode, checker); return lastEventKnown === false ? null : false; } -/** Every `scope -> name -> true|false|null` binding of a `const`/`let`/`var - * name = true` / `= false` / `= { capture: … }` (via - * `resolveObjectCaptureLiteral`) in `sourceFile`, keyed by the declaration's - * own declaration-KIND-aware declaring scope (`declarationScopeOwnerOf` — - * #592 review pass 3) — backs the plan's "simple local const aliases of - * either form" requirement for the THIRD `addEventListener` argument, - * resolved through `resolveCaptureFlag`'s lexical scope-chain lookup so a - * same-named alias in an unrelated sibling scope (the reviewed #672 P1 - * capture-alias-overwrite case), OR a same-named `var`/for-header `let` - * binding that would otherwise land in the wrong scope bucket (the #592 - * review-pass-3 fix), can never satisfy a different scope's lookup. */ -function buildCaptureAliasMap(sourceFile) { - const map = new Map(); // scope -> Map - const setAlias = (scope, name, value) => { - let local = map.get(scope); - if (!local) { local = new Map(); map.set(scope, local); } - local.set(name, value); - }; - walkTree(sourceFile, (node) => { - if ( - node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier - && node.initializer - ) { - const init = unwrapCastWrappers(node.initializer); - if (!init) return; - const scope = declarationScopeOwnerOf(node, sourceFile); - if (init.kind === SyntaxKind.TrueKeyword) setAlias(scope, node.name.text, true); - else if (init.kind === SyntaxKind.FalseKeyword) setAlias(scope, node.name.text, false); - else if (init.kind === SyntaxKind.ObjectLiteralExpression) { - setAlias(scope, node.name.text, resolveObjectCaptureLiteral(init, map, sourceFile)); - } - } - }); - return map; -} - /** * Resolve an `addEventListener` THIRD argument to `true` (capture), `false` * (non-capture — proven), or `null` (cannot prove non-capture — the plan's @@ -2661,25 +2493,50 @@ function buildCaptureAliasMap(sourceFile) { * 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; every other shape (a member access, a call, a - * conditional, an unresolved identifier) is `null`. + * 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, or a + * resolved declaration that isn't a plain `VariableDeclaration` with an + * initializer — e.g. a destructured `BindingElement`, never supported here + * either) is `null`. + * + * 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. * * @param {object} node - * @param {Map>} captureAliasMap - * @param {object} sourceFile + * @param {object} checker the file's real TypeScript `Checker` * @returns {boolean | null} */ -function resolveCaptureFlag(node, captureAliasMap, sourceFile) { +function resolveCaptureFlag(node, checker) { 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, captureAliasMap, sourceFile); - if (expr.kind === SyntaxKind.Identifier) { - const found = lookupInScopeChain(captureAliasMap, expr, sourceFile); - return found === undefined ? null : found; - } - return null; + if (expr.kind === SyntaxKind.ObjectLiteralExpression) return resolveObjectCaptureLiteral(expr, checker); + 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) return null; + return resolveCaptureFlag(declNode.initializer, checker); } /** The one Escape literal every semantic check below compares against — @@ -2778,75 +2635,81 @@ const SHELL_BODY_MOUNT_POLICY = Object.freeze([ ]); /** - * Every real `.appendChild(...)`/`.append(...)` call in `sourceFile` whose - * receiver structurally resolves to a recognized `Document.body` — covering, - * per the plan's candidate list: `document.body.appendChild(...)`, - * `doc.body.appendChild(...)`, `mainDoc.body.appendChild(...)`, - * `childDoc.body.appendChild(...)`, `deps.document.body.appendChild(...)`, - * `window.document.body.appendChild(...)`, the bracket-property spelling - * (`doc['body']['appendChild'](...)`), a propagated body alias (`const body = - * childDoc.body; body.appendChild(...)`), a further simple alias of that - * body binding, and (#592 review pass 3) a destructuring alias of - * `Document.body` — `const { body } = document;` or the renamed - * `const { body: host } = document;` — which is a DIRECT `Document.body` - * mount exactly like the plain-identifier `const body = document.body;` - * form, not a shape this table can afford to leave unrecognized. 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). The body-alias table - * (`bodyAliasMap`) is keyed `scope -> Map` and resolved through - * `lookupInScopeChain`, exactly like `buildGlobalAliasMap`/ - * `buildFunctionDeclMap`/`buildCaptureAliasMap` — #592 review pass 2: this - * used to be one flat file-wide `Set`, so a block-local `const body - * = …` unrelated to Document.body could still satisfy (or a block-local - * shadow could still starve) a lookup anywhere else in the file — and #592 - * review pass 3: every registration now goes through the declaration-KIND- - * aware `declarationScopeOwnerOf`, so a `var body = …` alias declared inside - * a nested block is still visible for the rest of its enclosing FUNCTION - * (real `var` hoisting), not just inside that block. + * 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). * - * @param {object} sourceFile - * @returns {{node: object, api: 'appendChild'|'append', scopePath: string[], scopeNode: object|null, pos: number}[]} + * @param {object} node + * @param {object} checker the file's real TypeScript `Checker` + * @returns {boolean} */ -function bodyMountCandidates(sourceFile) { - const aliasMap = buildGlobalAliasMap(sourceFile); - const bodyAliasMap = new Map(); // scope -> Map - const setBodyAlias = (scope, name) => { - let local = bodyAliasMap.get(scope); - if (!local) { local = new Map(); bodyAliasMap.set(scope, local); } - local.set(name, true); - }; - walkTree(sourceFile, (node) => { - if ( - node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.Identifier - && node.initializer - ) { - const init = unwrapCastWrappers(node.initializer); - const scope = declarationScopeOwnerOf(node, sourceFile); - if ( - init && init.kind === SyntaxKind.PropertyAccessExpression && init.name.text === 'body' - && resolveGlobalKind(init.expression, aliasMap, sourceFile) === 'document' - ) { - setBodyAlias(scope, node.name.text); - } - if (init && init.kind === SyntaxKind.Identifier && lookupInScopeChain(bodyAliasMap, init, sourceFile) !== undefined) { - setBodyAlias(scope, node.name.text); - } - } +function resolvesToDocumentBody(node, checker) { + const expr = unwrapCastWrappers(node); + if (!expr) return false; + if (expr.kind === SyntaxKind.PropertyAccessExpression && expr.name.text === 'body') { + return resolveGlobalKind(expr.expression, checker) === 'document'; + } + if (expr.kind === SyntaxKind.ElementAccessExpression) { + const arg = expr.argumentExpression; if ( - node.kind === SyntaxKind.VariableDeclaration && node.name && node.name.kind === SyntaxKind.ObjectBindingPattern - && node.initializer && resolveGlobalKind(node.initializer, aliasMap, sourceFile) === 'document' + arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral) + && arg.text === 'body' ) { - const scope = declarationScopeOwnerOf(node, sourceFile); - for (const el of node.name.elements) { - if (el.kind !== SyntaxKind.BindingElement || !el.name || el.name.kind !== SyntaxKind.Identifier) continue; - const propName = el.propertyName && el.propertyName.kind === SyntaxKind.Identifier - ? el.propertyName.text - : el.name.text; - if (propName === 'body') setBodyAlias(scope, el.name.text); - } + return resolveGlobalKind(expr.expression, checker) === '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) { + const propName = declNode.propertyName && declNode.propertyName.kind === SyntaxKind.Identifier + ? declNode.propertyName.text + : (declNode.name && declNode.name.kind === SyntaxKind.Identifier ? declNode.name.text : null); + if (propName !== 'body') return false; + const pattern = declNode.parent; // ObjectBindingPattern + const owner = pattern && pattern.parent; // VariableDeclaration + return !!(owner && owner.initializer && resolveGlobalKind(owner.initializer, checker) === 'document'); + } + if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer) { + return resolvesToDocumentBody(declNode.initializer, checker); + } + return false; +} + +/** + * 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; @@ -2865,24 +2728,7 @@ function bodyMountCandidates(sourceFile) { } if (apiName !== 'appendChild' && apiName !== 'append') return; if (!receiver) return; - const recv = unwrapCastWrappers(receiver); - if (!recv) return; - let isBody = false; - if (recv.kind === SyntaxKind.Identifier && lookupInScopeChain(bodyAliasMap, recv, sourceFile) !== undefined) { - isBody = true; - } else if (recv.kind === SyntaxKind.PropertyAccessExpression && recv.name.text === 'body' - && resolveGlobalKind(recv.expression, aliasMap, sourceFile) === 'document') { - isBody = true; - } else if (recv.kind === SyntaxKind.ElementAccessExpression) { - const argN = recv.argumentExpression; - if ( - argN && (argN.kind === SyntaxKind.StringLiteral || argN.kind === SyntaxKind.NoSubstitutionTemplateLiteral) - && argN.text === 'body' && resolveGlobalKind(recv.expression, aliasMap, sourceFile) === 'document' - ) { - isBody = true; - } - } - if (!isBody) return; + if (!resolvesToDocumentBody(receiver, checker)) return; candidates.push({ node, api: apiName, scopePath: enclosingScopePath(node), scopeNode: innermostScopeNode(node), pos: node.getStart(sourceFile), @@ -2906,9 +2752,9 @@ function bodyMountCandidates(sourceFile) { * 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) { +function shellBodyMountViolations(sourceFile, filename, checker) { const byScope = new Map(); - for (const c of bodyMountCandidates(sourceFile)) { + for (const c of bodyMountCandidates(sourceFile, checker)) { const key = scopeKey(c.scopePath); const list = byScope.get(key) ?? []; list.push(c); @@ -3016,13 +2862,17 @@ const SHELL_CAPTURE_ESCAPE_POLICY = Object.freeze([ * `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) { - const aliasMap = buildGlobalAliasMap(sourceFile); - const funcDeclMap = buildFunctionDeclMap(sourceFile); - const captureAliasMap = buildCaptureAliasMap(sourceFile); +function captureEscapeCandidates(sourceFile, checker) { const out = []; walkTree(sourceFile, (node) => { if (node.kind !== SyntaxKind.CallExpression) return; @@ -3035,15 +2885,15 @@ function captureEscapeCandidates(sourceFile) { !evtArg || (evtArg.kind !== SyntaxKind.StringLiteral && evtArg.kind !== SyntaxKind.NoSubstitutionTemplateLiteral) || evtArg.text !== 'keydown' ) return; - if (!resolveGlobalKind(callee.expression, aliasMap, sourceFile)) return; // not Document/Window — not a candidate + if (!resolveGlobalKind(callee.expression, checker)) 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, captureAliasMap, sourceFile); + const captureFlag = resolveCaptureFlag(third, checker); if (captureFlag === false) return; // provably non-capture if (captureFlag === null) { out.push({ kind: 'uncheckable-options', scopePath, pos }); return; } - const handlerNode = resolveHandlerNode(args[1], funcDeclMap, sourceFile); + const handlerNode = resolveHandlerNode(args[1], checker); if (!handlerNode) { out.push({ kind: 'uncheckable-handler', scopePath, pos }); return; } out.push({ kind: containsEscapeSemantics(handlerNode) ? 'escape' : 'clean', scopePath, pos }); }); @@ -3062,10 +2912,10 @@ function captureEscapeCandidates(sourceFile) { * 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) { +function shellCaptureEscapeViolations(sourceFile, filename, checker) { const byScope = new Map(); const violations = []; - for (const c of captureEscapeCandidates(sourceFile)) { + for (const c of captureEscapeCandidates(sourceFile, checker)) { if (c.kind === 'uncheckable-handler') { violations.push(makeViolation( 'shell-capture-escape', filename, c.pos, @@ -3124,17 +2974,22 @@ function shellCaptureEscapeViolations(sourceFile, filename) { * 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. + * 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. * * @param {readonly {filename: string, source: string}[]} sources * @returns {{rule: string, filename: string, pos: number, detail: string}[]} */ export function findShellGuardrailSourceContractViolations(sources) { - return withParsedSources(sources, (sourceFiles) => { + return withParsedSources(sources, (sourceFiles, checkers) => { const violations = []; for (const [filename, sourceFile] of sourceFiles) { - violations.push(...shellBodyMountViolations(sourceFile, filename)); - violations.push(...shellCaptureEscapeViolations(sourceFile, filename)); + const checker = checkers.get(filename); + violations.push(...shellBodyMountViolations(sourceFile, filename, checker)); + violations.push(...shellCaptureEscapeViolations(sourceFile, filename, checker)); } return violations; }); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index c29a16c4..fbec48f1 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -1069,6 +1069,110 @@ describe('#592 shell guardrails: files with none of the governed shapes stay cle }); }); +// ── 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 { From 9b530eca4bee2e4ab5a622414d94a639836136aa Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 12:55:22 +0200 Subject: [PATCH 6/8] fix(#592): address review pass 1 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five accepted ChatGPT PR-review findings against the #592 shell-guardrail checker-refactor commit: - Add findShellGuardrailMissingBaselineViolations, a complete-tree strict reverse pass for shell-body-mount/shell-capture-escape that (unlike the softened forward-check reverse pass) catches a whole approved function deleted outright or a whole approved file missing from the batch, wired into check-boundaries.mjs alongside the existing forward check. - resolveCaptureFlag/resolveHandlerNode now require a genuine const VariableDeclaration before trusting its initializer, so a let/var alias reassigned after declaration (capture flag or handler function) fails closed to "uncheckable" instead of resolving to its stale initial value. - classifyGlobalDeclaration resolves a local type-alias chain (type Doc = Document) through the real checker instead of only literal type-reference names, and both classifyGlobalDeclaration and resolvesToDocumentBody now recognize a quoted destructuring rename key (const { 'body': host } = document) via a shared bindingElementSourceKeyName helper. - The resize-handle CSS width extractor now counts every width: declaration targeting the resize classes instead of silently skipping a !important suffix or a non-literal (calc()/var()) value — an unconvertible value becomes a NaN sentinel that can never falsely satisfy the exact-equality contract. - scanFixedPositionDeclarations now marks a position: fixed declaration nested inside another plain style rule (real CSS nesting) as nested: true; findShellFixedPositionViolations unconditionally flags it instead of fingerprint-matching it against the approved baseline, and the missing- baseline reverse pass no longer treats it as "still present". Tests added alongside each fix in shell-guardrails-arch.test.ts and resize-handle-thickness-contract.test.js. Full local gate green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/check-boundaries.mjs | 11 + build/lib/check-legacy-owners.d.mts | 43 +- build/lib/check-legacy-owners.mjs | 409 ++++++++++++++++-- .../resize-handle-thickness-contract.test.js | 114 ++++- tests/unit/shell-guardrails-arch.test.ts | 275 +++++++++++- 5 files changed, 778 insertions(+), 74 deletions(-) diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index 877dcc53..8d0726a8 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -101,6 +101,7 @@ import { findDynamicImportUsages, mightContainDynamicImport, findShellGuardrailSourceContractViolations, + findShellGuardrailMissingBaselineViolations, findShellFixedPositionViolations, findShellFixedPositionMissingBaselineViolations, } from './lib/check-legacy-owners.mjs'; @@ -915,6 +916,16 @@ function lineOfOffset(source, pos) { const line = lineOfOffset(bySource.get(v.filename) ?? '', v.pos); violations.push(`${v.filename}:${line} → ${v.rule}: ${v.detail}`); } + // The complete-tree reverse half (PR #672 review pass 1 follow-up, + // ChatGPT) — meaningful only against the real, complete `src/**` tree, + // which `shellSources` (built from `collectFiles`'s live disk walk) always + // is here; see the function's own doc comment for why this is a separate + // export from `findShellGuardrailSourceContractViolations` rather than + // folded into it. + for (const v of findShellGuardrailMissingBaselineViolations(shellSources)) { + 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)) { diff --git a/build/lib/check-legacy-owners.d.mts b/build/lib/check-legacy-owners.d.mts index d0738dc3..68ba953e 100644 --- a/build/lib/check-legacy-owners.d.mts +++ b/build/lib/check-legacy-owners.d.mts @@ -126,19 +126,46 @@ export function findShellGuardrailSourceContractViolations( sources: readonly ShellGuardrailSourceEntry[], ): 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 caller is `build/check-boundaries.mjs`'s live + * `collectFiles(src/)` batch) 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). + */ +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-rules' 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 the declaration sits at the - * stylesheet's top level with no enclosing at-rule at all; `pos` is the - * declaration's own offset into the scanned CSS text (the first + * 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; } @@ -156,7 +183,9 @@ export function scanFixedPositionDeclarations(source: string): FixedPositionDecl * `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). + * 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, diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 95850af8..729b41ca 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2147,19 +2147,34 @@ function fullScopePathOf(fnLikeNode) { * 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 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). A policy entry whose ENTIRE enclosing scope has also been - * deleted from production code is a coarser change a rename/typecheck - * failure elsewhere in the pipeline would surface — deliberately out of - * this narrower check's scope. */ + * `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) => { @@ -2217,6 +2232,84 @@ function typeNamesOf(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 @@ -2242,18 +2335,24 @@ const MOUNT_CTX_TYPE_NAME = 'MountCtx'; * 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 `propertyName` is `document`/`window` - * (`const { document: doc } = opts` — `menu.ts`'s real shape); + * - 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 names - * `Document`/`Window` (`childDoc: Document`, `mainDoc: Document`) — 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 `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). @@ -2264,17 +2363,16 @@ const MOUNT_CTX_TYPE_NAME = 'MountCtx'; * block/function scoping, hoisting, and shadowing for free. * * @param {object} declNode - * @param {object} checker the file's real TypeScript `Checker` — needed only - * for the no-type-annotation initializer branch's recursive call + * @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 * @returns {'document'|'window'|null} */ function classifyGlobalDeclaration(declNode, checker) { - if ( - declNode.kind === SyntaxKind.BindingElement && declNode.propertyName - && declNode.propertyName.kind === SyntaxKind.Identifier - ) { - if (declNode.propertyName.text === 'document') return 'document'; - if (declNode.propertyName.text === 'window') return 'window'; + 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 @@ -2285,7 +2383,7 @@ function classifyGlobalDeclaration(declNode, checker) { 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 = typeNamesOf(declNode.type); + const names = resolvedTypeNames(declNode.type, checker, new Set()); if (names.includes('Document')) return 'document'; if (names.includes('Window')) return 'window'; } @@ -2359,6 +2457,29 @@ function resolveGlobalKind(node, checker) { return null; } +/** 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 @@ -2379,7 +2500,14 @@ function resolveGlobalKind(node, checker) { * 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". + * 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` @@ -2396,7 +2524,7 @@ function resolveHandlerNode(handlerArg, checker) { const declNode = handle?.resolve(); if (!declNode) return null; if (FUNCTION_LIKE_KINDS.has(declNode.kind)) return declNode; - if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer) { + if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer && isConstVariableDeclaration(declNode)) { const init = unwrapCastWrappers(declNode.initializer); if (init && FUNCTION_LIKE_KINDS.has(init.kind)) return init; } @@ -2502,10 +2630,17 @@ function resolveObjectCaptureLiteral(node, checker) { * 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, or 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) is `null`. + * 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 @@ -2535,7 +2670,10 @@ function resolveCaptureFlag(node, checker) { if (!symbol) return null; const handle = symbol.valueDeclaration ?? symbol.declarations[0]; const declNode = handle?.resolve(); - if (!declNode || declNode.kind !== SyntaxKind.VariableDeclaration || !declNode.initializer) return null; + if ( + !declNode || declNode.kind !== SyntaxKind.VariableDeclaration || !declNode.initializer + || !isConstVariableDeclaration(declNode) + ) return null; return resolveCaptureFlag(declNode.initializer, checker); } @@ -2683,9 +2821,12 @@ function resolvesToDocumentBody(node, checker) { const declNode = handle?.resolve(); if (!declNode) return false; if (declNode.kind === SyntaxKind.BindingElement) { - const propName = declNode.propertyName && declNode.propertyName.kind === SyntaxKind.Identifier - ? declNode.propertyName.text - : (declNode.name && declNode.name.kind === SyntaxKind.Identifier ? declNode.name.text : null); + // #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 @@ -2995,6 +3136,145 @@ export function findShellGuardrailSourceContractViolations(sources) { }); } +/** 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 caller (`findShellGuardrailMissingBaselineViolations`) + * already guarantees `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 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 + * caller is `build/check-boundaries.mjs`'s live `collectFiles(src/)` batch, + * 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. + * + * @param {readonly {filename: string, source: string}[]} sources + * @returns {{rule: string, filename: string, pos: number, detail: string}[]} + */ +export function findShellGuardrailMissingBaselineViolations(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', + )); + } + 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) => { + 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; + })); +} + // ── Guard 2: `shell-fixed-position` (focused CSS lexical scanner) ─────────── // // No CSS parser dependency (Architecture decision 2) — a small hand-written @@ -3097,10 +3377,19 @@ function firstMeaningfulCssOffset(source, from) { * `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. + * 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. * * @param {string} source - * @returns {{selector: string, atRule: string | null, pos: number}[]} + * @returns {{selector: string, atRule: string | null, nested: boolean, pos: number}[]} */ export function scanFixedPositionDeclarations(source) { const n = source.length; @@ -3136,12 +3425,14 @@ export function scanFixedPositionDeclarations(source) { const innermost = frames[frames.length - 1]; if (!innermost || innermost.kind !== 'rule') return; // no selector context — out of this rule's scope const atChain = []; + let nested = false; for (let k = frames.length - 2; 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, pos: firstMeaningfulCssOffset(source, segStart) }); + results.push({ selector: innermost.prelude, atRule, nested, pos: firstMeaningfulCssOffset(source, segStart) }); } while (i < n) { @@ -3234,6 +3525,18 @@ function fixedPositionKey(selector, atRule) { * 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}[]} @@ -3242,6 +3545,17 @@ 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); @@ -3297,7 +3611,16 @@ export function findShellFixedPositionViolations(cssSource, filename) { * @returns {{rule: string, filename: string, pos: number, detail: string}[]} */ export function findShellFixedPositionMissingBaselineViolations(cssSource, filename) { - const found = new Set(scanFixedPositionDeclarations(cssSource).map((d) => fixedPositionKey(d.selector, d.atRule))); + // 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); diff --git a/tests/unit/resize-handle-thickness-contract.test.js b/tests/unit/resize-handle-thickness-contract.test.js index b4b5a8ad..de6b4e98 100644 --- a/tests/unit/resize-handle-thickness-contract.test.js +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -96,27 +96,43 @@ function selectorTargetsResizeHandleClass(selector, className) { return new RegExp(`\\.${className}(?![\\w-])`).test(selector); } -/** Every `width: 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). - * Order-independent; additional selectors in the same group, e.g. - * `.row-resize`, are allowed. Zero, one, or many, across however many - * matching rule groups exist: the caller decides what count is valid — and - * the contract below requires EXACTLY one, so ANY standalone, compound, - * descendant, or media-scoped override of either class's `width` 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. */ +/** 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. */ function extractSharedResizeWidthPx(cssSource) { const values = []; for (const rule of flatCssRules(cssSource)) { @@ -124,7 +140,10 @@ function extractSharedResizeWidthPx(cssSource) { (s) => selectorTargetsResizeHandleClass(s, 'col-resize') || selectorTargetsResizeHandleClass(s, 'inspector-resize'), ); if (!targets) continue; - for (const m of rule.body.matchAll(/\bwidth\s*:\s*(-?\d+(?:\.\d+)?)px\s*;/g)) values.push(Number(m[1])); + for (const m of rule.body.matchAll(/\bwidth\s*:\s*([^;]+?)\s*;/g)) { + const numeric = /^(-?\d+(?:\.\d+)?)px(?:\s*!\s*important)?$/i.exec(m[1].trim()); + values.push(numeric ? Number(numeric[1]) : NaN); + } } return values; } @@ -243,6 +262,57 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ 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 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([]); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index fbec48f1..dbeaa10d 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { findShellGuardrailSourceContractViolations, + findShellGuardrailMissingBaselineViolations, findShellFixedPositionViolations, findShellFixedPositionMissingBaselineViolations, scanFixedPositionDeclarations, @@ -59,15 +60,18 @@ function rulesOf(vs: SourceContractViolation[]): string[] { describe('#592 shell guardrails: live-tree baseline', () => { let tsViolations: SourceContractViolation[]; + let missingBaselineViolations: SourceContractViolation[]; let cssViolations: SourceContractViolation[]; + let sources: ShellGuardrailSourceEntry[]; beforeAll(() => { const files = listSourceFiles(); - const sources: ShellGuardrailSourceEntry[] = files.map((relPath) => ({ + 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); @@ -80,6 +84,16 @@ describe('#592 shell guardrails: live-tree baseline', () => { 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([]); }); @@ -413,6 +427,97 @@ describe('#592 shell-body-mount: destructuring alias sabotage (each must fail)', .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 ─────────────────────────────────────────── @@ -534,6 +639,54 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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']); + }); + + // 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 @@ -677,6 +830,62 @@ describe('#592 shell-capture-escape: missing-baseline-entry sabotage (each must }); }); +// ── 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']); + }); +}); + // ── 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 @@ -865,6 +1074,19 @@ describe('#592 shell-fixed-position: positive characterization', () => { 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); + }); }); // ── Fixed-position sabotage cases ─────────────────────────────────────────── @@ -952,6 +1174,38 @@ describe('#592 shell-fixed-position: sabotage (each must fail)', () => { 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) ── @@ -1005,6 +1259,23 @@ describe('#592 shell-fixed-position: missing-baseline-entry sabotage (each must 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 ───────────────────────────────────────────────────────── @@ -1176,7 +1447,7 @@ describe('#592 Architecture decision 6: checker-based resolution (mirrors the pr // 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, pos: 0 }; + 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; From 5d5d166e372cb1ec4f8d4eb9d5d15eae9435bd8a Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 13:40:17 +0200 Subject: [PATCH 7/8] fix(#592): address review pass 2 findings Close five accepted ChatGPT PR #672 review-pass-2 findings against the shell-guardrail architecture checks: - resolveCaptureFlag now fails closed when a resolved const capture-options object literal is later mutated via a `.capture`/`['capture']` property write anywhere in the file (hasCapturePropertyMutation), not just on a whole-binding let/var reassignment. - resolvesToDocumentBody now also resolves a let/var binding's later whole-binding reassignment (laterAssignmentResolvesToDocumentBody), so a real `let body = ...; body = document.body; body.appendChild(x)` mount is no longer invisible to the shell-body-mount guard. - containsEscapeSemantics now resolves a const identifier alias of the 'Escape' literal (resolveStringLiteralValue/classifyEscapeComparison) in both `===`/`!==` comparisons and `switch` case values, and fails closed (treats as a possible Escape listener) on any unresolvable comparison value instead of silently dropping it as 'clean'. - scanFixedPositionDeclarations' processDeclaration now searches outward through the frame stack for the nearest enclosing style-rule frame instead of bailing on a single innermost at-rule frame, so a bare `position: fixed` declaration nested inside an at-rule nested inside a rule is no longer invisible to the shell-fixed-position guard. - extractSharedResizeWidthPx's value regex now treats end-of-rule-body as a valid declaration terminator alongside `;`, so a `width` declaration with no trailing semicolon before the closing brace is no longer silently skipped. - findShellGuardrailSourceContractViolations gains a `completeTree` option that folds the complete-tree reverse-baseline check into its own shared parser batch; production wiring in check-boundaries.mjs now uses that option instead of a second, separate call to findShellGuardrailMissingBaselineViolations, so check:arch no longer opens two native TypeScript-parser batches for one rule. Adds sabotage/characterization tests for every fix, plus a completeTree equivalence test proving the folded batch matches the union of running the forward and reverse checks separately. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/check-boundaries.mjs | 31 +- build/lib/check-legacy-owners.d.mts | 29 +- build/lib/check-legacy-owners.mjs | 426 ++++++++++++++---- .../resize-handle-thickness-contract.test.js | 22 +- tests/unit/shell-guardrails-arch.test.ts | 159 +++++++ 5 files changed, 568 insertions(+), 99 deletions(-) diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index 8d0726a8..1360e948 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -101,7 +101,6 @@ import { findDynamicImportUsages, mightContainDynamicImport, findShellGuardrailSourceContractViolations, - findShellGuardrailMissingBaselineViolations, findShellFixedPositionViolations, findShellFixedPositionMissingBaselineViolations, } from './lib/check-legacy-owners.mjs'; @@ -896,9 +895,12 @@ for (const file of collectFiles(path.join(repoRoot, 'src'))) { // 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); a third is a focused CSS lexical -// scanner over `src/styles.css` alone (`findShellFixedPositionViolations` — -// Architecture decision 2: no CSS parser dependency). `lineOfOffset` converts +// 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) { @@ -912,17 +914,16 @@ function lineOfOffset(source, pos) { return { filename: relFile, source: guardedFileSources.get(file) ?? fs.readFileSync(file, 'utf8') }; }); const bySource = new Map(shellSources.map((s) => [s.filename, s.source])); - for (const v of findShellGuardrailSourceContractViolations(shellSources)) { - const line = lineOfOffset(bySource.get(v.filename) ?? '', v.pos); - violations.push(`${v.filename}:${line} → ${v.rule}: ${v.detail}`); - } - // The complete-tree reverse half (PR #672 review pass 1 follow-up, - // ChatGPT) — meaningful only against the real, complete `src/**` tree, - // which `shellSources` (built from `collectFiles`'s live disk walk) always - // is here; see the function's own doc comment for why this is a separate - // export from `findShellGuardrailSourceContractViolations` rather than - // folded into it. - for (const v of findShellGuardrailMissingBaselineViolations(shellSources)) { + // `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}`); } diff --git a/build/lib/check-legacy-owners.d.mts b/build/lib/check-legacy-owners.d.mts index 68ba953e..c4acc3bd 100644 --- a/build/lib/check-legacy-owners.d.mts +++ b/build/lib/check-legacy-owners.d.mts @@ -116,14 +116,29 @@ export interface ShellGuardrailSourceEntry { 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). + * 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[]; /** @@ -137,14 +152,20 @@ export function findShellGuardrailSourceContractViolations( * 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 caller is `build/check-boundaries.mjs`'s live - * `collectFiles(src/)` batch) and reports, without that softening, every + * 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). + * 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[], diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 729b41ca..3f916408 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2596,9 +2596,12 @@ function staticPropertyKeyName(member) { * * @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) { +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) { @@ -2610,10 +2613,64 @@ function resolveObjectCaptureLiteral(node, checker) { 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); + if (lastEventKnown === true) return resolveCaptureFlag(lastKnownValueNode, checker, sourceFile); return lastEventKnown === false ? null : false; } +/** True when ANY `.capture = …` / `['capture'] = …` assignment + * exists anywhere in `sourceFile` whose OWN receiver resolves (via the real + * checker) 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. 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 || node.operatorToken.kind !== SyntaxKind.EqualsToken) 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 (!rExpr || rExpr.kind !== SyntaxKind.Identifier) return; + const sym = checker.getSymbolAtLocation(rExpr); + if (!sym) return; + const handle = sym.valueDeclaration ?? sym.declarations[0]; + if (handle?.resolve() === declNode) 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 @@ -2651,16 +2708,28 @@ function resolveObjectCaptureLiteral(node, checker) { * 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) { +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); + 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; @@ -2674,31 +2743,101 @@ function resolveCaptureFlag(node, checker) { !declNode || declNode.kind !== SyntaxKind.VariableDeclaration || !declNode.initializer || !isConstVariableDeclaration(declNode) ) return null; - return resolveCaptureFlag(declNode.initializer, checker); + 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'; } -/** The one Escape literal every semantic check below compares against — - * `event.key`/`event.code` forms alike (the plan does not distinguish - * between the two KeyboardEvent properties, only requires either to be - * recognized). */ -const ESCAPE_LITERAL_SET = new Set(['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 — - * `exactLiteralMatch` already normalizes string vs. no-substitution-template - * literals to the same decoded `.text`), the analogous `event.code` forms, and - * `switch (event.key) { case 'Escape': … }`. 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. + * === 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) { +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) => { @@ -2708,22 +2847,16 @@ function containsEscapeSemantics(fnLikeNode) { && (node.operatorToken.kind === SyntaxKind.EqualsEqualsEqualsToken || node.operatorToken.kind === SyntaxKind.ExclamationEqualsEqualsToken) ) { - const leftIsEscape = exactLiteralMatch(node.left, ESCAPE_LITERAL_SET); - const rightIsEscape = exactLiteralMatch(node.right, ESCAPE_LITERAL_SET); - const other = leftIsEscape ? node.right : (rightIsEscape ? node.left : null); - if (other) { - const names = terminalNames(other, 1); - if (names.length === 1 && (names[0] === 'key' || names[0] === 'code')) found = true; - } + 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 && exactLiteralMatch(clause.expression, ESCAPE_LITERAL_SET)) { - found = true; - break; - } + if (clause.kind !== SyntaxKind.CaseClause) continue; + const resolved = resolveStringLiteralValue(clause.expression, checker); + if (resolved === undefined || resolved === 'Escape') { found = true; break; } } } } @@ -2792,13 +2925,24 @@ const SHELL_BODY_MOUNT_POLICY = Object.freeze([ * 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). + * 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 * @returns {boolean} */ -function resolvesToDocumentBody(node, checker) { +function resolvesToDocumentBody(node, checker, sourceFile) { const expr = unwrapCastWrappers(node); if (!expr) return false; if (expr.kind === SyntaxKind.PropertyAccessExpression && expr.name.text === 'body') { @@ -2833,11 +2977,45 @@ function resolvesToDocumentBody(node, checker) { return !!(owner && owner.initializer && resolveGlobalKind(owner.initializer, checker) === 'document'); } if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer) { - return resolvesToDocumentBody(declNode.initializer, checker); + if (resolvesToDocumentBody(declNode.initializer, checker, sourceFile)) return true; + if (!isConstVariableDeclaration(declNode) && sourceFile + && laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker)) 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. */ +function laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker) { + 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)) found = true; + }); + return found; +} + /** * Every real `.appendChild(...)`/`.append(...)` call in `sourceFile` whose * receiver structurally resolves to a recognized `Document.body` @@ -2869,7 +3047,7 @@ function bodyMountCandidates(sourceFile, checker) { } if (apiName !== 'appendChild' && apiName !== 'append') return; if (!receiver) return; - if (!resolvesToDocumentBody(receiver, checker)) return; + if (!resolvesToDocumentBody(receiver, checker, sourceFile)) return; candidates.push({ node, api: apiName, scopePath: enclosingScopePath(node), scopeNode: innermostScopeNode(node), pos: node.getStart(sourceFile), @@ -3031,12 +3209,12 @@ function captureEscapeCandidates(sourceFile, checker) { 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); + 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) ? 'escape' : 'clean', scopePath, pos }); + out.push({ kind: containsEscapeSemantics(handlerNode, checker) ? 'escape' : 'clean', scopePath, pos }); }); return out; } @@ -3121,10 +3299,25 @@ function shellCaptureEscapeViolations(sourceFile, filename, checker) { * 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) { +export function findShellGuardrailSourceContractViolations(sources, options) { + const completeTree = !!options?.completeTree; return withParsedSources(sources, (sourceFiles, checkers) => { const violations = []; for (const [filename, sourceFile] of sourceFiles) { @@ -3132,6 +3325,13 @@ export function findShellGuardrailSourceContractViolations(sources) { 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; }); } @@ -3142,8 +3342,10 @@ export function findShellGuardrailSourceContractViolations(sources) { * 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 caller (`findShellGuardrailMissingBaselineViolations`) - * already guarantees `sourceFile` is the complete real file, never a + * 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(); @@ -3196,6 +3398,68 @@ function shellCaptureEscapeMissingBaselineViolationsStrict(sourceFile, filename, 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`, @@ -3218,9 +3482,10 @@ function shellCaptureEscapeMissingBaselineViolationsStrict(sourceFile, filename, * 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 - * caller is `build/check-boundaries.mjs`'s live `collectFiles(src/)` batch, - * which reads every file under `src/**` from disk) and reports, WITHOUT - * that softening: + * 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; @@ -3232,47 +3497,30 @@ function shellCaptureEscapeMissingBaselineViolationsStrict(sourceFile, filename, * 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 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', - )); - } + 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) => { - 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; - })); + return violations.concat(withParsedSources( + toParse, + (sourceFiles, checkers) => shellGuardrailStrictReverseViolations(sourceFiles, checkers), + )); } // ── Guard 2: `shell-fixed-position` (focused CSS lexical scanner) ─────────── @@ -3386,7 +3634,14 @@ function firstMeaningfulCssOffset(source, from) { * 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. + * 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}[]} @@ -3422,11 +3677,26 @@ export function scanFixedPositionDeclarations(source) { const value = decodeCssEscapes(trimmed.slice(colonIdx + 1)).trim(); if (prop.toLowerCase() !== 'position') return; if (!/^fixed(\s*!\s*important)?$/i.test(normalizeCssText(value))) return; - const innermost = frames[frames.length - 1]; - if (!innermost || innermost.kind !== 'rule') return; // no selector context — out of this rule's scope + // 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 = frames.length - 2; k >= 0; k--) { + 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 } diff --git a/tests/unit/resize-handle-thickness-contract.test.js b/tests/unit/resize-handle-thickness-contract.test.js index de6b4e98..e568bb1c 100644 --- a/tests/unit/resize-handle-thickness-contract.test.js +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -132,7 +132,15 @@ function selectorTargetsResizeHandleClass(selector, className) { * 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. */ + * 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. */ function extractSharedResizeWidthPx(cssSource) { const values = []; for (const rule of flatCssRules(cssSource)) { @@ -140,7 +148,7 @@ function extractSharedResizeWidthPx(cssSource) { (s) => selectorTargetsResizeHandleClass(s, 'col-resize') || selectorTargetsResizeHandleClass(s, 'inspector-resize'), ); if (!targets) continue; - for (const m of rule.body.matchAll(/\bwidth\s*:\s*([^;]+?)\s*;/g)) { + for (const m of rule.body.matchAll(/\bwidth\s*:\s*([^;]+?)\s*(?:;|$)/g)) { const numeric = /^(-?\d+(?:\.\d+)?)px(?:\s*!\s*important)?$/i.exec(m[1].trim()); values.push(numeric ? Number(numeric[1]) : NaN); } @@ -304,6 +312,16 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ 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); diff --git a/tests/unit/shell-guardrails-arch.test.ts b/tests/unit/shell-guardrails-arch.test.ts index dbeaa10d..c3a3e0bb 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -212,6 +212,13 @@ describe('#592 shell-body-mount: sabotage (each must fail)', () => { '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`, () => { @@ -221,6 +228,16 @@ describe('#592 shell-body-mount: sabotage (each must fail)', () => { }); } + // 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"));')); @@ -567,6 +584,18 @@ describe('#592 shell-capture-escape: positive characterization (sanctioned curre 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);"); @@ -671,6 +700,25 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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']); + }); + // 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 @@ -767,6 +815,40 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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);"); @@ -886,6 +968,53 @@ describe('#592 shell-guardrail missing-baseline: complete-tree strict reverse ch }); }); +// ── `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 @@ -1087,6 +1216,29 @@ describe('#592 shell-fixed-position: positive characterization', () => { 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 ─────────────────────────────────────────── @@ -1110,6 +1262,13 @@ describe('#592 shell-fixed-position: sabotage (each must fail)', () => { 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'); From aa5a4e50594dab2f487a656f256c110f1fba94db Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 12 Aug 2026 14:23:03 +0200 Subject: [PATCH 8/8] fix(#592): address review pass 3 findings Close five confirmed shell-guardrail escapes: resolveGlobalKind now tracks a let/var Document/Window receiver alias's later reassignment (not just its initializer), hasCapturePropertyMutation now recognizes compound assignment operators and alias-mediated writes on the same capture-options object, shell-capture-escape candidate discovery now recognizes a bracket-spelled addEventListener callee and a const alias of the 'keydown' event-name literal, the independent resize-handle width/property CSS extractor now decodes CSS identifier escapes, and resolvesToDocumentBody/laterAssignmentResolvesToDocumentBody now carry a cycle-safe visited-binding set so an alias-reassignment cycle can no longer crash the architecture guard. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz --- build/lib/check-legacy-owners.mjs | 244 +++++++++++++++--- .../resize-handle-thickness-contract.test.js | 93 ++++++- tests/unit/shell-guardrails-arch.test.ts | 148 +++++++++++ 3 files changed, 438 insertions(+), 47 deletions(-) diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 3f916408..552faccc 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -2355,7 +2355,22 @@ const MOUNT_CTX_TYPE_NAME = 'MountCtx'; * 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). + * = 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 @@ -2366,9 +2381,15 @@ const MOUNT_CTX_TYPE_NAME = 'MountCtx'; * @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) { +function classifyGlobalDeclaration(declNode, checker, sourceFile, seen = new Set()) { if (declNode.kind === SyntaxKind.BindingElement && declNode.propertyName) { const propName = bindingElementSourceKeyName(declNode); if (propName === 'document') return 'document'; @@ -2388,7 +2409,17 @@ function classifyGlobalDeclaration(declNode, checker) { if (names.includes('Window')) return 'window'; } if (declNode.kind === SyntaxKind.VariableDeclaration && !declNode.type && declNode.initializer) { - return resolveGlobalKind(declNode.initializer, checker); + 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; } @@ -2418,9 +2449,19 @@ function classifyGlobalDeclaration(declNode, checker) { * * @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) { +function resolveGlobalKind(node, checker, sourceFile, seen = new Set()) { const expr = unwrapCastWrappers(node); if (!expr) return null; if (expr.kind === SyntaxKind.Identifier) { @@ -2428,7 +2469,7 @@ function resolveGlobalKind(node, checker) { if (!symbol) return null; const handle = symbol.valueDeclaration ?? symbol.declarations[0]; const declNode = handle?.resolve(); - return declNode ? classifyGlobalDeclaration(declNode, checker) : null; + return declNode ? classifyGlobalDeclaration(declNode, checker, sourceFile, seen) : null; } if (expr.kind === SyntaxKind.PropertyAccessExpression) { if (expr.name.text === 'document') return 'document'; @@ -2446,17 +2487,58 @@ function resolveGlobalKind(node, checker) { if (expr.kind === SyntaxKind.BinaryExpression) { const op = expr.operatorToken.kind; if (op === SyntaxKind.BarBarToken || op === SyntaxKind.QuestionQuestionToken) { - return resolveGlobalKind(expr.left, checker) ?? resolveGlobalKind(expr.right, checker); + return resolveGlobalKind(expr.left, checker, sourceFile, seen) ?? resolveGlobalKind(expr.right, checker, sourceFile, seen); } - if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, checker); + if (op === SyntaxKind.AmpersandAmpersandToken) return resolveGlobalKind(expr.right, checker, sourceFile, seen); return null; } if (expr.kind === SyntaxKind.ConditionalExpression) { - return resolveGlobalKind(expr.whenTrue, checker) ?? resolveGlobalKind(expr.whenFalse, checker); + 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 @@ -2617,18 +2699,58 @@ function resolveObjectCaptureLiteral(node, checker, sourceFile) { return lastEventKnown === false ? null : false; } -/** True when ANY `.capture = …` / `['capture'] = …` assignment +/** 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) 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. This deliberately does - * NOT attempt real control-flow/ordering analysis — unlike - * `resolveObjectCaptureLiteral`'s own property-evaluation-order walk + * 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 @@ -2646,7 +2768,9 @@ function hasCapturePropertyMutation(sourceFile, declNode, checker) { let found = false; walkTree(sourceFile, (node) => { if (found) return; - if (node.kind !== SyntaxKind.BinaryExpression || node.operatorToken.kind !== SyntaxKind.EqualsToken) 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; @@ -2662,11 +2786,7 @@ function hasCapturePropertyMutation(sourceFile, declNode, checker) { } if (propName !== 'capture' || !receiver) return; const rExpr = unwrapCastWrappers(receiver); - if (!rExpr || rExpr.kind !== SyntaxKind.Identifier) return; - const sym = checker.getSymbolAtLocation(rExpr); - if (!sym) return; - const handle = sym.valueDeclaration ?? sym.declarations[0]; - if (handle?.resolve() === declNode) found = true; + if (receiverAliasesDecl(rExpr, checker, declNode, new Set())) found = true; }); return found; } @@ -2940,13 +3060,27 @@ const SHELL_BODY_MOUNT_POLICY = Object.freeze([ * 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) { +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) === 'document'; + return resolveGlobalKind(expr.expression, checker, sourceFile) === 'document'; } if (expr.kind === SyntaxKind.ElementAccessExpression) { const arg = expr.argumentExpression; @@ -2954,7 +3088,7 @@ function resolvesToDocumentBody(node, checker, sourceFile) { arg && (arg.kind === SyntaxKind.StringLiteral || arg.kind === SyntaxKind.NoSubstitutionTemplateLiteral) && arg.text === 'body' ) { - return resolveGlobalKind(expr.expression, checker) === 'document'; + return resolveGlobalKind(expr.expression, checker, sourceFile) === 'document'; } return false; } @@ -2974,12 +3108,14 @@ function resolvesToDocumentBody(node, checker, sourceFile) { if (propName !== 'body') return false; const pattern = declNode.parent; // ObjectBindingPattern const owner = pattern && pattern.parent; // VariableDeclaration - return !!(owner && owner.initializer && resolveGlobalKind(owner.initializer, checker) === 'document'); + return !!(owner && owner.initializer && resolveGlobalKind(owner.initializer, checker, sourceFile) === 'document'); } if (declNode.kind === SyntaxKind.VariableDeclaration && declNode.initializer) { - if (resolvesToDocumentBody(declNode.initializer, checker, sourceFile)) return true; + 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)) return true; + && laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker, seen)) return true; return false; } return false; @@ -2999,8 +3135,12 @@ function resolvesToDocumentBody(node, checker, sourceFile) { * 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. */ -function laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker) { + * 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; @@ -3011,7 +3151,7 @@ function laterAssignmentResolvesToDocumentBody(sourceFile, declNode, checker) { if (!symbol) return; const handle = symbol.valueDeclaration ?? symbol.declarations[0]; if (handle?.resolve() !== declNode) return; - if (resolvesToDocumentBody(node.right, checker, sourceFile)) found = true; + if (resolvesToDocumentBody(node.right, checker, sourceFile, seen)) found = true; }); return found; } @@ -3163,6 +3303,20 @@ const SHELL_CAPTURE_ESCAPE_POLICY = Object.freeze([ * 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) — @@ -3196,15 +3350,23 @@ function captureEscapeCandidates(sourceFile, checker) { walkTree(sourceFile, (node) => { if (node.kind !== SyntaxKind.CallExpression) return; const callee = node.expression; - if (callee.kind !== SyntaxKind.PropertyAccessExpression || callee.name.text !== 'addEventListener') return; + 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; - const evtArg = unwrapCastWrappers(args[0]); - if ( - !evtArg || (evtArg.kind !== SyntaxKind.StringLiteral && evtArg.kind !== SyntaxKind.NoSubstitutionTemplateLiteral) - || evtArg.text !== 'keydown' - ) return; - if (!resolveGlobalKind(callee.expression, checker)) return; // not Document/Window — not a candidate + 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]; diff --git a/tests/unit/resize-handle-thickness-contract.test.js b/tests/unit/resize-handle-thickness-contract.test.js index e568bb1c..9bdb8248 100644 --- a/tests/unit/resize-handle-thickness-contract.test.js +++ b/tests/unit/resize-handle-thickness-contract.test.js @@ -69,6 +69,35 @@ function flatCssRules(cssSource) { })); } +/** 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`, @@ -90,10 +119,14 @@ function flatCssRules(cssSource) { * 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). */ + * 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) { - if (selector.includes('::')) return false; - return new RegExp(`\\.${className}(?![\\w-])`).test(selector); + 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 @@ -140,7 +173,20 @@ function selectorTargetsResizeHandleClass(selector, className) { * 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. */ + * 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)) { @@ -148,8 +194,13 @@ function extractSharedResizeWidthPx(cssSource) { (s) => selectorTargetsResizeHandleClass(s, 'col-resize') || selectorTargetsResizeHandleClass(s, 'inspector-resize'), ); if (!targets) continue; - for (const m of rule.body.matchAll(/\bwidth\s*:\s*([^;]+?)\s*(?:;|$)/g)) { - const numeric = /^(-?\d+(?:\.\d+)?)px(?:\s*!\s*important)?$/i.exec(m[1].trim()); + 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); } } @@ -401,4 +452,34 @@ describe('#592 resize-handle thickness contract sabotage (synthetic — independ 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 index c3a3e0bb..3a66f86a 100644 --- a/tests/unit/shell-guardrails-arch.test.ts +++ b/tests/unit/shell-guardrails-arch.test.ts @@ -719,6 +719,31 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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 @@ -861,6 +886,30 @@ describe('#592 shell-capture-escape: sabotage (each must fail)', () => { 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); @@ -1145,6 +1194,105 @@ describe('#592 shell-capture-escape: declaration-kind-aware scoping sabotage (ea }); }); +// ── 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', () => {