Skip to content

Commit f1c2e79

Browse files
icecrasher321claude
andcommitted
fix(ci): resolve namespace and renamed imports in the pending-drop audit
Table references were matched by bare identifier only, so an argless read reached through `import * as schema` — a live pattern here, e.g. `db.insert(schema.userStats)` in lib/auth/anonymous.ts — or through a renamed import was invisible to the audit. Resolution now runs over the file's schema-module import bindings (named, renamed, and namespace) as well as alias() bindings, at every position the audit inspects. Verified by probe: argless select() via `schema.userStats`, via a renamed import, and an argless .returning() on `db.insert(schema.userStats)` are all reported now, where the previous version reported none of the three. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d0b61ea commit f1c2e79

1 file changed

Lines changed: 73 additions & 20 deletions

File tree

scripts/check-pending-drop-tables.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
* `const { doomed, ...live } = getTableColumns(t)` — are validated against that
2121
* doomed set, so an omit list that misses a doomed column (including one deprecated
2222
* later) fails here, in schema.ts's own `<table>Columns` helpers too;
23-
* - `alias(t, ...)` is resolved through both `const u = alias(t, 'u')` bindings and
24-
* inline `.from(alias(t, 'u'))` expressions.
23+
* - table references resolve through every way a file can name one: a direct import, a
24+
* renamed import, an `import * as schema` member, a `const u = alias(t, 'u')` binding,
25+
* and an inline `.from(alias(t, 'u'))` expression.
2526
*/
2627
import { readdirSync, readFileSync } from 'node:fs'
2728
import { dirname, extname, join, relative, resolve } from 'node:path'
@@ -235,54 +236,106 @@ function objectKeys(node: unknown): Set<string> | null {
235236
return keys
236237
}
237238

239+
/**
240+
* How a file can name a pending table: `locals` maps a local binding (a renamed
241+
* import or an `alias()` result) to its canonical table, and `namespaces` holds
242+
* `import * as schema` bindings whose members are canonical tables.
243+
*/
244+
interface TableBindings {
245+
locals: Map<string, string>
246+
namespaces: Set<string>
247+
}
248+
238249
/**
239250
* Resolves an expression to the canonical pending-table name it reads, seeing
240-
* through file-local alias bindings and inline `alias(t, 'name')` calls.
251+
* through renamed imports, `import * as schema` members, file-local alias
252+
* bindings, and inline `alias(t, 'name')` calls.
241253
*/
242254
function resolveTable(
243255
node: unknown,
244256
pendingTables: Map<string, Set<string>>,
245-
aliases: Map<string, string>
257+
bindings: TableBindings
246258
): string | null {
247259
const unwrapped = unwrap(node)
248260
if (!unwrapped) return null
249261
if (unwrapped.type === 'Identifier' && typeof unwrapped.name === 'string') {
250262
if (pendingTables.has(unwrapped.name)) return unwrapped.name
251-
return aliases.get(unwrapped.name) ?? null
263+
return bindings.locals.get(unwrapped.name) ?? null
264+
}
265+
if (unwrapped.type === 'MemberExpression' && !unwrapped.computed) {
266+
const namespace = identifierName(unwrapped.object)
267+
const member = propertyName(unwrapped.property)
268+
if (namespace && member && bindings.namespaces.has(namespace) && pendingTables.has(member)) {
269+
return member
270+
}
271+
return null
252272
}
253273
if (
254274
unwrapped.type === 'CallExpression' &&
255275
identifierName(unwrapped.callee) === 'alias' &&
256276
Array.isArray(unwrapped.arguments)
257277
) {
258-
return resolveTable(unwrapped.arguments[0], pendingTables, aliases)
278+
return resolveTable(unwrapped.arguments[0], pendingTables, bindings)
259279
}
260280
return null
261281
}
262282

263-
/** Maps `const u = alias(pendingTable, ...)` bindings to their canonical table. */
264-
function collectAliasBindings(
283+
/** A module that can export the schema's table objects. */
284+
function isSchemaModule(source: unknown): boolean {
285+
const value = isSyntaxNode(source) && typeof source.value === 'string' ? source.value : null
286+
return value !== null && (/@sim\/db(\/|$)/.test(value) || /(^|\/)schema(\.ts)?$/.test(value))
287+
}
288+
289+
/**
290+
* Collects every way this file can name a pending table: renamed imports,
291+
* namespace imports, and `const u = alias(pendingTable, ...)` bindings.
292+
*/
293+
function collectTableBindings(
265294
program: SyntaxNode,
266295
pendingTables: Map<string, Set<string>>
267-
): Map<string, string> {
268-
const aliases = new Map<string, string>()
269-
const visit = (node: SyntaxNode) => {
296+
): TableBindings {
297+
const bindings: TableBindings = { locals: new Map(), namespaces: new Set() }
298+
299+
const visitImports = (node: SyntaxNode) => {
300+
if (node.type === 'ImportDeclaration' && isSchemaModule(node.source)) {
301+
const specifiers = Array.isArray(node.specifiers) ? node.specifiers : []
302+
for (const specifier of specifiers) {
303+
if (!isSyntaxNode(specifier)) continue
304+
const local = propertyName(specifier.local)
305+
if (!local) continue
306+
if (specifier.type === 'ImportNamespaceSpecifier') {
307+
bindings.namespaces.add(local)
308+
continue
309+
}
310+
if (specifier.type !== 'ImportSpecifier') continue
311+
const imported = propertyName(specifier.imported)
312+
if (imported && imported !== local && pendingTables.has(imported)) {
313+
bindings.locals.set(local, imported)
314+
}
315+
}
316+
}
317+
for (const child of getChildNodes(node)) visitImports(child)
318+
}
319+
visitImports(program)
320+
321+
const visitAliases = (node: SyntaxNode) => {
270322
if (node.type === 'VariableDeclarator' && isSyntaxNode(node.init)) {
271323
const init = unwrap(node.init)
272324
if (init?.type === 'CallExpression' && identifierName(init.callee) === 'alias') {
273325
const canonical = resolveTable(
274326
Array.isArray(init.arguments) ? init.arguments[0] : undefined,
275327
pendingTables,
276-
aliases
328+
bindings
277329
)
278330
const bound = propertyName(node.id)
279-
if (canonical && bound) aliases.set(bound, canonical)
331+
if (canonical && bound) bindings.locals.set(bound, canonical)
280332
}
281333
}
282-
for (const child of getChildNodes(node)) visit(child)
334+
for (const child of getChildNodes(node)) visitAliases(child)
283335
}
284-
visit(program)
285-
return aliases
336+
visitAliases(program)
337+
338+
return bindings
286339
}
287340

288341
/**
@@ -333,12 +386,12 @@ function checkCall(
333386
call: SyntaxNode,
334387
parent: SyntaxNode | null,
335388
pendingTables: Map<string, Set<string>>,
336-
aliases: Map<string, string>,
389+
bindings: TableBindings,
337390
report: (node: SyntaxNode, table: string, pattern: string) => void
338391
): void {
339392
const callee = isSyntaxNode(call.callee) ? call.callee : null
340393
const args = Array.isArray(call.arguments) ? call.arguments : []
341-
const resolveArg = (node: unknown) => resolveTable(node, pendingTables, aliases)
394+
const resolveArg = (node: unknown) => resolveTable(node, pendingTables, bindings)
342395

343396
// getTableColumns(pendingTable) — spreads every declared column unless the
344397
// doomed ones are verifiably named away on the spot.
@@ -444,15 +497,15 @@ function auditFile(
444497
return violations
445498
}
446499

447-
const aliases = collectAliasBindings(program, pendingTables)
500+
const bindings = collectTableBindings(program, pendingTables)
448501

449502
const report = (node: SyntaxNode, table: string, pattern: string) => {
450503
violations.push({ file, line: node.loc?.start.line ?? 1, table, pattern })
451504
}
452505

453506
const visit = (node: SyntaxNode, parent: SyntaxNode | null) => {
454507
if (node.type === 'CallExpression') {
455-
checkCall(node, parent, pendingTables, aliases, report)
508+
checkCall(node, parent, pendingTables, bindings, report)
456509
}
457510
for (const child of getChildNodes(node)) visit(child, node)
458511
}

0 commit comments

Comments
 (0)