scopecairn 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -41,6 +66,13 @@ npm install -g scopecairn
41
66
 
42
67
  Requires Node.js ≥ 20.
43
68
 
69
+ Upgrade later with:
70
+
71
+ ```bash
72
+ scopecairn update # or: npm install -g scopecairn@latest
73
+ scopecairn update --check # only check for a new version
74
+ ```
75
+
44
76
  ## Agent-driven setup
45
77
 
46
78
  Install once, then just type `scopecairn ./` to your AI agent —
@@ -90,6 +122,8 @@ scopecairn context ./ # repository orientation map
90
122
  scopecairn impact src/request/RequestService.ts
91
123
  scopecairn read symbol approveRequest
92
124
  scopecairn graph RequestService
125
+ scopecairn path handleLogin dbQuery
126
+ scopecairn export --format mermaid
93
127
  scopecairn doctor
94
128
  ```
95
129
 
@@ -108,14 +142,20 @@ scopecairn agents list
108
142
  | Command | Description |
109
143
  |---|---|
110
144
  | `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) |
145
+ | `scan` / `rebuild` / `clean` | Manual index management (`scan` incremental; `rebuild` from scratch; `clean` removes `.scopecairn/`) |
112
146
  | `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 |
147
+ | `context "<task>"` | Main entry point: context + scope (`--escalate`, `--no-refresh`, `--mode NORMAL|FAST|SAFE|AUDIT`); a path task returns an orientation map |
114
148
  | `impact <path\|symbol>` | Change impact: direct, indirect, tests, UI, queries, routes |
115
- | `graph <symbol>` | Symbol relations |
149
+ | `graph <symbol>` | Symbol relations (+ PageRank, community, cycle status) |
150
+ | `path <from> <to>` | Shortest multi-hop call/dependency path between two symbols |
151
+ | `export --format <fmt>` | Graph export: `mermaid`, `graphml`, `dot`, or `json` (default `.scopecairn/graph.<ext>`, override with `--out`) |
116
152
  | `read symbol <name>` | Symbol-level source excerpt (not whole files) |
117
153
  | `<agent> install` | Per-agent setup: `antigravity`, `claude`, `gemini`, `cursor`, `windsurf`, `copilot`, `kiro` (each also `uninstall`; `agents list` to see all) |
154
+ | `dashboard` | Generate local HTML dashboard at `.scopecairn/dashboard.html` |
155
+ | `test-select <target>` | List test files affected by a file/symbol change target |
156
+ | `doctor --verbose` | Also surface adapter/graph errors from `.scopecairn/last-run.log` to stderr |
118
157
  | `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
158
+ | `update [--check]` | Update ScopeCairn to the latest npm release (`--check` only checks) |
119
159
 
120
160
  Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`)
121
161
  are read-only toward source and only write to `.scopecairn/` — safe to
@@ -131,11 +171,17 @@ No configuration needed.
131
171
  | `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
172
  | `prisma` | `schema.prisma` | `model` symbols for `QUERIES` resolution |
133
173
  | `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.
174
+ | `express` | `package.json` deps | Route symbols from `app/router.METHOD(path)`, handler edges |
175
+ | `fastapi` | `.py` routes, requirements/pyproject | Route symbols from `@app.METHOD("/path")` decorators |
176
+ | `sqlalchemy` | requirements/pyproject | `model` symbols from `class X(Base)`/`__tablename__`; `QUERIES` from `session.query/select` |
177
+ | `vue` | `.vue` SFCs, `package.json` deps | `component` symbols per SFC; `IMPORTS` edges from used components in template |
178
+
179
+ Limits (honest): non-Prisma/Drizzle/SQLAlchemy ORMs, inter-table
180
+ references, raw SQL strings, and fully dynamic table names are not mapped
181
+ yet — those edges are skipped (and logged to `.scopecairn/last-run.log`),
182
+ never hallucinated. Server Actions (`detectServerActions`) and middleware
183
+ (`detectMiddleware`) are detected in `nextjs.ts`. QUERIES re-derive on
184
+ every scan so new models resolve without a rebuild.
139
185
  Contributing a new adapter = one file + one registration line
140
186
  in `src/adapters/index.ts` (see `FrameworkAdapter` in `src/adapters/types.ts`).
141
187
 
@@ -0,0 +1,207 @@
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 missing = db.prepare(
163
+ `SELECT COUNT(*) AS n FROM symbols s LEFT JOIN node_metrics m ON m.node_id = s.id WHERE m.node_id IS NULL`
164
+ ).get().n;
165
+ const orphans = db.prepare(
166
+ `SELECT COUNT(*) AS n FROM node_metrics m LEFT JOIN symbols s ON s.id = m.node_id WHERE s.id IS NULL`
167
+ ).get().n;
168
+ if (missing === 0 && orphans === 0) {
169
+ return db.prepare(`SELECT COUNT(*) AS n FROM node_metrics`).get().n;
170
+ }
171
+ return refreshMetrics(db);
172
+ } catch {
173
+ return 0;
174
+ }
175
+ }
176
+ function getMetrics(db, nodeId) {
177
+ try {
178
+ const r = db.prepare(`SELECT * FROM node_metrics WHERE node_id = ?`).get(nodeId);
179
+ if (!r) return null;
180
+ return { nodeId: r.node_id, pagerank: r.pagerank, betweenness: r.betweenness, inDegree: r.in_degree, outDegree: r.out_degree };
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+ function topByPageRank(db, limit = 10) {
186
+ ensureMetrics(db);
187
+ try {
188
+ return db.prepare(
189
+ `SELECT s.id, s.name, s.type, f.path AS file, m.pagerank, m.in_degree AS inDegree, m.out_degree AS outDegree
190
+ FROM node_metrics m JOIN symbols s ON s.id = m.node_id
191
+ JOIN files f ON f.id = s.file_id
192
+ ORDER BY m.pagerank DESC LIMIT ${Math.max(1, Math.min(50, limit))}`
193
+ ).all();
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ export {
200
+ PAGERANK_DAMPING,
201
+ computePageRank,
202
+ computeBetweenness,
203
+ refreshMetrics,
204
+ ensureMetrics,
205
+ getMetrics,
206
+ topByPageRank
207
+ };
@@ -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
+ };