@sqldoc/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CompilerOutput, LintRule, NamespacePlugin, ResolvedConfig } from '../compiler/types'
3
+ import { lint } from '../lint'
4
+
5
+ /** Helper to create a minimal CompilerOutput */
6
+ function makeOutput(overrides: Partial<CompilerOutput> = {}): CompilerOutput {
7
+ return {
8
+ sourceFile: 'test.sql',
9
+ mergedSql: '',
10
+ sqlOutputs: [],
11
+ codeOutputs: [],
12
+ errors: [],
13
+ docsMeta: [],
14
+ fileTags: [],
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ /** Helper to create a minimal NamespacePlugin with lint rules */
20
+ function makePlugin(name: string, lintRules: LintRule[]): NamespacePlugin {
21
+ return {
22
+ apiVersion: 1,
23
+ name,
24
+ tags: {},
25
+ lintRules,
26
+ }
27
+ }
28
+
29
+ describe('lint engine', () => {
30
+ it('returns empty results when no plugins have lint rules', () => {
31
+ const plugins = new Map<string, NamespacePlugin>()
32
+ plugins.set('test', { apiVersion: 1, name: 'test', tags: {} })
33
+
34
+ const results = lint([makeOutput()], plugins, { dialect: 'postgres' })
35
+ expect(results).toEqual([])
36
+ })
37
+
38
+ it('runs lint rules from plugins and returns diagnostics', () => {
39
+ const rule: LintRule = {
40
+ name: 'test.always-fail',
41
+ description: 'Always produces a diagnostic',
42
+ default: 'warn',
43
+ check: () => [{ objectName: 'users', sourceFile: 'test.sql', message: 'Test failure' }],
44
+ }
45
+
46
+ const plugins = new Map<string, NamespacePlugin>()
47
+ plugins.set('test', makePlugin('test', [rule]))
48
+
49
+ const results = lint([makeOutput()], plugins, { dialect: 'postgres' })
50
+ expect(results).toHaveLength(1)
51
+ expect(results[0]).toEqual({
52
+ ruleName: 'test.always-fail',
53
+ severity: 'warn',
54
+ objectName: 'users',
55
+ sourceFile: 'test.sql',
56
+ message: 'Test failure',
57
+ })
58
+ })
59
+
60
+ it('applies severity override from config', () => {
61
+ const rule: LintRule = {
62
+ name: 'test.check',
63
+ description: 'Test rule',
64
+ default: 'warn',
65
+ check: () => [{ objectName: 'orders', sourceFile: 'schema.sql', message: 'Missing thing' }],
66
+ }
67
+
68
+ const plugins = new Map<string, NamespacePlugin>()
69
+ plugins.set('test', makePlugin('test', [rule]))
70
+
71
+ const config: ResolvedConfig = {
72
+ dialect: 'postgres',
73
+ lint: {
74
+ rules: { 'test.check': 'error' },
75
+ },
76
+ }
77
+
78
+ const results = lint([makeOutput({ sourceFile: 'schema.sql' })], plugins, config)
79
+ expect(results).toHaveLength(1)
80
+ expect(results[0].severity).toBe('error')
81
+ })
82
+
83
+ it('skips rules set to "off" in config', () => {
84
+ const rule: LintRule = {
85
+ name: 'test.check',
86
+ description: 'Test rule',
87
+ default: 'error',
88
+ check: () => [{ objectName: 'users', sourceFile: 'test.sql', message: 'Should not appear' }],
89
+ }
90
+
91
+ const plugins = new Map<string, NamespacePlugin>()
92
+ plugins.set('test', makePlugin('test', [rule]))
93
+
94
+ const config: ResolvedConfig = {
95
+ dialect: 'postgres',
96
+ lint: {
97
+ rules: { 'test.check': 'off' },
98
+ },
99
+ }
100
+
101
+ const results = lint([makeOutput()], plugins, config)
102
+ expect(results).toHaveLength(0)
103
+ })
104
+
105
+ it('respects @lint.ignore tags and marks results as "skip"', () => {
106
+ const rule: LintRule = {
107
+ name: 'audit.require-audit',
108
+ description: 'Tables should have @audit',
109
+ default: 'warn',
110
+ check: () => [{ objectName: 'events', sourceFile: 'test.sql', message: "Table 'events' has no @audit tag" }],
111
+ }
112
+
113
+ const plugins = new Map<string, NamespacePlugin>()
114
+ plugins.set('audit', makePlugin('audit', [rule]))
115
+
116
+ const output = makeOutput({
117
+ sourceFile: 'test.sql',
118
+ fileTags: [
119
+ {
120
+ objectName: 'events',
121
+ target: 'table',
122
+ tags: [{ namespace: 'lint', tag: 'ignore', args: ['audit.require-audit', 'Temporary staging table'] }],
123
+ },
124
+ ],
125
+ })
126
+
127
+ const results = lint([output], plugins, { dialect: 'postgres' })
128
+ expect(results).toHaveLength(1)
129
+ expect(results[0].severity).toBe('skip')
130
+ expect(results[0].ignoreReason).toBe('Temporary staging table')
131
+ expect(results[0].message).toBe("Table 'events' has no @audit tag")
132
+ })
133
+
134
+ it('does not ignore when @lint.ignore targets a different rule', () => {
135
+ const rule: LintRule = {
136
+ name: 'audit.require-audit',
137
+ description: 'Tables should have @audit',
138
+ default: 'warn',
139
+ check: () => [{ objectName: 'events', sourceFile: 'test.sql', message: "Table 'events' has no @audit tag" }],
140
+ }
141
+
142
+ const plugins = new Map<string, NamespacePlugin>()
143
+ plugins.set('audit', makePlugin('audit', [rule]))
144
+
145
+ const output = makeOutput({
146
+ sourceFile: 'test.sql',
147
+ fileTags: [
148
+ {
149
+ objectName: 'events',
150
+ target: 'table',
151
+ tags: [{ namespace: 'lint', tag: 'ignore', args: ['rls.require-policy', 'Not needed'] }],
152
+ },
153
+ ],
154
+ })
155
+
156
+ const results = lint([output], plugins, { dialect: 'postgres' })
157
+ expect(results).toHaveLength(1)
158
+ expect(results[0].severity).toBe('warn')
159
+ })
160
+
161
+ it('collects rules from multiple plugins', () => {
162
+ const rule1: LintRule = {
163
+ name: 'audit.require-audit',
164
+ description: 'Need audit',
165
+ default: 'warn',
166
+ check: () => [{ objectName: 'users', sourceFile: 'test.sql', message: 'No audit' }],
167
+ }
168
+
169
+ const rule2: LintRule = {
170
+ name: 'rls.require-policy',
171
+ description: 'Need RLS',
172
+ default: 'error',
173
+ check: () => [{ objectName: 'users', sourceFile: 'test.sql', message: 'No RLS' }],
174
+ }
175
+
176
+ const plugins = new Map<string, NamespacePlugin>()
177
+ plugins.set('audit', makePlugin('audit', [rule1]))
178
+ plugins.set('rls', makePlugin('rls', [rule2]))
179
+
180
+ const results = lint([makeOutput()], plugins, { dialect: 'postgres' })
181
+ expect(results).toHaveLength(2)
182
+ expect(results.map((r) => r.ruleName)).toEqual(['audit.require-audit', 'rls.require-policy'])
183
+ expect(results[0].severity).toBe('warn')
184
+ expect(results[1].severity).toBe('error')
185
+ })
186
+
187
+ it('handles multiple outputs (files) correctly', () => {
188
+ const rule: LintRule = {
189
+ name: 'test.check',
190
+ description: 'Test rule',
191
+ default: 'warn',
192
+ check: (ctx) => {
193
+ const diags = []
194
+ for (const output of ctx.outputs) {
195
+ for (const obj of output.fileTags) {
196
+ if (obj.target === 'table' && !obj.objectName.includes('.')) {
197
+ diags.push({
198
+ objectName: obj.objectName,
199
+ sourceFile: output.sourceFile,
200
+ message: `Missing on ${obj.objectName}`,
201
+ })
202
+ }
203
+ }
204
+ }
205
+ return diags
206
+ },
207
+ }
208
+
209
+ const plugins = new Map<string, NamespacePlugin>()
210
+ plugins.set('test', makePlugin('test', [rule]))
211
+
212
+ const outputs = [
213
+ makeOutput({
214
+ sourceFile: 'a.sql',
215
+ fileTags: [{ objectName: 'users', target: 'table', tags: [] }],
216
+ }),
217
+ makeOutput({
218
+ sourceFile: 'b.sql',
219
+ fileTags: [{ objectName: 'orders', target: 'table', tags: [] }],
220
+ }),
221
+ ]
222
+
223
+ const results = lint(outputs, plugins, { dialect: 'postgres' })
224
+ expect(results).toHaveLength(2)
225
+ expect(results[0].sourceFile).toBe('a.sql')
226
+ expect(results[0].objectName).toBe('users')
227
+ expect(results[1].sourceFile).toBe('b.sql')
228
+ expect(results[1].objectName).toBe('orders')
229
+ })
230
+
231
+ it('does not match @lint.ignore across different files', () => {
232
+ const rule: LintRule = {
233
+ name: 'test.check',
234
+ description: 'Test rule',
235
+ default: 'warn',
236
+ check: () => [{ objectName: 'users', sourceFile: 'b.sql', message: 'Problem in b.sql' }],
237
+ }
238
+
239
+ const plugins = new Map<string, NamespacePlugin>()
240
+ plugins.set('test', makePlugin('test', [rule]))
241
+
242
+ const outputs = [
243
+ makeOutput({
244
+ sourceFile: 'a.sql',
245
+ fileTags: [
246
+ {
247
+ objectName: 'users',
248
+ target: 'table',
249
+ tags: [{ namespace: 'lint', tag: 'ignore', args: ['test.check', 'Reason'] }],
250
+ },
251
+ ],
252
+ }),
253
+ makeOutput({
254
+ sourceFile: 'b.sql',
255
+ fileTags: [
256
+ {
257
+ objectName: 'users',
258
+ target: 'table',
259
+ tags: [],
260
+ },
261
+ ],
262
+ }),
263
+ ]
264
+
265
+ const results = lint(outputs, plugins, { dialect: 'postgres' })
266
+ expect(results).toHaveLength(1)
267
+ // The ignore is in a.sql but the diagnostic is in b.sql, so it should NOT be skipped
268
+ expect(results[0].severity).toBe('warn')
269
+ })
270
+ })
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parse, parseArgs } from '../parser'
3
+
4
+ describe('parse', () => {
5
+ it('extracts @import with single-quoted path', () => {
6
+ const result = parse("-- @import './anon.ts'")
7
+ expect(result.imports).toHaveLength(1)
8
+ expect(result.imports[0].path).toBe('./anon.ts')
9
+ expect(result.imports[0].line).toBe(0)
10
+ })
11
+
12
+ it('extracts @import with double-quoted path', () => {
13
+ const result = parse('-- @import "./rls.ts"')
14
+ expect(result.imports).toHaveLength(1)
15
+ expect(result.imports[0].path).toBe('./rls.ts')
16
+ })
17
+
18
+ it('extracts multiple @import lines with correct line numbers', () => {
19
+ const doc = `-- @import './anon.ts'\n-- @import './rls.ts'`
20
+ const result = parse(doc)
21
+ expect(result.imports).toHaveLength(2)
22
+ expect(result.imports[0].line).toBe(0)
23
+ expect(result.imports[0].path).toBe('./anon.ts')
24
+ expect(result.imports[1].line).toBe(1)
25
+ expect(result.imports[1].path).toBe('./rls.ts')
26
+ })
27
+
28
+ it('extracts namespace.tag(args) from comment line', () => {
29
+ const result = parse('-- @anon.mask(type: email)')
30
+ expect(result.tags).toHaveLength(1)
31
+ expect(result.tags[0].namespace).toBe('anon')
32
+ expect(result.tags[0].tag).toBe('mask')
33
+ expect(result.tags[0].rawArgs).toBe('type: email')
34
+ })
35
+
36
+ it('extracts standalone namespace tag (no dot, no args)', () => {
37
+ const result = parse('-- @searchable')
38
+ expect(result.tags).toHaveLength(1)
39
+ expect(result.tags[0].namespace).toBe('searchable')
40
+ expect(result.tags[0].tag).toBeNull()
41
+ expect(result.tags[0].rawArgs).toBeNull()
42
+ })
43
+
44
+ it('extracts namespace.tag with complex named args', () => {
45
+ const result = parse("-- @rls.policy(name: 'users_read', check: true)")
46
+ expect(result.tags).toHaveLength(1)
47
+ expect(result.tags[0].namespace).toBe('rls')
48
+ expect(result.tags[0].tag).toBe('policy')
49
+ expect(result.tags[0].rawArgs).toBe("name: 'users_read', check: true")
50
+ })
51
+
52
+ it('ignores tags outside of comment lines', () => {
53
+ const doc = `@anon.mask(type: email)\nCREATE TABLE users (\n id BIGSERIAL\n);`
54
+ const result = parse(doc)
55
+ expect(result.tags).toHaveLength(0)
56
+ })
57
+
58
+ it('extracts multiple tags on consecutive comment lines with correct line numbers', () => {
59
+ const doc = `-- @anon.mask(type: email)\n-- @searchable\nCREATE TABLE users (\n id BIGSERIAL\n);`
60
+ const result = parse(doc)
61
+ expect(result.tags).toHaveLength(2)
62
+ expect(result.tags[0].line).toBe(0)
63
+ expect(result.tags[0].namespace).toBe('anon')
64
+ expect(result.tags[1].line).toBe(1)
65
+ expect(result.tags[1].namespace).toBe('searchable')
66
+ })
67
+
68
+ it('separates imports from tags (import not in tags array)', () => {
69
+ const doc = `-- @import './foo.ts'\n-- @anon.mask`
70
+ const result = parse(doc)
71
+ expect(result.imports).toHaveLength(1)
72
+ expect(result.tags).toHaveLength(1)
73
+ expect(result.tags[0].namespace).toBe('anon')
74
+ })
75
+
76
+ it('sets correct position fields for namespace and tag spans', () => {
77
+ const result = parse('-- @anon.mask(type: email)')
78
+ const tag = result.tags[0]
79
+ // "-- @anon.mask(type: email)"
80
+ // ^ = index 3 = startCol of full match (@anon.mask(...))
81
+ // ^^^^ namespace "anon" starts at 4 (after @)
82
+ expect(tag.namespaceStart).toBe(4)
83
+ expect(tag.namespaceEnd).toBe(8) // 4 + "anon".length
84
+ expect(tag.tagStart).toBe(9) // after the dot
85
+ expect(tag.tagEnd).toBe(13) // 9 + "mask".length
86
+ })
87
+
88
+ it('correctly handles import startCol and endCol', () => {
89
+ const result = parse("-- @import './anon.ts'")
90
+ expect(result.imports[0].startCol).toBe(0)
91
+ expect(result.imports[0].endCol).toBe("-- @import './anon.ts'".length)
92
+ })
93
+
94
+ it('handles a realistic multi-line SQL document', () => {
95
+ const doc = `-- @import './anon.ts'
96
+ -- @anon.mask(type: email)
97
+ CREATE TABLE users (
98
+ id BIGSERIAL PRIMARY KEY,
99
+ email TEXT NOT NULL
100
+ );`
101
+ const result = parse(doc)
102
+ expect(result.imports).toHaveLength(1)
103
+ expect(result.imports[0].path).toBe('./anon.ts')
104
+ expect(result.tags).toHaveLength(1)
105
+ expect(result.tags[0].namespace).toBe('anon')
106
+ expect(result.tags[0].tag).toBe('mask')
107
+ expect(result.tags[0].line).toBe(1)
108
+ })
109
+
110
+ it('returns empty results for plain SQL with no tags or imports', () => {
111
+ const doc = `CREATE TABLE users (\n id BIGSERIAL\n);`
112
+ const result = parse(doc)
113
+ expect(result.imports).toHaveLength(0)
114
+ expect(result.tags).toHaveLength(0)
115
+ })
116
+
117
+ it('correctly sets argsStart and argsEnd for tag arguments', () => {
118
+ const result = parse('-- @anon.mask(type: email)')
119
+ const tag = result.tags[0]
120
+ expect(tag.rawArgs).toBe('type: email')
121
+ // argsStart should be the index right after '('
122
+ // argsEnd should be argsStart + rawArgs.length
123
+ expect(tag.argsEnd - tag.argsStart).toBe('type: email'.length)
124
+ })
125
+ })
126
+
127
+ describe('parseArgs', () => {
128
+ it('parses named args with string value', () => {
129
+ const result = parseArgs('type: email')
130
+ expect(result.type).toBe('named')
131
+ expect(result.values).toEqual({ type: 'email' })
132
+ })
133
+
134
+ it('parses positional args (comma-separated)', () => {
135
+ const result = parseArgs('email, name')
136
+ expect(result.type).toBe('positional')
137
+ expect(result.values).toEqual(['email', 'name'])
138
+ })
139
+
140
+ it('parses named args with numeric value', () => {
141
+ const result = parseArgs('count: 42')
142
+ expect(result.type).toBe('named')
143
+ expect(result.values).toEqual({ count: 42 })
144
+ })
145
+
146
+ it('parses named args with boolean value', () => {
147
+ const result = parseArgs('enabled: true')
148
+ expect(result.type).toBe('named')
149
+ expect(result.values).toEqual({ enabled: true })
150
+ })
151
+
152
+ it('parses positional array arg', () => {
153
+ const result = parseArgs('[a, b, c]')
154
+ expect(result.type).toBe('positional')
155
+ expect(result.values).toEqual([['a', 'b', 'c']])
156
+ })
157
+
158
+ it('returns empty positional for empty string', () => {
159
+ const result = parseArgs('')
160
+ expect(result.type).toBe('positional')
161
+ expect(result.values).toEqual([])
162
+ })
163
+
164
+ it('strips quotes from quoted string values', () => {
165
+ const result = parseArgs("'hello'")
166
+ expect(result.type).toBe('positional')
167
+ expect(result.values).toEqual(['hello'])
168
+ })
169
+ })
@@ -0,0 +1,15 @@
1
+ -- @for("one")
2
+ create table one ( -- @for("one again")
3
+
4
+ -- @for("two")
5
+ two text, -- @for("two again")
6
+ three text, -- @for("three")
7
+
8
+ -- @for("four")
9
+
10
+
11
+ four numeric
12
+
13
+ -- @for("one yet again")
14
+
15
+ )
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parse } from '../parser'
3
+ import type { TagDef, TagNamespace } from '../types'
4
+ import { detectTargetFallback, validate } from '../validator'
5
+
6
+ // ── Test namespace fixtures ──────────────────────────────────────────
7
+
8
+ const maskDef: TagDef = {
9
+ description: 'Mask column data',
10
+ targets: ['column'],
11
+ args: { type: { type: 'string', required: true } },
12
+ }
13
+
14
+ const policyDef: TagDef = {
15
+ description: 'RLS policy',
16
+ targets: ['table'],
17
+ args: { name: { type: 'string', required: true }, check: { type: 'boolean' } },
18
+ }
19
+
20
+ const selfDef: TagDef = {
21
+ description: 'Mark as searchable',
22
+ targets: ['table', 'column'],
23
+ }
24
+
25
+ const anonNamespace: TagNamespace = {
26
+ name: 'anon',
27
+ tags: { mask: maskDef },
28
+ }
29
+
30
+ const searchableNamespace: TagNamespace = {
31
+ name: 'searchable',
32
+ tags: { $self: selfDef, fulltext: { description: 'Full text search', targets: ['column'] } },
33
+ }
34
+
35
+ const _rlsNamespace: TagNamespace = {
36
+ name: 'rls',
37
+ tags: { policy: policyDef },
38
+ }
39
+
40
+ function makeNamespaces(...entries: TagNamespace[]): Map<string, TagNamespace> {
41
+ const map = new Map<string, TagNamespace>()
42
+ for (const ns of entries) map.set(ns.name, ns)
43
+ return map
44
+ }
45
+
46
+ // ── Helper: parse and validate in one call ───────────────────────────
47
+
48
+ function parseAndValidate(docText: string, namespaces: Map<string, TagNamespace>) {
49
+ const { tags } = parse(docText)
50
+ return validate(tags, namespaces, docText)
51
+ }
52
+
53
+ // ── validate() tests ─────────────────────────────────────────────────
54
+
55
+ describe('validate', () => {
56
+ it('reports unknown namespace for unregistered namespace', () => {
57
+ const doc = `-- @bogus.tag\nCREATE TABLE t (id INT);`
58
+ const diags = parseAndValidate(doc, makeNamespaces())
59
+ expect(diags).toHaveLength(1)
60
+ expect(diags[0].message).toContain("Unknown namespace '@bogus'")
61
+ expect(diags[0].severity).toBe('error')
62
+ })
63
+
64
+ it('reports unknown tag for tag not in namespace definition', () => {
65
+ const doc = `-- @anon.bogus\nCREATE TABLE t (id INT);`
66
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
67
+ expect(diags).toHaveLength(1)
68
+ expect(diags[0].message).toContain("Unknown tag 'bogus' in namespace 'anon'")
69
+ expect(diags[0].severity).toBe('error')
70
+ })
71
+
72
+ it('reports error when standalone tag used without $self defined', () => {
73
+ const doc = `-- @anon\nCREATE TABLE t (id INT);`
74
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
75
+ expect(diags).toHaveLength(1)
76
+ expect(diags[0].message).toContain('cannot be used as a standalone tag')
77
+ })
78
+
79
+ it('produces no diagnostic when standalone tag has $self defined', () => {
80
+ const doc = `-- @searchable\nCREATE TABLE t (id INT);`
81
+ const diags = parseAndValidate(doc, makeNamespaces(searchableNamespace))
82
+ expect(diags).toHaveLength(0)
83
+ })
84
+
85
+ it('reports target mismatch when tag is used on wrong SQL target', () => {
86
+ // mask targets=['column'] but placed above CREATE FUNCTION
87
+ const doc = `-- @anon.mask(type: email)\nCREATE OR REPLACE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;`
88
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
89
+ expect(diags.length).toBeGreaterThanOrEqual(1)
90
+ const targetDiag = diags.find((d) => d.message.includes('cannot be used on a function'))
91
+ expect(targetDiag).toBeDefined()
92
+ expect(targetDiag!.severity).toBe('error')
93
+ })
94
+
95
+ it('produces no diagnostics for valid usage', () => {
96
+ const doc = `-- @anon.mask(type: email)\n email TEXT NOT NULL`
97
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
98
+ expect(diags).toHaveLength(0)
99
+ })
100
+
101
+ it('reports missing required named argument', () => {
102
+ // mask requires 'type' arg
103
+ const doc = `-- @anon.mask\n email TEXT NOT NULL`
104
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
105
+ expect(diags.length).toBeGreaterThanOrEqual(1)
106
+ const argDiag = diags.find((d) => d.message.includes('Missing required argument'))
107
+ expect(argDiag).toBeDefined()
108
+ })
109
+
110
+ it('reports error when tag does not accept arguments but args provided', () => {
111
+ // searchable.$self has no args defined
112
+ const noArgsNs: TagNamespace = {
113
+ name: 'noargs',
114
+ tags: { simple: { description: 'No args tag' } },
115
+ }
116
+ const doc = `-- @noargs.simple(foo: bar)\nCREATE TABLE t (id INT);`
117
+ const diags = parseAndValidate(doc, makeNamespaces(noArgsNs))
118
+ expect(diags.length).toBeGreaterThanOrEqual(1)
119
+ const argDiag = diags.find((d) => d.message.includes('does not accept arguments'))
120
+ expect(argDiag).toBeDefined()
121
+ })
122
+
123
+ it('reports error when named args given but positional expected', () => {
124
+ const posNs: TagNamespace = {
125
+ name: 'pos',
126
+ tags: { order: { description: 'Order', args: [{ type: 'string' }] } },
127
+ }
128
+ const doc = `-- @pos.order(dir: asc)\nCREATE TABLE t (id INT);`
129
+ const diags = parseAndValidate(doc, makeNamespaces(posNs))
130
+ expect(diags.length).toBeGreaterThanOrEqual(1)
131
+ const argDiag = diags.find((d) => d.message.includes('expects positional arguments, not named'))
132
+ expect(argDiag).toBeDefined()
133
+ })
134
+
135
+ it('reports too many positional arguments', () => {
136
+ const posNs: TagNamespace = {
137
+ name: 'pos',
138
+ tags: { order: { description: 'Order', args: [{ type: 'string' }] } },
139
+ }
140
+ const doc = `-- @pos.order(asc, desc, extra)\nCREATE TABLE t (id INT);`
141
+ const diags = parseAndValidate(doc, makeNamespaces(posNs))
142
+ expect(diags.length).toBeGreaterThanOrEqual(1)
143
+ const argDiag = diags.find((d) => d.message.includes('Too many arguments'))
144
+ expect(argDiag).toBeDefined()
145
+ })
146
+
147
+ it('reports wrong arg type when tag expects number but gets string', () => {
148
+ const numNs: TagNamespace = {
149
+ name: 'num',
150
+ tags: { limit: { description: 'Limit', args: { count: { type: 'number', required: true } } } },
151
+ }
152
+ const doc = `-- @num.limit(count: abc)\nCREATE TABLE t (id INT);`
153
+ const diags = parseAndValidate(doc, makeNamespaces(numNs))
154
+ expect(diags.length).toBeGreaterThanOrEqual(1)
155
+ const typeDiag = diags.find((d) => d.message.includes('expected number'))
156
+ expect(typeDiag).toBeDefined()
157
+ })
158
+
159
+ it('sets correct line numbers on diagnostics', () => {
160
+ const doc = `CREATE TABLE t (\n -- @bogus.tag\n id INT\n);`
161
+ const diags = parseAndValidate(doc, makeNamespaces())
162
+ expect(diags).toHaveLength(1)
163
+ expect(diags[0].line).toBe(1) // zero-indexed, second line
164
+ })
165
+
166
+ it('validates multiple tags in same block independently', () => {
167
+ const doc = `-- @anon.mask(type: email)\n-- @anon.bogus\n email TEXT NOT NULL`
168
+ const diags = parseAndValidate(doc, makeNamespaces(anonNamespace))
169
+ // mask is valid on column, but bogus is unknown
170
+ expect(diags.length).toBeGreaterThanOrEqual(1)
171
+ const unknownDiag = diags.find((d) => d.message.includes("Unknown tag 'bogus'"))
172
+ expect(unknownDiag).toBeDefined()
173
+ })
174
+ })
175
+
176
+ // ── detectTargetFallback() tests ─────────────────────────────────────
177
+
178
+ describe('detectTargetFallback', () => {
179
+ it('detects CREATE TABLE', () => {
180
+ expect(detectTargetFallback(['CREATE TABLE users ('])).toBe('table')
181
+ })
182
+
183
+ it('detects CREATE OR REPLACE FUNCTION', () => {
184
+ expect(detectTargetFallback(['CREATE OR REPLACE FUNCTION foo()'])).toBe('function')
185
+ })
186
+
187
+ it('detects CREATE VIEW', () => {
188
+ expect(detectTargetFallback(['CREATE VIEW v AS'])).toBe('view')
189
+ })
190
+
191
+ it('detects CREATE UNIQUE INDEX', () => {
192
+ expect(detectTargetFallback(['CREATE UNIQUE INDEX idx ON t (c)'])).toBe('index')
193
+ })
194
+
195
+ it('detects CREATE TYPE', () => {
196
+ expect(detectTargetFallback(['CREATE TYPE mood AS ENUM'])).toBe('type')
197
+ })
198
+
199
+ it('detects CREATE TRIGGER', () => {
200
+ expect(detectTargetFallback(['CREATE TRIGGER t AFTER INSERT'])).toBe('trigger')
201
+ })
202
+
203
+ it('detects column from indented non-CREATE line', () => {
204
+ expect(detectTargetFallback([' email TEXT NOT NULL'])).toBe('column')
205
+ })
206
+
207
+ it('returns unknown for empty array', () => {
208
+ expect(detectTargetFallback([])).toBe('unknown')
209
+ })
210
+ })
@@ -0,0 +1,10 @@
1
+ import type { SqlCommentOn, SqlStatement } from './types.ts'
2
+
3
+ export interface SqlAstAdapter {
4
+ /** Initialize the parser (WASM loading, etc.) */
5
+ init(): Promise<void>
6
+ /** Parse SQL text and return normalized statements */
7
+ parseStatements(sql: string): SqlStatement[]
8
+ /** Parse COMMENT ON statements from SQL text */
9
+ parseComments(sql: string): SqlCommentOn[]
10
+ }
@@ -0,0 +1,3 @@
1
+ export type { SqlAstAdapter } from './adapter.ts'
2
+ export { SqlparserTsAdapter } from './sqlparser-ts.ts'
3
+ export type { SqlColumn, SqlCommentOn, SqlStatement } from './types.ts'