pi-fovea 0.1.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/LICENSE +21 -0
- package/README.md +159 -0
- package/cli.ts +82 -0
- package/package.json +34 -0
- package/src/core/anchors.ts +159 -0
- package/src/core/astgrep.ts +156 -0
- package/src/core/basins.ts +96 -0
- package/src/core/build.ts +417 -0
- package/src/core/cochange.ts +104 -0
- package/src/core/config.ts +152 -0
- package/src/core/extract.ts +348 -0
- package/src/core/heat.ts +194 -0
- package/src/core/join.ts +116 -0
- package/src/core/ops.ts +410 -0
- package/src/core/render.ts +166 -0
- package/src/core/session.ts +47 -0
- package/src/core/sync.ts +116 -0
- package/src/core/types.ts +73 -0
- package/src/index.ts +197 -0
- package/src/ui/settings.ts +269 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-fovea contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ๐๏ธ pi-fovea
|
|
4
|
+
|
|
5
|
+
**A foveated repo-mapping extension for [Pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_Survey, focus, dwell, impact โ a budget-capped field of view instead of a folder dump._
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<img src="https://raw.githubusercontent.com/monotykamary/pi-fovea/main/media/cover.svg" alt="pi-fovea: a code graph seen through a fovea โ hot at the center, collapsed at the rim" width="1100">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
[](https://www.npmjs.com/package/pi-fovea)
|
|
14
|
+
[](https://github.com/monotykamary/pi-fovea/actions/workflows/test.yml)
|
|
15
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
Large models have small context. pi-fovea turns a repository into a **heat field** over a cross-language code graph โ symbols, files, route anchors โ and hands the model exactly `maxTokens` of it at a time, sharp where you look and whole-picture-but-cheap everywhere else. After every edit it silently re-syncs, and only speaks up when the change didn't stay local.
|
|
23
|
+
|
|
24
|
+
## Why Fovea?
|
|
25
|
+
|
|
26
|
+
| | Capability | What it unlocks |
|
|
27
|
+
| :-: | ---------- | --------------- |
|
|
28
|
+
| ๐ญ | **Survey** | `fovea_sketch` renders the whole repo as a low-acuity silhouette โ feature anchors and basins by mass, never raw file lists. |
|
|
29
|
+
| ๐ฏ | **Focus** | `fovea_focus` centers on a symbol, route, or env key: hot nodes as signatures, warm nodes as one-liners, periphery collapsed. |
|
|
30
|
+
| โฑ๏ธ | **Dwell** | `fovea_dwell` diffuses longer and returns only the delta. Chebyshev vectors are cached โ a new timescale is coefficient recombination, not a re-walk. |
|
|
31
|
+
| ๐ก๏ธ | **Impact** | `fovea_impact` predicts the co-change cascade across languages โ what a file, symbol, or PR base warms up. |
|
|
32
|
+
| ๐ฉธ | **Turn sync** | After every edit turn the graph re-syncs for free. Anchor shifts and unwatched warmings surface as red flags; stable turns stay silent. |
|
|
33
|
+
| ๐ช | **Token truth** | Budgets are hard caps, not hopes: the renderer fits a monotonic prefix and never exceeds `maxTokens`. |
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
The repo compiles to a typed graph whose edges carry **conductance**: imports are bridges, calls are solid, and shared literals โ route paths, env keys โ are the cross-language welds, weighted by specificity. Your interest is a heat source `s`; the map the model receives is the heat kernel run for time `t` over the graph Laplacian:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
v(t) = e^{โtL} ยท s
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **sketch** โ large `t`, hub + anchor seeds: the whole repo in one blurry-but-valid silhouette.
|
|
44
|
+
- **focus** โ small `t`, your query as the seed: the fovea on exactly that feature.
|
|
45
|
+
- **dwell** โ `t` ร2 per call; only newly-luminous nodes are returned.
|
|
46
|
+
- **impact** โ changed files as the seed; warmth = predicted blast radius.
|
|
47
|
+
|
|
48
|
+
Lineage: spectral-graph heat kernels (SGWT evaluated by shared Chebyshev recurrence), progressive image coding (budget as bitrate over significance-sorted coefficients), foveated rendering. Nanobridge: aider's PageRank repo map is the fixed-timescale special case.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Requires Node.js 20+ and [ast-grep](https://ast-grep.github.io/) on PATH (`brew install ast-grep`, `npm i -g @ast-grep/cli`, or set `FOVEA_AST_GREP=/path/to/sg`).
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pi install npm:pi-fovea
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
<details>
|
|
59
|
+
<summary>Other install methods</summary>
|
|
60
|
+
|
|
61
|
+
From GitHub:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
pi install git:github.com/monotykamary/pi-fovea
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
From a local checkout:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pnpm install
|
|
71
|
+
pi install /absolute/path/to/pi-fovea
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
</details>
|
|
75
|
+
|
|
76
|
+
Then, in any repo session, the model gets the four `fovea_*` tools; you get:
|
|
77
|
+
|
|
78
|
+
- `/fovea status` โ graph stats, sync on/off
|
|
79
|
+
- `/fovea settings` โ an overlay built from the same SettingsList idiom as pi-fabric's `/fabric settings`
|
|
80
|
+
|
|
81
|
+
### CLI
|
|
82
|
+
|
|
83
|
+
The same ops, stateless and pipe-friendly โ for agent shells, CI, and `llmc`-style uses:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
fovea sketch /path/to/repo 900
|
|
87
|
+
fovea focus /path/to/repo "/v1/messages" 800
|
|
88
|
+
fovea impact /path/to/repo --base main 1200
|
|
89
|
+
fovea status /path/to/repo
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
(`fovea` bins to `cli.ts` via `tsx`; install `tsx` globally or use `pnpm fovea` from a checkout.)
|
|
93
|
+
|
|
94
|
+
## Turn-sync (default on)
|
|
95
|
+
|
|
96
|
+
After every assistant turn, fovea re-syncs the graph โ guaranteed incremental by content hash โ no edits means zero work. The verdict:
|
|
97
|
+
|
|
98
|
+
- **green** โ silent in the model's context (a UI toast only if `sync.ackClean` is on).
|
|
99
|
+
- **red** โ a capped custom message: route anchors that appeared/disappeared, plus files the edit cascade warmed that the model hasn't focused yet.
|
|
100
|
+
|
|
101
|
+
The first sync establishes the baseline; the first drift after it calibrates the warm neighborhood rather than alarming, so a steady feature cone doesn't page the model every edit.
|
|
102
|
+
|
|
103
|
+
Opt out per-repo or globally: `/fovea settings` โ "Turn sync โ false", or
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
FOVEA_TURN_SYNC=off pi
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
Global `~/.pi/agent/fovea.json`; project override `<repo>/.pi/fovea.json` when trusted โ the same two-scope model as pi-fabric's `fabric.json`.
|
|
112
|
+
|
|
113
|
+
| Key | Default | Meaning |
|
|
114
|
+
| --- | :-----: | ------- |
|
|
115
|
+
| `sync.enabled` | `true` | the turn-sync loop |
|
|
116
|
+
| `sync.budget` | `1024` | tokens for the red model-visible report |
|
|
117
|
+
| `sync.ackClean` | `false` | toast on clean structural turns (no model tokens either way) |
|
|
118
|
+
| `sync.warmFileThreshold` | `2` | newly-warm undisclosed files that justify red |
|
|
119
|
+
| `tools.defaultBudget` | `2000` | fallback maxTokens for fovea_* tool calls |
|
|
120
|
+
|
|
121
|
+
## Repo rule packs
|
|
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):
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"rules": [
|
|
128
|
+
{ "id": "fiber", "langs": ["Go"], "pattern": "$R.$M(\"$P\", $$H)", "methods": "^(get|post)$", "kind": "route" }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Changing the rules file invalidates **only** the anchor extraction cache โ green-node reuse one level up.
|
|
134
|
+
|
|
135
|
+
## How the graph is joined
|
|
136
|
+
|
|
137
|
+
- **imports / contains / inherits / tests** โ outline-derived; call edges specificity-tiered, with language builtins and log/test entry points warded off
|
|
138
|
+
- **literal joins** โ route paths, env keys, OpenAPI operation paths; document-frequency-gated cliques so rare literals bridge strongly and ubiquitous ones don't become gravity wells
|
|
139
|
+
- **co-change** โ mined from recent git history (Jaccard-tilted, per-file capped, cached by HEAD), so files that commute together warm each other even without a static edge
|
|
140
|
+
- **feature hubs** โ route declarations and every client call of the same normalized path collapse to ONE anchor node: where client, server, and spec meet
|
|
141
|
+
- **basins** โ where there are no routes at all (CLIs, kernels), sketch infers implicit features as conductance-cut regions around triangle-dense seeds
|
|
142
|
+
|
|
143
|
+
## Language matrix
|
|
144
|
+
|
|
145
|
+
Full symbol + call extraction: **TypeScript/TSX ยท JavaScript ยท Python ยท Go ยท Rust**.
|
|
146
|
+
Outline-based symbols with heuristic naming: **Elixir ยท Ruby ยท C ยท C++ ยท Java ยท Kotlin ยท Lua ยท PHP ยท Swift ยท Scala ยท Haskell ยท Bash**.
|
|
147
|
+
Config joins through literals: **YAML ยท JSON ยท TOML ยท env ยท Markdown ยท OpenAPI**.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
pnpm install
|
|
153
|
+
pnpm run check # typecheck + full vitest suite
|
|
154
|
+
pnpm run bench # rateโdistortion bench against ../pi-fabric
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
pi loads the extension straight from `src/` via jiti โ **there is no build step**. Per-repo caches live in `$TMPDIR` (content sha1 per file; only dirty files re-run ast-grep). Bump `CACHE_VERSION` in `src/core/build.ts` when extractor semantics change.
|
|
158
|
+
|
|
159
|
+
[MIT](LICENSE).
|
package/cli.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
// pi-fovea CLI โ the extension without pi, for agent shells and pipes:
|
|
3
|
+
// fovea status [root]
|
|
4
|
+
// fovea sketch [root] [budget]
|
|
5
|
+
// fovea focus [root] <query> [budget]
|
|
6
|
+
// fovea dwell [root] [factor] [budget] (deepens the in-process focus)
|
|
7
|
+
// fovea impact [root] [--files a,b] [--symbols x,y] [--base ref] [--no-uncommitted] [budget]
|
|
8
|
+
//
|
|
9
|
+
// The CLI is stateless across invocations (dwell needs a prior focus in the
|
|
10
|
+
// same process โ combine ops inside pi, where sessions persist); stdout is
|
|
11
|
+
// the rendered field, nothing else, so it composes with head/grep/$().
|
|
12
|
+
|
|
13
|
+
import { sketch, focus, dwell, impact } from "./src/core/ops.js";
|
|
14
|
+
|
|
15
|
+
const [, , cmd = "status", ...argv] = process.argv;
|
|
16
|
+
|
|
17
|
+
const VALUE_FLAGS = new Set(["files", "symbols", "base"]);
|
|
18
|
+
const flags = new Map<string, string | true>();
|
|
19
|
+
const pos: string[] = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i]!;
|
|
22
|
+
if (a.startsWith("--")) {
|
|
23
|
+
const name = a.slice(2);
|
|
24
|
+
if (VALUE_FLAGS.has(name)) flags.set(name, argv[++i] ?? "");
|
|
25
|
+
else flags.set(name, true);
|
|
26
|
+
} else {
|
|
27
|
+
pos.push(a);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const str = (name: string): string | undefined => {
|
|
31
|
+
const v = flags.get(name);
|
|
32
|
+
return typeof v === "string" ? v : undefined;
|
|
33
|
+
};
|
|
34
|
+
const numAt = (i: number): number | undefined => {
|
|
35
|
+
const n = Number(pos[i]);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
37
|
+
};
|
|
38
|
+
// Root is the first positional that names a path; everything else is arg data.
|
|
39
|
+
const rootAt = (i: number): string => {
|
|
40
|
+
const p = pos[i];
|
|
41
|
+
return p !== undefined && (p.includes("/") || p === ".") ? p : ".";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
let out = "";
|
|
46
|
+
if (cmd === "status") {
|
|
47
|
+
const s = sketch(rootAt(0), 256);
|
|
48
|
+
out = `${s.details.files} files, ${s.details.nodes} nodes, ${s.details.anchors} anchors`;
|
|
49
|
+
} else if (cmd === "sketch") {
|
|
50
|
+
const root = rootAt(0);
|
|
51
|
+
const B = numAt(pos[0] === root && pos.length > 1 ? 1 : 0) ?? 1400;
|
|
52
|
+
out = sketch(root, B).text;
|
|
53
|
+
} else if (cmd === "focus") {
|
|
54
|
+
const root = rootAt(0);
|
|
55
|
+
const q = pos[0] !== root ? pos[0] : pos[1];
|
|
56
|
+
if (!q) { console.error("fovea focus <query>"); process.exit(2); }
|
|
57
|
+
let bi = pos.indexOf(q) + 1;
|
|
58
|
+
out = focus(root, q, numAt(bi) ?? 2000).text;
|
|
59
|
+
} else if (cmd === "dwell") {
|
|
60
|
+
const root = rootAt(0);
|
|
61
|
+
const factor = numAt(pos[0] === root ? 1 : 0) ?? 2;
|
|
62
|
+
const B = numAt(pos[0] === root ? 2 : 1) ?? 2000;
|
|
63
|
+
out = dwell(root, factor, B).text;
|
|
64
|
+
} else if (cmd === "impact") {
|
|
65
|
+
const root = rootAt(0);
|
|
66
|
+
const B = pos[0] === root ? numAt(1) : numAt(0);
|
|
67
|
+
out = impact(root, {
|
|
68
|
+
files: str("files")?.split(",").filter(Boolean),
|
|
69
|
+
symbols: str("symbols")?.split(",").filter(Boolean),
|
|
70
|
+
base: str("base"),
|
|
71
|
+
includeUncommitted: !flags.has("no-uncommitted"),
|
|
72
|
+
budget: B ?? 2000,
|
|
73
|
+
}).text;
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`unknown command: ${cmd}`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
console.log(out);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(String(e instanceof Error ? e.message : e));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-fovea",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": { "node": ">=20" },
|
|
8
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
9
|
+
"exports": { "./ops": "./src/core/ops.ts" },
|
|
10
|
+
"files": ["src", "cli.ts", "README.md", "LICENSE"],
|
|
11
|
+
"bin": { "fovea": "cli.ts" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"bench": "tsx scripts/bench.ts",
|
|
16
|
+
"fovea": "tsx cli.ts",
|
|
17
|
+
"check": "pnpm run typecheck && pnpm run test"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"typebox": "^1.0.76"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
24
|
+
"@earendil-works/pi-tui": "0.83.0",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": ">=0.80.6",
|
|
32
|
+
"@earendil-works/pi-tui": ">=0.80.6"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Feature anchors: where a feature touches the outside world. Anchors are
|
|
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).
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join as joinPath } from "node:path";
|
|
10
|
+
import { groupByLang, patternRun } from "./astgrep.js";
|
|
11
|
+
import { PATH_TOKEN_RE } from "./extract.js";
|
|
12
|
+
import { classifyLiteral, normalizeLiteral } from "./join.js";
|
|
13
|
+
import type { Anchor } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export interface AnchorRule {
|
|
16
|
+
id: string;
|
|
17
|
+
langs: string[];
|
|
18
|
+
pattern: string;
|
|
19
|
+
methods: string; // regex tested against the captured method metavar
|
|
20
|
+
kind: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/;
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_PACK: AnchorRule[] = [
|
|
26
|
+
{
|
|
27
|
+
id: "http-route-call",
|
|
28
|
+
langs: ["TypeScript", "Tsx", "JavaScript", "Go"],
|
|
29
|
+
pattern: '$R.$M("$P", $$$H)',
|
|
30
|
+
methods: "^(?i:get|post|put|delete|patch|head|options|all|use|any|handle|handlefunc|route|group)$",
|
|
31
|
+
kind: "route",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
// Single-arg verb call: axios.get("/me") โ client call sites only become
|
|
35
|
+
// feature hubs when they reference a real path (validated below).
|
|
36
|
+
id: "http-verb-single-arg",
|
|
37
|
+
langs: ["TypeScript", "Tsx", "JavaScript"],
|
|
38
|
+
pattern: '$R.$M("$P")',
|
|
39
|
+
methods: "^(?i:get|post|put|delete|patch)$",
|
|
40
|
+
kind: "route",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "http-route-call-singlequote",
|
|
44
|
+
langs: ["TypeScript", "Tsx", "JavaScript"],
|
|
45
|
+
pattern: "$R.$M('$P', $$$H)",
|
|
46
|
+
methods: "^(?i:get|post|put|delete|patch|all|use|handle|route)$",
|
|
47
|
+
kind: "route",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// NestJS / decorator shape: @Get("/users/:id") on a controller method.
|
|
51
|
+
id: "ts-http-decorator",
|
|
52
|
+
langs: ["TypeScript", "Tsx"],
|
|
53
|
+
pattern: '@$M("$P")',
|
|
54
|
+
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
55
|
+
kind: "route",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "python-decorator-route",
|
|
59
|
+
langs: ["Python"],
|
|
60
|
+
pattern: '@$R.$M("$P")',
|
|
61
|
+
methods: "^(get|post|put|delete|patch|route|websocket)$",
|
|
62
|
+
kind: "route",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "python-decorator-route-singlequote",
|
|
66
|
+
langs: ["Python"],
|
|
67
|
+
pattern: "@$R.$M('$P')",
|
|
68
|
+
methods: "^(get|post|put|delete|patch|route|websocket)$",
|
|
69
|
+
kind: "route",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "flask-add-url-rule",
|
|
73
|
+
langs: ["Python"],
|
|
74
|
+
pattern: '$R.add_url_rule("$P", $$$H)',
|
|
75
|
+
methods: "^add_url_rule$",
|
|
76
|
+
kind: "route",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "rust-router-chain",
|
|
80
|
+
langs: ["Rust"],
|
|
81
|
+
pattern: '$R.route("$P", $$$H)',
|
|
82
|
+
methods: "^route$",
|
|
83
|
+
kind: "route",
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export interface AnchorDraft extends Anchor {}
|
|
88
|
+
|
|
89
|
+
export const extractAnchors = (
|
|
90
|
+
files: string[],
|
|
91
|
+
cwd: string,
|
|
92
|
+
resolveEnclosing: (file: string, line: number) => string | undefined,
|
|
93
|
+
pack: AnchorRule[] = DEFAULT_PACK,
|
|
94
|
+
): AnchorDraft[] => {
|
|
95
|
+
const byLang = groupByLang(files);
|
|
96
|
+
const out: AnchorDraft[] = [];
|
|
97
|
+
for (const rule of pack) {
|
|
98
|
+
const methodRe = new RegExp(rule.methods);
|
|
99
|
+
for (const lang of rule.langs) {
|
|
100
|
+
const langFiles = byLang.get(lang);
|
|
101
|
+
if (!langFiles?.length) continue;
|
|
102
|
+
for (const m of patternRun(rule.pattern, lang, langFiles, cwd)) {
|
|
103
|
+
const method = m.single.M;
|
|
104
|
+
const path = m.single.P;
|
|
105
|
+
if (!method || !path || !methodRe.test(method)) continue;
|
|
106
|
+
const raw = path.trim();
|
|
107
|
+
// $R.$M(...) also matches Map.get("key")-style data access; only real
|
|
108
|
+
// paths (or router-relative placeholders like ":id") may anchor.
|
|
109
|
+
if (!PATH_TOKEN_RE.test(raw) && !PLACEHOLDER_ONLY.test(raw)) continue;
|
|
110
|
+
const norm = normalizeLiteral(path, "path");
|
|
111
|
+
const httpMethod = method.toUpperCase() === "ROUTE" || method.toLowerCase() === "route" || method.toLowerCase() === "use" || method.toLowerCase() === "any"
|
|
112
|
+
? method.toUpperCase()
|
|
113
|
+
: method.toUpperCase();
|
|
114
|
+
const label = `${httpMethod} ${norm}`;
|
|
115
|
+
const enclosing = resolveEnclosing(m.file, m.line);
|
|
116
|
+
out.push({
|
|
117
|
+
id: label,
|
|
118
|
+
kind: rule.kind,
|
|
119
|
+
label,
|
|
120
|
+
nodeId: enclosing ?? `file:${m.file}`,
|
|
121
|
+
file: m.file,
|
|
122
|
+
line: m.line,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Dedupe identical anchors at the same site (overlapping rules).
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
return out.filter((a) => {
|
|
130
|
+
const k = `${a.id}|${a.file}|${a.line}`;
|
|
131
|
+
if (seen.has(k)) return false;
|
|
132
|
+
seen.add(k);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Re-export so callers can classify an anchor path like any literal.
|
|
138
|
+
export const anchorClassify = classifyLiteral;
|
|
139
|
+
|
|
140
|
+
// Repo-local overrides: .fovea/rules.json = { "rules": AnchorRule[] }.
|
|
141
|
+
// Merged after the default pack; the content hash invalidates the fact cache
|
|
142
|
+
// so changing rules rebuilds anchors only.
|
|
143
|
+
export const loadRepoRules = (root: string): { pack: AnchorRule[]; sha: string } => {
|
|
144
|
+
let raw = "";
|
|
145
|
+
try {
|
|
146
|
+
raw = readFileSync(joinPath(root, ".fovea", "rules.json"), "utf8");
|
|
147
|
+
} catch {
|
|
148
|
+
return { pack: DEFAULT_PACK, sha: "" };
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(raw) as { rules?: AnchorRule[] };
|
|
152
|
+
const rules = (parsed.rules ?? []).filter(
|
|
153
|
+
(r) => r && typeof r.pattern === "string" && typeof r.methods === "string" && Array.isArray(r.langs),
|
|
154
|
+
);
|
|
155
|
+
return { pack: [...DEFAULT_PACK, ...rules], sha: createHash("sha1").update(raw).digest("hex") };
|
|
156
|
+
} catch {
|
|
157
|
+
return { pack: DEFAULT_PACK, sha: createHash("sha1").update(raw).digest("hex") };
|
|
158
|
+
}
|
|
159
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Thin runner over the ast-grep CLI. All extraction goes through here.
|
|
2
|
+
// Resolution: FOVEA_AST_GREP env var, then `ast-grep` on PATH.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const LANG_BY_EXT: Record<string, string> = {
|
|
7
|
+
ts: "TypeScript", tsx: "Tsx", mts: "TypeScript", cts: "TypeScript",
|
|
8
|
+
js: "JavaScript", jsx: "Tsx", mjs: "JavaScript", cjs: "JavaScript",
|
|
9
|
+
py: "Python",
|
|
10
|
+
go: "Go",
|
|
11
|
+
rs: "Rust",
|
|
12
|
+
// Second tier: symbols via ast-grep outline; name derivation is heuristic.
|
|
13
|
+
ex: "Elixir", exs: "Elixir",
|
|
14
|
+
rb: "Ruby",
|
|
15
|
+
c: "C", h: "C",
|
|
16
|
+
cc: "C++", cpp: "C++", cxx: "C++", hpp: "C++", hh: "C++",
|
|
17
|
+
java: "Java",
|
|
18
|
+
kt: "Kotlin", kts: "Kotlin",
|
|
19
|
+
lua: "Lua",
|
|
20
|
+
php: "Php",
|
|
21
|
+
swift: "Swift",
|
|
22
|
+
scala: "Scala",
|
|
23
|
+
hs: "Haskell",
|
|
24
|
+
sh: "Bash",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Compiled artifacts masquerading as source extensions.
|
|
28
|
+
const BINARY_EXTS = new Set(["beam", "pyc", "o", "obj", "so", "a", "d"]);
|
|
29
|
+
export const isBinaryExt = (file: string): boolean =>
|
|
30
|
+
BINARY_EXTS.has(file.split(".").pop()?.toLowerCase() ?? "");
|
|
31
|
+
|
|
32
|
+
// Non-code files: literals are regex-extracted so config/spec files can join.
|
|
33
|
+
export const CONFIG_EXTS = new Set(["yaml", "yml", "json", "toml", "env", "tf", "hcl", "md"]);
|
|
34
|
+
|
|
35
|
+
export interface AgMatch {
|
|
36
|
+
file: string; // as passed to ast-grep (repo-relative)
|
|
37
|
+
line: number; // 1-indexed
|
|
38
|
+
text: string; // full matched node text
|
|
39
|
+
single: Record<string, string>; // $VAR -> text (single metavars)
|
|
40
|
+
multi: Record<string, string[]>; // $$$VAR -> texts
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const binary = (): string => process.env.FOVEA_AST_GREP ?? "ast-grep";
|
|
44
|
+
|
|
45
|
+
export const hasAstGrep = (): boolean => {
|
|
46
|
+
const r = spawnSync(binary(), ["--version"], { encoding: "utf8" });
|
|
47
|
+
return !r.error && r.status === 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CHUNK = 160;
|
|
51
|
+
|
|
52
|
+
const run = (args: string[], cwd: string): string => {
|
|
53
|
+
const res = spawnSync(binary(), args, {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
timeout: 120_000,
|
|
57
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
58
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
59
|
+
});
|
|
60
|
+
if (res.error || res.status !== 0) return "";
|
|
61
|
+
return res.stdout ?? "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const langOf = (file: string): string | undefined => {
|
|
65
|
+
const ext = file.split(".").pop()?.toLowerCase() ?? "";
|
|
66
|
+
return LANG_BY_EXT[ext];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const isConfigFile = (file: string): boolean => {
|
|
70
|
+
const ext = file.split(".").pop()?.toLowerCase() ?? "";
|
|
71
|
+
return CONFIG_EXTS.has(ext);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const groupByLang = (files: string[]): Map<string, string[]> => {
|
|
75
|
+
const m = new Map<string, string[]>();
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
const lang = langOf(f);
|
|
78
|
+
if (!lang) continue;
|
|
79
|
+
const arr = m.get(lang) ?? [];
|
|
80
|
+
arr.push(f);
|
|
81
|
+
m.set(lang, arr);
|
|
82
|
+
}
|
|
83
|
+
return m;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// `ast-grep outline` โ uniform symbol source across languages. Text format:
|
|
87
|
+
// <file>
|
|
88
|
+
// <line>: <signature source line>
|
|
89
|
+
// <kind>: <name> (children: methods/fields, indented)
|
|
90
|
+
export const outline = (files: string[], lang: string, cwd: string): string => {
|
|
91
|
+
let out = "";
|
|
92
|
+
for (let i = 0; i < files.length; i += CHUNK) {
|
|
93
|
+
out += run(["outline", ...files.slice(i, i + CHUNK)], cwd);
|
|
94
|
+
}
|
|
95
|
+
void lang;
|
|
96
|
+
return out;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
interface RawMatch {
|
|
100
|
+
text: string;
|
|
101
|
+
range: { start: { line: number; column: number } };
|
|
102
|
+
file: string;
|
|
103
|
+
metaVariables?: {
|
|
104
|
+
single?: Record<string, { text: string }>;
|
|
105
|
+
multi?: Record<string, Array<{ text: string }>>;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `ast-grep run --pattern` with JSON output for a set of files of one language.
|
|
110
|
+
export const patternRun = (
|
|
111
|
+
pattern: string,
|
|
112
|
+
lang: string,
|
|
113
|
+
files: string[],
|
|
114
|
+
cwd: string,
|
|
115
|
+
): AgMatch[] => {
|
|
116
|
+
const out: AgMatch[] = [];
|
|
117
|
+
for (let i = 0; i < files.length; i += CHUNK) {
|
|
118
|
+
const stdout = run(
|
|
119
|
+
["run", "--pattern", pattern, "--lang", lang, "--json=compact", ...files.slice(i, i + CHUNK)],
|
|
120
|
+
cwd,
|
|
121
|
+
);
|
|
122
|
+
if (!stdout.trim()) continue;
|
|
123
|
+
let parsed: RawMatch[];
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(stdout) as RawMatch[];
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(parsed)) continue;
|
|
130
|
+
for (const m of parsed) {
|
|
131
|
+
const single: Record<string, string> = {};
|
|
132
|
+
const multi: Record<string, string[]> = {};
|
|
133
|
+
for (const [k, v] of Object.entries(m.metaVariables?.single ?? {})) single[k] = v.text;
|
|
134
|
+
for (const [k, v] of Object.entries(m.metaVariables?.multi ?? {})) multi[k] = v.map((x) => x.text);
|
|
135
|
+
out.push({ file: m.file, line: m.range.start.line + 1, text: m.text, single, multi });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// First match of any of the patterns, per language/file set, concatenated.
|
|
142
|
+
// Spread-pushing big match arrays overflows the argument-list limit, so
|
|
143
|
+
// concatenate manually.
|
|
144
|
+
const pushAll = <T>(out: T[], more: T[]): void => { for (const x of more) out.push(x); };
|
|
145
|
+
|
|
146
|
+
// First match of any of the patterns, per language/file set, concatenated.
|
|
147
|
+
export const patternRunAll = (
|
|
148
|
+
patterns: string[],
|
|
149
|
+
lang: string,
|
|
150
|
+
files: string[],
|
|
151
|
+
cwd: string,
|
|
152
|
+
): AgMatch[] => {
|
|
153
|
+
const out: AgMatch[] = [];
|
|
154
|
+
for (const p of patterns) pushAll(out, patternRun(p, lang, files, cwd));
|
|
155
|
+
return out;
|
|
156
|
+
};
|