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/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 path17 from "path";
12
+ import fs27 from "fs";
13
+ import path27 from "path";
6
14
 
7
15
  // src/scanner.ts
8
- import fs7 from "fs";
9
- import path8 from "path";
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 = 4;
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 mod = m[1].replace(/["';]/g, "").trim().split(/\s+/)[0];
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("x"));
784
+ return extractTypeScript(source, relPath.endsWith(".tsx"));
751
785
  case "javascript":
752
- return extractTypeScript(source, relPath.endsWith("x"));
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 && !/^\d+$/.test(t) && !STOP.has(t));
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 ? LIMIT ${limit}`
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 r = db.prepare(`SELECT id FROM symbols WHERE name = ? LIMIT 1`).get(name);
870
- return r ? r.id : null;
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
- const noExt = p.replace(/\.[^.]+$/, "");
1005
- return noExt === stemBase && p !== f.path;
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 fs6 from "fs";
1246
- import path7 from "path";
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', ${WEIGHT.QUERIES}, 0.9)`
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 call of prismaCalls(f.content)) {
1410
- const modelId = resolveModel(db, call.model);
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', ${WEIGHT.QUERIES}, 0.85)`
1672
+ VALUES (?, ?, 'QUERIES', ?, ?)`
1565
1673
  );
1566
1674
  for (const f of files) {
1567
- const fileSym = fileSymbolOf(db, f.fileId);
1568
- if (!fileSym || !hasDrizzleMarker(f.content)) continue;
1675
+ if (!hasDrizzleMarker(f.content)) continue;
1569
1676
  del.run(f.fileId);
1570
- for (const q of parseDrizzleQueries(f.content)) {
1571
- const target = findModelInFile(db, f.fileId, q.table) ?? findModelGlobal(db, q.table);
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/index.ts
1644
- var ADAPTERS = [prismaAdapter, drizzleAdapter, nextjsAdapter];
1645
- function detectAdapters(files, repoRoot2) {
1646
- return ADAPTERS.filter((a) => {
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
- if (!a.detect(files)) return false;
1649
- if (repoRoot2 && a.confirm) return a.confirm(repoRoot2, files);
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
- function runAdapters(db, repoRoot2, changed) {
1657
- const allFiles = db.prepare(`SELECT path FROM files`).all().map((r) => r.path);
1658
- const active = detectAdapters(allFiles, repoRoot2);
1659
- if (active.length === 0) return { adapters: [], symbols: 0, relations: 0 };
1660
- let symbols = 0;
1661
- let relations = 0;
1662
- const touched = /* @__PURE__ */ new Set();
1663
- for (const adapter of active) {
1664
- const inputs = [];
1665
- for (const c of changed) {
1666
- if (!adapter.relevant(c.rel)) continue;
1667
- const abs = path7.join(repoRoot2, ...c.rel.split("/"));
1668
- let content;
1669
- try {
1670
- content = fs6.readFileSync(abs, "utf8");
1671
- } catch {
1672
- continue;
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
- if (inputs.length === 0) continue;
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
- const r = adapter.apply({ db, repoRoot: repoRoot2 }, inputs);
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
- for (const fileId of touched) {
1686
- const row = db.prepare(`SELECT path FROM files WHERE id = ?`).get(fileId);
1687
- if (row) {
1688
- try {
1689
- syncFileIndex(db, fileId, row.path);
1690
- } catch {
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
- const refreshTargets = (id, relevant, refresh) => {
1695
- if (!active.some((a) => a.id === id)) return;
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 all = db.prepare(`SELECT id, path FROM files`).all().filter((r) => relevant(r.path));
1698
- const inputs = [];
1699
- for (const r of all) {
1700
- try {
1701
- inputs.push({
1702
- fileId: r.id,
1703
- rel: r.path,
1704
- content: fs6.readFileSync(path7.join(repoRoot2, ...r.path.split("/")), "utf8")
1705
- });
1706
- } catch {
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
- refreshTargets("nextjs", nextjsAdapter.relevant, refreshQueries);
1714
- refreshTargets("drizzle", drizzleAdapter.relevant, refreshDrizzleQueries);
1715
- return { adapters: active.map((a) => a.id), symbols, relations };
1716
- }
1924
+ return { symbols, relations };
1925
+ }
1926
+ };
1717
1927
 
1718
- // src/scanner.ts
1719
- function toPosixRel(repoRoot2, abs) {
1720
- return path8.relative(repoRoot2, abs).split(path8.sep).join("/");
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 walk(absDir, out) {
1723
- const entries = fs7.readdirSync(absDir, { withFileTypes: true });
1724
- for (const e of entries) {
1725
- const abs = path8.join(absDir, e.name);
1726
- if (e.isDirectory()) {
1727
- if (e.isSymbolicLink()) continue;
1728
- walk(abs, out);
1729
- } else if (e.isFile()) {
1730
- out.push(abs);
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
- function scanRepository(repoRoot2) {
1735
- const started = Date.now();
1736
- const rules = loadIgnoreRules(repoRoot2);
1737
- const db = openDb(repoRoot2);
1738
- ensureDefaultFiles(repoRoot2);
1739
- try {
1740
- const allAbs = [];
1741
- walk(repoRoot2, allAbs);
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
- ftsCount = db.prepare(`SELECT COUNT(*) AS n FROM symbol_index`).get().n;
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
- ftsCount = 0;
2024
+ return false;
1762
2025
  }
1763
- const backfill = symbolCount === 0 || ftsCount === 0;
1764
- const pending = [];
1765
- const metaChanged = [];
1766
- for (const abs of allAbs) {
1767
- const rel = toPosixRel(repoRoot2, abs);
1768
- if (isIgnored(rel, rules)) continue;
1769
- totalFilesSeen++;
1770
- const source = isSourceFile(abs);
1771
- const meta = !source && isIndexableMetaFile(rel);
1772
- if (!source && !meta) continue;
1773
- if (source) sourceFiles++;
1774
- else metaFiles++;
1775
- seenPaths.add(rel);
1776
- let buf;
1777
- try {
1778
- buf = fs7.readFileSync(abs);
1779
- } catch {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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,'&amp;').replace(/</g,'&lt;')+'</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 fs8 from "fs";
1879
- import path9 from "path";
3657
+ import fs14 from "fs";
3658
+ import path15 from "path";
1880
3659
  function cmdStatus(repoRoot2) {
1881
3660
  const dir = dataDir(repoRoot2);
1882
- if (!fs8.existsSync(path9.join(dir, "scopecairn.db"))) {
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 fs9 from "fs";
3713
+ import fs15 from "fs";
1935
3714
  function cmdRebuild(repoRoot2) {
1936
3715
  const dir = dataDir(repoRoot2);
1937
- fs9.rmSync(dir, { recursive: true, force: true });
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 fs11 from "fs";
1944
- import path11 from "path";
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 fs10 from "fs";
1970
- import path10 from "path";
3747
+ // src/integrate/detect.ts
3748
+ import fs16 from "fs";
3749
+ import path16 from "path";
1971
3750
  function detectAgentDir(repoRoot2) {
1972
- const plural = fs10.existsSync(path10.join(repoRoot2, ".agents"));
1973
- const singular = fs10.existsSync(path10.join(repoRoot2, ".agent"));
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
- fs10.readFileSync(path10.join(repoRoot2, "package.json"), "utf8")
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 = fs11.existsSync(dbPath(repoRoot2));
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
- const protPath = path11.join(dataDir(repoRoot2), "protected.yml");
2230
- console.log(`${tick(fs11.existsSync(protPath))} Protected ${protPath}`);
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 = path11.join(repoRoot2, t.rel);
2237
- if (!fs11.existsSync(abs)) continue;
2238
- const text = fs11.readFileSync(abs, "utf8");
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 = path11.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
2256
- const skillOk = fs11.existsSync(skillPath);
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 = path11.join(repoRoot2, "workflows", "scopecairn.md");
2259
- console.log(`${tick(fs11.existsSync(wfPath))} Workflow (workflows/scopecairn.md, opsional)`);
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 fs12 from "fs";
2380
- import path12 from "path";
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 = path12.join(repoRoot2, node.file);
4375
+ const abs = path19.join(repoRoot2, node.file);
2397
4376
  let lines;
2398
4377
  try {
2399
- lines = fs12.readFileSync(abs, "utf8").split("\n");
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 = Math.max(1, row.start_line - 1);
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 moduleOf(file) {
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(moduleOf))];
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((f) => isProtected(f));
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
- ## Dependencies
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 fs13 from "fs";
2833
- import path13 from "path";
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
- fs13.statSync(path13.resolve(repoRoot2, t));
4857
+ fs20.statSync(path20.resolve(repoRoot2, t));
2844
4858
  return true;
2845
4859
  } catch {
2846
4860
  }
2847
4861
  try {
2848
- const row = db.prepare(`SELECT 1 AS ok FROM files WHERE path = ? OR path LIKE ? LIMIT 1`).get(t, `%/${t}`);
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 = path13.resolve(repoRoot2, t);
4894
+ const abs = path20.resolve(repoRoot2, t);
2880
4895
  let rel = null;
2881
4896
  try {
2882
- const stat = fs13.statSync(abs);
2883
- if (stat.isFile()) rel = path13.relative(repoRoot2, abs).split(path13.sep).join("/");
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: opts.escalate,
5089
+ escalate,
2997
5090
  repoRoot: repoRoot2
2998
5091
  });
2999
- console.log(built.markdown);
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 fs15 from "fs";
3134
- import path15 from "path";
5241
+ import fs23 from "fs";
5242
+ import path23 from "path";
3135
5243
 
3136
5244
  // src/integrate/install.ts
3137
- import fs14 from "fs";
3138
- import path14 from "path";
5245
+ import fs22 from "fs";
5246
+ import path22 from "path";
3139
5247
  function installTarget(repoRoot2, target, prefix) {
3140
- const abs = path14.join(repoRoot2, target.rel);
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 = fs14.existsSync(abs);
3149
- fs14.mkdirSync(path14.dirname(abs), { recursive: true });
3150
- fs14.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
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 (fs14.existsSync(abs)) {
3154
- const cur = fs14.readFileSync(abs, "utf8");
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
- fs14.writeFileSync(abs, (next2 + "\n\n" + block).trimEnd() + "\n");
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
- fs14.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
5270
+ fs22.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
3163
5271
  return finish("extended");
3164
5272
  }
3165
- fs14.mkdirSync(path14.dirname(abs), { recursive: true });
3166
- fs14.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
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 = path14.join(repoRoot2, target.rel);
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 (!fs14.existsSync(abs)) return finish("absent");
5308
+ if (!fs22.existsSync(abs)) return finish("absent");
3201
5309
  if (target.mode === "new") {
3202
- fs14.rmSync(abs, { force: true });
3203
- pruneEmptyDirs(path14.dirname(abs), repoRoot2);
5310
+ fs22.rmSync(abs, { force: true });
5311
+ pruneEmptyDirs(path22.dirname(abs), repoRoot2);
3204
5312
  return finish("removed");
3205
5313
  }
3206
- const cur = fs14.readFileSync(abs, "utf8");
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
- fs14.rmSync(abs, { force: true });
5323
+ fs22.rmSync(abs, { force: true });
3216
5324
  return finish("removed");
3217
5325
  }
3218
- fs14.writeFileSync(abs, next + "\n");
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.startsWith(stop) && cur !== stop) {
5331
+ while (cur !== stop && cur.toLowerCase().startsWith(stop.toLowerCase())) {
3224
5332
  try {
3225
- fs14.rmdirSync(cur);
5333
+ fs22.rmdirSync(cur);
3226
5334
  } catch {
3227
5335
  return;
3228
5336
  }
3229
- cur = path14.dirname(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 = path14.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
3236
- fs14.mkdirSync(path14.dirname(skillPath), { recursive: true });
3237
- fs14.writeFileSync(skillPath, skillMd(prefix));
3238
- out.push(`Skill installed (${path14.relative(repoRoot2, skillPath)})`);
3239
- const wfPath = path14.join(repoRoot2, "workflows", "scopecairn.md");
3240
- fs14.mkdirSync(path14.dirname(wfPath), { recursive: true });
3241
- fs14.writeFileSync(wfPath, workflowMd(prefix));
3242
- out.push(`Workflow installed (${path14.relative(repoRoot2, wfPath)})`);
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 = path14.join(repoRoot2, dir, "skills", "scopecairn", "SKILL.md");
3249
- if (fs14.existsSync(p)) {
3250
- fs14.rmSync(p, { force: true });
3251
- pruneEmptyDirs(path14.dirname(p), repoRoot2);
3252
- out.push(`Skill removed (${path14.relative(repoRoot2, p)})`);
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 = path14.join(repoRoot2, "workflows", "scopecairn.md");
3256
- if (fs14.existsSync(wf)) {
3257
- const text = fs14.readFileSync(wf, "utf8");
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
- fs14.rmSync(wf, { force: true });
3260
- out.push(`Workflow removed (${path14.relative(repoRoot2, wf)})`);
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 (fs15.existsSync(path15.join(repoRoot2, t.presentMarker))) targets.push(t);
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 fs16 from "fs";
3322
- import path16 from "path";
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 fs16.readFileSync(path16.join(repoRoot2, rel), "utf8").length;
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/agents.ts
3529
- function registerAgentCommands(program2, repoRoot2) {
3530
- const list = program2.command("agents").description("List supported agent installers");
3531
- list.command("list").description("Show agent ids").action(() => {
3532
- console.log(agentIds().join("\n"));
3533
- });
3534
- const anti = program2.command("antigravity").description("Antigravity IDE integration");
3535
- anti.command("install").description("Install Skill + Workflow, print allowlist guide").action(() => {
3536
- const root = repoRoot2();
3537
- const prefix = detectPrefix(root);
3538
- for (const line of installAntigravity(root, prefix)) console.log(`\u2713 ${line}`);
3539
- console.log("");
3540
- console.log(allowlistText(root));
3541
- });
3542
- anti.command("uninstall").description("Remove Skill + Workflow").action(() => {
3543
- for (const line of uninstallAntigravity(repoRoot2())) console.log(`\u2713 ${line}`);
3544
- });
3545
- for (const t of AGENT_MATRIX) {
3546
- if (t.id === "agents") continue;
3547
- const cmd = program2.command(t.id).description(`${t.label} integration`);
3548
- cmd.command("install").description(`Write ${t.rel}`).action(() => {
3549
- const root = repoRoot2();
3550
- const prefix = detectPrefix(root);
3551
- const r = installTarget(root, t, prefix);
3552
- console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
3553
- if (t.id === "opencode") {
3554
- console.log("");
3555
- console.log(opencodePermissionGuide(prefix));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
- cmd.command("uninstall").description(`Remove ScopeCairn block from ${t.rel}`).action(() => {
3559
- const r = uninstallTarget(repoRoot2(), t);
3560
- console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
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.4.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
- typecheck: "tsc --noEmit"
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 path17.resolve(process.cwd());
5912
+ return path27.resolve(process.cwd());
3636
5913
  }
3637
5914
  function resolveRoot(given) {
3638
5915
  if (!given) return repoRoot();
3639
- return path17.resolve(process.cwd(), given);
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 {