weavatrix 0.3.10 → 0.3.12

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,263 @@
1
+ // SQL extractor. No SQL grammar ships in the pinned tree-sitter-wasms, so this module is textOnly:
2
+ // a dependency-free statement scanner built for the graph's needs (symbols + references), NOT a SQL
3
+ // parser. Symbols: CREATE TABLE/VIEW (+ columns as member symbols, ALTER TABLE ADD COLUMN included),
4
+ // FUNCTION/PROCEDURE, INDEX, TRIGGER. References: FROM/JOIN/INSERT INTO/UPDATE…SET/DELETE FROM/
5
+ // TRUNCATE/REFERENCES/EXECUTE FUNCTION/CALL inside .sql files, plus the same verbs found in string
6
+ // literals of every other indexed language (scanEmbeddedSql) — that is what ties the code graph to
7
+ // the database schema: a query in app code becomes a `references` edge onto the table it touches.
8
+ // Known blind spots (kept honest downstream, see dead-check.js): ORM-generated SQL is invisible,
9
+ // `SELECT *` consumes columns namelessly (tables get sql_star), DROP statements are not replayed.
10
+ // String literals and comments are blanked before structural scanning so a quoted "SELECT" can
11
+ // never fabricate a reference; dollar-quoted function bodies stay scannable on purpose.
12
+
13
+ const NAME = String.raw`((?:[\`"\[]?[A-Za-z_][\w$]*[\`"\]]?\.)*[\`"\[]?[A-Za-z_][\w$]*[\`"\]]?)`;
14
+ const cleanName = (raw) => String(raw || "").replace(/[`"\[\]]/g, "").split(".").pop();
15
+ const CONSTRAINT_START = /^(?:CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|EXCLUDE|KEY|INDEX|LIKE|PERIOD)\b/i;
16
+
17
+ // strings ('…' with '' doubling) and comments (-- …, /* … */) become spaces of equal length,
18
+ // so offsets/line numbers survive and quoted text can't match structural patterns.
19
+ function sanitizeSql(text) {
20
+ const out = text.split("");
21
+ let i = 0;
22
+ const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; };
23
+ while (i < text.length) {
24
+ const ch = text[i], next = text[i + 1];
25
+ if (ch === "-" && next === "-") { let j = i; while (j < text.length && text[j] !== "\n") j++; blank(i, j); i = j; continue; }
26
+ if (ch === "/" && next === "*") { let j = text.indexOf("*/", i + 2); j = j < 0 ? text.length : j + 2; blank(i, j); i = j; continue; }
27
+ if (ch === "'") {
28
+ let j = i + 1;
29
+ while (j < text.length) { if (text[j] === "'") { if (text[j + 1] === "'") { j += 2; continue; } break; } j++; }
30
+ blank(i + 1, Math.min(j, text.length)); i = j + 1; continue;
31
+ }
32
+ i++;
33
+ }
34
+ return out.join("");
35
+ }
36
+
37
+ const lineIndex = (text) => {
38
+ const starts = [0];
39
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
40
+ return (offset) => {
41
+ let lo = 0, hi = starts.length - 1;
42
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= offset) lo = mid; else hi = mid - 1; }
43
+ return lo + 1;
44
+ };
45
+ };
46
+
47
+ // split a CREATE TABLE body on top-level commas, keeping each part's offset
48
+ function splitColumns(body, base) {
49
+ const parts = [];
50
+ let depth = 0, start = 0;
51
+ for (let i = 0; i < body.length; i++) {
52
+ const ch = body[i];
53
+ if (ch === "(") depth++;
54
+ else if (ch === ")") depth--;
55
+ else if (ch === "," && depth === 0) { parts.push({ text: body.slice(start, i), offset: base + start }); start = i + 1; }
56
+ }
57
+ parts.push({ text: body.slice(start), offset: base + start });
58
+ return parts;
59
+ }
60
+
61
+ const REF_PATTERNS = [
62
+ new RegExp(String.raw`\bFROM\s+${NAME}`, "gi"),
63
+ new RegExp(String.raw`\bJOIN\s+${NAME}`, "gi"),
64
+ new RegExp(String.raw`\bINSERT\s+INTO\s+${NAME}`, "gi"),
65
+ new RegExp(String.raw`\bMERGE\s+INTO\s+${NAME}`, "gi"),
66
+ new RegExp(String.raw`\bUPDATE\s+(?:ONLY\s+)?${NAME}\s+SET\b`, "gi"),
67
+ new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?${NAME}`, "gi"),
68
+ new RegExp(String.raw`\bREFERENCES\s+${NAME}`, "gi"),
69
+ new RegExp(String.raw`\bEXECUTE\s+(?:FUNCTION|PROCEDURE)\s+${NAME}`, "gi"),
70
+ ];
71
+ const STAR_RE = /\bSELECT\s+(?:[A-Za-z_]\w*\.)?\*/i;
72
+
73
+ export default {
74
+ family: "sql",
75
+ grammars: [],
76
+ exts: { ".sql": "sql" },
77
+ isWeb: false,
78
+ textOnly: true,
79
+ calls: null,
80
+ heritage: [],
81
+
82
+ pass1(ctx) {
83
+ const { fileRel, code, addSym, links, sqlRefs } = ctx;
84
+ const text = sanitizeSql(String(code || ""));
85
+ const lineOf = lineIndex(text);
86
+ const fakeNode = (startOffset, endOffset) => ({
87
+ startPosition: { row: lineOf(startOffset) - 1, column: 0 },
88
+ endPosition: { row: lineOf(Math.max(startOffset, endOffset - 1)) - 1, column: 0 },
89
+ });
90
+
91
+ // statements with offsets (';' inside dollar-quoted bodies splits early — refs/lines still land right)
92
+ const statements = [];
93
+ let cursor = 0;
94
+ while (cursor <= text.length) {
95
+ let end = text.indexOf(";", cursor);
96
+ if (end < 0) end = text.length;
97
+ if (text.slice(cursor, end).trim()) statements.push({ text: text.slice(cursor, end), offset: cursor });
98
+ cursor = end + 1;
99
+ }
100
+
101
+ const addRef = (table, offset, star) => {
102
+ const name = cleanName(table);
103
+ if (/^[A-Za-z_][\w$]*$/.test(name)) sqlRefs.push({ file: fileRel, line: lineOf(offset), table: name, star: !!star });
104
+ };
105
+
106
+ for (const stmt of statements) {
107
+ const at = (m) => stmt.offset + m.index;
108
+ const definedHere = new Set();
109
+ const defineSym = (raw, m, callable, extra) => {
110
+ const name = cleanName(raw);
111
+ definedHere.add(name);
112
+ return addSym(name, lineOf(at(m)), callable, {
113
+ sourceNode: fakeNode(at(m), stmt.offset + stmt.text.length),
114
+ exported: true, moduleDeclaration: true, ...extra,
115
+ });
116
+ };
117
+
118
+ let m;
119
+ if ((m = stmt.text.match(new RegExp(String.raw`\bCREATE\s+(?:GLOBAL\s+|LOCAL\s+)?(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${NAME}\s*\(`, "i")))) {
120
+ const tableName = cleanName(m[1]);
121
+ const open = stmt.offset + m.index + m[0].length - 1;
122
+ let depth = 0, close = open;
123
+ for (let i = open; i < text.length; i++) { if (text[i] === "(") depth++; else if (text[i] === ")" && --depth === 0) { close = i; break; } }
124
+ const fieldTypes = {};
125
+ const columns = [];
126
+ for (const part of splitColumns(text.slice(open + 1, close), open + 1)) {
127
+ const trimmed = part.text.trim();
128
+ if (!trimmed || CONSTRAINT_START.test(trimmed)) continue;
129
+ const col = trimmed.match(new RegExp(String.raw`^${NAME}\s+([A-Za-z_]\w*(?:\([^)]*\))?)`));
130
+ if (!col) continue;
131
+ const colName = cleanName(col[1]);
132
+ if (!/^[A-Za-z_][\w$]*$/.test(colName)) continue;
133
+ fieldTypes[colName] = col[2];
134
+ columns.push({ name: colName, offset: part.offset + part.text.indexOf(col[1]) });
135
+ }
136
+ const tableId = defineSym(m[1], m, false, { symbolKind: "table", fieldTypes });
137
+ for (const column of columns) {
138
+ const columnId = addSym(column.name, lineOf(column.offset), false, {
139
+ sourceNode: fakeNode(column.offset, column.offset + 1),
140
+ symbolKind: "column", memberOf: tableName,
141
+ });
142
+ if (tableId && columnId) links.push({ source: tableId, target: columnId, relation: "contains", confidence: "EXTRACTED" });
143
+ }
144
+ } else if ((m = stmt.text.match(new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:MATERIALIZED\s+)?VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?${NAME}`, "i")))) {
145
+ defineSym(m[1], m, false, { symbolKind: "view" });
146
+ } else if ((m = stmt.text.match(new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\s+${NAME}`, "i")))) {
147
+ defineSym(m[1], m, true, { symbolKind: "function" });
148
+ } else if ((m = stmt.text.match(new RegExp(String.raw`\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?${NAME}\s+ON\s+(?:ONLY\s+)?${NAME}`, "i")))) {
149
+ defineSym(m[1], m, false, { symbolKind: "index" });
150
+ addRef(m[2], at(m), false);
151
+ } else if ((m = stmt.text.match(new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:CONSTRAINT\s+)?TRIGGER\s+${NAME}[\s\S]*?\bON\s+${NAME}`, "i")))) {
152
+ defineSym(m[1], m, false, { symbolKind: "trigger" });
153
+ addRef(m[2], at(m), false);
154
+ } else if ((m = stmt.text.match(new RegExp(String.raw`\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?${NAME}`, "i")))) {
155
+ const tableName = cleanName(m[1]);
156
+ addRef(m[1], at(m), false);
157
+ const addColumn = new RegExp(String.raw`\bADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?!CONSTRAINT\b|PRIMARY\b|FOREIGN\b|UNIQUE\b|CHECK\b|EXCLUDE\b)${NAME}\s+[A-Za-z_]`, "gi");
158
+ for (const colMatch of stmt.text.matchAll(addColumn)) {
159
+ const colName = cleanName(colMatch[1]);
160
+ if (!/^[A-Za-z_][\w$]*$/.test(colName)) continue;
161
+ addSym(colName, lineOf(stmt.offset + colMatch.index), false, {
162
+ sourceNode: fakeNode(stmt.offset + colMatch.index, stmt.offset + colMatch.index + 1),
163
+ symbolKind: "column", memberOf: tableName,
164
+ });
165
+ }
166
+ }
167
+
168
+ const star = STAR_RE.test(stmt.text);
169
+ for (const pattern of REF_PATTERNS) {
170
+ pattern.lastIndex = 0;
171
+ for (const refMatch of stmt.text.matchAll(pattern)) {
172
+ const name = cleanName(refMatch[1]);
173
+ if (!definedHere.has(name)) addRef(refMatch[1], stmt.offset + refMatch.index, star);
174
+ }
175
+ }
176
+ }
177
+ },
178
+ };
179
+
180
+ // SQL verbs inside string literals of any host language — the cross-link between app code and schema.
181
+ // Runs on RAW text (strings are exactly where the queries live); comments can over-match, which only
182
+ // errs toward "referenced" and carries INFERRED confidence.
183
+ const EMBEDDED = [
184
+ { re: new RegExp(String.raw`\bSELECT\b[^;]{0,2000}?\bFROM\s+${NAME}`, "gi"), star: true },
185
+ { re: new RegExp(String.raw`\bINSERT\s+INTO\s+${NAME}`, "gi") },
186
+ { re: new RegExp(String.raw`\bUPDATE\s+${NAME}\s+SET\b`, "gi") },
187
+ { re: new RegExp(String.raw`\bDELETE\s+FROM\s+${NAME}`, "gi") },
188
+ { re: new RegExp(String.raw`\bJOIN\s+${NAME}\b`, "gi") },
189
+ { re: new RegExp(String.raw`\bCALL\s+${NAME}\s*\(`, "gi") },
190
+ ];
191
+ const EMBEDDED_PREFILTER = /\b(?:select|insert|update|delete|join|call)\b/i;
192
+
193
+ export function scanEmbeddedSql(code, fileRel, sqlRefs) {
194
+ const text = String(code || "");
195
+ if (!EMBEDDED_PREFILTER.test(text)) return;
196
+ const lineOf = lineIndex(text);
197
+ for (const { re, star } of EMBEDDED) {
198
+ re.lastIndex = 0;
199
+ for (const m of text.matchAll(re)) {
200
+ const name = cleanName(m[1]);
201
+ if (!/^[A-Za-z_][\w$]*$/.test(name)) continue;
202
+ sqlRefs.push({ file: fileRel, line: lineOf(m.index), table: name, star: !!(star && STAR_RE.test(m[0])) });
203
+ }
204
+ }
205
+ }
206
+
207
+ // Dead-code policy for SQL schema objects, consumed by analysis/dead-check.js. Static liveness can
208
+ // only be judged where the repo demonstrably uses literal SQL the scanner can read: without a single
209
+ // embedded-SQL edge from host code, DB consumers (ORMs, external services) are invisible and every
210
+ // verdict would be a guess. Indexes and triggers are DB-engine surface — never judged. Columns of a
211
+ // `SELECT *`-consumed table are consumed namelessly — never judged by name.
212
+ export function createSqlDeadVerdict({ nodes, links, ep, bareName }) {
213
+ const usageFromCode = links.some((l) => l.usage === "sql" && !String(ep(l.source)).split("#")[0].endsWith(".sql"));
214
+ const starTables = new Set(nodes.filter((n) => n.sql_star).map((n) => bareName(n.label)));
215
+ const classify = (n) => {
216
+ if (!String(n.source_file || "").endsWith(".sql")) return null;
217
+ const kind = String(n.symbol_kind || "");
218
+ if (kind === "index" || kind === "trigger" || !usageFromCode) return "veto";
219
+ if (kind === "column" && starTables.has(String(n.member_of || ""))) return "veto";
220
+ return "flag";
221
+ };
222
+ return {
223
+ veto: (n) => classify(n) === "veto",
224
+ reason: (n) => (classify(n) === "flag" ? "no SQL statement in the indexed sources references it" : null),
225
+ };
226
+ }
227
+
228
+ // After both passes: resolve collected refs against every table/view/function DEFINED in .sql files.
229
+ // Unknown names drop silently — only schema objects the repo actually declares can gain edges.
230
+ export function resolveSqlReferences({ sqlRefs, links, nodeById, perFileSymbols }) {
231
+ if (!sqlRefs?.length) return;
232
+ const index = new Map();
233
+ for (const [file, syms] of perFileSymbols) {
234
+ if (!file.endsWith(".sql")) continue;
235
+ for (const sym of syms) {
236
+ if (!["table", "view", "function"].includes(sym.symbolKind)) continue;
237
+ (index.get(sym.name) || index.set(sym.name, []).get(sym.name)).push(sym.id);
238
+ }
239
+ }
240
+ if (!index.size) return;
241
+ const enclosing = (file, line) => {
242
+ let best = null;
243
+ for (const symbol of perFileSymbols.get(file) || []) {
244
+ if (line < symbol.start || line > symbol.end) continue;
245
+ if (!best || symbol.start > best.start || (symbol.start === best.start && symbol.end < best.end)) best = symbol;
246
+ }
247
+ return best;
248
+ };
249
+ const seen = new Set();
250
+ for (const ref of sqlRefs) {
251
+ const targets = index.get(ref.table);
252
+ if (!targets) continue;
253
+ const source = enclosing(ref.file, ref.line)?.id || ref.file;
254
+ for (const target of targets) {
255
+ if (ref.star) { const node = nodeById.get(target); if (node) node.sql_star = true; }
256
+ if (target === source) continue;
257
+ const key = source + ">" + target;
258
+ if (seen.has(key)) continue;
259
+ seen.add(key);
260
+ links.push({ source, target, relation: "references", confidence: "INFERRED", usage: "sql", line: ref.line });
261
+ }
262
+ }
263
+ }
@@ -1,6 +1,11 @@
1
1
  export function createPass2Resolution({symIdsByFileName, nodeById, importedLocals, symByFileName}) {
2
2
  const dirSymbols = new Map(), dirMethods = new Map(), dirMethodsByName = new Map(), dirTypes = new Map()
3
- const sharesDirScope = (file) => file.endsWith('.go') || file.endsWith('.cs') || file.endsWith('.rs')
3
+ // Rust associated functions / methods indexed by their owning type, per directory: `Type::method`
4
+ // resolves to the exact impl member instead of any same-named function in the directory.
5
+ const rustMethods = new Map() // dir -> Map(typeName -> Map(methodName -> id))
6
+ // .sol shares dir scope because Solidity's project namespace is flat: `import "./Base.sol"` names no
7
+ // symbols yet pulls every declaration into scope, so same-dir name resolution is the honest static proxy.
8
+ const sharesDirScope = (file) => file.endsWith('.go') || file.endsWith('.cs') || file.endsWith('.rs') || file.endsWith('.sol')
4
9
  for (const [file, names] of symIdsByFileName) {
5
10
  if (!sharesDirScope(file)) continue
6
11
  const dir = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''
@@ -22,6 +27,13 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
22
27
  continue
23
28
  }
24
29
  if (!symbols.has(name)) symbols.set(name, id)
30
+ if (file.endsWith('.rs') && node?.member_of) {
31
+ let byType = rustMethods.get(dir)
32
+ if (!byType) rustMethods.set(dir, (byType = new Map()))
33
+ let methods = byType.get(node.member_of)
34
+ if (!methods) byType.set(node.member_of, (methods = new Map()))
35
+ if (!methods.has(name)) methods.set(name, id)
36
+ }
25
37
  if (file.endsWith('.go') && node?.symbol_space === 'type') {
26
38
  let types = dirTypes.get(dir)
27
39
  if (!types) dirTypes.set(dir, (types = new Map()))
@@ -53,6 +65,7 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
53
65
  if (!imported?.targetFile) return null
54
66
  return resolveNamedSymbol(imported.originFile || imported.targetFile, imported.originName || imported.imported, 'value')
55
67
  }
68
+ const resolveRustMethod = (dir, typeName, methodName) => rustMethods.get(dir)?.get(typeName)?.get(methodName) || null
56
69
  const javaTypeKinds = new Set(['class', 'interface', 'enum', 'record', 'annotation'])
57
70
  const resolveJavaType = (name, file) => {
58
71
  const imported = importedLocals.get(file)?.get(name)
@@ -64,5 +77,5 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
64
77
  const target = symByFileName.get(file)?.get(name)
65
78
  return target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind) ? target : null
66
79
  }
67
- return {dirSymbols, dirMethods, dirMethodsByName, dirTypes, resolveNamedSymbol, resolveCall, resolveJavaType}
80
+ return {dirSymbols, dirMethods, dirMethodsByName, dirTypes, resolveNamedSymbol, resolveCall, resolveJavaType, resolveRustMethod}
68
81
  }
@@ -27,7 +27,7 @@ const CURRENT_GRAPH_SCHEMA = Object.freeze({
27
27
  barrelResolutionV: 1,
28
28
  reExportOccurrencesV: 1,
29
29
  symbolSpacesV: 1,
30
- extractorSchemaV: 6,
30
+ extractorSchemaV: 7,
31
31
  })
32
32
  const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
33
33
  const MAX_CONTROL_BYTES = 1_000_000
@@ -184,7 +184,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
184
184
 
185
185
  if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
186
186
  || !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
187
- || Number(existingGraph.extractorSchemaV) < 6 || Number(existingGraph.physicalFileLocV) < 1
187
+ || Number(existingGraph.extractorSchemaV) < 7 || Number(existingGraph.physicalFileLocV) < 1
188
188
  || Number(existingGraph.reExportOccurrencesV) < 1
189
189
  || Number(existingGraph.symbolSpacesV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
190
190
  return full("incremental-baseline-unavailable");
@@ -8,6 +8,7 @@ import { specToPkg } from "./builder/spec-pkg.js";
8
8
  import { analyzeSyntaxComplexity } from "../analysis/source-complexity.js";
9
9
  import { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk } from "./internal-builder.langs.js";
10
10
  import { buildResolvers } from "./internal-builder.resolvers.js";
11
+ import { scanEmbeddedSql, resolveSqlReferences } from "./builder/lang-sql.js";
11
12
  import { assignDeterministicCommunities } from "./community.js";
12
13
  import { snapshotRepository } from "./incremental-refresh.js";
13
14
  import { EDGE_PROVENANCE_V, stampEdgeProvenance } from "./edge-provenance.js";
@@ -72,6 +73,9 @@ export async function buildInternalGraph(repoDir, opts = {}) {
72
73
  // Bare-package imports (axios, node:fs, @scope/x) — the graph can't resolve them to a repo file, but
73
74
  // dependency analysis NEEDS them (unused/missing deps). Additive top-level array; nodes/links untouched.
74
75
  const externalImports = [];
76
+ // SQL references (from .sql statements AND string literals in host-language code) collect during
77
+ // pass 1 and resolve after both passes, once every schema object the repo declares is indexed.
78
+ const sqlRefs = [];
75
79
 
76
80
  const resolvers = buildResolvers(repoDir, fileSet); // aliases, go.mod, java index, href, selectors
77
81
  const { selectorIndex, htmlUsages } = resolvers;
@@ -90,13 +94,16 @@ export async function buildInternalGraph(repoDir, opts = {}) {
90
94
  } // config/infra/docs file-only node
91
95
  let code; try { code = readFileSync(abs, "utf8"); } catch { addNode(fileNode); continue; }
92
96
  addNode({ ...fileNode, physical_loc: physicalLineCount(code) });
93
- const grammar = EXT_LANG[ext]; const lang = LANGS[FAMILY[grammar]]; if (!lang || !langs[grammar]) continue;
97
+ const grammar = EXT_LANG[ext]; const lang = LANGS[FAMILY[grammar]]; if (!lang || (!langs[grammar] && !lang.textOnly)) continue;
94
98
  // giant / generated / minified file → keep the file NODE but don't parse symbols (avoids a wedged parse)
95
99
  if (code.length > MAX_PARSE_BYTES) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); symIdsByFileName.set(fileRel, new Map()); continue; }
96
100
  if ((++_parsed % 24) === 0) await new Promise((r) => setImmediate(r)); // breathe: let the UI paint between chunks
97
101
  if (typeof opts.onParseFile === "function") opts.onParseFile(fileRel);
98
- const parser = new Parser(); parser.setLanguage(langs[grammar]);
99
- let tree; try { tree = parser.parse(code); } catch { continue; }
102
+ let tree = null; // textOnly languages (SQL) scan `code` directly — no grammar, no parse
103
+ if (!lang.textOnly) {
104
+ const parser = new Parser(); parser.setLanguage(langs[grammar]);
105
+ try { tree = parser.parse(code); } catch { continue; }
106
+ }
100
107
 
101
108
  const syms = []; const nameToId = new Map(); const nameToIds = new Map(); const moduleNameToId = new Map();
102
109
  const addSym = (name, line, callable, extra) => {
@@ -214,8 +221,10 @@ export async function buildInternalGraph(repoDir, opts = {}) {
214
221
  // Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
215
222
  const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
216
223
 
217
- try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, recordJsExport, fileSet, ...resolvers }); }
224
+ try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, recordJsExport, fileSet, sqlRefs, ...resolvers }); }
218
225
  catch (e) { /* one bad file never sinks the whole build */ void e; }
226
+ // string-literal SQL in host-language code → schema reference candidates (resolved post-pass-2)
227
+ if (!lang.textOnly && !lang.isWeb) try { scanEmbeddedSql(code, fileRel, sqlRefs); } catch { /* never sinks the build */ }
219
228
 
220
229
  syms.sort((a, b) => a.start - b.start);
221
230
  const eof = code.split("\n").length;
@@ -223,7 +232,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
223
232
  if (!syms[i].end || syms[i].end < syms[i].start) syms[i].end = i + 1 < syms.length ? syms[i + 1].start - 1 : eof;
224
233
  }
225
234
  perFileSymbols.set(fileRel, syms); symByFileName.set(fileRel, nameToId); symIdsByFileName.set(fileRel, nameToIds);
226
- tree.delete();
235
+ if (tree) tree.delete();
227
236
  }
228
237
 
229
238
  // ---- pass 2: scope-aware calls + inheritance (Go package = whole dir → same-dir symbols share scope;
@@ -231,7 +240,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
231
240
  // not files, so the folder map is the only reliable cross-file resolver) ----
232
241
  const {reExportOccurrences} = runInternalGraphPass2({
233
242
  files, rel, langs, caps, field, links, nodeById, perFileSymbols, symByFileName,
234
- symIdsByFileName, importedLocals, jsExports,
243
+ symIdsByFileName, importedLocals, jsExports, resolvers,
235
244
  });
236
245
  // HTML class/id usage → the CSS file(s) defining that selector: file-level reference edges (deduped per pair).
237
246
  const htmlRefSeen = new Set();
@@ -243,6 +252,8 @@ export async function buildInternalGraph(repoDir, opts = {}) {
243
252
  links.push({ source: u.htmlFile, target: cssFile, relation: "references", confidence: "INFERRED" });
244
253
  }
245
254
  }
255
+ // code/SQL → schema-object edges (tables/views/functions declared in .sql files)
256
+ try { resolveSqlReferences({ sqlRefs, links, nodeById, perFileSymbols }); } catch { /* never sinks the build */ }
246
257
 
247
258
  // community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
248
259
  // app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
@@ -266,7 +277,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
266
277
  barrelResolutionV: 1,
267
278
  reExportOccurrencesV: 1,
268
279
  symbolSpacesV: 1,
269
- extractorSchemaV: 6,
280
+ extractorSchemaV: 7, // v7 = Solidity + SQL indexing (schema objects, embedded-SQL edges)
270
281
  reExportOccurrences,
271
282
  jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
272
283
  fileHashes: snapshot.fileHashes,
@@ -16,6 +16,8 @@ import LANG_GO from "./builder/lang-go.js";
16
16
  import LANG_JAVA from "./builder/lang-java.js";
17
17
  import LANG_CSHARP from "./builder/lang-csharp.js";
18
18
  import LANG_RUST from "./builder/lang-rust.js";
19
+ import LANG_SOLIDITY from "./builder/lang-solidity.js";
20
+ import LANG_SQL from "./builder/lang-sql.js";
19
21
  import LANG_HTML from "./builder/lang-html.js";
20
22
  import LANG_CSS from "./builder/lang-css.js";
21
23
 
@@ -27,7 +29,7 @@ const NODE_MODULES = dirname(WTS_DIR);
27
29
  const DEFAULT_WASM_DIR = join(NODE_MODULES, "tree-sitter-wasms", "out");
28
30
 
29
31
  // ---- language registry (derived from the per-language modules) ----
30
- const LANG_MODULES = [LANG_JS, LANG_PY, LANG_GO, LANG_JAVA, LANG_CSHARP, LANG_RUST, LANG_HTML, LANG_CSS];
32
+ const LANG_MODULES = [LANG_JS, LANG_PY, LANG_GO, LANG_JAVA, LANG_CSHARP, LANG_RUST, LANG_SOLIDITY, LANG_SQL, LANG_HTML, LANG_CSS];
31
33
  const LANGS = {}; // family -> module
32
34
  const EXT_LANG = {}; // ext -> grammar
33
35
  const FAMILY = {}; // grammar -> family
@@ -8,10 +8,11 @@ import {createGoReceiverResolution} from './builder/go-receiver-resolution.js'
8
8
 
9
9
  export function runInternalGraphPass2({
10
10
  files, rel, langs, caps, field, links, nodeById, perFileSymbols, symByFileName,
11
- symIdsByFileName, importedLocals, jsExports,
11
+ symIdsByFileName, importedLocals, jsExports, resolvers,
12
12
  }) {
13
13
  const resolution = createPass2Resolution({symIdsByFileName, nodeById, importedLocals, symByFileName})
14
- const {dirSymbols, resolveNamedSymbol, resolveCall, resolveJavaType} = resolution
14
+ const {dirSymbols, resolveNamedSymbol, resolveCall, resolveJavaType, resolveRustMethod} = resolution
15
+ const {resolveRustPath, resolveRustCratePath} = resolvers || {}
15
16
  const {resolveNamespaceMember, reExportOccurrences} = resolveJsBarrels({
16
17
  jsExports, importedLocals, links,
17
18
  resolveSymbol: (file, name, typeOnly) => resolveNamedSymbol(file, name, typeOnly ? 'type' : 'value'),
@@ -46,6 +47,7 @@ export function runInternalGraphPass2({
46
47
  lang.pass2({
47
48
  grammar, tree, fileRel: file, code, caps, field, enclosing, links, nodeById,
48
49
  perFileSymbols, symByFileName, symIdsByFileName, importedLocals, resolveCall, resolveJavaType,
50
+ dirSymbols, resolveNamedSymbol, resolveRustMethod, resolveRustPath, resolveRustCratePath,
49
51
  })
50
52
  } catch { /* one language-specific resolver never sinks the graph */ }
51
53
  if (lang.selectorCall) for (const capture of caps(grammar, lang.selectorCall, tree.rootNode)) {
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from "node:fs";
6
6
  import { join, dirname } from "node:path";
7
7
  import { parseGoMod } from "../analysis/manifests.js";
8
+ import { parseCargoToml, cargoName } from "../analysis/cargo-manifests.js";
8
9
  import { createRepoBoundary } from "../repo-path.js";
9
10
  import { listRepoFiles } from "../analysis/internal-audit/repo-files.js";
10
11
  import { createRustResolvers } from "./resolvers/rust.js";
@@ -61,6 +62,40 @@ export function buildResolvers(repoDir, fileSet) {
61
62
 
62
63
  const { resolveRustMod, resolveRustPath } = createRustResolvers(fileSet);
63
64
 
65
+ // Workspace crate map: normalized package name → its crate source root file (lib.rs/main.rs). A path or
66
+ // `use` that starts with a sibling crate's name (`use radiochron::wlan`) then resolves INTO that crate's
67
+ // files instead of being logged as an external crates.io dependency — so cross-crate imports and calls
68
+ // become real graph edges rather than a false "unused dependency" plus missing coupling.
69
+ // Cargo.toml (like go.mod) is a manifest, not a parsed source file, so it is read via listRepoFiles
70
+ // rather than the source fileSet. The crate root .rs it points at IS in fileSet (it is parsed).
71
+ const rustCrates = new Map();
72
+ const ambiguousCrates = new Set();
73
+ for (const manifest of listRepoFiles(repoDir).filter((file) => /(^|\/)Cargo\.toml$/i.test(file))) {
74
+ let parsed;
75
+ try { parsed = parseCargoToml(readLocal(manifest)); } catch { continue; }
76
+ if (!parsed.packageName) continue;
77
+ const dir = manifest.includes("/") ? manifest.slice(0, manifest.lastIndexOf("/")) : "";
78
+ const rootFile = ["src/lib.rs", "src/main.rs", "lib.rs", "main.rs"]
79
+ .map((candidate) => [dir, candidate].filter(Boolean).join("/"))
80
+ .find((path) => fileSet.has(path));
81
+ if (!rootFile) continue;
82
+ const key = cargoName(parsed.packageName);
83
+ // A repo can hold two crates with the same package name (independent workspaces, vendored copies).
84
+ // Cross-crate resolution has no way to pick the right one from a bare `name::path`, so treat the name
85
+ // as ambiguous and resolve nothing for it — better a missing edge than a confident wrong one.
86
+ if (rustCrates.has(key) && rustCrates.get(key) !== rootFile) ambiguousCrates.add(key);
87
+ else rustCrates.set(key, rootFile);
88
+ }
89
+ // Resolve `<crate>::<segments...>` against the named sibling crate's root, reusing the crate-anchored
90
+ // module resolver. Returns {targetFile, remaining} or null when the head is not an unambiguous workspace crate.
91
+ const resolveRustCratePath = (crateName, segments = []) => {
92
+ const key = cargoName(crateName);
93
+ if (ambiguousCrates.has(key)) return null;
94
+ const rootFile = rustCrates.get(key);
95
+ if (!rootFile) return null;
96
+ return resolveRustPath(rootFile, ["crate", ...segments], { unqualified: false });
97
+ };
98
+
64
99
  // Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
65
100
  // Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
66
101
  const aliasContexts = new Map();
@@ -207,6 +242,36 @@ export function buildResolvers(repoDir, fileSet) {
207
242
  if (seg[0] === "src" && seg.length > 2) pyTopDirs.add(seg[1]);
208
243
  }
209
244
 
245
+ // Solidity: relative imports, Foundry remappings (remappings.txt beats foundry.toml, longest prefix
246
+ // wins), then root-anchored specs (`src/X.sol` — forge resolves those against the project root).
247
+ // Nested remapping files are intentionally not scoped per-package: one remapping table per repo is the
248
+ // overwhelmingly common layout, and a wrong guess here would fabricate cross-package edges.
249
+ const solRemappings = [];
250
+ const addRemapping = (entry) => {
251
+ const m = /^\s*([^=\s]+)\s*=\s*(\S+)\s*$/.exec(String(entry || ""));
252
+ if (m && !solRemappings.some((r) => r.prefix === m[1])) solRemappings.push({ prefix: m[1], target: cleanRel(m[2]) });
253
+ };
254
+ // neither file has an indexed extension, so probe the filesystem directly rather than fileSet
255
+ try { for (const line of readLocal("remappings.txt").split(/\r?\n/)) addRemapping(line); } catch { /* absent/unreadable */ }
256
+ try {
257
+ const toml = readLocal("foundry.toml");
258
+ for (const m of toml.matchAll(/remappings\s*=\s*\[([^\]]*)\]/g)) for (const s of m[1].matchAll(/["']([^"']+)["']/g)) addRemapping(s[1]);
259
+ } catch { /* absent/unreadable */ }
260
+ solRemappings.sort((a, b) => b.prefix.length - a.prefix.length);
261
+ const resolveSolidityImport = (fromRel, spec) => {
262
+ if (!spec || !spec.endsWith(".sol")) return null;
263
+ if (spec.startsWith(".")) {
264
+ const candidate = cleanRel(join(dirname(fromRel), spec));
265
+ return fileSet.has(candidate) ? candidate : null;
266
+ }
267
+ for (const { prefix, target } of solRemappings) {
268
+ if (spec !== prefix && !spec.startsWith(prefix)) continue;
269
+ const candidate = cleanRel(target + "/" + spec.slice(prefix.length)).replace(/\/+/g, "/");
270
+ if (fileSet.has(candidate)) return candidate;
271
+ }
272
+ return fileSet.has(spec) ? spec : null;
273
+ };
274
+
210
275
  const selectorIndex = new Map();
211
276
  const htmlUsages = [];
212
277
  const resolveHref = (fromRel, href) => {
@@ -217,5 +282,5 @@ export function buildResolvers(repoDir, fileSet) {
217
282
  return fileSet.has(cand) ? cand : null;
218
283
  };
219
284
 
220
- return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
285
+ return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveRustCratePath, resolveJavaImport, resolveSolidityImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
221
286
  }
@@ -17,6 +17,7 @@ const GRAMMAR_SHA256 = Object.freeze({
17
17
  java: '637aac4415fb39a211a4f4292d63c66b5ce9c32fa2cd35464af4f681d91b9a1f',
18
18
  c_sharp: '6266a7e32d68a3459104d994dc848df15d5672b0ea8e86d327274b694f8e6991',
19
19
  rust: '4409921a70d0aa5bec7d1d7ce809a557a8ee1cf6ace901e3ac6a76e62cfea903',
20
+ solidity: '160745e470f234cae903a9ba445d19e758d0b02e1197401fc765976c6254d2b6',
20
21
  html: '11b3405c1543fb012f5ed7f8ee73125076dce8b168301e1e787e4c717da6b456',
21
22
  css: '5fc615467b1b98420ed7517e5bf9e1f88468132dd903d842dfb13714f6a1cb0c',
22
23
  })
@@ -76,7 +76,7 @@ export async function tOpenRepo(g, args, ctx) {
76
76
  savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
77
77
  schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
78
78
  || !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
79
- || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 6
79
+ || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 7
80
80
  || !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
81
81
  || !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
82
82
  || !Number.isInteger(saved.physicalFileLocV) || saved.physicalFileLocV < 1
@@ -28,7 +28,7 @@ const BUDGET_PROPOSALS = Object.freeze(Object.entries(PROVISIONAL_BUDGETS)
28
28
  const SOURCE_ROOTS = new Set(['src', 'app', 'lib', 'packages', 'services'])
29
29
  const CODE_EXTENSIONS = new Set([
30
30
  '.cjs', '.cs', '.css', '.go', '.htm', '.html', '.java', '.js', '.jsx', '.less', '.mjs', '.py', '.pyi',
31
- '.rs', '.scss', '.ts', '.tsx',
31
+ '.rs', '.scss', '.sol', '.sql', '.ts', '.tsx',
32
32
  ])
33
33
  const CLASSIFIED = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
34
34
  const EDGE_RELATIONS = new Set(['imports', 're_exports', 'calls', 'references'])