pi-fovea 0.3.3 → 0.4.1
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 +29 -21
- package/cli.ts +3 -1
- package/package.json +1 -1
- package/skills/pi-fovea/SKILL.md +8 -8
- package/src/core/astgrep.ts +46 -4
- package/src/core/basins.ts +8 -3
- package/src/core/build.ts +1 -1
- package/src/core/config.ts +3 -3
- package/src/core/extract.ts +112 -2
- package/src/core/ops.ts +368 -40
- package/src/core/render.ts +152 -19
- package/src/core/session.ts +21 -7
- package/src/core/sync.ts +92 -41
- package/src/core/types.ts +3 -1
- package/src/index.ts +194 -62
- package/src/ui/settings.ts +10 -10
package/README.md
CHANGED
|
@@ -17,21 +17,23 @@ _See the whole repo on every prompt, sharp where you work and cheap everywhere e
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
pi-fovea hands the model a map of your repo on every prompt. The repo compiles once into a code graph across languages, where symbols, files, and route anchors join into one network. Each question becomes an interest vector that diffuses over the graph as heat. The renderer converts the field into a token-capped view: full signatures near your task, one-
|
|
20
|
+
pi-fovea hands the model a map of your repo on every prompt. The repo compiles once into a code graph across languages, where symbols, files, and route anchors join into one network. Each question becomes an interest vector that diffuses over the graph as heat. The renderer converts the field into a token-capped view: exact source locations and full signatures near your task, typed one-hop relationships next, and a skeleton of the rest.
|
|
21
21
|
|
|
22
|
-
After each assistant turn
|
|
22
|
+
At agent start Fovea establishes or checks its semantic baseline, so out-of-band edits made while Pi was idle enter context before the first model call. After each assistant turn it re-syncs again. Detection does not trust tool events: edits made by Pi tools, fabric_exec, bash, subagents, or an editor land identically, while comment- and formatting-only drift stays silent. A meaningful post-turn change is delivered as a **steer**, and Fovea triggers the continuation itself if the agent would otherwise wait.
|
|
23
23
|
|
|
24
24
|
## What the model gets
|
|
25
25
|
|
|
26
26
|
| Command | Ask | Answer |
|
|
27
27
|
|---|---|---|
|
|
28
|
-
| `fovea_sketch` | where is everything? |
|
|
29
|
-
| `fovea_focus` | what is this? |
|
|
30
|
-
| `fovea_dwell` | what else? |
|
|
28
|
+
| `fovea_sketch` | where is everything? | production-first silhouette; test and fixture architecture stays collapsed |
|
|
29
|
+
| `fovea_focus` | what is this? | exact matches, typed relationships, suggested reads, optional source scopes, and deterministic `fresh` views |
|
|
30
|
+
| `fovea_dwell` | what else? | widens the current focus and returns newly relevant neighbors |
|
|
31
31
|
| `fovea_impact` | what does this touch? | warms everything a file, symbol, or PR base reaches across languages |
|
|
32
|
-
| `grep` *(default
|
|
32
|
+
| `grep` *(default hybrid)* | graph or text? | bare identifiers, qualified symbols, repo paths, and routes use Fovea; search options and obvious regex retain native grep |
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Focus normalizes camelCase and common inflections, so an approximate name such as `switchServer` can resolve `switchingServers`. If a query is still uncertain, Fovea returns nearby symbols with locations instead of a dead miss. Direct graph edges are labeled (caller, callee, route, shared literal, co-change), while unrelated same-file siblings remain collapsed.
|
|
35
|
+
|
|
36
|
+
The **Hybrid grep** toggle is on by default. `grep({ pattern: "CreateUser" })`, `grep({ pattern: "Controller.create" })`, and route paths can navigate the graph. Calls with text-search options and obvious regexes delegate to Pi's native grep unchanged; a graph miss also falls back to native text. Disable the toggle for a purely native slot. Changing it reloads extensions so Pi and pi-fabric capture the same behavior.
|
|
35
37
|
|
|
36
38
|
### pi-fabric
|
|
37
39
|
|
|
@@ -52,10 +54,12 @@ return tools.call({ ref: action.ref, args: { query: "CreateUserHandler" } });
|
|
|
52
54
|
|
|
53
55
|
The stable explicit ref is `extensions.fovea_focus`, not bare `fovea_focus` or `fovea.fovea_focus`.
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
Runtime slash controls:
|
|
56
58
|
|
|
57
|
-
- `/fovea status` for graph
|
|
58
|
-
- `/fovea settings` for
|
|
59
|
+
- `/fovea status` for loaded versions, graph coverage, and active modes
|
|
60
|
+
- `/fovea settings` for a TUI configuration overlay
|
|
61
|
+
- `/fovea reset` for a fresh focus and sync baseline
|
|
62
|
+
- `/fovea reload` to activate updated extension source
|
|
59
63
|
|
|
60
64
|
## Install
|
|
61
65
|
|
|
@@ -97,14 +101,18 @@ fovea status /path/to/repo
|
|
|
97
101
|
|
|
98
102
|
## Turn sync
|
|
99
103
|
|
|
100
|
-
|
|
104
|
+
Continuous sync is on by default. Before an agent starts, Fovea establishes its baseline or injects any out-of-band drift before the first model call. After every assistant turn it compares extracted symbols, calls, imports, literals, and anchors again. Content hashes keep the unchanged fast path cheap, while comment- and formatting-only edits do not wake the model.
|
|
105
|
+
|
|
106
|
+
A meaningful change found before agent start is injected directly into that run. A post-turn route or dependency change is sent with `deliverAs: "steer"`; if the agent would otherwise settle, `triggerTurn` starts the continuation automatically. The compact update names directly changed files, route deltas, newly relevant files, and causal channels such as calls, imports, shared literals, tests, or co-change history. Clean turns remain silent unless `sync.ackClean` is enabled.
|
|
101
107
|
|
|
102
|
-
|
|
103
|
-
- **red**: a capped custom message naming route anchors that appeared or disappeared, plus files warmed by the edit cascade that the model has not focused on yet.
|
|
108
|
+
Runtime controls:
|
|
104
109
|
|
|
105
|
-
|
|
110
|
+
- `/fovea status` — loaded package/ast-grep versions, indexed coverage, anchor scopes, sync and grep modes.
|
|
111
|
+
- `/fovea reset` — clear focus disclosure/depth and establish a fresh sync baseline.
|
|
112
|
+
- `/fovea reload` — hot-reload extensions and activate newly installed source.
|
|
113
|
+
- `/fovea settings` — configure sync, budgets, and hybrid grep.
|
|
106
114
|
|
|
107
|
-
Turn
|
|
115
|
+
Turn sync off per repo or globally through settings, or with:
|
|
108
116
|
|
|
109
117
|
```sh
|
|
110
118
|
FOVEA_TURN_SYNC=off pi
|
|
@@ -116,12 +124,12 @@ Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
|
|
|
116
124
|
|
|
117
125
|
| Key | Default | Meaning |
|
|
118
126
|
| --- | :-----: | ------- |
|
|
119
|
-
| `sync.enabled` | `true` |
|
|
120
|
-
| `sync.budget` | `1024` | token cap for
|
|
127
|
+
| `sync.enabled` | `true` | pre-agent and post-turn continuous sync |
|
|
128
|
+
| `sync.budget` | `1024` | token cap for proactive steering context |
|
|
121
129
|
| `sync.ackClean` | `false` | toast after clean structural turns |
|
|
122
|
-
| `sync.warmFileThreshold` | `2` |
|
|
130
|
+
| `sync.warmFileThreshold` | `2` | newly relevant files that justify proactive model steering |
|
|
123
131
|
| `tools.defaultBudget` | `2000` | fallback maxTokens for the fovea_* tools |
|
|
124
|
-
| `tools.replaceGrep` | `true` |
|
|
132
|
+
| `tools.replaceGrep` | `true` | install hybrid native-text / bare-query graph grep |
|
|
125
133
|
|
|
126
134
|
## How routes are found
|
|
127
135
|
|
|
@@ -170,7 +178,7 @@ $$
|
|
|
170
178
|
v(t) = e^{-tL} \cdot s \quad \text{with} \quad L = I - D^{-1/2} W D^{-1/2}
|
|
171
179
|
$$
|
|
172
180
|
|
|
173
|
-
The four tools are the same operator at four timescales: sketch at $t=16$ with hub and anchor seeds, focus at $t=
|
|
181
|
+
The four tools are the same operator at four timescales: sketch at $t=16$ with production hub and anchor seeds, focus at $t=2$ with your query as seed, dwell doubling $t$ within that focus, and impact using changed files as seeds. Changing focus resets to the sharp timescale and its own disclosure scope.
|
|
174
182
|
|
|
175
183
|
The kernel is evaluated with a Chebyshev expansion. Rescale $M = L - I$ so the spectrum sits in $[-1,1]$; then with $T_k$ the Chebyshev polynomials and $I_k$ the modified Bessel functions:
|
|
176
184
|
|
|
@@ -188,7 +196,7 @@ $$
|
|
|
188
196
|
|
|
189
197
|
Measured against eight cloned projects, corpus junk sits below $\hat{p} \approx 0.27$ and real route shapes above $\hat{p} \approx 0.75$. The cutoff stays mid-cliff regardless of repo size.
|
|
190
198
|
|
|
191
|
-
Lineage: spectral-graph wavelets evaluated by shared Chebyshev recurrence, progressive image coding where the budget is a bitrate over significance-ordered coefficients, and foveated rendering. Aider's PageRank repo map is the fixed-timescale special case of this field. The full walkthrough of conductance tiers, specificity bridges, hub gravity, and
|
|
199
|
+
Lineage: spectral-graph wavelets evaluated by shared Chebyshev recurrence, progressive image coding where the budget is a bitrate over significance-ordered coefficients, and foveated rendering. Aider's PageRank repo map is the fixed-timescale special case of this field. The full walkthrough of conductance tiers, specificity bridges, hub gravity, and inferred regions lives in [docs/heat-diffusion.md](docs/heat-diffusion.md).
|
|
192
200
|
|
|
193
201
|
## Languages
|
|
194
202
|
|
package/cli.ts
CHANGED
|
@@ -56,7 +56,9 @@ try {
|
|
|
56
56
|
let out = "";
|
|
57
57
|
if (cmd === "status") {
|
|
58
58
|
const s = sketch(rootAt(0), 256);
|
|
59
|
-
|
|
59
|
+
const testAnchors = Number(s.details.testAnchors ?? 0);
|
|
60
|
+
out = `${s.details.files} files, ${s.details.nodes} symbols, ${s.details.productionAnchors ?? s.details.anchors} production anchors` +
|
|
61
|
+
(testAnchors ? `, ${testAnchors} test/fixture anchors collapsed` : "");
|
|
60
62
|
} else if (cmd === "sketch") {
|
|
61
63
|
const root = rootAt(0);
|
|
62
64
|
const B = numAt(pos[0] === root && pos.length > 1 ? 1 : 0) ?? 1400;
|
package/package.json
CHANGED
package/skills/pi-fovea/SKILL.md
CHANGED
|
@@ -9,22 +9,22 @@ pi-fovea maintains a cross-language code graph of the working repository — rou
|
|
|
9
9
|
|
|
10
10
|
## The loop
|
|
11
11
|
|
|
12
|
-
1. **`fovea_sketch`** —
|
|
13
|
-
2. **`fovea_focus` `<query>`** — point at a symbol name
|
|
14
|
-
3. **`fovea_dwell`** — optional second look. If
|
|
15
|
-
4. **`fovea_impact`** — blast radius. Seed with
|
|
12
|
+
1. **`fovea_sketch`** — production-first silhouette. Shipped routes and source regions lead; test and fixture architecture is collapsed. Start here in an unfamiliar repo. ~256–1024 tokens.
|
|
13
|
+
2. **`fovea_focus` `<query>`** — point at a symbol name (close spellings work), route, env key, or file. The active seed and direct relationships always remain visible; previously seen periphery is suppressed only within that focus. A different focus resets to sharp context. Use `path`, `language`, or `kind` to scope output and `fresh: true` for a reproducible full view. Structured details include nodes and suggested read windows.
|
|
14
|
+
3. **`fovea_dwell`** — optional second look. If focus says more results remain, dwell widens only the current focus and returns newly relevant neighbors.
|
|
15
|
+
4. **`fovea_impact`** — blast radius. Seed with repo-relative `files`, symbols, uncommitted changes, or a PR `base`. Output is likely review order with causal channels (calls, imports, literals, routes, tests, inheritance, co-change).
|
|
16
16
|
|
|
17
17
|
All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
|
|
18
18
|
|
|
19
19
|
## Working rules
|
|
20
20
|
|
|
21
|
-
- **
|
|
21
|
+
- **Do not bulk-read to discover structure.** Focus first, then read its suggested ranges. Native grep semantics remain available whenever grep receives path/glob/literal/context/limit options or an obvious regex; unresolved graph queries fall back to native text.
|
|
22
22
|
- **Impact before destructive edits.** One `fovea_impact` call is cheaper than rediscovering dependents by breaking them.
|
|
23
23
|
- **Sketch is the safe opening bid.** If unsure, pay for a sketch; it almost never exceeds a few hundred tokens.
|
|
24
24
|
|
|
25
25
|
## Turn sync
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Before an agent starts, pi-fovea establishes its baseline or injects out-of-band semantic drift into that run. After each assistant turn it compares again; meaningful route/dependency drift is delivered as a **steer**, and Fovea triggers a continuation if the agent would otherwise stop. Treat its changed files, route deltas, and causal channels as continuous task context. Comment- and formatting-only edits stay silent.
|
|
28
28
|
|
|
29
29
|
Sync is **mutation-path agnostic**: pi's edit/write tools, a pi-fabric `fabric_exec` program's inner `pi.edit`, a bash heredoc, a subagent, or an editor save outside the session all register identically. Content hashes are the source of truth; tool events are not consulted for detection. In repos with no `.git` directory this is also the only drift signal — do not fall back to `git status` assumptions.
|
|
30
30
|
|
|
@@ -36,7 +36,7 @@ When writing or editing code **inside a `fabric_exec` program**, the fovea tools
|
|
|
36
36
|
- For dynamic discovery, use `const hits = await tools.search({ query: "fovea_focus" })`, then call the returned namespaced ref with `tools.call({ ref: hits[0].ref, args: { query: "CreateUserHandler", maxTokens: 6000 } })`. The stable explicit ref is `extensions.fovea_focus`; bare `fovea_focus` and `fovea.fovea_focus` are invalid.
|
|
37
37
|
- Prefer a single `extensions.fovea_impact(...)` call over hand-rolled grep fan-outs when computing what an edit touches — the graph already resolved imports/calls across Go, TypeScript, Python, and Java.
|
|
38
38
|
- Any file mutation performed by the program (including `pi.edit`/`pi.write` calls inside the sandbox) is picked up by turn sync automatically, so post-edit verification does not need a re-sketch.
|
|
39
|
-
-
|
|
39
|
+
- Sketch `details` carries coverage counts; its compact text names the highest-value entry points. On an unfamiliar repo, fetch it once and reuse it instead of rediscovering entry points per call.
|
|
40
40
|
|
|
41
41
|
## CLI
|
|
42
42
|
|
|
@@ -44,4 +44,4 @@ The same engine runs headlessly as the `fovea` binary (repo root scan, plus JSON
|
|
|
44
44
|
|
|
45
45
|
## Settings
|
|
46
46
|
|
|
47
|
-
`/fovea
|
|
47
|
+
Use `/fovea status` for loaded version and index coverage, `/fovea reset` for fresh state, `/fovea reload` after updates, and `/fovea settings` for configuration. Files live under `~/.pi/agent/fovea.json` or trusted `.pi/fovea.json`. `tools.replaceGrep` installs hybrid grep: native text semantics plus bare-query graph navigation.
|
package/src/core/astgrep.ts
CHANGED
|
@@ -83,10 +83,52 @@ export const groupByLang = (files: string[]): Map<string, string[]> => {
|
|
|
83
83
|
return m;
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
// `ast-grep outline`
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
// `ast-grep outline` is the uniform symbol source across languages.
|
|
87
|
+
// Expanded JSON is primary; the legacy text view remains as a compatibility fallback.
|
|
88
|
+
export interface OutlineRange {
|
|
89
|
+
start: { line: number; column: number };
|
|
90
|
+
end?: { line: number; column: number };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface OutlineSymbol {
|
|
94
|
+
role: "item" | "member";
|
|
95
|
+
symbolType: string;
|
|
96
|
+
name: string;
|
|
97
|
+
range: OutlineRange;
|
|
98
|
+
signature: string;
|
|
99
|
+
astKind?: string;
|
|
100
|
+
members?: OutlineSymbol[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface OutlineFile {
|
|
104
|
+
path: string;
|
|
105
|
+
language: string;
|
|
106
|
+
items: OutlineSymbol[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Expanded JSON preserves each member's own range and signature. Return
|
|
110
|
+
// undefined when the installed ast-grep predates this interface so callers can
|
|
111
|
+
// fall back without presenting parent locations as exact member locations.
|
|
112
|
+
export const outlineStructured = (files: string[], lang: string, cwd: string): OutlineFile[] | undefined => {
|
|
113
|
+
const out: OutlineFile[] = [];
|
|
114
|
+
for (let i = 0; i < files.length; i += CHUNK) {
|
|
115
|
+
const stdout = run(
|
|
116
|
+
["outline", "--json=compact", "--view=expanded", ...files.slice(i, i + CHUNK)],
|
|
117
|
+
cwd,
|
|
118
|
+
);
|
|
119
|
+
if (!stdout.trim()) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(stdout) as OutlineFile[];
|
|
122
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
123
|
+
for (const file of parsed) out.push(file);
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
void lang;
|
|
129
|
+
return out;
|
|
130
|
+
};
|
|
131
|
+
|
|
90
132
|
export const outline = (files: string[], lang: string, cwd: string): string => {
|
|
91
133
|
let out = "";
|
|
92
134
|
for (let i = 0; i < files.length; i += CHUNK) {
|
package/src/core/basins.ts
CHANGED
|
@@ -15,11 +15,14 @@ const MIN_BASIN_SIZE = 4;
|
|
|
15
15
|
|
|
16
16
|
// eligible marks nodes that may seed a basin (symbols, not files/anchors: a
|
|
17
17
|
// file's contains-star has ~zero triangle density and yields useless seeds).
|
|
18
|
+
// include optionally constrains every member, for operation-specific views
|
|
19
|
+
// such as production-first sketching without changing the underlying graph.
|
|
18
20
|
export const detectBasins = (
|
|
19
21
|
adjacency: Map<number, Array<{ to: number; kind: string; w: number }>>,
|
|
20
22
|
conductance: Float64Array,
|
|
21
23
|
n: number,
|
|
22
24
|
eligible?: (i: number) => boolean,
|
|
25
|
+
include?: (i: number) => boolean,
|
|
23
26
|
): Basin[] => {
|
|
24
27
|
// Triangle density: fraction of a node's neighbors that are co-neighbors.
|
|
25
28
|
// Cheap O(deg^2) sampling with degree cap — hubs are star points anyway.
|
|
@@ -60,12 +63,14 @@ export const detectBasins = (
|
|
|
60
63
|
const order: number[] = [seed];
|
|
61
64
|
let internal = 0;
|
|
62
65
|
const boundary = new Map<number, number>();
|
|
63
|
-
for (const e of adjacency.get(seed) ?? [])
|
|
66
|
+
for (const e of adjacency.get(seed) ?? []) {
|
|
67
|
+
if (!include || include(e.to)) boundary.set(e.to, (boundary.get(e.to) ?? 0) + e.w);
|
|
68
|
+
}
|
|
64
69
|
while (order.length < MAX_BASIN_SIZE && boundary.size) {
|
|
65
70
|
let best = -1;
|
|
66
71
|
let bestRatio = -1;
|
|
67
72
|
for (const [j, inW] of boundary) {
|
|
68
|
-
if (claimed.has(j) || members.has(j)) continue;
|
|
73
|
+
if (claimed.has(j) || members.has(j) || (include && !include(j))) continue;
|
|
69
74
|
const total = [...(adjacency.get(j) ?? [])].reduce((s, e) => s + e.w, 0);
|
|
70
75
|
const ratio = total > 0 ? inW / total : 0;
|
|
71
76
|
if (ratio > bestRatio) { bestRatio = ratio; best = j; }
|
|
@@ -79,7 +84,7 @@ export const detectBasins = (
|
|
|
79
84
|
internal += boundary.get(best) ?? 0;
|
|
80
85
|
boundary.delete(best);
|
|
81
86
|
for (const e of adjacency.get(best) ?? []) {
|
|
82
|
-
if (members.has(e.to)) continue;
|
|
87
|
+
if (members.has(e.to) || (include && !include(e.to))) continue;
|
|
83
88
|
boundary.set(e.to, (boundary.get(e.to) ?? 0) + e.w);
|
|
84
89
|
}
|
|
85
90
|
const cut = [...boundary.values()].reduce((a, b2) => a + b2, 0);
|
package/src/core/build.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface FileFacts {
|
|
|
30
30
|
sigs?: FileSigs;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const CACHE_VERSION =
|
|
33
|
+
const CACHE_VERSION = 7; // bump when extractor semantics change
|
|
34
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"]);
|
|
35
35
|
const MAX_FILES = 24000;
|
|
36
36
|
// Generated dependency manifests are enormous and carry no first-class routes.
|
package/src/core/config.ts
CHANGED
|
@@ -11,18 +11,18 @@ import path from "node:path";
|
|
|
11
11
|
interface FoveaSyncConfig {
|
|
12
12
|
/** turn_end feedback loop on/off (the default-on, opt-out knob). */
|
|
13
13
|
enabled: boolean;
|
|
14
|
-
/** Token budget for
|
|
14
|
+
/** Token budget for proactive model steering context. */
|
|
15
15
|
budget: number;
|
|
16
16
|
/** Also send a tiny model-visible ack on clean turns (default false: silent green). */
|
|
17
17
|
ackClean: boolean;
|
|
18
|
-
/** Number of newly
|
|
18
|
+
/** Number of newly relevant files that justifies proactive steering on its own. */
|
|
19
19
|
warmFileThreshold: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
interface FoveaToolsConfig {
|
|
23
23
|
/** Budget applied when a fovea_* tool call omits maxTokens. */
|
|
24
24
|
defaultBudget: number;
|
|
25
|
-
/**
|
|
25
|
+
/** Install hybrid grep: native text semantics plus bare-query Fovea navigation. */
|
|
26
26
|
replaceGrep: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
package/src/core/extract.ts
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
groupByLang,
|
|
10
10
|
isConfigFile,
|
|
11
11
|
outline,
|
|
12
|
+
outlineStructured,
|
|
12
13
|
patternRunAll,
|
|
14
|
+
type OutlineFile,
|
|
15
|
+
type OutlineSymbol,
|
|
13
16
|
} from "./astgrep.js";
|
|
14
17
|
import type {
|
|
15
18
|
CallSite,
|
|
@@ -102,6 +105,104 @@ export const deriveName = (sig: string, lang: string, parentHint?: string): Name
|
|
|
102
105
|
return { name: first.replace(/^[*&]+/, "") || "?", kind: "decl" };
|
|
103
106
|
};
|
|
104
107
|
|
|
108
|
+
const OUTLINE_KINDS: Record<string, NodeKind> = {
|
|
109
|
+
class: "class",
|
|
110
|
+
struct: "class",
|
|
111
|
+
object: "class",
|
|
112
|
+
interface: "interface",
|
|
113
|
+
trait: "interface",
|
|
114
|
+
protocol: "interface",
|
|
115
|
+
enum: "type",
|
|
116
|
+
type: "type",
|
|
117
|
+
alias: "type",
|
|
118
|
+
function: "function",
|
|
119
|
+
method: "method",
|
|
120
|
+
field: "field",
|
|
121
|
+
property: "field",
|
|
122
|
+
constant: "decl",
|
|
123
|
+
variable: "decl",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const outlineKind = (symbol: OutlineSymbol, lang: string): NodeKind => {
|
|
127
|
+
if (symbol.symbolType === "constructor") return "method";
|
|
128
|
+
const mapped = OUTLINE_KINDS[symbol.symbolType];
|
|
129
|
+
if (symbol.role === "member" && mapped) return mapped;
|
|
130
|
+
const derived = deriveName(symbol.signature, lang).kind;
|
|
131
|
+
if (derived !== "decl") return derived;
|
|
132
|
+
return mapped ?? derived;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const identifierRe = (name: string): RegExp =>
|
|
136
|
+
new RegExp(`(^|[^A-Za-z0-9_$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_$]|$)`);
|
|
137
|
+
|
|
138
|
+
const topLocation = (
|
|
139
|
+
file: string,
|
|
140
|
+
item: OutlineSymbol,
|
|
141
|
+
cwd: string,
|
|
142
|
+
sourceCache: Map<string, string[]>,
|
|
143
|
+
): { line: number; sig: string } => {
|
|
144
|
+
let line = item.range.start.line + 1;
|
|
145
|
+
let sig = cleanSig(item.signature || item.name);
|
|
146
|
+
if (item.name && (!identifierRe(item.name).test(sig) || /^@/.test(sig))) {
|
|
147
|
+
let lines = sourceCache.get(file);
|
|
148
|
+
if (!lines) {
|
|
149
|
+
try {
|
|
150
|
+
lines = readFileSync(join(cwd, file), "utf8").split("\n");
|
|
151
|
+
} catch {
|
|
152
|
+
lines = [];
|
|
153
|
+
}
|
|
154
|
+
sourceCache.set(file, lines);
|
|
155
|
+
}
|
|
156
|
+
const end = Math.min(lines.length - 1, item.range.end?.line ?? item.range.start.line + 12);
|
|
157
|
+
for (let i = item.range.start.line; i <= end; i++) {
|
|
158
|
+
const candidate = lines[i];
|
|
159
|
+
if (candidate && identifierRe(item.name).test(candidate)) {
|
|
160
|
+
line = i + 1;
|
|
161
|
+
sig = cleanSig(candidate);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { line, sig };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const parseStructuredOutline = (files: OutlineFile[], cwd: string): SymbolRec[] => {
|
|
170
|
+
const out: SymbolRec[] = [];
|
|
171
|
+
const sourceCache = new Map<string, string[]>();
|
|
172
|
+
for (const record of files) {
|
|
173
|
+
const file = record.path.replace(/^\.\//, "");
|
|
174
|
+
const concreteParents = new Set(
|
|
175
|
+
record.items.filter((item) => item.symbolType !== "object").map((item) => item.name),
|
|
176
|
+
);
|
|
177
|
+
for (const item of record.items) {
|
|
178
|
+
const kind = outlineKind(item, record.language);
|
|
179
|
+
let name = item.name;
|
|
180
|
+
if (kind === "method") {
|
|
181
|
+
const derived = deriveName(item.signature, record.language);
|
|
182
|
+
if (derived.kind === "method" && derived.name.includes(".")) name = derived.name;
|
|
183
|
+
}
|
|
184
|
+
// Rust impl/object outlines repeat the concrete type. Keep its members,
|
|
185
|
+
// but do not emit a duplicate parent node when the struct is local.
|
|
186
|
+
if (!(item.symbolType === "object" && concreteParents.has(item.name))) {
|
|
187
|
+
const location = topLocation(file, item, cwd, sourceCache);
|
|
188
|
+
out.push({ name, kind, file, line: location.line, sig: location.sig, lang: record.language });
|
|
189
|
+
}
|
|
190
|
+
for (const member of item.members ?? []) {
|
|
191
|
+
const memberKind = outlineKind(member, record.language);
|
|
192
|
+
out.push({
|
|
193
|
+
name: `${item.name}.${member.name}`,
|
|
194
|
+
kind: memberKind,
|
|
195
|
+
file,
|
|
196
|
+
line: member.range.start.line + 1,
|
|
197
|
+
sig: cleanSig(member.signature || `${memberKind} ${item.name}.${member.name}`),
|
|
198
|
+
lang: record.language,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return dedupe(out, (symbol) => `${symbol.name}@${symbol.file}`);
|
|
204
|
+
};
|
|
205
|
+
|
|
105
206
|
const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
106
207
|
const out: SymbolRec[] = [];
|
|
107
208
|
let file = "";
|
|
@@ -128,7 +229,8 @@ const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
|
128
229
|
name: `${top.name}.${name}`,
|
|
129
230
|
kind: kindOf(child[2]!),
|
|
130
231
|
file,
|
|
131
|
-
line: top.line,
|
|
232
|
+
line: top.line,
|
|
233
|
+
lineApproximate: true,
|
|
132
234
|
sig: `${kindOf(child[2]!)} ${top.name}.${name}`,
|
|
133
235
|
lang,
|
|
134
236
|
});
|
|
@@ -145,11 +247,19 @@ const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
|
145
247
|
export const extractSymbols = (files: string[], cwd: string): SymbolRec[] => {
|
|
146
248
|
const out: SymbolRec[] = [];
|
|
147
249
|
for (const [lang, langFiles] of groupByLang(files)) {
|
|
250
|
+
const structured = outlineStructured(langFiles, lang, cwd);
|
|
251
|
+
if (structured) {
|
|
252
|
+
const parsed = parseStructuredOutline(structured, cwd);
|
|
253
|
+
if (parsed.length || structured.some((file) => file.items.length > 0)) {
|
|
254
|
+
pushAll(out, parsed);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
148
258
|
const text = outline(langFiles, lang, cwd);
|
|
149
259
|
if (!text.trim()) continue;
|
|
150
260
|
pushAll(out, parseOutlineText(text, lang));
|
|
151
261
|
}
|
|
152
|
-
return out.filter((
|
|
262
|
+
return out.filter((symbol) => symbol.file);
|
|
153
263
|
};
|
|
154
264
|
|
|
155
265
|
// --- imports ------------------------------------------------------------------
|