Skip to content

Commit d0b61ea

Browse files
icecrasher321claude
andcommitted
fix(ci): derive pending-drop columns from the AST, and align the mock schema
The doomed-column derivation matched `@deprecated` with a line regex, so a column documented with a multiline TSDoc block was never marked doomed and the live-column helpers were free to omit nothing for it. Derivation now runs off the parsed schema: guarded tables are pgTable calls whose enclosed comments carry a drop-flavored contract-pending marker, and doomed columns come from each property's own leading TSDoc block, so single-line and multiline forms read alike. Verified by mutation: making a deprecated column's TSDoc multiline and dropping it from the helper's omit list now fails with the column named, where the previous version reported clean. Also fills the seven live columns the testing mirror was missing (user_stats.limit_notifications, four organization settings columns, workflow_execution_logs.cost_total/models_used) so mock-derived selections have the same shape as production ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 53a31fd commit d0b61ea

2 files changed

Lines changed: 129 additions & 44 deletions

File tree

packages/testing/src/mocks/schema.mock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const workflowExecutionLogsMock = {
5555
totalDurationMs: 'workflowExecutionLogs.totalDurationMs',
5656
executionData: 'workflowExecutionLogs.executionData',
5757
cost: 'workflowExecutionLogs.cost',
58+
costTotal: 'workflowExecutionLogs.costTotal',
59+
modelsUsed: 'workflowExecutionLogs.modelsUsed',
5860
files: 'workflowExecutionLogs.files',
5961
createdAt: 'workflowExecutionLogs.createdAt',
6062
}
@@ -90,6 +92,7 @@ const userStatsMock = {
9092
lastActive: 'userStats.lastActive',
9193
billingBlocked: 'userStats.billingBlocked',
9294
billingBlockedReason: 'userStats.billingBlockedReason',
95+
limitNotifications: 'userStats.limitNotifications',
9396
}
9497

9598
const organizationMock = {
@@ -102,6 +105,10 @@ const organizationMock = {
102105
orgUsageLimit: 'organization.orgUsageLimit',
103106
storageUsedBytes: 'organization.storageUsedBytes',
104107
departedMemberUsage: 'organization.departedMemberUsage',
108+
sessionPolicySettings: 'organization.sessionPolicySettings',
109+
securityPolicyVersion: 'organization.securityPolicyVersion',
110+
dataRetentionSettings: 'organization.dataRetentionSettings',
111+
limitNotifications: 'organization.limitNotifications',
105112
creditBalance: 'organization.creditBalance',
106113
createdAt: 'organization.createdAt',
107114
updatedAt: 'organization.updatedAt',

scripts/check-pending-drop-tables.ts

Lines changed: 122 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@ const SCAN_DIRS = [join(ROOT, 'apps'), join(ROOT, 'packages'), join(ROOT, 'scrip
3535
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build', 'out'])
3636
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts'])
3737
const MARKER = 'contract-pending('
38-
const TABLE_EXPORT = /^export const (\w+) = pgTable\(/
39-
const COLUMN_DECLARATION = /^\s+(\w+):/
40-
const DOOMED_TAG = /^\s*(?:\/\*\*\s*@deprecated|\/\*\*\s*contract-pending\()/
4138

4239
interface Violation {
4340
file: string
@@ -48,7 +45,13 @@ interface Violation {
4845

4946
interface 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

5457
function 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+
6071
function 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
*/
94184
function 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 (/\bdrop\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) && /\bdrop\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

124212
function 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

Comments
 (0)