scopecairn 0.4.0 → 0.7.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/README.md +54 -8
- package/dist/chunk-DY2AAM62.js +207 -0
- package/dist/chunk-UU5WHX3D.js +155 -0
- package/dist/cli.js +2573 -286
- package/dist/cycles-6KSI4OPZ.js +15 -0
- package/dist/metrics-BLUHSM64.js +19 -0
- package/package.json +51 -49
- package/skills/scopecairn/SKILL.md +2 -0
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ensureMetrics,
|
|
4
|
+
getMetrics
|
|
5
|
+
} from "./chunk-DY2AAM62.js";
|
|
6
|
+
import {
|
|
7
|
+
nodeInCycle
|
|
8
|
+
} from "./chunk-UU5WHX3D.js";
|
|
2
9
|
|
|
3
10
|
// src/cli.ts
|
|
4
11
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
12
|
+
import fs27 from "fs";
|
|
13
|
+
import path27 from "path";
|
|
6
14
|
|
|
7
15
|
// src/scanner.ts
|
|
8
|
-
import
|
|
9
|
-
import
|
|
16
|
+
import fs13 from "fs";
|
|
17
|
+
import path14 from "path";
|
|
10
18
|
|
|
11
19
|
// src/languages.ts
|
|
12
20
|
var EXT_MAP = {
|
|
@@ -58,7 +66,7 @@ function loadDatabaseSync() {
|
|
|
58
66
|
}
|
|
59
67
|
var DATA_DIR_NAME = ".scopecairn";
|
|
60
68
|
var DB_FILE_NAME = "scopecairn.db";
|
|
61
|
-
var SCHEMA_VERSION =
|
|
69
|
+
var SCHEMA_VERSION = 5;
|
|
62
70
|
function dataDir(repoRoot2) {
|
|
63
71
|
return path.join(repoRoot2, DATA_DIR_NAME);
|
|
64
72
|
}
|
|
@@ -134,6 +142,31 @@ function openDb(repoRoot2) {
|
|
|
134
142
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
135
143
|
);
|
|
136
144
|
CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command);
|
|
145
|
+
-- Graphify Engine: node centrality, community clustering, cycle cache.
|
|
146
|
+
CREATE TABLE IF NOT EXISTS node_metrics (
|
|
147
|
+
node_id INTEGER PRIMARY KEY,
|
|
148
|
+
pagerank REAL NOT NULL DEFAULT 0.0,
|
|
149
|
+
betweenness REAL NOT NULL DEFAULT 0.0,
|
|
150
|
+
in_degree INTEGER NOT NULL DEFAULT 0,
|
|
151
|
+
out_degree INTEGER NOT NULL DEFAULT 0,
|
|
152
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
153
|
+
FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
|
|
154
|
+
);
|
|
155
|
+
CREATE TABLE IF NOT EXISTS clusters (
|
|
156
|
+
node_id INTEGER PRIMARY KEY,
|
|
157
|
+
cluster_id INTEGER NOT NULL,
|
|
158
|
+
cluster_name TEXT,
|
|
159
|
+
modularity REAL,
|
|
160
|
+
FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS idx_clusters_cid ON clusters(cluster_id);
|
|
163
|
+
CREATE TABLE IF NOT EXISTS cycles (
|
|
164
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
165
|
+
cycle_hash TEXT UNIQUE,
|
|
166
|
+
length INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
nodes_json TEXT NOT NULL DEFAULT '[]',
|
|
168
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
169
|
+
);
|
|
137
170
|
`);
|
|
138
171
|
db.prepare(
|
|
139
172
|
`INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
@@ -609,7 +642,8 @@ function extractGeneric(source, lang) {
|
|
|
609
642
|
let m;
|
|
610
643
|
const importLine = /^\s*import\s+(.+?)\s*$/gm;
|
|
611
644
|
while ((m = importLine.exec(source)) !== null) {
|
|
612
|
-
const
|
|
645
|
+
const goPath = lang === "go" ? m[1].match(/["`]([^"`]+)["`]/) : null;
|
|
646
|
+
const mod = goPath ? goPath[1] : m[1].replace(/["';]/g, "").trim().split(/\s+/)[0];
|
|
613
647
|
if (!mod || mod === "*") continue;
|
|
614
648
|
const ln = lineOf3(source, m.index);
|
|
615
649
|
symbols.push({ name: `import:${mod}`, type: "import", signature: m[0].trim().slice(0, 120), startLine: ln, endLine: ln });
|
|
@@ -747,9 +781,9 @@ function extractFile(relPath, source) {
|
|
|
747
781
|
const lang = detectLanguage(relPath);
|
|
748
782
|
switch (lang) {
|
|
749
783
|
case "typescript":
|
|
750
|
-
return extractTypeScript(source, relPath.endsWith("
|
|
784
|
+
return extractTypeScript(source, relPath.endsWith(".tsx"));
|
|
751
785
|
case "javascript":
|
|
752
|
-
return extractTypeScript(source, relPath.endsWith("
|
|
786
|
+
return extractTypeScript(source, relPath.endsWith(".jsx"));
|
|
753
787
|
case "python":
|
|
754
788
|
return extractPython(source);
|
|
755
789
|
case "html":
|
|
@@ -802,7 +836,7 @@ var STOP = /* @__PURE__ */ new Set([
|
|
|
802
836
|
"spec"
|
|
803
837
|
]);
|
|
804
838
|
function tokenizeIdentifier(raw) {
|
|
805
|
-
const parts = raw.replace(/[\\/]/g, " ").replace(/[.:\-_"']/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/g).filter((t) => t.length >= 2 &&
|
|
839
|
+
const parts = raw.replace(/[\\/]/g, " ").replace(/[.:\-_"']/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/g).filter((t) => t.length >= 2 && !STOP.has(t));
|
|
806
840
|
return [...new Set(parts)];
|
|
807
841
|
}
|
|
808
842
|
function tokenizeTask(text) {
|
|
@@ -835,9 +869,11 @@ function seedSearch(db, tokens, limit = 30) {
|
|
|
835
869
|
const q = tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
|
|
836
870
|
let rows;
|
|
837
871
|
try {
|
|
872
|
+
const lim = Math.max(1, Math.min(500, Math.floor(limit) || 30));
|
|
838
873
|
rows = db.prepare(
|
|
839
874
|
`SELECT symbol_id, bm25(symbol_index) AS rank
|
|
840
|
-
FROM symbol_index WHERE symbol_index MATCH ?
|
|
875
|
+
FROM symbol_index WHERE symbol_index MATCH ?
|
|
876
|
+
ORDER BY rank LIMIT ${lim}`
|
|
841
877
|
).all(q);
|
|
842
878
|
} catch {
|
|
843
879
|
return [];
|
|
@@ -865,9 +901,20 @@ function symbolIdInFile(db, fileId, name) {
|
|
|
865
901
|
const r = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
|
|
866
902
|
return r ? r.id : null;
|
|
867
903
|
}
|
|
868
|
-
function globalSymbolId(db, name) {
|
|
869
|
-
const
|
|
870
|
-
|
|
904
|
+
function globalSymbolId(db, name, preferDir = "") {
|
|
905
|
+
const rows = db.prepare(
|
|
906
|
+
`SELECT s.id, f.path AS p FROM symbols s JOIN files f ON f.id = s.file_id
|
|
907
|
+
WHERE s.name = ? ORDER BY s.id`
|
|
908
|
+
).all(name);
|
|
909
|
+
if (rows.length === 0) return null;
|
|
910
|
+
if (preferDir) {
|
|
911
|
+
const sameDir = rows.find((r) => r.p.startsWith(preferDir + "/"));
|
|
912
|
+
if (sameDir) return sameDir.id;
|
|
913
|
+
const top = preferDir.split("/")[0];
|
|
914
|
+
const sameTop = rows.find((r) => r.p.split("/")[0] === top);
|
|
915
|
+
if (sameTop) return sameTop.id;
|
|
916
|
+
}
|
|
917
|
+
return rows[0].id;
|
|
871
918
|
}
|
|
872
919
|
function methodSymbolId(db, fileId, ids, name) {
|
|
873
920
|
const local = ids.get(name);
|
|
@@ -941,9 +988,10 @@ function indexFileRelations(db, fileId, relPath, extraction, fileSym) {
|
|
|
941
988
|
contains.run(fileSym, id);
|
|
942
989
|
rels++;
|
|
943
990
|
}
|
|
991
|
+
const srcDir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
944
992
|
const resolveLocal = (name) => {
|
|
945
993
|
if (name === "__file__") return fileSym;
|
|
946
|
-
return ids.get(name) ?? symbolIdInFile(db, fileId, name) ?? globalSymbolId(db, name);
|
|
994
|
+
return ids.get(name) ?? symbolIdInFile(db, fileId, name) ?? globalSymbolId(db, name, srcDir);
|
|
947
995
|
};
|
|
948
996
|
for (const r of extraction.relations) {
|
|
949
997
|
if (r.rel === "IMPORTS") {
|
|
@@ -971,7 +1019,7 @@ function indexFileRelations(db, fileId, relPath, extraction, fileSym) {
|
|
|
971
1019
|
continue;
|
|
972
1020
|
}
|
|
973
1021
|
const src = resolveLocal(r.from);
|
|
974
|
-
const dst = r.rel === "CALLS" && r.methodCall ? methodSymbolId(db, fileId, ids, r.to) : ids.get(r.to) ?? globalSymbolId(db, r.to);
|
|
1022
|
+
const dst = r.rel === "CALLS" && r.methodCall ? methodSymbolId(db, fileId, ids, r.to) : ids.get(r.to) ?? globalSymbolId(db, r.to, srcDir);
|
|
975
1023
|
if (src && dst && src !== dst) {
|
|
976
1024
|
insRel.run(src, dst, r.rel, r.weight, r.confidence);
|
|
977
1025
|
rels++;
|
|
@@ -1000,9 +1048,11 @@ function linkTests(db) {
|
|
|
1000
1048
|
for (const f of files) {
|
|
1001
1049
|
const stemBase = testTarget(f.path);
|
|
1002
1050
|
if (!stemBase) continue;
|
|
1051
|
+
const stemName = stemBase.split("/").pop();
|
|
1003
1052
|
const matches = [...byPath.keys()].filter((p) => {
|
|
1004
|
-
|
|
1005
|
-
|
|
1053
|
+
if (p === f.path || testTarget(p)) return false;
|
|
1054
|
+
const base = p.split("/").pop() ?? p;
|
|
1055
|
+
return base.replace(/\.[^.]+$/, "") === stemName;
|
|
1006
1056
|
});
|
|
1007
1057
|
const srcSym = fileSymOf(f.id);
|
|
1008
1058
|
for (const mp of matches) {
|
|
@@ -1127,13 +1177,13 @@ var META_BASENAMES = /* @__PURE__ */ new Set([
|
|
|
1127
1177
|
"makefile",
|
|
1128
1178
|
"package.json"
|
|
1129
1179
|
]);
|
|
1130
|
-
function isIndexableMetaFile(posixPath) {
|
|
1180
|
+
function isIndexableMetaFile(posixPath, patterns) {
|
|
1131
1181
|
if (isSecretFile(posixPath)) return false;
|
|
1132
1182
|
const base = (posixPath.split("/").pop() ?? "").toLowerCase();
|
|
1133
1183
|
if (META_BASENAMES.has(base)) return true;
|
|
1134
1184
|
const dot = base.lastIndexOf(".");
|
|
1135
1185
|
if (dot !== -1 && META_EXTS.has(base.slice(dot))) return true;
|
|
1136
|
-
if (isProtected(posixPath)) return true;
|
|
1186
|
+
if (patterns ? isProtected(posixPath, patterns) : isProtected(posixPath)) return true;
|
|
1137
1187
|
return false;
|
|
1138
1188
|
}
|
|
1139
1189
|
function ensureProtectedFile(repoRoot2) {
|
|
@@ -1189,6 +1239,7 @@ function loadConfig(repoRoot2) {
|
|
|
1189
1239
|
cfg.wCochange = num("w_cochange", cfg.wCochange);
|
|
1190
1240
|
cfg.maxDepth = Math.max(1, Math.min(4, Math.round(num("max_depth", cfg.maxDepth))));
|
|
1191
1241
|
cfg.topN = Math.max(5, Math.min(100, Math.round(num("top_n", cfg.topN))));
|
|
1242
|
+
cfg.decayPerHop = Math.max(0, Math.min(1, num("decay_per_hop", cfg.decayPerHop)));
|
|
1192
1243
|
} catch {
|
|
1193
1244
|
}
|
|
1194
1245
|
return cfg;
|
|
@@ -1226,6 +1277,7 @@ w_recency: 0.1
|
|
|
1226
1277
|
w_cochange: 0.1
|
|
1227
1278
|
max_depth: 2
|
|
1228
1279
|
top_n: 20
|
|
1280
|
+
decay_per_hop: 0.7
|
|
1229
1281
|
`
|
|
1230
1282
|
);
|
|
1231
1283
|
}
|
|
@@ -1242,8 +1294,8 @@ top_n: 20
|
|
|
1242
1294
|
}
|
|
1243
1295
|
|
|
1244
1296
|
// src/adapters/index.ts
|
|
1245
|
-
import
|
|
1246
|
-
import
|
|
1297
|
+
import fs11 from "fs";
|
|
1298
|
+
import path12 from "path";
|
|
1247
1299
|
|
|
1248
1300
|
// src/adapters/types.ts
|
|
1249
1301
|
function findSymbolId(db, name, types) {
|
|
@@ -1281,6 +1333,32 @@ function enclosingFunction(db, fileId, line) {
|
|
|
1281
1333
|
).all(fileId, line);
|
|
1282
1334
|
return rows.length > 0 ? rows[0].id : null;
|
|
1283
1335
|
}
|
|
1336
|
+
function cleanupOrphans(db) {
|
|
1337
|
+
let n = 0;
|
|
1338
|
+
n += Number(
|
|
1339
|
+
db.prepare(
|
|
1340
|
+
`DELETE FROM relationships
|
|
1341
|
+
WHERE source_id NOT IN (SELECT id FROM symbols)
|
|
1342
|
+
OR target_id NOT IN (SELECT id FROM symbols)`
|
|
1343
|
+
).run().changes
|
|
1344
|
+
);
|
|
1345
|
+
n += Number(
|
|
1346
|
+
db.prepare(
|
|
1347
|
+
`DELETE FROM node_metrics WHERE node_id NOT IN (SELECT id FROM symbols)`
|
|
1348
|
+
).run().changes
|
|
1349
|
+
);
|
|
1350
|
+
n += Number(
|
|
1351
|
+
db.prepare(
|
|
1352
|
+
`DELETE FROM clusters WHERE node_id NOT IN (SELECT id FROM symbols)`
|
|
1353
|
+
).run().changes
|
|
1354
|
+
);
|
|
1355
|
+
n += Number(
|
|
1356
|
+
db.prepare(
|
|
1357
|
+
`DELETE FROM task_context WHERE symbol_id NOT IN (SELECT id FROM symbols)`
|
|
1358
|
+
).run().changes
|
|
1359
|
+
);
|
|
1360
|
+
return n;
|
|
1361
|
+
}
|
|
1284
1362
|
|
|
1285
1363
|
// src/adapters/prisma.ts
|
|
1286
1364
|
function parsePrismaModels(content) {
|
|
@@ -1393,6 +1471,25 @@ function prismaCalls(content) {
|
|
|
1393
1471
|
function resolveModel(db, name) {
|
|
1394
1472
|
return findSymbolId(db, name, ["model"]) ?? dbLowerModel(db, name);
|
|
1395
1473
|
}
|
|
1474
|
+
function derivePrismaQueries(db, fileId, content) {
|
|
1475
|
+
const out = [];
|
|
1476
|
+
const fileSym = fileSymbolOf(db, fileId);
|
|
1477
|
+
if (!fileSym) return out;
|
|
1478
|
+
for (const call of prismaCalls(content)) {
|
|
1479
|
+
const modelId = resolveModel(db, call.model);
|
|
1480
|
+
if (!modelId) continue;
|
|
1481
|
+
const line = lineOf4(content, call.index);
|
|
1482
|
+
const caller = enclosingFunction(db, fileId, line) ?? fileSym;
|
|
1483
|
+
if (caller === modelId) continue;
|
|
1484
|
+
out.push({
|
|
1485
|
+
callerId: caller,
|
|
1486
|
+
targetId: modelId,
|
|
1487
|
+
weight: WEIGHT.QUERIES,
|
|
1488
|
+
confidence: 0.9
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
return out;
|
|
1492
|
+
}
|
|
1396
1493
|
function refreshQueries(db, files) {
|
|
1397
1494
|
let n = 0;
|
|
1398
1495
|
const del = db.prepare(
|
|
@@ -1400,19 +1497,12 @@ function refreshQueries(db, files) {
|
|
|
1400
1497
|
);
|
|
1401
1498
|
const ins = db.prepare(
|
|
1402
1499
|
`INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
|
|
1403
|
-
VALUES (?, ?, 'QUERIES',
|
|
1500
|
+
VALUES (?, ?, 'QUERIES', ?, ?)`
|
|
1404
1501
|
);
|
|
1405
1502
|
for (const f of files) {
|
|
1406
|
-
const fileSym = fileSymbolOf(db, f.fileId);
|
|
1407
|
-
if (!fileSym) continue;
|
|
1408
1503
|
del.run(f.fileId);
|
|
1409
|
-
for (const
|
|
1410
|
-
|
|
1411
|
-
if (!modelId) continue;
|
|
1412
|
-
const line = lineOf4(f.content, call.index);
|
|
1413
|
-
const caller = enclosingFunction(db, f.fileId, line) ?? fileSym;
|
|
1414
|
-
if (caller === modelId) continue;
|
|
1415
|
-
ins.run(caller, modelId);
|
|
1504
|
+
for (const e of derivePrismaQueries(db, f.fileId, f.content)) {
|
|
1505
|
+
ins.run(e.callerId, e.targetId, e.weight, e.confidence);
|
|
1416
1506
|
n++;
|
|
1417
1507
|
}
|
|
1418
1508
|
}
|
|
@@ -1554,6 +1644,24 @@ function hasDrizzleMarker(content) {
|
|
|
1554
1644
|
}
|
|
1555
1645
|
return /db\s*\.\s*(select|selectDistinct|insert|update|delete)\b/.test(content);
|
|
1556
1646
|
}
|
|
1647
|
+
function deriveDrizzleQueries(db, fileId, content) {
|
|
1648
|
+
const out = [];
|
|
1649
|
+
const fileSym = fileSymbolOf(db, fileId);
|
|
1650
|
+
if (!fileSym) return out;
|
|
1651
|
+
for (const q of parseDrizzleQueries(content)) {
|
|
1652
|
+
const target = findModelInFile(db, fileId, q.table) ?? findModelGlobal(db, q.table);
|
|
1653
|
+
if (!target) continue;
|
|
1654
|
+
const caller = enclosingFunction(db, fileId, lineOf4(content, q.index)) ?? fileSym;
|
|
1655
|
+
if (caller === target) continue;
|
|
1656
|
+
out.push({
|
|
1657
|
+
callerId: caller,
|
|
1658
|
+
targetId: target,
|
|
1659
|
+
weight: WEIGHT.QUERIES,
|
|
1660
|
+
confidence: 0.85
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
return out;
|
|
1664
|
+
}
|
|
1557
1665
|
function refreshDrizzleQueries(db, files) {
|
|
1558
1666
|
let n = 0;
|
|
1559
1667
|
const del = db.prepare(
|
|
@@ -1561,18 +1669,13 @@ function refreshDrizzleQueries(db, files) {
|
|
|
1561
1669
|
);
|
|
1562
1670
|
const ins = db.prepare(
|
|
1563
1671
|
`INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
|
|
1564
|
-
VALUES (?, ?, 'QUERIES',
|
|
1672
|
+
VALUES (?, ?, 'QUERIES', ?, ?)`
|
|
1565
1673
|
);
|
|
1566
1674
|
for (const f of files) {
|
|
1567
|
-
|
|
1568
|
-
if (!fileSym || !hasDrizzleMarker(f.content)) continue;
|
|
1675
|
+
if (!hasDrizzleMarker(f.content)) continue;
|
|
1569
1676
|
del.run(f.fileId);
|
|
1570
|
-
for (const
|
|
1571
|
-
|
|
1572
|
-
if (!target) continue;
|
|
1573
|
-
const caller = enclosingFunction(db, f.fileId, lineOf4(f.content, q.index)) ?? fileSym;
|
|
1574
|
-
if (caller === target) continue;
|
|
1575
|
-
ins.run(caller, target);
|
|
1677
|
+
for (const e of deriveDrizzleQueries(db, f.fileId, f.content)) {
|
|
1678
|
+
ins.run(e.callerId, e.targetId, e.weight, e.confidence);
|
|
1576
1679
|
n++;
|
|
1577
1680
|
}
|
|
1578
1681
|
}
|
|
@@ -1640,143 +1743,1789 @@ var drizzleAdapter = {
|
|
|
1640
1743
|
}
|
|
1641
1744
|
};
|
|
1642
1745
|
|
|
1643
|
-
// src/adapters/
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1746
|
+
// src/adapters/express.ts
|
|
1747
|
+
import fs6 from "fs";
|
|
1748
|
+
import path7 from "path";
|
|
1749
|
+
var METHODS = ["get", "post", "put", "delete", "patch", "use"];
|
|
1750
|
+
function parseExpressRoutes(content) {
|
|
1751
|
+
const out = [];
|
|
1752
|
+
const re = new RegExp(
|
|
1753
|
+
`\\b(?:app|router)\\s*\\.\\s*(${METHODS.join("|")})\\s*\\(\\s*["'\`]([^"'\`]+)["'\`]\\s*,\\s*([^,)\\s]+)?`,
|
|
1754
|
+
"g"
|
|
1755
|
+
);
|
|
1756
|
+
let m;
|
|
1757
|
+
while ((m = re.exec(content)) !== null) {
|
|
1758
|
+
const rawHandler = m[3] ?? null;
|
|
1759
|
+
const handler = rawHandler && /^[A-Za-z_$][\w$]*$/.test(rawHandler) ? rawHandler : null;
|
|
1760
|
+
out.push({
|
|
1761
|
+
method: m[1].toUpperCase(),
|
|
1762
|
+
path: m[2],
|
|
1763
|
+
handler,
|
|
1764
|
+
line: lineOf4(content, m.index)
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
return out;
|
|
1768
|
+
}
|
|
1769
|
+
function hasExpressMarker(content) {
|
|
1770
|
+
return content.includes("express") || /\b(?:app|router)\s*\.\s*(get|post|put|delete|patch|use)\s*\(/.test(content);
|
|
1771
|
+
}
|
|
1772
|
+
function findInFile2(db, fileId, name) {
|
|
1773
|
+
const row = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
|
|
1774
|
+
return row ? row.id : null;
|
|
1775
|
+
}
|
|
1776
|
+
var expressAdapter = {
|
|
1777
|
+
id: "express",
|
|
1778
|
+
label: "Express",
|
|
1779
|
+
detect: (files) => files.some(
|
|
1780
|
+
(f) => f.endsWith("package.json") || /\.(js|ts|mjs|cjs)$/.test(f)
|
|
1781
|
+
),
|
|
1782
|
+
confirm: (repoRoot2) => {
|
|
1647
1783
|
try {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
return true;
|
|
1784
|
+
const pkg = fs6.readFileSync(path7.join(repoRoot2, "package.json"), "utf8");
|
|
1785
|
+
return pkg.includes('"express"') || pkg.includes("'express'");
|
|
1651
1786
|
} catch {
|
|
1652
1787
|
return false;
|
|
1653
1788
|
}
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1789
|
+
},
|
|
1790
|
+
relevant: (rel) => /\.(js|ts|mjs|cjs)$/.test(rel),
|
|
1791
|
+
apply(ctx, files) {
|
|
1792
|
+
let symbols = 0;
|
|
1793
|
+
let relations = 0;
|
|
1794
|
+
for (const f of files) {
|
|
1795
|
+
if (!hasExpressMarker(f.content)) continue;
|
|
1796
|
+
const fileSym = fileSymbolOf(ctx.db, f.fileId);
|
|
1797
|
+
if (!fileSym) continue;
|
|
1798
|
+
for (const r of parseExpressRoutes(f.content)) {
|
|
1799
|
+
const routeId = ensureAdapterSymbol(ctx.db, f.fileId, {
|
|
1800
|
+
name: `${r.method} ${r.path}`,
|
|
1801
|
+
type: "route",
|
|
1802
|
+
signature: `${r.method} ${r.path}`,
|
|
1803
|
+
startLine: r.line,
|
|
1804
|
+
endLine: r.line
|
|
1805
|
+
});
|
|
1806
|
+
symbols++;
|
|
1807
|
+
insertRelation(ctx.db, fileSym, routeId, "CONTAINS", 1, 1);
|
|
1808
|
+
relations++;
|
|
1809
|
+
if (r.handler) {
|
|
1810
|
+
const handlerId = findInFile2(ctx.db, f.fileId, r.handler);
|
|
1811
|
+
if (handlerId) {
|
|
1812
|
+
insertRelation(ctx.db, fileSym, handlerId, "USES", WEIGHT.USES, 0.9);
|
|
1813
|
+
relations++;
|
|
1814
|
+
const enclosing = enclosingFunction(ctx.db, f.fileId, r.line);
|
|
1815
|
+
if (enclosing && enclosing !== handlerId) {
|
|
1816
|
+
insertRelation(ctx.db, enclosing, handlerId, "CALLS", WEIGHT.CALLS, 0.7);
|
|
1817
|
+
relations++;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1673
1821
|
}
|
|
1674
|
-
inputs.push({ fileId: c.fileId, rel: c.rel, content });
|
|
1675
1822
|
}
|
|
1676
|
-
|
|
1823
|
+
return { symbols, relations };
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
// src/adapters/fastapi.ts
|
|
1828
|
+
import fs7 from "fs";
|
|
1829
|
+
import path8 from "path";
|
|
1830
|
+
var METHODS2 = ["get", "post", "put", "delete", "patch", "head", "options"];
|
|
1831
|
+
function parseFastApiRoutes(content) {
|
|
1832
|
+
const out = [];
|
|
1833
|
+
const re = new RegExp(
|
|
1834
|
+
`^\\s*@\\w+\\s*\\.\\s*(${METHODS2.join("|")})\\s*\\(\\s*["']([^"']+)["']`,
|
|
1835
|
+
"gm"
|
|
1836
|
+
);
|
|
1837
|
+
let m;
|
|
1838
|
+
while ((m = re.exec(content)) !== null) {
|
|
1839
|
+
const window = content.slice(m.index, m.index + 400);
|
|
1840
|
+
const hm = /(?:async\s+)?def\s+(\w+)/.exec(window);
|
|
1841
|
+
out.push({
|
|
1842
|
+
method: m[1].toUpperCase(),
|
|
1843
|
+
path: m[2],
|
|
1844
|
+
handler: hm ? hm[1] : null,
|
|
1845
|
+
line: lineOf4(content, m.index)
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
return out;
|
|
1849
|
+
}
|
|
1850
|
+
function hasFastApiMarker(content) {
|
|
1851
|
+
return content.includes("fastapi") || /^\s*@\w+\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(/m.test(content);
|
|
1852
|
+
}
|
|
1853
|
+
function findInFile3(db, fileId, name) {
|
|
1854
|
+
const row = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
|
|
1855
|
+
return row ? row.id : null;
|
|
1856
|
+
}
|
|
1857
|
+
function readDepFile(repoRoot2, names) {
|
|
1858
|
+
let out = "";
|
|
1859
|
+
for (const n of names) {
|
|
1677
1860
|
try {
|
|
1678
|
-
|
|
1679
|
-
symbols += r.symbols;
|
|
1680
|
-
relations += r.relations;
|
|
1681
|
-
for (const i of inputs) touched.add(i.fileId);
|
|
1861
|
+
out += fs7.readFileSync(path8.join(repoRoot2, n), "utf8");
|
|
1682
1862
|
} catch {
|
|
1683
1863
|
}
|
|
1684
1864
|
}
|
|
1685
|
-
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1865
|
+
try {
|
|
1866
|
+
const dir = fs7.readdirSync(repoRoot2);
|
|
1867
|
+
for (const f of dir) {
|
|
1868
|
+
if (/^requirements.*\.txt$/.test(f)) {
|
|
1869
|
+
try {
|
|
1870
|
+
out += fs7.readFileSync(path8.join(repoRoot2, f), "utf8");
|
|
1871
|
+
} catch {
|
|
1872
|
+
}
|
|
1691
1873
|
}
|
|
1692
1874
|
}
|
|
1875
|
+
} catch {
|
|
1693
1876
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1877
|
+
return out;
|
|
1878
|
+
}
|
|
1879
|
+
var fastapiAdapter = {
|
|
1880
|
+
id: "fastapi",
|
|
1881
|
+
label: "FastAPI",
|
|
1882
|
+
detect: (files) => files.some(
|
|
1883
|
+
(f) => /\.py$/.test(f) || /(^|\/)requirements.*\.txt$/.test(f) || /(^|\/)pyproject\.toml$/.test(f)
|
|
1884
|
+
),
|
|
1885
|
+
confirm: (repoRoot2) => {
|
|
1696
1886
|
try {
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1887
|
+
const pkg = fs7.readFileSync(path8.join(repoRoot2, "package.json"), "utf8");
|
|
1888
|
+
if (pkg.includes('"fastapi"')) return true;
|
|
1889
|
+
} catch {
|
|
1890
|
+
}
|
|
1891
|
+
const deps = readDepFile(repoRoot2, ["requirements.txt", "pyproject.toml"]);
|
|
1892
|
+
return deps.toLowerCase().includes("fastapi");
|
|
1893
|
+
},
|
|
1894
|
+
relevant: (rel) => /\.py$/.test(rel),
|
|
1895
|
+
apply(ctx, files) {
|
|
1896
|
+
let symbols = 0;
|
|
1897
|
+
let relations = 0;
|
|
1898
|
+
for (const f of files) {
|
|
1899
|
+
if (!hasFastApiMarker(f.content)) continue;
|
|
1900
|
+
const fileSym = fileSymbolOf(ctx.db, f.fileId);
|
|
1901
|
+
if (!fileSym) continue;
|
|
1902
|
+
for (const r of parseFastApiRoutes(f.content)) {
|
|
1903
|
+
const routeId = ensureAdapterSymbol(ctx.db, f.fileId, {
|
|
1904
|
+
name: `${r.method} ${r.path}`,
|
|
1905
|
+
type: "route",
|
|
1906
|
+
signature: `${r.method} ${r.path}`,
|
|
1907
|
+
startLine: r.line,
|
|
1908
|
+
endLine: r.line
|
|
1909
|
+
});
|
|
1910
|
+
symbols++;
|
|
1911
|
+
insertRelation(ctx.db, fileSym, routeId, "CONTAINS", 1, 1);
|
|
1912
|
+
relations++;
|
|
1913
|
+
if (r.handler) {
|
|
1914
|
+
const handlerId = findInFile3(ctx.db, f.fileId, r.handler);
|
|
1915
|
+
if (handlerId) {
|
|
1916
|
+
insertRelation(ctx.db, fileSym, handlerId, "USES", 0.5, 0.9);
|
|
1917
|
+
relations++;
|
|
1918
|
+
insertRelation(ctx.db, handlerId, routeId, "ROUTES_TO", 0.9, 1);
|
|
1919
|
+
relations++;
|
|
1920
|
+
}
|
|
1707
1921
|
}
|
|
1708
1922
|
}
|
|
1709
|
-
relations += refresh(db, inputs);
|
|
1710
|
-
} catch {
|
|
1711
1923
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
return { adapters: active.map((a) => a.id), symbols, relations };
|
|
1716
|
-
}
|
|
1924
|
+
return { symbols, relations };
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1717
1927
|
|
|
1718
|
-
// src/
|
|
1719
|
-
|
|
1720
|
-
|
|
1928
|
+
// src/adapters/sqlalchemy.ts
|
|
1929
|
+
import fs8 from "fs";
|
|
1930
|
+
import path9 from "path";
|
|
1931
|
+
function parseSqlAlchemyModels(content) {
|
|
1932
|
+
const out = [];
|
|
1933
|
+
const re = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
|
|
1934
|
+
let m;
|
|
1935
|
+
while ((m = re.exec(content)) !== null) {
|
|
1936
|
+
const rest = content.slice(m.index, m.index + 1500);
|
|
1937
|
+
const nextClass = rest.slice(1).search(/^class\s+\w+/m);
|
|
1938
|
+
const body = nextClass > 0 ? rest.slice(0, nextClass + 1) : rest;
|
|
1939
|
+
const tm = /__tablename__\s*=\s*["']([^"']+)["']/.exec(body);
|
|
1940
|
+
const isBase = /\b(Base|DeclarativeBase|declarative_base|Model)\b/.test(m[2]);
|
|
1941
|
+
if (tm || isBase) {
|
|
1942
|
+
out.push({
|
|
1943
|
+
name: m[1],
|
|
1944
|
+
table: tm ? tm[1] : null,
|
|
1945
|
+
line: lineOf4(content, m.index)
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
return out;
|
|
1721
1950
|
}
|
|
1722
|
-
function
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1951
|
+
function parseSqlAlchemyQueries(content) {
|
|
1952
|
+
const out = [];
|
|
1953
|
+
const re = /(?:session\s*\.\s*query|select)\s*\(\s*(\w+)/g;
|
|
1954
|
+
let m;
|
|
1955
|
+
while ((m = re.exec(content)) !== null) {
|
|
1956
|
+
out.push({ model: m[1], index: m.index });
|
|
1957
|
+
}
|
|
1958
|
+
return out;
|
|
1959
|
+
}
|
|
1960
|
+
function hasSqlAlchemyMarker(content) {
|
|
1961
|
+
return content.includes("sqlalchemy") || /__tablename__/.test(content) || /session\s*\.\s*query\s*\(/.test(content) || /\bselect\s*\(/.test(content);
|
|
1962
|
+
}
|
|
1963
|
+
function findModelGlobal2(db, name) {
|
|
1964
|
+
const rows = db.prepare(`SELECT id, name FROM symbols WHERE type = 'model'`).all();
|
|
1965
|
+
const lower = name.toLowerCase();
|
|
1966
|
+
return rows.find((r) => r.name.toLowerCase() === lower)?.id ?? null;
|
|
1967
|
+
}
|
|
1968
|
+
function deriveSqlAlchemyQueries(db, fileId, content) {
|
|
1969
|
+
const out = [];
|
|
1970
|
+
const fileSym = fileSymbolOf(db, fileId);
|
|
1971
|
+
if (!fileSym) return out;
|
|
1972
|
+
for (const q of parseSqlAlchemyQueries(content)) {
|
|
1973
|
+
const target = findModelGlobal2(db, q.model);
|
|
1974
|
+
if (!target) continue;
|
|
1975
|
+
const caller = enclosingFunction(db, fileId, lineOf4(content, q.index)) ?? fileSym;
|
|
1976
|
+
if (caller === target) continue;
|
|
1977
|
+
out.push({
|
|
1978
|
+
callerId: caller,
|
|
1979
|
+
targetId: target,
|
|
1980
|
+
weight: WEIGHT.QUERIES,
|
|
1981
|
+
confidence: 0.85
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
return out;
|
|
1985
|
+
}
|
|
1986
|
+
function refreshSqlAlchemyQueries(db, files) {
|
|
1987
|
+
let n = 0;
|
|
1988
|
+
const del = db.prepare(
|
|
1989
|
+
`DELETE FROM relationships WHERE relationship_type = 'QUERIES' AND source_id IN (SELECT id FROM symbols WHERE file_id = ?)`
|
|
1990
|
+
);
|
|
1991
|
+
const ins = db.prepare(
|
|
1992
|
+
`INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
|
|
1993
|
+
VALUES (?, ?, 'QUERIES', ?, ?)`
|
|
1994
|
+
);
|
|
1995
|
+
for (const f of files) {
|
|
1996
|
+
if (!hasSqlAlchemyMarker(f.content)) continue;
|
|
1997
|
+
del.run(f.fileId);
|
|
1998
|
+
for (const e of deriveSqlAlchemyQueries(db, f.fileId, f.content)) {
|
|
1999
|
+
ins.run(e.callerId, e.targetId, e.weight, e.confidence);
|
|
2000
|
+
n++;
|
|
1731
2001
|
}
|
|
1732
2002
|
}
|
|
2003
|
+
return n;
|
|
1733
2004
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
let totalFilesSeen = 0;
|
|
1743
|
-
let inserted = 0;
|
|
1744
|
-
let updated = 0;
|
|
1745
|
-
let unchanged = 0;
|
|
1746
|
-
const seenPaths = /* @__PURE__ */ new Set();
|
|
1747
|
-
const getStmt = db.prepare(`SELECT id, hash FROM files WHERE path = ?`);
|
|
1748
|
-
const insertStmt = db.prepare(
|
|
1749
|
-
`INSERT INTO files(path, language, hash, size) VALUES (?, ?, ?, ?)`
|
|
1750
|
-
);
|
|
1751
|
-
const updateStmt = db.prepare(
|
|
1752
|
-
`UPDATE files SET hash = ?, size = ?, language = ?, updated_at = datetime('now') WHERE path = ?`
|
|
1753
|
-
);
|
|
1754
|
-
let sourceFiles = 0;
|
|
1755
|
-
let metaFiles = 0;
|
|
1756
|
-
const symbolCount = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
1757
|
-
let ftsCount = 0;
|
|
2005
|
+
var sqlalchemyAdapter = {
|
|
2006
|
+
id: "sqlalchemy",
|
|
2007
|
+
label: "SQLAlchemy",
|
|
2008
|
+
detect: (files) => files.some(
|
|
2009
|
+
(f) => /\.py$/.test(f) || /(^|\/)requirements.*\.txt$/.test(f) || /(^|\/)pyproject\.toml$/.test(f)
|
|
2010
|
+
),
|
|
2011
|
+
confirm: (repoRoot2) => {
|
|
2012
|
+
let deps = "";
|
|
1758
2013
|
try {
|
|
1759
|
-
|
|
2014
|
+
const dir = fs8.readdirSync(repoRoot2);
|
|
2015
|
+
for (const f of dir) {
|
|
2016
|
+
if (/^requirements.*\.txt$/.test(f) || f === "pyproject.toml") {
|
|
2017
|
+
try {
|
|
2018
|
+
deps += fs8.readFileSync(path9.join(repoRoot2, f), "utf8");
|
|
2019
|
+
} catch {
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
1760
2023
|
} catch {
|
|
1761
|
-
|
|
2024
|
+
return false;
|
|
1762
2025
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1772
|
-
if (!
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2026
|
+
return deps.toLowerCase().includes("sqlalchemy");
|
|
2027
|
+
},
|
|
2028
|
+
relevant: (rel) => /\.py$/.test(rel),
|
|
2029
|
+
apply(ctx, files) {
|
|
2030
|
+
let symbols = 0;
|
|
2031
|
+
let relations = 0;
|
|
2032
|
+
for (const f of files) {
|
|
2033
|
+
if (!hasSqlAlchemyMarker(f.content)) continue;
|
|
2034
|
+
const fileSym = fileSymbolOf(ctx.db, f.fileId);
|
|
2035
|
+
if (!fileSym) continue;
|
|
2036
|
+
for (const model of parseSqlAlchemyModels(f.content)) {
|
|
2037
|
+
const id = ensureAdapterSymbol(ctx.db, f.fileId, {
|
|
2038
|
+
name: model.name,
|
|
2039
|
+
type: "model",
|
|
2040
|
+
signature: model.table ? `model ${model.name} ("${model.table}")` : `model ${model.name}`,
|
|
2041
|
+
startLine: model.line,
|
|
2042
|
+
endLine: model.line
|
|
2043
|
+
});
|
|
2044
|
+
symbols++;
|
|
2045
|
+
insertRelation(ctx.db, fileSym, id, "CONTAINS", 1, 1);
|
|
2046
|
+
relations++;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
relations += refreshSqlAlchemyQueries(
|
|
2050
|
+
ctx.db,
|
|
2051
|
+
files.map((f) => ({ fileId: f.fileId, rel: f.rel, content: f.content }))
|
|
2052
|
+
);
|
|
2053
|
+
return { symbols, relations };
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
// src/adapters/vue.ts
|
|
2058
|
+
import fs9 from "fs";
|
|
2059
|
+
import path10 from "path";
|
|
2060
|
+
function parseVueImports(content) {
|
|
2061
|
+
const out = [];
|
|
2062
|
+
const re = /import\s+(\w+)\s+from\s+["']([^"']+\.vue)["']/g;
|
|
2063
|
+
let m;
|
|
2064
|
+
while ((m = re.exec(content)) !== null) {
|
|
2065
|
+
out.push({ name: m[1], source: m[2], line: lineOf4(content, m.index) });
|
|
2066
|
+
}
|
|
2067
|
+
return out;
|
|
2068
|
+
}
|
|
2069
|
+
function parseTemplateTags(content) {
|
|
2070
|
+
const tm = /<template[^>]*>([\s\S]*?)<\/template>/.exec(content);
|
|
2071
|
+
if (!tm) return [];
|
|
2072
|
+
const body = tm[1];
|
|
2073
|
+
const tags = /* @__PURE__ */ new Set();
|
|
2074
|
+
const re = /<\s*([A-Z][\w]*|[a-z][\w]*(?:-[\w]+)+)/g;
|
|
2075
|
+
let m;
|
|
2076
|
+
while ((m = re.exec(body)) !== null) tags.add(m[1]);
|
|
2077
|
+
return [...tags];
|
|
2078
|
+
}
|
|
2079
|
+
function kebabToPascal(s) {
|
|
2080
|
+
return s.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
2081
|
+
}
|
|
2082
|
+
function usedVueComponents(content) {
|
|
2083
|
+
const imports = parseVueImports(content);
|
|
2084
|
+
if (imports.length === 0) return [];
|
|
2085
|
+
const tags = new Set(parseTemplateTags(content));
|
|
2086
|
+
return imports.filter((imp) => {
|
|
2087
|
+
const kebab = imp.name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2088
|
+
return tags.has(imp.name) || tags.has(kebab) || tags.has(kebabToPascal(kebab));
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
function vueComponentName(rel) {
|
|
2092
|
+
const base = rel.split("/").pop() ?? rel;
|
|
2093
|
+
return base.replace(/\.vue$/, "");
|
|
2094
|
+
}
|
|
2095
|
+
function hasVueDefineComponent(content) {
|
|
2096
|
+
return /\bdefineComponent\s*\(/.test(content);
|
|
2097
|
+
}
|
|
2098
|
+
function resolveImportRel(fromRel, source) {
|
|
2099
|
+
if (!source.startsWith(".")) return null;
|
|
2100
|
+
const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
|
|
2101
|
+
const parts = (dir ? dir + "/" + source : source).split("/");
|
|
2102
|
+
const out = [];
|
|
2103
|
+
for (const p of parts) {
|
|
2104
|
+
if (p === "" || p === ".") continue;
|
|
2105
|
+
if (p === "..") {
|
|
2106
|
+
if (out.length === 0) return null;
|
|
2107
|
+
out.pop();
|
|
2108
|
+
} else out.push(p);
|
|
2109
|
+
}
|
|
2110
|
+
return out.join("/");
|
|
2111
|
+
}
|
|
2112
|
+
var vueAdapter = {
|
|
2113
|
+
id: "vue",
|
|
2114
|
+
label: "Vue",
|
|
2115
|
+
detect: (files) => files.some((f) => /\.vue$/.test(f) || f.endsWith("package.json")),
|
|
2116
|
+
confirm: (repoRoot2) => {
|
|
2117
|
+
try {
|
|
2118
|
+
const pkg = fs9.readFileSync(path10.join(repoRoot2, "package.json"), "utf8");
|
|
2119
|
+
return pkg.includes('"vue"') || pkg.includes("'vue'");
|
|
2120
|
+
} catch {
|
|
2121
|
+
return false;
|
|
2122
|
+
}
|
|
2123
|
+
},
|
|
2124
|
+
relevant: (rel) => /\.vue$/.test(rel),
|
|
2125
|
+
apply(ctx, files) {
|
|
2126
|
+
let symbols = 0;
|
|
2127
|
+
let relations = 0;
|
|
2128
|
+
for (const f of files) {
|
|
2129
|
+
const fileSym = fileSymbolOf(ctx.db, f.fileId);
|
|
2130
|
+
if (!fileSym) continue;
|
|
2131
|
+
const name = vueComponentName(f.rel);
|
|
2132
|
+
const line = hasVueDefineComponent(f.content) ? lineOf4(f.content, f.content.search(/\bdefineComponent\s*\(/)) : 1;
|
|
2133
|
+
const id = ensureAdapterSymbol(ctx.db, f.fileId, {
|
|
2134
|
+
name,
|
|
2135
|
+
type: "component",
|
|
2136
|
+
signature: `component ${name}`,
|
|
2137
|
+
startLine: line,
|
|
2138
|
+
endLine: line
|
|
2139
|
+
});
|
|
2140
|
+
symbols++;
|
|
2141
|
+
insertRelation(ctx.db, fileSym, id, "CONTAINS", 1, 1);
|
|
2142
|
+
relations++;
|
|
2143
|
+
for (const imp of usedVueComponents(f.content)) {
|
|
2144
|
+
const targetRel = resolveImportRel(f.rel, imp.source);
|
|
2145
|
+
if (!targetRel) continue;
|
|
2146
|
+
const row = ctx.db.prepare(`SELECT id FROM files WHERE path = ? LIMIT 1`).get(targetRel);
|
|
2147
|
+
if (!row) continue;
|
|
2148
|
+
const targetFileSym = fileSymbolOf(ctx.db, row.id);
|
|
2149
|
+
if (!targetFileSym) continue;
|
|
2150
|
+
insertRelation(ctx.db, fileSym, targetFileSym, "IMPORTS", WEIGHT.IMPORTS, 0.9);
|
|
2151
|
+
relations++;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return { symbols, relations };
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
// src/log.ts
|
|
2159
|
+
import fs10 from "fs";
|
|
2160
|
+
import path11 from "path";
|
|
2161
|
+
function lastRunLogPath(repoRoot2 = process.cwd()) {
|
|
2162
|
+
return path11.join(repoRoot2, ".scopecairn", "last-run.log");
|
|
2163
|
+
}
|
|
2164
|
+
function append(repoRoot2, scope, text) {
|
|
2165
|
+
const root = repoRoot2 ?? process.cwd();
|
|
2166
|
+
try {
|
|
2167
|
+
fs10.mkdirSync(path11.dirname(lastRunLogPath(root)), { recursive: true });
|
|
2168
|
+
fs10.appendFileSync(lastRunLogPath(root), text + "\n", "utf8");
|
|
2169
|
+
} catch {
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
function logError(scope, err, repoRoot2) {
|
|
2173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2174
|
+
append(repoRoot2, scope, `${(/* @__PURE__ */ new Date()).toISOString()} [${scope}] ${message}`);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// src/adapters/index.ts
|
|
2178
|
+
var ADAPTERS = [
|
|
2179
|
+
prismaAdapter,
|
|
2180
|
+
drizzleAdapter,
|
|
2181
|
+
sqlalchemyAdapter,
|
|
2182
|
+
nextjsAdapter,
|
|
2183
|
+
expressAdapter,
|
|
2184
|
+
fastapiAdapter,
|
|
2185
|
+
vueAdapter
|
|
2186
|
+
];
|
|
2187
|
+
function detectAdapters(files, repoRoot2) {
|
|
2188
|
+
return ADAPTERS.filter((a) => {
|
|
2189
|
+
try {
|
|
2190
|
+
if (!a.detect(files)) return false;
|
|
2191
|
+
if (repoRoot2 && a.confirm) return a.confirm(repoRoot2, files);
|
|
2192
|
+
return true;
|
|
2193
|
+
} catch (err) {
|
|
2194
|
+
logError("adapters", err, repoRoot2);
|
|
2195
|
+
return false;
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
function runAdapters(db, repoRoot2, changed) {
|
|
2200
|
+
const allFiles = db.prepare(`SELECT path FROM files`).all().map((r) => r.path);
|
|
2201
|
+
const active = detectAdapters(allFiles, repoRoot2);
|
|
2202
|
+
if (active.length === 0) return { adapters: [], symbols: 0, relations: 0 };
|
|
2203
|
+
let symbols = 0;
|
|
2204
|
+
let relations = 0;
|
|
2205
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2206
|
+
for (const adapter of active) {
|
|
2207
|
+
const inputs = [];
|
|
2208
|
+
for (const c of changed) {
|
|
2209
|
+
if (!adapter.relevant(c.rel)) continue;
|
|
2210
|
+
const abs = path12.join(repoRoot2, ...c.rel.split("/"));
|
|
2211
|
+
let content;
|
|
2212
|
+
try {
|
|
2213
|
+
content = fs11.readFileSync(abs, "utf8");
|
|
2214
|
+
} catch {
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
inputs.push({ fileId: c.fileId, rel: c.rel, content });
|
|
2218
|
+
}
|
|
2219
|
+
if (inputs.length === 0) continue;
|
|
2220
|
+
try {
|
|
2221
|
+
const r = adapter.apply({ db, repoRoot: repoRoot2 }, inputs);
|
|
2222
|
+
symbols += r.symbols;
|
|
2223
|
+
relations += r.relations;
|
|
2224
|
+
for (const i of inputs) touched.add(i.fileId);
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
logError("adapters", err, repoRoot2);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
for (const fileId of touched) {
|
|
2230
|
+
const row = db.prepare(`SELECT path FROM files WHERE id = ?`).get(fileId);
|
|
2231
|
+
if (row) {
|
|
2232
|
+
try {
|
|
2233
|
+
syncFileIndex(db, fileId, row.path);
|
|
2234
|
+
} catch (err) {
|
|
2235
|
+
logError("adapters", err, repoRoot2);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const derivers = [];
|
|
2240
|
+
if (active.some((a) => a.id === "nextjs")) {
|
|
2241
|
+
derivers.push({ relevant: nextjsAdapter.relevant, derive: derivePrismaQueries });
|
|
2242
|
+
}
|
|
2243
|
+
if (active.some((a) => a.id === "drizzle")) {
|
|
2244
|
+
derivers.push({ relevant: drizzleAdapter.relevant, derive: deriveDrizzleQueries });
|
|
2245
|
+
}
|
|
2246
|
+
if (active.some((a) => a.id === "sqlalchemy")) {
|
|
2247
|
+
derivers.push({
|
|
2248
|
+
relevant: sqlalchemyAdapter.relevant,
|
|
2249
|
+
derive: deriveSqlAlchemyQueries
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
if (derivers.length > 0) {
|
|
2253
|
+
try {
|
|
2254
|
+
const all = db.prepare(`SELECT id, path FROM files`).all();
|
|
2255
|
+
const del = db.prepare(
|
|
2256
|
+
`DELETE FROM relationships WHERE relationship_type = 'QUERIES' AND source_id IN (SELECT id FROM symbols WHERE file_id = ?)`
|
|
2257
|
+
);
|
|
2258
|
+
const ins = db.prepare(
|
|
2259
|
+
`INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
|
|
2260
|
+
VALUES (?, ?, 'QUERIES', ?, ?)`
|
|
2261
|
+
);
|
|
2262
|
+
for (const r of all) {
|
|
2263
|
+
if (!derivers.some((d) => d.relevant(r.path))) continue;
|
|
2264
|
+
let content;
|
|
2265
|
+
try {
|
|
2266
|
+
content = fs11.readFileSync(path12.join(repoRoot2, ...r.path.split("/")), "utf8");
|
|
2267
|
+
} catch {
|
|
2268
|
+
continue;
|
|
2269
|
+
}
|
|
2270
|
+
del.run(r.id);
|
|
2271
|
+
for (const d of derivers) {
|
|
2272
|
+
for (const e of d.derive(db, r.id, content)) {
|
|
2273
|
+
ins.run(e.callerId, e.targetId, e.weight, e.confidence);
|
|
2274
|
+
relations++;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
} catch (err) {
|
|
2279
|
+
logError("adapters", err, repoRoot2);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
try {
|
|
2283
|
+
cleanupOrphans(db);
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
logError("adapters", err, repoRoot2);
|
|
2286
|
+
}
|
|
2287
|
+
return { adapters: active.map((a) => a.id), symbols, relations };
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/graph/export.ts
|
|
2291
|
+
import fs12 from "fs";
|
|
2292
|
+
import path13 from "path";
|
|
2293
|
+
var GRAPH_MD_NAME = "GRAPH.md";
|
|
2294
|
+
var MAX_ROWS = 20;
|
|
2295
|
+
var MAX_LINES = 160;
|
|
2296
|
+
function buildGraphMd(db, repoRoot2) {
|
|
2297
|
+
const one = (sql) => {
|
|
2298
|
+
try {
|
|
2299
|
+
return db.prepare(sql).get().n;
|
|
2300
|
+
} catch {
|
|
2301
|
+
return 0;
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
const files = one(`SELECT COUNT(*) AS n FROM files`);
|
|
2305
|
+
const symbols = one(`SELECT COUNT(*) AS n FROM symbols`);
|
|
2306
|
+
const rels = one(`SELECT COUNT(*) AS n FROM relationships`);
|
|
2307
|
+
const lines = [
|
|
2308
|
+
`# Codebase Graph`,
|
|
2309
|
+
``,
|
|
2310
|
+
`> Generated by \`scopecairn scan\`. Do not edit \u2014 regenerated when the graph changes.`,
|
|
2311
|
+
`> Visual map: open \`.scopecairn/GRAPH.html\` in a browser (offline, no dependencies).`,
|
|
2312
|
+
`> For task-specific context, run \`scopecairn context "<task>"\` instead of reading this whole file.`,
|
|
2313
|
+
``,
|
|
2314
|
+
`## Stats`,
|
|
2315
|
+
``,
|
|
2316
|
+
`- Files: ${files} | Symbols: ${symbols} | Relationships: ${rels}`,
|
|
2317
|
+
``
|
|
2318
|
+
];
|
|
2319
|
+
try {
|
|
2320
|
+
const mods = db.prepare(
|
|
2321
|
+
`SELECT CASE WHEN instr(path, '/') > 0 THEN substr(path, 1, instr(path, '/') - 1) ELSE '.' END AS mod,
|
|
2322
|
+
COUNT(*) AS n FROM files GROUP BY mod ORDER BY n DESC LIMIT ${MAX_ROWS}`
|
|
2323
|
+
).all();
|
|
2324
|
+
if (mods.length > 0) {
|
|
2325
|
+
lines.push(`## Modules`, ``);
|
|
2326
|
+
for (const m of mods) lines.push(`- ${m.mod}/ \u2014 ${m.n} files`);
|
|
2327
|
+
lines.push(``);
|
|
2328
|
+
}
|
|
2329
|
+
} catch {
|
|
2330
|
+
}
|
|
2331
|
+
try {
|
|
2332
|
+
const top = db.prepare(
|
|
2333
|
+
`SELECT s.name, s.type, f.path AS file, COUNT(*) AS fanin
|
|
2334
|
+
FROM relationships r
|
|
2335
|
+
JOIN symbols s ON s.id = r.target_id
|
|
2336
|
+
JOIN files f ON f.id = s.file_id
|
|
2337
|
+
WHERE s.type IN ('function','class','method','component','interface','type')
|
|
2338
|
+
GROUP BY r.target_id ORDER BY fanin DESC LIMIT ${MAX_ROWS}`
|
|
2339
|
+
).all();
|
|
2340
|
+
if (top.length > 0) {
|
|
2341
|
+
lines.push(`## Most-used symbols`, ``);
|
|
2342
|
+
for (const t of top) lines.push(`- ${t.name} (${t.type}, ${t.file}) \u2190 ${t.fanin}x`);
|
|
2343
|
+
lines.push(``);
|
|
2344
|
+
}
|
|
2345
|
+
} catch {
|
|
2346
|
+
}
|
|
2347
|
+
const symList = (type, title) => {
|
|
2348
|
+
try {
|
|
2349
|
+
const rows = db.prepare(
|
|
2350
|
+
`SELECT s.name, f.path AS file FROM symbols s
|
|
2351
|
+
JOIN files f ON f.id = s.file_id
|
|
2352
|
+
WHERE s.type = ? ORDER BY s.name LIMIT ${MAX_ROWS}`
|
|
2353
|
+
).all(type);
|
|
2354
|
+
if (rows.length > 0) {
|
|
2355
|
+
lines.push(`## ${title}`, ``);
|
|
2356
|
+
for (const r of rows) lines.push(`- ${r.name} \u2014 ${r.file}`);
|
|
2357
|
+
lines.push(``);
|
|
2358
|
+
}
|
|
2359
|
+
} catch {
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
symList("route", "Routes");
|
|
2363
|
+
symList("model", "Models");
|
|
2364
|
+
try {
|
|
2365
|
+
const top = db.prepare(
|
|
2366
|
+
`SELECT s.name, s.type, f.path AS file, m.pagerank AS pr
|
|
2367
|
+
FROM node_metrics m
|
|
2368
|
+
JOIN symbols s ON s.id = m.node_id
|
|
2369
|
+
JOIN files f ON f.id = s.file_id
|
|
2370
|
+
ORDER BY m.pagerank DESC LIMIT 5`
|
|
2371
|
+
).all();
|
|
2372
|
+
if (top.length > 0) {
|
|
2373
|
+
lines.push(`## Critical hotspots (PageRank)`, ``);
|
|
2374
|
+
for (const t of top) lines.push(`- ${t.name} (${t.type}, ${t.file}) [pr=${t.pr.toFixed(4)}]`);
|
|
2375
|
+
lines.push(``);
|
|
2376
|
+
}
|
|
2377
|
+
} catch {
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
const c = db.prepare(`SELECT COUNT(*) AS n FROM cycles`).get().n;
|
|
2381
|
+
if (c > 0) {
|
|
2382
|
+
lines.push(`## Circular dependencies`, ``);
|
|
2383
|
+
lines.push(`- ${c} dependency cycle(s) detected \u2014 see \`scopecairn doctor\`.`);
|
|
2384
|
+
lines.push(``);
|
|
2385
|
+
}
|
|
2386
|
+
} catch {
|
|
2387
|
+
}
|
|
2388
|
+
try {
|
|
2389
|
+
const patterns = loadProtectedPatterns(repoRoot2);
|
|
2390
|
+
if (patterns.length > 0) {
|
|
2391
|
+
lines.push(`## Protected (ask before touching)`, ``);
|
|
2392
|
+
for (const p of patterns.slice(0, MAX_ROWS)) lines.push(`- \`${p}\``);
|
|
2393
|
+
lines.push(``);
|
|
2394
|
+
}
|
|
2395
|
+
} catch {
|
|
2396
|
+
}
|
|
2397
|
+
const out = lines.join("\n");
|
|
2398
|
+
const trimmed = out.split("\n");
|
|
2399
|
+
if (trimmed.length > MAX_LINES) {
|
|
2400
|
+
return trimmed.slice(0, MAX_LINES).join("\n") + "\n";
|
|
2401
|
+
}
|
|
2402
|
+
return out;
|
|
2403
|
+
}
|
|
2404
|
+
function graphMdPath(repoRoot2) {
|
|
2405
|
+
return path13.join(dataDir(repoRoot2), GRAPH_MD_NAME);
|
|
2406
|
+
}
|
|
2407
|
+
function writeGraphMdIfChanged(db, repoRoot2, dirty) {
|
|
2408
|
+
if (!dirty) return false;
|
|
2409
|
+
try {
|
|
2410
|
+
fs12.mkdirSync(dataDir(repoRoot2), { recursive: true });
|
|
2411
|
+
fs12.writeFileSync(graphMdPath(repoRoot2), buildGraphMd(db, repoRoot2));
|
|
2412
|
+
return true;
|
|
2413
|
+
} catch {
|
|
2414
|
+
return false;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
var EXPORT_NODE_CAP = 500;
|
|
2418
|
+
var EXPORT_EDGE_CAP = 1500;
|
|
2419
|
+
function readExportGraph(db) {
|
|
2420
|
+
let nodes = [];
|
|
2421
|
+
try {
|
|
2422
|
+
nodes = db.prepare(
|
|
2423
|
+
`SELECT s.id, s.name, s.type, f.path AS file,
|
|
2424
|
+
(SELECT COUNT(*) FROM relationships r WHERE r.target_id = s.id) +
|
|
2425
|
+
(SELECT COUNT(*) FROM relationships r WHERE r.source_id = s.id) AS deg
|
|
2426
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
2427
|
+
ORDER BY deg DESC LIMIT ${EXPORT_NODE_CAP}`
|
|
2428
|
+
).all();
|
|
2429
|
+
} catch {
|
|
2430
|
+
try {
|
|
2431
|
+
nodes = db.prepare(
|
|
2432
|
+
`SELECT s.id, s.name, s.type, f.path AS file
|
|
2433
|
+
FROM symbols s JOIN files f ON f.id = s.file_id LIMIT ${EXPORT_NODE_CAP}`
|
|
2434
|
+
).all();
|
|
2435
|
+
} catch {
|
|
2436
|
+
return { nodes: [], edges: [] };
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
if (nodes.length === 0) return { nodes, edges: [] };
|
|
2440
|
+
const keep = new Set(nodes.map((n) => n.id));
|
|
2441
|
+
let edges = [];
|
|
2442
|
+
try {
|
|
2443
|
+
edges = db.prepare(
|
|
2444
|
+
`SELECT source_id AS s, target_id AS t, relationship_type AS rel,
|
|
2445
|
+
weight AS w FROM relationships LIMIT ${EXPORT_EDGE_CAP * 4}`
|
|
2446
|
+
).all().filter((e) => keep.has(e.s) && keep.has(e.t)).slice(0, EXPORT_EDGE_CAP);
|
|
2447
|
+
} catch {
|
|
2448
|
+
edges = [];
|
|
2449
|
+
}
|
|
2450
|
+
return { nodes, edges };
|
|
2451
|
+
}
|
|
2452
|
+
function escXml(s) {
|
|
2453
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2454
|
+
}
|
|
2455
|
+
function mermaidId(n) {
|
|
2456
|
+
return `n${n}`;
|
|
2457
|
+
}
|
|
2458
|
+
function mermaidLabel(n) {
|
|
2459
|
+
return `${n.name} [${n.type}]`.replace(/["#<>`]/g, "").slice(0, 60);
|
|
2460
|
+
}
|
|
2461
|
+
function exportMermaid(db) {
|
|
2462
|
+
const { nodes, edges } = readExportGraph(db);
|
|
2463
|
+
const lines = ["graph TD"];
|
|
2464
|
+
for (const n of nodes) {
|
|
2465
|
+
lines.push(` ${mermaidId(n.id)}["${mermaidLabel(n)}"]`);
|
|
2466
|
+
}
|
|
2467
|
+
for (const e of edges) {
|
|
2468
|
+
const rel = e.rel.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 24) || "REL";
|
|
2469
|
+
lines.push(` ${mermaidId(e.s)} -->|${rel}| ${mermaidId(e.t)}`);
|
|
2470
|
+
}
|
|
2471
|
+
return lines.join("\n") + "\n";
|
|
2472
|
+
}
|
|
2473
|
+
function exportGraphML(db) {
|
|
2474
|
+
const { nodes, edges } = readExportGraph(db);
|
|
2475
|
+
const out = [
|
|
2476
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
2477
|
+
`<graphml xmlns="http://graphml.graphdrawing.org/xmlns">`,
|
|
2478
|
+
` <key id="name" for="node" attr.name="name" attr.type="string"/>`,
|
|
2479
|
+
` <key id="type" for="node" attr.name="type" attr.type="string"/>`,
|
|
2480
|
+
` <key id="file" for="node" attr.name="file" attr.type="string"/>`,
|
|
2481
|
+
` <key id="rel" for="edge" attr.name="relationship" attr.type="string"/>`,
|
|
2482
|
+
` <key id="weight" for="edge" attr.name="weight" attr.type="double"/>`,
|
|
2483
|
+
` <graph id="scopecairn" edgedefault="directed">`
|
|
2484
|
+
];
|
|
2485
|
+
for (const n of nodes) {
|
|
2486
|
+
out.push(
|
|
2487
|
+
` <node id="${mermaidId(n.id)}"><data key="name">${escXml(n.name)}</data><data key="type">${escXml(n.type)}</data><data key="file">${escXml(n.file)}</data></node>`
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
edges.forEach((e, i) => {
|
|
2491
|
+
out.push(
|
|
2492
|
+
` <edge id="e${i}" source="${mermaidId(e.s)}" target="${mermaidId(e.t)}"><data key="rel">${escXml(e.rel)}</data><data key="weight">${e.w}</data></edge>`
|
|
2493
|
+
);
|
|
2494
|
+
});
|
|
2495
|
+
out.push(` </graph>`, `</graphml>`);
|
|
2496
|
+
return out.join("\n") + "\n";
|
|
2497
|
+
}
|
|
2498
|
+
function exportDot(db) {
|
|
2499
|
+
const { nodes, edges } = readExportGraph(db);
|
|
2500
|
+
const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2501
|
+
const lines = [`digraph scopecairn {`, ` rankdir=LR;`, ` node [shape=box, style=rounded];`];
|
|
2502
|
+
for (const n of nodes) {
|
|
2503
|
+
lines.push(` ${mermaidId(n.id)} [label=${q(`${n.name}
|
|
2504
|
+
(${n.type})`)}];`);
|
|
2505
|
+
}
|
|
2506
|
+
for (const e of edges) {
|
|
2507
|
+
lines.push(` ${mermaidId(e.s)} -> ${mermaidId(e.t)} [label=${q(e.rel)}];`);
|
|
2508
|
+
}
|
|
2509
|
+
lines.push(`}`);
|
|
2510
|
+
return lines.join("\n") + "\n";
|
|
2511
|
+
}
|
|
2512
|
+
function exportJson(db) {
|
|
2513
|
+
const { nodes, edges } = readExportGraph(db);
|
|
2514
|
+
return JSON.stringify(
|
|
2515
|
+
{
|
|
2516
|
+
generated_by: "scopecairn export",
|
|
2517
|
+
node_count: nodes.length,
|
|
2518
|
+
edge_count: edges.length,
|
|
2519
|
+
nodes: nodes.map((n) => ({ id: n.id, name: n.name, type: n.type, file: n.file })),
|
|
2520
|
+
edges: edges.map((e) => ({ source: e.s, target: e.t, relationship: e.rel, weight: e.w }))
|
|
2521
|
+
},
|
|
2522
|
+
null,
|
|
2523
|
+
2
|
|
2524
|
+
) + "\n";
|
|
2525
|
+
}
|
|
2526
|
+
function exportGraph(db, format) {
|
|
2527
|
+
switch (format) {
|
|
2528
|
+
case "mermaid":
|
|
2529
|
+
return exportMermaid(db);
|
|
2530
|
+
case "graphml":
|
|
2531
|
+
return exportGraphML(db);
|
|
2532
|
+
case "dot":
|
|
2533
|
+
return exportDot(db);
|
|
2534
|
+
case "json":
|
|
2535
|
+
return exportJson(db);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// src/graph/clusters.ts
|
|
2540
|
+
function readUndirected(db) {
|
|
2541
|
+
let ids = [];
|
|
2542
|
+
try {
|
|
2543
|
+
ids = db.prepare(`SELECT id FROM symbols ORDER BY id`).all().map((r) => r.id);
|
|
2544
|
+
} catch {
|
|
2545
|
+
return { ids: [], edges: [] };
|
|
2546
|
+
}
|
|
2547
|
+
const agg = /* @__PURE__ */ new Map();
|
|
2548
|
+
try {
|
|
2549
|
+
const rows = db.prepare(
|
|
2550
|
+
`SELECT source_id AS s, target_id AS t, SUM(MAX(weight, 0.01)) AS w
|
|
2551
|
+
FROM relationships GROUP BY source_id, target_id`
|
|
2552
|
+
).all();
|
|
2553
|
+
for (const r of rows) {
|
|
2554
|
+
if (r.s === r.t) continue;
|
|
2555
|
+
const key = r.s < r.t ? `${r.s}:${r.t}` : `${r.t}:${r.s}`;
|
|
2556
|
+
const e = agg.get(key);
|
|
2557
|
+
if (e) e.w += r.w;
|
|
2558
|
+
else agg.set(key, { a: Math.min(r.s, r.t), b: Math.max(r.s, r.t), w: r.w });
|
|
2559
|
+
}
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
return { ids, edges: [...agg.values()] };
|
|
2563
|
+
}
|
|
2564
|
+
function modularity(comm, edges, degree, m2) {
|
|
2565
|
+
if (m2 === 0) return 0;
|
|
2566
|
+
const inSum = /* @__PURE__ */ new Map();
|
|
2567
|
+
const totSum = /* @__PURE__ */ new Map();
|
|
2568
|
+
for (const [node, c] of comm) {
|
|
2569
|
+
totSum.set(c, (totSum.get(c) ?? 0) + (degree.get(node) ?? 0));
|
|
2570
|
+
}
|
|
2571
|
+
for (const e of edges) {
|
|
2572
|
+
if (comm.get(e.a) === comm.get(e.b)) {
|
|
2573
|
+
inSum.set(comm.get(e.a), (inSum.get(comm.get(e.a)) ?? 0) + 2 * e.w);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
let q = 0;
|
|
2577
|
+
for (const [c, tot] of totSum) {
|
|
2578
|
+
q += (inSum.get(c) ?? 0) / m2 - tot / m2 * (tot / m2);
|
|
2579
|
+
}
|
|
2580
|
+
return q;
|
|
2581
|
+
}
|
|
2582
|
+
function localMove(nodes, adj, comm, commTot, degree, m2) {
|
|
2583
|
+
let moved = false;
|
|
2584
|
+
for (const node of nodes) {
|
|
2585
|
+
const cur = comm.get(node);
|
|
2586
|
+
const ki = degree.get(node) ?? 0;
|
|
2587
|
+
const toComm = /* @__PURE__ */ new Map();
|
|
2588
|
+
for (const { to, w } of adj.get(node) ?? []) {
|
|
2589
|
+
const c = comm.get(to);
|
|
2590
|
+
toComm.set(c, (toComm.get(c) ?? 0) + w);
|
|
2591
|
+
}
|
|
2592
|
+
commTot.set(cur, (commTot.get(cur) ?? 0) - ki);
|
|
2593
|
+
let best = cur;
|
|
2594
|
+
let bestGain = 0;
|
|
2595
|
+
for (const [c, kin] of toComm) {
|
|
2596
|
+
if (c === cur) continue;
|
|
2597
|
+
const gain = kin - (commTot.get(c) ?? 0) * ki / Math.max(m2, 1e-9);
|
|
2598
|
+
if (gain > bestGain) {
|
|
2599
|
+
bestGain = gain;
|
|
2600
|
+
best = c;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
const selfGain = (toComm.get(cur) ?? 0) - (commTot.get(cur) ?? 0) * ki / Math.max(m2, 1e-9);
|
|
2604
|
+
if (best !== cur && bestGain > selfGain) {
|
|
2605
|
+
comm.set(node, best);
|
|
2606
|
+
commTot.set(best, (commTot.get(best) ?? 0) + ki);
|
|
2607
|
+
moved = true;
|
|
2608
|
+
} else {
|
|
2609
|
+
commTot.set(cur, (commTot.get(cur) ?? 0) + ki);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
return moved;
|
|
2613
|
+
}
|
|
2614
|
+
function louvain(db) {
|
|
2615
|
+
const { ids, edges } = readUndirected(db);
|
|
2616
|
+
const assignment = /* @__PURE__ */ new Map();
|
|
2617
|
+
if (ids.length === 0) return { assignment, modularity: 0 };
|
|
2618
|
+
let levelNodes = [...ids].sort((a, b) => a - b);
|
|
2619
|
+
let levelEdges = edges;
|
|
2620
|
+
let members = /* @__PURE__ */ new Map();
|
|
2621
|
+
for (const id of ids) members.set(id, [id]);
|
|
2622
|
+
for (let level = 0; level < 8; level++) {
|
|
2623
|
+
const adj = /* @__PURE__ */ new Map();
|
|
2624
|
+
const degree2 = /* @__PURE__ */ new Map();
|
|
2625
|
+
for (const n of levelNodes) {
|
|
2626
|
+
adj.set(n, []);
|
|
2627
|
+
degree2.set(n, 0);
|
|
2628
|
+
}
|
|
2629
|
+
let m22 = 0;
|
|
2630
|
+
for (const e of levelEdges) {
|
|
2631
|
+
adj.get(e.a)?.push({ to: e.b, w: e.w });
|
|
2632
|
+
adj.get(e.b)?.push({ to: e.a, w: e.w });
|
|
2633
|
+
degree2.set(e.a, (degree2.get(e.a) ?? 0) + e.w);
|
|
2634
|
+
degree2.set(e.b, (degree2.get(e.b) ?? 0) + e.w);
|
|
2635
|
+
m22 += 2 * e.w;
|
|
2636
|
+
}
|
|
2637
|
+
const comm = /* @__PURE__ */ new Map();
|
|
2638
|
+
const commTot = /* @__PURE__ */ new Map();
|
|
2639
|
+
for (const n of levelNodes) {
|
|
2640
|
+
comm.set(n, n);
|
|
2641
|
+
commTot.set(n, degree2.get(n) ?? 0);
|
|
2642
|
+
}
|
|
2643
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
2644
|
+
if (!localMove(levelNodes, adj, comm, commTot, degree2, m22)) break;
|
|
2645
|
+
}
|
|
2646
|
+
const remap2 = /* @__PURE__ */ new Map();
|
|
2647
|
+
let next2 = 0;
|
|
2648
|
+
for (const n of levelNodes) {
|
|
2649
|
+
const c = comm.get(n);
|
|
2650
|
+
if (!remap2.has(c)) remap2.set(c, next2++);
|
|
2651
|
+
comm.set(n, remap2.get(c));
|
|
2652
|
+
}
|
|
2653
|
+
const newMembers = /* @__PURE__ */ new Map();
|
|
2654
|
+
for (const n of levelNodes) {
|
|
2655
|
+
const c = comm.get(n);
|
|
2656
|
+
if (!newMembers.has(c)) newMembers.set(c, []);
|
|
2657
|
+
newMembers.get(c).push(...members.get(n) ?? [n]);
|
|
2658
|
+
}
|
|
2659
|
+
members = newMembers;
|
|
2660
|
+
if (remap2.size === levelNodes.length || remap2.size <= 1) break;
|
|
2661
|
+
const agg = /* @__PURE__ */ new Map();
|
|
2662
|
+
for (const e of levelEdges) {
|
|
2663
|
+
const ca = comm.get(e.a);
|
|
2664
|
+
const cb = comm.get(e.b);
|
|
2665
|
+
if (ca === cb) continue;
|
|
2666
|
+
const key = ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`;
|
|
2667
|
+
agg.set(key, (agg.get(key) ?? 0) + e.w);
|
|
2668
|
+
}
|
|
2669
|
+
levelNodes = [...members.keys()].sort((a, b) => a - b);
|
|
2670
|
+
levelEdges = [...agg.entries()].map(([k, w]) => {
|
|
2671
|
+
const [a, b] = k.split(":").map(Number);
|
|
2672
|
+
return { a, b, w };
|
|
2673
|
+
});
|
|
2674
|
+
if (levelEdges.length === 0) break;
|
|
2675
|
+
}
|
|
2676
|
+
for (const [superId, mems] of members) {
|
|
2677
|
+
for (const m of mems) assignment.set(m, superId);
|
|
2678
|
+
}
|
|
2679
|
+
const remap = /* @__PURE__ */ new Map();
|
|
2680
|
+
let next = 0;
|
|
2681
|
+
for (const id of ids) {
|
|
2682
|
+
const c = assignment.get(id) ?? 0;
|
|
2683
|
+
if (!remap.has(c)) remap.set(c, next++);
|
|
2684
|
+
assignment.set(id, remap.get(c));
|
|
2685
|
+
}
|
|
2686
|
+
const degree = /* @__PURE__ */ new Map();
|
|
2687
|
+
let m2 = 0;
|
|
2688
|
+
for (const e of edges) {
|
|
2689
|
+
degree.set(e.a, (degree.get(e.a) ?? 0) + e.w);
|
|
2690
|
+
degree.set(e.b, (degree.get(e.b) ?? 0) + e.w);
|
|
2691
|
+
m2 += 2 * e.w;
|
|
2692
|
+
}
|
|
2693
|
+
const q = modularity(assignment, edges, degree, m2);
|
|
2694
|
+
return { assignment, modularity: q };
|
|
2695
|
+
}
|
|
2696
|
+
function refreshClusters(db) {
|
|
2697
|
+
const { assignment, modularity: q } = louvain(db);
|
|
2698
|
+
if (assignment.size === 0) return { count: 0, modularity: 0 };
|
|
2699
|
+
try {
|
|
2700
|
+
db.prepare(`DELETE FROM clusters WHERE node_id NOT IN (SELECT id FROM symbols)`).run();
|
|
2701
|
+
} catch {
|
|
2702
|
+
}
|
|
2703
|
+
const names = /* @__PURE__ */ new Map();
|
|
2704
|
+
try {
|
|
2705
|
+
const byCluster = /* @__PURE__ */ new Map();
|
|
2706
|
+
for (const [nodeId, cid] of assignment) {
|
|
2707
|
+
const r = db.prepare(`SELECT f.path AS p FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?`).get(nodeId);
|
|
2708
|
+
const top = (r?.p ?? "").split("/")[0] || ".";
|
|
2709
|
+
if (!byCluster.has(cid)) byCluster.set(cid, /* @__PURE__ */ new Map());
|
|
2710
|
+
const m = byCluster.get(cid);
|
|
2711
|
+
m.set(top, (m.get(top) ?? 0) + 1);
|
|
2712
|
+
}
|
|
2713
|
+
for (const [cid, m] of byCluster) {
|
|
2714
|
+
let best = ".";
|
|
2715
|
+
let bestN = -1;
|
|
2716
|
+
for (const [k, v] of m) {
|
|
2717
|
+
if (v > bestN) {
|
|
2718
|
+
bestN = v;
|
|
2719
|
+
best = k;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
names.set(cid, `cluster-${cid} (${best}/)`);
|
|
2723
|
+
}
|
|
2724
|
+
} catch {
|
|
2725
|
+
}
|
|
2726
|
+
const up = db.prepare(
|
|
2727
|
+
`INSERT INTO clusters(node_id, cluster_id, cluster_name, modularity)
|
|
2728
|
+
VALUES (?, ?, ?, ?)
|
|
2729
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
2730
|
+
cluster_id = excluded.cluster_id, cluster_name = excluded.cluster_name,
|
|
2731
|
+
modularity = excluded.modularity`
|
|
2732
|
+
);
|
|
2733
|
+
for (const [nodeId, cid] of assignment) {
|
|
2734
|
+
up.run(nodeId, cid, names.get(cid) ?? `cluster-${cid}`, q);
|
|
2735
|
+
}
|
|
2736
|
+
return { count: new Set(assignment.values()).size, modularity: q };
|
|
2737
|
+
}
|
|
2738
|
+
function ensureClusters(db) {
|
|
2739
|
+
try {
|
|
2740
|
+
const syms = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
2741
|
+
if (syms === 0) return 0;
|
|
2742
|
+
const missing = db.prepare(
|
|
2743
|
+
`SELECT COUNT(*) AS n FROM symbols s LEFT JOIN clusters c ON c.node_id = s.id WHERE c.node_id IS NULL`
|
|
2744
|
+
).get().n;
|
|
2745
|
+
const orphans = db.prepare(
|
|
2746
|
+
`SELECT COUNT(*) AS n FROM clusters c LEFT JOIN symbols s ON s.id = c.node_id WHERE s.id IS NULL`
|
|
2747
|
+
).get().n;
|
|
2748
|
+
if (missing > 0 || orphans > 0) {
|
|
2749
|
+
refreshClusters(db);
|
|
2750
|
+
}
|
|
2751
|
+
return db.prepare(`SELECT COUNT(*) AS n FROM clusters`).get().n;
|
|
2752
|
+
} catch {
|
|
2753
|
+
return 0;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
function getCluster(db, nodeId) {
|
|
2757
|
+
try {
|
|
2758
|
+
const r = db.prepare(`SELECT * FROM clusters WHERE node_id = ?`).get(nodeId);
|
|
2759
|
+
if (!r) return null;
|
|
2760
|
+
return { nodeId: r.node_id, clusterId: r.cluster_id, clusterName: r.cluster_name ?? "", modularity: r.modularity ?? 0 };
|
|
2761
|
+
} catch {
|
|
2762
|
+
return null;
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// src/graph/visual.ts
|
|
2767
|
+
var GRAPH_HTML_NAME = "GRAPH.html";
|
|
2768
|
+
var MAX_FILES = 600;
|
|
2769
|
+
var MAX_EDGES = 1500;
|
|
2770
|
+
var MAX_SYMBOLS = 800;
|
|
2771
|
+
var MAX_SYM_EDGES = 2e3;
|
|
2772
|
+
function esc(s) {
|
|
2773
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2774
|
+
}
|
|
2775
|
+
function moduleOf(file) {
|
|
2776
|
+
const i = file.indexOf("/");
|
|
2777
|
+
return i === -1 ? "." : file.slice(0, i);
|
|
2778
|
+
}
|
|
2779
|
+
function colorFor(mod) {
|
|
2780
|
+
let h = 0;
|
|
2781
|
+
for (const c of mod) h = (h * 31 + c.charCodeAt(0)) % 360;
|
|
2782
|
+
return `hsl(${h}, 55%, 55%)`;
|
|
2783
|
+
}
|
|
2784
|
+
function buildVisualModel(db) {
|
|
2785
|
+
const empty = {
|
|
2786
|
+
nodes: [],
|
|
2787
|
+
edges: [],
|
|
2788
|
+
truncatedFiles: 0,
|
|
2789
|
+
truncatedEdges: 0,
|
|
2790
|
+
symbols: [],
|
|
2791
|
+
symbolEdges: [],
|
|
2792
|
+
truncatedSymbols: 0,
|
|
2793
|
+
clusterNames: [],
|
|
2794
|
+
modularity: 0
|
|
2795
|
+
};
|
|
2796
|
+
let files = [];
|
|
2797
|
+
try {
|
|
2798
|
+
files = db.prepare(`SELECT id, path, language AS lang FROM files`).all();
|
|
2799
|
+
} catch {
|
|
2800
|
+
return empty;
|
|
2801
|
+
}
|
|
2802
|
+
if (files.length === 0) return empty;
|
|
2803
|
+
let prBySym = /* @__PURE__ */ new Map();
|
|
2804
|
+
let clusterBySym = /* @__PURE__ */ new Map();
|
|
2805
|
+
const clusterNames = /* @__PURE__ */ new Map();
|
|
2806
|
+
let modularity2 = 0;
|
|
2807
|
+
try {
|
|
2808
|
+
ensureMetrics(db);
|
|
2809
|
+
for (const r of db.prepare(`SELECT node_id AS id, pagerank AS pr FROM node_metrics`).all()) prBySym.set(r.id, r.pr);
|
|
2810
|
+
} catch {
|
|
2811
|
+
}
|
|
2812
|
+
try {
|
|
2813
|
+
ensureClusters(db);
|
|
2814
|
+
for (const r of db.prepare(
|
|
2815
|
+
`SELECT node_id AS id, cluster_id AS c, cluster_name AS n, modularity AS q FROM clusters`
|
|
2816
|
+
).all()) {
|
|
2817
|
+
clusterBySym.set(r.id, r.c);
|
|
2818
|
+
if (r.n) clusterNames.set(r.c, r.n);
|
|
2819
|
+
modularity2 = r.q ?? modularity2;
|
|
2820
|
+
}
|
|
2821
|
+
} catch {
|
|
2822
|
+
}
|
|
2823
|
+
const useClusters = clusterBySym.size > 0;
|
|
2824
|
+
const symCount = /* @__PURE__ */ new Map();
|
|
2825
|
+
try {
|
|
2826
|
+
for (const r of db.prepare(`SELECT file_id AS f, COUNT(*) AS n FROM symbols GROUP BY file_id`).all()) {
|
|
2827
|
+
symCount.set(r.f, r.n);
|
|
2828
|
+
}
|
|
2829
|
+
} catch {
|
|
2830
|
+
}
|
|
2831
|
+
const deg = /* @__PURE__ */ new Map();
|
|
2832
|
+
const pair = /* @__PURE__ */ new Map();
|
|
2833
|
+
try {
|
|
2834
|
+
const rows = db.prepare(
|
|
2835
|
+
`SELECT s1.file_id AS fa, s2.file_id AS fb, r.relationship_type AS rel, r.confidence AS c
|
|
2836
|
+
FROM relationships r
|
|
2837
|
+
JOIN symbols s1 ON s1.id = r.source_id
|
|
2838
|
+
JOIN symbols s2 ON s2.id = r.target_id
|
|
2839
|
+
WHERE s1.file_id != s2.file_id`
|
|
2840
|
+
).all();
|
|
2841
|
+
for (const r of rows) {
|
|
2842
|
+
const d1 = deg.get(r.fa) ?? { inn: 0, out: 0 };
|
|
2843
|
+
d1.out++;
|
|
2844
|
+
deg.set(r.fa, d1);
|
|
2845
|
+
const d2 = deg.get(r.fb) ?? { inn: 0, out: 0 };
|
|
2846
|
+
d2.inn++;
|
|
2847
|
+
deg.set(r.fb, d2);
|
|
2848
|
+
const key = r.fa < r.fb ? `${r.fa}:${r.fb}` : `${r.fb}:${r.fa}`;
|
|
2849
|
+
let p = pair.get(key);
|
|
2850
|
+
if (!p) {
|
|
2851
|
+
p = { a: r.fa, b: r.fb, rels: /* @__PURE__ */ new Map() };
|
|
2852
|
+
pair.set(key, p);
|
|
2853
|
+
}
|
|
2854
|
+
const e = p.rels.get(r.rel) ?? { n: 0, c: 0 };
|
|
2855
|
+
e.n++;
|
|
2856
|
+
e.c += r.c;
|
|
2857
|
+
p.rels.set(r.rel, e);
|
|
2858
|
+
}
|
|
2859
|
+
} catch {
|
|
2860
|
+
}
|
|
2861
|
+
const scored = files.map((f) => ({
|
|
2862
|
+
f,
|
|
2863
|
+
score: (deg.get(f.id)?.inn ?? 0) + (deg.get(f.id)?.out ?? 0) + (symCount.get(f.id) ?? 0) * 0.5
|
|
2864
|
+
}));
|
|
2865
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2866
|
+
const kept = scored.slice(0, MAX_FILES);
|
|
2867
|
+
const keptIds = new Set(kept.map((k) => k.f.id));
|
|
2868
|
+
const truncatedFiles = scored.length - kept.length;
|
|
2869
|
+
const fileCluster = /* @__PURE__ */ new Map();
|
|
2870
|
+
if (useClusters) {
|
|
2871
|
+
try {
|
|
2872
|
+
const rows = db.prepare(
|
|
2873
|
+
`SELECT file_id AS f, cluster_id AS c, COUNT(*) AS n FROM
|
|
2874
|
+
(SELECT s.file_id, c.cluster_id FROM symbols s JOIN clusters c ON c.node_id = s.id)
|
|
2875
|
+
GROUP BY file_id, cluster_id`
|
|
2876
|
+
).all();
|
|
2877
|
+
const best = /* @__PURE__ */ new Map();
|
|
2878
|
+
for (const r of rows) {
|
|
2879
|
+
if (!keptIds.has(r.f)) continue;
|
|
2880
|
+
if ((best.get(r.f)?.n ?? -1) < r.n) best.set(r.f, { c: r.c, n: r.n });
|
|
2881
|
+
}
|
|
2882
|
+
for (const [f, v] of best) fileCluster.set(f, v.c);
|
|
2883
|
+
} catch {
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
const filePr = /* @__PURE__ */ new Map();
|
|
2887
|
+
if (prBySym.size > 0) {
|
|
2888
|
+
try {
|
|
2889
|
+
for (const r of db.prepare(
|
|
2890
|
+
`SELECT s.file_id AS f, SUM(m.pagerank) AS pr FROM symbols s
|
|
2891
|
+
JOIN node_metrics m ON m.node_id = s.id GROUP BY s.file_id`
|
|
2892
|
+
).all()) filePr.set(r.f, r.pr);
|
|
2893
|
+
} catch {
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const nodes = kept.map((k) => {
|
|
2897
|
+
const cid = fileCluster.get(k.f.id) ?? -1;
|
|
2898
|
+
const mod = moduleOf(k.f.path);
|
|
2899
|
+
return {
|
|
2900
|
+
id: k.f.id,
|
|
2901
|
+
file: k.f.path,
|
|
2902
|
+
lang: k.f.lang,
|
|
2903
|
+
symbols: symCount.get(k.f.id) ?? 0,
|
|
2904
|
+
module: mod,
|
|
2905
|
+
cluster: cid,
|
|
2906
|
+
clusterName: cid >= 0 ? clusterNames.get(cid) ?? `cluster-${cid}` : mod,
|
|
2907
|
+
pagerank: filePr.get(k.f.id) ?? 0,
|
|
2908
|
+
inDeg: deg.get(k.f.id)?.inn ?? 0,
|
|
2909
|
+
outDeg: deg.get(k.f.id)?.out ?? 0,
|
|
2910
|
+
x: 0,
|
|
2911
|
+
y: 0,
|
|
2912
|
+
r: 0,
|
|
2913
|
+
color: ""
|
|
2914
|
+
};
|
|
2915
|
+
});
|
|
2916
|
+
const edges = [];
|
|
2917
|
+
for (const p of pair.values()) {
|
|
2918
|
+
if (!keptIds.has(p.a) || !keptIds.has(p.b)) continue;
|
|
2919
|
+
let top = "";
|
|
2920
|
+
let topN = 0;
|
|
2921
|
+
let confSum = 0;
|
|
2922
|
+
let total = 0;
|
|
2923
|
+
for (const [rel, e] of p.rels) {
|
|
2924
|
+
total += e.n;
|
|
2925
|
+
confSum += e.c;
|
|
2926
|
+
if (e.n > topN) {
|
|
2927
|
+
topN = e.n;
|
|
2928
|
+
top = rel;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
edges.push({ a: p.a, b: p.b, rel: top, n: total, conf: total > 0 ? confSum / total : 1 });
|
|
2932
|
+
}
|
|
2933
|
+
edges.sort((a, b) => b.n - a.n);
|
|
2934
|
+
const truncatedEdges = Math.max(0, edges.length - MAX_EDGES);
|
|
2935
|
+
const keptEdges = edges.slice(0, MAX_EDGES);
|
|
2936
|
+
layout(nodes, keptEdges);
|
|
2937
|
+
const { symbols, symbolEdges, truncatedSymbols } = buildSymbolLayer(
|
|
2938
|
+
db,
|
|
2939
|
+
keptIds,
|
|
2940
|
+
nodes,
|
|
2941
|
+
prBySym,
|
|
2942
|
+
clusterBySym
|
|
2943
|
+
);
|
|
2944
|
+
return {
|
|
2945
|
+
nodes,
|
|
2946
|
+
edges: keptEdges,
|
|
2947
|
+
truncatedFiles,
|
|
2948
|
+
truncatedEdges,
|
|
2949
|
+
symbols,
|
|
2950
|
+
symbolEdges,
|
|
2951
|
+
truncatedSymbols,
|
|
2952
|
+
clusterNames: [...clusterNames.entries()].map(([id, name]) => ({ id, name })),
|
|
2953
|
+
modularity: modularity2
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
function buildSymbolLayer(db, keptFileIds, fileNodes, prBySym, clusterBySym) {
|
|
2957
|
+
const none = { symbols: [], symbolEdges: [], truncatedSymbols: 0 };
|
|
2958
|
+
let rows = [];
|
|
2959
|
+
try {
|
|
2960
|
+
rows = db.prepare(
|
|
2961
|
+
`SELECT s.id, s.name, s.type, f.path AS file, s.file_id AS fid,
|
|
2962
|
+
(SELECT COUNT(*) FROM relationships r WHERE r.target_id = s.id) +
|
|
2963
|
+
(SELECT COUNT(*) FROM relationships r WHERE r.source_id = s.id) AS deg
|
|
2964
|
+
FROM symbols s JOIN files f ON f.id = s.file_id
|
|
2965
|
+
ORDER BY deg DESC LIMIT ${MAX_SYMBOLS * 2}`
|
|
2966
|
+
).all();
|
|
2967
|
+
} catch {
|
|
2968
|
+
return none;
|
|
2969
|
+
}
|
|
2970
|
+
rows = rows.filter((r) => keptFileIds.has(r.fid));
|
|
2971
|
+
if (prBySym.size > 0) {
|
|
2972
|
+
rows.sort((a, b) => {
|
|
2973
|
+
const pa = (prBySym.get(a.id) ?? 0) * 100 + a.deg;
|
|
2974
|
+
const pb = (prBySym.get(b.id) ?? 0) * 100 + b.deg;
|
|
2975
|
+
return pb - pa;
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
const truncatedSymbols = Math.max(0, rows.length - MAX_SYMBOLS);
|
|
2979
|
+
const kept = rows.slice(0, MAX_SYMBOLS);
|
|
2980
|
+
const keepIds = new Set(kept.map((r) => r.id));
|
|
2981
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2982
|
+
for (const n of fileNodes) byFile.set(n.id, n);
|
|
2983
|
+
const rnd = mulberry32(777);
|
|
2984
|
+
const callers = /* @__PURE__ */ new Map();
|
|
2985
|
+
const callees = /* @__PURE__ */ new Map();
|
|
2986
|
+
const symEdges = [];
|
|
2987
|
+
try {
|
|
2988
|
+
const rels = db.prepare(
|
|
2989
|
+
`SELECT r.source_id AS s, r.target_id AS t, r.relationship_type AS rel,
|
|
2990
|
+
s1.name AS sn, s2.name AS tn
|
|
2991
|
+
FROM relationships r
|
|
2992
|
+
JOIN symbols s1 ON s1.id = r.source_id
|
|
2993
|
+
JOIN symbols s2 ON s2.id = r.target_id
|
|
2994
|
+
LIMIT 20000`
|
|
2995
|
+
).all();
|
|
2996
|
+
for (const r of rels) {
|
|
2997
|
+
if (keepIds.has(r.s) && keepIds.has(r.t)) {
|
|
2998
|
+
if (symEdges.length < MAX_SYM_EDGES) symEdges.push({ s: r.s, t: r.t, rel: r.rel });
|
|
2999
|
+
}
|
|
3000
|
+
if (keepIds.has(r.t) && (callers.get(r.t) ?? []).length < 8) {
|
|
3001
|
+
if (!callers.has(r.t)) callers.set(r.t, []);
|
|
3002
|
+
callers.get(r.t).push(`${r.sn} (${r.rel})`);
|
|
3003
|
+
}
|
|
3004
|
+
if (keepIds.has(r.s) && (callees.get(r.s) ?? []).length < 8) {
|
|
3005
|
+
if (!callees.has(r.s)) callees.set(r.s, []);
|
|
3006
|
+
callees.get(r.s).push(`${r.tn} (${r.rel})`);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
} catch {
|
|
3010
|
+
}
|
|
3011
|
+
const symbols = kept.map((r) => {
|
|
3012
|
+
const f = byFile.get(r.fid);
|
|
3013
|
+
const fx = f?.x ?? 500;
|
|
3014
|
+
const fy = f?.y ?? 380;
|
|
3015
|
+
return {
|
|
3016
|
+
id: r.id,
|
|
3017
|
+
name: r.name,
|
|
3018
|
+
type: r.type,
|
|
3019
|
+
file: r.file,
|
|
3020
|
+
cluster: clusterBySym.get(r.id) ?? -1,
|
|
3021
|
+
pr: prBySym.get(r.id) ?? 0,
|
|
3022
|
+
inn: 0,
|
|
3023
|
+
out: 0,
|
|
3024
|
+
callers: callers.get(r.id) ?? [],
|
|
3025
|
+
callees: callees.get(r.id) ?? [],
|
|
3026
|
+
x: Math.round(fx + (rnd() - 0.5) * 120),
|
|
3027
|
+
y: Math.round(fy + (rnd() - 0.5) * 120)
|
|
3028
|
+
};
|
|
3029
|
+
});
|
|
3030
|
+
const symById = new Map(symbols.map((s) => [s.id, s]));
|
|
3031
|
+
for (const e of symEdges) {
|
|
3032
|
+
const a = symById.get(e.s);
|
|
3033
|
+
const b = symById.get(e.t);
|
|
3034
|
+
if (a) a.out++;
|
|
3035
|
+
if (b) b.inn++;
|
|
3036
|
+
}
|
|
3037
|
+
return { symbols, symbolEdges: symEdges, truncatedSymbols };
|
|
3038
|
+
}
|
|
3039
|
+
function mulberry32(seed) {
|
|
3040
|
+
let a = seed;
|
|
3041
|
+
return () => {
|
|
3042
|
+
a |= 0;
|
|
3043
|
+
a = a + 1831565813 | 0;
|
|
3044
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
3045
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
3046
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
function layout(nodes, edges) {
|
|
3050
|
+
for (const n of nodes) {
|
|
3051
|
+
n.color = colorFor(n.cluster >= 0 ? `cluster-${n.cluster}` : n.module);
|
|
3052
|
+
n.r = Math.min(15, 5 + Math.sqrt(n.symbols) * 1.6);
|
|
3053
|
+
}
|
|
3054
|
+
if (nodes.length === 0) return;
|
|
3055
|
+
if (nodes.length === 1) {
|
|
3056
|
+
nodes[0].x = 400;
|
|
3057
|
+
nodes[0].y = 300;
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
const rnd = mulberry32(1337);
|
|
3061
|
+
const groups = [...new Set(nodes.map((n) => n.cluster >= 0 ? `c${n.cluster}` : n.module))];
|
|
3062
|
+
const modAngle = new Map(groups.map((m, i) => [m, i / groups.length * Math.PI * 2]));
|
|
3063
|
+
const R0 = 260;
|
|
3064
|
+
const px = /* @__PURE__ */ new Map();
|
|
3065
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
3066
|
+
for (const n of nodes) {
|
|
3067
|
+
const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
|
|
3068
|
+
const a = (modAngle.get(key) ?? 0) + rnd() * 0.6;
|
|
3069
|
+
const r = R0 * (0.5 + rnd() * 0.7);
|
|
3070
|
+
px.set(n.id, { x: 500 + Math.cos(a) * r, y: 380 + Math.sin(a) * r * 0.75 });
|
|
3071
|
+
}
|
|
3072
|
+
const AREA = 1e3 * 760;
|
|
3073
|
+
const k = Math.sqrt(AREA / nodes.length) * 0.9;
|
|
3074
|
+
const adj = /* @__PURE__ */ new Map();
|
|
3075
|
+
for (const e of edges) {
|
|
3076
|
+
const w = Math.min(2, 0.5 + e.n * 0.25);
|
|
3077
|
+
if (!adj.has(e.a)) adj.set(e.a, []);
|
|
3078
|
+
if (!adj.has(e.b)) adj.set(e.b, []);
|
|
3079
|
+
adj.get(e.a).push({ to: e.b, w });
|
|
3080
|
+
adj.get(e.b).push({ to: e.a, w });
|
|
3081
|
+
}
|
|
3082
|
+
const ITERS = 110;
|
|
3083
|
+
let temp = 90;
|
|
3084
|
+
const dTemp = Math.pow(0.5 / temp, 1 / ITERS);
|
|
3085
|
+
for (let it = 0; it < ITERS; it++) {
|
|
3086
|
+
const disp = /* @__PURE__ */ new Map();
|
|
3087
|
+
for (const n of nodes) disp.set(n.id, { x: 0, y: 0 });
|
|
3088
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3089
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
3090
|
+
const a = px.get(nodes[i].id);
|
|
3091
|
+
const b = px.get(nodes[j].id);
|
|
3092
|
+
let dx = a.x - b.x;
|
|
3093
|
+
let dy = a.y - b.y;
|
|
3094
|
+
let d2 = dx * dx + dy * dy;
|
|
3095
|
+
if (d2 < 0.01) {
|
|
3096
|
+
dx = (rnd() - 0.5) * 2;
|
|
3097
|
+
dy = (rnd() - 0.5) * 2;
|
|
3098
|
+
d2 = dx * dx + dy * dy;
|
|
3099
|
+
}
|
|
3100
|
+
const d = Math.sqrt(d2);
|
|
3101
|
+
const f = Math.min(k * k / d, temp * 2);
|
|
3102
|
+
const fx = dx / d * f;
|
|
3103
|
+
const fy = dy / d * f;
|
|
3104
|
+
const da = disp.get(nodes[i].id);
|
|
3105
|
+
const db2 = disp.get(nodes[j].id);
|
|
3106
|
+
da.x += fx;
|
|
3107
|
+
da.y += fy;
|
|
3108
|
+
db2.x -= fx;
|
|
3109
|
+
db2.y -= fy;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
for (const n of nodes) {
|
|
3113
|
+
const p = px.get(n.id);
|
|
3114
|
+
const d = disp.get(n.id);
|
|
3115
|
+
for (const e of adj.get(n.id) ?? []) {
|
|
3116
|
+
const q = px.get(e.to);
|
|
3117
|
+
if (!q) continue;
|
|
3118
|
+
const dx = p.x - q.x;
|
|
3119
|
+
const dy = p.y - q.y;
|
|
3120
|
+
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
|
3121
|
+
const f = dist * dist / k * 0.02 * e.w;
|
|
3122
|
+
d.x -= dx / dist * f;
|
|
3123
|
+
d.y -= dy / dist * f;
|
|
3124
|
+
}
|
|
3125
|
+
d.x += (500 - p.x) * 0.03;
|
|
3126
|
+
d.y += (380 - p.y) * 0.03;
|
|
3127
|
+
}
|
|
3128
|
+
const cent = /* @__PURE__ */ new Map();
|
|
3129
|
+
for (const n of nodes) {
|
|
3130
|
+
const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
|
|
3131
|
+
const p = px.get(n.id);
|
|
3132
|
+
const c = cent.get(key) ?? { x: 0, y: 0, n: 0 };
|
|
3133
|
+
c.x += p.x;
|
|
3134
|
+
c.y += p.y;
|
|
3135
|
+
c.n++;
|
|
3136
|
+
cent.set(key, c);
|
|
3137
|
+
}
|
|
3138
|
+
for (const n of nodes) {
|
|
3139
|
+
const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
|
|
3140
|
+
const c = cent.get(key);
|
|
3141
|
+
const p = px.get(n.id);
|
|
3142
|
+
const d = disp.get(n.id);
|
|
3143
|
+
d.x += (c.x / c.n - p.x) * 0.05;
|
|
3144
|
+
d.y += (c.y / c.n - p.y) * 0.05;
|
|
3145
|
+
}
|
|
3146
|
+
for (const n of nodes) {
|
|
3147
|
+
const p = px.get(n.id);
|
|
3148
|
+
const d = disp.get(n.id);
|
|
3149
|
+
const dl = Math.max(1e-3, Math.sqrt(d.x * d.x + d.y * d.y));
|
|
3150
|
+
const step = Math.min(dl, temp);
|
|
3151
|
+
p.x += d.x / dl * step;
|
|
3152
|
+
p.y += d.y / dl * step;
|
|
3153
|
+
}
|
|
3154
|
+
temp *= dTemp;
|
|
3155
|
+
}
|
|
3156
|
+
for (const n of nodes) {
|
|
3157
|
+
const p = px.get(n.id);
|
|
3158
|
+
n.x = Math.round(p.x);
|
|
3159
|
+
n.y = Math.round(p.y);
|
|
3160
|
+
}
|
|
3161
|
+
void byId;
|
|
3162
|
+
}
|
|
3163
|
+
function renderHtml(model, title, generated) {
|
|
3164
|
+
const payload = JSON.stringify({
|
|
3165
|
+
files: model.nodes.map((n) => ({
|
|
3166
|
+
id: n.id,
|
|
3167
|
+
file: n.file,
|
|
3168
|
+
lang: n.lang,
|
|
3169
|
+
symbols: n.symbols,
|
|
3170
|
+
module: n.module,
|
|
3171
|
+
cluster: n.cluster,
|
|
3172
|
+
clusterName: n.clusterName,
|
|
3173
|
+
pr: n.pagerank,
|
|
3174
|
+
inn: n.inDeg,
|
|
3175
|
+
out: n.outDeg,
|
|
3176
|
+
x: n.x,
|
|
3177
|
+
y: n.y,
|
|
3178
|
+
r: n.r,
|
|
3179
|
+
color: n.color
|
|
3180
|
+
})),
|
|
3181
|
+
edges: model.edges,
|
|
3182
|
+
symbols: model.symbols,
|
|
3183
|
+
symbolEdges: model.symbolEdges,
|
|
3184
|
+
clusters: model.clusterNames,
|
|
3185
|
+
modularity: model.modularity,
|
|
3186
|
+
truncated: {
|
|
3187
|
+
files: model.truncatedFiles,
|
|
3188
|
+
edges: model.truncatedEdges,
|
|
3189
|
+
symbols: model.truncatedSymbols
|
|
3190
|
+
}
|
|
3191
|
+
}).replace(/</g, "\\u003c");
|
|
3192
|
+
const truncNote = model.truncatedFiles > 0 || model.truncatedSymbols > 0 ? ` (+${model.truncatedFiles} files, +${model.truncatedSymbols} symbols beyond budget)` : "";
|
|
3193
|
+
const clusterNote = model.clusterNames.length > 0 ? ` \xB7 ${model.clusterNames.length} communities (Louvain Q=${model.modularity.toFixed(3)})` : "";
|
|
3194
|
+
return `<!DOCTYPE html>
|
|
3195
|
+
<html lang="en">
|
|
3196
|
+
<head>
|
|
3197
|
+
<meta charset="utf-8">
|
|
3198
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3199
|
+
<title>${esc(title)} \u2014 ScopeCairn graph explorer</title>
|
|
3200
|
+
<style>
|
|
3201
|
+
:root{--bg:#fafafa;--panel:#fff;--line:#ddd;--ink:#222;--mut:#666;--acc:#0969da}
|
|
3202
|
+
*{box-sizing:border-box}
|
|
3203
|
+
body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:var(--bg);color:var(--ink)}
|
|
3204
|
+
header{padding:10px 14px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:3}
|
|
3205
|
+
header h1{font-size:15px;margin:0 0 2px}
|
|
3206
|
+
header p{font-size:12px;color:var(--mut);margin:2px 0}
|
|
3207
|
+
#bar{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
|
|
3208
|
+
#bar input,#bar select{padding:5px 8px;font-size:13px;border:1px solid var(--line);border-radius:6px}
|
|
3209
|
+
#q{flex:1;min-width:160px;max-width:320px}
|
|
3210
|
+
.seg{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
3211
|
+
.seg button{border:0;background:#f3f4f6;padding:5px 10px;font-size:12.5px;cursor:pointer}
|
|
3212
|
+
.seg button.on{background:#1f2328;color:#fff}
|
|
3213
|
+
#main{display:flex;height:calc(100vh - 158px);min-height:420px}
|
|
3214
|
+
#wrap{flex:1;position:relative;overflow:hidden;cursor:grab;background:#fff}
|
|
3215
|
+
#cv{position:absolute;inset:0;width:100%;height:100%}
|
|
3216
|
+
#side{width:320px;max-width:38vw;overflow:auto;background:var(--panel);border-left:1px solid var(--line);padding:12px 14px;font-size:13px}
|
|
3217
|
+
#side h2{font-size:14px;margin:0 0 4px;word-break:break-all}
|
|
3218
|
+
#side .meta{color:var(--mut);font-size:12px;margin:0 0 8px}
|
|
3219
|
+
#side h3{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);margin:12px 0 4px}
|
|
3220
|
+
#side ul{margin:0;padding-left:18px}
|
|
3221
|
+
#side code{background:#f3f4f6;border-radius:4px;padding:0 4px}
|
|
3222
|
+
#tip{position:fixed;display:none;background:#111;color:#eee;font:12px ui-monospace,monospace;padding:6px 9px;border-radius:6px;pointer-events:none;white-space:pre;z-index:5;max-width:60vw}
|
|
3223
|
+
.hint{font-size:12px;color:var(--mut);padding:6px 14px}
|
|
3224
|
+
kbd{background:#f3f4f6;border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11px}
|
|
3225
|
+
</style>
|
|
3226
|
+
</head>
|
|
3227
|
+
<body>
|
|
3228
|
+
<header>
|
|
3229
|
+
<h1>${esc(title)} \u2014 ${model.nodes.length} files, ${model.symbols.length} symbols, ${model.edges.length} links${esc(truncNote)}</h1>
|
|
3230
|
+
<p>Generated by <code>scopecairn scan</code> \xB7 ${esc(generated)} \xB7 interactive Canvas explorer (offline, zero dependencies)${esc(clusterNote)}</p>
|
|
3231
|
+
<div id="bar">
|
|
3232
|
+
<div class="seg" role="tablist" aria-label="hierarchy">
|
|
3233
|
+
<button data-mode="cluster" class="on">Cluster</button><button data-mode="file">File</button><button data-mode="symbol">Symbol</button>
|
|
3234
|
+
</div>
|
|
3235
|
+
<input id="q" placeholder="live search: name, type, module\u2026" autocomplete="off">
|
|
3236
|
+
<select id="tf"><option value="">all types</option><option>function</option><option>class</option><option>method</option><option>route</option><option>model</option><option>component</option><option>interface</option><option>type</option></select>
|
|
3237
|
+
<button id="fit">fit</button>
|
|
3238
|
+
<span style="font-size:12px;color:var(--mut)">drag pan \xB7 wheel zoom \xB7 hover inspect \xB7 click details</span>
|
|
3239
|
+
</div>
|
|
3240
|
+
</header>
|
|
3241
|
+
<div class="hint" id="count"></div>
|
|
3242
|
+
<div id="main">
|
|
3243
|
+
<div id="wrap"><canvas id="cv"></canvas></div>
|
|
3244
|
+
<aside id="side"><h2>Graph explorer</h2><p class="meta">Klik node untuk melihat sumber singkat, pengimpor / yang dipanggil, dan skor centrality.</p><p class="meta">Toggle <kbd>Cluster</kbd>\u2194<kbd>File</kbd>\u2194<kbd>Symbol</kbd> untuk ganti hierarki tampilan.</p></aside>
|
|
3245
|
+
</div>
|
|
3246
|
+
<div id="tip"></div>
|
|
3247
|
+
<script>
|
|
3248
|
+
(function(){
|
|
3249
|
+
"use strict";
|
|
3250
|
+
var DATA=${payload};
|
|
3251
|
+
var files=DATA.files, fedges=DATA.edges, syms=DATA.symbols, sedges=DATA.symbolEdges;
|
|
3252
|
+
var byFileId={}, bySymId={};
|
|
3253
|
+
files.forEach(function(n){byFileId[n.id]=n;});
|
|
3254
|
+
syms.forEach(function(s){bySymId[s.id]=s;});
|
|
3255
|
+
// Cluster view: agregat file per komunitas Louvain (fallback modul folder).
|
|
3256
|
+
var clusters=[];
|
|
3257
|
+
(function(){
|
|
3258
|
+
var m={};
|
|
3259
|
+
files.forEach(function(n){
|
|
3260
|
+
var key=n.cluster>=0?("c"+n.cluster):("m:"+n.module);
|
|
3261
|
+
if(!m[key])m[key]={key:key,cluster:n.cluster,name:n.cluster>=0?n.clusterName:n.module,members:[],x:0,y:0,r:0};
|
|
3262
|
+
m[key].members.push(n);
|
|
3263
|
+
});
|
|
3264
|
+
Object.keys(m).forEach(function(k){
|
|
3265
|
+
var c=m[k],sx=0,sy=0;
|
|
3266
|
+
c.members.forEach(function(n){sx+=n.x;sy+=n.y;});
|
|
3267
|
+
c.x=sx/c.members.length;c.y=sy/c.members.length;
|
|
3268
|
+
c.r=Math.min(46,14+Math.sqrt(c.members.length)*6);
|
|
3269
|
+
clusters.push(c);
|
|
3270
|
+
});
|
|
3271
|
+
})();
|
|
3272
|
+
var cv=document.getElementById('cv'),ctx=cv.getContext('2d'),wrap=document.getElementById('wrap');
|
|
3273
|
+
var side=document.getElementById('side'),tip=document.getElementById('tip'),count=document.getElementById('count');
|
|
3274
|
+
var mode='cluster',query='',typeF='';
|
|
3275
|
+
var cam={x:0,y:0,scale:1},drag=null,hover=null,sel=null,dpr=1;
|
|
3276
|
+
function resize(){
|
|
3277
|
+
dpr=Math.min(2,window.devicePixelRatio||1);
|
|
3278
|
+
var r=wrap.getBoundingClientRect();
|
|
3279
|
+
cv.width=Math.max(1,r.width*dpr);cv.height=Math.max(1,r.height*dpr);
|
|
3280
|
+
draw();
|
|
3281
|
+
}
|
|
3282
|
+
window.addEventListener('resize',resize);
|
|
3283
|
+
function w2s(p){return [(p[0]-cam.x)*cam.scale*dpr,(p[1]-cam.y)*cam.scale*dpr];}
|
|
3284
|
+
function fit(){
|
|
3285
|
+
var pts=currentPoints();
|
|
3286
|
+
if(!pts.length){cam={x:0,y:0,scale:1};draw();return;}
|
|
3287
|
+
var xs=pts.map(function(p){return p.x;}),ys=pts.map(function(p){return p.y;});
|
|
3288
|
+
var x0=Math.min.apply(0,xs)-60,x1=Math.max.apply(0,xs)+60;
|
|
3289
|
+
var y0=Math.min.apply(0,ys)-60,y1=Math.max.apply(0,ys)+60;
|
|
3290
|
+
var r=wrap.getBoundingClientRect();
|
|
3291
|
+
var s=Math.min(r.width/(x1-x0),r.height/(y1-y0));
|
|
3292
|
+
s=Math.min(3,Math.max(.05,s));
|
|
3293
|
+
cam.scale=s;cam.x=x0-(r.width/s-(x1-x0))/2;cam.y=y0-(r.height/s-(y1-y0))/2;
|
|
3294
|
+
draw();
|
|
3295
|
+
}
|
|
3296
|
+
function match(n){
|
|
3297
|
+
if(typeF && (n.type||'')!==typeF)return false;
|
|
3298
|
+
if(!query)return true;
|
|
3299
|
+
var hay=((n.name||n.file||'')+' '+(n.type||'')+' '+(n.module||'')+' '+(n.clusterName||'')).toLowerCase();
|
|
3300
|
+
return hay.indexOf(query)>=0;
|
|
3301
|
+
}
|
|
3302
|
+
function currentPoints(){
|
|
3303
|
+
if(mode==='symbol')return syms.filter(match);
|
|
3304
|
+
if(mode==='file')return files.filter(match);
|
|
3305
|
+
return clusters;
|
|
3306
|
+
}
|
|
3307
|
+
function draw(){
|
|
3308
|
+
var r=wrap.getBoundingClientRect(),W=r.width*dpr,H=r.height*dpr;
|
|
3309
|
+
ctx.clearRect(0,0,W,H);
|
|
3310
|
+
ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);
|
|
3311
|
+
var s=cam.scale*dpr,ox=-cam.x*s,oy=-cam.y*s;
|
|
3312
|
+
function X(x){return x*s+ox;} function Y(y){return y*s+oy;}
|
|
3313
|
+
ctx.lineWidth=1;
|
|
3314
|
+
if(mode==='cluster'){
|
|
3315
|
+
clusters.forEach(function(c){
|
|
3316
|
+
ctx.beginPath();ctx.arc(X(c.x),Y(c.y),c.r*s,0,7);
|
|
3317
|
+
ctx.fillStyle='rgba(9,105,218,.10)';ctx.fill();
|
|
3318
|
+
ctx.fillStyle='#1f2328';ctx.font=(12*dpr)+'px system-ui';
|
|
3319
|
+
ctx.textAlign='center';
|
|
3320
|
+
ctx.fillText(c.name+' ('+c.members.length+')',X(c.x),Y(c.y)-c.r*s-6*dpr);
|
|
3321
|
+
});
|
|
3322
|
+
clusters.forEach(function(c){
|
|
3323
|
+
var sel2=(sel&&sel.kind==='cluster'&&sel.key===c.key);
|
|
3324
|
+
ctx.beginPath();ctx.arc(X(c.x),Y(c.y),Math.max(3,c.r*s),0,7);
|
|
3325
|
+
ctx.fillStyle=sel2?'#0969da':'#57606a';ctx.fill();
|
|
3326
|
+
if(sel2){ctx.strokeStyle='#0969da';ctx.lineWidth=3*dpr;ctx.stroke();}
|
|
3327
|
+
});
|
|
3328
|
+
}else if(mode==='file'){
|
|
3329
|
+
fedges.forEach(function(e){
|
|
3330
|
+
var a=byFileId[e.a],b=byFileId[e.b];
|
|
3331
|
+
if(!a||!b||!match(a)||!match(b))return;
|
|
3332
|
+
ctx.beginPath();ctx.moveTo(X(a.x),Y(a.y));ctx.lineTo(X(b.x),Y(b.y));
|
|
3333
|
+
ctx.strokeStyle=e.conf<0.8?'rgba(150,150,150,.45)':'rgba(120,120,120,.55)';
|
|
3334
|
+
ctx.setLineDash(e.conf<0.8?[5,4]:[]);
|
|
3335
|
+
ctx.lineWidth=Math.min(3,(0.5+e.n*0.4))*dpr*0.6;ctx.stroke();ctx.setLineDash([]);
|
|
3336
|
+
});
|
|
3337
|
+
var ranked=files.slice().sort(function(a,b){return (b.inn+b.out)-(a.inn+a.out);});
|
|
3338
|
+
var labeled={};ranked.slice(0,25).forEach(function(n){labeled[n.id]=1;});
|
|
3339
|
+
files.forEach(function(n){
|
|
3340
|
+
if(!match(n))return;
|
|
3341
|
+
var rad=Math.max(2.5,n.r*s),isH=hover&&hover.kind==='file'&&hover.id===n.id;
|
|
3342
|
+
var isS=sel&&sel.kind==='file'&&sel.id===n.id;
|
|
3343
|
+
ctx.beginPath();ctx.arc(X(n.x),Y(n.y),rad+(isH?2:0),0,7);
|
|
3344
|
+
ctx.fillStyle=n.color||'#57606a';ctx.fill();
|
|
3345
|
+
ctx.lineWidth=(isS?3:1.2)*dpr;ctx.strokeStyle=isS?'#0969da':'#1f2328';ctx.stroke();
|
|
3346
|
+
if(labeled[n.id]&&s>0.35){
|
|
3347
|
+
ctx.fillStyle='#24292f';ctx.font=(10.5*dpr)+'px ui-monospace,monospace';ctx.textAlign='left';
|
|
3348
|
+
var nm=n.file.split('/');var sh=nm.length>2?(nm[0]+'/\u2026/'+nm[nm.length-1]):n.file;
|
|
3349
|
+
ctx.fillText(sh,X(n.x)+rad+4*dpr,Y(n.y)+4*dpr);
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
}else{
|
|
3353
|
+
var se={};
|
|
3354
|
+
sedges.forEach(function(e){
|
|
3355
|
+
var a=bySymId[e.s],b=bySymId[e.t];
|
|
3356
|
+
if(!a||!b||!match(a)||!match(b))return;
|
|
3357
|
+
var k=e.s+':'+e.t;if(se[k])return;se[k]=1;
|
|
3358
|
+
ctx.beginPath();ctx.moveTo(X(a.x),Y(a.y));ctx.lineTo(X(b.x),Y(b.y));
|
|
3359
|
+
ctx.strokeStyle='rgba(120,120,120,.4)';ctx.lineWidth=1*dpr;ctx.stroke();
|
|
3360
|
+
});
|
|
3361
|
+
var top=syms.slice().sort(function(a,b){return b.pr-a.pr;});
|
|
3362
|
+
var lab={};top.slice(0,30).forEach(function(x){lab[x.id]=1;});
|
|
3363
|
+
syms.forEach(function(n){
|
|
3364
|
+
if(!match(n))return;
|
|
3365
|
+
var base=4+Math.min(9,Math.sqrt(n.inn+n.out)*2+(n.pr*400));
|
|
3366
|
+
var rad=Math.max(2.5,base*s),isH=hover&&hover.kind==='symbol'&&hover.id===n.id;
|
|
3367
|
+
var isS=sel&&sel.kind==='symbol'&&sel.id===n.id;
|
|
3368
|
+
ctx.beginPath();ctx.arc(X(n.x),Y(n.y),rad+(isH?2:0),0,7);
|
|
3369
|
+
ctx.fillStyle=isS?'#0969da':'#8250df';ctx.fill();
|
|
3370
|
+
ctx.lineWidth=1.2*dpr;ctx.strokeStyle='#1f2328';ctx.stroke();
|
|
3371
|
+
if(lab[n.id]&&s>0.5){
|
|
3372
|
+
ctx.fillStyle='#24292f';ctx.font=(10*dpr)+'px ui-monospace,monospace';ctx.textAlign='left';
|
|
3373
|
+
ctx.fillText(n.name.slice(0,32),X(n.x)+rad+4*dpr,Y(n.y)+4*dpr);
|
|
3374
|
+
}
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
var pts=currentPoints();
|
|
3378
|
+
count.textContent='showing '+pts.length+' / '+(mode==='symbol'?syms.length:mode==='file'?files.length:clusters.length)+' \xB7 '+mode+' view \xB7 scroll zoom \xB7 drag pan';
|
|
3379
|
+
}
|
|
3380
|
+
function pick(mx,my){
|
|
3381
|
+
var r=wrap.getBoundingClientRect(),s=cam.scale*dpr;
|
|
3382
|
+
var wx=(mx*dpr+cam.x*s)/s,wy=(my*dpr+cam.y*s)/s;
|
|
3383
|
+
var best=null,bd=1e12;
|
|
3384
|
+
currentPoints().forEach(function(n){
|
|
3385
|
+
var rad=(n.r||8)+8/s;
|
|
3386
|
+
var dx=n.x-wx,dy=n.y-wy,d=Math.sqrt(dx*dx+dy*dy);
|
|
3387
|
+
if(d<rad&&d<bd){bd=d;best=n;}
|
|
3388
|
+
});
|
|
3389
|
+
if(!best)return null;
|
|
3390
|
+
if(mode==='cluster')return {kind:'cluster',key:best.key};
|
|
3391
|
+
if(mode==='file')return {kind:'file',id:best.id};
|
|
3392
|
+
return {kind:'symbol',id:best.id};
|
|
3393
|
+
}
|
|
3394
|
+
function detailHTML(p){
|
|
3395
|
+
function li(a){return '<li><code>'+a.replace(/&/g,'&').replace(/</g,'<')+'</code></li>';}
|
|
3396
|
+
if(p.kind==='cluster'){
|
|
3397
|
+
var c=clusters.filter(function(x){return x.key===p.key;})[0];
|
|
3398
|
+
if(!c)return '';
|
|
3399
|
+
return '<h2>'+c.name+'</h2><p class="meta">community \xB7 '+c.members.length+' files</p><h3>Members</h3><ul>'+
|
|
3400
|
+
c.members.slice(0,20).map(function(n){return li(n.file);}).join('')+'</ul>'+
|
|
3401
|
+
(c.members.length>20?'<p class="meta">+'+(c.members.length-20)+' more</p>':'');
|
|
3402
|
+
}
|
|
3403
|
+
if(p.kind==='file'){
|
|
3404
|
+
var n=byFileId[p.id];if(!n)return '';
|
|
3405
|
+
var nbrs=fedges.filter(function(e){return e.a===n.id||e.b===n.id;}).slice(0,12);
|
|
3406
|
+
return '<h2>'+n.file+'</h2><p class="meta">'+n.lang+' \xB7 '+n.symbols+' symbols \xB7 in '+n.inn+' / out '+n.out+
|
|
3407
|
+
(n.cluster>=0?' \xB7 '+n.clusterName:'')+(n.pr?' \xB7 pr='+n.pr.toFixed(4):'')+'</p><h3>Links</h3><ul>'+
|
|
3408
|
+
nbrs.map(function(e){var o=e.a===n.id?byFileId[e.b]:byFileId[e.a];return li((o?o.file:e.a===n.id?e.b:e.a)+' ('+e.rel+' \xD7'+e.n+')');}).join('')+'</ul>';
|
|
3409
|
+
}
|
|
3410
|
+
var x=bySymId[p.id];if(!x)return '';
|
|
3411
|
+
return '<h2>'+x.name+'</h2><p class="meta">'+x.type+' \xB7 '+x.file+(x.cluster>=0?' \xB7 cluster '+x.cluster:'')+
|
|
3412
|
+
' \xB7 pr='+(x.pr||0).toFixed(4)+' \xB7 in '+x.inn+' / out '+x.out+'</p>'+
|
|
3413
|
+
'<h3>Callers / importers</h3><ul>'+(x.callers.slice(0,8).map(li).join('')||'<li>\u2014</li>')+'</ul>'+
|
|
3414
|
+
'<h3>Calls / depends on</h3><ul>'+(x.callees.slice(0,8).map(li).join('')||'<li>\u2014</li>')+'</ul>';
|
|
3415
|
+
}
|
|
3416
|
+
wrap.addEventListener('mousedown',function(e){drag={x:e.clientX-cam.x*cam.scale,y:e.clientY-cam.y*cam.scale};wrap.style.cursor='grabbing';});
|
|
3417
|
+
window.addEventListener('mouseup',function(){drag=null;wrap.style.cursor='grab';});
|
|
3418
|
+
window.addEventListener('mousemove',function(e){
|
|
3419
|
+
var r=wrap.getBoundingClientRect();
|
|
3420
|
+
if(drag){
|
|
3421
|
+
cam.x=(e.clientX-drag.x)/cam.scale;cam.y=(e.clientY-drag.y)/cam.scale;draw();tip.style.display='none';return;
|
|
3422
|
+
}
|
|
3423
|
+
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom){tip.style.display='none';hover=null;return;}
|
|
3424
|
+
var p=pick(e.clientX-r.left,e.clientY-r.top);
|
|
3425
|
+
hover=p;
|
|
3426
|
+
if(p){
|
|
3427
|
+
var t=p.kind==='cluster'?p.key:(p.kind==='file'?byFileId[p.id].file:bySymId[p.id].name+' ('+bySymId[p.id].type+', '+bySymId[p.id].file+')');
|
|
3428
|
+
tip.textContent=t;tip.style.display='block';tip.style.left=(e.clientX+12)+'px';tip.style.top=(e.clientY+12)+'px';
|
|
3429
|
+
cv.style.cursor='pointer';
|
|
3430
|
+
}else{tip.style.display='none';cv.style.cursor='default';}
|
|
3431
|
+
draw();
|
|
3432
|
+
});
|
|
3433
|
+
wrap.addEventListener('click',function(e){
|
|
3434
|
+
if(drag&&Math.abs(e.clientX-drag.x)>4)return;
|
|
3435
|
+
var r=wrap.getBoundingClientRect();
|
|
3436
|
+
var p=pick(e.clientX-r.left,e.clientY-r.top);
|
|
3437
|
+
sel=p;draw();
|
|
3438
|
+
if(p)side.innerHTML=detailHTML(p);
|
|
3439
|
+
});
|
|
3440
|
+
wrap.addEventListener('wheel',function(e){
|
|
3441
|
+
e.preventDefault();
|
|
3442
|
+
var f=e.deltaY<0?1.12:0.89;
|
|
3443
|
+
cam.scale=Math.min(8,Math.max(.05,cam.scale*f));draw();
|
|
3444
|
+
},{passive:false});
|
|
3445
|
+
document.querySelectorAll('.seg button').forEach(function(b){
|
|
3446
|
+
b.addEventListener('click',function(){
|
|
3447
|
+
document.querySelectorAll('.seg button').forEach(function(x){x.classList.remove('on');});
|
|
3448
|
+
b.classList.add('on');mode=b.dataset.mode;sel=null;fit();
|
|
3449
|
+
});
|
|
3450
|
+
});
|
|
3451
|
+
var q=document.getElementById('q');
|
|
3452
|
+
q.addEventListener('input',function(){query=q.value.toLowerCase();draw();});
|
|
3453
|
+
var tf=document.getElementById('tf');
|
|
3454
|
+
tf.addEventListener('change',function(){typeF=tf.value;if(mode!=='symbol'&&typeF)mode='symbol';
|
|
3455
|
+
document.querySelectorAll('.seg button').forEach(function(x){x.classList.toggle('on',x.dataset.mode===mode);});
|
|
3456
|
+
draw();});
|
|
3457
|
+
document.getElementById('fit').addEventListener('click',fit);
|
|
3458
|
+
resize();fit();
|
|
3459
|
+
})();
|
|
3460
|
+
</script>
|
|
3461
|
+
</body>
|
|
3462
|
+
</html>`;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// src/scanner.ts
|
|
3466
|
+
function toPosixRel(repoRoot2, abs) {
|
|
3467
|
+
return path14.relative(repoRoot2, abs).split(path14.sep).join("/");
|
|
3468
|
+
}
|
|
3469
|
+
function walk(absDir, out) {
|
|
3470
|
+
const entries = fs13.readdirSync(absDir, { withFileTypes: true });
|
|
3471
|
+
for (const e of entries) {
|
|
3472
|
+
const abs = path14.join(absDir, e.name);
|
|
3473
|
+
if (e.isDirectory()) {
|
|
3474
|
+
if (e.isSymbolicLink()) continue;
|
|
3475
|
+
walk(abs, out);
|
|
3476
|
+
} else if (e.isFile()) {
|
|
3477
|
+
out.push(abs);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
function scanRepository(repoRoot2) {
|
|
3482
|
+
const started = Date.now();
|
|
3483
|
+
const rules = loadIgnoreRules(repoRoot2);
|
|
3484
|
+
const db = openDb(repoRoot2);
|
|
3485
|
+
ensureDefaultFiles(repoRoot2);
|
|
3486
|
+
try {
|
|
3487
|
+
const allAbs = [];
|
|
3488
|
+
walk(repoRoot2, allAbs);
|
|
3489
|
+
let totalFilesSeen = 0;
|
|
3490
|
+
let inserted = 0;
|
|
3491
|
+
let updated = 0;
|
|
3492
|
+
let unchanged = 0;
|
|
3493
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
3494
|
+
const getStmt = db.prepare(`SELECT id, hash FROM files WHERE path = ?`);
|
|
3495
|
+
const insertStmt = db.prepare(
|
|
3496
|
+
`INSERT INTO files(path, language, hash, size) VALUES (?, ?, ?, ?)`
|
|
3497
|
+
);
|
|
3498
|
+
const updateStmt = db.prepare(
|
|
3499
|
+
`UPDATE files SET hash = ?, size = ?, language = ?, updated_at = datetime('now') WHERE path = ?`
|
|
3500
|
+
);
|
|
3501
|
+
let sourceFiles = 0;
|
|
3502
|
+
let metaFiles = 0;
|
|
3503
|
+
const protectedPatterns = loadProtectedPatterns(repoRoot2);
|
|
3504
|
+
const backfillDone = db.prepare(`SELECT value FROM meta WHERE key = 'backfill_done'`).get();
|
|
3505
|
+
const symbolCount = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
3506
|
+
let ftsCount = 0;
|
|
3507
|
+
try {
|
|
3508
|
+
ftsCount = db.prepare(`SELECT COUNT(*) AS n FROM symbol_index`).get().n;
|
|
3509
|
+
} catch {
|
|
3510
|
+
ftsCount = 0;
|
|
3511
|
+
}
|
|
3512
|
+
const backfill = !backfillDone && (symbolCount === 0 || ftsCount === 0);
|
|
3513
|
+
const pending = [];
|
|
3514
|
+
const metaChanged = [];
|
|
3515
|
+
for (const abs of allAbs) {
|
|
3516
|
+
const rel = toPosixRel(repoRoot2, abs);
|
|
3517
|
+
if (isIgnored(rel, rules)) continue;
|
|
3518
|
+
totalFilesSeen++;
|
|
3519
|
+
const source = isSourceFile(abs);
|
|
3520
|
+
const meta = !source && isIndexableMetaFile(rel, protectedPatterns);
|
|
3521
|
+
if (!source && !meta) continue;
|
|
3522
|
+
if (source) sourceFiles++;
|
|
3523
|
+
else metaFiles++;
|
|
3524
|
+
seenPaths.add(rel);
|
|
3525
|
+
let buf;
|
|
3526
|
+
try {
|
|
3527
|
+
buf = fs13.readFileSync(abs);
|
|
3528
|
+
} catch {
|
|
1780
3529
|
continue;
|
|
1781
3530
|
}
|
|
1782
3531
|
const hash = hashContent(buf);
|
|
@@ -1829,8 +3578,30 @@ function scanRepository(repoRoot2) {
|
|
|
1829
3578
|
`INSERT INTO meta(key, value) VALUES ('last_scan', datetime('now'))
|
|
1830
3579
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
1831
3580
|
).run();
|
|
3581
|
+
db.prepare(
|
|
3582
|
+
`INSERT INTO meta(key, value) VALUES ('backfill_done', '1')
|
|
3583
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
3584
|
+
).run();
|
|
1832
3585
|
linkTests(db);
|
|
1833
3586
|
cleanupIndex(db);
|
|
3587
|
+
const graphDirty = inserted + updated + removed > 0 || adapterRun.symbols > 0 || adapterRun.relations > 0;
|
|
3588
|
+
const graphWritten = writeGraphMdIfChanged(db, repoRoot2, graphDirty);
|
|
3589
|
+
let visualWritten = false;
|
|
3590
|
+
if (graphDirty) {
|
|
3591
|
+
try {
|
|
3592
|
+
const model = buildVisualModel(db);
|
|
3593
|
+
const html = renderHtml(
|
|
3594
|
+
model,
|
|
3595
|
+
path14.basename(path14.resolve(repoRoot2)),
|
|
3596
|
+
(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " ")
|
|
3597
|
+
);
|
|
3598
|
+
fs13.mkdirSync(dataDir(repoRoot2), { recursive: true });
|
|
3599
|
+
fs13.writeFileSync(path14.join(dataDir(repoRoot2), GRAPH_HTML_NAME), html);
|
|
3600
|
+
visualWritten = true;
|
|
3601
|
+
} catch {
|
|
3602
|
+
visualWritten = false;
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
1834
3605
|
const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
1835
3606
|
const relationships = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
|
|
1836
3607
|
return {
|
|
@@ -1841,6 +3612,8 @@ function scanRepository(repoRoot2) {
|
|
|
1841
3612
|
symbols,
|
|
1842
3613
|
relationships,
|
|
1843
3614
|
adapters: adapterRun.adapters,
|
|
3615
|
+
graphWritten,
|
|
3616
|
+
visualWritten,
|
|
1844
3617
|
inserted,
|
|
1845
3618
|
updated,
|
|
1846
3619
|
unchanged,
|
|
@@ -1867,6 +3640,12 @@ function cmdScan(repoRoot2) {
|
|
|
1867
3640
|
if (stats.adapters.length > 0) {
|
|
1868
3641
|
console.log(`\u2713 adapters: ${stats.adapters.join(", ")}`);
|
|
1869
3642
|
}
|
|
3643
|
+
if (stats.graphWritten) {
|
|
3644
|
+
console.log(`\u2713 GRAPH.md updated (.scopecairn/GRAPH.md)`);
|
|
3645
|
+
}
|
|
3646
|
+
if (stats.visualWritten) {
|
|
3647
|
+
console.log(`\u2713 GRAPH.html updated (.scopecairn/GRAPH.html \u2014 open in browser)`);
|
|
3648
|
+
}
|
|
1870
3649
|
console.log(
|
|
1871
3650
|
`\u2713 index: +${stats.inserted} new, ~${stats.updated} updated, =${stats.unchanged} unchanged, -${stats.removed} removed`
|
|
1872
3651
|
);
|
|
@@ -1875,11 +3654,11 @@ function cmdScan(repoRoot2) {
|
|
|
1875
3654
|
}
|
|
1876
3655
|
|
|
1877
3656
|
// src/commands/status.ts
|
|
1878
|
-
import
|
|
1879
|
-
import
|
|
3657
|
+
import fs14 from "fs";
|
|
3658
|
+
import path15 from "path";
|
|
1880
3659
|
function cmdStatus(repoRoot2) {
|
|
1881
3660
|
const dir = dataDir(repoRoot2);
|
|
1882
|
-
if (!
|
|
3661
|
+
if (!fs14.existsSync(path15.join(dir, "scopecairn.db"))) {
|
|
1883
3662
|
console.log("ScopeCairn");
|
|
1884
3663
|
console.log("Status: NOT INITIALIZED (run `scopecairn scan`)");
|
|
1885
3664
|
return;
|
|
@@ -1931,17 +3710,17 @@ function cmdStatus(repoRoot2) {
|
|
|
1931
3710
|
}
|
|
1932
3711
|
|
|
1933
3712
|
// src/commands/rebuild.ts
|
|
1934
|
-
import
|
|
3713
|
+
import fs15 from "fs";
|
|
1935
3714
|
function cmdRebuild(repoRoot2) {
|
|
1936
3715
|
const dir = dataDir(repoRoot2);
|
|
1937
|
-
|
|
3716
|
+
fs15.rmSync(dir, { recursive: true, force: true });
|
|
1938
3717
|
console.log(`Cleaned ${dir}`);
|
|
1939
3718
|
cmdScan(repoRoot2);
|
|
1940
3719
|
}
|
|
1941
3720
|
|
|
1942
3721
|
// src/commands/doctor.ts
|
|
1943
|
-
import
|
|
1944
|
-
import
|
|
3722
|
+
import fs17 from "fs";
|
|
3723
|
+
import path17 from "path";
|
|
1945
3724
|
|
|
1946
3725
|
// src/treesitter.ts
|
|
1947
3726
|
var cached = null;
|
|
@@ -1965,19 +3744,19 @@ async function checkTreeSitter() {
|
|
|
1965
3744
|
}
|
|
1966
3745
|
}
|
|
1967
3746
|
|
|
1968
|
-
// src/integrate/detect.ts
|
|
1969
|
-
import
|
|
1970
|
-
import
|
|
3747
|
+
// src/integrate/detect.ts
|
|
3748
|
+
import fs16 from "fs";
|
|
3749
|
+
import path16 from "path";
|
|
1971
3750
|
function detectAgentDir(repoRoot2) {
|
|
1972
|
-
const plural =
|
|
1973
|
-
const singular =
|
|
3751
|
+
const plural = fs16.existsSync(path16.join(repoRoot2, ".agents"));
|
|
3752
|
+
const singular = fs16.existsSync(path16.join(repoRoot2, ".agent"));
|
|
1974
3753
|
if (singular && !plural) return ".agent";
|
|
1975
3754
|
return ".agents";
|
|
1976
3755
|
}
|
|
1977
3756
|
function detectPrefix(repoRoot2) {
|
|
1978
3757
|
try {
|
|
1979
3758
|
const pkg = JSON.parse(
|
|
1980
|
-
|
|
3759
|
+
fs16.readFileSync(path16.join(repoRoot2, "package.json"), "utf8")
|
|
1981
3760
|
);
|
|
1982
3761
|
if (pkg.dependencies?.["scopecairn"] || pkg.devDependencies?.["scopecairn"]) {
|
|
1983
3762
|
return "npx scopecairn";
|
|
@@ -2197,7 +3976,7 @@ function parseAgentsOption(raw) {
|
|
|
2197
3976
|
function tick(ok) {
|
|
2198
3977
|
return ok ? "\u2713" : "\u2717";
|
|
2199
3978
|
}
|
|
2200
|
-
async function cmdDoctor(repoRoot2) {
|
|
3979
|
+
async function cmdDoctor(repoRoot2, opts) {
|
|
2201
3980
|
const nodeOk = Number(process.versions.node.split(".")[0]) >= 20;
|
|
2202
3981
|
console.log(`${tick(nodeOk)} Node.js ${process.version} (need >=20)`);
|
|
2203
3982
|
let sqliteOk = false;
|
|
@@ -2206,8 +3985,10 @@ async function cmdDoctor(repoRoot2) {
|
|
|
2206
3985
|
const req = createRequire2(import.meta.url);
|
|
2207
3986
|
const m = req("node:sqlite");
|
|
2208
3987
|
sqliteOk = typeof m.DatabaseSync === "function";
|
|
2209
|
-
} catch {
|
|
3988
|
+
} catch (err) {
|
|
2210
3989
|
sqliteOk = false;
|
|
3990
|
+
logError("doctor", err, repoRoot2);
|
|
3991
|
+
if (opts?.verbose) console.error(err);
|
|
2211
3992
|
}
|
|
2212
3993
|
console.log(`${tick(sqliteOk)} SQLite (node:sqlite built-in)`);
|
|
2213
3994
|
const ts = await checkTreeSitter();
|
|
@@ -2220,22 +4001,67 @@ async function cmdDoctor(repoRoot2) {
|
|
|
2220
4001
|
const s = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
2221
4002
|
const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
|
|
2222
4003
|
db.close();
|
|
2223
|
-
dbOk =
|
|
4004
|
+
dbOk = fs17.existsSync(dbPath(repoRoot2));
|
|
2224
4005
|
graphDetail = ` (${s} symbols, ${r} rels)`;
|
|
2225
|
-
} catch {
|
|
4006
|
+
} catch (err) {
|
|
2226
4007
|
dbOk = false;
|
|
4008
|
+
logError("doctor", err, repoRoot2);
|
|
4009
|
+
if (opts?.verbose) console.error(err);
|
|
2227
4010
|
}
|
|
2228
4011
|
console.log(`${tick(dbOk)} Database ${dbPath(repoRoot2)}${graphDetail}`);
|
|
2229
|
-
|
|
2230
|
-
|
|
4012
|
+
try {
|
|
4013
|
+
const db = openDb(repoRoot2);
|
|
4014
|
+
try {
|
|
4015
|
+
const { refreshCycles, getCycles, describeCycle } = await import("./cycles-6KSI4OPZ.js");
|
|
4016
|
+
const { ensureMetrics: ensureMetrics2, topByPageRank } = await import("./metrics-BLUHSM64.js");
|
|
4017
|
+
const cycles = getCycles(db, 5);
|
|
4018
|
+
if (cycles.length === 0) {
|
|
4019
|
+
try {
|
|
4020
|
+
refreshCycles(db);
|
|
4021
|
+
} catch (err) {
|
|
4022
|
+
logError("doctor", err, repoRoot2);
|
|
4023
|
+
if (opts?.verbose) console.error(err);
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
const found = getCycles(db, 5);
|
|
4027
|
+
console.log(`${tick(found.length === 0)} Circular dependencies (${found.length} cycle(s))`);
|
|
4028
|
+
for (const c of found.slice(0, 3)) {
|
|
4029
|
+
console.log(` ~ cycle[${c.length}]: ${describeCycle(db, c)}`);
|
|
4030
|
+
}
|
|
4031
|
+
ensureMetrics2(db);
|
|
4032
|
+
const gods = topByPageRank(db, 3);
|
|
4033
|
+
if (gods.length > 0) {
|
|
4034
|
+
console.log(`i Critical hotspots (PageRank):`);
|
|
4035
|
+
for (const g of gods) {
|
|
4036
|
+
console.log(
|
|
4037
|
+
` ! ${g.name} (${g.type}, ${g.file}) pr=${g.pagerank.toFixed(4)} in=${g.inDegree} out=${g.outDegree}`
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
} finally {
|
|
4042
|
+
db.close();
|
|
4043
|
+
}
|
|
4044
|
+
} catch (err) {
|
|
4045
|
+
logError("doctor", err, repoRoot2);
|
|
4046
|
+
if (opts?.verbose) console.error(err);
|
|
4047
|
+
console.log(`i Graph metrics unavailable (run \`scopecairn scan\` first)`);
|
|
4048
|
+
}
|
|
4049
|
+
const protPath = path17.join(dataDir(repoRoot2), "protected.yml");
|
|
4050
|
+
console.log(`${tick(fs17.existsSync(protPath))} Protected ${protPath}`);
|
|
2231
4051
|
const expectedPrefix = detectPrefix(repoRoot2);
|
|
2232
4052
|
const agentDir = detectAgentDir(repoRoot2);
|
|
2233
4053
|
let prefixOk = true;
|
|
2234
4054
|
let matrixOk = 0;
|
|
2235
4055
|
for (const t of AGENT_MATRIX) {
|
|
2236
|
-
const abs =
|
|
2237
|
-
if (!
|
|
2238
|
-
|
|
4056
|
+
const abs = path17.join(repoRoot2, t.rel);
|
|
4057
|
+
if (!fs17.existsSync(abs)) continue;
|
|
4058
|
+
let text;
|
|
4059
|
+
try {
|
|
4060
|
+
text = fs17.readFileSync(abs, "utf8");
|
|
4061
|
+
} catch {
|
|
4062
|
+
console.log(`\u2717 Tidak bisa membaca ${t.rel} \u2014 dilewati.`);
|
|
4063
|
+
continue;
|
|
4064
|
+
}
|
|
2239
4065
|
if (t.mode === "merge" && !text.includes(MARKER)) continue;
|
|
2240
4066
|
matrixOk++;
|
|
2241
4067
|
const used = extractPrefixUsed(text);
|
|
@@ -2252,11 +4078,11 @@ async function cmdDoctor(repoRoot2) {
|
|
|
2252
4078
|
} else if (prefixOk) {
|
|
2253
4079
|
console.log(`\u2713 Prefix konsisten (\`${expectedPrefix} context\`)`);
|
|
2254
4080
|
}
|
|
2255
|
-
const skillPath =
|
|
2256
|
-
const skillOk =
|
|
4081
|
+
const skillPath = path17.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
|
|
4082
|
+
const skillOk = fs17.existsSync(skillPath);
|
|
2257
4083
|
console.log(`${tick(skillOk)} Skill (${agentDir}/skills/scopecairn/SKILL.md)`);
|
|
2258
|
-
const wfPath =
|
|
2259
|
-
console.log(`${tick(
|
|
4084
|
+
const wfPath = path17.join(repoRoot2, "workflows", "scopecairn.md");
|
|
4085
|
+
console.log(`${tick(fs17.existsSync(wfPath))} Workflow (workflows/scopecairn.md, opsional)`);
|
|
2260
4086
|
console.log(`i Strict Mode mengabaikan allowlist \u2014 semua perintah tetap perlu persetujuan.`);
|
|
2261
4087
|
const healthy = nodeOk && sqliteOk && ts.ok && dbOk && (matrixOk > 0 || skillOk) && prefixOk;
|
|
2262
4088
|
console.log(`Status: ${healthy ? "HEALTHY" : "DEGRADED (see \u2717 above)"}`);
|
|
@@ -2327,6 +4153,71 @@ function neighbors(db, id, limit = 50) {
|
|
|
2327
4153
|
}))
|
|
2328
4154
|
];
|
|
2329
4155
|
}
|
|
4156
|
+
function findShortestPath(db, fromId, toId, maxDepth = 8, fanout = 200) {
|
|
4157
|
+
if (fromId === toId) return [];
|
|
4158
|
+
const prev = /* @__PURE__ */ new Map();
|
|
4159
|
+
const seen = /* @__PURE__ */ new Set([fromId]);
|
|
4160
|
+
let frontier = [fromId];
|
|
4161
|
+
let found = false;
|
|
4162
|
+
for (let d = 0; d < maxDepth && frontier.length > 0; d++) {
|
|
4163
|
+
const next = [];
|
|
4164
|
+
for (const cur2 of frontier) {
|
|
4165
|
+
let rows = [];
|
|
4166
|
+
try {
|
|
4167
|
+
rows = db.prepare(
|
|
4168
|
+
`SELECT target_id AS t, relationship_type AS rel FROM relationships
|
|
4169
|
+
WHERE source_id = ? LIMIT ${Math.max(1, Math.min(1e3, fanout))}`
|
|
4170
|
+
).all(cur2);
|
|
4171
|
+
} catch {
|
|
4172
|
+
rows = [];
|
|
4173
|
+
}
|
|
4174
|
+
for (const r of rows) {
|
|
4175
|
+
if (seen.has(r.t)) continue;
|
|
4176
|
+
seen.add(r.t);
|
|
4177
|
+
prev.set(r.t, { p: cur2, rel: r.rel });
|
|
4178
|
+
if (r.t === toId) {
|
|
4179
|
+
found = true;
|
|
4180
|
+
break;
|
|
4181
|
+
}
|
|
4182
|
+
next.push(r.t);
|
|
4183
|
+
}
|
|
4184
|
+
if (found) break;
|
|
4185
|
+
}
|
|
4186
|
+
if (found) break;
|
|
4187
|
+
frontier = next;
|
|
4188
|
+
}
|
|
4189
|
+
if (!found) return null;
|
|
4190
|
+
const chain = [toId];
|
|
4191
|
+
let cur = toId;
|
|
4192
|
+
while (cur !== fromId) {
|
|
4193
|
+
const step = prev.get(cur);
|
|
4194
|
+
if (!step) return null;
|
|
4195
|
+
chain.push(step.p);
|
|
4196
|
+
cur = step.p;
|
|
4197
|
+
}
|
|
4198
|
+
chain.reverse();
|
|
4199
|
+
const info = /* @__PURE__ */ new Map();
|
|
4200
|
+
try {
|
|
4201
|
+
const rows = db.prepare(
|
|
4202
|
+
`SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
|
|
4203
|
+
JOIN files f ON f.id = s.file_id`
|
|
4204
|
+
).all();
|
|
4205
|
+
for (const r of rows) {
|
|
4206
|
+
const n = asNode(r);
|
|
4207
|
+
if (chain.includes(n.id)) info.set(n.id, n);
|
|
4208
|
+
}
|
|
4209
|
+
} catch {
|
|
4210
|
+
return null;
|
|
4211
|
+
}
|
|
4212
|
+
const hops = [];
|
|
4213
|
+
for (let i = 0; i < chain.length - 1; i++) {
|
|
4214
|
+
const a = info.get(chain[i]);
|
|
4215
|
+
const b = info.get(chain[i + 1]);
|
|
4216
|
+
if (!a || !b) return null;
|
|
4217
|
+
hops.push({ from: a, rel: prev.get(chain[i + 1]).rel, to: b });
|
|
4218
|
+
}
|
|
4219
|
+
return hops;
|
|
4220
|
+
}
|
|
2330
4221
|
|
|
2331
4222
|
// src/commands/graph.ts
|
|
2332
4223
|
function cmdGraph(repoRoot2, key) {
|
|
@@ -2348,6 +4239,21 @@ function cmdGraph(repoRoot2, key) {
|
|
|
2348
4239
|
console.log("");
|
|
2349
4240
|
}
|
|
2350
4241
|
const edges = neighbors(db, node.id);
|
|
4242
|
+
try {
|
|
4243
|
+
ensureMetrics(db);
|
|
4244
|
+
ensureClusters(db);
|
|
4245
|
+
const m = getMetrics(db, node.id);
|
|
4246
|
+
const c = getCluster(db, node.id);
|
|
4247
|
+
const parts = [];
|
|
4248
|
+
if (m) parts.push(`pr=${m.pagerank.toFixed(4)} btw=${m.betweenness.toFixed(4)} in=${m.inDegree} out=${m.outDegree}`);
|
|
4249
|
+
if (c) parts.push(`community=${c.clusterName}`);
|
|
4250
|
+
try {
|
|
4251
|
+
if (nodeInCycle(db, node.id)) parts.push(`IN CYCLE`);
|
|
4252
|
+
} catch {
|
|
4253
|
+
}
|
|
4254
|
+
if (parts.length > 0) console.log(`Metrics: ${parts.join(" \xB7 ")}`);
|
|
4255
|
+
} catch {
|
|
4256
|
+
}
|
|
2351
4257
|
if (edges.length === 0) {
|
|
2352
4258
|
console.log("(no relations)");
|
|
2353
4259
|
return;
|
|
@@ -2375,9 +4281,82 @@ function cmdGraph(repoRoot2, key) {
|
|
|
2375
4281
|
}
|
|
2376
4282
|
}
|
|
2377
4283
|
|
|
4284
|
+
// src/commands/path.ts
|
|
4285
|
+
function cmdPath(repoRoot2, from, to) {
|
|
4286
|
+
const db = openDb(repoRoot2);
|
|
4287
|
+
try {
|
|
4288
|
+
logInvocation(db, "path");
|
|
4289
|
+
const aHits = findSymbols(db, from);
|
|
4290
|
+
const bHits = findSymbols(db, to);
|
|
4291
|
+
if (aHits.length === 0) {
|
|
4292
|
+
console.log(`No symbol or file matching "${from}".`);
|
|
4293
|
+
return;
|
|
4294
|
+
}
|
|
4295
|
+
if (bHits.length === 0) {
|
|
4296
|
+
console.log(`No symbol or file matching "${to}".`);
|
|
4297
|
+
return;
|
|
4298
|
+
}
|
|
4299
|
+
const a = aHits.find((h) => h.name === from) ?? aHits[0];
|
|
4300
|
+
const b = bHits.find((h) => h.name === to) ?? bHits[0];
|
|
4301
|
+
const hops = findShortestPath(db, a.id, b.id);
|
|
4302
|
+
if (!hops) {
|
|
4303
|
+
console.log(`No path from "${a.name}" to "${b.name}" (max 8 hops).`);
|
|
4304
|
+
if (aHits.length > 1 || bHits.length > 1) {
|
|
4305
|
+
console.log(
|
|
4306
|
+
`From candidates: ${aHits.slice(0, 5).map((h) => `${h.name} (${h.file})`).join(" | ")}`
|
|
4307
|
+
);
|
|
4308
|
+
console.log(
|
|
4309
|
+
`To candidates: ${bHits.slice(0, 5).map((h) => `${h.name} (${h.file})`).join(" | ")}`
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
if (hops.length === 0) {
|
|
4315
|
+
console.log(`"${a.name}" and "${b.name}" resolve to the same symbol.`);
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
console.log(`Path found (${hops.length} hop${hops.length === 1 ? "" : "s"}):`);
|
|
4319
|
+
hops.forEach((h, i) => {
|
|
4320
|
+
console.log(`[${i + 1}] ${h.from.name} (${h.from.type}, ${h.from.file})`);
|
|
4321
|
+
console.log(` \u2514\u2500(${h.rel})\u2500> ${h.to.name} (${h.to.type}, ${h.to.file})`);
|
|
4322
|
+
});
|
|
4323
|
+
} finally {
|
|
4324
|
+
db.close();
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
// src/commands/export.ts
|
|
4329
|
+
import fs18 from "fs";
|
|
4330
|
+
import path18 from "path";
|
|
4331
|
+
var FORMATS = ["mermaid", "graphml", "dot", "json"];
|
|
4332
|
+
var EXT = {
|
|
4333
|
+
mermaid: "mmd",
|
|
4334
|
+
graphml: "graphml",
|
|
4335
|
+
dot: "dot",
|
|
4336
|
+
json: "json"
|
|
4337
|
+
};
|
|
4338
|
+
function cmdExport(repoRoot2, format, out) {
|
|
4339
|
+
const fmt = format.toLowerCase();
|
|
4340
|
+
if (!FORMATS.includes(fmt)) {
|
|
4341
|
+
console.log(`Unknown format "${format}". Choose: ${FORMATS.join(", ")}.`);
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
const db = openDb(repoRoot2);
|
|
4345
|
+
try {
|
|
4346
|
+
logInvocation(db, "export");
|
|
4347
|
+
const text = exportGraph(db, fmt);
|
|
4348
|
+
const dest = out ?? path18.join(dataDir(repoRoot2), `graph.${EXT[fmt]}`);
|
|
4349
|
+
fs18.mkdirSync(path18.dirname(dest), { recursive: true });
|
|
4350
|
+
fs18.writeFileSync(dest, text);
|
|
4351
|
+
console.log(`Exported ${fmt} \u2192 ${dest}`);
|
|
4352
|
+
} finally {
|
|
4353
|
+
db.close();
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
|
|
2378
4357
|
// src/commands/read.ts
|
|
2379
|
-
import
|
|
2380
|
-
import
|
|
4358
|
+
import fs19 from "fs";
|
|
4359
|
+
import path19 from "path";
|
|
2381
4360
|
function cmdRead(repoRoot2, kind, name) {
|
|
2382
4361
|
if (kind !== "symbol") {
|
|
2383
4362
|
console.log(`Unknown read kind "${kind}". Usage: scopecairn read symbol <nama>`);
|
|
@@ -2393,15 +4372,15 @@ function cmdRead(repoRoot2, kind, name) {
|
|
|
2393
4372
|
return;
|
|
2394
4373
|
}
|
|
2395
4374
|
const row = db.prepare(`SELECT start_line, end_line FROM symbols WHERE id = ?`).get(node.id);
|
|
2396
|
-
const abs =
|
|
4375
|
+
const abs = path19.join(repoRoot2, node.file);
|
|
2397
4376
|
let lines;
|
|
2398
4377
|
try {
|
|
2399
|
-
lines =
|
|
4378
|
+
lines = fs19.readFileSync(abs, "utf8").split("\n");
|
|
2400
4379
|
} catch {
|
|
2401
4380
|
console.log(`Cannot read ${node.file}.`);
|
|
2402
4381
|
return;
|
|
2403
4382
|
}
|
|
2404
|
-
const start =
|
|
4383
|
+
const start = row.start_line;
|
|
2405
4384
|
const end = Math.min(lines.length, Math.max(row.end_line, row.start_line + 40));
|
|
2406
4385
|
console.log(`# ${node.name} (${node.type}) \u2014 ${node.file}:${start}-${end}`);
|
|
2407
4386
|
console.log(lines.slice(start - 1, end).join("\n"));
|
|
@@ -2617,14 +4596,14 @@ function retrieve(db, repoRoot2, task, opts) {
|
|
|
2617
4596
|
|
|
2618
4597
|
// src/retrieval/classify.ts
|
|
2619
4598
|
var FANIN_THRESHOLD = 5;
|
|
2620
|
-
function
|
|
4599
|
+
function moduleOf2(file) {
|
|
2621
4600
|
const i = file.indexOf("/");
|
|
2622
4601
|
return i === -1 ? "." : file.slice(0, i);
|
|
2623
4602
|
}
|
|
2624
|
-
function classify(ranked, faninOf, threshold = 0.15) {
|
|
4603
|
+
function classify(ranked, faninOf, threshold = 0.15, protectedPatterns) {
|
|
2625
4604
|
const relevant = ranked.filter((r) => r.score >= threshold);
|
|
2626
4605
|
const files = [...new Set(relevant.map((r) => r.file))];
|
|
2627
|
-
const modules = [...new Set(files.map(
|
|
4606
|
+
const modules = [...new Set(files.map(moduleOf2))];
|
|
2628
4607
|
const reasons = [];
|
|
2629
4608
|
const DEFINITIONAL = /* @__PURE__ */ new Set([
|
|
2630
4609
|
"function",
|
|
@@ -2635,7 +4614,9 @@ function classify(ranked, faninOf, threshold = 0.15) {
|
|
|
2635
4614
|
"type"
|
|
2636
4615
|
]);
|
|
2637
4616
|
const highFanin = relevant.filter((r) => DEFINITIONAL.has(r.type) && faninOf(r.id) >= FANIN_THRESHOLD).map((r) => r.name);
|
|
2638
|
-
const protectedHits = files.filter(
|
|
4617
|
+
const protectedHits = files.filter(
|
|
4618
|
+
(f) => protectedPatterns ? isProtected(f, protectedPatterns) : isProtected(f)
|
|
4619
|
+
);
|
|
2639
4620
|
let complexity = "SIMPLE";
|
|
2640
4621
|
if (files.length >= 3) {
|
|
2641
4622
|
complexity = "COMPLEX";
|
|
@@ -2794,6 +4775,36 @@ Task sederhana. Gunakan pencarian langsung. Kandidat: ${cand}
|
|
|
2794
4775
|
}
|
|
2795
4776
|
const tests = [...testSet].slice(0, 10);
|
|
2796
4777
|
const symLine = (r) => `- ${r.name} (${r.type}) \u2014 \`${r.file}\` [${r.score.toFixed(2)} ${r.reason}]`;
|
|
4778
|
+
let hotspots = [];
|
|
4779
|
+
let cycleWarn = "";
|
|
4780
|
+
try {
|
|
4781
|
+
const ids = ranked.slice(0, 20).map((r) => r.id);
|
|
4782
|
+
if (ids.length > 0) {
|
|
4783
|
+
const ph = ids.map(() => "?").join(",");
|
|
4784
|
+
const prRows = db.prepare(`SELECT node_id AS id, pagerank AS pr FROM node_metrics WHERE node_id IN (${ph})`).all(...ids);
|
|
4785
|
+
const pr = new Map(prRows.map((r) => [r.id, r.pr]));
|
|
4786
|
+
const boosted = ranked.slice(0, 20).map((r) => ({ r, boost: r.score + (pr.get(r.id) ?? 0) * 2 })).sort((a, b) => b.boost - a.boost).slice(0, 5);
|
|
4787
|
+
hotspots = boosted.map(
|
|
4788
|
+
(b) => `- ${b.r.name} (${b.r.type}) \u2014 \`${b.r.file}\` [pr=${(pr.get(b.r.id) ?? 0).toFixed(4)}]`
|
|
4789
|
+
);
|
|
4790
|
+
const cycRows = db.prepare(`SELECT nodes_json AS js FROM cycles`).all();
|
|
4791
|
+
const inCycle = /* @__PURE__ */ new Set();
|
|
4792
|
+
for (const c of cycRows) {
|
|
4793
|
+
try {
|
|
4794
|
+
for (const n of JSON.parse(c.js)) inCycle.add(n);
|
|
4795
|
+
} catch {
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
const trapped = ranked.slice(0, 10).filter((r) => inCycle.has(r.id));
|
|
4799
|
+
if (trapped.length > 0) {
|
|
4800
|
+
cycleWarn = `
|
|
4801
|
+
## Circular dependency warning
|
|
4802
|
+
` + trapped.slice(0, 5).map((r) => `- ${r.name} (\`${r.file}\`) is inside a dependency cycle`).join("\n") + `
|
|
4803
|
+
`;
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
} catch {
|
|
4807
|
+
}
|
|
2797
4808
|
const scope = computeScope(
|
|
2798
4809
|
highFiles,
|
|
2799
4810
|
medFiles,
|
|
@@ -2817,20 +4828,23 @@ ${medFiles.map((f) => `- ${f}`).join("\n") || "-"}
|
|
|
2817
4828
|
## Key Symbols
|
|
2818
4829
|
${hi.slice(0, 8).map(symLine).join("\n") || "-"}
|
|
2819
4830
|
|
|
2820
|
-
|
|
4831
|
+
` + (hotspots.length > 0 ? `## Critical hotspots (PageRank)
|
|
4832
|
+
${hotspots.join("\n")}
|
|
4833
|
+
|
|
4834
|
+
` : ``) + `## Dependencies
|
|
2821
4835
|
${deps.map((d) => `- ${d}`).join("\n") || "-"}
|
|
2822
4836
|
|
|
2823
4837
|
## Related Tests
|
|
2824
4838
|
${tests.map((t) => `- ${t}`).join("\n") || "-"}
|
|
2825
4839
|
|
|
2826
|
-
` + scopeBlock(scope) + `
|
|
4840
|
+
` + cycleWarn + scopeBlock(scope) + `
|
|
2827
4841
|
` + guardBlock();
|
|
2828
4842
|
return { markdown: md, high: highFiles, medium: medFiles, tests, dependencies: deps };
|
|
2829
4843
|
}
|
|
2830
4844
|
|
|
2831
4845
|
// src/commands/orientasi.ts
|
|
2832
|
-
import
|
|
2833
|
-
import
|
|
4846
|
+
import fs20 from "fs";
|
|
4847
|
+
import path20 from "path";
|
|
2834
4848
|
function isPathLike(task) {
|
|
2835
4849
|
const t = task.trim().replace(/^["']|["']$/g, "");
|
|
2836
4850
|
return t === "." || t === "./" || t === "/" || t.startsWith("./") || t.startsWith("../") || t.startsWith("/") || t.startsWith("~");
|
|
@@ -2840,12 +4854,13 @@ function looksLikeRepoPath(db, repoRoot2, task) {
|
|
|
2840
4854
|
if (/\s/.test(t)) return false;
|
|
2841
4855
|
if (isPathLike(task)) return true;
|
|
2842
4856
|
try {
|
|
2843
|
-
|
|
4857
|
+
fs20.statSync(path20.resolve(repoRoot2, t));
|
|
2844
4858
|
return true;
|
|
2845
4859
|
} catch {
|
|
2846
4860
|
}
|
|
2847
4861
|
try {
|
|
2848
|
-
const
|
|
4862
|
+
const esc2 = t.replace(/[\\%_]/g, (c) => "\\" + c);
|
|
4863
|
+
const row = db.prepare(`SELECT 1 AS ok FROM files WHERE path = ? OR path LIKE ? ESCAPE '\\' LIMIT 1`).get(t, `%/${esc2}`);
|
|
2849
4864
|
return !!row;
|
|
2850
4865
|
} catch {
|
|
2851
4866
|
return false;
|
|
@@ -2876,11 +4891,11 @@ function cmdOrientasi(repoRoot2, task) {
|
|
|
2876
4891
|
const byLang = db.prepare(`SELECT language, COUNT(*) AS n FROM files GROUP BY language ORDER BY n DESC`).all();
|
|
2877
4892
|
const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
2878
4893
|
const t = task.trim().replace(/^["']|["']$/g, "");
|
|
2879
|
-
const abs =
|
|
4894
|
+
const abs = path20.resolve(repoRoot2, t);
|
|
2880
4895
|
let rel = null;
|
|
2881
4896
|
try {
|
|
2882
|
-
const stat =
|
|
2883
|
-
if (stat.isFile()) rel =
|
|
4897
|
+
const stat = fs20.statSync(abs);
|
|
4898
|
+
if (stat.isFile()) rel = path20.relative(repoRoot2, abs).split(path20.sep).join("/");
|
|
2884
4899
|
} catch {
|
|
2885
4900
|
rel = null;
|
|
2886
4901
|
}
|
|
@@ -2938,6 +4953,70 @@ Mulai dari simbol di atas, atau jalankan \`scopecairn context "<deskripsi task>"
|
|
|
2938
4953
|
}
|
|
2939
4954
|
}
|
|
2940
4955
|
|
|
4956
|
+
// src/decisions.ts
|
|
4957
|
+
import fs21 from "fs";
|
|
4958
|
+
import path21 from "path";
|
|
4959
|
+
function decisionsPath(repoRoot2) {
|
|
4960
|
+
return path21.join(dataDir(repoRoot2), "decisions.md");
|
|
4961
|
+
}
|
|
4962
|
+
function loadDecisions(repoRoot2) {
|
|
4963
|
+
try {
|
|
4964
|
+
return fs21.readFileSync(decisionsPath(repoRoot2), "utf8");
|
|
4965
|
+
} catch {
|
|
4966
|
+
return null;
|
|
4967
|
+
}
|
|
4968
|
+
}
|
|
4969
|
+
|
|
4970
|
+
// src/retrieval/gitChanged.ts
|
|
4971
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
4972
|
+
function changedFilesVsHead(repoRoot2) {
|
|
4973
|
+
let out;
|
|
4974
|
+
try {
|
|
4975
|
+
out = execFileSync2("git", ["status", "--porcelain"], {
|
|
4976
|
+
cwd: repoRoot2,
|
|
4977
|
+
encoding: "utf8",
|
|
4978
|
+
timeout: 8e3,
|
|
4979
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4980
|
+
});
|
|
4981
|
+
} catch {
|
|
4982
|
+
return [];
|
|
4983
|
+
}
|
|
4984
|
+
const files = [];
|
|
4985
|
+
for (const line of out.split("\n")) {
|
|
4986
|
+
if (line.length < 4) continue;
|
|
4987
|
+
let p = line.slice(3).trim();
|
|
4988
|
+
const arrow = p.indexOf(" -> ");
|
|
4989
|
+
if (arrow >= 0) p = p.slice(arrow + 4);
|
|
4990
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
4991
|
+
const inner = p.slice(1, -1);
|
|
4992
|
+
const bytes = [];
|
|
4993
|
+
for (let i = 0; i < inner.length; i++) {
|
|
4994
|
+
if (inner[i] === "\\") {
|
|
4995
|
+
const next = inner[i + 1];
|
|
4996
|
+
if (next >= "0" && next <= "7") {
|
|
4997
|
+
bytes.push(parseInt(inner.slice(i + 1, i + 4), 8));
|
|
4998
|
+
i += 3;
|
|
4999
|
+
} else if (next === "n") {
|
|
5000
|
+
bytes.push(10);
|
|
5001
|
+
i++;
|
|
5002
|
+
} else if (next === "t") {
|
|
5003
|
+
bytes.push(9);
|
|
5004
|
+
i++;
|
|
5005
|
+
} else {
|
|
5006
|
+
bytes.push(next.charCodeAt(0));
|
|
5007
|
+
i++;
|
|
5008
|
+
}
|
|
5009
|
+
} else {
|
|
5010
|
+
bytes.push(inner.charCodeAt(i));
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
p = Buffer.from(bytes).toString("utf8");
|
|
5014
|
+
}
|
|
5015
|
+
if (p) files.push(p);
|
|
5016
|
+
}
|
|
5017
|
+
return files;
|
|
5018
|
+
}
|
|
5019
|
+
|
|
2941
5020
|
// src/commands/context.ts
|
|
2942
5021
|
function cmdContext(repoRoot2, task, opts = {}) {
|
|
2943
5022
|
if (!opts.noRefresh) {
|
|
@@ -2982,7 +5061,7 @@ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan
|
|
|
2982
5061
|
const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships WHERE target_id = ?`).get(id);
|
|
2983
5062
|
return r.n;
|
|
2984
5063
|
};
|
|
2985
|
-
const cls = classify(result.ranked, faninOf);
|
|
5064
|
+
const cls = classify(result.ranked, faninOf, 0.15, loadProtectedPatterns(repoRoot2));
|
|
2986
5065
|
const t = db.prepare(`INSERT INTO tasks(description, complexity) VALUES (?, ?)`).run(task, opts.escalate ? "COMPLEX" : cls.complexity);
|
|
2987
5066
|
const taskId = Number(t.lastInsertRowid);
|
|
2988
5067
|
const insCtx = db.prepare(
|
|
@@ -2992,11 +5071,40 @@ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan
|
|
|
2992
5071
|
insCtx.run(taskId, r.id, r.score, r.reason);
|
|
2993
5072
|
}
|
|
2994
5073
|
logInvocation(db, "context", taskId);
|
|
5074
|
+
const mode = opts.mode ?? "NORMAL";
|
|
5075
|
+
const effectiveMode = mode === "NORMAL" && cls.complexity === "SIMPLE" && cls.estimatedFiles <= 1 ? "FAST" : mode;
|
|
5076
|
+
const escalate = opts.escalate === true || effectiveMode === "SAFE" || effectiveMode === "AUDIT" || mode === "SAFE" || mode === "AUDIT";
|
|
5077
|
+
if (effectiveMode === "FAST") {
|
|
5078
|
+
result.ranked = result.ranked.slice(0, 8);
|
|
5079
|
+
}
|
|
5080
|
+
const changed = changedFilesVsHead(repoRoot2);
|
|
5081
|
+
if (changed.length > 0) {
|
|
5082
|
+
console.log(
|
|
5083
|
+
`> Working tree: ${changed.length} file berubah vs HEAD (${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}).
|
|
5084
|
+
`
|
|
5085
|
+
);
|
|
5086
|
+
}
|
|
5087
|
+
const decisions = loadDecisions(repoRoot2);
|
|
2995
5088
|
const built = buildContext(db, task, result, cls, {
|
|
2996
|
-
escalate
|
|
5089
|
+
escalate,
|
|
2997
5090
|
repoRoot: repoRoot2
|
|
2998
5091
|
});
|
|
2999
|
-
|
|
5092
|
+
let out = built.markdown;
|
|
5093
|
+
if (effectiveMode !== "NORMAL") {
|
|
5094
|
+
out += `
|
|
5095
|
+
|
|
5096
|
+
## Mode
|
|
5097
|
+
${effectiveMode}
|
|
5098
|
+
`;
|
|
5099
|
+
}
|
|
5100
|
+
if (decisions) {
|
|
5101
|
+
out += `
|
|
5102
|
+
|
|
5103
|
+
## Keputusan Arsitektur (.scopecairn/decisions.md)
|
|
5104
|
+
${decisions}
|
|
5105
|
+
`;
|
|
5106
|
+
}
|
|
5107
|
+
console.log(out);
|
|
3000
5108
|
} finally {
|
|
3001
5109
|
db.close();
|
|
3002
5110
|
}
|
|
@@ -3012,8 +5120,8 @@ function cmdImpact(repoRoot2, target, opts = {}) {
|
|
|
3012
5120
|
if (seeds.length === 0) {
|
|
3013
5121
|
const rows = db.prepare(
|
|
3014
5122
|
`SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
|
|
3015
|
-
JOIN files f ON f.id = s.file_id WHERE f.path LIKE ? LIMIT 20`
|
|
3016
|
-
).all(`%${target}`);
|
|
5123
|
+
JOIN files f ON f.id = s.file_id WHERE f.path LIKE ? ESCAPE '\\' LIMIT 20`
|
|
5124
|
+
).all(`%${target.replace(/[\\%_]/g, (c) => "\\" + c)}`);
|
|
3017
5125
|
seeds = rows;
|
|
3018
5126
|
}
|
|
3019
5127
|
if (seeds.length === 0) {
|
|
@@ -3130,14 +5238,14 @@ function cmdImpact(repoRoot2, target, opts = {}) {
|
|
|
3130
5238
|
}
|
|
3131
5239
|
|
|
3132
5240
|
// src/commands/init.ts
|
|
3133
|
-
import
|
|
3134
|
-
import
|
|
5241
|
+
import fs23 from "fs";
|
|
5242
|
+
import path23 from "path";
|
|
3135
5243
|
|
|
3136
5244
|
// src/integrate/install.ts
|
|
3137
|
-
import
|
|
3138
|
-
import
|
|
5245
|
+
import fs22 from "fs";
|
|
5246
|
+
import path22 from "path";
|
|
3139
5247
|
function installTarget(repoRoot2, target, prefix) {
|
|
3140
|
-
const abs =
|
|
5248
|
+
const abs = path22.join(repoRoot2, target.rel);
|
|
3141
5249
|
const block = target.render(prefix);
|
|
3142
5250
|
const finish = (how) => ({
|
|
3143
5251
|
agent: target.id,
|
|
@@ -3145,25 +5253,25 @@ function installTarget(repoRoot2, target, prefix) {
|
|
|
3145
5253
|
how
|
|
3146
5254
|
});
|
|
3147
5255
|
if (target.mode === "new") {
|
|
3148
|
-
const existed =
|
|
3149
|
-
|
|
3150
|
-
|
|
5256
|
+
const existed = fs22.existsSync(abs);
|
|
5257
|
+
fs22.mkdirSync(path22.dirname(abs), { recursive: true });
|
|
5258
|
+
fs22.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
|
|
3151
5259
|
return finish(existed ? "updated" : "generated");
|
|
3152
5260
|
}
|
|
3153
|
-
if (
|
|
3154
|
-
const cur =
|
|
5261
|
+
if (fs22.existsSync(abs)) {
|
|
5262
|
+
const cur = fs22.readFileSync(abs, "utf8");
|
|
3155
5263
|
if (cur.includes(MARKER)) {
|
|
3156
5264
|
const { next: next2 } = removeManagedBlock(cur, (p) => target.render(p));
|
|
3157
|
-
|
|
5265
|
+
fs22.writeFileSync(abs, (next2 + "\n\n" + block).trimEnd() + "\n");
|
|
3158
5266
|
return finish("updated");
|
|
3159
5267
|
}
|
|
3160
5268
|
const { next, changed } = removeManagedBlock(cur, (p) => target.render(p));
|
|
3161
5269
|
const base = changed ? next : cur;
|
|
3162
|
-
|
|
5270
|
+
fs22.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
|
|
3163
5271
|
return finish("extended");
|
|
3164
5272
|
}
|
|
3165
|
-
|
|
3166
|
-
|
|
5273
|
+
fs22.mkdirSync(path22.dirname(abs), { recursive: true });
|
|
5274
|
+
fs22.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
|
|
3167
5275
|
return finish("generated");
|
|
3168
5276
|
}
|
|
3169
5277
|
function removeManagedBlock(content, render) {
|
|
@@ -3191,19 +5299,19 @@ function foldBlank(s) {
|
|
|
3191
5299
|
return s.replace(/\n{3,}/g, "\n\n").trim();
|
|
3192
5300
|
}
|
|
3193
5301
|
function uninstallTarget(repoRoot2, target) {
|
|
3194
|
-
const abs =
|
|
5302
|
+
const abs = path22.join(repoRoot2, target.rel);
|
|
3195
5303
|
const finish = (how) => ({
|
|
3196
5304
|
agent: target.id,
|
|
3197
5305
|
rel: target.rel,
|
|
3198
5306
|
how
|
|
3199
5307
|
});
|
|
3200
|
-
if (!
|
|
5308
|
+
if (!fs22.existsSync(abs)) return finish("absent");
|
|
3201
5309
|
if (target.mode === "new") {
|
|
3202
|
-
|
|
3203
|
-
pruneEmptyDirs(
|
|
5310
|
+
fs22.rmSync(abs, { force: true });
|
|
5311
|
+
pruneEmptyDirs(path22.dirname(abs), repoRoot2);
|
|
3204
5312
|
return finish("removed");
|
|
3205
5313
|
}
|
|
3206
|
-
const cur =
|
|
5314
|
+
const cur = fs22.readFileSync(abs, "utf8");
|
|
3207
5315
|
if (!cur.includes(MARKER)) return finish("skipped");
|
|
3208
5316
|
const render = (p) => {
|
|
3209
5317
|
const t = resolveAgent(target.id);
|
|
@@ -3212,52 +5320,52 @@ function uninstallTarget(repoRoot2, target) {
|
|
|
3212
5320
|
const { next, changed } = removeManagedBlock(cur, render);
|
|
3213
5321
|
if (!changed) return finish("skipped");
|
|
3214
5322
|
if (!next) {
|
|
3215
|
-
|
|
5323
|
+
fs22.rmSync(abs, { force: true });
|
|
3216
5324
|
return finish("removed");
|
|
3217
5325
|
}
|
|
3218
|
-
|
|
5326
|
+
fs22.writeFileSync(abs, next + "\n");
|
|
3219
5327
|
return finish("removed");
|
|
3220
5328
|
}
|
|
3221
5329
|
function pruneEmptyDirs(dir, stop) {
|
|
3222
5330
|
let cur = dir;
|
|
3223
|
-
while (cur
|
|
5331
|
+
while (cur !== stop && cur.toLowerCase().startsWith(stop.toLowerCase())) {
|
|
3224
5332
|
try {
|
|
3225
|
-
|
|
5333
|
+
fs22.rmdirSync(cur);
|
|
3226
5334
|
} catch {
|
|
3227
5335
|
return;
|
|
3228
5336
|
}
|
|
3229
|
-
cur =
|
|
5337
|
+
cur = path22.dirname(cur);
|
|
3230
5338
|
}
|
|
3231
5339
|
}
|
|
3232
5340
|
function installAntigravity(repoRoot2, prefix = detectPrefix(repoRoot2)) {
|
|
3233
5341
|
const agentDir = detectAgentDir(repoRoot2);
|
|
3234
5342
|
const out = [];
|
|
3235
|
-
const skillPath =
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
out.push(`Skill installed (${
|
|
3239
|
-
const wfPath =
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
out.push(`Workflow installed (${
|
|
5343
|
+
const skillPath = path22.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
|
|
5344
|
+
fs22.mkdirSync(path22.dirname(skillPath), { recursive: true });
|
|
5345
|
+
fs22.writeFileSync(skillPath, skillMd(prefix));
|
|
5346
|
+
out.push(`Skill installed (${path22.relative(repoRoot2, skillPath)})`);
|
|
5347
|
+
const wfPath = path22.join(repoRoot2, "workflows", "scopecairn.md");
|
|
5348
|
+
fs22.mkdirSync(path22.dirname(wfPath), { recursive: true });
|
|
5349
|
+
fs22.writeFileSync(wfPath, workflowMd(prefix));
|
|
5350
|
+
out.push(`Workflow installed (${path22.relative(repoRoot2, wfPath)})`);
|
|
3243
5351
|
return out;
|
|
3244
5352
|
}
|
|
3245
5353
|
function uninstallAntigravity(repoRoot2) {
|
|
3246
5354
|
const out = [];
|
|
3247
5355
|
for (const dir of [".agents", ".agent"]) {
|
|
3248
|
-
const p =
|
|
3249
|
-
if (
|
|
3250
|
-
|
|
3251
|
-
pruneEmptyDirs(
|
|
3252
|
-
out.push(`Skill removed (${
|
|
5356
|
+
const p = path22.join(repoRoot2, dir, "skills", "scopecairn", "SKILL.md");
|
|
5357
|
+
if (fs22.existsSync(p)) {
|
|
5358
|
+
fs22.rmSync(p, { force: true });
|
|
5359
|
+
pruneEmptyDirs(path22.dirname(p), repoRoot2);
|
|
5360
|
+
out.push(`Skill removed (${path22.relative(repoRoot2, p)})`);
|
|
3253
5361
|
}
|
|
3254
5362
|
}
|
|
3255
|
-
const wf =
|
|
3256
|
-
if (
|
|
3257
|
-
const text =
|
|
5363
|
+
const wf = path22.join(repoRoot2, "workflows", "scopecairn.md");
|
|
5364
|
+
if (fs22.existsSync(wf)) {
|
|
5365
|
+
const text = fs22.readFileSync(wf, "utf8");
|
|
3258
5366
|
if (text.includes("scopecairn")) {
|
|
3259
|
-
|
|
3260
|
-
out.push(`Workflow removed (${
|
|
5367
|
+
fs22.rmSync(wf, { force: true });
|
|
5368
|
+
out.push(`Workflow removed (${path22.relative(repoRoot2, wf)})`);
|
|
3261
5369
|
} else {
|
|
3262
5370
|
out.push(`Workflow skipped (bukan milik ScopeCairn)`);
|
|
3263
5371
|
}
|
|
@@ -3312,14 +5420,14 @@ function withCore(ids) {
|
|
|
3312
5420
|
function withDetected(repoRoot2) {
|
|
3313
5421
|
const targets = [];
|
|
3314
5422
|
for (const t of AGENT_MATRIX) {
|
|
3315
|
-
if (
|
|
5423
|
+
if (fs23.existsSync(path23.join(repoRoot2, t.presentMarker))) targets.push(t);
|
|
3316
5424
|
}
|
|
3317
5425
|
return targets;
|
|
3318
5426
|
}
|
|
3319
5427
|
|
|
3320
5428
|
// src/benchmark/run.ts
|
|
3321
|
-
import
|
|
3322
|
-
import
|
|
5429
|
+
import fs24 from "fs";
|
|
5430
|
+
import path24 from "path";
|
|
3323
5431
|
|
|
3324
5432
|
// src/benchmark/dataset.ts
|
|
3325
5433
|
var BENCHMARK_TASKS = [
|
|
@@ -3376,7 +5484,7 @@ var BENCHMARK_TASKS = [
|
|
|
3376
5484
|
// src/benchmark/run.ts
|
|
3377
5485
|
function fileChars(repoRoot2, rel) {
|
|
3378
5486
|
try {
|
|
3379
|
-
return
|
|
5487
|
+
return fs24.readFileSync(path24.join(repoRoot2, rel), "utf8").length;
|
|
3380
5488
|
} catch {
|
|
3381
5489
|
return 0;
|
|
3382
5490
|
}
|
|
@@ -3525,47 +5633,133 @@ function cmdBenchmark(repoRoot2, opts = {}) {
|
|
|
3525
5633
|
}
|
|
3526
5634
|
}
|
|
3527
5635
|
|
|
3528
|
-
// src/commands/
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
const
|
|
3537
|
-
const
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
5636
|
+
// src/commands/dashboard.ts
|
|
5637
|
+
import fs25 from "fs";
|
|
5638
|
+
import path25 from "path";
|
|
5639
|
+
function cmdDashboard(repoRoot2, opts) {
|
|
5640
|
+
const db = openDb(repoRoot2);
|
|
5641
|
+
try {
|
|
5642
|
+
const files = db.prepare(`SELECT COUNT(*) AS n FROM files`).get().n;
|
|
5643
|
+
const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
5644
|
+
const rels = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
|
|
5645
|
+
const top = db.prepare(
|
|
5646
|
+
`SELECT s.name AS name, COUNT(r.target_id) AS n
|
|
5647
|
+
FROM relationships r JOIN symbols s ON s.id = r.target_id
|
|
5648
|
+
GROUP BY r.target_id ORDER BY n DESC LIMIT 20`
|
|
5649
|
+
).all();
|
|
5650
|
+
let adapters = [];
|
|
5651
|
+
try {
|
|
5652
|
+
const paths = db.prepare(`SELECT path FROM files`).all().map((r) => r.path);
|
|
5653
|
+
adapters = detectAdapters(paths, repoRoot2).map((a) => a.id);
|
|
5654
|
+
} catch {
|
|
5655
|
+
}
|
|
5656
|
+
let tasks = [];
|
|
5657
|
+
try {
|
|
5658
|
+
tasks = db.prepare(
|
|
5659
|
+
`SELECT description, complexity, created_at, completed_at FROM tasks ORDER BY id DESC LIMIT 20`
|
|
5660
|
+
).all();
|
|
5661
|
+
} catch {
|
|
5662
|
+
}
|
|
5663
|
+
const esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5664
|
+
const topRows = top.map((t) => `<tr><td>${esc2(t.name)}</td><td>${t.n}</td></tr>`).join("\n") || `<tr><td colspan="2">-</td></tr>`;
|
|
5665
|
+
const adapterList = adapters.length > 0 ? adapters.map(esc2).join(", ") : "-";
|
|
5666
|
+
const taskRows = tasks.map(
|
|
5667
|
+
(t) => `<tr><td>${esc2(t.description)}</td><td>${esc2(t.complexity)}</td><td>${esc2(t.created_at)}</td><td>${t.completed_at ? esc2(t.completed_at) : "-"}</td></tr>`
|
|
5668
|
+
).join("\n") || `<tr><td colspan="4">-</td></tr>`;
|
|
5669
|
+
const html = `<!DOCTYPE html>
|
|
5670
|
+
<html lang="en">
|
|
5671
|
+
<head>
|
|
5672
|
+
<meta charset="utf-8">
|
|
5673
|
+
<title>ScopeCairn Dashboard</title>
|
|
5674
|
+
<style>
|
|
5675
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 900px; color: #222; }
|
|
5676
|
+
h1 { font-size: 1.5rem; }
|
|
5677
|
+
h2 { font-size: 1.1rem; margin-top: 2rem; }
|
|
5678
|
+
.cards { display: flex; gap: 1rem; }
|
|
5679
|
+
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem 1.5rem; }
|
|
5680
|
+
.card .n { font-size: 1.8rem; font-weight: 700; }
|
|
5681
|
+
table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
|
|
5682
|
+
th, td { border: 1px solid #ddd; padding: 0.4rem 0.8rem; text-align: left; }
|
|
5683
|
+
th { background: #f5f5f5; }
|
|
5684
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
5685
|
+
</style>
|
|
5686
|
+
</head>
|
|
5687
|
+
<body>
|
|
5688
|
+
<h1>ScopeCairn Dashboard</h1>
|
|
5689
|
+
<div class="cards">
|
|
5690
|
+
<div class="card"><div class="n">${files}</div>files</div>
|
|
5691
|
+
<div class="card"><div class="n">${symbols}</div>symbols</div>
|
|
5692
|
+
<div class="card"><div class="n">${rels}</div>relationships</div>
|
|
5693
|
+
</div>
|
|
5694
|
+
<h2>Top Symbols by Fan-in</h2>
|
|
5695
|
+
<table><thead><tr><th>Symbol</th><th>Incoming</th></tr></thead><tbody>
|
|
5696
|
+
${topRows}
|
|
5697
|
+
</tbody></table>
|
|
5698
|
+
<h2>Detected Adapters</h2>
|
|
5699
|
+
<p>${adapterList}</p>
|
|
5700
|
+
<h2>Recent Tasks</h2>
|
|
5701
|
+
<table><thead><tr><th>Description</th><th>Complexity</th><th>Created</th><th>Completed</th></tr></thead><tbody>
|
|
5702
|
+
${taskRows}
|
|
5703
|
+
</tbody></table>
|
|
5704
|
+
</body>
|
|
5705
|
+
</html>
|
|
5706
|
+
`;
|
|
5707
|
+
const dest = opts.out ?? path25.join(dataDir(repoRoot2), "dashboard.html");
|
|
5708
|
+
fs25.mkdirSync(path25.dirname(dest), { recursive: true });
|
|
5709
|
+
fs25.writeFileSync(dest, html, "utf8");
|
|
5710
|
+
console.log(`Dashboard written \u2192 ${dest}`);
|
|
5711
|
+
} finally {
|
|
5712
|
+
db.close();
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
|
|
5716
|
+
// src/commands/testselect.ts
|
|
5717
|
+
import fs26 from "fs";
|
|
5718
|
+
import path26 from "path";
|
|
5719
|
+
function isTestPath(p) {
|
|
5720
|
+
return p.includes(".test.") || p.includes(".spec.") || p.includes("__tests__") || path26.basename(p).startsWith("test_");
|
|
5721
|
+
}
|
|
5722
|
+
function cmdTestSelect(repoRoot2, target) {
|
|
5723
|
+
const db = openDb(repoRoot2);
|
|
5724
|
+
try {
|
|
5725
|
+
const rows = db.prepare(`SELECT path FROM files`).all();
|
|
5726
|
+
const testPaths = rows.map((r) => r.path).filter(isTestPath);
|
|
5727
|
+
const base = path26.basename(target);
|
|
5728
|
+
const baseNoExt = base.replace(/\.[^./\\]+$/, "");
|
|
5729
|
+
const needles = new Set([target, base, baseNoExt].filter((s) => s.length > 0));
|
|
5730
|
+
const matches = [];
|
|
5731
|
+
for (const rel of testPaths) {
|
|
5732
|
+
let content;
|
|
5733
|
+
try {
|
|
5734
|
+
content = fs26.readFileSync(path26.join(repoRoot2, ...rel.split("/")), "utf8");
|
|
5735
|
+
} catch {
|
|
5736
|
+
continue;
|
|
3556
5737
|
}
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
5738
|
+
for (const n of needles) {
|
|
5739
|
+
if (content.includes(n)) {
|
|
5740
|
+
matches.push(rel);
|
|
5741
|
+
break;
|
|
5742
|
+
}
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5745
|
+
if (matches.length === 0) {
|
|
5746
|
+
console.log(`No tests reference "${target}".`);
|
|
5747
|
+
return;
|
|
5748
|
+
}
|
|
5749
|
+
console.log(`Tests referencing "${target}":`);
|
|
5750
|
+
for (const m of matches) console.log(` ${m}`);
|
|
5751
|
+
} finally {
|
|
5752
|
+
db.close();
|
|
3562
5753
|
}
|
|
3563
5754
|
}
|
|
3564
5755
|
|
|
5756
|
+
// src/commands/update.ts
|
|
5757
|
+
import { execFileSync as execFileSync3, execSync } from "child_process";
|
|
5758
|
+
|
|
3565
5759
|
// package.json
|
|
3566
5760
|
var package_default = {
|
|
3567
5761
|
name: "scopecairn",
|
|
3568
|
-
version: "0.
|
|
5762
|
+
version: "0.7.0",
|
|
3569
5763
|
description: "ScopeCairn \u2014 Codebase Intelligence & Agent Optimization Layer (local-first)",
|
|
3570
5764
|
license: "MIT",
|
|
3571
5765
|
type: "module",
|
|
@@ -3598,7 +5792,9 @@ var package_default = {
|
|
|
3598
5792
|
doctor: "tsx src/cli.ts doctor",
|
|
3599
5793
|
clean: "tsx src/cli.ts clean",
|
|
3600
5794
|
rebuild: "tsx src/cli.ts rebuild",
|
|
3601
|
-
|
|
5795
|
+
test: "tsx --test tests/*.test.ts",
|
|
5796
|
+
typecheck: "tsc --noEmit",
|
|
5797
|
+
changelog: "node scripts/changelog.js"
|
|
3602
5798
|
},
|
|
3603
5799
|
dependencies: {
|
|
3604
5800
|
commander: "^12.1.0",
|
|
@@ -3613,6 +5809,85 @@ var package_default = {
|
|
|
3613
5809
|
}
|
|
3614
5810
|
};
|
|
3615
5811
|
|
|
5812
|
+
// src/commands/update.ts
|
|
5813
|
+
function latestVersion() {
|
|
5814
|
+
try {
|
|
5815
|
+
const out = execFileSync3("npm", ["view", "scopecairn", "version"], {
|
|
5816
|
+
encoding: "utf8",
|
|
5817
|
+
timeout: 15e3,
|
|
5818
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
5819
|
+
shell: process.platform === "win32"
|
|
5820
|
+
}).trim();
|
|
5821
|
+
return out || null;
|
|
5822
|
+
} catch {
|
|
5823
|
+
return null;
|
|
5824
|
+
}
|
|
5825
|
+
}
|
|
5826
|
+
function cmdUpdate(opts = {}) {
|
|
5827
|
+
const current = package_default.version;
|
|
5828
|
+
const latest = latestVersion();
|
|
5829
|
+
if (!latest) {
|
|
5830
|
+
console.log("Gagal memeriksa versi terbaru (offline atau belum terpublish).");
|
|
5831
|
+
console.log("Update manual: npm i -g scopecairn@latest");
|
|
5832
|
+
return;
|
|
5833
|
+
}
|
|
5834
|
+
console.log(`Versi terpasang: ${current} | terbaru: ${latest}`);
|
|
5835
|
+
if (latest === current) {
|
|
5836
|
+
console.log("Sudah versi terbaru.");
|
|
5837
|
+
return;
|
|
5838
|
+
}
|
|
5839
|
+
if (opts.check) {
|
|
5840
|
+
console.log("Update tersedia. Jalankan `scopecairn update`.");
|
|
5841
|
+
return;
|
|
5842
|
+
}
|
|
5843
|
+
try {
|
|
5844
|
+
execSync("npm i -g scopecairn@latest", {
|
|
5845
|
+
stdio: "inherit",
|
|
5846
|
+
shell: process.platform === "win32" ? "cmd.exe" : "/bin/sh"
|
|
5847
|
+
});
|
|
5848
|
+
console.log(`\u2713 scopecairn diperbarui ke ${latest}.`);
|
|
5849
|
+
} catch {
|
|
5850
|
+
console.log("Update gagal. Jalankan manual: npm i -g scopecairn@latest");
|
|
5851
|
+
}
|
|
5852
|
+
}
|
|
5853
|
+
|
|
5854
|
+
// src/commands/agents.ts
|
|
5855
|
+
function registerAgentCommands(program2, repoRoot2) {
|
|
5856
|
+
const list = program2.command("agents").description("List supported agent installers");
|
|
5857
|
+
list.command("list").description("Show agent ids").action(() => {
|
|
5858
|
+
console.log(agentIds().join("\n"));
|
|
5859
|
+
});
|
|
5860
|
+
const anti = program2.command("antigravity").description("Antigravity IDE integration");
|
|
5861
|
+
anti.command("install").description("Install Skill + Workflow, print allowlist guide").action(() => {
|
|
5862
|
+
const root = repoRoot2();
|
|
5863
|
+
const prefix = detectPrefix(root);
|
|
5864
|
+
for (const line of installAntigravity(root, prefix)) console.log(`\u2713 ${line}`);
|
|
5865
|
+
console.log("");
|
|
5866
|
+
console.log(allowlistText(root));
|
|
5867
|
+
});
|
|
5868
|
+
anti.command("uninstall").description("Remove Skill + Workflow").action(() => {
|
|
5869
|
+
for (const line of uninstallAntigravity(repoRoot2())) console.log(`\u2713 ${line}`);
|
|
5870
|
+
});
|
|
5871
|
+
for (const t of AGENT_MATRIX) {
|
|
5872
|
+
if (t.id === "agents") continue;
|
|
5873
|
+
const cmd = program2.command(t.id).description(`${t.label} integration`);
|
|
5874
|
+
cmd.command("install").description(`Write ${t.rel}`).action(() => {
|
|
5875
|
+
const root = repoRoot2();
|
|
5876
|
+
const prefix = detectPrefix(root);
|
|
5877
|
+
const r = installTarget(root, t, prefix);
|
|
5878
|
+
console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
|
|
5879
|
+
if (t.id === "opencode") {
|
|
5880
|
+
console.log("");
|
|
5881
|
+
console.log(opencodePermissionGuide(prefix));
|
|
5882
|
+
}
|
|
5883
|
+
});
|
|
5884
|
+
cmd.command("uninstall").description(`Remove ScopeCairn block from ${t.rel}`).action(() => {
|
|
5885
|
+
const r = uninstallTarget(repoRoot2(), t);
|
|
5886
|
+
console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
|
|
5887
|
+
});
|
|
5888
|
+
}
|
|
5889
|
+
}
|
|
5890
|
+
|
|
3616
5891
|
// src/cli.ts
|
|
3617
5892
|
var program = new Command();
|
|
3618
5893
|
program.name("scopecairn").description("ScopeCairn \u2014 local-first codebase intelligence layer").version(package_default.version).addHelpText(
|
|
@@ -3625,6 +5900,8 @@ Examples:
|
|
|
3625
5900
|
$ scopecairn context ./ repository orientation map
|
|
3626
5901
|
$ scopecairn impact src/auth/login.ts change impact analysis
|
|
3627
5902
|
$ scopecairn read symbol approveRequest symbol-level source excerpt
|
|
5903
|
+
$ scopecairn path handleLogin dbQuery shortest call path between symbols
|
|
5904
|
+
$ scopecairn export --format mermaid export graph (mermaid|graphml|dot|json)
|
|
3628
5905
|
$ scopecairn doctor health + integration check
|
|
3629
5906
|
|
|
3630
5907
|
Agent commands (context, impact, read, graph, status, doctor) are
|
|
@@ -3632,21 +5909,30 @@ read-only toward source and only write to .scopecairn/ \u2014 safe to allowlist.
|
|
|
3632
5909
|
`
|
|
3633
5910
|
);
|
|
3634
5911
|
function repoRoot() {
|
|
3635
|
-
return
|
|
5912
|
+
return path27.resolve(process.cwd());
|
|
3636
5913
|
}
|
|
3637
5914
|
function resolveRoot(given) {
|
|
3638
5915
|
if (!given) return repoRoot();
|
|
3639
|
-
return
|
|
5916
|
+
return path27.resolve(process.cwd(), given);
|
|
3640
5917
|
}
|
|
3641
5918
|
program.command("scan").description("Index repository (files + symbols + relations)").action(() => cmdScan(repoRoot()));
|
|
3642
5919
|
program.command("status").description("Show index status").action(() => cmdStatus(repoRoot()));
|
|
3643
5920
|
program.command("rebuild").description("Rebuild index from scratch (recover from corrupt index)").action(() => cmdRebuild(repoRoot()));
|
|
3644
|
-
program.command("doctor").description("Check integration health").action(async () => {
|
|
3645
|
-
await cmdDoctor(repoRoot());
|
|
5921
|
+
program.command("doctor").description("Check integration health").option("--verbose", "print adapter/graph errors to stderr as well").action(async (opts) => {
|
|
5922
|
+
await cmdDoctor(repoRoot(), { verbose: opts.verbose });
|
|
5923
|
+
});
|
|
5924
|
+
program.command("clean").description("Remove local index (.scopecairn) \u2014 forces full rescan").action(() => {
|
|
5925
|
+
const dir = path27.join(repoRoot(), ".scopecairn");
|
|
5926
|
+
fs27.rmSync(dir, { recursive: true, force: true });
|
|
5927
|
+
console.log("Removed .scopecairn/. Run `scopecairn scan` to rebuild.");
|
|
3646
5928
|
});
|
|
5929
|
+
program.command("dashboard").description("Generate a local HTML dashboard into .scopecairn/dashboard.html").option("--out <path>", "output file (default .scopecairn/dashboard.html)").action((opts) => cmdDashboard(repoRoot(), opts));
|
|
5930
|
+
program.command("test-select <target>").description("List test files affected by a change target (file/symbol)").action((target) => cmdTestSelect(repoRoot(), target));
|
|
3647
5931
|
program.command("graph <symbol>").description("Show relations of a symbol").action((symbol) => cmdGraph(repoRoot(), symbol));
|
|
5932
|
+
program.command("path <from> <to>").description("Show shortest call/dependency path between two symbols").action((from, to) => cmdPath(repoRoot(), from, to));
|
|
5933
|
+
program.command("export").description("Export codebase graph (mermaid|graphml|dot|json)").option("--format <fmt>", "export format", "mermaid").option("--out <path>", "output file (default .scopecairn/graph.<ext>)").action((opts) => cmdExport(repoRoot(), opts.format, opts.out));
|
|
3648
5934
|
program.command("read").argument("<kind>", "what to read (symbol)").argument("<name>", "symbol name").description("Read a symbol's source excerpt (not the whole file)").action((kind, name) => cmdRead(repoRoot(), kind, name));
|
|
3649
|
-
program.command("context <task>").description("Main entry point: auto-refresh + classify + relevant context + scope").option("--escalate", "force full COMPLEX context").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
|
|
5935
|
+
program.command("context <task>").description("Main entry point: auto-refresh + classify + relevant context + scope").option("--escalate", "force full COMPLEX context").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").option("--mode <mode>", "agent mode: NORMAL|FAST|SAFE|AUDIT", "NORMAL").action(
|
|
3650
5936
|
(task, opts) => cmdContext(repoRoot(), task, opts)
|
|
3651
5937
|
);
|
|
3652
5938
|
program.command("impact <target>").description("Change impact analysis: file, symbol, function, or class").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
|
|
@@ -3658,6 +5944,7 @@ program.command("init [path]").description("Initial indexing + Skill, Workflow,
|
|
|
3658
5944
|
program.command("benchmark").description("Retrieval recall, irrelevant ratio, context reduction + weight tuning").option("--tune", "grid search bobot FR-05").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
|
|
3659
5945
|
(opts) => cmdBenchmark(repoRoot(), opts)
|
|
3660
5946
|
);
|
|
5947
|
+
program.command("update").description("Update scopecairn ke versi terbaru dari npm registry").option("--check", "hanya cek versi terbaru tanpa menginstall").action((opts) => cmdUpdate(opts));
|
|
3661
5948
|
registerAgentCommands(program, repoRoot);
|
|
3662
5949
|
program.parseAsync(process.argv);
|
|
3663
5950
|
export {
|