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/README.md +67 -13
- package/dist/chunk-ID4PMSFQ.js +200 -0
- package/dist/chunk-UU5WHX3D.js +155 -0
- package/dist/cli.js +2317 -164
- package/dist/cycles-6KSI4OPZ.js +15 -0
- package/dist/metrics-ZWWLUYJW.js +19 -0
- package/package.json +50 -49
- package/skills/scopecairn/SKILL.md +24 -23
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
|
|
12
|
+
import fs27 from "fs";
|
|
13
|
+
import path27 from "path";
|
|
6
14
|
|
|
7
15
|
// src/scanner.ts
|
|
8
|
-
import
|
|
9
|
-
import
|
|
16
|
+
import fs13 from "fs";
|
|
17
|
+
import path14 from "path";
|
|
10
18
|
|
|
11
19
|
// src/languages.ts
|
|
12
20
|
var EXT_MAP = {
|
|
@@ -58,7 +66,7 @@ function loadDatabaseSync() {
|
|
|
58
66
|
}
|
|
59
67
|
var DATA_DIR_NAME = ".scopecairn";
|
|
60
68
|
var DB_FILE_NAME = "scopecairn.db";
|
|
61
|
-
var SCHEMA_VERSION =
|
|
69
|
+
var SCHEMA_VERSION = 5;
|
|
62
70
|
function dataDir(repoRoot2) {
|
|
63
71
|
return path.join(repoRoot2, DATA_DIR_NAME);
|
|
64
72
|
}
|
|
@@ -134,6 +142,31 @@ function openDb(repoRoot2) {
|
|
|
134
142
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
135
143
|
);
|
|
136
144
|
CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command);
|
|
145
|
+
-- Graphify Engine: node centrality, community clustering, cycle cache.
|
|
146
|
+
CREATE TABLE IF NOT EXISTS node_metrics (
|
|
147
|
+
node_id INTEGER PRIMARY KEY,
|
|
148
|
+
pagerank REAL NOT NULL DEFAULT 0.0,
|
|
149
|
+
betweenness REAL NOT NULL DEFAULT 0.0,
|
|
150
|
+
in_degree INTEGER NOT NULL DEFAULT 0,
|
|
151
|
+
out_degree INTEGER NOT NULL DEFAULT 0,
|
|
152
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
153
|
+
FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
|
|
154
|
+
);
|
|
155
|
+
CREATE TABLE IF NOT EXISTS clusters (
|
|
156
|
+
node_id INTEGER PRIMARY KEY,
|
|
157
|
+
cluster_id INTEGER NOT NULL,
|
|
158
|
+
cluster_name TEXT,
|
|
159
|
+
modularity REAL,
|
|
160
|
+
FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS idx_clusters_cid ON clusters(cluster_id);
|
|
163
|
+
CREATE TABLE IF NOT EXISTS cycles (
|
|
164
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
165
|
+
cycle_hash TEXT UNIQUE,
|
|
166
|
+
length INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
nodes_json TEXT NOT NULL DEFAULT '[]',
|
|
168
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
169
|
+
);
|
|
137
170
|
`);
|
|
138
171
|
db.prepare(
|
|
139
172
|
`INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
@@ -1242,8 +1275,8 @@ top_n: 20
|
|
|
1242
1275
|
}
|
|
1243
1276
|
|
|
1244
1277
|
// src/adapters/index.ts
|
|
1245
|
-
import
|
|
1246
|
-
import
|
|
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 = [
|
|
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 =
|
|
2143
|
+
const abs = path12.join(repoRoot2, ...c.rel.split("/"));
|
|
1668
2144
|
let content;
|
|
1669
2145
|
try {
|
|
1670
|
-
content =
|
|
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:
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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,'&').replace(/</g,'<')+'</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
|
|
3369
|
+
return path14.relative(repoRoot2, abs).split(path14.sep).join("/");
|
|
1721
3370
|
}
|
|
1722
3371
|
function walk(absDir, out) {
|
|
1723
|
-
const entries =
|
|
3372
|
+
const entries = fs13.readdirSync(absDir, { withFileTypes: true });
|
|
1724
3373
|
for (const e of entries) {
|
|
1725
|
-
const abs =
|
|
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 =
|
|
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
|
|
1879
|
-
import
|
|
3553
|
+
import fs14 from "fs";
|
|
3554
|
+
import path15 from "path";
|
|
1880
3555
|
function cmdStatus(repoRoot2) {
|
|
1881
3556
|
const dir = dataDir(repoRoot2);
|
|
1882
|
-
if (!
|
|
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
|
|
3609
|
+
import fs15 from "fs";
|
|
1935
3610
|
function cmdRebuild(repoRoot2) {
|
|
1936
3611
|
const dir = dataDir(repoRoot2);
|
|
1937
|
-
|
|
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
|
|
1944
|
-
import
|
|
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
|
|
1970
|
-
import
|
|
3644
|
+
import fs16 from "fs";
|
|
3645
|
+
import path16 from "path";
|
|
1971
3646
|
function detectAgentDir(repoRoot2) {
|
|
1972
|
-
const plural =
|
|
1973
|
-
const singular =
|
|
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
|
-
|
|
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
|
|
2025
|
-
return
|
|
2026
|
-
|
|
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: "
|
|
2100
|
-
mode: "
|
|
2101
|
-
render:
|
|
2102
|
-
presentMarker: "
|
|
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 =
|
|
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
|
-
|
|
2188
|
-
|
|
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 =
|
|
2195
|
-
if (!
|
|
2196
|
-
const text =
|
|
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 =
|
|
2214
|
-
const skillOk =
|
|
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 =
|
|
2217
|
-
console.log(`${tick(
|
|
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
|
|
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
|
|
2338
|
-
import
|
|
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 =
|
|
4265
|
+
const abs = path19.join(repoRoot2, node.file);
|
|
2355
4266
|
let lines;
|
|
2356
4267
|
try {
|
|
2357
|
-
lines =
|
|
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
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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
|
|
2482
|
-
const base = proximity.get(
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
2784
|
-
import
|
|
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
|
-
|
|
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 =
|
|
4781
|
+
const abs = path20.resolve(repoRoot2, t);
|
|
2831
4782
|
let rel = null;
|
|
2832
4783
|
try {
|
|
2833
|
-
const stat =
|
|
2834
|
-
if (stat.isFile()) rel =
|
|
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
|
|
4949
|
+
escalate,
|
|
2948
4950
|
repoRoot: repoRoot2
|
|
2949
4951
|
});
|
|
2950
|
-
|
|
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
|
|
3085
|
-
import
|
|
5101
|
+
import fs23 from "fs";
|
|
5102
|
+
import path23 from "path";
|
|
3086
5103
|
|
|
3087
5104
|
// src/integrate/install.ts
|
|
3088
|
-
import
|
|
3089
|
-
import
|
|
5105
|
+
import fs22 from "fs";
|
|
5106
|
+
import path22 from "path";
|
|
3090
5107
|
function installTarget(repoRoot2, target, prefix) {
|
|
3091
|
-
const abs =
|
|
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 =
|
|
3100
|
-
|
|
3101
|
-
|
|
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 (
|
|
3105
|
-
const cur =
|
|
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
|
-
|
|
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
|
-
|
|
5130
|
+
fs22.writeFileSync(abs, (base.trimEnd() + "\n\n" + block).trimEnd() + "\n");
|
|
3114
5131
|
return finish("extended");
|
|
3115
5132
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
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 =
|
|
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 (!
|
|
5168
|
+
if (!fs22.existsSync(abs)) return finish("absent");
|
|
3152
5169
|
if (target.mode === "new") {
|
|
3153
|
-
|
|
3154
|
-
pruneEmptyDirs(
|
|
5170
|
+
fs22.rmSync(abs, { force: true });
|
|
5171
|
+
pruneEmptyDirs(path22.dirname(abs), repoRoot2);
|
|
3155
5172
|
return finish("removed");
|
|
3156
5173
|
}
|
|
3157
|
-
const cur =
|
|
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
|
-
|
|
5183
|
+
fs22.rmSync(abs, { force: true });
|
|
3167
5184
|
return finish("removed");
|
|
3168
5185
|
}
|
|
3169
|
-
|
|
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
|
-
|
|
5193
|
+
fs22.rmdirSync(cur);
|
|
3177
5194
|
} catch {
|
|
3178
5195
|
return;
|
|
3179
5196
|
}
|
|
3180
|
-
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 =
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
out.push(`Skill installed (${
|
|
3190
|
-
const wfPath =
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
out.push(`Workflow installed (${
|
|
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 =
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3202
|
-
pruneEmptyDirs(
|
|
3203
|
-
out.push(`Skill removed (${
|
|
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 =
|
|
3207
|
-
if (
|
|
3208
|
-
const text =
|
|
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
|
-
|
|
3211
|
-
out.push(`Workflow removed (${
|
|
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 = [
|
|
3266
|
-
for (const t of AGENT_MATRIX
|
|
3267
|
-
if (
|
|
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
|
|
3274
|
-
import
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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.
|
|
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
|
|
5726
|
+
return path27.resolve(process.cwd());
|
|
3583
5727
|
}
|
|
3584
5728
|
function resolveRoot(given) {
|
|
3585
5729
|
if (!given) return repoRoot();
|
|
3586
|
-
return
|
|
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 +
|
|
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(
|