From f44db58b0dbe15f553f02a580fa0dc412349b22c Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 17:04:40 -0500 Subject: [PATCH] fix(db): propagate symbol identity through conduits in writer-coverage assertion Round-3 review remediation (G1-G6): - export-map schema conduits: renames propagate through export-from, export-of-local, and export-const-from-namespace routes (G1) - fail-closed namespace destructure over-approximation + depth-2 alias derivation (const t = ns.companies, const { schema } = ns) (G1) - factory capability: consumer-side name gate dropped for conduit imports; export-of-local factory bindings make a module a factory conduit (G2) - createRequire tracked through import aliases and dynamic destructure (G3) - literal dynamic import of a schema/factory/driver source outside the tracked const-await binding shape is a violation (.then/deferred/ Promise.all) (G4) - prong (ii): quoted schema qualifier and SQL line-comment gaps (G5) - computed eval, spaced execution verbs, bracket-form and parenthesized write arguments, spaced sql.raw (G6) - value-flow residual documented in KNOWN RESIDUALS with counterfactuals - 16 new permanent controls (E24-E39) --- .../db/src/hierarchy-writer-coverage.test.ts | 515 ++++++++++++++---- 1 file changed, 418 insertions(+), 97 deletions(-) diff --git a/packages/db/src/hierarchy-writer-coverage.test.ts b/packages/db/src/hierarchy-writer-coverage.test.ts index ec1271df..747e62f4 100644 --- a/packages/db/src/hierarchy-writer-coverage.test.ts +++ b/packages/db/src/hierarchy-writer-coverage.test.ts @@ -9,9 +9,17 @@ * (i) Symbol prong — write references (insert/update/delete) to the * class-table schema symbols occur only in allowlisted modules. * Schema symbols are tracked through named imports (aliased or not), - * namespace imports, and re-export conduits: any scanned module that - * re-exports the schema (or another conduit) is itself treated as a - * schema source, computed to a fixpoint. + * namespace imports, and re-export conduits. Conduits carry an + * EXPORT MAP (per-module named exports and namespace exports), + * computed to a fixpoint, so a rename at the export site + * (`export { companies as c } from`), an export of a locally bound + * alias (`import { companies }; export { companies as co }`), and a + * binding derived from a namespace (`export const co = ns.companies`) + * all propagate symbol identity to the consumer. Destructuring a + * NON-class property from a schema namespace over-approximates: the + * binding is treated as a schema namespace itself (fail-closed), and + * single-alias derivation from a namespace (`const t = ns.companies`) + * is folded in to a fixed depth. * (ii) Literal prong — a class-table name inside a string or template * span that also carries SQL context fails outside the allowlist. * Spans are produced by a real lexer, so comments cannot hide code @@ -28,14 +36,23 @@ * receiver backstop still fires on db/client-shaped receivers. * `sql.raw` is tracked through import aliasing and namespaces. * - * Runtime code-construction primitives (eval, new Function) fail anywhere — - * allowlist and register included: constructed code defeats every static - * prong, so there is no enumerated disposition path for it. Unanalyzable - * import routes (a dynamic import whose specifier is not a single string - * literal, and createRequire) fail everywhere except their own enumerated - * disposition registers below. A LITERAL dynamic import is an import edge - * like any static import (contract rev 9): its bindings (destructured or - * namespace) feed prong (i) exactly as static import bindings do. + * Runtime code-construction primitives (eval, new Function — spelled + * directly or as a literal computed member like `globalThis['eval']`) fail + * anywhere — allowlist and register included: constructed code defeats every + * static prong, so there is no enumerated disposition path for it. + * Unanalyzable import routes fail everywhere except their own enumerated + * disposition registers below: a dynamic import whose specifier is not a + * single string literal, and createRequire (tracked through import aliasing + * — `createRequire as x` — and destructuring from the module namespace; a + * route that hides the NAME entirely, e.g. re-exporting createRequire from a + * helper, falls under the value-flow residual). A LITERAL dynamic import is + * an import edge like any static import (contract rev 9): its bindings + * (destructured or namespace) feed prong (i) exactly as static import + * bindings do — and to keep that claim sound, a literal dynamic import of a + * schema/factory/driver source OUTSIDE the tracked + * `const X = await import('…')` binding shape (`.then` chains, deferred + * awaits, Promise.all) is itself a violation: bindings the analyzer cannot + * track are not allowed to exist for capability-bearing modules. * * KNOWN RESIDUALS (deliberate, reviewed trade-offs — not claims of closure): * - In a file with NO detected raw capability (no driver/factory import on @@ -50,6 +67,17 @@ * statically resolvable; literal computed access (obj['insert']()) is * flagged. Constructing the verb at runtime is adjacent to eval and is * expected to be caught in review. + * - General VALUE FLOW is not modeled. The analyzer tracks import edges, + * re-export chains, and single-step alias/destructure derivations from a + * namespace binding (to depth 2) — not arbitrary assignment chains, + * function returns, or method extraction. Demonstrated escapes in this + * class: `const u = this.client.unsafe; u.call(this.client, s)` (the + * verb never appears as a member call), an alias chain three or more + * steps deep, and a helper function that returns a schema symbol. + * Closing it requires data-flow analysis (a type-checker-backed rewrite, + * tracked for M4-1b consideration); the counterfactual — flagging every + * bare identifier call — false-positives on essentially all callback + * code. Reviews of modules touching db handles carry this residual. * - The scan perimeter is //src for the three roots; production * TS outside a src/ directory (e.g. packages/mosaic/framework/**) is not * scanned (verified free of db/driver/execute references at review time). @@ -415,46 +443,109 @@ interface Violation { } /** - * Compute the fixpoint set of "schema sources": module paths from which a - * class-table symbol can be imported. Seeds: the schema module and the db - * package barrel (plus the bare '@mosaicstack/db' specifier, handled - * separately). Any scanned module that re-exports from a schema source - * (star, or a named list carrying a class symbol) — or imports class symbols - * and re-exports those local names — joins the set. + * Schema exports of one module in the conduit graph: `named` are exported + * identifiers bound to a class-table symbol (under WHATEVER exported name — + * renames propagate); `ns` are exported identifiers that are themselves + * namespaces over a schema source (`export * as x from …`). */ -function computeSchemaConduits(files: FileFacts[], fileSet: Set): Set { - const conduits = new Set(['packages/db/src/schema.ts', 'packages/db/src/index.ts']); - const isSchemaSpec = (rel: string, spec: string): boolean => { - if (spec === '@mosaicstack/db') return true; - const resolved = resolveSpecifier(rel, spec, fileSet); - return resolved !== null && conduits.has(resolved); - }; +interface SchemaExports { + named: Set; + ns: Set; +} +type SchemaConduits = Map; + +/** Exported class-symbol names reachable through `spec` from `rel` (null = not a schema source). */ +function schemaExportsOf( + rel: string, + spec: string, + conduits: SchemaConduits, + fileSet: Set, +): SchemaExports | null { + if (spec === '@mosaicstack/db') return { named: new Set(CLASS_SYMBOLS), ns: new Set() }; + const r = resolveSpecifier(rel, spec, fileSet); + return r !== null ? (conduits.get(r) ?? null) : null; +} + +/** + * Compute the fixpoint map of "schema sources": module → the exported names + * under which a class-table symbol (or a schema namespace) is reachable from + * it. Seeds: the schema module and the db package barrel (the bare + * '@mosaicstack/db' specifier is handled in schemaExportsOf). Renames are + * propagated: `export { companies as c } from …` exports `c`, an + * `export { x as y }` of a local class binding exports `y`, and + * `export const y = ns.companies` over a schema namespace exports `y`. + */ +function computeSchemaConduits(files: FileFacts[], fileSet: Set): SchemaConduits { + const conduits: SchemaConduits = new Map([ + ['packages/db/src/schema.ts', { named: new Set(CLASS_SYMBOLS), ns: new Set() }], + ['packages/db/src/index.ts', { named: new Set(CLASS_SYMBOLS), ns: new Set() }], + ]); let changed = true; while (changed) { changed = false; for (const f of files) { - if (conduits.has(f.rel)) continue; - let isConduit = false; + const mine: SchemaExports = conduits.get(f.rel) ?? { + named: new Set(), + ns: new Set(), + }; + const before = mine.named.size + mine.ns.size; for (const m of f.code.matchAll(EXPORT_FROM_RE)) { if (m[1]) continue; // export type — erased - if (!isSchemaSpec(f.rel, m[3]!)) continue; - if (m[2] === undefined) { - isConduit = true; // export * from schema source - } else if (CLASS_SYMBOLS.some((s) => new RegExp(`\\b${s}\\b`).test(m[2]!))) { - isConduit = true; + const src = schemaExportsOf(f.rel, m[3]!, conduits, fileSet); + if (src === null || (src.named.size === 0 && src.ns.size === 0)) continue; + const starAs = /export\s*\*\s*as\s+(\w+)/.exec(m[0]); + if (starAs) { + mine.ns.add(starAs[1]!); // export * as x from schema source + } else if (m[2] === undefined) { + for (const n of src.named) mine.named.add(n); // export * from … + for (const n of src.ns) mine.ns.add(n); + } else { + for (const part of m[2].split(',')) { + const seg = part.trim(); + if (!seg || seg.startsWith('type ')) continue; + const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg); + const original = asMatch ? asMatch[1]! : seg; + const exported = asMatch ? asMatch[2]! : seg; + if (src.named.has(original)) mine.named.add(exported); + if (src.ns.has(original)) mine.ns.add(exported); + } } } - if (!isConduit) { - const aliases = classAliases(f, conduits, fileSet); - if (aliases.named.length > 0) { - const exported = [...f.code.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)] - .map((m) => m[1]!) - .join(','); - if (aliases.named.some((a) => new RegExp(`\\b${a}\\b`).test(exported))) isConduit = true; + // Exports of local bindings: `export { x as y }` where x is a local + // class alias (or namespace), and `export const y = ns.`. + const aliases = classAliases(f, conduits, fileSet); + if (aliases.named.length > 0 || aliases.namespaces.length > 0) { + for (const m of f.code.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)) { + for (const part of m[1]!.split(',')) { + const seg = part.trim(); + if (!seg || seg.startsWith('type ')) continue; + const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg); + const local = asMatch ? asMatch[1]! : seg; + const exported = asMatch ? asMatch[2]! : seg; + if (aliases.named.includes(local)) mine.named.add(exported); + if (aliases.namespaces.includes(local)) mine.ns.add(exported); + } + } + for (const ns of aliases.namespaces) { + for (const m of f.code.matchAll( + new RegExp( + `export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${CLASS_SYMBOLS.join('|')})\\b`, + 'g', + ), + )) { + mine.named.add(m[1]!); + } + } + for (const local of aliases.named) { + for (const m of f.code.matchAll( + new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${local}\\b`, 'g'), + )) { + mine.named.add(m[1]!); + } } } - if (isConduit) { - conduits.add(f.rel); + if (mine.named.size + mine.ns.size > before) { + conduits.set(f.rel, mine); changed = true; } } @@ -467,48 +558,60 @@ interface ClassAliases { namespaces: string[]; // namespace identifiers over a schema source } -/** Parse an import named-binding list ("a, b as c") into locals bound to class symbols. */ -function importBindings(namedList: string, named: Set): void { +/** + * Parse an import named-binding list ("a, b as c") against the SOURCE's + * schema exports, adding locals bound to class symbols / schema namespaces. + */ +function importBindings( + namedList: string, + src: SchemaExports, + named: Set, + namespaces: Set, +): void { for (const part of namedList.split(',')) { const seg = part.trim(); if (!seg || seg.startsWith('type ')) continue; const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg); const original = asMatch ? asMatch[1]! : seg; const local = asMatch ? asMatch[2]! : seg; - if (CLASS_SYMBOLS.includes(original)) named.add(local); + if (src.named.has(original)) named.add(local); + if (src.ns.has(original)) namespaces.add(local); } } -/** Parse a destructuring pattern ("a, b: c") into locals bound to class symbols. */ -function destructureBindings(pattern: string, named: Set): void { +/** + * Parse a destructuring pattern ("a, b: c, d = x") over a schema source. + * A class-symbol property binds a named alias; ANY other destructured + * property is over-approximated as a schema namespace (it may be a nested + * namespace such as `const { schema } = ns` — fail-closed). + */ +function destructureBindings( + pattern: string, + src: SchemaExports, + named: Set, + namespaces: Set, +): void { for (const part of pattern.split(',')) { const seg = part.trim(); if (!seg) continue; - const m = /^(\w+)\s*(?::\s*(\w+))?$/.exec(seg); - if (m && CLASS_SYMBOLS.includes(m[1]!)) named.add(m[2] ?? m[1]!); + const m = /^(\w+)\s*(?::\s*(\w+))?\s*(?:=[\s\S]*)?$/.exec(seg); + if (!m) continue; + const local = m[2] ?? m[1]!; + if (src.named.has(m[1]!)) named.add(local); + else namespaces.add(local); } } -function isSchemaSpecifier( - rel: string, - spec: string, - conduits: Set, - fileSet: Set, -): boolean { - if (spec === '@mosaicstack/db') return true; - const r = resolveSpecifier(rel, spec, fileSet); - return r !== null && conduits.has(r); -} - -function classAliases(f: FileFacts, conduits: Set, fileSet: Set): ClassAliases { +function classAliases(f: FileFacts, conduits: SchemaConduits, fileSet: Set): ClassAliases { const named = new Set(); const namespaces = new Set(); for (const m of f.code.matchAll(IMPORT_RE)) { const [, typeOnly, , namedList, nsName, , spec] = m; if (typeOnly) continue; - if (!isSchemaSpecifier(f.rel, spec!, conduits, fileSet)) continue; + const src = schemaExportsOf(f.rel, spec!, conduits, fileSet); + if (src === null || (src.named.size === 0 && src.ns.size === 0)) continue; if (nsName) namespaces.add(nsName); - if (namedList) importBindings(namedList, named); + if (namedList) importBindings(namedList, src, named, namespaces); } // Literal dynamic imports of a schema source are import edges like any // other (contract rev 9): both binding shapes feed prong (i). @@ -516,17 +619,33 @@ function classAliases(f: FileFacts, conduits: Set, fileSet: Set) /(?:const|let|var)\s*(?:\{([^}]*)\}|(\w+))\s*=\s*await\s+import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, )) { const [, pattern, nsName, , spec] = m; - if (!isSchemaSpecifier(f.rel, spec!, conduits, fileSet)) continue; + const src = schemaExportsOf(f.rel, spec!, conduits, fileSet); + if (src === null || (src.named.size === 0 && src.ns.size === 0)) continue; if (nsName) namespaces.add(nsName); - if (pattern) destructureBindings(pattern, named); + if (pattern) destructureBindings(pattern, src, named, namespaces); } - // Destructuring from a schema namespace binds class symbols to locals: - // `import * as s from '@mosaicstack/db'; const { companies } = s;` - for (const ns of [...namespaces]) { - for (const m of f.code.matchAll( - new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${ns}\\b`, 'g'), - )) { - destructureBindings(m[1]!, named); + // Aliases derived FROM a schema namespace, to a bounded depth (2 passes): + // const { companies } = ns; → named alias + // const { schema } = ns; → nested namespace (fail-closed) + // const t = ns.companies; → named alias + // const s2 = ns.schema; → namespace alias + const nsSrc: SchemaExports = { named: new Set(CLASS_SYMBOLS), ns: new Set() }; + for (let pass = 0; pass < 2; pass += 1) { + for (const ns of [...namespaces]) { + for (const m of f.code.matchAll( + new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?${ns}\\b`, 'g'), + )) { + destructureBindings(m[1]!, nsSrc, named, namespaces); + } + for (const m of f.code.matchAll( + new RegExp( + `(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(\\w+)\\b`, + 'g', + ), + )) { + if (CLASS_SYMBOLS.includes(m[2]!)) named.add(m[1]!); + else namespaces.add(m[1]!); + } } } return { named: [...named], namespaces: [...namespaces] }; @@ -540,8 +659,10 @@ function classAliases(f: FileFacts, conduits: Set, fileSet: Set) * capability (the conduit module itself is additionally flagged by prong * (iii)'s literal check, so these routes fail at both ends). * `factory` — re-exports createDb/createPgliteDb from the db package or - * another factory conduit; importing from one while referencing a factory - * symbol confers factory capability. + * another factory conduit (via `export … from`, or by exporting a local + * binding of the factory under any name); importing ANYTHING from one + * confers factory capability — the conduit may rename the symbol, so there + * is no consumer-side name gate. */ function computeCapabilityConduits( files: FileFacts[], @@ -580,6 +701,58 @@ function computeCapabilityConduits( } // `export { default as x } from 'postgres'` matches EXPORT_FROM_RE's // named branch above; `export x from` is not valid syntax — covered. + // Export-of-local factory bindings: a module that BINDS the factory + // (named import, namespace member, or tracked dynamic import) and + // exports that binding under any name is a factory conduit even with + // no `export … from` clause (`export const mk = mod.createDb`). + if (!factory.has(f.rel)) { + const locals = new Set(); + const nss = new Set(); + for (const im of f.code.matchAll(IMPORT_RE)) { + const [, typeOnly, , namedList, nsName, , spec] = im; + if (typeOnly || !isFactorySpec(f.rel, spec!)) continue; + if (nsName) nss.add(nsName); + if (namedList) { + for (const part of namedList.split(',')) { + const am = /^(createDb|createPgliteDb)(?:\s+as\s+(\w+))?$/.exec(part.trim()); + if (am) locals.add(am[2] ?? am[1]!); + } + } + } + for (const dm of f.code.matchAll( + /(?:const|let|var)\s*(?:\{([^}]*)\}|(\w+))\s*=\s*await\s+import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, + )) { + const [, pattern, nsName, , spec] = dm; + if (!isFactorySpec(f.rel, spec!)) continue; + if (nsName) nss.add(nsName); + if (pattern) { + for (const part of pattern.split(',')) { + const pm = /^(createDb|createPgliteDb)\s*(?::\s*(\w+))?/.exec(part.trim()); + if (pm) locals.add(pm[2] ?? pm[1]!); + } + } + } + for (const ns of nss) { + for (const am of f.code.matchAll( + new RegExp( + `(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}\\s*\\.\\s*(?:createDb|createPgliteDb)\\b`, + 'g', + ), + )) { + locals.add(am[1]!); + } + } + for (const local of locals) { + if ( + new RegExp(`export\\s+(?:const|let|var|function)\\s+${local}\\b`).test(f.code) || + new RegExp(`export\\s*\\{[^}]*\\b${local}\\b[^}]*\\}`).test(f.code) + ) { + factory.add(f.rel); + changed = true; + break; + } + } + } } } return { driver, factory }; @@ -609,7 +782,7 @@ function hasSymbolImportEdge(code: string, symbols: string[], packageName: strin // --------------------------------------------------------------------------- interface AnalysisCtx { fileSet: Set; - conduits: Set; // schema-symbol sources (prong i) + conduits: SchemaConduits; // schema-symbol sources with exported names (prong i) driverConduits: Set; // driver-capability re-exporters (prong iii) factoryConduits: Set; // factory-capability re-exporters (prong iii) } @@ -623,39 +796,90 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { const inRegister = INFRA_REGISTER.includes(rel); const isSchemaDefinition = rel === 'packages/db/src/schema.ts'; - // Runtime code construction: fails anywhere. - if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(code)) { + // Runtime code construction: fails anywhere. Covers direct calls and + // literal computed access (window['eval'], globalThis['Function']). + if (/\beval\s*\(|\bnew\s+Function\s*\(|\[\s*['"](eval|Function)['"]\s*\]/.test(code)) { violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' }); } // Dynamic import whose specifier is not a single string literal: the // import graph becomes unanalyzable. Checked per call site, so a literal - // first fragment ('x' + y) does not slip past. + // first fragment ('x' + y) does not slip past. A LITERAL import of a + // schema/factory source must additionally sit in the tracked binding shape + // (const X = await import(…)) — any other consumption of its promise + // (.then, deferred await, array wrapping) hides the binding from prong (i), + // so it fails closed here. if (!DYNAMIC_IMPORT_REGISTER.includes(rel)) { for (const m of code.matchAll(/\bimport\s*\(/g)) { - const tail = code.slice((m.index ?? 0) + m[0].length); - if (!/^\s*(['"])(?:[^'"\\]|\\.)*?\1\s*[,)]/.test(tail)) { + const idx = m.index ?? 0; + const tail = code.slice(idx + m[0].length); + const lit = /^\s*(['"])((?:[^'"\\]|\\.)*?)\1\s*[,)]/.exec(tail); + if (!lit) { violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' }); + continue; + } + const spec = lit[2]!; + const schemaSrc = schemaExportsOf(rel, spec, conduits, fileSet); + const factoryLike = + spec === '@mosaicstack/db' || + (() => { + const r = resolveSpecifier(rel, spec, fileSet); + return r !== null && (factoryConduits.has(r) || driverConduits.has(r)); + })() || + DRIVER_SPECIFIERS.includes(spec); + if (schemaSrc !== null || factoryLike) { + const before = code.slice(Math.max(0, idx - 200), idx); + if (!/(?:const|let|var)\s*(?:\{[^}]*\}|\w+)\s*=\s*await\s*$/.test(before)) { + violations.push({ + file: rel, + prong: 'dynamic-import', + detail: `literal import('${spec}') outside the tracked binding shape`, + }); + } } } } - // createRequire: an unanalyzable CJS import route. - if (!CREATE_REQUIRE_REGISTER.includes(rel) && /\bcreateRequire\s*\(/.test(code)) { - violations.push({ file: rel, prong: 'dynamic-import', detail: 'createRequire()' }); + // createRequire: an unanalyzable CJS import route. Tracked by name AND + // through import aliases (import { createRequire as x } / destructured + // from a dynamic module import). + if (!CREATE_REQUIRE_REGISTER.includes(rel)) { + const crNames = new Set(); + if (/\bcreateRequire\s*\(/.test(code)) crNames.add('createRequire'); + for (const m of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"](?:node:)?module['"]/g)) { + const alias = /\bcreateRequire\s+as\s+(\w+)/.exec(m[1]!); + if (alias) crNames.add(alias[1]!); + } + for (const m of code.matchAll( + /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*await\s+import\s*\(\s*['"](?:node:)?module['"]\s*\)/g, + )) { + const alias = /\bcreateRequire\s*:\s*(\w+)/.exec(m[1]!); + if (alias) crNames.add(alias[1]!); + } + for (const n of crNames) { + if (n === 'createRequire' || new RegExp(`\\b${n}\\s*\\(`).test(code)) { + violations.push({ file: rel, prong: 'dynamic-import', detail: 'createRequire()' }); + break; + } + } } const aliases = classAliases(f, conduits, fileSet); // Prong (i): schema-symbol writes — alias-, namespace-, and conduit-aware. if (!inAllowlist) { - const targets: string[] = [...aliases.named]; + // Word boundaries live inside each alternative: the bracket form ends in + // `]` (non-word), where a trailing `\b` could never match. + const targets: string[] = [...aliases.named].map((a) => `${a}\\b`); + const symAlt = CLASS_SYMBOLS.join('|'); for (const ns of aliases.namespaces) { - // Allow intermediate property segments: ns.schema.companies (nested - // namespace re-exports) as well as ns.companies. - for (const s of CLASS_SYMBOLS) targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*${s}`); + // Allow intermediate property segments (ns.schema.companies — nested + // namespace re-exports) and literal computed access (ns['companies']). + targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${symAlt})\\b`); + targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\[\\s*['"\`](?:${symAlt})['"\`]\\s*\\]`); } if (targets.length > 0) { + // `\(\s*\(*` tolerates argument parenthesization: .insert((companies)). const writeRe = new RegExp( - `\\.\\s*(insert|update|delete)\\s*\\(\\s*(${targets.join('|')})\\b`, + `\\.\\s*(insert|update|delete)\\s*\\(\\s*\\(*\\s*(${targets.join('|')})`, 'g', ); for (const m of code.matchAll(writeRe)) { @@ -701,10 +925,10 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { if (!inAllowlist && !isSchemaDefinition) { const kw = '(?:insert\\s+into|update|delete\\s+from|from|join|truncate(?:\\s+table)?|alter\\s+table|drop\\s+table|references|into|copy)'; - const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/)+'; + const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/|--[^\\n]*\\n)+'; const q = `(?:\\\\?["'\`])?`; const sqlAdjacentRe = new RegExp( - `\\b${kw}${gap}${q}(?:\\w+\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`, + `\\b${kw}${gap}${q}(?:${q}\\w+${q}\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`, 'i', ); for (const span of f.spans) { @@ -733,20 +957,22 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { if (r !== null && factoryConduits.has(r)) factoryConduitImport = true; } const driverImport = literalDriver || conduitDriver; - const usesFactorySymbol = /\b(createDb|createPgliteDb)\b/.test(code); + // Factory capability: a named/namespace edge to the factory symbols, OR + // ANY value import from a factory conduit — the conduit may re-export + // the factory under a different name (export { createDb as default }), + // so the consumer-side name gate is dropped for conduit imports. const factoryImport = + factoryConduitImport || hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') || - (factoryConduitImport && usesFactorySymbol) || (aliases.namespaces.length > 0 && - usesFactorySymbol && new RegExp( `\\b(${aliases.namespaces.join('|')})(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(createDb|createPgliteDb)\\b`, ).test(code)); if (driverImport) { violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'direct driver import' }); // A driver client exposes query/execute/unsafe as raw primitives: - // flag them all on any receiver, any argument. - for (const m of code.matchAll(/\.(execute|query|unsafe)\s*\(/g)) { + // flag them all on any receiver, any argument, spaced or not. + for (const m of code.matchAll(/\.\s*(execute|query|unsafe)\s*\(/g)) { violations.push({ file: rel, prong: 'iii-raw-execution', @@ -758,7 +984,7 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { // the relational builder, and unrelated .query() methods are common), // so factory capability flags execute/unsafe on any receiver and query // only on the db/client-shaped receiver backstop below. - for (const m of code.matchAll(/\.(execute|unsafe)\s*\(/g)) { + for (const m of code.matchAll(/\.\s*(execute|unsafe)\s*\(/g)) { violations.push({ file: rel, prong: 'iii-raw-execution', @@ -770,7 +996,7 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { // DI residual backstop: db/client-shaped receivers fire regardless of // detected capability (a handle can arrive by injection). for (const m of code.matchAll( - /\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)\.(execute|query|unsafe)\s*\(/g, + /\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)\s*\.\s*(execute|query|unsafe)\s*\(/g, )) { violations.push({ file: rel, @@ -793,10 +1019,10 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { else if (asMatch) sqlAliases.add(asMatch[1]!); } } - if (nsName) sqlAliases.add(`${nsName}\\.sql`); + if (nsName) sqlAliases.add(`${nsName}\\s*\\.\\s*sql`); } if (sqlAliases.size > 0) { - const rawRe = new RegExp(`\\b(${[...sqlAliases].join('|')})\\.raw\\s*\\(`); + const rawRe = new RegExp(`\\b(${[...sqlAliases].join('|')})\\s*\\.\\s*raw\\s*\\(`); if (rawRe.test(code)) { violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'sql.raw()' }); } @@ -1035,6 +1261,101 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { name: 'E23 escaped-backtick-quoted table in SQL span', src: 'export const q = `DELETE FROM \\`hierarchy_grants\\` WHERE role = 1`;', }, + // --- round-3 review shapes (G1–G6) --- + { + name: 'E24 export-site rename through conduit', + src: `import { c } from './evasion-mid4.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid4.ts', + src: `export { companies as c } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E25 export of locally bound alias, renamed', + src: `import { co } from './evasion-mid5.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(co).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid5.ts', + src: `import { companies } from '@mosaicstack/db';\nexport { companies as co };`, + }, + ], + }, + { + name: 'E26 alias-export helper over dynamic namespace', + src: `import { co } from './evasion-mid6.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(co).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid6.ts', + src: `const mod = await import('@mosaicstack/db');\nexport const co = mod.companies;`, + }, + ], + }, + { + name: 'E27 single-const alias from namespace', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nconst t = ns.companies;\nexport async function f() { await db.insert(t).values({}); }`, + }, + { + name: 'E28 non-class destructure from namespace (fail-closed)', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nconst { schema } = ns;\nexport async function f() { await db.insert(schema.companies).values({}); }`, + }, + { + name: 'E29 destructure with default value', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nconst { companies: co = null } = ns;\nexport async function f() { await db.insert(co).values({}); }`, + }, + { + name: 'E30 factory renamed to default through conduit', + src: `import createDbNow from './evasion-mid7.js';\nexport async function f(t: string) { await createDbNow('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid7.ts', + src: `export { createDb as default } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E31 factory alias-export helper over dynamic namespace', + src: `import { mk } from './evasion-mid8.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid8.ts', + src: `const mod = await import('@mosaicstack/db');\nexport const mk = mod.createDb;`, + }, + ], + }, + { + name: 'E32 createRequire import alias', + src: `import { createRequire as mkReq } from 'node:module';\nconst req = mkReq(import.meta.url);\nexport const pg = req('postgres');`, + }, + { + name: 'E33 then-form dynamic schema import', + src: `import { db } from './x.js';\nexport function f() { return import('@mosaicstack/db').then((m) => db.insert(m.companies).values({})); }`, + }, + { + name: 'E34 quoted schema qualifier in SQL span', + src: `export const q = 'DELETE FROM "public".hierarchy_grants WHERE role = $1';`, + }, + { + name: 'E35 SQL line comment interposed in SQL span', + src: 'export const q = `DELETE FROM -- audit\nhierarchy_grants`;', + }, + { + name: 'E36 spaced member access on conventional receiver', + src: `export class R { constructor(private db: { query(s: string): Promise }) {}\n async f(t: string) { await this.db . query ('TRUNCATE ' + t); } }`, + }, + { + name: 'E37 bracket-form schema argument', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns['companies']).values({}); }`, + }, + { + name: 'E38 parenthesized schema argument', + src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert((companies)).values({}); }`, + }, + { + name: 'E39 computed-member eval', + src: `export function f(s: string) { return (globalThis as never)['eval'](s); }`, + }, ]; const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [ {