@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.
- package/agentmap.mjs +99 -20
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
1161
|
-
while (partial.length > 1)
|
|
1162
|
-
|
|
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) {
|
|
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
|
}
|