pi-fovea 0.1.2 → 0.3.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 +28 -1
- package/cli.ts +60 -2
- package/package.json +1 -1
- package/src/core/anchors.ts +303 -44
- package/src/core/build.ts +53 -15
- package/src/core/discover.ts +188 -0
- package/src/core/join.ts +1 -1
- package/src/core/ops.ts +4 -1
- package/src/core/sync.ts +11 -6
- package/src/core/types.ts +1 -0
package/README.md
CHANGED
|
@@ -120,7 +120,34 @@ 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
|
+
### Tier 3: discovery mode
|
|
138
|
+
|
|
139
|
+
Unknown shapes self-heal. During the literal pass fovea harvests a per-repo histogram of *call-shape signatures* — `(lang · shape · callee · argIdx)` with a path-precision — and promotes statistically significant unknown ones into **implicit rules**: exact-arity ast-grep patterns, synthesized automatically, wired into the graph at **half hub gravity** with a `△` sigil in `fovea anchors` output. Sync reports discovered churn but never lets an unconfirmed hypothesis escalate to red; a hub upgrades to first-class the moment any site matches a non-implicit rule.
|
|
140
|
+
|
|
141
|
+
Promotion needs: ≥4 path-carrying sites, spread over ≥2 files, and a Jeffreys-smoothed posterior `p̂ = (pathN + .5) / (n + 1) ≥ 0.55`. Measured against 8 cloned projects, junk bands land below p̂≈0.27 and real shapes above p̂≈0.75 — the line is not tuned to the corpus, it sits mid-cliff. Frameworks already known to the static pack are never re-promoted.
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
fovea anchors <root> --discovered # only the △ hypothesis hubs
|
|
145
|
+
fovea rules <root> # promoted rules + evidence, ready to paste into .fovea/rules.json
|
|
146
|
+
fovea rules <root> --sigs # every path-touching signature, by precision (audit the corpus)
|
|
147
|
+
fovea rules <root> --adopt # write them into the repo's rule pack explicitly
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Drop `.fovea/rules.json` in a repo to extend anchor detection beyond the built-ins:
|
|
124
151
|
|
|
125
152
|
```json
|
|
126
153
|
{
|
package/cli.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
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)
|
|
9
|
+
// fovea rules [root] (tier-3 discovered shape hypotheses)
|
|
8
10
|
//
|
|
9
11
|
// The CLI is stateless across invocations (dwell needs a prior focus in the
|
|
10
12
|
// same process — combine ops inside pi, where sessions persist); stdout is
|
|
11
13
|
// the rendered field, nothing else, so it composes with head/grep/$().
|
|
12
14
|
|
|
13
|
-
import {
|
|
15
|
+
import { statSync } from "node:fs";
|
|
16
|
+
import { ensureState, sketch, focus, dwell, impact } from "./src/core/ops.js";
|
|
17
|
+
import { aggregateFiles, posterior, promote } from "./src/core/discover.js";
|
|
18
|
+
import { DEFAULT_PACK } from "./src/core/anchors.js";
|
|
14
19
|
|
|
15
20
|
const [, , cmd = "status", ...argv] = process.argv;
|
|
16
21
|
|
|
@@ -36,9 +41,15 @@ const numAt = (i: number): number | undefined => {
|
|
|
36
41
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
37
42
|
};
|
|
38
43
|
// Root is the first positional that names a path; everything else is arg data.
|
|
44
|
+
// Existing directories count as paths even when bare ("next", "kernel").
|
|
39
45
|
const rootAt = (i: number): string => {
|
|
40
46
|
const p = pos[i];
|
|
41
|
-
|
|
47
|
+
if (p === undefined) return ".";
|
|
48
|
+
if (p.includes("/") || p === ".") return p;
|
|
49
|
+
try {
|
|
50
|
+
if (statSync(p).isDirectory()) return p;
|
|
51
|
+
} catch { /* not a path */ }
|
|
52
|
+
return ".";
|
|
42
53
|
};
|
|
43
54
|
|
|
44
55
|
try {
|
|
@@ -71,6 +82,53 @@ try {
|
|
|
71
82
|
includeUncommitted: !flags.has("no-uncommitted"),
|
|
72
83
|
budget: B ?? 2000,
|
|
73
84
|
}).text;
|
|
85
|
+
} else if (cmd === "anchors") {
|
|
86
|
+
const root = rootAt(0);
|
|
87
|
+
const filter = pos.find((p) => p !== root);
|
|
88
|
+
const rows = ensureState(root).graph.anchors
|
|
89
|
+
.map((a) => `${a.implicit ? "△" : " "}\t${a.kind}\t${a.id}\t${a.file}:${a.line}`)
|
|
90
|
+
.filter((r) => (!filter || r.includes(filter)) && (!flags.has("discovered") || r.startsWith("△")))
|
|
91
|
+
.sort();
|
|
92
|
+
out = rows.join("\n");
|
|
93
|
+
} else if (cmd === "rules") {
|
|
94
|
+
const root = rootAt(0);
|
|
95
|
+
const st = ensureState(root);
|
|
96
|
+
const sigs = aggregateFiles(Object.fromEntries(Object.entries(st.facts).map(([k, v]) => [k, v.sigs])));
|
|
97
|
+
const promoted = promote(sigs, DEFAULT_PACK);
|
|
98
|
+
if (flags.has("adopt") && promoted.length) {
|
|
99
|
+
const { mkdirSync, writeFileSync, readFileSync } = await import("node:fs");
|
|
100
|
+
const { join } = await import("node:path");
|
|
101
|
+
mkdirSync(join(root, ".fovea"), { recursive: true });
|
|
102
|
+
const rulesFile = join(root, ".fovea", "rules.json");
|
|
103
|
+
let existing = { rules: [] as unknown[] };
|
|
104
|
+
try { existing = JSON.parse(readFileSync(rulesFile, "utf8")); } catch { /* new */ }
|
|
105
|
+
const stamp = promoted.map((r) => ({
|
|
106
|
+
id: r.id.slice("implicit:".length),
|
|
107
|
+
langs: r.langs,
|
|
108
|
+
pattern: r.patterns[0],
|
|
109
|
+
methods: r.methods,
|
|
110
|
+
kind: r.kind,
|
|
111
|
+
}));
|
|
112
|
+
existing.rules = [...existing.rules, ...stamp];
|
|
113
|
+
writeFileSync(rulesFile, JSON.stringify(existing, null, 2) + "\n");
|
|
114
|
+
out = `wrote ${stamp.length} discovered rule(s) to .fovea/rules.json`;
|
|
115
|
+
} else if (flags.has("sigs")) {
|
|
116
|
+
out = sigs.filter((s) => s.pathN > 0)
|
|
117
|
+
.sort((a, b) => posterior(b.pathN, b.n) - posterior(a.pathN, a.n))
|
|
118
|
+
.map((s) => `${posterior(s.pathN, s.n).toFixed(2)} ${s.pathN}/${s.n} across ${s.files} files ${s.key}`)
|
|
119
|
+
.join("\n");
|
|
120
|
+
} else if (!promoted.length) {
|
|
121
|
+
out = "(no unknown shape passes the promotion floor — tier-1/2 coverage is doing fine)";
|
|
122
|
+
} else {
|
|
123
|
+
out = promoted.map((r) => JSON.stringify({
|
|
124
|
+
id: r.id.slice("implicit:".length),
|
|
125
|
+
langs: r.langs,
|
|
126
|
+
pattern: r.patterns[0],
|
|
127
|
+
methods: r.methods.replace(/\(\?i\)/, ""),
|
|
128
|
+
kind: r.kind,
|
|
129
|
+
_evidence: `p̂=${r.evidence.posterior.toFixed(2)} (${r.evidence.pathN}/${r.evidence.n} sites, ${r.evidence.files} files)`,
|
|
130
|
+
})).join("\n");
|
|
131
|
+
}
|
|
74
132
|
} else {
|
|
75
133
|
console.error(`unknown command: ${cmd}`);
|
|
76
134
|
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";
|
|
@@ -15,7 +26,8 @@ import type { Anchor } from "./types.js";
|
|
|
15
26
|
export interface AnchorRule {
|
|
16
27
|
id: string;
|
|
17
28
|
langs: string[];
|
|
18
|
-
pattern
|
|
29
|
+
/** Single ast-grep pattern; tier-3 synthesized rules use `patterns` instead. */
|
|
30
|
+
pattern?: string;
|
|
19
31
|
methods: string; // regex tested against the captured method metavar
|
|
20
32
|
kind: string;
|
|
21
33
|
/**
|
|
@@ -24,15 +36,56 @@ export interface AnchorRule {
|
|
|
24
36
|
* prefix + suffix so the anchor id is the full router-visible path.
|
|
25
37
|
*/
|
|
26
38
|
prefixPattern?: string[];
|
|
39
|
+
/** Metavar name that carries the HTTP verb (e.g. chi `r.Method("GET", …)`). */
|
|
40
|
+
verbFrom?: string;
|
|
41
|
+
/** Idiom writes paths mount-relative (Django `path("users/")`): root them. */
|
|
42
|
+
mountRoot?: boolean;
|
|
43
|
+
/** Synthesized tier-3 rules ship as variant lists (exact arity + trailing $$$H). */
|
|
44
|
+
patterns?: string[];
|
|
45
|
+
/** Discovered rules: half hub gravity until a real join upgrades them. */
|
|
46
|
+
implicit?: boolean;
|
|
27
47
|
}
|
|
28
48
|
|
|
29
|
-
const
|
|
49
|
+
const HTTP_VERB_RE = /^(?i:get|post|put|delete|patch|head|options)$/;
|
|
50
|
+
|
|
51
|
+
// Verbs-in-path: Go 1.22 net/http ServeMux writes the verb inside the pattern.
|
|
52
|
+
const VERB_IN_PATH = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\/\S*)$/;
|
|
53
|
+
|
|
54
|
+
// Arg-node captures land with their quote chars (and Python prefixes):
|
|
55
|
+
// '"/users"', "'/items'", '`/users/${id}`', 'f"/users/{id}"' → inner content.
|
|
56
|
+
const QUOTED_RE = /^[rbfuRBFU]{0,3}(["'`])([\s\S]*)\1$/;
|
|
57
|
+
const unquote = (s: string): string => {
|
|
58
|
+
const m = QUOTED_RE.exec(s.trim());
|
|
59
|
+
return m ? m[2]! : s.trim();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\}|\[[A-Za-z_]\w*\])$/;
|
|
63
|
+
|
|
64
|
+
// Method names that are mounts, not verbs: Django urls, Rails match/root,
|
|
65
|
+
// Spring's umbrella RequestMapping. They anchor as ANY so the hub exists
|
|
66
|
+
// without pretending a verb was declared.
|
|
67
|
+
// Method names that mount a target rather than declare a verb. fetch(url)
|
|
68
|
+
// and redirect targets both resolve to GET; Django urlconfs and Rails mounts
|
|
69
|
+
// accept any verb.
|
|
70
|
+
const METHOD_ALIASES: Record<string, string> = {
|
|
71
|
+
PATH: "ANY", RE_PATH: "ANY", URL: "ANY", MATCH: "ANY", ROOT: "ANY",
|
|
72
|
+
REQUESTMAPPING: "ANY", RESOURCES: "ANY", FORWARD: "ANY",
|
|
73
|
+
FETCH: "GET", REDIRECT: "GET", RESPONDREDIRECT: "GET", REDIRECT_TO: "GET",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const deriveVerb = (method: string): string => {
|
|
77
|
+
let up = method.toUpperCase();
|
|
78
|
+
if (up.endsWith("MAPPING")) up = up.slice(0, -"MAPPING".length); // Spring GetMapping → GET
|
|
79
|
+
return METHOD_ALIASES[up] ?? up;
|
|
80
|
+
};
|
|
30
81
|
|
|
31
82
|
export const DEFAULT_PACK: AnchorRule[] = [
|
|
32
83
|
{
|
|
84
|
+
// `$P` as an arg NODE, not in-string: binds across double/single quotes,
|
|
85
|
+
// backticks and Python f-strings. Quote chars are stripped by unquote().
|
|
33
86
|
id: "http-route-call",
|
|
34
87
|
langs: ["TypeScript", "Tsx", "JavaScript", "Go"],
|
|
35
|
-
pattern:
|
|
88
|
+
pattern: "$R.$M($P, $$$H)",
|
|
36
89
|
methods: "^(?i:get|post|put|delete|patch|head|options|all|use|any|handle|handlefunc|route|group)$",
|
|
37
90
|
kind: "route",
|
|
38
91
|
},
|
|
@@ -41,56 +94,141 @@ export const DEFAULT_PACK: AnchorRule[] = [
|
|
|
41
94
|
// feature hubs when they reference a real path (validated below).
|
|
42
95
|
id: "http-verb-single-arg",
|
|
43
96
|
langs: ["TypeScript", "Tsx", "JavaScript"],
|
|
44
|
-
pattern:
|
|
97
|
+
pattern: "$R.$M($P)",
|
|
45
98
|
methods: "^(?i:get|post|put|delete|patch)$",
|
|
46
99
|
kind: "route",
|
|
47
100
|
},
|
|
48
101
|
{
|
|
49
|
-
id: "http-
|
|
50
|
-
langs: ["
|
|
51
|
-
pattern: "$R.$M(
|
|
52
|
-
methods: "^(
|
|
102
|
+
id: "http-verb-single-arg-py",
|
|
103
|
+
langs: ["Python"],
|
|
104
|
+
pattern: "$R.$M($P)",
|
|
105
|
+
methods: "^(get|post|put|delete|patch|head|options)$",
|
|
53
106
|
kind: "route",
|
|
54
107
|
},
|
|
55
108
|
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
109
|
+
id: "python-route-call",
|
|
110
|
+
langs: ["Python"],
|
|
111
|
+
pattern: "$R.$M($P, $$$H)",
|
|
112
|
+
methods: "^(?:add_)?(?:get|post|put|delete|patch|head|options|route)$",
|
|
61
113
|
kind: "route",
|
|
62
|
-
prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
|
|
63
114
|
},
|
|
64
115
|
{
|
|
65
|
-
//
|
|
66
|
-
id: "ts-http-decorator
|
|
116
|
+
// NestJS / Angular-style decorators; class prefix via @Controller.
|
|
117
|
+
id: "ts-http-decorator",
|
|
67
118
|
langs: ["TypeScript", "Tsx"],
|
|
68
|
-
pattern: "@$M(
|
|
119
|
+
pattern: "@$M($P)",
|
|
69
120
|
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
70
121
|
kind: "route",
|
|
71
|
-
prefixPattern: [
|
|
122
|
+
prefixPattern: ["@Controller($P)"],
|
|
72
123
|
},
|
|
73
124
|
{
|
|
74
125
|
id: "python-decorator-route",
|
|
75
126
|
langs: ["Python"],
|
|
76
|
-
pattern:
|
|
127
|
+
pattern: "@$R.$M($P)",
|
|
77
128
|
methods: "^(get|post|put|delete|patch|route|websocket)$",
|
|
78
129
|
kind: "route",
|
|
79
130
|
},
|
|
80
131
|
{
|
|
81
|
-
|
|
132
|
+
// chi r.Method("GET", "/x", h), aiohttp web.route(...)/router.add_route(...).
|
|
133
|
+
id: "verb-as-argument",
|
|
134
|
+
langs: ["Go", "Python", "TypeScript", "Tsx", "JavaScript"],
|
|
135
|
+
pattern: '$R.$M("$V", "$P", $$$H)',
|
|
136
|
+
methods: "^(?i:method|methodfunc|add_route|add_view|route)$",
|
|
137
|
+
kind: "route",
|
|
138
|
+
verbFrom: "V",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
// aiohttp module-level / receiver-free form: route("GET", "/x", h).
|
|
142
|
+
id: "verb-as-argument-norecv",
|
|
82
143
|
langs: ["Python"],
|
|
83
|
-
pattern:
|
|
84
|
-
methods: "^
|
|
144
|
+
pattern: '$M("$V", "$P", $$$H)',
|
|
145
|
+
methods: "^route$",
|
|
146
|
+
kind: "route",
|
|
147
|
+
verbFrom: "V",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
// Django urlconf; mounts for every verb, so they anchor as ANY /x.
|
|
151
|
+
id: "django-url",
|
|
152
|
+
langs: ["Python"],
|
|
153
|
+
pattern: '$M("$P", $$$H)',
|
|
154
|
+
methods: "^(path|re_path|url)$",
|
|
155
|
+
kind: "route",
|
|
156
|
+
mountRoot: true,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
// Rails routes.rb macros. Bare word form, string content capture.
|
|
160
|
+
id: "rails-route-macro",
|
|
161
|
+
langs: ["Ruby"],
|
|
162
|
+
pattern: '$M "$P", $$$R',
|
|
163
|
+
methods: "^(get|post|put|delete|patch|match|redirect|mount|root|head|options)$",
|
|
164
|
+
kind: "route",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "rails-route-macro-sq",
|
|
168
|
+
langs: ["Ruby"],
|
|
169
|
+
pattern: "$M '$P', $$$R",
|
|
170
|
+
methods: "^(get|post|put|delete|patch|match|redirect|mount|root|head|options)$",
|
|
171
|
+
kind: "route",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
// Phoenix router.ex macros. Scope prefixes are not composed (see README).
|
|
175
|
+
// resources expands to the REST verb set — anchor the mount as ANY,
|
|
176
|
+
// the hub still merges with controller code via literal joins.
|
|
177
|
+
id: "phoenix-route-macro",
|
|
178
|
+
langs: ["Elixir"],
|
|
179
|
+
pattern: '$M "$P", $$$R',
|
|
180
|
+
methods: "^(get|post|put|delete|patch|head|options|forward|resources)$",
|
|
181
|
+
kind: "route",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "phoenix-route-call",
|
|
185
|
+
langs: ["Elixir"],
|
|
186
|
+
pattern: '$M("$P", $$$R)',
|
|
187
|
+
methods: "^(get|post|put|delete|patch|head|options|forward|resources)$",
|
|
188
|
+
kind: "route",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
// Ktor routing DSL: get("/health") { … }
|
|
192
|
+
id: "ktor-routing-dsl",
|
|
193
|
+
langs: ["Kotlin"],
|
|
194
|
+
pattern: '$M("$P") { $$$B }',
|
|
195
|
+
methods: "^(get|post|put|delete|patch|head|options|route)$",
|
|
196
|
+
kind: "route",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
// Spring MVC / WebFlux: class @RequestMapping prefix x method @GetMapping.
|
|
200
|
+
id: "spring-mapping-annotation",
|
|
201
|
+
langs: ["Java", "Kotlin"],
|
|
202
|
+
pattern: '@$M("$P")',
|
|
203
|
+
methods: "^(Get|Post|Put|Delete|Patch)Mapping$",
|
|
85
204
|
kind: "route",
|
|
205
|
+
prefixPattern: ['@RequestMapping("$P")'],
|
|
86
206
|
},
|
|
87
207
|
{
|
|
88
208
|
id: "flask-add-url-rule",
|
|
89
209
|
langs: ["Python"],
|
|
90
|
-
pattern:
|
|
210
|
+
pattern: "$R.add_url_rule($P, $$$H)",
|
|
91
211
|
methods: "^add_url_rule$",
|
|
92
212
|
kind: "route",
|
|
93
213
|
},
|
|
214
|
+
{
|
|
215
|
+
// Client fetch with no receiver: fetch("/api/x"). Precision-audited by
|
|
216
|
+
// discovery (~93% path precision in the next.js clone corpus).
|
|
217
|
+
id: "fetch-bare",
|
|
218
|
+
langs: ["TypeScript", "Tsx", "JavaScript"],
|
|
219
|
+
pattern: "$M($P, $$$H)", // trailing $$$H absorbs the options bag; zero-arg tail matches fetch("/x") too
|
|
220
|
+
methods: "^fetch$",
|
|
221
|
+
kind: "route",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
// Response-side route linkage: ktor respondRedirect("/myfiles") references
|
|
225
|
+
// an existing route without declaring it. Discovery found it at p̂≈0.81.
|
|
226
|
+
id: "ktor-respond-redirect",
|
|
227
|
+
langs: ["Kotlin"],
|
|
228
|
+
pattern: "$R.$M($P, $$$H)",
|
|
229
|
+
methods: "^respondRedirect$",
|
|
230
|
+
kind: "route",
|
|
231
|
+
},
|
|
94
232
|
{
|
|
95
233
|
id: "rust-router-chain",
|
|
96
234
|
langs: ["Rust"],
|
|
@@ -123,26 +261,40 @@ export const extractAnchors = (
|
|
|
123
261
|
for (const lang of rule.langs) {
|
|
124
262
|
const langFiles = byLang.get(lang);
|
|
125
263
|
if (!langFiles?.length) continue;
|
|
126
|
-
const prefixes = new Map<string, string>(); // file ->
|
|
264
|
+
const prefixes = new Map<string, string>(); // file -> class-level path prefix
|
|
127
265
|
if (rule.prefixPattern?.length) {
|
|
128
266
|
for (const pm of patternRunAll(rule.prefixPattern, lang, langFiles, cwd)) {
|
|
129
267
|
const p = pm.single.P?.trim();
|
|
130
|
-
if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, p);
|
|
268
|
+
if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, unquote(p));
|
|
131
269
|
}
|
|
132
270
|
}
|
|
133
|
-
|
|
271
|
+
const matchSets = patternRunAll(rule.patterns ?? [rule.pattern!], lang, langFiles, cwd);
|
|
272
|
+
for (const m of matchSets) {
|
|
134
273
|
const method = m.single.M;
|
|
135
|
-
const
|
|
136
|
-
if (!method || !
|
|
274
|
+
const pathLike = m.single.P;
|
|
275
|
+
if (!method || !pathLike || !methodRe.test(method)) continue;
|
|
137
276
|
const prefix = prefixes.get(m.file);
|
|
138
|
-
|
|
277
|
+
let raw = prefix !== undefined && prefix !== "" ? joinRoute(prefix, unquote(pathLike)) : unquote(pathLike);
|
|
278
|
+
if (rule.mountRoot && !raw.startsWith("/")) raw = "/" + raw.replace(/^\/+/, "");
|
|
279
|
+
// Verb embedded in the path string (Go 1.22 mux, Starlette Route).
|
|
280
|
+
const vip = VERB_IN_PATH.exec(raw);
|
|
281
|
+
let verbOverride: string | undefined;
|
|
282
|
+
if (vip) {
|
|
283
|
+
verbOverride = vip[1]!.toUpperCase();
|
|
284
|
+
raw = vip[2]!;
|
|
285
|
+
}
|
|
139
286
|
// $R.$M(...) also matches Map.get("key")-style data access; only real
|
|
140
|
-
// paths (or router-relative placeholders like ":id")
|
|
287
|
+
// paths (or router-relative placeholders like ":id", "[id]") anchor.
|
|
141
288
|
if (!PATH_TOKEN_RE.test(raw) && !PLACEHOLDER_ONLY.test(raw)) continue;
|
|
289
|
+
let httpMethod: string;
|
|
290
|
+
if (rule.verbFrom) {
|
|
291
|
+
const v = m.single[rule.verbFrom];
|
|
292
|
+
if (!v || !HTTP_VERB_RE.test(v)) continue;
|
|
293
|
+
httpMethod = v.toUpperCase();
|
|
294
|
+
} else {
|
|
295
|
+
httpMethod = verbOverride ?? deriveVerb(method);
|
|
296
|
+
}
|
|
142
297
|
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
298
|
const label = `${httpMethod} ${norm}`;
|
|
147
299
|
const enclosing = resolveEnclosing(m.file, m.line);
|
|
148
300
|
out.push({
|
|
@@ -152,6 +304,7 @@ export const extractAnchors = (
|
|
|
152
304
|
nodeId: enclosing ?? `file:${m.file}`,
|
|
153
305
|
file: m.file,
|
|
154
306
|
line: m.line,
|
|
307
|
+
...(rule.implicit ? { implicit: true } : {}),
|
|
155
308
|
});
|
|
156
309
|
}
|
|
157
310
|
}
|
|
@@ -166,26 +319,132 @@ export const extractAnchors = (
|
|
|
166
319
|
});
|
|
167
320
|
};
|
|
168
321
|
|
|
322
|
+
// --- file-convention routes -------------------------------------------------
|
|
323
|
+
|
|
324
|
+
// Frameworks where the route *is* the file path (no route string exists in
|
|
325
|
+
// code at all): Next App Router, SvelteKit, Nuxt. Each rule is a regex with
|
|
326
|
+
// capture group 1 = route stem, plus how verbs are known.
|
|
327
|
+
export interface FileRouteRule {
|
|
328
|
+
id: string;
|
|
329
|
+
re: string;
|
|
330
|
+
verbs: "exports" | "suffix";
|
|
331
|
+
pathPrefix?: string; // prepended to the derived route (Nuxt /api)
|
|
332
|
+
kind?: string; // default "route"
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const DEFAULT_FILE_ROUTES: FileRouteRule[] = [
|
|
336
|
+
{ id: "next-app-route", re: "(?:^|/)app/(.+)/route\\.(?:ts|tsx|js|jsx|mjs)$", verbs: "exports", kind: "route" },
|
|
337
|
+
{ id: "next-app-page", re: "(?:^|/)app/(?:(.+)/)?page\\.(?:tsx|jsx|mdx)$", verbs: "suffix", kind: "page" },
|
|
338
|
+
{ id: "next-pages-api", re: "(?:^|/)pages/api/(.+)\\.(?:ts|tsx|js|jsx)$", verbs: "suffix", pathPrefix: "/api", kind: "route" },
|
|
339
|
+
{ id: "sveltekit-server", re: "(?:^|/)src/routes/(?:(.+)/)?\\+server\\.(?:ts|js)$", verbs: "exports", kind: "route" },
|
|
340
|
+
{ id: "sveltekit-page", re: "(?:^|/)src/routes/(?:(.+)/)?\\+page\\.(?:svelte|md)$", verbs: "suffix", kind: "page" },
|
|
341
|
+
{ id: "nuxt-server-api", re: "(?:^|/)server/api/(.+)\\.(?:ts|js|mjs)$", verbs: "suffix", pathPrefix: "/api", kind: "route" },
|
|
342
|
+
{ id: "astro-endpoint", re: "(?:^|/)src/pages/(.+)\\.(?:ts|js|mjs)$", verbs: "exports", kind: "route" },
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
// `export async function GET`, `export const POST` — route handler verbs.
|
|
346
|
+
const EXPORTED_VERB_RE = /\bexport\s+(?:(?:async\s+)?function\s+|const\s+)(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/g;
|
|
347
|
+
const SUFFIX_VERB_RE = /\.(get|post|put|delete|patch|head|options)$/i;
|
|
348
|
+
|
|
349
|
+
// Cards → concrete path. `(group)` segments vanish (Next), dynamic segments
|
|
350
|
+
// become the canonical {*} placeholder, `index` is the path itself.
|
|
351
|
+
const FILE_DYNAMIC_SEG = /^@?\[+(?:\.\.\.)?[^\]]+\]+$/; // [x], [...x], [[...x]] (optional catch-all), @[x]
|
|
352
|
+
const toFileRoutePath = (stem: string): string => {
|
|
353
|
+
if (stem === "") return "/";
|
|
354
|
+
const segs = stem.split("/").flatMap((seg) => {
|
|
355
|
+
if (!seg || /^\(.+\)$/.test(seg)) return [];
|
|
356
|
+
if (FILE_DYNAMIC_SEG.test(seg)) return ["{*}"];
|
|
357
|
+
if (seg === "index") return [];
|
|
358
|
+
return [seg];
|
|
359
|
+
});
|
|
360
|
+
return "/" + segs.filter((s) => s !== "").join("/");
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export const extractFileRoutes = (files: string[], root: string, rules: FileRouteRule[] = DEFAULT_FILE_ROUTES): AnchorDraft[] => {
|
|
364
|
+
const compiled = rules.map((r) => ({ rule: r, re: new RegExp(r.re) }));
|
|
365
|
+
const out: AnchorDraft[] = [];
|
|
366
|
+
for (const file of files) {
|
|
367
|
+
for (const { rule, re } of compiled) {
|
|
368
|
+
const match = re.exec(file);
|
|
369
|
+
if (!match) continue;
|
|
370
|
+
let stem = match[1] ?? ""; // optional dir group: route lives at the root
|
|
371
|
+
if (!stem && !rule.pathPrefix) { /* root page/endpoint: keep going, path is / */ }
|
|
372
|
+
const verbs = new Set<string>();
|
|
373
|
+
let suffixVerb: string | undefined;
|
|
374
|
+
const sv = SUFFIX_VERB_RE.exec(stem);
|
|
375
|
+
if (sv) {
|
|
376
|
+
suffixVerb = sv[1]!.toUpperCase();
|
|
377
|
+
stem = stem.slice(0, -sv[0].length);
|
|
378
|
+
}
|
|
379
|
+
if (rule.verbs === "suffix") {
|
|
380
|
+
if (suffixVerb) verbs.add(suffixVerb);
|
|
381
|
+
} else {
|
|
382
|
+
let content = "";
|
|
383
|
+
try {
|
|
384
|
+
content = readFileSync(joinPath(root, file), "utf8");
|
|
385
|
+
} catch {
|
|
386
|
+
// unreadable: fall through with no verbs
|
|
387
|
+
}
|
|
388
|
+
for (const vm of content.matchAll(EXPORTED_VERB_RE)) verbs.add(vm[1]!);
|
|
389
|
+
}
|
|
390
|
+
if (verbs.size === 0) verbs.add("ANY")
|
|
391
|
+
const rel = (rule.pathPrefix ?? "") + toFileRoutePath(stem);
|
|
392
|
+
const norm = normalizeLiteral(rel, "path");
|
|
393
|
+
for (const verb of verbs) {
|
|
394
|
+
const label = `${verb} ${norm}`;
|
|
395
|
+
out.push({
|
|
396
|
+
id: label,
|
|
397
|
+
kind: rule.kind ?? "route",
|
|
398
|
+
label,
|
|
399
|
+
nodeId: `file:${file}`,
|
|
400
|
+
file,
|
|
401
|
+
line: 0,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return dedupeAnchors(out);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const dedupeAnchors = (out: AnchorDraft[]): AnchorDraft[] => {
|
|
410
|
+
const seen = new Set<string>();
|
|
411
|
+
return out.filter((a) => {
|
|
412
|
+
const k = `${a.id}|${a.file}|${a.line}`;
|
|
413
|
+
if (seen.has(k)) return false;
|
|
414
|
+
seen.add(k);
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
169
419
|
// Re-export so callers can classify an anchor path like any literal.
|
|
170
420
|
export const anchorClassify = classifyLiteral;
|
|
171
421
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
422
|
+
// Hash of the built-in packs: code changes to the default pack invalidate
|
|
423
|
+
// cached anchor facts even when a repo ships no rules.json of its own.
|
|
424
|
+
const DEFAULTS_SHA = createHash("sha1")
|
|
425
|
+
.update(JSON.stringify(DEFAULT_PACK))
|
|
426
|
+
.update(JSON.stringify(DEFAULT_FILE_ROUTES))
|
|
427
|
+
.digest("hex");
|
|
428
|
+
|
|
429
|
+
// Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[], "fileRoutes": FileRouteRule[] }.
|
|
430
|
+
export const loadRepoRules = (root: string): { pack: AnchorRule[]; fileRoutes: FileRouteRule[]; sha: string } => {
|
|
176
431
|
let raw = "";
|
|
177
432
|
try {
|
|
178
433
|
raw = readFileSync(joinPath(root, ".fovea", "rules.json"), "utf8");
|
|
179
434
|
} catch {
|
|
180
|
-
return { pack: DEFAULT_PACK, sha:
|
|
435
|
+
return { pack: DEFAULT_PACK, fileRoutes: DEFAULT_FILE_ROUTES, sha: DEFAULTS_SHA };
|
|
181
436
|
}
|
|
437
|
+
// Repo rules extend defaults; the defaults themselves ride in the hash,
|
|
438
|
+
// so upgrading pi-fovea invalidates anchors even with no repo rules file.
|
|
439
|
+
const sha = createHash("sha1").update(DEFAULTS_SHA + raw).digest("hex");
|
|
182
440
|
try {
|
|
183
|
-
const parsed = JSON.parse(raw) as { rules?: AnchorRule[] };
|
|
441
|
+
const parsed = JSON.parse(raw) as { rules?: AnchorRule[]; fileRoutes?: FileRouteRule[] };
|
|
184
442
|
const rules = (parsed.rules ?? []).filter(
|
|
185
443
|
(r) => r && typeof r.pattern === "string" && typeof r.methods === "string" && Array.isArray(r.langs),
|
|
186
444
|
);
|
|
187
|
-
|
|
445
|
+
const fileRoutes = (parsed.fileRoutes ?? []).filter((r) => r && typeof r.re === "string" && typeof r.verbs === "string");
|
|
446
|
+
return { pack: [...DEFAULT_PACK, ...rules], fileRoutes: [...DEFAULT_FILE_ROUTES, ...fileRoutes], sha };
|
|
188
447
|
} catch {
|
|
189
|
-
return { pack: DEFAULT_PACK, sha: createHash("sha1").update(raw).digest("hex") };
|
|
448
|
+
return { pack: DEFAULT_PACK, fileRoutes: DEFAULT_FILE_ROUTES, sha: createHash("sha1").update(raw).digest("hex") };
|
|
190
449
|
}
|
|
191
450
|
};
|
package/src/core/build.ts
CHANGED
|
@@ -8,10 +8,11 @@ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { basename, dirname, join as joinPath, posix } from "node:path";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { LANG_BY_EXT, isBinaryExt, isConfigFile } from "./astgrep.js";
|
|
11
|
+
import { LANG_BY_EXT, isBinaryExt, isConfigFile, langOf } 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
|
+
import { aggregateFiles, harvestFile, promote, type FileSigs, type SynthesizedRule } from "./discover.js";
|
|
15
16
|
import type { CallSite, Edge, Graph, ImportSite, LiteralSite, NodeRec, SymbolRec } from "./types.js";
|
|
16
17
|
import type { AnchorDraft } from "./anchors.js";
|
|
17
18
|
import { coChangePairs } from "./cochange.js";
|
|
@@ -23,9 +24,13 @@ export interface FileFacts {
|
|
|
23
24
|
calls: CallSite[];
|
|
24
25
|
literals: LiteralSite[];
|
|
25
26
|
anchors: AnchorDraft[];
|
|
27
|
+
// Tier-3 discovery: per-file histogram of call-shape signatures
|
|
28
|
+
// (sig -> [totalSites, pathSites]); aggregated repo-wide at load to promote
|
|
29
|
+
// statistically significant unknown shapes into implicit half-weight rules.
|
|
30
|
+
sigs?: FileSigs;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const CACHE_VERSION =
|
|
33
|
+
const CACHE_VERSION = 6; // bump when extractor semantics change
|
|
29
34
|
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
35
|
const MAX_FILES = 24000;
|
|
31
36
|
// Generated dependency manifests are enormous and carry no first-class routes.
|
|
@@ -41,12 +46,14 @@ const isJunk = (f: string): boolean => {
|
|
|
41
46
|
return LOCKFILE_NAMES.has(base) || base.endsWith(".lock");
|
|
42
47
|
};
|
|
43
48
|
|
|
44
|
-
const supported = (f: string): boolean => {
|
|
49
|
+
const supported = (f: string, routeRes?: RegExp[]): boolean => {
|
|
45
50
|
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
|
46
|
-
|
|
51
|
+
if (!isBinaryExt(f) && (ext in LANG_BY_EXT || isConfigFile(f))) return true;
|
|
52
|
+
// File-convention routers use extensions with no ast-grep lang (.svelte, .mdx).
|
|
53
|
+
return routeRes?.some((re) => re.test(f)) ?? false;
|
|
47
54
|
};
|
|
48
55
|
|
|
49
|
-
export const listFiles = (root: string): string[] => {
|
|
56
|
+
export const listFiles = (root: string, routeRes?: RegExp[]): string[] => {
|
|
50
57
|
const res = spawnSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
|
|
51
58
|
encoding: "utf8",
|
|
52
59
|
timeout: 30_000,
|
|
@@ -67,14 +74,14 @@ export const listFiles = (root: string): string[] => {
|
|
|
67
74
|
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
68
75
|
if (e.isDirectory()) {
|
|
69
76
|
if (!IGNORE_DIRS.has(e.name)) walk(joinPath(dir, e.name), rel);
|
|
70
|
-
} else if (e.isFile() && supported(rel)) {
|
|
77
|
+
} else if (e.isFile() && supported(rel, routeRes)) {
|
|
71
78
|
files.push(rel);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
};
|
|
75
82
|
walk(root, "");
|
|
76
83
|
}
|
|
77
|
-
files = files.filter((f) => supported(f) && !isJunk(f));
|
|
84
|
+
files = files.filter((f) => supported(f, routeRes) && !isJunk(f));
|
|
78
85
|
files.sort();
|
|
79
86
|
return files.slice(0, MAX_FILES);
|
|
80
87
|
};
|
|
@@ -101,8 +108,12 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
|
|
|
101
108
|
} catch {
|
|
102
109
|
cached = undefined;
|
|
103
110
|
}
|
|
104
|
-
const { pack:
|
|
105
|
-
const
|
|
111
|
+
const { pack: basePack, fileRoutes, sha: baseRulesSha } = loadRepoRules(root);
|
|
112
|
+
const anchorPack = [...basePack];
|
|
113
|
+
// Implicit rules promote AFTER the first fact pass: their evidence lives in
|
|
114
|
+
// freshly harvested sigs, so the pack can only be finalized once facts exist.
|
|
115
|
+
let implicitRules: SynthesizedRule[] = [];
|
|
116
|
+
let rulesSha = baseRulesSha;
|
|
106
117
|
if (cached && (cached.version !== CACHE_VERSION || cached.root !== root)) cached = undefined;
|
|
107
118
|
const facts: Record<string, FileFacts> = {};
|
|
108
119
|
const dirty: string[] = [];
|
|
@@ -131,12 +142,31 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
|
|
|
131
142
|
putByFile(extractImports(code, root), (f, v) => f.imports.push(v));
|
|
132
143
|
putByFile(extractCalls(code, root), (f, v) => f.calls.push(v));
|
|
133
144
|
putByFile(extractLiterals(dirty, root), (f, v) => f.literals.push(v));
|
|
145
|
+
// Tier-3 harvest: regex histogram of call-shape signatures per file. Cheap
|
|
146
|
+
// (line scan, no ast-grep) and cached alongside the other facts.
|
|
147
|
+
for (const f of code) {
|
|
148
|
+
const lang = langOf(f);
|
|
149
|
+
if (!lang) continue;
|
|
150
|
+
let text = "";
|
|
151
|
+
try { text = readFileSync(joinPath(root, f), "utf8"); } catch { continue; }
|
|
152
|
+
const sigs = harvestFile(lang, text);
|
|
153
|
+
if (Object.keys(sigs).length) facts[f]!.sigs = sigs;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Tier-3: promote harvested signatures into implicit rules BEFORE anchors
|
|
157
|
+
// run, and fold their ids into the rules hash so a promotion change rebuilds
|
|
158
|
+
// anchors exactly like a rules.json edit would.
|
|
159
|
+
implicitRules = promote(aggregateFiles(Object.fromEntries(Object.entries(facts).map(([k, v]) => [k, v.sigs]))), anchorPack);
|
|
160
|
+
if (implicitRules.length) {
|
|
161
|
+
anchorPack.push(...implicitRules);
|
|
162
|
+
rulesSha = createHash("sha1").update(baseRulesSha).update(JSON.stringify(implicitRules.map((r) => r.id).sort())).digest("hex");
|
|
134
163
|
}
|
|
164
|
+
const rulesChangedFinal = cached !== undefined && cached.rulesSha !== rulesSha;
|
|
135
165
|
// Anchors need enclosing-symbol resolution. Symbols, imports, calls and
|
|
136
166
|
// literals are rules-independent — when only the rule pack changed, re-run
|
|
137
167
|
// anchor extraction over every code file against cached symbols and keep
|
|
138
168
|
// every other fact (green-node reuse one level up).
|
|
139
|
-
const anchorTargets =
|
|
169
|
+
const anchorTargets = rulesChangedFinal
|
|
140
170
|
? files.filter((f) => !isConfigFile(f))
|
|
141
171
|
: dirty.filter((f) => !isConfigFile(f));
|
|
142
172
|
if (anchorTargets.length) {
|
|
@@ -149,7 +179,7 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
|
|
|
149
179
|
const symsByFile = new Map<string, SymbolRec[]>();
|
|
150
180
|
for (const rel of anchorTargets) {
|
|
151
181
|
symsByFile.set(rel, facts[rel]?.symbols.length ? facts[rel]!.symbols : (cached?.facts[rel]?.symbols ?? []));
|
|
152
|
-
if (
|
|
182
|
+
if (rulesChangedFinal) facts[rel] && (facts[rel]!.anchors = []);
|
|
153
183
|
}
|
|
154
184
|
const enclosingId = (file: string, line: number): string | undefined => {
|
|
155
185
|
const syms = symsByFile.get(file) ?? [];
|
|
@@ -158,6 +188,9 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
|
|
|
158
188
|
return best ? `${best.name}@${best.file}` : `file:${file}`;
|
|
159
189
|
};
|
|
160
190
|
putByFile(extractAnchors(anchorTargets, root, enclosingId, anchorPack), (f, v) => f.anchors.push(v));
|
|
191
|
+
// File-convention routes (Next/SvelteKit/Nuxt): the route path is derived
|
|
192
|
+
// from the file path; verbs come from exported handler names or suffix.
|
|
193
|
+
putByFile(extractFileRoutes(anchorTargets, root, fileRoutes), (f, v) => f.anchors.push(v));
|
|
161
194
|
}
|
|
162
195
|
try {
|
|
163
196
|
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
@@ -386,13 +419,18 @@ export const assembleGraph = (root: string, files: string[], facts: Record<strin
|
|
|
386
419
|
for (const [label, sites] of draftsByLabel) {
|
|
387
420
|
const first = sites[0]!;
|
|
388
421
|
const filesOf = [...new Set(sites.map((s) => s.file))];
|
|
389
|
-
|
|
422
|
+
// A hub is implicit only when EVERY site came from a discovered rule — a
|
|
423
|
+
// match by any real rule upgrades it back to first-class instantly.
|
|
424
|
+
const hubImplicit = sites.every((s) => s.implicit === true);
|
|
425
|
+
anchors.push({ id: label, kind: first.kind, label: sites.length > 1 ? `${label} · ${sites.length} sites` : label, nodeId: first.nodeId, file: first.file, line: first.line, ...(hubImplicit ? { implicit: true } : {}) });
|
|
390
426
|
const idx = addNode(nodes, seen, {
|
|
391
427
|
id: `anchor:${label}`, name: label, kind: "anchor", file: first.file, line: first.line,
|
|
392
|
-
sig: sites.length > 1 ? `${label} (${sites.length} sites)` : label
|
|
428
|
+
sig: `${hubImplicit ? "(△ discovered) " : ""}${sites.length > 1 ? `${label} (${sites.length} sites)` : label}`, lang: "anchor",
|
|
393
429
|
});
|
|
394
430
|
(byFile.get(first.file) ?? byFile.set(first.file, []).get(first.file)!).push(idx);
|
|
395
|
-
|
|
431
|
+
// Tier-3 hubs prove themselves at half conductance; a later literal join
|
|
432
|
+
// against a first-class hub can still warm them via the channel edges.
|
|
433
|
+
const w = (hubImplicit ? 0.5 : 1) / Math.sqrt(sites.length);
|
|
396
434
|
for (const s of sites) {
|
|
397
435
|
const handler = seen.get(s.nodeId) ?? fileIdx.get(s.file)!;
|
|
398
436
|
pushEdge(idx, handler, "anchors", w);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Tier-3: autonomy for route extraction. The static pack catches known port
|
|
2
|
+
// shapes; this module watches the literal channel for shapes the pack does NOT
|
|
3
|
+
// declare, promotes statistically significant ones into synthesized "implicit"
|
|
4
|
+
// rules at half hub gravity, and lets confirmatory joins against real hubs
|
|
5
|
+
// upgrade them to full first-class weight.
|
|
6
|
+
//
|
|
7
|
+
// The promotion statistic is a per-argument conditional: given the string arg
|
|
8
|
+
// at position i of call-shape (lang·shape·callee), how often does it classify
|
|
9
|
+
// as a path? A Jeffreys-smoothed posterior with global floors decides — not raw
|
|
10
|
+
// frequency, under which chaff like `assertEquals(...)` dwarfs every real shape.
|
|
11
|
+
//
|
|
12
|
+
// Corpus audit (8 repos, 183 sigs n≥4): junk bands sit below p̂≈0.27, real
|
|
13
|
+
// shapes above p̂≈0.75 — the 0.55 line is a cliff, not a gradient.
|
|
14
|
+
//
|
|
15
|
+
// Harvest is line-regex over source text — no ast-grep pass needed. Per file we
|
|
16
|
+
// store a compact signature histogram (sig -> [sites, pathSites]); promotions
|
|
17
|
+
// aggregate across ALL files of the repo, so a dependency update or a style
|
|
18
|
+
// drift can only flip a marginal signature when its repo-wide evidence moves.
|
|
19
|
+
|
|
20
|
+
import { classifyLiteral } from "./join.js";
|
|
21
|
+
|
|
22
|
+
export type Shape = "recv" | "bare" | "dec";
|
|
23
|
+
|
|
24
|
+
// Compacted per-file histogram: sigKey -> [totalSites, pathSites]
|
|
25
|
+
export type FileSigs = Record<string, [number, number]>;
|
|
26
|
+
|
|
27
|
+
const QUOTED = /^[rbfuRBFU]{0,3}(["'`])([\s\S]*)\1$/;
|
|
28
|
+
const unquote = (s: string): string | undefined => {
|
|
29
|
+
const m = QUOTED.exec(s.trim());
|
|
30
|
+
return m ? m[2] : undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Line-local call scan: grabs the callee shape (recv chain, bare, decorator)
|
|
34
|
+
// and the raw arg list of every `X(...)` on the line. Multi-line calls still
|
|
35
|
+
// land because route strings overwhelmingly sit on the call's first line.
|
|
36
|
+
const CALL_LINE_RE = /(?<at>@)?(?<recv>(?:[A-Za-z_$][\w$]*\.)+)?(?<method>[A-Za-z_$][\w$]*)\s*\((?<args>[^()]*)\)/g;
|
|
37
|
+
const STRING_RE = /([rbfuRBFU]{0,3})(["'`])((?:\\.|(?!\2)[^\\])*)\2/g;
|
|
38
|
+
|
|
39
|
+
// Callees that are noise even at 100% precision (env/fs access, string ctors,
|
|
40
|
+
// module loading — dynamic import()/require() hits the path channel but the
|
|
41
|
+
// import edge already covers it, an anchor would double-count the site).
|
|
42
|
+
const CALLEE_DENY = new Set([
|
|
43
|
+
"join", "resolve", "dirname", "basename", "expand_path",
|
|
44
|
+
"readFile", "readFileSync", "existsSync", "open", "load", "loads",
|
|
45
|
+
"import", "require",
|
|
46
|
+
// String predicates: membership tests hit the path column at high rate but
|
|
47
|
+
// never refer to routes. (Distinguishing cause from noise is callee-semantic.)
|
|
48
|
+
"startsWith", "endsWith", "contains", "includes", "equals", "equalsIgnoreCase",
|
|
49
|
+
"matches", "matchesPattern", "useParams", "matchPath",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export const harvestFile = (lang: string, text: string): FileSigs => {
|
|
53
|
+
const sigs: FileSigs = {};
|
|
54
|
+
for (const line of text.split("\n")) {
|
|
55
|
+
if (!line.includes("(") || !(line.includes('"') || line.includes("'") || line.includes("`"))) continue;
|
|
56
|
+
CALL_LINE_RE.lastIndex = 0;
|
|
57
|
+
let c: RegExpExecArray | null;
|
|
58
|
+
while ((c = CALL_LINE_RE.exec(line))) {
|
|
59
|
+
const method = c.groups?.method;
|
|
60
|
+
if (!method || CALLEE_DENY.has(method)) continue;
|
|
61
|
+
const shape: Shape = c.groups?.at ? "dec" : c.groups?.recv ? "recv" : "bare";
|
|
62
|
+
const argsText = c.groups?.args ?? "";
|
|
63
|
+
if (!argsText) continue;
|
|
64
|
+
// True argument index of each string literal (split tolerantly on commas).
|
|
65
|
+
const args = argsText.split(",");
|
|
66
|
+
let idx = 0;
|
|
67
|
+
for (const raw of args) {
|
|
68
|
+
STRING_RE.lastIndex = 0;
|
|
69
|
+
const sm = STRING_RE.exec(raw);
|
|
70
|
+
const idxNow = idx++;
|
|
71
|
+
if (!sm) continue;
|
|
72
|
+
const lit = sm[3];
|
|
73
|
+
if (lit === undefined || lit === "") continue;
|
|
74
|
+
const key = `${lang}|${shape}|${method}|${idxNow}`;
|
|
75
|
+
const rec = sigs[key] ?? [0, 0];
|
|
76
|
+
rec[0]++;
|
|
77
|
+
if (classifyLiteral(lit) === "path") rec[1]++;
|
|
78
|
+
sigs[key] = rec;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return sigs;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface SigStats {
|
|
86
|
+
key: string;
|
|
87
|
+
lang: string;
|
|
88
|
+
shape: Shape;
|
|
89
|
+
callee: string;
|
|
90
|
+
argIdx: number;
|
|
91
|
+
n: number;
|
|
92
|
+
pathN: number;
|
|
93
|
+
files: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const aggregateFiles = (perFile: Record<string, FileSigs | undefined>): SigStats[] => {
|
|
97
|
+
const agg = new Map<string, SigStats>();
|
|
98
|
+
for (const sigs of Object.values(perFile)) {
|
|
99
|
+
if (!sigs) continue;
|
|
100
|
+
for (const [key, [n, p]] of Object.entries(sigs)) {
|
|
101
|
+
const stat = agg.get(key);
|
|
102
|
+
if (stat) {
|
|
103
|
+
stat.n += n;
|
|
104
|
+
stat.pathN += p;
|
|
105
|
+
stat.files++;
|
|
106
|
+
} else {
|
|
107
|
+
const [lang, shape, callee, argIdx] = key.split("|");
|
|
108
|
+
agg.set(key, { key, lang: lang!, shape: shape as Shape, callee: callee!, argIdx: Number(argIdx), n, pathN: p, files: 1 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...agg.values()];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Jeffreys-ish posterior: p̂ = (pathN + .5) / (n + 1). */
|
|
116
|
+
export const posterior = (pathN: number, n: number): number => (pathN + 0.5) / (n + 1);
|
|
117
|
+
|
|
118
|
+
export const MIN_SITES = 4;
|
|
119
|
+
export const MIN_FILES = 2;
|
|
120
|
+
export const MIN_POSTERIOR = 0.55;
|
|
121
|
+
|
|
122
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
|
|
124
|
+
// Pattern synthesis per shape and arg position: dummy metavars for the slots
|
|
125
|
+
// the corpus says are not the path, $P at the proven position. Two variants
|
|
126
|
+
// per site — exact arity and with a trailing $$$H absorb — because some
|
|
127
|
+
// dialects only match with explicit tail holes (Python `$X, $P, $$$H`).
|
|
128
|
+
export interface SynthesizedRule {
|
|
129
|
+
id: string;
|
|
130
|
+
langs: string[];
|
|
131
|
+
patterns: string[]; // patternRunAll variants
|
|
132
|
+
methods: string;
|
|
133
|
+
kind: string;
|
|
134
|
+
implicit: true;
|
|
135
|
+
evidence: { n: number; pathN: number; files: number; posterior: number };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const synthesize = (s: SigStats): SynthesizedRule | undefined => {
|
|
139
|
+
const slots: string[] = [];
|
|
140
|
+
for (let i = 0; i <= s.argIdx; i++) slots.push(i === s.argIdx ? "$P" : `$X${i}`);
|
|
141
|
+
const inner = slots.join(", ");
|
|
142
|
+
let variants: string[];
|
|
143
|
+
switch (s.shape) {
|
|
144
|
+
case "recv": variants = [`$R.$M(${inner})`, `$R.$M(${inner}, $$$H)`]; break;
|
|
145
|
+
case "bare": variants = [`$M(${inner})`, `$M(${inner}, $$$H)`]; break;
|
|
146
|
+
case "dec": variants = [`@$M(${inner})`, `@$M(${inner}, $$$H)`]; break;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
id: `implicit:${s.lang.toLowerCase()}:${s.shape}:${s.callee}:${s.argIdx}`,
|
|
150
|
+
langs: [s.lang],
|
|
151
|
+
patterns: variants,
|
|
152
|
+
methods: `^(?i:${escapeRe(s.callee)})$`,
|
|
153
|
+
kind: "route",
|
|
154
|
+
implicit: true,
|
|
155
|
+
evidence: { n: s.n, pathN: s.pathN, files: s.files, posterior: posterior(s.pathN, s.n) },
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Shape-shape compatibility: a discovery is only NEW when no existing rule
|
|
160
|
+
// already binds the callee with a compatible pattern shape in that language.
|
|
161
|
+
// e.g. Java's @GetMapping is in the pack, so java:dec:GetMapping promotes nothing.
|
|
162
|
+
const shapeCompatPatterns: Record<Shape, RegExp> = {
|
|
163
|
+
recv: /^\$R\.\$M\(/,
|
|
164
|
+
bare: /^\$M[(\s]/,
|
|
165
|
+
dec: /^@\$(R\.)?M\(/,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const isCovered = (sig: SigStats, pack: Array<{ langs: string[]; methods: string; pattern?: string; patterns?: string[] }>): boolean => {
|
|
169
|
+
const mre = (methods: string, callee: string): boolean => new RegExp(methods.replace(/^\^\(\?i\)/, "^(?i:")).test(callee);
|
|
170
|
+
return pack.some((r) => {
|
|
171
|
+
if (!r.langs.includes(sig.lang)) return false;
|
|
172
|
+
if (!mre(r.methods, sig.callee)) return false;
|
|
173
|
+
const pats = r.patterns ?? (r.pattern ? [r.pattern] : []);
|
|
174
|
+
return pats.some((p) => shapeCompatPatterns[sig.shape].test(p));
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const promote = (sigs: SigStats[], pack: Array<{ id: string; langs: string[]; methods: string; pattern?: string; patterns?: string[] }> = []): SynthesizedRule[] => {
|
|
179
|
+
const out: SynthesizedRule[] = [];
|
|
180
|
+
for (const s of sigs) {
|
|
181
|
+
if (s.n < MIN_SITES || s.files < MIN_FILES) continue;
|
|
182
|
+
if (posterior(s.pathN, s.n) < MIN_POSTERIOR) continue;
|
|
183
|
+
if (isCovered(s, pack)) continue;
|
|
184
|
+
const r = synthesize(s);
|
|
185
|
+
if (r) out.push(r);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
};
|
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 = /^(?::[^/]+|\{[^}/]*\}|\$\{[^}/]*\}|\$[A-Za-z_]\w*|<[^/>]+>|\*+)$/; // $x = Kotlin template shorthand
|
|
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);
|
package/src/core/sync.ts
CHANGED
|
@@ -64,10 +64,14 @@ export const sync = (root: string, params: SyncParams, now?: RepoState): SyncOut
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Version drifted: measure what moved.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
67
|
+
// Version drifted: measure what moved. Implicit (tier-3 discovered) hubs
|
|
68
|
+
// churn is reported but NEVER escalates alone — hypotheses with no first-class
|
|
69
|
+
// backing don't get to wake the model with a red verdict.
|
|
70
|
+
const current = new Map(state.graph.anchors.map((a) => [a.id, a.implicit === true]));
|
|
71
|
+
const currentIds = new Set(current.keys());
|
|
72
|
+
const added = [...currentIds].filter((id) => !prev.anchors.has(id));
|
|
73
|
+
const removed = [...prev.anchors].filter((id) => !currentIds.has(id));
|
|
74
|
+
const newlyImplicit = added.filter((id) => current.get(id));
|
|
71
75
|
|
|
72
76
|
const session = getSession(root);
|
|
73
77
|
const disclosedFiles = new Set<string>();
|
|
@@ -92,7 +96,7 @@ export const sync = (root: string, params: SyncParams, now?: RepoState): SyncOut
|
|
|
92
96
|
|
|
93
97
|
baselines.set(root, { ...snapshot(state), warmed: warmNow });
|
|
94
98
|
|
|
95
|
-
const red = added.length + removed.length > 0 || warmNew.length >= Math.max(1, params.warmFileThreshold);
|
|
99
|
+
const red = (added.length - newlyImplicit.length) + removed.length > 0 || warmNew.length >= Math.max(1, params.warmFileThreshold);
|
|
96
100
|
if (!red) {
|
|
97
101
|
return {
|
|
98
102
|
structural: true, red: false, tokens: 0,
|
|
@@ -102,7 +106,8 @@ export const sync = (root: string, params: SyncParams, now?: RepoState): SyncOut
|
|
|
102
106
|
|
|
103
107
|
const lines: string[] = [];
|
|
104
108
|
lines.push(`fovea sync · v ${state.version} · edit cascade did not stay local`);
|
|
105
|
-
for (const id of added.slice(0, 12)) lines.push(` ⚑=new ${id}`);
|
|
109
|
+
for (const id of added.filter((a) => !newlyImplicit.includes(a)).slice(0, 12)) lines.push(` ⚑=new ${id}`);
|
|
110
|
+
for (const id of newlyImplicit.slice(0, 6)) lines.push(` △ newly discovered hub ${id}`);
|
|
106
111
|
for (const id of removed.slice(0, 12)) lines.push(` ⚑-removed ${id}`);
|
|
107
112
|
if (warmNew.length) {
|
|
108
113
|
lines.push(` newly warm undisclosed files (revisit with fovea_focus):`);
|
package/src/core/types.ts
CHANGED