@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/NOTICE +24 -0
- package/README.md +75 -47
- package/agentmap.mjs +894 -0
- package/hooks/INSTALL.md +211 -0
- package/hooks/agentmap-nudge.mjs +90 -0
- package/hooks/post-commit +61 -0
- package/mcp.mjs +206 -0
- package/package.json +11 -6
- package/repomap.mjs +0 -461
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
|
+
}
|