@raymondchins/agentmap 0.5.0 → 0.6.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.
Files changed (2) hide show
  1. package/agentmap.mjs +171 -22
  2. package/package.json +1 -1
package/agentmap.mjs CHANGED
@@ -17,6 +17,9 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdir
17
17
  import { execSync, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
+ import { homedir } from "node:os";
21
+ import { fileURLToPath } from "node:url";
22
+ import { join, dirname } from "node:path";
20
23
 
21
24
  // Lazy ts-morph: its ~105ms module init only fires on a COLD rebuild. Warm cache
22
25
  // queries (the common case) never construct a Project, so they skip the load
@@ -61,6 +64,10 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
61
64
  // Live content search for the --any fallback. `git grep` over tracked +
62
65
  // untracked files (skips gitignored paths like node_modules). Reads DISK, so
63
66
  // never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
67
+ // -i = case-insensitive BY DESIGN (discovery ergonomics, matches --find which
68
+ // lowercases its query): a "content" hit may differ in case from the query as
69
+ // typed, but every match is printed verbatim with file:line so the true casing
70
+ // is always visible — results are a superset, never a falsified exact-case hit.
64
71
  // stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
65
72
  // Exclude sensitive files from the --untracked sweep so a local .env / key /
66
73
  // secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
@@ -85,8 +92,11 @@ const dirtyCount = () =>
85
92
  // listed individually (default "all" folds it to "?? newdir/" and the
86
93
  // extension regex misses it → a STALE cache would be served).
87
94
  sh("git status --porcelain --untracked-files=all").split("\n").filter(Boolean).filter((l) => {
95
+ const xy = l.slice(0, 2); // porcelain status code (XY)
88
96
  let p = l.slice(3); // strip "XY " status prefix
89
- if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
97
+ // only rename/copy entries use the ` old -> new ` form — gating on the status
98
+ // code avoids falsely splitting a plain file whose NAME contains " -> ".
99
+ if (/[RC]/.test(xy) && p.includes(" -> ")) p = p.split(" -> ").pop(); // rename/copy: keep new path
90
100
  p = p.replace(/^"|"$/g, ""); // unquote space/special paths
91
101
  return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
92
102
  }).length;
@@ -104,8 +114,13 @@ const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
104
114
  function sourceFingerprint() {
105
115
  try {
106
116
  const entries = [];
107
- const walk = (dir) => {
108
- for (const name of readdirSync(dir)) {
117
+ const walk = (dir, depth) => {
118
+ if (depth > 40) return; // depth cap — don't fully walk a pathologically deep tree
119
+ // per-directory try/catch: a single permission-denied subdir must NOT abort
120
+ // the WHOLE walk (that would return "" and silently disable caching) — skip
121
+ // the unreadable dir and keep going so the fingerprint stays usable.
122
+ let names; try { names = readdirSync(dir); } catch { return; }
123
+ for (const name of names) {
109
124
  if (name === "node_modules" || name === ".git" || name === ".next") continue;
110
125
  const full = dir + "/" + name;
111
126
  let st;
@@ -115,11 +130,11 @@ function sourceFingerprint() {
115
130
  // recursion / stack overflow.
116
131
  try { st = lstatSync(full); } catch { continue; }
117
132
  if (st.isSymbolicLink()) continue;
118
- if (st.isDirectory()) walk(full);
133
+ if (st.isDirectory()) walk(full, depth + 1);
119
134
  else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
120
135
  }
121
136
  };
122
- walk(".");
137
+ walk(".", 0);
123
138
  entries.sort();
124
139
  return createHash("sha1").update(entries.join("\n")).digest("hex");
125
140
  } catch { return ""; }
@@ -344,19 +359,21 @@ function makeProject() {
344
359
  ]);
345
360
  // Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
346
361
  try {
347
- const walk = (dir) => {
348
- for (const name of readdirSync(dir)) {
362
+ const walk = (dir, depth) => {
363
+ if (depth > 40) return; // depth cap, matching sourceFingerprint()
364
+ let names; try { names = readdirSync(dir); } catch { return; } // skip unreadable dir, don't abort the whole walk
365
+ for (const name of names) {
349
366
  if (name === "node_modules" || name === ".git" || name === ".next") continue;
350
367
  const full = dir + "/" + name;
351
368
  // lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
352
369
  // a circular symlink would otherwise recurse until the stack overflows.
353
370
  let st; try { st = lstatSync(full); } catch { continue; }
354
371
  if (st.isSymbolicLink()) continue;
355
- if (st.isDirectory()) walk(full);
372
+ if (st.isDirectory()) walk(full, depth + 1);
356
373
  else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
357
374
  }
358
375
  };
359
- walk(".");
376
+ walk(".", 0);
360
377
  } catch { /* ignore — proceed without Vue */ }
361
378
  }
362
379
  // Build the virtual→real map and register each `<script>` block as a virtual
@@ -373,7 +390,7 @@ function makeProject() {
373
390
  vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
374
391
  vueReal[`${cwdp}/${f}`] = true;
375
392
  }
376
- return { project, vueMap, vueReal };
393
+ return { project, vueMap, vueReal, aliasOpts };
377
394
  }
378
395
 
379
396
  // ---------------------------------------------------------------------------
@@ -383,7 +400,7 @@ function makeProject() {
383
400
  // ---------------------------------------------------------------------------
384
401
  function build() {
385
402
  const t0 = Date.now();
386
- const { project, vueMap, vueReal } = makeProject();
403
+ const { project, vueMap, vueReal, aliasOpts } = makeProject();
387
404
  const { SyntaxKind } = tsMorph();
388
405
  const CallExpression = SyntaxKind.CallExpression;
389
406
  const cwd = process.cwd().replace(/\\/g, "/");
@@ -405,8 +422,51 @@ function build() {
405
422
  // /index.*. Returns the rel key or null. Powers side-effect (6b) + dynamic
406
423
  // import()/require() (6c) edges that ts-morph's specifier resolution skips.
407
424
  const RES_EXT = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
425
+ // posix-join that collapses "" / "." / ".." segments (no fs access).
426
+ const joinPosix = (a, b) => {
427
+ const parts = (a + "/" + b).split("/"); const st = [];
428
+ for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
429
+ return (a.startsWith("/") ? "/" : "") + st.join("/");
430
+ };
431
+ // resolve an absolute-ish path to an in-project source file, honoring
432
+ // extensionless + /index.* + .vue resolution (shared by relative + alias paths).
433
+ const tryResolveAt = (abs) => {
434
+ if (vueReal[abs]) return rel(abs);
435
+ let sf = project.getSourceFile(abs);
436
+ if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}.${e}`); if (sf) break; }
437
+ if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}/index.${e}`); if (sf) break; }
438
+ if (sf) return rel(sf.getFilePath());
439
+ if (vueReal[`${abs}.vue`]) return rel(`${abs}.vue`);
440
+ return null;
441
+ };
442
+ // #3 fix: tsconfig/jsconfig baseUrl+paths alias resolution ("@/x", "~/x") for
443
+ // the side-effect/dynamic/require edges too. Static imports already resolve via
444
+ // ts-morph's getModuleSpecifierSourceFile(); without this, `import "@/lib/x"`,
445
+ // `import("@/lib/x")` and `require("@/lib/x")` silently formed NO edge.
446
+ const ROOTABS = process.cwd().replace(/\\/g, "/");
447
+ const aliasBase = aliasOpts.baseUrl ? joinPosix(ROOTABS, aliasOpts.baseUrl) : ROOTABS;
448
+ const aliasEntries = Object.entries(aliasOpts.paths || {});
449
+ const resolveAlias = (spec) => {
450
+ for (const [pat, targets] of aliasEntries) {
451
+ const star = pat.indexOf("*");
452
+ let sub = null;
453
+ if (star === -1) { if (spec === pat) sub = ""; else continue; }
454
+ else {
455
+ const pre = pat.slice(0, star), suf = pat.slice(star + 1);
456
+ if (spec.length < pre.length + suf.length || !spec.startsWith(pre) || !spec.endsWith(suf)) continue;
457
+ sub = spec.slice(pre.length, spec.length - suf.length);
458
+ }
459
+ for (const tRaw of (Array.isArray(targets) ? targets : [targets])) {
460
+ const tStar = tRaw.indexOf("*");
461
+ const candidate = tStar === -1 ? tRaw : tRaw.slice(0, tStar) + sub + tRaw.slice(tStar + 1);
462
+ const hit = tryResolveAt(joinPosix(aliasBase, candidate));
463
+ if (hit) return hit;
464
+ }
465
+ }
466
+ return null;
467
+ };
408
468
  const resolveSpec = (fromAbsDir, spec) => {
409
- if (!spec.startsWith(".")) return null; // only relative, in-project specifiers
469
+ if (!spec.startsWith(".")) return resolveAlias(spec); // alias (baseUrl/paths) or null for non-relative
410
470
  // normalize fromAbsDir + spec into an absolute-ish posix path
411
471
  const join = (a, b) => {
412
472
  const parts = (a + "/" + b).split("/"); const st = [];
@@ -435,7 +495,9 @@ function build() {
435
495
  for (const sf of sourceFiles) {
436
496
  const path = rel(sf.getFilePath());
437
497
  if (excluded(path)) continue;
498
+ try {
438
499
  const fromDir = sf.getDirectoryPath().replace(/\\/g, "/");
500
+ const reExports = new Set(); // #2: names that are pass-through re-exports, not real uses
439
501
  // exports, remembering which exported name was the file's DEFAULT export so
440
502
  // default-import edges can later resolve "default" → the real symbol name.
441
503
  let defaultExportName = null;
@@ -472,7 +534,11 @@ function build() {
472
534
  for (const exp of sf.getExportDeclarations()) {
473
535
  if (exp.isTypeOnly()) continue; // type-only re-exports excluded from edges
474
536
  const t = exp.getModuleSpecifierSourceFile();
475
- if (t) addEdge(rel(t.getFilePath()), exp.getNamedExports().filter((n) => !n.isTypeOnly()).map((n) => n.getName()));
537
+ if (t) {
538
+ const names = exp.getNamedExports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
539
+ addEdge(rel(t.getFilePath()), names); // keep the FILE-level edge (barrel depends on origin)
540
+ for (const n of names) reExports.add(n); // #2: mark as re-export so rankSymbols won't count it as a reference
541
+ }
476
542
  }
477
543
  // 6c: dynamic import("./x") and require("./x") with relative, in-project
478
544
  // string-literal specifiers → edge with names ["*"]. Prefilter on raw text
@@ -491,9 +557,15 @@ function build() {
491
557
  }
492
558
  const imports = Object.keys(importedSymbols);
493
559
  for (const tp of imports) (dependents[tp] ??= []).push(path);
494
- files[path] = { exports, imports, importedSymbols, defaultExportName };
560
+ files[path] = { exports, imports, importedSymbols, defaultExportName, reExports: [...reExports] };
495
561
  const feat = featureOf(path);
496
562
  if (feat) (features[feat] ??= []).push(path);
563
+ } catch (e) {
564
+ // #1 fix: a single pathological file (malformed import specifier, ts-morph
565
+ // edge case) must NOT abort the whole map — skip it + warn, preserving the
566
+ // graceful-degradation contract agentmap advertises.
567
+ process.stderr.write(`# agentmap: skipped ${path} (parse error: ${e?.message ?? e})\n`);
568
+ }
497
569
  }
498
570
  // 7: resolve default-import edges. A default import was recorded literally as
499
571
  // "default"; rankSymbols skips "default", so default-exported symbols (the
@@ -561,10 +633,12 @@ function rankSymbols(files, focus) {
561
633
  definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
562
634
  }
563
635
  }
564
- for (const [p, f] of Object.entries(files))
636
+ for (const [p, f] of Object.entries(files)) {
637
+ const reExp = new Set(f.reExports || []); // #2: pass-through re-exports aren't real references
565
638
  for (const tp of f.imports)
566
639
  for (const name of f.importedSymbols[tp] || [])
567
- if (name !== "*" && name !== "default") getOrSet(references, name, () => []).push(p);
640
+ if (name !== "*" && name !== "default" && !reExp.has(name)) getOrSet(references, name, () => []).push(p);
641
+ }
568
642
 
569
643
  // mentioned idents from focus files' exports + their basenames
570
644
  let mentioned = null;
@@ -724,7 +798,7 @@ function parseSettings(text, settingsPath) {
724
798
  try { return JSON.parse(text) || {}; }
725
799
  catch {
726
800
  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`); }
801
+ catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run`); }
728
802
  }
729
803
  }
730
804
 
@@ -843,6 +917,65 @@ function installHooks({ dryRun = false } = {}) {
843
917
  console.log("\nDone — the map auto-refreshes on commit, and greps are nudged to agentmap first.");
844
918
  }
845
919
 
920
+ // ---------------------------------------------------------------------------
921
+ // --setup-mcp: register the agentmap MCP server in the global configs of
922
+ // MCP-capable IDEs that aren't Claude Code (which uses --install-hooks instead).
923
+ // Merge-safe + idempotent; with { dryRun:true } it prints the plan and writes
924
+ // nothing. Throws on the first malformed config so the caller can stderr+exit 1.
925
+ // ---------------------------------------------------------------------------
926
+ function setupMcp({ dryRun = false } = {}) {
927
+ const mcpPath = fileURLToPath(new URL("./mcp.mjs", import.meta.url));
928
+
929
+ // npx materializes the package under a `_npx` cache dir that gets garbage-
930
+ // collected, so a config pointing at that path would rot. When invoked via npx,
931
+ // pin to the published spec instead; otherwise reference the resolved file.
932
+ const isNpx = mcpPath.includes("_npx");
933
+ const command = isNpx ? "npx" : process.execPath;
934
+ const args = isNpx ? ["-y", "@raymondchins/agentmap", "--mcp"] : [mcpPath];
935
+
936
+ // Each target: a global config file + how to graft the agentmap entry into it.
937
+ // Antigravity is written to BOTH paths on purpose — older builds read only the
938
+ // IDE-specific ~/.gemini/antigravity path, newer unified builds read the shared
939
+ // ~/.gemini/config path, so writing both is version-proof.
940
+ const targets = [
941
+ {
942
+ label: "OpenCode",
943
+ path: join(homedir(), ".config", "opencode", "opencode.json"),
944
+ graft: (cfg) => { (cfg.mcp ??= {}).agentmap = { type: "stdio", command, args, enabled: true }; },
945
+ },
946
+ {
947
+ label: "Antigravity IDE",
948
+ path: join(homedir(), ".gemini", "antigravity", "mcp_config.json"),
949
+ graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
950
+ },
951
+ {
952
+ label: "Antigravity (shared)",
953
+ path: join(homedir(), ".gemini", "config", "mcp_config.json"),
954
+ graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
955
+ },
956
+ ];
957
+
958
+ if (dryRun) console.log("--dry-run: would configure MCP server (no changes written):");
959
+
960
+ for (const { label, path, graft } of targets) {
961
+ // Reuse parseSettings so JSONC (comments) is tolerated and a malformed file
962
+ // throws a clear error WITHOUT clobbering the original (we never write on the
963
+ // failure path, so no .bak dance is needed).
964
+ let cfg = {};
965
+ if (existsSync(path)) cfg = parseSettings(readFileSync(path, "utf8"), path);
966
+ graft(cfg);
967
+
968
+ if (dryRun) {
969
+ console.log(` ${label}: would write to ${path}`);
970
+ } else {
971
+ mkdirSync(dirname(path), { recursive: true });
972
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
973
+ console.log(`configured ${label} MCP server → ${path}`);
974
+ }
975
+ }
976
+ }
977
+
978
+
846
979
  // ---------------------------------------------------------------------------
847
980
  // CLI
848
981
  // ---------------------------------------------------------------------------
@@ -865,7 +998,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
865
998
  // NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
866
999
  const KNOWN = new Set([
867
1000
  "--json", "--print",
868
- "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
1001
+ "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--setup-mcp", "--mcp",
869
1002
  "--any", "--find", "--relates", "--map", "--focus", "--tokens",
870
1003
  "--symbols", "--feature", "--features", "--hubs",
871
1004
  ]);
@@ -902,6 +1035,9 @@ Maintenance:
902
1035
  --install-hooks [--dry-run]
903
1036
  install git post-commit + copy the PreToolUse nudge +
904
1037
  wire .claude/settings.json (--dry-run = preview, no writes)
1038
+ --setup-mcp [--dry-run]
1039
+ configure MCP server for OpenCode & Antigravity IDE
1040
+ (--dry-run = preview, no writes)
905
1041
  --mcp start a stdio MCP server (for MCP-capable agents)
906
1042
  --help, -h show this help
907
1043
  --version, -v print the version
@@ -937,6 +1073,11 @@ else if (has("--install-hooks")) {
937
1073
  try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
938
1074
  catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
939
1075
  }
1076
+ // --setup-mcp: configure MCP server for OpenCode & Antigravity IDE.
1077
+ else if (has("--setup-mcp")) {
1078
+ try { setupMcp({ dryRun: has("--dry-run") }); process.exit(0); }
1079
+ catch (e) { console.error(`agentmap --setup-mcp failed: ${e?.message || e}`); process.exit(1); }
1080
+ }
940
1081
  // Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
941
1082
  // 2). Runs BEFORE the bare-build fallthrough so a typo never silently rebuilds.
942
1083
  // Bare invocation with NO flags still builds (handled in the final else).
@@ -1087,11 +1228,19 @@ else if (has("--any")) {
1087
1228
  // omitted. Otherwise `continue` so smaller lower-ranked files can still
1088
1229
  // fill the remaining budget (don't `break` on the first overflow).
1089
1230
  if (first && budget > 0) {
1090
- let partial = capped;
1091
- while (partial.length > 1) {
1092
- partial = partial.slice(0, partial.length - 1);
1231
+ // #6 fix: try progressively fewer symbols DOWN TO ONE. The old
1232
+ // `while (partial.length > 1)` sliced before testing and never tried
1233
+ // the single-symbol block, so a tiny --tokens could emit NOTHING for
1234
+ // the top file despite the "never wholly omitted" intent. If even one
1235
+ // symbol overflows the budget, nothing fits (correct) — but we now try.
1236
+ for (let k = capped.length - 1; k >= 1; k--) {
1237
+ const partial = capped.slice(0, k);
1093
1238
  const pt = tokEst(lineOf(partial));
1094
- if (used + pt <= budget) { used += pt; shownFiles.push({ file, symbols: partial.map((s) => ({ name: s.name, kind: s.kind })) }); break; }
1239
+ if (used + pt <= budget) {
1240
+ used += pt;
1241
+ shownFiles.push({ file, symbols: partial.map((s) => ({ name: s.name, kind: s.kind })) });
1242
+ break;
1243
+ }
1095
1244
  }
1096
1245
  first = false;
1097
1246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raymondchins/agentmap",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },