@esneiderbravo/speclaw 2.0.9 → 2.0.10
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.
- package/README.md +1 -1
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/update.js +17 -0
- package/dist/modules/compass/affected-config.js +5 -0
- package/dist/modules/compass/affected.js +344 -27
- package/dist/modules/compass/db.js +88 -16
- package/dist/modules/compass/diff-context.js +4 -2
- package/dist/modules/compass/explore-rich.js +14 -7
- package/dist/modules/compass/extract.js +163 -4
- package/dist/modules/compass/hotspots.js +11 -6
- package/dist/modules/compass/hybrid.js +9 -4
- package/dist/modules/compass/indexer.js +365 -95
- package/dist/modules/compass/map.js +2 -2
- package/dist/modules/compass/query.js +70 -24
- package/dist/modules/compass/register.js +1 -1
- package/dist/modules/compass/visualize.js +5 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/output-budget.js +2 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { openDb, clearNeedsReindex, needsReindex } from "./db.js";
|
|
4
|
+
import { openDb, clearNeedsReindex, needsReindex, FILE_NODE_KIND } from "./db.js";
|
|
5
5
|
import { langForPath } from "./languages.js";
|
|
6
|
-
import { extract } from "./extract.js";
|
|
6
|
+
import { extract, importSpecifier } from "./extract.js";
|
|
7
7
|
import { getEmbedder, toBlob } from "./embedder.js";
|
|
8
8
|
import { contentHashFor, defaultEmbedText } from "./embed-input.js";
|
|
9
9
|
import { buildDirHashMap } from "./merkle.js";
|
|
@@ -36,106 +36,368 @@ function hashOf(content) {
|
|
|
36
36
|
const DEFAULT_MAX_CACHE_MB = 256;
|
|
37
37
|
const DEFAULT_RETENTION_DAYS = 30;
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* Call names that are ambient globals in common runtimes and test frameworks.
|
|
40
|
+
* A plain call to one of them binds only to a definition in the same file, so
|
|
41
|
+
* a project symbol that happens to share the name never collects every test
|
|
42
|
+
* file as a caller.
|
|
43
|
+
*/
|
|
44
|
+
export const BUILTIN_GLOBALS = [
|
|
45
|
+
"describe",
|
|
46
|
+
"it",
|
|
47
|
+
"test",
|
|
48
|
+
"expect",
|
|
49
|
+
"beforeEach",
|
|
50
|
+
"afterEach",
|
|
51
|
+
"beforeAll",
|
|
52
|
+
"afterAll",
|
|
53
|
+
"setTimeout",
|
|
54
|
+
"setInterval",
|
|
55
|
+
"clearTimeout",
|
|
56
|
+
"clearInterval",
|
|
57
|
+
"queueMicrotask",
|
|
58
|
+
"parseInt",
|
|
59
|
+
"parseFloat",
|
|
60
|
+
"require",
|
|
61
|
+
"structuredClone",
|
|
62
|
+
"fetch",
|
|
63
|
+
"print",
|
|
64
|
+
"len",
|
|
65
|
+
"isinstance",
|
|
66
|
+
"super",
|
|
67
|
+
];
|
|
68
|
+
/** {@link BUILTIN_GLOBALS} as a quoted SQL `IN (...)` list. */
|
|
69
|
+
export const BUILTIN_SQL_LIST = BUILTIN_GLOBALS.map((n) => `'${n}'`).join(", ");
|
|
70
|
+
/** Most bind parameters a scoped {@link resolveEdges} call uses before running unscoped. */
|
|
71
|
+
const MAX_SCOPE_PARAMS = 10_000;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve call and import edges to node ids — the one place edge resolution
|
|
74
|
+
* lives, shared by the full index run and incremental re-indexing.
|
|
75
|
+
*
|
|
76
|
+
* - Edges whose `dst_node_id` points at a node that no longer exists (its file
|
|
77
|
+
* was re-extracted) are reset to unresolved first.
|
|
78
|
+
* - Imports are resolved first (below). A call's "import target" is the file
|
|
79
|
+
* the same-file import edge with the call's `spec` resolved to.
|
|
80
|
+
* - Calls with `is_member = 1` are never resolved by name. Calls with
|
|
81
|
+
* `is_member = 2` (a member call on an import binding) resolve only when their
|
|
82
|
+
* import target exists: an import that no relative/alias/baseUrl resolution
|
|
83
|
+
* maps to a project file is a package, so `path.parse()` stays foreign.
|
|
84
|
+
* - A call to a {@link BUILTIN_GLOBALS} name resolves only to a definition in
|
|
85
|
+
* the same file or in its import target (`import { fetch } from "./http"`).
|
|
86
|
+
* - Otherwise a call prefers its import target, then a definition under the
|
|
87
|
+
* import target's directory (a barrel's subtree), then a same-file
|
|
88
|
+
* definition, else the lowest-id node with that name. File-owner nodes are
|
|
89
|
+
* never call targets. An import of a symbol-less or re-exporting file (a
|
|
90
|
+
* barrel) resolves to that file's file-owner node, so member calls through
|
|
91
|
+
* it bind by name like this.
|
|
92
|
+
* - Imports resolve their specifier relative to the importing file, then
|
|
93
|
+
* through the nearest `tsconfig.json`/`jsconfig.json` `paths`/`baseUrl`, and
|
|
94
|
+
* point at the target file's file-owner node when it has one, otherwise at
|
|
95
|
+
* its first node by `start_line`.
|
|
96
|
+
*
|
|
97
|
+
* The project root is read from the database's own path
|
|
98
|
+
* (`<root>/.speclaw/index.db`), so callers pass only the connection.
|
|
99
|
+
*
|
|
100
|
+
* @param db - Open index database (the caller owns the transaction).
|
|
101
|
+
* @param fileIds - When set, only calls owned by these files or by files whose
|
|
102
|
+
* import this pass resolved, and unresolved calls whose name a node in these
|
|
103
|
+
* files defines; unresolved imports are always all processed (a new file may
|
|
104
|
+
* satisfy any of them). When omitted, every
|
|
105
|
+
* unresolved edge.
|
|
106
|
+
* @returns How many call and import edges this call resolved.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveEdges(db, fileIds) {
|
|
109
|
+
// Covers: req~impact-id-first~1
|
|
110
|
+
db.exec(`UPDATE edges SET dst_node_id = NULL
|
|
111
|
+
WHERE dst_node_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.id = edges.dst_node_id)`);
|
|
112
|
+
const scoped = fileIds !== undefined;
|
|
113
|
+
const ids = fileIds ?? [];
|
|
114
|
+
const idList = ids.map(() => "?").join(", ") || "NULL";
|
|
115
|
+
const resolvedCalls = () => Number(db
|
|
116
|
+
.prepare("SELECT COUNT(*) AS n FROM edges WHERE kind = 'call' AND dst_node_id IS NOT NULL")
|
|
117
|
+
.get().n);
|
|
118
|
+
// Imports first: whether a call on an import binding may bind depends on
|
|
119
|
+
// whether that binding's import resolved to a project file.
|
|
120
|
+
const { resolved: imports, srcFiles } = resolveImportEdges(db, projectRootOf(db));
|
|
121
|
+
const before = resolvedCalls();
|
|
122
|
+
// A file whose import just resolved may have pending is_member = 2 calls.
|
|
123
|
+
// Past a bound (SQLite caps bind parameters) the pass is simply unscoped.
|
|
124
|
+
const importScope = [...srcFiles];
|
|
125
|
+
const importList = importScope.map(() => "?").join(", ") || "NULL";
|
|
126
|
+
const narrow = scoped && 2 * ids.length + importScope.length <= MAX_SCOPE_PARAMS;
|
|
127
|
+
const callScope = narrow
|
|
128
|
+
? `AND (src_file_id IN (${idList}) OR src_file_id IN (${importList})
|
|
129
|
+
OR dst_name IN (SELECT name FROM nodes WHERE file_id IN (${idList}) AND kind <> '${FILE_NODE_KIND}'))`
|
|
130
|
+
: "";
|
|
131
|
+
// File of the node the same-file import with this call's spec resolved to;
|
|
132
|
+
// NULL when the call has no spec (the subquery is skipped) or that import
|
|
133
|
+
// stayed unresolved (a package).
|
|
134
|
+
const importTarget = `(CASE WHEN edges.spec IS NULL THEN NULL ELSE (
|
|
135
|
+
SELECT t.file_id FROM edges i JOIN nodes t ON t.id = i.dst_node_id
|
|
136
|
+
WHERE i.src_file_id = edges.src_file_id AND i.kind = 'import' AND i.spec = edges.spec
|
|
137
|
+
ORDER BY i.id LIMIT 1) END)`;
|
|
138
|
+
// Candidate n lies under the import target's directory: a barrel
|
|
139
|
+
// (`libs/core/src/index.ts`) usually re-exports its own subtree, so this
|
|
140
|
+
// stands in for "reachable through the barrel's re-exports" without storing
|
|
141
|
+
// them. rtrim(p, <p without '/'>) is p's directory with its trailing slash.
|
|
142
|
+
const underTargetDir = `EXISTS (
|
|
143
|
+
SELECT 1 FROM files tf, files nf
|
|
144
|
+
WHERE tf.id = ${importTarget} AND nf.id = n.file_id
|
|
145
|
+
AND substr(nf.path, 1, length(rtrim(tf.path, replace(tf.path, '/', ''))))
|
|
146
|
+
= rtrim(tf.path, replace(tf.path, '/', '')))`;
|
|
147
|
+
db.prepare(`UPDATE edges SET dst_node_id = (
|
|
148
|
+
SELECT n.id FROM nodes n
|
|
149
|
+
WHERE n.name = edges.dst_name
|
|
150
|
+
AND n.kind <> '${FILE_NODE_KIND}'
|
|
151
|
+
AND (edges.dst_name NOT IN (${BUILTIN_SQL_LIST}) OR n.file_id = edges.src_file_id
|
|
152
|
+
OR n.file_id = ${importTarget})
|
|
153
|
+
ORDER BY CASE WHEN n.file_id = ${importTarget} THEN 0
|
|
154
|
+
WHEN edges.spec IS NOT NULL AND ${underTargetDir} THEN 1
|
|
155
|
+
WHEN n.file_id = edges.src_file_id THEN 2 ELSE 3 END, n.id
|
|
156
|
+
LIMIT 1
|
|
157
|
+
)
|
|
158
|
+
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
159
|
+
AND (is_member = 0
|
|
160
|
+
OR (is_member = 2 AND edges.spec IS NOT NULL AND ${importTarget} IS NOT NULL))
|
|
161
|
+
${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
|
|
162
|
+
const calls = resolvedCalls() - before;
|
|
163
|
+
return { calls, imports };
|
|
164
|
+
}
|
|
165
|
+
/** Project root of an index database stored at `<root>/.speclaw/index.db`. */
|
|
166
|
+
function projectRootOf(db) {
|
|
167
|
+
const row = db.prepare("SELECT file FROM pragma_database_list WHERE name = 'main'").get();
|
|
168
|
+
return path.dirname(path.dirname(row?.file ?? ""));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Point every unresolved import edge at a representative node of the imported
|
|
172
|
+
* file (its file-owner node, else its first node) so reverse reachability walks
|
|
173
|
+
* file-level dependencies, not just calls. Not narrowed by file: a newly
|
|
174
|
+
* indexed file can satisfy an import anywhere.
|
|
175
|
+
*
|
|
176
|
+
* @returns How many import edges were resolved, and the importing files' ids.
|
|
41
177
|
*/
|
|
42
178
|
function resolveImportEdges(db, projectPath) {
|
|
179
|
+
// Covers: req~import-resolution~1
|
|
43
180
|
const files = db.prepare("SELECT id, path FROM files").all();
|
|
44
181
|
const byNorm = new Map();
|
|
45
|
-
for (const f of files)
|
|
182
|
+
for (const f of files)
|
|
46
183
|
byNorm.set(f.path.split("\\").join("/"), f.id);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const namedNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? AND name = ? ORDER BY id ASC LIMIT 1");
|
|
184
|
+
const target = db.prepare(`SELECT id FROM nodes WHERE file_id = ?
|
|
185
|
+
ORDER BY kind = '${FILE_NODE_KIND}' DESC, start_line ASC, id ASC LIMIT 1`);
|
|
50
186
|
const upd = db.prepare("UPDATE edges SET dst_node_id = ? WHERE id = ?");
|
|
51
187
|
const imports = db
|
|
52
|
-
.prepare(`SELECT e.id, e.dst_name, e.src_file_id, f.path AS src_path
|
|
188
|
+
.prepare(`SELECT e.id, e.dst_name, e.spec, e.src_file_id, f.path AS src_path
|
|
53
189
|
FROM edges e JOIN files f ON f.id = e.src_file_id
|
|
54
190
|
WHERE e.kind = 'import' AND e.dst_node_id IS NULL`)
|
|
55
191
|
.all();
|
|
192
|
+
const aliases = new AliasResolver(projectPath);
|
|
193
|
+
let resolved = 0;
|
|
194
|
+
const srcFiles = new Set();
|
|
56
195
|
for (const edge of imports) {
|
|
57
|
-
const spec =
|
|
196
|
+
const spec = edge.spec ?? importSpecifier(edge.dst_name);
|
|
58
197
|
if (!spec)
|
|
59
198
|
continue;
|
|
60
|
-
const targetRel = resolveImportPath(projectPath, edge.src_path, spec
|
|
199
|
+
const targetRel = resolveImportPath(projectPath, edge.src_path, spec, byNorm, aliases);
|
|
61
200
|
if (!targetRel)
|
|
62
201
|
continue;
|
|
63
202
|
const fileId = byNorm.get(targetRel);
|
|
64
203
|
if (fileId === undefined)
|
|
65
204
|
continue;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
205
|
+
const row = target.get(fileId);
|
|
206
|
+
if (!row)
|
|
207
|
+
continue;
|
|
208
|
+
upd.run(row.id, edge.id);
|
|
209
|
+
resolved++;
|
|
210
|
+
srcFiles.add(edge.src_file_id);
|
|
211
|
+
}
|
|
212
|
+
return { resolved, srcFiles };
|
|
213
|
+
}
|
|
214
|
+
/** Repo-relative candidate files for an import target (extension and index probing). */
|
|
215
|
+
function importCandidates(projectPath, absBase) {
|
|
216
|
+
const rel = path.relative(projectPath, absBase).split(path.sep).join("/");
|
|
217
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
218
|
+
return [];
|
|
219
|
+
return [
|
|
220
|
+
rel,
|
|
221
|
+
rel.replace(/\.js$/, ".ts"),
|
|
222
|
+
rel.replace(/\.js$/, ".tsx"),
|
|
223
|
+
rel.replace(/\.jsx$/, ".tsx"),
|
|
224
|
+
rel.replace(/\.mjs$/, ".mts"),
|
|
225
|
+
rel.replace(/\.cjs$/, ".cts"),
|
|
226
|
+
`${rel}.ts`,
|
|
227
|
+
`${rel}.tsx`,
|
|
228
|
+
`${rel}.js`,
|
|
229
|
+
`${rel}.jsx`,
|
|
230
|
+
`${rel}.mjs`,
|
|
231
|
+
`${rel}.cjs`,
|
|
232
|
+
`${rel}/index.ts`,
|
|
233
|
+
`${rel}/index.tsx`,
|
|
234
|
+
`${rel}/index.js`,
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Resolve an import specifier to an indexed repo-relative path: relative
|
|
239
|
+
* specifiers against the importing file (including `.js` → `.ts`), then
|
|
240
|
+
* tsconfig/jsconfig `paths` aliases and bare `baseUrl` lookups. Bare package
|
|
241
|
+
* names that match no project file stay unresolved (null).
|
|
242
|
+
*/
|
|
243
|
+
function resolveImportPath(projectPath, srcRel, spec, indexed, aliases) {
|
|
244
|
+
const firstIndexed = (absBase) => importCandidates(projectPath, absBase).find((c) => indexed.has(c)) ?? null;
|
|
245
|
+
const srcDir = path.dirname(path.join(projectPath, srcRel));
|
|
246
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
247
|
+
return firstIndexed(path.resolve(srcDir, spec));
|
|
248
|
+
}
|
|
249
|
+
for (const absBase of aliases.candidates(srcDir, spec)) {
|
|
250
|
+
const hit = firstIndexed(absBase);
|
|
251
|
+
if (hit)
|
|
252
|
+
return hit;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Best-effort tsconfig/jsconfig alias lookup, cached per directory for one
|
|
258
|
+
* resolution pass. Never throws: an unreadable or malformed config disables
|
|
259
|
+
* alias resolution for the files under it, and relative imports still resolve.
|
|
260
|
+
*/
|
|
261
|
+
class AliasResolver {
|
|
262
|
+
root;
|
|
263
|
+
byDir = new Map();
|
|
264
|
+
constructor(root) {
|
|
265
|
+
this.root = root;
|
|
266
|
+
}
|
|
267
|
+
/** Absolute import bases to probe for a non-relative specifier, in order. */
|
|
268
|
+
candidates(fromDir, spec) {
|
|
269
|
+
const cfg = this.configFor(fromDir);
|
|
270
|
+
if (!cfg)
|
|
271
|
+
return [];
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const { pattern, targets } of cfg.paths) {
|
|
274
|
+
const star = pattern.indexOf("*");
|
|
275
|
+
let captured = null;
|
|
276
|
+
if (star < 0) {
|
|
277
|
+
if (spec === pattern)
|
|
278
|
+
captured = "";
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const pre = pattern.slice(0, star);
|
|
282
|
+
const post = pattern.slice(star + 1);
|
|
283
|
+
if (spec.startsWith(pre) &&
|
|
284
|
+
spec.endsWith(post) &&
|
|
285
|
+
spec.length >= pre.length + post.length) {
|
|
286
|
+
captured = spec.slice(pre.length, spec.length - post.length);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (captured === null)
|
|
290
|
+
continue;
|
|
291
|
+
for (const t of targets) {
|
|
292
|
+
out.push(path.resolve(cfg.baseUrl ?? cfg.pathsBase, t.replace("*", captured)));
|
|
72
293
|
}
|
|
73
294
|
}
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
295
|
+
if (cfg.baseUrl)
|
|
296
|
+
out.push(path.resolve(cfg.baseUrl, spec));
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/** Nearest tsconfig/jsconfig at or above `dir`, bounded by the project root. */
|
|
300
|
+
configFor(dir) {
|
|
301
|
+
const cached = this.byDir.get(dir);
|
|
302
|
+
if (cached !== undefined)
|
|
303
|
+
return cached;
|
|
304
|
+
let result = null;
|
|
305
|
+
const rel = path.relative(this.root, dir);
|
|
306
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
307
|
+
const own = ["tsconfig.json", "jsconfig.json"]
|
|
308
|
+
.map((n) => path.join(dir, n))
|
|
309
|
+
.find((f) => fs.existsSync(f));
|
|
310
|
+
if (own)
|
|
311
|
+
result = loadAliasConfig(own, this.root);
|
|
312
|
+
else if (dir !== this.root)
|
|
313
|
+
result = this.configFor(path.dirname(dir));
|
|
77
314
|
}
|
|
78
|
-
|
|
79
|
-
|
|
315
|
+
this.byDir.set(dir, result);
|
|
316
|
+
return result;
|
|
80
317
|
}
|
|
81
318
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Strip `//` and block comments and trailing commas from JSONC text, leaving
|
|
321
|
+
* string literals untouched.
|
|
322
|
+
*/
|
|
323
|
+
export function stripJsonComments(text) {
|
|
324
|
+
let out = "";
|
|
325
|
+
let i = 0;
|
|
326
|
+
while (i < text.length) {
|
|
327
|
+
const ch = text[i];
|
|
328
|
+
if (ch === '"') {
|
|
329
|
+
let j = i + 1;
|
|
330
|
+
while (j < text.length && text[j] !== '"')
|
|
331
|
+
j += text[j] === "\\" ? 2 : 1;
|
|
332
|
+
out += text.slice(i, j + 1);
|
|
333
|
+
i = j + 1;
|
|
334
|
+
}
|
|
335
|
+
else if (ch === "/" && text[i + 1] === "/") {
|
|
336
|
+
while (i < text.length && text[i] !== "\n")
|
|
337
|
+
i++;
|
|
338
|
+
}
|
|
339
|
+
else if (ch === "/" && text[i + 1] === "*") {
|
|
340
|
+
const close = text.indexOf("*/", i + 2);
|
|
341
|
+
i = close < 0 ? text.length : close + 2;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
out += ch;
|
|
345
|
+
i++;
|
|
98
346
|
}
|
|
99
347
|
}
|
|
100
|
-
|
|
101
|
-
if (def && !text.includes("{"))
|
|
102
|
-
names.push(def[1]);
|
|
103
|
-
return { from, names };
|
|
348
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
104
349
|
}
|
|
105
350
|
/**
|
|
106
|
-
*
|
|
351
|
+
* Read a tsconfig and its relative `extends` chain (max depth 5, cycle-safe,
|
|
352
|
+
* inside the project root). The nearest definition of `baseUrl` and of `paths`
|
|
353
|
+
* wins, each resolved against the config file that declares it.
|
|
354
|
+
*
|
|
355
|
+
* @returns The effective alias config, or null when the file cannot be parsed.
|
|
107
356
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
path.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
357
|
+
function loadAliasConfig(file, root) {
|
|
358
|
+
let baseUrl;
|
|
359
|
+
let paths;
|
|
360
|
+
let pathsBase = path.dirname(file);
|
|
361
|
+
const seen = new Set();
|
|
362
|
+
let current = file;
|
|
363
|
+
for (let depth = 0; current && depth < 6 && !seen.has(current); depth++) {
|
|
364
|
+
seen.add(current);
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = JSON.parse(stripJsonComments(fs.readFileSync(current, "utf8")));
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
if (depth === 0)
|
|
371
|
+
return null;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
const dir = path.dirname(current);
|
|
375
|
+
const co = parsed.compilerOptions ?? {};
|
|
376
|
+
if (baseUrl === undefined && typeof co.baseUrl === "string") {
|
|
377
|
+
baseUrl = path.resolve(dir, co.baseUrl);
|
|
378
|
+
}
|
|
379
|
+
if (paths === undefined && co.paths && typeof co.paths === "object") {
|
|
380
|
+
paths = co.paths;
|
|
381
|
+
pathsBase = dir;
|
|
382
|
+
}
|
|
383
|
+
current = null;
|
|
384
|
+
if (typeof parsed.extends === "string" && parsed.extends.startsWith(".")) {
|
|
385
|
+
let next = path.resolve(dir, parsed.extends);
|
|
386
|
+
if (!next.endsWith(".json"))
|
|
387
|
+
next += ".json";
|
|
388
|
+
const rel = path.relative(root, next);
|
|
389
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel) && fs.existsSync(next))
|
|
390
|
+
current = next;
|
|
391
|
+
}
|
|
131
392
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
393
|
+
const entries = Object.entries(paths ?? {})
|
|
394
|
+
.filter(([, t]) => Array.isArray(t))
|
|
395
|
+
.map(([pattern, targets]) => ({
|
|
396
|
+
pattern,
|
|
397
|
+
targets: targets.filter((t) => typeof t === "string"),
|
|
398
|
+
}))
|
|
399
|
+
.sort((a, b) => b.pattern.split("*")[0].length - a.pattern.split("*")[0].length);
|
|
400
|
+
return { baseUrl, paths: entries, pathsBase };
|
|
139
401
|
}
|
|
140
402
|
/**
|
|
141
403
|
* Infer a covering artifact's type from its project-relative path.
|
|
@@ -243,7 +505,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
243
505
|
const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash, content_hash)
|
|
244
506
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
245
507
|
const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
|
|
246
|
-
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
508
|
+
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
247
509
|
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
248
510
|
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
249
511
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
@@ -320,7 +582,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
320
582
|
else {
|
|
321
583
|
fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod, mtimeMs, size).lastInsertRowid);
|
|
322
584
|
}
|
|
323
|
-
const { symbols, refs, coverage } = await extract(content, lang);
|
|
585
|
+
const { symbols, refs, coverage, reexports } = await extract(content, lang);
|
|
324
586
|
const nodeIds = [];
|
|
325
587
|
const now = Date.now();
|
|
326
588
|
const touchCache = db.prepare(`UPDATE embedding_cache SET last_seen_at = ? WHERE content_hash = ? AND model = ?`);
|
|
@@ -350,12 +612,21 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
350
612
|
}
|
|
351
613
|
stats.embeddings++;
|
|
352
614
|
}
|
|
353
|
-
|
|
615
|
+
// Covers: req~impact-id-first~1
|
|
616
|
+
// Imports and calls outside any definition belong to the file: give the
|
|
617
|
+
// file one hidden owner node so no edge is ever stored ownerless. A file
|
|
618
|
+
// with no symbols or with re-exports (a barrel: `export * from "./x"`)
|
|
619
|
+
// gets one too, so an import of it resolves to the file instead of
|
|
620
|
+
// looking like a package. It gets no text, embedding, or metrics.
|
|
621
|
+
const isOrphan = (r) => r.kind === "import" || r.ownerIndex === null;
|
|
622
|
+
let fileOwner = null;
|
|
623
|
+
if (refs.some(isOrphan) || symbols.length === 0 || reexports.length > 0) {
|
|
624
|
+
const lastLine = content.length === 0 ? 1 : content.replace(/\n$/, "").split("\n").length;
|
|
625
|
+
fileOwner = Number(insNode.run(fileId, rel, FILE_NODE_KIND, 1, lastLine, 0, content.length, null, null, null, null, null).lastInsertRowid);
|
|
626
|
+
}
|
|
354
627
|
for (const r of refs) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
srcId = fileOwner;
|
|
358
|
-
insEdge.run(srcId, fileId, r.name, r.kind, r.line);
|
|
628
|
+
const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
|
|
629
|
+
insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
|
|
359
630
|
stats.edges++;
|
|
360
631
|
}
|
|
361
632
|
const sourceType = inferSourceType(rel);
|
|
@@ -393,16 +664,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
393
664
|
const nFiles = [...fileHashes.keys()].filter((f) => dir === "" ? true : f === dir || f.startsWith(dir + "/")).length;
|
|
394
665
|
insDir.run(dir, hash, nFiles, now);
|
|
395
666
|
}
|
|
396
|
-
db
|
|
397
|
-
UPDATE edges SET dst_node_id = (
|
|
398
|
-
SELECT n.id FROM nodes n
|
|
399
|
-
WHERE n.name = edges.dst_name
|
|
400
|
-
ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
|
|
401
|
-
LIMIT 1
|
|
402
|
-
)
|
|
403
|
-
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
404
|
-
`);
|
|
405
|
-
resolveImportEdges(db, projectPath);
|
|
667
|
+
resolveEdges(db);
|
|
406
668
|
recomputeGlobalPagerank(db);
|
|
407
669
|
// Touch last_seen for all live content hashes under active model
|
|
408
670
|
db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
|
|
@@ -446,7 +708,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
446
708
|
}
|
|
447
709
|
function countTotals(db) {
|
|
448
710
|
const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
449
|
-
|
|
711
|
+
const nodes = Number(db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().n);
|
|
712
|
+
return { files: count("files"), nodes, edges: count("edges") };
|
|
450
713
|
}
|
|
451
714
|
/** Next-step hint printed after an index run; names the query tools, not grep. */
|
|
452
715
|
export function indexNextStep(totals) {
|
|
@@ -482,7 +745,11 @@ function evictCacheBySize(db, maxCacheMB) {
|
|
|
482
745
|
*/
|
|
483
746
|
export function recomputeGlobalPagerank(db) {
|
|
484
747
|
const files = db.prepare("SELECT id, path FROM files").all();
|
|
485
|
-
|
|
748
|
+
// File-owner nodes are not ranked symbols; their outgoing calls count as
|
|
749
|
+
// references from their file in the file–symbol graph.
|
|
750
|
+
const nodes = db
|
|
751
|
+
.prepare(`SELECT id, name, file_id FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`)
|
|
752
|
+
.all();
|
|
486
753
|
if (nodes.length === 0) {
|
|
487
754
|
db.exec("DELETE FROM pagerank");
|
|
488
755
|
return;
|
|
@@ -499,10 +766,12 @@ export function recomputeGlobalPagerank(db) {
|
|
|
499
766
|
defCount.set(n.name, (defCount.get(n.name) ?? 0) + 1);
|
|
500
767
|
const refCount = new Map();
|
|
501
768
|
const callEdges = db
|
|
502
|
-
.prepare(`SELECT e.src_node_id, e.dst_node_id, e.dst_name,
|
|
769
|
+
.prepare(`SELECT e.src_node_id, e.src_file_id, src.kind AS src_kind, e.dst_node_id, e.dst_name,
|
|
770
|
+
f.path AS src_path
|
|
503
771
|
FROM edges e
|
|
504
772
|
JOIN files f ON f.id = e.src_file_id
|
|
505
|
-
|
|
773
|
+
JOIN nodes src ON src.id = e.src_node_id
|
|
774
|
+
WHERE e.kind = 'call'`)
|
|
506
775
|
.all();
|
|
507
776
|
for (const e of callEdges) {
|
|
508
777
|
refCount.set(e.dst_name, (refCount.get(e.dst_name) ?? 0) + 1);
|
|
@@ -521,7 +790,8 @@ export function recomputeGlobalPagerank(db) {
|
|
|
521
790
|
if (e.dst_node_id == null)
|
|
522
791
|
continue;
|
|
523
792
|
const w = edgeWeightMul(e.dst_name, e.src_path, ctx);
|
|
524
|
-
|
|
793
|
+
const from = e.src_kind === FILE_NODE_KIND ? fileNodeId(e.src_file_id) : e.src_node_id;
|
|
794
|
+
edges.push({ from, to: e.dst_node_id, weight: w });
|
|
525
795
|
}
|
|
526
796
|
const scores = personalizedPageRank(nodeIds, edges, []);
|
|
527
797
|
db.exec("DELETE FROM pagerank");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb } from "./db.js";
|
|
3
|
+
import { openDb, FILE_NODE_KIND } from "./db.js";
|
|
4
4
|
import { estimateTokens } from "../../shared/tokens.js";
|
|
5
5
|
import { loadDeclaredBudget } from "../../shared/exposure.js";
|
|
6
6
|
export const MAP_START = "<!-- speclaw:map:start -->";
|
|
@@ -19,7 +19,7 @@ export function generateCompactMap(projectPath) {
|
|
|
19
19
|
const db = openDb(projectPath);
|
|
20
20
|
try {
|
|
21
21
|
const fileCount = db.prepare("SELECT COUNT(*) AS c FROM files").get().c;
|
|
22
|
-
const nodeCount = db.prepare(
|
|
22
|
+
const nodeCount = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().c;
|
|
23
23
|
if (fileCount === 0)
|
|
24
24
|
return null;
|
|
25
25
|
const hubs = db
|