seer-mcp 0.1.0
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/.vscode/settings.json +3 -0
- package/LICENSE +176 -0
- package/README.md +272 -0
- package/README_dev.md +199 -0
- package/dist/bundle/ci.d.ts +47 -0
- package/dist/bundle/ci.d.ts.map +1 -0
- package/dist/bundle/ci.js +113 -0
- package/dist/bundle/ci.js.map +1 -0
- package/dist/bundle/contract.d.ts +111 -0
- package/dist/bundle/contract.d.ts.map +1 -0
- package/dist/bundle/contract.js +352 -0
- package/dist/bundle/contract.js.map +1 -0
- package/dist/bundle/export.d.ts +36 -0
- package/dist/bundle/export.d.ts.map +1 -0
- package/dist/bundle/export.js +152 -0
- package/dist/bundle/export.js.map +1 -0
- package/dist/bundle/external.d.ts +66 -0
- package/dist/bundle/external.d.ts.map +1 -0
- package/dist/bundle/external.js +238 -0
- package/dist/bundle/external.js.map +1 -0
- package/dist/bundle/format.d.ts +94 -0
- package/dist/bundle/format.d.ts.map +1 -0
- package/dist/bundle/format.js +42 -0
- package/dist/bundle/format.js.map +1 -0
- package/dist/bundle/import.d.ts +49 -0
- package/dist/bundle/import.d.ts.map +1 -0
- package/dist/bundle/import.js +116 -0
- package/dist/bundle/import.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +1402 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +48 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +284 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/db/schema.d.ts +3 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +616 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/store.d.ts +1011 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/db/store.js +3888 -0
- package/dist/db/store.js.map +1 -0
- package/dist/graph/pagerank.d.ts +9 -0
- package/dist/graph/pagerank.d.ts.map +1 -0
- package/dist/graph/pagerank.js +47 -0
- package/dist/graph/pagerank.js.map +1 -0
- package/dist/indexer/architecture.d.ts +72 -0
- package/dist/indexer/architecture.d.ts.map +1 -0
- package/dist/indexer/architecture.js +112 -0
- package/dist/indexer/architecture.js.map +1 -0
- package/dist/indexer/behavior.d.ts +75 -0
- package/dist/indexer/behavior.d.ts.map +1 -0
- package/dist/indexer/behavior.js +395 -0
- package/dist/indexer/behavior.js.map +1 -0
- package/dist/indexer/boundaries.d.ts +60 -0
- package/dist/indexer/boundaries.d.ts.map +1 -0
- package/dist/indexer/boundaries.js +366 -0
- package/dist/indexer/boundaries.js.map +1 -0
- package/dist/indexer/churn.d.ts +15 -0
- package/dist/indexer/churn.d.ts.map +1 -0
- package/dist/indexer/churn.js +49 -0
- package/dist/indexer/churn.js.map +1 -0
- package/dist/indexer/classify.d.ts +9 -0
- package/dist/indexer/classify.d.ts.map +1 -0
- package/dist/indexer/classify.js +90 -0
- package/dist/indexer/classify.js.map +1 -0
- package/dist/indexer/context.d.ts +176 -0
- package/dist/indexer/context.d.ts.map +1 -0
- package/dist/indexer/context.js +193 -0
- package/dist/indexer/context.js.map +1 -0
- package/dist/indexer/continuity.d.ts +67 -0
- package/dist/indexer/continuity.d.ts.map +1 -0
- package/dist/indexer/continuity.js +288 -0
- package/dist/indexer/continuity.js.map +1 -0
- package/dist/indexer/detectchanges.d.ts +32 -0
- package/dist/indexer/detectchanges.d.ts.map +1 -0
- package/dist/indexer/detectchanges.js +74 -0
- package/dist/indexer/detectchanges.js.map +1 -0
- package/dist/indexer/discovery.d.ts +37 -0
- package/dist/indexer/discovery.d.ts.map +1 -0
- package/dist/indexer/discovery.js +136 -0
- package/dist/indexer/discovery.js.map +1 -0
- package/dist/indexer/externaldeps.d.ts +18 -0
- package/dist/indexer/externaldeps.d.ts.map +1 -0
- package/dist/indexer/externaldeps.js +288 -0
- package/dist/indexer/externaldeps.js.map +1 -0
- package/dist/indexer/freshness.d.ts +48 -0
- package/dist/indexer/freshness.d.ts.map +1 -0
- package/dist/indexer/freshness.js +128 -0
- package/dist/indexer/freshness.js.map +1 -0
- package/dist/indexer/git.d.ts +144 -0
- package/dist/indexer/git.d.ts.map +1 -0
- package/dist/indexer/git.js +444 -0
- package/dist/indexer/git.js.map +1 -0
- package/dist/indexer/index.d.ts +145 -0
- package/dist/indexer/index.d.ts.map +1 -0
- package/dist/indexer/index.js +930 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/modules.d.ts +62 -0
- package/dist/indexer/modules.d.ts.map +1 -0
- package/dist/indexer/modules.js +293 -0
- package/dist/indexer/modules.js.map +1 -0
- package/dist/indexer/preflight.d.ts +154 -0
- package/dist/indexer/preflight.d.ts.map +1 -0
- package/dist/indexer/preflight.js +399 -0
- package/dist/indexer/preflight.js.map +1 -0
- package/dist/indexer/protoScanner.d.ts +34 -0
- package/dist/indexer/protoScanner.d.ts.map +1 -0
- package/dist/indexer/protoScanner.js +133 -0
- package/dist/indexer/protoScanner.js.map +1 -0
- package/dist/indexer/risk.d.ts +115 -0
- package/dist/indexer/risk.d.ts.map +1 -0
- package/dist/indexer/risk.js +194 -0
- package/dist/indexer/risk.js.map +1 -0
- package/dist/indexer/serviceHostScanner.d.ts +25 -0
- package/dist/indexer/serviceHostScanner.d.ts.map +1 -0
- package/dist/indexer/serviceHostScanner.js +95 -0
- package/dist/indexer/serviceHostScanner.js.map +1 -0
- package/dist/indexer/serviceLinks.d.ts +105 -0
- package/dist/indexer/serviceLinks.d.ts.map +1 -0
- package/dist/indexer/serviceLinks.js +509 -0
- package/dist/indexer/serviceLinks.js.map +1 -0
- package/dist/indexer/shapehash.d.ts +98 -0
- package/dist/indexer/shapehash.d.ts.map +1 -0
- package/dist/indexer/shapehash.js +354 -0
- package/dist/indexer/shapehash.js.map +1 -0
- package/dist/indexer/skeleton.d.ts +15 -0
- package/dist/indexer/skeleton.d.ts.map +1 -0
- package/dist/indexer/skeleton.js +136 -0
- package/dist/indexer/skeleton.js.map +1 -0
- package/dist/indexer/symbolhistory.d.ts +41 -0
- package/dist/indexer/symbolhistory.d.ts.map +1 -0
- package/dist/indexer/symbolhistory.js +124 -0
- package/dist/indexer/symbolhistory.js.map +1 -0
- package/dist/indexer/watcher.d.ts +68 -0
- package/dist/indexer/watcher.d.ts.map +1 -0
- package/dist/indexer/watcher.js +179 -0
- package/dist/indexer/watcher.js.map +1 -0
- package/dist/mcp/server.d.ts +80 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +1610 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/parser/index.d.ts +8 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +33 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/languages/cpp.d.ts +3 -0
- package/dist/parser/languages/cpp.d.ts.map +1 -0
- package/dist/parser/languages/cpp.js +350 -0
- package/dist/parser/languages/cpp.js.map +1 -0
- package/dist/parser/languages/csharp.d.ts +3 -0
- package/dist/parser/languages/csharp.d.ts.map +1 -0
- package/dist/parser/languages/csharp.js +239 -0
- package/dist/parser/languages/csharp.js.map +1 -0
- package/dist/parser/languages/go.d.ts +3 -0
- package/dist/parser/languages/go.d.ts.map +1 -0
- package/dist/parser/languages/go.js +259 -0
- package/dist/parser/languages/go.js.map +1 -0
- package/dist/parser/languages/java.d.ts +3 -0
- package/dist/parser/languages/java.d.ts.map +1 -0
- package/dist/parser/languages/java.js +391 -0
- package/dist/parser/languages/java.js.map +1 -0
- package/dist/parser/languages/python.d.ts +3 -0
- package/dist/parser/languages/python.d.ts.map +1 -0
- package/dist/parser/languages/python.js +396 -0
- package/dist/parser/languages/python.js.map +1 -0
- package/dist/parser/languages/rust.d.ts +3 -0
- package/dist/parser/languages/rust.d.ts.map +1 -0
- package/dist/parser/languages/rust.js +159 -0
- package/dist/parser/languages/rust.js.map +1 -0
- package/dist/parser/languages/typescript.d.ts +3 -0
- package/dist/parser/languages/typescript.d.ts.map +1 -0
- package/dist/parser/languages/typescript.js +1442 -0
- package/dist/parser/languages/typescript.js.map +1 -0
- package/dist/parser/parserContext.d.ts +77 -0
- package/dist/parser/parserContext.d.ts.map +1 -0
- package/dist/parser/parserContext.js +354 -0
- package/dist/parser/parserContext.js.map +1 -0
- package/dist/parser/walker.d.ts +81 -0
- package/dist/parser/walker.d.ts.map +1 -0
- package/dist/parser/walker.js +217 -0
- package/dist/parser/walker.js.map +1 -0
- package/dist/parser/worker.d.ts +66 -0
- package/dist/parser/worker.d.ts.map +1 -0
- package/dist/parser/worker.js +129 -0
- package/dist/parser/worker.js.map +1 -0
- package/dist/parser/workerpool.d.ts +107 -0
- package/dist/parser/workerpool.d.ts.map +1 -0
- package/dist/parser/workerpool.js +383 -0
- package/dist/parser/workerpool.js.map +1 -0
- package/dist/scip/format.d.ts +87 -0
- package/dist/scip/format.d.ts.map +1 -0
- package/dist/scip/format.js +31 -0
- package/dist/scip/format.js.map +1 -0
- package/dist/scip/import.d.ts +37 -0
- package/dist/scip/import.d.ts.map +1 -0
- package/dist/scip/import.js +180 -0
- package/dist/scip/import.js.map +1 -0
- package/dist/types.d.ts +392 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/docs/architecture.md +105 -0
- package/docs/benchmarks/methodology.md +134 -0
- package/docs/benchmarks/raw-results.md +71 -0
- package/docs/benchmarks.md +74 -0
- package/docs/cli.md +148 -0
- package/docs/examples/behavior-tests.md +70 -0
- package/docs/examples/change-history.md +85 -0
- package/docs/examples/pre-edit-context.md +81 -0
- package/docs/examples/service-links.md +88 -0
- package/docs/examples.md +80 -0
- package/docs/faq.md +70 -0
- package/docs/internals.md +104 -0
- package/docs/languages.md +70 -0
- package/docs/limits.md +52 -0
- package/docs/mcp.md +199 -0
- package/docs/quickstart.md +119 -0
- package/docs/testing.md +123 -0
- package/docs/tools.md +115 -0
- package/package.json +52 -0
- package/research-codebase.md +578 -0
- package/seer-cli-docs.md +326 -0
- package/seer-master-guide.md +246 -0
- package/src/bundle/ci.ts +141 -0
- package/src/bundle/contract.ts +387 -0
- package/src/bundle/export.ts +175 -0
- package/src/bundle/external.ts +285 -0
- package/src/bundle/format.ts +92 -0
- package/src/bundle/import.ts +157 -0
- package/src/cli/index.ts +1249 -0
- package/src/cli/init.ts +389 -0
- package/src/db/schema.ts +614 -0
- package/src/db/store.ts +4306 -0
- package/src/graph/pagerank.ts +53 -0
- package/src/indexer/architecture.ts +148 -0
- package/src/indexer/behavior.ts +466 -0
- package/src/indexer/boundaries.ts +374 -0
- package/src/indexer/churn.ts +58 -0
- package/src/indexer/classify.ts +96 -0
- package/src/indexer/context.ts +340 -0
- package/src/indexer/continuity.ts +322 -0
- package/src/indexer/detectchanges.ts +94 -0
- package/src/indexer/discovery.ts +176 -0
- package/src/indexer/externaldeps.ts +243 -0
- package/src/indexer/freshness.ts +166 -0
- package/src/indexer/git.ts +453 -0
- package/src/indexer/index.ts +1092 -0
- package/src/indexer/modules.ts +358 -0
- package/src/indexer/preflight.ts +548 -0
- package/src/indexer/protoScanner.ts +147 -0
- package/src/indexer/risk.ts +304 -0
- package/src/indexer/serviceHostScanner.ts +92 -0
- package/src/indexer/serviceLinks.ts +543 -0
- package/src/indexer/shapehash.ts +370 -0
- package/src/indexer/skeleton.ts +169 -0
- package/src/indexer/symbolhistory.ts +172 -0
- package/src/indexer/watcher.ts +206 -0
- package/src/mcp/server.ts +1659 -0
- package/src/parser/index.ts +37 -0
- package/src/parser/languages/cpp.ts +361 -0
- package/src/parser/languages/csharp.ts +235 -0
- package/src/parser/languages/go.ts +259 -0
- package/src/parser/languages/java.ts +382 -0
- package/src/parser/languages/python.ts +370 -0
- package/src/parser/languages/rust.ts +164 -0
- package/src/parser/languages/typescript.ts +1435 -0
- package/src/parser/parserContext.ts +392 -0
- package/src/parser/walker.ts +306 -0
- package/src/parser/worker.ts +181 -0
- package/src/parser/workerpool.ts +448 -0
- package/src/scip/format.ts +83 -0
- package/src/scip/import.ts +216 -0
- package/src/types.ts +457 -0
- package/tests/benchmark-service-links.ts +244 -0
- package/tests/bug-regressions.ts +626 -0
- package/tests/filters.ts +264 -0
- package/tests/fixtures/Counter.tsx +38 -0
- package/tests/fixtures/caller.ts +7 -0
- package/tests/fixtures/collisions.ts +23 -0
- package/tests/fixtures/local_helper.ts +5 -0
- package/tests/fixtures/overloads.java +17 -0
- package/tests/fixtures/remote_helper.ts +4 -0
- package/tests/fixtures/sample.c +15 -0
- package/tests/fixtures/sample.cpp +47 -0
- package/tests/fixtures/sample.cs +62 -0
- package/tests/fixtures/sample.go +68 -0
- package/tests/fixtures/sample.h +30 -0
- package/tests/fixtures/sample.java +85 -0
- package/tests/fixtures/sample.py +46 -0
- package/tests/fixtures/sample.rs +78 -0
- package/tests/fixtures/sample.ts +76 -0
- package/tests/fixtures-service/HttpClients.cs +30 -0
- package/tests/fixtures-service/HttpClients.java +24 -0
- package/tests/fixtures-service/billing.ts +15 -0
- package/tests/fixtures-service/docker-compose.yml +15 -0
- package/tests/fixtures-service/gateway.ts +10 -0
- package/tests/fixtures-service/get_user.ts +11 -0
- package/tests/fixtures-service/graphql_client.ts +63 -0
- package/tests/fixtures-service/graphql_server.ts +30 -0
- package/tests/fixtures-service/grpc_client.go +30 -0
- package/tests/fixtures-service/http_clients.go +23 -0
- package/tests/fixtures-service/http_clients.py +38 -0
- package/tests/fixtures-service/http_clients.ts +49 -0
- package/tests/fixtures-service/k8s/payment-service.yaml +22 -0
- package/tests/fixtures-service/k8s_calls.ts +20 -0
- package/tests/fixtures-service/messaging.ts +87 -0
- package/tests/fixtures-service/trpc_client.ts +39 -0
- package/tests/fixtures-service/trpc_server.ts +39 -0
- package/tests/fixtures-service/user_service.proto +33 -0
- package/tests/fixtures-trackcd/Cargo.toml +11 -0
- package/tests/fixtures-trackcd/SpringController.java +36 -0
- package/tests/fixtures-trackcd/auth_service.ts +19 -0
- package/tests/fixtures-trackcd/complex_module.py +50 -0
- package/tests/fixtures-trackcd/express_app.js +30 -0
- package/tests/fixtures-trackcd/fastapi_app.py +49 -0
- package/tests/fixtures-trackcd/fastify_object_routes.js +32 -0
- package/tests/fixtures-trackcd/go.mod +8 -0
- package/tests/fixtures-trackcd/package.json +15 -0
- package/tests/fixtures-trackcd/requirements.txt +4 -0
- package/tests/fixtures-trackcd/tests/auth_service.test.ts +13 -0
- package/tests/fixtures-tracke/auth/AuthService.ts +23 -0
- package/tests/fixtures-tracke/auth/crypto.ts +7 -0
- package/tests/fixtures-tracke/billing/Billing.ts +20 -0
- package/tests/fixtures-tracke/billing/Invoice.ts +10 -0
- package/tests/fixtures-tracke/billing/server.ts +17 -0
- package/tests/fixtures-tracke/package.json +7 -0
- package/tests/fixtures-tracke/tests/auth.test.ts +23 -0
- package/tests/fixtures-tracke/tests/billing.test.ts +14 -0
- package/tests/fixtures-trackf/package.json +5 -0
- package/tests/fixtures-trackf/src/auth.ts +26 -0
- package/tests/fixtures-trackf/src/handlers.ts +35 -0
- package/tests/fixtures-tracki/billing/routes.ts +12 -0
- package/tests/fixtures-tracki/gateway/client.ts +13 -0
- package/tests/git-features.ts +267 -0
- package/tests/init.ts +141 -0
- package/tests/mcp-jit.ts +130 -0
- package/tests/mcp-smoke.ts +191 -0
- package/tests/mcp-trackcd.ts +169 -0
- package/tests/mcp-tracke.ts +229 -0
- package/tests/mcp-trackf.ts +330 -0
- package/tests/mcp-trackg.ts +219 -0
- package/tests/mcp-tracki.ts +174 -0
- package/tests/mcp-watcher.ts +126 -0
- package/tests/optspec.ts +194 -0
- package/tests/parallel-index.ts +333 -0
- package/tests/parallel-read.ts +125 -0
- package/tests/parallel-recovery.ts +241 -0
- package/tests/perf-callers.ts +145 -0
- package/tests/query-parity.ts +184 -0
- package/tests/query-perf.ts +55 -0
- package/tests/scale-parallel-parity.ts +225 -0
- package/tests/scale-test.ts +523 -0
- package/tests/smoke.ts +396 -0
- package/tests/trackcd.ts +325 -0
- package/tests/tracke-collisions.ts +255 -0
- package/tests/tracke.ts +314 -0
- package/tests/trackf-bugs.ts +406 -0
- package/tests/trackf.ts +390 -0
- package/tests/trackg.ts +1372 -0
- package/tests/tracki-boundaries.ts +202 -0
- package/tests/tracki-continuity.ts +253 -0
- package/tests/tracki-contract-diff.ts +249 -0
- package/tests/tracki-external-bundles.ts +341 -0
- package/tests/tracki-preflight.ts +251 -0
- package/tests/verify-roles.ts +51 -0
- package/tests/worker-parity.ts +286 -0
- package/tests/worker-pool.ts +262 -0
- package/tsconfig.json +20 -0
package/dist/db/store.js
ADDED
|
@@ -0,0 +1,3888 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Store = void 0;
|
|
7
|
+
exports.isRankableKind = isRankableKind;
|
|
8
|
+
exports.splitIdentifierTokens = splitIdentifierTokens;
|
|
9
|
+
exports.makeSymbolKey = makeSymbolKey;
|
|
10
|
+
exports.ftsQuery = ftsQuery;
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const node_sqlite_1 = require("node:sqlite");
|
|
13
|
+
const schema_js_1 = require("./schema.js");
|
|
14
|
+
/**
|
|
15
|
+
* Which symbol kinds participate in PageRank, ranking, and the default
|
|
16
|
+
* symbol list. Functions/methods/constructors/classes are rankable because
|
|
17
|
+
* they are call targets — edges flow into them and meaningful behavior lives
|
|
18
|
+
* there. Structs, enums, type aliases, interfaces, and variables are not
|
|
19
|
+
* rankable: they are type/state declarations, not call targets.
|
|
20
|
+
*
|
|
21
|
+
* Excluding non-rankable kinds from PageRank is a correctness fix as much as
|
|
22
|
+
* an optimization. With them included, the graph has hundreds of thousands of
|
|
23
|
+
* isolated zero-edge nodes (every struct/enum row) that absorb the (1-d)/n
|
|
24
|
+
* mass on each iteration but never propagate it. That dilutes every real
|
|
25
|
+
* function's score and inflates compute time linearly with the noise count.
|
|
26
|
+
*/
|
|
27
|
+
const RANKABLE_KINDS = new Set([
|
|
28
|
+
'function', 'method', 'constructor', 'class',
|
|
29
|
+
]);
|
|
30
|
+
const SERVICE_CALLS_BACKFILL_VERSION = '1';
|
|
31
|
+
function isRankableKind(kind) {
|
|
32
|
+
return RANKABLE_KINDS.has(kind);
|
|
33
|
+
}
|
|
34
|
+
function toNum(v) { return Number(v); }
|
|
35
|
+
/** Escape SQLite LIKE metacharacters (`%`, `_`, `\`) for use with ESCAPE '\'.
|
|
36
|
+
* Lets a literal filename like `bom_crlf.ts` match without `_` acting as a
|
|
37
|
+
* single-char wildcard. */
|
|
38
|
+
function escapeLike(s) {
|
|
39
|
+
return s.replace(/[\\%_]/g, m => '\\' + m);
|
|
40
|
+
}
|
|
41
|
+
function toStr(v) { return String(v ?? ''); }
|
|
42
|
+
function toNullStr(v) { return v == null ? null : String(v); }
|
|
43
|
+
function toNullNum(v) { return v == null ? null : Number(v); }
|
|
44
|
+
/**
|
|
45
|
+
* Convert a 64-bit unsigned bigint shape hash into a signed bigint suitable
|
|
46
|
+
* for storage in an SQLite INTEGER column. We treat the high bit as the sign,
|
|
47
|
+
* so `0x8000_0000_0000_0000` and above wrap into negative values; this round-
|
|
48
|
+
* trips losslessly with `toUnsignedI64` below.
|
|
49
|
+
*/
|
|
50
|
+
function toSignedI64(u) {
|
|
51
|
+
const MAX_I64 = 0x7fffffffffffffffn;
|
|
52
|
+
return u > MAX_I64 ? u - 0x10000000000000000n : u;
|
|
53
|
+
}
|
|
54
|
+
function toUnsignedI64(v) {
|
|
55
|
+
if (v == null)
|
|
56
|
+
return 0n;
|
|
57
|
+
const b = typeof v === 'bigint' ? v : BigInt(Number(v));
|
|
58
|
+
return b < 0n ? b + 0x10000000000000000n : b;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the per-table predicate clauses for the default project-first lens.
|
|
62
|
+
* Used by `findSymbols` / `getDefinition` / `getTopSymbols` / `countSymbols`
|
|
63
|
+
* and the MCP tool wrappers around them. Each `include*` flag turns OFF the
|
|
64
|
+
* corresponding restriction.
|
|
65
|
+
*
|
|
66
|
+
* The function is forgiving about pre-v4 / pre-v5 DBs: when the role columns
|
|
67
|
+
* or the symbol_role column don't exist on disk, the corresponding clauses
|
|
68
|
+
* are simply dropped so a read-only open against an old index keeps working.
|
|
69
|
+
*/
|
|
70
|
+
function buildRoleFilter(filePrefix, includeVendor, includeGenerated, hasRoleColumns, options) {
|
|
71
|
+
const clauses = [];
|
|
72
|
+
if (hasRoleColumns) {
|
|
73
|
+
if (!includeVendor)
|
|
74
|
+
clauses.push(`${filePrefix}is_vendor = 0`);
|
|
75
|
+
if (!includeGenerated)
|
|
76
|
+
clauses.push(`${filePrefix}is_generated = 0`);
|
|
77
|
+
if (options && options.includeTests === false)
|
|
78
|
+
clauses.push(`${filePrefix}role <> 'test'`);
|
|
79
|
+
}
|
|
80
|
+
if (options?.hasSymbolRoleColumn) {
|
|
81
|
+
const sp = options.symbolPrefix ?? 's.';
|
|
82
|
+
if (options.includeDeclarations === false)
|
|
83
|
+
clauses.push(`${sp}symbol_role <> 'declaration'`);
|
|
84
|
+
if (options.includeTypeRefs === false)
|
|
85
|
+
clauses.push(`${sp}symbol_role <> 'type_ref'`);
|
|
86
|
+
}
|
|
87
|
+
return clauses.length === 0 ? '' : 'AND ' + clauses.join(' AND ');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the agent-facing query defaults for the include-flags. The contract:
|
|
91
|
+
* - vendor / generated stay hidden by default (existing behavior).
|
|
92
|
+
* - tests stay hidden by default for ranking/search tools, on top of the
|
|
93
|
+
* existing file-role classification. seer_behavior overrides via
|
|
94
|
+
* includeTests=true since tests ARE its content.
|
|
95
|
+
* - declarations stay hidden by default so callers/top-by-rank focus on
|
|
96
|
+
* real definition sites.
|
|
97
|
+
* - type_refs stay hidden by default (and aren't even produced yet).
|
|
98
|
+
*/
|
|
99
|
+
function resolveSearchFlags(opts) {
|
|
100
|
+
return {
|
|
101
|
+
includeVendor: opts.includeVendor ?? false,
|
|
102
|
+
includeGenerated: opts.includeGenerated ?? false,
|
|
103
|
+
includeTests: opts.includeTests ?? false,
|
|
104
|
+
includeDeclarations: opts.includeDeclarations ?? false,
|
|
105
|
+
includeTypeRefs: opts.includeTypeRefs ?? false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Split an identifier into searchable tokens. Used at FTS-insert time so a
|
|
110
|
+
* query for "auth" finds `AuthService`, `auth_service`, `authService`, and
|
|
111
|
+
* `AuthServiceImpl` alike.
|
|
112
|
+
*
|
|
113
|
+
* - splits on _ and -
|
|
114
|
+
* - splits camelCase boundaries (`AuthService` → "Auth Service Auth_Service")
|
|
115
|
+
* - splits consecutive caps like XMLParser → "XML Parser"
|
|
116
|
+
* - always includes the original token so prefix matches still work
|
|
117
|
+
*/
|
|
118
|
+
function splitIdentifierTokens(s) {
|
|
119
|
+
if (!s)
|
|
120
|
+
return '';
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const push = (t) => { if (t)
|
|
123
|
+
seen.add(t.toLowerCase()); };
|
|
124
|
+
push(s);
|
|
125
|
+
// Split on . _ - / : ::
|
|
126
|
+
for (const part of s.split(/[._\-/:]+/)) {
|
|
127
|
+
push(part);
|
|
128
|
+
// CamelCase / PascalCase split: split before an upper-case letter that's
|
|
129
|
+
// either preceded by a lower-case letter, or followed by a lower-case
|
|
130
|
+
// letter when preceded by another upper-case letter (XMLParser → XML, Parser).
|
|
131
|
+
const camel = part.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
132
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2');
|
|
133
|
+
for (const tok of camel.split(/\s+/))
|
|
134
|
+
push(tok);
|
|
135
|
+
}
|
|
136
|
+
return Array.from(seen).join(' ');
|
|
137
|
+
}
|
|
138
|
+
class Store {
|
|
139
|
+
db;
|
|
140
|
+
readonly;
|
|
141
|
+
cachedSchemaInfo;
|
|
142
|
+
hasRoleColumns;
|
|
143
|
+
hasComplexityColumns;
|
|
144
|
+
hasV4Tables;
|
|
145
|
+
/**
|
|
146
|
+
* True when the v5 `symbols.symbol_role` column exists. Read-only opens
|
|
147
|
+
* against a pre-v5 DB transparently skip declaration/type_ref filtering;
|
|
148
|
+
* writer opens always have it since runMigrations() adds the column.
|
|
149
|
+
*/
|
|
150
|
+
hasSymbolRoleColumn;
|
|
151
|
+
/**
|
|
152
|
+
* True when the v6 module tables (modules / module_members / module_edges)
|
|
153
|
+
* exist. Read-only opens against a pre-v6 DB skip module queries gracefully
|
|
154
|
+
* (they return empty arrays); writer opens always have it.
|
|
155
|
+
*/
|
|
156
|
+
hasModuleTables;
|
|
157
|
+
/**
|
|
158
|
+
* True when the v7 provenance/shape_hash columns + scip_imports table exist.
|
|
159
|
+
* Read-only opens against a pre-v7 DB return empty arrays for SCIP / dup
|
|
160
|
+
* queries and skip the provenance column on selects.
|
|
161
|
+
*/
|
|
162
|
+
hasV7Columns;
|
|
163
|
+
/**
|
|
164
|
+
* True when v10 external_bundles / boundaries / symbol_history_continuity
|
|
165
|
+
* tables exist. Read-only opens against a pre-v10 DB return empty arrays.
|
|
166
|
+
*/
|
|
167
|
+
hasV10Tables;
|
|
168
|
+
// Prepared statements — initialized in constructor (writer path only)
|
|
169
|
+
stmtUpsertFile;
|
|
170
|
+
stmtInsertSymbol;
|
|
171
|
+
stmtInsertEdge;
|
|
172
|
+
stmtInsertFileImport;
|
|
173
|
+
stmtInsertRoute;
|
|
174
|
+
stmtInsertConfigKey;
|
|
175
|
+
stmtInsertExternalDep;
|
|
176
|
+
stmtInsertServiceCall;
|
|
177
|
+
stmtInsertServiceLink;
|
|
178
|
+
stmtInsertSymbolsFts;
|
|
179
|
+
stmtInsertFilesFts;
|
|
180
|
+
stmtDeleteSymbolsFtsForFile;
|
|
181
|
+
stmtDeleteFilesFtsForFile;
|
|
182
|
+
constructor(dbPath, options = {}) {
|
|
183
|
+
this.readonly = Boolean(options.readonly);
|
|
184
|
+
const busyMs = options.busyTimeoutMs ?? 5000;
|
|
185
|
+
if (this.readonly) {
|
|
186
|
+
this.db = new node_sqlite_1.DatabaseSync(dbPath, { readOnly: true });
|
|
187
|
+
try {
|
|
188
|
+
this.db.exec(`PRAGMA busy_timeout = ${busyMs}; PRAGMA query_only = ON;`);
|
|
189
|
+
}
|
|
190
|
+
catch { /* best effort */ }
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
this.db = new node_sqlite_1.DatabaseSync(dbPath);
|
|
194
|
+
this.db.exec(schema_js_1.SCHEMA_SQL);
|
|
195
|
+
try {
|
|
196
|
+
this.db.exec(`PRAGMA busy_timeout = ${busyMs};`);
|
|
197
|
+
}
|
|
198
|
+
catch { /* best effort */ }
|
|
199
|
+
this.runMigrations();
|
|
200
|
+
this.prepare();
|
|
201
|
+
}
|
|
202
|
+
this.cachedSchemaInfo = this.readSchemaInfo();
|
|
203
|
+
this.hasRoleColumns = this.checkHasRoleColumns();
|
|
204
|
+
this.hasComplexityColumns = this.hasColumn('symbols', 'cyclomatic');
|
|
205
|
+
this.hasV4Tables = this.checkHasV4Tables();
|
|
206
|
+
this.hasSymbolRoleColumn = this.hasColumn('symbols', 'symbol_role');
|
|
207
|
+
this.hasModuleTables = this.checkHasModuleTables();
|
|
208
|
+
this.hasV7Columns = this.hasColumn('symbols', 'provenance') && this.hasColumn('symbols', 'shape_hash');
|
|
209
|
+
this.hasV10Tables = this.checkHasV10Tables();
|
|
210
|
+
}
|
|
211
|
+
checkHasV10Tables() {
|
|
212
|
+
try {
|
|
213
|
+
const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('external_bundles','boundaries','boundary_members','boundary_edges','symbol_history_continuity')").all();
|
|
214
|
+
return rows.length === 5;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
checkHasModuleTables() {
|
|
221
|
+
try {
|
|
222
|
+
const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('modules','module_members','module_edges')").all();
|
|
223
|
+
return rows.length === 3;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
checkHasRoleColumns() {
|
|
230
|
+
try {
|
|
231
|
+
const cols = this.db.prepare('PRAGMA table_info(files)').all();
|
|
232
|
+
const names = new Set(cols.map(c => toStr(c.name)));
|
|
233
|
+
return names.has('role') && names.has('is_vendor') && names.has('is_generated');
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
checkHasV4Tables() {
|
|
240
|
+
try {
|
|
241
|
+
const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('routes','external_dependencies','config_keys','file_churn','symbol_history','git_index_state')").all();
|
|
242
|
+
return rows.length === 6;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
static openReadOnly(dbPath, busyTimeoutMs) {
|
|
249
|
+
return new Store(dbPath, { readonly: true, busyTimeoutMs });
|
|
250
|
+
}
|
|
251
|
+
isReadOnly() { return this.readonly; }
|
|
252
|
+
assertWritable() {
|
|
253
|
+
if (this.readonly) {
|
|
254
|
+
throw new Error('Store is read-only; open a writable Store to mutate the index');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
schemaInfo() { return this.cachedSchemaInfo; }
|
|
258
|
+
/**
|
|
259
|
+
* v8 Track-G migration guard. When an existing v7 DB is opened by v8 code,
|
|
260
|
+
* service_calls/service_links tables are created empty. A normal cached
|
|
261
|
+
* re-index would skip every unchanged file, so service_calls would remain
|
|
262
|
+
* empty forever. Until an index run marks this backfill version complete,
|
|
263
|
+
* the indexer must force one full parse pass.
|
|
264
|
+
*/
|
|
265
|
+
needsServiceCallBackfill() {
|
|
266
|
+
try {
|
|
267
|
+
const row = this.db.prepare("SELECT value FROM _schema_meta WHERE key = 'service_calls_backfilled'").get();
|
|
268
|
+
if (row && toStr(row.value) === SERVICE_CALLS_BACKFILL_VERSION)
|
|
269
|
+
return false;
|
|
270
|
+
const files = this.db.prepare('SELECT COUNT(*) AS c FROM files').get();
|
|
271
|
+
return toNum(files.c) > 0;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
markServiceCallsBackfilled() {
|
|
278
|
+
this.assertWritable();
|
|
279
|
+
this.db.prepare("INSERT INTO _schema_meta (key, value) VALUES ('service_calls_backfilled', ?) " +
|
|
280
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SERVICE_CALLS_BACKFILL_VERSION);
|
|
281
|
+
}
|
|
282
|
+
readSchemaInfo() {
|
|
283
|
+
let dbVersion = 0;
|
|
284
|
+
try {
|
|
285
|
+
const row = this.db.prepare("SELECT value FROM _schema_meta WHERE key = 'schema_version'").get();
|
|
286
|
+
if (row)
|
|
287
|
+
dbVersion = parseInt(toStr(row.value), 10) || 0;
|
|
288
|
+
}
|
|
289
|
+
catch { /* */ }
|
|
290
|
+
return {
|
|
291
|
+
dbVersion,
|
|
292
|
+
buildVersion: schema_js_1.CURRENT_SCHEMA_VERSION,
|
|
293
|
+
current: dbVersion === schema_js_1.CURRENT_SCHEMA_VERSION,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
runMigrations() {
|
|
297
|
+
this.addColumnIfMissing('symbols', 'qualified_name', 'TEXT');
|
|
298
|
+
this.addColumnIfMissing('file_imports', 'resolved_file_id', 'INTEGER REFERENCES files(id) ON DELETE SET NULL');
|
|
299
|
+
this.addColumnIfMissing('files', 'role', "TEXT NOT NULL DEFAULT 'project'");
|
|
300
|
+
this.addColumnIfMissing('files', 'is_vendor', 'INTEGER NOT NULL DEFAULT 0');
|
|
301
|
+
this.addColumnIfMissing('files', 'is_generated', 'INTEGER NOT NULL DEFAULT 0');
|
|
302
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_role ON files(role)');
|
|
303
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_is_vendor ON files(is_vendor)');
|
|
304
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_is_generated ON files(is_generated)');
|
|
305
|
+
// v3: is_rankable
|
|
306
|
+
const isV3Migration = !this.hasColumn('symbols', 'is_rankable');
|
|
307
|
+
this.addColumnIfMissing('symbols', 'is_rankable', 'INTEGER NOT NULL DEFAULT 1');
|
|
308
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_is_rankable ON symbols(is_rankable)');
|
|
309
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_file_name ON symbols(file_id, name)');
|
|
310
|
+
if (isV3Migration) {
|
|
311
|
+
this.db.prepare(`UPDATE symbols SET is_rankable = 0 WHERE kind NOT IN ('function','method','constructor','class')`).run();
|
|
312
|
+
this.db.prepare('UPDATE symbols SET pagerank = 0 WHERE is_rankable = 0').run();
|
|
313
|
+
}
|
|
314
|
+
// v4: complexity columns, symbol_key, edges.kind index
|
|
315
|
+
const isV4Migration = !this.hasColumn('symbols', 'symbol_key');
|
|
316
|
+
this.addColumnIfMissing('symbols', 'loc', 'INTEGER');
|
|
317
|
+
this.addColumnIfMissing('symbols', 'cyclomatic', 'INTEGER');
|
|
318
|
+
this.addColumnIfMissing('symbols', 'cognitive', 'INTEGER');
|
|
319
|
+
this.addColumnIfMissing('symbols', 'max_nesting', 'INTEGER');
|
|
320
|
+
this.addColumnIfMissing('symbols', 'symbol_key', 'TEXT');
|
|
321
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_symbol_key ON symbols(symbol_key)');
|
|
322
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind)');
|
|
323
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_from_to_kind ON edges(from_id, to_id, kind)');
|
|
324
|
+
// v4.1: separate history HEAD marker so churn doesn't poison the
|
|
325
|
+
// skip-if-unchanged check used by buildSymbolHistory. Cheap ALTER ADD;
|
|
326
|
+
// existing DBs get NULL which forces history to run on next invocation.
|
|
327
|
+
this.addColumnIfMissing('git_index_state', 'last_history_head_sha', 'TEXT');
|
|
328
|
+
this.addColumnIfMissing('git_index_state', 'last_history_at', 'INTEGER');
|
|
329
|
+
// v5: symbol_role on symbols. The NOT NULL DEFAULT 'definition' on the
|
|
330
|
+
// ALTER means every pre-v5 row gets a sane default without an explicit
|
|
331
|
+
// UPDATE backfill. The role only changes its meaning when the indexer
|
|
332
|
+
// re-runs against the file (e.g. for C/C++ fixtures where field_declaration
|
|
333
|
+
// is now emitted as 'declaration').
|
|
334
|
+
this.addColumnIfMissing('symbols', 'symbol_role', "TEXT NOT NULL DEFAULT 'definition'");
|
|
335
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_symbol_role ON symbols(symbol_role)');
|
|
336
|
+
// v7: provenance + shape_hash on symbols/edges, plus scip_imports table.
|
|
337
|
+
// ALTER ADD COLUMN paths are cheap and idempotent; the index creation is
|
|
338
|
+
// guarded by hasColumn so a partial migration on an older DB doesn't fail.
|
|
339
|
+
this.addColumnIfMissing('symbols', 'provenance', "TEXT NOT NULL DEFAULT 'tree-sitter'");
|
|
340
|
+
this.addColumnIfMissing('symbols', 'shape_hash', 'INTEGER');
|
|
341
|
+
this.addColumnIfMissing('edges', 'provenance', "TEXT NOT NULL DEFAULT 'tree-sitter'");
|
|
342
|
+
// v7.1 — scip_import_id links a SCIP-provenance row back to the
|
|
343
|
+
// scip_imports table entry that produced it, so re-importing or clearing
|
|
344
|
+
// ONE SCIP layer doesn't nuke rows contributed by sibling layers (the
|
|
345
|
+
// original v7 wipe was global, which collapsed multi-layer setups).
|
|
346
|
+
this.addColumnIfMissing('symbols', 'scip_import_id', 'INTEGER');
|
|
347
|
+
this.addColumnIfMissing('edges', 'scip_import_id', 'INTEGER');
|
|
348
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_provenance ON symbols(provenance)');
|
|
349
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_shape_hash ON symbols(shape_hash) WHERE shape_hash IS NOT NULL');
|
|
350
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance)');
|
|
351
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_scip_import ON symbols(scip_import_id)');
|
|
352
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_scip_import ON edges(scip_import_id)');
|
|
353
|
+
this.db.exec(`
|
|
354
|
+
CREATE TABLE IF NOT EXISTS scip_imports (
|
|
355
|
+
id INTEGER PRIMARY KEY,
|
|
356
|
+
path TEXT NOT NULL,
|
|
357
|
+
sha256 TEXT NOT NULL,
|
|
358
|
+
tool TEXT,
|
|
359
|
+
project_root TEXT,
|
|
360
|
+
imported_at INTEGER NOT NULL,
|
|
361
|
+
symbol_count INTEGER NOT NULL DEFAULT 0,
|
|
362
|
+
ref_count INTEGER NOT NULL DEFAULT 0,
|
|
363
|
+
UNIQUE(path, sha256)
|
|
364
|
+
);
|
|
365
|
+
CREATE INDEX IF NOT EXISTS idx_scip_imports_path ON scip_imports(path);
|
|
366
|
+
`);
|
|
367
|
+
// v6: modules + module_members + module_edges. CREATE TABLE IF NOT EXISTS
|
|
368
|
+
// is the migration — pre-v6 DBs get the tables on first writer open.
|
|
369
|
+
// No backfill needed: the clustering pass repopulates them on the next
|
|
370
|
+
// index run (it always runs when the graph changed; otherwise the cached
|
|
371
|
+
// membership stays valid because the graph it was built from stays valid).
|
|
372
|
+
this.db.exec(`
|
|
373
|
+
CREATE TABLE IF NOT EXISTS modules (
|
|
374
|
+
id INTEGER PRIMARY KEY,
|
|
375
|
+
label TEXT NOT NULL,
|
|
376
|
+
size_files INTEGER NOT NULL DEFAULT 0,
|
|
377
|
+
size_symbols INTEGER NOT NULL DEFAULT 0,
|
|
378
|
+
primary_language TEXT,
|
|
379
|
+
cohesion REAL NOT NULL DEFAULT 0,
|
|
380
|
+
centrality REAL NOT NULL DEFAULT 0,
|
|
381
|
+
computed_at INTEGER NOT NULL DEFAULT 0,
|
|
382
|
+
algorithm TEXT NOT NULL DEFAULT 'louvain'
|
|
383
|
+
);
|
|
384
|
+
CREATE INDEX IF NOT EXISTS idx_modules_label ON modules(label);
|
|
385
|
+
CREATE INDEX IF NOT EXISTS idx_modules_centrality ON modules(centrality DESC);
|
|
386
|
+
CREATE INDEX IF NOT EXISTS idx_modules_size ON modules(size_files DESC);
|
|
387
|
+
CREATE TABLE IF NOT EXISTS module_members (
|
|
388
|
+
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
|
389
|
+
module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE
|
|
390
|
+
);
|
|
391
|
+
CREATE INDEX IF NOT EXISTS idx_module_members_module ON module_members(module_id);
|
|
392
|
+
CREATE TABLE IF NOT EXISTS module_edges (
|
|
393
|
+
id INTEGER PRIMARY KEY,
|
|
394
|
+
from_module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
|
|
395
|
+
to_module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
|
|
396
|
+
kind TEXT NOT NULL DEFAULT 'call',
|
|
397
|
+
weight INTEGER NOT NULL DEFAULT 1,
|
|
398
|
+
UNIQUE(from_module_id, to_module_id, kind)
|
|
399
|
+
);
|
|
400
|
+
CREATE INDEX IF NOT EXISTS idx_module_edges_from ON module_edges(from_module_id);
|
|
401
|
+
CREATE INDEX IF NOT EXISTS idx_module_edges_to ON module_edges(to_module_id);
|
|
402
|
+
`);
|
|
403
|
+
// v8: Track-G service_calls + service_links. CREATE TABLE IF NOT EXISTS
|
|
404
|
+
// is the migration. Existing cached DBs need one forced parse pass to
|
|
405
|
+
// populate service_calls; needsServiceCallBackfill() + the indexer marker
|
|
406
|
+
// handle that so unchanged hashes do not leave the tables empty forever.
|
|
407
|
+
this.db.exec(`
|
|
408
|
+
CREATE TABLE IF NOT EXISTS service_calls (
|
|
409
|
+
id INTEGER PRIMARY KEY,
|
|
410
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
411
|
+
symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
|
|
412
|
+
protocol TEXT NOT NULL,
|
|
413
|
+
method TEXT,
|
|
414
|
+
raw_target TEXT NOT NULL,
|
|
415
|
+
normalized_path TEXT,
|
|
416
|
+
host_hint TEXT,
|
|
417
|
+
env_key TEXT,
|
|
418
|
+
framework TEXT NOT NULL,
|
|
419
|
+
line INTEGER NOT NULL DEFAULT 0,
|
|
420
|
+
confidence REAL NOT NULL DEFAULT 0.5
|
|
421
|
+
);
|
|
422
|
+
CREATE INDEX IF NOT EXISTS idx_service_calls_symbol_id ON service_calls(symbol_id);
|
|
423
|
+
CREATE INDEX IF NOT EXISTS idx_service_calls_path ON service_calls(normalized_path);
|
|
424
|
+
CREATE INDEX IF NOT EXISTS idx_service_calls_protocol ON service_calls(protocol);
|
|
425
|
+
CREATE INDEX IF NOT EXISTS idx_service_calls_file_id ON service_calls(file_id);
|
|
426
|
+
|
|
427
|
+
CREATE TABLE IF NOT EXISTS service_links (
|
|
428
|
+
id INTEGER PRIMARY KEY,
|
|
429
|
+
call_id INTEGER NOT NULL REFERENCES service_calls(id) ON DELETE CASCADE,
|
|
430
|
+
route_id INTEGER REFERENCES routes(id) ON DELETE CASCADE,
|
|
431
|
+
caller_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
|
|
432
|
+
handler_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
|
|
433
|
+
protocol TEXT NOT NULL,
|
|
434
|
+
match_kind TEXT NOT NULL,
|
|
435
|
+
confidence REAL NOT NULL,
|
|
436
|
+
evidence_json TEXT NOT NULL DEFAULT '{}'
|
|
437
|
+
);
|
|
438
|
+
CREATE INDEX IF NOT EXISTS idx_service_links_call_id ON service_links(call_id);
|
|
439
|
+
CREATE INDEX IF NOT EXISTS idx_service_links_handler ON service_links(handler_symbol_id);
|
|
440
|
+
CREATE INDEX IF NOT EXISTS idx_service_links_caller ON service_links(caller_symbol_id);
|
|
441
|
+
CREATE INDEX IF NOT EXISTS idx_service_links_protocol ON service_links(protocol);
|
|
442
|
+
CREATE INDEX IF NOT EXISTS idx_service_links_match_kind ON service_links(match_kind);
|
|
443
|
+
`);
|
|
444
|
+
// v9: Track-H protocol expansion. Adds generalized columns to service_calls
|
|
445
|
+
// and routes so non-HTTP protocols (tRPC / GraphQL / gRPC / Kafka / etc.)
|
|
446
|
+
// can be stored alongside HTTP without one column per protocol. All
|
|
447
|
+
// additions are nullable (or default 'http' for routes.protocol) so v8 DBs
|
|
448
|
+
// upgrade in-place with no data rewrite. Existing HTTP rows keep working
|
|
449
|
+
// unchanged because the resolver still matches on normalized_path + method
|
|
450
|
+
// when the new fields are NULL.
|
|
451
|
+
this.addColumnIfMissing('service_calls', 'operation', 'TEXT');
|
|
452
|
+
this.addColumnIfMissing('service_calls', 'topic', 'TEXT');
|
|
453
|
+
this.addColumnIfMissing('service_calls', 'queue', 'TEXT');
|
|
454
|
+
this.addColumnIfMissing('service_calls', 'exchange', 'TEXT');
|
|
455
|
+
this.addColumnIfMissing('service_calls', 'service', 'TEXT');
|
|
456
|
+
this.addColumnIfMissing('service_calls', 'broker', 'TEXT');
|
|
457
|
+
this.addColumnIfMissing('service_calls', 'metadata_json', 'TEXT');
|
|
458
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_operation ON service_calls(operation) WHERE operation IS NOT NULL');
|
|
459
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_topic ON service_calls(topic) WHERE topic IS NOT NULL');
|
|
460
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_queue ON service_calls(queue) WHERE queue IS NOT NULL');
|
|
461
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_service ON service_calls(service) WHERE service IS NOT NULL');
|
|
462
|
+
this.addColumnIfMissing('routes', 'protocol', "TEXT NOT NULL DEFAULT 'http'");
|
|
463
|
+
this.addColumnIfMissing('routes', 'operation', 'TEXT');
|
|
464
|
+
this.addColumnIfMissing('routes', 'topic', 'TEXT');
|
|
465
|
+
this.addColumnIfMissing('routes', 'queue', 'TEXT');
|
|
466
|
+
this.addColumnIfMissing('routes', 'exchange', 'TEXT');
|
|
467
|
+
this.addColumnIfMissing('routes', 'service', 'TEXT');
|
|
468
|
+
this.addColumnIfMissing('routes', 'broker', 'TEXT');
|
|
469
|
+
this.addColumnIfMissing('routes', 'metadata_json', 'TEXT');
|
|
470
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_protocol ON routes(protocol)');
|
|
471
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_operation ON routes(operation) WHERE operation IS NOT NULL');
|
|
472
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_topic ON routes(topic) WHERE topic IS NOT NULL');
|
|
473
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_queue ON routes(queue) WHERE queue IS NOT NULL');
|
|
474
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_service ON routes(service) WHERE service IS NOT NULL');
|
|
475
|
+
// v10 — external bundle layers + monorepo boundaries + history continuity.
|
|
476
|
+
// CREATE IF NOT EXISTS + ALTER ADD COLUMN keep older DBs upgradable
|
|
477
|
+
// without data rewrites. The default values are chosen so HTTP/local
|
|
478
|
+
// behavior is unchanged on rows that don't set the new fields.
|
|
479
|
+
this.db.exec(`
|
|
480
|
+
CREATE TABLE IF NOT EXISTS external_bundles (
|
|
481
|
+
id INTEGER PRIMARY KEY,
|
|
482
|
+
source_kind TEXT NOT NULL DEFAULT 'external-bundle',
|
|
483
|
+
bundle_path TEXT NOT NULL,
|
|
484
|
+
external_project TEXT,
|
|
485
|
+
external_version TEXT,
|
|
486
|
+
external_hash TEXT,
|
|
487
|
+
schema_version INTEGER NOT NULL DEFAULT 0,
|
|
488
|
+
imported_at INTEGER NOT NULL,
|
|
489
|
+
routes_imported INTEGER NOT NULL DEFAULT 0,
|
|
490
|
+
service_calls_imported INTEGER NOT NULL DEFAULT 0,
|
|
491
|
+
service_links_imported INTEGER NOT NULL DEFAULT 0,
|
|
492
|
+
UNIQUE(bundle_path)
|
|
493
|
+
);
|
|
494
|
+
CREATE INDEX IF NOT EXISTS idx_external_bundles_project ON external_bundles(external_project);
|
|
495
|
+
CREATE TABLE IF NOT EXISTS boundaries (
|
|
496
|
+
id INTEGER PRIMARY KEY,
|
|
497
|
+
label TEXT NOT NULL,
|
|
498
|
+
kind TEXT NOT NULL DEFAULT 'package',
|
|
499
|
+
root_rel_path TEXT NOT NULL,
|
|
500
|
+
manifest_path TEXT,
|
|
501
|
+
ecosystem TEXT,
|
|
502
|
+
size_files INTEGER NOT NULL DEFAULT 0,
|
|
503
|
+
computed_at INTEGER NOT NULL DEFAULT 0,
|
|
504
|
+
UNIQUE(root_rel_path)
|
|
505
|
+
);
|
|
506
|
+
CREATE INDEX IF NOT EXISTS idx_boundaries_label ON boundaries(label);
|
|
507
|
+
CREATE INDEX IF NOT EXISTS idx_boundaries_kind ON boundaries(kind);
|
|
508
|
+
CREATE TABLE IF NOT EXISTS boundary_members (
|
|
509
|
+
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
|
510
|
+
boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE
|
|
511
|
+
);
|
|
512
|
+
CREATE INDEX IF NOT EXISTS idx_boundary_members_boundary ON boundary_members(boundary_id);
|
|
513
|
+
CREATE TABLE IF NOT EXISTS boundary_edges (
|
|
514
|
+
id INTEGER PRIMARY KEY,
|
|
515
|
+
from_boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE,
|
|
516
|
+
to_boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE,
|
|
517
|
+
kind TEXT NOT NULL DEFAULT 'call',
|
|
518
|
+
weight INTEGER NOT NULL DEFAULT 1,
|
|
519
|
+
UNIQUE(from_boundary_id, to_boundary_id, kind)
|
|
520
|
+
);
|
|
521
|
+
CREATE INDEX IF NOT EXISTS idx_boundary_edges_from ON boundary_edges(from_boundary_id);
|
|
522
|
+
CREATE INDEX IF NOT EXISTS idx_boundary_edges_to ON boundary_edges(to_boundary_id);
|
|
523
|
+
CREATE TABLE IF NOT EXISTS symbol_history_continuity (
|
|
524
|
+
id INTEGER PRIMARY KEY,
|
|
525
|
+
symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
|
|
526
|
+
symbol_key TEXT NOT NULL,
|
|
527
|
+
previous_symbol_key TEXT,
|
|
528
|
+
previous_name TEXT,
|
|
529
|
+
previous_file TEXT,
|
|
530
|
+
bridging_sha TEXT,
|
|
531
|
+
confidence REAL NOT NULL DEFAULT 0.0,
|
|
532
|
+
match_reasons TEXT NOT NULL DEFAULT '[]',
|
|
533
|
+
recorded_at INTEGER NOT NULL,
|
|
534
|
+
UNIQUE(symbol_id, previous_symbol_key)
|
|
535
|
+
);
|
|
536
|
+
CREATE INDEX IF NOT EXISTS idx_symbol_history_continuity_symbol ON symbol_history_continuity(symbol_id);
|
|
537
|
+
CREATE INDEX IF NOT EXISTS idx_symbol_history_continuity_prev ON symbol_history_continuity(previous_symbol_key);
|
|
538
|
+
`);
|
|
539
|
+
// v10 — external_bundle_id columns on rows that can come from an external
|
|
540
|
+
// layer. NULL = local row (default).
|
|
541
|
+
this.addColumnIfMissing('routes', 'external_bundle_id', 'INTEGER');
|
|
542
|
+
this.addColumnIfMissing('service_calls', 'external_bundle_id', 'INTEGER');
|
|
543
|
+
this.addColumnIfMissing('service_links', 'external_bundle_id', 'INTEGER');
|
|
544
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_external_bundle ON routes(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
|
|
545
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_external_bundle ON service_calls(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
|
|
546
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_links_external_bundle ON service_links(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
|
|
547
|
+
// v4 backfill — required because upsertFileWithCache() short-circuits on
|
|
548
|
+
// unchanged content hash, so a v3 DB upgraded to v4 would never get
|
|
549
|
+
// symbol_key populated (nor FTS rebuilt) for any file whose source hadn't
|
|
550
|
+
// changed. That left seer_history with zero candidates and FTS search
|
|
551
|
+
// returning empty for the entire pre-upgrade corpus until a manual
|
|
552
|
+
// --reset. Both backfills are cheap and idempotent.
|
|
553
|
+
if (isV4Migration) {
|
|
554
|
+
this.backfillSymbolKeysFromExistingRows();
|
|
555
|
+
}
|
|
556
|
+
// FTS rebuild: detect "v4 columns exist but FTS tables are empty while
|
|
557
|
+
// symbols/files have rows". Triggers on the v3→v4 upgrade AND on the rare
|
|
558
|
+
// case where a v4 DB lost its FTS rows (e.g. a manual schema patch). The
|
|
559
|
+
// check is constant-time (COUNT on empty FTS is instant).
|
|
560
|
+
this.rebuildFtsIfStale();
|
|
561
|
+
this.db.prepare("INSERT INTO _schema_meta (key, value) VALUES ('schema_version', ?) " +
|
|
562
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(schema_js_1.CURRENT_SCHEMA_VERSION));
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Populate symbols.symbol_key for every existing row. Mirrors
|
|
566
|
+
* makeSymbolKey() — `kind:qualified_name` (or `kind:name` if qualified is
|
|
567
|
+
* NULL). symbol_history is keyed on these so without the backfill,
|
|
568
|
+
* listSymbolsForHistoryIndex() returns zero candidates after a v3→v4
|
|
569
|
+
* upgrade.
|
|
570
|
+
*/
|
|
571
|
+
backfillSymbolKeysFromExistingRows() {
|
|
572
|
+
try {
|
|
573
|
+
this.db.exec(`
|
|
574
|
+
UPDATE symbols
|
|
575
|
+
SET symbol_key = kind || ':' || COALESCE(qualified_name, name)
|
|
576
|
+
WHERE symbol_key IS NULL
|
|
577
|
+
`);
|
|
578
|
+
}
|
|
579
|
+
catch { /* table may not exist on a brand-new DB; non-fatal */ }
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Rebuild symbols_fts / files_fts from the current symbols / files rows if
|
|
583
|
+
* either FTS table is empty while its source table has rows. This is the
|
|
584
|
+
* only safe trigger condition — Seer never deliberately leaves FTS empty
|
|
585
|
+
* while symbols are populated, so emptiness is a reliable "stale FTS"
|
|
586
|
+
* signal (post-migration or post-manual-patch).
|
|
587
|
+
*/
|
|
588
|
+
rebuildFtsIfStale() {
|
|
589
|
+
try {
|
|
590
|
+
const sym = this.db.prepare('SELECT COUNT(*) AS c FROM symbols').get();
|
|
591
|
+
const symFts = this.db.prepare('SELECT COUNT(*) AS c FROM symbols_fts').get();
|
|
592
|
+
if (toNum(sym.c) > 0 && toNum(symFts.c) === 0) {
|
|
593
|
+
const ins = this.db.prepare('INSERT INTO symbols_fts(rowid, name, qualified_name, signature, split) VALUES (?, ?, ?, ?, ?)');
|
|
594
|
+
const rows = this.db.prepare('SELECT id, name, qualified_name, signature FROM symbols').all();
|
|
595
|
+
this.db.exec('BEGIN');
|
|
596
|
+
try {
|
|
597
|
+
for (const r of rows) {
|
|
598
|
+
const name = toStr(r.name);
|
|
599
|
+
const qual = toStr(r.qualified_name ?? r.name);
|
|
600
|
+
ins.run(toNum(r.id), name, qual, toStr(r.signature ?? ''), splitIdentifierTokens(`${name} ${qual}`));
|
|
601
|
+
}
|
|
602
|
+
this.db.exec('COMMIT');
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
this.db.exec('ROLLBACK');
|
|
606
|
+
throw err;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch { /* FTS5 unavailable; non-fatal */ }
|
|
611
|
+
try {
|
|
612
|
+
const file = this.db.prepare('SELECT COUNT(*) AS c FROM files').get();
|
|
613
|
+
const fileFts = this.db.prepare('SELECT COUNT(*) AS c FROM files_fts').get();
|
|
614
|
+
if (toNum(file.c) > 0 && toNum(fileFts.c) === 0) {
|
|
615
|
+
const ins = this.db.prepare('INSERT INTO files_fts(rowid, rel_path) VALUES (?, ?)');
|
|
616
|
+
const rows = this.db.prepare('SELECT id, rel_path FROM files').all();
|
|
617
|
+
this.db.exec('BEGIN');
|
|
618
|
+
try {
|
|
619
|
+
for (const r of rows) {
|
|
620
|
+
ins.run(toNum(r.id), splitIdentifierTokens(toStr(r.rel_path)));
|
|
621
|
+
}
|
|
622
|
+
this.db.exec('COMMIT');
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
this.db.exec('ROLLBACK');
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch { /* FTS5 unavailable; non-fatal */ }
|
|
631
|
+
}
|
|
632
|
+
hasColumn(table, column) {
|
|
633
|
+
try {
|
|
634
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
635
|
+
return cols.some(c => toStr(c.name) === column);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
addColumnIfMissing(table, column, def) {
|
|
642
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
643
|
+
if (cols.some(c => toStr(c.name) === column))
|
|
644
|
+
return;
|
|
645
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`);
|
|
646
|
+
}
|
|
647
|
+
prepare() {
|
|
648
|
+
this.stmtUpsertFile = this.db.prepare(`
|
|
649
|
+
INSERT INTO files (path, rel_path, language, hash, lines, indexed_at, role, is_vendor, is_generated)
|
|
650
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
651
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
652
|
+
rel_path = excluded.rel_path,
|
|
653
|
+
language = excluded.language,
|
|
654
|
+
hash = excluded.hash,
|
|
655
|
+
lines = excluded.lines,
|
|
656
|
+
indexed_at = excluded.indexed_at,
|
|
657
|
+
role = excluded.role,
|
|
658
|
+
is_vendor = excluded.is_vendor,
|
|
659
|
+
is_generated = excluded.is_generated
|
|
660
|
+
`);
|
|
661
|
+
this.stmtInsertSymbol = this.db.prepare(`
|
|
662
|
+
INSERT INTO symbols
|
|
663
|
+
(name, qualified_name, kind, file_id, line_start, line_end, col_start, col_end,
|
|
664
|
+
signature, is_rankable, loc, cyclomatic, cognitive, max_nesting, symbol_key, symbol_role)
|
|
665
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
666
|
+
`);
|
|
667
|
+
this.stmtInsertEdge = this.db.prepare(`
|
|
668
|
+
INSERT INTO edges (from_id, to_name, kind, line) VALUES (?, ?, ?, ?)
|
|
669
|
+
`);
|
|
670
|
+
this.stmtInsertFileImport = this.db.prepare(`
|
|
671
|
+
INSERT OR IGNORE INTO file_imports (from_file_id, import_name) VALUES (?, ?)
|
|
672
|
+
`);
|
|
673
|
+
this.stmtInsertRoute = this.db.prepare(`
|
|
674
|
+
INSERT INTO routes
|
|
675
|
+
(file_id, method, path, framework, handler_name, line,
|
|
676
|
+
protocol, operation, topic, queue, exchange, service, broker, metadata_json)
|
|
677
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
678
|
+
`);
|
|
679
|
+
this.stmtInsertConfigKey = this.db.prepare(`
|
|
680
|
+
INSERT INTO config_keys (key, source, file_id, symbol_id, line)
|
|
681
|
+
VALUES (?, ?, ?, ?, ?)
|
|
682
|
+
`);
|
|
683
|
+
this.stmtInsertExternalDep = this.db.prepare(`
|
|
684
|
+
INSERT OR REPLACE INTO external_dependencies
|
|
685
|
+
(ecosystem, name, version_range, manifest_path, is_dev)
|
|
686
|
+
VALUES (?, ?, ?, ?, ?)
|
|
687
|
+
`);
|
|
688
|
+
this.stmtInsertServiceCall = this.db.prepare(`
|
|
689
|
+
INSERT INTO service_calls
|
|
690
|
+
(file_id, symbol_id, protocol, method, raw_target, normalized_path,
|
|
691
|
+
host_hint, env_key, framework, line, confidence,
|
|
692
|
+
operation, topic, queue, exchange, service, broker, metadata_json)
|
|
693
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
694
|
+
`);
|
|
695
|
+
this.stmtInsertServiceLink = this.db.prepare(`
|
|
696
|
+
INSERT INTO service_links
|
|
697
|
+
(call_id, route_id, caller_symbol_id, handler_symbol_id,
|
|
698
|
+
protocol, match_kind, confidence, evidence_json)
|
|
699
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
700
|
+
`);
|
|
701
|
+
this.stmtInsertSymbolsFts = this.db.prepare('INSERT INTO symbols_fts(rowid, name, qualified_name, signature, split) VALUES (?, ?, ?, ?, ?)');
|
|
702
|
+
this.stmtInsertFilesFts = this.db.prepare('INSERT INTO files_fts(rowid, rel_path) VALUES (?, ?)');
|
|
703
|
+
this.stmtDeleteSymbolsFtsForFile = this.db.prepare('DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_id = ?)');
|
|
704
|
+
this.stmtDeleteFilesFtsForFile = this.db.prepare('DELETE FROM files_fts WHERE rowid = ?');
|
|
705
|
+
}
|
|
706
|
+
// ── Write operations ────────────────────────────────────────────────────────
|
|
707
|
+
pruneFilesNotIn(keepIds) {
|
|
708
|
+
this.assertWritable();
|
|
709
|
+
// v10 — external bundle phantom files use path 'external' as language so
|
|
710
|
+
// they're never pruned by accident on a cached re-index. The pruner adds
|
|
711
|
+
// them to keepIds before the delete pass so importing an external bundle,
|
|
712
|
+
// then running a regular `seer index`, leaves the external layer intact.
|
|
713
|
+
const externalIds = this.listExternalPhantomFileIds();
|
|
714
|
+
if (keepIds.size === 0 && externalIds.length === 0) {
|
|
715
|
+
const res = this.db.prepare('DELETE FROM files').run();
|
|
716
|
+
// FTS is contentless — wipe in parallel.
|
|
717
|
+
try {
|
|
718
|
+
this.db.exec('DELETE FROM symbols_fts');
|
|
719
|
+
this.db.exec('DELETE FROM files_fts');
|
|
720
|
+
}
|
|
721
|
+
catch { /* */ }
|
|
722
|
+
return toNum(res.changes);
|
|
723
|
+
}
|
|
724
|
+
this.db.exec('BEGIN');
|
|
725
|
+
try {
|
|
726
|
+
this.db.exec('CREATE TEMP TABLE IF NOT EXISTS _keep (id INTEGER PRIMARY KEY)');
|
|
727
|
+
this.db.exec('DELETE FROM _keep');
|
|
728
|
+
const insert = this.db.prepare('INSERT INTO _keep (id) VALUES (?)');
|
|
729
|
+
for (const id of keepIds)
|
|
730
|
+
insert.run(id);
|
|
731
|
+
for (const id of externalIds) {
|
|
732
|
+
try {
|
|
733
|
+
insert.run(id);
|
|
734
|
+
}
|
|
735
|
+
catch { /* duplicate keep id; ignore */ }
|
|
736
|
+
}
|
|
737
|
+
// Wipe FTS rows for files we're about to delete (pre-delete, before
|
|
738
|
+
// their ids become unrecoverable).
|
|
739
|
+
try {
|
|
740
|
+
this.db.exec(`
|
|
741
|
+
DELETE FROM symbols_fts WHERE rowid IN (
|
|
742
|
+
SELECT s.id FROM symbols s
|
|
743
|
+
JOIN files f ON f.id = s.file_id
|
|
744
|
+
WHERE f.id NOT IN (SELECT id FROM _keep)
|
|
745
|
+
)
|
|
746
|
+
`);
|
|
747
|
+
this.db.exec(`
|
|
748
|
+
DELETE FROM files_fts WHERE rowid IN (
|
|
749
|
+
SELECT id FROM files WHERE id NOT IN (SELECT id FROM _keep)
|
|
750
|
+
)
|
|
751
|
+
`);
|
|
752
|
+
}
|
|
753
|
+
catch { /* */ }
|
|
754
|
+
const res = this.db.prepare('DELETE FROM files WHERE id NOT IN (SELECT id FROM _keep)').run();
|
|
755
|
+
this.db.exec('COMMIT');
|
|
756
|
+
return toNum(res.changes);
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
this.db.exec('ROLLBACK');
|
|
760
|
+
throw err;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
upsertFile(path, relPath, language, hash, lines, classification = { role: 'project', isVendor: 0, isGenerated: 0 }) {
|
|
764
|
+
this.assertWritable();
|
|
765
|
+
const existing = this.db.prepare('SELECT id FROM files WHERE path = ?').get(path);
|
|
766
|
+
if (existing) {
|
|
767
|
+
const fileId = toNum(existing.id);
|
|
768
|
+
// Wipe FTS rows + dependent table rows for this file
|
|
769
|
+
try {
|
|
770
|
+
this.stmtDeleteSymbolsFtsForFile.run(fileId);
|
|
771
|
+
}
|
|
772
|
+
catch { /* */ }
|
|
773
|
+
this.db.prepare('DELETE FROM symbols WHERE file_id = ?').run(fileId);
|
|
774
|
+
this.db.prepare('DELETE FROM file_imports WHERE from_file_id = ?').run(fileId);
|
|
775
|
+
this.db.prepare('DELETE FROM routes WHERE file_id = ?').run(fileId);
|
|
776
|
+
this.db.prepare('DELETE FROM config_keys WHERE file_id = ?').run(fileId);
|
|
777
|
+
this.db.prepare('DELETE FROM service_calls WHERE file_id = ?').run(fileId);
|
|
778
|
+
try {
|
|
779
|
+
this.stmtDeleteFilesFtsForFile.run(fileId);
|
|
780
|
+
}
|
|
781
|
+
catch { /* */ }
|
|
782
|
+
}
|
|
783
|
+
const result = this.stmtUpsertFile.run(path, relPath, language, hash, lines, Date.now(), classification.role, classification.isVendor, classification.isGenerated);
|
|
784
|
+
const fileId = existing ? toNum(existing.id) : toNum(result.lastInsertRowid);
|
|
785
|
+
try {
|
|
786
|
+
this.stmtInsertFilesFts.run(fileId, splitIdentifierTokens(relPath));
|
|
787
|
+
}
|
|
788
|
+
catch { /* */ }
|
|
789
|
+
return fileId;
|
|
790
|
+
}
|
|
791
|
+
upsertFileWithCache(path, relPath, language, hash, lines, classification = { role: 'project', isVendor: 0, isGenerated: 0 }) {
|
|
792
|
+
this.assertWritable();
|
|
793
|
+
const existing = this.db
|
|
794
|
+
.prepare('SELECT id, hash, role, is_vendor, is_generated FROM files WHERE path = ?')
|
|
795
|
+
.get(path);
|
|
796
|
+
if (existing && toStr(existing.hash) === hash) {
|
|
797
|
+
const fileId = toNum(existing.id);
|
|
798
|
+
const existingRole = toStr(existing.role);
|
|
799
|
+
const existingVendor = toNum(existing.is_vendor);
|
|
800
|
+
const existingGen = toNum(existing.is_generated);
|
|
801
|
+
if (existingRole !== classification.role ||
|
|
802
|
+
existingVendor !== classification.isVendor ||
|
|
803
|
+
existingGen !== classification.isGenerated) {
|
|
804
|
+
this.db.prepare('UPDATE files SET indexed_at = ?, role = ?, is_vendor = ?, is_generated = ? WHERE id = ?').run(Date.now(), classification.role, classification.isVendor, classification.isGenerated, fileId);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
this.db.prepare('UPDATE files SET indexed_at = ? WHERE id = ?')
|
|
808
|
+
.run(Date.now(), fileId);
|
|
809
|
+
}
|
|
810
|
+
return { fileId, unchanged: true };
|
|
811
|
+
}
|
|
812
|
+
if (existing) {
|
|
813
|
+
const fileId = toNum(existing.id);
|
|
814
|
+
try {
|
|
815
|
+
this.stmtDeleteSymbolsFtsForFile.run(fileId);
|
|
816
|
+
}
|
|
817
|
+
catch { /* */ }
|
|
818
|
+
this.db.prepare('DELETE FROM symbols WHERE file_id = ?').run(fileId);
|
|
819
|
+
this.db.prepare('DELETE FROM file_imports WHERE from_file_id = ?').run(fileId);
|
|
820
|
+
this.db.prepare('DELETE FROM routes WHERE file_id = ?').run(fileId);
|
|
821
|
+
this.db.prepare('DELETE FROM config_keys WHERE file_id = ?').run(fileId);
|
|
822
|
+
this.db.prepare('DELETE FROM service_calls WHERE file_id = ?').run(fileId);
|
|
823
|
+
try {
|
|
824
|
+
this.stmtDeleteFilesFtsForFile.run(fileId);
|
|
825
|
+
}
|
|
826
|
+
catch { /* */ }
|
|
827
|
+
}
|
|
828
|
+
const result = this.stmtUpsertFile.run(path, relPath, language, hash, lines, Date.now(), classification.role, classification.isVendor, classification.isGenerated);
|
|
829
|
+
const fileId = existing ? toNum(existing.id) : toNum(result.lastInsertRowid);
|
|
830
|
+
try {
|
|
831
|
+
this.stmtInsertFilesFts.run(fileId, splitIdentifierTokens(relPath));
|
|
832
|
+
}
|
|
833
|
+
catch { /* */ }
|
|
834
|
+
return { fileId, unchanged: false };
|
|
835
|
+
}
|
|
836
|
+
insertSymbol(fileId, def) {
|
|
837
|
+
this.assertWritable();
|
|
838
|
+
const sig = def.signature ? def.signature.slice(0, 240) : null;
|
|
839
|
+
const qualified = def.qualifiedName ?? def.name;
|
|
840
|
+
const symbolRole = def.symbolRole ?? 'definition';
|
|
841
|
+
// Declarations are not call targets in the same canonical sense as
|
|
842
|
+
// definitions, so they're excluded from PageRank just like type rows.
|
|
843
|
+
// The kind-based rankability still applies — a class declaration would
|
|
844
|
+
// already be non-rankable from the kind check; this is the belt-and-
|
|
845
|
+
// suspenders guard for the rarer "method declaration" case.
|
|
846
|
+
const rankable = (symbolRole === 'definition' && isRankableKind(def.kind)) ? 1 : 0;
|
|
847
|
+
const symbolKey = makeSymbolKey(def.kind, qualified);
|
|
848
|
+
const result = this.stmtInsertSymbol.run(def.name, qualified, def.kind, fileId, def.lineStart, def.lineEnd, def.colStart, def.colEnd, sig, rankable, def.loc ?? null, def.cyclomatic ?? null, def.cognitive ?? null, def.maxNesting ?? null, symbolKey, symbolRole);
|
|
849
|
+
const symbolId = toNum(result.lastInsertRowid);
|
|
850
|
+
try {
|
|
851
|
+
this.stmtInsertSymbolsFts.run(symbolId, def.name, qualified, sig ?? '', splitIdentifierTokens(`${def.name} ${qualified}`));
|
|
852
|
+
}
|
|
853
|
+
catch { /* FTS5 unavailable; non-fatal */ }
|
|
854
|
+
return symbolId;
|
|
855
|
+
}
|
|
856
|
+
insertEdge(fromSymbolId, toName, kind, line) {
|
|
857
|
+
this.assertWritable();
|
|
858
|
+
this.stmtInsertEdge.run(fromSymbolId, toName, kind, line);
|
|
859
|
+
}
|
|
860
|
+
insertFileImport(fromFileId, importName) {
|
|
861
|
+
this.assertWritable();
|
|
862
|
+
this.stmtInsertFileImport.run(fromFileId, importName);
|
|
863
|
+
}
|
|
864
|
+
insertRoute(fileId, method, routePath, framework, handlerName, line, options = {}) {
|
|
865
|
+
this.assertWritable();
|
|
866
|
+
this.stmtInsertRoute.run(fileId, method, routePath, framework, handlerName, line, options.protocol ?? 'http', options.operation ?? null, options.topic ?? null, options.queue ?? null, options.exchange ?? null, options.service ?? null, options.broker ?? null, options.metadataJson ?? null);
|
|
867
|
+
}
|
|
868
|
+
insertConfigKey(key, source, fileId, symbolId, line) {
|
|
869
|
+
this.assertWritable();
|
|
870
|
+
this.stmtInsertConfigKey.run(key, source, fileId, symbolId, line);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* v8 Track G — return a closure that inserts service_link rows. Used by
|
|
874
|
+
* the resolver in `resolveServiceLinks(store)` so it can stream inserts
|
|
875
|
+
* inside one prepared statement rather than re-resolving the statement
|
|
876
|
+
* per row.
|
|
877
|
+
*/
|
|
878
|
+
makeServiceLinkInserter() {
|
|
879
|
+
this.assertWritable();
|
|
880
|
+
const stmt = this.stmtInsertServiceLink;
|
|
881
|
+
return (a) => {
|
|
882
|
+
stmt.run(a.callId, a.routeId, a.callerSymbolId, a.handlerSymbolId, a.protocol, a.matchKind, a.confidence, a.evidenceJson);
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* v8 Track G — insert a service-call row (outbound HTTP/etc. client call).
|
|
887
|
+
* The post-index resolver derives service_links from these and from routes.
|
|
888
|
+
* Returns the new row id so callers can attach evidence in the same batch.
|
|
889
|
+
*/
|
|
890
|
+
insertServiceCall(args) {
|
|
891
|
+
this.assertWritable();
|
|
892
|
+
const r = this.stmtInsertServiceCall.run(args.fileId, args.symbolId, args.protocol, args.method, args.rawTarget.slice(0, 240), args.normalizedPath, args.hostHint, args.envKey, args.framework, args.line, args.confidence, args.operation ?? null, args.topic ?? null, args.queue ?? null, args.exchange ?? null, args.service ?? null, args.broker ?? null, args.metadataJson ?? null);
|
|
893
|
+
return toNum(r.lastInsertRowid);
|
|
894
|
+
}
|
|
895
|
+
insertExternalDep(ecosystem, name, versionRange, manifestPath, isDev) {
|
|
896
|
+
this.assertWritable();
|
|
897
|
+
this.stmtInsertExternalDep.run(ecosystem, name, versionRange, manifestPath, isDev);
|
|
898
|
+
}
|
|
899
|
+
clearExternalDeps() {
|
|
900
|
+
this.assertWritable();
|
|
901
|
+
this.db.exec('DELETE FROM external_dependencies');
|
|
902
|
+
}
|
|
903
|
+
// ── Import resolution ───────────────────────────────────────────────────────
|
|
904
|
+
resolveImports() {
|
|
905
|
+
const files = this.db.prepare('SELECT id, path, language FROM files').all();
|
|
906
|
+
if (files.length === 0)
|
|
907
|
+
return 0;
|
|
908
|
+
const fileByPath = new Map();
|
|
909
|
+
for (const f of files) {
|
|
910
|
+
fileByPath.set(normalizePath(toStr(f.path)), toNum(f.id));
|
|
911
|
+
}
|
|
912
|
+
const imports = this.db.prepare(`
|
|
913
|
+
SELECT fi.id, fi.from_file_id, fi.import_name, f.path AS from_path, f.language
|
|
914
|
+
FROM file_imports fi
|
|
915
|
+
JOIN files f ON f.id = fi.from_file_id
|
|
916
|
+
WHERE fi.resolved_file_id IS NULL
|
|
917
|
+
`).all();
|
|
918
|
+
const updateStmt = this.db.prepare('UPDATE file_imports SET resolved_file_id = ? WHERE id = ?');
|
|
919
|
+
let resolved = 0;
|
|
920
|
+
this.db.exec('BEGIN');
|
|
921
|
+
try {
|
|
922
|
+
for (const imp of imports) {
|
|
923
|
+
const fromPath = toStr(imp.from_path);
|
|
924
|
+
const language = toStr(imp.language);
|
|
925
|
+
const importName = toStr(imp.import_name);
|
|
926
|
+
const targetId = resolveImportToFileId(fromPath, language, importName, fileByPath);
|
|
927
|
+
if (targetId !== null) {
|
|
928
|
+
updateStmt.run(targetId, toNum(imp.id));
|
|
929
|
+
resolved++;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
this.db.exec('COMMIT');
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
this.db.exec('ROLLBACK');
|
|
936
|
+
throw err;
|
|
937
|
+
}
|
|
938
|
+
return resolved;
|
|
939
|
+
}
|
|
940
|
+
// ── Edge resolution (scope-aware) ───────────────────────────────────────────
|
|
941
|
+
resolveEdges() {
|
|
942
|
+
const countUnresolved = () => toNum(this.db.prepare('SELECT COUNT(*) AS c FROM edges WHERE to_id IS NULL').get().c);
|
|
943
|
+
const before0 = countUnresolved();
|
|
944
|
+
this.db.prepare(`
|
|
945
|
+
UPDATE edges
|
|
946
|
+
SET to_id = (
|
|
947
|
+
SELECT t.id
|
|
948
|
+
FROM symbols t, symbols s
|
|
949
|
+
WHERE s.id = edges.from_id
|
|
950
|
+
AND t.name = edges.to_name
|
|
951
|
+
AND t.file_id = s.file_id
|
|
952
|
+
LIMIT 1
|
|
953
|
+
)
|
|
954
|
+
WHERE to_id IS NULL
|
|
955
|
+
AND EXISTS (
|
|
956
|
+
SELECT 1
|
|
957
|
+
FROM symbols t, symbols s
|
|
958
|
+
WHERE s.id = edges.from_id
|
|
959
|
+
AND t.name = edges.to_name
|
|
960
|
+
AND t.file_id = s.file_id
|
|
961
|
+
);
|
|
962
|
+
`).run();
|
|
963
|
+
const after1 = countUnresolved();
|
|
964
|
+
const sameFile = before0 - after1;
|
|
965
|
+
this.db.prepare(`
|
|
966
|
+
UPDATE edges
|
|
967
|
+
SET to_id = (
|
|
968
|
+
SELECT t.id
|
|
969
|
+
FROM symbols t
|
|
970
|
+
JOIN file_imports fi ON fi.resolved_file_id = t.file_id
|
|
971
|
+
JOIN symbols s ON s.id = edges.from_id
|
|
972
|
+
WHERE fi.from_file_id = s.file_id
|
|
973
|
+
AND t.name = edges.to_name
|
|
974
|
+
LIMIT 1
|
|
975
|
+
)
|
|
976
|
+
WHERE to_id IS NULL
|
|
977
|
+
AND EXISTS (
|
|
978
|
+
SELECT 1
|
|
979
|
+
FROM symbols t
|
|
980
|
+
JOIN file_imports fi ON fi.resolved_file_id = t.file_id
|
|
981
|
+
JOIN symbols s ON s.id = edges.from_id
|
|
982
|
+
WHERE fi.from_file_id = s.file_id
|
|
983
|
+
AND t.name = edges.to_name
|
|
984
|
+
);
|
|
985
|
+
`).run();
|
|
986
|
+
const after2 = countUnresolved();
|
|
987
|
+
const imported = after1 - after2;
|
|
988
|
+
this.db.prepare(`
|
|
989
|
+
UPDATE edges
|
|
990
|
+
SET to_id = (
|
|
991
|
+
SELECT id FROM symbols WHERE name = edges.to_name LIMIT 1
|
|
992
|
+
)
|
|
993
|
+
WHERE to_id IS NULL
|
|
994
|
+
AND EXISTS (SELECT 1 FROM symbols WHERE name = edges.to_name);
|
|
995
|
+
`).run();
|
|
996
|
+
const after3 = countUnresolved();
|
|
997
|
+
const global = after2 - after3;
|
|
998
|
+
return {
|
|
999
|
+
sameFile,
|
|
1000
|
+
imported,
|
|
1001
|
+
global,
|
|
1002
|
+
total: sameFile + imported + global,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* After symbol IDs are known, link routes.handler_id by name. Routes that
|
|
1007
|
+
* named a handler not defined in the same file stay with handler_id NULL.
|
|
1008
|
+
* Matching is by `handler_name = symbols.name` AND `file_id = routes.file_id`
|
|
1009
|
+
* — handlers nearly always live in the same file as the route registration.
|
|
1010
|
+
*/
|
|
1011
|
+
resolveRouteHandlers() {
|
|
1012
|
+
const res = this.db.prepare(`
|
|
1013
|
+
UPDATE routes
|
|
1014
|
+
SET handler_id = (
|
|
1015
|
+
SELECT s.id FROM symbols s
|
|
1016
|
+
WHERE s.file_id = routes.file_id
|
|
1017
|
+
AND s.name = routes.handler_name
|
|
1018
|
+
LIMIT 1
|
|
1019
|
+
)
|
|
1020
|
+
WHERE handler_id IS NULL
|
|
1021
|
+
AND handler_name IS NOT NULL
|
|
1022
|
+
`).run();
|
|
1023
|
+
return toNum(res.changes);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Backfill config_keys.symbol_id by line span. The extractor doesn't always
|
|
1027
|
+
* know the enclosing symbol id (extraction precedes symbol insertion), so we
|
|
1028
|
+
* resolve it via "the smallest function/method containing this line."
|
|
1029
|
+
*/
|
|
1030
|
+
resolveConfigKeySymbols() {
|
|
1031
|
+
const res = this.db.prepare(`
|
|
1032
|
+
UPDATE config_keys
|
|
1033
|
+
SET symbol_id = (
|
|
1034
|
+
SELECT s.id FROM symbols s
|
|
1035
|
+
WHERE s.file_id = config_keys.file_id
|
|
1036
|
+
AND s.line_start <= config_keys.line
|
|
1037
|
+
AND s.line_end >= config_keys.line
|
|
1038
|
+
AND s.kind IN ('function','method','constructor')
|
|
1039
|
+
ORDER BY (s.line_end - s.line_start) ASC
|
|
1040
|
+
LIMIT 1
|
|
1041
|
+
)
|
|
1042
|
+
WHERE symbol_id IS NULL
|
|
1043
|
+
`).run();
|
|
1044
|
+
return toNum(res.changes);
|
|
1045
|
+
}
|
|
1046
|
+
// ── Test-edge synthesis ─────────────────────────────────────────────────────
|
|
1047
|
+
/**
|
|
1048
|
+
* Promote calls from a test-file symbol to a non-test target into 'tests'
|
|
1049
|
+
* edges (in addition to keeping the original 'call' edge). The original
|
|
1050
|
+
* call edge is left in place so caller/callee queries don't double-count;
|
|
1051
|
+
* test edges live in their own kind so `seer_behavior` can pull them
|
|
1052
|
+
* directly without scanning the full edge table.
|
|
1053
|
+
*
|
|
1054
|
+
* The synthesized edge copies the SOURCE 'call' edge's `to_id` verbatim —
|
|
1055
|
+
* the call-edge resolution pass already did the same-file / imported /
|
|
1056
|
+
* global fallback work to pick the correct target. Re-resolving by name
|
|
1057
|
+
* via `WHERE name = edges.to_name LIMIT 1` (the old behavior) was buggy
|
|
1058
|
+
* when two symbols shared the same short name (`Alpha.run` / `Beta.run`):
|
|
1059
|
+
* `LIMIT 1` would attribute every test edge to whichever id sorted first,
|
|
1060
|
+
* so `seer_behavior(Beta.run)` returned tests that actually exercised
|
|
1061
|
+
* `Alpha.run`. Preserving the source `to_id` matches what the original
|
|
1062
|
+
* resolver already chose.
|
|
1063
|
+
*
|
|
1064
|
+
* Returns the number of new test edges inserted.
|
|
1065
|
+
*/
|
|
1066
|
+
synthesizeTestEdges() {
|
|
1067
|
+
// Find call edges from a test file to a non-test target whose 'tests'
|
|
1068
|
+
// counterpart doesn't yet exist.
|
|
1069
|
+
const rows = this.db.prepare(`
|
|
1070
|
+
SELECT e.from_id, e.to_id, e.to_name, e.line
|
|
1071
|
+
FROM edges e
|
|
1072
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1073
|
+
JOIN files fs ON fs.id = s.file_id
|
|
1074
|
+
JOIN symbols t ON t.id = e.to_id
|
|
1075
|
+
JOIN files ft ON ft.id = t.file_id
|
|
1076
|
+
WHERE e.kind = 'call'
|
|
1077
|
+
AND fs.role = 'test'
|
|
1078
|
+
AND ft.role <> 'test'
|
|
1079
|
+
AND NOT EXISTS (
|
|
1080
|
+
SELECT 1 FROM edges e2
|
|
1081
|
+
WHERE e2.from_id = e.from_id
|
|
1082
|
+
AND e2.to_id = e.to_id
|
|
1083
|
+
AND e2.kind = 'tests'
|
|
1084
|
+
)
|
|
1085
|
+
`).all();
|
|
1086
|
+
if (rows.length === 0)
|
|
1087
|
+
return 0;
|
|
1088
|
+
// Insert with to_id set explicitly from the source edge — no LIMIT 1
|
|
1089
|
+
// name re-resolution that would collapse same-short-name symbols.
|
|
1090
|
+
const insert = this.db.prepare("INSERT INTO edges (from_id, to_name, to_id, kind, line) VALUES (?, ?, ?, 'tests', ?)");
|
|
1091
|
+
this.db.exec('BEGIN');
|
|
1092
|
+
try {
|
|
1093
|
+
for (const r of rows) {
|
|
1094
|
+
insert.run(toNum(r.from_id), toStr(r.to_name), toNum(r.to_id), toNum(r.line));
|
|
1095
|
+
}
|
|
1096
|
+
this.db.exec('COMMIT');
|
|
1097
|
+
}
|
|
1098
|
+
catch (err) {
|
|
1099
|
+
this.db.exec('ROLLBACK');
|
|
1100
|
+
throw err;
|
|
1101
|
+
}
|
|
1102
|
+
return rows.length;
|
|
1103
|
+
}
|
|
1104
|
+
// ── Read operations ─────────────────────────────────────────────────────────
|
|
1105
|
+
findCallers(symbolName, limit) {
|
|
1106
|
+
const hasLimit = typeof limit === 'number' && limit > 0;
|
|
1107
|
+
const sql = hasLimit
|
|
1108
|
+
? `
|
|
1109
|
+
SELECT
|
|
1110
|
+
s.name AS callerName,
|
|
1111
|
+
s.qualified_name AS callerQualifiedName,
|
|
1112
|
+
s.kind AS callerKind,
|
|
1113
|
+
f.path AS callerFile,
|
|
1114
|
+
e.line AS callerLine,
|
|
1115
|
+
e.kind AS edgeKind
|
|
1116
|
+
FROM edges e
|
|
1117
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1118
|
+
JOIN files f ON f.id = s.file_id
|
|
1119
|
+
WHERE e.to_name = ? AND e.kind = 'call'
|
|
1120
|
+
LIMIT ?
|
|
1121
|
+
`
|
|
1122
|
+
: `
|
|
1123
|
+
SELECT
|
|
1124
|
+
s.name AS callerName,
|
|
1125
|
+
s.qualified_name AS callerQualifiedName,
|
|
1126
|
+
s.kind AS callerKind,
|
|
1127
|
+
f.path AS callerFile,
|
|
1128
|
+
e.line AS callerLine,
|
|
1129
|
+
e.kind AS edgeKind
|
|
1130
|
+
FROM edges e
|
|
1131
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1132
|
+
JOIN files f ON f.id = s.file_id
|
|
1133
|
+
WHERE e.to_name = ? AND e.kind = 'call'
|
|
1134
|
+
ORDER BY f.path, e.line
|
|
1135
|
+
`;
|
|
1136
|
+
const stmt = this.db.prepare(sql);
|
|
1137
|
+
const rows = (hasLimit
|
|
1138
|
+
? stmt.all(symbolName, limit)
|
|
1139
|
+
: stmt.all(symbolName));
|
|
1140
|
+
const out = rows.map(r => ({
|
|
1141
|
+
callerName: toStr(r.callerName),
|
|
1142
|
+
callerQualifiedName: toNullStr(r.callerQualifiedName),
|
|
1143
|
+
callerKind: toStr(r.callerKind),
|
|
1144
|
+
callerFile: toStr(r.callerFile),
|
|
1145
|
+
callerLine: toNum(r.callerLine),
|
|
1146
|
+
edgeKind: toStr(r.edgeKind),
|
|
1147
|
+
}));
|
|
1148
|
+
if (hasLimit) {
|
|
1149
|
+
out.sort((a, b) => a.callerFile < b.callerFile ? -1 :
|
|
1150
|
+
a.callerFile > b.callerFile ? 1 :
|
|
1151
|
+
a.callerLine - b.callerLine);
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
countCallers(symbolName) {
|
|
1156
|
+
const row = this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_name = ? AND kind = 'call'").get(symbolName);
|
|
1157
|
+
return toNum(row.c);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Callers of a specific symbol id — never collapses short-name siblings.
|
|
1161
|
+
* Track E + any tool that already has a resolved symbol id should use
|
|
1162
|
+
* this instead of `findCallers(name)`. Edges whose `to_id` is NULL
|
|
1163
|
+
* (unresolved) are intentionally skipped: with no resolved id we can't
|
|
1164
|
+
* tell whether they target THIS specific symbol vs. a same-short-name
|
|
1165
|
+
* sibling, and Track E callers want id-specificity.
|
|
1166
|
+
*/
|
|
1167
|
+
findCallersById(symbolId, limit) {
|
|
1168
|
+
const hasLimit = typeof limit === 'number' && limit > 0;
|
|
1169
|
+
const sql = hasLimit
|
|
1170
|
+
? `
|
|
1171
|
+
SELECT
|
|
1172
|
+
s.name AS callerName,
|
|
1173
|
+
s.qualified_name AS callerQualifiedName,
|
|
1174
|
+
s.kind AS callerKind,
|
|
1175
|
+
f.path AS callerFile,
|
|
1176
|
+
e.line AS callerLine,
|
|
1177
|
+
e.kind AS edgeKind
|
|
1178
|
+
FROM edges e
|
|
1179
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1180
|
+
JOIN files f ON f.id = s.file_id
|
|
1181
|
+
WHERE e.to_id = ? AND e.kind = 'call'
|
|
1182
|
+
LIMIT ?
|
|
1183
|
+
`
|
|
1184
|
+
: `
|
|
1185
|
+
SELECT
|
|
1186
|
+
s.name AS callerName,
|
|
1187
|
+
s.qualified_name AS callerQualifiedName,
|
|
1188
|
+
s.kind AS callerKind,
|
|
1189
|
+
f.path AS callerFile,
|
|
1190
|
+
e.line AS callerLine,
|
|
1191
|
+
e.kind AS edgeKind
|
|
1192
|
+
FROM edges e
|
|
1193
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1194
|
+
JOIN files f ON f.id = s.file_id
|
|
1195
|
+
WHERE e.to_id = ? AND e.kind = 'call'
|
|
1196
|
+
ORDER BY f.path, e.line
|
|
1197
|
+
`;
|
|
1198
|
+
const stmt = this.db.prepare(sql);
|
|
1199
|
+
const rows = (hasLimit ? stmt.all(symbolId, limit) : stmt.all(symbolId));
|
|
1200
|
+
const out = rows.map(r => ({
|
|
1201
|
+
callerName: toStr(r.callerName),
|
|
1202
|
+
callerQualifiedName: toNullStr(r.callerQualifiedName),
|
|
1203
|
+
callerKind: toStr(r.callerKind),
|
|
1204
|
+
callerFile: toStr(r.callerFile),
|
|
1205
|
+
callerLine: toNum(r.callerLine),
|
|
1206
|
+
edgeKind: toStr(r.edgeKind),
|
|
1207
|
+
}));
|
|
1208
|
+
if (hasLimit) {
|
|
1209
|
+
out.sort((a, b) => a.callerFile < b.callerFile ? -1 :
|
|
1210
|
+
a.callerFile > b.callerFile ? 1 :
|
|
1211
|
+
a.callerLine - b.callerLine);
|
|
1212
|
+
}
|
|
1213
|
+
return out;
|
|
1214
|
+
}
|
|
1215
|
+
/** Count of callers for a specific symbol id (id-scoped). */
|
|
1216
|
+
countCallersById(symbolId) {
|
|
1217
|
+
const row = this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_id = ? AND kind = 'call'").get(symbolId);
|
|
1218
|
+
return toNum(row.c);
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Callees emitted by a specific caller symbol id — never collapses
|
|
1222
|
+
* short-name siblings the way `findCallees(name)` does. Returns one row
|
|
1223
|
+
* per call edge.
|
|
1224
|
+
*/
|
|
1225
|
+
findCalleesById(symbolId) {
|
|
1226
|
+
const rows = this.db.prepare(`
|
|
1227
|
+
SELECT
|
|
1228
|
+
e.to_name AS calleeName,
|
|
1229
|
+
s2.kind AS calleeKind,
|
|
1230
|
+
f2.path AS calleeFile,
|
|
1231
|
+
s2.line_start AS calleeLineStart,
|
|
1232
|
+
e.kind AS edgeKind
|
|
1233
|
+
FROM edges e
|
|
1234
|
+
LEFT JOIN symbols s2 ON s2.id = e.to_id
|
|
1235
|
+
LEFT JOIN files f2 ON f2.id = s2.file_id
|
|
1236
|
+
WHERE e.from_id = ? AND e.kind = 'call'
|
|
1237
|
+
ORDER BY e.line
|
|
1238
|
+
`).all(symbolId);
|
|
1239
|
+
return rows.map(r => ({
|
|
1240
|
+
calleeName: toStr(r.calleeName),
|
|
1241
|
+
calleeKind: toNullStr(r.calleeKind),
|
|
1242
|
+
calleeFile: toNullStr(r.calleeFile),
|
|
1243
|
+
calleeLineStart: toNullNum(r.calleeLineStart),
|
|
1244
|
+
edgeKind: toStr(r.edgeKind),
|
|
1245
|
+
}));
|
|
1246
|
+
}
|
|
1247
|
+
findCallees(symbolName) {
|
|
1248
|
+
const rows = this.db.prepare(`
|
|
1249
|
+
SELECT
|
|
1250
|
+
e.to_name AS calleeName,
|
|
1251
|
+
s2.kind AS calleeKind,
|
|
1252
|
+
f2.path AS calleeFile,
|
|
1253
|
+
s2.line_start AS calleeLineStart,
|
|
1254
|
+
e.kind AS edgeKind
|
|
1255
|
+
FROM edges e
|
|
1256
|
+
JOIN symbols s ON s.id = e.from_id
|
|
1257
|
+
LEFT JOIN symbols s2 ON s2.id = e.to_id
|
|
1258
|
+
LEFT JOIN files f2 ON f2.id = s2.file_id
|
|
1259
|
+
WHERE s.name = ? AND e.kind = 'call'
|
|
1260
|
+
ORDER BY e.line
|
|
1261
|
+
`).all(symbolName);
|
|
1262
|
+
return rows.map(r => ({
|
|
1263
|
+
calleeName: toStr(r.calleeName),
|
|
1264
|
+
calleeKind: toNullStr(r.calleeKind),
|
|
1265
|
+
calleeFile: toNullStr(r.calleeFile),
|
|
1266
|
+
calleeLineStart: toNullNum(r.calleeLineStart),
|
|
1267
|
+
edgeKind: toStr(r.edgeKind),
|
|
1268
|
+
}));
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Build the predicate suffix shared by findSymbols / getDefinition /
|
|
1272
|
+
* getTopSymbols / countSymbols. Returns the `AND …` string that augments a
|
|
1273
|
+
* WHERE clause; never starts the WHERE itself so callers control the rest.
|
|
1274
|
+
*/
|
|
1275
|
+
filterClauseFromOptions(opts) {
|
|
1276
|
+
const f = resolveSearchFlags(opts);
|
|
1277
|
+
return buildRoleFilter('f.', f.includeVendor, f.includeGenerated, this.hasRoleColumns, {
|
|
1278
|
+
symbolPrefix: 's.',
|
|
1279
|
+
includeTests: f.includeTests,
|
|
1280
|
+
includeDeclarations: f.includeDeclarations,
|
|
1281
|
+
includeTypeRefs: f.includeTypeRefs,
|
|
1282
|
+
hasSymbolRoleColumn: this.hasSymbolRoleColumn,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
findSymbols(name, options = {}) {
|
|
1286
|
+
const limit = Math.max(1, options.limit ?? 50);
|
|
1287
|
+
const filter = this.filterClauseFromOptions(options);
|
|
1288
|
+
const rows = this.db.prepare(`
|
|
1289
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
1290
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1291
|
+
WHERE (s.name LIKE ? OR s.qualified_name LIKE ?)
|
|
1292
|
+
${filter}
|
|
1293
|
+
ORDER BY s.pagerank DESC
|
|
1294
|
+
LIMIT ?
|
|
1295
|
+
`).all(`%${name}%`, `%${name}%`, limit);
|
|
1296
|
+
return rows.map(toSymbolRow);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* FTS5 search across symbol name / qualified_name / signature / split form.
|
|
1300
|
+
* Falls back to `findSymbols` (LIKE) when FTS5 isn't available or returns
|
|
1301
|
+
* nothing. Returns BM25-ranked results.
|
|
1302
|
+
*/
|
|
1303
|
+
searchSymbolsFts(query, options = {}) {
|
|
1304
|
+
const limit = Math.max(1, options.limit ?? 50);
|
|
1305
|
+
if (!this.hasV4Tables)
|
|
1306
|
+
return this.findSymbols(query, options);
|
|
1307
|
+
const matchExpr = ftsQuery(query);
|
|
1308
|
+
if (!matchExpr)
|
|
1309
|
+
return this.findSymbols(query, options);
|
|
1310
|
+
const filter = this.filterClauseFromOptions(options);
|
|
1311
|
+
try {
|
|
1312
|
+
const rows = this.db.prepare(`
|
|
1313
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)},
|
|
1314
|
+
bm25(symbols_fts) AS rank
|
|
1315
|
+
FROM symbols_fts
|
|
1316
|
+
JOIN symbols s ON s.id = symbols_fts.rowid
|
|
1317
|
+
JOIN files f ON f.id = s.file_id
|
|
1318
|
+
WHERE symbols_fts MATCH ?
|
|
1319
|
+
${filter}
|
|
1320
|
+
ORDER BY rank, s.pagerank DESC
|
|
1321
|
+
LIMIT ?
|
|
1322
|
+
`).all(matchExpr, limit);
|
|
1323
|
+
if (rows.length > 0)
|
|
1324
|
+
return rows.map(toSymbolRow);
|
|
1325
|
+
}
|
|
1326
|
+
catch { /* fall through */ }
|
|
1327
|
+
return this.findSymbols(query, options);
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* FTS5 search over file paths. Returns matching files ranked by BM25.
|
|
1331
|
+
*/
|
|
1332
|
+
searchFilesFts(query, limit = 30, options = {}) {
|
|
1333
|
+
if (!this.hasV4Tables)
|
|
1334
|
+
return [];
|
|
1335
|
+
const matchExpr = ftsQuery(query);
|
|
1336
|
+
if (!matchExpr)
|
|
1337
|
+
return [];
|
|
1338
|
+
const includeTests = options.includeTests ?? false;
|
|
1339
|
+
const includeVendor = options.includeVendor ?? false;
|
|
1340
|
+
const includeGenerated = options.includeGenerated ?? false;
|
|
1341
|
+
try {
|
|
1342
|
+
const rows = this.db.prepare(`
|
|
1343
|
+
SELECT f.id, f.path, f.rel_path AS relPath, f.language, f.role
|
|
1344
|
+
FROM files_fts
|
|
1345
|
+
JOIN files f ON f.id = files_fts.rowid
|
|
1346
|
+
WHERE files_fts MATCH ?
|
|
1347
|
+
ORDER BY bm25(files_fts)
|
|
1348
|
+
LIMIT ?
|
|
1349
|
+
`).all(matchExpr, limit * 2);
|
|
1350
|
+
return rows
|
|
1351
|
+
.map(r => ({
|
|
1352
|
+
id: toNum(r.id),
|
|
1353
|
+
path: toStr(r.path),
|
|
1354
|
+
relPath: toStr(r.relPath),
|
|
1355
|
+
language: toStr(r.language),
|
|
1356
|
+
role: toStr(r.role),
|
|
1357
|
+
}))
|
|
1358
|
+
.filter(f => (includeVendor || f.role !== 'vendor') &&
|
|
1359
|
+
(includeGenerated || f.role !== 'generated') &&
|
|
1360
|
+
(includeTests || f.role !== 'test'))
|
|
1361
|
+
.slice(0, limit);
|
|
1362
|
+
}
|
|
1363
|
+
catch {
|
|
1364
|
+
return [];
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
listSymbolsInFile(filePath, limit = 200) {
|
|
1368
|
+
const rows = this.db.prepare(`
|
|
1369
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
1370
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1371
|
+
WHERE f.path = ? OR f.rel_path = ?
|
|
1372
|
+
ORDER BY s.line_start
|
|
1373
|
+
LIMIT ?
|
|
1374
|
+
`).all(filePath, filePath, limit);
|
|
1375
|
+
return rows.map(toSymbolRow);
|
|
1376
|
+
}
|
|
1377
|
+
getTopSymbols(limit = 20, options = {}) {
|
|
1378
|
+
const filter = this.filterClauseFromOptions(options);
|
|
1379
|
+
const where = filter ? `WHERE ${filter.replace(/^AND\s+/, '')}` : '';
|
|
1380
|
+
const rows = this.db.prepare(`
|
|
1381
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
1382
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1383
|
+
${where}
|
|
1384
|
+
ORDER BY s.pagerank DESC
|
|
1385
|
+
LIMIT ?
|
|
1386
|
+
`).all(limit);
|
|
1387
|
+
return rows.map(toSymbolRow);
|
|
1388
|
+
}
|
|
1389
|
+
getDefinition(name, options = {}) {
|
|
1390
|
+
const filter = this.filterClauseFromOptions(options);
|
|
1391
|
+
// File disambiguation accepts an absolute path, the exact rel_path, OR a
|
|
1392
|
+
// trailing path fragment on a segment boundary (`weird.c` matches
|
|
1393
|
+
// `src/weird.c`; `auth/service.ts` matches `packages/api/auth/service.ts`).
|
|
1394
|
+
// Without this an agent had to know the full rel_path or the filter
|
|
1395
|
+
// silently returned nothing — a wasted round-trip. Matching stays
|
|
1396
|
+
// deterministic: the fragment must align to a `/` boundary (so `auth.ts`
|
|
1397
|
+
// never matches `oauth.ts`), and LIKE metacharacters are escaped so a `_`
|
|
1398
|
+
// in a filename can't act as a wildcard.
|
|
1399
|
+
const fp = options.filePath;
|
|
1400
|
+
let fileClause = '';
|
|
1401
|
+
let fileArgs = [];
|
|
1402
|
+
if (fp) {
|
|
1403
|
+
const norm = fp.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
1404
|
+
const suffix = '%/' + escapeLike(norm);
|
|
1405
|
+
fileClause = 'AND (f.path = ? OR f.rel_path = ? OR f.rel_path LIKE ? ESCAPE \'\\\')';
|
|
1406
|
+
fileArgs = [fp, norm, suffix];
|
|
1407
|
+
}
|
|
1408
|
+
const stmt = this.db.prepare(`
|
|
1409
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
1410
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1411
|
+
WHERE (s.name = ? OR s.qualified_name = ?)
|
|
1412
|
+
${filter}
|
|
1413
|
+
${fileClause}
|
|
1414
|
+
ORDER BY s.pagerank DESC
|
|
1415
|
+
LIMIT 50
|
|
1416
|
+
`);
|
|
1417
|
+
const rows = stmt.all(name, name, ...fileArgs);
|
|
1418
|
+
return rows.map(toSymbolRow);
|
|
1419
|
+
}
|
|
1420
|
+
getSymbolById(id) {
|
|
1421
|
+
const row = this.db.prepare(`
|
|
1422
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
1423
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1424
|
+
WHERE s.id = ?
|
|
1425
|
+
`).get(id);
|
|
1426
|
+
return row ? toSymbolRow(row) : null;
|
|
1427
|
+
}
|
|
1428
|
+
countSymbols(name, options = {}) {
|
|
1429
|
+
const filter = this.filterClauseFromOptions(options);
|
|
1430
|
+
const row = this.db.prepare(`
|
|
1431
|
+
SELECT COUNT(*) AS c
|
|
1432
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
1433
|
+
WHERE (s.name LIKE ? OR s.qualified_name LIKE ?) ${filter}
|
|
1434
|
+
`).get(`%${name}%`, `%${name}%`);
|
|
1435
|
+
return toNum(row.c);
|
|
1436
|
+
}
|
|
1437
|
+
listFiles() {
|
|
1438
|
+
const rows = this.db.prepare(`
|
|
1439
|
+
SELECT id, path, rel_path AS relPath, language, hash, indexed_at AS indexedAt,
|
|
1440
|
+
role, is_vendor AS isVendor, is_generated AS isGenerated
|
|
1441
|
+
FROM files
|
|
1442
|
+
`).all();
|
|
1443
|
+
return rows.map(r => ({
|
|
1444
|
+
id: toNum(r.id),
|
|
1445
|
+
path: toStr(r.path),
|
|
1446
|
+
relPath: toStr(r.relPath),
|
|
1447
|
+
language: toStr(r.language),
|
|
1448
|
+
hash: toStr(r.hash),
|
|
1449
|
+
indexedAt: toNum(r.indexedAt),
|
|
1450
|
+
role: toStr(r.role),
|
|
1451
|
+
isVendor: toNum(r.isVendor),
|
|
1452
|
+
isGenerated: toNum(r.isGenerated),
|
|
1453
|
+
}));
|
|
1454
|
+
}
|
|
1455
|
+
getRoleCounts() {
|
|
1456
|
+
const out = { project: 0, vendor: 0, generated: 0, test: 0 };
|
|
1457
|
+
try {
|
|
1458
|
+
const rows = this.db.prepare('SELECT role, COUNT(*) AS c FROM files GROUP BY role').all();
|
|
1459
|
+
for (const r of rows) {
|
|
1460
|
+
const role = toStr(r.role);
|
|
1461
|
+
if (role in out)
|
|
1462
|
+
out[role] = toNum(r.c);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
catch { /* */ }
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
1469
|
+
listRoutes(options = {}) {
|
|
1470
|
+
if (!this.hasV4Tables)
|
|
1471
|
+
return [];
|
|
1472
|
+
const hasProtocol = this.hasColumn('routes', 'protocol');
|
|
1473
|
+
const where = [];
|
|
1474
|
+
const args = [];
|
|
1475
|
+
if (options.method) {
|
|
1476
|
+
where.push('r.method = ?');
|
|
1477
|
+
args.push(options.method.toUpperCase());
|
|
1478
|
+
}
|
|
1479
|
+
if (options.pathSubstr) {
|
|
1480
|
+
where.push('r.path LIKE ?');
|
|
1481
|
+
args.push(`%${options.pathSubstr}%`);
|
|
1482
|
+
}
|
|
1483
|
+
if (options.framework) {
|
|
1484
|
+
where.push('r.framework = ?');
|
|
1485
|
+
args.push(options.framework);
|
|
1486
|
+
}
|
|
1487
|
+
if (hasProtocol) {
|
|
1488
|
+
if (options.protocol) {
|
|
1489
|
+
where.push('r.protocol = ?');
|
|
1490
|
+
args.push(options.protocol);
|
|
1491
|
+
}
|
|
1492
|
+
if (options.operation) {
|
|
1493
|
+
where.push('r.operation = ?');
|
|
1494
|
+
args.push(options.operation);
|
|
1495
|
+
}
|
|
1496
|
+
if (options.topic) {
|
|
1497
|
+
where.push('r.topic = ?');
|
|
1498
|
+
args.push(options.topic);
|
|
1499
|
+
}
|
|
1500
|
+
if (options.queue) {
|
|
1501
|
+
where.push('r.queue = ?');
|
|
1502
|
+
args.push(options.queue);
|
|
1503
|
+
}
|
|
1504
|
+
if (options.service) {
|
|
1505
|
+
where.push('r.service = ?');
|
|
1506
|
+
args.push(options.service);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
const limit = options.limit ?? 200;
|
|
1510
|
+
const protocolCols = hasProtocol
|
|
1511
|
+
? ', r.protocol, r.operation, r.topic, r.queue, r.exchange, r.service, r.broker, r.metadata_json AS metadataJson'
|
|
1512
|
+
: '';
|
|
1513
|
+
const sql = `
|
|
1514
|
+
SELECT r.id, r.method, r.path, r.framework, r.handler_name AS handlerName,
|
|
1515
|
+
r.handler_id AS handlerId,
|
|
1516
|
+
s.qualified_name AS handlerSymbol,
|
|
1517
|
+
sf.path AS handlerFile,
|
|
1518
|
+
f.path AS filePath, r.line
|
|
1519
|
+
${protocolCols}
|
|
1520
|
+
FROM routes r
|
|
1521
|
+
JOIN files f ON f.id = r.file_id
|
|
1522
|
+
LEFT JOIN symbols s ON s.id = r.handler_id
|
|
1523
|
+
LEFT JOIN files sf ON sf.id = s.file_id
|
|
1524
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
1525
|
+
ORDER BY r.path, r.method
|
|
1526
|
+
LIMIT ?
|
|
1527
|
+
`;
|
|
1528
|
+
args.push(limit);
|
|
1529
|
+
const rows = this.db.prepare(sql).all(...args);
|
|
1530
|
+
return rows.map(r => ({
|
|
1531
|
+
id: toNum(r.id),
|
|
1532
|
+
method: toStr(r.method),
|
|
1533
|
+
path: toStr(r.path),
|
|
1534
|
+
framework: toStr(r.framework),
|
|
1535
|
+
handlerName: toNullStr(r.handlerName),
|
|
1536
|
+
handlerId: toNullNum(r.handlerId),
|
|
1537
|
+
handlerSymbol: toNullStr(r.handlerSymbol),
|
|
1538
|
+
handlerFile: toNullStr(r.handlerFile),
|
|
1539
|
+
filePath: toStr(r.filePath),
|
|
1540
|
+
line: toNum(r.line),
|
|
1541
|
+
protocol: hasProtocol ? toNullStr(r.protocol) : null,
|
|
1542
|
+
operation: hasProtocol ? toNullStr(r.operation) : null,
|
|
1543
|
+
topic: hasProtocol ? toNullStr(r.topic) : null,
|
|
1544
|
+
queue: hasProtocol ? toNullStr(r.queue) : null,
|
|
1545
|
+
exchange: hasProtocol ? toNullStr(r.exchange) : null,
|
|
1546
|
+
service: hasProtocol ? toNullStr(r.service) : null,
|
|
1547
|
+
broker: hasProtocol ? toNullStr(r.broker) : null,
|
|
1548
|
+
metadataJson: hasProtocol ? toNullStr(r.metadataJson) : null,
|
|
1549
|
+
}));
|
|
1550
|
+
}
|
|
1551
|
+
countRoutes() {
|
|
1552
|
+
if (!this.hasV4Tables)
|
|
1553
|
+
return 0;
|
|
1554
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM routes').get();
|
|
1555
|
+
return toNum(row.c);
|
|
1556
|
+
}
|
|
1557
|
+
// ── v8 Track-G service calls + links ────────────────────────────────────
|
|
1558
|
+
/** Total count of service_calls rows. */
|
|
1559
|
+
countServiceCalls() {
|
|
1560
|
+
try {
|
|
1561
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM service_calls').get();
|
|
1562
|
+
return toNum(row.c);
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
return 0;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/** Total count of service_links rows. */
|
|
1569
|
+
countServiceLinks() {
|
|
1570
|
+
try {
|
|
1571
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM service_links').get();
|
|
1572
|
+
return toNum(row.c);
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
return 0;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
/** List service_calls with the AST-attributed caller joined in. */
|
|
1579
|
+
listServiceCalls(options = {}) {
|
|
1580
|
+
const where = [];
|
|
1581
|
+
const args = [];
|
|
1582
|
+
if (options.protocol) {
|
|
1583
|
+
where.push('sc.protocol = ?');
|
|
1584
|
+
args.push(options.protocol);
|
|
1585
|
+
}
|
|
1586
|
+
if (options.method) {
|
|
1587
|
+
where.push('sc.method = ?');
|
|
1588
|
+
args.push(options.method.toUpperCase());
|
|
1589
|
+
}
|
|
1590
|
+
if (options.framework) {
|
|
1591
|
+
where.push('sc.framework = ?');
|
|
1592
|
+
args.push(options.framework);
|
|
1593
|
+
}
|
|
1594
|
+
if (options.pathSubstr) {
|
|
1595
|
+
where.push('sc.normalized_path LIKE ?');
|
|
1596
|
+
args.push(`%${options.pathSubstr}%`);
|
|
1597
|
+
}
|
|
1598
|
+
if (options.callerSymbolId != null) {
|
|
1599
|
+
where.push('sc.symbol_id = ?');
|
|
1600
|
+
args.push(options.callerSymbolId);
|
|
1601
|
+
}
|
|
1602
|
+
if (options.minConfidence != null) {
|
|
1603
|
+
where.push('sc.confidence >= ?');
|
|
1604
|
+
args.push(options.minConfidence);
|
|
1605
|
+
}
|
|
1606
|
+
if (options.operation) {
|
|
1607
|
+
where.push('sc.operation = ?');
|
|
1608
|
+
args.push(options.operation);
|
|
1609
|
+
}
|
|
1610
|
+
if (options.topic) {
|
|
1611
|
+
where.push('sc.topic = ?');
|
|
1612
|
+
args.push(options.topic);
|
|
1613
|
+
}
|
|
1614
|
+
if (options.queue) {
|
|
1615
|
+
where.push('sc.queue = ?');
|
|
1616
|
+
args.push(options.queue);
|
|
1617
|
+
}
|
|
1618
|
+
if (options.service) {
|
|
1619
|
+
where.push('sc.service = ?');
|
|
1620
|
+
args.push(options.service);
|
|
1621
|
+
}
|
|
1622
|
+
const limit = Math.min(options.limit ?? 100, 1000);
|
|
1623
|
+
const offset = options.offset ?? 0;
|
|
1624
|
+
args.push(limit, offset);
|
|
1625
|
+
const sql = `
|
|
1626
|
+
SELECT sc.id, sc.protocol, sc.method, sc.raw_target AS rawTarget,
|
|
1627
|
+
sc.normalized_path AS normalizedPath, sc.host_hint AS hostHint,
|
|
1628
|
+
sc.env_key AS envKey, sc.framework, sc.line, sc.confidence,
|
|
1629
|
+
sc.operation, sc.topic, sc.queue, sc.exchange, sc.service,
|
|
1630
|
+
sc.broker, sc.metadata_json AS metadataJson,
|
|
1631
|
+
f.rel_path AS filePath,
|
|
1632
|
+
sc.symbol_id AS callerSymbolId,
|
|
1633
|
+
s.name AS callerName, s.qualified_name AS callerQualifiedName,
|
|
1634
|
+
s.kind AS callerKind
|
|
1635
|
+
FROM service_calls sc
|
|
1636
|
+
JOIN files f ON f.id = sc.file_id
|
|
1637
|
+
LEFT JOIN symbols s ON s.id = sc.symbol_id
|
|
1638
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
1639
|
+
ORDER BY sc.id ASC
|
|
1640
|
+
LIMIT ? OFFSET ?
|
|
1641
|
+
`;
|
|
1642
|
+
try {
|
|
1643
|
+
const rows = this.db.prepare(sql).all(...args);
|
|
1644
|
+
return rows.map(r => ({
|
|
1645
|
+
id: toNum(r.id),
|
|
1646
|
+
protocol: toStr(r.protocol),
|
|
1647
|
+
method: toNullStr(r.method),
|
|
1648
|
+
rawTarget: toStr(r.rawTarget),
|
|
1649
|
+
normalizedPath: toNullStr(r.normalizedPath),
|
|
1650
|
+
hostHint: toNullStr(r.hostHint),
|
|
1651
|
+
envKey: toNullStr(r.envKey),
|
|
1652
|
+
framework: toStr(r.framework),
|
|
1653
|
+
line: toNum(r.line),
|
|
1654
|
+
confidence: Number(r.confidence ?? 0),
|
|
1655
|
+
filePath: toStr(r.filePath),
|
|
1656
|
+
callerSymbolId: r.callerSymbolId == null ? null : toNum(r.callerSymbolId),
|
|
1657
|
+
callerName: toNullStr(r.callerName),
|
|
1658
|
+
callerQualifiedName: toNullStr(r.callerQualifiedName),
|
|
1659
|
+
callerKind: toNullStr(r.callerKind),
|
|
1660
|
+
operation: toNullStr(r.operation),
|
|
1661
|
+
topic: toNullStr(r.topic),
|
|
1662
|
+
queue: toNullStr(r.queue),
|
|
1663
|
+
exchange: toNullStr(r.exchange),
|
|
1664
|
+
service: toNullStr(r.service),
|
|
1665
|
+
broker: toNullStr(r.broker),
|
|
1666
|
+
metadataJson: toNullStr(r.metadataJson),
|
|
1667
|
+
}));
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
return [];
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
/** List service_links with caller + handler + route joined in. */
|
|
1674
|
+
listServiceLinks(options = {}) {
|
|
1675
|
+
const where = [];
|
|
1676
|
+
const args = [];
|
|
1677
|
+
if (options.protocol) {
|
|
1678
|
+
where.push('sl.protocol = ?');
|
|
1679
|
+
args.push(options.protocol);
|
|
1680
|
+
}
|
|
1681
|
+
if (options.matchKind) {
|
|
1682
|
+
where.push('sl.match_kind = ?');
|
|
1683
|
+
args.push(options.matchKind);
|
|
1684
|
+
}
|
|
1685
|
+
if (options.minConfidence != null) {
|
|
1686
|
+
where.push('sl.confidence >= ?');
|
|
1687
|
+
args.push(options.minConfidence);
|
|
1688
|
+
}
|
|
1689
|
+
if (options.callerSymbolId != null) {
|
|
1690
|
+
where.push('sl.caller_symbol_id = ?');
|
|
1691
|
+
args.push(options.callerSymbolId);
|
|
1692
|
+
}
|
|
1693
|
+
if (options.handlerSymbolId != null) {
|
|
1694
|
+
where.push('sl.handler_symbol_id = ?');
|
|
1695
|
+
args.push(options.handlerSymbolId);
|
|
1696
|
+
}
|
|
1697
|
+
if (options.method) {
|
|
1698
|
+
where.push('sc.method = ?');
|
|
1699
|
+
args.push(options.method.toUpperCase());
|
|
1700
|
+
}
|
|
1701
|
+
if (options.pathSubstr) {
|
|
1702
|
+
where.push('(sc.normalized_path LIKE ? OR r.path LIKE ?)');
|
|
1703
|
+
args.push(`%${options.pathSubstr}%`, `%${options.pathSubstr}%`);
|
|
1704
|
+
}
|
|
1705
|
+
const limit = Math.min(options.limit ?? 100, 1000);
|
|
1706
|
+
const offset = options.offset ?? 0;
|
|
1707
|
+
args.push(limit, offset);
|
|
1708
|
+
const sql = `
|
|
1709
|
+
SELECT sl.id, sl.call_id AS callId, sl.route_id AS routeId,
|
|
1710
|
+
sl.protocol, sl.match_kind AS matchKind,
|
|
1711
|
+
sl.confidence, sl.evidence_json AS evidenceJson,
|
|
1712
|
+
sl.caller_symbol_id AS callerSymbolId,
|
|
1713
|
+
cs.name AS callerName, cs.qualified_name AS callerQualifiedName,
|
|
1714
|
+
cf.rel_path AS callerFile,
|
|
1715
|
+
sc.line AS callerLine,
|
|
1716
|
+
sc.method AS callMethod, sc.raw_target AS callRawTarget,
|
|
1717
|
+
sc.normalized_path AS callNormalizedPath, sc.framework AS callFramework,
|
|
1718
|
+
sc.env_key AS callEnvKey, sc.host_hint AS callHostHint,
|
|
1719
|
+
sc.operation AS callOperation, sc.topic AS callTopic,
|
|
1720
|
+
sc.queue AS callQueue, sc.service AS callService,
|
|
1721
|
+
sl.handler_symbol_id AS handlerSymbolId,
|
|
1722
|
+
hs.name AS handlerName, hs.qualified_name AS handlerQualifiedName,
|
|
1723
|
+
hf.rel_path AS handlerFile, hs.line_start AS handlerLine,
|
|
1724
|
+
r.method AS routeMethod, r.path AS routePath, r.framework AS routeFramework,
|
|
1725
|
+
r.operation AS routeOperation, r.topic AS routeTopic,
|
|
1726
|
+
r.queue AS routeQueue, r.service AS routeService
|
|
1727
|
+
FROM service_links sl
|
|
1728
|
+
LEFT JOIN service_calls sc ON sc.id = sl.call_id
|
|
1729
|
+
LEFT JOIN files cf ON cf.id = sc.file_id
|
|
1730
|
+
LEFT JOIN symbols cs ON cs.id = sl.caller_symbol_id
|
|
1731
|
+
LEFT JOIN symbols hs ON hs.id = sl.handler_symbol_id
|
|
1732
|
+
LEFT JOIN files hf ON hf.id = hs.file_id
|
|
1733
|
+
LEFT JOIN routes r ON r.id = sl.route_id
|
|
1734
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
1735
|
+
ORDER BY sl.id ASC
|
|
1736
|
+
LIMIT ? OFFSET ?
|
|
1737
|
+
`;
|
|
1738
|
+
try {
|
|
1739
|
+
const rows = this.db.prepare(sql).all(...args);
|
|
1740
|
+
return rows.map(r => ({
|
|
1741
|
+
id: toNum(r.id),
|
|
1742
|
+
callId: toNum(r.callId),
|
|
1743
|
+
routeId: r.routeId == null ? null : toNum(r.routeId),
|
|
1744
|
+
protocol: toStr(r.protocol),
|
|
1745
|
+
matchKind: toStr(r.matchKind),
|
|
1746
|
+
confidence: Number(r.confidence ?? 0),
|
|
1747
|
+
evidenceJson: toStr(r.evidenceJson),
|
|
1748
|
+
callerSymbolId: r.callerSymbolId == null ? null : toNum(r.callerSymbolId),
|
|
1749
|
+
callerName: toNullStr(r.callerName),
|
|
1750
|
+
callerQualifiedName: toNullStr(r.callerQualifiedName),
|
|
1751
|
+
callerFile: toNullStr(r.callerFile),
|
|
1752
|
+
callerLine: toNum(r.callerLine ?? 0),
|
|
1753
|
+
callMethod: toNullStr(r.callMethod),
|
|
1754
|
+
callRawTarget: toStr(r.callRawTarget),
|
|
1755
|
+
callNormalizedPath: toNullStr(r.callNormalizedPath),
|
|
1756
|
+
callFramework: toStr(r.callFramework),
|
|
1757
|
+
callEnvKey: toNullStr(r.callEnvKey),
|
|
1758
|
+
callHostHint: toNullStr(r.callHostHint),
|
|
1759
|
+
callOperation: toNullStr(r.callOperation),
|
|
1760
|
+
callTopic: toNullStr(r.callTopic),
|
|
1761
|
+
callQueue: toNullStr(r.callQueue),
|
|
1762
|
+
callService: toNullStr(r.callService),
|
|
1763
|
+
handlerSymbolId: r.handlerSymbolId == null ? null : toNum(r.handlerSymbolId),
|
|
1764
|
+
handlerName: toNullStr(r.handlerName),
|
|
1765
|
+
handlerQualifiedName: toNullStr(r.handlerQualifiedName),
|
|
1766
|
+
handlerFile: toNullStr(r.handlerFile),
|
|
1767
|
+
handlerLine: r.handlerLine == null ? null : toNum(r.handlerLine),
|
|
1768
|
+
routeMethod: toNullStr(r.routeMethod),
|
|
1769
|
+
routePath: toNullStr(r.routePath),
|
|
1770
|
+
routeFramework: toNullStr(r.routeFramework),
|
|
1771
|
+
routeOperation: toNullStr(r.routeOperation),
|
|
1772
|
+
routeTopic: toNullStr(r.routeTopic),
|
|
1773
|
+
routeQueue: toNullStr(r.routeQueue),
|
|
1774
|
+
routeService: toNullStr(r.routeService),
|
|
1775
|
+
}));
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
return [];
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/** id-scoped helper: every service_link whose caller is symbolId. */
|
|
1782
|
+
serviceLinksForCaller(symbolId, options = {}) {
|
|
1783
|
+
return this.listServiceLinks({ callerSymbolId: symbolId, limit: options.limit });
|
|
1784
|
+
}
|
|
1785
|
+
/** id-scoped helper: every service_link whose handler is symbolId. */
|
|
1786
|
+
serviceLinksForHandler(symbolId, options = {}) {
|
|
1787
|
+
return this.listServiceLinks({ handlerSymbolId: symbolId, limit: options.limit });
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Bounded BFS over service_links from caller to handler. Treats each
|
|
1791
|
+
* service_link as a directed edge `caller_symbol_id → handler_symbol_id`.
|
|
1792
|
+
* Returns the shortest path as an array of symbol ids, or [] if unreachable
|
|
1793
|
+
* within maxDepth. Combines with the normal call-graph trace done by
|
|
1794
|
+
* `tracePath`; this one is service-link only.
|
|
1795
|
+
*/
|
|
1796
|
+
traceServicePath(fromSymbolId, toSymbolId, maxDepth = 6) {
|
|
1797
|
+
if (fromSymbolId === toSymbolId)
|
|
1798
|
+
return [fromSymbolId];
|
|
1799
|
+
if (maxDepth <= 0)
|
|
1800
|
+
return [];
|
|
1801
|
+
try {
|
|
1802
|
+
const stmt = this.db.prepare(`SELECT DISTINCT handler_symbol_id AS h
|
|
1803
|
+
FROM service_links
|
|
1804
|
+
WHERE caller_symbol_id = ? AND handler_symbol_id IS NOT NULL`);
|
|
1805
|
+
const parents = new Map();
|
|
1806
|
+
const visited = new Set([fromSymbolId]);
|
|
1807
|
+
let frontier = [fromSymbolId];
|
|
1808
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
1809
|
+
const next = [];
|
|
1810
|
+
for (const cur of frontier) {
|
|
1811
|
+
const rows = stmt.all(cur);
|
|
1812
|
+
for (const r of rows) {
|
|
1813
|
+
const h = toNum(r.h);
|
|
1814
|
+
if (visited.has(h))
|
|
1815
|
+
continue;
|
|
1816
|
+
visited.add(h);
|
|
1817
|
+
parents.set(h, cur);
|
|
1818
|
+
if (h === toSymbolId) {
|
|
1819
|
+
// Reconstruct path
|
|
1820
|
+
const path = [h];
|
|
1821
|
+
let cursor = cur;
|
|
1822
|
+
while (cursor !== fromSymbolId) {
|
|
1823
|
+
path.push(cursor);
|
|
1824
|
+
cursor = parents.get(cursor);
|
|
1825
|
+
}
|
|
1826
|
+
path.push(fromSymbolId);
|
|
1827
|
+
path.reverse();
|
|
1828
|
+
return path;
|
|
1829
|
+
}
|
|
1830
|
+
next.push(h);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
if (next.length === 0)
|
|
1834
|
+
break;
|
|
1835
|
+
frontier = next;
|
|
1836
|
+
}
|
|
1837
|
+
return [];
|
|
1838
|
+
}
|
|
1839
|
+
catch {
|
|
1840
|
+
return [];
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* v9 Track-H — bounded service-link traversal from a single symbol.
|
|
1845
|
+
*
|
|
1846
|
+
* Walks the directed service-link graph starting at `fromSymbolId`. Each
|
|
1847
|
+
* step follows `caller_symbol_id → handler_symbol_id` edges, recording the
|
|
1848
|
+
* protocol / matchKind / hop chain for every reachable handler.
|
|
1849
|
+
*
|
|
1850
|
+
* Bounds (all configurable; defaults are conservative):
|
|
1851
|
+
* - maxDepth limit hops away from the source (default 4)
|
|
1852
|
+
* - maxNodes stop after expanding this many handlers (default 200)
|
|
1853
|
+
* - maxFanout stop expanding a node after this many outgoing service
|
|
1854
|
+
* links (default 20)
|
|
1855
|
+
*
|
|
1856
|
+
* Returns one record per reached handler with the protocols and match-kinds
|
|
1857
|
+
* encountered along the path; `cutoff` flags the limit that fired (if any).
|
|
1858
|
+
*/
|
|
1859
|
+
traceServiceDependencies(fromSymbolId, options = {}) {
|
|
1860
|
+
const maxDepth = options.maxDepth ?? 4;
|
|
1861
|
+
const maxNodes = options.maxNodes ?? 200;
|
|
1862
|
+
const maxFanout = options.maxFanout ?? 20;
|
|
1863
|
+
const reached = new Map();
|
|
1864
|
+
let cutoff = null;
|
|
1865
|
+
let expanded = 0;
|
|
1866
|
+
try {
|
|
1867
|
+
// Deterministic ordering by handler symbol id ASC inside each step.
|
|
1868
|
+
const stmt = this.db.prepare(`SELECT handler_symbol_id AS h, protocol AS p, match_kind AS mk
|
|
1869
|
+
FROM service_links
|
|
1870
|
+
WHERE caller_symbol_id = ? AND handler_symbol_id IS NOT NULL
|
|
1871
|
+
ORDER BY confidence DESC, handler_symbol_id ASC
|
|
1872
|
+
LIMIT ?`);
|
|
1873
|
+
let frontier = [fromSymbolId];
|
|
1874
|
+
let maxDepthFrontier = [];
|
|
1875
|
+
reached.set(fromSymbolId, { depth: 0, protocols: new Set(), matchKinds: new Set(), parent: null });
|
|
1876
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
1877
|
+
const next = [];
|
|
1878
|
+
for (const cur of frontier) {
|
|
1879
|
+
// +1 so we can detect fanout-cap hits cleanly (over-by-one).
|
|
1880
|
+
const rows = stmt.all(cur, maxFanout + 1);
|
|
1881
|
+
if (rows.length > maxFanout)
|
|
1882
|
+
cutoff = 'maxFanout';
|
|
1883
|
+
for (let i = 0; i < Math.min(rows.length, maxFanout); i++) {
|
|
1884
|
+
const h = toNum(rows[i].h);
|
|
1885
|
+
const p = toStr(rows[i].p);
|
|
1886
|
+
const mk = toStr(rows[i].mk);
|
|
1887
|
+
if (reached.has(h)) {
|
|
1888
|
+
const entry = reached.get(h);
|
|
1889
|
+
entry.protocols.add(p);
|
|
1890
|
+
entry.matchKinds.add(mk);
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
reached.set(h, {
|
|
1894
|
+
depth: depth + 1,
|
|
1895
|
+
protocols: new Set([p]),
|
|
1896
|
+
matchKinds: new Set([mk]),
|
|
1897
|
+
parent: cur,
|
|
1898
|
+
});
|
|
1899
|
+
next.push(h);
|
|
1900
|
+
if (reached.size > maxNodes) {
|
|
1901
|
+
cutoff = 'maxNodes';
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
expanded++;
|
|
1906
|
+
if (cutoff === 'maxNodes')
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
if (cutoff === 'maxNodes')
|
|
1910
|
+
break;
|
|
1911
|
+
if (next.length === 0)
|
|
1912
|
+
break;
|
|
1913
|
+
if (depth + 1 >= maxDepth) {
|
|
1914
|
+
maxDepthFrontier = next;
|
|
1915
|
+
break;
|
|
1916
|
+
}
|
|
1917
|
+
frontier = next;
|
|
1918
|
+
}
|
|
1919
|
+
if (!cutoff && reached.size >= maxNodes)
|
|
1920
|
+
cutoff = 'maxNodes';
|
|
1921
|
+
if (!cutoff && maxDepthFrontier.length > 0) {
|
|
1922
|
+
const placeholders = maxDepthFrontier.map(() => '?').join(',');
|
|
1923
|
+
const row = this.db.prepare(`SELECT 1 AS ok
|
|
1924
|
+
FROM service_links
|
|
1925
|
+
WHERE caller_symbol_id IN (${placeholders})
|
|
1926
|
+
AND handler_symbol_id IS NOT NULL
|
|
1927
|
+
LIMIT 1`).get(...maxDepthFrontier);
|
|
1928
|
+
if (row)
|
|
1929
|
+
cutoff = 'maxDepth';
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
catch { /* fall through with what we have */ }
|
|
1933
|
+
// Build hop chains for each reached handler.
|
|
1934
|
+
const out = [];
|
|
1935
|
+
for (const [id, entry] of reached) {
|
|
1936
|
+
if (id === fromSymbolId)
|
|
1937
|
+
continue;
|
|
1938
|
+
const hops = [id];
|
|
1939
|
+
let p = entry.parent;
|
|
1940
|
+
while (p !== null && p !== fromSymbolId) {
|
|
1941
|
+
hops.push(p);
|
|
1942
|
+
p = reached.get(p)?.parent ?? null;
|
|
1943
|
+
}
|
|
1944
|
+
hops.push(fromSymbolId);
|
|
1945
|
+
hops.reverse();
|
|
1946
|
+
out.push({
|
|
1947
|
+
symbolId: id,
|
|
1948
|
+
depth: entry.depth,
|
|
1949
|
+
protocols: Array.from(entry.protocols).sort(),
|
|
1950
|
+
matchKinds: Array.from(entry.matchKinds).sort(),
|
|
1951
|
+
hops,
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
// Deterministic order: by depth ASC, then symbolId ASC.
|
|
1955
|
+
out.sort((a, b) => a.depth - b.depth || a.symbolId - b.symbolId);
|
|
1956
|
+
return { reached: out, cutoff, fromExpanded: expanded };
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* v9 Track-H — bounded service-link traversal at module granularity.
|
|
1960
|
+
*
|
|
1961
|
+
* Returns the set of modules reachable from `fromModuleId` by following
|
|
1962
|
+
* cross-module service links (one or more service_link edges whose caller
|
|
1963
|
+
* and handler live in different modules). For each reached module the
|
|
1964
|
+
* result includes the minimum hop depth and which protocols carry traffic
|
|
1965
|
+
* into it.
|
|
1966
|
+
*
|
|
1967
|
+
* Useful for "which modules depend on `billing` through HTTP/Kafka/etc?".
|
|
1968
|
+
*/
|
|
1969
|
+
traceModuleServiceDependencies(fromModuleId, options = {}) {
|
|
1970
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
1971
|
+
const maxNodes = options.maxNodes ?? 50;
|
|
1972
|
+
if (!this.hasModuleTables)
|
|
1973
|
+
return { reached: [], cutoff: null };
|
|
1974
|
+
const edges = this.db.prepare(`SELECT mm1.module_id AS f, mm2.module_id AS t, sl.protocol AS p, COUNT(*) AS n
|
|
1975
|
+
FROM service_links sl
|
|
1976
|
+
JOIN service_calls sc ON sc.id = sl.call_id
|
|
1977
|
+
JOIN module_members mm1 ON mm1.file_id = sc.file_id
|
|
1978
|
+
JOIN symbols hs ON hs.id = sl.handler_symbol_id
|
|
1979
|
+
JOIN module_members mm2 ON mm2.file_id = hs.file_id
|
|
1980
|
+
WHERE mm1.module_id <> mm2.module_id
|
|
1981
|
+
GROUP BY mm1.module_id, mm2.module_id, sl.protocol
|
|
1982
|
+
ORDER BY mm1.module_id ASC, mm2.module_id ASC, sl.protocol ASC`).all();
|
|
1983
|
+
const adj = new Map();
|
|
1984
|
+
for (const e of edges) {
|
|
1985
|
+
const from = toNum(e.f);
|
|
1986
|
+
const list = adj.get(from) ?? [];
|
|
1987
|
+
list.push({ from, to: toNum(e.t), protocol: toStr(e.p), n: toNum(e.n) });
|
|
1988
|
+
adj.set(from, list);
|
|
1989
|
+
}
|
|
1990
|
+
const reached = new Map();
|
|
1991
|
+
reached.set(fromModuleId, { depth: 0, protocols: new Set(), viaLinks: 0 });
|
|
1992
|
+
let cutoff = null;
|
|
1993
|
+
let frontier = [fromModuleId];
|
|
1994
|
+
let maxDepthFrontier = [];
|
|
1995
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
1996
|
+
const next = [];
|
|
1997
|
+
for (const cur of frontier) {
|
|
1998
|
+
const outs = adj.get(cur) ?? [];
|
|
1999
|
+
for (const e of outs) {
|
|
2000
|
+
if (e.to === fromModuleId)
|
|
2001
|
+
continue;
|
|
2002
|
+
let entry = reached.get(e.to);
|
|
2003
|
+
if (!entry) {
|
|
2004
|
+
entry = { depth: depth + 1, protocols: new Set(), viaLinks: 0 };
|
|
2005
|
+
reached.set(e.to, entry);
|
|
2006
|
+
next.push(e.to);
|
|
2007
|
+
if (reached.size > maxNodes) {
|
|
2008
|
+
cutoff = 'maxNodes';
|
|
2009
|
+
break;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
entry.protocols.add(e.protocol);
|
|
2013
|
+
entry.viaLinks += e.n;
|
|
2014
|
+
}
|
|
2015
|
+
if (cutoff)
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
if (cutoff)
|
|
2019
|
+
break;
|
|
2020
|
+
if (next.length === 0)
|
|
2021
|
+
break;
|
|
2022
|
+
if (depth + 1 >= maxDepth) {
|
|
2023
|
+
maxDepthFrontier = next;
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
frontier = next;
|
|
2027
|
+
}
|
|
2028
|
+
if (!cutoff && maxDepthFrontier.some(id => (adj.get(id)?.length ?? 0) > 0)) {
|
|
2029
|
+
cutoff = 'maxDepth';
|
|
2030
|
+
}
|
|
2031
|
+
const out = [];
|
|
2032
|
+
for (const [id, r] of reached) {
|
|
2033
|
+
if (id === fromModuleId)
|
|
2034
|
+
continue;
|
|
2035
|
+
out.push({
|
|
2036
|
+
moduleId: id, depth: r.depth,
|
|
2037
|
+
protocols: Array.from(r.protocols).sort(),
|
|
2038
|
+
viaLinks: r.viaLinks,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
out.sort((a, b) => a.depth - b.depth || a.moduleId - b.moduleId);
|
|
2042
|
+
return { reached: out, cutoff };
|
|
2043
|
+
}
|
|
2044
|
+
// ── v10 External bundle layers ─────────────────────────────────────────────
|
|
2045
|
+
/** True iff the v10 external/boundary/continuity tables exist on disk. */
|
|
2046
|
+
hasV10() { return this.hasV10Tables; }
|
|
2047
|
+
/** Replace the boundaries / boundary_members / boundary_edges tables.
|
|
2048
|
+
* Atomic — wrapped in a single transaction. */
|
|
2049
|
+
replaceBoundaries(boundaries, edges) {
|
|
2050
|
+
this.assertWritable();
|
|
2051
|
+
if (!this.hasV10Tables)
|
|
2052
|
+
return;
|
|
2053
|
+
this.db.exec('BEGIN');
|
|
2054
|
+
try {
|
|
2055
|
+
this.db.exec('DELETE FROM boundary_edges');
|
|
2056
|
+
this.db.exec('DELETE FROM boundary_members');
|
|
2057
|
+
this.db.exec('DELETE FROM boundaries');
|
|
2058
|
+
const insBoundary = this.db.prepare(`
|
|
2059
|
+
INSERT INTO boundaries
|
|
2060
|
+
(label, kind, root_rel_path, manifest_path, ecosystem, size_files, computed_at)
|
|
2061
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2062
|
+
`);
|
|
2063
|
+
const insMember = this.db.prepare('INSERT OR REPLACE INTO boundary_members (file_id, boundary_id) VALUES (?, ?)');
|
|
2064
|
+
const insEdge = this.db.prepare('INSERT OR REPLACE INTO boundary_edges (from_boundary_id, to_boundary_id, kind, weight) VALUES (?, ?, ?, ?)');
|
|
2065
|
+
const now = Date.now();
|
|
2066
|
+
const indexToId = [];
|
|
2067
|
+
for (const b of boundaries) {
|
|
2068
|
+
const res = insBoundary.run(b.label, b.kind, b.rootRelPath, b.manifestPath, b.ecosystem, b.fileIds.length, now);
|
|
2069
|
+
const id = toNum(res.lastInsertRowid);
|
|
2070
|
+
indexToId.push(id);
|
|
2071
|
+
for (const fid of b.fileIds)
|
|
2072
|
+
insMember.run(fid, id);
|
|
2073
|
+
}
|
|
2074
|
+
for (const e of edges) {
|
|
2075
|
+
const f = indexToId[e.fromIndex];
|
|
2076
|
+
const t = indexToId[e.toIndex];
|
|
2077
|
+
if (f == null || t == null)
|
|
2078
|
+
continue;
|
|
2079
|
+
insEdge.run(f, t, e.kind, e.weight);
|
|
2080
|
+
}
|
|
2081
|
+
this.db.exec('COMMIT');
|
|
2082
|
+
}
|
|
2083
|
+
catch (err) {
|
|
2084
|
+
this.db.exec('ROLLBACK');
|
|
2085
|
+
throw err;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
/** True iff boundaries were populated this build. */
|
|
2089
|
+
hasBoundariesData() {
|
|
2090
|
+
if (!this.hasV10Tables)
|
|
2091
|
+
return false;
|
|
2092
|
+
try {
|
|
2093
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM boundaries').get();
|
|
2094
|
+
return toNum(row.c) > 0;
|
|
2095
|
+
}
|
|
2096
|
+
catch {
|
|
2097
|
+
return false;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
countBoundaries() {
|
|
2101
|
+
if (!this.hasV10Tables)
|
|
2102
|
+
return 0;
|
|
2103
|
+
try {
|
|
2104
|
+
return toNum(this.db.prepare('SELECT COUNT(*) AS c FROM boundaries').get().c);
|
|
2105
|
+
}
|
|
2106
|
+
catch {
|
|
2107
|
+
return 0;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
listBoundaries(limit = 200) {
|
|
2111
|
+
if (!this.hasV10Tables)
|
|
2112
|
+
return [];
|
|
2113
|
+
try {
|
|
2114
|
+
const rows = this.db.prepare(`
|
|
2115
|
+
SELECT id, label, kind, root_rel_path AS rootRelPath,
|
|
2116
|
+
manifest_path AS manifestPath, ecosystem,
|
|
2117
|
+
size_files AS sizeFiles
|
|
2118
|
+
FROM boundaries
|
|
2119
|
+
ORDER BY size_files DESC, label
|
|
2120
|
+
LIMIT ?
|
|
2121
|
+
`).all(limit);
|
|
2122
|
+
return rows.map(r => ({
|
|
2123
|
+
id: toNum(r.id), label: toStr(r.label), kind: toStr(r.kind),
|
|
2124
|
+
rootRelPath: toStr(r.rootRelPath),
|
|
2125
|
+
manifestPath: toNullStr(r.manifestPath),
|
|
2126
|
+
ecosystem: toNullStr(r.ecosystem),
|
|
2127
|
+
sizeFiles: toNum(r.sizeFiles),
|
|
2128
|
+
}));
|
|
2129
|
+
}
|
|
2130
|
+
catch {
|
|
2131
|
+
return [];
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
/** Boundary that owns a file id (or null). */
|
|
2135
|
+
boundaryForFile(fileId) {
|
|
2136
|
+
if (!this.hasV10Tables)
|
|
2137
|
+
return null;
|
|
2138
|
+
try {
|
|
2139
|
+
const row = this.db.prepare(`
|
|
2140
|
+
SELECT b.id, b.label, b.kind, b.root_rel_path AS rootRelPath
|
|
2141
|
+
FROM boundary_members bm JOIN boundaries b ON b.id = bm.boundary_id
|
|
2142
|
+
WHERE bm.file_id = ?
|
|
2143
|
+
`).get(fileId);
|
|
2144
|
+
if (!row)
|
|
2145
|
+
return null;
|
|
2146
|
+
return {
|
|
2147
|
+
id: toNum(row.id),
|
|
2148
|
+
label: toStr(row.label),
|
|
2149
|
+
kind: toStr(row.kind),
|
|
2150
|
+
rootRelPath: toStr(row.rootRelPath),
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
catch {
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
/** Cross-boundary dependency edges from a boundary (outgoing by default). */
|
|
2158
|
+
boundaryDependencies(boundaryId, options = {}) {
|
|
2159
|
+
if (!this.hasV10Tables)
|
|
2160
|
+
return [];
|
|
2161
|
+
const direction = options.direction ?? 'out';
|
|
2162
|
+
const limit = options.limit ?? 100;
|
|
2163
|
+
const sideThis = direction === 'out' ? 'from_boundary_id' : 'to_boundary_id';
|
|
2164
|
+
const sideOther = direction === 'out' ? 'to_boundary_id' : 'from_boundary_id';
|
|
2165
|
+
try {
|
|
2166
|
+
const rows = this.db.prepare(`
|
|
2167
|
+
SELECT b.id AS boundaryId, b.label, be.kind, be.weight
|
|
2168
|
+
FROM boundary_edges be JOIN boundaries b ON b.id = be.${sideOther}
|
|
2169
|
+
WHERE be.${sideThis} = ?
|
|
2170
|
+
ORDER BY be.weight DESC
|
|
2171
|
+
LIMIT ?
|
|
2172
|
+
`).all(boundaryId, limit);
|
|
2173
|
+
return rows.map(r => ({
|
|
2174
|
+
boundaryId: toNum(r.boundaryId),
|
|
2175
|
+
label: toStr(r.label),
|
|
2176
|
+
kind: toStr(r.kind),
|
|
2177
|
+
weight: toNum(r.weight),
|
|
2178
|
+
}));
|
|
2179
|
+
}
|
|
2180
|
+
catch {
|
|
2181
|
+
return [];
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
/** For a given symbol id, return the boundaries of each of its callees. */
|
|
2185
|
+
calleeBoundariesOf(symbolId) {
|
|
2186
|
+
if (!this.hasV10Tables)
|
|
2187
|
+
return [];
|
|
2188
|
+
try {
|
|
2189
|
+
const rows = this.db.prepare(`
|
|
2190
|
+
SELECT DISTINCT e.to_id AS calleeId, bm.boundary_id AS boundaryId
|
|
2191
|
+
FROM edges e
|
|
2192
|
+
JOIN symbols s ON s.id = e.to_id
|
|
2193
|
+
JOIN boundary_members bm ON bm.file_id = s.file_id
|
|
2194
|
+
WHERE e.from_id = ? AND e.kind = 'call' AND e.to_id IS NOT NULL
|
|
2195
|
+
`).all(symbolId);
|
|
2196
|
+
return rows.map(r => ({
|
|
2197
|
+
calleeId: toNum(r.calleeId), boundaryId: toNum(r.boundaryId),
|
|
2198
|
+
}));
|
|
2199
|
+
}
|
|
2200
|
+
catch {
|
|
2201
|
+
return [];
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Return every files.id that's actually a phantom file backing an external
|
|
2206
|
+
* bundle layer. The indexer's prune pass preserves these so a local
|
|
2207
|
+
* re-index never drops external-imported rows.
|
|
2208
|
+
*/
|
|
2209
|
+
listExternalPhantomFileIds() {
|
|
2210
|
+
try {
|
|
2211
|
+
const rows = this.db.prepare("SELECT id FROM files WHERE path LIKE '__external_bundle__/%'").all();
|
|
2212
|
+
return rows.map(r => toNum(r.id));
|
|
2213
|
+
}
|
|
2214
|
+
catch {
|
|
2215
|
+
return [];
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
/** Insert (or replace) an external_bundles row for a given bundle path. */
|
|
2219
|
+
upsertExternalBundle(args) {
|
|
2220
|
+
this.assertWritable();
|
|
2221
|
+
if (!this.hasV10Tables)
|
|
2222
|
+
return 0;
|
|
2223
|
+
const existing = this.db.prepare('SELECT id FROM external_bundles WHERE bundle_path = ?').get(args.bundlePath);
|
|
2224
|
+
if (existing) {
|
|
2225
|
+
const id = toNum(existing.id);
|
|
2226
|
+
this.db.prepare(`
|
|
2227
|
+
UPDATE external_bundles
|
|
2228
|
+
SET external_project = ?, external_version = ?, external_hash = ?,
|
|
2229
|
+
schema_version = ?, imported_at = ?, routes_imported = ?,
|
|
2230
|
+
service_calls_imported = ?, service_links_imported = ?
|
|
2231
|
+
WHERE id = ?
|
|
2232
|
+
`).run(args.externalProject, args.externalVersion, args.externalHash, args.schemaVersion, Date.now(), args.routesImported, args.serviceCallsImported, args.serviceLinksImported, id);
|
|
2233
|
+
return id;
|
|
2234
|
+
}
|
|
2235
|
+
const r = this.db.prepare(`
|
|
2236
|
+
INSERT INTO external_bundles
|
|
2237
|
+
(source_kind, bundle_path, external_project, external_version, external_hash,
|
|
2238
|
+
schema_version, imported_at, routes_imported, service_calls_imported, service_links_imported)
|
|
2239
|
+
VALUES ('external-bundle', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2240
|
+
`).run(args.bundlePath, args.externalProject, args.externalVersion, args.externalHash, args.schemaVersion, Date.now(), args.routesImported, args.serviceCallsImported, args.serviceLinksImported);
|
|
2241
|
+
return toNum(r.lastInsertRowid);
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Look up an existing external_bundles row by its bundle path. Returns the
|
|
2245
|
+
* id and the imported_at/external_hash for the existing layer when present.
|
|
2246
|
+
*/
|
|
2247
|
+
findExternalBundleByPath(bundlePath) {
|
|
2248
|
+
if (!this.hasV10Tables)
|
|
2249
|
+
return null;
|
|
2250
|
+
try {
|
|
2251
|
+
const row = this.db.prepare(`
|
|
2252
|
+
SELECT id, bundle_path AS bundlePath, external_project AS externalProject,
|
|
2253
|
+
external_version AS externalVersion, external_hash AS externalHash
|
|
2254
|
+
FROM external_bundles WHERE bundle_path = ?
|
|
2255
|
+
`).get(bundlePath);
|
|
2256
|
+
if (!row)
|
|
2257
|
+
return null;
|
|
2258
|
+
return {
|
|
2259
|
+
id: toNum(row.id),
|
|
2260
|
+
bundlePath: toStr(row.bundlePath),
|
|
2261
|
+
externalProject: toNullStr(row.externalProject),
|
|
2262
|
+
externalVersion: toNullStr(row.externalVersion),
|
|
2263
|
+
externalHash: toNullStr(row.externalHash),
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
catch {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
/** List every external_bundles row (newest first). */
|
|
2271
|
+
listExternalBundles() {
|
|
2272
|
+
if (!this.hasV10Tables)
|
|
2273
|
+
return [];
|
|
2274
|
+
try {
|
|
2275
|
+
const rows = this.db.prepare(`
|
|
2276
|
+
SELECT id, source_kind AS sourceKind, bundle_path AS bundlePath,
|
|
2277
|
+
external_project AS externalProject,
|
|
2278
|
+
external_version AS externalVersion,
|
|
2279
|
+
external_hash AS externalHash,
|
|
2280
|
+
schema_version AS schemaVersion,
|
|
2281
|
+
imported_at AS importedAt,
|
|
2282
|
+
routes_imported AS routesImported,
|
|
2283
|
+
service_calls_imported AS serviceCallsImported,
|
|
2284
|
+
service_links_imported AS serviceLinksImported
|
|
2285
|
+
FROM external_bundles
|
|
2286
|
+
ORDER BY imported_at DESC
|
|
2287
|
+
`).all();
|
|
2288
|
+
return rows.map(r => ({
|
|
2289
|
+
id: toNum(r.id),
|
|
2290
|
+
sourceKind: toStr(r.sourceKind),
|
|
2291
|
+
bundlePath: toStr(r.bundlePath),
|
|
2292
|
+
externalProject: toNullStr(r.externalProject),
|
|
2293
|
+
externalVersion: toNullStr(r.externalVersion),
|
|
2294
|
+
externalHash: toNullStr(r.externalHash),
|
|
2295
|
+
schemaVersion: toNum(r.schemaVersion),
|
|
2296
|
+
importedAt: toNum(r.importedAt),
|
|
2297
|
+
routesImported: toNum(r.routesImported),
|
|
2298
|
+
serviceCallsImported: toNum(r.serviceCallsImported),
|
|
2299
|
+
serviceLinksImported: toNum(r.serviceLinksImported),
|
|
2300
|
+
}));
|
|
2301
|
+
}
|
|
2302
|
+
catch {
|
|
2303
|
+
return [];
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Delete every row associated with a given external_bundles.id — its
|
|
2308
|
+
* routes/service_calls/service_links rows and the bundle row itself. Used
|
|
2309
|
+
* during re-import so a fresh import is fully replacing the previous
|
|
2310
|
+
* snapshot of that bundle.
|
|
2311
|
+
*/
|
|
2312
|
+
clearExternalBundle(bundleId) {
|
|
2313
|
+
this.assertWritable();
|
|
2314
|
+
if (!this.hasV10Tables)
|
|
2315
|
+
return { routes: 0, serviceCalls: 0, serviceLinks: 0 };
|
|
2316
|
+
let routes = 0, serviceCalls = 0, serviceLinks = 0;
|
|
2317
|
+
this.db.exec('BEGIN');
|
|
2318
|
+
try {
|
|
2319
|
+
try {
|
|
2320
|
+
routes = toNum(this.db.prepare('DELETE FROM routes WHERE external_bundle_id = ?').run(bundleId).changes);
|
|
2321
|
+
}
|
|
2322
|
+
catch { /* */ }
|
|
2323
|
+
try {
|
|
2324
|
+
serviceCalls = toNum(this.db.prepare('DELETE FROM service_calls WHERE external_bundle_id = ?').run(bundleId).changes);
|
|
2325
|
+
}
|
|
2326
|
+
catch { /* */ }
|
|
2327
|
+
try {
|
|
2328
|
+
serviceLinks = toNum(this.db.prepare('DELETE FROM service_links WHERE external_bundle_id = ?').run(bundleId).changes);
|
|
2329
|
+
}
|
|
2330
|
+
catch { /* */ }
|
|
2331
|
+
// Drop the phantom file row that owned this layer's external routes so a
|
|
2332
|
+
// forced re-import (which mints a new bundle id + phantom path) does not
|
|
2333
|
+
// leak orphaned `__external_bundle__/...` rows alongside sibling layers.
|
|
2334
|
+
try {
|
|
2335
|
+
this.db.prepare("DELETE FROM files WHERE hash = ? AND path LIKE '__external_bundle__/%'").run(`external:${bundleId}`);
|
|
2336
|
+
}
|
|
2337
|
+
catch { /* */ }
|
|
2338
|
+
this.db.prepare('DELETE FROM external_bundles WHERE id = ?').run(bundleId);
|
|
2339
|
+
this.db.exec('COMMIT');
|
|
2340
|
+
}
|
|
2341
|
+
catch (err) {
|
|
2342
|
+
this.db.exec('ROLLBACK');
|
|
2343
|
+
throw err;
|
|
2344
|
+
}
|
|
2345
|
+
return { routes, serviceCalls, serviceLinks };
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Insert a route from an external bundle. file_id is intentionally NULL —
|
|
2349
|
+
* external routes do not belong to any local file. The Store schema does
|
|
2350
|
+
* not allow NULL on routes.file_id by default; v10 keeps file_id NOT NULL,
|
|
2351
|
+
* so we have to ensure an external "phantom" file row exists per bundle to
|
|
2352
|
+
* own the routes. The route stays linked to the external_bundle_id so we
|
|
2353
|
+
* can wipe them as a layer.
|
|
2354
|
+
*/
|
|
2355
|
+
insertExternalRoute(args) {
|
|
2356
|
+
this.assertWritable();
|
|
2357
|
+
if (!this.hasV10Tables)
|
|
2358
|
+
return 0;
|
|
2359
|
+
const r = this.db.prepare(`
|
|
2360
|
+
INSERT INTO routes
|
|
2361
|
+
(file_id, method, path, framework, handler_name, line,
|
|
2362
|
+
protocol, operation, topic, queue, exchange, service, broker, metadata_json,
|
|
2363
|
+
external_bundle_id)
|
|
2364
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2365
|
+
`).run(args.externalFileId, args.method, args.path, args.framework, args.handlerName, args.line, args.protocol ?? 'http', args.operation ?? null, args.topic ?? null, args.queue ?? null, args.exchange ?? null, args.service ?? null, args.broker ?? null, args.metadataJson ?? null, args.bundleId);
|
|
2366
|
+
return toNum(r.lastInsertRowid);
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Create (or reuse) an "external" phantom file row that owns external
|
|
2370
|
+
* bundle rows. Each external_bundles.id gets its own external-phantom file
|
|
2371
|
+
* so deleting a layer doesn't disturb sibling layers. The phantom file
|
|
2372
|
+
* carries role='vendor' so it stays out of project-first defaults.
|
|
2373
|
+
*/
|
|
2374
|
+
ensureExternalFile(bundleId, externalProject) {
|
|
2375
|
+
this.assertWritable();
|
|
2376
|
+
const phantomPath = `__external_bundle__/${externalProject}/${bundleId}`;
|
|
2377
|
+
const existing = this.db.prepare('SELECT id FROM files WHERE path = ?')
|
|
2378
|
+
.get(phantomPath);
|
|
2379
|
+
if (existing)
|
|
2380
|
+
return toNum(existing.id);
|
|
2381
|
+
const r = this.stmtUpsertFile.run(phantomPath, phantomPath, 'external', `external:${bundleId}`, 0, Date.now(), 'vendor', 1, 0);
|
|
2382
|
+
return toNum(r.lastInsertRowid);
|
|
2383
|
+
}
|
|
2384
|
+
/** Count of routes that came from an external bundle. */
|
|
2385
|
+
countExternalRoutes() {
|
|
2386
|
+
if (!this.hasV10Tables)
|
|
2387
|
+
return 0;
|
|
2388
|
+
try {
|
|
2389
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM routes WHERE external_bundle_id IS NOT NULL').get();
|
|
2390
|
+
return toNum(row.c);
|
|
2391
|
+
}
|
|
2392
|
+
catch {
|
|
2393
|
+
return 0;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* List routes filtered to external bundles only. Useful for verifying that
|
|
2398
|
+
* an external import landed and for the seer_external_bundles MCP tool.
|
|
2399
|
+
*/
|
|
2400
|
+
listExternalRoutes(options = {}) {
|
|
2401
|
+
if (!this.hasV10Tables)
|
|
2402
|
+
return [];
|
|
2403
|
+
const where = ['r.external_bundle_id IS NOT NULL'];
|
|
2404
|
+
const args = [];
|
|
2405
|
+
if (options.bundleId != null) {
|
|
2406
|
+
where.push('r.external_bundle_id = ?');
|
|
2407
|
+
args.push(options.bundleId);
|
|
2408
|
+
}
|
|
2409
|
+
if (options.method) {
|
|
2410
|
+
where.push('r.method = ?');
|
|
2411
|
+
args.push(options.method.toUpperCase());
|
|
2412
|
+
}
|
|
2413
|
+
if (options.pathSubstr) {
|
|
2414
|
+
where.push('r.path LIKE ?');
|
|
2415
|
+
args.push(`%${options.pathSubstr}%`);
|
|
2416
|
+
}
|
|
2417
|
+
if (options.protocol) {
|
|
2418
|
+
where.push('r.protocol = ?');
|
|
2419
|
+
args.push(options.protocol);
|
|
2420
|
+
}
|
|
2421
|
+
const limit = options.limit ?? 200;
|
|
2422
|
+
args.push(limit);
|
|
2423
|
+
try {
|
|
2424
|
+
const rows = this.db.prepare(`
|
|
2425
|
+
SELECT r.id, r.method, r.path, r.framework, r.handler_name AS handlerName,
|
|
2426
|
+
r.line, r.protocol, r.operation, r.topic, r.queue, r.service,
|
|
2427
|
+
r.external_bundle_id AS externalBundleId,
|
|
2428
|
+
eb.external_project AS externalProject
|
|
2429
|
+
FROM routes r
|
|
2430
|
+
JOIN external_bundles eb ON eb.id = r.external_bundle_id
|
|
2431
|
+
WHERE ${where.join(' AND ')}
|
|
2432
|
+
ORDER BY r.path, r.method
|
|
2433
|
+
LIMIT ?
|
|
2434
|
+
`).all(...args);
|
|
2435
|
+
return rows.map(r => ({
|
|
2436
|
+
id: toNum(r.id),
|
|
2437
|
+
method: toStr(r.method),
|
|
2438
|
+
path: toStr(r.path),
|
|
2439
|
+
framework: toStr(r.framework),
|
|
2440
|
+
handlerName: toNullStr(r.handlerName),
|
|
2441
|
+
line: toNum(r.line),
|
|
2442
|
+
protocol: toNullStr(r.protocol),
|
|
2443
|
+
operation: toNullStr(r.operation),
|
|
2444
|
+
topic: toNullStr(r.topic),
|
|
2445
|
+
queue: toNullStr(r.queue),
|
|
2446
|
+
service: toNullStr(r.service),
|
|
2447
|
+
externalBundleId: toNum(r.externalBundleId),
|
|
2448
|
+
externalProject: toNullStr(r.externalProject),
|
|
2449
|
+
}));
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
return [];
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
// ── External dependencies ───────────────────────────────────────────────────
|
|
2456
|
+
listExternalDeps(options = {}) {
|
|
2457
|
+
if (!this.hasV4Tables)
|
|
2458
|
+
return [];
|
|
2459
|
+
const where = [];
|
|
2460
|
+
const args = [];
|
|
2461
|
+
if (options.ecosystem) {
|
|
2462
|
+
where.push('ecosystem = ?');
|
|
2463
|
+
args.push(options.ecosystem);
|
|
2464
|
+
}
|
|
2465
|
+
if (options.nameSubstr) {
|
|
2466
|
+
where.push('name LIKE ?');
|
|
2467
|
+
args.push(`%${options.nameSubstr}%`);
|
|
2468
|
+
}
|
|
2469
|
+
const limit = options.limit ?? 500;
|
|
2470
|
+
args.push(limit);
|
|
2471
|
+
const sql = `
|
|
2472
|
+
SELECT id, ecosystem, name, version_range AS versionRange,
|
|
2473
|
+
manifest_path AS manifestPath, is_dev AS isDev
|
|
2474
|
+
FROM external_dependencies
|
|
2475
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
2476
|
+
ORDER BY ecosystem, name
|
|
2477
|
+
LIMIT ?
|
|
2478
|
+
`;
|
|
2479
|
+
const rows = this.db.prepare(sql).all(...args);
|
|
2480
|
+
return rows.map(r => ({
|
|
2481
|
+
id: toNum(r.id),
|
|
2482
|
+
ecosystem: toStr(r.ecosystem),
|
|
2483
|
+
name: toStr(r.name),
|
|
2484
|
+
versionRange: toNullStr(r.versionRange),
|
|
2485
|
+
manifestPath: toStr(r.manifestPath),
|
|
2486
|
+
isDev: toNum(r.isDev),
|
|
2487
|
+
}));
|
|
2488
|
+
}
|
|
2489
|
+
countExternalDeps() {
|
|
2490
|
+
if (!this.hasV4Tables)
|
|
2491
|
+
return 0;
|
|
2492
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM external_dependencies').get();
|
|
2493
|
+
return toNum(row.c);
|
|
2494
|
+
}
|
|
2495
|
+
// ── Config keys ─────────────────────────────────────────────────────────────
|
|
2496
|
+
listConfigKeys(options = {}) {
|
|
2497
|
+
if (!this.hasV4Tables)
|
|
2498
|
+
return [];
|
|
2499
|
+
const where = [];
|
|
2500
|
+
const args = [];
|
|
2501
|
+
if (options.key) {
|
|
2502
|
+
where.push('c.key LIKE ?');
|
|
2503
|
+
args.push(`%${options.key}%`);
|
|
2504
|
+
}
|
|
2505
|
+
if (options.source) {
|
|
2506
|
+
where.push('c.source = ?');
|
|
2507
|
+
args.push(options.source);
|
|
2508
|
+
}
|
|
2509
|
+
const limit = options.limit ?? 200;
|
|
2510
|
+
args.push(limit);
|
|
2511
|
+
const sql = `
|
|
2512
|
+
SELECT c.id, c.key, c.source, f.path AS filePath,
|
|
2513
|
+
c.symbol_id AS symbolId,
|
|
2514
|
+
s.qualified_name AS symbolName,
|
|
2515
|
+
c.line
|
|
2516
|
+
FROM config_keys c
|
|
2517
|
+
JOIN files f ON f.id = c.file_id
|
|
2518
|
+
LEFT JOIN symbols s ON s.id = c.symbol_id
|
|
2519
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
2520
|
+
ORDER BY c.key, f.path
|
|
2521
|
+
LIMIT ?
|
|
2522
|
+
`;
|
|
2523
|
+
const rows = this.db.prepare(sql).all(...args);
|
|
2524
|
+
return rows.map(r => ({
|
|
2525
|
+
id: toNum(r.id),
|
|
2526
|
+
key: toStr(r.key),
|
|
2527
|
+
source: toStr(r.source),
|
|
2528
|
+
filePath: toStr(r.filePath),
|
|
2529
|
+
symbolId: toNullNum(r.symbolId),
|
|
2530
|
+
symbolName: toNullStr(r.symbolName),
|
|
2531
|
+
line: toNum(r.line),
|
|
2532
|
+
}));
|
|
2533
|
+
}
|
|
2534
|
+
countConfigKeys() {
|
|
2535
|
+
if (!this.hasV4Tables)
|
|
2536
|
+
return 0;
|
|
2537
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM config_keys').get();
|
|
2538
|
+
return toNum(row.c);
|
|
2539
|
+
}
|
|
2540
|
+
// ── File churn ──────────────────────────────────────────────────────────────
|
|
2541
|
+
upsertFileChurn(fileId, commitCount, lastCommitSha, lastCommitAt, topAuthor, secondAuthor) {
|
|
2542
|
+
this.db.prepare(`
|
|
2543
|
+
INSERT INTO file_churn (file_id, commit_count, last_commit_sha, last_commit_at, top_author, second_author, collected_at)
|
|
2544
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2545
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
2546
|
+
commit_count = excluded.commit_count,
|
|
2547
|
+
last_commit_sha = excluded.last_commit_sha,
|
|
2548
|
+
last_commit_at = excluded.last_commit_at,
|
|
2549
|
+
top_author = excluded.top_author,
|
|
2550
|
+
second_author = excluded.second_author,
|
|
2551
|
+
collected_at = excluded.collected_at
|
|
2552
|
+
`).run(fileId, commitCount, lastCommitSha, lastCommitAt, topAuthor, secondAuthor, Date.now());
|
|
2553
|
+
}
|
|
2554
|
+
getFileChurn(filePath) {
|
|
2555
|
+
if (!this.hasV4Tables)
|
|
2556
|
+
return null;
|
|
2557
|
+
const row = this.db.prepare(`
|
|
2558
|
+
SELECT c.file_id AS fileId, f.path AS filePath,
|
|
2559
|
+
c.commit_count AS commitCount,
|
|
2560
|
+
c.last_commit_sha AS lastCommitSha,
|
|
2561
|
+
c.last_commit_at AS lastCommitAt,
|
|
2562
|
+
c.top_author AS topAuthor,
|
|
2563
|
+
c.second_author AS secondAuthor
|
|
2564
|
+
FROM file_churn c JOIN files f ON f.id = c.file_id
|
|
2565
|
+
WHERE f.path = ? OR f.rel_path = ?
|
|
2566
|
+
`).get(filePath, filePath);
|
|
2567
|
+
if (!row)
|
|
2568
|
+
return null;
|
|
2569
|
+
return {
|
|
2570
|
+
fileId: toNum(row.fileId),
|
|
2571
|
+
filePath: toStr(row.filePath),
|
|
2572
|
+
commitCount: toNum(row.commitCount),
|
|
2573
|
+
lastCommitSha: toNullStr(row.lastCommitSha),
|
|
2574
|
+
lastCommitAt: toNullNum(row.lastCommitAt),
|
|
2575
|
+
topAuthor: toNullStr(row.topAuthor),
|
|
2576
|
+
secondAuthor: toNullStr(row.secondAuthor),
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
topChurnedFiles(limit = 20) {
|
|
2580
|
+
if (!this.hasV4Tables)
|
|
2581
|
+
return [];
|
|
2582
|
+
const rows = this.db.prepare(`
|
|
2583
|
+
SELECT c.file_id AS fileId, f.path AS filePath,
|
|
2584
|
+
c.commit_count AS commitCount,
|
|
2585
|
+
c.last_commit_sha AS lastCommitSha,
|
|
2586
|
+
c.last_commit_at AS lastCommitAt,
|
|
2587
|
+
c.top_author AS topAuthor,
|
|
2588
|
+
c.second_author AS secondAuthor
|
|
2589
|
+
FROM file_churn c JOIN files f ON f.id = c.file_id
|
|
2590
|
+
ORDER BY c.commit_count DESC
|
|
2591
|
+
LIMIT ?
|
|
2592
|
+
`).all(limit);
|
|
2593
|
+
return rows.map(r => ({
|
|
2594
|
+
fileId: toNum(r.fileId),
|
|
2595
|
+
filePath: toStr(r.filePath),
|
|
2596
|
+
commitCount: toNum(r.commitCount),
|
|
2597
|
+
lastCommitSha: toNullStr(r.lastCommitSha),
|
|
2598
|
+
lastCommitAt: toNullNum(r.lastCommitAt),
|
|
2599
|
+
topAuthor: toNullStr(r.topAuthor),
|
|
2600
|
+
secondAuthor: toNullStr(r.secondAuthor),
|
|
2601
|
+
}));
|
|
2602
|
+
}
|
|
2603
|
+
// ── Symbol history ──────────────────────────────────────────────────────────
|
|
2604
|
+
insertSymbolHistory(symbolId, symbolKey, commitSha, authorName, authorEmail, committedAt, message, linesAdded, linesRemoved, prNumber, prUrl, matchStrategy, confidence) {
|
|
2605
|
+
this.db.prepare(`
|
|
2606
|
+
INSERT OR IGNORE INTO symbol_history
|
|
2607
|
+
(symbol_id, symbol_key, commit_sha, author_name, author_email, committed_at, message,
|
|
2608
|
+
lines_added, lines_removed, pr_number, pr_url, match_strategy, confidence)
|
|
2609
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2610
|
+
`).run(symbolId, symbolKey, commitSha, authorName, authorEmail, committedAt, message, linesAdded, linesRemoved, prNumber, prUrl, matchStrategy, confidence);
|
|
2611
|
+
}
|
|
2612
|
+
getSymbolHistory(symbolId, options = {}) {
|
|
2613
|
+
if (!this.hasV4Tables)
|
|
2614
|
+
return [];
|
|
2615
|
+
const limit = Math.max(1, options.limit ?? 50);
|
|
2616
|
+
const since = options.since;
|
|
2617
|
+
const where = since != null ? 'AND committed_at >= ?' : '';
|
|
2618
|
+
const args = [symbolId];
|
|
2619
|
+
if (since != null)
|
|
2620
|
+
args.push(since);
|
|
2621
|
+
args.push(limit);
|
|
2622
|
+
const rows = this.db.prepare(`
|
|
2623
|
+
SELECT id, symbol_id AS symbolId, symbol_key AS symbolKey, commit_sha AS commitSha,
|
|
2624
|
+
author_name AS authorName, author_email AS authorEmail,
|
|
2625
|
+
committed_at AS committedAt, message,
|
|
2626
|
+
lines_added AS linesAdded, lines_removed AS linesRemoved,
|
|
2627
|
+
pr_number AS prNumber, pr_url AS prUrl,
|
|
2628
|
+
match_strategy AS matchStrategy, confidence
|
|
2629
|
+
FROM symbol_history
|
|
2630
|
+
WHERE symbol_id = ? ${where}
|
|
2631
|
+
ORDER BY committed_at DESC
|
|
2632
|
+
LIMIT ?
|
|
2633
|
+
`).all(...args);
|
|
2634
|
+
return rows.map(r => ({
|
|
2635
|
+
id: toNum(r.id),
|
|
2636
|
+
symbolId: toNum(r.symbolId),
|
|
2637
|
+
symbolKey: toStr(r.symbolKey),
|
|
2638
|
+
commitSha: toStr(r.commitSha),
|
|
2639
|
+
authorName: toNullStr(r.authorName),
|
|
2640
|
+
authorEmail: toNullStr(r.authorEmail),
|
|
2641
|
+
committedAt: toNum(r.committedAt),
|
|
2642
|
+
message: toNullStr(r.message),
|
|
2643
|
+
linesAdded: toNum(r.linesAdded),
|
|
2644
|
+
linesRemoved: toNum(r.linesRemoved),
|
|
2645
|
+
prNumber: toNullNum(r.prNumber),
|
|
2646
|
+
prUrl: toNullStr(r.prUrl),
|
|
2647
|
+
matchStrategy: toStr(r.matchStrategy),
|
|
2648
|
+
confidence: Number(r.confidence),
|
|
2649
|
+
}));
|
|
2650
|
+
}
|
|
2651
|
+
/** Total history count for a symbol — for "showing N of M commits" headers. */
|
|
2652
|
+
countSymbolHistory(symbolId) {
|
|
2653
|
+
if (!this.hasV4Tables)
|
|
2654
|
+
return 0;
|
|
2655
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM symbol_history WHERE symbol_id = ?').get(symbolId);
|
|
2656
|
+
return toNum(row.c);
|
|
2657
|
+
}
|
|
2658
|
+
getGitIndexState() {
|
|
2659
|
+
if (!this.hasV4Tables)
|
|
2660
|
+
return null;
|
|
2661
|
+
const row = this.db.prepare(`SELECT repo_root AS repoRoot, last_head_sha AS lastHeadSha,
|
|
2662
|
+
last_processed_at AS lastProcessedAt, remote_url AS remoteUrl,
|
|
2663
|
+
algorithm_version AS algorithmVersion,
|
|
2664
|
+
last_history_head_sha AS lastHistoryHeadSha,
|
|
2665
|
+
last_history_at AS lastHistoryAt
|
|
2666
|
+
FROM git_index_state WHERE id = 1`).get();
|
|
2667
|
+
if (!row)
|
|
2668
|
+
return null;
|
|
2669
|
+
return {
|
|
2670
|
+
repoRoot: toStr(row.repoRoot),
|
|
2671
|
+
lastHeadSha: toNullStr(row.lastHeadSha),
|
|
2672
|
+
lastProcessedAt: toNum(row.lastProcessedAt),
|
|
2673
|
+
remoteUrl: toNullStr(row.remoteUrl),
|
|
2674
|
+
algorithmVersion: toNum(row.algorithmVersion),
|
|
2675
|
+
lastHistoryHeadSha: toNullStr(row.lastHistoryHeadSha),
|
|
2676
|
+
lastHistoryAt: row.lastHistoryAt == null ? null : toNum(row.lastHistoryAt),
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Generic "the indexer has seen this HEAD" stamp — used by churn and any
|
|
2681
|
+
* other read-only git pass. Does NOT touch the history-specific marker.
|
|
2682
|
+
* symbol-history has its own setHistoryHeadSha() so the two passes can't
|
|
2683
|
+
* mask each other.
|
|
2684
|
+
*/
|
|
2685
|
+
setGitIndexState(repoRoot, lastHeadSha, remoteUrl, algorithmVersion = 1) {
|
|
2686
|
+
this.db.prepare(`
|
|
2687
|
+
INSERT INTO git_index_state (id, repo_root, last_head_sha, last_processed_at, remote_url, algorithm_version)
|
|
2688
|
+
VALUES (1, ?, ?, ?, ?, ?)
|
|
2689
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2690
|
+
repo_root = excluded.repo_root,
|
|
2691
|
+
last_head_sha = excluded.last_head_sha,
|
|
2692
|
+
last_processed_at = excluded.last_processed_at,
|
|
2693
|
+
remote_url = excluded.remote_url,
|
|
2694
|
+
algorithm_version = excluded.algorithm_version
|
|
2695
|
+
`).run(repoRoot, lastHeadSha, Date.now(), remoteUrl, algorithmVersion);
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* Stamp the HEAD that symbol-history was last built against. Independent of
|
|
2699
|
+
* setGitIndexState() so running file-level churn never makes a subsequent
|
|
2700
|
+
* buildSymbolHistory() skip.
|
|
2701
|
+
*/
|
|
2702
|
+
setHistoryHeadSha(repoRoot, lastHistoryHeadSha, remoteUrl) {
|
|
2703
|
+
// Upsert: insert a fresh row if churn hasn't run yet; otherwise just
|
|
2704
|
+
// update the history columns. repo_root + remote_url are kept in sync
|
|
2705
|
+
// either way so the row stays self-describing.
|
|
2706
|
+
this.db.prepare(`
|
|
2707
|
+
INSERT INTO git_index_state
|
|
2708
|
+
(id, repo_root, last_processed_at, remote_url, algorithm_version,
|
|
2709
|
+
last_history_head_sha, last_history_at)
|
|
2710
|
+
VALUES (1, ?, ?, ?, 1, ?, ?)
|
|
2711
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2712
|
+
repo_root = excluded.repo_root,
|
|
2713
|
+
remote_url = COALESCE(excluded.remote_url, git_index_state.remote_url),
|
|
2714
|
+
last_history_head_sha = excluded.last_history_head_sha,
|
|
2715
|
+
last_history_at = excluded.last_history_at
|
|
2716
|
+
`).run(repoRoot, Date.now(), remoteUrl, lastHistoryHeadSha, Date.now());
|
|
2717
|
+
}
|
|
2718
|
+
/** All symbols matching a symbol_key — used by `seer_history` to find the
|
|
2719
|
+
* current id for a key that came from the indexed graph. */
|
|
2720
|
+
findSymbolsByKey(symbolKey) {
|
|
2721
|
+
const rows = this.db.prepare(`
|
|
2722
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
2723
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
2724
|
+
WHERE s.symbol_key = ?
|
|
2725
|
+
ORDER BY s.pagerank DESC
|
|
2726
|
+
`).all(symbolKey);
|
|
2727
|
+
return rows.map(toSymbolRow);
|
|
2728
|
+
}
|
|
2729
|
+
/** Iterate over (id, file_id, line_start, line_end, symbol_key) — used by
|
|
2730
|
+
* the symbol-history indexer to map historical line ranges to current ids. */
|
|
2731
|
+
listSymbolsForHistoryIndex() {
|
|
2732
|
+
const rows = this.db.prepare(`
|
|
2733
|
+
SELECT s.id, s.file_id AS fileId, f.path AS filePath, f.rel_path AS relPath,
|
|
2734
|
+
s.line_start AS lineStart, s.line_end AS lineEnd, s.symbol_key AS symbolKey
|
|
2735
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
2736
|
+
WHERE s.symbol_key IS NOT NULL
|
|
2737
|
+
AND s.kind IN ('function','method','constructor','class')
|
|
2738
|
+
`).all();
|
|
2739
|
+
return rows.map(r => ({
|
|
2740
|
+
id: toNum(r.id), fileId: toNum(r.fileId),
|
|
2741
|
+
filePath: toStr(r.filePath), relPath: toStr(r.relPath),
|
|
2742
|
+
lineStart: toNum(r.lineStart), lineEnd: toNum(r.lineEnd),
|
|
2743
|
+
symbolKey: toStr(r.symbolKey),
|
|
2744
|
+
}));
|
|
2745
|
+
}
|
|
2746
|
+
// ── PageRank helpers ────────────────────────────────────────────────────────
|
|
2747
|
+
getAllEdges() {
|
|
2748
|
+
const rows = this.db.prepare(`
|
|
2749
|
+
SELECT e.from_id AS \`from\`, e.to_id AS \`to\`
|
|
2750
|
+
FROM edges e
|
|
2751
|
+
JOIN symbols sf ON sf.id = e.from_id AND sf.is_rankable = 1
|
|
2752
|
+
JOIN symbols st ON st.id = e.to_id AND st.is_rankable = 1
|
|
2753
|
+
WHERE e.to_id IS NOT NULL
|
|
2754
|
+
AND e.kind = 'call'
|
|
2755
|
+
`).all();
|
|
2756
|
+
return rows.map(r => ({ from: toNum(r.from), to: toNum(r.to) }));
|
|
2757
|
+
}
|
|
2758
|
+
getAllSymbolIds() {
|
|
2759
|
+
const rows = this.db.prepare('SELECT id FROM symbols WHERE is_rankable = 1').all();
|
|
2760
|
+
return rows.map(r => toNum(r.id));
|
|
2761
|
+
}
|
|
2762
|
+
updatePageRanks(ranks) {
|
|
2763
|
+
const stmt = this.db.prepare('UPDATE symbols SET pagerank = ? WHERE id = ?');
|
|
2764
|
+
this.db.exec('BEGIN');
|
|
2765
|
+
try {
|
|
2766
|
+
this.db.prepare('UPDATE symbols SET pagerank = 0 WHERE is_rankable = 0 AND pagerank != 0').run();
|
|
2767
|
+
for (const [id, rank] of ranks) {
|
|
2768
|
+
stmt.run(rank, id);
|
|
2769
|
+
}
|
|
2770
|
+
this.db.exec('COMMIT');
|
|
2771
|
+
}
|
|
2772
|
+
catch (err) {
|
|
2773
|
+
this.db.exec('ROLLBACK');
|
|
2774
|
+
throw err;
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
// ── Graph traversal ─────────────────────────────────────────────────────────
|
|
2778
|
+
/**
|
|
2779
|
+
* Bounded breadth-first search over the call graph. Returns one shortest
|
|
2780
|
+
* path from `fromId` to `toId` (by edge count), or null if none found.
|
|
2781
|
+
* The search expands at most `maxDepth` hops and at most `maxNodes` nodes
|
|
2782
|
+
* visited overall — without those caps a cycle in the graph would explode.
|
|
2783
|
+
*/
|
|
2784
|
+
tracePath(fromId, toId, maxDepth = 6, maxNodes = 20_000) {
|
|
2785
|
+
if (fromId === toId) {
|
|
2786
|
+
const row = this.getSymbolById(fromId);
|
|
2787
|
+
return row ? [{ id: row.id, name: row.name, qualifiedName: row.qualifiedName, kind: row.kind, filePath: row.filePath }] : null;
|
|
2788
|
+
}
|
|
2789
|
+
const adjStmt = this.db.prepare("SELECT DISTINCT to_id FROM edges WHERE from_id = ? AND to_id IS NOT NULL AND kind = 'call'");
|
|
2790
|
+
const parent = new Map();
|
|
2791
|
+
parent.set(fromId, -1);
|
|
2792
|
+
const queue = [{ id: fromId, depth: 0 }];
|
|
2793
|
+
let visited = 0;
|
|
2794
|
+
while (queue.length > 0) {
|
|
2795
|
+
const { id, depth } = queue.shift();
|
|
2796
|
+
visited++;
|
|
2797
|
+
if (visited > maxNodes)
|
|
2798
|
+
return null;
|
|
2799
|
+
if (depth >= maxDepth)
|
|
2800
|
+
continue;
|
|
2801
|
+
const rows = adjStmt.all(id);
|
|
2802
|
+
for (const r of rows) {
|
|
2803
|
+
const next = toNum(r.to_id);
|
|
2804
|
+
if (parent.has(next))
|
|
2805
|
+
continue;
|
|
2806
|
+
parent.set(next, id);
|
|
2807
|
+
if (next === toId) {
|
|
2808
|
+
// Reconstruct
|
|
2809
|
+
const path = [];
|
|
2810
|
+
let cur = next;
|
|
2811
|
+
while (cur !== -1) {
|
|
2812
|
+
path.push(cur);
|
|
2813
|
+
cur = parent.get(cur);
|
|
2814
|
+
}
|
|
2815
|
+
path.reverse();
|
|
2816
|
+
return path.map(pid => {
|
|
2817
|
+
const s = this.getSymbolById(pid);
|
|
2818
|
+
return s ? { id: s.id, name: s.name, qualifiedName: s.qualifiedName, kind: s.kind, filePath: s.filePath }
|
|
2819
|
+
: { id: pid, name: '', qualifiedName: null, kind: '', filePath: '' };
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
queue.push({ id: next, depth: depth + 1 });
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
return null;
|
|
2826
|
+
}
|
|
2827
|
+
/** Reverse BFS from a symbol — for "everything that transitively calls X". */
|
|
2828
|
+
reverseReachable(toId, maxDepth = 4, maxNodes = 20_000) {
|
|
2829
|
+
const stmt = this.db.prepare("SELECT DISTINCT from_id FROM edges WHERE to_id = ? AND kind = 'call'");
|
|
2830
|
+
const seen = new Set([toId]);
|
|
2831
|
+
const queue = [{ id: toId, depth: 0 }];
|
|
2832
|
+
while (queue.length > 0) {
|
|
2833
|
+
const { id, depth } = queue.shift();
|
|
2834
|
+
if (seen.size > maxNodes)
|
|
2835
|
+
break;
|
|
2836
|
+
if (depth >= maxDepth)
|
|
2837
|
+
continue;
|
|
2838
|
+
const rows = stmt.all(id);
|
|
2839
|
+
for (const r of rows) {
|
|
2840
|
+
const next = toNum(r.from_id);
|
|
2841
|
+
if (seen.has(next))
|
|
2842
|
+
continue;
|
|
2843
|
+
seen.add(next);
|
|
2844
|
+
queue.push({ id: next, depth: depth + 1 });
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
seen.delete(toId);
|
|
2848
|
+
return Array.from(seen);
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Bounded reverse-reachable callers WITH depth, for risk/context callers.
|
|
2852
|
+
* Same termination semantics as reverseReachable() but returns the depth
|
|
2853
|
+
* at which each id was first discovered (1-indexed; direct callers = 1).
|
|
2854
|
+
*/
|
|
2855
|
+
reverseReachableWithDepth(toId, maxDepth = 4, maxNodes = 20_000) {
|
|
2856
|
+
const stmt = this.db.prepare("SELECT DISTINCT from_id FROM edges WHERE to_id = ? AND kind = 'call'");
|
|
2857
|
+
const seen = new Map([[toId, 0]]);
|
|
2858
|
+
const queue = [{ id: toId, depth: 0 }];
|
|
2859
|
+
while (queue.length > 0) {
|
|
2860
|
+
const { id, depth } = queue.shift();
|
|
2861
|
+
if (seen.size > maxNodes)
|
|
2862
|
+
break;
|
|
2863
|
+
if (depth >= maxDepth)
|
|
2864
|
+
continue;
|
|
2865
|
+
const rows = stmt.all(id);
|
|
2866
|
+
for (const r of rows) {
|
|
2867
|
+
const next = toNum(r.from_id);
|
|
2868
|
+
if (seen.has(next))
|
|
2869
|
+
continue;
|
|
2870
|
+
seen.set(next, depth + 1);
|
|
2871
|
+
queue.push({ id: next, depth: depth + 1 });
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
seen.delete(toId);
|
|
2875
|
+
return Array.from(seen.entries()).map(([id, depth]) => ({ id, depth }));
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Bounded forward-reachable callees with depth — for callee blast-radius
|
|
2879
|
+
* questions and behavioral indirect-coverage. Mirror of
|
|
2880
|
+
* reverseReachableWithDepth().
|
|
2881
|
+
*/
|
|
2882
|
+
forwardReachableWithDepth(fromId, maxDepth = 4, maxNodes = 20_000) {
|
|
2883
|
+
const stmt = this.db.prepare("SELECT DISTINCT to_id FROM edges WHERE from_id = ? AND to_id IS NOT NULL AND kind = 'call'");
|
|
2884
|
+
const seen = new Map([[fromId, 0]]);
|
|
2885
|
+
const queue = [{ id: fromId, depth: 0 }];
|
|
2886
|
+
while (queue.length > 0) {
|
|
2887
|
+
const { id, depth } = queue.shift();
|
|
2888
|
+
if (seen.size > maxNodes)
|
|
2889
|
+
break;
|
|
2890
|
+
if (depth >= maxDepth)
|
|
2891
|
+
continue;
|
|
2892
|
+
const rows = stmt.all(id);
|
|
2893
|
+
for (const r of rows) {
|
|
2894
|
+
const next = toNum(r.to_id);
|
|
2895
|
+
if (seen.has(next))
|
|
2896
|
+
continue;
|
|
2897
|
+
seen.set(next, depth + 1);
|
|
2898
|
+
queue.push({ id: next, depth: depth + 1 });
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
seen.delete(fromId);
|
|
2902
|
+
return Array.from(seen.entries()).map(([id, depth]) => ({ id, depth }));
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Bounded BFS over the file-import graph. Used by
|
|
2906
|
+
* seer_trace_file_dependencies — returns each reachable file with the BFS
|
|
2907
|
+
* depth at which we first saw it.
|
|
2908
|
+
*/
|
|
2909
|
+
fileImportClosure(fileId, maxDepth = 4, maxNodes = 5_000) {
|
|
2910
|
+
const stmt = this.db.prepare('SELECT DISTINCT resolved_file_id FROM file_imports WHERE from_file_id = ? AND resolved_file_id IS NOT NULL');
|
|
2911
|
+
const seen = new Map([[fileId, 0]]);
|
|
2912
|
+
const queue = [{ id: fileId, depth: 0 }];
|
|
2913
|
+
while (queue.length > 0) {
|
|
2914
|
+
const { id, depth } = queue.shift();
|
|
2915
|
+
if (seen.size > maxNodes)
|
|
2916
|
+
break;
|
|
2917
|
+
if (depth >= maxDepth)
|
|
2918
|
+
continue;
|
|
2919
|
+
const rows = stmt.all(id);
|
|
2920
|
+
for (const r of rows) {
|
|
2921
|
+
const next = toNum(r.resolved_file_id);
|
|
2922
|
+
if (seen.has(next))
|
|
2923
|
+
continue;
|
|
2924
|
+
seen.set(next, depth + 1);
|
|
2925
|
+
queue.push({ id: next, depth: depth + 1 });
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
seen.delete(fileId);
|
|
2929
|
+
if (seen.size === 0)
|
|
2930
|
+
return [];
|
|
2931
|
+
const ids = Array.from(seen.keys());
|
|
2932
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
2933
|
+
const rows = this.db.prepare(`SELECT id, rel_path AS relPath, language FROM files WHERE id IN (${placeholders})`).all(...ids);
|
|
2934
|
+
const meta = new Map(rows.map(r => [
|
|
2935
|
+
toNum(r.id),
|
|
2936
|
+
{ relPath: toStr(r.relPath), language: toStr(r.language) },
|
|
2937
|
+
]));
|
|
2938
|
+
return ids.map(id => {
|
|
2939
|
+
const m = meta.get(id);
|
|
2940
|
+
return {
|
|
2941
|
+
id,
|
|
2942
|
+
depth: seen.get(id),
|
|
2943
|
+
relPath: m?.relPath ?? '',
|
|
2944
|
+
language: m?.language ?? '',
|
|
2945
|
+
};
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
// ── Track-E: file/module aggregate graph helpers ────────────────────────────
|
|
2949
|
+
/**
|
|
2950
|
+
* All cross-file call edges as (fromFile, toFile, weight) triples.
|
|
2951
|
+
* Used by the Louvain clusterer; only resolved 'call' edges count.
|
|
2952
|
+
*/
|
|
2953
|
+
fileCallEdgeWeights() {
|
|
2954
|
+
const rows = this.db.prepare(`
|
|
2955
|
+
SELECT sf.file_id AS fromFile, st.file_id AS toFile, COUNT(*) AS w
|
|
2956
|
+
FROM edges e
|
|
2957
|
+
JOIN symbols sf ON sf.id = e.from_id
|
|
2958
|
+
JOIN symbols st ON st.id = e.to_id
|
|
2959
|
+
WHERE e.kind = 'call' AND e.to_id IS NOT NULL
|
|
2960
|
+
AND sf.file_id <> st.file_id
|
|
2961
|
+
GROUP BY sf.file_id, st.file_id
|
|
2962
|
+
`).all();
|
|
2963
|
+
return rows.map(r => ({
|
|
2964
|
+
from: toNum(r.fromFile),
|
|
2965
|
+
to: toNum(r.toFile),
|
|
2966
|
+
weight: toNum(r.w),
|
|
2967
|
+
}));
|
|
2968
|
+
}
|
|
2969
|
+
/** Resolved cross-file import edges as (fromFile, toFile, weight). */
|
|
2970
|
+
fileImportEdgeWeights() {
|
|
2971
|
+
const rows = this.db.prepare(`
|
|
2972
|
+
SELECT from_file_id AS fromFile, resolved_file_id AS toFile, COUNT(*) AS w
|
|
2973
|
+
FROM file_imports
|
|
2974
|
+
WHERE resolved_file_id IS NOT NULL
|
|
2975
|
+
AND from_file_id <> resolved_file_id
|
|
2976
|
+
GROUP BY from_file_id, resolved_file_id
|
|
2977
|
+
`).all();
|
|
2978
|
+
return rows.map(r => ({
|
|
2979
|
+
from: toNum(r.fromFile),
|
|
2980
|
+
to: toNum(r.toFile),
|
|
2981
|
+
weight: toNum(r.w),
|
|
2982
|
+
}));
|
|
2983
|
+
}
|
|
2984
|
+
/** Synthesized test → production edges, file-aggregated. */
|
|
2985
|
+
fileTestEdgeWeights() {
|
|
2986
|
+
const rows = this.db.prepare(`
|
|
2987
|
+
SELECT sf.file_id AS fromFile, st.file_id AS toFile, COUNT(*) AS w
|
|
2988
|
+
FROM edges e
|
|
2989
|
+
JOIN symbols sf ON sf.id = e.from_id
|
|
2990
|
+
JOIN symbols st ON st.id = e.to_id
|
|
2991
|
+
WHERE e.kind = 'tests' AND e.to_id IS NOT NULL
|
|
2992
|
+
AND sf.file_id <> st.file_id
|
|
2993
|
+
GROUP BY sf.file_id, st.file_id
|
|
2994
|
+
`).all();
|
|
2995
|
+
return rows.map(r => ({
|
|
2996
|
+
from: toNum(r.fromFile),
|
|
2997
|
+
to: toNum(r.toFile),
|
|
2998
|
+
weight: toNum(r.w),
|
|
2999
|
+
}));
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* v8 Track-G — service-link file-aggregated edges. Each link contributes one
|
|
3003
|
+
* cross-file edge from the call-site file (service_calls.file_id) to the
|
|
3004
|
+
* handler-symbol's file. Used by the module clusterer to surface
|
|
3005
|
+
* client→handler dependencies as architecturally important.
|
|
3006
|
+
*/
|
|
3007
|
+
fileServiceLinkEdgeWeights() {
|
|
3008
|
+
try {
|
|
3009
|
+
const rows = this.db.prepare(`
|
|
3010
|
+
SELECT sc.file_id AS fromFile, hs.file_id AS toFile, COUNT(*) AS w
|
|
3011
|
+
FROM service_links sl
|
|
3012
|
+
JOIN service_calls sc ON sc.id = sl.call_id
|
|
3013
|
+
LEFT JOIN symbols hs ON hs.id = sl.handler_symbol_id
|
|
3014
|
+
WHERE hs.file_id IS NOT NULL
|
|
3015
|
+
AND sc.file_id <> hs.file_id
|
|
3016
|
+
GROUP BY sc.file_id, hs.file_id
|
|
3017
|
+
`).all();
|
|
3018
|
+
return rows.map(r => ({
|
|
3019
|
+
from: toNum(r.fromFile),
|
|
3020
|
+
to: toNum(r.toFile),
|
|
3021
|
+
weight: toNum(r.w),
|
|
3022
|
+
}));
|
|
3023
|
+
}
|
|
3024
|
+
catch {
|
|
3025
|
+
return [];
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
/** All file ids + their language + rel path — feeds the clusterer. */
|
|
3029
|
+
listFileSummaries() {
|
|
3030
|
+
const rows = this.db.prepare('SELECT id, rel_path AS relPath, language, role FROM files').all();
|
|
3031
|
+
return rows.map(r => ({
|
|
3032
|
+
id: toNum(r.id), relPath: toStr(r.relPath),
|
|
3033
|
+
language: toStr(r.language), role: toStr(r.role),
|
|
3034
|
+
}));
|
|
3035
|
+
}
|
|
3036
|
+
// ── Track-E: modules persistence ────────────────────────────────────────────
|
|
3037
|
+
/**
|
|
3038
|
+
* Replace the modules / module_members / module_edges tables with the
|
|
3039
|
+
* provided clustering. Atomic — wrapped in a single transaction so a
|
|
3040
|
+
* partial write can't leave inconsistent membership.
|
|
3041
|
+
*/
|
|
3042
|
+
replaceModules(modules, edges, algorithm = 'louvain') {
|
|
3043
|
+
if (!this.hasModuleTables)
|
|
3044
|
+
return;
|
|
3045
|
+
this.db.exec('BEGIN');
|
|
3046
|
+
try {
|
|
3047
|
+
this.db.exec('DELETE FROM module_edges');
|
|
3048
|
+
this.db.exec('DELETE FROM module_members');
|
|
3049
|
+
this.db.exec('DELETE FROM modules');
|
|
3050
|
+
const insModule = this.db.prepare(`
|
|
3051
|
+
INSERT INTO modules (label, size_files, size_symbols, primary_language, cohesion, centrality, computed_at, algorithm)
|
|
3052
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3053
|
+
`);
|
|
3054
|
+
const insMember = this.db.prepare('INSERT INTO module_members (file_id, module_id) VALUES (?, ?)');
|
|
3055
|
+
const insEdge = this.db.prepare('INSERT OR REPLACE INTO module_edges (from_module_id, to_module_id, kind, weight) VALUES (?, ?, ?, ?)');
|
|
3056
|
+
const now = Date.now();
|
|
3057
|
+
const indexToId = [];
|
|
3058
|
+
for (const m of modules) {
|
|
3059
|
+
const res = insModule.run(m.label, m.sizeFiles, m.sizeSymbols, m.primaryLanguage, m.cohesion, m.centrality, now, algorithm);
|
|
3060
|
+
const id = toNum(res.lastInsertRowid);
|
|
3061
|
+
indexToId.push(id);
|
|
3062
|
+
for (const fid of m.fileIds)
|
|
3063
|
+
insMember.run(fid, id);
|
|
3064
|
+
}
|
|
3065
|
+
for (const e of edges) {
|
|
3066
|
+
const f = indexToId[e.fromIndex];
|
|
3067
|
+
const t = indexToId[e.toIndex];
|
|
3068
|
+
if (f == null || t == null)
|
|
3069
|
+
continue;
|
|
3070
|
+
insEdge.run(f, t, e.kind, e.weight);
|
|
3071
|
+
}
|
|
3072
|
+
this.db.exec('COMMIT');
|
|
3073
|
+
}
|
|
3074
|
+
catch (err) {
|
|
3075
|
+
this.db.exec('ROLLBACK');
|
|
3076
|
+
throw err;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
hasModulesData() {
|
|
3080
|
+
if (!this.hasModuleTables)
|
|
3081
|
+
return false;
|
|
3082
|
+
try {
|
|
3083
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM modules').get();
|
|
3084
|
+
return toNum(row.c) > 0;
|
|
3085
|
+
}
|
|
3086
|
+
catch {
|
|
3087
|
+
return false;
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
countModules() {
|
|
3091
|
+
if (!this.hasModuleTables)
|
|
3092
|
+
return 0;
|
|
3093
|
+
try {
|
|
3094
|
+
return toNum(this.db.prepare('SELECT COUNT(*) AS c FROM modules').get().c);
|
|
3095
|
+
}
|
|
3096
|
+
catch {
|
|
3097
|
+
return 0;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
listModules(options = {}) {
|
|
3101
|
+
if (!this.hasModuleTables)
|
|
3102
|
+
return [];
|
|
3103
|
+
const limit = options.limit ?? 100;
|
|
3104
|
+
const sortBy = options.sortBy ?? 'centrality';
|
|
3105
|
+
const order = sortBy === 'label' ? 'label ASC'
|
|
3106
|
+
: sortBy === 'size' ? 'size_files DESC, size_symbols DESC'
|
|
3107
|
+
: 'centrality DESC, size_files DESC';
|
|
3108
|
+
try {
|
|
3109
|
+
const rows = this.db.prepare(`
|
|
3110
|
+
SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
|
|
3111
|
+
primary_language AS primaryLanguage, cohesion, centrality
|
|
3112
|
+
FROM modules
|
|
3113
|
+
ORDER BY ${order}
|
|
3114
|
+
LIMIT ?
|
|
3115
|
+
`).all(limit);
|
|
3116
|
+
return rows.map(r => ({
|
|
3117
|
+
id: toNum(r.id),
|
|
3118
|
+
label: toStr(r.label),
|
|
3119
|
+
sizeFiles: toNum(r.sizeFiles),
|
|
3120
|
+
sizeSymbols: toNum(r.sizeSymbols),
|
|
3121
|
+
primaryLanguage: toNullStr(r.primaryLanguage),
|
|
3122
|
+
cohesion: Number(r.cohesion),
|
|
3123
|
+
centrality: Number(r.centrality),
|
|
3124
|
+
}));
|
|
3125
|
+
}
|
|
3126
|
+
catch {
|
|
3127
|
+
return [];
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
getModuleById(id) {
|
|
3131
|
+
if (!this.hasModuleTables)
|
|
3132
|
+
return null;
|
|
3133
|
+
try {
|
|
3134
|
+
const row = this.db.prepare(`
|
|
3135
|
+
SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
|
|
3136
|
+
primary_language AS primaryLanguage, cohesion, centrality
|
|
3137
|
+
FROM modules WHERE id = ?
|
|
3138
|
+
`).get(id);
|
|
3139
|
+
if (!row)
|
|
3140
|
+
return null;
|
|
3141
|
+
return {
|
|
3142
|
+
id: toNum(row.id),
|
|
3143
|
+
label: toStr(row.label),
|
|
3144
|
+
sizeFiles: toNum(row.sizeFiles),
|
|
3145
|
+
sizeSymbols: toNum(row.sizeSymbols),
|
|
3146
|
+
primaryLanguage: toNullStr(row.primaryLanguage),
|
|
3147
|
+
cohesion: Number(row.cohesion),
|
|
3148
|
+
centrality: Number(row.centrality),
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
catch {
|
|
3152
|
+
return null;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
/** Module label → row. Used by CLI/MCP module lookups by name. */
|
|
3156
|
+
getModuleByLabel(label) {
|
|
3157
|
+
if (!this.hasModuleTables)
|
|
3158
|
+
return null;
|
|
3159
|
+
try {
|
|
3160
|
+
const row = this.db.prepare(`
|
|
3161
|
+
SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
|
|
3162
|
+
primary_language AS primaryLanguage, cohesion, centrality
|
|
3163
|
+
FROM modules WHERE label = ?
|
|
3164
|
+
`).get(label);
|
|
3165
|
+
if (!row)
|
|
3166
|
+
return null;
|
|
3167
|
+
return {
|
|
3168
|
+
id: toNum(row.id),
|
|
3169
|
+
label: toStr(row.label),
|
|
3170
|
+
sizeFiles: toNum(row.sizeFiles),
|
|
3171
|
+
sizeSymbols: toNum(row.sizeSymbols),
|
|
3172
|
+
primaryLanguage: toNullStr(row.primaryLanguage),
|
|
3173
|
+
cohesion: Number(row.cohesion),
|
|
3174
|
+
centrality: Number(row.centrality),
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
catch {
|
|
3178
|
+
return null;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* Files in a module, sorted by file path. Returns empty array if the
|
|
3183
|
+
* module id doesn't exist or modules haven't been built.
|
|
3184
|
+
*/
|
|
3185
|
+
listModuleMembers(moduleId, limit = 1000) {
|
|
3186
|
+
if (!this.hasModuleTables)
|
|
3187
|
+
return [];
|
|
3188
|
+
try {
|
|
3189
|
+
const rows = this.db.prepare(`
|
|
3190
|
+
SELECT f.id AS fileId, f.path, f.rel_path AS relPath, f.language, f.role
|
|
3191
|
+
FROM module_members mm
|
|
3192
|
+
JOIN files f ON f.id = mm.file_id
|
|
3193
|
+
WHERE mm.module_id = ?
|
|
3194
|
+
ORDER BY f.rel_path
|
|
3195
|
+
LIMIT ?
|
|
3196
|
+
`).all(moduleId, limit);
|
|
3197
|
+
return rows.map(r => ({
|
|
3198
|
+
fileId: toNum(r.fileId), path: toStr(r.path), relPath: toStr(r.relPath),
|
|
3199
|
+
language: toStr(r.language), role: toStr(r.role),
|
|
3200
|
+
}));
|
|
3201
|
+
}
|
|
3202
|
+
catch {
|
|
3203
|
+
return [];
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
/** Top symbols (by PageRank) inside a module. Useful for "what does this module own?" */
|
|
3207
|
+
listModuleTopSymbols(moduleId, limit = 20) {
|
|
3208
|
+
if (!this.hasModuleTables)
|
|
3209
|
+
return [];
|
|
3210
|
+
try {
|
|
3211
|
+
const rows = this.db.prepare(`
|
|
3212
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
3213
|
+
FROM symbols s
|
|
3214
|
+
JOIN files f ON f.id = s.file_id
|
|
3215
|
+
JOIN module_members mm ON mm.file_id = s.file_id
|
|
3216
|
+
WHERE mm.module_id = ? AND s.is_rankable = 1
|
|
3217
|
+
ORDER BY s.pagerank DESC
|
|
3218
|
+
LIMIT ?
|
|
3219
|
+
`).all(moduleId, limit);
|
|
3220
|
+
return rows.map(toSymbolRow);
|
|
3221
|
+
}
|
|
3222
|
+
catch {
|
|
3223
|
+
return [];
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
/** Module containing a file id, or null when the file has no membership row. */
|
|
3227
|
+
moduleForFile(fileId) {
|
|
3228
|
+
if (!this.hasModuleTables)
|
|
3229
|
+
return null;
|
|
3230
|
+
try {
|
|
3231
|
+
const row = this.db.prepare(`
|
|
3232
|
+
SELECT m.id, m.label
|
|
3233
|
+
FROM module_members mm JOIN modules m ON m.id = mm.module_id
|
|
3234
|
+
WHERE mm.file_id = ?
|
|
3235
|
+
`).get(fileId);
|
|
3236
|
+
if (!row)
|
|
3237
|
+
return null;
|
|
3238
|
+
return { id: toNum(row.id), label: toStr(row.label) };
|
|
3239
|
+
}
|
|
3240
|
+
catch {
|
|
3241
|
+
return null;
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Cross-module dependency edges. Direction is configurable:
|
|
3246
|
+
* - 'out' (default) → modules this one depends on (from = moduleId)
|
|
3247
|
+
* - 'in' → modules that depend on this one (to = moduleId)
|
|
3248
|
+
* Aggregates across all edge kinds; the kind is preserved per row.
|
|
3249
|
+
*/
|
|
3250
|
+
moduleDependencies(moduleId, options = {}) {
|
|
3251
|
+
if (!this.hasModuleTables)
|
|
3252
|
+
return [];
|
|
3253
|
+
const direction = options.direction ?? 'out';
|
|
3254
|
+
const limit = options.limit ?? 100;
|
|
3255
|
+
const sideThis = direction === 'out' ? 'from_module_id' : 'to_module_id';
|
|
3256
|
+
const sideOther = direction === 'out' ? 'to_module_id' : 'from_module_id';
|
|
3257
|
+
try {
|
|
3258
|
+
const rows = this.db.prepare(`
|
|
3259
|
+
SELECT m.id AS moduleId, m.label, me.kind, me.weight
|
|
3260
|
+
FROM module_edges me JOIN modules m ON m.id = me.${sideOther}
|
|
3261
|
+
WHERE me.${sideThis} = ?
|
|
3262
|
+
ORDER BY me.weight DESC
|
|
3263
|
+
LIMIT ?
|
|
3264
|
+
`).all(moduleId, limit);
|
|
3265
|
+
return rows.map(r => ({
|
|
3266
|
+
moduleId: toNum(r.moduleId),
|
|
3267
|
+
label: toStr(r.label),
|
|
3268
|
+
kind: toStr(r.kind),
|
|
3269
|
+
weight: toNum(r.weight),
|
|
3270
|
+
}));
|
|
3271
|
+
}
|
|
3272
|
+
catch {
|
|
3273
|
+
return [];
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
// ── Track-E: behavioral / risk helpers ──────────────────────────────────────
|
|
3277
|
+
/**
|
|
3278
|
+
* Raw 'tests' edges into a specific symbol id — id-scoped so short-name
|
|
3279
|
+
* siblings (`Alpha.run` / `Beta.run`) don't share a behavioral contract.
|
|
3280
|
+
* Returns the test-side caller info (name, file, line) so the ranker can
|
|
3281
|
+
* compute path-convention and naming-convention signals without
|
|
3282
|
+
* re-fetching.
|
|
3283
|
+
*
|
|
3284
|
+
* The id-based filter is correct because `synthesizeTestEdges()` now
|
|
3285
|
+
* preserves the source call edge's resolved `to_id` verbatim instead of
|
|
3286
|
+
* re-resolving via `WHERE name = edges.to_name LIMIT 1` (which collapsed
|
|
3287
|
+
* same-short-name symbols).
|
|
3288
|
+
*/
|
|
3289
|
+
directTestEdgesForId(symbolId, limit = 200) {
|
|
3290
|
+
if (!this.hasV4Tables)
|
|
3291
|
+
return [];
|
|
3292
|
+
try {
|
|
3293
|
+
const rows = this.db.prepare(`
|
|
3294
|
+
SELECT
|
|
3295
|
+
s.id AS callerId,
|
|
3296
|
+
s.name AS callerName,
|
|
3297
|
+
s.qualified_name AS callerQualifiedName,
|
|
3298
|
+
s.kind AS callerKind,
|
|
3299
|
+
f.path AS callerFile,
|
|
3300
|
+
s.line_start AS callerLineStart,
|
|
3301
|
+
s.line_end AS callerLineEnd,
|
|
3302
|
+
e.line AS edgeLine
|
|
3303
|
+
FROM edges e
|
|
3304
|
+
JOIN symbols s ON s.id = e.from_id
|
|
3305
|
+
JOIN files f ON f.id = s.file_id
|
|
3306
|
+
WHERE e.to_id = ? AND e.kind = 'tests'
|
|
3307
|
+
ORDER BY f.path, e.line
|
|
3308
|
+
LIMIT ?
|
|
3309
|
+
`).all(symbolId, limit);
|
|
3310
|
+
return rows.map(r => ({
|
|
3311
|
+
callerId: toNum(r.callerId),
|
|
3312
|
+
callerName: toStr(r.callerName),
|
|
3313
|
+
callerQualifiedName: toNullStr(r.callerQualifiedName),
|
|
3314
|
+
callerKind: toStr(r.callerKind),
|
|
3315
|
+
callerFile: toStr(r.callerFile),
|
|
3316
|
+
callerLineStart: toNum(r.callerLineStart),
|
|
3317
|
+
callerLineEnd: toNum(r.callerLineEnd),
|
|
3318
|
+
edgeLine: toNum(r.edgeLine),
|
|
3319
|
+
// Computed in JS — needs the file contents.
|
|
3320
|
+
assertionCount: 0,
|
|
3321
|
+
}));
|
|
3322
|
+
}
|
|
3323
|
+
catch {
|
|
3324
|
+
return [];
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
/**
|
|
3328
|
+
* Count how many distinct routes have this symbol as their resolved handler.
|
|
3329
|
+
* Used by seer_risk for the "route exposure" signal.
|
|
3330
|
+
*/
|
|
3331
|
+
routesForHandler(symbolId) {
|
|
3332
|
+
if (!this.hasV4Tables)
|
|
3333
|
+
return [];
|
|
3334
|
+
try {
|
|
3335
|
+
const rows = this.db.prepare(`
|
|
3336
|
+
SELECT method, path, framework
|
|
3337
|
+
FROM routes WHERE handler_id = ?
|
|
3338
|
+
`).all(symbolId);
|
|
3339
|
+
return rows.map(r => ({
|
|
3340
|
+
method: toStr(r.method), path: toStr(r.path), framework: toStr(r.framework),
|
|
3341
|
+
}));
|
|
3342
|
+
}
|
|
3343
|
+
catch {
|
|
3344
|
+
return [];
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
/** Distinct config keys read inside a symbol's body. */
|
|
3348
|
+
configKeysForSymbol(symbolId) {
|
|
3349
|
+
if (!this.hasV4Tables)
|
|
3350
|
+
return [];
|
|
3351
|
+
try {
|
|
3352
|
+
const rows = this.db.prepare(`
|
|
3353
|
+
SELECT DISTINCT key, source, line
|
|
3354
|
+
FROM config_keys WHERE symbol_id = ?
|
|
3355
|
+
ORDER BY line
|
|
3356
|
+
`).all(symbolId);
|
|
3357
|
+
return rows.map(r => ({
|
|
3358
|
+
key: toStr(r.key), source: toStr(r.source), line: toNum(r.line),
|
|
3359
|
+
}));
|
|
3360
|
+
}
|
|
3361
|
+
catch {
|
|
3362
|
+
return [];
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
/**
|
|
3366
|
+
* For each call edge OUT of a symbol, return the callee's module id (when
|
|
3367
|
+
* resolved). Used by seer_risk for the "module-boundary crossing" signal.
|
|
3368
|
+
* NULL module ids are filtered out — those are external/unresolved calls.
|
|
3369
|
+
*/
|
|
3370
|
+
calleeModulesOf(symbolId) {
|
|
3371
|
+
if (!this.hasModuleTables)
|
|
3372
|
+
return [];
|
|
3373
|
+
try {
|
|
3374
|
+
const rows = this.db.prepare(`
|
|
3375
|
+
SELECT DISTINCT e.to_id AS calleeId, mm.module_id AS moduleId
|
|
3376
|
+
FROM edges e
|
|
3377
|
+
JOIN symbols s ON s.id = e.to_id
|
|
3378
|
+
JOIN module_members mm ON mm.file_id = s.file_id
|
|
3379
|
+
WHERE e.from_id = ? AND e.kind = 'call' AND e.to_id IS NOT NULL
|
|
3380
|
+
`).all(symbolId);
|
|
3381
|
+
return rows.map(r => ({
|
|
3382
|
+
calleeId: toNum(r.calleeId), moduleId: toNum(r.moduleId),
|
|
3383
|
+
}));
|
|
3384
|
+
}
|
|
3385
|
+
catch {
|
|
3386
|
+
return [];
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* For each file id, return the symbols that match the given line ranges.
|
|
3391
|
+
* Used by `detect_changes` to compute the blast radius of a diff.
|
|
3392
|
+
*/
|
|
3393
|
+
symbolsTouchingLines(fileId, lineRanges) {
|
|
3394
|
+
if (lineRanges.length === 0)
|
|
3395
|
+
return [];
|
|
3396
|
+
const clauses = lineRanges.map(() => '(s.line_start <= ? AND s.line_end >= ?)').join(' OR ');
|
|
3397
|
+
const args = [fileId];
|
|
3398
|
+
for (const [start, end] of lineRanges) {
|
|
3399
|
+
args.push(end); // s.line_start <= rangeEnd
|
|
3400
|
+
args.push(start); // s.line_end >= rangeStart
|
|
3401
|
+
}
|
|
3402
|
+
const rows = this.db.prepare(`
|
|
3403
|
+
SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
|
|
3404
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
3405
|
+
WHERE s.file_id = ? AND (${clauses})
|
|
3406
|
+
ORDER BY s.line_start
|
|
3407
|
+
`).all(...args);
|
|
3408
|
+
return rows.map(toSymbolRow);
|
|
3409
|
+
}
|
|
3410
|
+
// ── Track-F: SCIP imports tracking ──────────────────────────────────────────
|
|
3411
|
+
/**
|
|
3412
|
+
* Record (or refresh) a SCIP import. Returns the row id. UNIQUE on
|
|
3413
|
+
* (path, sha256) — if the same file with the same content is re-imported,
|
|
3414
|
+
* the existing row is kept (the caller's idempotency guarantee).
|
|
3415
|
+
*/
|
|
3416
|
+
recordScipImport(scipPath, sha256, tool, projectRoot, symbolCount, refCount) {
|
|
3417
|
+
if (!this.hasV7Columns)
|
|
3418
|
+
return 0;
|
|
3419
|
+
const existing = this.db.prepare('SELECT id FROM scip_imports WHERE path = ? AND sha256 = ?').get(scipPath, sha256);
|
|
3420
|
+
if (existing) {
|
|
3421
|
+
this.db.prepare('UPDATE scip_imports SET imported_at = ?, tool = ?, project_root = ?, symbol_count = ?, ref_count = ? WHERE id = ?').run(Date.now(), tool, projectRoot, symbolCount, refCount, toNum(existing.id));
|
|
3422
|
+
return toNum(existing.id);
|
|
3423
|
+
}
|
|
3424
|
+
const res = this.db.prepare('INSERT INTO scip_imports (path, sha256, tool, project_root, imported_at, symbol_count, ref_count) VALUES (?, ?, ?, ?, ?, ?, ?)').run(scipPath, sha256, tool, projectRoot, Date.now(), symbolCount, refCount);
|
|
3425
|
+
return toNum(res.lastInsertRowid);
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* Has this exact SCIP file (by sha) been imported already? Lets callers
|
|
3429
|
+
* short-circuit a re-parse on no-op CI re-runs.
|
|
3430
|
+
*/
|
|
3431
|
+
hasScipImport(scipPath, sha256) {
|
|
3432
|
+
if (!this.hasV7Columns)
|
|
3433
|
+
return false;
|
|
3434
|
+
const row = this.db.prepare('SELECT 1 FROM scip_imports WHERE path = ? AND sha256 = ?').get(scipPath, sha256);
|
|
3435
|
+
return row != null;
|
|
3436
|
+
}
|
|
3437
|
+
/** Listing for `seer_scip_imports` / the bundle manifest. */
|
|
3438
|
+
listScipImports() {
|
|
3439
|
+
if (!this.hasV7Columns)
|
|
3440
|
+
return [];
|
|
3441
|
+
const rows = this.db.prepare(`
|
|
3442
|
+
SELECT id, path, sha256, tool, project_root AS projectRoot,
|
|
3443
|
+
imported_at AS importedAt, symbol_count AS symbolCount, ref_count AS refCount
|
|
3444
|
+
FROM scip_imports ORDER BY imported_at DESC
|
|
3445
|
+
`).all();
|
|
3446
|
+
return rows.map(r => ({
|
|
3447
|
+
id: toNum(r.id), path: toStr(r.path), sha256: toStr(r.sha256),
|
|
3448
|
+
tool: toNullStr(r.tool), projectRoot: toNullStr(r.projectRoot),
|
|
3449
|
+
importedAt: toNum(r.importedAt),
|
|
3450
|
+
symbolCount: toNum(r.symbolCount), refCount: toNum(r.refCount),
|
|
3451
|
+
}));
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Insert (or upsert) a SCIP-sourced symbol. Returns the row id. Uses
|
|
3455
|
+
* (file_id, qualified_name, line_start, kind) as the dedup key when the
|
|
3456
|
+
* existing row was also SCIP-sourced — we never delete tree-sitter rows.
|
|
3457
|
+
* Tree-sitter rows with the same identifier and overlapping line range are
|
|
3458
|
+
* marked 'scip-merge' (precision confirmed by SCIP) instead of being
|
|
3459
|
+
* duplicated, so the agent-facing default lens stays compact.
|
|
3460
|
+
*
|
|
3461
|
+
* `scipImportId` is the `scip_imports.id` row this symbol came from — it
|
|
3462
|
+
* gets persisted on both fresh inserts and merge updates so a later
|
|
3463
|
+
* `clearScipProvenance(path)` can scope its wipe to a single layer instead
|
|
3464
|
+
* of nuking every SCIP row in the DB.
|
|
3465
|
+
*/
|
|
3466
|
+
insertOrMergeScipSymbol(fileId, def, scipImportId) {
|
|
3467
|
+
if (!this.hasV7Columns) {
|
|
3468
|
+
const id = this.insertSymbol(fileId, def);
|
|
3469
|
+
return { id, merged: false };
|
|
3470
|
+
}
|
|
3471
|
+
const qualified = def.qualifiedName ?? def.name;
|
|
3472
|
+
// Look for a tree-sitter row with the same qualified name and overlapping
|
|
3473
|
+
// line range — that's the "SCIP confirms our row" case.
|
|
3474
|
+
const existing = this.db.prepare(`
|
|
3475
|
+
SELECT id, provenance FROM symbols
|
|
3476
|
+
WHERE file_id = ?
|
|
3477
|
+
AND (qualified_name = ? OR name = ?)
|
|
3478
|
+
AND kind = ?
|
|
3479
|
+
AND line_start <= ?
|
|
3480
|
+
AND line_end >= ?
|
|
3481
|
+
`).get(fileId, qualified, def.name, def.kind, def.lineEnd, def.lineStart);
|
|
3482
|
+
if (existing) {
|
|
3483
|
+
const existingId = toNum(existing.id);
|
|
3484
|
+
const prov = toStr(existing.provenance);
|
|
3485
|
+
// tree-sitter rows get re-labeled scip-merge AND linked to the import
|
|
3486
|
+
// id, so clearScipProvenance(path) can demote them back. Pre-existing
|
|
3487
|
+
// scip-merge / scip rows keep their original import id so two different
|
|
3488
|
+
// SCIP layers confirming the same tree-sitter row don't fight over it.
|
|
3489
|
+
if (prov === 'tree-sitter') {
|
|
3490
|
+
this.db.prepare("UPDATE symbols SET provenance = 'scip-merge', scip_import_id = ? WHERE id = ?")
|
|
3491
|
+
.run(scipImportId, existingId);
|
|
3492
|
+
}
|
|
3493
|
+
// Stay using the existing id — SCIP-sourced references can point at it.
|
|
3494
|
+
return { id: existingId, merged: true };
|
|
3495
|
+
}
|
|
3496
|
+
// No overlap → insert a fresh SCIP-provenance row.
|
|
3497
|
+
const sig = def.signature ? def.signature.slice(0, 240) : null;
|
|
3498
|
+
const symbolKey = makeSymbolKey(def.kind, qualified);
|
|
3499
|
+
const res = this.db.prepare(`
|
|
3500
|
+
INSERT INTO symbols
|
|
3501
|
+
(name, qualified_name, kind, file_id, line_start, line_end, col_start, col_end,
|
|
3502
|
+
signature, is_rankable, loc, cyclomatic, cognitive, max_nesting, symbol_key, symbol_role, provenance, shape_hash, scip_import_id)
|
|
3503
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scip', NULL, ?)
|
|
3504
|
+
`).run(def.name, qualified, def.kind, fileId, def.lineStart, def.lineEnd, def.colStart, def.colEnd, sig, (isRankableKind(def.kind) ? 1 : 0), def.loc ?? null, def.cyclomatic ?? null, def.cognitive ?? null, def.maxNesting ?? null, symbolKey, 'definition', scipImportId);
|
|
3505
|
+
return { id: toNum(res.lastInsertRowid), merged: false };
|
|
3506
|
+
}
|
|
3507
|
+
/**
|
|
3508
|
+
* Insert a SCIP-sourced reference edge. `to_id` is set immediately because
|
|
3509
|
+
* SCIP gives us precise targets — no need for the same-file/imported/global
|
|
3510
|
+
* fallback resolver used for tree-sitter call edges.
|
|
3511
|
+
*
|
|
3512
|
+
* `scipImportId` ties the edge to the contributing SCIP layer so per-layer
|
|
3513
|
+
* wipes are clean.
|
|
3514
|
+
*/
|
|
3515
|
+
insertScipEdge(fromSymbolId, toSymbolId, toName, kind, line, scipImportId) {
|
|
3516
|
+
if (!this.hasV7Columns)
|
|
3517
|
+
return;
|
|
3518
|
+
this.db.prepare("INSERT INTO edges (from_id, to_name, to_id, kind, line, provenance, scip_import_id) VALUES (?, ?, ?, ?, ?, 'scip', ?)").run(fromSymbolId, toName, toSymbolId, kind, line, scipImportId);
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Wipe SCIP-sourced rows so a fresh import can replace them. Tree-sitter
|
|
3522
|
+
* rows are preserved; only the rows that came from the specified SCIP layer
|
|
3523
|
+
* are touched.
|
|
3524
|
+
*
|
|
3525
|
+
* - scipPath omitted → ALL SCIP layers are wiped (every scip-provenance
|
|
3526
|
+
* row is dropped, every scip-merge row demoted to tree-sitter, and
|
|
3527
|
+
* the scip_imports table emptied). Useful for "I want my baseline
|
|
3528
|
+
* back."
|
|
3529
|
+
* - scipPath provided → only rows linked to scip_imports.id for that
|
|
3530
|
+
* path are touched. Sibling layers stay intact. This is what
|
|
3531
|
+
* importScip() calls before re-ingesting the same path, so a
|
|
3532
|
+
* multi-layer setup (rust+ts SCIPs) stays correct on partial refresh.
|
|
3533
|
+
*/
|
|
3534
|
+
clearScipProvenance(scipPath) {
|
|
3535
|
+
if (!this.hasV7Columns)
|
|
3536
|
+
return 0;
|
|
3537
|
+
let edgeDeletes = 0, symDeletes = 0;
|
|
3538
|
+
this.db.exec('BEGIN');
|
|
3539
|
+
try {
|
|
3540
|
+
if (scipPath == null) {
|
|
3541
|
+
// Global wipe — every SCIP layer collapses.
|
|
3542
|
+
this.db.exec("UPDATE symbols SET provenance = 'tree-sitter', scip_import_id = NULL WHERE provenance = 'scip-merge'");
|
|
3543
|
+
const eRes = this.db.prepare("DELETE FROM edges WHERE provenance = 'scip'").run();
|
|
3544
|
+
edgeDeletes = toNum(eRes.changes);
|
|
3545
|
+
const sRes = this.db.prepare("DELETE FROM symbols WHERE provenance = 'scip'").run();
|
|
3546
|
+
symDeletes = toNum(sRes.changes);
|
|
3547
|
+
this.db.exec('DELETE FROM scip_imports');
|
|
3548
|
+
}
|
|
3549
|
+
else {
|
|
3550
|
+
// Per-layer wipe — look up the import id for this path. If there's
|
|
3551
|
+
// no row, treat it as "nothing to do" rather than failing (callers
|
|
3552
|
+
// can blindly call clearScipProvenance(path) before insertion).
|
|
3553
|
+
const rows = this.db.prepare('SELECT id FROM scip_imports WHERE path = ?').all(scipPath);
|
|
3554
|
+
const ids = rows.map(r => toNum(r.id));
|
|
3555
|
+
if (ids.length > 0) {
|
|
3556
|
+
const ph = ids.map(() => '?').join(',');
|
|
3557
|
+
this.db.prepare(`UPDATE symbols SET provenance = 'tree-sitter', scip_import_id = NULL
|
|
3558
|
+
WHERE provenance = 'scip-merge' AND scip_import_id IN (${ph})`).run(...ids);
|
|
3559
|
+
const eRes = this.db.prepare(`DELETE FROM edges WHERE provenance = 'scip' AND scip_import_id IN (${ph})`).run(...ids);
|
|
3560
|
+
edgeDeletes = toNum(eRes.changes);
|
|
3561
|
+
const sRes = this.db.prepare(`DELETE FROM symbols WHERE provenance = 'scip' AND scip_import_id IN (${ph})`).run(...ids);
|
|
3562
|
+
symDeletes = toNum(sRes.changes);
|
|
3563
|
+
this.db.prepare('DELETE FROM scip_imports WHERE path = ?').run(scipPath);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
this.db.exec('COMMIT');
|
|
3567
|
+
}
|
|
3568
|
+
catch (err) {
|
|
3569
|
+
this.db.exec('ROLLBACK');
|
|
3570
|
+
throw err;
|
|
3571
|
+
}
|
|
3572
|
+
return symDeletes + edgeDeletes;
|
|
3573
|
+
}
|
|
3574
|
+
/** Provenance breakdown for `seer_health` / `seer_stats`. */
|
|
3575
|
+
getProvenanceCounts() {
|
|
3576
|
+
const out = {
|
|
3577
|
+
symbols: { 'tree-sitter': 0, scip: 0, 'scip-merge': 0 },
|
|
3578
|
+
edges: { 'tree-sitter': 0, scip: 0, 'scip-merge': 0 },
|
|
3579
|
+
};
|
|
3580
|
+
if (!this.hasV7Columns)
|
|
3581
|
+
return out;
|
|
3582
|
+
try {
|
|
3583
|
+
for (const r of this.db.prepare('SELECT provenance, COUNT(*) AS c FROM symbols GROUP BY provenance').all()) {
|
|
3584
|
+
out.symbols[toStr(r.provenance)] = toNum(r.c);
|
|
3585
|
+
}
|
|
3586
|
+
for (const r of this.db.prepare('SELECT provenance, COUNT(*) AS c FROM edges GROUP BY provenance').all()) {
|
|
3587
|
+
out.edges[toStr(r.provenance)] = toNum(r.c);
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
catch { /* */ }
|
|
3591
|
+
return out;
|
|
3592
|
+
}
|
|
3593
|
+
// ── Track-F: shape-hash (structural SimHash) ────────────────────────────────
|
|
3594
|
+
/** Set a symbol's shape_hash. NULL clears it. Persisted as INTEGER. */
|
|
3595
|
+
setShapeHash(symbolId, hash) {
|
|
3596
|
+
if (!this.hasV7Columns)
|
|
3597
|
+
return;
|
|
3598
|
+
// node:sqlite accepts bigint for INTEGER columns; convert to signed range.
|
|
3599
|
+
const value = hash == null ? null : toSignedI64(hash);
|
|
3600
|
+
this.db.prepare('UPDATE symbols SET shape_hash = ? WHERE id = ?').run(value, symbolId);
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* Fetch all symbols that have a non-null shape_hash. Used as the candidate
|
|
3604
|
+
* pool for duplicate detection. Returns minimal fields to keep the working
|
|
3605
|
+
* set small on huge codebases.
|
|
3606
|
+
*/
|
|
3607
|
+
listSymbolsWithShapeHash(opts = {}) {
|
|
3608
|
+
if (!this.hasV7Columns)
|
|
3609
|
+
return [];
|
|
3610
|
+
const conds = ['s.shape_hash IS NOT NULL'];
|
|
3611
|
+
const args = [];
|
|
3612
|
+
if (opts.minLoc != null) {
|
|
3613
|
+
conds.push('s.loc >= ?');
|
|
3614
|
+
args.push(opts.minLoc);
|
|
3615
|
+
}
|
|
3616
|
+
if (opts.includeTests === false) {
|
|
3617
|
+
conds.push("f.role <> 'test'");
|
|
3618
|
+
}
|
|
3619
|
+
const limit = opts.limit ?? 50000;
|
|
3620
|
+
args.push(limit);
|
|
3621
|
+
const stmt = this.db.prepare(`
|
|
3622
|
+
SELECT s.id, s.name, s.qualified_name AS qualifiedName, s.kind,
|
|
3623
|
+
f.path AS filePath, s.line_start AS lineStart, s.line_end AS lineEnd,
|
|
3624
|
+
s.loc, s.shape_hash AS shapeHash
|
|
3625
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
3626
|
+
WHERE ${conds.join(' AND ')}
|
|
3627
|
+
ORDER BY s.id
|
|
3628
|
+
LIMIT ?
|
|
3629
|
+
`);
|
|
3630
|
+
// shape_hash regularly overflows JS safe-integer range; without this flag
|
|
3631
|
+
// node:sqlite throws on row materialization, which the outer try-catch
|
|
3632
|
+
// would swallow into an empty result. We opt the entire row into bigint
|
|
3633
|
+
// and convert the small-int columns back to plain numbers.
|
|
3634
|
+
try {
|
|
3635
|
+
stmt.setReadBigInts(true);
|
|
3636
|
+
}
|
|
3637
|
+
catch { /* */ }
|
|
3638
|
+
try {
|
|
3639
|
+
const rows = stmt.all(...args);
|
|
3640
|
+
return rows.map(r => ({
|
|
3641
|
+
id: toNum(r.id),
|
|
3642
|
+
name: toStr(r.name),
|
|
3643
|
+
qualifiedName: toNullStr(r.qualifiedName),
|
|
3644
|
+
kind: toStr(r.kind),
|
|
3645
|
+
filePath: toStr(r.filePath),
|
|
3646
|
+
lineStart: toNum(r.lineStart),
|
|
3647
|
+
lineEnd: toNum(r.lineEnd),
|
|
3648
|
+
loc: toNullNum(r.loc),
|
|
3649
|
+
shapeHash: toUnsignedI64(r.shapeHash),
|
|
3650
|
+
}));
|
|
3651
|
+
}
|
|
3652
|
+
catch {
|
|
3653
|
+
return [];
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
/** v7 read-flag accessor for downstream features that need to gate on it. */
|
|
3657
|
+
hasV7() { return this.hasV7Columns; }
|
|
3658
|
+
/**
|
|
3659
|
+
* Are there function-like symbols (kind function/method/constructor, role
|
|
3660
|
+
* not 'declaration', loc >= 4) that don't yet have a shape_hash? Used by
|
|
3661
|
+
* the indexer to decide whether to run buildShapeHashes() on a cached
|
|
3662
|
+
* re-run — when a pre-v7 DB migrates to v7, every existing row still has
|
|
3663
|
+
* shape_hash NULL even though the file is "cached" (its content hash
|
|
3664
|
+
* didn't change), so the normal graphChanged predicate misses the
|
|
3665
|
+
* backfill. This check catches that.
|
|
3666
|
+
*/
|
|
3667
|
+
hasMissingShapeHashes(minLoc = 4) {
|
|
3668
|
+
if (!this.hasV7Columns)
|
|
3669
|
+
return false;
|
|
3670
|
+
try {
|
|
3671
|
+
const row = this.db.prepare(`
|
|
3672
|
+
SELECT 1 FROM symbols
|
|
3673
|
+
WHERE shape_hash IS NULL
|
|
3674
|
+
AND kind IN ('function','method','constructor')
|
|
3675
|
+
AND symbol_role <> 'declaration'
|
|
3676
|
+
AND loc >= ?
|
|
3677
|
+
LIMIT 1
|
|
3678
|
+
`).get(minLoc);
|
|
3679
|
+
return row != null;
|
|
3680
|
+
}
|
|
3681
|
+
catch {
|
|
3682
|
+
return false;
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
// ── Stats ───────────────────────────────────────────────────────────────────
|
|
3686
|
+
getStats() {
|
|
3687
|
+
const files = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM files').get().c);
|
|
3688
|
+
const symbols = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c);
|
|
3689
|
+
const edges = toNum(this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE kind = 'call'").get().c);
|
|
3690
|
+
const resolvedEdges = toNum(this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_id IS NOT NULL AND kind = 'call'").get().c);
|
|
3691
|
+
const langRows = this.db.prepare('SELECT language, COUNT(*) AS c FROM files GROUP BY language').all();
|
|
3692
|
+
const languages = {};
|
|
3693
|
+
for (const r of langRows)
|
|
3694
|
+
languages[toStr(r.language)] = toNum(r.c);
|
|
3695
|
+
let routes = 0, externalDependencies = 0, configKeys = 0, symbolHistory = 0, modules = 0;
|
|
3696
|
+
try {
|
|
3697
|
+
routes = this.countRoutes();
|
|
3698
|
+
}
|
|
3699
|
+
catch { /* */ }
|
|
3700
|
+
try {
|
|
3701
|
+
externalDependencies = this.countExternalDeps();
|
|
3702
|
+
}
|
|
3703
|
+
catch { /* */ }
|
|
3704
|
+
try {
|
|
3705
|
+
configKeys = this.countConfigKeys();
|
|
3706
|
+
}
|
|
3707
|
+
catch { /* */ }
|
|
3708
|
+
try {
|
|
3709
|
+
if (this.hasV4Tables) {
|
|
3710
|
+
symbolHistory = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbol_history').get().c);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
catch { /* */ }
|
|
3714
|
+
try {
|
|
3715
|
+
modules = this.countModules();
|
|
3716
|
+
}
|
|
3717
|
+
catch { /* */ }
|
|
3718
|
+
// v7 extras — provenance breakdown and SCIP imports + shape_hash coverage.
|
|
3719
|
+
let scipImports = 0;
|
|
3720
|
+
let shapeHashed = 0;
|
|
3721
|
+
if (this.hasV7Columns) {
|
|
3722
|
+
try {
|
|
3723
|
+
scipImports = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM scip_imports').get().c);
|
|
3724
|
+
}
|
|
3725
|
+
catch { /* */ }
|
|
3726
|
+
try {
|
|
3727
|
+
shapeHashed = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbols WHERE shape_hash IS NOT NULL').get().c);
|
|
3728
|
+
}
|
|
3729
|
+
catch { /* */ }
|
|
3730
|
+
}
|
|
3731
|
+
// v8 Track G — service-link counts.
|
|
3732
|
+
let serviceCalls = 0;
|
|
3733
|
+
let serviceLinks = 0;
|
|
3734
|
+
try {
|
|
3735
|
+
serviceCalls = this.countServiceCalls();
|
|
3736
|
+
}
|
|
3737
|
+
catch { /* */ }
|
|
3738
|
+
try {
|
|
3739
|
+
serviceLinks = this.countServiceLinks();
|
|
3740
|
+
}
|
|
3741
|
+
catch { /* */ }
|
|
3742
|
+
return {
|
|
3743
|
+
files, symbols, edges, resolvedEdges, languages,
|
|
3744
|
+
roles: this.getRoleCounts(),
|
|
3745
|
+
routes,
|
|
3746
|
+
externalDependencies,
|
|
3747
|
+
configKeys,
|
|
3748
|
+
symbolHistory,
|
|
3749
|
+
modules,
|
|
3750
|
+
scipImports,
|
|
3751
|
+
shapeHashed,
|
|
3752
|
+
provenance: this.getProvenanceCounts(),
|
|
3753
|
+
serviceCalls,
|
|
3754
|
+
serviceLinks,
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
/** Direct access to the underlying DB for niche callers (history indexer). */
|
|
3758
|
+
rawDb() { return this.db; }
|
|
3759
|
+
begin() { this.db.exec('BEGIN'); }
|
|
3760
|
+
commit() { this.db.exec('COMMIT'); }
|
|
3761
|
+
rollback() { this.db.exec('ROLLBACK'); }
|
|
3762
|
+
close() {
|
|
3763
|
+
this.db.close();
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
exports.Store = Store;
|
|
3767
|
+
function symbolSelectCols(hasComplexity, hasSymbolRole) {
|
|
3768
|
+
let cols = `s.id, s.name, s.qualified_name AS qualifiedName, s.kind, s.file_id AS fileId,
|
|
3769
|
+
f.path AS filePath, s.line_start AS lineStart,
|
|
3770
|
+
s.line_end AS lineEnd, s.signature, s.pagerank`;
|
|
3771
|
+
if (hasComplexity)
|
|
3772
|
+
cols += `, s.loc, s.cyclomatic, s.cognitive, s.max_nesting AS maxNesting`;
|
|
3773
|
+
if (hasSymbolRole)
|
|
3774
|
+
cols += `, s.symbol_role AS symbolRole`;
|
|
3775
|
+
return cols;
|
|
3776
|
+
}
|
|
3777
|
+
function toSymbolRow(r) {
|
|
3778
|
+
return {
|
|
3779
|
+
id: toNum(r.id),
|
|
3780
|
+
name: toStr(r.name),
|
|
3781
|
+
qualifiedName: toNullStr(r.qualifiedName),
|
|
3782
|
+
kind: toStr(r.kind),
|
|
3783
|
+
fileId: toNum(r.fileId),
|
|
3784
|
+
filePath: toStr(r.filePath),
|
|
3785
|
+
lineStart: toNum(r.lineStart),
|
|
3786
|
+
lineEnd: toNum(r.lineEnd),
|
|
3787
|
+
signature: toNullStr(r.signature),
|
|
3788
|
+
pagerank: toNum(r.pagerank),
|
|
3789
|
+
loc: toNullNum(r.loc),
|
|
3790
|
+
cyclomatic: toNullNum(r.cyclomatic),
|
|
3791
|
+
cognitive: toNullNum(r.cognitive),
|
|
3792
|
+
maxNesting: toNullNum(r.maxNesting),
|
|
3793
|
+
symbolRole: r.symbolRole == null ? null : toStr(r.symbolRole),
|
|
3794
|
+
};
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Build a stable symbol-history key for a symbol. The shape is
|
|
3798
|
+
* `kind:qualified_name` — coarse on purpose so a function rename within a
|
|
3799
|
+
* file collapses history to the new name (we'd rather lose precision than
|
|
3800
|
+
* lose history entirely when extractors disagree about parameter shape).
|
|
3801
|
+
*
|
|
3802
|
+
* Future: include parameter arity or signature-hash for overload distinction.
|
|
3803
|
+
*/
|
|
3804
|
+
function makeSymbolKey(kind, qualifiedName) {
|
|
3805
|
+
return `${kind}:${qualifiedName}`;
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* Build an FTS5 MATCH expression from a free-text query. Strategy:
|
|
3809
|
+
* - lower-case
|
|
3810
|
+
* - split on whitespace and identifier punctuation
|
|
3811
|
+
* - quote each non-empty token and OR them together with `*` for prefix
|
|
3812
|
+
*
|
|
3813
|
+
* Empty / invalid → null (the caller falls back to LIKE).
|
|
3814
|
+
*/
|
|
3815
|
+
function ftsQuery(input) {
|
|
3816
|
+
if (!input)
|
|
3817
|
+
return null;
|
|
3818
|
+
const tokens = splitIdentifierTokens(input)
|
|
3819
|
+
.split(/\s+/)
|
|
3820
|
+
.filter(t => t.length > 0 && /^[a-z0-9]/i.test(t))
|
|
3821
|
+
.map(t => t.replace(/["'*]/g, ''))
|
|
3822
|
+
.filter(t => t.length > 0);
|
|
3823
|
+
if (tokens.length === 0)
|
|
3824
|
+
return null;
|
|
3825
|
+
return tokens.map(t => `"${t}"*`).join(' OR ');
|
|
3826
|
+
}
|
|
3827
|
+
// ── Import path resolution ───────────────────────────────────────────────────
|
|
3828
|
+
function normalizePath(p) {
|
|
3829
|
+
return p.replace(/\\/g, '/');
|
|
3830
|
+
}
|
|
3831
|
+
const TS_JS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
3832
|
+
function resolveImportToFileId(fromPath, language, importName, fileByPath) {
|
|
3833
|
+
if (language === 'typescript' || language === 'javascript') {
|
|
3834
|
+
return resolveJsImport(fromPath, importName, fileByPath);
|
|
3835
|
+
}
|
|
3836
|
+
if (language === 'python') {
|
|
3837
|
+
return resolvePythonImport(fromPath, importName, fileByPath);
|
|
3838
|
+
}
|
|
3839
|
+
return null;
|
|
3840
|
+
}
|
|
3841
|
+
function resolveJsImport(fromPath, importName, fileByPath) {
|
|
3842
|
+
if (!importName.startsWith('./') && !importName.startsWith('../'))
|
|
3843
|
+
return null;
|
|
3844
|
+
const fromDir = path_1.default.dirname(fromPath);
|
|
3845
|
+
const target = path_1.default.resolve(fromDir, importName);
|
|
3846
|
+
const ext = path_1.default.extname(target);
|
|
3847
|
+
if (ext && TS_JS_EXTS.includes(ext)) {
|
|
3848
|
+
const id = fileByPath.get(normalizePath(target));
|
|
3849
|
+
if (id !== undefined)
|
|
3850
|
+
return id;
|
|
3851
|
+
}
|
|
3852
|
+
for (const e of TS_JS_EXTS) {
|
|
3853
|
+
const id = fileByPath.get(normalizePath(target + e));
|
|
3854
|
+
if (id !== undefined)
|
|
3855
|
+
return id;
|
|
3856
|
+
}
|
|
3857
|
+
for (const e of TS_JS_EXTS) {
|
|
3858
|
+
const id = fileByPath.get(normalizePath(path_1.default.join(target, 'index' + e)));
|
|
3859
|
+
if (id !== undefined)
|
|
3860
|
+
return id;
|
|
3861
|
+
}
|
|
3862
|
+
return null;
|
|
3863
|
+
}
|
|
3864
|
+
function resolvePythonImport(fromPath, importName, fileByPath) {
|
|
3865
|
+
if (!importName.startsWith('.'))
|
|
3866
|
+
return null;
|
|
3867
|
+
let levelsUp = 0;
|
|
3868
|
+
while (levelsUp < importName.length && importName[levelsUp] === '.') {
|
|
3869
|
+
levelsUp++;
|
|
3870
|
+
}
|
|
3871
|
+
const modulePath = importName.slice(levelsUp);
|
|
3872
|
+
if (modulePath.length === 0)
|
|
3873
|
+
return null;
|
|
3874
|
+
let baseDir = path_1.default.dirname(fromPath);
|
|
3875
|
+
for (let i = 1; i < levelsUp; i++) {
|
|
3876
|
+
baseDir = path_1.default.dirname(baseDir);
|
|
3877
|
+
}
|
|
3878
|
+
const parts = modulePath.split('.');
|
|
3879
|
+
const target = path_1.default.join(baseDir, ...parts);
|
|
3880
|
+
const fileCandidate = fileByPath.get(normalizePath(target + '.py'));
|
|
3881
|
+
if (fileCandidate !== undefined)
|
|
3882
|
+
return fileCandidate;
|
|
3883
|
+
const pkgCandidate = fileByPath.get(normalizePath(path_1.default.join(target, '__init__.py')));
|
|
3884
|
+
if (pkgCandidate !== undefined)
|
|
3885
|
+
return pkgCandidate;
|
|
3886
|
+
return null;
|
|
3887
|
+
}
|
|
3888
|
+
//# sourceMappingURL=store.js.map
|