@raymondchins/agentmap 0.4.0 → 0.5.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/agentmap.mjs +137 -11
- package/package.json +2 -2
package/agentmap.mjs
CHANGED
|
@@ -28,7 +28,10 @@ const tsMorph = () => (_tsm ??= _require("ts-morph"));
|
|
|
28
28
|
|
|
29
29
|
const MAP = ".claude/agentmap/map.json";
|
|
30
30
|
const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
|
|
31
|
-
|
|
31
|
+
// Bumped 2 → 3: Vue SFC support. `.vue` files now appear in the map and the
|
|
32
|
+
// source-discovery / freshness checks treat them as first-class source files.
|
|
33
|
+
// Old caches (schema 2) are ignored so the first run after upgrade rebuilds.
|
|
34
|
+
const SCHEMA_VERSION = 3;
|
|
32
35
|
|
|
33
36
|
// ---------------------------------------------------------------------------
|
|
34
37
|
// Tuning constants — KEEP THESE VALUES IDENTICAL (output + marketing must not
|
|
@@ -85,7 +88,7 @@ const dirtyCount = () =>
|
|
|
85
88
|
let p = l.slice(3); // strip "XY " status prefix
|
|
86
89
|
if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
|
|
87
90
|
p = p.replace(/^"|"$/g, ""); // unquote space/special paths
|
|
88
|
-
return /\.(ts|tsx|mjs|cjs|
|
|
91
|
+
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
|
|
89
92
|
}).length;
|
|
90
93
|
const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
|
|
91
94
|
|
|
@@ -96,7 +99,8 @@ const getOrSet = (m, k, make) => { let v = m.get(k); if (v === undefined) { v =
|
|
|
96
99
|
// "path:mtimeMs:size" for source files so the cache can be trusted between runs
|
|
97
100
|
// without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller
|
|
98
101
|
// falls through to build, i.e. current behavior). Never used on the git path.
|
|
99
|
-
|
|
102
|
+
// Includes `.vue` so editing a Vue SFC invalidates the non-git cache too.
|
|
103
|
+
const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
|
|
100
104
|
function sourceFingerprint() {
|
|
101
105
|
try {
|
|
102
106
|
const entries = [];
|
|
@@ -121,6 +125,70 @@ function sourceFingerprint() {
|
|
|
121
125
|
} catch { return ""; }
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// Vue Single File Component support — best-effort, zero-dependency.
|
|
130
|
+
//
|
|
131
|
+
// agentmap is TS/JS-first. Vue `.vue` SFCs are NOT TypeScript; the Vue compiler
|
|
132
|
+
// (`@vue/compiler-sfc`) is intentionally NOT a dependency (CONTRIBUTING near-
|
|
133
|
+
// zero-deps rule). Instead we extract ONLY the `<script>` / `<script setup>`
|
|
134
|
+
// block text with a conservative regex and feed it to ts-morph as a VIRTUAL
|
|
135
|
+
// source file (e.g. `App.vue.ts`). A virtual→real path map (see build())
|
|
136
|
+
// rewrites every user-facing path back to the real `.vue` path so no
|
|
137
|
+
// `.vue.ts` / `.vue.js` ever leaks into JSON or prose.
|
|
138
|
+
//
|
|
139
|
+
// Non-goals: no template AST, no `<style>` parsing, no Nuxt auto-import
|
|
140
|
+
// resolution, no Svelte/Astro. Only `<script>` blocks that look like JS/TS.
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
// Find the first top-level `<script ...>` block (optionally `<script setup ...>`)
|
|
144
|
+
// whose opening tag does NOT carry `src="..."` (external script reference —
|
|
145
|
+
// the actual JS lives in another file agentmap already indexes on its own).
|
|
146
|
+
// Handles single + double quoted lang/src attributes and `lang="ts"`/`ts`.
|
|
147
|
+
// Returns { lang, setup, text } for the matched block, or null if none.
|
|
148
|
+
//
|
|
149
|
+
// Greedy-free: stops at the FIRST `</script>` on its own. Vue forbids nested
|
|
150
|
+
// `<script>` tags, so a non-greedy match up to `</script>` is safe. We do NOT
|
|
151
|
+
// support `<script>` + `<script setup>` in the same SFC for indexing — we pick
|
|
152
|
+
// the richer one: prefer `setup` block if present, else the normal block.
|
|
153
|
+
function extractVueScripts(text) {
|
|
154
|
+
const blocks = [];
|
|
155
|
+
// Open-tag matcher is QUOTE-AWARE: attribute values may legitimately contain
|
|
156
|
+
// `>` (e.g. `<script setup lang="ts" generic="T extends Record<string, unknown>">`
|
|
157
|
+
// — a common Vue 3 idiom for typed generic components). We require all
|
|
158
|
+
// attributes to be either bare (`setup`) or quoted (`name="value"` or
|
|
159
|
+
// `name='value'`), which matches valid SFC syntax. Bareword and unquoted forms
|
|
160
|
+
// are intentionally not matched because they're not valid HTML and would
|
|
161
|
+
// almost certainly indicate a parsing bug we want to surface, not silently
|
|
162
|
+
// misparse.
|
|
163
|
+
const re = /<script(\s+[a-zA-Z][\w-]*(\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s*\/?>/gi;
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = re.exec(text)) !== null) {
|
|
166
|
+
const attrs = (m[0].slice(7, -1) || "").trim(); // strip <script…> wrapper
|
|
167
|
+
// find body: text after the opening tag up to </script>
|
|
168
|
+
const openEnd = m.index + m[0].length;
|
|
169
|
+
const closeStart = text.toLowerCase().indexOf("</script>", openEnd);
|
|
170
|
+
if (closeStart === -1) break; // unterminated — stop scanning
|
|
171
|
+
const body = text.slice(openEnd, closeStart);
|
|
172
|
+
// external script reference → skip (the target file is indexed directly).
|
|
173
|
+
if (/\bsrc\s*=\s*["'][^"']+["']/i.test(attrs)) continue;
|
|
174
|
+
if (!body.trim()) continue; // empty body (e.g. <script/>) — not useful
|
|
175
|
+
const setup = /\bsetup\b/i.test(attrs);
|
|
176
|
+
const lang = (attrs.match(/\blang\s*=\s*["']([^"']+)["']/i) || [])[1] || "js";
|
|
177
|
+
blocks.push({ lang: lang.toLowerCase(), setup, text: body });
|
|
178
|
+
re.lastIndex = closeStart + "</script>".length; // resume after </script>
|
|
179
|
+
}
|
|
180
|
+
if (!blocks.length) return null;
|
|
181
|
+
// Prefer a setup block (the modern idiom) when present; else the plain block.
|
|
182
|
+
return blocks.find((b) => b.setup) || blocks[0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Virtual file path mapping for a `.vue` source. The virtual path is what
|
|
186
|
+
// ts-morph sees (so `.ts`/`.js` parsing kicks in); the real path is what every
|
|
187
|
+
// user-facing output shows. `lang="ts"` → `.vue.ts`, otherwise `.vue.js`.
|
|
188
|
+
function vueVirtualPath(realPath, lang) {
|
|
189
|
+
return lang === "ts" ? `${realPath}.ts` : `${realPath}.js`;
|
|
190
|
+
}
|
|
191
|
+
|
|
124
192
|
// Feature = first real route segment under app/ (or src/app/), skipping route
|
|
125
193
|
// groups (parens), dynamic segments ([id]) and parallel routes (@slot).
|
|
126
194
|
function featureOf(path) {
|
|
@@ -246,13 +314,25 @@ function makeProject() {
|
|
|
246
314
|
const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
|
|
247
315
|
const cwdp = process.cwd().replace(/\\/g, "/");
|
|
248
316
|
const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
|
|
317
|
+
// `.vue` discovery: same channel as TS/JS (git ls-files when available, else
|
|
318
|
+
// a broad glob fallback). We do NOT hand `.vue` straight to ts-morph (it is
|
|
319
|
+
// not TS/JS). Instead, for each `.vue` file we read its `<script>` block via
|
|
320
|
+
// extractVueScripts() and register it as a VIRTUAL source file
|
|
321
|
+
// (`App.vue.ts` / `App.vue.js`). A virtual→real path map is returned alongside
|
|
322
|
+
// the project so build() can rewrite every user-facing path back to `.vue`.
|
|
323
|
+
const vueFiles = [];
|
|
249
324
|
if (listed.length) {
|
|
250
325
|
const missing = [];
|
|
251
326
|
for (const f of listed) {
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
327
|
+
if (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) {
|
|
328
|
+
const segs = f.split("/");
|
|
329
|
+
if (segs.includes("node_modules") || segs.includes(".next")) continue;
|
|
330
|
+
if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
|
|
331
|
+
} else if (f.endsWith(".vue")) {
|
|
332
|
+
const segs = f.split("/");
|
|
333
|
+
if (segs.includes("node_modules") || segs.includes(".next")) continue;
|
|
334
|
+
vueFiles.push(f);
|
|
335
|
+
}
|
|
256
336
|
}
|
|
257
337
|
if (missing.length) project.addSourceFilesAtPaths(missing);
|
|
258
338
|
} else {
|
|
@@ -262,8 +342,38 @@ function makeProject() {
|
|
|
262
342
|
"components/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "lib/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
|
263
343
|
"pages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
|
264
344
|
]);
|
|
345
|
+
// Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
|
|
346
|
+
try {
|
|
347
|
+
const walk = (dir) => {
|
|
348
|
+
for (const name of readdirSync(dir)) {
|
|
349
|
+
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
350
|
+
const full = dir + "/" + name;
|
|
351
|
+
// lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
|
|
352
|
+
// a circular symlink would otherwise recurse until the stack overflows.
|
|
353
|
+
let st; try { st = lstatSync(full); } catch { continue; }
|
|
354
|
+
if (st.isSymbolicLink()) continue;
|
|
355
|
+
if (st.isDirectory()) walk(full);
|
|
356
|
+
else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
walk(".");
|
|
360
|
+
} catch { /* ignore — proceed without Vue */ }
|
|
361
|
+
}
|
|
362
|
+
// Build the virtual→real map and register each `<script>` block as a virtual
|
|
363
|
+
// ts-morph source. Files without a usable `<script>` block are silently
|
|
364
|
+
// skipped (template/style-only SFCs contribute nothing to the import graph).
|
|
365
|
+
const vueMap = Object.create(null); // virtualPath → realPath
|
|
366
|
+
const vueReal = Object.create(null); // realPath → true (for resolver)
|
|
367
|
+
for (const f of vueFiles) {
|
|
368
|
+
let text; try { text = readFileSync(f, "utf8"); } catch { continue; }
|
|
369
|
+
const block = extractVueScripts(text);
|
|
370
|
+
if (!block || !block.text.trim()) continue;
|
|
371
|
+
const vpath = vueVirtualPath(f, block.lang);
|
|
372
|
+
project.createSourceFile(`${cwdp}/${vpath}`, block.text, { overwrite: true });
|
|
373
|
+
vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
|
|
374
|
+
vueReal[`${cwdp}/${f}`] = true;
|
|
265
375
|
}
|
|
266
|
-
return project;
|
|
376
|
+
return { project, vueMap, vueReal };
|
|
267
377
|
}
|
|
268
378
|
|
|
269
379
|
// ---------------------------------------------------------------------------
|
|
@@ -273,11 +383,18 @@ function makeProject() {
|
|
|
273
383
|
// ---------------------------------------------------------------------------
|
|
274
384
|
function build() {
|
|
275
385
|
const t0 = Date.now();
|
|
276
|
-
const project = makeProject();
|
|
386
|
+
const { project, vueMap, vueReal } = makeProject();
|
|
277
387
|
const { SyntaxKind } = tsMorph();
|
|
278
388
|
const CallExpression = SyntaxKind.CallExpression;
|
|
279
389
|
const cwd = process.cwd().replace(/\\/g, "/");
|
|
280
|
-
|
|
390
|
+
// rel() rewrites ts-morph file paths to repo-relative keys. For Vue virtual
|
|
391
|
+
// sources (`App.vue.ts`), vueMap rewrites back to the real `.vue` path so
|
|
392
|
+
// users never see virtual paths in the map, hubs, --relates, or --find.
|
|
393
|
+
const rel = (p) => {
|
|
394
|
+
const abs = p.replace(/\\/g, "/");
|
|
395
|
+
const real = vueMap[abs];
|
|
396
|
+
return (real || abs).replace(cwd + "/", "");
|
|
397
|
+
};
|
|
281
398
|
const files = {}, dependents = {}, features = {};
|
|
282
399
|
// PATH-SEGMENT exclusion (not substring) so e.g. components/.next-demo or
|
|
283
400
|
// src/node_modules_helper.ts are NOT wrongly excluded.
|
|
@@ -298,10 +415,19 @@ function build() {
|
|
|
298
415
|
};
|
|
299
416
|
const baseAbs = join(fromAbsDir, spec);
|
|
300
417
|
const tryGet = (abs) => { const sf = project.getSourceFile(abs); return sf ? sf : null; };
|
|
418
|
+
// Vue SFC: `import X from "./C.vue"` (exact) ALWAYS wins — the user wrote
|
|
419
|
+
// `.vue` explicitly, so we honor that. This check must stay BEFORE the
|
|
420
|
+
// TS/JS loop.
|
|
421
|
+
if (vueReal[baseAbs]) return rel(baseAbs);
|
|
301
422
|
let sf = tryGet(baseAbs);
|
|
302
423
|
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}.${e}`); if (sf) break; }
|
|
303
424
|
if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}/index.${e}`); if (sf) break; }
|
|
304
|
-
|
|
425
|
+
// TS/JS SHADOW WINS: when a same-name .ts/.js exists, the extensionless
|
|
426
|
+
// `import "./C"` resolves to it (TS/JS-first priority is preserved). Only
|
|
427
|
+
// fall through to `.vue` as a last resort, when no TS/JS shadow exists.
|
|
428
|
+
if (sf) return rel(sf.getFilePath());
|
|
429
|
+
if (vueReal[`${baseAbs}.vue`]) return rel(`${baseAbs}.vue`);
|
|
430
|
+
return null;
|
|
305
431
|
};
|
|
306
432
|
|
|
307
433
|
const sourceFiles = project.getSourceFiles();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raymondchins/agentmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"map": "node agentmap.mjs",
|
|
21
|
-
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"test": "node --test test/*.test.mjs test/**/*.test.mjs",
|
|
22
22
|
"eval": "node eval/eval.mjs"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|