|
20 | 20 | * `const { doomed, ...live } = getTableColumns(t)` — are validated against that |
21 | 21 | * doomed set, so an omit list that misses a doomed column (including one deprecated |
22 | 22 | * 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. |
25 | 26 | */ |
26 | 27 | import { readdirSync, readFileSync } from 'node:fs' |
27 | 28 | import { dirname, extname, join, relative, resolve } from 'node:path' |
@@ -235,54 +236,106 @@ function objectKeys(node: unknown): Set<string> | null { |
235 | 236 | return keys |
236 | 237 | } |
237 | 238 |
|
| 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 | + |
238 | 249 | /** |
239 | 250 | * 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. |
241 | 253 | */ |
242 | 254 | function resolveTable( |
243 | 255 | node: unknown, |
244 | 256 | pendingTables: Map<string, Set<string>>, |
245 | | - aliases: Map<string, string> |
| 257 | + bindings: TableBindings |
246 | 258 | ): string | null { |
247 | 259 | const unwrapped = unwrap(node) |
248 | 260 | if (!unwrapped) return null |
249 | 261 | if (unwrapped.type === 'Identifier' && typeof unwrapped.name === 'string') { |
250 | 262 | 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 |
252 | 272 | } |
253 | 273 | if ( |
254 | 274 | unwrapped.type === 'CallExpression' && |
255 | 275 | identifierName(unwrapped.callee) === 'alias' && |
256 | 276 | Array.isArray(unwrapped.arguments) |
257 | 277 | ) { |
258 | | - return resolveTable(unwrapped.arguments[0], pendingTables, aliases) |
| 278 | + return resolveTable(unwrapped.arguments[0], pendingTables, bindings) |
259 | 279 | } |
260 | 280 | return null |
261 | 281 | } |
262 | 282 |
|
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( |
265 | 294 | program: SyntaxNode, |
266 | 295 | 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) => { |
270 | 322 | if (node.type === 'VariableDeclarator' && isSyntaxNode(node.init)) { |
271 | 323 | const init = unwrap(node.init) |
272 | 324 | if (init?.type === 'CallExpression' && identifierName(init.callee) === 'alias') { |
273 | 325 | const canonical = resolveTable( |
274 | 326 | Array.isArray(init.arguments) ? init.arguments[0] : undefined, |
275 | 327 | pendingTables, |
276 | | - aliases |
| 328 | + bindings |
277 | 329 | ) |
278 | 330 | const bound = propertyName(node.id) |
279 | | - if (canonical && bound) aliases.set(bound, canonical) |
| 331 | + if (canonical && bound) bindings.locals.set(bound, canonical) |
280 | 332 | } |
281 | 333 | } |
282 | | - for (const child of getChildNodes(node)) visit(child) |
| 334 | + for (const child of getChildNodes(node)) visitAliases(child) |
283 | 335 | } |
284 | | - visit(program) |
285 | | - return aliases |
| 336 | + visitAliases(program) |
| 337 | + |
| 338 | + return bindings |
286 | 339 | } |
287 | 340 |
|
288 | 341 | /** |
@@ -333,12 +386,12 @@ function checkCall( |
333 | 386 | call: SyntaxNode, |
334 | 387 | parent: SyntaxNode | null, |
335 | 388 | pendingTables: Map<string, Set<string>>, |
336 | | - aliases: Map<string, string>, |
| 389 | + bindings: TableBindings, |
337 | 390 | report: (node: SyntaxNode, table: string, pattern: string) => void |
338 | 391 | ): void { |
339 | 392 | const callee = isSyntaxNode(call.callee) ? call.callee : null |
340 | 393 | 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) |
342 | 395 |
|
343 | 396 | // getTableColumns(pendingTable) — spreads every declared column unless the |
344 | 397 | // doomed ones are verifiably named away on the spot. |
@@ -444,15 +497,15 @@ function auditFile( |
444 | 497 | return violations |
445 | 498 | } |
446 | 499 |
|
447 | | - const aliases = collectAliasBindings(program, pendingTables) |
| 500 | + const bindings = collectTableBindings(program, pendingTables) |
448 | 501 |
|
449 | 502 | const report = (node: SyntaxNode, table: string, pattern: string) => { |
450 | 503 | violations.push({ file, line: node.loc?.start.line ?? 1, table, pattern }) |
451 | 504 | } |
452 | 505 |
|
453 | 506 | const visit = (node: SyntaxNode, parent: SyntaxNode | null) => { |
454 | 507 | if (node.type === 'CallExpression') { |
455 | | - checkCall(node, parent, pendingTables, aliases, report) |
| 508 | + checkCall(node, parent, pendingTables, bindings, report) |
456 | 509 | } |
457 | 510 | for (const child of getChildNodes(node)) visit(child, node) |
458 | 511 | } |
|
0 commit comments