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 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
@@ -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.0",
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": { "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"
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
+ }
@@ -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 raw = path.trim();
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(path, "path");
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 = 3; // bump when extractor semantics change
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
- if (classifyLiteral(q)) {
94
- for (const occ of state.joinIndex.byKey.get(normalizeLiteral(q, classifyLiteral(q)!))?.occ ?? []) {
95
- bump(occ.node, 1);
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