@raymondchins/agentmap 0.1.0 → 0.2.1

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/repomap.mjs DELETED
@@ -1,461 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-License-Identifier: MIT
3
- // ============================================================================
4
- // agentmap — the repo map your coding agent is *forced* to use.
5
- //
6
- // A ts-morph code-relationship map for TypeScript/JavaScript repos. Unlike
7
- // one-shot "pack the repo into a prompt" tools, this is a QUERYABLE, RANKED
8
- // map: PageRank importance (ported from Aider's repo map), Aider-style
9
- // symbol ranking, a token-budgeted `--map` digest, and a single `--any`
10
- // router (file → symbol → feature → live git-grep) — wired into the agent
11
- // loop via a post-commit auto-refresh + a PreToolUse hook.
12
- //
13
- // Near-zero deps (ts-morph only). Runs in the target repo's cwd.
14
- // Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
15
- // ============================================================================
16
- import { Project } from "ts-morph";
17
- import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
18
- import { execSync, execFileSync } from "node:child_process";
19
-
20
- const MAP = ".claude/repomap.json";
21
- const SCHEMA_VERSION = 2;
22
- const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; } };
23
-
24
- // Live content search for the --any fallback. `git grep` over tracked +
25
- // untracked files (skips gitignored paths like node_modules). Reads DISK, so
26
- // never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
27
- const contentSearch = (q) => {
28
- try {
29
- return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/repomap.json"], { encoding: "utf8" }).trim();
30
- } catch { return ""; }
31
- };
32
- const currentSha = () => sh("git rev-parse --short HEAD");
33
- const dirtyCount = () =>
34
- sh("git status --porcelain").split("\n").filter(Boolean).filter((l) => {
35
- let p = l.slice(3); // strip "XY " status prefix
36
- if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
37
- p = p.replace(/^"|"$/g, ""); // unquote space/special paths
38
- return /\.(ts|tsx|mjs|cjs|jsx|js)$/.test(p);
39
- }).length;
40
- const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
41
-
42
- // Feature = first real route segment under app/ (or src/app/), skipping route
43
- // groups (parens), dynamic segments ([id]) and parallel routes (@slot).
44
- function featureOf(path) {
45
- const m = path.match(/(?:^|.*\/)(?:src\/)?app\/(.+)/);
46
- if (!m) return null;
47
- for (const p of m[1].split("/").slice(0, -1)) {
48
- if (p.startsWith("(") || p.startsWith("[") || p.startsWith("@")) continue;
49
- return p;
50
- }
51
- return null;
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Personalized PageRank — dependency-free power iteration. Deterministic
56
- // (stable node order, no PRNG). Edges = [{from, to, weight}]. Rank flows
57
- // from→to, so with importer→imported edges, heavily-imported hubs rank high.
58
- // Dangling-node mass + teleport both go to the personalization vector
59
- // (matches Aider's `dangling=personalization`). Returns { node: score }.
60
- // ---------------------------------------------------------------------------
61
- function pagerank(nodes, edges, { personalization = null, damping = 0.85, tol = 1e-6, maxIter = 100 } = {}) {
62
- const N = nodes.length;
63
- if (N === 0) return {};
64
- const idx = new Map(nodes.map((n, i) => [n, i]));
65
- const outW = new Float64Array(N);
66
- const adj = Array.from({ length: N }, () => []);
67
- for (const e of edges) {
68
- const a = idx.get(e.from), b = idx.get(e.to);
69
- if (a === undefined || b === undefined || a === b) continue; // skip self-loops
70
- const w = e.weight > 0 ? e.weight : 1;
71
- adj[a].push([b, w]); outW[a] += w;
72
- }
73
- // teleport vector p (normalized personalization, or uniform)
74
- const p = new Float64Array(N);
75
- if (personalization) {
76
- let s = 0;
77
- for (const [k, v] of Object.entries(personalization)) {
78
- const i = idx.get(k);
79
- if (i !== undefined && v > 0) { p[i] = v; s += v; }
80
- }
81
- if (s === 0) p.fill(1 / N); else for (let i = 0; i < N; i++) p[i] /= s;
82
- } else p.fill(1 / N);
83
- let r = Float64Array.from(p);
84
- for (let iter = 0; iter < maxIter; iter++) {
85
- let dangling = 0;
86
- for (let i = 0; i < N; i++) if (outW[i] === 0) dangling += r[i];
87
- const next = new Float64Array(N);
88
- for (let i = 0; i < N; i++) next[i] = (1 - damping) * p[i] + damping * dangling * p[i];
89
- for (let i = 0; i < N; i++) {
90
- if (outW[i] === 0) continue;
91
- const ri = damping * r[i];
92
- for (const [j, w] of adj[i]) next[j] += ri * (w / outW[i]);
93
- }
94
- let diff = 0;
95
- for (let i = 0; i < N; i++) diff += Math.abs(next[i] - r[i]);
96
- r = next;
97
- if (diff < tol) break;
98
- }
99
- const out = {};
100
- for (let i = 0; i < N; i++) out[nodes[i]] = r[i];
101
- return out;
102
- }
103
-
104
- // Aider-style identifier edge-weight multipliers. `mentioned` = focus/query
105
- // idents (boosted). Rarity is approximated by the >5-definers penalty.
106
- function identMul(ident, defineCount, mentioned) {
107
- let mul = 1.0;
108
- const hasAlpha = /[a-zA-Z]/.test(ident);
109
- const isSnake = ident.includes("_") && hasAlpha;
110
- const isKebab = ident.includes("-") && hasAlpha;
111
- const isCamel = /[a-z]/.test(ident) && /[A-Z]/.test(ident);
112
- if (mentioned && mentioned.has(ident)) mul *= 10;
113
- if ((isSnake || isKebab || isCamel) && ident.length >= 8) mul *= 10;
114
- if (ident.startsWith("_")) mul *= 0.1;
115
- if (defineCount > 5) mul *= 0.1;
116
- return mul;
117
- }
118
-
119
- // Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
120
- // else (missing / malformed / solution-style references that index 0 files) fall
121
- // back to broad source globs so the tool degrades gracefully instead of crashing.
122
- function makeProject() {
123
- let project;
124
- if (existsSync("tsconfig.json")) {
125
- try { project = new Project({ tsConfigFilePath: "tsconfig.json" }); }
126
- catch { project = new Project({ compilerOptions: { allowJs: true } }); }
127
- } else {
128
- project = new Project({ compilerOptions: { allowJs: true } });
129
- }
130
- // tsconfig `include` usually omits build/pipeline scripts — add by path.
131
- project.addSourceFilesAtPaths(["scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.js", "*.mjs", "*.cjs"]);
132
- // Fallback: nothing indexed (no / empty / references-only tsconfig) → broad globs.
133
- if (project.getSourceFiles().length === 0)
134
- project.addSourceFilesAtPaths([
135
- "src/**/*.{ts,tsx,js,jsx}", "app/**/*.{ts,tsx,js,jsx}",
136
- "components/**/*.{ts,tsx,js,jsx}", "lib/**/*.{ts,tsx,js,jsx}",
137
- "pages/**/*.{ts,tsx,js,jsx}", "*.{ts,tsx,js,jsx}",
138
- ]);
139
- return project;
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // build() — parse the repo, extract file imports/exports (+ which named
144
- // symbols cross each edge), compute file PageRank, run the Aider-style
145
- // identifier graph to rank individual symbols, and persist repomap.json.
146
- // ---------------------------------------------------------------------------
147
- function build() {
148
- const project = makeProject();
149
- const cwd = process.cwd().replace(/\\/g, "/");
150
- const rel = (p) => p.replace(cwd + "/", "");
151
- const files = {}, dependents = {}, features = {};
152
-
153
- for (const sf of project.getSourceFiles()) {
154
- const path = rel(sf.getFilePath());
155
- if (path.includes("node_modules") || path.includes(".next")) continue;
156
- const exports = [...sf.getExportedDeclarations()].map(([name, d]) => ({
157
- name: name === "default" ? (d[0]?.getName?.() ?? "default") : name,
158
- kind: d[0]?.getKindName?.() ?? "?",
159
- }));
160
- // Dependency edges from static imports + re-export barrels, with the set
161
- // of named symbols crossing each edge (used for edge weights + the ident
162
- // graph). importedSymbols[targetPath] = [names...].
163
- const importedSymbols = {};
164
- const addEdge = (tp, names) => {
165
- if (tp.includes("node_modules")) return;
166
- (importedSymbols[tp] ??= []).push(...names);
167
- };
168
- for (const imp of sf.getImportDeclarations()) {
169
- const t = imp.getModuleSpecifierSourceFile();
170
- if (!t) continue;
171
- const names = imp.getNamedImports().map((n) => n.getName());
172
- if (imp.getDefaultImport()) names.push("default"); // canonical: local alias never matches the export name
173
- if (imp.getNamespaceImport()) names.push("*");
174
- addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
175
- }
176
- for (const exp of sf.getExportDeclarations()) {
177
- const t = exp.getModuleSpecifierSourceFile();
178
- if (!t) continue;
179
- addEdge(rel(t.getFilePath()), exp.getNamedExports().map((n) => n.getName()));
180
- }
181
- const imports = Object.keys(importedSymbols);
182
- for (const tp of imports) (dependents[tp] ??= []).push(path);
183
- files[path] = { exports, imports, importedSymbols };
184
- const feat = featureOf(path);
185
- if (feat) (features[feat] ??= []).push(path);
186
- }
187
- for (const p in files) files[p].dependents = dependents[p] ?? [];
188
-
189
- // --- File PageRank: edges importer→imported, weighted by # symbols crossed.
190
- const nodes = Object.keys(files);
191
- const fileEdges = [];
192
- for (const [p, f] of Object.entries(files))
193
- for (const tp of f.imports)
194
- if (files[tp]) fileEdges.push({ from: p, to: tp, weight: (f.importedSymbols[tp] || []).length || 1 });
195
- const fileRank = pagerank(nodes, fileEdges);
196
- for (const p of nodes) files[p].pagerank = +(fileRank[p] || 0).toFixed(6);
197
-
198
- // --- Symbol ranking (Aider-style): identifier graph from named imports.
199
- const rankedSymbols = rankSymbols(files, null);
200
-
201
- // hubs: now PageRank-ranked (raw dependent count shown alongside).
202
- const hubs = nodes
203
- .map((p) => [p, files[p].pagerank, files[p].dependents.length])
204
- .sort((a, b) => b[1] - a[1])
205
- .slice(0, 15)
206
- .map(([p, pr, deg]) => `${p} (deg ${deg}, pr ${pr})`);
207
-
208
- const out = {
209
- schema: SCHEMA_VERSION, generatedSha: currentSha(), dirty: dirtyCount(), fileCount: nodes.length,
210
- hubs, features, rankedSymbols: rankedSymbols.slice(0, 80), files,
211
- };
212
- mkdirSync(".claude", { recursive: true });
213
- writeFileSync(MAP, JSON.stringify(out));
214
- return out;
215
- }
216
-
217
- // Build the Aider-style identifier graph from the file map and return a
218
- // ranked list of { file, name, kind, rank }. `focus` (Set of paths) +
219
- // derived mentioned idents personalize the ranking when given.
220
- function rankSymbols(files, focus) {
221
- const defines = new Map(); // ident -> Set(file)
222
- const references = new Map(); // ident -> [file...] (multiplicity)
223
- const definition = new Map(); // `${file}|${ident}` -> {file, name, kind}
224
- for (const [p, f] of Object.entries(files)) {
225
- for (const e of f.exports) {
226
- (defines.get(e.name) ?? defines.set(e.name, new Set()).get(e.name)).add(p);
227
- definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
228
- }
229
- }
230
- for (const [p, f] of Object.entries(files))
231
- for (const tp of f.imports)
232
- for (const name of f.importedSymbols[tp] || [])
233
- if (name !== "*" && name !== "default") (references.get(name) ?? references.set(name, []).get(name)).push(p);
234
-
235
- // mentioned idents from focus files' exports + their basenames
236
- let mentioned = null;
237
- if (focus && focus.size) {
238
- mentioned = new Set();
239
- for (const p of focus) {
240
- for (const e of (files[p]?.exports || [])) mentioned.add(e.name);
241
- const base = p.split("/").pop().replace(/\.[^.]+$/, "");
242
- mentioned.add(base);
243
- }
244
- }
245
-
246
- const nodes = Object.keys(files);
247
- const edges = [];
248
- for (const ident of defines.keys()) {
249
- if (!references.has(ident)) continue;
250
- const mul = identMul(ident, defines.get(ident).size, mentioned);
251
- const counts = new Map();
252
- for (const refFile of references.get(ident)) counts.set(refFile, (counts.get(refFile) || 0) + 1);
253
- for (const [refFile, n] of counts)
254
- for (const defFile of defines.get(ident)) {
255
- if (refFile === defFile) continue;
256
- let useMul = mul;
257
- if (focus && focus.has(refFile)) useMul *= 50;
258
- edges.push({ from: refFile, to: defFile, weight: useMul * Math.sqrt(n), ident });
259
- }
260
- }
261
- // personalization seeds: focus files + files whose name matches a mention
262
- let pers = null;
263
- if (focus && focus.size) {
264
- pers = {};
265
- const unit = 100 / nodes.length;
266
- for (const p of nodes) {
267
- let v = 0;
268
- if (focus.has(p)) v += unit;
269
- const parts = new Set([...p.split("/"), p.split("/").pop(), p.split("/").pop().replace(/\.[^.]+$/, "")]);
270
- if (mentioned && [...parts].some((x) => mentioned.has(x))) v += unit;
271
- if (v > 0) pers[p] = v;
272
- }
273
- if (!Object.keys(pers).length) pers = null;
274
- }
275
- const rank = pagerank(nodes, edges, pers ? { personalization: pers } : {});
276
-
277
- // redistribute each file's rank across its out-edges onto (defFile, ident)
278
- const out = new Map(); // `${file}|${ident}` -> total weight
279
- const totalW = new Map();
280
- for (const e of edges) totalW.set(e.from, (totalW.get(e.from) || 0) + e.weight);
281
- for (const e of edges) {
282
- const share = (rank[e.from] || 0) * e.weight / (totalW.get(e.from) || 1);
283
- const k = `${e.to}|${e.ident}`;
284
- out.set(k, (out.get(k) || 0) + share);
285
- }
286
- return [...out.entries()]
287
- .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
288
- .map(([k, r]) => ({ ...(definition.get(k) || { file: k.slice(0, k.lastIndexOf("|")), name: k.slice(k.lastIndexOf("|") + 1), kind: "?" }), rank: +r.toFixed(6) }))
289
- .filter((d) => !(focus && focus.has(d.file)));
290
- }
291
-
292
- // Serve the cached map only when provably current: same HEAD, known schema,
293
- // clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
294
- function ensureFresh() {
295
- const sha = currentSha();
296
- if (existsSync(MAP)) {
297
- try {
298
- const cached = JSON.parse(readFileSync(MAP, "utf8"));
299
- // Trust cache only if: same HEAD, known schema, it was built CLEAN
300
- // (cached.dirty === 0 — never trust a map built mid-edit, even after a
301
- // revert returns the tree to clean), AND the tree is clean right now.
302
- if (sha && cached.generatedSha === sha && cached.schema === SCHEMA_VERSION && cached.dirty === 0 && dirtyCount() === 0) return cached;
303
- } catch {}
304
- }
305
- return build();
306
- }
307
-
308
- // Resolve a query to a file key: exact path → unique basename → unique substring.
309
- function resolveFile(keys, filesObj, q) {
310
- if (filesObj[q]) return { key: q };
311
- const base = keys.filter((k) => k.split("/").pop() === q);
312
- if (base.length === 1) return { key: base[0] };
313
- const subs = keys.filter((k) => k.toLowerCase().includes(q.toLowerCase()));
314
- if (subs.length === 1) return { key: subs[0] };
315
- return { key: null, candidates: subs };
316
- }
317
-
318
- function fileBlock(key, f) {
319
- console.log(`exports (${f.exports.length}): ${f.exports.map((e) => `${e.name}(${e.kind})`).join(", ") || "—"}`);
320
- console.log(`imports (${f.imports.length}): ${f.imports.join(", ") || "—"}`);
321
- console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
322
- }
323
-
324
- // ---------------------------------------------------------------------------
325
- // CLI
326
- // ---------------------------------------------------------------------------
327
- const args = process.argv.slice(2);
328
- const has = (f) => args.includes(f);
329
- const arg = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
330
-
331
- if (has("--any")) {
332
- // Unified router: cached structure (file → symbol → feature) then a LIVE
333
- // git-grep fallback for data/copy/string-literals the graph never indexes.
334
- const raw = arg("--any") || "";
335
- if (!raw) { console.log('--any needs a query, e.g. `--any PremiumCard` or `--any "multi-modal"`'); }
336
- else {
337
- const q = raw.toLowerCase();
338
- const data = ensureFresh();
339
- const keys = Object.keys(data.files);
340
- const { key: fileKey, candidates } = resolveFile(keys, data.files, raw);
341
- const symHits = [];
342
- for (const [path, f] of Object.entries(data.files))
343
- for (const e of f.exports)
344
- if (e.name.toLowerCase().includes(q)) symHits.push(` ${path} → ${e.name} (${e.kind})`);
345
- const featNames = Object.keys(data.features || {}).filter((k) => k.toLowerCase().includes(q));
346
- if (fileKey) {
347
- console.log(`[structure:file] ${fileKey} (pr ${data.files[fileKey].pagerank ?? "—"})`);
348
- fileBlock(fileKey, data.files[fileKey]);
349
- } else if (symHits.length || featNames.length) {
350
- console.log(`[structure] ${symHits.length} symbol, ${featNames.length} feature match for "${raw}"`);
351
- if (symHits.length) console.log(symHits.join("\n"));
352
- if (featNames.length) console.log("features: " + featNames.map((n) => `${n} (${data.features[n].length})`).join(", "));
353
- } else if (candidates && candidates.length > 1) {
354
- console.log(`[structure] "${raw}" matched ${candidates.length} files — narrow it:`);
355
- for (const k of candidates) console.log(` ${k}`);
356
- } else {
357
- const res = contentSearch(raw);
358
- if (!res) console.log(`[content] 0 match for "${raw}" (git grep, tracked + untracked)`);
359
- else {
360
- const lines = res.split("\n");
361
- console.log(`[content] ${lines.length} line${lines.length > 1 ? "s" : ""}${lines.length > 40 ? " (showing 40)" : ""}:`);
362
- console.log(lines.slice(0, 40).join("\n"));
363
- }
364
- }
365
- }
366
- } else if (has("--find")) {
367
- const raw = arg("--find") || "", q = raw.toLowerCase();
368
- const data = ensureFresh();
369
- const hits = [];
370
- for (const [path, f] of Object.entries(data.files))
371
- for (const e of f.exports)
372
- if (e.name.toLowerCase().includes(q)) hits.push(` ${path} → ${e.name} (${e.kind})`);
373
- console.log(`find "${raw}": ${hits.length} match`);
374
- if (hits.length) console.log(hits.join("\n"));
375
- } else if (has("--relates")) {
376
- const q = arg("--relates") || "";
377
- const data = ensureFresh();
378
- const keys = Object.keys(data.files);
379
- const { key, candidates } = resolveFile(keys, data.files, q);
380
- if (!key) {
381
- if (candidates && candidates.length > 1) { console.log(`relates: "${q}" matched ${candidates.length} files — narrow it:`); for (const k of candidates) console.log(` ${k}`); }
382
- else console.log(`relates: no file matching "${q}"`);
383
- } else {
384
- const f = data.files[key];
385
- console.log(`relates: ${key} (pr ${f.pagerank ?? "—"})`);
386
- fileBlock(key, f);
387
- // query-focused relevance: personalized PageRank (random-walk-with-restart)
388
- // on a BIDIRECTIONAL graph → files most related to the target, transitively.
389
- const biEdges = [];
390
- for (const [p, ff] of Object.entries(data.files))
391
- for (const tp of ff.imports) if (data.files[tp]) { biEdges.push({ from: p, to: tp, weight: 1 }); biEdges.push({ from: tp, to: p, weight: 1 }); }
392
- const rel = pagerank(keys, biEdges, { personalization: { [key]: 1 } });
393
- const top = Object.entries(rel).filter(([k]) => k !== key).sort((a, b) => b[1] - a[1]).slice(0, 10);
394
- console.log(`related (random-walk relevance):`);
395
- for (const [k, r] of top) console.log(` ${k} (${r.toFixed(4)})`);
396
- }
397
- } else if (has("--map")) {
398
- // Token-budgeted ranked digest (Aider's killer feature). --focus <path>
399
- // personalizes toward a file; default budget 1024, ×8 with no focus.
400
- const data = ensureFresh();
401
- const focusArg = arg("--focus");
402
- const tk = parseInt(arg("--tokens") ?? "", 10);
403
- const budget = Number.isFinite(tk) && tk > 0 ? tk : (focusArg ? 1024 : 8192);
404
- let ranked = data.rankedSymbols || [];
405
- let focusLabel = "global";
406
- if (focusArg) {
407
- const { key, candidates } = resolveFile(Object.keys(data.files), data.files, focusArg);
408
- if (key) { ranked = rankSymbols(data.files, new Set([key])); focusLabel = key; }
409
- else console.error(`# warning: --focus "${focusArg}" matched ${(candidates && candidates.length) || 0} files — using global ranking`);
410
- }
411
- // Fallback for default-export-heavy repos (sparse named-symbol graph): build
412
- // the digest from file PageRank so --map is never empty.
413
- if (!ranked.length)
414
- ranked = Object.entries(data.files)
415
- .sort((a, b) => (b[1].pagerank || 0) - (a[1].pagerank || 0))
416
- .flatMap(([file, f]) => (f.exports || []).map((e) => ({ file, name: e.name, kind: e.kind, rank: f.pagerank || 0 })));
417
- console.log(`# repomap (${data.fileCount} files, sha ${data.generatedSha}) — focus: ${focusLabel}, budget ~${budget} tok`);
418
- let used = 0, shown = 0;
419
- const byFile = new Map();
420
- for (const s of ranked) { if (!byFile.has(s.file)) byFile.set(s.file, []); byFile.get(s.file).push(s); }
421
- for (const [file, syms] of byFile) {
422
- const line = `\n${file}:\n` + syms.slice(0, 8).map((s) => ` ${s.name} (${s.kind})`).join("\n");
423
- const t = tokEst(line);
424
- if (used + t > budget) break;
425
- used += t; shown++; console.log(line);
426
- }
427
- console.log(`\n# ~${used} tokens (${shown} files shown)`);
428
- } else if (has("--symbols")) {
429
- const data = ensureFresh();
430
- const sn = parseInt(arg("--symbols") ?? "", 10); const n = Number.isFinite(sn) && sn > 0 ? sn : 30;
431
- console.log(`top ${n} ranked symbols (Aider-style):`);
432
- for (const s of (data.rankedSymbols || []).slice(0, n)) console.log(` ${s.rank} ${s.file} → ${s.name} (${s.kind})`);
433
- } else if (has("--feature")) {
434
- const raw = arg("--feature") || "", q = raw.toLowerCase();
435
- const data = ensureFresh();
436
- const name = Object.keys(data.features).find((k) => k.toLowerCase() === q) || Object.keys(data.features).find((k) => k.toLowerCase().includes(q));
437
- if (!name) console.log(`feature: no match for "${raw}" — run --features to list them.`);
438
- else {
439
- const fl = data.features[name], set = new Set(fl), exts = new Set();
440
- for (const p of fl) for (const dep of (data.files[p]?.dependents || [])) if (!set.has(dep)) exts.add(dep);
441
- console.log(`feature "${name}": ${fl.length} files`);
442
- for (const p of fl) console.log(` ${p}`);
443
- console.log(`external dependents (${exts.size}): ${[...exts].join(", ") || "—"}`);
444
- }
445
- } else if (has("--features")) {
446
- const data = ensureFresh();
447
- const list = Object.entries(data.features).map(([k, v]) => [k, v.length]).sort((a, b) => b[1] - a[1]);
448
- console.log(`features (${list.length}):`);
449
- for (const [k, n] of list) console.log(` ${k} (${n} files)`);
450
- } else if (has("--hubs")) {
451
- const data = ensureFresh();
452
- console.log(`repomap: ${data.fileCount} files (sha ${data.generatedSha})`);
453
- console.log("hubs (PageRank importance):");
454
- for (const h of data.hubs) console.log(` ${h}`);
455
- } else if (has("--print")) {
456
- const data = ensureFresh();
457
- console.log(JSON.stringify({ hubs: data.hubs, features: data.features, rankedSymbols: data.rankedSymbols, files: data.files }));
458
- } else {
459
- const out = build();
460
- console.log(`repomap: ${out.fileCount} files | ${Object.keys(out.features).length} features | top hub: ${out.hubs[0] || "—"}`);
461
- }