scopecairn 0.4.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 CHANGED
@@ -22,6 +22,15 @@ 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),
@@ -32,6 +41,22 @@ No MCP server, no embeddings, no network calls, no telemetry.
32
41
  ready), Antigravity, Cursor, Windsurf, Copilot, Kiro, and an OpenCode
33
42
  slash command. ScopeCairn never touches your `AGENTS.md`/`CLAUDE.md` —
34
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).
35
60
 
36
61
  ## Install
37
62
 
@@ -90,6 +115,8 @@ scopecairn context ./ # repository orientation map
90
115
  scopecairn impact src/request/RequestService.ts
91
116
  scopecairn read symbol approveRequest
92
117
  scopecairn graph RequestService
118
+ scopecairn path handleLogin dbQuery
119
+ scopecairn export --format mermaid
93
120
  scopecairn doctor
94
121
  ```
95
122
 
@@ -108,13 +135,18 @@ scopecairn agents list
108
135
  | Command | Description |
109
136
  |---|---|
110
137
  | `init [path]` | Initial indexing + Skill, Workflow, detected agent skills (`--agents …\|all`; never writes `AGENTS.md`) |
111
- | `scan` / `rebuild` | Manual index management (`scan` inkremental; `rebuild` dari nol) |
138
+ | `scan` / `rebuild` / `clean` | Manual index management (`scan` incremental; `rebuild` from scratch; `clean` removes `.scopecairn/`) |
112
139
  | `status` / `doctor` | Index status (incl. adapters, invocations) and health checks |
113
- | `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 |
114
141
  | `impact <path\|symbol>` | Change impact: direct, indirect, tests, UI, queries, routes |
115
- | `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`) |
116
145
  | `read symbol <name>` | Symbol-level source excerpt (not whole files) |
117
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 |
118
150
  | `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
119
151
 
120
152
  Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`)
@@ -131,11 +163,17 @@ No configuration needed.
131
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 |
132
164
  | `prisma` | `schema.prisma` | `model` symbols for `QUERIES` resolution |
133
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 |
134
-
135
- Limits (honest): Server Actions, middleware, non-Prisma/Drizzle ORMs,
136
- inter-table references, raw SQL strings, and fully dynamic table names
137
- are not mapped yet — those edges are skipped silently, never hallucinated.
138
- QUERIES re-derive on every scan so new models resolve without a rebuild.
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.
139
177
  Contributing a new adapter = one file + one registration line
140
178
  in `src/adapters/index.ts` (see `FrameworkAdapter` in `src/adapters/types.ts`).
141
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
+ };