pi-fovea 0.1.2 → 0.2.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/README.md CHANGED
@@ -120,7 +120,21 @@ Global `~/.pi/agent/fovea.json`; project override `<repo>/.pi/fovea.json` when t
120
120
 
121
121
  ## Repo rule packs
122
122
 
123
- Drop `.fovea/rules.json` in a repo to extend anchor detection beyond the built-ins (express/orval chains, NestJS decorators, Flask/FastAPI decorators, Go chi/mux, axum):
123
+ The built-in pack catches route declarations by **port shape**, not framework name five shapes cover almost the whole ecosystem:
124
+
125
+ | Port shape | Examples |
126
+ |---|---|
127
+ | `recv.verb("path", handlers…)` | express, koa, fastify, hono, gin, echo, chi, net/http (any quotes, incl. TS template literals and Python f-strings) |
128
+ | verb-annotation + optional class prefix | NestJS `@Controller + @Get`, Flask/FastAPI decorators, Spring `@RequestMapping + @GetMapping` |
129
+ | verb embedded in the path | Go 1.22 `mux.HandleFunc("GET /x", h)` |
130
+ | verb as first string argument | chi `r.Method("GET", path, h)`, aiohttp `router.add_route("GET", path, h)` |
131
+ | receiver-less DSL macros | Rails `routes.rb`, Phoenix `router.ex`, Django `path()`, Ktor `routing { get("/x") {} }` |
132
+
133
+ File-convention routers never write a route string at all — those anchors are derived from **file paths** (Next.js App Router `app/**/route.ts` + `page.tsx`, Pages Router `pages/api/**`, SvelteKit `+server.ts` / `+page.svelte`, Nuxt `server/api/**.get.ts`, Astro endpoints), with verbs pulled from exported handler names or file-name suffixes.
134
+
135
+ **Known blind spots** (deliberate, logged in `src/core/anchors.ts`): Rust proc-macro attribute routers (actix `#[get("/x")]`, rocket) — ast-grep patterns can't parameterize attribute paths; frameworks with constructor-assigned prefixes (Flask Blueprint, FastAPI `APIRouter(prefix=…)`, chi `Mount`, Express `Router` mounts) — variable binding tracking is out of band; `scope`/`namespace` nesting in Phoenix/Rails/Django `include()` — prefixes across blocks aren't composed; tRPC/GraphQL/gRPC — no path token exists to anchor on.
136
+
137
+ Drop `.fovea/rules.json` in a repo to extend anchor detection beyond the built-ins:
124
138
 
125
139
  ```json
126
140
  {
package/cli.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  // fovea focus [root] <query> [budget]
6
6
  // fovea dwell [root] [factor] [budget] (deepens the in-process focus)
7
7
  // fovea impact [root] [--files a,b] [--symbols x,y] [--base ref] [--no-uncommitted] [budget]
8
+ // fovea anchors [root] [filter] (every feature anchor, sorted)
8
9
  //
9
10
  // The CLI is stateless across invocations (dwell needs a prior focus in the
10
11
  // same process — combine ops inside pi, where sessions persist); stdout is
11
12
  // the rendered field, nothing else, so it composes with head/grep/$().
12
13
 
13
- import { sketch, focus, dwell, impact } from "./src/core/ops.js";
14
+ import { ensureState, sketch, focus, dwell, impact } from "./src/core/ops.js";
14
15
 
15
16
  const [, , cmd = "status", ...argv] = process.argv;
16
17
 
@@ -71,6 +72,14 @@ try {
71
72
  includeUncommitted: !flags.has("no-uncommitted"),
72
73
  budget: B ?? 2000,
73
74
  }).text;
75
+ } else if (cmd === "anchors") {
76
+ const root = rootAt(0);
77
+ const filter = pos.find((p) => p !== root);
78
+ const rows = ensureState(root).graph.anchors
79
+ .map((a) => `${a.kind}\t${a.id}\t${a.file}:${a.line}`)
80
+ .filter((r) => !filter || r.includes(filter))
81
+ .sort();
82
+ out = rows.join("\n");
74
83
  } else {
75
84
  console.error(`unknown command: ${cmd}`);
76
85
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,8 +1,19 @@
1
1
  // Feature anchors: where a feature touches the outside world. Anchors are
2
2
  // extracted by a declarative rule pack (ast-grep patterns + metadata), so new
3
- // frameworks are added as data. A route registration is the canonical anchor:
4
- // one pattern shape covers express/koa (TS), gin/echo/chi (Go), flask/fastapi
5
- // decorators (Python), and axum-style chains (Rust).
3
+ // frameworks are added as data. The pack covers five port shapes:
4
+ //
5
+ // 1. recv.verb("path", handlers...) express/koa/gin/echo/chi(net style)
6
+ // 2. verb-annotation on handler Nest/Flask/FastAPI(+class prefix)
7
+ // 3. verb embedded in the path string Go 1.22 mux.HandleFunc("GET /x", h)
8
+ // 4. receiver-less route DSL Rails, Phoenix, Django
9
+ // 5. file-convention routes Next/SvelteKit/Nuxt (extractFileRoutes)
10
+ //
11
+ // Every captured token still has to validate as a path before it can become a
12
+ // hub — the route string is the real discriminator, the call shape is flavor.
13
+ // Known blind spots (documented in README): Rust proc-macro attributes
14
+ // (actix/rocket) — ast-grep cannot parameterize attribute paths; frameworks
15
+ // with constructor-assigned prefixes (Flask Blueprint, FastAPI APIRouter,
16
+ // chi Mount); tRPC/GraphQL/gRPC have no path token to anchor at all.
6
17
 
7
18
  import { createHash } from "node:crypto";
8
19
  import { readFileSync } from "node:fs";
@@ -24,15 +35,45 @@ export interface AnchorRule {
24
35
  * prefix + suffix so the anchor id is the full router-visible path.
25
36
  */
26
37
  prefixPattern?: string[];
38
+ /** Metavar name that carries the HTTP verb (e.g. chi `r.Method("GET", …)`). */
39
+ verbFrom?: string;
40
+ /** Idiom writes paths mount-relative (Django `path("users/")`): root them. */
41
+ mountRoot?: boolean;
27
42
  }
28
43
 
29
- const PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/;
44
+ const HTTP_VERB_RE = /^(?i:get|post|put|delete|patch|head|options)$/;
45
+
46
+ // Verbs-in-path: Go 1.22 net/http ServeMux writes the verb inside the pattern.
47
+ const VERB_IN_PATH = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\/\S*)$/;
48
+
49
+ // Arg-node captures land with their quote chars (and Python prefixes):
50
+ // '"/users"', "'/items'", '`/users/${id}`', 'f"/users/{id}"' → inner content.
51
+ const QUOTED_RE = /^[rbfuRBFU]{0,3}(["'`])([\s\S]*)\1$/;
52
+ const unquote = (s: string): string => {
53
+ const m = QUOTED_RE.exec(s.trim());
54
+ return m ? m[2]! : s.trim();
55
+ };
56
+
57
+ const PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\}|\[[A-Za-z_]\w*\])$/;
58
+
59
+ // Method names that are mounts, not verbs: Django urls, Rails match/root,
60
+ // Spring's umbrella RequestMapping. They anchor as ANY so the hub exists
61
+ // without pretending a verb was declared.
62
+ const NON_VERB_METHODS = new Set(["PATH", "RE_PATH", "URL", "MATCH", "ROOT", "REQUESTMAPPING", "REDIRECT", "RESOURCES"]);
63
+
64
+ const deriveVerb = (method: string): string => {
65
+ let up = method.toUpperCase();
66
+ if (up.endsWith("MAPPING")) up = up.slice(0, -"MAPPING".length); // Spring GetMapping → GET
67
+ return NON_VERB_METHODS.has(up) ? "ANY" : up;
68
+ };
30
69
 
31
70
  export const DEFAULT_PACK: AnchorRule[] = [
32
71
  {
72
+ // `$P` as an arg NODE, not in-string: binds across double/single quotes,
73
+ // backticks and Python f-strings. Quote chars are stripped by unquote().
33
74
  id: "http-route-call",
34
75
  langs: ["TypeScript", "Tsx", "JavaScript", "Go"],
35
- pattern: '$R.$M("$P", $$$H)',
76
+ pattern: "$R.$M($P, $$$H)",
36
77
  methods: "^(?i:get|post|put|delete|patch|head|options|all|use|any|handle|handlefunc|route|group)$",
37
78
  kind: "route",
38
79
  },
@@ -41,53 +82,120 @@ export const DEFAULT_PACK: AnchorRule[] = [
41
82
  // feature hubs when they reference a real path (validated below).
42
83
  id: "http-verb-single-arg",
43
84
  langs: ["TypeScript", "Tsx", "JavaScript"],
44
- pattern: '$R.$M("$P")',
85
+ pattern: "$R.$M($P)",
45
86
  methods: "^(?i:get|post|put|delete|patch)$",
46
87
  kind: "route",
47
88
  },
48
89
  {
49
- id: "http-route-call-singlequote",
50
- langs: ["TypeScript", "Tsx", "JavaScript"],
51
- pattern: "$R.$M('$P', $$$H)",
52
- methods: "^(?i:get|post|put|delete|patch|all|use|handle|route)$",
90
+ id: "http-verb-single-arg-py",
91
+ langs: ["Python"],
92
+ pattern: "$R.$M($P)",
93
+ methods: "^(get|post|put|delete|patch|head|options)$",
53
94
  kind: "route",
54
95
  },
55
96
  {
56
- // NestJS / decorator shape: @Get("/users/:id") on a controller method.
57
- id: "ts-http-decorator",
58
- langs: ["TypeScript", "Tsx"],
59
- pattern: '@$M("$P")',
60
- methods: "^(?i:get|post|put|delete|patch|options|head)$",
97
+ id: "python-route-call",
98
+ langs: ["Python"],
99
+ pattern: "$R.$M($P, $$$H)",
100
+ methods: "^(?:add_)?(?:get|post|put|delete|patch|head|options|route)$",
61
101
  kind: "route",
62
- prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
63
102
  },
64
103
  {
65
- // Single-quotes dominate real NestJS codebases (NOMAD, starters, docs).
66
- id: "ts-http-decorator-singlequote",
104
+ // NestJS / Angular-style decorators; class prefix via @Controller.
105
+ id: "ts-http-decorator",
67
106
  langs: ["TypeScript", "Tsx"],
68
- pattern: "@$M('$P')",
107
+ pattern: "@$M($P)",
69
108
  methods: "^(?i:get|post|put|delete|patch|options|head)$",
70
109
  kind: "route",
71
- prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
110
+ prefixPattern: ["@Controller($P)"],
72
111
  },
73
112
  {
74
113
  id: "python-decorator-route",
75
114
  langs: ["Python"],
76
- pattern: '@$R.$M("$P")',
115
+ pattern: "@$R.$M($P)",
77
116
  methods: "^(get|post|put|delete|patch|route|websocket)$",
78
117
  kind: "route",
79
118
  },
80
119
  {
81
- id: "python-decorator-route-singlequote",
120
+ // chi r.Method("GET", "/x", h), aiohttp web.route(...)/router.add_route(...).
121
+ id: "verb-as-argument",
122
+ langs: ["Go", "Python", "TypeScript", "Tsx", "JavaScript"],
123
+ pattern: '$R.$M("$V", "$P", $$$H)',
124
+ methods: "^(?i:method|methodfunc|add_route|add_view|route)$",
125
+ kind: "route",
126
+ verbFrom: "V",
127
+ },
128
+ {
129
+ // aiohttp module-level / receiver-free form: route("GET", "/x", h).
130
+ id: "verb-as-argument-norecv",
82
131
  langs: ["Python"],
83
- pattern: "@$R.$M('$P')",
84
- methods: "^(get|post|put|delete|patch|route|websocket)$",
132
+ pattern: '$M("$V", "$P", $$$H)',
133
+ methods: "^route$",
134
+ kind: "route",
135
+ verbFrom: "V",
136
+ },
137
+ {
138
+ // Django urlconf; mounts for every verb, so they anchor as ANY /x.
139
+ id: "django-url",
140
+ langs: ["Python"],
141
+ pattern: '$M("$P", $$$H)',
142
+ methods: "^(path|re_path|url)$",
143
+ kind: "route",
144
+ mountRoot: true,
145
+ },
146
+ {
147
+ // Rails routes.rb macros. Bare word form, string content capture.
148
+ id: "rails-route-macro",
149
+ langs: ["Ruby"],
150
+ pattern: '$M "$P", $$$R',
151
+ methods: "^(get|post|put|delete|patch|match|redirect|mount|root|head|options)$",
152
+ kind: "route",
153
+ },
154
+ {
155
+ id: "rails-route-macro-sq",
156
+ langs: ["Ruby"],
157
+ pattern: "$M '$P', $$$R",
158
+ methods: "^(get|post|put|delete|patch|match|redirect|mount|root|head|options)$",
159
+ kind: "route",
160
+ },
161
+ {
162
+ // Phoenix router.ex macros. Scope prefixes are not composed (see README).
163
+ // resources expands to the REST verb set — anchor the mount as ANY,
164
+ // the hub still merges with controller code via literal joins.
165
+ id: "phoenix-route-macro",
166
+ langs: ["Elixir"],
167
+ pattern: '$M "$P", $$$R',
168
+ methods: "^(get|post|put|delete|patch|head|options|forward|resources)$",
169
+ kind: "route",
170
+ },
171
+ {
172
+ id: "phoenix-route-call",
173
+ langs: ["Elixir"],
174
+ pattern: '$M("$P", $$$R)',
175
+ methods: "^(get|post|put|delete|patch|head|options|forward|resources)$",
176
+ kind: "route",
177
+ },
178
+ {
179
+ // Ktor routing DSL: get("/health") { … }
180
+ id: "ktor-routing-dsl",
181
+ langs: ["Kotlin"],
182
+ pattern: '$M("$P") { $$$B }',
183
+ methods: "^(get|post|put|delete|patch|head|options|route)$",
85
184
  kind: "route",
86
185
  },
186
+ {
187
+ // Spring MVC / WebFlux: class @RequestMapping prefix x method @GetMapping.
188
+ id: "spring-mapping-annotation",
189
+ langs: ["Java", "Kotlin"],
190
+ pattern: '@$M("$P")',
191
+ methods: "^(Get|Post|Put|Delete|Patch)Mapping$",
192
+ kind: "route",
193
+ prefixPattern: ['@RequestMapping("$P")'],
194
+ },
87
195
  {
88
196
  id: "flask-add-url-rule",
89
197
  langs: ["Python"],
90
- pattern: '$R.add_url_rule("$P", $$$H)',
198
+ pattern: "$R.add_url_rule($P, $$$H)",
91
199
  methods: "^add_url_rule$",
92
200
  kind: "route",
93
201
  },
@@ -123,26 +231,39 @@ export const extractAnchors = (
123
231
  for (const lang of rule.langs) {
124
232
  const langFiles = byLang.get(lang);
125
233
  if (!langFiles?.length) continue;
126
- const prefixes = new Map<string, string>(); // file -> @Controller prefix
234
+ const prefixes = new Map<string, string>(); // file -> class-level path prefix
127
235
  if (rule.prefixPattern?.length) {
128
236
  for (const pm of patternRunAll(rule.prefixPattern, lang, langFiles, cwd)) {
129
237
  const p = pm.single.P?.trim();
130
- if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, p);
238
+ if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, unquote(p));
131
239
  }
132
240
  }
133
241
  for (const m of patternRun(rule.pattern, lang, langFiles, cwd)) {
134
242
  const method = m.single.M;
135
- const path = m.single.P;
136
- if (!method || !path || !methodRe.test(method)) continue;
243
+ const pathLike = m.single.P;
244
+ if (!method || !pathLike || !methodRe.test(method)) continue;
137
245
  const prefix = prefixes.get(m.file);
138
- const raw = prefix !== undefined && prefix !== "" ? joinRoute(prefix, path.trim()) : path.trim();
246
+ let raw = prefix !== undefined && prefix !== "" ? joinRoute(prefix, unquote(pathLike)) : unquote(pathLike);
247
+ if (rule.mountRoot && !raw.startsWith("/")) raw = "/" + raw.replace(/^\/+/, "");
248
+ // Verb embedded in the path string (Go 1.22 mux, Starlette Route).
249
+ const vip = VERB_IN_PATH.exec(raw);
250
+ let verbOverride: string | undefined;
251
+ if (vip) {
252
+ verbOverride = vip[1]!.toUpperCase();
253
+ raw = vip[2]!;
254
+ }
139
255
  // $R.$M(...) also matches Map.get("key")-style data access; only real
140
- // paths (or router-relative placeholders like ":id") may anchor.
256
+ // paths (or router-relative placeholders like ":id", "[id]") anchor.
141
257
  if (!PATH_TOKEN_RE.test(raw) && !PLACEHOLDER_ONLY.test(raw)) continue;
258
+ let httpMethod: string;
259
+ if (rule.verbFrom) {
260
+ const v = m.single[rule.verbFrom];
261
+ if (!v || !HTTP_VERB_RE.test(v)) continue;
262
+ httpMethod = v.toUpperCase();
263
+ } else {
264
+ httpMethod = verbOverride ?? deriveVerb(method);
265
+ }
142
266
  const norm = normalizeLiteral(raw, "path");
143
- const httpMethod = method.toUpperCase() === "ROUTE" || method.toLowerCase() === "route" || method.toLowerCase() === "use" || method.toLowerCase() === "any"
144
- ? method.toUpperCase()
145
- : method.toUpperCase();
146
267
  const label = `${httpMethod} ${norm}`;
147
268
  const enclosing = resolveEnclosing(m.file, m.line);
148
269
  out.push({
@@ -166,26 +287,132 @@ export const extractAnchors = (
166
287
  });
167
288
  };
168
289
 
290
+ // --- file-convention routes -------------------------------------------------
291
+
292
+ // Frameworks where the route *is* the file path (no route string exists in
293
+ // code at all): Next App Router, SvelteKit, Nuxt. Each rule is a regex with
294
+ // capture group 1 = route stem, plus how verbs are known.
295
+ export interface FileRouteRule {
296
+ id: string;
297
+ re: string;
298
+ verbs: "exports" | "suffix";
299
+ pathPrefix?: string; // prepended to the derived route (Nuxt /api)
300
+ kind?: string; // default "route"
301
+ }
302
+
303
+ export const DEFAULT_FILE_ROUTES: FileRouteRule[] = [
304
+ { id: "next-app-route", re: "(?:^|/)app/(.+)/route\\.(?:ts|tsx|js|jsx|mjs)$", verbs: "exports", kind: "route" },
305
+ { id: "next-app-page", re: "(?:^|/)app/(?:(.+)/)?page\\.(?:tsx|jsx|mdx)$", verbs: "suffix", kind: "page" },
306
+ { id: "next-pages-api", re: "(?:^|/)pages/api/(.+)\\.(?:ts|tsx|js|jsx)$", verbs: "suffix", pathPrefix: "/api", kind: "route" },
307
+ { id: "sveltekit-server", re: "(?:^|/)src/routes/(?:(.+)/)?\\+server\\.(?:ts|js)$", verbs: "exports", kind: "route" },
308
+ { id: "sveltekit-page", re: "(?:^|/)src/routes/(?:(.+)/)?\\+page\\.(?:svelte|md)$", verbs: "suffix", kind: "page" },
309
+ { id: "nuxt-server-api", re: "(?:^|/)server/api/(.+)\\.(?:ts|js|mjs)$", verbs: "suffix", pathPrefix: "/api", kind: "route" },
310
+ { id: "astro-endpoint", re: "(?:^|/)src/pages/(.+)\\.(?:ts|js|mjs)$", verbs: "exports", kind: "route" },
311
+ ];
312
+
313
+ // `export async function GET`, `export const POST` — route handler verbs.
314
+ const EXPORTED_VERB_RE = /\bexport\s+(?:(?:async\s+)?function\s+|const\s+)(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/g;
315
+ const SUFFIX_VERB_RE = /\.(get|post|put|delete|patch|head|options)$/i;
316
+
317
+ // Cards → concrete path. `(group)` segments vanish (Next), dynamic segments
318
+ // become the canonical {*} placeholder, `index` is the path itself.
319
+ const FILE_DYNAMIC_SEG = /^@?\[+(?:\.\.\.)?[^\]]+\]+$/; // [x], [...x], [[...x]] (optional catch-all), @[x]
320
+ const toFileRoutePath = (stem: string): string => {
321
+ if (stem === "") return "/";
322
+ const segs = stem.split("/").flatMap((seg) => {
323
+ if (!seg || /^\(.+\)$/.test(seg)) return [];
324
+ if (FILE_DYNAMIC_SEG.test(seg)) return ["{*}"];
325
+ if (seg === "index") return [];
326
+ return [seg];
327
+ });
328
+ return "/" + segs.filter((s) => s !== "").join("/");
329
+ };
330
+
331
+ export const extractFileRoutes = (files: string[], root: string, rules: FileRouteRule[] = DEFAULT_FILE_ROUTES): AnchorDraft[] => {
332
+ const compiled = rules.map((r) => ({ rule: r, re: new RegExp(r.re) }));
333
+ const out: AnchorDraft[] = [];
334
+ for (const file of files) {
335
+ for (const { rule, re } of compiled) {
336
+ const match = re.exec(file);
337
+ if (!match) continue;
338
+ let stem = match[1] ?? ""; // optional dir group: route lives at the root
339
+ if (!stem && !rule.pathPrefix) { /* root page/endpoint: keep going, path is / */ }
340
+ const verbs = new Set<string>();
341
+ let suffixVerb: string | undefined;
342
+ const sv = SUFFIX_VERB_RE.exec(stem);
343
+ if (sv) {
344
+ suffixVerb = sv[1]!.toUpperCase();
345
+ stem = stem.slice(0, -sv[0].length);
346
+ }
347
+ if (rule.verbs === "suffix") {
348
+ if (suffixVerb) verbs.add(suffixVerb);
349
+ } else {
350
+ let content = "";
351
+ try {
352
+ content = readFileSync(joinPath(root, file), "utf8");
353
+ } catch {
354
+ // unreadable: fall through with no verbs
355
+ }
356
+ for (const vm of content.matchAll(EXPORTED_VERB_RE)) verbs.add(vm[1]!);
357
+ }
358
+ if (verbs.size === 0) verbs.add("ANY")
359
+ const rel = (rule.pathPrefix ?? "") + toFileRoutePath(stem);
360
+ const norm = normalizeLiteral(rel, "path");
361
+ for (const verb of verbs) {
362
+ const label = `${verb} ${norm}`;
363
+ out.push({
364
+ id: label,
365
+ kind: rule.kind ?? "route",
366
+ label,
367
+ nodeId: `file:${file}`,
368
+ file,
369
+ line: 0,
370
+ });
371
+ }
372
+ }
373
+ }
374
+ return dedupeAnchors(out);
375
+ };
376
+
377
+ const dedupeAnchors = (out: AnchorDraft[]): AnchorDraft[] => {
378
+ const seen = new Set<string>();
379
+ return out.filter((a) => {
380
+ const k = `${a.id}|${a.file}|${a.line}`;
381
+ if (seen.has(k)) return false;
382
+ seen.add(k);
383
+ return true;
384
+ });
385
+ };
386
+
169
387
  // Re-export so callers can classify an anchor path like any literal.
170
388
  export const anchorClassify = classifyLiteral;
171
389
 
172
- // Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[] }.
173
- // Merged after the default pack; the content hash invalidates the fact cache
174
- // so changing rules rebuilds anchors only.
175
- export const loadRepoRules = (root: string): { pack: AnchorRule[]; sha: string } => {
390
+ // Hash of the built-in packs: code changes to the default pack invalidate
391
+ // cached anchor facts even when a repo ships no rules.json of its own.
392
+ const DEFAULTS_SHA = createHash("sha1")
393
+ .update(JSON.stringify(DEFAULT_PACK))
394
+ .update(JSON.stringify(DEFAULT_FILE_ROUTES))
395
+ .digest("hex");
396
+
397
+ // Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[], "fileRoutes": FileRouteRule[] }.
398
+ export const loadRepoRules = (root: string): { pack: AnchorRule[]; fileRoutes: FileRouteRule[]; sha: string } => {
176
399
  let raw = "";
177
400
  try {
178
401
  raw = readFileSync(joinPath(root, ".fovea", "rules.json"), "utf8");
179
402
  } catch {
180
- return { pack: DEFAULT_PACK, sha: "" };
403
+ return { pack: DEFAULT_PACK, fileRoutes: DEFAULT_FILE_ROUTES, sha: DEFAULTS_SHA };
181
404
  }
405
+ // Repo rules extend defaults; the defaults themselves ride in the hash,
406
+ // so upgrading pi-fovea invalidates anchors even with no repo rules file.
407
+ const sha = createHash("sha1").update(DEFAULTS_SHA + raw).digest("hex");
182
408
  try {
183
- const parsed = JSON.parse(raw) as { rules?: AnchorRule[] };
409
+ const parsed = JSON.parse(raw) as { rules?: AnchorRule[]; fileRoutes?: FileRouteRule[] };
184
410
  const rules = (parsed.rules ?? []).filter(
185
411
  (r) => r && typeof r.pattern === "string" && typeof r.methods === "string" && Array.isArray(r.langs),
186
412
  );
187
- return { pack: [...DEFAULT_PACK, ...rules], sha: createHash("sha1").update(raw).digest("hex") };
413
+ const fileRoutes = (parsed.fileRoutes ?? []).filter((r) => r && typeof r.re === "string" && typeof r.verbs === "string");
414
+ return { pack: [...DEFAULT_PACK, ...rules], fileRoutes: [...DEFAULT_FILE_ROUTES, ...fileRoutes], sha };
188
415
  } catch {
189
- return { pack: DEFAULT_PACK, sha: createHash("sha1").update(raw).digest("hex") };
416
+ return { pack: DEFAULT_PACK, fileRoutes: DEFAULT_FILE_ROUTES, sha: createHash("sha1").update(raw).digest("hex") };
190
417
  }
191
418
  };
package/src/core/build.ts CHANGED
@@ -11,7 +11,7 @@ import { spawnSync } from "node:child_process";
11
11
  import { LANG_BY_EXT, isBinaryExt, isConfigFile } from "./astgrep.js";
12
12
  import { extractCalls, extractImports, extractLiterals, extractSymbols, isTestFile } from "./extract.js";
13
13
  import { buildJoinIndex } from "./join.js";
14
- import { extractAnchors, loadRepoRules } from "./anchors.js";
14
+ import { extractAnchors, extractFileRoutes, loadRepoRules } from "./anchors.js";
15
15
  import type { CallSite, Edge, Graph, ImportSite, LiteralSite, NodeRec, SymbolRec } from "./types.js";
16
16
  import type { AnchorDraft } from "./anchors.js";
17
17
  import { coChangePairs } from "./cochange.js";
@@ -25,7 +25,7 @@ export interface FileFacts {
25
25
  anchors: AnchorDraft[];
26
26
  }
27
27
 
28
- const CACHE_VERSION = 4; // bump when extractor semantics change
28
+ const CACHE_VERSION = 5; // bump when extractor semantics change
29
29
  const IGNORE_DIRS = new Set([".git", "node_modules", "dist", "vendor", ".venv", "venv", "target", "coverage", ".next", "build", "__pycache__", ".pi", ".pi-fovea", "deps", "_build", ".tox", "Pods"]);
30
30
  const MAX_FILES = 24000;
31
31
  // Generated dependency manifests are enormous and carry no first-class routes.
@@ -41,12 +41,14 @@ const isJunk = (f: string): boolean => {
41
41
  return LOCKFILE_NAMES.has(base) || base.endsWith(".lock");
42
42
  };
43
43
 
44
- const supported = (f: string): boolean => {
44
+ const supported = (f: string, routeRes?: RegExp[]): boolean => {
45
45
  const ext = f.split(".").pop()?.toLowerCase() ?? "";
46
- return !isBinaryExt(f) && (ext in LANG_BY_EXT || isConfigFile(f));
46
+ if (!isBinaryExt(f) && (ext in LANG_BY_EXT || isConfigFile(f))) return true;
47
+ // File-convention routers use extensions with no ast-grep lang (.svelte, .mdx).
48
+ return routeRes?.some((re) => re.test(f)) ?? false;
47
49
  };
48
50
 
49
- export const listFiles = (root: string): string[] => {
51
+ export const listFiles = (root: string, routeRes?: RegExp[]): string[] => {
50
52
  const res = spawnSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
51
53
  encoding: "utf8",
52
54
  timeout: 30_000,
@@ -67,14 +69,14 @@ export const listFiles = (root: string): string[] => {
67
69
  const rel = prefix ? `${prefix}/${e.name}` : e.name;
68
70
  if (e.isDirectory()) {
69
71
  if (!IGNORE_DIRS.has(e.name)) walk(joinPath(dir, e.name), rel);
70
- } else if (e.isFile() && supported(rel)) {
72
+ } else if (e.isFile() && supported(rel, routeRes)) {
71
73
  files.push(rel);
72
74
  }
73
75
  }
74
76
  };
75
77
  walk(root, "");
76
78
  }
77
- files = files.filter((f) => supported(f) && !isJunk(f));
79
+ files = files.filter((f) => supported(f, routeRes) && !isJunk(f));
78
80
  files.sort();
79
81
  return files.slice(0, MAX_FILES);
80
82
  };
@@ -101,7 +103,7 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
101
103
  } catch {
102
104
  cached = undefined;
103
105
  }
104
- const { pack: anchorPack, sha: rulesSha } = loadRepoRules(root);
106
+ const { pack: anchorPack, fileRoutes, sha: rulesSha } = loadRepoRules(root);
105
107
  const rulesChanged = cached !== undefined && cached.rulesSha !== rulesSha;
106
108
  if (cached && (cached.version !== CACHE_VERSION || cached.root !== root)) cached = undefined;
107
109
  const facts: Record<string, FileFacts> = {};
@@ -158,6 +160,9 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
158
160
  return best ? `${best.name}@${best.file}` : `file:${file}`;
159
161
  };
160
162
  putByFile(extractAnchors(anchorTargets, root, enclosingId, anchorPack), (f, v) => f.anchors.push(v));
163
+ // File-convention routes (Next/SvelteKit/Nuxt): the route path is derived
164
+ // from the file path; verbs come from exported handler names or suffix.
165
+ putByFile(extractFileRoutes(anchorTargets, root, fileRoutes), (f, v) => f.anchors.push(v));
161
166
  }
162
167
  try {
163
168
  mkdirSync(dirname(cacheFile), { recursive: true });
package/src/core/join.ts CHANGED
@@ -14,7 +14,7 @@ export interface LitOccurrence { node: number; line: number; file: string; }
14
14
 
15
15
  export interface JoinEdge { a: number; b: number; w: number; }
16
16
 
17
- const PLACEHOLDER_SEGMENT = /^(?::[^/]+|\{[^}/]*\}|\$\{[^}/]*\}|\*+)$/;
17
+ const PLACEHOLDER_SEGMENT = /^(?::[^/]+|\{[^}/]*\}|\$\{[^}/]*\}|<[^/>]+>|\*+)$/;
18
18
  const WORD_RE = /^[A-Za-z][\w$.\-]{6,63}$/;
19
19
  const URLISH_RE = /^(?:https?|wss?):\/\/[^/]+/;
20
20
 
package/src/core/ops.ts CHANGED
@@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process";
9
9
  import { posix } from "node:path";
10
10
  import { hasAstGrep } from "./astgrep.js";
11
11
  import { assembleGraph, listFiles, loadFacts, type FileFacts } from "./build.js";
12
+ import { loadRepoRules } from "./anchors.js";
12
13
  import { buildCsr, chebyshevVectors, chooseOrder, heatField, type Csr } from "./heat.js";
13
14
  import { revealFoveated, revealGroups, tokenEstimate, type GroupLine } from "./render.js";
14
15
  import { getSession, TK_ORDER } from "./session.js";
@@ -49,7 +50,9 @@ export const ensureState = (root: string): RepoState => {
49
50
  "fovea: `ast-grep` binary not found on PATH (set FOVEA_AST_GREP to override). Install: https://ast-grep.github.io/",
50
51
  );
51
52
  }
52
- const files = listFiles(root);
53
+ const { fileRoutes } = loadRepoRules(root);
54
+ const routeRes = fileRoutes.map((r) => new RegExp(r.re));
55
+ const files = listFiles(root, routeRes);
53
56
  const facts = loadFacts(root, files);
54
57
  const version = graphVersion(facts);
55
58
  const cached = states.get(root);