@raymondchins/agentmap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/package.json +54 -0
- package/repomap.mjs +461 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raymond Surya Chin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# agentmap
|
|
2
|
+
|
|
3
|
+
**The repo map your coding agent is _forced_ to use.**
|
|
4
|
+
|
|
5
|
+
A queryable, ranked code-relationship map for TypeScript/JavaScript repos — personalized
|
|
6
|
+
PageRank importance, Aider-style symbol ranking, a token-budgeted digest, and a single
|
|
7
|
+
`--any` router (file → symbol → feature → live git-grep) — wired straight into the agent
|
|
8
|
+
loop so it actually gets used, not just published.
|
|
9
|
+
|
|
10
|
+
<!-- badges (placeholder — wire up once published) -->
|
|
11
|
+
[](https://www.npmjs.com/package/@raymondchins/agentmap)
|
|
12
|
+
[](#)
|
|
13
|
+
[](./LICENSE)
|
|
14
|
+
[](#)
|
|
15
|
+
|
|
16
|
+
> One file, one dependency (`ts-morph`). No vector DB, no embedding API, no server.
|
|
17
|
+
> `npx @raymondchins/agentmap --any <query>` and you have a ranked answer.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Why it's different
|
|
22
|
+
|
|
23
|
+
Most "repo context" tools are one-shot: they pack the repository (or a slice of it) into a
|
|
24
|
+
prompt and stop there. agentmap is a **queryable, ranked, and self-refreshing** map that an
|
|
25
|
+
agent can interrogate flag-by-flag — and, crucially, is **wired into the agent loop** via a
|
|
26
|
+
post-commit auto-refresh and a `PreToolUse` hook that nudges the agent to use the map
|
|
27
|
+
*before* it falls back to serial grep.
|
|
28
|
+
|
|
29
|
+
| | **agentmap** | Aider repo map | RepoMapper | Repomix | code2prompt |
|
|
30
|
+
| --- | --- | --- | --- | --- | --- |
|
|
31
|
+
| **Ranking algorithm** | Personalized PageRank (file + symbol graphs) | PageRank (graph ranking) | Importance heuristics | None (file order) | None (file order) |
|
|
32
|
+
| **Languages** | TS/JS (via ts-morph) | Many (tree-sitter) | Many (tree-sitter) | Language-agnostic (text) | Language-agnostic (text) |
|
|
33
|
+
| **Token-budget output** | Yes — `--map [--tokens N]` ranked digest | Yes (built into Aider's context) | Partial | Yes (size caps) | Yes (templates/caps) |
|
|
34
|
+
| **Agent-loop integration** | **Yes — post-commit auto-refresh + PreToolUse hook** | In-process (Aider only) | No | No | No |
|
|
35
|
+
| **Dependencies** | `ts-morph` only | Python + tree-sitter stack | Python + tree-sitter | Node | Rust binary |
|
|
36
|
+
| **Install** | `npx @raymondchins/agentmap` | `pip install aider` | `pip install` | `npx`/global | `cargo`/binary |
|
|
37
|
+
|
|
38
|
+
What that table is **not** claiming: agentmap is TS/JS-only (the others are multi-language),
|
|
39
|
+
and it's a **file-level import graph**, not a full call-site/reference resolver (see
|
|
40
|
+
[Scope & limitations](#scope--limitations)). The differentiators are narrow and honest:
|
|
41
|
+
**(1)** the `--any` router, and **(2)** the agent-loop wiring. Everything else is table stakes.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
No install needed:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @raymondchins/agentmap --any <query>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
…or run it directly from a checkout:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
node repomap.mjs --any <query>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The first run builds and caches the map to `.claude/repomap.json` (add it to
|
|
60
|
+
`.gitignore`). Subsequent runs serve the cache when the tree is clean and `HEAD` is
|
|
61
|
+
unchanged, and silently rebuild from disk when there are uncommitted `.ts/.tsx/.js/...`
|
|
62
|
+
edits — so queries always reflect your in-flight work.
|
|
63
|
+
|
|
64
|
+
Run with no flag to build + print a one-line summary:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ node repomap.mjs
|
|
68
|
+
repomap: 154 files | 4 features | top hub: lib/utils.ts (deg 52, pr 0.105171)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## The `--any` router
|
|
74
|
+
|
|
75
|
+
One flag, no flag-picking. `--any <query>` resolves your query through a cascade and
|
|
76
|
+
returns the first layer that hits:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
--any <query>
|
|
80
|
+
│
|
|
81
|
+
├─ 1. FILE exact path → unique basename → unique substring
|
|
82
|
+
├─ 2. SYMBOL exported name contains the query (across all files)
|
|
83
|
+
├─ 3. FEATURE app/-router feature name contains the query
|
|
84
|
+
└─ 4. CONTENT live `git grep` (tracked + untracked) — never stale
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Layers 1–3 read the cached structural map (fast, ranked). Layer 4 is a **live disk read**
|
|
88
|
+
via `git grep -F`, so raw strings, copy, Tailwind classes, and config values the structural
|
|
89
|
+
graph never indexes still resolve instead of coming up empty.
|
|
90
|
+
|
|
91
|
+
**Symbol hit** (query resolved to a symbol → full block):
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
$ node repomap.mjs --any cn
|
|
95
|
+
[structure] 1 symbol, 0 feature match for "cn"
|
|
96
|
+
lib/utils.ts → cn (FunctionDeclaration)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Ambiguous file hit** (query matched multiple files → narrow it):
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
$ node repomap.mjs --any utils
|
|
103
|
+
[structure] "utils" matched 3 files — narrow it:
|
|
104
|
+
lib/utils.ts
|
|
105
|
+
lib/db/utils.ts
|
|
106
|
+
tests/prompts/utils.ts
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Content fallback** (no file/symbol/feature match → live git-grep):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
$ node repomap.mjs --any streamText
|
|
113
|
+
[content] 13 lines:
|
|
114
|
+
app/(chat)/api/chat/route.ts:8: streamText,
|
|
115
|
+
app/(chat)/api/chat/route.ts:194: const result = streamText({
|
|
116
|
+
artifacts/code/server.ts:1:import { streamText } from "ai";
|
|
117
|
+
artifacts/code/server.ts:18: const { fullStream } = streamText({
|
|
118
|
+
artifacts/code/server.ts:40: const { fullStream } = streamText({
|
|
119
|
+
artifacts/sheet/server.ts:1:import { streamText } from "ai";
|
|
120
|
+
artifacts/sheet/server.ts:11: const { fullStream } = streamText({
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Commands
|
|
126
|
+
|
|
127
|
+
Every snippet below is **verbatim output** from running agentmap against the public
|
|
128
|
+
154-file Next.js repo [vercel/ai-chatbot](https://github.com/vercel/ai-chatbot) (sha 2becdb4).
|
|
129
|
+
|
|
130
|
+
### `--any <q>` — the router (file → symbol → feature → live content)
|
|
131
|
+
|
|
132
|
+
See [The `--any` router](#the---any-router) above. Default first move for any
|
|
133
|
+
"where/what/who" question.
|
|
134
|
+
|
|
135
|
+
### `--find <q>` — reuse-before-rebuild symbol search
|
|
136
|
+
|
|
137
|
+
Find every exported symbol whose name contains the query. Use it before writing a new util
|
|
138
|
+
or component to check what already exists.
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
$ node repomap.mjs --find Message
|
|
142
|
+
find "Message": 55 match
|
|
143
|
+
hooks/use-messages.tsx → useMessages (FunctionDeclaration)
|
|
144
|
+
lib/errors.ts → getMessageByErrorCode (FunctionDeclaration)
|
|
145
|
+
lib/types.ts → messageMetadataSchema (VariableDeclaration)
|
|
146
|
+
lib/types.ts → MessageMetadata (TypeAliasDeclaration)
|
|
147
|
+
lib/types.ts → ChatMessage (TypeAliasDeclaration)
|
|
148
|
+
lib/utils.ts → convertToUIMessages (FunctionDeclaration)
|
|
149
|
+
lib/utils.ts → getTextFromMessage (FunctionDeclaration)
|
|
150
|
+
tests/helpers.ts → generateTestMessage (FunctionDeclaration)
|
|
151
|
+
app/(chat)/actions.ts → generateTitleFromUserMessage (FunctionDeclaration)
|
|
152
|
+
…
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `--relates <path>` — blast radius + transitive relevance
|
|
156
|
+
|
|
157
|
+
The file's own block (exports / imports / direct dependents) **plus** a random-walk
|
|
158
|
+
relevance list (personalized PageRank on the bidirectional import graph) — the files most
|
|
159
|
+
related to the target, transitively, not just its direct importers.
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
$ node repomap.mjs --relates lib/db/schema.ts
|
|
163
|
+
relates: lib/db/schema.ts (pr 0.073744)
|
|
164
|
+
exports (14): user(VariableDeclaration), User(TypeAliasDeclaration), chat(VariableDeclaration), Chat(TypeAliasDeclaration), message(VariableDeclaration), DBMessage(TypeAliasDeclaration), …
|
|
165
|
+
imports (0): —
|
|
166
|
+
dependents (21): hooks/use-active-chat.tsx, lib/types.ts, lib/utils.ts, components/chat/artifact.tsx, components/chat/message.tsx, lib/db/queries.ts, app/(chat)/api/chat/route.ts, …
|
|
167
|
+
related (random-walk relevance):
|
|
168
|
+
lib/utils.ts (0.0476)
|
|
169
|
+
lib/types.ts (0.0376)
|
|
170
|
+
components/chat/artifact.tsx (0.0372)
|
|
171
|
+
components/chat/icons.tsx (0.0264)
|
|
172
|
+
components/chat/message.tsx (0.0237)
|
|
173
|
+
lib/db/queries.ts (0.0225)
|
|
174
|
+
app/(chat)/api/chat/route.ts (0.0218)
|
|
175
|
+
…
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### `--feature <name>` — files that make up a feature
|
|
179
|
+
|
|
180
|
+
Resolves a Next.js `app/`-router feature to its file set, plus the external files that
|
|
181
|
+
depend on it.
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
$ node repomap.mjs --feature api
|
|
185
|
+
feature "api": 11 files
|
|
186
|
+
app/(chat)/api/chat/route.ts
|
|
187
|
+
app/(chat)/api/chat/schema.ts
|
|
188
|
+
app/(chat)/api/document/route.ts
|
|
189
|
+
app/(chat)/api/history/route.ts
|
|
190
|
+
app/(chat)/api/messages/route.ts
|
|
191
|
+
app/(chat)/api/models/route.ts
|
|
192
|
+
app/(chat)/api/suggestions/route.ts
|
|
193
|
+
app/(chat)/api/vote/route.ts
|
|
194
|
+
app/(auth)/api/auth/guest/route.ts
|
|
195
|
+
app/(chat)/api/files/upload/route.ts
|
|
196
|
+
app/(chat)/api/chat/[id]/stream/route.ts
|
|
197
|
+
external dependents (0): —
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `--features` — list features by size
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
$ node repomap.mjs --features
|
|
204
|
+
features (4):
|
|
205
|
+
api (11 files)
|
|
206
|
+
login (1 files)
|
|
207
|
+
register (1 files)
|
|
208
|
+
chat (1 files)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `--hubs` — most important files (PageRank)
|
|
212
|
+
|
|
213
|
+
The files that matter most, ranked by PageRank importance (raw dependent degree shown
|
|
214
|
+
alongside).
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
$ node repomap.mjs --hubs
|
|
218
|
+
repomap: 154 files (sha 2becdb4)
|
|
219
|
+
hubs (PageRank importance):
|
|
220
|
+
lib/utils.ts (deg 52, pr 0.105171)
|
|
221
|
+
lib/db/schema.ts (deg 21, pr 0.073744)
|
|
222
|
+
lib/types.ts (deg 23, pr 0.067589)
|
|
223
|
+
components/chat/artifact.tsx (deg 15, pr 0.036882)
|
|
224
|
+
components/chat/icons.tsx (deg 27, pr 0.035378)
|
|
225
|
+
lib/errors.ts (deg 9, pr 0.032787)
|
|
226
|
+
lib/db/queries.ts (deg 14, pr 0.030085)
|
|
227
|
+
…
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### `--symbols [N]` — top ranked symbols (Aider-style)
|
|
231
|
+
|
|
232
|
+
The most important individual symbols across the repo, ranked by the identifier graph
|
|
233
|
+
(defaults to 30).
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
$ node repomap.mjs --symbols 10
|
|
237
|
+
top 10 ranked symbols (Aider-style):
|
|
238
|
+
0.109902 lib/utils.ts → cn (FunctionDeclaration)
|
|
239
|
+
0.036013 lib/types.ts → ChatMessage (TypeAliasDeclaration)
|
|
240
|
+
0.025686 components/chat/artifact.tsx → ArtifactKind (TypeAliasDeclaration)
|
|
241
|
+
0.022461 lib/errors.ts → ChatbotError (ClassDeclaration)
|
|
242
|
+
0.021068 lib/types.ts → CustomUIDataTypes (TypeAliasDeclaration)
|
|
243
|
+
0.020872 lib/db/schema.ts → Document (TypeAliasDeclaration)
|
|
244
|
+
0.020555 components/ai-elements/suggestion.tsx → Suggestion (VariableDeclaration)
|
|
245
|
+
0.020555 lib/db/schema.ts → Suggestion (TypeAliasDeclaration)
|
|
246
|
+
0.018124 lib/db/schema.ts → DBMessage (TypeAliasDeclaration)
|
|
247
|
+
0.015034 lib/errors.ts → ErrorCode (TypeAliasDeclaration)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `--map [--tokens N] [--focus <path>]` — token-budgeted ranked digest
|
|
251
|
+
|
|
252
|
+
The token-budgeted digest (Aider's killer feature): a ranked, files-and-symbols summary
|
|
253
|
+
that fits a token budget. Default budget is 8192 (1024 with `--focus`). `--focus <path>`
|
|
254
|
+
personalizes the ranking toward a file you're working on.
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
$ node repomap.mjs --map --tokens 400
|
|
258
|
+
# repomap (154 files, sha 2becdb4) — focus: global, budget ~400 tok
|
|
259
|
+
|
|
260
|
+
lib/utils.ts:
|
|
261
|
+
cn (FunctionDeclaration)
|
|
262
|
+
generateUUID (FunctionDeclaration)
|
|
263
|
+
|
|
264
|
+
lib/types.ts:
|
|
265
|
+
ChatMessage (TypeAliasDeclaration)
|
|
266
|
+
CustomUIDataTypes (TypeAliasDeclaration)
|
|
267
|
+
ChatTools (TypeAliasDeclaration)
|
|
268
|
+
Attachment (TypeAliasDeclaration)
|
|
269
|
+
|
|
270
|
+
components/chat/artifact.tsx:
|
|
271
|
+
ArtifactKind (TypeAliasDeclaration)
|
|
272
|
+
UIArtifact (TypeAliasDeclaration)
|
|
273
|
+
Artifact (VariableDeclaration)
|
|
274
|
+
|
|
275
|
+
lib/errors.ts:
|
|
276
|
+
ChatbotError (ClassDeclaration)
|
|
277
|
+
ErrorCode (TypeAliasDeclaration)
|
|
278
|
+
|
|
279
|
+
lib/db/schema.ts:
|
|
280
|
+
Document (TypeAliasDeclaration)
|
|
281
|
+
Suggestion (TypeAliasDeclaration)
|
|
282
|
+
DBMessage (TypeAliasDeclaration)
|
|
283
|
+
|
|
284
|
+
# ~387 tokens (14 files shown)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Focused on a working file — the ranking re-centers on what `lib/db/queries.ts` actually touches:
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
$ node repomap.mjs --map --focus lib/db/queries.ts --tokens 350
|
|
291
|
+
# repomap (154 files, sha 2becdb4) — focus: lib/db/queries.ts, budget ~350 tok
|
|
292
|
+
|
|
293
|
+
lib/utils.ts:
|
|
294
|
+
cn (FunctionDeclaration)
|
|
295
|
+
generateUUID (FunctionDeclaration)
|
|
296
|
+
getDocumentTimestampByIndex (FunctionDeclaration)
|
|
297
|
+
fetcher (VariableDeclaration)
|
|
298
|
+
getTextFromMessage (FunctionDeclaration)
|
|
299
|
+
convertToUIMessages (FunctionDeclaration)
|
|
300
|
+
fetchWithErrorHandlers (FunctionDeclaration)
|
|
301
|
+
sanitizeText (FunctionDeclaration)
|
|
302
|
+
|
|
303
|
+
lib/db/schema.ts:
|
|
304
|
+
DBMessage (TypeAliasDeclaration)
|
|
305
|
+
Suggestion (TypeAliasDeclaration)
|
|
306
|
+
Document (TypeAliasDeclaration)
|
|
307
|
+
Chat (TypeAliasDeclaration)
|
|
308
|
+
User (TypeAliasDeclaration)
|
|
309
|
+
chat (VariableDeclaration)
|
|
310
|
+
document (VariableDeclaration)
|
|
311
|
+
message (VariableDeclaration)
|
|
312
|
+
|
|
313
|
+
lib/errors.ts:
|
|
314
|
+
ChatbotError (ClassDeclaration)
|
|
315
|
+
ErrorCode (TypeAliasDeclaration)
|
|
316
|
+
|
|
317
|
+
# ~324 tokens (8 files shown)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### `--print` — full map as JSON
|
|
321
|
+
|
|
322
|
+
Dumps the cached map (`hubs`, `features`, `rankedSymbols`, `files`) as one JSON object —
|
|
323
|
+
for piping into other tools.
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
$ node repomap.mjs --print | jq '.hubs[0]'
|
|
327
|
+
"lib/utils.ts (deg 52, pr 0.105171)"
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## The agent loop (the actual point)
|
|
333
|
+
|
|
334
|
+
A repo map only helps if the agent uses it. agentmap ships two hooks (in [`./hooks/`](./hooks/))
|
|
335
|
+
that close the loop: the map refreshes itself after every commit, and the agent gets nudged
|
|
336
|
+
to query the map before it serial-greps.
|
|
337
|
+
|
|
338
|
+
### 1. Auto-refresh on commit
|
|
339
|
+
|
|
340
|
+
[`hooks/post-commit`](./hooks/post-commit) rebuilds `.claude/repomap.json` after each
|
|
341
|
+
commit, detached + silenced so it never slows the commit. It skips during
|
|
342
|
+
rebase/merge/cherry-pick and no-ops if Node is missing.
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
# from your repo root
|
|
346
|
+
cp hooks/post-commit .git/hooks/post-commit
|
|
347
|
+
chmod +x .git/hooks/post-commit
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The hook auto-locates the builder: a local `repomap.mjs`, then `scripts/repomap.mjs`, then
|
|
351
|
+
the installed `agentmap` binary, then `npx @raymondchins/agentmap`.
|
|
352
|
+
|
|
353
|
+
### 2. Force the agent to use it — `PreToolUse` hook
|
|
354
|
+
|
|
355
|
+
[`hooks/repomap-nudge.mjs`](./hooks/repomap-nudge.mjs) is a **non-blocking** `PreToolUse(Grep)`
|
|
356
|
+
hook for Claude Code. When a `Grep` looks like a dependency / who-imports / component-usage /
|
|
357
|
+
reuse search, it injects a reminder steering the agent to `agentmap --any` first. It never
|
|
358
|
+
denies the grep, and stays silent for raw-string / Tailwind-class / lowercase-HTML-tag
|
|
359
|
+
sweeps — so it's high-signal, not nagging.
|
|
360
|
+
|
|
361
|
+
Wire it up in `.claude/settings.json`:
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{
|
|
365
|
+
"hooks": {
|
|
366
|
+
"PreToolUse": [
|
|
367
|
+
{
|
|
368
|
+
"matcher": "Grep",
|
|
369
|
+
"hooks": [
|
|
370
|
+
{ "type": "command", "command": "node ./hooks/repomap-nudge.mjs" }
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
That's the "forced to use it" in the tagline: the map stays current on its own, and the
|
|
379
|
+
agent is steered to it the moment it reaches for a dependency-shaped grep.
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Scope & limitations
|
|
384
|
+
|
|
385
|
+
Honesty first — this is deliberately a small, sharp tool, not a universal code-graph.
|
|
386
|
+
|
|
387
|
+
- **TS/JS only, by design.** Built on `ts-morph`. No Python, Go, Rust, etc. If your repo
|
|
388
|
+
isn't TypeScript/JavaScript, use a tree-sitter-based tool instead.
|
|
389
|
+
- **File-level import graph, not a full reference graph.** Edges come from static
|
|
390
|
+
`import` / re-export declarations and the named symbols crossing them. It does **not**
|
|
391
|
+
do call-site or full reference resolution — `--relates` tells you which files import a
|
|
392
|
+
module, not every line that calls a given function.
|
|
393
|
+
- **PageRank + symbol ranking are real and implemented** (damping 0.85, deterministic
|
|
394
|
+
power iteration; personalized variants for `--relates` and `--map --focus`). The symbol
|
|
395
|
+
ranking is a faithful port of Aider's identifier-graph approach (credit:
|
|
396
|
+
[Aider](https://github.com/Aider-AI/aider), Apache-2.0).
|
|
397
|
+
- **Feature detection assumes the Next.js `app/` router.** `--feature` / `--features`
|
|
398
|
+
derive features from the first real route segment under `app/` (or `src/app/`), skipping
|
|
399
|
+
route groups `(...)`, dynamic `[...]`, and parallel `@...` segments. Repos without an
|
|
400
|
+
`app/` directory simply report zero features — every other command still works.
|
|
401
|
+
- **Token counts are estimates** (`chars / 4`), not a real BPE tokenizer. Treat
|
|
402
|
+
`--map`/`--tokens` budgets as approximate (±10%).
|
|
403
|
+
- The PreToolUse hook is **Claude Code-specific** (it speaks Claude Code's hook JSON). The
|
|
404
|
+
post-commit hook is generic git.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Benchmark
|
|
409
|
+
|
|
410
|
+
Against the public **154-file Next.js repo (vercel/ai-chatbot, sha 2becdb4)**:
|
|
411
|
+
|
|
412
|
+
- **70.3% fewer tokens across 3 scenarios (5598 → 1664 tokens)**:
|
|
413
|
+
- **A. Understand file deps** (`lib/utils.ts`): baseline 583 tok → agentmap 517 tok (11.3% saved)
|
|
414
|
+
- **B. Find symbol** (`ChatMessage`): baseline 1950 tok → agentmap 20 tok (99% saved)
|
|
415
|
+
- **C. Repo overview** (tree + 3 hub files): baseline 3065 tok → agentmap 1127 tok (63.2% saved)
|
|
416
|
+
- Cold build (parse + PageRank + symbol graph): **~1.2s**. Warm cached query (`--hubs`,
|
|
417
|
+
clean tree): **~0.2s**.
|
|
418
|
+
|
|
419
|
+
Caveat: these numbers measure context efficiency (tokens sent to the model per task), **not**
|
|
420
|
+
end-to-end retrieval accuracy — there's no "did the agent fix the bug faster" eval yet.
|
|
421
|
+
|
|
422
|
+
Full methodology, commands, and caveats: **[`./benchmark/RESULTS.md`](./benchmark/RESULTS.md)**.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Contributing
|
|
427
|
+
|
|
428
|
+
Issues and PRs welcome. High-value directions:
|
|
429
|
+
|
|
430
|
+
- An end-to-end retrieval/accuracy eval (the benchmark is context-efficiency only today).
|
|
431
|
+
- A real tokenizer behind the `--map` budget.
|
|
432
|
+
- Hardening feature detection for non-`app/`-router layouts.
|
|
433
|
+
|
|
434
|
+
Keep the dependency footprint minimal — `ts-morph` is the only runtime dep, and that's a
|
|
435
|
+
feature.
|
|
436
|
+
|
|
437
|
+
## License
|
|
438
|
+
|
|
439
|
+
[MIT](./LICENSE). Symbol-ranking algorithm credit: [Aider](https://github.com/Aider-AI/aider) (Apache-2.0).
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raymondchins/agentmap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "The repo map your coding agent is forced to use. A queryable, ranked ts-morph code-relationship map for TypeScript/JavaScript repos — PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"agentmap": "./repomap.mjs"
|
|
11
|
+
},
|
|
12
|
+
"main": "repomap.mjs",
|
|
13
|
+
"files": [
|
|
14
|
+
"repomap.mjs"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"map": "node repomap.mjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"ts-morph": "28.0.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"repo-map",
|
|
27
|
+
"repomap",
|
|
28
|
+
"code-map",
|
|
29
|
+
"ts-morph",
|
|
30
|
+
"typescript",
|
|
31
|
+
"javascript",
|
|
32
|
+
"pagerank",
|
|
33
|
+
"static-analysis",
|
|
34
|
+
"ast",
|
|
35
|
+
"code-graph",
|
|
36
|
+
"dependency-graph",
|
|
37
|
+
"ai-agent",
|
|
38
|
+
"coding-agent",
|
|
39
|
+
"llm",
|
|
40
|
+
"claude",
|
|
41
|
+
"aider",
|
|
42
|
+
"context"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/raymondchins/agentmap.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/raymondchins/agentmap#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/raymondchins/agentmap/issues"
|
|
51
|
+
},
|
|
52
|
+
"author": "Raymond Surya Chin",
|
|
53
|
+
"license": "MIT"
|
|
54
|
+
}
|
package/repomap.mjs
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// agentmap — the repo map your coding agent is *forced* to use.
|
|
5
|
+
//
|
|
6
|
+
// A ts-morph code-relationship map for TypeScript/JavaScript repos. Unlike
|
|
7
|
+
// one-shot "pack the repo into a prompt" tools, this is a QUERYABLE, RANKED
|
|
8
|
+
// map: PageRank importance (ported from Aider's repo map), Aider-style
|
|
9
|
+
// symbol ranking, a token-budgeted `--map` digest, and a single `--any`
|
|
10
|
+
// router (file → symbol → feature → live git-grep) — wired into the agent
|
|
11
|
+
// loop via a post-commit auto-refresh + a PreToolUse hook.
|
|
12
|
+
//
|
|
13
|
+
// Near-zero deps (ts-morph only). Runs in the target repo's cwd.
|
|
14
|
+
// Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
|
|
15
|
+
// ============================================================================
|
|
16
|
+
import { Project } from "ts-morph";
|
|
17
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const MAP = ".claude/repomap.json";
|
|
21
|
+
const SCHEMA_VERSION = 2;
|
|
22
|
+
const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; } };
|
|
23
|
+
|
|
24
|
+
// Live content search for the --any fallback. `git grep` over tracked +
|
|
25
|
+
// untracked files (skips gitignored paths like node_modules). Reads DISK, so
|
|
26
|
+
// never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
|
|
27
|
+
const contentSearch = (q) => {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/repomap.json"], { encoding: "utf8" }).trim();
|
|
30
|
+
} catch { return ""; }
|
|
31
|
+
};
|
|
32
|
+
const currentSha = () => sh("git rev-parse --short HEAD");
|
|
33
|
+
const dirtyCount = () =>
|
|
34
|
+
sh("git status --porcelain").split("\n").filter(Boolean).filter((l) => {
|
|
35
|
+
let p = l.slice(3); // strip "XY " status prefix
|
|
36
|
+
if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
|
|
37
|
+
p = p.replace(/^"|"$/g, ""); // unquote space/special paths
|
|
38
|
+
return /\.(ts|tsx|mjs|cjs|jsx|js)$/.test(p);
|
|
39
|
+
}).length;
|
|
40
|
+
const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
|
|
41
|
+
|
|
42
|
+
// Feature = first real route segment under app/ (or src/app/), skipping route
|
|
43
|
+
// groups (parens), dynamic segments ([id]) and parallel routes (@slot).
|
|
44
|
+
function featureOf(path) {
|
|
45
|
+
const m = path.match(/(?:^|.*\/)(?:src\/)?app\/(.+)/);
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
for (const p of m[1].split("/").slice(0, -1)) {
|
|
48
|
+
if (p.startsWith("(") || p.startsWith("[") || p.startsWith("@")) continue;
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Personalized PageRank — dependency-free power iteration. Deterministic
|
|
56
|
+
// (stable node order, no PRNG). Edges = [{from, to, weight}]. Rank flows
|
|
57
|
+
// from→to, so with importer→imported edges, heavily-imported hubs rank high.
|
|
58
|
+
// Dangling-node mass + teleport both go to the personalization vector
|
|
59
|
+
// (matches Aider's `dangling=personalization`). Returns { node: score }.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function pagerank(nodes, edges, { personalization = null, damping = 0.85, tol = 1e-6, maxIter = 100 } = {}) {
|
|
62
|
+
const N = nodes.length;
|
|
63
|
+
if (N === 0) return {};
|
|
64
|
+
const idx = new Map(nodes.map((n, i) => [n, i]));
|
|
65
|
+
const outW = new Float64Array(N);
|
|
66
|
+
const adj = Array.from({ length: N }, () => []);
|
|
67
|
+
for (const e of edges) {
|
|
68
|
+
const a = idx.get(e.from), b = idx.get(e.to);
|
|
69
|
+
if (a === undefined || b === undefined || a === b) continue; // skip self-loops
|
|
70
|
+
const w = e.weight > 0 ? e.weight : 1;
|
|
71
|
+
adj[a].push([b, w]); outW[a] += w;
|
|
72
|
+
}
|
|
73
|
+
// teleport vector p (normalized personalization, or uniform)
|
|
74
|
+
const p = new Float64Array(N);
|
|
75
|
+
if (personalization) {
|
|
76
|
+
let s = 0;
|
|
77
|
+
for (const [k, v] of Object.entries(personalization)) {
|
|
78
|
+
const i = idx.get(k);
|
|
79
|
+
if (i !== undefined && v > 0) { p[i] = v; s += v; }
|
|
80
|
+
}
|
|
81
|
+
if (s === 0) p.fill(1 / N); else for (let i = 0; i < N; i++) p[i] /= s;
|
|
82
|
+
} else p.fill(1 / N);
|
|
83
|
+
let r = Float64Array.from(p);
|
|
84
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
85
|
+
let dangling = 0;
|
|
86
|
+
for (let i = 0; i < N; i++) if (outW[i] === 0) dangling += r[i];
|
|
87
|
+
const next = new Float64Array(N);
|
|
88
|
+
for (let i = 0; i < N; i++) next[i] = (1 - damping) * p[i] + damping * dangling * p[i];
|
|
89
|
+
for (let i = 0; i < N; i++) {
|
|
90
|
+
if (outW[i] === 0) continue;
|
|
91
|
+
const ri = damping * r[i];
|
|
92
|
+
for (const [j, w] of adj[i]) next[j] += ri * (w / outW[i]);
|
|
93
|
+
}
|
|
94
|
+
let diff = 0;
|
|
95
|
+
for (let i = 0; i < N; i++) diff += Math.abs(next[i] - r[i]);
|
|
96
|
+
r = next;
|
|
97
|
+
if (diff < tol) break;
|
|
98
|
+
}
|
|
99
|
+
const out = {};
|
|
100
|
+
for (let i = 0; i < N; i++) out[nodes[i]] = r[i];
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Aider-style identifier edge-weight multipliers. `mentioned` = focus/query
|
|
105
|
+
// idents (boosted). Rarity is approximated by the >5-definers penalty.
|
|
106
|
+
function identMul(ident, defineCount, mentioned) {
|
|
107
|
+
let mul = 1.0;
|
|
108
|
+
const hasAlpha = /[a-zA-Z]/.test(ident);
|
|
109
|
+
const isSnake = ident.includes("_") && hasAlpha;
|
|
110
|
+
const isKebab = ident.includes("-") && hasAlpha;
|
|
111
|
+
const isCamel = /[a-z]/.test(ident) && /[A-Z]/.test(ident);
|
|
112
|
+
if (mentioned && mentioned.has(ident)) mul *= 10;
|
|
113
|
+
if ((isSnake || isKebab || isCamel) && ident.length >= 8) mul *= 10;
|
|
114
|
+
if (ident.startsWith("_")) mul *= 0.1;
|
|
115
|
+
if (defineCount > 5) mul *= 0.1;
|
|
116
|
+
return mul;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
|
|
120
|
+
// else (missing / malformed / solution-style references that index 0 files) fall
|
|
121
|
+
// back to broad source globs so the tool degrades gracefully instead of crashing.
|
|
122
|
+
function makeProject() {
|
|
123
|
+
let project;
|
|
124
|
+
if (existsSync("tsconfig.json")) {
|
|
125
|
+
try { project = new Project({ tsConfigFilePath: "tsconfig.json" }); }
|
|
126
|
+
catch { project = new Project({ compilerOptions: { allowJs: true } }); }
|
|
127
|
+
} else {
|
|
128
|
+
project = new Project({ compilerOptions: { allowJs: true } });
|
|
129
|
+
}
|
|
130
|
+
// tsconfig `include` usually omits build/pipeline scripts — add by path.
|
|
131
|
+
project.addSourceFilesAtPaths(["scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.js", "*.mjs", "*.cjs"]);
|
|
132
|
+
// Fallback: nothing indexed (no / empty / references-only tsconfig) → broad globs.
|
|
133
|
+
if (project.getSourceFiles().length === 0)
|
|
134
|
+
project.addSourceFilesAtPaths([
|
|
135
|
+
"src/**/*.{ts,tsx,js,jsx}", "app/**/*.{ts,tsx,js,jsx}",
|
|
136
|
+
"components/**/*.{ts,tsx,js,jsx}", "lib/**/*.{ts,tsx,js,jsx}",
|
|
137
|
+
"pages/**/*.{ts,tsx,js,jsx}", "*.{ts,tsx,js,jsx}",
|
|
138
|
+
]);
|
|
139
|
+
return project;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// build() — parse the repo, extract file imports/exports (+ which named
|
|
144
|
+
// symbols cross each edge), compute file PageRank, run the Aider-style
|
|
145
|
+
// identifier graph to rank individual symbols, and persist repomap.json.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
function build() {
|
|
148
|
+
const project = makeProject();
|
|
149
|
+
const cwd = process.cwd().replace(/\\/g, "/");
|
|
150
|
+
const rel = (p) => p.replace(cwd + "/", "");
|
|
151
|
+
const files = {}, dependents = {}, features = {};
|
|
152
|
+
|
|
153
|
+
for (const sf of project.getSourceFiles()) {
|
|
154
|
+
const path = rel(sf.getFilePath());
|
|
155
|
+
if (path.includes("node_modules") || path.includes(".next")) continue;
|
|
156
|
+
const exports = [...sf.getExportedDeclarations()].map(([name, d]) => ({
|
|
157
|
+
name: name === "default" ? (d[0]?.getName?.() ?? "default") : name,
|
|
158
|
+
kind: d[0]?.getKindName?.() ?? "?",
|
|
159
|
+
}));
|
|
160
|
+
// Dependency edges from static imports + re-export barrels, with the set
|
|
161
|
+
// of named symbols crossing each edge (used for edge weights + the ident
|
|
162
|
+
// graph). importedSymbols[targetPath] = [names...].
|
|
163
|
+
const importedSymbols = {};
|
|
164
|
+
const addEdge = (tp, names) => {
|
|
165
|
+
if (tp.includes("node_modules")) return;
|
|
166
|
+
(importedSymbols[tp] ??= []).push(...names);
|
|
167
|
+
};
|
|
168
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
169
|
+
const t = imp.getModuleSpecifierSourceFile();
|
|
170
|
+
if (!t) continue;
|
|
171
|
+
const names = imp.getNamedImports().map((n) => n.getName());
|
|
172
|
+
if (imp.getDefaultImport()) names.push("default"); // canonical: local alias never matches the export name
|
|
173
|
+
if (imp.getNamespaceImport()) names.push("*");
|
|
174
|
+
addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
|
|
175
|
+
}
|
|
176
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
177
|
+
const t = exp.getModuleSpecifierSourceFile();
|
|
178
|
+
if (!t) continue;
|
|
179
|
+
addEdge(rel(t.getFilePath()), exp.getNamedExports().map((n) => n.getName()));
|
|
180
|
+
}
|
|
181
|
+
const imports = Object.keys(importedSymbols);
|
|
182
|
+
for (const tp of imports) (dependents[tp] ??= []).push(path);
|
|
183
|
+
files[path] = { exports, imports, importedSymbols };
|
|
184
|
+
const feat = featureOf(path);
|
|
185
|
+
if (feat) (features[feat] ??= []).push(path);
|
|
186
|
+
}
|
|
187
|
+
for (const p in files) files[p].dependents = dependents[p] ?? [];
|
|
188
|
+
|
|
189
|
+
// --- File PageRank: edges importer→imported, weighted by # symbols crossed.
|
|
190
|
+
const nodes = Object.keys(files);
|
|
191
|
+
const fileEdges = [];
|
|
192
|
+
for (const [p, f] of Object.entries(files))
|
|
193
|
+
for (const tp of f.imports)
|
|
194
|
+
if (files[tp]) fileEdges.push({ from: p, to: tp, weight: (f.importedSymbols[tp] || []).length || 1 });
|
|
195
|
+
const fileRank = pagerank(nodes, fileEdges);
|
|
196
|
+
for (const p of nodes) files[p].pagerank = +(fileRank[p] || 0).toFixed(6);
|
|
197
|
+
|
|
198
|
+
// --- Symbol ranking (Aider-style): identifier graph from named imports.
|
|
199
|
+
const rankedSymbols = rankSymbols(files, null);
|
|
200
|
+
|
|
201
|
+
// hubs: now PageRank-ranked (raw dependent count shown alongside).
|
|
202
|
+
const hubs = nodes
|
|
203
|
+
.map((p) => [p, files[p].pagerank, files[p].dependents.length])
|
|
204
|
+
.sort((a, b) => b[1] - a[1])
|
|
205
|
+
.slice(0, 15)
|
|
206
|
+
.map(([p, pr, deg]) => `${p} (deg ${deg}, pr ${pr})`);
|
|
207
|
+
|
|
208
|
+
const out = {
|
|
209
|
+
schema: SCHEMA_VERSION, generatedSha: currentSha(), dirty: dirtyCount(), fileCount: nodes.length,
|
|
210
|
+
hubs, features, rankedSymbols: rankedSymbols.slice(0, 80), files,
|
|
211
|
+
};
|
|
212
|
+
mkdirSync(".claude", { recursive: true });
|
|
213
|
+
writeFileSync(MAP, JSON.stringify(out));
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build the Aider-style identifier graph from the file map and return a
|
|
218
|
+
// ranked list of { file, name, kind, rank }. `focus` (Set of paths) +
|
|
219
|
+
// derived mentioned idents personalize the ranking when given.
|
|
220
|
+
function rankSymbols(files, focus) {
|
|
221
|
+
const defines = new Map(); // ident -> Set(file)
|
|
222
|
+
const references = new Map(); // ident -> [file...] (multiplicity)
|
|
223
|
+
const definition = new Map(); // `${file}|${ident}` -> {file, name, kind}
|
|
224
|
+
for (const [p, f] of Object.entries(files)) {
|
|
225
|
+
for (const e of f.exports) {
|
|
226
|
+
(defines.get(e.name) ?? defines.set(e.name, new Set()).get(e.name)).add(p);
|
|
227
|
+
definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const [p, f] of Object.entries(files))
|
|
231
|
+
for (const tp of f.imports)
|
|
232
|
+
for (const name of f.importedSymbols[tp] || [])
|
|
233
|
+
if (name !== "*" && name !== "default") (references.get(name) ?? references.set(name, []).get(name)).push(p);
|
|
234
|
+
|
|
235
|
+
// mentioned idents from focus files' exports + their basenames
|
|
236
|
+
let mentioned = null;
|
|
237
|
+
if (focus && focus.size) {
|
|
238
|
+
mentioned = new Set();
|
|
239
|
+
for (const p of focus) {
|
|
240
|
+
for (const e of (files[p]?.exports || [])) mentioned.add(e.name);
|
|
241
|
+
const base = p.split("/").pop().replace(/\.[^.]+$/, "");
|
|
242
|
+
mentioned.add(base);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const nodes = Object.keys(files);
|
|
247
|
+
const edges = [];
|
|
248
|
+
for (const ident of defines.keys()) {
|
|
249
|
+
if (!references.has(ident)) continue;
|
|
250
|
+
const mul = identMul(ident, defines.get(ident).size, mentioned);
|
|
251
|
+
const counts = new Map();
|
|
252
|
+
for (const refFile of references.get(ident)) counts.set(refFile, (counts.get(refFile) || 0) + 1);
|
|
253
|
+
for (const [refFile, n] of counts)
|
|
254
|
+
for (const defFile of defines.get(ident)) {
|
|
255
|
+
if (refFile === defFile) continue;
|
|
256
|
+
let useMul = mul;
|
|
257
|
+
if (focus && focus.has(refFile)) useMul *= 50;
|
|
258
|
+
edges.push({ from: refFile, to: defFile, weight: useMul * Math.sqrt(n), ident });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// personalization seeds: focus files + files whose name matches a mention
|
|
262
|
+
let pers = null;
|
|
263
|
+
if (focus && focus.size) {
|
|
264
|
+
pers = {};
|
|
265
|
+
const unit = 100 / nodes.length;
|
|
266
|
+
for (const p of nodes) {
|
|
267
|
+
let v = 0;
|
|
268
|
+
if (focus.has(p)) v += unit;
|
|
269
|
+
const parts = new Set([...p.split("/"), p.split("/").pop(), p.split("/").pop().replace(/\.[^.]+$/, "")]);
|
|
270
|
+
if (mentioned && [...parts].some((x) => mentioned.has(x))) v += unit;
|
|
271
|
+
if (v > 0) pers[p] = v;
|
|
272
|
+
}
|
|
273
|
+
if (!Object.keys(pers).length) pers = null;
|
|
274
|
+
}
|
|
275
|
+
const rank = pagerank(nodes, edges, pers ? { personalization: pers } : {});
|
|
276
|
+
|
|
277
|
+
// redistribute each file's rank across its out-edges onto (defFile, ident)
|
|
278
|
+
const out = new Map(); // `${file}|${ident}` -> total weight
|
|
279
|
+
const totalW = new Map();
|
|
280
|
+
for (const e of edges) totalW.set(e.from, (totalW.get(e.from) || 0) + e.weight);
|
|
281
|
+
for (const e of edges) {
|
|
282
|
+
const share = (rank[e.from] || 0) * e.weight / (totalW.get(e.from) || 1);
|
|
283
|
+
const k = `${e.to}|${e.ident}`;
|
|
284
|
+
out.set(k, (out.get(k) || 0) + share);
|
|
285
|
+
}
|
|
286
|
+
return [...out.entries()]
|
|
287
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
288
|
+
.map(([k, r]) => ({ ...(definition.get(k) || { file: k.slice(0, k.lastIndexOf("|")), name: k.slice(k.lastIndexOf("|") + 1), kind: "?" }), rank: +r.toFixed(6) }))
|
|
289
|
+
.filter((d) => !(focus && focus.has(d.file)));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Serve the cached map only when provably current: same HEAD, known schema,
|
|
293
|
+
// clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
|
|
294
|
+
function ensureFresh() {
|
|
295
|
+
const sha = currentSha();
|
|
296
|
+
if (existsSync(MAP)) {
|
|
297
|
+
try {
|
|
298
|
+
const cached = JSON.parse(readFileSync(MAP, "utf8"));
|
|
299
|
+
// Trust cache only if: same HEAD, known schema, it was built CLEAN
|
|
300
|
+
// (cached.dirty === 0 — never trust a map built mid-edit, even after a
|
|
301
|
+
// revert returns the tree to clean), AND the tree is clean right now.
|
|
302
|
+
if (sha && cached.generatedSha === sha && cached.schema === SCHEMA_VERSION && cached.dirty === 0 && dirtyCount() === 0) return cached;
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
return build();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Resolve a query to a file key: exact path → unique basename → unique substring.
|
|
309
|
+
function resolveFile(keys, filesObj, q) {
|
|
310
|
+
if (filesObj[q]) return { key: q };
|
|
311
|
+
const base = keys.filter((k) => k.split("/").pop() === q);
|
|
312
|
+
if (base.length === 1) return { key: base[0] };
|
|
313
|
+
const subs = keys.filter((k) => k.toLowerCase().includes(q.toLowerCase()));
|
|
314
|
+
if (subs.length === 1) return { key: subs[0] };
|
|
315
|
+
return { key: null, candidates: subs };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function fileBlock(key, f) {
|
|
319
|
+
console.log(`exports (${f.exports.length}): ${f.exports.map((e) => `${e.name}(${e.kind})`).join(", ") || "—"}`);
|
|
320
|
+
console.log(`imports (${f.imports.length}): ${f.imports.join(", ") || "—"}`);
|
|
321
|
+
console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// CLI
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
const args = process.argv.slice(2);
|
|
328
|
+
const has = (f) => args.includes(f);
|
|
329
|
+
const arg = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
|
|
330
|
+
|
|
331
|
+
if (has("--any")) {
|
|
332
|
+
// Unified router: cached structure (file → symbol → feature) then a LIVE
|
|
333
|
+
// git-grep fallback for data/copy/string-literals the graph never indexes.
|
|
334
|
+
const raw = arg("--any") || "";
|
|
335
|
+
if (!raw) { console.log('--any needs a query, e.g. `--any PremiumCard` or `--any "multi-modal"`'); }
|
|
336
|
+
else {
|
|
337
|
+
const q = raw.toLowerCase();
|
|
338
|
+
const data = ensureFresh();
|
|
339
|
+
const keys = Object.keys(data.files);
|
|
340
|
+
const { key: fileKey, candidates } = resolveFile(keys, data.files, raw);
|
|
341
|
+
const symHits = [];
|
|
342
|
+
for (const [path, f] of Object.entries(data.files))
|
|
343
|
+
for (const e of f.exports)
|
|
344
|
+
if (e.name.toLowerCase().includes(q)) symHits.push(` ${path} → ${e.name} (${e.kind})`);
|
|
345
|
+
const featNames = Object.keys(data.features || {}).filter((k) => k.toLowerCase().includes(q));
|
|
346
|
+
if (fileKey) {
|
|
347
|
+
console.log(`[structure:file] ${fileKey} (pr ${data.files[fileKey].pagerank ?? "—"})`);
|
|
348
|
+
fileBlock(fileKey, data.files[fileKey]);
|
|
349
|
+
} else if (symHits.length || featNames.length) {
|
|
350
|
+
console.log(`[structure] ${symHits.length} symbol, ${featNames.length} feature match for "${raw}"`);
|
|
351
|
+
if (symHits.length) console.log(symHits.join("\n"));
|
|
352
|
+
if (featNames.length) console.log("features: " + featNames.map((n) => `${n} (${data.features[n].length})`).join(", "));
|
|
353
|
+
} else if (candidates && candidates.length > 1) {
|
|
354
|
+
console.log(`[structure] "${raw}" matched ${candidates.length} files — narrow it:`);
|
|
355
|
+
for (const k of candidates) console.log(` ${k}`);
|
|
356
|
+
} else {
|
|
357
|
+
const res = contentSearch(raw);
|
|
358
|
+
if (!res) console.log(`[content] 0 match for "${raw}" (git grep, tracked + untracked)`);
|
|
359
|
+
else {
|
|
360
|
+
const lines = res.split("\n");
|
|
361
|
+
console.log(`[content] ${lines.length} line${lines.length > 1 ? "s" : ""}${lines.length > 40 ? " (showing 40)" : ""}:`);
|
|
362
|
+
console.log(lines.slice(0, 40).join("\n"));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} else if (has("--find")) {
|
|
367
|
+
const raw = arg("--find") || "", q = raw.toLowerCase();
|
|
368
|
+
const data = ensureFresh();
|
|
369
|
+
const hits = [];
|
|
370
|
+
for (const [path, f] of Object.entries(data.files))
|
|
371
|
+
for (const e of f.exports)
|
|
372
|
+
if (e.name.toLowerCase().includes(q)) hits.push(` ${path} → ${e.name} (${e.kind})`);
|
|
373
|
+
console.log(`find "${raw}": ${hits.length} match`);
|
|
374
|
+
if (hits.length) console.log(hits.join("\n"));
|
|
375
|
+
} else if (has("--relates")) {
|
|
376
|
+
const q = arg("--relates") || "";
|
|
377
|
+
const data = ensureFresh();
|
|
378
|
+
const keys = Object.keys(data.files);
|
|
379
|
+
const { key, candidates } = resolveFile(keys, data.files, q);
|
|
380
|
+
if (!key) {
|
|
381
|
+
if (candidates && candidates.length > 1) { console.log(`relates: "${q}" matched ${candidates.length} files — narrow it:`); for (const k of candidates) console.log(` ${k}`); }
|
|
382
|
+
else console.log(`relates: no file matching "${q}"`);
|
|
383
|
+
} else {
|
|
384
|
+
const f = data.files[key];
|
|
385
|
+
console.log(`relates: ${key} (pr ${f.pagerank ?? "—"})`);
|
|
386
|
+
fileBlock(key, f);
|
|
387
|
+
// query-focused relevance: personalized PageRank (random-walk-with-restart)
|
|
388
|
+
// on a BIDIRECTIONAL graph → files most related to the target, transitively.
|
|
389
|
+
const biEdges = [];
|
|
390
|
+
for (const [p, ff] of Object.entries(data.files))
|
|
391
|
+
for (const tp of ff.imports) if (data.files[tp]) { biEdges.push({ from: p, to: tp, weight: 1 }); biEdges.push({ from: tp, to: p, weight: 1 }); }
|
|
392
|
+
const rel = pagerank(keys, biEdges, { personalization: { [key]: 1 } });
|
|
393
|
+
const top = Object.entries(rel).filter(([k]) => k !== key).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
394
|
+
console.log(`related (random-walk relevance):`);
|
|
395
|
+
for (const [k, r] of top) console.log(` ${k} (${r.toFixed(4)})`);
|
|
396
|
+
}
|
|
397
|
+
} else if (has("--map")) {
|
|
398
|
+
// Token-budgeted ranked digest (Aider's killer feature). --focus <path>
|
|
399
|
+
// personalizes toward a file; default budget 1024, ×8 with no focus.
|
|
400
|
+
const data = ensureFresh();
|
|
401
|
+
const focusArg = arg("--focus");
|
|
402
|
+
const tk = parseInt(arg("--tokens") ?? "", 10);
|
|
403
|
+
const budget = Number.isFinite(tk) && tk > 0 ? tk : (focusArg ? 1024 : 8192);
|
|
404
|
+
let ranked = data.rankedSymbols || [];
|
|
405
|
+
let focusLabel = "global";
|
|
406
|
+
if (focusArg) {
|
|
407
|
+
const { key, candidates } = resolveFile(Object.keys(data.files), data.files, focusArg);
|
|
408
|
+
if (key) { ranked = rankSymbols(data.files, new Set([key])); focusLabel = key; }
|
|
409
|
+
else console.error(`# warning: --focus "${focusArg}" matched ${(candidates && candidates.length) || 0} files — using global ranking`);
|
|
410
|
+
}
|
|
411
|
+
// Fallback for default-export-heavy repos (sparse named-symbol graph): build
|
|
412
|
+
// the digest from file PageRank so --map is never empty.
|
|
413
|
+
if (!ranked.length)
|
|
414
|
+
ranked = Object.entries(data.files)
|
|
415
|
+
.sort((a, b) => (b[1].pagerank || 0) - (a[1].pagerank || 0))
|
|
416
|
+
.flatMap(([file, f]) => (f.exports || []).map((e) => ({ file, name: e.name, kind: e.kind, rank: f.pagerank || 0 })));
|
|
417
|
+
console.log(`# repomap (${data.fileCount} files, sha ${data.generatedSha}) — focus: ${focusLabel}, budget ~${budget} tok`);
|
|
418
|
+
let used = 0, shown = 0;
|
|
419
|
+
const byFile = new Map();
|
|
420
|
+
for (const s of ranked) { if (!byFile.has(s.file)) byFile.set(s.file, []); byFile.get(s.file).push(s); }
|
|
421
|
+
for (const [file, syms] of byFile) {
|
|
422
|
+
const line = `\n${file}:\n` + syms.slice(0, 8).map((s) => ` ${s.name} (${s.kind})`).join("\n");
|
|
423
|
+
const t = tokEst(line);
|
|
424
|
+
if (used + t > budget) break;
|
|
425
|
+
used += t; shown++; console.log(line);
|
|
426
|
+
}
|
|
427
|
+
console.log(`\n# ~${used} tokens (${shown} files shown)`);
|
|
428
|
+
} else if (has("--symbols")) {
|
|
429
|
+
const data = ensureFresh();
|
|
430
|
+
const sn = parseInt(arg("--symbols") ?? "", 10); const n = Number.isFinite(sn) && sn > 0 ? sn : 30;
|
|
431
|
+
console.log(`top ${n} ranked symbols (Aider-style):`);
|
|
432
|
+
for (const s of (data.rankedSymbols || []).slice(0, n)) console.log(` ${s.rank} ${s.file} → ${s.name} (${s.kind})`);
|
|
433
|
+
} else if (has("--feature")) {
|
|
434
|
+
const raw = arg("--feature") || "", q = raw.toLowerCase();
|
|
435
|
+
const data = ensureFresh();
|
|
436
|
+
const name = Object.keys(data.features).find((k) => k.toLowerCase() === q) || Object.keys(data.features).find((k) => k.toLowerCase().includes(q));
|
|
437
|
+
if (!name) console.log(`feature: no match for "${raw}" — run --features to list them.`);
|
|
438
|
+
else {
|
|
439
|
+
const fl = data.features[name], set = new Set(fl), exts = new Set();
|
|
440
|
+
for (const p of fl) for (const dep of (data.files[p]?.dependents || [])) if (!set.has(dep)) exts.add(dep);
|
|
441
|
+
console.log(`feature "${name}": ${fl.length} files`);
|
|
442
|
+
for (const p of fl) console.log(` ${p}`);
|
|
443
|
+
console.log(`external dependents (${exts.size}): ${[...exts].join(", ") || "—"}`);
|
|
444
|
+
}
|
|
445
|
+
} else if (has("--features")) {
|
|
446
|
+
const data = ensureFresh();
|
|
447
|
+
const list = Object.entries(data.features).map(([k, v]) => [k, v.length]).sort((a, b) => b[1] - a[1]);
|
|
448
|
+
console.log(`features (${list.length}):`);
|
|
449
|
+
for (const [k, n] of list) console.log(` ${k} (${n} files)`);
|
|
450
|
+
} else if (has("--hubs")) {
|
|
451
|
+
const data = ensureFresh();
|
|
452
|
+
console.log(`repomap: ${data.fileCount} files (sha ${data.generatedSha})`);
|
|
453
|
+
console.log("hubs (PageRank importance):");
|
|
454
|
+
for (const h of data.hubs) console.log(` ${h}`);
|
|
455
|
+
} else if (has("--print")) {
|
|
456
|
+
const data = ensureFresh();
|
|
457
|
+
console.log(JSON.stringify({ hubs: data.hubs, features: data.features, rankedSymbols: data.rankedSymbols, files: data.files }));
|
|
458
|
+
} else {
|
|
459
|
+
const out = build();
|
|
460
|
+
console.log(`repomap: ${out.fileCount} files | ${Object.keys(out.features).length} features | top hub: ${out.hubs[0] || "—"}`);
|
|
461
|
+
}
|