pi-fovea 0.1.1 → 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
  {
@@ -132,6 +146,8 @@ Drop `.fovea/rules.json` in a repo to extend anchor detection beyond the built-i
132
146
 
133
147
  Changing the rules file invalidates **only** the anchor extraction cache — green-node reuse one level up.
134
148
 
149
+ A rule may additionally declare `prefixPattern` (e.g. NestJS `@Controller('api/airports')`) so per-method paths like `@Get('search')` compose into the full router-visible anchor `GET /api/airports/search` — see `ts-http-decorator*` in `src/core/anchors.ts`.
150
+
135
151
  ## How the graph is joined
136
152
 
137
153
  - **imports / contains / inherits / tests** — outline-derived; call edges specificity-tiered, with language builtins and log/test entry points warded off
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.1",
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,13 +1,24 @@
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";
9
20
  import { join as joinPath } from "node:path";
10
- import { groupByLang, patternRun } from "./astgrep.js";
21
+ import { groupByLang, patternRun, patternRunAll } from "./astgrep.js";
11
22
  import { PATH_TOKEN_RE } from "./extract.js";
12
23
  import { classifyLiteral, normalizeLiteral } from "./join.js";
13
24
  import type { Anchor } from "./types.js";
@@ -18,15 +29,51 @@ export interface AnchorRule {
18
29
  pattern: string;
19
30
  methods: string; // regex tested against the captured method metavar
20
31
  kind: string;
32
+ /**
33
+ * Optional class-level prefix patterns (e.g. NestJS '@Controller("api/x")').
34
+ * Their $P captures are collected per file; a matched route path is composed
35
+ * prefix + suffix so the anchor id is the full router-visible path.
36
+ */
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;
21
42
  }
22
43
 
23
- 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
+ };
24
69
 
25
70
  export const DEFAULT_PACK: AnchorRule[] = [
26
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().
27
74
  id: "http-route-call",
28
75
  langs: ["TypeScript", "Tsx", "JavaScript", "Go"],
29
- pattern: '$R.$M("$P", $$$H)',
76
+ pattern: "$R.$M($P, $$$H)",
30
77
  methods: "^(?i:get|post|put|delete|patch|head|options|all|use|any|handle|handlefunc|route|group)$",
31
78
  kind: "route",
32
79
  },
@@ -35,43 +82,120 @@ export const DEFAULT_PACK: AnchorRule[] = [
35
82
  // feature hubs when they reference a real path (validated below).
36
83
  id: "http-verb-single-arg",
37
84
  langs: ["TypeScript", "Tsx", "JavaScript"],
38
- pattern: '$R.$M("$P")',
85
+ pattern: "$R.$M($P)",
39
86
  methods: "^(?i:get|post|put|delete|patch)$",
40
87
  kind: "route",
41
88
  },
42
89
  {
43
- id: "http-route-call-singlequote",
44
- langs: ["TypeScript", "Tsx", "JavaScript"],
45
- pattern: "$R.$M('$P', $$$H)",
46
- 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)$",
94
+ kind: "route",
95
+ },
96
+ {
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)$",
47
101
  kind: "route",
48
102
  },
49
103
  {
50
- // NestJS / decorator shape: @Get("/users/:id") on a controller method.
104
+ // NestJS / Angular-style decorators; class prefix via @Controller.
51
105
  id: "ts-http-decorator",
52
106
  langs: ["TypeScript", "Tsx"],
53
- pattern: '@$M("$P")',
107
+ pattern: "@$M($P)",
54
108
  methods: "^(?i:get|post|put|delete|patch|options|head)$",
55
109
  kind: "route",
110
+ prefixPattern: ["@Controller($P)"],
56
111
  },
57
112
  {
58
113
  id: "python-decorator-route",
59
114
  langs: ["Python"],
60
- pattern: '@$R.$M("$P")',
115
+ pattern: "@$R.$M($P)",
61
116
  methods: "^(get|post|put|delete|patch|route|websocket)$",
62
117
  kind: "route",
63
118
  },
64
119
  {
65
- 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",
66
131
  langs: ["Python"],
67
- pattern: "@$R.$M('$P')",
68
- methods: "^(get|post|put|delete|patch|route|websocket)$",
132
+ pattern: '$M("$V", "$P", $$$H)',
133
+ methods: "^route$",
69
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)$",
184
+ kind: "route",
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")'],
70
194
  },
71
195
  {
72
196
  id: "flask-add-url-rule",
73
197
  langs: ["Python"],
74
- pattern: '$R.add_url_rule("$P", $$$H)',
198
+ pattern: "$R.add_url_rule($P, $$$H)",
75
199
  methods: "^add_url_rule$",
76
200
  kind: "route",
77
201
  },
@@ -86,6 +210,14 @@ export const DEFAULT_PACK: AnchorRule[] = [
86
210
 
87
211
  export interface AnchorDraft extends Anchor {}
88
212
 
213
+ // NestJS mounts every controller at the router root, so the joined path is
214
+ // always slash-rooted regardless of how each framework writes the pieces.
215
+ const joinRoute = (prefix: string, child: string): string => {
216
+ const p = prefix.replace(/^\/+|\/+$/g, "");
217
+ const c = child.replace(/^\/+|\/+$/g, "");
218
+ return c ? `/${[p, c].filter(Boolean).join("/")}` : `/${p}`;
219
+ };
220
+
89
221
  export const extractAnchors = (
90
222
  files: string[],
91
223
  cwd: string,
@@ -99,18 +231,39 @@ export const extractAnchors = (
99
231
  for (const lang of rule.langs) {
100
232
  const langFiles = byLang.get(lang);
101
233
  if (!langFiles?.length) continue;
234
+ const prefixes = new Map<string, string>(); // file -> class-level path prefix
235
+ if (rule.prefixPattern?.length) {
236
+ for (const pm of patternRunAll(rule.prefixPattern, lang, langFiles, cwd)) {
237
+ const p = pm.single.P?.trim();
238
+ if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, unquote(p));
239
+ }
240
+ }
102
241
  for (const m of patternRun(rule.pattern, lang, langFiles, cwd)) {
103
242
  const method = m.single.M;
104
- const path = m.single.P;
105
- if (!method || !path || !methodRe.test(method)) continue;
106
- const raw = path.trim();
243
+ const pathLike = m.single.P;
244
+ if (!method || !pathLike || !methodRe.test(method)) continue;
245
+ const prefix = prefixes.get(m.file);
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
+ }
107
255
  // $R.$M(...) also matches Map.get("key")-style data access; only real
108
- // paths (or router-relative placeholders like ":id") may anchor.
256
+ // paths (or router-relative placeholders like ":id", "[id]") anchor.
109
257
  if (!PATH_TOKEN_RE.test(raw) && !PLACEHOLDER_ONLY.test(raw)) continue;
110
- const norm = normalizeLiteral(path, "path");
111
- const httpMethod = method.toUpperCase() === "ROUTE" || method.toLowerCase() === "route" || method.toLowerCase() === "use" || method.toLowerCase() === "any"
112
- ? method.toUpperCase()
113
- : method.toUpperCase();
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
+ }
266
+ const norm = normalizeLiteral(raw, "path");
114
267
  const label = `${httpMethod} ${norm}`;
115
268
  const enclosing = resolveEnclosing(m.file, m.line);
116
269
  out.push({
@@ -134,26 +287,132 @@ export const extractAnchors = (
134
287
  });
135
288
  };
136
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
+
137
387
  // Re-export so callers can classify an anchor path like any literal.
138
388
  export const anchorClassify = classifyLiteral;
139
389
 
140
- // Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[] }.
141
- // Merged after the default pack; the content hash invalidates the fact cache
142
- // so changing rules rebuilds anchors only.
143
- 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 } => {
144
399
  let raw = "";
145
400
  try {
146
401
  raw = readFileSync(joinPath(root, ".fovea", "rules.json"), "utf8");
147
402
  } catch {
148
- return { pack: DEFAULT_PACK, sha: "" };
403
+ return { pack: DEFAULT_PACK, fileRoutes: DEFAULT_FILE_ROUTES, sha: DEFAULTS_SHA };
149
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");
150
408
  try {
151
- const parsed = JSON.parse(raw) as { rules?: AnchorRule[] };
409
+ const parsed = JSON.parse(raw) as { rules?: AnchorRule[]; fileRoutes?: FileRouteRule[] };
152
410
  const rules = (parsed.rules ?? []).filter(
153
411
  (r) => r && typeof r.pattern === "string" && typeof r.methods === "string" && Array.isArray(r.langs),
154
412
  );
155
- 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 };
156
415
  } catch {
157
- 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") };
158
417
  }
159
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 = 3; // 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);
@@ -90,9 +93,25 @@ export const resolveSeeds = (state: RepoState, query: string): SeedResolution =>
90
93
  const terms = q.split(/\s+/).filter((t) => t.length > 1);
91
94
 
92
95
  // Literal route: treat the query itself as a join token (path/env/word).
93
- if (classifyLiteral(q)) {
94
- for (const occ of state.joinIndex.byKey.get(normalizeLiteral(q, classifyLiteral(q)!))?.occ ?? []) {
95
- bump(occ.node, 1);
96
+ const cls = classifyLiteral(q);
97
+ if (cls) {
98
+ const norm = normalizeLiteral(q, cls);
99
+ for (const occ of state.joinIndex.byKey.get(norm)?.occ ?? []) bump(occ.node, 1);
100
+ if (cls === "path") {
101
+ // Route-prefix queries ("/api/airports") seed everything mounted below
102
+ // them — the query is rarely a literal in code, which is the point: the
103
+ // model shouldn't have to guess the full route string to look at it.
104
+ const under = `${norm}/`;
105
+ for (const [key, bucket] of state.joinIndex.byKey) {
106
+ if (bucket.cls !== "path" || !key.startsWith(under)) continue;
107
+ for (const occ of bucket.occ) bump(occ.node, 0.8);
108
+ }
109
+ // Anchors whose route sits under the query get seeded directly.
110
+ state.graph.nodes.forEach((n, i) => {
111
+ if (n.kind !== "anchor") return;
112
+ const route = n.name.slice(n.name.indexOf(" ") + 1);
113
+ if (route === norm || route.startsWith(under)) bump(i, 0.9);
114
+ });
96
115
  }
97
116
  }
98
117