@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/agentmap.mjs ADDED
@@ -0,0 +1,894 @@
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 (approach 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, SyntaxKind } from "ts-morph";
17
+ const CallExpression = SyntaxKind.CallExpression;
18
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, chmodSync } from "node:fs";
19
+ import { execSync, execFileSync } from "node:child_process";
20
+ import { createHash } from "node:crypto";
21
+
22
+ const MAP = ".claude/agentmap.json";
23
+ const SCHEMA_VERSION = 2;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Tuning constants — KEEP THESE VALUES IDENTICAL (output + marketing must not
27
+ // shift). Hoisted out of inline literals so the algorithm is self-documenting.
28
+ // ---------------------------------------------------------------------------
29
+ const DAMPING = 0.85; // PageRank damping (Aider parity)
30
+ const TOL = 1e-6; // power-iteration convergence tolerance
31
+ const MAX_ITER = 100; // power-iteration iteration cap
32
+ const IDENT_BOOST = 10; // weight ×: mentioned ident, or long multi-word ident
33
+ const RARE_PENALTY = 0.1; // weight ×: ident defined in >RARE_DEFINERS files (too common)
34
+ const UNDERSCORE_PENALTY = 0.1; // weight ×: private-ish `_`-prefixed ident
35
+ const MIN_IDENT_LEN = 8; // min length for the long-multi-word ident boost
36
+ const RARE_DEFINERS = 5; // >this many definers ⇒ ident is too common ⇒ penalize
37
+ const FOCUS_BOOST = 50; // ref-edge weight × when refFile is in the focus set
38
+ const DEFAULT_BUDGET = 8192; // --map token budget with no --focus
39
+ const FOCUS_BUDGET = 1024; // --map token budget when --focus is given
40
+ const HUBS_LIMIT = 15; // # of hubs persisted/printed
41
+ const RANKED_SYMBOLS_LIMIT = 80; // # of ranked symbols persisted
42
+ const CONTENT_LINES_LIMIT = 40; // # of git-grep lines shown in the --any content fallback
43
+ const RELATED_LIMIT = 10; // # of related files shown by --relates
44
+ const SYMS_PER_FILE = 8; // per-file symbol cap in the --map digest
45
+ const DEFAULT_SYMBOLS = 30; // default count for --symbols with no n
46
+ const MAXBUF = 64 * 1024 * 1024; // child_process maxBuffer — avoid ENOBUFS on big git output
47
+
48
+ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).toString().trim(); } catch { return ""; } };
49
+
50
+ // Live content search for the --any fallback. `git grep` over tracked +
51
+ // untracked files (skips gitignored paths like node_modules). Reads DISK, so
52
+ // never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
53
+ // stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
54
+ const contentSearch = (q) => {
55
+ try {
56
+ return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap.json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim();
57
+ } catch { return ""; }
58
+ };
59
+ const currentSha = () => sh("git rev-parse --short HEAD");
60
+ const dirtyCount = () =>
61
+ // --untracked-files=all so a new file inside a brand-new untracked DIR is
62
+ // listed individually (default "all" folds it to "?? newdir/" and the
63
+ // extension regex misses it → a STALE cache would be served).
64
+ sh("git status --porcelain --untracked-files=all").split("\n").filter(Boolean).filter((l) => {
65
+ let p = l.slice(3); // strip "XY " status prefix
66
+ if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
67
+ p = p.replace(/^"|"$/g, ""); // unquote space/special paths
68
+ return /\.(ts|tsx|mjs|cjs|jsx|js)$/.test(p);
69
+ }).length;
70
+ const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
71
+
72
+ // get-or-init a Map value (readable replacement for the dense `m.get(k) ?? m.set(...)` idiom).
73
+ const getOrSet = (m, k, make) => { let v = m.get(k); if (v === undefined) { v = make(); m.set(k, v); } return v; };
74
+
75
+ // Best-effort source fingerprint for NON-git repos (sha == ""). Hash of sorted
76
+ // "path:mtimeMs:size" for source files so the cache can be trusted between runs
77
+ // without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller
78
+ // falls through to build, i.e. current behavior). Never used on the git path.
79
+ const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs)$/;
80
+ function sourceFingerprint() {
81
+ try {
82
+ const entries = [];
83
+ const walk = (dir) => {
84
+ for (const name of readdirSync(dir)) {
85
+ if (name === "node_modules" || name === ".git" || name === ".next") continue;
86
+ const full = dir + "/" + name;
87
+ let st;
88
+ try { st = statSync(full); } catch { continue; }
89
+ if (st.isDirectory()) walk(full);
90
+ else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
91
+ }
92
+ };
93
+ walk(".");
94
+ entries.sort();
95
+ return createHash("sha1").update(entries.join("\n")).digest("hex");
96
+ } catch { return ""; }
97
+ }
98
+
99
+ // Feature = first real route segment under app/ (or src/app/), skipping route
100
+ // groups (parens), dynamic segments ([id]) and parallel routes (@slot).
101
+ function featureOf(path) {
102
+ const m = path.match(/(?:^|.*\/)(?:src\/)?app\/(.+)/);
103
+ if (!m) return null;
104
+ for (const p of m[1].split("/").slice(0, -1)) {
105
+ if (p.startsWith("(") || p.startsWith("[") || p.startsWith("@")) continue;
106
+ return p;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Personalized PageRank — dependency-free power iteration. Deterministic
113
+ // (stable node order, no PRNG). Edges = [{from, to, weight}]. Rank flows
114
+ // from→to, so with importer→imported edges, heavily-imported hubs rank high.
115
+ // Dangling-node mass + teleport both go to the personalization vector
116
+ // (matches Aider's `dangling=personalization`). Returns { node: score }.
117
+ // ---------------------------------------------------------------------------
118
+ function pagerank(nodes, edges, { personalization = null, damping = DAMPING, tol = TOL, maxIter = MAX_ITER } = {}) {
119
+ const N = nodes.length;
120
+ if (N === 0) return {};
121
+ const idx = new Map(nodes.map((n, i) => [n, i]));
122
+ const outW = new Float64Array(N);
123
+ const adj = Array.from({ length: N }, () => []);
124
+ for (const e of edges) {
125
+ const a = idx.get(e.from), b = idx.get(e.to);
126
+ if (a === undefined || b === undefined || a === b) continue; // skip self-loops
127
+ const w = e.weight > 0 ? e.weight : 1;
128
+ adj[a].push([b, w]); outW[a] += w;
129
+ }
130
+ // teleport vector p (normalized personalization, or uniform)
131
+ const p = new Float64Array(N);
132
+ if (personalization) {
133
+ let s = 0;
134
+ for (const [k, v] of Object.entries(personalization)) {
135
+ const i = idx.get(k);
136
+ if (i !== undefined && v > 0) { p[i] = v; s += v; }
137
+ }
138
+ if (s === 0) p.fill(1 / N); else for (let i = 0; i < N; i++) p[i] /= s;
139
+ } else p.fill(1 / N);
140
+ let r = Float64Array.from(p);
141
+ for (let iter = 0; iter < maxIter; iter++) {
142
+ let dangling = 0;
143
+ for (let i = 0; i < N; i++) if (outW[i] === 0) dangling += r[i];
144
+ const next = new Float64Array(N);
145
+ for (let i = 0; i < N; i++) next[i] = (1 - damping) * p[i] + damping * dangling * p[i];
146
+ for (let i = 0; i < N; i++) {
147
+ if (outW[i] === 0) continue;
148
+ const ri = damping * r[i];
149
+ for (const [j, w] of adj[i]) next[j] += ri * (w / outW[i]);
150
+ }
151
+ let diff = 0;
152
+ for (let i = 0; i < N; i++) diff += Math.abs(next[i] - r[i]);
153
+ r = next;
154
+ if (diff < tol) break;
155
+ }
156
+ const out = {};
157
+ for (let i = 0; i < N; i++) out[nodes[i]] = r[i];
158
+ return out;
159
+ }
160
+
161
+ // Aider-style identifier edge-weight multipliers. `mentioned` = focus/query
162
+ // idents (boosted). Rarity is approximated by the >5-definers penalty.
163
+ function identMul(ident, defineCount, mentioned) {
164
+ let mul = 1.0;
165
+ const hasAlpha = /[a-zA-Z]/.test(ident);
166
+ const isSnake = ident.includes("_") && hasAlpha;
167
+ const isKebab = ident.includes("-") && hasAlpha;
168
+ const isCamel = /[a-z]/.test(ident) && /[A-Z]/.test(ident);
169
+ if (mentioned && mentioned.has(ident)) mul *= IDENT_BOOST;
170
+ if ((isSnake || isKebab || isCamel) && ident.length >= MIN_IDENT_LEN) mul *= IDENT_BOOST;
171
+ if (ident.startsWith("_")) mul *= UNDERSCORE_PENALTY;
172
+ if (defineCount > RARE_DEFINERS) mul *= RARE_PENALTY;
173
+ return mul;
174
+ }
175
+
176
+ // Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
177
+ // else (missing / malformed / solution-style references that index 0 files) fall
178
+ // back to broad source globs so the tool degrades gracefully instead of crashing.
179
+ function makeProject() {
180
+ // skipFileDependencyResolution: ~40% faster build, verified identical edge
181
+ // set (we resolve module specifiers explicitly below, never via the implicit
182
+ // dependency graph). allowJs so .js/.jsx are parsed.
183
+ const FAST = { skipFileDependencyResolution: true };
184
+ // Read tsconfig/jsconfig baseUrl+paths defensively so "@/…"/"~/…" alias
185
+ // imports still resolve when tsconfig is absent/broken. Any failure ⇒ none.
186
+ const aliasOpts = (() => {
187
+ for (const cfg of ["tsconfig.json", "jsconfig.json"]) {
188
+ try {
189
+ if (!existsSync(cfg)) continue;
190
+ const co = (JSON.parse(readFileSync(cfg, "utf8")) || {}).compilerOptions || {};
191
+ const out = {};
192
+ if (co.baseUrl) out.baseUrl = co.baseUrl;
193
+ if (co.paths) out.paths = co.paths;
194
+ if (Object.keys(out).length) return out;
195
+ } catch { /* ignore — proceed without paths */ }
196
+ }
197
+ return {};
198
+ })();
199
+
200
+ let project;
201
+ if (existsSync("tsconfig.json")) {
202
+ try { project = new Project({ tsConfigFilePath: "tsconfig.json", ...FAST }); }
203
+ catch {
204
+ // tsconfig present but unreadable/malformed — don't silently degrade.
205
+ console.error("# warning: tsconfig.json unreadable, using source globs");
206
+ project = new Project({ compilerOptions: { allowJs: true, ...aliasOpts }, ...FAST });
207
+ }
208
+ } else {
209
+ project = new Project({ compilerOptions: { allowJs: true, ...aliasOpts }, ...FAST });
210
+ }
211
+ // tsconfig `include` usually omits build/pipeline scripts — add by path.
212
+ project.addSourceFilesAtPaths([
213
+ "scripts/**/*.{mjs,cjs,js}", "*.mjs", "*.cjs",
214
+ ]);
215
+ // Catch source files a narrow tsconfig `include` misses (monorepo / subdir-
216
+ // scoped) WITHOUT an expensive full-tree FS glob (which cost ~600ms to find a
217
+ // handful of files). Enumerate cheaply via `git ls-files` (tracked + untracked-
218
+ // not-ignored — node_modules etc. excluded by --exclude-standard) and add only
219
+ // files not already loaded. Non-git repos fall back to the broad globs.
220
+ const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
221
+ const cwdp = process.cwd().replace(/\\/g, "/");
222
+ const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
223
+ if (listed.length) {
224
+ const missing = [];
225
+ for (const f of listed) {
226
+ if (!/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) continue;
227
+ const segs = f.split("/");
228
+ if (segs.includes("node_modules") || segs.includes(".next")) continue;
229
+ if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
230
+ }
231
+ if (missing.length) project.addSourceFilesAtPaths(missing);
232
+ } else {
233
+ // non-git fallback: broad globs (mts/cts/mjs/cjs per base dir included).
234
+ project.addSourceFilesAtPaths([
235
+ "src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "app/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
236
+ "components/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "lib/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
237
+ "pages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
238
+ ]);
239
+ }
240
+ return project;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // build() — parse the repo, extract file imports/exports (+ which named
245
+ // symbols cross each edge), compute file PageRank, run the Aider-style
246
+ // identifier graph to rank individual symbols, and persist agentmap.json.
247
+ // ---------------------------------------------------------------------------
248
+ function build() {
249
+ const t0 = Date.now();
250
+ const project = makeProject();
251
+ const cwd = process.cwd().replace(/\\/g, "/");
252
+ const rel = (p) => p.replace(cwd + "/", "");
253
+ const files = {}, dependents = {}, features = {};
254
+ // PATH-SEGMENT exclusion (not substring) so e.g. components/.next-demo or
255
+ // src/node_modules_helper.ts are NOT wrongly excluded.
256
+ const excluded = (p) => { const segs = p.split("/"); return segs.includes("node_modules") || segs.includes(".next"); };
257
+
258
+ // Resolve a relative module specifier (from the importing file's dir) to an
259
+ // in-project source file key. Tries the bare path, then each extension, then
260
+ // /index.*. Returns the rel key or null. Powers side-effect (6b) + dynamic
261
+ // import()/require() (6c) edges that ts-morph's specifier resolution skips.
262
+ const RES_EXT = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
263
+ const resolveSpec = (fromAbsDir, spec) => {
264
+ if (!spec.startsWith(".")) return null; // only relative, in-project specifiers
265
+ // normalize fromAbsDir + spec into an absolute-ish posix path
266
+ const join = (a, b) => {
267
+ const parts = (a + "/" + b).split("/"); const st = [];
268
+ for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
269
+ return (a.startsWith("/") ? "/" : "") + st.join("/");
270
+ };
271
+ const baseAbs = join(fromAbsDir, spec);
272
+ const tryGet = (abs) => { const sf = project.getSourceFile(abs); return sf ? sf : null; };
273
+ let sf = tryGet(baseAbs);
274
+ if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}.${e}`); if (sf) break; }
275
+ if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}/index.${e}`); if (sf) break; }
276
+ return sf ? rel(sf.getFilePath()) : null;
277
+ };
278
+
279
+ const sourceFiles = project.getSourceFiles();
280
+ process.stderr.write(`# agentmap: parsing ${sourceFiles.length} source files…\n`);
281
+ for (const sf of sourceFiles) {
282
+ const path = rel(sf.getFilePath());
283
+ if (excluded(path)) continue;
284
+ const fromDir = sf.getDirectoryPath().replace(/\\/g, "/");
285
+ // exports, remembering which exported name was the file's DEFAULT export so
286
+ // default-import edges can later resolve "default" → the real symbol name.
287
+ let defaultExportName = null;
288
+ const exports = [...sf.getExportedDeclarations()].map(([name, d]) => {
289
+ const resolved = name === "default" ? (d[0]?.getName?.() ?? "default") : name;
290
+ if (name === "default") defaultExportName = resolved;
291
+ return { name: resolved, kind: d[0]?.getKindName?.() ?? "?" };
292
+ });
293
+ // Dependency edges from static imports + re-export barrels, with the set
294
+ // of named symbols crossing each edge (used for edge weights + the ident
295
+ // graph). importedSymbols[targetPath] = [names...].
296
+ const importedSymbols = {};
297
+ const addEdge = (tp, names) => {
298
+ if (!tp || excluded(tp)) return;
299
+ (importedSymbols[tp] ??= []).push(...names);
300
+ };
301
+ for (const imp of sf.getImportDeclarations()) {
302
+ if (imp.isTypeOnly()) continue; // type-only modules must not inflate runtime PageRank
303
+ const t = imp.getModuleSpecifierSourceFile();
304
+ if (t) {
305
+ // skip individual type-only named specifiers (`import { type X }`)
306
+ const names = imp.getNamedImports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
307
+ if (imp.getDefaultImport()) names.push("default"); // resolved to the real name in a post-pass below
308
+ if (imp.getNamespaceImport()) names.push("*");
309
+ addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
310
+ } else {
311
+ // 6b: side-effect import (`import "./x"`) — no source file via ts-morph,
312
+ // but a relative specifier resolving in-project still counts as an edge.
313
+ const spec = imp.getModuleSpecifierValue();
314
+ const tp = resolveSpec(fromDir, spec);
315
+ if (tp) addEdge(tp, ["*"]);
316
+ }
317
+ }
318
+ for (const exp of sf.getExportDeclarations()) {
319
+ if (exp.isTypeOnly()) continue; // type-only re-exports excluded from edges
320
+ const t = exp.getModuleSpecifierSourceFile();
321
+ if (t) addEdge(rel(t.getFilePath()), exp.getNamedExports().filter((n) => !n.isTypeOnly()).map((n) => n.getName()));
322
+ }
323
+ // 6c: dynamic import("./x") and require("./x") with relative, in-project
324
+ // string-literal specifiers → edge with names ["*"]. Prefilter on raw text
325
+ // so we only AST-walk the few files that actually contain a dynamic call.
326
+ const srcText = sf.getFullText();
327
+ if (srcText.includes("import(") || srcText.includes("require(")) for (const call of sf.getDescendantsOfKind(CallExpression)) {
328
+ const expr = call.getExpression();
329
+ const kind = expr.getKind();
330
+ const isImport = kind === SyntaxKind.ImportKeyword;
331
+ const isRequire = expr.getText?.() === "require";
332
+ if (!isImport && !isRequire) continue;
333
+ const a0 = call.getArguments()[0];
334
+ if (!a0 || a0.getKind() !== SyntaxKind.StringLiteral) continue;
335
+ const tp = resolveSpec(fromDir, a0.getLiteralText());
336
+ if (tp) addEdge(tp, ["*"]);
337
+ }
338
+ const imports = Object.keys(importedSymbols);
339
+ for (const tp of imports) (dependents[tp] ??= []).push(path);
340
+ files[path] = { exports, imports, importedSymbols, defaultExportName };
341
+ const feat = featureOf(path);
342
+ if (feat) (features[feat] ??= []).push(path);
343
+ }
344
+ // 7: resolve default-import edges. A default import was recorded literally as
345
+ // "default"; rankSymbols skips "default", so default-exported symbols (the
346
+ // dominant Next.js component) never ranked. Map each "default" entry to the
347
+ // TARGET file's resolved default-export name so it forms reference edges.
348
+ for (const f of Object.values(files)) {
349
+ for (const tp of Object.keys(f.importedSymbols)) {
350
+ const dn = files[tp]?.defaultExportName;
351
+ if (!dn || dn === "default") continue;
352
+ f.importedSymbols[tp] = f.importedSymbols[tp].map((n) => (n === "default" ? dn : n));
353
+ }
354
+ }
355
+ for (const p in files) files[p].dependents = dependents[p] ?? [];
356
+
357
+ // --- File PageRank: edges importer→imported, weighted by # symbols crossed.
358
+ const nodes = Object.keys(files);
359
+ const fileEdges = [];
360
+ for (const [p, f] of Object.entries(files))
361
+ for (const tp of f.imports)
362
+ if (files[tp]) fileEdges.push({ from: p, to: tp, weight: (f.importedSymbols[tp] || []).length || 1 });
363
+ const fileRank = pagerank(nodes, fileEdges);
364
+ for (const p of nodes) files[p].pagerank = +(fileRank[p] || 0).toFixed(6);
365
+
366
+ // --- Symbol ranking (Aider-style): identifier graph from named imports.
367
+ const rankedSymbols = rankSymbols(files, null);
368
+
369
+ // hubs: now PageRank-ranked (raw dependent count shown alongside).
370
+ const hubs = nodes
371
+ .map((p) => [p, files[p].pagerank, files[p].dependents.length])
372
+ .sort((a, b) => b[1] - a[1])
373
+ .slice(0, HUBS_LIMIT)
374
+ .map(([p, pr, deg]) => `${p} (deg ${deg}, pr ${pr})`);
375
+
376
+ // defaultExportName was only needed for the fix-#7 post-pass — drop it before
377
+ // persisting so the on-disk `files` shape stays stable.
378
+ for (const p of nodes) delete files[p].defaultExportName;
379
+
380
+ const sha = currentSha();
381
+ const out = {
382
+ schema: SCHEMA_VERSION, generatedSha: sha, dirty: dirtyCount(), fileCount: nodes.length,
383
+ // fingerprint lets non-git repos (sha === "") trust the cache across runs.
384
+ fingerprint: sha ? undefined : sourceFingerprint(),
385
+ hubs, features, rankedSymbols: rankedSymbols.slice(0, RANKED_SYMBOLS_LIMIT), files,
386
+ };
387
+ mkdirSync(".claude", { recursive: true });
388
+ // Atomic write: tmp + rename so a concurrent background rebuild can never
389
+ // expose a torn/truncated agentmap.json to a reader.
390
+ const tmp = MAP + ".tmp";
391
+ writeFileSync(tmp, JSON.stringify(out));
392
+ renameSync(tmp, MAP);
393
+ process.stderr.write(`# agentmap: built ${nodes.length} files in ${Date.now() - t0}ms\n`);
394
+ return out;
395
+ }
396
+
397
+ // Build the Aider-style identifier graph from the file map and return a
398
+ // ranked list of { file, name, kind, rank }. `focus` (Set of paths) +
399
+ // derived mentioned idents personalize the ranking when given.
400
+ function rankSymbols(files, focus) {
401
+ const defines = new Map(); // ident -> Set(file)
402
+ const references = new Map(); // ident -> [file...] (multiplicity)
403
+ const definition = new Map(); // `${file}|${ident}` -> {file, name, kind}
404
+ for (const [p, f] of Object.entries(files)) {
405
+ for (const e of f.exports) {
406
+ getOrSet(defines, e.name, () => new Set()).add(p);
407
+ definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
408
+ }
409
+ }
410
+ for (const [p, f] of Object.entries(files))
411
+ for (const tp of f.imports)
412
+ for (const name of f.importedSymbols[tp] || [])
413
+ if (name !== "*" && name !== "default") getOrSet(references, name, () => []).push(p);
414
+
415
+ // mentioned idents from focus files' exports + their basenames
416
+ let mentioned = null;
417
+ if (focus && focus.size) {
418
+ mentioned = new Set();
419
+ for (const p of focus) {
420
+ for (const e of (files[p]?.exports || [])) mentioned.add(e.name);
421
+ const base = p.split("/").pop().replace(/\.[^.]+$/, "");
422
+ mentioned.add(base);
423
+ }
424
+ }
425
+
426
+ const nodes = Object.keys(files);
427
+ const edges = [];
428
+ for (const ident of defines.keys()) {
429
+ if (!references.has(ident)) continue;
430
+ const mul = identMul(ident, defines.get(ident).size, mentioned);
431
+ const counts = new Map();
432
+ for (const refFile of references.get(ident)) counts.set(refFile, (counts.get(refFile) || 0) + 1);
433
+ for (const [refFile, n] of counts)
434
+ for (const defFile of defines.get(ident)) {
435
+ if (refFile === defFile) continue;
436
+ let useMul = mul;
437
+ if (focus && focus.has(refFile)) useMul *= FOCUS_BOOST;
438
+ edges.push({ from: refFile, to: defFile, weight: useMul * Math.sqrt(n), ident });
439
+ }
440
+ }
441
+ // personalization seeds: focus files + files whose name matches a mention
442
+ let pers = null;
443
+ if (focus && focus.size) {
444
+ pers = {};
445
+ const unit = 100 / nodes.length;
446
+ for (const p of nodes) {
447
+ let v = 0;
448
+ if (focus.has(p)) v += unit;
449
+ const parts = new Set([...p.split("/"), p.split("/").pop(), p.split("/").pop().replace(/\.[^.]+$/, "")]);
450
+ if (mentioned && [...parts].some((x) => mentioned.has(x))) v += unit;
451
+ if (v > 0) pers[p] = v;
452
+ }
453
+ if (!Object.keys(pers).length) pers = null;
454
+ }
455
+ const rank = pagerank(nodes, edges, pers ? { personalization: pers } : {});
456
+
457
+ // redistribute each file's rank across its out-edges onto (defFile, ident)
458
+ const out = new Map(); // `${file}|${ident}` -> total weight
459
+ const totalW = new Map();
460
+ for (const e of edges) totalW.set(e.from, (totalW.get(e.from) || 0) + e.weight);
461
+ for (const e of edges) {
462
+ const share = (rank[e.from] || 0) * e.weight / (totalW.get(e.from) || 1);
463
+ const k = `${e.to}|${e.ident}`;
464
+ out.set(k, (out.get(k) || 0) + share);
465
+ }
466
+ const ranked = [...out.entries()]
467
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
468
+ .map(([k, r]) => ({ ...(definition.get(k) || { file: k.slice(0, k.lastIndexOf("|")), name: k.slice(k.lastIndexOf("|") + 1), kind: "?" }), rank: +r.toFixed(6) }))
469
+ .filter((d) => !(focus && focus.has(d.file)));
470
+
471
+ // Aider parity (#8): keep exported symbols that NOTHING imports (Aider gives
472
+ // them a 0.1 self-edge; pagerank() skips self-loops, so we append them here
473
+ // with a tiny baseline rank below the lowest real rank). Lets public-API
474
+ // entry points + default-export components surface in the digest tail.
475
+ const present = new Set(ranked.map((d) => `${d.file}|${d.name}`));
476
+ const lowest = ranked.length ? ranked[ranked.length - 1].rank : 0;
477
+ const baseline = +(lowest - 1e-6 > 0 ? lowest - 1e-6 : 1e-6).toFixed(6);
478
+ const tail = [];
479
+ for (const def of definition.values()) {
480
+ const k = `${def.file}|${def.name}`;
481
+ if (present.has(k)) continue;
482
+ if (focus && focus.has(def.file)) continue;
483
+ tail.push({ ...def, rank: baseline });
484
+ }
485
+ tail.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
486
+ return [...ranked, ...tail];
487
+ }
488
+
489
+ // Serve the cached map only when provably current: same HEAD, known schema,
490
+ // clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
491
+ function ensureFresh() {
492
+ const sha = currentSha();
493
+ if (existsSync(MAP)) {
494
+ try {
495
+ const cached = JSON.parse(readFileSync(MAP, "utf8"));
496
+ // Trust cache only if: same HEAD, known schema, it was built CLEAN
497
+ // (cached.dirty === 0 — never trust a map built mid-edit, even after a
498
+ // revert returns the tree to clean), AND the tree is clean right now.
499
+ if (sha && cached.generatedSha === sha && cached.schema === SCHEMA_VERSION && cached.dirty === 0 && dirtyCount() === 0) return cached;
500
+ // NON-git repo (sha === ""): no HEAD to compare. Trust the cache when a
501
+ // lightweight source fingerprint (path:mtime:size hash) is unchanged so
502
+ // we don't full-reparse on every call. Best-effort — any mismatch/error
503
+ // falls through to build(). Does NOT touch the git-repo path above.
504
+ if (!sha && cached.schema === SCHEMA_VERSION && cached.fingerprint) {
505
+ const fp = sourceFingerprint();
506
+ if (fp && cached.fingerprint === fp) return cached;
507
+ }
508
+ } catch {}
509
+ }
510
+ return build();
511
+ }
512
+
513
+ // Resolve a query to a file key, in PREFERENCE order so a loose substring path
514
+ // match never shadows a symbol the user actually wanted:
515
+ // (a) exact path key
516
+ // (b) unique basename match, CASE-INSENSITIVE
517
+ // (c) unique case-insensitive SUBSTRING match (weakest — only when a/b miss)
518
+ // (d) multiple substring matches → {key:null, candidates} for disambiguation
519
+ function resolveFile(keys, filesObj, q) {
520
+ if (filesObj[q]) return { key: q }; // (a)
521
+ const ql = q.toLowerCase();
522
+ const base = keys.filter((k) => k.split("/").pop().toLowerCase() === ql); // (b) case-insensitive basename
523
+ if (base.length === 1) return { key: base[0] };
524
+ const subs = keys.filter((k) => k.toLowerCase().includes(ql)); // (c)/(d) substring
525
+ if (subs.length === 1) return { key: subs[0] };
526
+ return { key: null, candidates: subs };
527
+ }
528
+
529
+ function fileBlock(key, f) {
530
+ console.log(`exports (${f.exports.length}): ${f.exports.map((e) => `${e.name}(${e.kind})`).join(", ") || "—"}`);
531
+ console.log(`imports (${f.imports.length}): ${f.imports.join(", ") || "—"}`);
532
+ console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // --install-hooks: copy the package post-commit hook into .git/hooks, ensure
537
+ // .claude/agentmap.json is gitignored, and print the Claude Code PreToolUse
538
+ // snippet (mirrors hooks/INSTALL.md). Throws on any failure so the caller can
539
+ // stderr+exit 1. Resolves the package hooks/ dir relative to THIS script so it
540
+ // works whether agentmap is run locally, via npx, or globally installed.
541
+ // ---------------------------------------------------------------------------
542
+ function installHooks() {
543
+ const src = new URL("./hooks/post-commit", import.meta.url);
544
+ const nudge = new URL("./hooks/agentmap-nudge.mjs", import.meta.url);
545
+ // The package hooks/ dir must ship alongside agentmap.mjs.
546
+ if (!existsSync(src)) throw new Error(`packaged hook not found at ${src.pathname} (is the hooks/ dir present?)`);
547
+
548
+ // Locate the git dir of the CURRENT repo (cwd), then copy in the hook.
549
+ const gitDir = sh("git rev-parse --git-dir");
550
+ if (!gitDir) throw new Error("not a git repository (cwd has no .git) — run inside the repo you want to wire up");
551
+ const hooksDir = `${gitDir}/hooks`;
552
+ mkdirSync(hooksDir, { recursive: true });
553
+ const dest = `${hooksDir}/post-commit`;
554
+ writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
555
+ chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
556
+
557
+ // Ensure .gitignore (in cwd) contains the derived-map line (append/create).
558
+ const IGNORE_LINE = ".claude/agentmap.json";
559
+ let ignoredAlready = false;
560
+ if (existsSync(".gitignore")) {
561
+ const cur = readFileSync(".gitignore", "utf8");
562
+ if (cur.split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE)) ignoredAlready = true;
563
+ else writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
564
+ } else {
565
+ writeFileSync(".gitignore", IGNORE_LINE + "\n");
566
+ }
567
+
568
+ // Success report + the ready-to-paste PreToolUse snippet (points node at the
569
+ // installed nudge — use its absolute path so it resolves from any cwd).
570
+ console.log(`installed post-commit hook → ${dest}`);
571
+ console.log(ignoredAlready ? `.gitignore already has ${IGNORE_LINE}` : `added ${IGNORE_LINE} to .gitignore`);
572
+ console.log("\nAdd this to your .claude/settings.json to enable the PreToolUse(Grep) nudge:\n");
573
+ console.log(JSON.stringify({
574
+ hooks: { PreToolUse: [{ matcher: "Grep", hooks: [{ type: "command", command: `node ${nudge.pathname}` }] }] },
575
+ }, null, 2));
576
+ }
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // CLI
580
+ // ---------------------------------------------------------------------------
581
+ const args = process.argv.slice(2);
582
+ const has = (f) => args.includes(f);
583
+ // Return the value after flag `f`, but treat a missing value OR one that looks
584
+ // like another flag (starts with "--") as undefined so the missing-arg guards
585
+ // fire — e.g. `--any --foo` must NOT search for the literal "--foo".
586
+ const arg = (f) => { const i = args.indexOf(f); if (i < 0) return undefined; const v = args[i + 1]; return v === undefined || v.startsWith("--") ? undefined : v; };
587
+
588
+ // --json is a GLOBAL modifier: when present, the chosen command emits exactly
589
+ // ONE JSON object to stdout (no prose). Branches build a result, then either
590
+ // console.log(JSON.stringify(obj)) or fall through to the prose printer. Exit
591
+ // codes are identical in both modes.
592
+ const wantJson = has("--json");
593
+ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); else prose(); };
594
+
595
+ // Every recognized flag (the global modifiers + maintenance flags + each
596
+ // command + sub-flags that take a value). Anything starting with "-" that is
597
+ // NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
598
+ const KNOWN = new Set([
599
+ "--json", "--print",
600
+ "--help", "-h", "--version", "-v", "--install-hooks", "--mcp",
601
+ "--any", "--find", "--relates", "--map", "--focus", "--tokens",
602
+ "--symbols", "--feature", "--features", "--hubs",
603
+ ]);
604
+
605
+ // A token consumed as the VALUE of a value-taking flag is never itself a flag —
606
+ // so a dash-leading query like `--any "-O/bin/sh"` is bound as the query, not
607
+ // mistaken for an unknown flag. (arg() already rejects a "--"-leading value, so
608
+ // `--any --foo` still falls through to the missing-arg guard instead.)
609
+ const VALUE_FLAGS = new Set(["--any", "--find", "--relates", "--feature", "--focus", "--tokens", "--symbols"]);
610
+ const valueIdx = new Set();
611
+ for (let i = 0; i < args.length - 1; i++) if (VALUE_FLAGS.has(args[i])) valueIdx.add(i + 1);
612
+
613
+ const USAGE = `agentmap — the queryable, ranked repo map your coding agent is forced to use.
614
+
615
+ Usage: agentmap [command] [--json]
616
+
617
+ Query commands:
618
+ --any <q> route a query: file → symbol → feature → live git-grep
619
+ --find <sym> find exported symbols by (sub)name
620
+ --relates <path> a file's exports/imports/dependents + related files
621
+ --map [--focus <p>] [--tokens <n>]
622
+ token-budgeted ranked digest (--focus personalizes)
623
+ --symbols [n] top-n Aider-style ranked symbols (default 30)
624
+ --feature <name> files composing a feature + external dependents
625
+ --features list all features (route segments) by size
626
+ --hubs top files by PageRank importance
627
+ --print dump the full cached map as JSON
628
+ (no flags) build the map + print a one-line summary
629
+
630
+ Global modifier:
631
+ --json emit exactly one JSON object (no prose) for the command
632
+
633
+ Maintenance:
634
+ --install-hooks install git post-commit + print the PreToolUse snippet
635
+ --mcp start a stdio MCP server (for MCP-capable agents)
636
+ --help, -h show this help
637
+ --version, -v print the version
638
+
639
+ Exit codes: 0 ok · 1 query had zero results · 2 usage error.`;
640
+
641
+ // --help / --version short-circuit BEFORE any build or dispatch.
642
+ if (has("--help") || has("-h")) {
643
+ console.log(USAGE);
644
+ process.exit(0);
645
+ }
646
+ if (has("--version") || has("-v")) {
647
+ let version = "0.0.0";
648
+ try { version = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version || version; } catch {}
649
+ console.log(version);
650
+ process.exit(0);
651
+ }
652
+
653
+ // --mcp: hand off to the stdio MCP server (authored separately). Dynamic import
654
+ // so a missing mcp.mjs only fails when --mcp is actually requested.
655
+ if (has("--mcp")) {
656
+ try {
657
+ const m = await import(new URL("./mcp.mjs", import.meta.url));
658
+ await m.serve();
659
+ } catch (e) {
660
+ console.error(`agentmap --mcp failed: ${e?.message || e}`);
661
+ process.exit(1);
662
+ }
663
+ }
664
+ // --install-hooks: wire the git post-commit refresh + emit the PreToolUse
665
+ // snippet. Self-contained (resolves the package hooks/ dir relative to here).
666
+ else if (has("--install-hooks")) {
667
+ try { installHooks(); process.exit(0); }
668
+ catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
669
+ }
670
+ // Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
671
+ // 2). Runs BEFORE the bare-build fallthrough so a typo never silently rebuilds.
672
+ // Bare invocation with NO flags still builds (handled in the final else).
673
+ else if (args.some((a, i) => a.startsWith("-") && !KNOWN.has(a) && !valueIdx.has(i))) {
674
+ const bad = args.find((a, i) => a.startsWith("-") && !KNOWN.has(a) && !valueIdx.has(i));
675
+ console.error(`unknown flag: ${bad}\ntry \`agentmap --help\` for the list of commands.`);
676
+ process.exit(2);
677
+ }
678
+ else if (has("--any")) {
679
+ // Unified router: cached structure (file → symbol → feature) then a LIVE
680
+ // git-grep fallback for data/copy/string-literals the graph never indexes.
681
+ const raw = arg("--any");
682
+ if (!raw) { console.error('--any needs a query, e.g. `--any PremiumCard` or `--any "multi-modal"`'); process.exitCode = 2; }
683
+ else {
684
+ const q = raw.toLowerCase();
685
+ const data = ensureFresh();
686
+ const keys = Object.keys(data.files);
687
+ const { key: fileKey, candidates } = resolveFile(keys, data.files, raw);
688
+ // structured symbol/feature hits (reused by both prose + JSON shapes)
689
+ const symObjs = [];
690
+ for (const [path, f] of Object.entries(data.files))
691
+ for (const e of f.exports)
692
+ if (e.name.toLowerCase().includes(q)) symObjs.push({ file: path, name: e.name, kind: e.kind });
693
+ const symHits = symObjs.map((s) => ` ${s.file} → ${s.name} (${s.kind})`);
694
+ const featNames = Object.keys(data.features || {}).filter((k) => k.toLowerCase().includes(q));
695
+ if (fileKey) {
696
+ // A file resolved — but ALSO surface symbol/feature hits (fix #3) so a
697
+ // loose path match (e.g. "auth") can't shadow a symbol the user wanted.
698
+ const f = data.files[fileKey];
699
+ out({ command: "any", query: raw, kind: "file", file: fileKey, pagerank: f.pagerank ?? null, exports: f.exports, imports: f.imports, dependents: f.dependents, symbols: symObjs, features: featNames.map((n) => ({ name: n, count: data.features[n].length })) }, () => {
700
+ console.log(`[structure:file] ${fileKey} (pr ${f.pagerank ?? "—"})`);
701
+ fileBlock(fileKey, f);
702
+ if (symHits.length) { console.log(`[structure] ${symHits.length} symbol match for "${raw}":`); console.log(symHits.join("\n")); }
703
+ if (featNames.length) console.log("features: " + featNames.map((n) => `${n} (${data.features[n].length})`).join(", "));
704
+ });
705
+ } else if (symHits.length || featNames.length) {
706
+ out({ command: "any", query: raw, kind: "structure", symbols: symObjs, features: featNames.map((n) => ({ name: n, count: data.features[n].length })) }, () => {
707
+ console.log(`[structure] ${symHits.length} symbol, ${featNames.length} feature match for "${raw}"`);
708
+ if (symHits.length) console.log(symHits.join("\n"));
709
+ if (featNames.length) console.log("features: " + featNames.map((n) => `${n} (${data.features[n].length})`).join(", "));
710
+ });
711
+ } else if (candidates && candidates.length > 1) {
712
+ out({ command: "any", query: raw, kind: "candidates", candidates }, () => {
713
+ console.log(`[structure] "${raw}" matched ${candidates.length} files — narrow it:`);
714
+ for (const k of candidates) console.log(` ${k}`);
715
+ });
716
+ } else {
717
+ const res = contentSearch(raw);
718
+ if (!res) {
719
+ process.exitCode = 1;
720
+ out({ command: "any", query: raw, kind: "empty" }, () => console.log(`[content] 0 match for "${raw}" (git grep, tracked + untracked)`));
721
+ } else {
722
+ const lines = res.split("\n");
723
+ const shown = lines.slice(0, CONTENT_LINES_LIMIT);
724
+ out({ command: "any", query: raw, kind: "content", total: lines.length, lines: shown }, () => {
725
+ console.log(`[content] ${lines.length} line${lines.length > 1 ? "s" : ""}${lines.length > CONTENT_LINES_LIMIT ? ` (showing ${CONTENT_LINES_LIMIT})` : ""}:`);
726
+ console.log(shown.join("\n"));
727
+ });
728
+ }
729
+ }
730
+ }
731
+ } else if (has("--find")) {
732
+ const raw = arg("--find");
733
+ if (!raw) { console.error("--find needs a symbol query, e.g. `--find PremiumCard`"); process.exitCode = 2; }
734
+ else {
735
+ const q = raw.toLowerCase();
736
+ const data = ensureFresh();
737
+ const matches = [];
738
+ for (const [path, f] of Object.entries(data.files))
739
+ for (const e of f.exports)
740
+ if (e.name.toLowerCase().includes(q)) matches.push({ file: path, name: e.name, kind: e.kind });
741
+ if (!matches.length) process.exitCode = 1;
742
+ out({ command: "find", query: raw, matches }, () => {
743
+ console.log(`find "${raw}": ${matches.length} match`);
744
+ if (matches.length) console.log(matches.map((m) => ` ${m.file} → ${m.name} (${m.kind})`).join("\n"));
745
+ });
746
+ }
747
+ } else if (has("--relates")) {
748
+ const q = arg("--relates");
749
+ if (!q) { console.error("--relates needs a file path/name, e.g. `--relates agentmap.mjs`"); process.exitCode = 2; }
750
+ else {
751
+ const data = ensureFresh();
752
+ const keys = Object.keys(data.files);
753
+ const { key, candidates } = resolveFile(keys, data.files, q);
754
+ if (!key) {
755
+ process.exitCode = 1;
756
+ out({ command: "relates", error: "no match", query: q, candidates: candidates || [] }, () => {
757
+ if (candidates && candidates.length > 1) { console.log(`relates: "${q}" matched ${candidates.length} files — narrow it:`); for (const k of candidates) console.log(` ${k}`); }
758
+ else console.log(`relates: no file matching "${q}"`);
759
+ });
760
+ } else {
761
+ const f = data.files[key];
762
+ // query-focused relevance: personalized PageRank (random-walk-with-restart)
763
+ // on a BIDIRECTIONAL graph → files most related to the target, transitively.
764
+ const biEdges = [];
765
+ for (const [p, ff] of Object.entries(data.files))
766
+ 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 }); }
767
+ const rel = pagerank(keys, biEdges, { personalization: { [key]: 1 } });
768
+ const top = Object.entries(rel).filter(([k]) => k !== key).sort((a, b) => b[1] - a[1]).slice(0, RELATED_LIMIT);
769
+ out({ command: "relates", file: key, pagerank: f.pagerank ?? null, exports: f.exports, imports: f.imports, dependents: f.dependents, related: top.map(([file, score]) => ({ file, score: +score.toFixed(6) })) }, () => {
770
+ console.log(`relates: ${key} (pr ${f.pagerank ?? "—"})`);
771
+ fileBlock(key, f);
772
+ console.log(`related (random-walk relevance):`);
773
+ for (const [k, r] of top) console.log(` ${k} (${r.toFixed(4)})`);
774
+ });
775
+ }
776
+ }
777
+ } else if (has("--map")) {
778
+ // Token-budgeted ranked digest (Aider's killer feature). --focus <path>
779
+ // personalizes toward a file; default budget FOCUS_BUDGET, ×8 with no focus.
780
+ const focusArg = arg("--focus");
781
+ // #14: `--focus` present but with NO value (it's the last arg, or another
782
+ // flag follows) — warn + exit 2 instead of silently using the global budget.
783
+ if (has("--focus") && focusArg === undefined) {
784
+ console.error("--focus needs a file path/name, e.g. `--map --focus agentmap.mjs`");
785
+ process.exitCode = 2;
786
+ } else {
787
+ const data = ensureFresh();
788
+ const tk = parseInt(arg("--tokens") ?? "", 10);
789
+ const budget = Number.isFinite(tk) && tk > 0 ? tk : (focusArg ? FOCUS_BUDGET : DEFAULT_BUDGET);
790
+ let ranked = data.rankedSymbols || [];
791
+ let focusLabel = "global";
792
+ if (focusArg) {
793
+ const { key, candidates } = resolveFile(Object.keys(data.files), data.files, focusArg);
794
+ if (key) { ranked = rankSymbols(data.files, new Set([key])); focusLabel = key; }
795
+ else console.error(`# warning: --focus "${focusArg}" matched ${(candidates && candidates.length) || 0} files — using global ranking`);
796
+ }
797
+ // Fallback for default-export-heavy repos (sparse named-symbol graph): build
798
+ // the digest from file PageRank so --map is never empty.
799
+ if (!ranked.length)
800
+ ranked = Object.entries(data.files)
801
+ .sort((a, b) => (b[1].pagerank || 0) - (a[1].pagerank || 0))
802
+ .flatMap(([file, f]) => (f.exports || []).map((e) => ({ file, name: e.name, kind: e.kind, rank: f.pagerank || 0 })));
803
+ // Budget the digest into per-file blocks; collect the SHOWN files (with the
804
+ // exact symbols that fit) so prose + JSON render from one source of truth.
805
+ let used = 0;
806
+ const byFile = new Map();
807
+ for (const s of ranked) { if (!byFile.has(s.file)) byFile.set(s.file, []); byFile.get(s.file).push(s); }
808
+ const shownFiles = []; // [{ file, symbols:[{name,kind}] }]
809
+ let first = true;
810
+ for (const [file, syms] of byFile) {
811
+ const capped = syms.slice(0, SYMS_PER_FILE);
812
+ const lineOf = (arr) => `\n${file}:\n` + arr.map((s) => ` ${s.name} (${s.kind})`).join("\n");
813
+ const t = tokEst(lineOf(capped));
814
+ if (used + t > budget) {
815
+ // #13: if the FIRST (highest-ranked) block alone overruns the budget,
816
+ // emit a PARTIAL block (fewer symbols) so the top file is never wholly
817
+ // omitted. Otherwise `continue` so smaller lower-ranked files can still
818
+ // fill the remaining budget (don't `break` on the first overflow).
819
+ if (first && budget > 0) {
820
+ let partial = capped;
821
+ while (partial.length > 1) {
822
+ partial = partial.slice(0, partial.length - 1);
823
+ const pt = tokEst(lineOf(partial));
824
+ if (used + pt <= budget) { used += pt; shownFiles.push({ file, symbols: partial.map((s) => ({ name: s.name, kind: s.kind })) }); break; }
825
+ }
826
+ first = false;
827
+ }
828
+ continue;
829
+ }
830
+ used += t; first = false;
831
+ shownFiles.push({ file, symbols: capped.map((s) => ({ name: s.name, kind: s.kind })) });
832
+ }
833
+ out({ command: "map", focus: focusLabel, budget, tokens: used, files: shownFiles }, () => {
834
+ console.log(`# agentmap (${data.fileCount} files, sha ${data.generatedSha}) — focus: ${focusLabel}, budget ~${budget} tok`);
835
+ for (const { file, symbols } of shownFiles)
836
+ console.log(`\n${file}:\n` + symbols.map((s) => ` ${s.name} (${s.kind})`).join("\n"));
837
+ console.log(`\n# ~${used} tokens (${shownFiles.length} files shown)`);
838
+ });
839
+ }
840
+ } else if (has("--symbols")) {
841
+ const data = ensureFresh();
842
+ const sn = parseInt(arg("--symbols") ?? "", 10); const n = Number.isFinite(sn) && sn > 0 ? sn : DEFAULT_SYMBOLS;
843
+ const syms = (data.rankedSymbols || []).slice(0, n);
844
+ out({ command: "symbols", symbols: syms.map((s) => ({ rank: s.rank, file: s.file, name: s.name, kind: s.kind })) }, () => {
845
+ console.log(`top ${n} ranked symbols (Aider-style):`);
846
+ for (const s of syms) console.log(` ${s.rank} ${s.file} → ${s.name} (${s.kind})`);
847
+ });
848
+ } else if (has("--feature")) {
849
+ const raw = arg("--feature");
850
+ if (!raw) { console.error("--feature needs a name, e.g. `--feature dashboard` (run --features to list)"); process.exitCode = 2; }
851
+ else {
852
+ const q = raw.toLowerCase();
853
+ const data = ensureFresh();
854
+ const name = Object.keys(data.features).find((k) => k.toLowerCase() === q) || Object.keys(data.features).find((k) => k.toLowerCase().includes(q));
855
+ if (!name) {
856
+ process.exitCode = 1;
857
+ out({ command: "feature", error: "no match", query: raw }, () => console.log(`feature: no match for "${raw}" — run --features to list them.`));
858
+ } else {
859
+ const fl = data.features[name], set = new Set(fl), exts = new Set();
860
+ for (const p of fl) for (const dep of (data.files[p]?.dependents || [])) if (!set.has(dep)) exts.add(dep);
861
+ out({ command: "feature", name, files: fl, externalDependents: [...exts] }, () => {
862
+ console.log(`feature "${name}": ${fl.length} files`);
863
+ for (const p of fl) console.log(` ${p}`);
864
+ console.log(`external dependents (${exts.size}): ${[...exts].join(", ") || "—"}`);
865
+ });
866
+ }
867
+ }
868
+ } else if (has("--features")) {
869
+ const data = ensureFresh();
870
+ const list = Object.entries(data.features).map(([k, v]) => [k, v.length]).sort((a, b) => b[1] - a[1]);
871
+ out({ command: "features", features: Object.fromEntries(list) }, () => {
872
+ console.log(`features (${list.length}):`);
873
+ for (const [k, n] of list) console.log(` ${k} (${n} files)`);
874
+ });
875
+ } else if (has("--hubs")) {
876
+ const data = ensureFresh();
877
+ out({ command: "hubs", fileCount: data.fileCount, sha: data.generatedSha, hubs: data.hubs }, () => {
878
+ console.log(`agentmap: ${data.fileCount} files (sha ${data.generatedSha})`);
879
+ console.log("hubs (PageRank importance):");
880
+ for (const h of data.hubs) console.log(` ${h}`);
881
+ });
882
+ } else if (has("--print")) {
883
+ const data = ensureFresh();
884
+ // --print is already JSON-only; add top-level fileCount (was omitted before).
885
+ console.log(JSON.stringify({ fileCount: data.fileCount, hubs: data.hubs, features: data.features, rankedSymbols: data.rankedSymbols, files: data.files }));
886
+ } else {
887
+ // Bare invocation (possibly `--json` alone): build + one-line summary, or the
888
+ // {command:"build", ...} JSON object.
889
+ const built = build();
890
+ const topHub = built.hubs[0] || null;
891
+ out({ command: "build", fileCount: built.fileCount, features: Object.fromEntries(Object.entries(built.features).map(([k, v]) => [k, v.length])), topHub }, () => {
892
+ console.log(`agentmap: ${built.fileCount} files | ${Object.keys(built.features).length} features | top hub: ${topHub || "—"}`);
893
+ });
894
+ }