Skip to content

Commit d828a11

Browse files
committed
fix(scripts): honor shadowed bindings and defaulted destructured Dates
1 parent 6d0a971 commit d828a11

2 files changed

Lines changed: 78 additions & 5 deletions

File tree

scripts/check-sql-date-binding.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,31 @@ describe('sql Date binding audit', () => {
228228
expect(analysis.violations).toEqual([])
229229
})
230230
})
231+
232+
test('treats a local non-Date binding as a shadow of an outer Date', () => {
233+
const violations = findSqlDateBindingViolations(`
234+
${DRIZZLE_IMPORT}
235+
const now = new Date()
236+
function outer() {
237+
return sql\`a < \${now}\`
238+
}
239+
function inner() {
240+
const now = Date.now()
241+
return sql\`b < \${now}\`
242+
}
243+
`)
244+
245+
expect(violations.map((violation) => violation.expression)).toEqual(['now'])
246+
})
247+
248+
test('binds a destructured Date field that carries a default', () => {
249+
const violations = findSqlDateBindingViolations(`
250+
${DRIZZLE_IMPORT}
251+
function scan({ since = new Date() }: { since: Date }) {
252+
return sql\`c < \${since}\`
253+
}
254+
`)
255+
256+
expect(violations.map((violation) => violation.expression)).toEqual(['since'])
257+
})
231258
})

scripts/check-sql-date-binding.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,61 @@ function isDateExpression(node: unknown, isDateName: (name: string) => boolean):
107107
return false
108108
}
109109

110-
/** Lexical scope for Date-typed bindings; lookups walk the parent chain. */
110+
/**
111+
* Lexical scope for Date-typed bindings; lookups walk the parent chain.
112+
*
113+
* `localNames` holds every name the scope binds, Date-typed or not, so a lookup
114+
* stops at the nearest declaring scope instead of reaching past a shadow.
115+
*/
111116
interface Scope {
112117
parent: Scope | null
113118
dateNames: Set<string>
119+
localNames: Set<string>
114120
}
115121

122+
const createScope = (parent: Scope | null): Scope => ({
123+
parent,
124+
dateNames: new Set(),
125+
localNames: new Set(),
126+
})
127+
128+
/**
129+
* Resolves `name` to the nearest scope that binds it.
130+
*
131+
* `dateNames` is consulted before `localNames` at every level so the resolution
132+
* fixpoint still converges: a local binding not *yet* proven to be a Date blocks
133+
* the walk on this pass, and a later pass finds it once proven.
134+
*/
116135
function hasDateName(scope: Scope, name: string): boolean {
117-
for (let current: Scope | null = scope; current; current = current.parent)
136+
for (let current: Scope | null = scope; current; current = current.parent) {
118137
if (current.dateNames.has(name)) return true
138+
if (current.localNames.has(name)) return false
139+
}
119140
return false
120141
}
121142

143+
/** Records every identifier a binding pattern introduces, however nested. */
144+
function collectBoundNames(node: unknown, into: Set<string>): void {
145+
if (!isSyntaxNode(node)) return
146+
if (node.type === 'Identifier') {
147+
if (typeof node.name === 'string') into.add(node.name)
148+
return
149+
}
150+
if (node.type === 'ObjectPattern' && Array.isArray(node.properties)) {
151+
for (const property of node.properties) {
152+
if (!isSyntaxNode(property)) continue
153+
collectBoundNames(property.type === 'RestElement' ? property.argument : property.value, into)
154+
}
155+
return
156+
}
157+
if (node.type === 'ArrayPattern' && Array.isArray(node.elements)) {
158+
for (const element of node.elements) collectBoundNames(element, into)
159+
return
160+
}
161+
if (node.type === 'AssignmentPattern') collectBoundNames(node.left, into)
162+
if (node.type === 'RestElement') collectBoundNames(node.argument, into)
163+
}
164+
122165
const FUNCTION_TYPES = new Set([
123166
'FunctionDeclaration',
124167
'FunctionExpression',
@@ -302,7 +345,7 @@ export function analyzeSource(source: string, file = 'source.ts'): FileAnalysis
302345
if (bindings.tags.size === 0 && bindings.namespaces.size === 0) return { violations: [] }
303346

304347
const dateTypeFields = collectDateTypeFields(program)
305-
const rootScope: Scope = { parent: null, dateNames: new Set() }
348+
const rootScope: Scope = createScope(null)
306349
const candidates: BindingCandidate[] = []
307350
const checks: CheckSite[] = []
308351

@@ -336,7 +379,8 @@ export function analyzeSource(source: string, file = 'source.ts'): FileAnalysis
336379
for (const property of pattern.properties) {
337380
if (!isSyntaxNode(property) || property.type !== 'ObjectProperty') continue
338381
const key = isSyntaxNode(property.key) ? property.key.name : undefined
339-
const value = isSyntaxNode(property.value) ? property.value : undefined
382+
const raw = isSyntaxNode(property.value) ? property.value : undefined
383+
const value = raw?.type === 'AssignmentPattern' && isSyntaxNode(raw.left) ? raw.left : raw
340384
if (typeof key !== 'string' || !fields.has(key)) continue
341385
if (value?.type === 'Identifier' && typeof value.name === 'string')
342386
scope.dateNames.add(value.name)
@@ -348,6 +392,7 @@ export function analyzeSource(source: string, file = 'source.ts'): FileAnalysis
348392
for (const raw of fn.params) {
349393
if (!isSyntaxNode(raw)) continue
350394
const param = raw.type === 'AssignmentPattern' && isSyntaxNode(raw.left) ? raw.left : raw
395+
collectBoundNames(param, scope.localNames)
351396
if (param.type === 'Identifier' && typeof param.name === 'string') {
352397
if (isDateAnnotation(param.typeAnnotation)) scope.dateNames.add(param.name)
353398
} else if (param.type === 'ObjectPattern') {
@@ -361,14 +406,15 @@ export function analyzeSource(source: string, file = 'source.ts'): FileAnalysis
361406
const visit = (node: SyntaxNode, parentScope: Scope, parentStatementLine: number) => {
362407
let scope = parentScope
363408
if (FUNCTION_TYPES.has(node.type)) {
364-
scope = { parent: parentScope, dateNames: new Set() }
409+
scope = createScope(parentScope)
365410
bindParameters(node, scope)
366411
}
367412
const statementLine =
368413
STATEMENT_TYPE.test(node.type) && node.loc ? node.loc.start.line : parentStatementLine
369414

370415
if (node.type === 'VariableDeclarator' && isSyntaxNode(node.id)) {
371416
const target = node.id
417+
collectBoundNames(target, scope.localNames)
372418
if (target.type === 'Identifier' && typeof target.name === 'string') {
373419
candidates.push({
374420
scope,

0 commit comments

Comments
 (0)