scopecairn 0.3.0 → 0.6.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-ID4PMSFQ.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', ?)
@@ -1242,8 +1275,8 @@ top_n: 20
1242
1275
  }
1243
1276
 
1244
1277
  // src/adapters/index.ts
1245
- import fs6 from "fs";
1246
- import path7 from "path";
1278
+ import fs11 from "fs";
1279
+ import path12 from "path";
1247
1280
 
1248
1281
  // src/adapters/types.ts
1249
1282
  function findSymbolId(db, name, types) {
@@ -1640,15 +1673,458 @@ var drizzleAdapter = {
1640
1673
  }
1641
1674
  };
1642
1675
 
1676
+ // src/adapters/express.ts
1677
+ import fs6 from "fs";
1678
+ import path7 from "path";
1679
+ var METHODS = ["get", "post", "put", "delete", "patch", "use"];
1680
+ function parseExpressRoutes(content) {
1681
+ const out = [];
1682
+ const re = new RegExp(
1683
+ `\\b(?:app|router)\\s*\\.\\s*(${METHODS.join("|")})\\s*\\(\\s*["'\`]([^"'\`]+)["'\`]\\s*,\\s*([^,)\\s]+)?`,
1684
+ "g"
1685
+ );
1686
+ let m;
1687
+ while ((m = re.exec(content)) !== null) {
1688
+ const rawHandler = m[3] ?? null;
1689
+ const handler = rawHandler && /^[A-Za-z_$][\w$]*$/.test(rawHandler) ? rawHandler : null;
1690
+ out.push({
1691
+ method: m[1].toUpperCase(),
1692
+ path: m[2],
1693
+ handler,
1694
+ line: lineOf4(content, m.index)
1695
+ });
1696
+ }
1697
+ return out;
1698
+ }
1699
+ function hasExpressMarker(content) {
1700
+ return content.includes("express") || /\b(?:app|router)\s*\.\s*(get|post|put|delete|patch|use)\s*\(/.test(content);
1701
+ }
1702
+ function findInFile2(db, fileId, name) {
1703
+ const row = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
1704
+ return row ? row.id : null;
1705
+ }
1706
+ var expressAdapter = {
1707
+ id: "express",
1708
+ label: "Express",
1709
+ detect: (files) => files.some(
1710
+ (f) => f.endsWith("package.json") || /\.(js|ts|mjs|cjs)$/.test(f)
1711
+ ),
1712
+ confirm: (repoRoot2) => {
1713
+ try {
1714
+ const pkg = fs6.readFileSync(path7.join(repoRoot2, "package.json"), "utf8");
1715
+ return pkg.includes('"express"') || pkg.includes("'express'");
1716
+ } catch {
1717
+ return false;
1718
+ }
1719
+ },
1720
+ relevant: (rel) => /\.(js|ts|mjs|cjs)$/.test(rel),
1721
+ apply(ctx, files) {
1722
+ let symbols = 0;
1723
+ let relations = 0;
1724
+ for (const f of files) {
1725
+ if (!hasExpressMarker(f.content)) continue;
1726
+ const fileSym = fileSymbolOf(ctx.db, f.fileId);
1727
+ if (!fileSym) continue;
1728
+ for (const r of parseExpressRoutes(f.content)) {
1729
+ const routeId = ensureAdapterSymbol(ctx.db, f.fileId, {
1730
+ name: `${r.method} ${r.path}`,
1731
+ type: "route",
1732
+ signature: `${r.method} ${r.path}`,
1733
+ startLine: r.line,
1734
+ endLine: r.line
1735
+ });
1736
+ symbols++;
1737
+ insertRelation(ctx.db, fileSym, routeId, "CONTAINS", 1, 1);
1738
+ relations++;
1739
+ if (r.handler) {
1740
+ const handlerId = findInFile2(ctx.db, f.fileId, r.handler);
1741
+ if (handlerId) {
1742
+ insertRelation(ctx.db, fileSym, handlerId, "USES", WEIGHT.USES, 0.9);
1743
+ relations++;
1744
+ const enclosing = enclosingFunction(ctx.db, f.fileId, r.line);
1745
+ if (enclosing && enclosing !== handlerId) {
1746
+ insertRelation(ctx.db, enclosing, handlerId, "CALLS", WEIGHT.CALLS, 0.7);
1747
+ relations++;
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ }
1753
+ return { symbols, relations };
1754
+ }
1755
+ };
1756
+
1757
+ // src/adapters/fastapi.ts
1758
+ import fs7 from "fs";
1759
+ import path8 from "path";
1760
+ var METHODS2 = ["get", "post", "put", "delete", "patch", "head", "options"];
1761
+ function parseFastApiRoutes(content) {
1762
+ const out = [];
1763
+ const re = new RegExp(
1764
+ `^\\s*@\\w+\\s*\\.\\s*(${METHODS2.join("|")})\\s*\\(\\s*["']([^"']+)["']`,
1765
+ "gm"
1766
+ );
1767
+ let m;
1768
+ while ((m = re.exec(content)) !== null) {
1769
+ const window = content.slice(m.index, m.index + 400);
1770
+ const hm = /(?:async\s+)?def\s+(\w+)/.exec(window);
1771
+ out.push({
1772
+ method: m[1].toUpperCase(),
1773
+ path: m[2],
1774
+ handler: hm ? hm[1] : null,
1775
+ line: lineOf4(content, m.index)
1776
+ });
1777
+ }
1778
+ return out;
1779
+ }
1780
+ function hasFastApiMarker(content) {
1781
+ return content.includes("fastapi") || /^\s*@\w+\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(/m.test(content);
1782
+ }
1783
+ function findInFile3(db, fileId, name) {
1784
+ const row = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
1785
+ return row ? row.id : null;
1786
+ }
1787
+ function readDepFile(repoRoot2, names) {
1788
+ let out = "";
1789
+ for (const n of names) {
1790
+ try {
1791
+ out += fs7.readFileSync(path8.join(repoRoot2, n), "utf8");
1792
+ } catch {
1793
+ }
1794
+ }
1795
+ try {
1796
+ const dir = fs7.readdirSync(repoRoot2);
1797
+ for (const f of dir) {
1798
+ if (/^requirements.*\.txt$/.test(f)) {
1799
+ try {
1800
+ out += fs7.readFileSync(path8.join(repoRoot2, f), "utf8");
1801
+ } catch {
1802
+ }
1803
+ }
1804
+ }
1805
+ } catch {
1806
+ }
1807
+ return out;
1808
+ }
1809
+ var fastapiAdapter = {
1810
+ id: "fastapi",
1811
+ label: "FastAPI",
1812
+ detect: (files) => files.some(
1813
+ (f) => /\.py$/.test(f) || /(^|\/)requirements.*\.txt$/.test(f) || /(^|\/)pyproject\.toml$/.test(f)
1814
+ ),
1815
+ confirm: (repoRoot2) => {
1816
+ try {
1817
+ const pkg = fs7.readFileSync(path8.join(repoRoot2, "package.json"), "utf8");
1818
+ if (pkg.includes('"fastapi"')) return true;
1819
+ } catch {
1820
+ }
1821
+ const deps = readDepFile(repoRoot2, ["requirements.txt", "pyproject.toml"]);
1822
+ return deps.toLowerCase().includes("fastapi");
1823
+ },
1824
+ relevant: (rel) => /\.py$/.test(rel),
1825
+ apply(ctx, files) {
1826
+ let symbols = 0;
1827
+ let relations = 0;
1828
+ for (const f of files) {
1829
+ if (!hasFastApiMarker(f.content)) continue;
1830
+ const fileSym = fileSymbolOf(ctx.db, f.fileId);
1831
+ if (!fileSym) continue;
1832
+ for (const r of parseFastApiRoutes(f.content)) {
1833
+ const routeId = ensureAdapterSymbol(ctx.db, f.fileId, {
1834
+ name: `${r.method} ${r.path}`,
1835
+ type: "route",
1836
+ signature: `${r.method} ${r.path}`,
1837
+ startLine: r.line,
1838
+ endLine: r.line
1839
+ });
1840
+ symbols++;
1841
+ insertRelation(ctx.db, fileSym, routeId, "CONTAINS", 1, 1);
1842
+ relations++;
1843
+ if (r.handler) {
1844
+ const handlerId = findInFile3(ctx.db, f.fileId, r.handler);
1845
+ if (handlerId) {
1846
+ insertRelation(ctx.db, fileSym, handlerId, "USES", 0.5, 0.9);
1847
+ relations++;
1848
+ insertRelation(ctx.db, handlerId, routeId, "ROUTES_TO", 0.9, 1);
1849
+ relations++;
1850
+ }
1851
+ }
1852
+ }
1853
+ }
1854
+ return { symbols, relations };
1855
+ }
1856
+ };
1857
+
1858
+ // src/adapters/sqlalchemy.ts
1859
+ import fs8 from "fs";
1860
+ import path9 from "path";
1861
+ function parseSqlAlchemyModels(content) {
1862
+ const out = [];
1863
+ const re = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
1864
+ let m;
1865
+ while ((m = re.exec(content)) !== null) {
1866
+ const rest = content.slice(m.index, m.index + 1500);
1867
+ const nextClass = rest.slice(1).search(/^class\s+\w+/m);
1868
+ const body = nextClass > 0 ? rest.slice(0, nextClass + 1) : rest;
1869
+ const tm = /__tablename__\s*=\s*["']([^"']+)["']/.exec(body);
1870
+ const isBase = /\b(Base|DeclarativeBase|declarative_base|Model)\b/.test(m[2]);
1871
+ if (tm || isBase) {
1872
+ out.push({
1873
+ name: m[1],
1874
+ table: tm ? tm[1] : null,
1875
+ line: lineOf4(content, m.index)
1876
+ });
1877
+ }
1878
+ }
1879
+ return out;
1880
+ }
1881
+ function parseSqlAlchemyQueries(content) {
1882
+ const out = [];
1883
+ const re = /(?:session\s*\.\s*query|select)\s*\(\s*(\w+)/g;
1884
+ let m;
1885
+ while ((m = re.exec(content)) !== null) {
1886
+ out.push({ model: m[1], index: m.index });
1887
+ }
1888
+ return out;
1889
+ }
1890
+ function hasSqlAlchemyMarker(content) {
1891
+ return content.includes("sqlalchemy") || /__tablename__/.test(content) || /session\s*\.\s*query\s*\(/.test(content) || /\bselect\s*\(/.test(content);
1892
+ }
1893
+ function findModelGlobal2(db, name) {
1894
+ const rows = db.prepare(`SELECT id, name FROM symbols WHERE type = 'model'`).all();
1895
+ const lower = name.toLowerCase();
1896
+ return rows.find((r) => r.name.toLowerCase() === lower)?.id ?? null;
1897
+ }
1898
+ function refreshSqlAlchemyQueries(db, files) {
1899
+ let n = 0;
1900
+ const del = db.prepare(
1901
+ `DELETE FROM relationships WHERE relationship_type = 'QUERIES' AND source_id IN (SELECT id FROM symbols WHERE file_id = ?)`
1902
+ );
1903
+ const ins = db.prepare(
1904
+ `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
1905
+ VALUES (?, ?, 'QUERIES', ${WEIGHT.QUERIES}, 0.85)`
1906
+ );
1907
+ for (const f of files) {
1908
+ if (!hasSqlAlchemyMarker(f.content)) continue;
1909
+ const fileSym = fileSymbolOf(db, f.fileId);
1910
+ if (!fileSym) continue;
1911
+ 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);
1918
+ n++;
1919
+ }
1920
+ }
1921
+ return n;
1922
+ }
1923
+ var sqlalchemyAdapter = {
1924
+ id: "sqlalchemy",
1925
+ label: "SQLAlchemy",
1926
+ detect: (files) => files.some(
1927
+ (f) => /\.py$/.test(f) || /(^|\/)requirements.*\.txt$/.test(f) || /(^|\/)pyproject\.toml$/.test(f)
1928
+ ),
1929
+ confirm: (repoRoot2) => {
1930
+ let deps = "";
1931
+ try {
1932
+ const dir = fs8.readdirSync(repoRoot2);
1933
+ for (const f of dir) {
1934
+ if (/^requirements.*\.txt$/.test(f) || f === "pyproject.toml") {
1935
+ try {
1936
+ deps += fs8.readFileSync(path9.join(repoRoot2, f), "utf8");
1937
+ } catch {
1938
+ }
1939
+ }
1940
+ }
1941
+ } catch {
1942
+ return false;
1943
+ }
1944
+ return deps.toLowerCase().includes("sqlalchemy");
1945
+ },
1946
+ relevant: (rel) => /\.py$/.test(rel),
1947
+ apply(ctx, files) {
1948
+ let symbols = 0;
1949
+ let relations = 0;
1950
+ for (const f of files) {
1951
+ if (!hasSqlAlchemyMarker(f.content)) continue;
1952
+ const fileSym = fileSymbolOf(ctx.db, f.fileId);
1953
+ if (!fileSym) continue;
1954
+ for (const model of parseSqlAlchemyModels(f.content)) {
1955
+ const id = ensureAdapterSymbol(ctx.db, f.fileId, {
1956
+ name: model.name,
1957
+ type: "model",
1958
+ signature: model.table ? `model ${model.name} ("${model.table}")` : `model ${model.name}`,
1959
+ startLine: model.line,
1960
+ endLine: model.line
1961
+ });
1962
+ symbols++;
1963
+ insertRelation(ctx.db, fileSym, id, "CONTAINS", 1, 1);
1964
+ relations++;
1965
+ }
1966
+ }
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)`
1973
+ );
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
+ return { symbols, relations };
1989
+ }
1990
+ };
1991
+
1992
+ // src/adapters/vue.ts
1993
+ import fs9 from "fs";
1994
+ import path10 from "path";
1995
+ function parseVueImports(content) {
1996
+ const out = [];
1997
+ const re = /import\s+(\w+)\s+from\s+["']([^"']+\.vue)["']/g;
1998
+ let m;
1999
+ while ((m = re.exec(content)) !== null) {
2000
+ out.push({ name: m[1], source: m[2], line: lineOf4(content, m.index) });
2001
+ }
2002
+ return out;
2003
+ }
2004
+ function parseTemplateTags(content) {
2005
+ const tm = /<template[^>]*>([\s\S]*?)<\/template>/.exec(content);
2006
+ if (!tm) return [];
2007
+ const body = tm[1];
2008
+ const tags = /* @__PURE__ */ new Set();
2009
+ const re = /<\s*([A-Z][\w]*|[a-z][\w]*(?:-[\w]+)+)/g;
2010
+ let m;
2011
+ while ((m = re.exec(body)) !== null) tags.add(m[1]);
2012
+ return [...tags];
2013
+ }
2014
+ function kebabToPascal(s) {
2015
+ return s.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
2016
+ }
2017
+ function usedVueComponents(content) {
2018
+ const imports = parseVueImports(content);
2019
+ if (imports.length === 0) return [];
2020
+ const tags = new Set(parseTemplateTags(content));
2021
+ return imports.filter((imp) => {
2022
+ const kebab = imp.name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2023
+ return tags.has(imp.name) || tags.has(kebab) || tags.has(kebabToPascal(kebab));
2024
+ });
2025
+ }
2026
+ function vueComponentName(rel) {
2027
+ const base = rel.split("/").pop() ?? rel;
2028
+ return base.replace(/\.vue$/, "");
2029
+ }
2030
+ function hasVueDefineComponent(content) {
2031
+ return /\bdefineComponent\s*\(/.test(content);
2032
+ }
2033
+ function resolveImportRel(fromRel, source) {
2034
+ if (!source.startsWith(".")) return null;
2035
+ const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
2036
+ const parts = (dir ? dir + "/" + source : source).split("/");
2037
+ const out = [];
2038
+ for (const p of parts) {
2039
+ if (p === "" || p === ".") continue;
2040
+ if (p === "..") out.pop();
2041
+ else out.push(p);
2042
+ }
2043
+ return out.join("/");
2044
+ }
2045
+ var vueAdapter = {
2046
+ id: "vue",
2047
+ label: "Vue",
2048
+ detect: (files) => files.some((f) => /\.vue$/.test(f) || f.endsWith("package.json")),
2049
+ confirm: (repoRoot2) => {
2050
+ try {
2051
+ const pkg = fs9.readFileSync(path10.join(repoRoot2, "package.json"), "utf8");
2052
+ return pkg.includes('"vue"') || pkg.includes("'vue'");
2053
+ } catch {
2054
+ return false;
2055
+ }
2056
+ },
2057
+ relevant: (rel) => /\.vue$/.test(rel),
2058
+ apply(ctx, files) {
2059
+ let symbols = 0;
2060
+ let relations = 0;
2061
+ for (const f of files) {
2062
+ const fileSym = fileSymbolOf(ctx.db, f.fileId);
2063
+ if (!fileSym) continue;
2064
+ const name = vueComponentName(f.rel);
2065
+ const line = hasVueDefineComponent(f.content) ? lineOf4(f.content, f.content.search(/\bdefineComponent\s*\(/)) : 1;
2066
+ const id = ensureAdapterSymbol(ctx.db, f.fileId, {
2067
+ name,
2068
+ type: "component",
2069
+ signature: `component ${name}`,
2070
+ startLine: line,
2071
+ endLine: line
2072
+ });
2073
+ symbols++;
2074
+ insertRelation(ctx.db, fileSym, id, "CONTAINS", 1, 1);
2075
+ relations++;
2076
+ for (const imp of usedVueComponents(f.content)) {
2077
+ const targetRel = resolveImportRel(f.rel, imp.source);
2078
+ if (!targetRel) continue;
2079
+ const row = ctx.db.prepare(`SELECT id FROM files WHERE path = ? LIMIT 1`).get(targetRel);
2080
+ if (!row) continue;
2081
+ const targetFileSym = fileSymbolOf(ctx.db, row.id);
2082
+ if (!targetFileSym) continue;
2083
+ insertRelation(ctx.db, fileSym, targetFileSym, "IMPORTS", WEIGHT.IMPORTS, 0.9);
2084
+ relations++;
2085
+ }
2086
+ }
2087
+ return { symbols, relations };
2088
+ }
2089
+ };
2090
+
2091
+ // src/log.ts
2092
+ import fs10 from "fs";
2093
+ import path11 from "path";
2094
+ function lastRunLogPath(repoRoot2 = process.cwd()) {
2095
+ return path11.join(repoRoot2, ".scopecairn", "last-run.log");
2096
+ }
2097
+ function append(repoRoot2, scope, text) {
2098
+ const root = repoRoot2 ?? process.cwd();
2099
+ try {
2100
+ fs10.mkdirSync(path11.dirname(lastRunLogPath(root)), { recursive: true });
2101
+ fs10.appendFileSync(lastRunLogPath(root), text + "\n", "utf8");
2102
+ } catch {
2103
+ }
2104
+ }
2105
+ function logError(scope, err, repoRoot2) {
2106
+ const message = err instanceof Error ? err.message : String(err);
2107
+ append(repoRoot2, scope, `${(/* @__PURE__ */ new Date()).toISOString()} [${scope}] ${message}`);
2108
+ }
2109
+
1643
2110
  // src/adapters/index.ts
1644
- var ADAPTERS = [prismaAdapter, drizzleAdapter, nextjsAdapter];
2111
+ var ADAPTERS = [
2112
+ prismaAdapter,
2113
+ drizzleAdapter,
2114
+ sqlalchemyAdapter,
2115
+ nextjsAdapter,
2116
+ expressAdapter,
2117
+ fastapiAdapter,
2118
+ vueAdapter
2119
+ ];
1645
2120
  function detectAdapters(files, repoRoot2) {
1646
2121
  return ADAPTERS.filter((a) => {
1647
2122
  try {
1648
2123
  if (!a.detect(files)) return false;
1649
2124
  if (repoRoot2 && a.confirm) return a.confirm(repoRoot2, files);
1650
2125
  return true;
1651
- } catch {
2126
+ } catch (err) {
2127
+ logError("adapters", err, repoRoot2);
1652
2128
  return false;
1653
2129
  }
1654
2130
  });
@@ -1664,10 +2140,10 @@ function runAdapters(db, repoRoot2, changed) {
1664
2140
  const inputs = [];
1665
2141
  for (const c of changed) {
1666
2142
  if (!adapter.relevant(c.rel)) continue;
1667
- const abs = path7.join(repoRoot2, ...c.rel.split("/"));
2143
+ const abs = path12.join(repoRoot2, ...c.rel.split("/"));
1668
2144
  let content;
1669
2145
  try {
1670
- content = fs6.readFileSync(abs, "utf8");
2146
+ content = fs11.readFileSync(abs, "utf8");
1671
2147
  } catch {
1672
2148
  continue;
1673
2149
  }
@@ -1679,7 +2155,8 @@ function runAdapters(db, repoRoot2, changed) {
1679
2155
  symbols += r.symbols;
1680
2156
  relations += r.relations;
1681
2157
  for (const i of inputs) touched.add(i.fileId);
1682
- } catch {
2158
+ } catch (err) {
2159
+ logError("adapters", err, repoRoot2);
1683
2160
  }
1684
2161
  }
1685
2162
  for (const fileId of touched) {
@@ -1687,7 +2164,8 @@ function runAdapters(db, repoRoot2, changed) {
1687
2164
  if (row) {
1688
2165
  try {
1689
2166
  syncFileIndex(db, fileId, row.path);
1690
- } catch {
2167
+ } catch (err) {
2168
+ logError("adapters", err, repoRoot2);
1691
2169
  }
1692
2170
  }
1693
2171
  }
@@ -1701,28 +2179,1199 @@ function runAdapters(db, repoRoot2, changed) {
1701
2179
  inputs.push({
1702
2180
  fileId: r.id,
1703
2181
  rel: r.path,
1704
- content: fs6.readFileSync(path7.join(repoRoot2, ...r.path.split("/")), "utf8")
2182
+ content: fs11.readFileSync(path12.join(repoRoot2, ...r.path.split("/")), "utf8")
1705
2183
  });
1706
2184
  } catch {
1707
2185
  }
1708
2186
  }
1709
- relations += refresh(db, inputs);
1710
- } catch {
2187
+ relations += refresh(db, inputs);
2188
+ } catch (err) {
2189
+ logError("adapters", err, repoRoot2);
2190
+ }
2191
+ };
2192
+ refreshTargets("nextjs", nextjsAdapter.relevant, refreshQueries);
2193
+ refreshTargets("drizzle", drizzleAdapter.relevant, refreshDrizzleQueries);
2194
+ refreshTargets("sqlalchemy", sqlalchemyAdapter.relevant, refreshSqlAlchemyQueries);
2195
+ return { adapters: active.map((a) => a.id), symbols, relations };
2196
+ }
2197
+
2198
+ // src/graph/export.ts
2199
+ import fs12 from "fs";
2200
+ import path13 from "path";
2201
+ var GRAPH_MD_NAME = "GRAPH.md";
2202
+ var MAX_ROWS = 20;
2203
+ var MAX_LINES = 160;
2204
+ function buildGraphMd(db, repoRoot2) {
2205
+ const one = (sql) => {
2206
+ try {
2207
+ return db.prepare(sql).get().n;
2208
+ } catch {
2209
+ return 0;
2210
+ }
2211
+ };
2212
+ const files = one(`SELECT COUNT(*) AS n FROM files`);
2213
+ const symbols = one(`SELECT COUNT(*) AS n FROM symbols`);
2214
+ const rels = one(`SELECT COUNT(*) AS n FROM relationships`);
2215
+ const lines = [
2216
+ `# Codebase Graph`,
2217
+ ``,
2218
+ `> Generated by \`scopecairn scan\`. Do not edit \u2014 regenerated when the graph changes.`,
2219
+ `> Visual map: open \`.scopecairn/GRAPH.html\` in a browser (offline, no dependencies).`,
2220
+ `> For task-specific context, run \`scopecairn context "<task>"\` instead of reading this whole file.`,
2221
+ ``,
2222
+ `## Stats`,
2223
+ ``,
2224
+ `- Files: ${files} | Symbols: ${symbols} | Relationships: ${rels}`,
2225
+ ``
2226
+ ];
2227
+ try {
2228
+ const mods = db.prepare(
2229
+ `SELECT CASE WHEN instr(path, '/') > 0 THEN substr(path, 1, instr(path, '/') - 1) ELSE '.' END AS mod,
2230
+ COUNT(*) AS n FROM files GROUP BY mod ORDER BY n DESC LIMIT ${MAX_ROWS}`
2231
+ ).all();
2232
+ if (mods.length > 0) {
2233
+ lines.push(`## Modules`, ``);
2234
+ for (const m of mods) lines.push(`- ${m.mod}/ \u2014 ${m.n} files`);
2235
+ lines.push(``);
2236
+ }
2237
+ } catch {
2238
+ }
2239
+ try {
2240
+ const top = db.prepare(
2241
+ `SELECT s.name, s.type, f.path AS file, COUNT(*) AS fanin
2242
+ FROM relationships r
2243
+ JOIN symbols s ON s.id = r.target_id
2244
+ JOIN files f ON f.id = s.file_id
2245
+ WHERE s.type IN ('function','class','method','component','interface','type')
2246
+ GROUP BY r.target_id ORDER BY fanin DESC LIMIT ${MAX_ROWS}`
2247
+ ).all();
2248
+ if (top.length > 0) {
2249
+ lines.push(`## Most-used symbols`, ``);
2250
+ for (const t of top) lines.push(`- ${t.name} (${t.type}, ${t.file}) \u2190 ${t.fanin}x`);
2251
+ lines.push(``);
2252
+ }
2253
+ } catch {
2254
+ }
2255
+ const symList = (type, title) => {
2256
+ try {
2257
+ const rows = db.prepare(
2258
+ `SELECT s.name, f.path AS file FROM symbols s
2259
+ JOIN files f ON f.id = s.file_id
2260
+ WHERE s.type = ? ORDER BY s.name LIMIT ${MAX_ROWS}`
2261
+ ).all(type);
2262
+ if (rows.length > 0) {
2263
+ lines.push(`## ${title}`, ``);
2264
+ for (const r of rows) lines.push(`- ${r.name} \u2014 ${r.file}`);
2265
+ lines.push(``);
2266
+ }
2267
+ } catch {
2268
+ }
2269
+ };
2270
+ symList("route", "Routes");
2271
+ symList("model", "Models");
2272
+ try {
2273
+ const top = db.prepare(
2274
+ `SELECT s.name, s.type, f.path AS file, m.pagerank AS pr
2275
+ FROM node_metrics m
2276
+ JOIN symbols s ON s.id = m.node_id
2277
+ JOIN files f ON f.id = s.file_id
2278
+ ORDER BY m.pagerank DESC LIMIT 5`
2279
+ ).all();
2280
+ if (top.length > 0) {
2281
+ lines.push(`## Critical hotspots (PageRank)`, ``);
2282
+ for (const t of top) lines.push(`- ${t.name} (${t.type}, ${t.file}) [pr=${t.pr.toFixed(4)}]`);
2283
+ lines.push(``);
2284
+ }
2285
+ } catch {
2286
+ }
2287
+ try {
2288
+ const c = db.prepare(`SELECT COUNT(*) AS n FROM cycles`).get().n;
2289
+ if (c > 0) {
2290
+ lines.push(`## Circular dependencies`, ``);
2291
+ lines.push(`- ${c} dependency cycle(s) detected \u2014 see \`scopecairn doctor\`.`);
2292
+ lines.push(``);
2293
+ }
2294
+ } catch {
2295
+ }
2296
+ try {
2297
+ const patterns = loadProtectedPatterns(repoRoot2);
2298
+ if (patterns.length > 0) {
2299
+ lines.push(`## Protected (ask before touching)`, ``);
2300
+ for (const p of patterns.slice(0, MAX_ROWS)) lines.push(`- \`${p}\``);
2301
+ lines.push(``);
2302
+ }
2303
+ } catch {
2304
+ }
2305
+ const out = lines.join("\n");
2306
+ const trimmed = out.split("\n");
2307
+ if (trimmed.length > MAX_LINES) {
2308
+ return trimmed.slice(0, MAX_LINES).join("\n") + "\n";
2309
+ }
2310
+ return out;
2311
+ }
2312
+ function graphMdPath(repoRoot2) {
2313
+ return path13.join(dataDir(repoRoot2), GRAPH_MD_NAME);
2314
+ }
2315
+ function writeGraphMdIfChanged(db, repoRoot2, dirty) {
2316
+ if (!dirty) return false;
2317
+ try {
2318
+ fs12.mkdirSync(dataDir(repoRoot2), { recursive: true });
2319
+ fs12.writeFileSync(graphMdPath(repoRoot2), buildGraphMd(db, repoRoot2));
2320
+ return true;
2321
+ } catch {
2322
+ return false;
2323
+ }
2324
+ }
2325
+ var EXPORT_NODE_CAP = 500;
2326
+ var EXPORT_EDGE_CAP = 1500;
2327
+ function readExportGraph(db) {
2328
+ let nodes = [];
2329
+ try {
2330
+ nodes = db.prepare(
2331
+ `SELECT s.id, s.name, s.type, f.path AS file,
2332
+ (SELECT COUNT(*) FROM relationships r WHERE r.target_id = s.id) +
2333
+ (SELECT COUNT(*) FROM relationships r WHERE r.source_id = s.id) AS deg
2334
+ FROM symbols s JOIN files f ON f.id = s.file_id
2335
+ ORDER BY deg DESC LIMIT ${EXPORT_NODE_CAP}`
2336
+ ).all();
2337
+ } catch {
2338
+ try {
2339
+ nodes = db.prepare(
2340
+ `SELECT s.id, s.name, s.type, f.path AS file
2341
+ FROM symbols s JOIN files f ON f.id = s.file_id LIMIT ${EXPORT_NODE_CAP}`
2342
+ ).all();
2343
+ } catch {
2344
+ return { nodes: [], edges: [] };
2345
+ }
2346
+ }
2347
+ if (nodes.length === 0) return { nodes, edges: [] };
2348
+ const keep = new Set(nodes.map((n) => n.id));
2349
+ let edges = [];
2350
+ try {
2351
+ edges = db.prepare(
2352
+ `SELECT source_id AS s, target_id AS t, relationship_type AS rel,
2353
+ weight AS w FROM relationships LIMIT ${EXPORT_EDGE_CAP * 4}`
2354
+ ).all().filter((e) => keep.has(e.s) && keep.has(e.t)).slice(0, EXPORT_EDGE_CAP);
2355
+ } catch {
2356
+ edges = [];
2357
+ }
2358
+ return { nodes, edges };
2359
+ }
2360
+ function escXml(s) {
2361
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2362
+ }
2363
+ function mermaidId(n) {
2364
+ return `n${n}`;
2365
+ }
2366
+ function mermaidLabel(n) {
2367
+ return `${n.name} [${n.type}]`.replace(/["#<>`]/g, "").slice(0, 60);
2368
+ }
2369
+ function exportMermaid(db) {
2370
+ const { nodes, edges } = readExportGraph(db);
2371
+ const lines = ["graph TD"];
2372
+ for (const n of nodes) {
2373
+ lines.push(` ${mermaidId(n.id)}["${mermaidLabel(n)}"]`);
2374
+ }
2375
+ for (const e of edges) {
2376
+ const rel = e.rel.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 24) || "REL";
2377
+ lines.push(` ${mermaidId(e.s)} -->|${rel}| ${mermaidId(e.t)}`);
2378
+ }
2379
+ return lines.join("\n") + "\n";
2380
+ }
2381
+ function exportGraphML(db) {
2382
+ const { nodes, edges } = readExportGraph(db);
2383
+ const out = [
2384
+ `<?xml version="1.0" encoding="UTF-8"?>`,
2385
+ `<graphml xmlns="http://graphml.graphdrawing.org/xmlns">`,
2386
+ ` <key id="name" for="node" attr.name="name" attr.type="string"/>`,
2387
+ ` <key id="type" for="node" attr.name="type" attr.type="string"/>`,
2388
+ ` <key id="file" for="node" attr.name="file" attr.type="string"/>`,
2389
+ ` <key id="rel" for="edge" attr.name="relationship" attr.type="string"/>`,
2390
+ ` <key id="weight" for="edge" attr.name="weight" attr.type="double"/>`,
2391
+ ` <graph id="scopecairn" edgedefault="directed">`
2392
+ ];
2393
+ for (const n of nodes) {
2394
+ out.push(
2395
+ ` <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>`
2396
+ );
2397
+ }
2398
+ edges.forEach((e, i) => {
2399
+ out.push(
2400
+ ` <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>`
2401
+ );
2402
+ });
2403
+ out.push(` </graph>`, `</graphml>`);
2404
+ return out.join("\n") + "\n";
2405
+ }
2406
+ function exportDot(db) {
2407
+ const { nodes, edges } = readExportGraph(db);
2408
+ const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2409
+ const lines = [`digraph scopecairn {`, ` rankdir=LR;`, ` node [shape=box, style=rounded];`];
2410
+ for (const n of nodes) {
2411
+ lines.push(` ${mermaidId(n.id)} [label=${q(`${n.name}
2412
+ (${n.type})`)}];`);
2413
+ }
2414
+ for (const e of edges) {
2415
+ lines.push(` ${mermaidId(e.s)} -> ${mermaidId(e.t)} [label=${q(e.rel)}];`);
2416
+ }
2417
+ lines.push(`}`);
2418
+ return lines.join("\n") + "\n";
2419
+ }
2420
+ function exportJson(db) {
2421
+ const { nodes, edges } = readExportGraph(db);
2422
+ return JSON.stringify(
2423
+ {
2424
+ generated_by: "scopecairn export",
2425
+ node_count: nodes.length,
2426
+ edge_count: edges.length,
2427
+ nodes: nodes.map((n) => ({ id: n.id, name: n.name, type: n.type, file: n.file })),
2428
+ edges: edges.map((e) => ({ source: e.s, target: e.t, relationship: e.rel, weight: e.w }))
2429
+ },
2430
+ null,
2431
+ 2
2432
+ ) + "\n";
2433
+ }
2434
+ function exportGraph(db, format) {
2435
+ switch (format) {
2436
+ case "mermaid":
2437
+ return exportMermaid(db);
2438
+ case "graphml":
2439
+ return exportGraphML(db);
2440
+ case "dot":
2441
+ return exportDot(db);
2442
+ case "json":
2443
+ return exportJson(db);
2444
+ }
2445
+ }
2446
+
2447
+ // src/graph/clusters.ts
2448
+ function readUndirected(db) {
2449
+ let ids = [];
2450
+ try {
2451
+ ids = db.prepare(`SELECT id FROM symbols ORDER BY id`).all().map((r) => r.id);
2452
+ } catch {
2453
+ return { ids: [], edges: [] };
2454
+ }
2455
+ const agg = /* @__PURE__ */ new Map();
2456
+ try {
2457
+ const rows = db.prepare(
2458
+ `SELECT source_id AS s, target_id AS t, SUM(MAX(weight, 0.01)) AS w
2459
+ FROM relationships GROUP BY source_id, target_id`
2460
+ ).all();
2461
+ for (const r of rows) {
2462
+ if (r.s === r.t) continue;
2463
+ const key = r.s < r.t ? `${r.s}:${r.t}` : `${r.t}:${r.s}`;
2464
+ const e = agg.get(key);
2465
+ if (e) e.w += r.w;
2466
+ else agg.set(key, { a: Math.min(r.s, r.t), b: Math.max(r.s, r.t), w: r.w });
2467
+ }
2468
+ } catch {
2469
+ }
2470
+ return { ids, edges: [...agg.values()] };
2471
+ }
2472
+ function modularity(comm, edges, degree, m2) {
2473
+ if (m2 === 0) return 0;
2474
+ const inSum = /* @__PURE__ */ new Map();
2475
+ const totSum = /* @__PURE__ */ new Map();
2476
+ for (const [node, c] of comm) {
2477
+ totSum.set(c, (totSum.get(c) ?? 0) + (degree.get(node) ?? 0));
2478
+ }
2479
+ for (const e of edges) {
2480
+ if (comm.get(e.a) === comm.get(e.b)) {
2481
+ inSum.set(comm.get(e.a), (inSum.get(comm.get(e.a)) ?? 0) + 2 * e.w);
2482
+ }
2483
+ }
2484
+ let q = 0;
2485
+ for (const [c, tot] of totSum) {
2486
+ q += (inSum.get(c) ?? 0) / m2 - tot / m2 * (tot / m2);
2487
+ }
2488
+ return q;
2489
+ }
2490
+ function localMove(nodes, adj, comm, commTot, degree, m2) {
2491
+ let moved = false;
2492
+ for (const node of nodes) {
2493
+ const cur = comm.get(node);
2494
+ const ki = degree.get(node) ?? 0;
2495
+ const toComm = /* @__PURE__ */ new Map();
2496
+ for (const { to, w } of adj.get(node) ?? []) {
2497
+ const c = comm.get(to);
2498
+ toComm.set(c, (toComm.get(c) ?? 0) + w);
2499
+ }
2500
+ commTot.set(cur, (commTot.get(cur) ?? 0) - ki);
2501
+ let best = cur;
2502
+ let bestGain = 0;
2503
+ for (const [c, kin] of toComm) {
2504
+ if (c === cur) continue;
2505
+ const gain = kin - (commTot.get(c) ?? 0) * ki / Math.max(m2, 1e-9);
2506
+ if (gain > bestGain) {
2507
+ bestGain = gain;
2508
+ best = c;
2509
+ }
2510
+ }
2511
+ const selfGain = (toComm.get(cur) ?? 0) - (commTot.get(cur) ?? 0) * ki / Math.max(m2, 1e-9);
2512
+ if (best !== cur && bestGain > selfGain) {
2513
+ comm.set(node, best);
2514
+ commTot.set(best, (commTot.get(best) ?? 0) + ki);
2515
+ moved = true;
2516
+ } else {
2517
+ commTot.set(cur, (commTot.get(cur) ?? 0) + ki);
2518
+ }
2519
+ }
2520
+ return moved;
2521
+ }
2522
+ function louvain(db) {
2523
+ const { ids, edges } = readUndirected(db);
2524
+ const assignment = /* @__PURE__ */ new Map();
2525
+ if (ids.length === 0) return { assignment, modularity: 0 };
2526
+ let levelNodes = [...ids].sort((a, b) => a - b);
2527
+ let levelEdges = edges;
2528
+ let members = /* @__PURE__ */ new Map();
2529
+ for (const id of ids) members.set(id, [id]);
2530
+ for (let level = 0; level < 8; level++) {
2531
+ const adj = /* @__PURE__ */ new Map();
2532
+ const degree2 = /* @__PURE__ */ new Map();
2533
+ for (const n of levelNodes) {
2534
+ adj.set(n, []);
2535
+ degree2.set(n, 0);
2536
+ }
2537
+ let m22 = 0;
2538
+ for (const e of levelEdges) {
2539
+ adj.get(e.a)?.push({ to: e.b, w: e.w });
2540
+ adj.get(e.b)?.push({ to: e.a, w: e.w });
2541
+ degree2.set(e.a, (degree2.get(e.a) ?? 0) + e.w);
2542
+ degree2.set(e.b, (degree2.get(e.b) ?? 0) + e.w);
2543
+ m22 += 2 * e.w;
2544
+ }
2545
+ const comm = /* @__PURE__ */ new Map();
2546
+ const commTot = /* @__PURE__ */ new Map();
2547
+ for (const n of levelNodes) {
2548
+ comm.set(n, n);
2549
+ commTot.set(n, degree2.get(n) ?? 0);
2550
+ }
2551
+ for (let pass = 0; pass < 10; pass++) {
2552
+ if (!localMove(levelNodes, adj, comm, commTot, degree2, m22)) break;
2553
+ }
2554
+ const remap2 = /* @__PURE__ */ new Map();
2555
+ let next2 = 0;
2556
+ for (const n of levelNodes) {
2557
+ const c = comm.get(n);
2558
+ if (!remap2.has(c)) remap2.set(c, next2++);
2559
+ comm.set(n, remap2.get(c));
2560
+ }
2561
+ const newMembers = /* @__PURE__ */ new Map();
2562
+ for (const n of levelNodes) {
2563
+ const c = comm.get(n);
2564
+ if (!newMembers.has(c)) newMembers.set(c, []);
2565
+ newMembers.get(c).push(...members.get(n) ?? [n]);
2566
+ }
2567
+ members = newMembers;
2568
+ if (remap2.size === levelNodes.length || remap2.size <= 1) break;
2569
+ const agg = /* @__PURE__ */ new Map();
2570
+ for (const e of levelEdges) {
2571
+ const ca = comm.get(e.a);
2572
+ const cb = comm.get(e.b);
2573
+ if (ca === cb) continue;
2574
+ const key = ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`;
2575
+ agg.set(key, (agg.get(key) ?? 0) + e.w);
2576
+ }
2577
+ levelNodes = [...members.keys()].sort((a, b) => a - b);
2578
+ levelEdges = [...agg.entries()].map(([k, w]) => {
2579
+ const [a, b] = k.split(":").map(Number);
2580
+ return { a, b, w };
2581
+ });
2582
+ if (levelEdges.length === 0) break;
2583
+ }
2584
+ for (const [superId, mems] of members) {
2585
+ for (const m of mems) assignment.set(m, superId);
2586
+ }
2587
+ const remap = /* @__PURE__ */ new Map();
2588
+ let next = 0;
2589
+ for (const id of ids) {
2590
+ const c = assignment.get(id) ?? 0;
2591
+ if (!remap.has(c)) remap.set(c, next++);
2592
+ assignment.set(id, remap.get(c));
2593
+ }
2594
+ const degree = /* @__PURE__ */ new Map();
2595
+ let m2 = 0;
2596
+ for (const e of edges) {
2597
+ degree.set(e.a, (degree.get(e.a) ?? 0) + e.w);
2598
+ degree.set(e.b, (degree.get(e.b) ?? 0) + e.w);
2599
+ m2 += 2 * e.w;
2600
+ }
2601
+ const q = modularity(assignment, edges, degree, m2);
2602
+ return { assignment, modularity: q };
2603
+ }
2604
+ function refreshClusters(db) {
2605
+ const { assignment, modularity: q } = louvain(db);
2606
+ if (assignment.size === 0) return { count: 0, modularity: 0 };
2607
+ try {
2608
+ db.prepare(`DELETE FROM clusters WHERE node_id NOT IN (SELECT id FROM symbols)`).run();
2609
+ } catch {
2610
+ }
2611
+ const names = /* @__PURE__ */ new Map();
2612
+ try {
2613
+ const byCluster = /* @__PURE__ */ new Map();
2614
+ for (const [nodeId, cid] of assignment) {
2615
+ 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);
2616
+ const top = (r?.p ?? "").split("/")[0] || ".";
2617
+ if (!byCluster.has(cid)) byCluster.set(cid, /* @__PURE__ */ new Map());
2618
+ const m = byCluster.get(cid);
2619
+ m.set(top, (m.get(top) ?? 0) + 1);
2620
+ }
2621
+ for (const [cid, m] of byCluster) {
2622
+ let best = ".";
2623
+ let bestN = -1;
2624
+ for (const [k, v] of m) {
2625
+ if (v > bestN) {
2626
+ bestN = v;
2627
+ best = k;
2628
+ }
2629
+ }
2630
+ names.set(cid, `cluster-${cid} (${best}/)`);
2631
+ }
2632
+ } catch {
2633
+ }
2634
+ const up = db.prepare(
2635
+ `INSERT INTO clusters(node_id, cluster_id, cluster_name, modularity)
2636
+ VALUES (?, ?, ?, ?)
2637
+ ON CONFLICT(node_id) DO UPDATE SET
2638
+ cluster_id = excluded.cluster_id, cluster_name = excluded.cluster_name,
2639
+ modularity = excluded.modularity`
2640
+ );
2641
+ for (const [nodeId, cid] of assignment) {
2642
+ up.run(nodeId, cid, names.get(cid) ?? `cluster-${cid}`, q);
2643
+ }
2644
+ return { count: new Set(assignment.values()).size, modularity: q };
2645
+ }
2646
+ function ensureClusters(db) {
2647
+ try {
2648
+ const syms = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
2649
+ 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);
2653
+ return db.prepare(`SELECT COUNT(*) AS n FROM clusters`).get().n;
2654
+ } catch {
2655
+ return 0;
2656
+ }
2657
+ }
2658
+ function getCluster(db, nodeId) {
2659
+ try {
2660
+ const r = db.prepare(`SELECT * FROM clusters WHERE node_id = ?`).get(nodeId);
2661
+ if (!r) return null;
2662
+ return { nodeId: r.node_id, clusterId: r.cluster_id, clusterName: r.cluster_name ?? "", modularity: r.modularity ?? 0 };
2663
+ } catch {
2664
+ return null;
2665
+ }
2666
+ }
2667
+
2668
+ // src/graph/visual.ts
2669
+ var GRAPH_HTML_NAME = "GRAPH.html";
2670
+ var MAX_FILES = 600;
2671
+ var MAX_EDGES = 1500;
2672
+ var MAX_SYMBOLS = 800;
2673
+ var MAX_SYM_EDGES = 2e3;
2674
+ function esc(s) {
2675
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2676
+ }
2677
+ function moduleOf(file) {
2678
+ const i = file.indexOf("/");
2679
+ return i === -1 ? "." : file.slice(0, i);
2680
+ }
2681
+ function colorFor(mod) {
2682
+ let h = 0;
2683
+ for (const c of mod) h = (h * 31 + c.charCodeAt(0)) % 360;
2684
+ return `hsl(${h}, 55%, 55%)`;
2685
+ }
2686
+ function buildVisualModel(db) {
2687
+ const empty = {
2688
+ nodes: [],
2689
+ edges: [],
2690
+ truncatedFiles: 0,
2691
+ truncatedEdges: 0,
2692
+ symbols: [],
2693
+ symbolEdges: [],
2694
+ truncatedSymbols: 0,
2695
+ clusterNames: [],
2696
+ modularity: 0
2697
+ };
2698
+ let files = [];
2699
+ try {
2700
+ files = db.prepare(`SELECT id, path, language AS lang FROM files`).all();
2701
+ } catch {
2702
+ return empty;
2703
+ }
2704
+ if (files.length === 0) return empty;
2705
+ let prBySym = /* @__PURE__ */ new Map();
2706
+ let clusterBySym = /* @__PURE__ */ new Map();
2707
+ const clusterNames = /* @__PURE__ */ new Map();
2708
+ let modularity2 = 0;
2709
+ try {
2710
+ ensureMetrics(db);
2711
+ for (const r of db.prepare(`SELECT node_id AS id, pagerank AS pr FROM node_metrics`).all()) prBySym.set(r.id, r.pr);
2712
+ } catch {
2713
+ }
2714
+ try {
2715
+ ensureClusters(db);
2716
+ for (const r of db.prepare(
2717
+ `SELECT node_id AS id, cluster_id AS c, cluster_name AS n, modularity AS q FROM clusters`
2718
+ ).all()) {
2719
+ clusterBySym.set(r.id, r.c);
2720
+ if (r.n) clusterNames.set(r.c, r.n);
2721
+ modularity2 = r.q ?? modularity2;
2722
+ }
2723
+ } catch {
2724
+ }
2725
+ const useClusters = clusterBySym.size > 0;
2726
+ const symCount = /* @__PURE__ */ new Map();
2727
+ try {
2728
+ for (const r of db.prepare(`SELECT file_id AS f, COUNT(*) AS n FROM symbols GROUP BY file_id`).all()) {
2729
+ symCount.set(r.f, r.n);
2730
+ }
2731
+ } catch {
2732
+ }
2733
+ const deg = /* @__PURE__ */ new Map();
2734
+ const pair = /* @__PURE__ */ new Map();
2735
+ try {
2736
+ const rows = db.prepare(
2737
+ `SELECT s1.file_id AS fa, s2.file_id AS fb, r.relationship_type AS rel, r.confidence AS c
2738
+ FROM relationships r
2739
+ JOIN symbols s1 ON s1.id = r.source_id
2740
+ JOIN symbols s2 ON s2.id = r.target_id
2741
+ WHERE s1.file_id != s2.file_id`
2742
+ ).all();
2743
+ for (const r of rows) {
2744
+ const d1 = deg.get(r.fa) ?? { inn: 0, out: 0 };
2745
+ d1.out++;
2746
+ deg.set(r.fa, d1);
2747
+ const d2 = deg.get(r.fb) ?? { inn: 0, out: 0 };
2748
+ d2.inn++;
2749
+ deg.set(r.fb, d2);
2750
+ const key = r.fa < r.fb ? `${r.fa}:${r.fb}` : `${r.fb}:${r.fa}`;
2751
+ let p = pair.get(key);
2752
+ if (!p) {
2753
+ p = { a: r.fa, b: r.fb, rels: /* @__PURE__ */ new Map() };
2754
+ pair.set(key, p);
2755
+ }
2756
+ const e = p.rels.get(r.rel) ?? { n: 0, c: 0 };
2757
+ e.n++;
2758
+ e.c += r.c;
2759
+ p.rels.set(r.rel, e);
2760
+ }
2761
+ } catch {
2762
+ }
2763
+ const scored = files.map((f) => ({
2764
+ f,
2765
+ score: (deg.get(f.id)?.inn ?? 0) + (deg.get(f.id)?.out ?? 0) + (symCount.get(f.id) ?? 0) * 0.5
2766
+ }));
2767
+ scored.sort((a, b) => b.score - a.score);
2768
+ const kept = scored.slice(0, MAX_FILES);
2769
+ const keptIds = new Set(kept.map((k) => k.f.id));
2770
+ const truncatedFiles = scored.length - kept.length;
2771
+ const fileCluster = /* @__PURE__ */ new Map();
2772
+ if (useClusters) {
2773
+ try {
2774
+ const rows = db.prepare(
2775
+ `SELECT file_id AS f, cluster_id AS c, COUNT(*) AS n FROM
2776
+ (SELECT s.file_id, c.cluster_id FROM symbols s JOIN clusters c ON c.node_id = s.id)
2777
+ GROUP BY file_id, cluster_id`
2778
+ ).all();
2779
+ const best = /* @__PURE__ */ new Map();
2780
+ for (const r of rows) {
2781
+ if (!keptIds.has(r.f)) continue;
2782
+ if ((best.get(r.f)?.n ?? -1) < r.n) best.set(r.f, { c: r.c, n: r.n });
2783
+ }
2784
+ for (const [f, v] of best) fileCluster.set(f, v.c);
2785
+ } catch {
2786
+ }
2787
+ }
2788
+ const filePr = /* @__PURE__ */ new Map();
2789
+ if (prBySym.size > 0) {
2790
+ try {
2791
+ for (const r of db.prepare(
2792
+ `SELECT s.file_id AS f, SUM(m.pagerank) AS pr FROM symbols s
2793
+ JOIN node_metrics m ON m.node_id = s.id GROUP BY s.file_id`
2794
+ ).all()) filePr.set(r.f, r.pr);
2795
+ } catch {
2796
+ }
2797
+ }
2798
+ const nodes = kept.map((k) => {
2799
+ const cid = fileCluster.get(k.f.id) ?? -1;
2800
+ const mod = moduleOf(k.f.path);
2801
+ return {
2802
+ id: k.f.id,
2803
+ file: k.f.path,
2804
+ lang: k.f.lang,
2805
+ symbols: symCount.get(k.f.id) ?? 0,
2806
+ module: mod,
2807
+ cluster: cid,
2808
+ clusterName: cid >= 0 ? clusterNames.get(cid) ?? `cluster-${cid}` : mod,
2809
+ pagerank: filePr.get(k.f.id) ?? 0,
2810
+ inDeg: deg.get(k.f.id)?.inn ?? 0,
2811
+ outDeg: deg.get(k.f.id)?.out ?? 0,
2812
+ x: 0,
2813
+ y: 0,
2814
+ r: 0,
2815
+ color: ""
2816
+ };
2817
+ });
2818
+ const edges = [];
2819
+ for (const p of pair.values()) {
2820
+ if (!keptIds.has(p.a) || !keptIds.has(p.b)) continue;
2821
+ let top = "";
2822
+ let topN = 0;
2823
+ let confSum = 0;
2824
+ let total = 0;
2825
+ for (const [rel, e] of p.rels) {
2826
+ total += e.n;
2827
+ confSum += e.c;
2828
+ if (e.n > topN) {
2829
+ topN = e.n;
2830
+ top = rel;
2831
+ }
2832
+ }
2833
+ edges.push({ a: p.a, b: p.b, rel: top, n: total, conf: total > 0 ? confSum / total : 1 });
2834
+ }
2835
+ edges.sort((a, b) => b.n - a.n);
2836
+ const truncatedEdges = Math.max(0, edges.length - MAX_EDGES);
2837
+ const keptEdges = edges.slice(0, MAX_EDGES);
2838
+ layout(nodes, keptEdges);
2839
+ const { symbols, symbolEdges, truncatedSymbols } = buildSymbolLayer(
2840
+ db,
2841
+ keptIds,
2842
+ nodes,
2843
+ prBySym,
2844
+ clusterBySym
2845
+ );
2846
+ return {
2847
+ nodes,
2848
+ edges: keptEdges,
2849
+ truncatedFiles,
2850
+ truncatedEdges,
2851
+ symbols,
2852
+ symbolEdges,
2853
+ truncatedSymbols,
2854
+ clusterNames: [...clusterNames.entries()].map(([id, name]) => ({ id, name })),
2855
+ modularity: modularity2
2856
+ };
2857
+ }
2858
+ function buildSymbolLayer(db, keptFileIds, fileNodes, prBySym, clusterBySym) {
2859
+ const none = { symbols: [], symbolEdges: [], truncatedSymbols: 0 };
2860
+ let rows = [];
2861
+ try {
2862
+ rows = db.prepare(
2863
+ `SELECT s.id, s.name, s.type, f.path AS file, s.file_id AS fid,
2864
+ (SELECT COUNT(*) FROM relationships r WHERE r.target_id = s.id) +
2865
+ (SELECT COUNT(*) FROM relationships r WHERE r.source_id = s.id) AS deg
2866
+ FROM symbols s JOIN files f ON f.id = s.file_id
2867
+ ORDER BY deg DESC LIMIT ${MAX_SYMBOLS * 2}`
2868
+ ).all();
2869
+ } catch {
2870
+ return none;
2871
+ }
2872
+ rows = rows.filter((r) => keptFileIds.has(r.fid));
2873
+ if (prBySym.size > 0) {
2874
+ rows.sort((a, b) => {
2875
+ const pa = (prBySym.get(a.id) ?? 0) * 100 + a.deg;
2876
+ const pb = (prBySym.get(b.id) ?? 0) * 100 + b.deg;
2877
+ return pb - pa;
2878
+ });
2879
+ }
2880
+ const truncatedSymbols = Math.max(0, rows.length - MAX_SYMBOLS);
2881
+ const kept = rows.slice(0, MAX_SYMBOLS);
2882
+ const keepIds = new Set(kept.map((r) => r.id));
2883
+ const byFile = /* @__PURE__ */ new Map();
2884
+ for (const n of fileNodes) byFile.set(n.id, n);
2885
+ const rnd = mulberry32(777);
2886
+ const callers = /* @__PURE__ */ new Map();
2887
+ const callees = /* @__PURE__ */ new Map();
2888
+ const symEdges = [];
2889
+ try {
2890
+ const rels = db.prepare(
2891
+ `SELECT r.source_id AS s, r.target_id AS t, r.relationship_type AS rel,
2892
+ s1.name AS sn, s2.name AS tn
2893
+ FROM relationships r
2894
+ JOIN symbols s1 ON s1.id = r.source_id
2895
+ JOIN symbols s2 ON s2.id = r.target_id
2896
+ LIMIT 20000`
2897
+ ).all();
2898
+ for (const r of rels) {
2899
+ if (keepIds.has(r.s) && keepIds.has(r.t)) {
2900
+ if (symEdges.length < MAX_SYM_EDGES) symEdges.push({ s: r.s, t: r.t, rel: r.rel });
2901
+ }
2902
+ if (keepIds.has(r.t) && (callers.get(r.t) ?? []).length < 8) {
2903
+ if (!callers.has(r.t)) callers.set(r.t, []);
2904
+ callers.get(r.t).push(`${r.sn} (${r.rel})`);
2905
+ }
2906
+ if (keepIds.has(r.s) && (callees.get(r.s) ?? []).length < 8) {
2907
+ if (!callees.has(r.s)) callees.set(r.s, []);
2908
+ callees.get(r.s).push(`${r.tn} (${r.rel})`);
2909
+ }
2910
+ }
2911
+ } catch {
2912
+ }
2913
+ const symbols = kept.map((r) => {
2914
+ const f = byFile.get(r.fid);
2915
+ const fx = f?.x ?? 500;
2916
+ const fy = f?.y ?? 380;
2917
+ return {
2918
+ id: r.id,
2919
+ name: r.name,
2920
+ type: r.type,
2921
+ file: r.file,
2922
+ cluster: clusterBySym.get(r.id) ?? -1,
2923
+ pr: prBySym.get(r.id) ?? 0,
2924
+ inn: 0,
2925
+ out: 0,
2926
+ callers: callers.get(r.id) ?? [],
2927
+ callees: callees.get(r.id) ?? [],
2928
+ x: Math.round(fx + (rnd() - 0.5) * 120),
2929
+ y: Math.round(fy + (rnd() - 0.5) * 120)
2930
+ };
2931
+ });
2932
+ const symById = new Map(symbols.map((s) => [s.id, s]));
2933
+ for (const e of symEdges) {
2934
+ const a = symById.get(e.s);
2935
+ const b = symById.get(e.t);
2936
+ if (a) a.out++;
2937
+ if (b) b.inn++;
2938
+ }
2939
+ return { symbols, symbolEdges: symEdges, truncatedSymbols };
2940
+ }
2941
+ function mulberry32(seed) {
2942
+ let a = seed;
2943
+ return () => {
2944
+ a |= 0;
2945
+ a = a + 1831565813 | 0;
2946
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
2947
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
2948
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
2949
+ };
2950
+ }
2951
+ function layout(nodes, edges) {
2952
+ for (const n of nodes) {
2953
+ n.color = colorFor(n.cluster >= 0 ? `cluster-${n.cluster}` : n.module);
2954
+ n.r = Math.min(15, 5 + Math.sqrt(n.symbols) * 1.6);
2955
+ }
2956
+ if (nodes.length === 0) return;
2957
+ if (nodes.length === 1) {
2958
+ nodes[0].x = 400;
2959
+ nodes[0].y = 300;
2960
+ return;
2961
+ }
2962
+ const rnd = mulberry32(1337);
2963
+ const groups = [...new Set(nodes.map((n) => n.cluster >= 0 ? `c${n.cluster}` : n.module))];
2964
+ const modAngle = new Map(groups.map((m, i) => [m, i / groups.length * Math.PI * 2]));
2965
+ const R0 = 260;
2966
+ const px = /* @__PURE__ */ new Map();
2967
+ const byId = new Map(nodes.map((n) => [n.id, n]));
2968
+ for (const n of nodes) {
2969
+ const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
2970
+ const a = (modAngle.get(key) ?? 0) + rnd() * 0.6;
2971
+ const r = R0 * (0.5 + rnd() * 0.7);
2972
+ px.set(n.id, { x: 500 + Math.cos(a) * r, y: 380 + Math.sin(a) * r * 0.75 });
2973
+ }
2974
+ const AREA = 1e3 * 760;
2975
+ const k = Math.sqrt(AREA / nodes.length) * 0.9;
2976
+ const adj = /* @__PURE__ */ new Map();
2977
+ for (const e of edges) {
2978
+ const w = Math.min(2, 0.5 + e.n * 0.25);
2979
+ if (!adj.has(e.a)) adj.set(e.a, []);
2980
+ if (!adj.has(e.b)) adj.set(e.b, []);
2981
+ adj.get(e.a).push({ to: e.b, w });
2982
+ adj.get(e.b).push({ to: e.a, w });
2983
+ }
2984
+ const ITERS = 110;
2985
+ let temp = 90;
2986
+ const dTemp = Math.pow(0.5 / temp, 1 / ITERS);
2987
+ for (let it = 0; it < ITERS; it++) {
2988
+ const disp = /* @__PURE__ */ new Map();
2989
+ for (const n of nodes) disp.set(n.id, { x: 0, y: 0 });
2990
+ for (let i = 0; i < nodes.length; i++) {
2991
+ for (let j = i + 1; j < nodes.length; j++) {
2992
+ const a = px.get(nodes[i].id);
2993
+ const b = px.get(nodes[j].id);
2994
+ let dx = a.x - b.x;
2995
+ let dy = a.y - b.y;
2996
+ let d2 = dx * dx + dy * dy;
2997
+ if (d2 < 0.01) {
2998
+ dx = (rnd() - 0.5) * 2;
2999
+ dy = (rnd() - 0.5) * 2;
3000
+ d2 = dx * dx + dy * dy;
3001
+ }
3002
+ const d = Math.sqrt(d2);
3003
+ const f = Math.min(k * k / d, temp * 2);
3004
+ const fx = dx / d * f;
3005
+ const fy = dy / d * f;
3006
+ const da = disp.get(nodes[i].id);
3007
+ const db2 = disp.get(nodes[j].id);
3008
+ da.x += fx;
3009
+ da.y += fy;
3010
+ db2.x -= fx;
3011
+ db2.y -= fy;
3012
+ }
1711
3013
  }
1712
- };
1713
- refreshTargets("nextjs", nextjsAdapter.relevant, refreshQueries);
1714
- refreshTargets("drizzle", drizzleAdapter.relevant, refreshDrizzleQueries);
1715
- return { adapters: active.map((a) => a.id), symbols, relations };
3014
+ for (const n of nodes) {
3015
+ const p = px.get(n.id);
3016
+ const d = disp.get(n.id);
3017
+ for (const e of adj.get(n.id) ?? []) {
3018
+ const q = px.get(e.to);
3019
+ if (!q) continue;
3020
+ const dx = p.x - q.x;
3021
+ const dy = p.y - q.y;
3022
+ const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
3023
+ const f = dist * dist / k * 0.02 * e.w;
3024
+ d.x -= dx / dist * f;
3025
+ d.y -= dy / dist * f;
3026
+ }
3027
+ d.x += (500 - p.x) * 0.03;
3028
+ d.y += (380 - p.y) * 0.03;
3029
+ }
3030
+ const cent = /* @__PURE__ */ new Map();
3031
+ for (const n of nodes) {
3032
+ const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
3033
+ const p = px.get(n.id);
3034
+ const c = cent.get(key) ?? { x: 0, y: 0, n: 0 };
3035
+ c.x += p.x;
3036
+ c.y += p.y;
3037
+ c.n++;
3038
+ cent.set(key, c);
3039
+ }
3040
+ for (const n of nodes) {
3041
+ const key = n.cluster >= 0 ? `c${n.cluster}` : n.module;
3042
+ const c = cent.get(key);
3043
+ const p = px.get(n.id);
3044
+ const d = disp.get(n.id);
3045
+ d.x += (c.x / c.n - p.x) * 0.05;
3046
+ d.y += (c.y / c.n - p.y) * 0.05;
3047
+ }
3048
+ for (const n of nodes) {
3049
+ const p = px.get(n.id);
3050
+ const d = disp.get(n.id);
3051
+ const dl = Math.max(1e-3, Math.sqrt(d.x * d.x + d.y * d.y));
3052
+ const step = Math.min(dl, temp);
3053
+ p.x += d.x / dl * step;
3054
+ p.y += d.y / dl * step;
3055
+ }
3056
+ temp *= dTemp;
3057
+ }
3058
+ for (const n of nodes) {
3059
+ const p = px.get(n.id);
3060
+ n.x = Math.round(p.x);
3061
+ n.y = Math.round(p.y);
3062
+ }
3063
+ void byId;
3064
+ }
3065
+ function renderHtml(model, title, generated) {
3066
+ const payload = JSON.stringify({
3067
+ files: model.nodes.map((n) => ({
3068
+ id: n.id,
3069
+ file: n.file,
3070
+ lang: n.lang,
3071
+ symbols: n.symbols,
3072
+ module: n.module,
3073
+ cluster: n.cluster,
3074
+ clusterName: n.clusterName,
3075
+ pr: n.pagerank,
3076
+ inn: n.inDeg,
3077
+ out: n.outDeg,
3078
+ x: n.x,
3079
+ y: n.y,
3080
+ r: n.r,
3081
+ color: n.color
3082
+ })),
3083
+ edges: model.edges,
3084
+ symbols: model.symbols,
3085
+ symbolEdges: model.symbolEdges,
3086
+ clusters: model.clusterNames,
3087
+ modularity: model.modularity,
3088
+ truncated: {
3089
+ files: model.truncatedFiles,
3090
+ edges: model.truncatedEdges,
3091
+ symbols: model.truncatedSymbols
3092
+ }
3093
+ }).replace(/</g, "\\u003c");
3094
+ const truncNote = model.truncatedFiles > 0 || model.truncatedSymbols > 0 ? ` (+${model.truncatedFiles} files, +${model.truncatedSymbols} symbols beyond budget)` : "";
3095
+ const clusterNote = model.clusterNames.length > 0 ? ` \xB7 ${model.clusterNames.length} communities (Louvain Q=${model.modularity.toFixed(3)})` : "";
3096
+ return `<!DOCTYPE html>
3097
+ <html lang="en">
3098
+ <head>
3099
+ <meta charset="utf-8">
3100
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3101
+ <title>${esc(title)} \u2014 ScopeCairn graph explorer</title>
3102
+ <style>
3103
+ :root{--bg:#fafafa;--panel:#fff;--line:#ddd;--ink:#222;--mut:#666;--acc:#0969da}
3104
+ *{box-sizing:border-box}
3105
+ body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:var(--bg);color:var(--ink)}
3106
+ header{padding:10px 14px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:3}
3107
+ header h1{font-size:15px;margin:0 0 2px}
3108
+ header p{font-size:12px;color:var(--mut);margin:2px 0}
3109
+ #bar{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
3110
+ #bar input,#bar select{padding:5px 8px;font-size:13px;border:1px solid var(--line);border-radius:6px}
3111
+ #q{flex:1;min-width:160px;max-width:320px}
3112
+ .seg{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
3113
+ .seg button{border:0;background:#f3f4f6;padding:5px 10px;font-size:12.5px;cursor:pointer}
3114
+ .seg button.on{background:#1f2328;color:#fff}
3115
+ #main{display:flex;height:calc(100vh - 158px);min-height:420px}
3116
+ #wrap{flex:1;position:relative;overflow:hidden;cursor:grab;background:#fff}
3117
+ #cv{position:absolute;inset:0;width:100%;height:100%}
3118
+ #side{width:320px;max-width:38vw;overflow:auto;background:var(--panel);border-left:1px solid var(--line);padding:12px 14px;font-size:13px}
3119
+ #side h2{font-size:14px;margin:0 0 4px;word-break:break-all}
3120
+ #side .meta{color:var(--mut);font-size:12px;margin:0 0 8px}
3121
+ #side h3{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);margin:12px 0 4px}
3122
+ #side ul{margin:0;padding-left:18px}
3123
+ #side code{background:#f3f4f6;border-radius:4px;padding:0 4px}
3124
+ #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}
3125
+ .hint{font-size:12px;color:var(--mut);padding:6px 14px}
3126
+ kbd{background:#f3f4f6;border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11px}
3127
+ </style>
3128
+ </head>
3129
+ <body>
3130
+ <header>
3131
+ <h1>${esc(title)} \u2014 ${model.nodes.length} files, ${model.symbols.length} symbols, ${model.edges.length} links${esc(truncNote)}</h1>
3132
+ <p>Generated by <code>scopecairn scan</code> \xB7 ${esc(generated)} \xB7 interactive Canvas explorer (offline, zero dependencies)${esc(clusterNote)}</p>
3133
+ <div id="bar">
3134
+ <div class="seg" role="tablist" aria-label="hierarchy">
3135
+ <button data-mode="cluster" class="on">Cluster</button><button data-mode="file">File</button><button data-mode="symbol">Symbol</button>
3136
+ </div>
3137
+ <input id="q" placeholder="live search: name, type, module\u2026" autocomplete="off">
3138
+ <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>
3139
+ <button id="fit">fit</button>
3140
+ <span style="font-size:12px;color:var(--mut)">drag pan \xB7 wheel zoom \xB7 hover inspect \xB7 click details</span>
3141
+ </div>
3142
+ </header>
3143
+ <div class="hint" id="count"></div>
3144
+ <div id="main">
3145
+ <div id="wrap"><canvas id="cv"></canvas></div>
3146
+ <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>
3147
+ </div>
3148
+ <div id="tip"></div>
3149
+ <script>
3150
+ (function(){
3151
+ "use strict";
3152
+ var DATA=${payload};
3153
+ var files=DATA.files, fedges=DATA.edges, syms=DATA.symbols, sedges=DATA.symbolEdges;
3154
+ var byFileId={}, bySymId={};
3155
+ files.forEach(function(n){byFileId[n.id]=n;});
3156
+ syms.forEach(function(s){bySymId[s.id]=s;});
3157
+ // Cluster view: agregat file per komunitas Louvain (fallback modul folder).
3158
+ var clusters=[];
3159
+ (function(){
3160
+ var m={};
3161
+ files.forEach(function(n){
3162
+ var key=n.cluster>=0?("c"+n.cluster):("m:"+n.module);
3163
+ 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};
3164
+ m[key].members.push(n);
3165
+ });
3166
+ Object.keys(m).forEach(function(k){
3167
+ var c=m[k],sx=0,sy=0;
3168
+ c.members.forEach(function(n){sx+=n.x;sy+=n.y;});
3169
+ c.x=sx/c.members.length;c.y=sy/c.members.length;
3170
+ c.r=Math.min(46,14+Math.sqrt(c.members.length)*6);
3171
+ clusters.push(c);
3172
+ });
3173
+ })();
3174
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d'),wrap=document.getElementById('wrap');
3175
+ var side=document.getElementById('side'),tip=document.getElementById('tip'),count=document.getElementById('count');
3176
+ var mode='cluster',query='',typeF='';
3177
+ var cam={x:0,y:0,scale:1},drag=null,hover=null,sel=null,dpr=1;
3178
+ function resize(){
3179
+ dpr=Math.min(2,window.devicePixelRatio||1);
3180
+ var r=wrap.getBoundingClientRect();
3181
+ cv.width=Math.max(1,r.width*dpr);cv.height=Math.max(1,r.height*dpr);
3182
+ draw();
3183
+ }
3184
+ window.addEventListener('resize',resize);
3185
+ function w2s(p){return [(p[0]-cam.x)*cam.scale*dpr,(p[1]-cam.y)*cam.scale*dpr];}
3186
+ function fit(){
3187
+ var pts=currentPoints();
3188
+ if(!pts.length){cam={x:0,y:0,scale:1};draw();return;}
3189
+ var xs=pts.map(function(p){return p.x;}),ys=pts.map(function(p){return p.y;});
3190
+ var x0=Math.min.apply(0,xs)-60,x1=Math.max.apply(0,xs)+60;
3191
+ var y0=Math.min.apply(0,ys)-60,y1=Math.max.apply(0,ys)+60;
3192
+ var r=wrap.getBoundingClientRect();
3193
+ var s=Math.min(r.width/(x1-x0),r.height/(y1-y0));
3194
+ s=Math.min(3,Math.max(.05,s));
3195
+ cam.scale=s;cam.x=x0-(r.width/s-(x1-x0))/2;cam.y=y0-(r.height/s-(y1-y0))/2;
3196
+ draw();
3197
+ }
3198
+ function match(n){
3199
+ if(typeF && (n.type||'')!==typeF)return false;
3200
+ if(!query)return true;
3201
+ var hay=((n.name||n.file||'')+' '+(n.type||'')+' '+(n.module||'')+' '+(n.clusterName||'')).toLowerCase();
3202
+ return hay.indexOf(query)>=0;
3203
+ }
3204
+ function currentPoints(){
3205
+ if(mode==='symbol')return syms.filter(match);
3206
+ if(mode==='file')return files.filter(match);
3207
+ return clusters;
3208
+ }
3209
+ function draw(){
3210
+ var r=wrap.getBoundingClientRect(),W=r.width*dpr,H=r.height*dpr;
3211
+ ctx.clearRect(0,0,W,H);
3212
+ ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);
3213
+ var s=cam.scale*dpr,ox=-cam.x*s,oy=-cam.y*s;
3214
+ function X(x){return x*s+ox;} function Y(y){return y*s+oy;}
3215
+ ctx.lineWidth=1;
3216
+ if(mode==='cluster'){
3217
+ clusters.forEach(function(c){
3218
+ ctx.beginPath();ctx.arc(X(c.x),Y(c.y),c.r*s,0,7);
3219
+ ctx.fillStyle='rgba(9,105,218,.10)';ctx.fill();
3220
+ ctx.fillStyle='#1f2328';ctx.font=(12*dpr)+'px system-ui';
3221
+ ctx.textAlign='center';
3222
+ ctx.fillText(c.name+' ('+c.members.length+')',X(c.x),Y(c.y)-c.r*s-6*dpr);
3223
+ });
3224
+ clusters.forEach(function(c){
3225
+ var sel2=(sel&&sel.kind==='cluster'&&sel.key===c.key);
3226
+ ctx.beginPath();ctx.arc(X(c.x),Y(c.y),Math.max(3,c.r*s),0,7);
3227
+ ctx.fillStyle=sel2?'#0969da':'#57606a';ctx.fill();
3228
+ if(sel2){ctx.strokeStyle='#0969da';ctx.lineWidth=3*dpr;ctx.stroke();}
3229
+ });
3230
+ }else if(mode==='file'){
3231
+ fedges.forEach(function(e){
3232
+ var a=byFileId[e.a],b=byFileId[e.b];
3233
+ if(!a||!b||!match(a)||!match(b))return;
3234
+ ctx.beginPath();ctx.moveTo(X(a.x),Y(a.y));ctx.lineTo(X(b.x),Y(b.y));
3235
+ ctx.strokeStyle=e.conf<0.8?'rgba(150,150,150,.45)':'rgba(120,120,120,.55)';
3236
+ ctx.setLineDash(e.conf<0.8?[5,4]:[]);
3237
+ ctx.lineWidth=Math.min(3,(0.5+e.n*0.4))*dpr*0.6;ctx.stroke();ctx.setLineDash([]);
3238
+ });
3239
+ var ranked=files.slice().sort(function(a,b){return (b.inn+b.out)-(a.inn+a.out);});
3240
+ var labeled={};ranked.slice(0,25).forEach(function(n){labeled[n.id]=1;});
3241
+ files.forEach(function(n){
3242
+ if(!match(n))return;
3243
+ var rad=Math.max(2.5,n.r*s),isH=hover&&hover.kind==='file'&&hover.id===n.id;
3244
+ var isS=sel&&sel.kind==='file'&&sel.id===n.id;
3245
+ ctx.beginPath();ctx.arc(X(n.x),Y(n.y),rad+(isH?2:0),0,7);
3246
+ ctx.fillStyle=n.color||'#57606a';ctx.fill();
3247
+ ctx.lineWidth=(isS?3:1.2)*dpr;ctx.strokeStyle=isS?'#0969da':'#1f2328';ctx.stroke();
3248
+ if(labeled[n.id]&&s>0.35){
3249
+ ctx.fillStyle='#24292f';ctx.font=(10.5*dpr)+'px ui-monospace,monospace';ctx.textAlign='left';
3250
+ var nm=n.file.split('/');var sh=nm.length>2?(nm[0]+'/\u2026/'+nm[nm.length-1]):n.file;
3251
+ ctx.fillText(sh,X(n.x)+rad+4*dpr,Y(n.y)+4*dpr);
3252
+ }
3253
+ });
3254
+ }else{
3255
+ var se={};
3256
+ sedges.forEach(function(e){
3257
+ var a=bySymId[e.s],b=bySymId[e.t];
3258
+ if(!a||!b||!match(a)||!match(b))return;
3259
+ var k=e.s+':'+e.t;if(se[k])return;se[k]=1;
3260
+ ctx.beginPath();ctx.moveTo(X(a.x),Y(a.y));ctx.lineTo(X(b.x),Y(b.y));
3261
+ ctx.strokeStyle='rgba(120,120,120,.4)';ctx.lineWidth=1*dpr;ctx.stroke();
3262
+ });
3263
+ var top=syms.slice().sort(function(a,b){return b.pr-a.pr;});
3264
+ var lab={};top.slice(0,30).forEach(function(x){lab[x.id]=1;});
3265
+ syms.forEach(function(n){
3266
+ if(!match(n))return;
3267
+ var base=4+Math.min(9,Math.sqrt(n.inn+n.out)*2+(n.pr*400));
3268
+ var rad=Math.max(2.5,base*s),isH=hover&&hover.kind==='symbol'&&hover.id===n.id;
3269
+ var isS=sel&&sel.kind==='symbol'&&sel.id===n.id;
3270
+ ctx.beginPath();ctx.arc(X(n.x),Y(n.y),rad+(isH?2:0),0,7);
3271
+ ctx.fillStyle=isS?'#0969da':'#8250df';ctx.fill();
3272
+ ctx.lineWidth=1.2*dpr;ctx.strokeStyle='#1f2328';ctx.stroke();
3273
+ if(lab[n.id]&&s>0.5){
3274
+ ctx.fillStyle='#24292f';ctx.font=(10*dpr)+'px ui-monospace,monospace';ctx.textAlign='left';
3275
+ ctx.fillText(n.name.slice(0,32),X(n.x)+rad+4*dpr,Y(n.y)+4*dpr);
3276
+ }
3277
+ });
3278
+ }
3279
+ var pts=currentPoints();
3280
+ count.textContent='showing '+pts.length+' / '+(mode==='symbol'?syms.length:mode==='file'?files.length:clusters.length)+' \xB7 '+mode+' view \xB7 scroll zoom \xB7 drag pan';
3281
+ }
3282
+ function pick(mx,my){
3283
+ var r=wrap.getBoundingClientRect(),s=cam.scale*dpr;
3284
+ var wx=(mx*dpr+cam.x*s)/s,wy=(my*dpr+cam.y*s)/s;
3285
+ var best=null,bd=1e12;
3286
+ currentPoints().forEach(function(n){
3287
+ var rad=(n.r||8)+8/s;
3288
+ var dx=n.x-wx,dy=n.y-wy,d=Math.sqrt(dx*dx+dy*dy);
3289
+ if(d<rad&&d<bd){bd=d;best=n;}
3290
+ });
3291
+ if(!best)return null;
3292
+ if(mode==='cluster')return {kind:'cluster',key:best.key};
3293
+ if(mode==='file')return {kind:'file',id:best.id};
3294
+ return {kind:'symbol',id:best.id};
3295
+ }
3296
+ function detailHTML(p){
3297
+ function li(a){return '<li><code>'+a.replace(/&/g,'&amp;').replace(/</g,'&lt;')+'</code></li>';}
3298
+ if(p.kind==='cluster'){
3299
+ var c=clusters.filter(function(x){return x.key===p.key;})[0];
3300
+ if(!c)return '';
3301
+ return '<h2>'+c.name+'</h2><p class="meta">community \xB7 '+c.members.length+' files</p><h3>Members</h3><ul>'+
3302
+ c.members.slice(0,20).map(function(n){return li(n.file);}).join('')+'</ul>'+
3303
+ (c.members.length>20?'<p class="meta">+'+(c.members.length-20)+' more</p>':'');
3304
+ }
3305
+ if(p.kind==='file'){
3306
+ var n=byFileId[p.id];if(!n)return '';
3307
+ var nbrs=fedges.filter(function(e){return e.a===n.id||e.b===n.id;}).slice(0,12);
3308
+ return '<h2>'+n.file+'</h2><p class="meta">'+n.lang+' \xB7 '+n.symbols+' symbols \xB7 in '+n.inn+' / out '+n.out+
3309
+ (n.cluster>=0?' \xB7 '+n.clusterName:'')+(n.pr?' \xB7 pr='+n.pr.toFixed(4):'')+'</p><h3>Links</h3><ul>'+
3310
+ 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>';
3311
+ }
3312
+ var x=bySymId[p.id];if(!x)return '';
3313
+ return '<h2>'+x.name+'</h2><p class="meta">'+x.type+' \xB7 '+x.file+(x.cluster>=0?' \xB7 cluster '+x.cluster:'')+
3314
+ ' \xB7 pr='+(x.pr||0).toFixed(4)+' \xB7 in '+x.inn+' / out '+x.out+'</p>'+
3315
+ '<h3>Callers / importers</h3><ul>'+(x.callers.slice(0,8).map(li).join('')||'<li>\u2014</li>')+'</ul>'+
3316
+ '<h3>Calls / depends on</h3><ul>'+(x.callees.slice(0,8).map(li).join('')||'<li>\u2014</li>')+'</ul>';
3317
+ }
3318
+ wrap.addEventListener('mousedown',function(e){drag={x:e.clientX-cam.x*cam.scale,y:e.clientY-cam.y*cam.scale};wrap.style.cursor='grabbing';});
3319
+ window.addEventListener('mouseup',function(){drag=null;wrap.style.cursor='grab';});
3320
+ window.addEventListener('mousemove',function(e){
3321
+ var r=wrap.getBoundingClientRect();
3322
+ if(drag){
3323
+ cam.x=(e.clientX-drag.x)/cam.scale;cam.y=(e.clientY-drag.y)/cam.scale;draw();tip.style.display='none';return;
3324
+ }
3325
+ if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom){tip.style.display='none';hover=null;return;}
3326
+ var p=pick(e.clientX-r.left,e.clientY-r.top);
3327
+ hover=p;
3328
+ if(p){
3329
+ 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+')');
3330
+ tip.textContent=t;tip.style.display='block';tip.style.left=(e.clientX+12)+'px';tip.style.top=(e.clientY+12)+'px';
3331
+ cv.style.cursor='pointer';
3332
+ }else{tip.style.display='none';cv.style.cursor='default';}
3333
+ draw();
3334
+ });
3335
+ wrap.addEventListener('click',function(e){
3336
+ if(drag&&Math.abs(e.clientX-drag.x)>4)return;
3337
+ var r=wrap.getBoundingClientRect();
3338
+ var p=pick(e.clientX-r.left,e.clientY-r.top);
3339
+ sel=p;draw();
3340
+ if(p)side.innerHTML=detailHTML(p);
3341
+ });
3342
+ wrap.addEventListener('wheel',function(e){
3343
+ e.preventDefault();
3344
+ var f=e.deltaY<0?1.12:0.89;
3345
+ cam.scale=Math.min(8,Math.max(.05,cam.scale*f));draw();
3346
+ },{passive:false});
3347
+ document.querySelectorAll('.seg button').forEach(function(b){
3348
+ b.addEventListener('click',function(){
3349
+ document.querySelectorAll('.seg button').forEach(function(x){x.classList.remove('on');});
3350
+ b.classList.add('on');mode=b.dataset.mode;sel=null;fit();
3351
+ });
3352
+ });
3353
+ var q=document.getElementById('q');
3354
+ q.addEventListener('input',function(){query=q.value.toLowerCase();draw();});
3355
+ var tf=document.getElementById('tf');
3356
+ tf.addEventListener('change',function(){typeF=tf.value;if(mode!=='symbol'&&typeF)mode='symbol';
3357
+ document.querySelectorAll('.seg button').forEach(function(x){x.classList.toggle('on',x.dataset.mode===mode);});
3358
+ draw();});
3359
+ document.getElementById('fit').addEventListener('click',fit);
3360
+ resize();fit();
3361
+ })();
3362
+ </script>
3363
+ </body>
3364
+ </html>`;
1716
3365
  }
1717
3366
 
1718
3367
  // src/scanner.ts
1719
3368
  function toPosixRel(repoRoot2, abs) {
1720
- return path8.relative(repoRoot2, abs).split(path8.sep).join("/");
3369
+ return path14.relative(repoRoot2, abs).split(path14.sep).join("/");
1721
3370
  }
1722
3371
  function walk(absDir, out) {
1723
- const entries = fs7.readdirSync(absDir, { withFileTypes: true });
3372
+ const entries = fs13.readdirSync(absDir, { withFileTypes: true });
1724
3373
  for (const e of entries) {
1725
- const abs = path8.join(absDir, e.name);
3374
+ const abs = path14.join(absDir, e.name);
1726
3375
  if (e.isDirectory()) {
1727
3376
  if (e.isSymbolicLink()) continue;
1728
3377
  walk(abs, out);
@@ -1775,7 +3424,7 @@ function scanRepository(repoRoot2) {
1775
3424
  seenPaths.add(rel);
1776
3425
  let buf;
1777
3426
  try {
1778
- buf = fs7.readFileSync(abs);
3427
+ buf = fs13.readFileSync(abs);
1779
3428
  } catch {
1780
3429
  continue;
1781
3430
  }
@@ -1831,6 +3480,24 @@ function scanRepository(repoRoot2) {
1831
3480
  ).run();
1832
3481
  linkTests(db);
1833
3482
  cleanupIndex(db);
3483
+ const graphDirty = inserted + updated + removed > 0 || adapterRun.symbols > 0 || adapterRun.relations > 0;
3484
+ const graphWritten = writeGraphMdIfChanged(db, repoRoot2, graphDirty);
3485
+ let visualWritten = false;
3486
+ if (graphDirty) {
3487
+ try {
3488
+ const model = buildVisualModel(db);
3489
+ const html = renderHtml(
3490
+ model,
3491
+ path14.basename(path14.resolve(repoRoot2)),
3492
+ (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " ")
3493
+ );
3494
+ fs13.mkdirSync(dataDir(repoRoot2), { recursive: true });
3495
+ fs13.writeFileSync(path14.join(dataDir(repoRoot2), GRAPH_HTML_NAME), html);
3496
+ visualWritten = true;
3497
+ } catch {
3498
+ visualWritten = false;
3499
+ }
3500
+ }
1834
3501
  const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
1835
3502
  const relationships = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
1836
3503
  return {
@@ -1841,6 +3508,8 @@ function scanRepository(repoRoot2) {
1841
3508
  symbols,
1842
3509
  relationships,
1843
3510
  adapters: adapterRun.adapters,
3511
+ graphWritten,
3512
+ visualWritten,
1844
3513
  inserted,
1845
3514
  updated,
1846
3515
  unchanged,
@@ -1867,6 +3536,12 @@ function cmdScan(repoRoot2) {
1867
3536
  if (stats.adapters.length > 0) {
1868
3537
  console.log(`\u2713 adapters: ${stats.adapters.join(", ")}`);
1869
3538
  }
3539
+ if (stats.graphWritten) {
3540
+ console.log(`\u2713 GRAPH.md updated (.scopecairn/GRAPH.md)`);
3541
+ }
3542
+ if (stats.visualWritten) {
3543
+ console.log(`\u2713 GRAPH.html updated (.scopecairn/GRAPH.html \u2014 open in browser)`);
3544
+ }
1870
3545
  console.log(
1871
3546
  `\u2713 index: +${stats.inserted} new, ~${stats.updated} updated, =${stats.unchanged} unchanged, -${stats.removed} removed`
1872
3547
  );
@@ -1875,11 +3550,11 @@ function cmdScan(repoRoot2) {
1875
3550
  }
1876
3551
 
1877
3552
  // src/commands/status.ts
1878
- import fs8 from "fs";
1879
- import path9 from "path";
3553
+ import fs14 from "fs";
3554
+ import path15 from "path";
1880
3555
  function cmdStatus(repoRoot2) {
1881
3556
  const dir = dataDir(repoRoot2);
1882
- if (!fs8.existsSync(path9.join(dir, "scopecairn.db"))) {
3557
+ if (!fs14.existsSync(path15.join(dir, "scopecairn.db"))) {
1883
3558
  console.log("ScopeCairn");
1884
3559
  console.log("Status: NOT INITIALIZED (run `scopecairn scan`)");
1885
3560
  return;
@@ -1931,17 +3606,17 @@ function cmdStatus(repoRoot2) {
1931
3606
  }
1932
3607
 
1933
3608
  // src/commands/rebuild.ts
1934
- import fs9 from "fs";
3609
+ import fs15 from "fs";
1935
3610
  function cmdRebuild(repoRoot2) {
1936
3611
  const dir = dataDir(repoRoot2);
1937
- fs9.rmSync(dir, { recursive: true, force: true });
3612
+ fs15.rmSync(dir, { recursive: true, force: true });
1938
3613
  console.log(`Cleaned ${dir}`);
1939
3614
  cmdScan(repoRoot2);
1940
3615
  }
1941
3616
 
1942
3617
  // src/commands/doctor.ts
1943
- import fs11 from "fs";
1944
- import path11 from "path";
3618
+ import fs17 from "fs";
3619
+ import path17 from "path";
1945
3620
 
1946
3621
  // src/treesitter.ts
1947
3622
  var cached = null;
@@ -1966,18 +3641,18 @@ async function checkTreeSitter() {
1966
3641
  }
1967
3642
 
1968
3643
  // src/integrate/detect.ts
1969
- import fs10 from "fs";
1970
- import path10 from "path";
3644
+ import fs16 from "fs";
3645
+ import path16 from "path";
1971
3646
  function detectAgentDir(repoRoot2) {
1972
- const plural = fs10.existsSync(path10.join(repoRoot2, ".agents"));
1973
- const singular = fs10.existsSync(path10.join(repoRoot2, ".agent"));
3647
+ const plural = fs16.existsSync(path16.join(repoRoot2, ".agents"));
3648
+ const singular = fs16.existsSync(path16.join(repoRoot2, ".agent"));
1974
3649
  if (singular && !plural) return ".agent";
1975
3650
  return ".agents";
1976
3651
  }
1977
3652
  function detectPrefix(repoRoot2) {
1978
3653
  try {
1979
3654
  const pkg = JSON.parse(
1980
- fs10.readFileSync(path10.join(repoRoot2, "package.json"), "utf8")
3655
+ fs16.readFileSync(path16.join(repoRoot2, "package.json"), "utf8")
1981
3656
  );
1982
3657
  if (pkg.dependencies?.["scopecairn"] || pkg.devDependencies?.["scopecairn"]) {
1983
3658
  return "npx scopecairn";
@@ -2021,9 +3696,28 @@ Jalankan \`${prefix} scan\` agar indeks ScopeCairn mencerminkan perubahanmu.
2021
3696
  Lewati ScopeCairn hanya untuk pertanyaan non-coding.
2022
3697
  `;
2023
3698
  }
2024
- function agentsMd(prefix) {
2025
- return `${MARKER}
2026
- ${ruleBody(prefix)}`;
3699
+ function claudeSkillMd(prefix) {
3700
+ return `---
3701
+ name: scopecairn
3702
+ description: ScopeCairn codebase context + scope. Use for ALL coding tasks
3703
+ (feature, bugfix, refactor, code questions) before exploring the repo,
3704
+ and when the user says "scopecairn", asks for a repo overview, or asks
3705
+ to set up ScopeCairn.
3706
+ allowed-tools: Bash
3707
+ ---
3708
+
3709
+ # ScopeCairn
3710
+
3711
+ 1. Jika \`.scopecairn/scopecairn.db\` belum ada: jalankan \`${prefix} init\`
3712
+ (bila CLI belum ada: \`npm install -g scopecairn\`), laporkan file/simbol/relasi, berhenti.
3713
+ 2. Jika sudah ada: jalankan \`${prefix} context "<task user>"\`.
3714
+ Mulai dari Relevant Files. Jangan menjelajah dari nol.
3715
+ 3. Ubah hanya Required/Optional; Protected perlu persetujuan user.
3716
+ Simbol bersama \u2192 \`${prefix} impact <path>\`.
3717
+ Butuh isi fungsi \u2192 \`${prefix} read symbol <nama>\`, bukan baca file penuh.
3718
+ 4. Task membesar (>2 file)? Panggil ulang \`${prefix} context --escalate\`.
3719
+ 5. Setelah mengedit: \`${prefix} scan\`. Berhenti saat acceptance criteria terpenuhi.
3720
+ `;
2027
3721
  }
2028
3722
  function genericRuleMd(prefix) {
2029
3723
  return `${MARKER}
@@ -2064,6 +3758,37 @@ Shortcut manual bila agent lupa memanggil ScopeCairn otomatis.
2064
3758
  4. Perlu source simbol? \`${prefix} read symbol <nama>\`.
2065
3759
  `;
2066
3760
  }
3761
+ function opencodeCommandMd(prefix) {
3762
+ return `---
3763
+ description: ScopeCairn codebase context + scope for a task
3764
+ ---
3765
+
3766
+ Run \`${prefix} context "$ARGUMENTS"\` with the bash tool, then:
3767
+
3768
+ 1. Use Relevant Files as the starting point \u2014 do not explore from zero.
3769
+ 2. Change only Required and Optional files; Protected needs user approval.
3770
+ 3. If shared symbols change, run \`${prefix} impact <path>\`.
3771
+ 4. Need a function body? Use \`${prefix} read symbol <nama>\`, not full reads.
3772
+ 5. After editing, run \`${prefix} scan\` so the ScopeCairn index stays fresh.
3773
+ <!-- scopecairn:managed -->
3774
+ `;
3775
+ }
3776
+ function opencodePermissionGuide(prefix) {
3777
+ const cmds = ["context", "impact", "read", "graph", "status", "doctor"];
3778
+ const rules = cmds.map((c) => ` "${prefix} ${c} *": "allow"`).join(",\n");
3779
+ return `Agar command di atas jalan tanpa persetujuan, gabungkan ke opencode.json
3780
+ (project root atau ~/.config/opencode/opencode.json):
3781
+
3782
+ {
3783
+ "permission": {
3784
+ "bash": {
3785
+ ${rules}
3786
+ }
3787
+ }
3788
+ }
3789
+
3790
+ Catatan: ScopeCairn tidak mengubah opencode.json Anda secara otomatis.`;
3791
+ }
2067
3792
  var ALLOWLIST_COMMANDS = [
2068
3793
  "context",
2069
3794
  "impact",
@@ -2085,29 +3810,13 @@ Catatan: ScopeCairn tidak mengubah pengaturan IDE Anda secara otomatis.`;
2085
3810
 
2086
3811
  // src/integrate/matrix.ts
2087
3812
  var AGENT_MATRIX = [
2088
- {
2089
- id: "agents",
2090
- label: "AGENTS.md (universal: Codex, Aider, Opencode, Amp, Jules, ...)",
2091
- rel: "AGENTS.md",
2092
- mode: "merge",
2093
- render: agentsMd,
2094
- presentMarker: "AGENTS.md"
2095
- },
2096
3813
  {
2097
3814
  id: "claude",
2098
3815
  label: "Claude Code",
2099
- rel: "CLAUDE.md",
2100
- mode: "merge",
2101
- render: genericRuleMd,
2102
- presentMarker: "CLAUDE.md"
2103
- },
2104
- {
2105
- id: "gemini",
2106
- label: "Gemini / Antigravity CLI",
2107
- rel: "GEMINI.md",
2108
- mode: "merge",
2109
- render: genericRuleMd,
2110
- presentMarker: "GEMINI.md"
3816
+ rel: ".claude/skills/scopecairn/SKILL.md",
3817
+ mode: "new",
3818
+ render: claudeSkillMd,
3819
+ presentMarker: ".claude"
2111
3820
  },
2112
3821
  {
2113
3822
  id: "cursor",
@@ -2140,6 +3849,14 @@ var AGENT_MATRIX = [
2140
3849
  mode: "new",
2141
3850
  render: genericRuleMd,
2142
3851
  presentMarker: ".kiro"
3852
+ },
3853
+ {
3854
+ id: "opencode",
3855
+ label: "OpenCode",
3856
+ rel: ".opencode/commands/scopecairn.md",
3857
+ mode: "new",
3858
+ render: opencodeCommandMd,
3859
+ presentMarker: ".opencode"
2143
3860
  }
2144
3861
  ];
2145
3862
  function findTarget(id) {
@@ -2155,7 +3872,7 @@ function parseAgentsOption(raw) {
2155
3872
  function tick(ok) {
2156
3873
  return ok ? "\u2713" : "\u2717";
2157
3874
  }
2158
- async function cmdDoctor(repoRoot2) {
3875
+ async function cmdDoctor(repoRoot2, opts) {
2159
3876
  const nodeOk = Number(process.versions.node.split(".")[0]) >= 20;
2160
3877
  console.log(`${tick(nodeOk)} Node.js ${process.version} (need >=20)`);
2161
3878
  let sqliteOk = false;
@@ -2164,8 +3881,10 @@ async function cmdDoctor(repoRoot2) {
2164
3881
  const req = createRequire2(import.meta.url);
2165
3882
  const m = req("node:sqlite");
2166
3883
  sqliteOk = typeof m.DatabaseSync === "function";
2167
- } catch {
3884
+ } catch (err) {
2168
3885
  sqliteOk = false;
3886
+ logError("doctor", err, repoRoot2);
3887
+ if (opts?.verbose) console.error(err);
2169
3888
  }
2170
3889
  console.log(`${tick(sqliteOk)} SQLite (node:sqlite built-in)`);
2171
3890
  const ts = await checkTreeSitter();
@@ -2178,23 +3897,62 @@ async function cmdDoctor(repoRoot2) {
2178
3897
  const s = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
2179
3898
  const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
2180
3899
  db.close();
2181
- dbOk = fs11.existsSync(dbPath(repoRoot2));
3900
+ dbOk = fs17.existsSync(dbPath(repoRoot2));
2182
3901
  graphDetail = ` (${s} symbols, ${r} rels)`;
2183
- } catch {
3902
+ } catch (err) {
2184
3903
  dbOk = false;
3904
+ logError("doctor", err, repoRoot2);
3905
+ if (opts?.verbose) console.error(err);
2185
3906
  }
2186
3907
  console.log(`${tick(dbOk)} Database ${dbPath(repoRoot2)}${graphDetail}`);
2187
- const protPath = path11.join(dataDir(repoRoot2), "protected.yml");
2188
- console.log(`${tick(fs11.existsSync(protPath))} Protected ${protPath}`);
3908
+ try {
3909
+ const db = openDb(repoRoot2);
3910
+ try {
3911
+ const { refreshCycles, getCycles, describeCycle } = await import("./cycles-6KSI4OPZ.js");
3912
+ const { ensureMetrics: ensureMetrics2, topByPageRank } = await import("./metrics-ZWWLUYJW.js");
3913
+ const cycles = getCycles(db, 5);
3914
+ if (cycles.length === 0) {
3915
+ try {
3916
+ refreshCycles(db);
3917
+ } catch (err) {
3918
+ logError("doctor", err, repoRoot2);
3919
+ if (opts?.verbose) console.error(err);
3920
+ }
3921
+ }
3922
+ const found = getCycles(db, 5);
3923
+ console.log(`${tick(found.length === 0)} Circular dependencies (${found.length} cycle(s))`);
3924
+ for (const c of found.slice(0, 3)) {
3925
+ console.log(` ~ cycle[${c.length}]: ${describeCycle(db, c)}`);
3926
+ }
3927
+ ensureMetrics2(db);
3928
+ const gods = topByPageRank(db, 3);
3929
+ if (gods.length > 0) {
3930
+ console.log(`i Critical hotspots (PageRank):`);
3931
+ for (const g of gods) {
3932
+ console.log(
3933
+ ` ! ${g.name} (${g.type}, ${g.file}) pr=${g.pagerank.toFixed(4)} in=${g.inDegree} out=${g.outDegree}`
3934
+ );
3935
+ }
3936
+ }
3937
+ } finally {
3938
+ db.close();
3939
+ }
3940
+ } catch (err) {
3941
+ logError("doctor", err, repoRoot2);
3942
+ if (opts?.verbose) console.error(err);
3943
+ console.log(`i Graph metrics unavailable (run \`scopecairn scan\` first)`);
3944
+ }
3945
+ const protPath = path17.join(dataDir(repoRoot2), "protected.yml");
3946
+ console.log(`${tick(fs17.existsSync(protPath))} Protected ${protPath}`);
2189
3947
  const expectedPrefix = detectPrefix(repoRoot2);
2190
3948
  const agentDir = detectAgentDir(repoRoot2);
2191
3949
  let prefixOk = true;
2192
3950
  let matrixOk = 0;
2193
3951
  for (const t of AGENT_MATRIX) {
2194
- const abs = path11.join(repoRoot2, t.rel);
2195
- if (!fs11.existsSync(abs)) continue;
2196
- const text = fs11.readFileSync(abs, "utf8");
2197
- if (!text.includes(MARKER)) continue;
3952
+ const abs = path17.join(repoRoot2, t.rel);
3953
+ if (!fs17.existsSync(abs)) continue;
3954
+ const text = fs17.readFileSync(abs, "utf8");
3955
+ if (t.mode === "merge" && !text.includes(MARKER)) continue;
2198
3956
  matrixOk++;
2199
3957
  const used = extractPrefixUsed(text);
2200
3958
  if (used && used !== expectedPrefix) {
@@ -2210,13 +3968,13 @@ async function cmdDoctor(repoRoot2) {
2210
3968
  } else if (prefixOk) {
2211
3969
  console.log(`\u2713 Prefix konsisten (\`${expectedPrefix} context\`)`);
2212
3970
  }
2213
- const skillPath = path11.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
2214
- const skillOk = fs11.existsSync(skillPath);
3971
+ const skillPath = path17.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
3972
+ const skillOk = fs17.existsSync(skillPath);
2215
3973
  console.log(`${tick(skillOk)} Skill (${agentDir}/skills/scopecairn/SKILL.md)`);
2216
- const wfPath = path11.join(repoRoot2, "workflows", "scopecairn.md");
2217
- console.log(`${tick(fs11.existsSync(wfPath))} Workflow (workflows/scopecairn.md, opsional)`);
3974
+ const wfPath = path17.join(repoRoot2, "workflows", "scopecairn.md");
3975
+ console.log(`${tick(fs17.existsSync(wfPath))} Workflow (workflows/scopecairn.md, opsional)`);
2218
3976
  console.log(`i Strict Mode mengabaikan allowlist \u2014 semua perintah tetap perlu persetujuan.`);
2219
- const healthy = nodeOk && sqliteOk && ts.ok && dbOk && matrixOk > 0 && skillOk && prefixOk;
3977
+ const healthy = nodeOk && sqliteOk && ts.ok && dbOk && (matrixOk > 0 || skillOk) && prefixOk;
2220
3978
  console.log(`Status: ${healthy ? "HEALTHY" : "DEGRADED (see \u2717 above)"}`);
2221
3979
  }
2222
3980
 
@@ -2285,6 +4043,71 @@ function neighbors(db, id, limit = 50) {
2285
4043
  }))
2286
4044
  ];
2287
4045
  }
4046
+ function findShortestPath(db, fromId, toId, maxDepth = 8, fanout = 200) {
4047
+ if (fromId === toId) return [];
4048
+ const prev = /* @__PURE__ */ new Map();
4049
+ const seen = /* @__PURE__ */ new Set([fromId]);
4050
+ let frontier = [fromId];
4051
+ let found = false;
4052
+ for (let d = 0; d < maxDepth && frontier.length > 0; d++) {
4053
+ const next = [];
4054
+ for (const cur2 of frontier) {
4055
+ let rows = [];
4056
+ try {
4057
+ rows = db.prepare(
4058
+ `SELECT target_id AS t, relationship_type AS rel FROM relationships
4059
+ WHERE source_id = ? LIMIT ${Math.max(1, Math.min(1e3, fanout))}`
4060
+ ).all(cur2);
4061
+ } catch {
4062
+ rows = [];
4063
+ }
4064
+ for (const r of rows) {
4065
+ if (seen.has(r.t)) continue;
4066
+ seen.add(r.t);
4067
+ prev.set(r.t, { p: cur2, rel: r.rel });
4068
+ if (r.t === toId) {
4069
+ found = true;
4070
+ break;
4071
+ }
4072
+ next.push(r.t);
4073
+ }
4074
+ if (found) break;
4075
+ }
4076
+ if (found) break;
4077
+ frontier = next;
4078
+ }
4079
+ if (!found) return null;
4080
+ const chain = [toId];
4081
+ let cur = toId;
4082
+ while (cur !== fromId) {
4083
+ const step = prev.get(cur);
4084
+ if (!step) return null;
4085
+ chain.push(step.p);
4086
+ cur = step.p;
4087
+ }
4088
+ chain.reverse();
4089
+ const info = /* @__PURE__ */ new Map();
4090
+ try {
4091
+ const rows = db.prepare(
4092
+ `SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
4093
+ JOIN files f ON f.id = s.file_id`
4094
+ ).all();
4095
+ for (const r of rows) {
4096
+ const n = asNode(r);
4097
+ if (chain.includes(n.id)) info.set(n.id, n);
4098
+ }
4099
+ } catch {
4100
+ return null;
4101
+ }
4102
+ const hops = [];
4103
+ for (let i = 0; i < chain.length - 1; i++) {
4104
+ const a = info.get(chain[i]);
4105
+ const b = info.get(chain[i + 1]);
4106
+ if (!a || !b) return null;
4107
+ hops.push({ from: a, rel: prev.get(chain[i + 1]).rel, to: b });
4108
+ }
4109
+ return hops;
4110
+ }
2288
4111
 
2289
4112
  // src/commands/graph.ts
2290
4113
  function cmdGraph(repoRoot2, key) {
@@ -2306,6 +4129,21 @@ function cmdGraph(repoRoot2, key) {
2306
4129
  console.log("");
2307
4130
  }
2308
4131
  const edges = neighbors(db, node.id);
4132
+ try {
4133
+ ensureMetrics(db);
4134
+ ensureClusters(db);
4135
+ const m = getMetrics(db, node.id);
4136
+ const c = getCluster(db, node.id);
4137
+ const parts = [];
4138
+ if (m) parts.push(`pr=${m.pagerank.toFixed(4)} btw=${m.betweenness.toFixed(4)} in=${m.inDegree} out=${m.outDegree}`);
4139
+ if (c) parts.push(`community=${c.clusterName}`);
4140
+ try {
4141
+ if (nodeInCycle(db, node.id)) parts.push(`IN CYCLE`);
4142
+ } catch {
4143
+ }
4144
+ if (parts.length > 0) console.log(`Metrics: ${parts.join(" \xB7 ")}`);
4145
+ } catch {
4146
+ }
2309
4147
  if (edges.length === 0) {
2310
4148
  console.log("(no relations)");
2311
4149
  return;
@@ -2333,9 +4171,82 @@ function cmdGraph(repoRoot2, key) {
2333
4171
  }
2334
4172
  }
2335
4173
 
4174
+ // src/commands/path.ts
4175
+ function cmdPath(repoRoot2, from, to) {
4176
+ const db = openDb(repoRoot2);
4177
+ try {
4178
+ logInvocation(db, "path");
4179
+ const aHits = findSymbols(db, from);
4180
+ const bHits = findSymbols(db, to);
4181
+ if (aHits.length === 0) {
4182
+ console.log(`No symbol or file matching "${from}".`);
4183
+ return;
4184
+ }
4185
+ if (bHits.length === 0) {
4186
+ console.log(`No symbol or file matching "${to}".`);
4187
+ return;
4188
+ }
4189
+ const a = aHits.find((h) => h.name === from) ?? aHits[0];
4190
+ const b = bHits.find((h) => h.name === to) ?? bHits[0];
4191
+ const hops = findShortestPath(db, a.id, b.id);
4192
+ if (!hops) {
4193
+ console.log(`No path from "${a.name}" to "${b.name}" (max 8 hops).`);
4194
+ if (aHits.length > 1 || bHits.length > 1) {
4195
+ console.log(
4196
+ `From candidates: ${aHits.slice(0, 5).map((h) => `${h.name} (${h.file})`).join(" | ")}`
4197
+ );
4198
+ console.log(
4199
+ `To candidates: ${bHits.slice(0, 5).map((h) => `${h.name} (${h.file})`).join(" | ")}`
4200
+ );
4201
+ }
4202
+ return;
4203
+ }
4204
+ if (hops.length === 0) {
4205
+ console.log(`"${a.name}" and "${b.name}" resolve to the same symbol.`);
4206
+ return;
4207
+ }
4208
+ console.log(`Path found (${hops.length} hop${hops.length === 1 ? "" : "s"}):`);
4209
+ hops.forEach((h, i) => {
4210
+ console.log(`[${i + 1}] ${h.from.name} (${h.from.type}, ${h.from.file})`);
4211
+ console.log(` \u2514\u2500(${h.rel})\u2500> ${h.to.name} (${h.to.type}, ${h.to.file})`);
4212
+ });
4213
+ } finally {
4214
+ db.close();
4215
+ }
4216
+ }
4217
+
4218
+ // src/commands/export.ts
4219
+ import fs18 from "fs";
4220
+ import path18 from "path";
4221
+ var FORMATS = ["mermaid", "graphml", "dot", "json"];
4222
+ var EXT = {
4223
+ mermaid: "mmd",
4224
+ graphml: "graphml",
4225
+ dot: "dot",
4226
+ json: "json"
4227
+ };
4228
+ function cmdExport(repoRoot2, format, out) {
4229
+ const fmt = format.toLowerCase();
4230
+ if (!FORMATS.includes(fmt)) {
4231
+ console.log(`Unknown format "${format}". Choose: ${FORMATS.join(", ")}.`);
4232
+ return;
4233
+ }
4234
+ const db = openDb(repoRoot2);
4235
+ try {
4236
+ logInvocation(db, "export");
4237
+ const text = exportGraph(db, fmt);
4238
+ const dest = out ?? path18.join(dataDir(repoRoot2), `graph.${EXT[fmt]}`);
4239
+ fs18.mkdirSync(path18.dirname(dest), { recursive: true });
4240
+ fs18.writeFileSync(dest, text);
4241
+ console.log(`Exported ${fmt} \u2192 ${dest}`);
4242
+ } finally {
4243
+ db.close();
4244
+ }
4245
+ }
4246
+
2336
4247
  // src/commands/read.ts
2337
- import fs12 from "fs";
2338
- import path12 from "path";
4248
+ import fs19 from "fs";
4249
+ import path19 from "path";
2339
4250
  function cmdRead(repoRoot2, kind, name) {
2340
4251
  if (kind !== "symbol") {
2341
4252
  console.log(`Unknown read kind "${kind}". Usage: scopecairn read symbol <nama>`);
@@ -2351,10 +4262,10 @@ function cmdRead(repoRoot2, kind, name) {
2351
4262
  return;
2352
4263
  }
2353
4264
  const row = db.prepare(`SELECT start_line, end_line FROM symbols WHERE id = ?`).get(node.id);
2354
- const abs = path12.join(repoRoot2, node.file);
4265
+ const abs = path19.join(repoRoot2, node.file);
2355
4266
  let lines;
2356
4267
  try {
2357
- lines = fs12.readFileSync(abs, "utf8").split("\n");
4268
+ lines = fs19.readFileSync(abs, "utf8").split("\n");
2358
4269
  } catch {
2359
4270
  console.log(`Cannot read ${node.file}.`);
2360
4271
  return;
@@ -2464,29 +4375,36 @@ function retrieve(db, repoRoot2, task, opts) {
2464
4375
  proximity.set(s.symbolId, Math.max(proximity.get(s.symbolId) ?? 0, s.bm25));
2465
4376
  visited.add(s.symbolId);
2466
4377
  });
2467
- const edgeStmt = db.prepare(
2468
- `SELECT source_id, target_id, weight FROM relationships`
2469
- );
2470
- const adj = /* @__PURE__ */ new Map();
2471
- for (const r of edgeStmt.all()) {
2472
- if (!adj.has(r.source_id)) adj.set(r.source_id, []);
2473
- adj.get(r.source_id).push({ to: r.target_id, w: r.weight });
2474
- if (!adj.has(r.target_id)) adj.set(r.target_id, []);
2475
- adj.get(r.target_id).push({ to: r.source_id, w: r.weight * 0.8 });
4378
+ const CHUNK = 2e3;
4379
+ function neighborsOf(ids) {
4380
+ const out = [];
4381
+ for (let i = 0; i < ids.length; i += CHUNK) {
4382
+ const c = ids.slice(i, i + CHUNK);
4383
+ const ph = c.map(() => "?").join(",");
4384
+ for (const r of db.prepare(
4385
+ `SELECT source_id AS f, target_id AS t, weight AS w FROM relationships WHERE source_id IN (${ph})`
4386
+ ).all(...c)) {
4387
+ out.push({ from: r.f, to: r.t, w: r.w });
4388
+ }
4389
+ for (const r of db.prepare(
4390
+ `SELECT target_id AS f, source_id AS t, weight * 0.8 AS w FROM relationships WHERE target_id IN (${ph})`
4391
+ ).all(...c)) {
4392
+ out.push({ from: r.f, to: r.t, w: r.w });
4393
+ }
4394
+ }
4395
+ return out;
2476
4396
  }
2477
4397
  let hopScore = 1;
2478
4398
  for (let depth = 1; depth <= config.maxDepth; depth++) {
2479
4399
  hopScore *= config.decayPerHop;
2480
4400
  const next = [];
2481
- for (const id of frontier) {
2482
- const base = proximity.get(id) ?? 0;
2483
- for (const e of adj.get(id) ?? []) {
2484
- const cand = base * e.w * hopScore;
2485
- if (cand > (proximity.get(e.to) ?? 0)) proximity.set(e.to, cand);
2486
- if (!visited.has(e.to)) {
2487
- visited.add(e.to);
2488
- next.push(e.to);
2489
- }
4401
+ for (const e of neighborsOf(frontier)) {
4402
+ const base = proximity.get(e.from) ?? 0;
4403
+ const cand = base * e.w * hopScore;
4404
+ if (cand > (proximity.get(e.to) ?? 0)) proximity.set(e.to, cand);
4405
+ if (!visited.has(e.to)) {
4406
+ visited.add(e.to);
4407
+ next.push(e.to);
2490
4408
  }
2491
4409
  }
2492
4410
  frontier = next;
@@ -2568,14 +4486,14 @@ function retrieve(db, repoRoot2, task, opts) {
2568
4486
 
2569
4487
  // src/retrieval/classify.ts
2570
4488
  var FANIN_THRESHOLD = 5;
2571
- function moduleOf(file) {
4489
+ function moduleOf2(file) {
2572
4490
  const i = file.indexOf("/");
2573
4491
  return i === -1 ? "." : file.slice(0, i);
2574
4492
  }
2575
4493
  function classify(ranked, faninOf, threshold = 0.15) {
2576
4494
  const relevant = ranked.filter((r) => r.score >= threshold);
2577
4495
  const files = [...new Set(relevant.map((r) => r.file))];
2578
- const modules = [...new Set(files.map(moduleOf))];
4496
+ const modules = [...new Set(files.map(moduleOf2))];
2579
4497
  const reasons = [];
2580
4498
  const DEFINITIONAL = /* @__PURE__ */ new Set([
2581
4499
  "function",
@@ -2745,6 +4663,36 @@ Task sederhana. Gunakan pencarian langsung. Kandidat: ${cand}
2745
4663
  }
2746
4664
  const tests = [...testSet].slice(0, 10);
2747
4665
  const symLine = (r) => `- ${r.name} (${r.type}) \u2014 \`${r.file}\` [${r.score.toFixed(2)} ${r.reason}]`;
4666
+ let hotspots = [];
4667
+ let cycleWarn = "";
4668
+ try {
4669
+ const ids = ranked.slice(0, 20).map((r) => r.id);
4670
+ if (ids.length > 0) {
4671
+ const ph = ids.map(() => "?").join(",");
4672
+ const prRows = db.prepare(`SELECT node_id AS id, pagerank AS pr FROM node_metrics WHERE node_id IN (${ph})`).all(...ids);
4673
+ const pr = new Map(prRows.map((r) => [r.id, r.pr]));
4674
+ 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);
4675
+ hotspots = boosted.map(
4676
+ (b) => `- ${b.r.name} (${b.r.type}) \u2014 \`${b.r.file}\` [pr=${(pr.get(b.r.id) ?? 0).toFixed(4)}]`
4677
+ );
4678
+ const cycRows = db.prepare(`SELECT nodes_json AS js FROM cycles`).all();
4679
+ const inCycle = /* @__PURE__ */ new Set();
4680
+ for (const c of cycRows) {
4681
+ try {
4682
+ for (const n of JSON.parse(c.js)) inCycle.add(n);
4683
+ } catch {
4684
+ }
4685
+ }
4686
+ const trapped = ranked.slice(0, 10).filter((r) => inCycle.has(r.id));
4687
+ if (trapped.length > 0) {
4688
+ cycleWarn = `
4689
+ ## Circular dependency warning
4690
+ ` + trapped.slice(0, 5).map((r) => `- ${r.name} (\`${r.file}\`) is inside a dependency cycle`).join("\n") + `
4691
+ `;
4692
+ }
4693
+ }
4694
+ } catch {
4695
+ }
2748
4696
  const scope = computeScope(
2749
4697
  highFiles,
2750
4698
  medFiles,
@@ -2768,20 +4716,23 @@ ${medFiles.map((f) => `- ${f}`).join("\n") || "-"}
2768
4716
  ## Key Symbols
2769
4717
  ${hi.slice(0, 8).map(symLine).join("\n") || "-"}
2770
4718
 
2771
- ## Dependencies
4719
+ ` + (hotspots.length > 0 ? `## Critical hotspots (PageRank)
4720
+ ${hotspots.join("\n")}
4721
+
4722
+ ` : ``) + `## Dependencies
2772
4723
  ${deps.map((d) => `- ${d}`).join("\n") || "-"}
2773
4724
 
2774
4725
  ## Related Tests
2775
4726
  ${tests.map((t) => `- ${t}`).join("\n") || "-"}
2776
4727
 
2777
- ` + scopeBlock(scope) + `
4728
+ ` + cycleWarn + scopeBlock(scope) + `
2778
4729
  ` + guardBlock();
2779
4730
  return { markdown: md, high: highFiles, medium: medFiles, tests, dependencies: deps };
2780
4731
  }
2781
4732
 
2782
4733
  // src/commands/orientasi.ts
2783
- import fs13 from "fs";
2784
- import path13 from "path";
4734
+ import fs20 from "fs";
4735
+ import path20 from "path";
2785
4736
  function isPathLike(task) {
2786
4737
  const t = task.trim().replace(/^["']|["']$/g, "");
2787
4738
  return t === "." || t === "./" || t === "/" || t.startsWith("./") || t.startsWith("../") || t.startsWith("/") || t.startsWith("~");
@@ -2791,7 +4742,7 @@ function looksLikeRepoPath(db, repoRoot2, task) {
2791
4742
  if (/\s/.test(t)) return false;
2792
4743
  if (isPathLike(task)) return true;
2793
4744
  try {
2794
- fs13.statSync(path13.resolve(repoRoot2, t));
4745
+ fs20.statSync(path20.resolve(repoRoot2, t));
2795
4746
  return true;
2796
4747
  } catch {
2797
4748
  }
@@ -2827,11 +4778,11 @@ function cmdOrientasi(repoRoot2, task) {
2827
4778
  const byLang = db.prepare(`SELECT language, COUNT(*) AS n FROM files GROUP BY language ORDER BY n DESC`).all();
2828
4779
  const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
2829
4780
  const t = task.trim().replace(/^["']|["']$/g, "");
2830
- const abs = path13.resolve(repoRoot2, t);
4781
+ const abs = path20.resolve(repoRoot2, t);
2831
4782
  let rel = null;
2832
4783
  try {
2833
- const stat = fs13.statSync(abs);
2834
- if (stat.isFile()) rel = path13.relative(repoRoot2, abs).split(path13.sep).join("/");
4784
+ const stat = fs20.statSync(abs);
4785
+ if (stat.isFile()) rel = path20.relative(repoRoot2, abs).split(path20.sep).join("/");
2835
4786
  } catch {
2836
4787
  rel = null;
2837
4788
  }
@@ -2889,6 +4840,46 @@ Mulai dari simbol di atas, atau jalankan \`scopecairn context "<deskripsi task>"
2889
4840
  }
2890
4841
  }
2891
4842
 
4843
+ // src/decisions.ts
4844
+ import fs21 from "fs";
4845
+ import path21 from "path";
4846
+ function decisionsPath(repoRoot2) {
4847
+ return path21.join(dataDir(repoRoot2), "decisions.md");
4848
+ }
4849
+ function loadDecisions(repoRoot2) {
4850
+ try {
4851
+ return fs21.readFileSync(decisionsPath(repoRoot2), "utf8");
4852
+ } catch {
4853
+ return null;
4854
+ }
4855
+ }
4856
+
4857
+ // src/retrieval/gitChanged.ts
4858
+ import { execFileSync as execFileSync2 } from "child_process";
4859
+ function changedFilesVsHead(repoRoot2) {
4860
+ let out;
4861
+ try {
4862
+ out = execFileSync2("git", ["status", "--porcelain"], {
4863
+ cwd: repoRoot2,
4864
+ encoding: "utf8",
4865
+ timeout: 8e3,
4866
+ stdio: ["ignore", "pipe", "ignore"]
4867
+ });
4868
+ } catch {
4869
+ return [];
4870
+ }
4871
+ const files = [];
4872
+ for (const line of out.split("\n")) {
4873
+ if (line.length < 4) continue;
4874
+ let p = line.slice(3).trim();
4875
+ const arrow = p.indexOf(" -> ");
4876
+ if (arrow >= 0) p = p.slice(arrow + 4);
4877
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
4878
+ if (p) files.push(p);
4879
+ }
4880
+ return files;
4881
+ }
4882
+
2892
4883
  // src/commands/context.ts
2893
4884
  function cmdContext(repoRoot2, task, opts = {}) {
2894
4885
  if (!opts.noRefresh) {
@@ -2943,11 +4934,37 @@ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan
2943
4934
  insCtx.run(taskId, r.id, r.score, r.reason);
2944
4935
  }
2945
4936
  logInvocation(db, "context", taskId);
4937
+ const mode = opts.mode ?? "NORMAL";
4938
+ const effectiveMode = mode === "NORMAL" && cls.complexity === "SIMPLE" && cls.estimatedFiles <= 1 ? "FAST" : mode;
4939
+ const escalate = opts.escalate === true || effectiveMode === "SAFE" || effectiveMode === "AUDIT" || mode === "SAFE" || mode === "AUDIT";
4940
+ const changed = changedFilesVsHead(repoRoot2);
4941
+ if (changed.length > 0) {
4942
+ console.log(
4943
+ `> Working tree: ${changed.length} file berubah vs HEAD (${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}).
4944
+ `
4945
+ );
4946
+ }
4947
+ const decisions = loadDecisions(repoRoot2);
2946
4948
  const built = buildContext(db, task, result, cls, {
2947
- escalate: opts.escalate,
4949
+ escalate,
2948
4950
  repoRoot: repoRoot2
2949
4951
  });
2950
- console.log(built.markdown);
4952
+ let out = built.markdown;
4953
+ if (effectiveMode !== "NORMAL") {
4954
+ out += `
4955
+
4956
+ ## Mode
4957
+ ${effectiveMode}
4958
+ `;
4959
+ }
4960
+ if (decisions) {
4961
+ out += `
4962
+
4963
+ ## Keputusan Arsitektur (.scopecairn/decisions.md)
4964
+ ${decisions}
4965
+ `;
4966
+ }
4967
+ console.log(out);
2951
4968
  } finally {
2952
4969
  db.close();
2953
4970
  }
@@ -3081,14 +5098,14 @@ function cmdImpact(repoRoot2, target, opts = {}) {
3081
5098
  }
3082
5099
 
3083
5100
  // src/commands/init.ts
3084
- import fs15 from "fs";
3085
- import path15 from "path";
5101
+ import fs23 from "fs";
5102
+ import path23 from "path";
3086
5103
 
3087
5104
  // src/integrate/install.ts
3088
- import fs14 from "fs";
3089
- import path14 from "path";
5105
+ import fs22 from "fs";
5106
+ import path22 from "path";
3090
5107
  function installTarget(repoRoot2, target, prefix) {
3091
- const abs = path14.join(repoRoot2, target.rel);
5108
+ const abs = path22.join(repoRoot2, target.rel);
3092
5109
  const block = target.render(prefix);
3093
5110
  const finish = (how) => ({
3094
5111
  agent: target.id,
@@ -3096,25 +5113,25 @@ function installTarget(repoRoot2, target, prefix) {
3096
5113
  how
3097
5114
  });
3098
5115
  if (target.mode === "new") {
3099
- const existed = fs14.existsSync(abs);
3100
- fs14.mkdirSync(path14.dirname(abs), { recursive: true });
3101
- fs14.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
5116
+ const existed = fs22.existsSync(abs);
5117
+ fs22.mkdirSync(path22.dirname(abs), { recursive: true });
5118
+ fs22.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
3102
5119
  return finish(existed ? "updated" : "generated");
3103
5120
  }
3104
- if (fs14.existsSync(abs)) {
3105
- const cur = fs14.readFileSync(abs, "utf8");
5121
+ if (fs22.existsSync(abs)) {
5122
+ const cur = fs22.readFileSync(abs, "utf8");
3106
5123
  if (cur.includes(MARKER)) {
3107
5124
  const { next: next2 } = removeManagedBlock(cur, (p) => target.render(p));
3108
- fs14.writeFileSync(abs, (next2 + "\n\n" + block).trimEnd() + "\n");
5125
+ fs22.writeFileSync(abs, (next2 + "\n\n" + block).trimEnd() + "\n");
3109
5126
  return finish("updated");
3110
5127
  }
3111
5128
  const { next, changed } = removeManagedBlock(cur, (p) => target.render(p));
3112
5129
  const base = changed ? next : cur;
3113
- fs14.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
5130
+ fs22.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
3114
5131
  return finish("extended");
3115
5132
  }
3116
- fs14.mkdirSync(path14.dirname(abs), { recursive: true });
3117
- fs14.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
5133
+ fs22.mkdirSync(path22.dirname(abs), { recursive: true });
5134
+ fs22.writeFileSync(abs, block.endsWith("\n") ? block : block + "\n");
3118
5135
  return finish("generated");
3119
5136
  }
3120
5137
  function removeManagedBlock(content, render) {
@@ -3142,19 +5159,19 @@ function foldBlank(s) {
3142
5159
  return s.replace(/\n{3,}/g, "\n\n").trim();
3143
5160
  }
3144
5161
  function uninstallTarget(repoRoot2, target) {
3145
- const abs = path14.join(repoRoot2, target.rel);
5162
+ const abs = path22.join(repoRoot2, target.rel);
3146
5163
  const finish = (how) => ({
3147
5164
  agent: target.id,
3148
5165
  rel: target.rel,
3149
5166
  how
3150
5167
  });
3151
- if (!fs14.existsSync(abs)) return finish("absent");
5168
+ if (!fs22.existsSync(abs)) return finish("absent");
3152
5169
  if (target.mode === "new") {
3153
- fs14.rmSync(abs, { force: true });
3154
- pruneEmptyDirs(path14.dirname(abs), repoRoot2);
5170
+ fs22.rmSync(abs, { force: true });
5171
+ pruneEmptyDirs(path22.dirname(abs), repoRoot2);
3155
5172
  return finish("removed");
3156
5173
  }
3157
- const cur = fs14.readFileSync(abs, "utf8");
5174
+ const cur = fs22.readFileSync(abs, "utf8");
3158
5175
  if (!cur.includes(MARKER)) return finish("skipped");
3159
5176
  const render = (p) => {
3160
5177
  const t = resolveAgent(target.id);
@@ -3163,52 +5180,52 @@ function uninstallTarget(repoRoot2, target) {
3163
5180
  const { next, changed } = removeManagedBlock(cur, render);
3164
5181
  if (!changed) return finish("skipped");
3165
5182
  if (!next) {
3166
- fs14.rmSync(abs, { force: true });
5183
+ fs22.rmSync(abs, { force: true });
3167
5184
  return finish("removed");
3168
5185
  }
3169
- fs14.writeFileSync(abs, next + "\n");
5186
+ fs22.writeFileSync(abs, next + "\n");
3170
5187
  return finish("removed");
3171
5188
  }
3172
5189
  function pruneEmptyDirs(dir, stop) {
3173
5190
  let cur = dir;
3174
5191
  while (cur.startsWith(stop) && cur !== stop) {
3175
5192
  try {
3176
- fs14.rmdirSync(cur);
5193
+ fs22.rmdirSync(cur);
3177
5194
  } catch {
3178
5195
  return;
3179
5196
  }
3180
- cur = path14.dirname(cur);
5197
+ cur = path22.dirname(cur);
3181
5198
  }
3182
5199
  }
3183
5200
  function installAntigravity(repoRoot2, prefix = detectPrefix(repoRoot2)) {
3184
5201
  const agentDir = detectAgentDir(repoRoot2);
3185
5202
  const out = [];
3186
- const skillPath = path14.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
3187
- fs14.mkdirSync(path14.dirname(skillPath), { recursive: true });
3188
- fs14.writeFileSync(skillPath, skillMd(prefix));
3189
- out.push(`Skill installed (${path14.relative(repoRoot2, skillPath)})`);
3190
- const wfPath = path14.join(repoRoot2, "workflows", "scopecairn.md");
3191
- fs14.mkdirSync(path14.dirname(wfPath), { recursive: true });
3192
- fs14.writeFileSync(wfPath, workflowMd(prefix));
3193
- out.push(`Workflow installed (${path14.relative(repoRoot2, wfPath)})`);
5203
+ const skillPath = path22.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
5204
+ fs22.mkdirSync(path22.dirname(skillPath), { recursive: true });
5205
+ fs22.writeFileSync(skillPath, skillMd(prefix));
5206
+ out.push(`Skill installed (${path22.relative(repoRoot2, skillPath)})`);
5207
+ const wfPath = path22.join(repoRoot2, "workflows", "scopecairn.md");
5208
+ fs22.mkdirSync(path22.dirname(wfPath), { recursive: true });
5209
+ fs22.writeFileSync(wfPath, workflowMd(prefix));
5210
+ out.push(`Workflow installed (${path22.relative(repoRoot2, wfPath)})`);
3194
5211
  return out;
3195
5212
  }
3196
5213
  function uninstallAntigravity(repoRoot2) {
3197
5214
  const out = [];
3198
5215
  for (const dir of [".agents", ".agent"]) {
3199
- const p = path14.join(repoRoot2, dir, "skills", "scopecairn", "SKILL.md");
3200
- if (fs14.existsSync(p)) {
3201
- fs14.rmSync(p, { force: true });
3202
- pruneEmptyDirs(path14.dirname(p), repoRoot2);
3203
- out.push(`Skill removed (${path14.relative(repoRoot2, p)})`);
5216
+ const p = path22.join(repoRoot2, dir, "skills", "scopecairn", "SKILL.md");
5217
+ if (fs22.existsSync(p)) {
5218
+ fs22.rmSync(p, { force: true });
5219
+ pruneEmptyDirs(path22.dirname(p), repoRoot2);
5220
+ out.push(`Skill removed (${path22.relative(repoRoot2, p)})`);
3204
5221
  }
3205
5222
  }
3206
- const wf = path14.join(repoRoot2, "workflows", "scopecairn.md");
3207
- if (fs14.existsSync(wf)) {
3208
- const text = fs14.readFileSync(wf, "utf8");
5223
+ const wf = path22.join(repoRoot2, "workflows", "scopecairn.md");
5224
+ if (fs22.existsSync(wf)) {
5225
+ const text = fs22.readFileSync(wf, "utf8");
3209
5226
  if (text.includes("scopecairn")) {
3210
- fs14.rmSync(wf, { force: true });
3211
- out.push(`Workflow removed (${path14.relative(repoRoot2, wf)})`);
5227
+ fs22.rmSync(wf, { force: true });
5228
+ out.push(`Workflow removed (${path22.relative(repoRoot2, wf)})`);
3212
5229
  } else {
3213
5230
  out.push(`Workflow skipped (bukan milik ScopeCairn)`);
3214
5231
  }
@@ -3256,22 +5273,21 @@ function withCore(ids) {
3256
5273
  console.log(`! Unknown agent "${id}" (pilihan: ${AGENT_MATRIX.map((x) => x.id).join(", ")}, all)`);
3257
5274
  continue;
3258
5275
  }
3259
- out.push(t);
5276
+ if (!out.includes(t)) out.push(t);
3260
5277
  }
3261
- if (!out.includes(AGENT_MATRIX[0])) out.unshift(AGENT_MATRIX[0]);
3262
5278
  return out;
3263
5279
  }
3264
5280
  function withDetected(repoRoot2) {
3265
- const targets = [AGENT_MATRIX[0]];
3266
- for (const t of AGENT_MATRIX.slice(1)) {
3267
- if (fs15.existsSync(path15.join(repoRoot2, t.presentMarker))) targets.push(t);
5281
+ const targets = [];
5282
+ for (const t of AGENT_MATRIX) {
5283
+ if (fs23.existsSync(path23.join(repoRoot2, t.presentMarker))) targets.push(t);
3268
5284
  }
3269
5285
  return targets;
3270
5286
  }
3271
5287
 
3272
5288
  // src/benchmark/run.ts
3273
- import fs16 from "fs";
3274
- import path16 from "path";
5289
+ import fs24 from "fs";
5290
+ import path24 from "path";
3275
5291
 
3276
5292
  // src/benchmark/dataset.ts
3277
5293
  var BENCHMARK_TASKS = [
@@ -3328,7 +5344,7 @@ var BENCHMARK_TASKS = [
3328
5344
  // src/benchmark/run.ts
3329
5345
  function fileChars(repoRoot2, rel) {
3330
5346
  try {
3331
- return fs16.readFileSync(path16.join(repoRoot2, rel), "utf8").length;
5347
+ return fs24.readFileSync(path24.join(repoRoot2, rel), "utf8").length;
3332
5348
  } catch {
3333
5349
  return 0;
3334
5350
  }
@@ -3477,6 +5493,126 @@ function cmdBenchmark(repoRoot2, opts = {}) {
3477
5493
  }
3478
5494
  }
3479
5495
 
5496
+ // src/commands/dashboard.ts
5497
+ import fs25 from "fs";
5498
+ import path25 from "path";
5499
+ function cmdDashboard(repoRoot2, opts) {
5500
+ const db = openDb(repoRoot2);
5501
+ try {
5502
+ const files = db.prepare(`SELECT COUNT(*) AS n FROM files`).get().n;
5503
+ const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
5504
+ const rels = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
5505
+ const top = db.prepare(
5506
+ `SELECT s.name AS name, COUNT(r.target_id) AS n
5507
+ FROM relationships r JOIN symbols s ON s.id = r.target_id
5508
+ GROUP BY r.target_id ORDER BY n DESC LIMIT 20`
5509
+ ).all();
5510
+ let adapters = [];
5511
+ try {
5512
+ const paths = db.prepare(`SELECT path FROM files`).all().map((r) => r.path);
5513
+ adapters = detectAdapters(paths, repoRoot2).map((a) => a.id);
5514
+ } catch {
5515
+ }
5516
+ let tasks = [];
5517
+ try {
5518
+ tasks = db.prepare(
5519
+ `SELECT description, complexity, created_at, completed_at FROM tasks ORDER BY id DESC LIMIT 20`
5520
+ ).all();
5521
+ } catch {
5522
+ }
5523
+ const esc2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5524
+ const topRows = top.map((t) => `<tr><td>${esc2(t.name)}</td><td>${t.n}</td></tr>`).join("\n") || `<tr><td colspan="2">-</td></tr>`;
5525
+ const adapterList = adapters.length > 0 ? adapters.map(esc2).join(", ") : "-";
5526
+ const taskRows = tasks.map(
5527
+ (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>`
5528
+ ).join("\n") || `<tr><td colspan="4">-</td></tr>`;
5529
+ const html = `<!DOCTYPE html>
5530
+ <html lang="en">
5531
+ <head>
5532
+ <meta charset="utf-8">
5533
+ <title>ScopeCairn Dashboard</title>
5534
+ <style>
5535
+ body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 900px; color: #222; }
5536
+ h1 { font-size: 1.5rem; }
5537
+ h2 { font-size: 1.1rem; margin-top: 2rem; }
5538
+ .cards { display: flex; gap: 1rem; }
5539
+ .card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem 1.5rem; }
5540
+ .card .n { font-size: 1.8rem; font-weight: 700; }
5541
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
5542
+ th, td { border: 1px solid #ddd; padding: 0.4rem 0.8rem; text-align: left; }
5543
+ th { background: #f5f5f5; }
5544
+ tr:nth-child(even) { background: #fafafa; }
5545
+ </style>
5546
+ </head>
5547
+ <body>
5548
+ <h1>ScopeCairn Dashboard</h1>
5549
+ <div class="cards">
5550
+ <div class="card"><div class="n">${files}</div>files</div>
5551
+ <div class="card"><div class="n">${symbols}</div>symbols</div>
5552
+ <div class="card"><div class="n">${rels}</div>relationships</div>
5553
+ </div>
5554
+ <h2>Top Symbols by Fan-in</h2>
5555
+ <table><thead><tr><th>Symbol</th><th>Incoming</th></tr></thead><tbody>
5556
+ ${topRows}
5557
+ </tbody></table>
5558
+ <h2>Detected Adapters</h2>
5559
+ <p>${adapterList}</p>
5560
+ <h2>Recent Tasks</h2>
5561
+ <table><thead><tr><th>Description</th><th>Complexity</th><th>Created</th><th>Completed</th></tr></thead><tbody>
5562
+ ${taskRows}
5563
+ </tbody></table>
5564
+ </body>
5565
+ </html>
5566
+ `;
5567
+ const dest = opts.out ?? path25.join(dataDir(repoRoot2), "dashboard.html");
5568
+ fs25.mkdirSync(path25.dirname(dest), { recursive: true });
5569
+ fs25.writeFileSync(dest, html, "utf8");
5570
+ console.log(`Dashboard written \u2192 ${dest}`);
5571
+ } finally {
5572
+ db.close();
5573
+ }
5574
+ }
5575
+
5576
+ // src/commands/testselect.ts
5577
+ import fs26 from "fs";
5578
+ import path26 from "path";
5579
+ function isTestPath(p) {
5580
+ return p.includes(".test.") || p.includes(".spec.") || p.includes("__tests__") || path26.basename(p).startsWith("test_");
5581
+ }
5582
+ function cmdTestSelect(repoRoot2, target) {
5583
+ const db = openDb(repoRoot2);
5584
+ try {
5585
+ const rows = db.prepare(`SELECT path FROM files`).all();
5586
+ const testPaths = rows.map((r) => r.path).filter(isTestPath);
5587
+ const base = path26.basename(target);
5588
+ const baseNoExt = base.replace(/\.[^./\\]+$/, "");
5589
+ const needles = new Set([target, base, baseNoExt].filter((s) => s.length > 0));
5590
+ const matches = [];
5591
+ for (const rel of testPaths) {
5592
+ let content;
5593
+ try {
5594
+ content = fs26.readFileSync(path26.join(repoRoot2, ...rel.split("/")), "utf8");
5595
+ } catch {
5596
+ continue;
5597
+ }
5598
+ for (const n of needles) {
5599
+ if (content.includes(n)) {
5600
+ matches.push(rel);
5601
+ break;
5602
+ }
5603
+ }
5604
+ }
5605
+ if (matches.length === 0) {
5606
+ console.log(`No tests reference "${target}".`);
5607
+ return;
5608
+ }
5609
+ console.log(`Tests referencing "${target}":`);
5610
+ for (const m of matches) console.log(` ${m}`);
5611
+ } finally {
5612
+ db.close();
5613
+ }
5614
+ }
5615
+
3480
5616
  // src/commands/agents.ts
3481
5617
  function registerAgentCommands(program2, repoRoot2) {
3482
5618
  const list = program2.command("agents").description("List supported agent installers");
@@ -3499,8 +5635,13 @@ function registerAgentCommands(program2, repoRoot2) {
3499
5635
  const cmd = program2.command(t.id).description(`${t.label} integration`);
3500
5636
  cmd.command("install").description(`Write ${t.rel}`).action(() => {
3501
5637
  const root = repoRoot2();
3502
- const r = installTarget(root, t, detectPrefix(root));
5638
+ const prefix = detectPrefix(root);
5639
+ const r = installTarget(root, t, prefix);
3503
5640
  console.log(`\u2713 ${t.label}: ${r.rel} (${r.how})`);
5641
+ if (t.id === "opencode") {
5642
+ console.log("");
5643
+ console.log(opencodePermissionGuide(prefix));
5644
+ }
3504
5645
  });
3505
5646
  cmd.command("uninstall").description(`Remove ScopeCairn block from ${t.rel}`).action(() => {
3506
5647
  const r = uninstallTarget(repoRoot2(), t);
@@ -3512,7 +5653,7 @@ function registerAgentCommands(program2, repoRoot2) {
3512
5653
  // package.json
3513
5654
  var package_default = {
3514
5655
  name: "scopecairn",
3515
- version: "0.3.0",
5656
+ version: "0.6.0",
3516
5657
  description: "ScopeCairn \u2014 Codebase Intelligence & Agent Optimization Layer (local-first)",
3517
5658
  license: "MIT",
3518
5659
  type: "module",
@@ -3545,6 +5686,7 @@ var package_default = {
3545
5686
  doctor: "tsx src/cli.ts doctor",
3546
5687
  clean: "tsx src/cli.ts clean",
3547
5688
  rebuild: "tsx src/cli.ts rebuild",
5689
+ test: "tsx --test tests/*.test.ts",
3548
5690
  typecheck: "tsc --noEmit"
3549
5691
  },
3550
5692
  dependencies: {
@@ -3572,6 +5714,8 @@ Examples:
3572
5714
  $ scopecairn context ./ repository orientation map
3573
5715
  $ scopecairn impact src/auth/login.ts change impact analysis
3574
5716
  $ scopecairn read symbol approveRequest symbol-level source excerpt
5717
+ $ scopecairn path handleLogin dbQuery shortest call path between symbols
5718
+ $ scopecairn export --format mermaid export graph (mermaid|graphml|dot|json)
3575
5719
  $ scopecairn doctor health + integration check
3576
5720
 
3577
5721
  Agent commands (context, impact, read, graph, status, doctor) are
@@ -3579,27 +5723,36 @@ read-only toward source and only write to .scopecairn/ \u2014 safe to allowlist.
3579
5723
  `
3580
5724
  );
3581
5725
  function repoRoot() {
3582
- return path17.resolve(process.cwd());
5726
+ return path27.resolve(process.cwd());
3583
5727
  }
3584
5728
  function resolveRoot(given) {
3585
5729
  if (!given) return repoRoot();
3586
- return path17.resolve(process.cwd(), given);
5730
+ return path27.resolve(process.cwd(), given);
3587
5731
  }
3588
5732
  program.command("scan").description("Index repository (files + symbols + relations)").action(() => cmdScan(repoRoot()));
3589
5733
  program.command("status").description("Show index status").action(() => cmdStatus(repoRoot()));
3590
5734
  program.command("rebuild").description("Rebuild index from scratch (recover from corrupt index)").action(() => cmdRebuild(repoRoot()));
3591
- program.command("doctor").description("Check integration health").action(async () => {
3592
- await cmdDoctor(repoRoot());
5735
+ program.command("doctor").description("Check integration health").option("--verbose", "print adapter/graph errors to stderr as well").action(async (opts) => {
5736
+ await cmdDoctor(repoRoot(), { verbose: opts.verbose });
5737
+ });
5738
+ program.command("clean").description("Remove local index (.scopecairn) \u2014 forces full rescan").action(() => {
5739
+ const dir = path27.join(repoRoot(), ".scopecairn");
5740
+ fs27.rmSync(dir, { recursive: true, force: true });
5741
+ console.log("Removed .scopecairn/. Run `scopecairn scan` to rebuild.");
3593
5742
  });
5743
+ 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));
5744
+ program.command("test-select <target>").description("List test files affected by a change target (file/symbol)").action((target) => cmdTestSelect(repoRoot(), target));
3594
5745
  program.command("graph <symbol>").description("Show relations of a symbol").action((symbol) => cmdGraph(repoRoot(), symbol));
5746
+ program.command("path <from> <to>").description("Show shortest call/dependency path between two symbols").action((from, to) => cmdPath(repoRoot(), from, to));
5747
+ 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));
3595
5748
  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));
3596
- 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(
5749
+ 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(
3597
5750
  (task, opts) => cmdContext(repoRoot(), task, opts)
3598
5751
  );
3599
5752
  program.command("impact <target>").description("Change impact analysis: file, symbol, function, or class").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
3600
5753
  (target, opts) => cmdImpact(repoRoot(), target, opts)
3601
5754
  );
3602
- program.command("init [path]").description("Initial indexing + generate AGENTS.md, Skill, Workflow, agent matrix").option("--agents <list>", "agent targets: comma list from agents,claude,gemini,cursor,windsurf,copilot,kiro, or 'all'").action(
5755
+ program.command("init [path]").description("Initial indexing + Skill, Workflow, agent skills (never touches AGENTS.md)").option("--agents <list>", "agent targets: comma list from claude,cursor,windsurf,copilot,kiro,opencode, or 'all'").action(
3603
5756
  (targetPath, opts) => cmdInit(resolveRoot(targetPath), opts)
3604
5757
  );
3605
5758
  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(