@raymondchins/agentmap 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/agentmap.mjs +282 -55
- package/hooks/agentmap-nudge.mjs +13 -5
- package/hooks/post-commit +15 -4
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
# agentmap
|
|
6
6
|
|
|
7
|
-
**The repo map your coding agent is _forced_ to use — ~98% fewer tokens to understand your TS/JS codebase.**
|
|
7
|
+
**The repo map your coding agent is _forced_ to use — ~98% fewer context tokens to understand your TS/JS codebase.**
|
|
8
8
|
|
|
9
9
|
Your AI coding agent re-learns your codebase every session — opening files and grepping to find
|
|
10
10
|
what connects to what, burning tokens before it writes a line. agentmap gives it a **queryable,
|
|
11
11
|
ranked code-relationship map for TypeScript/JavaScript repos** instead — a `ts-morph` import/symbol
|
|
12
12
|
graph ranked by personalized PageRank. Ask it to *"add a field"* or *"fix the login bug"* and it
|
|
13
13
|
finds the right files, their imports, and what already exists in
|
|
14
|
-
**~98% fewer tokens on average** (up to
|
|
14
|
+
**~98% fewer context tokens on average** (up to **~99.9% per task**; figures are chars/4 estimates applied equally to both sides) — kept current by a post-commit
|
|
15
15
|
auto-refresh and actually used via a `PreToolUse(Grep)` hook.
|
|
16
16
|
|
|
17
17
|
[](https://www.npmjs.com/package/@raymondchins/agentmap)
|
|
@@ -60,6 +60,19 @@ across [zod](https://github.com/colinhacks/zod) (367 files, **99.2%**) and
|
|
|
60
60
|
on a single whole-repo map. Reproducible at pinned shas; full per-scenario tables in
|
|
61
61
|
**[`./benchmark/RESULTS.md`](./benchmark/RESULTS.md)**.
|
|
62
62
|
|
|
63
|
+
> **Methodology note:** the 58× overall figure is dominated by the whole-repo-load scenario
|
|
64
|
+
> (Scenario F — 150 K vs 1 K tokens), which skews the combined ratio sharply upward. Excluding it,
|
|
65
|
+
> the per-task overall ratio on the same sample repo is approximately 32×. Both numbers are real;
|
|
66
|
+
> the headline captures the most common agent worst-case (repo-dump on session start), while the
|
|
67
|
+
> per-task average better represents typical individual queries. RESULTS.md has the full breakdown.
|
|
68
|
+
|
|
69
|
+
**Fewer tokens, but are they the _right_ tokens?** Token efficiency is only half the story — a
|
|
70
|
+
separate [`EVAL.md`](./EVAL.md) (`npm run eval`) scores **retrieval accuracy** against ground
|
|
71
|
+
truth derived live from real repos (zod, zustand, hono). Headline: agentmap returns the symbol
|
|
72
|
+
definition in the **top 3 ~95%** of the time (naive grep ~79%) at **~2.6× fewer tokens**, and
|
|
73
|
+
identifies a module's dependents at **~100% precision** (grep ~58%). Honest tradeoffs and method
|
|
74
|
+
in EVAL.md.
|
|
75
|
+
|
|
63
76
|
**Speed:** a cold build (parse + PageRank + symbol graph) takes **~1.2s**; a warm cached query
|
|
64
77
|
returns in **~0.1s** (the lazy-loaded path added in 0.2.2) — the agent has a ranked answer back
|
|
65
78
|
before it would have finished opening the first handful of files.
|
|
@@ -504,7 +517,9 @@ Honesty first — this is deliberately a small, sharp tool, not a universal code
|
|
|
504
517
|
|
|
505
518
|
Issues and PRs welcome. High-value directions:
|
|
506
519
|
|
|
507
|
-
-
|
|
520
|
+
- Retrieval-accuracy eval — **done** ([`EVAL.md`](./EVAL.md), `npm run eval`). Next: a
|
|
521
|
+
type-aware dependents mode (the eval excludes type-only edges to match the value-import
|
|
522
|
+
graph) and an `app/`-router fixture so `--feature` retrieval can be scored too.
|
|
508
523
|
- A real tokenizer behind the `--map` budget.
|
|
509
524
|
- Hardening feature detection for non-`app/`-router layouts.
|
|
510
525
|
|
package/agentmap.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Near-zero deps (ts-morph only). Runs in the target repo's cwd.
|
|
14
14
|
// Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
|
|
15
15
|
// ============================================================================
|
|
16
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, chmodSync } from "node:fs";
|
|
16
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, lstatSync, chmodSync } from "node:fs";
|
|
17
17
|
import { execSync, execFileSync } from "node:child_process";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
@@ -26,8 +26,12 @@ const _require = createRequire(import.meta.url);
|
|
|
26
26
|
let _tsm = null;
|
|
27
27
|
const tsMorph = () => (_tsm ??= _require("ts-morph"));
|
|
28
28
|
|
|
29
|
-
const MAP = ".claude/agentmap.json";
|
|
30
|
-
const
|
|
29
|
+
const MAP = ".claude/agentmap/map.json";
|
|
30
|
+
const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
|
|
31
|
+
// Bumped 2 → 3: Vue SFC support. `.vue` files now appear in the map and the
|
|
32
|
+
// source-discovery / freshness checks treat them as first-class source files.
|
|
33
|
+
// Old caches (schema 2) are ignored so the first run after upgrade rebuilds.
|
|
34
|
+
const SCHEMA_VERSION = 3;
|
|
31
35
|
|
|
32
36
|
// ---------------------------------------------------------------------------
|
|
33
37
|
// Tuning constants — KEEP THESE VALUES IDENTICAL (output + marketing must not
|
|
@@ -58,9 +62,21 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
|
|
|
58
62
|
// untracked files (skips gitignored paths like node_modules). Reads DISK, so
|
|
59
63
|
// never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
|
|
60
64
|
// stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
|
|
65
|
+
// Exclude sensitive files from the --untracked sweep so a local .env / key /
|
|
66
|
+
// secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
|
|
67
|
+
// Mix of path globs (env/key/cert/SSH-key shapes) and case-insensitive name
|
|
68
|
+
// matches (anything *secret* / *credential* / *.password*). These are pathspecs,
|
|
69
|
+
// not regexes — git applies them as exclusions to the search tree.
|
|
70
|
+
const SENSITIVE_EXCLUDES = [
|
|
71
|
+
":!.env", ":!.env.*", ":!**/.env", ":!**/.env.*",
|
|
72
|
+
// also any *.env (e.g. prod.env, .env.local already covered above) at any depth
|
|
73
|
+
":!*.env", ":!**/*.env",
|
|
74
|
+
":!*.pem", ":!*.key", ":!*.p12", ":!*.pfx", ":!*.crt", ":!id_rsa*",
|
|
75
|
+
":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*.password*",
|
|
76
|
+
];
|
|
61
77
|
const contentSearch = (q) => {
|
|
62
78
|
try {
|
|
63
|
-
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap
|
|
79
|
+
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap/", ...SENSITIVE_EXCLUDES], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim();
|
|
64
80
|
} catch { return ""; }
|
|
65
81
|
};
|
|
66
82
|
const currentSha = () => sh("git rev-parse --short HEAD");
|
|
@@ -72,7 +88,7 @@ const dirtyCount = () =>
|
|
|
72
88
|
let p = l.slice(3); // strip "XY " status prefix
|
|
73
89
|
if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
|
|
74
90
|
p = p.replace(/^"|"$/g, ""); // unquote space/special paths
|
|
75
|
-
return /\.(ts|tsx|mjs|cjs|
|
|
91
|
+
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
|
|
76
92
|
}).length;
|
|
77
93
|
const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
|
|
78
94
|
|
|
@@ -83,7 +99,8 @@ const getOrSet = (m, k, make) => { let v = m.get(k); if (v === undefined) { v =
|
|
|
83
99
|
// "path:mtimeMs:size" for source files so the cache can be trusted between runs
|
|
84
100
|
// without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller
|
|
85
101
|
// falls through to build, i.e. current behavior). Never used on the git path.
|
|
86
|
-
|
|
102
|
+
// Includes `.vue` so editing a Vue SFC invalidates the non-git cache too.
|
|
103
|
+
const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
|
|
87
104
|
function sourceFingerprint() {
|
|
88
105
|
try {
|
|
89
106
|
const entries = [];
|
|
@@ -92,7 +109,12 @@ function sourceFingerprint() {
|
|
|
92
109
|
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
93
110
|
const full = dir + "/" + name;
|
|
94
111
|
let st;
|
|
95
|
-
|
|
112
|
+
// lstatSync (NOT statSync) so a symlink reports as a symlink instead of
|
|
113
|
+
// its target. Symlinked entries are SKIPPED entirely — never recursed
|
|
114
|
+
// into, never stat'd through — so a circular symlink can't cause infinite
|
|
115
|
+
// recursion / stack overflow.
|
|
116
|
+
try { st = lstatSync(full); } catch { continue; }
|
|
117
|
+
if (st.isSymbolicLink()) continue;
|
|
96
118
|
if (st.isDirectory()) walk(full);
|
|
97
119
|
else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
|
|
98
120
|
}
|
|
@@ -103,6 +125,70 @@ function sourceFingerprint() {
|
|
|
103
125
|
} catch { return ""; }
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// Vue Single File Component support — best-effort, zero-dependency.
|
|
130
|
+
//
|
|
131
|
+
// agentmap is TS/JS-first. Vue `.vue` SFCs are NOT TypeScript; the Vue compiler
|
|
132
|
+
// (`@vue/compiler-sfc`) is intentionally NOT a dependency (CONTRIBUTING near-
|
|
133
|
+
// zero-deps rule). Instead we extract ONLY the `<script>` / `<script setup>`
|
|
134
|
+
// block text with a conservative regex and feed it to ts-morph as a VIRTUAL
|
|
135
|
+
// source file (e.g. `App.vue.ts`). A virtual→real path map (see build())
|
|
136
|
+
// rewrites every user-facing path back to the real `.vue` path so no
|
|
137
|
+
// `.vue.ts` / `.vue.js` ever leaks into JSON or prose.
|
|
138
|
+
//
|
|
139
|
+
// Non-goals: no template AST, no `<style>` parsing, no Nuxt auto-import
|
|
140
|
+
// resolution, no Svelte/Astro. Only `<script>` blocks that look like JS/TS.
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
// Find the first top-level `<script ...>` block (optionally `<script setup ...>`)
|
|
144
|
+
// whose opening tag does NOT carry `src="..."` (external script reference —
|
|
145
|
+
// the actual JS lives in another file agentmap already indexes on its own).
|
|
146
|
+
// Handles single + double quoted lang/src attributes and `lang="ts"`/`ts`.
|
|
147
|
+
// Returns { lang, setup, text } for the matched block, or null if none.
|
|
148
|
+
//
|
|
149
|
+
// Greedy-free: stops at the FIRST `</script>` on its own. Vue forbids nested
|
|
150
|
+
// `<script>` tags, so a non-greedy match up to `</script>` is safe. We do NOT
|
|
151
|
+
// support `<script>` + `<script setup>` in the same SFC for indexing — we pick
|
|
152
|
+
// the richer one: prefer `setup` block if present, else the normal block.
|
|
153
|
+
function extractVueScripts(text) {
|
|
154
|
+
const blocks = [];
|
|
155
|
+
// Open-tag matcher is QUOTE-AWARE: attribute values may legitimately contain
|
|
156
|
+
// `>` (e.g. `<script setup lang="ts" generic="T extends Record<string, unknown>">`
|
|
157
|
+
// — a common Vue 3 idiom for typed generic components). We require all
|
|
158
|
+
// attributes to be either bare (`setup`) or quoted (`name="value"` or
|
|
159
|
+
// `name='value'`), which matches valid SFC syntax. Bareword and unquoted forms
|
|
160
|
+
// are intentionally not matched because they're not valid HTML and would
|
|
161
|
+
// almost certainly indicate a parsing bug we want to surface, not silently
|
|
162
|
+
// misparse.
|
|
163
|
+
const re = /<script(\s+[a-zA-Z][\w-]*(\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s*\/?>/gi;
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = re.exec(text)) !== null) {
|
|
166
|
+
const attrs = (m[0].slice(7, -1) || "").trim(); // strip <script…> wrapper
|
|
167
|
+
// find body: text after the opening tag up to </script>
|
|
168
|
+
const openEnd = m.index + m[0].length;
|
|
169
|
+
const closeStart = text.toLowerCase().indexOf("</script>", openEnd);
|
|
170
|
+
if (closeStart === -1) break; // unterminated — stop scanning
|
|
171
|
+
const body = text.slice(openEnd, closeStart);
|
|
172
|
+
// external script reference → skip (the target file is indexed directly).
|
|
173
|
+
if (/\bsrc\s*=\s*["'][^"']+["']/i.test(attrs)) continue;
|
|
174
|
+
if (!body.trim()) continue; // empty body (e.g. <script/>) — not useful
|
|
175
|
+
const setup = /\bsetup\b/i.test(attrs);
|
|
176
|
+
const lang = (attrs.match(/\blang\s*=\s*["']([^"']+)["']/i) || [])[1] || "js";
|
|
177
|
+
blocks.push({ lang: lang.toLowerCase(), setup, text: body });
|
|
178
|
+
re.lastIndex = closeStart + "</script>".length; // resume after </script>
|
|
179
|
+
}
|
|
180
|
+
if (!blocks.length) return null;
|
|
181
|
+
// Prefer a setup block (the modern idiom) when present; else the plain block.
|
|
182
|
+
return blocks.find((b) => b.setup) || blocks[0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Virtual file path mapping for a `.vue` source. The virtual path is what
|
|
186
|
+
// ts-morph sees (so `.ts`/`.js` parsing kicks in); the real path is what every
|
|
187
|
+
// user-facing output shows. `lang="ts"` → `.vue.ts`, otherwise `.vue.js`.
|
|
188
|
+
function vueVirtualPath(realPath, lang) {
|
|
189
|
+
return lang === "ts" ? `${realPath}.ts` : `${realPath}.js`;
|
|
190
|
+
}
|
|
191
|
+
|
|
106
192
|
// Feature = first real route segment under app/ (or src/app/), skipping route
|
|
107
193
|
// groups (parens), dynamic segments ([id]) and parallel routes (@slot).
|
|
108
194
|
function featureOf(path) {
|
|
@@ -228,13 +314,25 @@ function makeProject() {
|
|
|
228
314
|
const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
|
|
229
315
|
const cwdp = process.cwd().replace(/\\/g, "/");
|
|
230
316
|
const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
|
|
317
|
+
// `.vue` discovery: same channel as TS/JS (git ls-files when available, else
|
|
318
|
+
// a broad glob fallback). We do NOT hand `.vue` straight to ts-morph (it is
|
|
319
|
+
// not TS/JS). Instead, for each `.vue` file we read its `<script>` block via
|
|
320
|
+
// extractVueScripts() and register it as a VIRTUAL source file
|
|
321
|
+
// (`App.vue.ts` / `App.vue.js`). A virtual→real path map is returned alongside
|
|
322
|
+
// the project so build() can rewrite every user-facing path back to `.vue`.
|
|
323
|
+
const vueFiles = [];
|
|
231
324
|
if (listed.length) {
|
|
232
325
|
const missing = [];
|
|
233
326
|
for (const f of listed) {
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
327
|
+
if (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) {
|
|
328
|
+
const segs = f.split("/");
|
|
329
|
+
if (segs.includes("node_modules") || segs.includes(".next")) continue;
|
|
330
|
+
if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
|
|
331
|
+
} else if (f.endsWith(".vue")) {
|
|
332
|
+
const segs = f.split("/");
|
|
333
|
+
if (segs.includes("node_modules") || segs.includes(".next")) continue;
|
|
334
|
+
vueFiles.push(f);
|
|
335
|
+
}
|
|
238
336
|
}
|
|
239
337
|
if (missing.length) project.addSourceFilesAtPaths(missing);
|
|
240
338
|
} else {
|
|
@@ -244,8 +342,38 @@ function makeProject() {
|
|
|
244
342
|
"components/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "lib/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
|
245
343
|
"pages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
|
246
344
|
]);
|
|
345
|
+
// Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
|
|
346
|
+
try {
|
|
347
|
+
const walk = (dir) => {
|
|
348
|
+
for (const name of readdirSync(dir)) {
|
|
349
|
+
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
350
|
+
const full = dir + "/" + name;
|
|
351
|
+
// lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
|
|
352
|
+
// a circular symlink would otherwise recurse until the stack overflows.
|
|
353
|
+
let st; try { st = lstatSync(full); } catch { continue; }
|
|
354
|
+
if (st.isSymbolicLink()) continue;
|
|
355
|
+
if (st.isDirectory()) walk(full);
|
|
356
|
+
else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
walk(".");
|
|
360
|
+
} catch { /* ignore — proceed without Vue */ }
|
|
247
361
|
}
|
|
248
|
-
|
|
362
|
+
// Build the virtual→real map and register each `<script>` block as a virtual
|
|
363
|
+
// ts-morph source. Files without a usable `<script>` block are silently
|
|
364
|
+
// skipped (template/style-only SFCs contribute nothing to the import graph).
|
|
365
|
+
const vueMap = Object.create(null); // virtualPath → realPath
|
|
366
|
+
const vueReal = Object.create(null); // realPath → true (for resolver)
|
|
367
|
+
for (const f of vueFiles) {
|
|
368
|
+
let text; try { text = readFileSync(f, "utf8"); } catch { continue; }
|
|
369
|
+
const block = extractVueScripts(text);
|
|
370
|
+
if (!block || !block.text.trim()) continue;
|
|
371
|
+
const vpath = vueVirtualPath(f, block.lang);
|
|
372
|
+
project.createSourceFile(`${cwdp}/${vpath}`, block.text, { overwrite: true });
|
|
373
|
+
vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
|
|
374
|
+
vueReal[`${cwdp}/${f}`] = true;
|
|
375
|
+
}
|
|
376
|
+
return { project, vueMap, vueReal };
|
|
249
377
|
}
|
|
250
378
|
|
|
251
379
|
// ---------------------------------------------------------------------------
|
|
@@ -255,11 +383,18 @@ function makeProject() {
|
|
|
255
383
|
// ---------------------------------------------------------------------------
|
|
256
384
|
function build() {
|
|
257
385
|
const t0 = Date.now();
|
|
258
|
-
const project = makeProject();
|
|
386
|
+
const { project, vueMap, vueReal } = makeProject();
|
|
259
387
|
const { SyntaxKind } = tsMorph();
|
|
260
388
|
const CallExpression = SyntaxKind.CallExpression;
|
|
261
389
|
const cwd = process.cwd().replace(/\\/g, "/");
|
|
262
|
-
|
|
390
|
+
// rel() rewrites ts-morph file paths to repo-relative keys. For Vue virtual
|
|
391
|
+
// sources (`App.vue.ts`), vueMap rewrites back to the real `.vue` path so
|
|
392
|
+
// users never see virtual paths in the map, hubs, --relates, or --find.
|
|
393
|
+
const rel = (p) => {
|
|
394
|
+
const abs = p.replace(/\\/g, "/");
|
|
395
|
+
const real = vueMap[abs];
|
|
396
|
+
return (real || abs).replace(cwd + "/", "");
|
|
397
|
+
};
|
|
263
398
|
const files = {}, dependents = {}, features = {};
|
|
264
399
|
// PATH-SEGMENT exclusion (not substring) so e.g. components/.next-demo or
|
|
265
400
|
// src/node_modules_helper.ts are NOT wrongly excluded.
|
|
@@ -280,10 +415,19 @@ function build() {
|
|
|
280
415
|
};
|
|
281
416
|
const baseAbs = join(fromAbsDir, spec);
|
|
282
417
|
const tryGet = (abs) => { const sf = project.getSourceFile(abs); return sf ? sf : null; };
|
|
418
|
+
// Vue SFC: `import X from "./C.vue"` (exact) ALWAYS wins — the user wrote
|
|
419
|
+
// `.vue` explicitly, so we honor that. This check must stay BEFORE the
|
|
420
|
+
// TS/JS loop.
|
|
421
|
+
if (vueReal[baseAbs]) return rel(baseAbs);
|
|
283
422
|
let sf = tryGet(baseAbs);
|
|
284
423
|
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}.${e}`); if (sf) break; }
|
|
285
424
|
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}/index.${e}`); if (sf) break; }
|
|
286
|
-
|
|
425
|
+
// TS/JS SHADOW WINS: when a same-name .ts/.js exists, the extensionless
|
|
426
|
+
// `import "./C"` resolves to it (TS/JS-first priority is preserved). Only
|
|
427
|
+
// fall through to `.vue` as a last resort, when no TS/JS shadow exists.
|
|
428
|
+
if (sf) return rel(sf.getFilePath());
|
|
429
|
+
if (vueReal[`${baseAbs}.vue`]) return rel(`${baseAbs}.vue`);
|
|
430
|
+
return null;
|
|
287
431
|
};
|
|
288
432
|
|
|
289
433
|
const sourceFiles = project.getSourceFiles();
|
|
@@ -394,9 +538,9 @@ function build() {
|
|
|
394
538
|
fingerprint: sha ? undefined : sourceFingerprint(),
|
|
395
539
|
hubs, features, rankedSymbols: rankedSymbols.slice(0, RANKED_SYMBOLS_LIMIT), files,
|
|
396
540
|
};
|
|
397
|
-
mkdirSync(".claude", { recursive: true });
|
|
541
|
+
mkdirSync(".claude/agentmap", { recursive: true });
|
|
398
542
|
// Atomic write: tmp + rename so a concurrent background rebuild can never
|
|
399
|
-
// expose a torn/truncated
|
|
543
|
+
// expose a torn/truncated map.json to a reader.
|
|
400
544
|
const tmp = MAP + ".tmp";
|
|
401
545
|
writeFileSync(tmp, JSON.stringify(out));
|
|
402
546
|
renameSync(tmp, MAP);
|
|
@@ -500,9 +644,13 @@ function rankSymbols(files, focus) {
|
|
|
500
644
|
// clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
|
|
501
645
|
function ensureFresh() {
|
|
502
646
|
const sha = currentSha();
|
|
503
|
-
|
|
647
|
+
// Read the namespaced path; fall back to the legacy '.claude/agentmap.json'
|
|
648
|
+
// when the new path is missing (migration from a pre-namespacing install — the
|
|
649
|
+
// legacy file is still trustworthy, the next build() rewrites to the new path).
|
|
650
|
+
const mapPath = existsSync(MAP) ? MAP : (existsSync(MAP_LEGACY) ? MAP_LEGACY : MAP);
|
|
651
|
+
if (existsSync(mapPath)) {
|
|
504
652
|
try {
|
|
505
|
-
const cached = JSON.parse(readFileSync(
|
|
653
|
+
const cached = JSON.parse(readFileSync(mapPath, "utf8"));
|
|
506
654
|
// Trust cache only if: same HEAD, known schema, it was built CLEAN
|
|
507
655
|
// (cached.dirty === 0 — never trust a map built mid-edit, even after a
|
|
508
656
|
// revert returns the tree to clean), AND the tree is clean right now.
|
|
@@ -542,55 +690,95 @@ function fileBlock(key, f) {
|
|
|
542
690
|
console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
|
|
543
691
|
}
|
|
544
692
|
|
|
693
|
+
// Strip // line comments and /* */ block comments from a JSONC string WITHOUT
|
|
694
|
+
// touching comment-like sequences inside double-quoted strings (so a value like
|
|
695
|
+
// "https://x" or "a /* b */ c" is preserved verbatim). Single-pass state machine:
|
|
696
|
+
// tracks whether we're inside a string (and an escape inside it) vs a line/block
|
|
697
|
+
// comment. Trailing commas are NOT handled — only comments, which is what real-
|
|
698
|
+
// world settings.json files carry.
|
|
699
|
+
function stripJsonComments(src) {
|
|
700
|
+
let out = "";
|
|
701
|
+
let inStr = false, esc = false, inLine = false, inBlock = false;
|
|
702
|
+
for (let i = 0; i < src.length; i++) {
|
|
703
|
+
const c = src[i], n = src[i + 1];
|
|
704
|
+
if (inLine) { if (c === "\n") { inLine = false; out += c; } continue; }
|
|
705
|
+
if (inBlock) { if (c === "*" && n === "/") { inBlock = false; i++; } continue; }
|
|
706
|
+
if (inStr) {
|
|
707
|
+
out += c;
|
|
708
|
+
if (esc) esc = false;
|
|
709
|
+
else if (c === "\\") esc = true;
|
|
710
|
+
else if (c === '"') inStr = false;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (c === '"') { inStr = true; out += c; continue; }
|
|
714
|
+
if (c === "/" && n === "/") { inLine = true; i++; continue; }
|
|
715
|
+
if (c === "/" && n === "*") { inBlock = true; i++; continue; }
|
|
716
|
+
out += c;
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Parse a settings.json that may be JSONC: try strict JSON first, then retry
|
|
722
|
+
// after stripping comments, and only then surface the caller's clear error.
|
|
723
|
+
function parseSettings(text, settingsPath) {
|
|
724
|
+
try { return JSON.parse(text) || {}; }
|
|
725
|
+
catch {
|
|
726
|
+
try { return JSON.parse(stripJsonComments(text)) || {}; }
|
|
727
|
+
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
545
731
|
// ---------------------------------------------------------------------------
|
|
546
|
-
// --install-hooks: copy the package post-commit hook into .git/hooks,
|
|
547
|
-
// .claude/agentmap.
|
|
548
|
-
// PreToolUse(Grep|Bash) nudge into
|
|
549
|
-
// enforcement is ON by default (no
|
|
550
|
-
//
|
|
732
|
+
// --install-hooks: copy the package post-commit hook into .git/hooks, copy the
|
|
733
|
+
// PreToolUse nudge into .claude/hooks/agentmap-nudge.mjs, ensure .claude/agentmap/
|
|
734
|
+
// is gitignored, and auto-wire the Claude Code PreToolUse(Grep|Bash) nudge into
|
|
735
|
+
// the project's .claude/settings.json so map enforcement is ON by default (no
|
|
736
|
+
// manual copy-paste). Merge-safe + idempotent. With { dryRun:true } it prints the
|
|
737
|
+
// files it WOULD touch and writes nothing. Throws on any failure so the caller
|
|
738
|
+
// can stderr+exit 1.
|
|
551
739
|
// ---------------------------------------------------------------------------
|
|
552
|
-
function installHooks() {
|
|
740
|
+
function installHooks({ dryRun = false } = {}) {
|
|
553
741
|
const src = new URL("./hooks/post-commit", import.meta.url);
|
|
554
742
|
// The package hooks/ dir must ship alongside agentmap.mjs.
|
|
555
743
|
if (!existsSync(src)) throw new Error(`packaged hook not found at ${src.pathname} (is the hooks/ dir present?)`);
|
|
744
|
+
// The PreToolUse nudge that gets COPIED into the project (see below). It must
|
|
745
|
+
// ship alongside agentmap.mjs too.
|
|
746
|
+
const nudgeSrc = new URL("./hooks/agentmap-nudge.mjs", import.meta.url);
|
|
747
|
+
if (!existsSync(nudgeSrc)) throw new Error(`packaged nudge not found at ${nudgeSrc.pathname} (is the hooks/ dir present?)`);
|
|
556
748
|
|
|
557
749
|
// Locate the git dir of the CURRENT repo (cwd), then copy in the hook.
|
|
558
750
|
const gitDir = sh("git rev-parse --git-dir");
|
|
559
751
|
if (!gitDir) throw new Error("not a git repository (cwd has no .git) — run inside the repo you want to wire up");
|
|
560
752
|
const hooksDir = `${gitDir}/hooks`;
|
|
561
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
562
753
|
const dest = `${hooksDir}/post-commit`;
|
|
563
|
-
writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
|
|
564
|
-
chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
|
|
565
754
|
|
|
566
|
-
//
|
|
567
|
-
|
|
755
|
+
// The nudge is copied into the PROJECT (not referenced inside node_modules) so
|
|
756
|
+
// the documented one-liner `npx @raymondchins/agentmap --install-hooks` works
|
|
757
|
+
// even though npx never populates ./node_modules — the old path
|
|
758
|
+
// node_modules/@raymondchins/agentmap/hooks/agentmap-nudge.mjs simply does not
|
|
759
|
+
// exist after an npx install, so the hook silently never fired. The nudge is
|
|
760
|
+
// self-contained (Node stdlib only, no relative package imports), so copying it
|
|
761
|
+
// standalone is safe. CLAUDE_PROJECT_DIR is set by Claude Code at hook time, so
|
|
762
|
+
// the wired command resolves the copied file regardless of cwd.
|
|
763
|
+
const nudgeDestRel = ".claude/hooks/agentmap-nudge.mjs";
|
|
764
|
+
const NUDGE_CMD = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/agentmap-nudge.mjs"`;
|
|
765
|
+
|
|
766
|
+
// .gitignore line: ignore the namespaced map DIR (not the legacy single file).
|
|
767
|
+
const IGNORE_LINE = ".claude/agentmap/";
|
|
768
|
+
const settingsPath = ".claude/settings.json";
|
|
769
|
+
|
|
770
|
+
// --- Determine what WOULD change (so --dry-run and the pre-write notice both
|
|
771
|
+
// describe the real plan). ---
|
|
568
772
|
let ignoredAlready = false;
|
|
569
773
|
if (existsSync(".gitignore")) {
|
|
570
|
-
|
|
571
|
-
if (cur.split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE)) ignoredAlready = true;
|
|
572
|
-
else writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
|
|
573
|
-
} else {
|
|
574
|
-
writeFileSync(".gitignore", IGNORE_LINE + "\n");
|
|
774
|
+
ignoredAlready = readFileSync(".gitignore", "utf8").split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE);
|
|
575
775
|
}
|
|
576
|
-
|
|
577
|
-
// Auto-wire the PreToolUse(Grep|Bash) enforcement nudge into the PROJECT
|
|
578
|
-
// settings (.claude/settings.json) so "the agent is forced to use the map"
|
|
579
|
-
// is ON by default — not a manual paste. Merge-safe + idempotent: preserves
|
|
580
|
-
// any existing settings/hooks, never duplicates our entry. Uses a project-
|
|
581
|
-
// relative command so a committed settings.json stays portable across machines.
|
|
582
|
-
// Both the Grep tool AND raw Bash searchers (grep/rg/ag/ack) are covered by
|
|
583
|
-
// a single hook file — the nudge routes internally based on tool_name.
|
|
584
|
-
const NUDGE_CMD = "node node_modules/@raymondchins/agentmap/hooks/agentmap-nudge.mjs";
|
|
585
|
-
const settingsPath = ".claude/settings.json";
|
|
586
776
|
let settings = {};
|
|
587
777
|
if (existsSync(settingsPath)) {
|
|
588
|
-
|
|
589
|
-
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
|
|
778
|
+
settings = parseSettings(readFileSync(settingsPath, "utf8"), settingsPath);
|
|
590
779
|
}
|
|
591
780
|
settings.hooks ??= {};
|
|
592
781
|
settings.hooks.PreToolUse ??= [];
|
|
593
|
-
// Check whether BOTH matchers are already present.
|
|
594
782
|
const hasGrep = settings.hooks.PreToolUse.some(
|
|
595
783
|
(e) => e?.matcher === "Grep" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
|
|
596
784
|
);
|
|
@@ -598,12 +786,48 @@ function installHooks() {
|
|
|
598
786
|
(e) => e?.matcher === "Bash" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
|
|
599
787
|
);
|
|
600
788
|
const alreadyWired = hasGrep && hasBash;
|
|
601
|
-
|
|
602
|
-
|
|
789
|
+
|
|
790
|
+
// The set of files this run touches — used by both the dry-run report and the
|
|
791
|
+
// one-line pre-write notice in the normal path.
|
|
792
|
+
const targets = [
|
|
793
|
+
dest, // .git/hooks/post-commit
|
|
794
|
+
nudgeDestRel, // .claude/hooks/agentmap-nudge.mjs
|
|
795
|
+
...(ignoredAlready ? [] : [".gitignore"]), // only if the ignore line is missing
|
|
796
|
+
...(alreadyWired ? [] : [settingsPath]), // only if not already wired
|
|
797
|
+
];
|
|
798
|
+
|
|
799
|
+
if (dryRun) {
|
|
800
|
+
console.log("--dry-run: would create/overwrite the following files (no changes written):");
|
|
801
|
+
for (const t of targets) console.log(` ${t}`);
|
|
802
|
+
return;
|
|
603
803
|
}
|
|
604
|
-
|
|
605
|
-
|
|
804
|
+
|
|
805
|
+
// Normal path: announce the plan, then write.
|
|
806
|
+
console.log(`agentmap --install-hooks: writing ${targets.length} file(s): ${targets.join(", ")}`);
|
|
807
|
+
|
|
808
|
+
// 1) post-commit hook → .git/hooks
|
|
809
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
810
|
+
writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
|
|
811
|
+
chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
|
|
812
|
+
|
|
813
|
+
// 2) nudge → .claude/hooks/agentmap-nudge.mjs (idempotent overwrite-on-rerun)
|
|
814
|
+
mkdirSync(".claude/hooks", { recursive: true });
|
|
815
|
+
writeFileSync(nudgeDestRel, readFileSync(nudgeSrc, "utf8"));
|
|
816
|
+
|
|
817
|
+
// 3) .gitignore: ignore the namespaced map dir (append/create).
|
|
818
|
+
if (!ignoredAlready) {
|
|
819
|
+
if (existsSync(".gitignore")) {
|
|
820
|
+
const cur = readFileSync(".gitignore", "utf8");
|
|
821
|
+
writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
|
|
822
|
+
} else {
|
|
823
|
+
writeFileSync(".gitignore", IGNORE_LINE + "\n");
|
|
824
|
+
}
|
|
606
825
|
}
|
|
826
|
+
|
|
827
|
+
// 4) Auto-wire the PreToolUse(Grep|Bash) nudge into project settings. Merge-
|
|
828
|
+
// safe + idempotent: preserves existing settings/hooks, never duplicates ours.
|
|
829
|
+
if (!hasGrep) settings.hooks.PreToolUse.push({ matcher: "Grep", hooks: [{ type: "command", command: NUDGE_CMD }] });
|
|
830
|
+
if (!hasBash) settings.hooks.PreToolUse.push({ matcher: "Bash", hooks: [{ type: "command", command: NUDGE_CMD }] });
|
|
607
831
|
if (!alreadyWired) {
|
|
608
832
|
mkdirSync(".claude", { recursive: true });
|
|
609
833
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
@@ -611,6 +835,7 @@ function installHooks() {
|
|
|
611
835
|
|
|
612
836
|
// Success report.
|
|
613
837
|
console.log(`installed post-commit hook → ${dest}`);
|
|
838
|
+
console.log(`installed PreToolUse nudge → ${nudgeDestRel}`);
|
|
614
839
|
console.log(ignoredAlready ? `.gitignore already has ${IGNORE_LINE}` : `added ${IGNORE_LINE} to .gitignore`);
|
|
615
840
|
console.log(alreadyWired
|
|
616
841
|
? `${settingsPath} already wires the PreToolUse(Grep|Bash) → agentmap nudge — left as-is`
|
|
@@ -640,7 +865,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
|
|
|
640
865
|
// NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
|
|
641
866
|
const KNOWN = new Set([
|
|
642
867
|
"--json", "--print",
|
|
643
|
-
"--help", "-h", "--version", "-v", "--install-hooks", "--mcp",
|
|
868
|
+
"--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
|
|
644
869
|
"--any", "--find", "--relates", "--map", "--focus", "--tokens",
|
|
645
870
|
"--symbols", "--feature", "--features", "--hubs",
|
|
646
871
|
]);
|
|
@@ -674,7 +899,9 @@ Global modifier:
|
|
|
674
899
|
--json emit exactly one JSON object (no prose) for the command
|
|
675
900
|
|
|
676
901
|
Maintenance:
|
|
677
|
-
--install-hooks
|
|
902
|
+
--install-hooks [--dry-run]
|
|
903
|
+
install git post-commit + copy the PreToolUse nudge +
|
|
904
|
+
wire .claude/settings.json (--dry-run = preview, no writes)
|
|
678
905
|
--mcp start a stdio MCP server (for MCP-capable agents)
|
|
679
906
|
--help, -h show this help
|
|
680
907
|
--version, -v print the version
|
|
@@ -707,7 +934,7 @@ if (has("--mcp")) {
|
|
|
707
934
|
// --install-hooks: wire the git post-commit refresh + emit the PreToolUse
|
|
708
935
|
// snippet. Self-contained (resolves the package hooks/ dir relative to here).
|
|
709
936
|
else if (has("--install-hooks")) {
|
|
710
|
-
try { installHooks(); process.exit(0); }
|
|
937
|
+
try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
|
|
711
938
|
catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
|
|
712
939
|
}
|
|
713
940
|
// Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
|
package/hooks/agentmap-nudge.mjs
CHANGED
|
@@ -96,11 +96,19 @@ process.stdin.on("end", () => {
|
|
|
96
96
|
// hunts.
|
|
97
97
|
const SEARCHER_RE = /(^|[;&]\s*)(rg|ripgrep|grep|egrep|fgrep|ag|ack)\b/;
|
|
98
98
|
if (SEARCHER_RE.test(cmd)) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
// Guard: if any operand token references a non-source data file, stay
|
|
100
|
+
// silent — e.g. `rg TypeError app.log` is log-filtering, not a
|
|
101
|
+
// symbol/component hunt. Match on extension only (not inside quoted
|
|
102
|
+
// patterns) by scanning whitespace-separated tokens for a data-file ext.
|
|
103
|
+
const DATA_FILE_RE = /\.(log|txt|out|csv|tsv|jsonl|ndjson|json|md|ya?ml|xml)(\b|$)/i;
|
|
104
|
+
const hasDataFileTarget = cmd.split(/\s+/).some((tok) => DATA_FILE_RE.test(tok));
|
|
105
|
+
if (!hasDataFileTarget) {
|
|
106
|
+
fire =
|
|
107
|
+
DEP_RE.test(cmd) ||
|
|
108
|
+
(COMPONENT_TAG_RE.test(cmd) && !GENERIC_DENYLIST.test(cmd)) ||
|
|
109
|
+
INTENT_RE.test(cmd) ||
|
|
110
|
+
SYMBOL_RE.test(cmd);
|
|
111
|
+
}
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
|
package/hooks/post-commit
CHANGED
|
@@ -28,15 +28,26 @@ for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG RE
|
|
|
28
28
|
fi
|
|
29
29
|
done
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Runner resolution — security rationale:
|
|
33
|
+
#
|
|
34
|
+
# Only TWO repo-local paths are trusted: ./agentmap.mjs (this repo's own
|
|
35
|
+
# dogfooding entry-point, reviewed and version-controlled at the root) and
|
|
36
|
+
# nothing else. ./scripts/agentmap.mjs was removed because it is an unusual
|
|
37
|
+
# path that a malicious PR could introduce to gain arbitrary code execution
|
|
38
|
+
# on a victim's next commit without touching any obviously sensitive file.
|
|
39
|
+
#
|
|
40
|
+
# Set AGENTMAP_HOOK_NO_LOCAL=1 to skip even ./agentmap.mjs and rely solely
|
|
41
|
+
# on the installed binary / npx — useful in CI or when reviewing untrusted
|
|
42
|
+
# branches.
|
|
43
|
+
#
|
|
32
44
|
# We cd "$ROOT" before running, so use RELATIVE paths — avoids word-splitting on
|
|
33
45
|
# spaces in the repo path (POSIX sh has no arrays; quoting $RUNNER at invocation
|
|
34
46
|
# would bundle cmd+args into one token and break argument passing).
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
35
48
|
RUNNER=""
|
|
36
|
-
if [ -f "$ROOT/agentmap.mjs" ]; then
|
|
49
|
+
if [ -z "$AGENTMAP_HOOK_NO_LOCAL" ] && [ -f "$ROOT/agentmap.mjs" ]; then
|
|
37
50
|
RUNNER="node ./agentmap.mjs"
|
|
38
|
-
elif [ -f "$ROOT/scripts/agentmap.mjs" ]; then
|
|
39
|
-
RUNNER="node ./scripts/agentmap.mjs"
|
|
40
51
|
elif command -v agentmap >/dev/null 2>&1; then
|
|
41
52
|
# Bare binary name — the installed binary is named `agentmap` (no scope).
|
|
42
53
|
RUNNER="agentmap"
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raymondchins/agentmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "The repo map your coding agent is forced to use — ~98% fewer tokens (up to 99.9% per task) to understand a TS/JS codebase. A queryable, ranked ts-morph code-relationship map: PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
|
|
7
|
+
"description": "The repo map your coding agent is forced to use — estimated ~98% fewer context tokens (up to ~99.9% per task, chars/4 estimate) to understand a TS/JS codebase. A queryable, ranked ts-morph code-relationship map: PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
10
|
"agentmap": "agentmap.mjs"
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"map": "node agentmap.mjs",
|
|
21
|
-
"test": "node --test test/*.test.mjs"
|
|
21
|
+
"test": "node --test test/*.test.mjs test/**/*.test.mjs",
|
|
22
|
+
"eval": "node eval/eval.mjs"
|
|
22
23
|
},
|
|
23
24
|
"engines": {
|
|
24
25
|
"node": ">=18"
|