-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathagentmap.mjs
More file actions
executable file
·4746 lines (4597 loc) · 261 KB
/
Copy pathagentmap.mjs
File metadata and controls
executable file
·4746 lines (4597 loc) · 261 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, lstatSync, chmodSync, realpathSync } 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, resolve } 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"));
const MAP = ".claude/agentmap/map.json";
const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
const MAP_DIRTY = ".claude/agentmap/map.dirty.json"; // dirty-tree build cache, keyed by dirtyFingerprint (Batch 3 Tier 1)
const FACTS = ".claude/agentmap/facts.json"; // raw per-file facts snapshot for incremental rebuild (Batch 3 Tier 2)
// Call-edge index, written ONLY by `--build-edges` (which the post-commit hook
// runs in the background). Deliberately a SEPARATE file from map.json, not a new
// field on it: building it costs ~4x a normal build (the type-checker's
// go-to-definition per call site is irreducible — measured 5.9s vs 1.5s on a
// 250-file repo even after prefiltering), and every other command
// (--relates/--find/--hubs/--map) must not pay that. A missing or stale sidecar
// is never an error: --callers silently falls back to the live ts-morph walk it
// has always used, so this is a pure speedup with no new failure mode.
const EDGES = ".claude/agentmap/calledges.json";
// Version of the EDGE ROW FORMAT, independent of SCHEMA_VERSION (which versions
// map.json). Bump on any change to what a row means or contains — otherwise an
// upgrade silently serves a sidecar written by older logic, and since a missing
// edge just looks like "no caller", the failure is a confident wrong answer with
// no symptom. Bumped to 2 when rows gained export-alias names.
const EDGES_FORMAT = 4;
// 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.
// Bumped 3 → 4: per-file `locals` (non-exported top-level declarations) now
// persisted for --find/--any discovery. Old caches (which lack `locals`) rebuild
// on upgrade so a private helper becomes findable without waiting for the next commit.
// Bumped 5 → 6: per-export `definedIn`/`external` (the real declaration site behind
// a re-export barrel) and per-file `typeOnlyImports`/`typeOnlyDependents` (edges a
// fully `import type` declaration used to drop on the floor). Both are spread
// conditionally, so a repo with no barrels and no type-only imports serialises
// byte-identically to schema 5; caches rebuild once on upgrade.
// Bumped 6 → 7: truncation accounting (`incomplete` / `skippedCount` / `skipped` /
// `skippedTruncated`). Spread conditionally, so a repo that indexes every file
// serialises byte-identically to schema 6. The bump is what forces a rebuild of
// caches written BEFORE this feature — those could already be missing files with no
// way to tell, which is the exact bug being fixed, so serving them unchanged would
// leave the lie in place until HEAD happened to move.
// Bumped 7 → 8: `tryResolveAt` gained the emitted-extension rung, so a NodeNext
// `~/x.js` alias specifier now resolves to `x.ts` instead of dropping the edge.
// The map SHAPE is unchanged — this bump exists because the CONTENT is not: a
// cache written by an older build is keyed only by HEAD sha, so on a repo whose
// sha has not moved it would keep serving the missing edges (and a `dependents (0)`
// on a file with real importers) indefinitely after upgrade. Same reasoning as the
// 6 → 7 bump: the stale cache encodes exactly the bug being fixed.
const SCHEMA_VERSION = 8;
// --- .agentmapignore + .d.ts default-exclude (config-file / flag scoping) ------
// The map-cache path used when --include-dts is set. Kept SEPARATE from map.json
// so the two modes never collide: the default (`.d.ts` excluded) map.json — the
// one the post-commit hook writes and every normal query reads — stays untouched
// and byte-identical, while --include-dts builds/reads its own cache.
const MAP_DTS = ".claude/agentmap/map.dts.json"; // --include-dts full-build cache
const AGENTMAPIGNORE = ".agentmapignore"; // repo-root ignore file (gitignore-ish)
// Module-scoped backend config, (re)resolved at the start of every extractFacts()
// so importing this module stays side-effect-free and each build() re-reads
// .agentmapignore from disk. main() flips INCLUDE_DTS via --include-dts.
let INCLUDE_DTS = false;
// Non-exported TOP-LEVEL declarations (module-scope function/class/interface/type/
// enum/const) are always indexed into each file's `locals` at build time and
// surfaced ONLY by --find / --any — they are NEVER ranked (rankSymbols / --map /
// --symbols / --hubs read only `exports`), so the focused digest stays byte-identical.
// --no-locals flips this OFF as a query-time filter that hides them from --find/--any.
let INCLUDE_LOCALS = true;
// Minimal, dependency-free .agentmapignore matcher. Reads repo-root
// `.agentmapignore` (gitignore-STYLE, a documented SUBSET — see below) and returns
// a predicate `(relPath) => boolean` (true ⇒ ignore). Returns null when the file
// is absent/empty so callers skip matching entirely (byte-identical to today).
// Supported subset (kept deliberately small + predictable):
// • blank lines and `#` comments are skipped;
// • a leading `/` anchors the pattern to the repo root (else it matches at any
// depth — the path OR any `/`-bounded segment prefix);
// • a trailing `/` marks a directory prefix (matches everything under it);
// • `*` matches any run of non-`/` chars; no `**`, `?`, `[...]`, or negation.
// Anything outside this subset is treated literally. Documented in README.
function loadIgnoreMatcher() {
let raw;
try { raw = readFileSync(AGENTMAPIGNORE, "utf8"); } catch { return null; }
const rules = [];
for (let line of raw.split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith("#")) continue;
const anchored = line.startsWith("/");
if (anchored) line = line.slice(1);
const dir = line.endsWith("/");
if (dir) line = line.slice(0, -1);
if (!line) continue;
// ReDoS guard: this glob subset has NO `**` semantics, so a run of `*` means
// the same as one `*`. Collapse runs BEFORE translating — otherwise adjacent
// `[^/]*[^/]*…` groups backtrack catastrophically (a `*`×50 line hung the
// matcher ~80s, freezing build + the post-commit hook + the MCP server). Also
// skip a pathologically long pattern outright.
if (line.length > 512) continue;
line = line.replace(/\*{2,}/g, "*");
// Escape regex metachars in the literal, then turn `*` into `[^/]*`. Anchored
// ⇒ `^pat`; unanchored ⇒ match the whole path OR any `/`-bounded segment
// prefix so a bare `dist` ignores `pkg/dist/x` too.
const body = line.replace(/[.+^$(){}|\[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
const tail = dir ? "(?:/|$)" : "$";
const re = anchored ? new RegExp("^" + body + tail)
: new RegExp("(?:^|/)" + body + tail);
rules.push(re);
}
if (!rules.length) return null;
return (p) => rules.some((re) => re.test(p));
}
// ---------------------------------------------------------------------------
// 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 CONTENT_LINES_LIMIT = 40; // # of git-grep lines shown in the --any content fallback
const SYMBOL_MATCH_LIMIT = 50; // max --find/--any symbol matches shown (ranked by PageRank), else token blowup
const DEPTH_CAP = 5; // hard ceiling on --callers/--calls --depth (transitive call graph)
const CLOSURE_FRONTIER_CAP = 200; // per-level frontier cap for --depth traversal (hub-explosion guard)
const CLOSURE_TOTAL_CAP = 500; // global emitted-node cap across all --depth levels
const BM25_K1 = 1.2; // BM25 term-frequency saturation (classic default)
const BM25_B = 0.75; // BM25 length normalization (classic default)
const PR_WEIGHT = 0.5; // PageRank fusion strength: finalScore = bm25 * (1 + PR_WEIGHT*normPR)
const LEXICAL_DOC_LIMIT = 5000; // max symbol-docs indexed into map.json (monorepo bloat guard)
const LEXICAL_STOPWORDS = new Set(["the", "a", "an", "that", "which", "is", "of", "for", "to", "where", "in", "and", "or"]);
const RELATED_LIMIT = 10; // # of related files shown by --relates
const SYMS_PER_FILE = 8; // per-file symbol cap in the --map digest
const EXPORT_NODE_CAP = 60; // max nodes in --export dot/mermaid (top-N by pagerank) so a big repo stays readable
const DEFAULT_SYMBOLS = 30; // default count for --symbols with no n
const SKIPPED_LIST_LIMIT = 100; // max per-file skip records persisted (skippedCount stays exact)
const MAXBUF = 64 * 1024 * 1024; // child_process maxBuffer — avoid ENOBUFS on big git output
// ---------------------------------------------------------------------------
// TS/JS backend descriptor — the single source of truth for which extensions
// this backend handles. Hoisted out of the 5 sites that used to hardcode the
// list (dirty-check, source fingerprint, ts-morph discovery, non-git glob
// fallback, specifier resolution) so a second-language backend can be a drop-in
// later (Batch 2 seam). Regex alternation order is irrelevant here — each source
// path ends in exactly one extension — so one canonical list stays behavior-
// identical to the old per-site orderings.
//
// CODE_EXT — extensions ts-morph parses directly. `.vue` is deliberately
// NOT here: a Vue SFC is not TS/JS, so it's indexed via a virtual
// `.vue.ts`/`.vue.js` source (see extractVueScripts), not handed
// to ts-morph raw.
// SOURCE_EXT — everything that counts as a "source file" for freshness / dirty
// detection. INCLUDES `.vue` so editing an SFC busts the cache.
// ---------------------------------------------------------------------------
const CODE_EXT = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
const SOURCE_EXT = [...CODE_EXT, "vue"];
const extBrace = (list) => `{${list.join(",")}}`; // glob brace body
const CODE_EXT_RE = new RegExp(`\\.(${CODE_EXT.join("|")})$`); // ts-morph-parseable files
const SOURCE_EXT_RE = new RegExp(`\\.(${SOURCE_EXT.join("|")})$`); // any source file (incl. .vue)
// ---------------------------------------------------------------------------
// Language census — the demand instrument.
//
// The old plan gated multi-language work on "post-distribution demand actually
// asks for Python". That gate could never fire: it waited for an ISSUE, and a
// user whose repo agentmap cannot read gets a useless map and leaves without
// filing anything. Two people forked to add a language (agentmap-go,
// agentmap-php) rather than ask for one — the demand was real and the detector
// was pointed the wrong way.
//
// So: count what is actually in the repo, and when a language agentmap does not
// support dominates, say so plainly and point at one countable place to vote.
// No telemetry, ever (ROADMAP Phase 1A) — the count is computed locally, printed
// locally, and never leaves the machine. That is a deliberate trade: the signal
// is lagging and loudness-biased, and that is still better than the trust cost
// of phoning home from a dev tool.
//
// Not exhaustive, and deliberately so — this is a ballot, not a taxonomy. Only
// languages a ts-morph-shaped tool could plausibly grow a backend for.
const FOREIGN_EXT = {
py: "Python", pyi: "Python",
go: "Go",
rs: "Rust",
java: "Java",
kt: "Kotlin", kts: "Kotlin",
rb: "Ruby",
php: "PHP",
cs: "C#",
swift: "Swift",
scala: "Scala",
dart: "Dart",
ex: "Elixir", exs: "Elixir",
c: "C", h: "C",
cpp: "C++", cc: "C++", cxx: "C++", hpp: "C++", hh: "C++",
};
// A single unsupported language must be at least this share of all counted
// source files before agentmap says anything. Set so a repo with a handful of
// build scripts in another language stays quiet.
const CENSUS_MIN_SHARE = 0.3;
const VOTE_URL = "https://github.com/raymondchins/agentmap/issues/43";
// Bucket the repo's source files by language. Counts ONLY code — a docs- or
// fixture-heavy repo must not tip the census on .md/.json/.lock/images.
// Returns null when there is nothing to say.
function languageCensus(files) {
let supported = 0;
const foreign = new Map();
for (const f of files) {
const ext = f.slice(f.lastIndexOf(".") + 1).toLowerCase();
if (SOURCE_EXT.includes(ext)) { supported++; continue; }
const lang = FOREIGN_EXT[ext];
if (lang) foreign.set(lang, (foreign.get(lang) ?? 0) + 1);
}
let total = supported;
for (const n of foreign.values()) total += n;
if (!total) return null;
let top = null;
for (const [lang, count] of foreign) if (!top || count > top.count) top = { lang, count };
if (!top || top.count / total < CENSUS_MIN_SHARE) return null;
return { ...top, supported, total, share: top.count / total };
}
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*",
// more private-key / keystore shapes + SSH key variants beyond id_rsa.
":!*.p8", ":!*.jks", ":!*.keystore", ":!id_ed25519*", ":!id_ecdsa*",
// conventionally-named credential dotfiles (root + any depth). Deliberately NOT
// a broad `*token*` name match — that would over-exclude source files like
// tokenizer.ts / token.ts / useToken.tsx from the content search.
":!.npmrc", ":!**/.npmrc", ":!.netrc", ":!**/.netrc",
":!.git-credentials", ":!**/.git-credentials", ":!.pgpass", ":!**/.pgpass",
":!.htpasswd", ":!**/.htpasswd", ":!.pypirc", ":!**/.pypirc",
// name-substring matches: `*password*` (not `*.password*`) so a plain
// password.txt / passwords.json is excluded, not just foo.password.ts.
":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*password*",
];
// Neutralise terminal control sequences in content-search output. These lines are
// the ONLY place agentmap echoes raw repository bytes back out — everything else it
// prints is its own metadata. `git grep -I` already skips binary files, so what is
// left is a TEXT file with escapes deliberately embedded in it: an ESC[2J or a
// cursor-up run can blank the terminal or overwrite the file:line prefix, making a
// hit appear to come from a file it did not. Replaces C0 controls (keeping \t) and
// DEL with U+FFFD, so the line count and column alignment survive and the escape
// becomes visible instead of executable. Applied inside contentSearch() rather than
// at the two print sites, so prose AND --json get the same sanitised bytes — a JSON
// consumer that parses and echoes a line is exposed to exactly the same trick, and
// MCP's JSON.stringify escaping protects the model but not that consumer.
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\x00-\x08\x0B-\x1F\x7F]/g;
const sanitizeContentLines = (s) => s.replace(CONTROL_CHARS, "�");
const contentSearch = (q) => {
try {
return sanitizeContentLines(execFileSync("git", ["-c", "core.quotePath=off", "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");
// git ls-files (tracked + untracked-not-ignored) as an array. `-z` (NUL-separated)
// so non-ASCII / space / special-char filenames come back RAW — the default
// newline output C-quotes them (`"src/caf\303\251.ts"`), which fails the extension
// check and silently drops those files from the map. Returns [] on any git error.
const gitListFiles = () => {
try { return execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).split("\0").filter(Boolean); }
catch { return []; }
};
// Parse `git status --porcelain` into entries { code, path, oldPath? }. One parse
// feeds both the dirty-SOURCE list (dirtyFiles) and the dirty-CONFIG list
// (dirtyConfigFiles), so the freshness gate + cache key can't diverge.
// - RAW (UNTRIMMED) output: `sh()` trims, which strips the leading space of an
// unstaged " M path" line and shifts the fixed-column parse (dropping the path's
// first char); the fingerprint needs the true path for lstat.
// - core.quotePath=off so non-ASCII paths come back as UTF-8, not C-quoted octal
// (`"src/caf\303\251.ts"`) — otherwise those files silently escape detection.
// - --untracked-files=all so a new file inside a brand-new untracked DIR is listed
// individually (default "all" folds it to "?? newdir/" and the regex misses it).
function parsePorcelain() {
let raw;
try { raw = execFileSync("git", ["-c", "core.quotePath=off", "status", "--porcelain", "--untracked-files=all"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }); }
catch { return []; } // not a git repo / git failure → treat as no dirty files
const out = [];
for (const l of raw.split("\n")) {
if (!l) continue;
const code = l.slice(0, 2); // porcelain status code (XY)
let p = l.slice(3); // strip "XY " status prefix
let oldPath;
// 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(code) && p.includes(" -> ")) {
const parts = p.split(" -> ");
oldPath = parts[0].replace(/^"|"$/g, ""); // rename/copy: remember old path
p = parts.pop(); // …keep new path
}
p = p.replace(/^"|"$/g, ""); // unquote any residual (literal quote/newline)
out.push({ code, path: p, oldPath });
}
return out;
}
// tsconfig.json / jsconfig.json (any name e.g. tsconfig.build.json), at any depth.
const CONFIG_DIRTY_RE = /(^|\/)(tsconfig|jsconfig)(\.[\w.-]+)?\.json$/;
// Dirty SOURCE files — the Tier-2 changed-set + Tier-1 fingerprint input. BOTH
// sides of a rename count: a source file renamed to a NON-source name (git mv
// a.ts a.txt) still removes it from the map, so the cache must bust.
function dirtyFiles() {
return parsePorcelain().filter((e) => SOURCE_EXT_RE.test(e.path) || (e.oldPath && SOURCE_EXT_RE.test(e.oldPath)));
}
// Dirty tsconfig/jsconfig files. These aren't SOURCE (nothing to reparse for their
// own text) but editing them silently changes alias/path RESOLUTION for every
// file, so they must bust the cache WITHOUT entering the source changed-set (which
// would make Tier-2 try to parse JSON as TS). Gate freshness + fingerprint only.
function dirtyConfigFiles() {
return parsePorcelain().filter((e) => CONFIG_DIRTY_RE.test(e.path) || (e.oldPath && CONFIG_DIRTY_RE.test(e.oldPath)));
}
const dirtyCount = () => dirtyFiles().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; };
// Project a stored export entry into a --find/--any match. Barrel provenance rides
// along so an agent is sent to the file it can actually edit rather than to the
// index.ts that only forwards the name. Defined once because --find and --any each
// have a CLI copy AND an MCP copy, and a four-way copy-paste is how a fix lands in
// one path and silently misses the other three.
const symMatch = (file, e) => ({
file, name: e.name, kind: e.kind,
...(e.definedIn ? { definedIn: e.definedIn } : {}),
...(e.external ? { external: true } : {}),
});
// Human-readable suffix for a match/export entry that came through a barrel.
const originNote = (e) => (e.definedIn ? ` → defined in ${e.definedIn}` : e.external ? " → defined outside the repo" : "");
// Compile-time-only relationships, emitted next to (never merged into) the runtime
// ones. Omitted entirely when empty so a repo without type imports is unchanged.
const typeOnlyOut = (f) => ({
...(f.typeOnlyImports?.length ? { typeOnlyImports: f.typeOnlyImports } : {}),
...(f.typeOnlyDependents?.length ? { typeOnlyDependents: f.typeOnlyDependents } : {}),
});
// Rank symbol matches ({file,name,kind}): real declaration sites first, then by
// their containing file's PageRank (desc), tie-broken by path then name for a
// stable order. A broad --find/--any on a large repo can match thousands of
// exports; showing them all defeats the token-savings point, so callers slice the
// ranked list to SYMBOL_MATCH_LIMIT and surface a "showing N of M" footer.
//
// The declaration-first tier exists because PageRank alone actively works against
// the question --find answers. A barrel is imported by everything, so index.ts
// outranks the file that actually declares the symbol — and the top-1 answer sends
// the agent to a line that only forwards the name. EVAL.md's own caveat (3) named
// this as why top-1 trailed top-3, and it is the one measured place where naive
// grep beat agentmap.
//
// `!definedIn && !external` is the predicate, not `!definedIn` alone: a re-export
// of something outside the repo carries `external: true` and NO `definedIn`, so
// the naive form would promote exactly the rows that cannot be edited at all.
const isDeclarationSite = (m) => !m.definedIn && !m.external;
const rankMatches = (files, matches) =>
matches.slice().sort((a, b) =>
(isDeclarationSite(b) ? 1 : 0) - (isDeclarationSite(a) ? 1 : 0)
|| (files[b.file]?.pagerank ?? 0) - (files[a.file]?.pagerank ?? 0)
|| (a.file < b.file ? -1 : a.file > b.file ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
// Split an identifier / path segment into lowercased subtokens on camelCase,
// snake_, kebab-, and digit boundaries. `rankMatches`→[rank,matches];
// `authRetry`→[auth,retry]; `src/lib/http2Client`→[src,lib,http,client]. The
// SINGLE tokenizer used by BOTH the lexical corpus and the query, so they can
// never drift.
function splitIdent(s) {
return String(s)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase boundary
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // HTTPClient → HTTP Client
.split(/[^a-zA-Z0-9]+|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])/)
.map((t) => t.toLowerCase())
.filter(Boolean);
}
// BM25 lexical retrieval over the persisted per-symbol index (`data.lexical`),
// fused with file PageRank so a strong hit in an important file wins ties. Answers
// vague NL queries ("where's the auth retry logic") that exact --find/--any miss.
// Pure + dependency-free. Returns { matches:[{file,name,kind,score}], total }.
function bm25Search(lexical, files, rawQuery, { limit = SYMBOL_MATCH_LIMIT } = {}) {
if (!lexical || !lexical.docs || !lexical.docs.length) return { matches: [], total: 0 };
const qToks = splitIdent(rawQuery).filter((t) => !LEXICAL_STOPWORDS.has(t));
if (!qToks.length) return { matches: [], total: 0 };
const { N, avgdl, df, docs } = lexical;
let maxPr = 0;
for (const p in files) maxPr = Math.max(maxPr, files[p].pagerank || 0);
const scored = [];
for (const d of docs) {
let s = 0;
for (const t of qToks) {
const tf = d.tf[t];
if (!tf) continue;
const n = df[t] || 0;
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
s += idf * (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * (d.len / (avgdl || 1))));
}
if (s <= 0) continue;
const normPr = maxPr ? (files[d.file]?.pagerank || 0) / maxPr : 0;
scored.push({ file: d.file, name: d.name, kind: d.kind, score: +(s * (1 + PR_WEIGHT * normPr)).toFixed(6) });
}
scored.sort((a, b) => b.score - a.score
|| (a.file < b.file ? -1 : a.file > b.file ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
return { matches: scored.slice(0, limit), total: scored.length };
}
// 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.
// SOURCE_EXT_RE includes `.vue` so editing a Vue SFC invalidates the cache too.
// The one recursive source walk. Two callers needed exactly this traversal and
// differed only in what they do with a file, so it existed twice with the safety
// rules restated in both — the failure mode being a fix applied to one copy. The
// rules are load-bearing and each is here for a specific reason:
// • depth cap 40 — don't fully walk a pathologically deep tree;
// • per-directory try/catch — one permission-denied subdir must NOT abort the
// WHOLE walk. In sourceFingerprint() that would return "" and silently
// disable caching, which looks like a performance mystery, not an error;
// • lstatSync, NOT statSync, so a symlink reports as itself rather than its
// target, and symlinked entries are skipped entirely — never recursed into,
// never stat'd through — so a circular symlink cannot recurse until the
// stack overflows;
// • node_modules/.git/.next pruned before any stat.
// `onFile(fullPath, name, stat)` is called for every non-directory survivor.
function walkSources(dir, onFile, depth = 0) {
if (depth > 40) return;
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; try { st = lstatSync(full); } catch { continue; }
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) walkSources(full, onFile, depth + 1);
else onFile(full, name, st);
}
}
function sourceFingerprint() {
try {
const entries = [];
walkSources(".", (full, name, st) => {
if (SOURCE_EXT_RE.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
});
entries.sort();
return createHash("sha1").update(entries.join("\n")).digest("hex");
} catch { return ""; }
}
// Fingerprint of the DIRTY working-tree state for the dirty-map cache (Tier 1).
// sha1 over HEAD sha + sorted per-dirty-file tokens: an existing file →
// "path:mtimeMs:size" (mirrors sourceFingerprint, and only lstat's the handful
// of dirty files, not the whole tree); a deleted/unstattable file → "CODE:path";
// a rename additionally appends "R:old->new" so it can't collide with an
// independent add+delete. HEAD is included so the same edit against a different
// HEAD keys differently. The key changes iff the dirty rebuild's output would.
function dirtyFingerprint(sha, list, configList = []) {
const toks = [];
// Source dirty files, plus dirty configs (c: marker) so a tsconfig/jsconfig edit
// — changes resolution but isn't reparsed as source — still re-keys the cache.
const entries = [...list, ...configList.map((e) => ({ ...e, _cfg: true }))];
for (const { code, path, oldPath, _cfg } of entries) {
let tok = _cfg ? "c:" : "";
try { const st = lstatSync(path); tok += `${path}:${st.mtimeMs}:${st.size}`; }
catch { tok += `${(code || "").trim() || "?"}:${path}`; } // deleted / unstattable
if (oldPath) tok += `\0R:${oldPath}->${path}`; // rename ≠ add+delete
toks.push(tok);
}
toks.sort();
return createHash("sha1").update("HEAD:" + sha + "\n" + toks.join("\n")).digest("hex");
}
// =============================================================================
// 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;
}
// React Server/Client boundary — read the directive PROLOGUE (compiler-accurate,
// not a text grep): the leading string-literal ExpressionStatements. Returns
// 'client' for `'use client'`, 'server' for `'use server'`, else null. Tolerates
// other prologue directives (`'use strict'`) and stops at the first non-directive
// statement. `SyntaxKind` is passed in so this stays a pure module-scope helper.
function rscBoundary(sf, SyntaxKind) {
for (const st of sf.getStatements()) {
if (st.getKind() !== SyntaxKind.ExpressionStatement) break; // prologue ended
const e = st.getExpression();
if (e.getKind() !== SyntaxKind.StringLiteral) break;
const v = e.getLiteralText();
if (v === "use client") return "client";
if (v === "use server") return "server";
}
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;
}
// posix-join that resolves ./ .. and returns an absolute-ish path; passes an
// already-absolute `b` through. Used to anchor a tsconfig baseUrl to the dir of
// the config that DEFINED it (module-scope twin of the joinPosix inside extractFacts).
function joinPosixAbs(a, b) {
if (/^(\/|[A-Za-z]:[\\/])/.test(b)) return b.replace(/\\/g, "/"); // already absolute
const abs = a.replace(/\\/g, "/");
const parts = (abs + "/" + b).split("/"); const st = [];
for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
return (abs.startsWith("/") ? "/" : "") + st.join("/");
}
// 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, _memo = new Map()) {
// Terminating condition for `extends`: read each config AT MOST ONCE per
// top-level call. Without this, `extends` fans out as branch^depth and the
// `_depth < 10` cap below bounds DEPTH but not WORK: since TS 5.0 `extends`
// may be an array, so one self-referencing file (`extends: ["./tsconfig.json"
// ×4]`) is 4^10 readFileSync+JSON.parse calls and spins a core indefinitely.
// It is a synchronous loop, so the process never reaches a signal handler —
// SIGTERM is ignored and only SIGKILL stops it. The in-flight `null` doubles
// as the cycle guard. A fresh Map per top-level call keeps every acyclic,
// non-duplicated config — i.e. every real repo — byte-identical.
const _key = cfgPath.replace(/\\/g, "/");
if (_memo.has(_key)) return _memo.get(_key);
_memo.set(_key, null);
try {
const raw = JSON.parse(readFileSync(cfgPath, "utf8")) || {};
const co = raw.compilerOptions || {};
const here = dirname(cfgPath).replace(/\\/g, "/");
// 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];
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, _memo);
if (parent) inherited = { ...(inherited || {}), ...parent };
}
}
const self = {};
// Anchor baseUrl to THIS config's own dir at read time — before it's merged
// into a child via `extends`. Once absolute it resolves correctly no matter
// which dir a downstream consumer pairs it with (fixes inherited baseUrl/paths
// resolving against the child config's dir instead of the base's origin).
if (co.baseUrl) self.baseUrl = joinPosixAbs(here, co.baseUrl);
if (co.paths) self.paths = co.paths;
const out = { ...(inherited || {}), ...self };
if (!Object.keys(out).length) return null;
_memo.set(_key, out);
return out;
} catch { return null; }
}
// vite.config / webpack config file names we probe for a `resolve.alias` literal.
const VITE_CONFIG_RE = /(^|\/)(vite|vitest|webpack)\.config\.(js|ts|mts|cts|mjs|cjs)$/; // .mts/.cts are valid config extensions and were silently skipped
// Extract STRING→STRING `resolve.alias` object-literal entries from a bundler config
// WITHOUT executing it (untrusted repo code). ts-morph parses the file to an AST and
// we read only string-literal keys/values off the `alias` object literal — no eval,
// no import, no require of the config. Function/regex/URL-idiom aliases are skipped
// (deferred). The common Vite idiom `'@': path.resolve(__dirname, 'src')` is handled
// by taking the LAST string-literal argument of a path.resolve()/join() call. Returns
// { find: replacement, … } (raw, dir-relative) or null when nothing usable is found.
function readBundlerAliasEntries(cfgPath) {
let text; try { text = readFileSync(cfgPath, "utf8"); } catch { return null; }
let SyntaxKind, Project;
try { ({ SyntaxKind, Project } = tsMorph()); } catch { return null; }
let sf;
// Parse in an in-memory FS so the config file is never added to the real project.
try {
const p = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
sf = p.createSourceFile("bundler.config.ts", text, { overwrite: true });
} catch { return null; }
const strLit = (node) => {
const k = node?.getKind?.();
if (k === SyntaxKind.StringLiteral || k === SyntaxKind.NoSubstitutionTemplateLiteral) {
try { return node.getLiteralText(); } catch { return null; }
}
return null;
};
// last string-literal ARG of a path.resolve/join(__dirname, X) style call.
const callTail = (call) => {
let args; try { args = call.getArguments(); } catch { return null; }
let last = null;
for (const a of args) { const s = strLit(a); if (s !== null) last = s; }
return last;
};
const out = {};
let props; try { props = sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment); } catch { return null; }
for (const p of props) {
let name; try { name = p.getName(); } catch { continue; }
// getName() keeps quotes for string-literal keys; accept both quoted + bare `alias`.
if (name !== "alias" && name !== "'alias'" && name !== '"alias"' && name !== "`alias`") continue;
let init; try { init = p.getInitializer(); } catch { continue; }
// object-literal form only; the array `[{ find, replacement }]` form is deferred.
if (!init || init.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
for (const entry of init.getProperties()) {
if (entry.getKind() !== SyntaxKind.PropertyAssignment) continue; // skip spreads/methods
let rawKey; try { rawKey = entry.getName(); } catch { continue; }
const key = rawKey.replace(/^['"`]|['"`]$/g, ""); // strip surrounding quotes
if (!key) continue;
let val; try { val = entry.getInitializer(); } catch { continue; }
if (!val) continue;
let target = strLit(val);
if (target === null && val.getKind() === SyntaxKind.CallExpression) target = callTail(val);
if (target === null) continue; // function/regex/URL alias → defer (never execute)
out[key] = target;
}
}
return Object.keys(out).length ? out : null;
}
// Normalize raw bundler alias entries → the same `paths` shape tsconfig uses, keyed
// dir-relative so it resolves against baseUrl = the config's own dir. A vite alias is
// PREFIX-based (`@/foo` → `<repl>/foo`) and also matches the BARE find (`@` → `<repl>`),
// so each entry emits BOTH an exact `find` and a wildcard `find/*` — resolveAlias's
// exact-beats-wildcard precedence then does the right thing. `__dirname`-relative
// targets like `./src/components` collapse via the shared joinPosix in resolveAlias.
function bundlerAliasToPaths(entries) {
const paths = {};
for (const [find, repl] of Object.entries(entries)) {
if (!find || repl == null) continue;
const target = repl.replace(/^\.\//, ""); // drop a leading ./ (baseUrl-relative anyway)
paths[find] = [target];
paths[`${find}/*`] = [`${target}/*`];
}
return paths;
}
// Normalize a package.json Node "imports" map (self-referencing internal subpath
// specifiers — keys ALWAYS start with `#`, e.g. `#lib/util`, `#internal/*`) → the
// same `paths` shape tsconfig uses, keyed against baseUrl = the package's own dir
// (where its package.json lives; "imports" targets are package-dir-relative). A
// value may be a plain string OR a conditions object ({ import, default, node, … });
// impLeaf() pulls the ESM/source target out — prefer `import`, then `default`, then
// any string leaf (same order discoverWorkspacePackages uses for "exports"). "imports"
// targets point at the EMITTED file (`./dist/x.js`, `./src/x.js`); to reach SOURCE we
// emit an extensionless TWIN first (drop a trailing code ext) so tryResolveAt's
// extension ladder lands the `.ts`/`.tsx` source, with the raw target kept as a
// fallback. Non-`#` keys, non-string leaves, and function/URL conditions are skipped
// (never executed). Returns { "#foo": [...], "#foo/*": [...], … } or {} when empty.
function packageImportsToPaths(imports) {
if (!imports || typeof imports !== "object" || Array.isArray(imports)) return {};
const impLeaf = (c) => {
if (typeof c === "string") return c;
if (c && typeof c === "object") { for (const k of ["import", "default"]) if (typeof c[k] === "string") return c[k]; for (const k in c) if (typeof c[k] === "string") return c[k]; }
return null;
};
// strip a leading ./ (package-dir-relative anyway) + a trailing code ext for the
// source-preferred extensionless twin. Keeps a `*` intact (`src/x/*.js`→`src/x/*`).
const stripExt = (t) => t.replace(CODE_EXT_RE, ""); // derive from CODE_EXT (:158) — a second hand-written copy silently stops matching the day a 9th extension is added
const paths = {};
for (const key of Object.keys(imports)) {
if (typeof key !== "string" || !key.startsWith("#")) continue; // "imports" keys are always #-prefixed
const leaf = impLeaf(imports[key]);
if (typeof leaf !== "string" || !leaf) continue;
const raw = leaf.replace(/^\.\//, "");
const bare = stripExt(raw);
paths[key] = bare === raw ? [raw] : [bare, raw]; // source-preferred (extensionless) first
}
return paths;
}
// 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;
const cfgDir = join(root, dirname(rel)).replace(/\\/g, "/");
configs.push({
dir: cfgDir,
// baseUrl from readTsconfigAliasOpts is already absolute (anchored at read
// time); the fallback is this config's own dir, not the literal ".".
baseUrl: opts.baseUrl || cfgDir,
paths: opts.paths || {},
});
}
// Bundler (vite/webpack) resolve.alias configs. Read WITHOUT executing the config
// (readBundlerAliasEntries parses the AST only). Normalized to the tsconfig `paths`
// shape with baseUrl = the config's own dir. tsconfig WINS on conflict: when a
// tsconfig config already exists at the SAME dir AND is anchored to that dir
// (baseUrl === cfgDir, the `baseUrl: "."` default), vite paths merge UNDER it
// ({ ...vite, ...tsconfig }); otherwise the vite entry is appended separately AFTER
// the tsconfig one so the tsconfig still wins the nearest-config pick.
const cfgFiles = listed.length ? listed.filter((f) => VITE_CONFIG_RE.test(f)) : [];
for (const rel of cfgFiles) {
const full = join(root, rel);
if (!existsSync(full)) continue;
const entries = readBundlerAliasEntries(full);
if (!entries) continue;
const vitePaths = bundlerAliasToPaths(entries);
if (!Object.keys(vitePaths).length) continue;
const cfgDir = join(root, dirname(rel)).replace(/\\/g, "/");
const sameDirTs = configs.find((c) => c.dir === cfgDir && c.baseUrl === cfgDir);
if (sameDirTs) sameDirTs.paths = { ...vitePaths, ...sameDirTs.paths }; // tsconfig keys win
else configs.push({ dir: cfgDir, baseUrl: cfgDir, paths: vitePaths });
}
// package.json Node "imports" maps (self-referencing `#internal/*` subpaths). Read
// WITHOUT executing the package.json (JSON.parse only). Normalized to the tsconfig
// `paths` shape with baseUrl = the package's own dir ("imports" targets are always
// package-dir-relative). `#`-keyed, so they never collide with `@/`/`~/` tsconfig or
// vite aliases; a `#`-prefixed specifier already routes to resolveAlias (it's non-
// relative and no workspace name starts with `#`), so this needs no resolver change.
// Merge into an aligned same-dir config (baseUrl === pkgDir — the common `baseUrl:"."`
// / no-baseUrl case) so nearestAliasConfig's one-config-per-dir pick still sees the
// imports keys; otherwise append a standalone config anchored at the package dir.
const pkgFiles = listed.length ? listed.filter((f) => /(^|\/)package\.json$/.test(f) && !f.split("/").includes("node_modules")) : [];
for (const rel of pkgFiles) {
const full = join(root, rel);
if (!existsSync(full)) continue;
let pkg; try { pkg = JSON.parse(readFileSync(full, "utf8")); } catch { continue; }
const impPaths = packageImportsToPaths(pkg && pkg.imports);
if (!Object.keys(impPaths).length) continue;
const cfgDir = join(root, dirname(rel)).replace(/\\/g, "/");
const sameDir = configs.find((c) => c.dir === cfgDir && c.baseUrl === cfgDir);
if (sameDir) sameDir.paths = { ...impPaths, ...sameDir.paths }; // existing (tsconfig/vite) keys win on the impossible collision
else configs.push({ dir: cfgDir, baseUrl: cfgDir, paths: impPaths });
}
configs.sort((a, b) => b.dir.length - a.dir.length);
return configs;
}
// Collect workspace cross-package resolution targets from every tracked
// package.json that declares a "name" (pnpm/npm/yarn workspaces). Maps the
// Look up a requested subpath in a package's declared `exports` map. An exact key
// wins; otherwise Node's subpath PATTERNS apply ("./wild/*": "./src/wild/*.ts"),
// where the matched text replaces every `*` in the target. Without this the lookup
// was exact-match only, so every wildcard subpath in a workspace package silently
// produced no edge at all.
//
// Selection follows Node's own patternKeyCompare, verified against Node v26:
// longest prefix before the `*` wins, and on a TIE the longer FULL key wins —
// declaration order never decides. That tie is not exotic: Node's own docs use
// `"./lib/*"` alongside `"./lib/*.js"` for extension-optional exports, and picking
// by key order there resolves to the wrong file.
//
// An EMPTY fill is rejected. Node raises ERR_PACKAGE_PATH_NOT_EXPORTED when the
// specifier is exactly prefix+suffix with nothing between, so accepting it would
// invent an edge for an import that throws at runtime.
function matchSubpath(subpaths, sub) {
if (subpaths[sub] !== undefined) return subpaths[sub];
let best = null, bestPre = -1, bestKey = -1;
for (const key in subpaths) {
const star = key.indexOf("*");
if (star < 0) continue;
const pre = key.slice(0, star), post = key.slice(star + 1);
if (sub.length <= pre.length + post.length) continue; // no empty match
if (!sub.startsWith(pre) || !sub.endsWith(post)) continue;
if (pre.length < bestPre || (pre.length === bestPre && key.length <= bestKey)) continue;
bestPre = pre.length; bestKey = key.length;
const fill = sub.slice(pre.length, sub.length - post.length);
best = subpaths[key].map((t) => t.split("*").join(fill));
}
return best;
}
// package NAME → { dir, entries, subpaths } where `dir` is the package's absolute
// posix directory, `entries` are the raw "." source-entry candidates in
// preference order (SOURCE over dist): "exports"["."] → "module" → "main" →
// "./index", and `subpaths` maps a declared "exports" subpath (`./button`) to its
// source target. Actual file resolution is deferred to the resolver's
// tryResolveAt (which needs the ts-morph project), so a `dist/index.js` entry
// with no source sibling still can't wrongly resolve — it just misses, same as
// today. Any package.json with no "name" (root/private shells) contributes
// nothing. node_modules is path-segment excluded. Empty when no NAMED
// package.json is tracked, so a single-package repo stays byte-identical (the
// workspace branch never fires).
function discoverWorkspacePackages(rootAbs, listed) {
const root = rootAbs.replace(/\\/g, "/");
const pkgs = Object.create(null);
const pkgRels = listed.length
? listed.filter((f) => /(^|\/)package\.json$/.test(f) && !f.split("/").includes("node_modules"))
: [];
for (const rel of pkgRels) {
const full = join(root, rel);
if (!existsSync(full)) continue;
let pkg; try { pkg = JSON.parse(readFileSync(full, "utf8")); } catch { continue; }
const name = pkg && typeof pkg.name === "string" ? pkg.name : "";
if (!name) continue; // unnamed / root shell → not a resolution target
const dir = join(root, dirname(rel)).replace(/\\/g, "/");
// "." entry candidates, SOURCE-first & deduped, plus a subpath map from any
// declared "exports" subpaths.
//
// Order matters and mirrors TypeScript's own preference: the `types` /
// `typings` field wins over `main`. In a monorepo the published entry
// (`main`) points at BUILT output that usually isn't in the repo, while
// `types` points at source or at a .d.ts next to it — so consulting `main`
// first resolved a workspace import to a `dist/` path that does not exist
// and dropped the cross-package edge entirely. Measured: a package declaring
// `main: "dist/index.js", types: "src/index.ts"` reported ZERO dependents.
const entries = [];
const subpaths = Object.create(null);
const push = (v) => { if (typeof v === "string" && v && !entries.includes(v)) entries.push(v); };
// Pull a string target out of a string OR a conditions object. `types` is
// checked first for the same reason as above — TypeScript resolves the
// "types" export condition ahead of "import"/"require"/"default", and in a
// source repo that condition is the one pointing at code that exists.
// Recursive: Node allows conditions to nest to any depth ({"node":{"import":…}}),
// and only reading one level down made a nested map resolve to nothing at all.
// Returns EVERY candidate in precedence order, not just the first, because an
// array target is Node's fallback list ("try each until one resolves") and only
// the caller knows what exists on disk. The "." entry already worked this way
// via `entries`; subpaths held a single string and so could never fall back.