pi-fovea 0.1.0 → 0.1.2
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 +2 -0
- package/media/cover.svg +46 -0
- package/package.json +50 -13
- package/src/core/anchors.ts +35 -3
- package/src/core/build.ts +1 -1
- package/src/core/ops.ts +19 -3
package/README.md
CHANGED
|
@@ -132,6 +132,8 @@ Drop `.fovea/rules.json` in a repo to extend anchor detection beyond the built-i
|
|
|
132
132
|
|
|
133
133
|
Changing the rules file invalidates **only** the anchor extraction cache — green-node reuse one level up.
|
|
134
134
|
|
|
135
|
+
A rule may additionally declare `prefixPattern` (e.g. NestJS `@Controller('api/airports')`) so per-method paths like `@Get('search')` compose into the full router-visible anchor `GET /api/airports/search` — see `ts-http-decorator*` in `src/core/anchors.ts`.
|
|
136
|
+
|
|
135
137
|
## How the graph is joined
|
|
136
138
|
|
|
137
139
|
- **imports / contains / inherits / tests** — outline-derived; call edges specificity-tiered, with language builtins and log/test entry points warded off
|
package/media/cover.svg
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="330" viewBox="0 0 1100 330">
|
|
2
|
+
<defs>
|
|
3
|
+
<radialGradient id="fovea" cx="50%" cy="50%" r="50%">
|
|
4
|
+
<stop offset="0%" stop-color="#ffb454" stop-opacity="0.95"/>
|
|
5
|
+
<stop offset="18%" stop-color="#ff7a3d" stop-opacity="0.75"/>
|
|
6
|
+
<stop offset="40%" stop-color="#b3452e" stop-opacity="0.35"/>
|
|
7
|
+
<stop offset="100%" stop-color="#0a0e14" stop-opacity="0"/>
|
|
8
|
+
</radialGradient>
|
|
9
|
+
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
|
10
|
+
<stop offset="0" stop-color="#2a3441" stop-opacity="0.2"/>
|
|
11
|
+
<stop offset="0.5" stop-color="#ffb454" stop-opacity="0.55"/>
|
|
12
|
+
<stop offset="1" stop-color="#2a3441" stop-opacity="0.2"/>
|
|
13
|
+
</linearGradient>
|
|
14
|
+
</defs>
|
|
15
|
+
<rect width="1100" height="330" fill="#0a0e14"/>
|
|
16
|
+
<g opacity="0.55">
|
|
17
|
+
<line x1="60" y1="258" x2="550" y2="165" stroke="url(#edge)" stroke-width="1.4"/>
|
|
18
|
+
<line x1="140" y1="88" x2="550" y2="165" stroke="url(#edge)" stroke-width="1"/>
|
|
19
|
+
<line x1="260" y1="298" x2="550" y2="165" stroke="url(#edge)" stroke-width="1.2"/>
|
|
20
|
+
<line x1="330" y1="56" x2="550" y2="165" stroke="url(#edge)" stroke-width="1"/>
|
|
21
|
+
<line x1="770" y1="66" x2="550" y2="165" stroke="url(#edge)" stroke-width="1.1"/>
|
|
22
|
+
<line x1="830" y1="274" x2="550" y2="165" stroke="url(#edge)" stroke-width="1.4"/>
|
|
23
|
+
<line x1="980" y1="130" x2="550" y2="165" stroke="url(#edge)" stroke-width="1"/>
|
|
24
|
+
<line x1="1030" y1="248" x2="550" y2="165" stroke="url(#edge)" stroke-width="0.9"/>
|
|
25
|
+
<line x1="430" y1="300" x2="550" y2="165" stroke="url(#edge)" stroke-width="0.9"/>
|
|
26
|
+
<line x1="205" y1="180" x2="550" y2="165" stroke="url(#edge)" stroke-width="1.3"/>
|
|
27
|
+
</g>
|
|
28
|
+
<circle cx="550" cy="165" r="150" fill="url(#fovea)"/>
|
|
29
|
+
<g fill="#39424f">
|
|
30
|
+
<circle cx="60" cy="258" r="4.5"/><circle cx="140" cy="88" r="4"/><circle cx="260" cy="298" r="4.5"/>
|
|
31
|
+
<circle cx="330" cy="56" r="4"/><circle cx="770" cy="66" r="4"/><circle cx="830" cy="274" r="4.5"/>
|
|
32
|
+
<circle cx="980" cy="130" r="4"/><circle cx="1030" cy="248" r="4"/><circle cx="430" cy="300" r="4"/>
|
|
33
|
+
<circle cx="205" cy="180" r="4.5"/>
|
|
34
|
+
</g>
|
|
35
|
+
<g fill="#ffb454">
|
|
36
|
+
<circle cx="550" cy="165" r="7"/>
|
|
37
|
+
<circle cx="512" cy="137" r="4"/><circle cx="588" cy="142" r="4"/><circle cx="600" cy="192" r="4.5"/>
|
|
38
|
+
<circle cx="498" cy="193" r="4"/><circle cx="556" cy="108" r="3.6"/><circle cx="534" cy="216" r="3.4"/>
|
|
39
|
+
</g>
|
|
40
|
+
<g fill="none" stroke="#ffb454" stroke-opacity="0.85">
|
|
41
|
+
<circle cx="550" cy="165" r="26" stroke-width="2"/>
|
|
42
|
+
<circle cx="550" cy="165" r="52" stroke-opacity="0.5" stroke-width="1.4"/>
|
|
43
|
+
<circle cx="550" cy="165" r="84" stroke-opacity="0.28" stroke-width="1.1" stroke-dasharray="3 6"/>
|
|
44
|
+
<circle cx="550" cy="165" r="120" stroke-opacity="0.14" stroke-width="1" stroke-dasharray="2 8"/>
|
|
45
|
+
</g>
|
|
46
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,20 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fovea",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
7
|
-
"engines": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"author": "monotykamary",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/monotykamary/pi-fovea.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/monotykamary/pi-fovea#readme",
|
|
16
|
+
"bugs": "https://github.com/monotykamary/pi-fovea/issues",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"repo-map",
|
|
21
|
+
"code-graph",
|
|
22
|
+
"heat-diffusion",
|
|
23
|
+
"progressive-disclosure",
|
|
24
|
+
"context-engineering",
|
|
25
|
+
"agents"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
],
|
|
31
|
+
"image": "https://raw.githubusercontent.com/monotykamary/pi-fovea/main/media/cover.svg"
|
|
32
|
+
},
|
|
33
|
+
"exports": {
|
|
34
|
+
"./ops": "./src/core/ops.ts"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"cli.ts",
|
|
39
|
+
"media",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"bin": {
|
|
47
|
+
"fovea": "cli.ts"
|
|
18
48
|
},
|
|
19
49
|
"dependencies": {
|
|
20
50
|
"typebox": "^1.0.76"
|
|
@@ -30,5 +60,12 @@
|
|
|
30
60
|
"peerDependencies": {
|
|
31
61
|
"@earendil-works/pi-coding-agent": ">=0.80.6",
|
|
32
62
|
"@earendil-works/pi-tui": ">=0.80.6"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"bench": "tsx scripts/bench.ts",
|
|
68
|
+
"fovea": "tsx cli.ts",
|
|
69
|
+
"check": "pnpm run typecheck && pnpm run test"
|
|
33
70
|
}
|
|
34
|
-
}
|
|
71
|
+
}
|
package/src/core/anchors.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { join as joinPath } from "node:path";
|
|
10
|
-
import { groupByLang, patternRun } from "./astgrep.js";
|
|
10
|
+
import { groupByLang, patternRun, patternRunAll } from "./astgrep.js";
|
|
11
11
|
import { PATH_TOKEN_RE } from "./extract.js";
|
|
12
12
|
import { classifyLiteral, normalizeLiteral } from "./join.js";
|
|
13
13
|
import type { Anchor } from "./types.js";
|
|
@@ -18,6 +18,12 @@ export interface AnchorRule {
|
|
|
18
18
|
pattern: string;
|
|
19
19
|
methods: string; // regex tested against the captured method metavar
|
|
20
20
|
kind: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional class-level prefix patterns (e.g. NestJS '@Controller("api/x")').
|
|
23
|
+
* Their $P captures are collected per file; a matched route path is composed
|
|
24
|
+
* prefix + suffix so the anchor id is the full router-visible path.
|
|
25
|
+
*/
|
|
26
|
+
prefixPattern?: string[];
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const PLACEHOLDER_ONLY = /^(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/;
|
|
@@ -53,6 +59,16 @@ export const DEFAULT_PACK: AnchorRule[] = [
|
|
|
53
59
|
pattern: '@$M("$P")',
|
|
54
60
|
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
55
61
|
kind: "route",
|
|
62
|
+
prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
// Single-quotes dominate real NestJS codebases (NOMAD, starters, docs).
|
|
66
|
+
id: "ts-http-decorator-singlequote",
|
|
67
|
+
langs: ["TypeScript", "Tsx"],
|
|
68
|
+
pattern: "@$M('$P')",
|
|
69
|
+
methods: "^(?i:get|post|put|delete|patch|options|head)$",
|
|
70
|
+
kind: "route",
|
|
71
|
+
prefixPattern: ['@Controller("$P")', "@Controller('$P')"],
|
|
56
72
|
},
|
|
57
73
|
{
|
|
58
74
|
id: "python-decorator-route",
|
|
@@ -86,6 +102,14 @@ export const DEFAULT_PACK: AnchorRule[] = [
|
|
|
86
102
|
|
|
87
103
|
export interface AnchorDraft extends Anchor {}
|
|
88
104
|
|
|
105
|
+
// NestJS mounts every controller at the router root, so the joined path is
|
|
106
|
+
// always slash-rooted regardless of how each framework writes the pieces.
|
|
107
|
+
const joinRoute = (prefix: string, child: string): string => {
|
|
108
|
+
const p = prefix.replace(/^\/+|\/+$/g, "");
|
|
109
|
+
const c = child.replace(/^\/+|\/+$/g, "");
|
|
110
|
+
return c ? `/${[p, c].filter(Boolean).join("/")}` : `/${p}`;
|
|
111
|
+
};
|
|
112
|
+
|
|
89
113
|
export const extractAnchors = (
|
|
90
114
|
files: string[],
|
|
91
115
|
cwd: string,
|
|
@@ -99,15 +123,23 @@ export const extractAnchors = (
|
|
|
99
123
|
for (const lang of rule.langs) {
|
|
100
124
|
const langFiles = byLang.get(lang);
|
|
101
125
|
if (!langFiles?.length) continue;
|
|
126
|
+
const prefixes = new Map<string, string>(); // file -> @Controller prefix
|
|
127
|
+
if (rule.prefixPattern?.length) {
|
|
128
|
+
for (const pm of patternRunAll(rule.prefixPattern, lang, langFiles, cwd)) {
|
|
129
|
+
const p = pm.single.P?.trim();
|
|
130
|
+
if (p !== undefined && !prefixes.has(pm.file)) prefixes.set(pm.file, p);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
102
133
|
for (const m of patternRun(rule.pattern, lang, langFiles, cwd)) {
|
|
103
134
|
const method = m.single.M;
|
|
104
135
|
const path = m.single.P;
|
|
105
136
|
if (!method || !path || !methodRe.test(method)) continue;
|
|
106
|
-
const
|
|
137
|
+
const prefix = prefixes.get(m.file);
|
|
138
|
+
const raw = prefix !== undefined && prefix !== "" ? joinRoute(prefix, path.trim()) : path.trim();
|
|
107
139
|
// $R.$M(...) also matches Map.get("key")-style data access; only real
|
|
108
140
|
// paths (or router-relative placeholders like ":id") may anchor.
|
|
109
141
|
if (!PATH_TOKEN_RE.test(raw) && !PLACEHOLDER_ONLY.test(raw)) continue;
|
|
110
|
-
const norm = normalizeLiteral(
|
|
142
|
+
const norm = normalizeLiteral(raw, "path");
|
|
111
143
|
const httpMethod = method.toUpperCase() === "ROUTE" || method.toLowerCase() === "route" || method.toLowerCase() === "use" || method.toLowerCase() === "any"
|
|
112
144
|
? method.toUpperCase()
|
|
113
145
|
: method.toUpperCase();
|
package/src/core/build.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface FileFacts {
|
|
|
25
25
|
anchors: AnchorDraft[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const CACHE_VERSION =
|
|
28
|
+
const CACHE_VERSION = 4; // bump when extractor semantics change
|
|
29
29
|
const IGNORE_DIRS = new Set([".git", "node_modules", "dist", "vendor", ".venv", "venv", "target", "coverage", ".next", "build", "__pycache__", ".pi", ".pi-fovea", "deps", "_build", ".tox", "Pods"]);
|
|
30
30
|
const MAX_FILES = 24000;
|
|
31
31
|
// Generated dependency manifests are enormous and carry no first-class routes.
|
package/src/core/ops.ts
CHANGED
|
@@ -90,9 +90,25 @@ export const resolveSeeds = (state: RepoState, query: string): SeedResolution =>
|
|
|
90
90
|
const terms = q.split(/\s+/).filter((t) => t.length > 1);
|
|
91
91
|
|
|
92
92
|
// Literal route: treat the query itself as a join token (path/env/word).
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const cls = classifyLiteral(q);
|
|
94
|
+
if (cls) {
|
|
95
|
+
const norm = normalizeLiteral(q, cls);
|
|
96
|
+
for (const occ of state.joinIndex.byKey.get(norm)?.occ ?? []) bump(occ.node, 1);
|
|
97
|
+
if (cls === "path") {
|
|
98
|
+
// Route-prefix queries ("/api/airports") seed everything mounted below
|
|
99
|
+
// them — the query is rarely a literal in code, which is the point: the
|
|
100
|
+
// model shouldn't have to guess the full route string to look at it.
|
|
101
|
+
const under = `${norm}/`;
|
|
102
|
+
for (const [key, bucket] of state.joinIndex.byKey) {
|
|
103
|
+
if (bucket.cls !== "path" || !key.startsWith(under)) continue;
|
|
104
|
+
for (const occ of bucket.occ) bump(occ.node, 0.8);
|
|
105
|
+
}
|
|
106
|
+
// Anchors whose route sits under the query get seeded directly.
|
|
107
|
+
state.graph.nodes.forEach((n, i) => {
|
|
108
|
+
if (n.kind !== "anchor") return;
|
|
109
|
+
const route = n.name.slice(n.name.indexOf(" ") + 1);
|
|
110
|
+
if (route === norm || route.startsWith(under)) bump(i, 0.9);
|
|
111
|
+
});
|
|
96
112
|
}
|
|
97
113
|
}
|
|
98
114
|
|