@raymondchins/agentmap 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 **99.9% per task**) — kept current by a post-commit
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
  [![npm](https://img.shields.io/npm/v/@raymondchins/agentmap)](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
- - An end-to-end retrieval/accuracy eval (the benchmark is context-efficiency only today).
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,7 +26,8 @@ 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";
29
+ const MAP = ".claude/agentmap/map.json";
30
+ const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
30
31
  const SCHEMA_VERSION = 2;
31
32
 
32
33
  // ---------------------------------------------------------------------------
@@ -58,9 +59,21 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
58
59
  // untracked files (skips gitignored paths like node_modules). Reads DISK, so
59
60
  // never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
60
61
  // stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
62
+ // Exclude sensitive files from the --untracked sweep so a local .env / key /
63
+ // secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
64
+ // Mix of path globs (env/key/cert/SSH-key shapes) and case-insensitive name
65
+ // matches (anything *secret* / *credential* / *.password*). These are pathspecs,
66
+ // not regexes — git applies them as exclusions to the search tree.
67
+ const SENSITIVE_EXCLUDES = [
68
+ ":!.env", ":!.env.*", ":!**/.env", ":!**/.env.*",
69
+ // also any *.env (e.g. prod.env, .env.local already covered above) at any depth
70
+ ":!*.env", ":!**/*.env",
71
+ ":!*.pem", ":!*.key", ":!*.p12", ":!*.pfx", ":!*.crt", ":!id_rsa*",
72
+ ":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*.password*",
73
+ ];
61
74
  const contentSearch = (q) => {
62
75
  try {
63
- return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap.json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim();
76
+ 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
77
  } catch { return ""; }
65
78
  };
66
79
  const currentSha = () => sh("git rev-parse --short HEAD");
@@ -92,7 +105,12 @@ function sourceFingerprint() {
92
105
  if (name === "node_modules" || name === ".git" || name === ".next") continue;
93
106
  const full = dir + "/" + name;
94
107
  let st;
95
- try { st = statSync(full); } catch { continue; }
108
+ // lstatSync (NOT statSync) so a symlink reports as a symlink instead of
109
+ // its target. Symlinked entries are SKIPPED entirely — never recursed
110
+ // into, never stat'd through — so a circular symlink can't cause infinite
111
+ // recursion / stack overflow.
112
+ try { st = lstatSync(full); } catch { continue; }
113
+ if (st.isSymbolicLink()) continue;
96
114
  if (st.isDirectory()) walk(full);
97
115
  else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
98
116
  }
@@ -394,9 +412,9 @@ function build() {
394
412
  fingerprint: sha ? undefined : sourceFingerprint(),
395
413
  hubs, features, rankedSymbols: rankedSymbols.slice(0, RANKED_SYMBOLS_LIMIT), files,
396
414
  };
397
- mkdirSync(".claude", { recursive: true });
415
+ mkdirSync(".claude/agentmap", { recursive: true });
398
416
  // Atomic write: tmp + rename so a concurrent background rebuild can never
399
- // expose a torn/truncated agentmap.json to a reader.
417
+ // expose a torn/truncated map.json to a reader.
400
418
  const tmp = MAP + ".tmp";
401
419
  writeFileSync(tmp, JSON.stringify(out));
402
420
  renameSync(tmp, MAP);
@@ -500,9 +518,13 @@ function rankSymbols(files, focus) {
500
518
  // clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
501
519
  function ensureFresh() {
502
520
  const sha = currentSha();
503
- if (existsSync(MAP)) {
521
+ // Read the namespaced path; fall back to the legacy '.claude/agentmap.json'
522
+ // when the new path is missing (migration from a pre-namespacing install — the
523
+ // legacy file is still trustworthy, the next build() rewrites to the new path).
524
+ const mapPath = existsSync(MAP) ? MAP : (existsSync(MAP_LEGACY) ? MAP_LEGACY : MAP);
525
+ if (existsSync(mapPath)) {
504
526
  try {
505
- const cached = JSON.parse(readFileSync(MAP, "utf8"));
527
+ const cached = JSON.parse(readFileSync(mapPath, "utf8"));
506
528
  // Trust cache only if: same HEAD, known schema, it was built CLEAN
507
529
  // (cached.dirty === 0 — never trust a map built mid-edit, even after a
508
530
  // revert returns the tree to clean), AND the tree is clean right now.
@@ -542,55 +564,95 @@ function fileBlock(key, f) {
542
564
  console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
543
565
  }
544
566
 
567
+ // Strip // line comments and /* */ block comments from a JSONC string WITHOUT
568
+ // touching comment-like sequences inside double-quoted strings (so a value like
569
+ // "https://x" or "a /* b */ c" is preserved verbatim). Single-pass state machine:
570
+ // tracks whether we're inside a string (and an escape inside it) vs a line/block
571
+ // comment. Trailing commas are NOT handled — only comments, which is what real-
572
+ // world settings.json files carry.
573
+ function stripJsonComments(src) {
574
+ let out = "";
575
+ let inStr = false, esc = false, inLine = false, inBlock = false;
576
+ for (let i = 0; i < src.length; i++) {
577
+ const c = src[i], n = src[i + 1];
578
+ if (inLine) { if (c === "\n") { inLine = false; out += c; } continue; }
579
+ if (inBlock) { if (c === "*" && n === "/") { inBlock = false; i++; } continue; }
580
+ if (inStr) {
581
+ out += c;
582
+ if (esc) esc = false;
583
+ else if (c === "\\") esc = true;
584
+ else if (c === '"') inStr = false;
585
+ continue;
586
+ }
587
+ if (c === '"') { inStr = true; out += c; continue; }
588
+ if (c === "/" && n === "/") { inLine = true; i++; continue; }
589
+ if (c === "/" && n === "*") { inBlock = true; i++; continue; }
590
+ out += c;
591
+ }
592
+ return out;
593
+ }
594
+
595
+ // Parse a settings.json that may be JSONC: try strict JSON first, then retry
596
+ // after stripping comments, and only then surface the caller's clear error.
597
+ function parseSettings(text, settingsPath) {
598
+ try { return JSON.parse(text) || {}; }
599
+ catch {
600
+ try { return JSON.parse(stripJsonComments(text)) || {}; }
601
+ catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
602
+ }
603
+ }
604
+
545
605
  // ---------------------------------------------------------------------------
546
- // --install-hooks: copy the package post-commit hook into .git/hooks, ensure
547
- // .claude/agentmap.json is gitignored, and auto-wire the Claude Code
548
- // PreToolUse(Grep|Bash) nudge into the project's .claude/settings.json so map
549
- // enforcement is ON by default (no manual copy-paste). Merge-safe + idempotent.
550
- // Throws on any failure so the caller can stderr+exit 1.
606
+ // --install-hooks: copy the package post-commit hook into .git/hooks, copy the
607
+ // PreToolUse nudge into .claude/hooks/agentmap-nudge.mjs, ensure .claude/agentmap/
608
+ // is gitignored, and auto-wire the Claude Code PreToolUse(Grep|Bash) nudge into
609
+ // the project's .claude/settings.json so map enforcement is ON by default (no
610
+ // manual copy-paste). Merge-safe + idempotent. With { dryRun:true } it prints the
611
+ // files it WOULD touch and writes nothing. Throws on any failure so the caller
612
+ // can stderr+exit 1.
551
613
  // ---------------------------------------------------------------------------
552
- function installHooks() {
614
+ function installHooks({ dryRun = false } = {}) {
553
615
  const src = new URL("./hooks/post-commit", import.meta.url);
554
616
  // The package hooks/ dir must ship alongside agentmap.mjs.
555
617
  if (!existsSync(src)) throw new Error(`packaged hook not found at ${src.pathname} (is the hooks/ dir present?)`);
618
+ // The PreToolUse nudge that gets COPIED into the project (see below). It must
619
+ // ship alongside agentmap.mjs too.
620
+ const nudgeSrc = new URL("./hooks/agentmap-nudge.mjs", import.meta.url);
621
+ if (!existsSync(nudgeSrc)) throw new Error(`packaged nudge not found at ${nudgeSrc.pathname} (is the hooks/ dir present?)`);
556
622
 
557
623
  // Locate the git dir of the CURRENT repo (cwd), then copy in the hook.
558
624
  const gitDir = sh("git rev-parse --git-dir");
559
625
  if (!gitDir) throw new Error("not a git repository (cwd has no .git) — run inside the repo you want to wire up");
560
626
  const hooksDir = `${gitDir}/hooks`;
561
- mkdirSync(hooksDir, { recursive: true });
562
627
  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
628
 
566
- // Ensure .gitignore (in cwd) contains the derived-map line (append/create).
567
- const IGNORE_LINE = ".claude/agentmap.json";
629
+ // The nudge is copied into the PROJECT (not referenced inside node_modules) so
630
+ // the documented one-liner `npx @raymondchins/agentmap --install-hooks` works
631
+ // even though npx never populates ./node_modules — the old path
632
+ // node_modules/@raymondchins/agentmap/hooks/agentmap-nudge.mjs simply does not
633
+ // exist after an npx install, so the hook silently never fired. The nudge is
634
+ // self-contained (Node stdlib only, no relative package imports), so copying it
635
+ // standalone is safe. CLAUDE_PROJECT_DIR is set by Claude Code at hook time, so
636
+ // the wired command resolves the copied file regardless of cwd.
637
+ const nudgeDestRel = ".claude/hooks/agentmap-nudge.mjs";
638
+ const NUDGE_CMD = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/agentmap-nudge.mjs"`;
639
+
640
+ // .gitignore line: ignore the namespaced map DIR (not the legacy single file).
641
+ const IGNORE_LINE = ".claude/agentmap/";
642
+ const settingsPath = ".claude/settings.json";
643
+
644
+ // --- Determine what WOULD change (so --dry-run and the pre-write notice both
645
+ // describe the real plan). ---
568
646
  let ignoredAlready = false;
569
647
  if (existsSync(".gitignore")) {
570
- const cur = readFileSync(".gitignore", "utf8");
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");
648
+ ignoredAlready = readFileSync(".gitignore", "utf8").split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE);
575
649
  }
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
650
  let settings = {};
587
651
  if (existsSync(settingsPath)) {
588
- try { settings = JSON.parse(readFileSync(settingsPath, "utf8")) || {}; }
589
- catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
652
+ settings = parseSettings(readFileSync(settingsPath, "utf8"), settingsPath);
590
653
  }
591
654
  settings.hooks ??= {};
592
655
  settings.hooks.PreToolUse ??= [];
593
- // Check whether BOTH matchers are already present.
594
656
  const hasGrep = settings.hooks.PreToolUse.some(
595
657
  (e) => e?.matcher === "Grep" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
596
658
  );
@@ -598,12 +660,48 @@ function installHooks() {
598
660
  (e) => e?.matcher === "Bash" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
599
661
  );
600
662
  const alreadyWired = hasGrep && hasBash;
601
- if (!hasGrep) {
602
- settings.hooks.PreToolUse.push({ matcher: "Grep", hooks: [{ type: "command", command: NUDGE_CMD }] });
663
+
664
+ // The set of files this run touches used by both the dry-run report and the
665
+ // one-line pre-write notice in the normal path.
666
+ const targets = [
667
+ dest, // .git/hooks/post-commit
668
+ nudgeDestRel, // .claude/hooks/agentmap-nudge.mjs
669
+ ...(ignoredAlready ? [] : [".gitignore"]), // only if the ignore line is missing
670
+ ...(alreadyWired ? [] : [settingsPath]), // only if not already wired
671
+ ];
672
+
673
+ if (dryRun) {
674
+ console.log("--dry-run: would create/overwrite the following files (no changes written):");
675
+ for (const t of targets) console.log(` ${t}`);
676
+ return;
603
677
  }
604
- if (!hasBash) {
605
- settings.hooks.PreToolUse.push({ matcher: "Bash", hooks: [{ type: "command", command: NUDGE_CMD }] });
678
+
679
+ // Normal path: announce the plan, then write.
680
+ console.log(`agentmap --install-hooks: writing ${targets.length} file(s): ${targets.join(", ")}`);
681
+
682
+ // 1) post-commit hook → .git/hooks
683
+ mkdirSync(hooksDir, { recursive: true });
684
+ writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
685
+ chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
686
+
687
+ // 2) nudge → .claude/hooks/agentmap-nudge.mjs (idempotent overwrite-on-rerun)
688
+ mkdirSync(".claude/hooks", { recursive: true });
689
+ writeFileSync(nudgeDestRel, readFileSync(nudgeSrc, "utf8"));
690
+
691
+ // 3) .gitignore: ignore the namespaced map dir (append/create).
692
+ if (!ignoredAlready) {
693
+ if (existsSync(".gitignore")) {
694
+ const cur = readFileSync(".gitignore", "utf8");
695
+ writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
696
+ } else {
697
+ writeFileSync(".gitignore", IGNORE_LINE + "\n");
698
+ }
606
699
  }
700
+
701
+ // 4) Auto-wire the PreToolUse(Grep|Bash) nudge into project settings. Merge-
702
+ // safe + idempotent: preserves existing settings/hooks, never duplicates ours.
703
+ if (!hasGrep) settings.hooks.PreToolUse.push({ matcher: "Grep", hooks: [{ type: "command", command: NUDGE_CMD }] });
704
+ if (!hasBash) settings.hooks.PreToolUse.push({ matcher: "Bash", hooks: [{ type: "command", command: NUDGE_CMD }] });
607
705
  if (!alreadyWired) {
608
706
  mkdirSync(".claude", { recursive: true });
609
707
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
@@ -611,6 +709,7 @@ function installHooks() {
611
709
 
612
710
  // Success report.
613
711
  console.log(`installed post-commit hook → ${dest}`);
712
+ console.log(`installed PreToolUse nudge → ${nudgeDestRel}`);
614
713
  console.log(ignoredAlready ? `.gitignore already has ${IGNORE_LINE}` : `added ${IGNORE_LINE} to .gitignore`);
615
714
  console.log(alreadyWired
616
715
  ? `${settingsPath} already wires the PreToolUse(Grep|Bash) → agentmap nudge — left as-is`
@@ -640,7 +739,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
640
739
  // NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
641
740
  const KNOWN = new Set([
642
741
  "--json", "--print",
643
- "--help", "-h", "--version", "-v", "--install-hooks", "--mcp",
742
+ "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
644
743
  "--any", "--find", "--relates", "--map", "--focus", "--tokens",
645
744
  "--symbols", "--feature", "--features", "--hubs",
646
745
  ]);
@@ -674,7 +773,9 @@ Global modifier:
674
773
  --json emit exactly one JSON object (no prose) for the command
675
774
 
676
775
  Maintenance:
677
- --install-hooks install git post-commit + print the PreToolUse snippet
776
+ --install-hooks [--dry-run]
777
+ install git post-commit + copy the PreToolUse nudge +
778
+ wire .claude/settings.json (--dry-run = preview, no writes)
678
779
  --mcp start a stdio MCP server (for MCP-capable agents)
679
780
  --help, -h show this help
680
781
  --version, -v print the version
@@ -707,7 +808,7 @@ if (has("--mcp")) {
707
808
  // --install-hooks: wire the git post-commit refresh + emit the PreToolUse
708
809
  // snippet. Self-contained (resolves the package hooks/ dir relative to here).
709
810
  else if (has("--install-hooks")) {
710
- try { installHooks(); process.exit(0); }
811
+ try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
711
812
  catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
712
813
  }
713
814
  // Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
@@ -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
- fire =
100
- DEP_RE.test(cmd) ||
101
- (COMPONENT_TAG_RE.test(cmd) && !GENERIC_DENYLIST.test(cmd)) ||
102
- INTENT_RE.test(cmd) ||
103
- SYMBOL_RE.test(cmd);
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
- # Locate the builder: prefer a local agentmap.mjs, else the installed `agentmap`.
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.0",
3
+ "version": "0.4.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",
22
+ "eval": "node eval/eval.mjs"
22
23
  },
23
24
  "engines": {
24
25
  "node": ">=18"