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/README.md
CHANGED
|
@@ -22,14 +22,41 @@ No MCP server, no embeddings, no network calls, no telemetry.
|
|
|
22
22
|
Definition of Done, so the agent changes only what matters.
|
|
23
23
|
- **Change impact** — direct, indirect (2-hop), tests, and UI components
|
|
24
24
|
affected by a file or symbol.
|
|
25
|
+
- **Call path tracing** — `scopecairn path <A> <B>` shows the shortest
|
|
26
|
+
multi-hop call/dependency chain between two symbols.
|
|
27
|
+
- **Centrality ranking** — weighted PageRank + betweenness cached per
|
|
28
|
+
symbol; `context` surfaces critical hotspots, `doctor` flags
|
|
29
|
+
single points of failure.
|
|
30
|
+
- **Community detection** — Louvain clustering finds real modules by
|
|
31
|
+
link density (not just folders); `graph` shows each symbol's community.
|
|
32
|
+
- **Cycle detection** — Tarjan SCC finds circular dependencies;
|
|
33
|
+
reported by `doctor`, warned in `context`, listed in `GRAPH.md`.
|
|
25
34
|
- **Auto-refresh** — `context` and `impact` re-index changed files first;
|
|
26
35
|
run `scan` after editing to close the loop.
|
|
27
36
|
- **Framework adapters** — Next.js (App + Pages Router routes),
|
|
28
37
|
Prisma models, and Drizzle tables/queries detected automatically.
|
|
29
38
|
- **Multi-language** — TypeScript, JavaScript, Python, Java, Go, Rust,
|
|
30
39
|
PHP, C#, C/C++, Ruby, HTML (plus config/schema files as graph nodes).
|
|
31
|
-
- **Multi-agent setup** —
|
|
32
|
-
|
|
40
|
+
- **Multi-agent setup** — skills for Claude Code (plugin + marketplace
|
|
41
|
+
ready), Antigravity, Cursor, Windsurf, Copilot, Kiro, and an OpenCode
|
|
42
|
+
slash command. ScopeCairn never touches your `AGENTS.md`/`CLAUDE.md` —
|
|
43
|
+
those hold your repo's details, not tool instructions.
|
|
44
|
+
- **`GRAPH.md` knowledge file** — `.scopecairn/GRAPH.md` is a budgeted
|
|
45
|
+
(±150 lines) frozen summary of the graph (modules, most-used symbols,
|
|
46
|
+
PageRank hotspots, circular dependencies, routes, models, protected).
|
|
47
|
+
Written on setup, rewritten only when the
|
|
48
|
+
graph changes — for cold-start orientation; per-task precision still
|
|
49
|
+
comes from `context`.
|
|
50
|
+
- **`GRAPH.html` visual explorer** — offline, dependency-free Canvas
|
|
51
|
+
explorer written next to `GRAPH.md` on every graph change.
|
|
52
|
+
Cluster ↔ File ↔ Symbol hierarchy toggle, live search, type filter,
|
|
53
|
+
click-for-details side panel (callers, callees, centrality scores).
|
|
54
|
+
Nodes sized by symbols/PageRank, colored by Louvain community.
|
|
55
|
+
Pan, zoom, hover inspect. Double-click to open, no server.
|
|
56
|
+
- **`export` for external tools** — `scopecairn export --format
|
|
57
|
+
mermaid|graphml|dot|json` writes `.scopecairn/graph.<ext>` for PR
|
|
58
|
+
diagrams (Mermaid), Gephi/Cytoscape (GraphML), Graphviz (DOT),
|
|
59
|
+
or custom scripts (JSON).
|
|
33
60
|
|
|
34
61
|
## Install
|
|
35
62
|
|
|
@@ -63,7 +90,20 @@ it runs project setup itself.
|
|
|
63
90
|
|
|
64
91
|
```bash
|
|
65
92
|
cd your-repo
|
|
66
|
-
scopecairn init # index +
|
|
93
|
+
scopecairn init # index + Antigravity Skill + Workflow
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Claude Code (project skill, committed with the repo):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
scopecairn claude install
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or as a plugin from the bundled marketplace (this repo):
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
/plugin marketplace add <github-user>/scopecairn
|
|
106
|
+
/plugin install scopecairn@scopecairn
|
|
67
107
|
```
|
|
68
108
|
|
|
69
109
|
After setup, your agent calls ScopeCairn automatically on every coding task.
|
|
@@ -75,6 +115,8 @@ scopecairn context ./ # repository orientation map
|
|
|
75
115
|
scopecairn impact src/request/RequestService.ts
|
|
76
116
|
scopecairn read symbol approveRequest
|
|
77
117
|
scopecairn graph RequestService
|
|
118
|
+
scopecairn path handleLogin dbQuery
|
|
119
|
+
scopecairn export --format mermaid
|
|
78
120
|
scopecairn doctor
|
|
79
121
|
```
|
|
80
122
|
|
|
@@ -82,7 +124,8 @@ Per-agent installers (instead of `init --agents …`):
|
|
|
82
124
|
|
|
83
125
|
```bash
|
|
84
126
|
scopecairn antigravity install # Skill + Workflow + allowlist guide
|
|
85
|
-
scopecairn claude install #
|
|
127
|
+
scopecairn claude install # project skill .claude/skills (also: cursor, windsurf, copilot, kiro)
|
|
128
|
+
scopecairn opencode install # /scopecairn slash command + permission snippet
|
|
86
129
|
scopecairn claude uninstall # clean removal, user content preserved
|
|
87
130
|
scopecairn agents list
|
|
88
131
|
```
|
|
@@ -91,14 +134,19 @@ scopecairn agents list
|
|
|
91
134
|
|
|
92
135
|
| Command | Description |
|
|
93
136
|
|---|---|
|
|
94
|
-
| `init [path]` | Initial indexing +
|
|
95
|
-
| `scan` / `rebuild` | Manual index management (`scan`
|
|
137
|
+
| `init [path]` | Initial indexing + Skill, Workflow, detected agent skills (`--agents …\|all`; never writes `AGENTS.md`) |
|
|
138
|
+
| `scan` / `rebuild` / `clean` | Manual index management (`scan` incremental; `rebuild` from scratch; `clean` removes `.scopecairn/`) |
|
|
96
139
|
| `status` / `doctor` | Index status (incl. adapters, invocations) and health checks |
|
|
97
|
-
| `context "<task>"` | Main entry point: context + scope (`--escalate`, `--no-refresh`); a path task returns an orientation map |
|
|
140
|
+
| `context "<task>"` | Main entry point: context + scope (`--escalate`, `--no-refresh`, `--mode NORMAL|FAST|SAFE|AUDIT`); a path task returns an orientation map |
|
|
98
141
|
| `impact <path\|symbol>` | Change impact: direct, indirect, tests, UI, queries, routes |
|
|
99
|
-
| `graph <symbol>` | Symbol relations |
|
|
142
|
+
| `graph <symbol>` | Symbol relations (+ PageRank, community, cycle status) |
|
|
143
|
+
| `path <from> <to>` | Shortest multi-hop call/dependency path between two symbols |
|
|
144
|
+
| `export --format <fmt>` | Graph export: `mermaid`, `graphml`, `dot`, or `json` (default `.scopecairn/graph.<ext>`, override with `--out`) |
|
|
100
145
|
| `read symbol <name>` | Symbol-level source excerpt (not whole files) |
|
|
101
146
|
| `<agent> install` | Per-agent setup: `antigravity`, `claude`, `gemini`, `cursor`, `windsurf`, `copilot`, `kiro` (each also `uninstall`; `agents list` to see all) |
|
|
147
|
+
| `dashboard` | Generate local HTML dashboard at `.scopecairn/dashboard.html` |
|
|
148
|
+
| `test-select <target>` | List test files affected by a file/symbol change target |
|
|
149
|
+
| `doctor --verbose` | Also surface adapter/graph errors from `.scopecairn/last-run.log` to stderr |
|
|
102
150
|
| `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
|
|
103
151
|
|
|
104
152
|
Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`)
|
|
@@ -115,11 +163,17 @@ No configuration needed.
|
|
|
115
163
|
| `nextjs` | `app/`, `pages/`, `next.config.*` | Route symbols (`GET /api/x`, `PAGE /y` incl. route groups, dynamic segments), `ROUTES_TO` handler edges, `QUERIES` handler→model edges |
|
|
116
164
|
| `prisma` | `schema.prisma` | `model` symbols for `QUERIES` resolution |
|
|
117
165
|
| `drizzle` | `drizzle*` paths/config, `db/` schemas | `model` symbols from `pgTable`/`sqliteTable`/`mysqlTable`; `QUERIES` from `db.query.*` relational and `db.select/insert/update/delete` builder calls |
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
166
|
+
| `express` | `package.json` deps | Route symbols from `app/router.METHOD(path)`, handler edges |
|
|
167
|
+
| `fastapi` | `.py` routes, requirements/pyproject | Route symbols from `@app.METHOD("/path")` decorators |
|
|
168
|
+
| `sqlalchemy` | requirements/pyproject | `model` symbols from `class X(Base)`/`__tablename__`; `QUERIES` from `session.query/select` |
|
|
169
|
+
| `vue` | `.vue` SFCs, `package.json` deps | `component` symbols per SFC; `IMPORTS` edges from used components in template |
|
|
170
|
+
|
|
171
|
+
Limits (honest): non-Prisma/Drizzle/SQLAlchemy ORMs, inter-table
|
|
172
|
+
references, raw SQL strings, and fully dynamic table names are not mapped
|
|
173
|
+
yet — those edges are skipped (and logged to `.scopecairn/last-run.log`),
|
|
174
|
+
never hallucinated. Server Actions (`detectServerActions`) and middleware
|
|
175
|
+
(`detectMiddleware`) are detected in `nextjs.ts`. QUERIES re-derive on
|
|
176
|
+
every scan so new models resolve without a rebuild.
|
|
123
177
|
Contributing a new adapter = one file + one registration line
|
|
124
178
|
in `src/adapters/index.ts` (see `FrameworkAdapter` in `src/adapters/types.ts`).
|
|
125
179
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/graph/metrics.ts
|
|
4
|
+
var PAGERANK_DAMPING = 0.85;
|
|
5
|
+
var MAX_ITERS = 40;
|
|
6
|
+
var TOLERANCE = 1e-6;
|
|
7
|
+
var BETWEENNESS_SOURCE_CAP = 400;
|
|
8
|
+
function readGraph(db) {
|
|
9
|
+
let ids = [];
|
|
10
|
+
try {
|
|
11
|
+
ids = db.prepare(`SELECT id FROM symbols ORDER BY id`).all().map((r) => r.id);
|
|
12
|
+
} catch {
|
|
13
|
+
return { ids: [], edges: [] };
|
|
14
|
+
}
|
|
15
|
+
let edges = [];
|
|
16
|
+
try {
|
|
17
|
+
edges = db.prepare(
|
|
18
|
+
`SELECT source_id AS s, target_id AS t,
|
|
19
|
+
MAX(weight, 0.01) AS w FROM relationships`
|
|
20
|
+
).all().filter((e) => e.s !== void 0 && e.t !== void 0);
|
|
21
|
+
} catch {
|
|
22
|
+
edges = [];
|
|
23
|
+
}
|
|
24
|
+
return { ids, edges };
|
|
25
|
+
}
|
|
26
|
+
function computePageRank(db, damping = PAGERANK_DAMPING) {
|
|
27
|
+
const { ids, edges } = readGraph(db);
|
|
28
|
+
const pr = /* @__PURE__ */ new Map();
|
|
29
|
+
const n = ids.length;
|
|
30
|
+
if (n === 0) return pr;
|
|
31
|
+
for (const id of ids) pr.set(id, 1 / n);
|
|
32
|
+
const outW = /* @__PURE__ */ new Map();
|
|
33
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const e of edges) {
|
|
35
|
+
outW.set(e.s, (outW.get(e.s) ?? 0) + e.w);
|
|
36
|
+
if (!incoming.has(e.t)) incoming.set(e.t, []);
|
|
37
|
+
incoming.get(e.t).push({ v: e.s, w: e.w });
|
|
38
|
+
}
|
|
39
|
+
const base = (1 - damping) / n;
|
|
40
|
+
for (let it = 0; it < MAX_ITERS; it++) {
|
|
41
|
+
let dangling = 0;
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
if (!outW.has(id) || outW.get(id) === 0) dangling += pr.get(id);
|
|
44
|
+
}
|
|
45
|
+
const danglingShare = damping * dangling / n;
|
|
46
|
+
let delta = 0;
|
|
47
|
+
const next = /* @__PURE__ */ new Map();
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
let s = 0;
|
|
50
|
+
for (const inc of incoming.get(id) ?? []) {
|
|
51
|
+
const ow = outW.get(inc.v) ?? 0;
|
|
52
|
+
if (ow > 0) s += (pr.get(inc.v) ?? 0) * inc.w / ow;
|
|
53
|
+
}
|
|
54
|
+
const v = base + danglingShare + damping * s;
|
|
55
|
+
next.set(id, v);
|
|
56
|
+
delta = Math.max(delta, Math.abs(v - (pr.get(id) ?? 0)));
|
|
57
|
+
}
|
|
58
|
+
for (const [k, v] of next) pr.set(k, v);
|
|
59
|
+
if (delta < TOLERANCE) break;
|
|
60
|
+
}
|
|
61
|
+
return pr;
|
|
62
|
+
}
|
|
63
|
+
function computeBetweenness(db) {
|
|
64
|
+
const { ids, edges } = readGraph(db);
|
|
65
|
+
const btw = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const id of ids) btw.set(id, 0);
|
|
67
|
+
const n = ids.length;
|
|
68
|
+
if (n === 0) return btw;
|
|
69
|
+
const adj = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const e of edges) {
|
|
71
|
+
if (!adj.has(e.s)) adj.set(e.s, []);
|
|
72
|
+
adj.get(e.s).push(e.t);
|
|
73
|
+
}
|
|
74
|
+
const sources = n > BETWEENNESS_SOURCE_CAP ? ids.filter((_, i) => i % Math.ceil(n / BETWEENNESS_SOURCE_CAP) === 0) : ids;
|
|
75
|
+
const scale = n / sources.length;
|
|
76
|
+
for (const s of sources) {
|
|
77
|
+
const stack = [];
|
|
78
|
+
const pred = /* @__PURE__ */ new Map();
|
|
79
|
+
const sigma = /* @__PURE__ */ new Map();
|
|
80
|
+
const dist = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const id of ids) {
|
|
82
|
+
pred.set(id, []);
|
|
83
|
+
sigma.set(id, 0);
|
|
84
|
+
dist.set(id, -1);
|
|
85
|
+
}
|
|
86
|
+
sigma.set(s, 1);
|
|
87
|
+
dist.set(s, 0);
|
|
88
|
+
const queue = [s];
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const v = queue.shift();
|
|
91
|
+
stack.push(v);
|
|
92
|
+
for (const w of adj.get(v) ?? []) {
|
|
93
|
+
if ((dist.get(w) ?? -1) < 0) {
|
|
94
|
+
dist.set(w, (dist.get(v) ?? 0) + 1);
|
|
95
|
+
queue.push(w);
|
|
96
|
+
}
|
|
97
|
+
if (dist.get(w) === (dist.get(v) ?? 0) + 1) {
|
|
98
|
+
sigma.set(w, (sigma.get(w) ?? 0) + (sigma.get(v) ?? 0));
|
|
99
|
+
pred.get(w).push(v);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const delta = /* @__PURE__ */ new Map();
|
|
104
|
+
for (const id of ids) delta.set(id, 0);
|
|
105
|
+
while (stack.length > 0) {
|
|
106
|
+
const w = stack.pop();
|
|
107
|
+
for (const v of pred.get(w) ?? []) {
|
|
108
|
+
const c = (sigma.get(v) ?? 0) / Math.max(1, sigma.get(w) ?? 1) * (1 + (delta.get(w) ?? 0));
|
|
109
|
+
delta.set(v, (delta.get(v) ?? 0) + c);
|
|
110
|
+
}
|
|
111
|
+
if (w !== s) btw.set(w, (btw.get(w) ?? 0) + (delta.get(w) ?? 0) * scale);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const norm = (n - 1) * (n - 2);
|
|
115
|
+
if (norm > 0) {
|
|
116
|
+
for (const [k, v] of btw) btw.set(k, v / norm);
|
|
117
|
+
}
|
|
118
|
+
return btw;
|
|
119
|
+
}
|
|
120
|
+
function degrees(db) {
|
|
121
|
+
const m = /* @__PURE__ */ new Map();
|
|
122
|
+
try {
|
|
123
|
+
for (const r of db.prepare(`SELECT target_id AS t, COUNT(*) AS n FROM relationships GROUP BY target_id`).all()) {
|
|
124
|
+
m.set(r.t, { inn: r.n, out: m.get(r.t)?.out ?? 0 });
|
|
125
|
+
}
|
|
126
|
+
for (const r of db.prepare(`SELECT source_id AS s, COUNT(*) AS n FROM relationships GROUP BY source_id`).all()) {
|
|
127
|
+
m.set(r.s, { inn: m.get(r.s)?.inn ?? 0, out: r.n });
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
return m;
|
|
132
|
+
}
|
|
133
|
+
function refreshMetrics(db) {
|
|
134
|
+
const { ids } = readGraph(db);
|
|
135
|
+
if (ids.length === 0) return 0;
|
|
136
|
+
const pr = computePageRank(db);
|
|
137
|
+
const btw = computeBetweenness(db);
|
|
138
|
+
const deg = degrees(db);
|
|
139
|
+
try {
|
|
140
|
+
db.prepare(`DELETE FROM node_metrics WHERE node_id NOT IN (SELECT id FROM symbols)`).run();
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
const up = db.prepare(
|
|
144
|
+
`INSERT INTO node_metrics(node_id, pagerank, betweenness, in_degree, out_degree, updated_at)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
146
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
147
|
+
pagerank = excluded.pagerank, betweenness = excluded.betweenness,
|
|
148
|
+
in_degree = excluded.in_degree, out_degree = excluded.out_degree,
|
|
149
|
+
updated_at = excluded.updated_at`
|
|
150
|
+
);
|
|
151
|
+
let n = 0;
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
up.run(id, pr.get(id) ?? 0, btw.get(id) ?? 0, deg.get(id)?.inn ?? 0, deg.get(id)?.out ?? 0);
|
|
154
|
+
n++;
|
|
155
|
+
}
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
function ensureMetrics(db) {
|
|
159
|
+
try {
|
|
160
|
+
const syms = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
|
|
161
|
+
if (syms === 0) return 0;
|
|
162
|
+
const c = db.prepare(`SELECT COUNT(*) AS n FROM node_metrics`).get().n;
|
|
163
|
+
if (c === syms) return c;
|
|
164
|
+
return refreshMetrics(db);
|
|
165
|
+
} catch {
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function getMetrics(db, nodeId) {
|
|
170
|
+
try {
|
|
171
|
+
const r = db.prepare(`SELECT * FROM node_metrics WHERE node_id = ?`).get(nodeId);
|
|
172
|
+
if (!r) return null;
|
|
173
|
+
return { nodeId: r.node_id, pagerank: r.pagerank, betweenness: r.betweenness, inDegree: r.in_degree, outDegree: r.out_degree };
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function topByPageRank(db, limit = 10) {
|
|
179
|
+
ensureMetrics(db);
|
|
180
|
+
try {
|
|
181
|
+
return db.prepare(
|
|
182
|
+
`SELECT s.id, s.name, s.type, f.path AS file, m.pagerank, m.in_degree AS inDegree, m.out_degree AS outDegree
|
|
183
|
+
FROM node_metrics m JOIN symbols s ON s.id = m.node_id
|
|
184
|
+
JOIN files f ON f.id = s.file_id
|
|
185
|
+
ORDER BY m.pagerank DESC LIMIT ${Math.max(1, Math.min(50, limit))}`
|
|
186
|
+
).all();
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export {
|
|
193
|
+
PAGERANK_DAMPING,
|
|
194
|
+
computePageRank,
|
|
195
|
+
computeBetweenness,
|
|
196
|
+
refreshMetrics,
|
|
197
|
+
ensureMetrics,
|
|
198
|
+
getMetrics,
|
|
199
|
+
topByPageRank
|
|
200
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/graph/cycles.ts
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
function readAdj(db) {
|
|
6
|
+
let ids = [];
|
|
7
|
+
try {
|
|
8
|
+
ids = db.prepare(`SELECT id FROM symbols ORDER BY id`).all().map((r) => r.id);
|
|
9
|
+
} catch {
|
|
10
|
+
return { ids: [], adj: /* @__PURE__ */ new Map() };
|
|
11
|
+
}
|
|
12
|
+
const adj = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const id of ids) adj.set(id, []);
|
|
14
|
+
try {
|
|
15
|
+
const rows = db.prepare(`SELECT source_id AS s, target_id AS t FROM relationships`).all();
|
|
16
|
+
for (const r of rows) {
|
|
17
|
+
if (adj.has(r.s)) adj.get(r.s).push(r.t);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
return { ids, adj };
|
|
22
|
+
}
|
|
23
|
+
function stronglyConnected(db) {
|
|
24
|
+
const { ids, adj } = readAdj(db);
|
|
25
|
+
const index = /* @__PURE__ */ new Map();
|
|
26
|
+
const low = /* @__PURE__ */ new Map();
|
|
27
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
28
|
+
const stack = [];
|
|
29
|
+
const out = [];
|
|
30
|
+
let counter = 0;
|
|
31
|
+
for (const root of ids) {
|
|
32
|
+
if (index.has(root)) continue;
|
|
33
|
+
const work = [{ v: root, i: 0 }];
|
|
34
|
+
while (work.length > 0) {
|
|
35
|
+
const top = work[work.length - 1];
|
|
36
|
+
const v = top.v;
|
|
37
|
+
if (!index.has(v)) {
|
|
38
|
+
index.set(v, counter);
|
|
39
|
+
low.set(v, counter);
|
|
40
|
+
counter++;
|
|
41
|
+
stack.push(v);
|
|
42
|
+
onStack.add(v);
|
|
43
|
+
}
|
|
44
|
+
const nbrs = adj.get(v) ?? [];
|
|
45
|
+
if (top.i < nbrs.length) {
|
|
46
|
+
const w = nbrs[top.i++];
|
|
47
|
+
if (!index.has(w)) {
|
|
48
|
+
work.push({ v: w, i: 0 });
|
|
49
|
+
} else if (onStack.has(w)) {
|
|
50
|
+
low.set(v, Math.min(low.get(v), index.get(w)));
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
work.pop();
|
|
54
|
+
if (work.length > 0) {
|
|
55
|
+
const parent = work[work.length - 1].v;
|
|
56
|
+
low.set(parent, Math.min(low.get(parent), low.get(v)));
|
|
57
|
+
}
|
|
58
|
+
if (low.get(v) === index.get(v)) {
|
|
59
|
+
const comp = [];
|
|
60
|
+
let w;
|
|
61
|
+
do {
|
|
62
|
+
w = stack.pop();
|
|
63
|
+
onStack.delete(w);
|
|
64
|
+
comp.push(w);
|
|
65
|
+
} while (w !== v);
|
|
66
|
+
out.push(comp);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function hashIds(ids) {
|
|
74
|
+
return crypto.createHash("sha1").update([...ids].sort((a, b) => a - b).join(",")).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
function refreshCycles(db) {
|
|
77
|
+
const { adj } = readAdj(db);
|
|
78
|
+
const comps = stronglyConnected(db);
|
|
79
|
+
const cycles = [];
|
|
80
|
+
for (const c of comps) {
|
|
81
|
+
if (c.length > 1) {
|
|
82
|
+
cycles.push(c);
|
|
83
|
+
} else if (c.length === 1 && (adj.get(c[0]) ?? []).includes(c[0])) {
|
|
84
|
+
cycles.push(c);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
db.prepare(`DELETE FROM cycles`).run();
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
const up = db.prepare(
|
|
92
|
+
`INSERT INTO cycles(cycle_hash, length, nodes_json, created_at)
|
|
93
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
94
|
+
ON CONFLICT(cycle_hash) DO UPDATE SET
|
|
95
|
+
length = excluded.length, nodes_json = excluded.nodes_json`
|
|
96
|
+
);
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const c of cycles) {
|
|
99
|
+
const sorted = [...c].sort((a, b) => a - b);
|
|
100
|
+
const h = hashIds(sorted);
|
|
101
|
+
up.run(h, sorted.length, JSON.stringify(sorted));
|
|
102
|
+
const row = db.prepare(`SELECT id FROM cycles WHERE cycle_hash = ?`).get(h);
|
|
103
|
+
out.push({ id: row.id, hash: h, length: sorted.length, nodeIds: sorted });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function getCycles(db, limit = 20) {
|
|
108
|
+
try {
|
|
109
|
+
const rows = db.prepare(
|
|
110
|
+
`SELECT id, cycle_hash AS hash, length, nodes_json AS js FROM cycles ORDER BY length DESC LIMIT ${Math.max(1, Math.min(100, limit))}`
|
|
111
|
+
).all();
|
|
112
|
+
return rows.map((r) => {
|
|
113
|
+
let nodeIds = [];
|
|
114
|
+
try {
|
|
115
|
+
nodeIds = JSON.parse(r.js);
|
|
116
|
+
} catch {
|
|
117
|
+
nodeIds = [];
|
|
118
|
+
}
|
|
119
|
+
return { id: r.id, hash: r.hash, length: r.length, nodeIds };
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function describeCycle(db, c, limit = 8) {
|
|
126
|
+
try {
|
|
127
|
+
const names = [];
|
|
128
|
+
for (const id of c.nodeIds.slice(0, limit)) {
|
|
129
|
+
const r = db.prepare(`SELECT s.name AS n, f.path AS p FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?`).get(id);
|
|
130
|
+
if (r) names.push(`${r.n} (${r.p})`);
|
|
131
|
+
}
|
|
132
|
+
const more = c.nodeIds.length > limit ? ` +${c.nodeIds.length - limit} more` : "";
|
|
133
|
+
return names.join(" \u2192 ") + more;
|
|
134
|
+
} catch {
|
|
135
|
+
return c.nodeIds.join(" \u2192 ");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function nodeInCycle(db, nodeId) {
|
|
139
|
+
try {
|
|
140
|
+
for (const c of getCycles(db, 100)) {
|
|
141
|
+
if (c.nodeIds.includes(nodeId)) return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
stronglyConnected,
|
|
151
|
+
refreshCycles,
|
|
152
|
+
getCycles,
|
|
153
|
+
describeCycle,
|
|
154
|
+
nodeInCycle
|
|
155
|
+
};
|