scopecairn 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,13 @@ npm install -g scopecairn
66
66
 
67
67
  Requires Node.js ≥ 20.
68
68
 
69
+ Upgrade later with:
70
+
71
+ ```bash
72
+ scopecairn update # or: npm install -g scopecairn@latest
73
+ scopecairn update --check # only check for a new version
74
+ ```
75
+
69
76
  ## Agent-driven setup
70
77
 
71
78
  Install once, then just type `scopecairn ./` to your AI agent —
@@ -148,6 +155,7 @@ scopecairn agents list
148
155
  | `test-select <target>` | List test files affected by a file/symbol change target |
149
156
  | `doctor --verbose` | Also surface adapter/graph errors from `.scopecairn/last-run.log` to stderr |
150
157
  | `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
158
+ | `update [--check]` | Update ScopeCairn to the latest npm release (`--check` only checks) |
151
159
 
152
160
  Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`)
153
161
  are read-only toward source and only write to `.scopecairn/` — safe to
@@ -159,8 +159,15 @@ function ensureMetrics(db) {
159
159
  try {
160
160
  const syms = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
161
161
  if (syms === 0) return 0;
162
- const c = db.prepare(`SELECT COUNT(*) AS n FROM node_metrics`).get().n;
163
- if (c === syms) return c;
162
+ const missing = db.prepare(
163
+ `SELECT COUNT(*) AS n FROM symbols s LEFT JOIN node_metrics m ON m.node_id = s.id WHERE m.node_id IS NULL`
164
+ ).get().n;
165
+ const orphans = db.prepare(
166
+ `SELECT COUNT(*) AS n FROM node_metrics m LEFT JOIN symbols s ON s.id = m.node_id WHERE s.id IS NULL`
167
+ ).get().n;
168
+ if (missing === 0 && orphans === 0) {
169
+ return db.prepare(`SELECT COUNT(*) AS n FROM node_metrics`).get().n;
170
+ }
164
171
  return refreshMetrics(db);
165
172
  } catch {
166
173
  return 0;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ensureMetrics,
4
4
  getMetrics
5
- } from "./chunk-ID4PMSFQ.js";
5
+ } from "./chunk-DY2AAM62.js";
6
6
  import {
7
7
  nodeInCycle
8
8
  } from "./chunk-UU5WHX3D.js";
@@ -642,7 +642,8 @@ function extractGeneric(source, lang) {
642
642
  let m;
643
643
  const importLine = /^\s*import\s+(.+?)\s*$/gm;
644
644
  while ((m = importLine.exec(source)) !== null) {
645
- 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];
646
647
  if (!mod || mod === "*") continue;
647
648
  const ln = lineOf3(source, m.index);
648
649
  symbols.push({ name: `import:${mod}`, type: "import", signature: m[0].trim().slice(0, 120), startLine: ln, endLine: ln });
@@ -780,9 +781,9 @@ function extractFile(relPath, source) {
780
781
  const lang = detectLanguage(relPath);
781
782
  switch (lang) {
782
783
  case "typescript":
783
- return extractTypeScript(source, relPath.endsWith("x"));
784
+ return extractTypeScript(source, relPath.endsWith(".tsx"));
784
785
  case "javascript":
785
- return extractTypeScript(source, relPath.endsWith("x"));
786
+ return extractTypeScript(source, relPath.endsWith(".jsx"));
786
787
  case "python":
787
788
  return extractPython(source);
788
789
  case "html":
@@ -835,7 +836,7 @@ var STOP = /* @__PURE__ */ new Set([
835
836
  "spec"
836
837
  ]);
837
838
  function tokenizeIdentifier(raw) {
838
- 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));
839
840
  return [...new Set(parts)];
840
841
  }
841
842
  function tokenizeTask(text) {
@@ -868,9 +869,11 @@ function seedSearch(db, tokens, limit = 30) {
868
869
  const q = tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
869
870
  let rows;
870
871
  try {
872
+ const lim = Math.max(1, Math.min(500, Math.floor(limit) || 30));
871
873
  rows = db.prepare(
872
874
  `SELECT symbol_id, bm25(symbol_index) AS rank
873
- FROM symbol_index WHERE symbol_index MATCH ? LIMIT ${limit}`
875
+ FROM symbol_index WHERE symbol_index MATCH ?
876
+ ORDER BY rank LIMIT ${lim}`
874
877
  ).all(q);
875
878
  } catch {
876
879
  return [];
@@ -898,9 +901,20 @@ function symbolIdInFile(db, fileId, name) {
898
901
  const r = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
899
902
  return r ? r.id : null;
900
903
  }
901
- function globalSymbolId(db, name) {
902
- const r = db.prepare(`SELECT id FROM symbols WHERE name = ? LIMIT 1`).get(name);
903
- 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;
904
918
  }
905
919
  function methodSymbolId(db, fileId, ids, name) {
906
920
  const local = ids.get(name);
@@ -974,9 +988,10 @@ function indexFileRelations(db, fileId, relPath, extraction, fileSym) {
974
988
  contains.run(fileSym, id);
975
989
  rels++;
976
990
  }
991
+ const srcDir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
977
992
  const resolveLocal = (name) => {
978
993
  if (name === "__file__") return fileSym;
979
- return ids.get(name) ?? symbolIdInFile(db, fileId, name) ?? globalSymbolId(db, name);
994
+ return ids.get(name) ?? symbolIdInFile(db, fileId, name) ?? globalSymbolId(db, name, srcDir);
980
995
  };
981
996
  for (const r of extraction.relations) {
982
997
  if (r.rel === "IMPORTS") {
@@ -1004,7 +1019,7 @@ function indexFileRelations(db, fileId, relPath, extraction, fileSym) {
1004
1019
  continue;
1005
1020
  }
1006
1021
  const src = resolveLocal(r.from);
1007
- 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);
1008
1023
  if (src && dst && src !== dst) {
1009
1024
  insRel.run(src, dst, r.rel, r.weight, r.confidence);
1010
1025
  rels++;
@@ -1033,9 +1048,11 @@ function linkTests(db) {
1033
1048
  for (const f of files) {
1034
1049
  const stemBase = testTarget(f.path);
1035
1050
  if (!stemBase) continue;
1051
+ const stemName = stemBase.split("/").pop();
1036
1052
  const matches = [...byPath.keys()].filter((p) => {
1037
- const noExt = p.replace(/\.[^.]+$/, "");
1038
- 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;
1039
1056
  });
1040
1057
  const srcSym = fileSymOf(f.id);
1041
1058
  for (const mp of matches) {
@@ -1160,13 +1177,13 @@ var META_BASENAMES = /* @__PURE__ */ new Set([
1160
1177
  "makefile",
1161
1178
  "package.json"
1162
1179
  ]);
1163
- function isIndexableMetaFile(posixPath) {
1180
+ function isIndexableMetaFile(posixPath, patterns) {
1164
1181
  if (isSecretFile(posixPath)) return false;
1165
1182
  const base = (posixPath.split("/").pop() ?? "").toLowerCase();
1166
1183
  if (META_BASENAMES.has(base)) return true;
1167
1184
  const dot = base.lastIndexOf(".");
1168
1185
  if (dot !== -1 && META_EXTS.has(base.slice(dot))) return true;
1169
- if (isProtected(posixPath)) return true;
1186
+ if (patterns ? isProtected(posixPath, patterns) : isProtected(posixPath)) return true;
1170
1187
  return false;
1171
1188
  }
1172
1189
  function ensureProtectedFile(repoRoot2) {
@@ -1222,6 +1239,7 @@ function loadConfig(repoRoot2) {
1222
1239
  cfg.wCochange = num("w_cochange", cfg.wCochange);
1223
1240
  cfg.maxDepth = Math.max(1, Math.min(4, Math.round(num("max_depth", cfg.maxDepth))));
1224
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)));
1225
1243
  } catch {
1226
1244
  }
1227
1245
  return cfg;
@@ -1259,6 +1277,7 @@ w_recency: 0.1
1259
1277
  w_cochange: 0.1
1260
1278
  max_depth: 2
1261
1279
  top_n: 20
1280
+ decay_per_hop: 0.7
1262
1281
  `
1263
1282
  );
1264
1283
  }
@@ -1314,6 +1333,32 @@ function enclosingFunction(db, fileId, line) {
1314
1333
  ).all(fileId, line);
1315
1334
  return rows.length > 0 ? rows[0].id : null;
1316
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
+ }
1317
1362
 
1318
1363
  // src/adapters/prisma.ts
1319
1364
  function parsePrismaModels(content) {
@@ -1426,6 +1471,25 @@ function prismaCalls(content) {
1426
1471
  function resolveModel(db, name) {
1427
1472
  return findSymbolId(db, name, ["model"]) ?? dbLowerModel(db, name);
1428
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
+ }
1429
1493
  function refreshQueries(db, files) {
1430
1494
  let n = 0;
1431
1495
  const del = db.prepare(
@@ -1433,19 +1497,12 @@ function refreshQueries(db, files) {
1433
1497
  );
1434
1498
  const ins = db.prepare(
1435
1499
  `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
1436
- VALUES (?, ?, 'QUERIES', ${WEIGHT.QUERIES}, 0.9)`
1500
+ VALUES (?, ?, 'QUERIES', ?, ?)`
1437
1501
  );
1438
1502
  for (const f of files) {
1439
- const fileSym = fileSymbolOf(db, f.fileId);
1440
- if (!fileSym) continue;
1441
1503
  del.run(f.fileId);
1442
- for (const call of prismaCalls(f.content)) {
1443
- const modelId = resolveModel(db, call.model);
1444
- if (!modelId) continue;
1445
- const line = lineOf4(f.content, call.index);
1446
- const caller = enclosingFunction(db, f.fileId, line) ?? fileSym;
1447
- if (caller === modelId) continue;
1448
- 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);
1449
1506
  n++;
1450
1507
  }
1451
1508
  }
@@ -1587,6 +1644,24 @@ function hasDrizzleMarker(content) {
1587
1644
  }
1588
1645
  return /db\s*\.\s*(select|selectDistinct|insert|update|delete)\b/.test(content);
1589
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
+ }
1590
1665
  function refreshDrizzleQueries(db, files) {
1591
1666
  let n = 0;
1592
1667
  const del = db.prepare(
@@ -1594,18 +1669,13 @@ function refreshDrizzleQueries(db, files) {
1594
1669
  );
1595
1670
  const ins = db.prepare(
1596
1671
  `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
1597
- VALUES (?, ?, 'QUERIES', ${WEIGHT.QUERIES}, 0.85)`
1672
+ VALUES (?, ?, 'QUERIES', ?, ?)`
1598
1673
  );
1599
1674
  for (const f of files) {
1600
- const fileSym = fileSymbolOf(db, f.fileId);
1601
- if (!fileSym || !hasDrizzleMarker(f.content)) continue;
1675
+ if (!hasDrizzleMarker(f.content)) continue;
1602
1676
  del.run(f.fileId);
1603
- for (const q of parseDrizzleQueries(f.content)) {
1604
- const target = findModelInFile(db, f.fileId, q.table) ?? findModelGlobal(db, q.table);
1605
- if (!target) continue;
1606
- const caller = enclosingFunction(db, f.fileId, lineOf4(f.content, q.index)) ?? fileSym;
1607
- if (caller === target) continue;
1608
- 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);
1609
1679
  n++;
1610
1680
  }
1611
1681
  }
@@ -1895,6 +1965,24 @@ function findModelGlobal2(db, name) {
1895
1965
  const lower = name.toLowerCase();
1896
1966
  return rows.find((r) => r.name.toLowerCase() === lower)?.id ?? null;
1897
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
+ }
1898
1986
  function refreshSqlAlchemyQueries(db, files) {
1899
1987
  let n = 0;
1900
1988
  const del = db.prepare(
@@ -1902,19 +1990,13 @@ function refreshSqlAlchemyQueries(db, files) {
1902
1990
  );
1903
1991
  const ins = db.prepare(
1904
1992
  `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
1905
- VALUES (?, ?, 'QUERIES', ${WEIGHT.QUERIES}, 0.85)`
1993
+ VALUES (?, ?, 'QUERIES', ?, ?)`
1906
1994
  );
1907
1995
  for (const f of files) {
1908
1996
  if (!hasSqlAlchemyMarker(f.content)) continue;
1909
- const fileSym = fileSymbolOf(db, f.fileId);
1910
- if (!fileSym) continue;
1911
1997
  del.run(f.fileId);
1912
- for (const q of parseSqlAlchemyQueries(f.content)) {
1913
- const target = findModelGlobal2(db, q.model);
1914
- if (!target) continue;
1915
- const caller = enclosingFunction(db, f.fileId, lineOf4(f.content, q.index)) ?? fileSym;
1916
- if (caller === target) continue;
1917
- ins.run(caller, target);
1998
+ for (const e of deriveSqlAlchemyQueries(db, f.fileId, f.content)) {
1999
+ ins.run(e.callerId, e.targetId, e.weight, e.confidence);
1918
2000
  n++;
1919
2001
  }
1920
2002
  }
@@ -1964,27 +2046,10 @@ var sqlalchemyAdapter = {
1964
2046
  relations++;
1965
2047
  }
1966
2048
  }
1967
- const del = ctx.db.prepare(
1968
- `DELETE FROM relationships WHERE relationship_type = 'QUERIES' AND source_id IN (SELECT id FROM symbols WHERE file_id = ?)`
1969
- );
1970
- const ins = ctx.db.prepare(
1971
- `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
1972
- VALUES (?, ?, 'QUERIES', ${WEIGHT.QUERIES}, 0.85)`
2049
+ relations += refreshSqlAlchemyQueries(
2050
+ ctx.db,
2051
+ files.map((f) => ({ fileId: f.fileId, rel: f.rel, content: f.content }))
1973
2052
  );
1974
- for (const f of files) {
1975
- if (!hasSqlAlchemyMarker(f.content)) continue;
1976
- const fileSym = fileSymbolOf(ctx.db, f.fileId);
1977
- if (!fileSym) continue;
1978
- del.run(f.fileId);
1979
- for (const q of parseSqlAlchemyQueries(f.content)) {
1980
- const target = findModelGlobal2(ctx.db, q.model);
1981
- if (!target) continue;
1982
- const caller = enclosingFunction(ctx.db, f.fileId, lineOf4(f.content, q.index)) ?? fileSym;
1983
- if (caller === target) continue;
1984
- ins.run(caller, target);
1985
- relations++;
1986
- }
1987
- }
1988
2053
  return { symbols, relations };
1989
2054
  }
1990
2055
  };
@@ -2037,8 +2102,10 @@ function resolveImportRel(fromRel, source) {
2037
2102
  const out = [];
2038
2103
  for (const p of parts) {
2039
2104
  if (p === "" || p === ".") continue;
2040
- if (p === "..") out.pop();
2041
- else out.push(p);
2105
+ if (p === "..") {
2106
+ if (out.length === 0) return null;
2107
+ out.pop();
2108
+ } else out.push(p);
2042
2109
  }
2043
2110
  return out.join("/");
2044
2111
  }
@@ -2169,29 +2236,54 @@ function runAdapters(db, repoRoot2, changed) {
2169
2236
  }
2170
2237
  }
2171
2238
  }
2172
- const refreshTargets = (id, relevant, refresh) => {
2173
- if (!active.some((a) => a.id === id)) return;
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) {
2174
2253
  try {
2175
- const all = db.prepare(`SELECT id, path FROM files`).all().filter((r) => relevant(r.path));
2176
- const inputs = [];
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
+ );
2177
2262
  for (const r of all) {
2263
+ if (!derivers.some((d) => d.relevant(r.path))) continue;
2264
+ let content;
2178
2265
  try {
2179
- inputs.push({
2180
- fileId: r.id,
2181
- rel: r.path,
2182
- content: fs11.readFileSync(path12.join(repoRoot2, ...r.path.split("/")), "utf8")
2183
- });
2266
+ content = fs11.readFileSync(path12.join(repoRoot2, ...r.path.split("/")), "utf8");
2184
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
+ }
2185
2276
  }
2186
2277
  }
2187
- relations += refresh(db, inputs);
2188
2278
  } catch (err) {
2189
2279
  logError("adapters", err, repoRoot2);
2190
2280
  }
2191
- };
2192
- refreshTargets("nextjs", nextjsAdapter.relevant, refreshQueries);
2193
- refreshTargets("drizzle", drizzleAdapter.relevant, refreshDrizzleQueries);
2194
- refreshTargets("sqlalchemy", sqlalchemyAdapter.relevant, refreshSqlAlchemyQueries);
2281
+ }
2282
+ try {
2283
+ cleanupOrphans(db);
2284
+ } catch (err) {
2285
+ logError("adapters", err, repoRoot2);
2286
+ }
2195
2287
  return { adapters: active.map((a) => a.id), symbols, relations };
2196
2288
  }
2197
2289
 
@@ -2647,9 +2739,15 @@ function ensureClusters(db) {
2647
2739
  try {
2648
2740
  const syms = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
2649
2741
  if (syms === 0) return 0;
2650
- const c = db.prepare(`SELECT COUNT(*) AS n FROM clusters`).get().n;
2651
- if (c === syms) return c;
2652
- refreshClusters(db);
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
+ }
2653
2751
  return db.prepare(`SELECT COUNT(*) AS n FROM clusters`).get().n;
2654
2752
  } catch {
2655
2753
  return 0;
@@ -3402,6 +3500,8 @@ function scanRepository(repoRoot2) {
3402
3500
  );
3403
3501
  let sourceFiles = 0;
3404
3502
  let metaFiles = 0;
3503
+ const protectedPatterns = loadProtectedPatterns(repoRoot2);
3504
+ const backfillDone = db.prepare(`SELECT value FROM meta WHERE key = 'backfill_done'`).get();
3405
3505
  const symbolCount = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
3406
3506
  let ftsCount = 0;
3407
3507
  try {
@@ -3409,7 +3509,7 @@ function scanRepository(repoRoot2) {
3409
3509
  } catch {
3410
3510
  ftsCount = 0;
3411
3511
  }
3412
- const backfill = symbolCount === 0 || ftsCount === 0;
3512
+ const backfill = !backfillDone && (symbolCount === 0 || ftsCount === 0);
3413
3513
  const pending = [];
3414
3514
  const metaChanged = [];
3415
3515
  for (const abs of allAbs) {
@@ -3417,7 +3517,7 @@ function scanRepository(repoRoot2) {
3417
3517
  if (isIgnored(rel, rules)) continue;
3418
3518
  totalFilesSeen++;
3419
3519
  const source = isSourceFile(abs);
3420
- const meta = !source && isIndexableMetaFile(rel);
3520
+ const meta = !source && isIndexableMetaFile(rel, protectedPatterns);
3421
3521
  if (!source && !meta) continue;
3422
3522
  if (source) sourceFiles++;
3423
3523
  else metaFiles++;
@@ -3478,6 +3578,10 @@ function scanRepository(repoRoot2) {
3478
3578
  `INSERT INTO meta(key, value) VALUES ('last_scan', datetime('now'))
3479
3579
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3480
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();
3481
3585
  linkTests(db);
3482
3586
  cleanupIndex(db);
3483
3587
  const graphDirty = inserted + updated + removed > 0 || adapterRun.symbols > 0 || adapterRun.relations > 0;
@@ -3909,7 +4013,7 @@ async function cmdDoctor(repoRoot2, opts) {
3909
4013
  const db = openDb(repoRoot2);
3910
4014
  try {
3911
4015
  const { refreshCycles, getCycles, describeCycle } = await import("./cycles-6KSI4OPZ.js");
3912
- const { ensureMetrics: ensureMetrics2, topByPageRank } = await import("./metrics-ZWWLUYJW.js");
4016
+ const { ensureMetrics: ensureMetrics2, topByPageRank } = await import("./metrics-BLUHSM64.js");
3913
4017
  const cycles = getCycles(db, 5);
3914
4018
  if (cycles.length === 0) {
3915
4019
  try {
@@ -3951,7 +4055,13 @@ async function cmdDoctor(repoRoot2, opts) {
3951
4055
  for (const t of AGENT_MATRIX) {
3952
4056
  const abs = path17.join(repoRoot2, t.rel);
3953
4057
  if (!fs17.existsSync(abs)) continue;
3954
- const text = fs17.readFileSync(abs, "utf8");
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
+ }
3955
4065
  if (t.mode === "merge" && !text.includes(MARKER)) continue;
3956
4066
  matrixOk++;
3957
4067
  const used = extractPrefixUsed(text);
@@ -4270,7 +4380,7 @@ function cmdRead(repoRoot2, kind, name) {
4270
4380
  console.log(`Cannot read ${node.file}.`);
4271
4381
  return;
4272
4382
  }
4273
- const start = Math.max(1, row.start_line - 1);
4383
+ const start = row.start_line;
4274
4384
  const end = Math.min(lines.length, Math.max(row.end_line, row.start_line + 40));
4275
4385
  console.log(`# ${node.name} (${node.type}) \u2014 ${node.file}:${start}-${end}`);
4276
4386
  console.log(lines.slice(start - 1, end).join("\n"));
@@ -4490,7 +4600,7 @@ function moduleOf2(file) {
4490
4600
  const i = file.indexOf("/");
4491
4601
  return i === -1 ? "." : file.slice(0, i);
4492
4602
  }
4493
- function classify(ranked, faninOf, threshold = 0.15) {
4603
+ function classify(ranked, faninOf, threshold = 0.15, protectedPatterns) {
4494
4604
  const relevant = ranked.filter((r) => r.score >= threshold);
4495
4605
  const files = [...new Set(relevant.map((r) => r.file))];
4496
4606
  const modules = [...new Set(files.map(moduleOf2))];
@@ -4504,7 +4614,9 @@ function classify(ranked, faninOf, threshold = 0.15) {
4504
4614
  "type"
4505
4615
  ]);
4506
4616
  const highFanin = relevant.filter((r) => DEFINITIONAL.has(r.type) && faninOf(r.id) >= FANIN_THRESHOLD).map((r) => r.name);
4507
- const protectedHits = files.filter((f) => isProtected(f));
4617
+ const protectedHits = files.filter(
4618
+ (f) => protectedPatterns ? isProtected(f, protectedPatterns) : isProtected(f)
4619
+ );
4508
4620
  let complexity = "SIMPLE";
4509
4621
  if (files.length >= 3) {
4510
4622
  complexity = "COMPLEX";
@@ -4747,7 +4859,8 @@ function looksLikeRepoPath(db, repoRoot2, task) {
4747
4859
  } catch {
4748
4860
  }
4749
4861
  try {
4750
- 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}`);
4751
4864
  return !!row;
4752
4865
  } catch {
4753
4866
  return false;
@@ -4874,7 +4987,31 @@ function changedFilesVsHead(repoRoot2) {
4874
4987
  let p = line.slice(3).trim();
4875
4988
  const arrow = p.indexOf(" -> ");
4876
4989
  if (arrow >= 0) p = p.slice(arrow + 4);
4877
- if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
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
+ }
4878
5015
  if (p) files.push(p);
4879
5016
  }
4880
5017
  return files;
@@ -4924,7 +5061,7 @@ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan
4924
5061
  const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships WHERE target_id = ?`).get(id);
4925
5062
  return r.n;
4926
5063
  };
4927
- const cls = classify(result.ranked, faninOf);
5064
+ const cls = classify(result.ranked, faninOf, 0.15, loadProtectedPatterns(repoRoot2));
4928
5065
  const t = db.prepare(`INSERT INTO tasks(description, complexity) VALUES (?, ?)`).run(task, opts.escalate ? "COMPLEX" : cls.complexity);
4929
5066
  const taskId = Number(t.lastInsertRowid);
4930
5067
  const insCtx = db.prepare(
@@ -4937,6 +5074,9 @@ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan
4937
5074
  const mode = opts.mode ?? "NORMAL";
4938
5075
  const effectiveMode = mode === "NORMAL" && cls.complexity === "SIMPLE" && cls.estimatedFiles <= 1 ? "FAST" : mode;
4939
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
+ }
4940
5080
  const changed = changedFilesVsHead(repoRoot2);
4941
5081
  if (changed.length > 0) {
4942
5082
  console.log(
@@ -4980,8 +5120,8 @@ function cmdImpact(repoRoot2, target, opts = {}) {
4980
5120
  if (seeds.length === 0) {
4981
5121
  const rows = db.prepare(
4982
5122
  `SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
4983
- JOIN files f ON f.id = s.file_id WHERE f.path LIKE ? LIMIT 20`
4984
- ).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)}`);
4985
5125
  seeds = rows;
4986
5126
  }
4987
5127
  if (seeds.length === 0) {
@@ -5188,7 +5328,7 @@ function uninstallTarget(repoRoot2, target) {
5188
5328
  }
5189
5329
  function pruneEmptyDirs(dir, stop) {
5190
5330
  let cur = dir;
5191
- while (cur.startsWith(stop) && cur !== stop) {
5331
+ while (cur !== stop && cur.toLowerCase().startsWith(stop.toLowerCase())) {
5192
5332
  try {
5193
5333
  fs22.rmdirSync(cur);
5194
5334
  } catch {
@@ -5613,47 +5753,13 @@ function cmdTestSelect(repoRoot2, target) {
5613
5753
  }
5614
5754
  }
5615
5755
 
5616
- // src/commands/agents.ts
5617
- function registerAgentCommands(program2, repoRoot2) {
5618
- const list = program2.command("agents").description("List supported agent installers");
5619
- list.command("list").description("Show agent ids").action(() => {
5620
- console.log(agentIds().join("\n"));
5621
- });
5622
- const anti = program2.command("antigravity").description("Antigravity IDE integration");
5623
- anti.command("install").description("Install Skill + Workflow, print allowlist guide").action(() => {
5624
- const root = repoRoot2();
5625
- const prefix = detectPrefix(root);
5626
- for (const line of installAntigravity(root, prefix)) console.log(`\u2713 ${line}`);
5627
- console.log("");
5628
- console.log(allowlistText(root));
5629
- });
5630
- anti.command("uninstall").description("Remove Skill + Workflow").action(() => {
5631
- for (const line of uninstallAntigravity(repoRoot2())) console.log(`\u2713 ${line}`);
5632
- });
5633
- for (const t of AGENT_MATRIX) {
5634
- if (t.id === "agents") continue;
5635
- const cmd = program2.command(t.id).description(`${t.label} integration`);
5636
- cmd.command("install").description(`Write ${t.rel}`).action(() => {
5637
- const root = repoRoot2();
5638
- const prefix = detectPrefix(root);
5639
- const r = installTarget(root, t, prefix);
5640
- console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
5641
- if (t.id === "opencode") {
5642
- console.log("");
5643
- console.log(opencodePermissionGuide(prefix));
5644
- }
5645
- });
5646
- cmd.command("uninstall").description(`Remove ScopeCairn block from ${t.rel}`).action(() => {
5647
- const r = uninstallTarget(repoRoot2(), t);
5648
- console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
5649
- });
5650
- }
5651
- }
5756
+ // src/commands/update.ts
5757
+ import { execFileSync as execFileSync3, execSync } from "child_process";
5652
5758
 
5653
5759
  // package.json
5654
5760
  var package_default = {
5655
5761
  name: "scopecairn",
5656
- version: "0.6.0",
5762
+ version: "0.7.0",
5657
5763
  description: "ScopeCairn \u2014 Codebase Intelligence & Agent Optimization Layer (local-first)",
5658
5764
  license: "MIT",
5659
5765
  type: "module",
@@ -5687,7 +5793,8 @@ var package_default = {
5687
5793
  clean: "tsx src/cli.ts clean",
5688
5794
  rebuild: "tsx src/cli.ts rebuild",
5689
5795
  test: "tsx --test tests/*.test.ts",
5690
- typecheck: "tsc --noEmit"
5796
+ typecheck: "tsc --noEmit",
5797
+ changelog: "node scripts/changelog.js"
5691
5798
  },
5692
5799
  dependencies: {
5693
5800
  commander: "^12.1.0",
@@ -5702,6 +5809,85 @@ var package_default = {
5702
5809
  }
5703
5810
  };
5704
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
+
5705
5891
  // src/cli.ts
5706
5892
  var program = new Command();
5707
5893
  program.name("scopecairn").description("ScopeCairn \u2014 local-first codebase intelligence layer").version(package_default.version).addHelpText(
@@ -5758,6 +5944,7 @@ program.command("init [path]").description("Initial indexing + Skill, Workflow,
5758
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(
5759
5945
  (opts) => cmdBenchmark(repoRoot(), opts)
5760
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));
5761
5948
  registerAgentCommands(program, repoRoot);
5762
5949
  program.parseAsync(process.argv);
5763
5950
  export {
@@ -7,7 +7,7 @@ import {
7
7
  getMetrics,
8
8
  refreshMetrics,
9
9
  topByPageRank
10
- } from "./chunk-ID4PMSFQ.js";
10
+ } from "./chunk-DY2AAM62.js";
11
11
  export {
12
12
  PAGERANK_DAMPING,
13
13
  computeBetweenness,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scopecairn",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "ScopeCairn — Codebase Intelligence & Agent Optimization Layer (local-first)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,8 @@
34
34
  "clean": "tsx src/cli.ts clean",
35
35
  "rebuild": "tsx src/cli.ts rebuild",
36
36
  "test": "tsx --test tests/*.test.ts",
37
- "typecheck": "tsc --noEmit"
37
+ "typecheck": "tsc --noEmit",
38
+ "changelog": "node scripts/changelog.js"
38
39
  },
39
40
  "dependencies": {
40
41
  "commander": "^12.1.0",