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 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
- 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
+ ### 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 { sketch, focus, dwell, impact } from "./src/core/ops.js";
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
- return p !== undefined && (p.includes("/") || p === ".") ? p : ".";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,8 +1,19 @@
1
1
  // Feature anchors: where a feature touches the outside world. Anchors are
2
2
  // extracted by a declarative rule pack (ast-grep patterns + metadata), so new
3
- // frameworks are added as data. A route registration is the canonical anchor:
4
- // one pattern shape covers express/koa (TS), gin/echo/chi (Go), flask/fastapi
5
- // decorators (Python), and axum-style chains (Rust).
3
+ // frameworks are added as data. The pack covers five port shapes:
4
+ //
5
+ // 1. recv.verb("path", handlers...) express/koa/gin/echo/chi(net style)
6
+ // 2. verb-annotation on handler Nest/Flask/FastAPI(+class prefix)
7
+ // 3. verb embedded in the path string Go 1.22 mux.HandleFunc("GET /x", h)
8
+ // 4. receiver-less route DSL Rails, Phoenix, Django
9
+ // 5. file-convention routes Next/SvelteKit/Nuxt (extractFileRoutes)
10
+ //
11
+ // Every captured token still has to validate as a path before it can become a
12
+ // hub — the route string is the real discriminator, the call shape is flavor.
13
+ // Known blind spots (documented in README): Rust proc-macro attributes
14
+ // (actix/rocket) — ast-grep cannot parameterize attribute paths; frameworks
15
+ // with constructor-assigned prefixes (Flask Blueprint, FastAPI APIRouter,
16
+ // chi Mount); tRPC/GraphQL/gRPC have no path token to anchor at all.
6
17
 
7
18
  import { createHash } from "node:crypto";
8
19
  import { readFileSync } from "node:fs";
@@ -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: string;
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 PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/;
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: '$R.$M("$P", $$$H)',
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: '$R.$M("$P")',
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-route-call-singlequote",
50
- langs: ["TypeScript", "Tsx", "JavaScript"],
51
- pattern: "$R.$M('$P', $$$H)",
52
- methods: "^(?i:get|post|put|delete|patch|all|use|handle|route)$",
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
- // NestJS / decorator shape: @Get("/users/:id") on a controller method.
57
- id: "ts-http-decorator",
58
- langs: ["TypeScript", "Tsx"],
59
- pattern: '@$M("$P")',
60
- methods: "^(?i:get|post|put|delete|patch|options|head)$",
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
- // Single-quotes dominate real NestJS codebases (NOMAD, starters, docs).
66
- id: "ts-http-decorator-singlequote",
116
+ // NestJS / Angular-style decorators; class prefix via @Controller.
117
+ id: "ts-http-decorator",
67
118
  langs: ["TypeScript", "Tsx"],
68
- pattern: "@$M('$P')",
119
+ pattern: "@$M($P)",
69
120
  methods: "^(?i:get|post|put|delete|patch|options|head)$",
70
121
  kind: "route",
71
- prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
122
+ prefixPattern: ["@Controller($P)"],
72
123
  },
73
124
  {
74
125
  id: "python-decorator-route",
75
126
  langs: ["Python"],
76
- pattern: '@$R.$M("$P")',
127
+ pattern: "@$R.$M($P)",
77
128
  methods: "^(get|post|put|delete|patch|route|websocket)$",
78
129
  kind: "route",
79
130
  },
80
131
  {
81
- id: "python-decorator-route-singlequote",
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: "@$R.$M('$P')",
84
- methods: "^(get|post|put|delete|patch|route|websocket)$",
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: '$R.add_url_rule("$P", $$$H)',
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 -> @Controller prefix
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
- for (const m of patternRun(rule.pattern, lang, langFiles, cwd)) {
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 path = m.single.P;
136
- if (!method || !path || !methodRe.test(method)) continue;
274
+ const pathLike = m.single.P;
275
+ if (!method || !pathLike || !methodRe.test(method)) continue;
137
276
  const prefix = prefixes.get(m.file);
138
- const raw = prefix !== undefined && prefix !== "" ? joinRoute(prefix, path.trim()) : path.trim();
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") may anchor.
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
- // Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[] }.
173
- // Merged after the default pack; the content hash invalidates the fact cache
174
- // so changing rules rebuilds anchors only.
175
- export const loadRepoRules = (root: string): { pack: AnchorRule[]; sha: string } => {
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
- return { pack: [...DEFAULT_PACK, ...rules], sha: createHash("sha1").update(raw).digest("hex") };
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 = 4; // bump when extractor semantics change
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
- return !isBinaryExt(f) && (ext in LANG_BY_EXT || isConfigFile(f));
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: anchorPack, sha: rulesSha } = loadRepoRules(root);
105
- const rulesChanged = cached !== undefined && cached.rulesSha !== rulesSha;
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 = rulesChanged
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 (rulesChanged) facts[rel] && (facts[rel]!.anchors = []);
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
- 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 });
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, lang: "anchor",
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
- const w = 1 / Math.sqrt(sites.length);
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 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);
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
- const current = new Set(state.graph.anchors.map((a) => a.id));
69
- const added = [...current].filter((id) => !prev.anchors.has(id));
70
- const removed = [...prev.anchors].filter((id) => !current.has(id));
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
@@ -48,6 +48,7 @@ export interface Anchor {
48
48
  nodeId: string; // handler symbol node id, or enclosing node
49
49
  file: string;
50
50
  line: number;
51
+ implicit?: boolean; // tier-3 discovered shape: half hub gravity, shown with △
51
52
  }
52
53
 
53
54
  export interface Graph {