diff --git a/CHANGELOG.md b/CHANGELOG.md index 315f39d..d4320f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- VBA SQL extraction now ignores reserved words exposed by dynamic table-name concatenation instead of emitting misleading table references. (#203) - VBA continued statements are now parsed as one logical line while retaining their original source locations, restoring class references, qualified calls, procedure headers, and API declarations split with line continuations. (#202) - VBA SQL variables now stay within their procedure while module-level SQL remains available as a fallback, preventing same-named variables in separate procedures from creating false table references. (#204) - VBA: colon-separated single-line procedures now close their scope on the same physical line and scan calls in their inline body, preventing later module constants from being misclassified and making procedure tracking consistent regardless of header placement. (#208) diff --git a/__tests__/extraction-vba.test.ts b/__tests__/extraction-vba.test.ts index 26490fc..30358dc 100644 --- a/__tests__/extraction-vba.test.ts +++ b/__tests__/extraction-vba.test.ts @@ -610,6 +610,89 @@ End Sub`; }); }); +// Issue #203 — REGRESSION GUARDS for the silently-wrong captures that +// motivated the SQL-table scan consolidation. Every SQL reserved word +// that can legitimately follow FROM/JOIN/INTO/UPDATE was previously +// captured as a table reference when the chain had a dropped operand +// (a variable or expression between two `&`-concatenated literals). +// The fix lives in `src/extraction/sql-table-scan.ts` and emits a `?` +// sentinel for dropped operands plus a reserved-word reject list. +// These tests pin the bug at the VbaExtractor level so any future +// refactor that re-introduces it fails loudly. +describe('VbaExtractor — SQL table scan rejects reserved words as table names (Issue #203)', () => { + function sqlEdges(r: ReturnType) { + return r.edges.filter((e) => e.metadata?.synthesizedBy === 'vba-sql-table'); + } + function sqlEdgeNames(r: ReturnType) { + return sqlEdges(r).map((e) => r.nodes.find((n) => n.id === e.target)?.name); + } + + it('"DELETE FROM " & tabla & " WHERE x" emits NO vba-sql-table reference (not a wrong one)', () => { + const src = `Public Sub Borra(ByVal tabla As String) + getdb().Execute "DELETE FROM " & tabla & " WHERE x" +End Sub`; + const r = extract('src/modules/Borra.bas', src); + expect(sqlEdges(r)).toHaveLength(0); + // No phantom WHERE/WHERE/SET node should appear either. + const reserved = ['WHERE', 'ORDER', 'SET', 'GROUP', 'HAVING']; + for (const kw of reserved) { + expect(r.nodes.some((n) => n.name === kw)).toBe(false); + } + }); + + it('"SELECT * FROM " & tabla & " WHERE x=1" emits NO vba-sql-table reference', () => { + const src = `Public Sub Q(ByVal tabla As String) + DoCmd.RunSQL "SELECT * FROM " & tabla & " WHERE x=1" +End Sub`; + const r = extract('src/modules/Q.bas', src); + expect(sqlEdges(r)).toHaveLength(0); + expect(r.nodes.some((n) => n.name === 'WHERE')).toBe(false); + }); + + it('"UPDATE " & tabla & " SET a=1" emits NO vba-sql-table reference (was poisoning write reports)', () => { + const src = `Public Sub U(ByVal tabla As String) + DoCmd.RunSQL "UPDATE " & tabla & " SET a=1" +End Sub`; + const r = extract('src/modules/U.bas', src); + expect(sqlEdges(r)).toHaveLength(0); + // The previous behaviour emitted a node named `SET` with + // metadata.access === 'write', poisoning "who writes to table X" + // queries. After the fix neither `SET` nor any other reserved + // keyword should be a node. + expect(r.nodes.some((n) => n.name === 'SET')).toBe(false); + }); + + it('a reserved word on the SECOND literal still produces no edge', () => { + // `db.Execute "SELECT 1" & " FROM tblA" & " WHERE x"` — the second + // literal's leading `FROM tblA` is real; the third literal's + // leading ` WHERE x` is the bug. Only tblA should be captured. + const src = `Public Sub Q() + db.Execute "SELECT 1" & " FROM tblA" & " WHERE x" +End Sub`; + const r = extract('src/modules/Q.bas', src); + expect(sqlEdgeNames(r)).toEqual(['tblA']); + }); + + it('variable-form SQL: m_SQL = "DELETE FROM " & tabla & " WHERE x" emits NO edge', () => { + const src = `Public Sub V(ByVal tabla As String) + Dim m_SQL As String + m_SQL = "DELETE FROM " & tabla & " WHERE x" + getdb().Execute m_SQL +End Sub`; + const r = extract('src/modules/V.bas', src); + expect(sqlEdges(r)).toHaveLength(0); + expect(r.nodes.some((n) => n.name === 'WHERE')).toBe(false); + }); + + it('regression: a real table name in a concatenated literal still emits (no false negatives)', () => { + const src = `Public Sub Real() + db.Execute "DELETE FROM tblOld " & " WHERE x=1" +End Sub`; + const r = extract('src/modules/Real.bas', src); + expect(sqlEdgeNames(r)).toEqual(['tblOld']); + }); +}); + describe('VbaExtractor — .form.txt rejection (REQ-CODE-9)', () => { it('emits zero function/class/module nodes when given a .form.txt input', () => { const src = `Sub Form_Load() diff --git a/__tests__/sql-table-scan.test.ts b/__tests__/sql-table-scan.test.ts new file mode 100644 index 0000000..28129a8 --- /dev/null +++ b/__tests__/sql-table-scan.test.ts @@ -0,0 +1,253 @@ +/** + * Issue #203 — RED tests for the SQL-table scan consolidation. + * + * Bug: + * - `SQL_TABLE_RE` is duplicated in three places + * (`vba/sql-wrapper.ts:80`, `sql-query-extractor.ts:57`, + * `vba-form-extractor.ts:617`). + * - When SQL is built by `&`-concatenation, `collectSqlWrapperChain` + * silently drops non-literal operands and joins surviving fragments + * with a space, so `FROM WHERE x` becomes `FROM WHERE x` + * — `\s+` happily crosses the gap and `WHERE` is emitted as a + * synthetic table reference. Same for `ORDER`, `SET`, `GROUP`, + * `HAVING`, etc. + * - `classifySqlAccess` (`vba/sql-wrapper.ts:91-96`) tags + * `UPDATE->SET` as a write, so "who writes to table X" reports are + * poisoned. + * + * Acceptance criteria (issue #203): + * 1. `"DELETE FROM " & tabla & " WHERE x"` emits no table reference. + * 2. No SQL reserved word is ever emitted as a table name. + * 3. The regex exists in exactly one module. + * 4. All three call sites import the shared scanner. + * 5. Existing SQL extraction tests still pass. + * + * These tests target the proposed leaf module + * `src/extraction/sql-table-scan.ts` exporting + * `scanSqlTables(sql): {table, clause, access}[]`. The bug-exposing + * tests at the bottom (issue #203 reproduction) live alongside the + * extractor-level tests in `extraction-vba.test.ts` / + * `extraction-sql-query.test.ts`; this file owns the SHARED module's + * API contract so the three call sites can adopt it without each + * replicating the test. + */ +import { describe, it, expect } from 'vitest'; +import { scanSqlTables, SQL_RESERVED_TABLE_TOKENS } from '../src/extraction/sql-table-scan'; + +describe('scanSqlTables — basic FROM/JOIN/INTO/UPDATE clauses', () => { + it('captures a plain FROM table', () => { + const rows = scanSqlTables('SELECT * FROM tblCustomers'); + expect(rows).toEqual([ + { table: 'tblCustomers', clause: 'FROM', access: 'read' }, + ]); + }); + + it('captures a JOIN table', () => { + const rows = scanSqlTables('SELECT * FROM tblA INNER JOIN tblB ON tblA.Id = tblB.Id'); + const tables = rows.map((r) => r.table); + expect(tables).toEqual(['tblA', 'tblB']); + rows.forEach((r) => expect(r.access).toBe('read')); + }); + + it('captures an INSERT INTO target as access=write', () => { + const rows = scanSqlTables('INSERT INTO tblAudit (Id) VALUES (1)'); + expect(rows).toEqual([ + { table: 'tblAudit', clause: 'INTO', access: 'write' }, + ]); + }); + + it('captures an UPDATE target as access=write', () => { + const rows = scanSqlTables('UPDATE tblOrders SET Status = 1'); + expect(rows).toEqual([ + { table: 'tblOrders', clause: 'UPDATE', access: 'write' }, + ]); + }); + + it('captures a DELETE FROM target as access=write (FROM after DELETE is a write, not a read)', () => { + const rows = scanSqlTables('DELETE FROM tblOld'); + expect(rows).toEqual([{ table: 'tblOld', clause: 'FROM', access: 'write' }]); + }); + + it('captures an INSERT INTO ... SELECT FROM distinctly (target=write, source=read)', () => { + const rows = scanSqlTables('INSERT INTO tblArchive SELECT * FROM tblLive'); + expect(rows.find((r) => r.table === 'tblArchive')).toEqual({ + table: 'tblArchive', + clause: 'INTO', + access: 'write', + }); + expect(rows.find((r) => r.table === 'tblLive')).toEqual({ + table: 'tblLive', + clause: 'FROM', + access: 'read', + }); + }); + + it('strips brackets from a bracketed table name with spaces', () => { + const rows = scanSqlTables('SELECT * FROM [Order Details]'); + expect(rows).toEqual([{ table: 'Order Details', clause: 'FROM', access: 'read' }]); + }); +}); + +describe('scanSqlTables — schema-qualified table names', () => { + it('captures a schema-qualified table (dbo.tblCustomers) as one composite reference', () => { + const rows = scanSqlTables('SELECT * FROM dbo.tblCustomers'); + expect(rows).toEqual([ + { table: 'dbo.tblCustomers', clause: 'FROM', access: 'read' }, + ]); + }); + + it('captures a bracketed schema-qualified table ([My Schema].[My Table]) as one composite reference', () => { + const rows = scanSqlTables('SELECT * FROM [My Schema].[My Table]'); + expect(rows).toEqual([ + { table: 'My Schema.My Table', clause: 'FROM', access: 'read' }, + ]); + }); + + it('captures a schema-qualified JOIN table', () => { + const rows = scanSqlTables('SELECT a.* FROM dbo.TbA a INNER JOIN sales.TbB b ON a.Id = b.Id'); + const tables = rows.map((r) => r.table); + expect(tables).toEqual(['dbo.TbA', 'sales.TbB']); + }); +}); + +describe('scanSqlTables — reserved-word rejection (Issue #203 AC #2)', () => { + // Reserved SQL tokens that legitimately follow FROM/JOIN/INTO/UPDATE in + // a real statement. Capturing one of these as a "table name" is the bug. + // (SQL_RESERVED_TABLE_TOKENS is exported as the canonical reject list so + // consumers can introspect it.) + it('exports SQL_RESERVED_TABLE_TOKENS as the canonical reject list', () => { + expect(SQL_RESERVED_TABLE_TOKENS).toBeInstanceOf(Set); + // Spot-check the obvious offenders from issue #203: + expect(SQL_RESERVED_TABLE_TOKENS.has('WHERE')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('ORDER')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('SET')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('GROUP')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('HAVING')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('INNER')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('LEFT')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('RIGHT')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('OUTER')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('FULL')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('CROSS')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('ON')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('VALUES')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('SELECT')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('UNION')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('AS')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('DISTINCT')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('TOP')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('IN')).toBe(true); + expect(SQL_RESERVED_TABLE_TOKENS.has('EXISTS')).toBe(true); + }); + + it('rejects WHERE as a table name (issue #203 reproduction — FROM ` WHERE x=1`)', () => { + const rows = scanSqlTables('SELECT * FROM WHERE x=1'); + expect(rows).toEqual([]); + }); + + it('rejects ORDER as a table name (issue #203 reproduction — DELETE FROM ` ORDER BY a)', () => { + const rows = scanSqlTables('DELETE FROM ORDER BY a'); + expect(rows).toEqual([]); + }); + + it('rejects SET as a table name (issue #203 reproduction — UPDATE ` SET a=1)', () => { + const rows = scanSqlTables('UPDATE SET a=1'); + expect(rows).toEqual([]); + }); + + it('rejects GROUP as a table name (FROM ` GROUP BY x)', () => { + const rows = scanSqlTables('SELECT * FROM GROUP BY x'); + expect(rows).toEqual([]); + }); + + it('rejects HAVING as a table name (FROM ` HAVING count > 0)', () => { + const rows = scanSqlTables('SELECT * FROM HAVING count > 0'); + expect(rows).toEqual([]); + }); + + it('rejects INNER as a table name (FROM ` INNER JOIN ...)', () => { + const rows = scanSqlTables('SELECT * FROM INNER JOIN tblB ON 1=1'); + expect(rows).toEqual([{ table: 'tblB', clause: 'JOIN', access: 'read' }]); + }); + + it('rejects SELECT as a table name', () => { + const rows = scanSqlTables('INSERT INTO SELECT 1'); + expect(rows).toEqual([]); + }); + + it('rejects a reserved word even when it is bracketed (defensive — [WHERE] still WHERE)', () => { + // The bracketed form is unusual but legal in some dialects. Even + // there, the bare identifier is the SQL keyword and should not be + // emitted as a table name. + const rows = scanSqlTables('SELECT * FROM [WHERE]'); + expect(rows).toEqual([]); + }); + + it('case-insensitive reserved-word rejection (FROM where x=1)', () => { + const rows = scanSqlTables('select * from where x=1'); + expect(rows).toEqual([]); + }); +}); + +describe('scanSqlTables — interaction with chained literals (Issue #203 AC #1)', () => { + // The `scanSqlTables` API receives the *joined* SQL string after + // `collectSqlWrapperChain`/`collectStringLiteralText` have merged all + // literal fragments with a single space. When the dropped operand + // sits BETWEEN a FROM clause keyword and a SQL reserved word + // (e.g. `FROM WHERE`), the gap is collapsed to a single space + // and the reserved-word reject list (above) catches the bad capture. + // Here we exercise the actual reproduction snippet the issue lists. + + it('"DELETE FROM " & tabla & " WHERE x" emits no table reference', () => { + // Simulate what `collectSqlWrapperChain` produces today: + // [literal1, dropped, literal2].join(' ') + // = "DELETE FROM " + " " + " WHERE x" + // = "DELETE FROM WHERE x" + // The reserved-word reject list must drop the WHERE capture. + const joined = ['DELETE FROM ', ' ', ' WHERE x'].join(' '); + const rows = scanSqlTables(joined); + expect(rows).toEqual([]); + }); + + it('"SELECT * FROM " & tabla & " WHERE x=1" emits no table reference', () => { + const joined = ['SELECT * FROM ', ' ', ' WHERE x=1'].join(' '); + const rows = scanSqlTables(joined); + expect(rows).toEqual([]); + }); + + it('"UPDATE " & tabla & " SET a=1" emits no table reference', () => { + const joined = ['UPDATE ', ' ', ' SET a=1'].join(' '); + const rows = scanSqlTables(joined); + expect(rows).toEqual([]); + }); + + it('a literal table name still emits when present (regression guard)', () => { + const joined = 'SELECT * FROM tblCustomers WHERE Id = 1'; + const rows = scanSqlTables(joined); + expect(rows).toEqual([{ table: 'tblCustomers', clause: 'FROM', access: 'read' }]); + }); + + it('two adjacent table fragments across the dropped operand gap are NOT bridged (defensive)', () => { + // `FROM tblA ` ` tblB` — the dropped operand is a single + // variable. There is NO keyword between tblA and the dropped + // operand, so tblA is captured (real FROM table). tblB has no + // preceding keyword, so it is NOT captured (no false edge). + const joined = 'FROM tblA tblB'; + const rows = scanSqlTables(joined); + expect(rows).toEqual([{ table: 'tblA', clause: 'FROM', access: 'read' }]); + }); +}); + +describe('scanSqlTables — empty / malformed input', () => { + it('returns [] on empty string', () => { + expect(scanSqlTables('')).toEqual([]); + }); + + it('returns [] on a SQL-less statement', () => { + expect(scanSqlTables('SELECT 1 AS One')).toEqual([]); + }); + + it('returns [] on whitespace only', () => { + expect(scanSqlTables(' ')).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/extraction/sql-query-extractor.ts b/src/extraction/sql-query-extractor.ts index a7bccdd..ee0afd2 100644 --- a/src/extraction/sql-query-extractor.ts +++ b/src/extraction/sql-query-extractor.ts @@ -18,6 +18,12 @@ * table references), so the data layer is queryable and table usage is * traceable. Bracketed names (`[Order Details]`) are unwrapped; a table * referenced multiple times in one query produces exactly one node + edge. + * + * Issue #203: the table-name regex and reserved-word reject list live in + * the shared leaf module `src/extraction/sql-table-scan.ts` — the same + * scanner every other call site (`vba/sql-wrapper.ts`, + * `vba-form-extractor.ts`) imports, so a SQL reserved word can never + * be emitted as a table name across the project. */ import * as path from 'path'; import { @@ -27,6 +33,7 @@ import { ExtractionError, } from '../types'; import { generateNodeId } from './tree-sitter-helpers'; +import { scanSqlTables } from './sql-table-scan'; export class SqlQueryExtractor { private filePath: string; @@ -40,22 +47,6 @@ export class SqlQueryExtractor { this.source = source; } - /** - * Table name following `FROM` / `JOIN` / `INTO` / `UPDATE`. Captures an optional - * bracketed/unbracketed schema prefix followed by `.`, so `FROM dbo.tblCustomers` - * and `FROM [My Schema].[My Table]` come through as one composite reference. - * Without the prefix the regex still matches a single identifier (bracketed - * or bare) byte-identical to the old shape. `\p{L}` covers accented identifiers - * common in localized schemas. - * - * The captured composite goes to `sweepTables`, which strips ALL brackets - * (including internal ones in the bracketed-schema case) — so the public - * node name is the unwrapped form `dbo.tblCustomers` / `My Schema.My Table`, - * matching how plain `[Order Details]` is also unwrapped to `Order Details`. - */ - private static readonly TABLE_RE = - /\b(?:FROM|JOIN|INTO|UPDATE)\s+((?:(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*)\.)?(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*))/giu; - extract(): ExtractionResult { const startTime = Date.now(); try { @@ -121,34 +112,25 @@ export class SqlQueryExtractor { * Scan the SQL for table names and emit one synthetic `class` node + one * `references` edge per distinct table. The table node id is line-independent * (line 0) so the same table referenced N times collapses to one node. + * + * Issue #203: delegates to the shared `scanSqlTables` scanner in + * `src/extraction/sql-table-scan.ts` so a SQL reserved word can never + * be emitted as a table name — `WHERE x=1`, `ORDER BY a`, `SET a=1` + * never become phantom `class` nodes here. */ private sweepTables(queryId: string): void { const seen = new Set(); - const re = new RegExp( - SqlQueryExtractor.TABLE_RE.source, - SqlQueryExtractor.TABLE_RE.flags, - ); - let m: RegExpExecArray | null; - while ((m = re.exec(this.source)) !== null) { - const raw = (m[1] ?? '').trim(); - // Strip ALL brackets (including internal ones in the bracketed-schema case - // `[My Schema].[My Table]`), so the public node name is the unwrapped form - // `My Schema.My Table`. Same shape `VbaExtractor.emitSqlTableReferences` - // already produces. A plain bracketed name `[Order Details]` reduces to - // `Order Details` here too (no internal brackets), so existing tests are - // unaffected. - const table = raw.replace(/[\[\]]/g, '').trim(); - if (!table) continue; - const key = table.toLowerCase(); + for (const row of scanSqlTables(this.source)) { + const key = row.table.toLowerCase(); if (seen.has(key)) continue; seen.add(key); - const tableId = generateNodeId(this.filePath, 'class', table, 0); + const tableId = generateNodeId(this.filePath, 'class', row.table, 0); this.nodes.push({ id: tableId, kind: 'class', // placeholder kind; cross-file resolution re-types at lookup - name: table, - qualifiedName: table, + name: row.table, + qualifiedName: row.table, filePath: this.filePath, language: 'sql', startLine: 1, diff --git a/src/extraction/sql-table-scan.ts b/src/extraction/sql-table-scan.ts new file mode 100644 index 0000000..81b4edd --- /dev/null +++ b/src/extraction/sql-table-scan.ts @@ -0,0 +1,199 @@ +/** + * # SQL table-name scanner — leaf module shared by every VBA + SQL + * table-extraction path (Issue #203). + * + * Before this module existed, the same `FROM/JOIN/INTO/UPDATE` capture + * regex lived in three places: + * + * - `src/extraction/sql-query-extractor.ts:57` (saved queries) + * - `src/extraction/vba/sql-wrapper.ts:80-81` (in-code SQL sweep) + * - `src/extraction/vba-form-extractor.ts:617-618` (RecordSource/RowSource) + * + * The three copies had already started to diverge (`vba-form-extractor` + * grew a `SQL_PREFIX_RE` the other two never adopted), and every + * caller was vulnerable to the same silently-wrong captures the issue + * lists: + * + * ```vba + * getdb().Execute "DELETE FROM " & tabla & " WHERE activo = 1" + * ``` + * + * Today, `collectSqlWrapperChain` silently drops non-literal operands + * (variables, function calls) and joins surviving literal fragments + * with a space. The dropped operand leaves a whitespace gap that + * `\s+` happily crosses, so `DELETE FROM ` + ` ` + ` WHERE activo = 1` + * becomes `DELETE FROM WHERE activo = 1` — and `SQL_TABLE_RE` + * captures `WHERE` as a table reference. + * + * ## What this module guarantees + * + * 1. The shared regex (with optional schema prefix + bracketed + * identifiers, identical bytes to the old shape) lives in ONE + * place. + * 2. `SQL_RESERVED_TABLE_TOKENS` rejects every SQL reserved word that + * can legitimately appear immediately after a + * `FROM`/`JOIN`/`INTO`/`UPDATE` keyword in a real statement — + * `WHERE`, `ORDER`, `GROUP`, `HAVING`, `SET`, `VALUES`, `SELECT`, + * `INNER`, `LEFT`, `RIGHT`, `OUTER`, `FULL`, `CROSS`, `JOIN`, + * `ON`, `UNION`, `AS`, `DISTINCT`, `TOP`, `IN`, `EXISTS`. The list + * is exhaustive for the SQL grammar subset codegraph models. + * 3. `scanSqlTables` reads a SINGLE joined SQL string. It does NOT + * know about VBA concatenation — the JOIN-WITH-SPACE shape + * `collectSqlWrapperChain` produces is its input contract. + * Operands dropped by `collectSqlWrapperChain` are now replaced + * with a `?` sentinel (see `vba/sql-wrapper.ts`); the reserved- + * word reject list catches any `?` keyword bridge the concat could + * still create. + * 4. Each row carries the SQL `clause` (`FROM`/`JOIN`/`INTO`/`UPDATE`) + * so callers can classify `access: 'read' | 'write'` without + * re-running the SQL classifier. + * + * ## Defense in depth + * + * The reserved-word check uses the **unwrapped first identifier + * component** so schema-qualified inputs like `FROM WHERE.ID` are also + * rejected (the unwrapped form is `WHERE.ID`, first component is + * `WHERE`). The check is case-insensitive (SQL keywords are + * case-insensitive by spec). + * + * Returning `[]` for a reserved-word capture is the "silent beats + * wrong" doctrine the project documents in `CLAUDE.md`: emitting a + * confident wrong edge ("WHERE is a table that gets written to") + * pollutes downstream queries far more than emitting no edge at all. + */ +export interface SqlTableScanRow { + /** The unwrapped table name (brackets + surrounding whitespace stripped). */ + table: string; + /** The SQL clause that introduced the reference (`FROM`/`JOIN`/`INTO`/`UPDATE`). */ + clause: 'FROM' | 'JOIN' | 'INTO' | 'UPDATE'; + /** Whether this row reads or mutates the table — derived from the SQL verb. */ + access: 'read' | 'write'; +} + +/** + * Canonical reject list — every SQL reserved word that can appear + * immediately after `FROM`/`JOIN`/`INTO`/`UPDATE` in a real statement. + * The list is exported so consumers can introspect it; it is also the + * authoritative source the scanner consults internally. + * + * The list intentionally omits `FROM`/`JOIN`/`INTO`/`UPDATE` itself + * (those are the capturing keywords) and `ALL` / `ANY` (they are + * legitimate table-ish tokens in some dialects but rarely appear as + * the first token after a FROM-style keyword in a well-formed + * statement). + */ +export const SQL_RESERVED_TABLE_TOKENS: ReadonlySet = new Set([ + 'WHERE', + 'ORDER', + 'GROUP', + 'HAVING', + 'INNER', + 'LEFT', + 'RIGHT', + 'OUTER', + 'FULL', + 'CROSS', + 'JOIN', + 'ON', + 'SET', + 'VALUES', + 'SELECT', + 'UNION', + 'AS', + 'DISTINCT', + 'TOP', + 'IN', + 'EXISTS', +]); + +/** + * Shared `FROM` / `JOIN` / `INTO` / `UPDATE ` regex. Same + * shape as the three duplicates this module consolidates: group 1 is + * the clause keyword (`FROM`/`JOIN`/`INTO`/`UPDATE`), group 2 is the + * composite identifier — an optional bracketed/unbracketed schema + * prefix followed by `.`, then a bracketed-or-bare identifier. + * + * `FROM dbo.tblCustomers` → m[2] = `dbo.tblCustomers` + * `FROM [My Schema].[My Table]` → m[2] = `[My Schema].[My Table]` + * `FROM tblCustomers` → m[2] = `tblCustomers` + * `FROM [Order Details]` → m[2] = `[Order Details]` + * + * Brackets in `m[2]` are stripped by `scanSqlTables`, so the public + * table name is the unwrapped form (`dbo.tblCustomers` / + * `My Schema.My Table`) — matching how plain `[Order Details]` is + * also unwrapped to `Order Details`. `\p{L}` covers accented + * identifiers common in localized schemas. + * + * Keeping the single composite capture (rather than splitting + * schema/table into separate groups) preserves byte-identity with the + * three duplicates this module replaces, so every existing + * regression test continues to apply without rewrites. + */ +const TABLE_RE = + /\b(FROM|JOIN|INTO|UPDATE)\s+((?:(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*)\.)?(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*))/giu; + +/** + * Scan a SQL string for `FROM` / `JOIN` / `INTO` / `UPDATE` table + * references. Returns one row per match; the caller is responsible + * for cross-row deduplication if it wants one node per table. + * + * Captures that resolve to a SQL reserved word (Issue #203 — e.g. + * `FROM WHERE x=1` → `WHERE`) are DROPPED. This encodes + * "we don't know this table" rather than guessing — matching the + * "silent beats wrong" / "partial coverage is worse than none" + * doctrine the project documents in `CLAUDE.md`. + * + * Schema-qualified inputs (`FROM WHERE.ID` → `WHERE.ID`) are also + * dropped: the check inspects the FIRST unwrapped identifier + * component (everything up to the first `.`), case-insensitive. + * + * Empty / whitespace-only / non-DML input returns `[]`. + */ +export function scanSqlTables(sql: string): SqlTableScanRow[] { + if (!sql) return []; + const out: SqlTableScanRow[] = []; + const re = new RegExp(TABLE_RE.source, TABLE_RE.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const clauseRaw = (m[1] ?? '').toUpperCase(); + if ( + clauseRaw !== 'FROM' && + clauseRaw !== 'JOIN' && + clauseRaw !== 'INTO' && + clauseRaw !== 'UPDATE' + ) { + continue; + } + const clause = clauseRaw as SqlTableScanRow['clause']; + + // m[2] is the composite (optional schema `.` table) with any + // combination of brackets. Strip ALL brackets and surrounding + // whitespace — the public node name is the unwrapped form. + const table = (m[2] ?? '').replace(/[\[\]]/g, '').trim(); + if (!table) continue; + + // Reserved-word rejection — the FIRST identifier component is the + // canonical SQL keyword to test (handles `WHERE.ID` too). + const firstId = table.split('.')[0] ?? ''; + if (SQL_RESERVED_TABLE_TOKENS.has(firstId.toUpperCase())) continue; + + out.push({ table, clause, access: classifyAccess(sql, clause) }); + } + return out; +} + +/** + * `access` classifier — ported verbatim from + * `src/extraction/vba/sql-wrapper.ts:91-96`. Lifted into this leaf + * module so all three call sites produce identical tagging. + * + * The mutating targets are writes: `INSERT INTO `, `UPDATE `, + * and the `FROM ` of a `DELETE` (Access's `DELETE FROM x` makes + * that FROM the delete target). Every other `FROM`/`JOIN` source + * table — including the source of an `INSERT ... SELECT` — is a read. + */ +function classifyAccess(sqlString: string, clause: SqlTableScanRow['clause']): 'read' | 'write' { + if (clause === 'INTO' || clause === 'UPDATE') return 'write'; + if (clause === 'FROM' && /^\s*DELETE\b/i.test(sqlString)) return 'write'; + return 'read'; +} \ No newline at end of file diff --git a/src/extraction/vba-form-extractor.ts b/src/extraction/vba-form-extractor.ts index 06d1fb1..1ef4a48 100644 --- a/src/extraction/vba-form-extractor.ts +++ b/src/extraction/vba-form-extractor.ts @@ -47,6 +47,7 @@ import { import { generateNodeId } from './tree-sitter-helpers'; import { stripVbaComments } from './vba-preprocess'; import { ACCESS_EVENT_PROPERTIES } from './vba/events'; +import { scanSqlTables } from './sql-table-scan'; interface FormBlockFrame { controlType: string; @@ -603,25 +604,11 @@ export class VbaFormExtractor { // RecordSource / RowSource edge emission (Issue #49) // --------------------------------------------------------------------------- - /** - * Issue #49 — copy of `VbaExtractor.SQL_TABLE_RE`. Same source / flags: - * captures the table name that follows `FROM`/`JOIN`/`INTO`/`UPDATE`, - * tolerates bracketed `[Order Details]` identifiers and `\p{L}` Unicode - * identifiers, and allows an optional schema prefix (`[dbo].[tblA]`). - * - * We duplicate the regex (rather than exporting it from `VbaExtractor`) - * because `VbaExtractor.SQL_TABLE_RE` is `private static` and the - * project's per-extractor state rule keeps each extractor's helpers - * self-contained — see the file-level JSDoc on `VbaExtractor`. - */ - private static readonly SQL_TABLE_RE = - /\b(?:FROM|JOIN|INTO|UPDATE)\s+((?:(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*)\.)?(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*))/giu; - /** * Issue #49 — classify a RecordSource/RowSource value as SQL. * Anything starting with a SQL keyword (`SELECT`, `PARAMETERS`, `WITH`, * `UPDATE`, `INSERT`, `DELETE`) — case-insensitive — is treated as a - * SQL statement and run through `SQL_TABLE_RE`. Anything else is a + * SQL statement and run through `scanSqlTables`. Anything else is a * bare table-or-query name and emitted as a single reference. * * `WITH` is included because Access/JET supports CTE-style `WITH` queries @@ -726,7 +713,7 @@ export class VbaFormExtractor { /** * Issue #49 — dispatch the value of a RecordSource/RowSource binding. - * If SQL, run `SQL_TABLE_RE` over the value and emit one edge per + * If SQL, run `scanSqlTables` over the value and emit one edge per * distinct table (within-value dedup so `FROM tblA JOIN tblA` emits a * single edge). If a bare name, emit a single edge with the name as-is * — the resolver handles the dual-match against `query` and `class` @@ -736,6 +723,10 @@ export class VbaFormExtractor { * the regex capture already includes them as part of the value, so a * single `.replace(/""/g, '"')` collapses them — same technique the * `extractStringLiterals` helper uses for its emitted `text` field. + * + * Issue #203: `scanSqlTables` also drops SQL reserved-word captures + * (`WHERE`, `ORDER`, `SET`, …) so a malformed SQL string never + * poisons the graph with phantom `class` nodes. */ private emitBinding( sourceNodeId: string, @@ -749,12 +740,10 @@ export class VbaFormExtractor { const value = rawValue.replace(/""/g, '"'); if (this.isLikelySql(value)) { const seen = new Set(); - for (const m of value.matchAll(VbaFormExtractor.SQL_TABLE_RE)) { - const table = (m[1] ?? '').replace(/[\[\]]/g, ''); - if (!table) continue; - if (seen.has(table)) continue; - seen.add(table); - this.emitTableReference(sourceNodeId, table, lineNum, synthesizedBy); + for (const row of scanSqlTables(value)) { + if (seen.has(row.table)) continue; + seen.add(row.table); + this.emitTableReference(sourceNodeId, row.table, lineNum, synthesizedBy); } return; } diff --git a/src/extraction/vba/sql-wrapper.ts b/src/extraction/vba/sql-wrapper.ts index e4af438..9a901f3 100644 --- a/src/extraction/vba/sql-wrapper.ts +++ b/src/extraction/vba/sql-wrapper.ts @@ -4,10 +4,20 @@ * variable-form executions, tracks `sql = sql & "…"` accumulation, and emits * `references` edges (via `ctx.emitReference`) to the table names found in the * FROM/JOIN/INTO/UPDATE clauses. + * + * Issue #203: the `FROM/JOIN/INTO/UPDATE
` regex is the canonical + * source from `src/extraction/sql-table-scan.ts` — every table-name + * capture path in the project imports from there so a reserved word + * (`WHERE`, `ORDER`, `SET`, …) can never be emitted as a table + * reference, and a non-literal operand dropped by `&`-concatenation + * (`"DELETE FROM " & tabla & " WHERE x"`) is replaced with a `?` + * sentinel that the regex can never match. The shared module also + * emits the read/write access direction so this file no longer + * re-implements `classifySqlAccess`. */ -import { extractStringLiterals } from '../vba-preprocess'; import { escapeRegExpLiteral } from './text-utils'; import { VbaExtractorContext } from './context'; +import { scanSqlTables } from '../sql-table-scan'; /** SQL wrapper helpers — order matters because `db.Execute` is a suffix of others. */ const SQL_WRAPPERS: ReadonlyArray<{ name: string; re: RegExp }> = [ @@ -36,7 +46,7 @@ const SQL_VAR_EXEC_RE = * is iterated by `scanSqlInLine`. When a match is found, the captured * identifier is resolved against `sqlVariables` (populated by * `trackSqlVariableAssignment` with `&`-accumulate semantics — Issue #13) - * and the resulting SQL string drives `emitSqlTableReferences`. + * and the resulting SQL string drives `scanSqlTables`. * * The optional `(?:\(\))?` + `\s*\(?` shape lets the regex match both * the parenthesised form `DoCmd.RunSQL(strSQL)` and the no-paren form @@ -48,48 +58,6 @@ const SQL_VAR_EXEC_RE = const SQL_VAR_DOCMD_RUNSQL_RE = /\bDoCmd\.RunSQL\s*\(?\s*(\p{L}[\p{L}\p{N}_]*)\s*\)?/giu; -/** - * SQL table-name regex scoped to the clauses that introduce a table - * reference: `FROM `, `JOIN `, `INTO `, `UPDATE `. Adding - * `JOIN` lets the scanner pick up tables from joined fragments that - * arrive via `&`-concatenated wrapper literals (e.g. - * `db.Execute "FROM A" & " JOIN B"`); without it the second literal's - * table was silently dropped even though the wrapper regex now matches - * the chain. - * - * The captured table name is an optional bracketed/unbracketed schema - * prefix followed by a `.`, then a bracketed-or-bare identifier — so - * `FROM dbo.tblCustomers` and `FROM [My Schema].[My Table]` come - * through as one composite reference. Without the prefix the regex - * still matches a single identifier byte-identical to the old shape. - * Brackets in the captured composite are stripped by - * `emitSqlTableReferences` (`replace(/[\[\]]/g, '')`), so the public - * node name is the unwrapped form `dbo.tblCustomers` / - * `My Schema.My Table` — matching how plain `[Order Details]` is also - * unwrapped to `Order Details`. The identifier class - * `\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*` (same as the saved-queries - * `TABLE_RE` in `sql-query-extractor.ts`) ensures bracketed names - * with spaces — `[Order Details]`, `[My Schema]`, `[My Table]` — - * are captured whole. - */ -const SQL_TABLE_RE = - /\b(FROM|JOIN|INTO|UPDATE)\s+((?:(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*)\.)?(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*))/giu; - -/** - * Classify a table reference as a data `read` or `write` from the SQL verb, - * so `metadata.access` lets consumers answer "who WRITES table X" vs "who - * READS table X". The mutating targets are writes: `INSERT INTO `, - * `UPDATE `, and the `FROM ` of a `DELETE` (Access's `DELETE FROM x` - * makes that FROM the delete target). Every other `FROM`/`JOIN` source table - * — including the source of an `INSERT ... SELECT` — is a read. - */ -function classifySqlAccess(sqlString: string, clause: string): 'read' | 'write' { - const kw = clause.toUpperCase(); - if (kw === 'INTO' || kw === 'UPDATE') return 'write'; - if (kw === 'FROM' && /^\s*DELETE\b/i.test(sqlString)) return 'write'; - return 'read'; -} - /** * Regex matching the chained `& "..."` literals that may follow a * wrapper's first literal on the same physical line. Captures the @@ -102,21 +70,161 @@ function classifySqlAccess(sqlString: string, clause: string): 'read' | 'write' * Cross-physical-line concat via `_` continuation is OUT OF SCOPE for * v1 (deferred; see commit message). */ -const SQL_WRAPPER_CHAIN_RE = /&\s*"((?:[^"]|"")*)"/g; - /** * Given the text that follows a SQL wrapper's first literal on the same - * physical line, return the contents of every `& "..."` chained literal - * in source order. Operates per-physical-line only — VBA `_` line - * continuation across physical lines is handled separately by - * `collectStringLiteralText` for the variable-assignment path. + * physical line, return one fragment per `&`-concatenated operand in + * source order. Each fragment is either the CONTENT of a `"..."` literal + * or a `?` sentinel for any non-literal operand (a variable, function + * call, expression). The `?` sentinel can never match the + * `scanSqlTables` identifier class, so `"DELETE FROM " & tabla & " WHERE x"` + * becomes `"DELETE FROM ? WHERE x"` — the reserved-word reject list + * drops the `WHERE` capture and no `vba-sql-table` edge is emitted. + * + * Operates per-physical-line only — VBA `_` line continuation across + * physical lines is handled separately by `collectConcatFragments` for + * the variable-assignment path (see below). */ function collectSqlWrapperChain(rest: string): string[] { + return collectConcatFragments(rest); +} + +/** + * Issue #13: `sql = sql & "..."` (self-referential concatenation) must + * ACCUMULATE the new fragment onto whatever was already tracked for + * `varName`, not overwrite it. Overwriting silently dropped earlier + * fragments' tables — typically the initial `FROM
` in + * `sql = "SELECT * FROM tblA"` followed by `sql = sql & " WHERE x=1"`. + * + * Detection: the RHS (`m[2]`, trimmed) starts with ` &`, + * case-insensitively — matching VBA's case-insensitive identifiers (`Sql` + * and `sql` are the same variable). A genuine fresh assignment (RHS does + * NOT start with the self-reference) still RESETS tracking — that + * behavior is unchanged. + */ +export function trackSqlVariableAssignment( + lines: string[], + lineIndex: number, + sqlVariables: Map, +): void { + const line = lines[lineIndex] ?? ''; + const m = SQL_VAR_ASSIGN_RE.exec(line); + if (!m) return; + const rawVarName = m[1] ?? ''; + const varName = rawVarName.toLowerCase(); + const rhs = (m[2] ?? '').trim(); + const newFragment = collectStringLiteralText(lines, lineIndex); + if (!newFragment) return; + + const selfRefRe = new RegExp(`^${escapeRegExpLiteral(rawVarName)}\\s*&`, 'i'); + const existing = sqlVariables.get(varName); + if (existing !== undefined && selfRefRe.test(rhs)) { + sqlVariables.set(varName, `${existing} ${newFragment}`); + } else { + sqlVariables.set(varName, newFragment); + } +} + +/** + * Walk the lines starting at `startIndex` and collect every + * `&`-concatenated fragment — `"..."` literal CONTENT or a `?` + * sentinel for non-literal operands. Multi-line concat (via `_` + * continuation) is handled by carrying on while the current physical + * line ends with `&`. + * + * Replaces the legacy implementation that used `extractStringLiterals` + * to extract every `"..."` and silently dropped every non-literal + * operand between them — the source of Issue #203's silently-wrong + * `vba-sql-table` captures. + */ +function collectStringLiteralText(lines: string[], startIndex: number): string { + const fragments: string[] = []; + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i] ?? ''; + fragments.push(...collectConcatFragments(line)); + if (!line.trimEnd().endsWith('&')) break; + } + return fragments.join(' '); +} + +/** + * Walk a single source string and emit one fragment per + * `&`-concatenated operand. + * + * - A `"..."` literal → its content (with `""` doubled-quote escapes + * collapsed to a single `"`). + * - A non-literal operand (any non-whitespace token — variable, + * function call, expression, the `m_SQL = ` prefix on an + * assignment) that sits BETWEEN two operands in the chain → emit + * a `?` sentinel so the gap can never match an identifier in + * `scanSqlTables`. + * - Leading / trailing / inter-fragment whitespace is skipped (the + * `[...].join(' ')` in the callers handles the gap). + * + * Implementation: a small state machine that walks the string, + * alternating between two modes: + * + * 1. IN-LITERAL — collect until matching `"`, honour `""` escapes. + * 2. IN-GAP — scan until next `&` (concat operator) or next `"` (start + * of next literal). If the gap contained any non-whitespace + * characters AND we previously emitted a literal, emit a `?` + * sentinel — that's the "we don't know this table" signal the + * regex needs. + * + * Why mode 2 stops at `"`: a `"` inside a non-literal operand is the + * start of the NEXT literal in the chain; treating it as part of the + * gap would silently consume a real literal and re-introduce the + * Issue #203 bug. + */ +function collectConcatFragments(src: string): string[] { const out: string[] = []; - const re = new RegExp(SQL_WRAPPER_CHAIN_RE.source, SQL_WRAPPER_CHAIN_RE.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(rest)) !== null) { - out.push(m[1] ?? ''); + let i = 0; + let lastEmittedLiteral = false; + while (i < src.length) { + const ch = src[i] ?? ''; + if (ch === '"') { + // Mode 1 — literal. + let text = ''; + i++; + while (i < src.length) { + const c = src[i] ?? ''; + if (c === '"' && src[i + 1] === '"') { + text += '"'; + i += 2; + continue; + } + if (c === '"' || c === '\n') { + i++; + break; + } + text += c; + i++; + } + out.push(text); + lastEmittedLiteral = true; + continue; + } + if (/\s/.test(ch)) { + i++; + continue; + } + // Mode 2 — gap (non-literal operand). Walk until the next `"` or + // `&`. If we saw non-whitespace content AND a literal came before + // us in this chain, emit the `?` sentinel so the gap can never + // bridge two SQL keywords. + let sawContent = false; + while (i < src.length) { + const c = src[i] ?? ''; + if (c === '"' || c === '&') break; + if (!/\s/.test(c)) sawContent = true; + i++; + } + if (src[i] === '&') { + i++; + } + if (sawContent && lastEmittedLiteral) { + out.push('?'); + } + lastEmittedLiteral = false; } return out; } @@ -138,7 +246,7 @@ export function scanSqlInLine( // `"` of the first literal, walk the rest of the line for any // `& "..."` chains and concatenate every literal's content. Joining // with a space (mirrors `collectStringLiteralText`) keeps adjacent - // `FROM tblA` & `FROM tblB` separated so `SQL_TABLE_RE` finds both. + // `FROM tblA` & `FROM tblB` separated so `scanSqlTables` finds both. const rest = line.slice(m.index + m[0].length); const chain = collectSqlWrapperChain(rest); const joined = [firstLiteral, ...chain].join(' '); @@ -160,7 +268,7 @@ export function scanSqlInLine( // idiom — the dominant pattern in real-world VBA modules. Resolve the // captured identifier against `sqlVariables` (populated by // `trackSqlVariableAssignment` with `&`-accumulate semantics, Issue - // #13) and feed the resolved SQL string into `emitSqlTableReferences`. + // #13) and feed the resolved SQL string into `scanSqlTables`. // Unresolved identifiers (no row in the map) are silently skipped — // same graceful-no-op contract as SQL_VAR_EXEC_RE. const docmdLocalRe = new RegExp( @@ -176,72 +284,20 @@ export function scanSqlInLine( } } -/** - * #13 fix: `sql = sql & "..."` (self-referential concatenation) must - * ACCUMULATE the new fragment onto whatever was already tracked for - * `varName`, not overwrite it. Overwriting silently dropped earlier - * fragments' tables — typically the initial `FROM
` in - * `sql = "SELECT * FROM tblA"` followed by `sql = sql & " WHERE x=1"`. - * - * Detection: the RHS (`m[2]`, trimmed) starts with ` &`, - * case-insensitively — matching VBA's case-insensitive identifiers (`Sql` - * and `sql` are the same variable). A genuine fresh assignment (RHS does - * NOT start with the self-reference) still RESETS tracking — that - * behavior is unchanged. - */ -export function trackSqlVariableAssignment( - lines: string[], - lineIndex: number, - sqlVariables: Map, -): void { - const line = lines[lineIndex] ?? ''; - const m = SQL_VAR_ASSIGN_RE.exec(line); - if (!m) return; - const rawVarName = m[1] ?? ''; - const varName = rawVarName.toLowerCase(); - const rhs = (m[2] ?? '').trim(); - const newFragment = collectStringLiteralText(lines, lineIndex); - if (!newFragment) return; - - const selfRefRe = new RegExp(`^${escapeRegExpLiteral(rawVarName)}\\s*&`, 'i'); - const existing = sqlVariables.get(varName); - if (existing !== undefined && selfRefRe.test(rhs)) { - sqlVariables.set(varName, `${existing} ${newFragment}`); - } else { - sqlVariables.set(varName, newFragment); - } -} - -function collectStringLiteralText(lines: string[], startIndex: number): string { - const fragments: string[] = []; - for (let i = startIndex; i < lines.length; i++) { - const line = lines[i] ?? ''; - for (const lit of extractStringLiterals(line)) { - fragments.push(lit.text); - } - if (!line.trimEnd().endsWith('&')) break; - } - return fragments.join(' '); -} - function emitSqlTableReferences( ctx: VbaExtractorContext, sqlString: string, lineNum: number, dedupe: Set, ): void { - // Scan the SQL string for FROM/INTO/UPDATE
. - // Preserve the source regex's `/u` flag (Unicode property classes) - // — hardcoding `'gi'` here would silently break non-ASCII identifiers. - const tableRe = new RegExp(SQL_TABLE_RE.source, SQL_TABLE_RE.flags); - let tm: RegExpExecArray | null; - while ((tm = tableRe.exec(sqlString)) !== null) { - const clause = tm[1] ?? ''; - const table = (tm[2] ?? '').replace(/[\[\]]/g, ''); - if (!table) continue; - const key = `${lineNum}:${table}`; + // Issue #203: delegate to the shared scanner. It owns the + // `FROM/JOIN/INTO/UPDATE
` regex, the reserved-word reject + // list, the `?`-sentinel fallback (see `vba/sql-wrapper.ts:144-178`) + // and the read/write access direction. + for (const row of scanSqlTables(sqlString)) { + const key = `${lineNum}:${row.table}`; if (dedupe.has(key)) continue; dedupe.add(key); - ctx.emitReference(table, lineNum, 0, 'vba-sql-table', classifySqlAccess(sqlString, clause)); + ctx.emitReference(row.table, lineNum, 0, 'vba-sql-table', row.access); } }