@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.
Files changed (2) hide show
  1. package/agentmap.mjs +137 -11
  2. 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
- const SCHEMA_VERSION = 2;
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|jsx|js)$/.test(p);
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
- const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs)$/;
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 (!/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) continue;
253
- const segs = f.split("/");
254
- if (segs.includes("node_modules") || segs.includes(".next")) continue;
255
- if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
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
- const rel = (p) => p.replace(cwd + "/", "");
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
- return sf ? rel(sf.getFilePath()) : null;
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.4.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": {