-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentmap.mjs
More file actions
executable file
·2173 lines (2075 loc) · 102 KB
/
Copy pathagentmap.mjs
File metadata and controls
executable file
·2173 lines (2075 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// ============================================================================
// agentmap — the repo map your coding agent is *forced* to use.
//
// A ts-morph code-relationship map for TypeScript/JavaScript repos. Unlike
// one-shot "pack the repo into a prompt" tools, this is a QUERYABLE, RANKED
// map: PageRank importance (approach from Aider's repo map), Aider-style
// symbol ranking, a token-budgeted `--map` digest, and a single `--any`
// router (file → symbol → feature → live git-grep) — wired into the agent
// loop via a post-commit auto-refresh + a PreToolUse hook.
//
// Near-zero deps (ts-morph only). Runs in the target repo's cwd.
// Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
// ============================================================================
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, lstatSync, chmodSync } from "node:fs";
import { execSync, execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { join, dirname } from "node:path";
// Lazy ts-morph: its ~105ms module init only fires on a COLD rebuild. Warm cache
// queries (the common case) never construct a Project, so they skip the load
// entirely (~2x faster warm). createRequire keeps it synchronous — no async to
// thread through build()/makeProject().
const _require = createRequire(import.meta.url);
let _tsm = null;
const tsMorph = () => (_tsm ??= _require("ts-morph"));
import { EnhancedLaravelParser as PhpParserClass } from "./src/Core/EnhancedLaravelParser.mjs";
let _phpParser = null;
const getPhpParser = () => { const p = _phpParser ?? (new PhpParserClass()); p.init(); return _phpParser ??= p; };
import { TypeResolver } from "./src/Core/TypeResolver.mjs";
import { ComposerParser } from "./src/Core/ComposerParser.mjs";
import { LegacyDetector } from "./src/Core/LegacyDetector.mjs";
import { DEFAULT_CHAIN_DEPTH } from "./src/Core/constants.mjs";
const MAP = ".claude/agentmap/map.json";
const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
// Bumped 2 → 3: Vue SFC support. `.vue` files now appear in the map and the
// source-discovery / freshness checks treat them as first-class source files.
// Old caches (schema 2) are ignored so the first run after upgrade rebuilds.
// Bumped 3 → 4: PHP packages, legacyWarnings, and type data (assignedTypes,
// phpDocTypes, chainTypes) now in map. Package nodes carry pagerank field.
const SCHEMA_VERSION = 4;
// ---------------------------------------------------------------------------
// Tuning constants — KEEP THESE VALUES IDENTICAL (output + marketing must not
// shift). Hoisted out of inline literals so the algorithm is self-documenting.
// ---------------------------------------------------------------------------
const DAMPING = 0.85; // PageRank damping (Aider parity)
const TOL = 1e-6; // power-iteration convergence tolerance
const MAX_ITER = 100; // power-iteration iteration cap
const IDENT_BOOST = 10; // weight ×: mentioned ident, or long multi-word ident
const RARE_PENALTY = 0.1; // weight ×: ident defined in >RARE_DEFINERS files (too common)
const UNDERSCORE_PENALTY = 0.1; // weight ×: private-ish `_`-prefixed ident
const MIN_IDENT_LEN = 8; // min length for the long-multi-word ident boost
const RARE_DEFINERS = 5; // >this many definers ⇒ ident is too common ⇒ penalize
const FOCUS_BOOST = 50; // ref-edge weight × when refFile is in the focus set
const DEFAULT_BUDGET = 8192; // --map token budget with no --focus
const FOCUS_BUDGET = 1024; // --map token budget when --focus is given
const HUBS_LIMIT = 15; // # of hubs persisted/printed
const RANKED_SYMBOLS_LIMIT = 80; // # of ranked symbols persisted
const PKG_EDGE_CAP = 1000; // max package→file edges per package (CMP-04 edge explosion guard)
const CONTENT_LINES_LIMIT = 40; // # of git-grep lines shown in the --any content fallback
const RELATED_LIMIT = 10; // # of related files shown by --relates
const SYMS_PER_FILE = 8; // per-file symbol cap in the --map digest
const DEFAULT_SYMBOLS = 30; // default count for --symbols with no n
const MAXBUF = 64 * 1024 * 1024; // child_process maxBuffer — avoid ENOBUFS on big git output
const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).toString().trim(); } catch { return ""; } };
// Live content search for the --any fallback. `git grep` over tracked +
// untracked files (skips gitignored paths like node_modules). Reads DISK, so
// never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
// -i = case-insensitive BY DESIGN (discovery ergonomics, matches --find which
// lowercases its query): a "content" hit may differ in case from the query as
// typed, but every match is printed verbatim with file:line so the true casing
// is always visible — results are a superset, never a falsified exact-case hit.
// stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
// Exclude sensitive files from the --untracked sweep so a local .env / key /
// secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
// Mix of path globs (env/key/cert/SSH-key shapes) and case-insensitive name
// matches (anything *secret* / *credential* / *.password*). These are pathspecs,
// not regexes — git applies them as exclusions to the search tree.
const SENSITIVE_EXCLUDES = [
":!.env", ":!.env.*", ":!**/.env", ":!**/.env.*",
// also any *.env (e.g. prod.env, .env.local already covered above) at any depth
":!*.env", ":!**/*.env",
":!*.pem", ":!*.key", ":!*.p12", ":!*.pfx", ":!*.crt", ":!id_rsa*",
":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*.password*",
];
const contentSearch = (q) => {
try {
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap/", ...SENSITIVE_EXCLUDES], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim();
} catch { return ""; }
};
const currentSha = () => sh("git rev-parse --short HEAD");
const dirtyCount = () =>
// --untracked-files=all so a new file inside a brand-new untracked DIR is
// listed individually (default "all" folds it to "?? newdir/" and the
// extension regex misses it → a STALE cache would be served).
sh("git status --porcelain --untracked-files=all").split("\n").filter(Boolean).filter((l) => {
const xy = l.slice(0, 2); // porcelain status code (XY)
let p = l.slice(3); // strip "XY " status prefix
// only rename/copy entries use the ` old -> new ` form — gating on the status
// code avoids falsely splitting a plain file whose NAME contains " -> ".
if (/[RC]/.test(xy) && p.includes(" -> ")) p = p.split(" -> ").pop(); // rename/copy: keep new path
p = p.replace(/^"|"$/g, ""); // unquote space/special paths
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue|php)$/.test(p);
}).length;
const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
// get-or-init a Map value (readable replacement for the dense `m.get(k) ?? m.set(...)` idiom).
const getOrSet = (m, k, make) => { let v = m.get(k); if (v === undefined) { v = make(); m.set(k, v); } return v; };
// Best-effort source fingerprint for NON-git repos (sha == ""). Hash of sorted
// "path:mtimeMs:size" for source files so the cache can be trusted between runs
// without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller
// falls through to build, i.e. current behavior). Never used on the git path.
// Includes `.vue` so editing a Vue SFC invalidates the non-git cache too.
const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue|php)$/;
function sourceFingerprint() {
try {
const entries = [];
const walk = (dir, depth) => {
if (depth > 40) return; // depth cap — don't fully walk a pathologically deep tree
// per-directory try/catch: a single permission-denied subdir must NOT abort
// the WHOLE walk (that would return "" and silently disable caching) — skip
// the unreadable dir and keep going so the fingerprint stays usable.
let names; try { names = readdirSync(dir); } catch { return; }
for (const name of names) {
if (name === "node_modules" || name === ".git" || name === ".next") continue;
const full = dir + "/" + name;
let st;
// lstatSync (NOT statSync) so a symlink reports as a symlink instead of
// its target. Symlinked entries are SKIPPED entirely — never recursed
// into, never stat'd through — so a circular symlink can't cause infinite
// recursion / stack overflow.
try { st = lstatSync(full); } catch { continue; }
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) walk(full, depth + 1);
else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
}
};
walk(".", 0);
entries.sort();
return createHash("sha1").update(entries.join("\n")).digest("hex");
} catch { return ""; }
}
// =============================================================================
// Vue Single File Component support — best-effort, zero-dependency.
//
// agentmap is TS/JS-first. Vue `.vue` SFCs are NOT TypeScript; the Vue compiler
// (`@vue/compiler-sfc`) is intentionally NOT a dependency (CONTRIBUTING near-
// zero-deps rule). Instead we extract ONLY the `<script>` / `<script setup>`
// block text with a conservative regex and feed it to ts-morph as a VIRTUAL
// source file (e.g. `App.vue.ts`). A virtual→real path map (see build())
// rewrites every user-facing path back to the real `.vue` path so no
// `.vue.ts` / `.vue.js` ever leaks into JSON or prose.
//
// Non-goals: no template AST, no `<style>` parsing, no Nuxt auto-import
// resolution, no Svelte/Astro. Only `<script>` blocks that look like JS/TS.
// =============================================================================
// Find the first top-level `<script ...>` block (optionally `<script setup ...>`)
// whose opening tag does NOT carry `src="..."` (external script reference —
// the actual JS lives in another file agentmap already indexes on its own).
// Handles single + double quoted lang/src attributes and `lang="ts"`/`ts`.
// Returns { lang, setup, text } for the matched block, or null if none.
//
// Greedy-free: stops at the FIRST `</script>` on its own. Vue forbids nested
// `<script>` tags, so a non-greedy match up to `</script>` is safe. We do NOT
// support `<script>` + `<script setup>` in the same SFC for indexing — we pick
// the richer one: prefer `setup` block if present, else the normal block.
function extractVueScripts(text) {
const blocks = [];
// Open-tag matcher is QUOTE-AWARE: attribute values may legitimately contain
// `>` (e.g. `<script setup lang="ts" generic="T extends Record<string, unknown>">`
// — a common Vue 3 idiom for typed generic components). We require all
// attributes to be either bare (`setup`) or quoted (`name="value"` or
// `name='value'`), which matches valid SFC syntax. Bareword and unquoted forms
// are intentionally not matched because they're not valid HTML and would
// almost certainly indicate a parsing bug we want to surface, not silently
// misparse.
const re = /<script(\s+[a-zA-Z][\w-]*(\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s*\/?>/gi;
let m;
while ((m = re.exec(text)) !== null) {
const attrs = (m[0].slice(7, -1) || "").trim(); // strip <script…> wrapper
// find body: text after the opening tag up to </script>
const openEnd = m.index + m[0].length;
const closeStart = text.toLowerCase().indexOf("</script>", openEnd);
if (closeStart === -1) break; // unterminated — stop scanning
const body = text.slice(openEnd, closeStart);
// external script reference → skip (the target file is indexed directly).
if (/\bsrc\s*=\s*["'][^"']+["']/i.test(attrs)) continue;
if (!body.trim()) continue; // empty body (e.g. <script/>) — not useful
const setup = /\bsetup\b/i.test(attrs);
const lang = (attrs.match(/\blang\s*=\s*["']([^"']+)["']/i) || [])[1] || "js";
blocks.push({ lang: lang.toLowerCase(), setup, text: body });
re.lastIndex = closeStart + "</script>".length; // resume after </script>
}
if (!blocks.length) return null;
// Prefer a setup block (the modern idiom) when present; else the plain block.
return blocks.find((b) => b.setup) || blocks[0];
}
// Virtual file path mapping for a `.vue` source. The virtual path is what
// ts-morph sees (so `.ts`/`.js` parsing kicks in); the real path is what every
// user-facing output shows. `lang="ts"` → `.vue.ts`, otherwise `.vue.js`.
function vueVirtualPath(realPath, lang) {
return lang === "ts" ? `${realPath}.ts` : `${realPath}.js`;
}
// Feature = first real route segment under app/ (or src/app/), skipping route
// groups (parens), dynamic segments ([id]) and parallel routes (@slot).
function featureOf(path) {
const m = path.match(/(?:^|.*\/)(?:src\/)?app\/(.+)/);
if (!m) return null;
for (const p of m[1].split("/").slice(0, -1)) {
if (p.startsWith("(") || p.startsWith("[") || p.startsWith("@")) continue;
return p;
}
return null;
}
// ---------------------------------------------------------------------------
// Personalized PageRank — dependency-free power iteration. Deterministic
// (stable node order, no PRNG). Edges = [{from, to, weight}]. Rank flows
// from→to, so with importer→imported edges, heavily-imported hubs rank high.
// Dangling-node mass + teleport both go to the personalization vector
// (matches Aider's `dangling=personalization`). Returns { node: score }.
// ---------------------------------------------------------------------------
function pagerank(nodes, edges, { personalization = null, damping = DAMPING, tol = TOL, maxIter = MAX_ITER } = {}) {
const N = nodes.length;
if (N === 0) return {};
const idx = new Map(nodes.map((n, i) => [n, i]));
const outW = new Float64Array(N);
const adj = Array.from({ length: N }, () => []);
for (const e of edges) {
const a = idx.get(e.from), b = idx.get(e.to);
if (a === undefined || b === undefined || a === b) continue; // skip self-loops
const w = e.weight > 0 ? e.weight : 1;
adj[a].push([b, w]); outW[a] += w;
}
// teleport vector p (normalized personalization, or uniform)
const p = new Float64Array(N);
if (personalization) {
let s = 0;
for (const [k, v] of Object.entries(personalization)) {
const i = idx.get(k);
if (i !== undefined && v > 0) { p[i] = v; s += v; }
}
if (s === 0) p.fill(1 / N); else for (let i = 0; i < N; i++) p[i] /= s;
} else p.fill(1 / N);
let r = Float64Array.from(p);
for (let iter = 0; iter < maxIter; iter++) {
let dangling = 0;
for (let i = 0; i < N; i++) if (outW[i] === 0) dangling += r[i];
const next = new Float64Array(N);
for (let i = 0; i < N; i++) next[i] = (1 - damping) * p[i] + damping * dangling * p[i];
for (let i = 0; i < N; i++) {
if (outW[i] === 0) continue;
const ri = damping * r[i];
for (const [j, w] of adj[i]) next[j] += ri * (w / outW[i]);
}
let diff = 0;
for (let i = 0; i < N; i++) diff += Math.abs(next[i] - r[i]);
r = next;
if (diff < tol) break;
}
const out = {};
for (let i = 0; i < N; i++) out[nodes[i]] = r[i];
return out;
}
// Aider-style identifier edge-weight multipliers. `mentioned` = focus/query
// idents (boosted). Rarity is approximated by the >5-definers penalty.
function identMul(ident, defineCount, mentioned) {
let mul = 1.0;
const hasAlpha = /[a-zA-Z]/.test(ident);
const isSnake = ident.includes("_") && hasAlpha;
const isKebab = ident.includes("-") && hasAlpha;
const isCamel = /[a-z]/.test(ident) && /[A-Z]/.test(ident);
if (mentioned && mentioned.has(ident)) mul *= IDENT_BOOST;
if ((isSnake || isKebab || isCamel) && ident.length >= MIN_IDENT_LEN) mul *= IDENT_BOOST;
if (ident.startsWith("_")) mul *= UNDERSCORE_PENALTY;
if (defineCount > RARE_DEFINERS) mul *= RARE_PENALTY;
return mul;
}
// Read baseUrl+paths from a tsconfig/jsconfig file. Returns null when absent.
// Follows `extends` recursively (depth-capped) so a package tsconfig that only
// `extends` a shared base (Turborepo tsconfig.base.json holding all `paths`)
// still contributes its inherited baseUrl/paths. Child overrides parent.
function readTsconfigAliasOpts(cfgPath, _depth = 0) {
try {
const raw = JSON.parse(readFileSync(cfgPath, "utf8")) || {};
const co = raw.compilerOptions || {};
// Resolve inherited opts from `extends` first (parent), then layer self on top.
let inherited = null;
if (raw.extends && _depth < 10) {
const exts = Array.isArray(raw.extends) ? raw.extends : [raw.extends];
const here = dirname(cfgPath);
for (const ext of exts) {
if (typeof ext !== "string" || !ext) continue;
// Only resolve path-like extends (./, ../, absolute). Bare package
// extends (e.g. "@tsconfig/strict") live in node_modules and don't
// carry repo-local `paths`, so skip them safely.
if (!/^(\.\.?\/|\/)/.test(ext)) continue;
let base = join(here, ext);
if (!existsSync(base) && existsSync(base + ".json")) base += ".json";
else if (!/\.json$/.test(base) && existsSync(join(base, "tsconfig.json"))) base = join(base, "tsconfig.json");
if (!existsSync(base)) continue;
const parent = readTsconfigAliasOpts(base, _depth + 1);
if (parent) inherited = { ...(inherited || {}), ...parent };
}
}
const self = {};
if (co.baseUrl) self.baseUrl = co.baseUrl;
if (co.paths) self.paths = co.paths;
const out = { ...(inherited || {}), ...self };
if (!Object.keys(out).length) return null;
return out;
} catch { return null; }
}
// Collect package-level alias configs from tsconfig/jsconfig files in the repo.
// Deepest-dir-first sort so nearestAliasConfig can pick the longest prefix match.
function discoverPackageAliasConfigs(rootAbs, listed) {
const root = rootAbs.replace(/\\/g, "/");
const configs = [];
const cfgRels = listed.length
? listed.filter((f) => /(^|\/)tsconfig\.json$/.test(f) || /(^|\/)jsconfig\.json$/.test(f))
: [];
for (const rel of cfgRels) {
const full = join(root, rel);
if (!existsSync(full)) continue;
const opts = readTsconfigAliasOpts(full);
if (!opts) continue;
configs.push({
dir: join(root, dirname(rel)).replace(/\\/g, "/"),
baseUrl: opts.baseUrl || ".",
paths: opts.paths || {},
});
}
configs.sort((a, b) => b.dir.length - a.dir.length);
return configs;
}
// Longest matching tsconfig dir wins (monorepo package boundary).
function nearestAliasConfig(fromAbsDir, configs, rootAbs, rootOpts) {
const norm = fromAbsDir.replace(/\\/g, "/");
let best = null;
for (const c of configs) {
const d = c.dir;
if (norm === d || norm.startsWith(d + "/")) { best = c; break; } // configs sorted deepest-first
}
if (best) return best;
return { dir: rootAbs, baseUrl: rootOpts.baseUrl || ".", paths: rootOpts.paths || {} };
}
// Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
// else (missing / malformed / solution-style references that index 0 files) fall
// back to broad source globs so the tool degrades gracefully instead of crashing.
function makeProject() {
const { Project } = tsMorph();
// skipFileDependencyResolution: ~40% faster build, verified identical edge
// set (we resolve module specifiers explicitly below, never via the implicit
// dependency graph). allowJs so .js/.jsx are parsed.
const FAST = { skipFileDependencyResolution: true };
// Read tsconfig/jsconfig baseUrl+paths defensively so "@/…"/"~/…" alias
// imports still resolve when tsconfig is absent/broken. Any failure ⇒ none.
const aliasOpts = (() => {
for (const cfg of ["tsconfig.json", "jsconfig.json"]) {
try {
if (!existsSync(cfg)) continue;
const opts = readTsconfigAliasOpts(cfg);
if (opts) return opts;
} catch { /* ignore — proceed without paths */ }
}
return {};
})();
let project;
if (existsSync("tsconfig.json")) {
try { project = new Project({ tsConfigFilePath: "tsconfig.json", ...FAST }); }
catch {
// tsconfig present but unreadable/malformed — don't silently degrade.
console.error("# warning: tsconfig.json unreadable, using source globs");
project = new Project({ compilerOptions: { allowJs: true, ...aliasOpts }, ...FAST });
}
} else {
project = new Project({ compilerOptions: { allowJs: true, ...aliasOpts }, ...FAST });
}
// tsconfig `include` usually omits build/pipeline scripts — add by path.
project.addSourceFilesAtPaths([
"scripts/**/*.{mjs,cjs,js}", "*.mjs", "*.cjs",
]);
// Catch source files a narrow tsconfig `include` misses (monorepo / subdir-
// scoped) WITHOUT an expensive full-tree FS glob (which cost ~600ms to find a
// handful of files). Enumerate cheaply via `git ls-files` (tracked + untracked-
// not-ignored — node_modules etc. excluded by --exclude-standard) and add only
// files not already loaded. Non-git repos fall back to the broad globs.
const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
const cwdp = process.cwd().replace(/\\/g, "/");
const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
const packageAliasConfigs = discoverPackageAliasConfigs(cwdp, listed);
// `.vue` discovery: same channel as TS/JS (git ls-files when available, else
// a broad glob fallback). We do NOT hand `.vue` straight to ts-morph (it is
// not TS/JS). Instead, for each `.vue` file we read its `<script>` block via
// extractVueScripts() and register it as a VIRTUAL source file
// (`App.vue.ts` / `App.vue.js`). A virtual→real path map is returned alongside
// the project so build() can rewrite every user-facing path back to `.vue`.
const vueFiles = [];
if (listed.length) {
const missing = [];
for (const f of listed) {
if (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) {
const segs = f.split("/");
if (segs.includes("node_modules") || segs.includes(".next")) continue;
if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
} else if (f.endsWith(".vue")) {
const segs = f.split("/");
if (segs.includes("node_modules") || segs.includes(".next")) continue;
vueFiles.push(f);
}
}
if (missing.length) project.addSourceFilesAtPaths(missing);
} else {
// non-git fallback: broad globs (mts/cts/mjs/cjs per base dir included).
project.addSourceFilesAtPaths([
"src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "app/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"components/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "lib/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"pages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
]);
// Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
try {
const walk = (dir, depth) => {
if (depth > 40) return; // depth cap, matching sourceFingerprint()
let names; try { names = readdirSync(dir); } catch { return; } // skip unreadable dir, don't abort the whole walk
for (const name of names) {
if (name === "node_modules" || name === ".git" || name === ".next") continue;
const full = dir + "/" + name;
// lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
// a circular symlink would otherwise recurse until the stack overflows.
let st; try { st = lstatSync(full); } catch { continue; }
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) walk(full, depth + 1);
else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
}
};
walk(".", 0);
} catch { /* ignore — proceed without Vue */ }
}
// Build the virtual→real map and register each `<script>` block as a virtual
// ts-morph source. Files without a usable `<script>` block are silently
// skipped (template/style-only SFCs contribute nothing to the import graph).
const vueMap = Object.create(null); // virtualPath → realPath
const vueReal = Object.create(null); // realPath → true (for resolver)
for (const f of vueFiles) {
let text; try { text = readFileSync(f, "utf8"); } catch { continue; }
const block = extractVueScripts(text);
if (!block || !block.text.trim()) continue;
const vpath = vueVirtualPath(f, block.lang);
project.createSourceFile(`${cwdp}/${vpath}`, block.text, { overwrite: true });
vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
vueReal[`${cwdp}/${f}`] = true;
}
return { project, vueMap, vueReal, aliasOpts, packageAliasConfigs };
}
// ---------------------------------------------------------------------------
// build() — parse the repo, extract file imports/exports (+ which named
// symbols cross each edge), compute file PageRank, run the Aider-style
// identifier graph to rank individual symbols, and persist agentmap.json.
// ---------------------------------------------------------------------------
function build() {
const t0 = Date.now();
const { project, vueMap, vueReal, aliasOpts, packageAliasConfigs } = makeProject();
const { SyntaxKind } = tsMorph();
const CallExpression = SyntaxKind.CallExpression;
const cwd = process.cwd().replace(/\\/g, "/");
// rel() rewrites ts-morph file paths to repo-relative keys. For Vue virtual
// sources (`App.vue.ts`), vueMap rewrites back to the real `.vue` path so
// users never see virtual paths in the map, hubs, --relates, or --find.
const rel = (p) => {
const abs = p.replace(/\\/g, "/");
const real = vueMap[abs];
return (real || abs).replace(cwd + "/", "");
};
const files = {}, dependents = {}, features = {};
// PATH-SEGMENT exclusion (not substring) so e.g. components/.next-demo or
// src/node_modules_helper.ts are NOT wrongly excluded.
const excluded = (p) => { const segs = p.split("/"); return segs.includes("node_modules") || segs.includes(".next"); };
// Resolve a relative module specifier (from the importing file's dir) to an
// in-project source file key. Tries the bare path, then each extension, then
// /index.*. Returns the rel key or null. Powers side-effect (6b) + dynamic
// import()/require() (6c) edges that ts-morph's specifier resolution skips.
const RES_EXT = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
// posix-join that collapses "" / "." / ".." segments (no fs access).
const joinPosix = (a, b) => {
const parts = (a + "/" + b).split("/"); const st = [];
for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
return (a.startsWith("/") ? "/" : "") + st.join("/");
};
// resolve an absolute-ish path to an in-project source file, honoring
// extensionless + /index.* + .vue resolution (shared by relative + alias paths).
const tryResolveAt = (abs) => {
if (vueReal[abs]) return rel(abs);
let sf = project.getSourceFile(abs);
if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}.${e}`); if (sf) break; }
if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}/index.${e}`); if (sf) break; }
if (sf) return rel(sf.getFilePath());
if (vueReal[`${abs}.vue`]) return rel(`${abs}.vue`);
return null;
};
// #3 fix + monorepo: tsconfig/jsconfig baseUrl+paths alias resolution ("@/x",
// "#/x", "~/x") for side-effect/dynamic/require edges AND static imports when
// ts-morph can't resolve (cwd tsconfig lacks package paths). Per importing
// file, use the nearest discovered tsconfig paths.
const ROOTABS = process.cwd().replace(/\\/g, "/");
const resolveAlias = (spec, fromAbsDir) => {
const cfg = nearestAliasConfig(fromAbsDir, packageAliasConfigs, ROOTABS, aliasOpts);
const aliasBase = joinPosix(cfg.dir, cfg.baseUrl || ".");
const aliasEntries = Object.entries(cfg.paths || {});
for (const [pat, targets] of aliasEntries) {
const star = pat.indexOf("*");
let sub = null;
if (star === -1) { if (spec === pat) sub = ""; else continue; }
else {
const pre = pat.slice(0, star), suf = pat.slice(star + 1);
if (spec.length < pre.length + suf.length || !spec.startsWith(pre) || !spec.endsWith(suf)) continue;
sub = spec.slice(pre.length, spec.length - suf.length);
}
for (const tRaw of (Array.isArray(targets) ? targets : [targets])) {
const tStar = tRaw.indexOf("*");
const candidate = tStar === -1 ? tRaw : tRaw.slice(0, tStar) + sub + tRaw.slice(tStar + 1);
const hit = tryResolveAt(joinPosix(aliasBase, candidate));
if (hit) return hit;
}
}
return null;
};
const resolveSpec = (fromAbsDir, spec) => {
if (!spec.startsWith(".")) return resolveAlias(spec, fromAbsDir); // alias (baseUrl/paths) or null for non-relative
// normalize fromAbsDir + spec into an absolute-ish posix path
const join = (a, b) => {
const parts = (a + "/" + b).split("/"); const st = [];
for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
return (a.startsWith("/") ? "/" : "") + st.join("/");
};
const baseAbs = join(fromAbsDir, spec);
const tryGet = (abs) => { const sf = project.getSourceFile(abs); return sf ? sf : null; };
// Vue SFC: `import X from "./C.vue"` (exact) ALWAYS wins — the user wrote
// `.vue` explicitly, so we honor that. This check must stay BEFORE the
// TS/JS loop.
if (vueReal[baseAbs]) return rel(baseAbs);
let sf = tryGet(baseAbs);
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}.${e}`); if (sf) break; }
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}/index.${e}`); if (sf) break; }
// TS/JS SHADOW WINS: when a same-name .ts/.js exists, the extensionless
// `import "./C"` resolves to it (TS/JS-first priority is preserved). Only
// fall through to `.vue` as a last resort, when no TS/JS shadow exists.
if (sf) return rel(sf.getFilePath());
if (vueReal[`${baseAbs}.vue`]) return rel(`${baseAbs}.vue`);
return null;
};
const sourceFiles = project.getSourceFiles();
process.stderr.write(`# agentmap: parsing ${sourceFiles.length} source files…\n`);
for (const sf of sourceFiles) {
const path = rel(sf.getFilePath());
if (excluded(path)) continue;
try {
const fromDir = sf.getDirectoryPath().replace(/\\/g, "/");
const reExports = new Set(); // #2: names that are pass-through re-exports, not real uses
// exports, remembering which exported name was the file's DEFAULT export so
// default-import edges can later resolve "default" → the real symbol name.
let defaultExportName = null;
const exports = [...sf.getExportedDeclarations()].map(([name, d]) => {
const resolved = name === "default" ? (d[0]?.getName?.() ?? "default") : name;
if (name === "default") defaultExportName = resolved;
return { name: resolved, kind: d[0]?.getKindName?.() ?? "?" };
});
// Dependency edges from static imports + re-export barrels, with the set
// of named symbols crossing each edge (used for edge weights + the ident
// graph). importedSymbols[targetPath] = [names...].
const importedSymbols = {};
const addEdge = (tp, names) => {
if (!tp || excluded(tp)) return;
(importedSymbols[tp] ??= []).push(...names);
};
for (const imp of sf.getImportDeclarations()) {
if (imp.isTypeOnly()) continue; // type-only modules must not inflate runtime PageRank
const t = imp.getModuleSpecifierSourceFile();
if (t) {
// skip individual type-only named specifiers (`import { type X }`)
const names = imp.getNamedImports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
if (imp.getDefaultImport()) names.push("default"); // resolved to the real name in a post-pass below
if (imp.getNamespaceImport()) names.push("*");
addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
} else {
// 6b: side-effect or alias import — ts-morph may not resolve when cwd
// tsconfig lacks package paths; resolveSpec uses nearest tsconfig paths.
const spec = imp.getModuleSpecifierValue();
const tp = resolveSpec(fromDir, spec);
if (tp) {
const names = imp.getNamedImports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
if (imp.getDefaultImport()) names.push("default");
if (imp.getNamespaceImport()) names.push("*");
addEdge(tp, names.length ? names : ["*"]);
}
}
}
for (const exp of sf.getExportDeclarations()) {
if (exp.isTypeOnly()) continue; // type-only re-exports excluded from edges
const t = exp.getModuleSpecifierSourceFile();
if (t) {
const names = exp.getNamedExports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
addEdge(rel(t.getFilePath()), names); // keep the FILE-level edge (barrel depends on origin)
for (const n of names) reExports.add(n); // #2: mark as re-export so rankSymbols won't count it as a reference
}
}
// 6c: dynamic import("./x") and require("./x") with relative, in-project
// string-literal specifiers → edge with names ["*"]. Prefilter on raw text
// so we only AST-walk the few files that actually contain a dynamic call.
const srcText = sf.getFullText();
if (srcText.includes("import(") || srcText.includes("require(")) for (const call of sf.getDescendantsOfKind(CallExpression)) {
const expr = call.getExpression();
const kind = expr.getKind();
const isImport = kind === SyntaxKind.ImportKeyword;
const isRequire = expr.getText?.() === "require";
if (!isImport && !isRequire) continue;
const a0 = call.getArguments()[0];
if (!a0 || a0.getKind() !== SyntaxKind.StringLiteral) continue;
const tp = resolveSpec(fromDir, a0.getLiteralText());
if (tp) addEdge(tp, ["*"]);
}
const imports = Object.keys(importedSymbols);
for (const tp of imports) (dependents[tp] ??= []).push(path);
files[path] = { exports, imports, importedSymbols, defaultExportName, reExports: [...reExports] };
const feat = featureOf(path);
if (feat) (features[feat] ??= []).push(path);
} catch (e) {
// #1 fix: a single pathological file (malformed import specifier, ts-morph
// edge case) must NOT abort the whole map — skip it + warn, preserving the
// graceful-degradation contract agentmap advertises.
process.stderr.write(`# agentmap: skipped ${path} (parse error: ${e?.message ?? e})\n`);
}
}
// --- PHP support: discover and parse .php files, merge into graph.
{
const phpParser = getPhpParser();
let phpFiles = [];
const cwdp = process.cwd().replace(/\\/g, "/");
const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
if (listed.length) {
for (const f of listed) {
if (f.endsWith(".php")) { const segs = f.split("/"); if (!segs.includes("node_modules") && !segs.includes("vendor")) phpFiles.push(f); }
}
} else {
const walk = (dir, depth) => {
if (depth > 40) return;
let names; try { names = readdirSync(dir); } catch { return; }
for (const name of names) {
if (name === "node_modules" || name === ".git" || name === "vendor" || name === ".next") continue;
const full = dir + "/" + name;
let st; try { st = lstatSync(full); } catch { continue; }
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) walk(full, depth + 1);
else if (name.endsWith(".php")) phpFiles.push(full.replace(/^\.\//, ""));
}
};
walk(".", 0);
}
for (const f of phpFiles) {
try {
const absPath = join(cwdp, f);
const text = readFileSync(absPath, "utf8");
const ast = phpParser.parse(f, text);
const exports = phpParser.extractExports(ast);
const imports = phpParser.extractImports(ast);
const fileKey = f.replace(/\\/g, "/");
const phpExports = exports.map((e) => ({ name: e.name, kind: e.kind }));
const phpImports = [];
const phpImportedSymbols = {};
for (const imp of imports) {
if (imp.type === "use") {
const resolved = phpParser.resolveImport(imp.name, absPath, cwdp);
if (resolved) {
const targetKey = resolved.replace(cwdp + "/", "").replace(/\\/g, "/");
phpImports.push(targetKey);
const shortName = imp.name.split("\\").pop();
(phpImportedSymbols[targetKey] ??= []).push(shortName);
(dependents[targetKey] ??= []).push(fileKey);
}
}
if (imp.type === "include" || imp.type === "require") {
const resolved = phpParser.resolveImport(imp.path, absPath, cwdp);
if (resolved) {
const targetKey = resolved.replace(cwdp + "/", "").replace(/\\/g, "/");
phpImports.push(targetKey);
(phpImportedSymbols[targetKey] ??= []).push("*");
(dependents[targetKey] ??= []).push(fileKey);
}
}
}
const enhanced = {};
if (f.includes(".blade.php")) {
try {
const bladeResult = phpParser.parseBlade(f, text);
enhanced.blade = { directives: bladeResult.directives.length, includes: bladeResult.includes.length, livewireBindings: bladeResult.livewireBindings.length };
} catch {}
}
if (f.includes("/database/migrations/")) {
try {
const migResult = phpParser.parseMigration(f, text);
enhanced.migration = { tables: migResult.tables, columns: migResult.columns };
} catch {}
}
if (f.includes("/Console/Commands/") || f.endsWith("Command.php")) {
try {
const artResult = phpParser.parseArtisan(f, text);
enhanced.artisan = artResult.commands;
} catch {}
}
try {
const ddd = phpParser.detectDDD(f, exports);
if (ddd.length) enhanced.ddd = ddd;
const calls = phpParser.traceMethodCalls(f, ast);
if (calls.length) enhanced.calls = calls;
const types = phpParser.inferTypes(f, ast);
if (types.length) enhanced.types = types;
const mw = phpParser.detectMiddleware(f, ast);
if (mw.length) enhanced.middleware = mw;
} catch {}
files[fileKey] = { exports: phpExports, imports: phpImports, importedSymbols: phpImportedSymbols, dependents: dependents[fileKey] ?? [], ...(Object.keys(enhanced).length ? { enhanced } : {}) };
} catch (e) {
process.stderr.write(`# agentmap: skipped ${f} (PHP parse error: ${e?.message ?? e})\n`);
}
}
}
// --- TypeResolver: enrich PHP file entries with assignedTypes + phpDocTypes + chainTypes.
// composerResult is hoisted to build() scope so CMP-04 package→PageRank edges can use it.
let _composerResultForBuild = null;
{
const typeResolver = new TypeResolver();
const cwdp = process.cwd().replace(/\\/g, "/");
let psr4Map = {};
try {
const cp = new ComposerParser();
const parsed = cp.parse(process.cwd());
_composerResultForBuild = parsed;
psr4Map = parsed?.psr4Map ?? {};
} catch (_) { /* no composer.json — degrade gracefully */ }
for (const [filePath, entry] of Object.entries(files)) {
if (!filePath.endsWith(".php") || filePath.includes("/vendor/")) continue;
try {
const text = readFileSync(filePath, "utf8");
const { assignedTypes, phpDocTypes } = typeResolver.resolve(filePath, text, {}, cwdp);
const chainDepthLimit = (typeof config?.chainDepth === "number") ? config.chainDepth : DEFAULT_CHAIN_DEPTH;
const chainTypes = typeResolver.resolveChain(
typeResolver._lastRoot,
typeResolver._lastUseMap,
assignedTypes,
psr4Map,
cwdp,
chainDepthLimit
);
entry.assignedTypes = assignedTypes;
entry.phpDocTypes = phpDocTypes;
entry.chainTypes = chainTypes;
if (entry.enhanced?.types) {
entry.enhanced.types = entry.enhanced.types.map(t => ({
...t,
confidence: "HIGH",
source: "declared",
}));
}
} catch (_) {
entry.assignedTypes = [];
entry.phpDocTypes = [];
entry.chainTypes = [];
}
}
}
// 7: resolve default-import edges. A default import was recorded literally as
// "default"; rankSymbols skips "default", so default-exported symbols (the
// dominant Next.js component) never ranked. Map each "default" entry to the
// TARGET file's resolved default-export name so it forms reference edges.
for (const f of Object.values(files)) {
for (const tp of Object.keys(f.importedSymbols)) {
const dn = files[tp]?.defaultExportName;
if (!dn || dn === "default") continue;
f.importedSymbols[tp] = f.importedSymbols[tp].map((n) => (n === "default" ? dn : n));
}
}
for (const p in files) files[p].dependents = dependents[p] ?? [];
// --- File PageRank: edges importer→imported, weighted by # symbols crossed.
const nodes = Object.keys(files);
const fileEdges = [];
for (const [p, f] of Object.entries(files))
for (const tp of f.imports)
if (files[tp]) fileEdges.push({ from: p, to: tp, weight: (f.importedSymbols[tp] || []).length || 1 });
// --- CMP-04: Package→file PageRank edge merging.
// Add synthetic package nodes to the graph so packages get their own PageRank score.
// Edges: each PHP source file that depends on a package gets an edge → package node.
// Weight: 0.1× average direct import weight (subtle boost, per CONTEXT.md locked decision).
// Cap: 1000 edges per package to prevent edge explosion on large projects.
const pkgNodes = []; // synthetic package node ids
const rawPkgs = _composerResultForBuild?.packages ?? [];
const requirePkgs = rawPkgs.filter(p => p.type === "require");
if (requirePkgs.length > 0) {
const phpSrcFiles = Object.keys(files).filter(p => p.endsWith(".php") && !p.includes("/vendor/"));
const avgWeight = fileEdges.length > 0
? fileEdges.reduce((s, e) => s + e.weight, 0) / fileEdges.length
: 1;
const pkgEdgeWeight = +(avgWeight * 0.1).toFixed(6) || 0.1;
for (const pkg of requirePkgs) {
const pkgNode = `__pkg__${pkg.to}`;
pkgNodes.push(pkgNode);
// Add edges from PHP source files → package node (up to PKG_EDGE_CAP per package)
let edgeCount = 0;
for (const srcFile of phpSrcFiles) {
if (edgeCount >= PKG_EDGE_CAP) {
process.stderr.write(`# agentmap: package edge cap reached for ${pkg.to} (${PKG_EDGE_CAP} edges)\n`);
break;
}
fileEdges.push({ from: srcFile, to: pkgNode, weight: pkgEdgeWeight });
edgeCount++;
}
}
}
const allNodes = [...nodes, ...pkgNodes];
const fileRank = pagerank(allNodes, fileEdges);
for (const p of nodes) files[p].pagerank = +(fileRank[p] || 0).toFixed(6);
// Attach pagerank to each package entry (for --packages and --print output).
const packagesWithRank = rawPkgs.map(pkg => {
const pkgNode = `__pkg__${pkg.to}`;
const pr = fileRank[pkgNode];
return pr !== undefined ? { ...pkg, pagerank: +(pr).toFixed(6) } : pkg;
});
// --- Symbol ranking (Aider-style): identifier graph from named imports.
const rankedSymbols = rankSymbols(files, null);
// hubs: now PageRank-ranked (raw dependent count shown alongside).
const hubs = nodes
.map((p) => [p, files[p].pagerank, files[p].dependents.length])
.sort((a, b) => b[1] - a[1])
.slice(0, HUBS_LIMIT)
.map(([p, pr, deg]) => `${p} (deg ${deg}, pr ${pr})`);
// defaultExportName was only needed for the fix-#7 post-pass — drop it before
// persisting so the on-disk `files` shape stays stable.
for (const p of nodes) delete files[p].defaultExportName;
// --- LegacyDetector: detect non-PSR-4 / legacy code indicators.
let legacyWarningsForBuild = [];
try {
const cr = _composerResultForBuild;
legacyWarningsForBuild = new LegacyDetector().detect(
process.cwd(),
cr?.psr4Map ?? {},
cr?.classmaps ?? [],
cr?.autoFiles ?? []
);
} catch (_) { /* degrade gracefully */ }
const sha = currentSha();
const out = {
schema: SCHEMA_VERSION, generatedSha: sha, dirty: dirtyCount(), fileCount: nodes.length,
// fingerprint lets non-git repos (sha === "") trust the cache across runs.
fingerprint: sha ? undefined : sourceFingerprint(),
hubs, features, rankedSymbols: rankedSymbols.slice(0, RANKED_SYMBOLS_LIMIT), files,
packages: packagesWithRank,
legacyWarnings: legacyWarningsForBuild,
};
mkdirSync(".claude/agentmap", { recursive: true });
// Atomic write: tmp + rename so a concurrent background rebuild can never
// expose a torn/truncated map.json to a reader.
const tmp = MAP + ".tmp";
writeFileSync(tmp, JSON.stringify(out));
renameSync(tmp, MAP);
process.stderr.write(`# agentmap: built ${nodes.length} files in ${Date.now() - t0}ms\n`);
return out;
}
// Build the Aider-style identifier graph from the file map and return a
// ranked list of { file, name, kind, rank }. `focus` (Set of paths) +
// derived mentioned idents personalize the ranking when given.
function rankSymbols(files, focus) {
const defines = new Map(); // ident -> Set(file)
const references = new Map(); // ident -> [file...] (multiplicity)
const definition = new Map(); // `${file}|${ident}` -> {file, name, kind}
for (const [p, f] of Object.entries(files)) {
for (const e of f.exports) {
getOrSet(defines, e.name, () => new Set()).add(p);
definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
}
}
for (const [p, f] of Object.entries(files)) {
const reExp = new Set(f.reExports || []); // #2: pass-through re-exports aren't real references
for (const tp of f.imports)
for (const name of f.importedSymbols[tp] || [])
if (name !== "*" && name !== "default" && !reExp.has(name)) getOrSet(references, name, () => []).push(p);
}
// mentioned idents from focus files' exports + their basenames
let mentioned = null;
if (focus && focus.size) {
mentioned = new Set();
for (const p of focus) {
for (const e of (files[p]?.exports || [])) mentioned.add(e.name);
const base = p.split("/").pop().replace(/\.[^.]+$/, "");
mentioned.add(base);
}
}
const nodes = Object.keys(files);
const edges = [];
for (const ident of defines.keys()) {
if (!references.has(ident)) continue;
const mul = identMul(ident, defines.get(ident).size, mentioned);
const counts = new Map();
for (const refFile of references.get(ident)) counts.set(refFile, (counts.get(refFile) || 0) + 1);
for (const [refFile, n] of counts)
for (const defFile of defines.get(ident)) {
if (refFile === defFile) continue;
let useMul = mul;
if (focus && focus.has(refFile)) useMul *= FOCUS_BOOST;
edges.push({ from: refFile, to: defFile, weight: useMul * Math.sqrt(n), ident });
}
}
// personalization seeds: focus files + files whose name matches a mention
let pers = null;
if (focus && focus.size) {
pers = {};
const unit = 100 / nodes.length;
for (const p of nodes) {
let v = 0;
if (focus.has(p)) v += unit;
const parts = new Set([...p.split("/"), p.split("/").pop(), p.split("/").pop().replace(/\.[^.]+$/, "")]);
if (mentioned && [...parts].some((x) => mentioned.has(x))) v += unit;
if (v > 0) pers[p] = v;
}
if (!Object.keys(pers).length) pers = null;
}
const rank = pagerank(nodes, edges, pers ? { personalization: pers } : {});
// redistribute each file's rank across its out-edges onto (defFile, ident)
const out = new Map(); // `${file}|${ident}` -> total weight
const totalW = new Map();
for (const e of edges) totalW.set(e.from, (totalW.get(e.from) || 0) + e.weight);
for (const e of edges) {
const share = (rank[e.from] || 0) * e.weight / (totalW.get(e.from) || 1);
const k = `${e.to}|${e.ident}`;
out.set(k, (out.get(k) || 0) + share);
}
const ranked = [...out.entries()]
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.map(([k, r]) => ({ ...(definition.get(k) || { file: k.slice(0, k.lastIndexOf("|")), name: k.slice(k.lastIndexOf("|") + 1), kind: "?" }), rank: +r.toFixed(6) }))
.filter((d) => !(focus && focus.has(d.file)));
// Aider parity (#8): keep exported symbols that NOTHING imports (Aider gives
// them a 0.1 self-edge; pagerank() skips self-loops, so we append them here
// with a tiny baseline rank below the lowest real rank). Lets public-API
// entry points + default-export components surface in the digest tail.
const present = new Set(ranked.map((d) => `${d.file}|${d.name}`));
const lowest = ranked.length ? ranked[ranked.length - 1].rank : 0;
const baseline = +(lowest - 1e-6 > 0 ? lowest - 1e-6 : 1e-6).toFixed(6);
const tail = [];
for (const def of definition.values()) {
const k = `${def.file}|${def.name}`;
if (present.has(k)) continue;
if (focus && focus.has(def.file)) continue;
tail.push({ ...def, rank: baseline });
}
tail.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
return [...ranked, ...tail];
}
// Serve the cached map only when provably current: same HEAD, known schema,
// clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
function ensureFresh() {
const sha = currentSha();
// Read the namespaced path; fall back to the legacy '.claude/agentmap.json'
// when the new path is missing (migration from a pre-namespacing install — the
// legacy file is still trustworthy, the next build() rewrites to the new path).
const mapPath = existsSync(MAP) ? MAP : (existsSync(MAP_LEGACY) ? MAP_LEGACY : MAP);