@esneiderbravo/speclaw 2.0.8 → 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 +38 -9
- package/dist/cli/commands/index-build.js +2 -15
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/lawbook.js +2 -0
- package/dist/cli/commands/laws.js +149 -19
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/update.js +110 -24
- package/dist/cli/index.js +24 -66
- package/dist/cli/lib/help.js +489 -0
- package/dist/cli/lib/self-update.js +107 -0
- package/dist/cli/lib/update-check.js +17 -10
- 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/cortex/brief.js +3 -1
- package/dist/modules/cortex/harness.js +51 -5
- package/dist/modules/cortex/paths.js +39 -0
- package/dist/modules/cortex/status.js +4 -1
- package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
- package/dist/modules/foundation/compile-laws.js +23 -6
- package/dist/modules/foundation/doctor.js +11 -1
- package/dist/modules/foundation/lock.js +224 -20
- package/dist/modules/foundation/scaffold.js +24 -8
- package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/engine.js +24 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/agents.js +56 -7
- package/dist/shared/output-budget.js +2 -0
- package/package.json +1 -1
|
@@ -48,6 +48,13 @@ CREATE TABLE IF NOT EXISTS node_metrics (
|
|
|
48
48
|
branches INTEGER NOT NULL
|
|
49
49
|
);
|
|
50
50
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
51
|
+
-- src_node_id is never NULL for an edge indexed under schema 11: a reference
|
|
52
|
+
-- outside any definition is owned by its file's file-owner node (kind 'file').
|
|
53
|
+
-- is_member = 1 marks a member call on a foreign receiver (never bound by name);
|
|
54
|
+
-- 2 a member call on an import binding, bound only once the import with the
|
|
55
|
+
-- same spec in the same file resolves to a project file (a package otherwise).
|
|
56
|
+
-- spec: JS/TS module specifier of an import edge, or of the import binding a
|
|
57
|
+
-- call's receiver/callee comes from.
|
|
51
58
|
CREATE TABLE IF NOT EXISTS edges (
|
|
52
59
|
id INTEGER PRIMARY KEY,
|
|
53
60
|
src_node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
|
|
@@ -55,9 +62,12 @@ CREATE TABLE IF NOT EXISTS edges (
|
|
|
55
62
|
dst_name TEXT NOT NULL,
|
|
56
63
|
dst_node_id INTEGER,
|
|
57
64
|
kind TEXT NOT NULL,
|
|
58
|
-
line INTEGER NOT NULL
|
|
65
|
+
line INTEGER NOT NULL,
|
|
66
|
+
is_member INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
spec TEXT
|
|
59
68
|
);
|
|
60
69
|
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst_name);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_edges_srcfile ON edges(src_file_id, kind);
|
|
61
71
|
CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src_node_id);
|
|
62
72
|
CREATE INDEX IF NOT EXISTS idx_edges_dstid ON edges(dst_node_id);
|
|
63
73
|
-- embedding_cache: vectors keyed by embedder-input content hash (survives reindex).
|
|
@@ -224,7 +234,13 @@ export function probeFts5Support() {
|
|
|
224
234
|
}
|
|
225
235
|
}
|
|
226
236
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
227
|
-
export const SCHEMA_VERSION = "
|
|
237
|
+
export const SCHEMA_VERSION = "11";
|
|
238
|
+
/**
|
|
239
|
+
* `nodes.kind` of the synthetic per-file owner node. It owns the references that
|
|
240
|
+
* sit outside any definition (top-level imports, calls in callbacks) and is
|
|
241
|
+
* hidden from find, name lookup, PageRank, FTS, embeddings, and metrics.
|
|
242
|
+
*/
|
|
243
|
+
export const FILE_NODE_KIND = "file";
|
|
228
244
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
229
245
|
function readSchemaVersion(db) {
|
|
230
246
|
try {
|
|
@@ -237,7 +253,7 @@ function readSchemaVersion(db) {
|
|
|
237
253
|
}
|
|
238
254
|
/**
|
|
239
255
|
* Decide whether an existing database is from an incompatible schema and must be
|
|
240
|
-
* rebuilt. Schema 8→9
|
|
256
|
+
* rebuilt. Schema 8→9, 9→10, and 10→11 are handled by migrators instead of a wipe.
|
|
241
257
|
*/
|
|
242
258
|
function isStale(db) {
|
|
243
259
|
const hasEdges = db
|
|
@@ -246,13 +262,19 @@ function isStale(db) {
|
|
|
246
262
|
if (!hasEdges)
|
|
247
263
|
return false;
|
|
248
264
|
const ver = readSchemaVersion(db);
|
|
249
|
-
if (ver === "8" || ver === "9")
|
|
265
|
+
if (ver === "8" || ver === "9" || ver === "10")
|
|
250
266
|
return false; // migrate in openDb
|
|
267
|
+
if (ver === "11" && !hasEdgeSpec(db))
|
|
268
|
+
return false; // pre-release 11: migrate in openDb
|
|
251
269
|
if (ver !== SCHEMA_VERSION)
|
|
252
270
|
return true;
|
|
253
271
|
const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
254
|
-
if (!edgeCols.includes("src_node_id") ||
|
|
272
|
+
if (!edgeCols.includes("src_node_id") ||
|
|
273
|
+
!edgeCols.includes("dst_node_id") ||
|
|
274
|
+
!edgeCols.includes("is_member") ||
|
|
275
|
+
!edgeCols.includes("spec")) {
|
|
255
276
|
return true;
|
|
277
|
+
}
|
|
256
278
|
const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
|
|
257
279
|
if (!fileCols.includes("is_test") || !fileCols.includes("module"))
|
|
258
280
|
return true;
|
|
@@ -402,15 +424,62 @@ export function migrate9to10(db) {
|
|
|
402
424
|
throw new Error(`schema 9→10 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
|
|
403
425
|
}
|
|
404
426
|
}
|
|
427
|
+
/** Whether `edges` already has the schema-11 `spec` column (or does not exist yet). */
|
|
428
|
+
function hasEdgeSpec(db) {
|
|
429
|
+
const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
430
|
+
return cols.length === 0 || cols.includes("spec");
|
|
431
|
+
}
|
|
432
|
+
/** Reason recorded with the needs-reindex marker by the 10→11 migration. */
|
|
433
|
+
export const SCHEMA_11_REINDEX_REASON = "schema 11 adds file-owner nodes, member-call flags and full import text; reindex required";
|
|
434
|
+
/**
|
|
435
|
+
* Migrate schema 10 → 11: add `edges.is_member` and `edges.spec` (each only
|
|
436
|
+
* when missing, so a pre-release schema-11 index lacking `spec` takes the same
|
|
437
|
+
* path), set the needs-reindex marker, and stamp `"11"`, all in one
|
|
438
|
+
* `BEGIN IMMEDIATE` transaction. The next index
|
|
439
|
+
* run re-extracts every file (edge ownership and import text change), while
|
|
440
|
+
* `embedding_cache` is untouched so unchanged symbols reuse their vectors.
|
|
441
|
+
*
|
|
442
|
+
* @param db - Open connection already at schema 10.
|
|
443
|
+
* @throws If any step fails; the transaction is rolled back and the stamp stays `"10"`.
|
|
444
|
+
*/
|
|
445
|
+
export function migrate10to11(db) {
|
|
446
|
+
// Covers: req~schema-edge-membership~1
|
|
447
|
+
db.exec("BEGIN IMMEDIATE");
|
|
448
|
+
try {
|
|
449
|
+
const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
450
|
+
// No edges table yet (a partial older index): the schema DDL creates it with the column.
|
|
451
|
+
if (cols.length > 0 && !cols.includes("is_member")) {
|
|
452
|
+
db.exec("ALTER TABLE edges ADD COLUMN is_member INTEGER NOT NULL DEFAULT 0");
|
|
453
|
+
}
|
|
454
|
+
if (cols.length > 0 && !cols.includes("spec")) {
|
|
455
|
+
db.exec("ALTER TABLE edges ADD COLUMN spec TEXT");
|
|
456
|
+
}
|
|
457
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
|
|
458
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('reindex_reason', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SCHEMA_11_REINDEX_REASON);
|
|
459
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("11");
|
|
460
|
+
db.exec("COMMIT");
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
try {
|
|
464
|
+
db.exec("ROLLBACK");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
/* already rolled back */
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`schema 10→11 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
405
472
|
/**
|
|
406
473
|
* Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
|
|
407
474
|
*
|
|
408
475
|
* Ensures the `.speclaw` directory exists, enables WAL journaling and foreign
|
|
409
|
-
* keys, and applies the schema. Schema 8→9
|
|
410
|
-
* (embeddings preserved). Other incompatible schemas are wiped
|
|
476
|
+
* keys, and applies the schema. Schema 8→9, 9→10, and 10→11 migrate in place
|
|
477
|
+
* and chain forward (embeddings preserved). Other incompatible schemas are wiped
|
|
478
|
+
* and rebuilt.
|
|
411
479
|
*
|
|
412
480
|
* @param projectPath - Absolute path to the project root.
|
|
413
481
|
* @returns An open connection to the index database.
|
|
482
|
+
* @throws If an in-place migration fails (the connection is closed first).
|
|
414
483
|
*/
|
|
415
484
|
export function openDb(projectPath) {
|
|
416
485
|
const dir = path.join(projectPath, ".speclaw");
|
|
@@ -425,15 +494,18 @@ export function openDb(projectPath) {
|
|
|
425
494
|
return null;
|
|
426
495
|
}
|
|
427
496
|
})();
|
|
428
|
-
if (ver === "8") {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
497
|
+
if (ver === "8" || ver === "9" || ver === "10" || (ver === "11" && !hasEdgeSpec(db))) {
|
|
498
|
+
try {
|
|
499
|
+
if (ver === "8")
|
|
500
|
+
migrate8to9(db, projectPath);
|
|
501
|
+
if (ver === "8" || ver === "9")
|
|
502
|
+
migrate9to10(db);
|
|
503
|
+
migrate10to11(db);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
db.close();
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
437
509
|
db.exec(SCHEMA);
|
|
438
510
|
ensureEmbeddingsView(db);
|
|
439
511
|
ensureFts(db);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isGitRepo, changedFiles, worktreeChangedFiles } from "../../shared/git.js";
|
|
2
|
-
import { openDb, indexExists } from "./db.js";
|
|
2
|
+
import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
|
|
3
3
|
import { impact } from "./query.js";
|
|
4
4
|
import { affectedTests } from "./affected.js";
|
|
5
5
|
import { hotspots } from "./hotspots.js";
|
|
@@ -26,7 +26,7 @@ function symbolsForFiles(projectPath, files, overestimate) {
|
|
|
26
26
|
const nodes = db
|
|
27
27
|
.prepare(`SELECT n.name, n.kind, n.start_line AS line, f.path AS file
|
|
28
28
|
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
29
|
-
WHERE f.path =
|
|
29
|
+
WHERE f.path = ? AND n.kind <> '${FILE_NODE_KIND}'`)
|
|
30
30
|
.all(file);
|
|
31
31
|
if (overestimate || nodes.length === 0) {
|
|
32
32
|
for (const n of nodes)
|
|
@@ -100,6 +100,8 @@ export function diffContext(query) {
|
|
|
100
100
|
result.affectedTests = {
|
|
101
101
|
tests: at.tests,
|
|
102
102
|
command: at.command,
|
|
103
|
+
commandReason: at.commandReason,
|
|
104
|
+
commands: at.commands,
|
|
103
105
|
mode: at.mode,
|
|
104
106
|
reason: at.reason,
|
|
105
107
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { explore, impact, trace } from "./query.js";
|
|
2
2
|
import { affectedTests } from "./affected.js";
|
|
3
|
+
import { FILE_NODE_KIND } from "./db.js";
|
|
3
4
|
import { hotspots } from "./hotspots.js";
|
|
4
5
|
import { summarizeImpact } from "./impact-summary.js";
|
|
5
6
|
import { budgetExploreShape, applyTextBudget, } from "../../shared/output-budget.js";
|
|
@@ -41,8 +42,10 @@ export async function exploreRich(query) {
|
|
|
41
42
|
out.symbol = withoutSource(out.symbol);
|
|
42
43
|
if (!includes.includes("callers"))
|
|
43
44
|
out.callers = [];
|
|
44
|
-
if (!includes.includes("callees"))
|
|
45
|
+
if (!includes.includes("callees")) {
|
|
45
46
|
out.callees = [];
|
|
47
|
+
delete out.unresolvedCallees;
|
|
48
|
+
}
|
|
46
49
|
budgetExploreShape(out, mode, truncated);
|
|
47
50
|
return out;
|
|
48
51
|
}
|
|
@@ -52,15 +55,17 @@ export async function exploreRich(query) {
|
|
|
52
55
|
out.symbol = withoutSource(out.symbol);
|
|
53
56
|
if (!includes.includes("callers"))
|
|
54
57
|
out.callers = [];
|
|
55
|
-
if (!includes.includes("callees"))
|
|
58
|
+
if (!includes.includes("callees")) {
|
|
56
59
|
out.callees = [];
|
|
60
|
+
delete out.unresolvedCallees;
|
|
61
|
+
}
|
|
57
62
|
if (base.found && base.symbol) {
|
|
58
63
|
const sym = base.symbol.name;
|
|
59
64
|
const file = base.symbol.file;
|
|
60
65
|
if (includes.includes("blast_radius")) {
|
|
61
66
|
try {
|
|
62
67
|
const imp = impact(query.projectPath, {
|
|
63
|
-
symbol: sym,
|
|
68
|
+
...(base.symbol.kind === FILE_NODE_KIND ? { files: [file] } : { symbol: sym }),
|
|
64
69
|
maxDepth: query.maxDepth ?? 4,
|
|
65
70
|
format: "grouped",
|
|
66
71
|
});
|
|
@@ -72,14 +77,16 @@ export async function exploreRich(query) {
|
|
|
72
77
|
}
|
|
73
78
|
if (includes.includes("tests")) {
|
|
74
79
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
maxDepth: query.maxDepth ?? 6
|
|
78
|
-
|
|
80
|
+
// A file-owner node is named by its path: select by file, not by name.
|
|
81
|
+
const at = affectedTests(query.projectPath, base.symbol.kind === FILE_NODE_KIND
|
|
82
|
+
? { files: [file], maxDepth: query.maxDepth ?? 6 }
|
|
83
|
+
: { symbols: [sym], maxDepth: query.maxDepth ?? 6 });
|
|
79
84
|
out.affectedTests = {
|
|
80
85
|
count: at.tests.length,
|
|
81
86
|
files: at.tests.map((t) => t.file),
|
|
82
87
|
command: at.command,
|
|
88
|
+
commandReason: at.commandReason,
|
|
89
|
+
commands: at.commands,
|
|
83
90
|
};
|
|
84
91
|
}
|
|
85
92
|
catch {
|
|
@@ -34,6 +34,115 @@ function calleeName(node, lang) {
|
|
|
34
34
|
return fn.text;
|
|
35
35
|
return fn.text.split(/[.\s(]/)[0] || null;
|
|
36
36
|
}
|
|
37
|
+
/** Python receivers that mean "this class/instance". */
|
|
38
|
+
const PY_SELF = new Set(["self", "cls"]);
|
|
39
|
+
/**
|
|
40
|
+
* The receiver of a member call (`recv.f()`), or null for a plain call.
|
|
41
|
+
* `kind` is `self` for `this`/`super`/`self`/`cls`, `ident` for a bare
|
|
42
|
+
* identifier (classified later against the file's import bindings), and
|
|
43
|
+
* `other` for any chain, subscript, or call result.
|
|
44
|
+
*/
|
|
45
|
+
function callReceiver(node, lang) {
|
|
46
|
+
const fn = node.childForFieldName(lang.callField);
|
|
47
|
+
if (!fn)
|
|
48
|
+
return null;
|
|
49
|
+
if (fn.type === "identifier" || fn.type === "super")
|
|
50
|
+
return null;
|
|
51
|
+
if (fn.type !== "member_expression" && fn.type !== "attribute") {
|
|
52
|
+
return { kind: "other", text: fn.text };
|
|
53
|
+
}
|
|
54
|
+
const obj = fn.childForFieldName("object");
|
|
55
|
+
if (!obj)
|
|
56
|
+
return { kind: "other", text: fn.text };
|
|
57
|
+
if (obj.type === "this" || obj.type === "super")
|
|
58
|
+
return { kind: "self", text: obj.text };
|
|
59
|
+
if (obj.type === "identifier") {
|
|
60
|
+
// Python's `self`/`cls` are conventions, not keywords; JS `self` is a global.
|
|
61
|
+
if (lang.id === "python" && PY_SELF.has(obj.text))
|
|
62
|
+
return { kind: "self", text: obj.text };
|
|
63
|
+
return { kind: "ident", text: obj.text };
|
|
64
|
+
}
|
|
65
|
+
return { kind: "other", text: obj.text };
|
|
66
|
+
}
|
|
67
|
+
/** Longest import text stored on an edge (`dst_name`). */
|
|
68
|
+
export const IMPORT_TEXT_CAP = 1024;
|
|
69
|
+
/**
|
|
70
|
+
* Module specifier of a raw import statement (`from "x"`, `import "x"`,
|
|
71
|
+
* `require("x")`, `import("x")`), or null when the text has none.
|
|
72
|
+
*
|
|
73
|
+
* @param text - Import statement text, whitespace collapsed.
|
|
74
|
+
*/
|
|
75
|
+
export function importSpecifier(text) {
|
|
76
|
+
const m = text.match(/\bfrom\s+['"]([^'"]+)['"]/) ??
|
|
77
|
+
text.match(/^import\s+['"]([^'"]+)['"]/) ??
|
|
78
|
+
text.match(/\brequire\s*\(\s*['"]([^'"]+)['"]/) ??
|
|
79
|
+
text.match(/\bimport\s*\(\s*['"]([^'"]+)['"]/);
|
|
80
|
+
return m ? m[1] : null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Import text as stored on its edge: unchanged up to {@link IMPORT_TEXT_CAP}
|
|
84
|
+
* characters, else the head of the statement, ` … `, and its trailing `from`
|
|
85
|
+
* clause, so the specifier at the end survives the cap. Bindings and the
|
|
86
|
+
* specifier are read from the full text before capping; this only bounds what
|
|
87
|
+
* is stored.
|
|
88
|
+
*
|
|
89
|
+
* @param text - Import statement text, whitespace collapsed.
|
|
90
|
+
*/
|
|
91
|
+
export function capImportText(text) {
|
|
92
|
+
if (text.length <= IMPORT_TEXT_CAP)
|
|
93
|
+
return text;
|
|
94
|
+
const tail = text.match(/\s(from\s+['"][^'"]+['"].*)$/)?.[1] ??
|
|
95
|
+
text.match(/((?:require|import)\s*\(\s*['"][^'"]+['"].*)$/)?.[1];
|
|
96
|
+
if (!tail || tail.length > IMPORT_TEXT_CAP / 2)
|
|
97
|
+
return text.slice(0, IMPORT_TEXT_CAP);
|
|
98
|
+
return `${text.slice(0, IMPORT_TEXT_CAP - tail.length - 3)} … ${tail}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Local names an import statement binds: default, namespace (`* as ns`), and
|
|
102
|
+
* named (`{ a, b as c }` → `a`, `c`) for JS/TS; `import a.b` → `a`,
|
|
103
|
+
* `import x as y` → `y`, `from m import a, b as c` → `a`, `c` for Python.
|
|
104
|
+
*
|
|
105
|
+
* @param text - Import statement text, whitespace collapsed.
|
|
106
|
+
* @param langId - Language id of the file.
|
|
107
|
+
*/
|
|
108
|
+
export function importBindings(text, langId) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const ident = /^[A-Za-z_$][\w$]*$/;
|
|
111
|
+
const localOf = (part) => {
|
|
112
|
+
const bits = part.trim().split(/\s+as\s+/);
|
|
113
|
+
const name = (bits[1] ?? bits[0] ?? "").trim().replace(/^type\s+/, "");
|
|
114
|
+
return ident.test(name) ? name : null;
|
|
115
|
+
};
|
|
116
|
+
if (langId === "python") {
|
|
117
|
+
const from = text.match(/^from\s+\S+\s+import\s+(.+)$/);
|
|
118
|
+
const list = from ? from[1] : (text.match(/^import\s+(.+)$/)?.[1] ?? "");
|
|
119
|
+
for (const raw of list.replace(/[()]/g, "").split(",")) {
|
|
120
|
+
const bits = raw.trim().split(/\s+as\s+/);
|
|
121
|
+
const name = bits[1] ?? (from ? bits[0] : bits[0]?.split(".")[0]);
|
|
122
|
+
if (name && ident.test(name.trim()))
|
|
123
|
+
out.push(name.trim());
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
const clause = text.match(/^import\s+(?:type\s+)?(.+?)\s+from\s/)?.[1];
|
|
128
|
+
if (!clause)
|
|
129
|
+
return out;
|
|
130
|
+
const ns = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
131
|
+
if (ns)
|
|
132
|
+
out.push(ns[1]);
|
|
133
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
134
|
+
if (brace) {
|
|
135
|
+
for (const part of brace[1].split(",")) {
|
|
136
|
+
const name = localOf(part);
|
|
137
|
+
if (name)
|
|
138
|
+
out.push(name);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const def = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/);
|
|
142
|
+
if (def)
|
|
143
|
+
out.push(def[1]);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
37
146
|
/** First line of the node's text, trimmed — a lightweight signature. */
|
|
38
147
|
function signatureOf(node) {
|
|
39
148
|
return node.text.split("\n")[0].trim().slice(0, 200);
|
|
@@ -223,6 +332,17 @@ export async function extract(source, lang) {
|
|
|
223
332
|
const symbols = [];
|
|
224
333
|
const refs = [];
|
|
225
334
|
const rawCoverage = [];
|
|
335
|
+
// Identifier receivers and bare callees are classified after the walk, once
|
|
336
|
+
// every import binding of the file is known (imports may follow their first
|
|
337
|
+
// use). Whether a binding is a package is not decided here: that needs the
|
|
338
|
+
// project's alias/baseUrl config, so resolveEdges decides it from whether the
|
|
339
|
+
// binding's import (matched by `spec`) resolved to a project file.
|
|
340
|
+
const identReceivers = [];
|
|
341
|
+
const bareCalls = [];
|
|
342
|
+
const bindings = new Set();
|
|
343
|
+
// JS/TS import binding → its import's module specifier.
|
|
344
|
+
const bindingSpecs = new Map();
|
|
345
|
+
const reexports = [];
|
|
226
346
|
const walk = (node, ownerIndex) => {
|
|
227
347
|
let nextOwner = ownerIndex;
|
|
228
348
|
if (kinds.has(node.type)) {
|
|
@@ -252,20 +372,52 @@ export async function extract(source, lang) {
|
|
|
252
372
|
}
|
|
253
373
|
else if (node.type === lang.callNode) {
|
|
254
374
|
const name = calleeName(node, lang);
|
|
255
|
-
if (name)
|
|
256
|
-
|
|
375
|
+
if (name) {
|
|
376
|
+
const receiver = callReceiver(node, lang);
|
|
377
|
+
const ref = {
|
|
378
|
+
name,
|
|
379
|
+
kind: "call",
|
|
380
|
+
line: node.startPosition.row + 1,
|
|
381
|
+
ownerIndex,
|
|
382
|
+
member: receiver !== null && receiver.kind === "other" ? 1 : 0,
|
|
383
|
+
spec: null,
|
|
384
|
+
};
|
|
385
|
+
if (receiver?.kind === "ident")
|
|
386
|
+
identReceivers.push({ ref, receiver: receiver.text });
|
|
387
|
+
else if (receiver === null)
|
|
388
|
+
bareCalls.push(ref);
|
|
389
|
+
refs.push(ref);
|
|
390
|
+
}
|
|
257
391
|
}
|
|
258
392
|
else if (importSet.has(node.type)) {
|
|
393
|
+
// Covers: req~import-resolution~1
|
|
394
|
+
// The whole statement, so a specifier on a later line is still parseable.
|
|
395
|
+
// Bindings and the specifier come from the full text; only storage is capped.
|
|
396
|
+
const text = node.text.replace(/\s+/g, " ").trim();
|
|
397
|
+
const bound = importBindings(text, lang.id);
|
|
398
|
+
const spec = lang.id === "python" ? null : importSpecifier(text);
|
|
399
|
+
for (const b of bound) {
|
|
400
|
+
bindings.add(b);
|
|
401
|
+
if (spec !== null)
|
|
402
|
+
bindingSpecs.set(b, spec);
|
|
403
|
+
}
|
|
259
404
|
refs.push({
|
|
260
|
-
name:
|
|
405
|
+
name: capImportText(text),
|
|
261
406
|
kind: "import",
|
|
262
407
|
line: node.startPosition.row + 1,
|
|
263
408
|
ownerIndex,
|
|
409
|
+
member: 0,
|
|
410
|
+
spec,
|
|
264
411
|
});
|
|
265
412
|
}
|
|
266
413
|
else if (COMMENT_TYPES.has(node.type)) {
|
|
267
414
|
rawCoverage.push(...parseCoverageComment(node, ownerIndex));
|
|
268
415
|
}
|
|
416
|
+
else if (node.type === "export_statement" && lang.id !== "python") {
|
|
417
|
+
const source = node.childForFieldName("source");
|
|
418
|
+
if (source && source.text.length >= 2)
|
|
419
|
+
reexports.push(source.text.slice(1, -1));
|
|
420
|
+
}
|
|
269
421
|
for (let i = 0; i < node.childCount; i++) {
|
|
270
422
|
const child = node.child(i);
|
|
271
423
|
if (child)
|
|
@@ -274,5 +426,12 @@ export async function extract(source, lang) {
|
|
|
274
426
|
};
|
|
275
427
|
walk(tree.rootNode, null);
|
|
276
428
|
tree.delete();
|
|
277
|
-
|
|
429
|
+
for (const { ref, receiver } of identReceivers) {
|
|
430
|
+
const spec = bindingSpecs.get(receiver) ?? null;
|
|
431
|
+
ref.member = !bindings.has(receiver) ? 1 : spec !== null ? 2 : 0;
|
|
432
|
+
ref.spec = spec;
|
|
433
|
+
}
|
|
434
|
+
for (const ref of bareCalls)
|
|
435
|
+
ref.spec = bindingSpecs.get(ref.name) ?? null;
|
|
436
|
+
return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols), reexports };
|
|
278
437
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { openDb } from "./db.js";
|
|
1
|
+
import { openDb, FILE_NODE_KIND } from "./db.js";
|
|
2
|
+
import { BUILTIN_SQL_LIST } from "./indexer.js";
|
|
2
3
|
import { cachedCoChanges, cachedFileActivity } from "./git-history-cache.js";
|
|
3
4
|
import { jaccardStrength } from "../../shared/git-history.js";
|
|
4
5
|
/** Default history window for hotspot / coupling ranking. */
|
|
@@ -31,7 +32,7 @@ function loadFileHealth(projectPath) {
|
|
|
31
32
|
COALESCE(MAX(m.max_nesting), 0) AS worst_nesting,
|
|
32
33
|
COALESCE(MAX(m.branches), 0) AS worst_branches
|
|
33
34
|
FROM files f
|
|
34
|
-
LEFT JOIN nodes n ON n.file_id = f.id
|
|
35
|
+
LEFT JOIN nodes n ON n.file_id = f.id AND n.kind <> '${FILE_NODE_KIND}'
|
|
35
36
|
LEFT JOIN node_metrics m ON m.node_id = n.id
|
|
36
37
|
GROUP BY f.id`)
|
|
37
38
|
.all();
|
|
@@ -148,17 +149,21 @@ function pairInGraph(projectPath, a, b) {
|
|
|
148
149
|
.get(a, b, b, a);
|
|
149
150
|
if (row)
|
|
150
151
|
return true;
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
152
|
+
// Fallback: an unresolved edge from a whose dst_name names a symbol defined
|
|
153
|
+
// in b (or reverse). Same rule as the query-time by-name arms: member calls
|
|
154
|
+
// (is_member 1 = foreign receiver, 2 = package receiver) never match by
|
|
155
|
+
// name, and a builtin global name only matches within its own file, which
|
|
156
|
+
// a cross-file pair never is.
|
|
154
157
|
const byName = db
|
|
155
158
|
.prepare(`SELECT 1 AS ok
|
|
156
159
|
FROM edges e
|
|
157
160
|
JOIN files sf ON sf.id = e.src_file_id
|
|
158
|
-
JOIN nodes dn ON dn.name = e.dst_name
|
|
161
|
+
JOIN nodes dn ON dn.name = e.dst_name AND dn.kind <> '${FILE_NODE_KIND}'
|
|
159
162
|
JOIN files df ON df.id = dn.file_id
|
|
160
163
|
WHERE e.dst_node_id IS NULL
|
|
161
164
|
AND e.kind IN ('call', 'import')
|
|
165
|
+
AND e.is_member = 0
|
|
166
|
+
AND e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
162
167
|
AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
|
|
163
168
|
LIMIT 1`)
|
|
164
169
|
.get(a, b, b, a);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Hybrid retrieval pipeline: BM25 + KNN + name → RRF → ego expand →
|
|
3
3
|
* personalized PageRank → structural rerank → token budget.
|
|
4
4
|
*/
|
|
5
|
-
import { openDb, indexExists, ftsAvailable } from "./db.js";
|
|
5
|
+
import { openDb, indexExists, ftsAvailable, FILE_NODE_KIND } from "./db.js";
|
|
6
6
|
import { getEmbedder, fromBlob, cosine } from "./embedder.js";
|
|
7
7
|
import { isGitRepo, worktreeChangedFiles } from "../../shared/git.js";
|
|
8
8
|
import { escapeFtsQuery, nameBoost, routeWeights, rrfFuse, structuralScore, MAX_DEGREE, isSymbolQuery, } from "./rank.js";
|
|
@@ -113,7 +113,7 @@ export async function hybridSearch(projectPath, query, opts = {}) {
|
|
|
113
113
|
const likeRows = db
|
|
114
114
|
.prepare(`SELECT n.id
|
|
115
115
|
FROM nodes n
|
|
116
|
-
WHERE n.name LIKE ?
|
|
116
|
+
WHERE n.name LIKE ? AND n.kind <> '${FILE_NODE_KIND}'
|
|
117
117
|
ORDER BY (n.name = ?) DESC, length(n.name) ASC
|
|
118
118
|
LIMIT 20`)
|
|
119
119
|
.all(`%${q}%`, q);
|
|
@@ -125,8 +125,11 @@ export async function hybridSearch(projectPath, query, opts = {}) {
|
|
|
125
125
|
const fused = rrfFuse({ bm25: bm25Ids, knn: knnIds, name: nameIds }, { bm25: weights.bm25, knn: weights.knn, name: weights.name });
|
|
126
126
|
// Apply name boost using node names.
|
|
127
127
|
const idMeta = new Map();
|
|
128
|
+
// File-owner nodes have no metadata here, so they never become a hit, even
|
|
129
|
+
// when the ego-graph expansion reaches one through a call edge.
|
|
128
130
|
const loadMeta = db.prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line, n.signature
|
|
129
|
-
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
131
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
132
|
+
WHERE n.id = ? AND n.kind <> '${FILE_NODE_KIND}'`);
|
|
130
133
|
for (const id of fused.keys()) {
|
|
131
134
|
const row = loadMeta.get(id);
|
|
132
135
|
if (row) {
|
|
@@ -208,7 +211,9 @@ export async function hybridSearch(projectPath, query, opts = {}) {
|
|
|
208
211
|
}
|
|
209
212
|
const prNodeIds = [...subgraphNodes, ...[...fileIdsNeeded].map((fid) => fileNodeId(fid))];
|
|
210
213
|
const defCount = new Map();
|
|
211
|
-
const allNames = db
|
|
214
|
+
const allNames = db
|
|
215
|
+
.prepare(`SELECT name FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`)
|
|
216
|
+
.all();
|
|
212
217
|
for (const r of allNames)
|
|
213
218
|
defCount.set(r.name, (defCount.get(r.name) ?? 0) + 1);
|
|
214
219
|
const refCount = new Map();
|