@raymondchins/agentmap 0.6.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 +99 -20
  2. package/package.json +1 -1
package/agentmap.mjs CHANGED
@@ -64,6 +64,10 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
64
64
  // Live content search for the --any fallback. `git grep` over tracked +
65
65
  // untracked files (skips gitignored paths like node_modules). Reads DISK, so
66
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.
67
71
  // stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
68
72
  // Exclude sensitive files from the --untracked sweep so a local .env / key /
69
73
  // secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
@@ -88,8 +92,11 @@ const dirtyCount = () =>
88
92
  // listed individually (default "all" folds it to "?? newdir/" and the
89
93
  // extension regex misses it → a STALE cache would be served).
90
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)
91
96
  let p = l.slice(3); // strip "XY " status prefix
92
- 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
93
100
  p = p.replace(/^"|"$/g, ""); // unquote space/special paths
94
101
  return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
95
102
  }).length;
@@ -107,8 +114,13 @@ const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
107
114
  function sourceFingerprint() {
108
115
  try {
109
116
  const entries = [];
110
- const walk = (dir) => {
111
- 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) {
112
124
  if (name === "node_modules" || name === ".git" || name === ".next") continue;
113
125
  const full = dir + "/" + name;
114
126
  let st;
@@ -118,11 +130,11 @@ function sourceFingerprint() {
118
130
  // recursion / stack overflow.
119
131
  try { st = lstatSync(full); } catch { continue; }
120
132
  if (st.isSymbolicLink()) continue;
121
- if (st.isDirectory()) walk(full);
133
+ if (st.isDirectory()) walk(full, depth + 1);
122
134
  else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
123
135
  }
124
136
  };
125
- walk(".");
137
+ walk(".", 0);
126
138
  entries.sort();
127
139
  return createHash("sha1").update(entries.join("\n")).digest("hex");
128
140
  } catch { return ""; }
@@ -347,19 +359,21 @@ function makeProject() {
347
359
  ]);
348
360
  // Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
349
361
  try {
350
- const walk = (dir) => {
351
- 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) {
352
366
  if (name === "node_modules" || name === ".git" || name === ".next") continue;
353
367
  const full = dir + "/" + name;
354
368
  // lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
355
369
  // a circular symlink would otherwise recurse until the stack overflows.
356
370
  let st; try { st = lstatSync(full); } catch { continue; }
357
371
  if (st.isSymbolicLink()) continue;
358
- if (st.isDirectory()) walk(full);
372
+ if (st.isDirectory()) walk(full, depth + 1);
359
373
  else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
360
374
  }
361
375
  };
362
- walk(".");
376
+ walk(".", 0);
363
377
  } catch { /* ignore — proceed without Vue */ }
364
378
  }
365
379
  // Build the virtual→real map and register each `<script>` block as a virtual
@@ -376,7 +390,7 @@ function makeProject() {
376
390
  vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
377
391
  vueReal[`${cwdp}/${f}`] = true;
378
392
  }
379
- return { project, vueMap, vueReal };
393
+ return { project, vueMap, vueReal, aliasOpts };
380
394
  }
381
395
 
382
396
  // ---------------------------------------------------------------------------
@@ -386,7 +400,7 @@ function makeProject() {
386
400
  // ---------------------------------------------------------------------------
387
401
  function build() {
388
402
  const t0 = Date.now();
389
- const { project, vueMap, vueReal } = makeProject();
403
+ const { project, vueMap, vueReal, aliasOpts } = makeProject();
390
404
  const { SyntaxKind } = tsMorph();
391
405
  const CallExpression = SyntaxKind.CallExpression;
392
406
  const cwd = process.cwd().replace(/\\/g, "/");
@@ -408,8 +422,51 @@ function build() {
408
422
  // /index.*. Returns the rel key or null. Powers side-effect (6b) + dynamic
409
423
  // import()/require() (6c) edges that ts-morph's specifier resolution skips.
410
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
+ };
411
468
  const resolveSpec = (fromAbsDir, spec) => {
412
- 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
413
470
  // normalize fromAbsDir + spec into an absolute-ish posix path
414
471
  const join = (a, b) => {
415
472
  const parts = (a + "/" + b).split("/"); const st = [];
@@ -438,7 +495,9 @@ function build() {
438
495
  for (const sf of sourceFiles) {
439
496
  const path = rel(sf.getFilePath());
440
497
  if (excluded(path)) continue;
498
+ try {
441
499
  const fromDir = sf.getDirectoryPath().replace(/\\/g, "/");
500
+ const reExports = new Set(); // #2: names that are pass-through re-exports, not real uses
442
501
  // exports, remembering which exported name was the file's DEFAULT export so
443
502
  // default-import edges can later resolve "default" → the real symbol name.
444
503
  let defaultExportName = null;
@@ -475,7 +534,11 @@ function build() {
475
534
  for (const exp of sf.getExportDeclarations()) {
476
535
  if (exp.isTypeOnly()) continue; // type-only re-exports excluded from edges
477
536
  const t = exp.getModuleSpecifierSourceFile();
478
- 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
+ }
479
542
  }
480
543
  // 6c: dynamic import("./x") and require("./x") with relative, in-project
481
544
  // string-literal specifiers → edge with names ["*"]. Prefilter on raw text
@@ -494,9 +557,15 @@ function build() {
494
557
  }
495
558
  const imports = Object.keys(importedSymbols);
496
559
  for (const tp of imports) (dependents[tp] ??= []).push(path);
497
- files[path] = { exports, imports, importedSymbols, defaultExportName };
560
+ files[path] = { exports, imports, importedSymbols, defaultExportName, reExports: [...reExports] };
498
561
  const feat = featureOf(path);
499
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
+ }
500
569
  }
501
570
  // 7: resolve default-import edges. A default import was recorded literally as
502
571
  // "default"; rankSymbols skips "default", so default-exported symbols (the
@@ -564,10 +633,12 @@ function rankSymbols(files, focus) {
564
633
  definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
565
634
  }
566
635
  }
567
- 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
568
638
  for (const tp of f.imports)
569
639
  for (const name of f.importedSymbols[tp] || [])
570
- if (name !== "*" && name !== "default") getOrSet(references, name, () => []).push(p);
640
+ if (name !== "*" && name !== "default" && !reExp.has(name)) getOrSet(references, name, () => []).push(p);
641
+ }
571
642
 
572
643
  // mentioned idents from focus files' exports + their basenames
573
644
  let mentioned = null;
@@ -1157,11 +1228,19 @@ else if (has("--any")) {
1157
1228
  // omitted. Otherwise `continue` so smaller lower-ranked files can still
1158
1229
  // fill the remaining budget (don't `break` on the first overflow).
1159
1230
  if (first && budget > 0) {
1160
- let partial = capped;
1161
- while (partial.length > 1) {
1162
- 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);
1163
1238
  const pt = tokEst(lineOf(partial));
1164
- 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
+ }
1165
1244
  }
1166
1245
  first = false;
1167
1246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raymondchins/agentmap",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },