@@ -35,9 +35,6 @@ const SCAN_DIRS = [join(ROOT, 'apps'), join(ROOT, 'packages'), join(ROOT, 'scrip
3535const SKIP_DIRS = new Set ( [ 'node_modules' , '.next' , '.turbo' , 'coverage' , 'dist' , 'build' , 'out' ] )
3636const SOURCE_EXTENSIONS = new Set ( [ '.ts' , '.tsx' , '.mts' , '.cts' ] )
3737const MARKER = 'contract-pending('
38- const TABLE_EXPORT = / ^ e x p o r t c o n s t ( \w + ) = p g T a b l e \( /
39- const COLUMN_DECLARATION = / ^ \s + ( \w + ) : /
40- const DOOMED_TAG = / ^ \s * (?: \/ \* \* \s * @ d e p r e c a t e d | \/ \* \* \s * c o n t r a c t - p e n d i n g \( ) /
4138
4239interface Violation {
4340 file : string
@@ -48,7 +45,13 @@ interface Violation {
4845
4946interface SyntaxNode extends Record < string , unknown > {
5047 type : string
51- loc ?: { start : { line : number } } | null
48+ start ?: number | null
49+ end ?: number | null
50+ loc ?: { start : { line : number } ; end : { line : number } } | null
51+ }
52+
53+ interface CommentNode extends SyntaxNode {
54+ value : string
5255}
5356
5457function isSyntaxNode ( value : unknown ) : value is SyntaxNode {
@@ -57,6 +60,14 @@ function isSyntaxNode(value: unknown): value is SyntaxNode {
5760 )
5861}
5962
63+ function isCommentNode ( value : unknown ) : value is CommentNode {
64+ return (
65+ isSyntaxNode ( value ) &&
66+ ( value . type === 'CommentLine' || value . type === 'CommentBlock' ) &&
67+ typeof value . value === 'string'
68+ )
69+ }
70+
6071function getChildNodes ( node : SyntaxNode ) : SyntaxNode [ ] {
6172 const children : SyntaxNode [ ] = [ ]
6273 for ( const value of Object . values ( node ) ) {
@@ -82,43 +93,120 @@ function unwrap(node: unknown): SyntaxNode | null {
8293 return current
8394}
8495
96+ /** Parses one source file, returning its program and detached comment list. */
97+ function parseSource (
98+ file : string ,
99+ source : string
100+ ) : { program : SyntaxNode ; comments : CommentNode [ ] } {
101+ const syntaxTree = parse ( source , {
102+ sourceFilename : file ,
103+ sourceType : 'unambiguous' ,
104+ errorRecovery : true ,
105+ plugins : [ ...( extname ( file ) === '.tsx' ? ( [ 'jsx' ] as const ) : [ ] ) , 'typescript' , 'decorators' ] ,
106+ } )
107+ const comments = Array . isArray ( syntaxTree . comments )
108+ ? syntaxTree . comments . filter ( isCommentNode )
109+ : [ ]
110+ return { program : syntaxTree . program as unknown as SyntaxNode , comments }
111+ }
112+
113+ /**
114+ * Merges runs of adjacent `//` comments into one text so a marker whose prose
115+ * wraps across lines reads as a single sentence. Block comments already carry
116+ * their whole body, so each stands alone.
117+ */
118+ function groupCommentText ( comments : CommentNode [ ] ) : string [ ] {
119+ const ordered = [ ...comments ] . sort ( ( a , b ) => ( a . start ?? 0 ) - ( b . start ?? 0 ) )
120+ const groups : string [ ] = [ ]
121+ let run : CommentNode [ ] = [ ]
122+ const flush = ( ) => {
123+ if ( run . length > 0 ) groups . push ( run . map ( ( comment ) => comment . value ) . join ( '\n' ) )
124+ run = [ ]
125+ }
126+ for ( const comment of ordered ) {
127+ if ( comment . type === 'CommentBlock' ) {
128+ flush ( )
129+ groups . push ( comment . value )
130+ continue
131+ }
132+ const previous = run . at ( - 1 )
133+ const contiguous =
134+ previous && ( comment . loc ?. start . line ?? 0 ) === ( previous . loc ?. end . line ?? 0 ) + 1
135+ if ( ! contiguous ) flush ( )
136+ run . push ( comment )
137+ }
138+ flush ( )
139+ return groups
140+ }
141+
142+ /**
143+ * TSDoc blocks that sit on their own line(s) directly above `node`. Only block
144+ * comments count as column documentation: a `//` run above a column is the
145+ * table-level contract marker, which describes the table, not that column.
146+ */
147+ function leadingDocBlocks ( node : SyntaxNode ) : CommentNode [ ] {
148+ const leading = Array . isArray ( node . leadingComments ) ? node . leadingComments : [ ]
149+ const startLine = node . loc ?. start . line ?? 0
150+ return leading . filter (
151+ ( comment ) : comment is CommentNode =>
152+ isCommentNode ( comment ) &&
153+ comment . type === 'CommentBlock' &&
154+ ( comment . loc ?. end . line ?? 0 ) < startLine
155+ )
156+ }
157+
158+ /** Column properties whose TSDoc marks them for the pending drop. */
159+ function readDoomedColumns ( columns : SyntaxNode ) : Set < string > {
160+ const doomed = new Set < string > ( )
161+ const properties = Array . isArray ( columns . properties ) ? columns . properties : [ ]
162+ for ( const property of properties ) {
163+ if ( ! isSyntaxNode ( property ) || property . type !== 'ObjectProperty' ) continue
164+ const name = propertyName ( property . key )
165+ if ( ! name ) continue
166+ const marked = leadingDocBlocks ( property ) . some (
167+ ( comment ) => comment . value . includes ( '@deprecated' ) || comment . value . includes ( MARKER )
168+ )
169+ if ( marked ) doomed . add ( name )
170+ }
171+ return doomed
172+ }
173+
85174/**
86175 * Tables owed a DROP contract, mapped to their doomed columns.
87176 *
88- * A table is guarded when its body carries a `contract-pending(` marker that
89- * mentions a drop; markers for non-drop contracts (e.g. a pending `SET NOT
90- * NULL` normalization) don't make argless reads hazardous and are excluded. A
91- * column is doomed when its declaration sits directly under an `@deprecated`
92- * TSDoc line or carries the `contract-pending` marker itself.
177+ * A table is guarded when a comment inside its `pgTable(...)` call carries a
178+ * `contract-pending(` marker that mentions a drop; markers for non-drop
179+ * contracts (e.g. a pending `SET NOT NULL` normalization) don't make argless
180+ * reads hazardous and are excluded. A column is doomed when its own TSDoc says
181+ * `@deprecated` or carries the marker — read from the parsed comment blocks, so
182+ * single-line and multiline TSDoc are recognized alike.
93183 */
94184function readPendingTables ( ) : Map < string , Set < string > > {
95- const doomedByTable = new Map < string , Set < string > > ( )
96- const pending = new Set < string > ( )
97- const lines = readFileSync ( SCHEMA_PATH , 'utf8' ) . split ( '\n' )
98- let currentTable : string | null = null
99- for ( let i = 0 ; i < lines . length ; i ++ ) {
100- const exported = TABLE_EXPORT . exec ( lines [ i ] )
101- if ( exported ) {
102- currentTable = exported [ 1 ]
103- continue
104- }
105- if ( ! currentTable ) continue
106- if ( lines [ i ] . includes ( MARKER ) ) {
107- const markerText = `${ lines [ i ] } \n${ lines [ i + 1 ] ?? '' } `
108- if ( / \b d r o p \b / i. test ( markerText ) ) pending . add ( currentTable )
109- }
110- if ( DOOMED_TAG . test ( lines [ i ] ) ) {
111- const declaration = COLUMN_DECLARATION . exec ( lines [ i + 1 ] ?? '' )
112- if ( declaration ) {
113- const doomed = doomedByTable . get ( currentTable ) ?? new Set < string > ( )
114- doomed . add ( declaration [ 1 ] )
115- doomedByTable . set ( currentTable , doomed )
185+ const { program, comments } = parseSource ( SCHEMA_PATH , readFileSync ( SCHEMA_PATH , 'utf8' ) )
186+ const pending = new Map < string , Set < string > > ( )
187+
188+ const visit = ( node : SyntaxNode ) => {
189+ if ( node . type === 'VariableDeclarator' ) {
190+ const init = unwrap ( node . init )
191+ const bound = propertyName ( node . id )
192+ if ( bound && init ?. type === 'CallExpression' && identifierName ( init . callee ) === 'pgTable' ) {
193+ const columns = unwrap ( Array . isArray ( init . arguments ) ? init . arguments [ 1 ] : undefined )
194+ if ( columns ?. type === 'ObjectExpression' ) {
195+ const within = comments . filter (
196+ ( comment ) =>
197+ ( comment . start ?? - 1 ) >= ( init . start ?? 0 ) && ( comment . end ?? - 1 ) <= ( init . end ?? 0 )
198+ )
199+ const declaresDrop = groupCommentText ( within ) . some (
200+ ( text ) => text . includes ( MARKER ) && / \b d r o p \b / i. test ( text )
201+ )
202+ if ( declaresDrop ) pending . set ( bound , readDoomedColumns ( columns ) )
203+ }
116204 }
117205 }
206+ for ( const child of getChildNodes ( node ) ) visit ( child )
118207 }
119- const result = new Map < string , Set < string > > ( )
120- for ( const table of pending ) result . set ( table , doomedByTable . get ( table ) ?? new Set ( ) )
121- return result
208+ visit ( program )
209+ return pending
122210}
123211
124212function identifierName ( node : unknown ) : string | null {
@@ -345,17 +433,7 @@ function auditFile(
345433 const violations : Violation [ ] = [ ]
346434 let program : SyntaxNode
347435 try {
348- const syntaxTree = parse ( source , {
349- sourceFilename : file ,
350- sourceType : 'unambiguous' ,
351- errorRecovery : true ,
352- plugins : [
353- ...( extname ( file ) === '.tsx' ? ( [ 'jsx' ] as const ) : [ ] ) ,
354- 'typescript' ,
355- 'decorators' ,
356- ] ,
357- } )
358- program = syntaxTree . program as unknown as SyntaxNode
436+ program = parseSource ( file , source ) . program
359437 } catch ( error ) {
360438 violations . push ( {
361439 file,
0 commit comments