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 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** — one `init` writes rules for AGENTS.md,
32
- Claude Code, Gemini, Cursor, Windsurf, Copilot, Kiro, or all at once.
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 + generate AGENTS.md, Skill, Workflow
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 # CLAUDE.md (also: gemini, cursor, windsurf, copilot, kiro)
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 + `AGENTS.md`, Skill, Workflow, agent matrix (`--agents …\|all`) |
95
- | `scan` / `rebuild` | Manual index management (`scan` inkremental; `rebuild` dari nol) |
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
- Limits (honest): Server Actions, middleware, non-Prisma/Drizzle ORMs,
120
- inter-table references, raw SQL strings, and fully dynamic table names
121
- are not mapped yet — those edges are skipped silently, never hallucinated.
122
- 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.
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
+ };