11import { describe , expect , test } from 'bun:test'
2- import { findSqlDateBindingViolations } from './check-sql-date-binding'
2+ import { analyzeSource , findSqlDateBindingViolations , SCAN_DIRS } from './check-sql-date-binding'
3+
4+ const DRIZZLE_IMPORT = "import { sql } from 'drizzle-orm'"
35
46describe ( 'sql Date binding audit' , ( ) => {
57 test ( 'rejects every unbound Date form that reaches a raw template' , ( ) => {
68 const violations = findSqlDateBindingViolations ( `
9+ ${ DRIZZLE_IMPORT }
710 const now = new Date()
811 const threshold = new Date(now.getTime() - 1000)
912 const alias = threshold
@@ -34,6 +37,7 @@ describe('sql Date binding audit', () => {
3437 test ( 'accepts column-bound params, non-Date values, and annotated exceptions' , ( ) => {
3538 expect (
3639 findSqlDateBindingViolations ( `
40+ ${ DRIZZLE_IMPORT }
3741 const now = new Date()
3842 const bound = sql\`col < \${sql.param(now, asyncJobs.startedAt)}\`
3943 const fragment = sql\`col < \${sql.param(new Date(), table.createdAt)}\`
@@ -48,6 +52,7 @@ describe('sql Date binding audit', () => {
4852
4953 test ( 'rejects annotation markers that are malformed or incidental' , ( ) => {
5054 const violations = findSqlDateBindingViolations ( `
55+ ${ DRIZZLE_IMPORT }
5156 const now = new Date()
5257 // sql-date-bound:
5358 const bareMarker = sql\`col < \${now}\`
@@ -59,4 +64,168 @@ describe('sql Date binding audit', () => {
5964
6065 expect ( violations . map ( ( violation ) => violation . expression ) ) . toEqual ( [ 'now' , 'now' , 'now' ] )
6166 } )
67+
68+ describe ( 'tag resolution' , ( ) => {
69+ test ( 'ignores a postgres-js client tag that happens to be named sql' , ( ) => {
70+ expect (
71+ findSqlDateBindingViolations ( `
72+ import postgres from 'postgres'
73+ const sql = postgres(process.env.DATABASE_URL)
74+ const now = new Date()
75+ const rows = await sql\`select * from runs where started_at < \${now}\`
76+ ` )
77+ ) . toEqual ( [ ] )
78+ } )
79+
80+ test ( 'flags an aliased drizzle import' , ( ) => {
81+ expect (
82+ findSqlDateBindingViolations ( `
83+ import { sql as raw } from 'drizzle-orm'
84+ const now = new Date()
85+ const fragment = raw\`col < \${now}\`
86+ ` ) . map ( ( violation ) => violation . expression )
87+ ) . toEqual ( [ 'now' ] )
88+ } )
89+
90+ test ( 'flags a namespace-imported drizzle tag and its param helper' , ( ) => {
91+ expect (
92+ findSqlDateBindingViolations ( `
93+ import * as d from 'drizzle-orm'
94+ const now = new Date()
95+ const fragment = d.sql\`col < \${now}\`
96+ const unencoded = d.sql.param(now)
97+ ` ) . map ( ( violation ) => violation . expression )
98+ ) . toEqual ( [ 'now' , 'now' ] )
99+ } )
100+ } )
101+
102+ describe ( 'scoping' , ( ) => {
103+ test ( 'does not let an interface field poison identifiers elsewhere in the file' , ( ) => {
104+ expect (
105+ findSqlDateBindingViolations ( `
106+ ${ DRIZZLE_IMPORT }
107+ interface Row {
108+ start: Date
109+ }
110+ function query(start: number) {
111+ return sql\`limit \${start}\`
112+ }
113+ ` )
114+ ) . toEqual ( [ ] )
115+ } )
116+
117+ test ( 'does not let a Date in one function bind a same-named value in another' , ( ) => {
118+ expect (
119+ findSqlDateBindingViolations ( `
120+ ${ DRIZZLE_IMPORT }
121+ function stamp() {
122+ const now = new Date()
123+ return now.toISOString()
124+ }
125+ function paginate() {
126+ const now = Date.now()
127+ return sql\`created_at_ms < \${now}\`
128+ }
129+ ` )
130+ ) . toEqual ( [ ] )
131+ } )
132+
133+ test ( 'still flags a module-scope Date used inside a function' , ( ) => {
134+ expect (
135+ findSqlDateBindingViolations ( `
136+ ${ DRIZZLE_IMPORT }
137+ const now = new Date()
138+ function paginate() {
139+ return sql\`created_at < \${now}\`
140+ }
141+ ` ) . map ( ( violation ) => violation . expression )
142+ ) . toEqual ( [ 'now' ] )
143+ } )
144+
145+ test ( 'still flags a destructured Date parameter typed inline' , ( ) => {
146+ expect (
147+ findSqlDateBindingViolations ( `
148+ ${ DRIZZLE_IMPORT }
149+ function query({ since }: { since: Date }) {
150+ return sql\`col < \${since}\`
151+ }
152+ ` ) . map ( ( violation ) => violation . expression )
153+ ) . toEqual ( [ 'since' ] )
154+ } )
155+
156+ test ( 'still flags a destructured Date parameter typed by a named interface' , ( ) => {
157+ expect (
158+ findSqlDateBindingViolations ( `
159+ ${ DRIZZLE_IMPORT }
160+ interface Range {
161+ since: Date
162+ }
163+ function query({ since }: Range) {
164+ return sql\`col < \${since}\`
165+ }
166+ ` ) . map ( ( violation ) => violation . expression )
167+ ) . toEqual ( [ 'since' ] )
168+ } )
169+ } )
170+
171+ describe ( 'allow annotation placement' , ( ) => {
172+ test ( 'accepts the annotation above a multi-line template' , ( ) => {
173+ expect (
174+ findSqlDateBindingViolations ( `
175+ ${ DRIZZLE_IMPORT }
176+ const now = new Date()
177+ // sql-date-bound: text column, compared as an ISO string
178+ const fragment = sql\`
179+ CASE
180+ WHEN started_at < \${now} THEN 1
181+ ELSE 0
182+ END
183+ \`
184+ ` )
185+ ) . toEqual ( [ ] )
186+ } )
187+
188+ test ( 'still rejects a bare marker above a multi-line template' , ( ) => {
189+ expect (
190+ findSqlDateBindingViolations ( `
191+ ${ DRIZZLE_IMPORT }
192+ const now = new Date()
193+ // sql-date-bound:
194+ const fragment = sql\`
195+ CASE
196+ WHEN started_at < \${now} THEN 1
197+ END
198+ \`
199+ ` ) . map ( ( violation ) => violation . expression )
200+ ) . toEqual ( [ 'now' ] )
201+ } )
202+ } )
203+
204+ test ( 'scans the root scripts directory' , ( ) => {
205+ expect ( SCAN_DIRS . some ( ( dir ) => dir . endsWith ( '/scripts' ) ) ) . toBe ( true )
206+ } )
207+
208+ describe ( 'parser robustness' , ( ) => {
209+ test ( 'parses decorators instead of throwing' , ( ) => {
210+ const analysis = analyzeSource ( `
211+ ${ DRIZZLE_IMPORT }
212+ @Injectable()
213+ class Repo {
214+ find(since: Date) {
215+ return sql\`col < \${since}\`
216+ }
217+ }
218+ ` )
219+
220+ expect ( analysis . parseError ) . toBeUndefined ( )
221+ expect ( analysis . violations . map ( ( violation ) => violation . expression ) ) . toEqual ( [ 'since' ] )
222+ } )
223+
224+ test ( 'reports an unparseable file as skipped instead of throwing' , ( ) => {
225+ const analysis = analyzeSource ( 'const a = (' , 'broken.ts' )
226+
227+ expect ( analysis . parseError ) . toBeTruthy ( )
228+ expect ( analysis . violations ) . toEqual ( [ ] )
229+ } )
230+ } )
62231} )
0 commit comments