diff --git a/packages/db/src/hierarchy-writer-coverage.test.ts b/packages/db/src/hierarchy-writer-coverage.test.ts index fc5265fd..4c9ee996 100644 --- a/packages/db/src/hierarchy-writer-coverage.test.ts +++ b/packages/db/src/hierarchy-writer-coverage.test.ts @@ -87,7 +87,9 @@ * 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. + * steps deep, a helper function that returns a schema symbol, and an + * export whose expression COMPUTES the value (a ternary, a call result) + * rather than naming a binding or member. * 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 @@ -444,6 +446,33 @@ const IMPORT_RE = const EXPORT_FROM_RE = /export\s*(type\s+)?(?:\{([^}]*)\}|\*(?:\s*as\s+\w+)?)\s*from\s*['"]([^'"]+)['"]/g; +// Member-access shapes, tolerant of optional chaining and non-null +// assertions (ns?.companies, ns!.companies, ns?.['companies']) — a `?.` or +// `!` between receiver and member must not break a match. +const DOT = `\\s*(?:\\?\\.|!\\s*\\.|\\.)\\s*`; +const BRACKET_OPEN = `\\s*(?:\\?\\.|!)?\\s*\\[`; +const MEMBER_SEG = `(?:${DOT}\\w+|${BRACKET_OPEN}\\s*['"\`]\\w+['"\`]\\s*\\])`; +/** Terminal member access matching one of `alt`'s names, dot or bracket form. */ +const memberTail = (alt: string): string => + `(?:${DOT}(?:${alt})\\b|${BRACKET_OPEN}\\s*['"\`](?:${alt})['"\`]\\s*\\])`; +/** + * Reduce an exported expression to its core: strip parenthesization and + * trailing type assertions (`(companies)`, `companies as unknown as object`) + * so `export default ` passes see the binding under the dressing. + */ +function stripExprDressing(raw: string): string { + let expr = raw.trim(); + for (let prev = ''; prev !== expr; ) { + prev = expr; + expr = expr + .replace(/^\(\s*/, '') + .replace(/\s*\)$/, '') + .replace(/\s+as\s+[^()]+$/, '') + .trim(); + } + return expr; +} + interface FileFacts { rel: string; code: string; @@ -543,7 +572,7 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set): Schema 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`, + `export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}${MEMBER_SEG}*${memberTail(CLASS_SYMBOLS.join('|'))}`, 'g', ), )) { @@ -552,24 +581,28 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set): Schema } 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'), + new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*\\(*\\s*${local}\\b`, 'g'), )) { mine.named.add(m[1]!); } } - // The default slot is an export name like any other: - // `export default companies;` / `export default mod.companies;` - for (const m of f.code.matchAll(/export\s+default\s+(\w+)\s*;/g)) { - if (aliases.named.includes(m[1]!)) mine.named.add('default'); - if (aliases.namespaces.includes(m[1]!)) mine.ns.add('default'); - } - for (const ns of aliases.namespaces) { - if ( - new RegExp( - `export\\s+default\\s+${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${CLASS_SYMBOLS.join('|')})\\b`, - ).test(f.code) - ) { - mine.named.add('default'); + // The default slot is an export name like any other, whatever the + // expression dressing: bare local, parenthesized, type-asserted, or + // a namespace member chain in dot or bracket form, semicolon or not. + for (const m of f.code.matchAll(/export\s+default\s+([^;\n]+)/g)) { + const expr = stripExprDressing(m[1]!); + if (/^\w+$/.test(expr)) { + if (aliases.named.includes(expr)) mine.named.add('default'); + if (aliases.namespaces.includes(expr)) mine.ns.add('default'); + } else { + for (const ns of aliases.namespaces) { + if ( + new RegExp(`^${ns}${MEMBER_SEG}*${memberTail(CLASS_SYMBOLS.join('|'))}$`).test(expr) + ) { + mine.named.add('default'); + break; + } + } } } } @@ -681,11 +714,11 @@ function classAliases(f: FileFacts, conduits: SchemaConduits, fileSet: Set 0) { @@ -815,8 +851,32 @@ function computeCapabilityConduits( if (new RegExp(`export\\s+(?:const|let|var|function)\\s+${local}\\b`).test(f.code)) { mine.add(local); } - if (new RegExp(`export\\s+default\\s+${local}\\b`).test(f.code)) { - mine.add('default'); + // A derived binding exported under a NEW name re-exports the + // capability under that name: `export const mk2 = (mk);` + for (const dm of f.code.matchAll( + new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*\\(*\\s*${local}\\b`, 'g'), + )) { + mine.add(dm[1]!); + } + } + } + // The default slot, under any expression dressing — a local factory + // binding (`export default (createDb)`) or a namespace factory member + // (`export default mod.createDb`, `export default mod['createDb']`) — + // mirrors the schema side's default-export pass. + for (const m of f.code.matchAll(/export\s+default\s+([^;\n]+)/g)) { + const expr = stripExprDressing(m[1]!); + if (/^\w+$/.test(expr)) { + if (locals.has(expr)) mine.add('default'); + } else { + for (const [ns, src] of nss) { + const t = new RegExp( + `^${ns}(?:${DOT}(\\w+)\\b|${BRACKET_OPEN}\\s*['"\`](\\w+)['"\`]\\s*\\])$`, + ).exec(expr); + if (t && src.has((t[1] ?? t[2])!)) { + mine.add('default'); + break; + } } } } @@ -837,12 +897,13 @@ function hasSymbolImportEdge(code: string, symbols: string[], packageName: strin ).test(code); const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code); const dynamicEdge = usesSymbol && new RegExp(`import\\s*\\(\\s*['"]${pkg}['"]\\s*\\)`).test(code); - // Namespace form: `import * as ns from ''` + `ns.` usage. + // Namespace form: `import * as ns from ''` + `ns.` usage + // (dot or bracket member, optional-chain/non-null tolerant). let nsEdge = false; for (const m of code.matchAll( new RegExp(`import\\s*\\*\\s*as\\s+(\\w+)\\s*from\\s*['"]${pkg}['"]`, 'g'), )) { - if (new RegExp(`\\b${m[1]}\\s*\\.\\s*(${symbols.join('|')})\\b`).test(code)) nsEdge = true; + if (new RegExp(`\\b${m[1]}${memberTail(symbols.join('|'))}`).test(code)) nsEdge = true; } return staticEdge || dynamicEdge || nsEdge; } @@ -964,9 +1025,9 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { const symAlt = [...aliases.memberSyms].join('|'); for (const ns of aliases.namespaces) { // 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*\\]`); + // namespace re-exports), literal computed access (ns['companies']), + // and optional-chain/non-null markers (ns?.companies, ns!.companies). + targets.push(`${ns}${MEMBER_SEG}*${memberTail(symAlt)}`); } if (targets.length > 0) { // The argument prefix tolerates parenthesization, spread, and array @@ -1013,15 +1074,19 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { // The SQL keyword must be ADJACENT to the table name — co-residence // anywhere in one span over-matches prose ("pnpm workspaces" plus an // unrelated "from" in an embedded doc string is not SQL). Adjacency - // tolerates schema qualification (public.hierarchy_grants), interposed - // block comments, and quoting (including escaped quotes in span text). + // tolerates schema qualification (public.hierarchy_grants), bare + // qualifier words (TABLE, ONLY, IF EXISTS), interposed block comments, + // and quoting (including escaped quotes in span text). 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|on|lock(?:\\s+table)?)'; const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/|--[^\\n]*\\n)+'; + // Bare qualifier words may sit between keyword and table name: + // GRANT … ON TABLE t, DELETE FROM ONLY t, DROP TABLE IF EXISTS t. + const qual = `(?:(?:table|only|if\\s+exists)${gap})*`; const q = `(?:\\\\?["'\`])?`; const sqlAdjacentRe = new RegExp( - `\\b${kw}${gap}${q}(?:${q}\\w+${q}\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`, + `\\b${kw}${gap}${qual}${q}(?:${q}\\w+${q}\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`, 'i', ); for (const span of f.spans) { @@ -1066,7 +1131,7 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') || (aliases.namespaces.length > 0 && new RegExp( - `\\b(${aliases.namespaces.join('|')})(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(createDb|createPgliteDb)\\b`, + `\\b(?:${aliases.namespaces.join('|')})${MEMBER_SEG}*${memberTail('createDb|createPgliteDb')}`, ).test(code)) || // Destructuring a factory symbol OUT of a schema namespace confers // capability whatever the local rename: const { createDb: mk } = dbns. @@ -1102,9 +1167,13 @@ 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). // The receiver may be a dotted name OR a literal bracketed member with - // a conventional name: this['db'].query(…). + // a conventional name — this['db'].query(…) — and the final member + // access tolerates ?. and ! markers (this.db?.query(…)). for (const m of code.matchAll( - /(?:\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)|\[\s*['"](?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)['"]\s*\])\s*\.\s*(execute|query|unsafe)\s*\(/g, + new RegExp( + `(?:\\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)|\\[\\s*['"](?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)['"]\\s*\\])${DOT}(execute|query|unsafe)\\s*\\(`, + 'g', + ), )) { violations.push({ file: rel, @@ -1572,6 +1641,114 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { name: 'E53 bracketed conventional receiver', src: `export class R { async f(t: string) { await this['db'].query('TRUNCATE ' + t); } }`, }, + { + name: 'E54 parenthesized default export of schema symbol', + src: `import c from './evasion-mid18.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid18.ts', + src: `import { companies } from '@mosaicstack/db';\nexport default (companies);`, + }, + ], + }, + { + name: 'E55 bracket-member default export over dynamic namespace', + src: `import c from './evasion-mid19.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid19.ts', + src: `const mod = await import('@mosaicstack/db');\nexport default mod['companies'];`, + }, + ], + }, + { + name: 'E56 type-asserted default export of schema symbol', + src: `import c from './evasion-mid20.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid20.ts', + src: `import { companies } from '@mosaicstack/db';\nexport default companies as unknown as object;`, + }, + ], + }, + { + name: 'E57 export-const bracket member of schema namespace', + src: `import { co } from './evasion-mid21.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(co).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid21.ts', + src: `import * as ns from '@mosaicstack/db';\nexport const co = ns['companies'];`, + }, + ], + }, + { + name: 'E58 default export of dynamic namespace factory member', + src: `import mk from './evasion-mid22.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid22.ts', + src: `const mod = await import('@mosaicstack/db');\nexport default mod.createDb;`, + }, + ], + }, + { + name: 'E59 default export of static namespace factory member', + src: `import mk from './evasion-mid23.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid23.ts', + src: `import * as ns from '@mosaicstack/db';\nexport default ns.createDb;`, + }, + ], + }, + { + name: 'E60 export-const bracket factory member', + src: `import { mk } from './evasion-mid24.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid24.ts', + src: `import * as ns from '@mosaicstack/db';\nexport const mk = ns['createDb'];`, + }, + ], + }, + { + name: 'E61 parenthesized default export of factory local', + src: `import mk from './evasion-mid25.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid25.ts', + src: `import { createDb } from '@mosaicstack/db';\nexport default (createDb);`, + }, + ], + }, + { + name: 'E62 GRANT ON TABLE class table in SQL span', + src: `export const q = 'GRANT SELECT ON TABLE hierarchy_grants TO auditor';`, + }, + { + name: 'E63 DROP TABLE IF EXISTS class table in SQL span', + src: `export const q = 'DROP TABLE IF EXISTS hierarchy_grants';`, + }, + { + name: 'E64 DELETE FROM ONLY class table in SQL span', + src: `export const q = 'DELETE FROM ONLY hierarchy_grants WHERE id = $1';`, + }, + { + name: 'E65 optional-chained namespace write target', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns?.companies).values({}); }`, + }, + { + name: 'E66 non-null-asserted namespace write target', + src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns!.companies).values({}); }`, + }, + { + name: 'E67 optional-chained DI receiver', + src: `export class R { constructor(private db?: { query(s: string): Promise }) {}\n async f(t: string) { await this.db?.query('TRUNCATE ' + t); } }`, + }, + { + name: 'E68 optional-chained bracketed DI receiver', + src: `export class R { async f(t: string) { await this['db']?.query('TRUNCATE ' + t); } }`, + }, ]; const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [ {