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 +15 -1
- package/cli.ts +10 -1
- package/package.json +1 -1
- package/src/core/anchors.ts +269 -42
- package/src/core/build.ts +13 -8
- package/src/core/join.ts +1 -1
- package/src/core/ops.ts +4 -1
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
|
-
|
|
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
package/src/core/anchors.ts
CHANGED
|
@@ -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.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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:
|
|
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:
|
|
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-
|
|
50
|
-
langs: ["
|
|
51
|
-
pattern: "$R.$M(
|
|
52
|
-
methods: "^(
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
66
|
-
id: "ts-http-decorator
|
|
104
|
+
// NestJS / Angular-style decorators; class prefix via @Controller.
|
|
105
|
+
id: "ts-http-decorator",
|
|
67
106
|
langs: ["TypeScript", "Tsx"],
|
|
68
|
-
pattern: "@$M(
|
|
107
|
+
pattern: "@$M($P)",
|
|
69
108
|
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
70
109
|
kind: "route",
|
|
71
|
-
prefixPattern: [
|
|
110
|
+
prefixPattern: ["@Controller($P)"],
|
|
72
111
|
},
|
|
73
112
|
{
|
|
74
113
|
id: "python-decorator-route",
|
|
75
114
|
langs: ["Python"],
|
|
76
|
-
pattern:
|
|
115
|
+
pattern: "@$R.$M($P)",
|
|
77
116
|
methods: "^(get|post|put|delete|patch|route|websocket)$",
|
|
78
117
|
kind: "route",
|
|
79
118
|
},
|
|
80
119
|
{
|
|
81
|
-
|
|
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:
|
|
84
|
-
methods: "^
|
|
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:
|
|
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 ->
|
|
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
|
|
136
|
-
if (!method || !
|
|
243
|
+
const pathLike = m.single.P;
|
|
244
|
+
if (!method || !pathLike || !methodRe.test(method)) continue;
|
|
137
245
|
const prefix = prefixes.get(m.file);
|
|
138
|
-
|
|
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")
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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);
|