From 8305d129a2249c128f982638fa94bc1cb65ce474 Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 16:15:53 -0500 Subject: [PATCH] fix(db): harden writer-coverage assertion per M4-1a review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings F1-F8 (REQUEST_CHANGES) addressed: - F5: replace regex comment stripping with a string-aware lexer producing comment-free code plus accurate string/template spans (handles nesting, regex literals, escapes) - F3: prong (i) now tracks namespace imports and re-export conduits via a fixpoint over the scanned import graph - F1/F2: tagged-template exemption dropped; backlog.ts joins the infra register; capability-gated prong (iii) — driver import flags any-receiver execute/query/unsafe, factory import flags any-receiver execute/unsafe, db-shaped receiver backstop covers DI'd handles; sql.raw tracked through aliases and namespaces - F4: closed importer enumeration added for createDb/createPgliteDb; composition property of remaining register modules documented - F6: plugins/ added to scan roots - F7: per-parent slug scoping witnessed at platform_projects and workspaces - F8: dynamic-import disposition register added; stricter-than-contract stances documented in the header The review's 8 evasion samples are embedded as permanent controls run through the production analyzer, plus clean controls guarding against false positives. --- .../db/src/hierarchy-schema.witness.test.ts | 30 + .../db/src/hierarchy-writer-coverage.test.ts | 799 ++++++++++++++---- 2 files changed, 653 insertions(+), 176 deletions(-) diff --git a/packages/db/src/hierarchy-schema.witness.test.ts b/packages/db/src/hierarchy-schema.witness.test.ts index f4e40b93..ff226e84 100644 --- a/packages/db/src/hierarchy-schema.witness.test.ts +++ b/packages/db/src/hierarchy-schema.witness.test.ts @@ -265,6 +265,36 @@ function witnessSuite(getHandle: () => AnyDb): void { ); }); + it('scopes platform_projects and workspaces slugs per parent (refuse same-parent duplicate, accept cross-parent)', async () => { + // Dedicated parent estate so this test leaves estate2 a leaf (the §3.4 + // cascade witness depends on that). + const estate3Id = randomUUID(); + await db() + .insert(estates) + .values({ id: estate3Id, name: 'Estate 3', slug: `${T}-e3`, companyId }); + // platform_projects: (estate_id, slug) unique. + await expectViolation( + db() + .insert(platformProjects) + .values({ id: randomUUID(), name: 'dup', slug: `${T}-pp1`, estateId }), + /duplicate key|unique/i, + ); + const pp2Id = randomUUID(); + await db() + .insert(platformProjects) + .values({ id: pp2Id, name: 'ok', slug: `${T}-pp1`, estateId: estate3Id }); + // workspaces: (platform_project_id, slug) unique. + await expectViolation( + db() + .insert(workspaces) + .values({ id: randomUUID(), name: 'dup', slug: `${T}-ws1`, platformProjectId: ppId }), + /duplicate key|unique/i, + ); + await db() + .insert(workspaces) + .values({ id: randomUUID(), name: 'ok', slug: `${T}-ws1`, platformProjectId: pp2Id }); + }); + // ── §6.2 column allowlist ────────────────────────────────────────────────── it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => { diff --git a/packages/db/src/hierarchy-writer-coverage.test.ts b/packages/db/src/hierarchy-writer-coverage.test.ts index e9853966..af973c62 100644 --- a/packages/db/src/hierarchy-writer-coverage.test.ts +++ b/packages/db/src/hierarchy-writer-coverage.test.ts @@ -2,46 +2,65 @@ * Hierarchy writer-coverage assertion — contract 1 * (docs/requirements/hierarchy-schema.md) §6.3(b). * - * A static CI assertion over the Gateway and package production sources with - * three prongs, each bound to a closed, explicitly enumerated allowlist: + * A static CI assertion over all production sources (apps/, packages/, + * plugins/) with three prongs, each bound to a closed, explicitly enumerated + * allowlist: * * (i) Symbol prong — write references (insert/update/delete) to the * class-table schema symbols occur only in allowlisted modules. - * Import aliasing is followed: `import { companies as c }` makes `c` - * a class-table symbol in that file. - * (ii) Literal prong — a class-table name inside a SQL string or tagged - * SQL template outside the allowlist fails. Schema definitions and - * generated migrations are excluded from this prong (per contract). - * (iii) Raw-execution prong — raw-SQL execution primitives (the ORM's - * raw/unsafe constructors, driver-level clients) outside the writer - * allowlist and the infrastructure register fail, regardless of SQL - * content. Direct database-driver imports count as raw-execution - * capability: they are what makes dynamically assembled SQL - * executable, and the import is statically detectable even when the - * SQL string is not. + * 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. + * (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 + * and strings cannot hide comments. Schema definitions and generated + * migrations are excluded from this prong only (per contract). + * (iii) Raw-execution prong — content-independent. A file is RAW-CAPABLE + * when it imports a database driver or the createDb/createPgliteDb + * factories (directly or via namespace). In a raw-capable file + * outside the allowlist and register, EVERY `.execute(`, `.query(`, + * and `.unsafe(` call fails, on any receiver, with any argument — + * there is no tagged-template exemption (§6.3(b): "regardless of what + * the SQL string contains or how it is constructed"). In files + * without detected capability (the DI residual), a conventional + * 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) and non-literal - * dynamic imports fail anywhere — allowlist and register included. + * dynamic imports fail anywhere — allowlist and register included. This is + * deliberately stricter than the contract's minimum: an unanalyzable import + * or constructed code defeats every static prong, so there is no enumerated + * disposition path for them. * * The writer allowlist names hierarchy command/repository modules ONLY. It is * empty today: the hierarchy command family (M4-1b) has not landed, so no * production module may write the class tables. The infrastructure register - * holds legitimate non-hierarchy raw execution (migration runner, storage - * adapters, health probes); registered modules are exempt from prong (iii) - * only — prongs (i) and (ii) apply to them with no exemption, and no - * registered module may appear on the writer allowlist. Neither list may take - * a generic raw-SQL helper, and an allowlisted module must not export a - * function that executes caller-supplied SQL (review-enforced, §5.1). + * holds legitimate non-hierarchy raw execution; registered modules are exempt + * from prong (iii) only — prongs (i) and (ii) apply to them with no + * exemption, and no registered module may appear on the writer allowlist. + * + * Register modules whose exports let a CALLER reach SQL execution carry their + * own closed importer enumerations (the laundering path §6.3(b) closes): + * the migration runner, migrate-tier, and the createDb/createPgliteDb + * factories. The remaining registered modules execute only fixed statements + * or collection-CRUD over the storage `(id, data)` shape — which cannot + * address class-table columns — and export no caller-supplied-SQL surface; + * that composition property is review-enforced (§5.1) on any change to them. * * A false positive is resolved in the same PR by adding the module to the one * enumerated list its role permits — never by weakening the assertion. * * Test files (*.spec.*, *.test.*, __tests__/) are not scanned: they are not * production mutation paths, and the contract's own §6 witnesses must write - * class tables directly to witness database constraints. + * class tables directly to witness database constraints. The known evasion + * forms from the M4-1a review are kept below as permanent controls: the + * analyzer must flag every one of them, so a regression that reopens an + * escape fails this suite. */ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; -import { join, relative, resolve, sep } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -63,7 +82,8 @@ const CLASS_TABLES = [ * Writer allowlist (§6.3b): hierarchy command/repository modules only. * EMPTY until the hierarchy command family lands (M4-1b). Adding a module * here is a contract-conformance decision reviewed under §5.1 — the module - * must be part of the Gateway hierarchy command path. + * must be part of the Gateway hierarchy command path, and it must not export + * a function that executes caller-supplied SQL. */ const WRITER_ALLOWLIST: string[] = []; @@ -76,9 +96,10 @@ const INFRA_REGISTER: string[] = [ 'packages/db/src/client.ts', // connection factory (imports postgres driver) 'packages/db/src/client-pglite.ts', // PGlite factory (imports the pglite driver) 'packages/db/src/migrate.ts', // migration runner (hash-ledger DDL execution) + 'packages/db/src/backlog.ts', // backlog domain module: parameterized sql`` over backlog tables only 'packages/memory/src/insights.ts', // analytics raw query over memory tables - 'packages/storage/src/tier-detection.ts', // driver import for tier probing - 'packages/storage/src/adapters/pglite.ts', // storage adapter (driver-level query) + 'packages/storage/src/tier-detection.ts', // driver import for tier probing (fixed statements) + 'packages/storage/src/adapters/pglite.ts', // storage adapter (collection CRUD over (id, data)) 'packages/storage/src/adapters/postgres.ts', // storage adapter (extension bootstrap) 'packages/storage/src/migrate-tier.ts', // storage tier migration 'packages/storage/src/cli.ts', // storage CLI health probe @@ -86,17 +107,13 @@ const INFRA_REGISTER: string[] = [ ]; /** - * Closed importer enumerations for registered modules that EXPORT - * SQL-executing functions (the laundering path §6.3b closes). Import edges - * are checked re-export-aware — the db package barrel and literal dynamic - * `import('@mosaicstack/db')` are edges like any static import. The measured - * production importer set of the migration runner (contract 1 revision 9): - * the Gateway database module, the storage Postgres adapter, and two mosaic - * CLI commands routed through literal dynamic imports. The gateway - * schema-check module receives the runner's functions by parameter injection - * and has no import edge, so it is not enumerated. Being enumerated confers - * nothing else: importers stay subject to prongs (i)/(ii) and gain no writer - * standing. + * Closed importer enumerations for registered modules whose exports execute + * SQL or hand out an executing handle. An import edge is a static value + * import naming the symbol (from the module path or the package barrel), or + * a literal dynamic import of the package in a file using the symbol. + * `import type` is erased and is not an edge; parameter injection (the + * gateway schema-check module) has no edge. Being enumerated confers nothing + * else: importers stay subject to every prong and gain no writer standing. */ const MIGRATION_RUNNER_SYMBOLS = ['runMigrations', 'runPgliteMigrations', 'getMigrationStatus']; const MIGRATION_RUNNER_IMPORTERS: string[] = [ @@ -115,9 +132,31 @@ const MIGRATE_TIER_IMPORTERS: string[] = [ 'packages/storage/src/cli.ts', // storage CLI command surface 'packages/storage/src/index.ts', // package barrel re-export (public API) ]; +/** + * Enumerated disposition for non-literal dynamic imports (§6.3b review F8): + * files here may use a computed import specifier; every other prong still + * applies to them in full. Each entry needs a justification. + */ +const DYNAMIC_IMPORT_REGISTER: string[] = [ + 'plugins/macp/src/index.ts', // loads the ACP runtime SDK from a configured sdkRoot; no db access +]; +/** Importers of the db-handle factories (measured set; a new importer must be reviewed in). */ +const DB_FACTORY_SYMBOLS = ['createDb', 'createPgliteDb']; +const DB_FACTORY_IMPORTERS: string[] = [ + 'apps/gateway/src/database/database.module.ts', + 'packages/brain/src/cli.ts', + 'packages/log/src/cli.ts', + 'packages/memory/src/adapters/pgvector.ts', + 'packages/memory/src/cli.ts', + 'packages/mosaic/src/commands/fleet-backlog.ts', + 'packages/storage/src/adapters/postgres.ts', + 'packages/storage/src/cli.ts', + 'packages/storage/src/migrate-tier.ts', +]; -const SCAN_ROOTS = ['apps', 'packages']; +const SCAN_ROOTS = ['apps', 'packages', 'plugins']; const EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const DRIVER_SPECIFIERS = ['postgres', 'pg', '@electric-sql/pglite']; function isTestPath(rel: string): boolean { return ( @@ -143,7 +182,7 @@ function collectSources(): string[] { if (entry === 'node_modules' || entry === 'dist') continue; walk(full); } else if (EXTENSIONS.has(full.slice(full.lastIndexOf('.')))) { - const rel = relative(REPO_ROOT, full); + const rel = relative(REPO_ROOT, full).split(sep).join('/'); if (!isTestPath(rel)) files.push(rel); } } @@ -154,36 +193,187 @@ function collectSources(): string[] { return files.sort(); } -/** Strip line and block comments so commented-out code cannot trip prongs. */ -function stripComments(src: string): string { - return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +// --------------------------------------------------------------------------- +// Lexer: single pass producing (a) comment-free source with strings intact +// (for import/export and call-site regexes) and (b) the string/template text +// spans (for the literal prong). String-aware, so `//` inside a string is not +// a comment and a quote inside a comment does not open a string. Template +// expressions re-enter code mode (nesting supported); regex literals are +// recognized with the standard prev-token heuristic so their contents cannot +// open a phantom string. +// --------------------------------------------------------------------------- +interface Lexed { + code: string; + spans: string[]; } -/** Extract string and template literal spans (approximation, multi-line for templates). */ -function stringSpans(src: string): string[] { +function lexSource(src: string): Lexed { + let code = ''; const spans: string[] = []; - const re = /`[^`]*`|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/gs; - for (const m of src.matchAll(re)) spans.push(m[0]); - return spans; + let i = 0; + const n = src.length; + // Template nesting: each entry is the accumulated text of one template. + const tplStack: string[] = []; + // Brace depth inside the current ${ } expression, one entry per nesting level. + const exprDepth: number[] = []; + let lastSig = ''; // last significant code char (regex-vs-division heuristic) + let lastWord = ''; // last identifier/keyword emitted to code + + const emit = (ch: string): void => { + code += ch; + if (!/\s/.test(ch)) { + lastSig = ch; + if (/[A-Za-z0-9_$]/.test(ch)) lastWord += ch; + else lastWord = ''; + } + }; + + const regexCanStart = (): boolean => { + if (lastSig === '' || '([{,;=:!&|?+-*%^<>~'.includes(lastSig)) return true; + return ['return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'do', 'else'].includes( + lastWord, + ); + }; + + while (i < n) { + const ch = src[i]!; + const next = i + 1 < n ? src[i + 1]! : ''; + + if (tplStack.length > 0 && exprDepth.length < tplStack.length) { + // Inside template literal text. + const top = tplStack.length - 1; + if (ch === '\\') { + tplStack[top] += src.slice(i, i + 2); + i += 2; + continue; + } + if (ch === '`') { + spans.push(tplStack.pop()!); + emit('`'); + i += 1; + continue; + } + if (ch === '$' && next === '{') { + exprDepth.push(0); + emit('$'); + emit('{'); + i += 2; + continue; + } + tplStack[top] += ch; + i += 1; + continue; + } + + // Code mode (possibly inside a ${ } expression). + if (ch === '/' && next === '/') { + while (i < n && src[i] !== '\n') i += 1; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1; + i += 2; + emit(' '); + continue; + } + if (ch === "'" || ch === '"') { + let span = ''; + i += 1; + while (i < n && src[i] !== ch) { + if (src[i] === '\\') { + span += src.slice(i, i + 2); + i += 2; + } else { + span += src[i]; + i += 1; + } + } + i += 1; + spans.push(span); + // Keep quoted strings in code output so import specifiers stay parseable. + emit(ch); + code += span; + emit(ch); + continue; + } + if (ch === '`') { + tplStack.push(''); + emit('`'); + i += 1; + continue; + } + if (ch === '/' && regexCanStart()) { + // Regex literal: consume without interpreting quotes/backticks inside. + i += 1; + let inClass = false; + while (i < n) { + const rc = src[i]!; + if (rc === '\\') { + i += 2; + continue; + } + if (rc === '[') inClass = true; + else if (rc === ']') inClass = false; + else if (rc === '/' && !inClass) break; + else if (rc === '\n') break; // not a regex after all; bail safely + i += 1; + } + i += 1; + while (i < n && /[a-z]/.test(src[i]!)) i += 1; // flags + emit('/'); + continue; + } + if (exprDepth.length > 0) { + const top = exprDepth.length - 1; + if (ch === '{') exprDepth[top] = exprDepth[top]! + 1; + if (ch === '}') { + if (exprDepth[top] === 0) { + exprDepth.pop(); + emit('}'); + i += 1; + continue; + } + exprDepth[top] = exprDepth[top]! - 1; + } + } + emit(ch); + i += 1; + } + while (tplStack.length > 0) spans.push(tplStack.pop()!); + return { code, spans }; } -/** Local names (including aliases) under which class-table symbols are imported. */ -function classSymbolAliases(src: string): string[] { - const names = new Set(); - const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g; - for (const m of src.matchAll(importRe)) { - const specifier = m[2]!; - if (!/@mosaicstack\/db|\.\.?\/(?:.*\/)?(?:schema|index)(?:\.js)?$/.test(specifier)) continue; - for (const part of m[1]!.split(',')) { - const seg = part.trim().replace(/^type\s+/, ''); - if (!seg) 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)) names.add(local); - } +// --------------------------------------------------------------------------- +// Import graph helpers +// --------------------------------------------------------------------------- +function resolveSpecifier(fromRel: string, spec: string, fileSet: Set): string | null { + if (!spec.startsWith('.')) return null; + const base = join(dirname(fromRel), spec).split(sep).join('/'); + const noJs = base.replace(/\.(js|mjs|cjs)$/, ''); + for (const cand of [ + base, + noJs, + `${noJs}.ts`, + `${noJs}.tsx`, + `${noJs}.mts`, + `${noJs}.cts`, + `${noJs}/index.ts`, + ]) { + if (fileSet.has(cand)) return cand; } - return [...names]; + return null; +} + +const IMPORT_RE = + /import\s*(type\s+)?(?:(\w+)\s*,\s*)?(?:\{([^}]*)\}|\*\s*as\s+(\w+)|(\w+))?\s*from\s*['"]([^'"]+)['"]/g; +const EXPORT_FROM_RE = + /export\s*(type\s+)?(?:\{([^}]*)\}|\*(?:\s*as\s+\w+)?)\s*from\s*['"]([^'"]+)['"]/g; + +interface FileFacts { + rel: string; + code: string; + spans: string[]; } interface Violation { @@ -192,11 +382,252 @@ interface Violation { detail: string; } -describe('hierarchy writer coverage (contract 1 §6.3b)', () => { - const sources = collectSources(); +/** + * 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. + */ +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); + }; + let changed = true; + while (changed) { + changed = false; + for (const f of files) { + if (conduits.has(f.rel)) continue; + let isConduit = false; + 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; + } + } + 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; + } + } + if (isConduit) { + conduits.add(f.rel); + changed = true; + } + } + } + return conduits; +} - it('scans a non-empty production source set', () => { - expect(sources.length).toBeGreaterThan(100); +interface ClassAliases { + named: string[]; // local identifiers bound to class-table symbols + namespaces: string[]; // namespace identifiers over a schema source +} + +function classAliases(f: FileFacts, conduits: Set, 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; + const fromSchema = + spec === '@mosaicstack/db' || + (() => { + const r = resolveSpecifier(f.rel, spec!, fileSet); + return r !== null && conduits.has(r); + })(); + if (!fromSchema) continue; + if (nsName) namespaces.add(nsName); + if (namedList) { + 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); + } + } + } + return { named: [...named], namespaces: [...namespaces] }; +} + +/** Value-import edge naming one of `symbols` (named import from anywhere, or literal dynamic package import while using the symbol). */ +function hasSymbolImportEdge(code: string, symbols: string[], packageName: string): boolean { + const staticEdge = new RegExp( + `import\\s*(?!type\\b)(?:\\w+\\s*,\\s*)?\\{[^}]*\\b(${symbols.join('|')})\\b[^}]*\\}\\s*from\\s*['"][^'"]+['"]`, + ).test(code); + const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code); + const dynamicEdge = + usesSymbol && + new RegExp(`import\\s*\\(\\s*['"]${packageName.replace('/', '\\/')}['"]\\s*\\)`).test(code); + return staticEdge || dynamicEdge; +} + +// --------------------------------------------------------------------------- +// The analyzer — pure over (rel, source) so the evasion controls below can +// feed synthetic files through the exact production logic. +// --------------------------------------------------------------------------- +function analyzeFile(f: FileFacts, conduits: Set, fileSet: Set): Violation[] { + const violations: Violation[] = []; + const rel = f.rel; + const code = f.code; + const inAllowlist = WRITER_ALLOWLIST.includes(rel); + 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)) { + violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' }); + } + // Non-literal dynamic import: makes the import graph unanalyzable. + if (!DYNAMIC_IMPORT_REGISTER.includes(rel) && /\bimport\s*\(\s*(?!['"])/.test(code)) { + violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' }); + } + + const aliases = classAliases(f, conduits, fileSet); + + // Prong (i): schema-symbol writes — alias-, namespace-, and conduit-aware. + if (!inAllowlist) { + const targets: string[] = [...aliases.named]; + for (const ns of aliases.namespaces) { + for (const s of CLASS_SYMBOLS) targets.push(`${ns}\\.${s}`); + } + if (targets.length > 0) { + const writeRe = new RegExp( + `\\.(insert|update|delete)\\s*\\(\\s*(${targets.join('|')})\\b`, + 'g', + ); + for (const m of code.matchAll(writeRe)) { + violations.push({ + file: rel, + prong: 'i-symbol', + detail: `.${m[1]}(${m[2]}) outside the writer allowlist`, + }); + } + } + } + + // Prong (ii): class-table names in string/template spans with SQL context. + // The SQL keyword must be ADJACENT to the table name (optionally quoted): + // co-residence anywhere in one span over-matches prose (English "from" plus + // "pnpm workspaces" in an embedded doc string is not SQL). + if (!inAllowlist && !isSchemaDefinition) { + const sqlAdjacentRe = new RegExp( + `\\b(insert\\s+into|update|delete\\s+from|from|join|truncate(\\s+table)?|alter\\s+table|drop\\s+table|references|into)\\s+["'\`]?(${CLASS_TABLES.join('|')})\\b`, + 'i', + ); + for (const span of f.spans) { + if (sqlAdjacentRe.test(span)) { + violations.push({ + file: rel, + prong: 'ii-literal', + detail: `class-table name in SQL context: ${span.slice(0, 80)}`, + }); + } + } + } + + // Prong (iii): content-independent raw execution. + if (!inAllowlist && !inRegister) { + const driverImport = new RegExp( + `(from\\s*|import\\s*\\(\\s*)['"](${DRIVER_SPECIFIERS.map((s) => s.replace('/', '\\/')).join('|')})['"]`, + ).test(code); + const factoryImport = + hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') || + (aliases.namespaces.length > 0 && + new RegExp(`\\b(${aliases.namespaces.join('|')})\\.(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)) { + violations.push({ + file: rel, + prong: 'iii-raw-execution', + detail: `.${m[1]}() in a driver-importing module`, + }); + } + } else if (factoryImport) { + // A drizzle handle's raw primitive is .execute (its .query namespace is + // 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)) { + violations.push({ + file: rel, + prong: 'iii-raw-execution', + detail: `.${m[1]}() in a factory-importing module`, + }); + } + } + if (!driverImport) { + // 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, + )) { + violations.push({ + file: rel, + prong: 'iii-raw-execution', + detail: `.${m[1]}() on a db-shaped receiver`, + }); + } + } + // sql.raw through aliases and namespaces (drizzle-orm and the db barrel). + const sqlAliases = new Set(); + for (const m of code.matchAll(IMPORT_RE)) { + const [, typeOnly, , namedList, nsName, , spec] = m; + if (typeOnly) continue; + if (!/^drizzle-orm|^@mosaicstack\/db$/.test(spec!)) continue; + if (namedList) { + for (const part of namedList.split(',')) { + const seg = part.trim(); + const asMatch = /^sql\s+as\s+(\w+)$/.exec(seg); + if (seg === 'sql') sqlAliases.add('sql'); + else if (asMatch) sqlAliases.add(asMatch[1]!); + } + } + if (nsName) sqlAliases.add(`${nsName}\\.sql`); + } + if (sqlAliases.size > 0) { + const rawRe = new RegExp(`\\b(${[...sqlAliases].join('|')})\\.raw\\s*\\(`); + if (rawRe.test(code)) { + violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'sql.raw()' }); + } + } + } + return violations; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('hierarchy writer coverage (contract 1 §6.3b)', () => { + const sourceRels = collectSources(); + const fileSet = new Set(sourceRels); + const files: FileFacts[] = sourceRels.map((rel) => { + const { code, spans } = lexSource(readFileSync(join(REPO_ROOT, rel), 'utf8')); + return { rel, code, spans }; + }); + const conduits = computeSchemaConduits(files, fileSet); + + it('scans a non-empty production source set including plugins', () => { + expect(files.length).toBeGreaterThan(100); + expect(sourceRels.some((r) => r.startsWith('plugins/'))).toBe(true); }); it('enumerated modules exist on disk (no stale allowlist/register entries)', () => { @@ -205,6 +636,8 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { ...INFRA_REGISTER, ...MIGRATION_RUNNER_IMPORTERS, ...MIGRATE_TIER_IMPORTERS, + ...DB_FACTORY_IMPORTERS, + ...DYNAMIC_IMPORT_REGISTER, ]) { expect(existsSync(join(REPO_ROOT, p)), `enumerated module missing: ${p}`).toBe(true); } @@ -216,87 +649,8 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { } }); - it('three-prong writer coverage holds', () => { - const violations: Violation[] = []; - const allow = new Set(WRITER_ALLOWLIST); - const register = new Set(INFRA_REGISTER); - - for (const rel of sources) { - const raw = readFileSync(join(REPO_ROOT, rel), 'utf8'); - const src = stripComments(raw); - const inAllowlist = allow.has(rel); - const inRegister = register.has(rel); - const isSchemaDefinition = rel === 'packages/db/src/schema.ts'; - - // Runtime code construction: fails anywhere. - if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(src)) { - violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' }); - } - // Non-literal dynamic import: makes the import graph unanalyzable. - if (/\bimport\s*\(\s*(?!['"`])/.test(src)) { - violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' }); - } - - // Prong (i): schema-symbol writes — alias-aware. - if (!inAllowlist) { - const aliases = classSymbolAliases(src); - if (aliases.length > 0) { - const writeRe = new RegExp( - `\\.(insert|update|delete)\\s*\\(\\s*(${aliases.join('|')})\\b`, - 'g', - ); - for (const m of src.matchAll(writeRe)) { - violations.push({ - file: rel, - prong: 'i-symbol', - detail: `.${m[1]}(${m[2]}) outside the writer allowlist`, - }); - } - } - } - - // Prong (ii): class-table names in SQL strings/templates. - // Schema definitions and generated migrations are excluded (contract); - // migrations live outside the scanned source roots already. - if (!inAllowlist && !isSchemaDefinition) { - const tableRe = new RegExp(`\\b(${CLASS_TABLES.join('|')})\\b`); - const sqlContextRe = - /\b(select|insert\s+into|update|delete\s+from|join|truncate|alter\s+table|drop\s+table|references)\b/i; - for (const span of stringSpans(src)) { - if (tableRe.test(span) && sqlContextRe.test(span)) { - violations.push({ - file: rel, - prong: 'ii-literal', - detail: `class-table name in SQL context: ${span.slice(0, 80)}`, - }); - } - } - } - - // Prong (iii): raw-execution primitives outside allowlist ∪ register. - if (!inAllowlist && !inRegister) { - const rawPatterns: Array<[RegExp, string]> = [ - [/\bsql\.raw\s*\(/, 'sql.raw()'], - [/\.unsafe\s*\(/, '.unsafe()'], - // `.execute(sql\`...\`)` is exempt: the tagged template keeps the - // SQL literal in source, where prong (ii) scans it. The flagged - // forms are the ones whose SQL content is not statically visible - // at the call site: `.execute(variable)` and `.execute("string")`. - [/\b(?:db|database|tx|trx)\.execute\s*\((?!\s*sql`)/, 'raw db.execute()'], - [/from\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]/, 'direct driver import'], - [ - /\bimport\s*\(\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]\s*\)/, - 'dynamic driver import', - ], - ]; - for (const [re, label] of rawPatterns) { - if (re.test(src)) { - violations.push({ file: rel, prong: 'iii-raw-execution', detail: label }); - } - } - } - } - + it('three-prong writer coverage holds over the production tree', () => { + const violations = files.flatMap((f) => analyzeFile(f, conduits, fileSet)); expect( violations, violations.map((v) => `[prong ${v.prong}] ${v.file}: ${v.detail}`).join('\n'), @@ -305,22 +659,14 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { it('migration-runner import edges are exactly the closed importer enumeration', () => { const offenders: string[] = []; - for (const rel of sources) { - if (rel.startsWith('packages/db/src/')) continue; // the runner's own package - const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8')); - const symbolRe = new RegExp(`\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b`); - if (!symbolRe.test(src)) continue; - // An import edge is a static value import from the db package (or its - // migrate module) naming a runner symbol, or a literal dynamic - // import('@mosaicstack/db') in a file that uses a runner symbol. - // `import type` is erased at runtime and is not an edge; parameter - // injection (the gateway schema-check module) has no edge. - const staticEdge = new RegExp( - `import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"](@mosaicstack/db|[^'"]*migrate(\\.js)?)['"]`, - ).test(src); - const dynamicEdge = /import\s*\(\s*['"]@mosaicstack\/db['"]\s*\)/.test(src); - if ((staticEdge || dynamicEdge) && !MIGRATION_RUNNER_IMPORTERS.includes(rel)) { - offenders.push(rel); + for (const f of files) { + if (f.rel.startsWith('packages/db/src/')) continue; // the runner's own package + if (!new RegExp(`\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b`).test(f.code)) continue; + if ( + hasSymbolImportEdge(f.code, MIGRATION_RUNNER_SYMBOLS, '@mosaicstack/db') && + !MIGRATION_RUNNER_IMPORTERS.includes(f.rel) + ) { + offenders.push(f.rel); } } expect(offenders, `unenumerated migration-runner importers:\n${offenders.join('\n')}`).toEqual( @@ -330,20 +676,121 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { it('migrate-tier import edges are exactly the closed importer enumeration', () => { const offenders: string[] = []; - for (const rel of sources) { - if (rel === 'packages/storage/src/migrate-tier.ts') continue; - const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8')); - // Edge = direct module-path import, or a value import of a migrate-tier - // symbol from the storage package barrel (the barrel is itself - // enumerated as a re-exporter, so barrel consumers must not escape). + for (const f of files) { + if (f.rel === 'packages/storage/src/migrate-tier.ts') continue; const pathEdge = - /from\s*['"][^'"]*migrate-tier(\.js)?['"]/.test(src) || - /import\s*\(\s*['"][^'"]*migrate-tier(\.js)?['"]\s*\)/.test(src); - const barrelEdge = new RegExp( - `import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATE_TIER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"]@mosaicstack/storage['"]`, - ).test(src); - if ((pathEdge || barrelEdge) && !MIGRATE_TIER_IMPORTERS.includes(rel)) offenders.push(rel); + /from\s*['"][^'"]*migrate-tier(\.js)?['"]/.test(f.code) || + /import\s*\(\s*['"][^'"]*migrate-tier(\.js)?['"]\s*\)/.test(f.code); + const symbolEdge = + new RegExp(`\\b(${MIGRATE_TIER_SYMBOLS.join('|')})\\b`).test(f.code) && + hasSymbolImportEdge(f.code, MIGRATE_TIER_SYMBOLS, '@mosaicstack/storage'); + if ((pathEdge || symbolEdge) && !MIGRATE_TIER_IMPORTERS.includes(f.rel)) { + offenders.push(f.rel); + } } expect(offenders, `unenumerated migrate-tier importers:\n${offenders.join('\n')}`).toEqual([]); }); + + it('db-factory (createDb/createPgliteDb) import edges are exactly the closed importer enumeration', () => { + const offenders: string[] = []; + for (const f of files) { + if (f.rel.startsWith('packages/db/src/')) continue; // the factories' own package + if (!new RegExp(`\\b(${DB_FACTORY_SYMBOLS.join('|')})\\b`).test(f.code)) continue; + if ( + hasSymbolImportEdge(f.code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') && + !DB_FACTORY_IMPORTERS.includes(f.rel) + ) { + offenders.push(f.rel); + } + } + expect(offenders, `unenumerated db-factory importers:\n${offenders.join('\n')}`).toEqual([]); + }); + + // ------------------------------------------------------------------------- + // Permanent evasion controls: every known escape from the M4-1a review must + // be flagged by the analyzer, and the two legitimate controls must pass. + // Synthetic files run through the exact production analyzer. + // ------------------------------------------------------------------------- + const EVASIONS: Array<{ name: string; src: string }> = [ + { + name: 'E1 namespace import write', + src: `import * as dbSchema from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(dbSchema.companies).values({}); }`, + }, + { + name: 'E2 re-export laundering (conduit consumer)', + src: `import { companies } from './evasion-barrel.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(companies).values({}); }`, + }, + { + name: 'E3 renamed createDb handle', + src: `import { createDb } from '@mosaicstack/db';\nexport async function f(t: string) { const d = createDb('u'); await d.execute('DELETE FROM ' + t); }`, + }, + { + name: 'E4 aliased sql.raw', + src: `import { sql as q } from 'drizzle-orm';\nimport { db } from './x.js';\nexport async function f(t: string) { await db.execute(q.raw('TRUNCATE ' + t)); }`, + }, + { + name: 'E5 interpolated tagged template on db receiver', + src: `import { sql } from 'drizzle-orm';\nimport { db } from './x.js';\nexport async function f() { const tbl = 'hierarchy_grants'; await db.execute(sql\`DELETE FROM \${tbl}\`); }`, + }, + { + name: 'E6 driver client via import', + src: `import postgres from 'postgres';\nexport async function f(t: string) { const c = postgres('u'); await c.unsafe('TRUNCATE ' + t); }`, + }, + { + name: 'E7 DI-shaped driver client query', + src: `export class R { constructor(private client: { query(s: string): Promise }) {}\n async f(t: string) { await this.client.query('TRUNCATE ' + t); } }`, + }, + { + name: 'E8 string containing // does not hide code', + src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { const x = 'oops//'; await db.insert(companies).values({}); }`, + }, + { + name: 'C-literal SQL string with class table', + src: `export const q = 'DELETE FROM hierarchy_grants WHERE role = $1';`, + }, + { + name: 'C-eval', + src: `export function f(s: string) { return eval(s); }`, + }, + ]; + const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [ + { + name: 'clean: schema read via select', + src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { return db.select().from(companies); }`, + }, + { + name: 'clean: comment mentioning a table is not SQL', + src: `// syncs hierarchy_grants downstream\nexport const n = 1;`, + }, + ]; + + it('analyzer flags every known evasion form', () => { + // A synthetic conduit so E2 exercises the fixpoint through a real re-export. + const barrel: FileFacts = { + rel: 'packages/db/src/evasion-barrel.ts', + ...lexSource(`export { companies } from '@mosaicstack/db';`), + }; + for (const e of EVASIONS) { + const rel = 'packages/db/src/evasion-sample.ts'; + const synthetic: FileFacts = { rel, ...lexSource(e.src) }; + const synthSet = new Set([...fileSet, rel, barrel.rel]); + const synthConduits = computeSchemaConduits([...files, barrel, synthetic], synthSet); + const v = analyzeFile(synthetic, synthConduits, synthSet); + expect(v.length, `evasion not caught: ${e.name}`).toBeGreaterThan(0); + } + }); + + it('analyzer passes legitimate non-writer code (no false positives on controls)', () => { + for (const c of CLEAN_CONTROLS) { + const rel = 'packages/db/src/clean-sample.ts'; + const synthetic: FileFacts = { rel, ...lexSource(c.src) }; + const synthSet = new Set([...fileSet, rel]); + const synthConduits = computeSchemaConduits([...files, synthetic], synthSet); + const v = analyzeFile(synthetic, synthConduits, synthSet); + expect( + v, + `false positive on ${c.name}: ${v.map((x) => `${x.prong}:${x.detail}`).join('; ')}`, + ).toEqual([]); + } + }); });