fapony 0.5.0 → 0.6.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 +32 -23
- package/package.json +1 -1
- package/skill/plan-with-pony/SKILL.md +3 -1
- package/src/adapters/cli.ts +1 -1
- package/src/adapters/hooks/bug-markers.ts +19 -9
- package/src/adapters/hooks/context-data.ts +7 -2
- package/src/adapters/hooks/edit-hint.ts +11 -1
- package/src/adapters/hooks/read-hint.ts +2 -1
- package/src/adapters/mcp/evidence.ts +8 -0
- package/src/adapters/mcp/primitives.ts +1 -1
- package/src/adapters/mcp/tools/check.ts +1 -1
- package/src/adapters/mcp/tools/collect.ts +1 -1
- package/src/adapters/mcp/tools/report.ts +1 -1
- package/src/analyze/barrels.ts +56 -0
- package/src/analyze/blast.ts +58 -0
- package/src/analyze/cache.ts +162 -0
- package/src/analyze/cli.ts +28 -0
- package/src/analyze/criteria.ts +81 -0
- package/src/analyze/diagnose.ts +113 -0
- package/src/analyze/discover.ts +84 -0
- package/src/analyze/format.ts +38 -0
- package/src/analyze/graph.ts +170 -0
- package/src/analyze/index.ts +18 -0
- package/src/analyze/python.ts +411 -0
- package/src/analyze/resolve-ts.ts +39 -0
- package/src/analyze/types.ts +47 -0
- package/src/conventions-seed.ts +6 -2
- package/src/debt/scan.ts +1 -1
- package/src/init.ts +116 -54
- package/src/install/antigravity.ts +3 -1
- package/src/install/opencode.ts +2 -2
- package/src/map.ts +220 -0
- package/src/mem/selectors.ts +4 -1
- package/src/seed/plan-seed.ts +7 -3
- package/src/seed/review-seed.ts +3 -3
- package/templates/PLAN.md +1 -0
- package/src/analyze.ts +0 -688
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# fapony
|
|
6
6
|
|
|
7
|
-
[](https://www.npmjs.com/package/fapony)
|
|
7
|
+
[](https://www.npmjs.com/package/fapony) [](https://github.com/kire21b/fapony)
|
|
8
8
|
|
|
9
9
|
**See what your coding agents actually cost.** fapony reads the session logs Claude Code, Codex,
|
|
10
10
|
OpenCode and ZCode already write, and puts them all on one yardstick — tokens, cost and time per
|
|
@@ -63,6 +63,8 @@ flowchart LR
|
|
|
63
63
|
B[OpenCode] --> F
|
|
64
64
|
C[ZCode] --> F
|
|
65
65
|
D[Codex] --> F
|
|
66
|
+
E[Cursor] --> F
|
|
67
|
+
G[Antigravity] --> F
|
|
66
68
|
F --> U[usage — tokens & cost]
|
|
67
69
|
F --> M[mem log — what was decided here]
|
|
68
70
|
F --> D[debt — how far the move has gone]
|
|
@@ -97,7 +99,11 @@ fapony install --all # skip the prompt, wire everything detected
|
|
|
97
99
|
# 4. Turn on the memory layer (per project you want it in)
|
|
98
100
|
fapony init /path/to/your-worktree
|
|
99
101
|
# creates .fapony/ — .memory/ (the mem log the 3 MCP tools read and write)
|
|
100
|
-
# and conventions.json for `fapony debt` (shared rules: commit them)
|
|
102
|
+
# and conventions.json for `fapony debt` (shared rules: commit them),
|
|
103
|
+
# then offers to write the memory rules into CLAUDE.md / AGENTS.md
|
|
104
|
+
# (none yet = AGENTS.md + a CLAUDE.md that imports it) — agents only log
|
|
105
|
+
# what the rules they already read tell them to
|
|
106
|
+
fapony init /path/to/your-worktree --rules --yes # repo already set up: rules only, no prompt
|
|
101
107
|
```
|
|
102
108
|
|
|
103
109
|
…or add it manually to any MCP client: `{ "mcpServers": { "fapony": { "command": "fapony", "args": ["mcp"] } } }`. Full protocol and adapter examples: [docs/mcp-handcheck.md](https://github.com/kire21b/fapony/blob/main/docs/mcp-handcheck.md).
|
|
@@ -119,26 +125,28 @@ Stated up front, because the gap between these two things is where most tooling
|
|
|
119
125
|
|
|
120
126
|
## What runs where
|
|
121
127
|
|
|
122
|
-
`fapony install` wires
|
|
123
|
-
piece all of them get — the hooks and in-process hints are per-client, and the read/edit
|
|
124
|
-
arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate
|
|
125
|
-
is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
136
|
-
| Skills symlinked into `~/.
|
|
137
|
-
|
|
|
128
|
+
`fapony install` wires six clients (Claude Code, OpenCode, Cursor, ZCode, Codex, Antigravity). MCP is
|
|
129
|
+
the only piece all of them get — the hooks and in-process hints are per-client, and the read/edit
|
|
130
|
+
hints arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate
|
|
131
|
+
channel is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still
|
|
132
|
+
answers.
|
|
133
|
+
|
|
134
|
+
| | Claude Code | OpenCode | Cursor | ZCode | Codex | Antigravity |
|
|
135
|
+
|---|---|---|---|---|---|---|
|
|
136
|
+
| MCP tools — `mem_find` `mem_add` `mem_close` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
137
|
+
| Stop hook — refuse to end a turn with commits but no new mem row | ✅ | — | ✅ | — | ✅ after trust | — |
|
|
138
|
+
| Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — | — |
|
|
139
|
+
| Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — | — |
|
|
140
|
+
| Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — | — |
|
|
141
|
+
| Commit hint — `git commit` → record-a-mem-row nudge | — | ✅ after | — | — | — | — |
|
|
142
|
+
| Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — | — |
|
|
143
|
+
| Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ | ✅ |
|
|
144
|
+
| `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ | — |
|
|
138
145
|
|
|
139
146
|
`—` means not wired, not impossible. Codex hooks require trust via `/hooks` before they run —
|
|
140
|
-
`fapony install` tells you when.
|
|
141
|
-
|
|
147
|
+
`fapony install` tells you when. Antigravity gets MCP + skills now; its hook surface is still
|
|
148
|
+
evolving, and `usage-scan` can't read its session log yet. The hints live on hooks rather than MCP
|
|
149
|
+
on purpose — they must fire mid-turn without the agent deciding to call anything.
|
|
142
150
|
|
|
143
151
|
## The ledger — one habit, 3 tools
|
|
144
152
|
|
|
@@ -235,7 +243,7 @@ fapony price-scan # fetch model price table → prices.
|
|
|
235
243
|
fapony usage-web [port] # usage comparison dashboard from cache
|
|
236
244
|
|
|
237
245
|
# lookup (read-only, never touches state)
|
|
238
|
-
fapony analyze [path] # live repo graph: hubs, orphans, cycles, changed-untested
|
|
246
|
+
fapony analyze [path] # live repo graph: hubs, orphans, cycles, changed-untested (TS/JS + Python .py/.pyi; stdlib→external, no sys.path)
|
|
239
247
|
fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>] # scope facts for a review
|
|
240
248
|
fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]... # write PLAN (+SPEC): frontmatter, capped sections, prior-art list
|
|
241
249
|
|
|
@@ -297,8 +305,9 @@ vendor-neutral skills (anything that reads stdin) · opt-in telemetry, off by de
|
|
|
297
305
|
([TELEMETRY.md](https://github.com/kire21b/fapony/blob/main/TELEMETRY.md) lists exactly what
|
|
298
306
|
leaves the machine) · Bun-only; run state in SQLite via `bun:sqlite` (WAL mode).
|
|
299
307
|
|
|
300
|
-
**Not supported (yet):** PreToolUse hints on Cursor, ZCode or
|
|
301
|
-
the other two expose no in-process hook surface for read/edit hints
|
|
308
|
+
**Not supported (yet):** PreToolUse hints on Cursor, ZCode, Codex or Antigravity — Cursor has no
|
|
309
|
+
such hook, the other two expose no in-process hook surface for read/edit hints, and Antigravity's
|
|
310
|
+
hook surface is still evolving. A hosted or shared ledger —
|
|
302
311
|
`FAPONY_STATE_DIR` on a synced folder works as an experiment only; SQLite's WAL mode does not
|
|
303
312
|
tolerate concurrent writers over NFS/Dropbox/iCloud Drive and can corrupt the db under real
|
|
304
313
|
contention.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -192,7 +192,9 @@ only place that ordering stays true.
|
|
|
192
192
|
becomes a second copy of the plan, and then neither copy can be trusted. `fapony mem kickoff` reads the
|
|
193
193
|
checkboxes in the **first `##` section only**, so section 6 stays detail rather than status.
|
|
194
194
|
|
|
195
|
-
Section 6 — every step must be verifiable. Section 8 — must link back to anything it came from.
|
|
195
|
+
Section 6 — every step must be verifiable. Section 8 — must link back to anything it came from. **A step that needs something the system does not store yet** ("the month the accountant has seen",
|
|
196
|
+
"last synced") must say where it lives, who writes it, and who reads it — or the executing agent
|
|
197
|
+
designs it alone, by exploring (measured: one such chunk burned ~250k tokens before a line of code).
|
|
196
198
|
**Plan = what/why/order, spec = how in detail**: never paste API shapes, schemas, wireframes, or
|
|
197
199
|
edge-case tables into section 7; link to the spec instead. Full template: `templates/PLAN.md`.
|
|
198
200
|
|
package/src/adapters/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// argv routing.
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
-
import { cmdAnalyze } from "../analyze.js";
|
|
8
|
+
import { cmdAnalyze } from "../analyze/index.js";
|
|
9
9
|
import { renderUsage, suggestCommand } from "../commands.js";
|
|
10
10
|
import { cmdDebt } from "../debt/cli.js";
|
|
11
11
|
import { cmdDigest } from "../digest/cli.js";
|
|
@@ -18,23 +18,33 @@
|
|
|
18
18
|
// appear in every bug report and would fire on any turn that reads one — the
|
|
19
19
|
// Stop hook blocks once per session on a match, so a false positive is costly.
|
|
20
20
|
|
|
21
|
-
/** Free-text announcement phrases ("I found a bug"), never symptom words.
|
|
21
|
+
/** Free-text announcement phrases ("I found a bug"), never symptom words.
|
|
22
|
+
* `g` flag is required — hasBugMarker walks every match (matchAll). */
|
|
22
23
|
export const BUG_MARKERS: RegExp[] = [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/\bfound (?:a |the )?bug\b/
|
|
26
|
-
/\b(?:this|that|it)(?:'s| is) a bug\b/
|
|
27
|
-
/\
|
|
24
|
+
/(?:เจอ|พบ)(?:ว่า)?(?:เป็น)?บั๊ก/g, // พบบั๊ก · พบว่าเป็นบั๊กจริง
|
|
25
|
+
/บั๊กที่(?:เจอ|พบ)/g,
|
|
26
|
+
/\bfound (?:a |the )?(?:real |actual )?bug\b/gi,
|
|
27
|
+
/\b(?:this|that|it)(?:'s| is) a (?:real )?bug\b/gi,
|
|
28
|
+
/\b(?:bug confirmed|confirmed (?:a |real )?bug)\b/gi,
|
|
29
|
+
/\bbug\b\s*(?:\([^)\n]{0,80}\))?\s*:/gi, // **Bug (cause…):**
|
|
28
30
|
];
|
|
29
31
|
|
|
32
|
+
// Negation / hypothetical right before a match ("ไม่พบบั๊ก", "จะเจอบั๊ก",
|
|
33
|
+
// "not a bug"). Bare "เป็นบั๊ก" is deliberately not a marker: "อาจเป็นบั๊ก" is
|
|
34
|
+
// everywhere and a false fire costs a Stop-hook block.
|
|
35
|
+
const NEGATED = /(?:ไม่|จะ|ถ้า|อาจ|\bnot\s|\bno\s|\bif\s)\s*$/i;
|
|
36
|
+
|
|
30
37
|
/**
|
|
31
38
|
* The matched marker phrase, or null. Returns the matched text so the caller
|
|
32
|
-
* can quote it back to the agent (stop.ts does, in its block reason).
|
|
39
|
+
* can quote it back to the agent (stop.ts does, in its block reason). Every
|
|
40
|
+
* match is checked, so "ไม่พบบั๊กใหม่ แต่เจอบั๊กที่ X" still fires on the second.
|
|
33
41
|
*/
|
|
34
42
|
export function hasBugMarker(text: string): string | null {
|
|
35
43
|
for (const re of BUG_MARKERS) {
|
|
36
|
-
const m
|
|
37
|
-
|
|
44
|
+
for (const m of text.matchAll(re)) {
|
|
45
|
+
const before = text.slice(Math.max(0, m.index - 15), m.index);
|
|
46
|
+
if (!NEGATED.test(before)) return m[0];
|
|
47
|
+
}
|
|
38
48
|
}
|
|
39
49
|
return null;
|
|
40
50
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { realpathSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, join, relative } from "node:path";
|
|
8
|
-
import { collectSourceFiles, SCAN_EXTS } from "../../analyze.js";
|
|
8
|
+
import { collectSourceFiles, SCAN_EXTS } from "../../analyze/index.js";
|
|
9
9
|
import { MEM_TEXT_MAX, readMemLog } from "../../core/mem-log.js";
|
|
10
10
|
import { debtForFile, resolveDebtScope } from "../../debt/index.js";
|
|
11
11
|
|
|
@@ -19,6 +19,8 @@ export interface ContextLineData {
|
|
|
19
19
|
memLines: string[];
|
|
20
20
|
/** Ids of open bugs actually emitted as OPEN BUG lines above (for the fire log). */
|
|
21
21
|
openBugIds: string[];
|
|
22
|
+
/** Ids of every mem row emitted in memLines (open bugs first) — joined to authors offline. */
|
|
23
|
+
memIds: string[];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
|
|
@@ -45,6 +47,7 @@ export function readContextData(
|
|
|
45
47
|
const debtLines: string[] = [];
|
|
46
48
|
const memLines: string[] = [];
|
|
47
49
|
const openBugIds: string[] = [];
|
|
50
|
+
const memIds: string[] = [];
|
|
48
51
|
|
|
49
52
|
// convention debt — source files only, fresh from the repo. The scope
|
|
50
53
|
// pairs the git root (repo-relative `where`) with the nearest
|
|
@@ -112,13 +115,15 @@ export function readContextData(
|
|
|
112
115
|
.filter((r) => !(r.id && emitted.has(r.id)))
|
|
113
116
|
.slice(0, MEM_HINT_MAX - openLines.length);
|
|
114
117
|
for (const line of openLines) memLines.push(line);
|
|
118
|
+
memIds.push(...openBugIds);
|
|
115
119
|
for (const r of rest) {
|
|
120
|
+
if (r.id) memIds.push(r.id);
|
|
116
121
|
memLines.push(
|
|
117
122
|
`fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
|
|
118
123
|
);
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
|
-
return { worktree, debtIds, debtLines, memLines, openBugIds };
|
|
126
|
+
return { worktree, debtIds, debtLines, memLines, openBugIds, memIds };
|
|
122
127
|
} catch {
|
|
123
128
|
return null;
|
|
124
129
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join, relative, sep } from "node:path";
|
|
15
|
-
import { buildGraphCached, SCAN_EXTS } from "../../analyze.js";
|
|
15
|
+
import { buildGraphCached, SCAN_EXTS } from "../../analyze/index.js";
|
|
16
16
|
import { recordHintFire } from "../../core/hint-log.js";
|
|
17
17
|
import { sessionKey } from "../../core/hook-helpers.js";
|
|
18
18
|
import { readContextData } from "./context-data.js";
|
|
@@ -184,6 +184,16 @@ export async function cmdHookEditHint(): Promise<void> {
|
|
|
184
184
|
file: rel && !rel.startsWith("..") ? rel : null,
|
|
185
185
|
count: 1,
|
|
186
186
|
});
|
|
187
|
+
if (ctx && ctx.memLines.length > 0) {
|
|
188
|
+
recordHintFire({
|
|
189
|
+
ts: new Date().toISOString(),
|
|
190
|
+
worktree,
|
|
191
|
+
surface: "mem",
|
|
192
|
+
file: rel && !rel.startsWith("..") ? rel : null,
|
|
193
|
+
count: ctx.memLines.length,
|
|
194
|
+
ids: ctx.memIds,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
187
197
|
if (ctx && ctx.openBugIds.length > 0) {
|
|
188
198
|
recordHintFire({
|
|
189
199
|
ts: new Date().toISOString(),
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, relative, resolve } from "node:path";
|
|
16
|
-
import { SCAN_EXTS } from "../../analyze.js";
|
|
16
|
+
import { SCAN_EXTS } from "../../analyze/index.js";
|
|
17
17
|
import { recordHintFire } from "../../core/hint-log.js";
|
|
18
18
|
import { sessionKey } from "../../core/hook-helpers.js";
|
|
19
19
|
import { readMemLog } from "../../memory.js";
|
|
@@ -390,6 +390,7 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
390
390
|
surface: "mem",
|
|
391
391
|
file: rel,
|
|
392
392
|
count: ctx.memLines.length,
|
|
393
|
+
ids: ctx.memIds,
|
|
393
394
|
});
|
|
394
395
|
}
|
|
395
396
|
if (ctx && ctx.openBugIds.length > 0) {
|
|
@@ -156,9 +156,17 @@ function runCommand(
|
|
|
156
156
|
timeoutMs: number,
|
|
157
157
|
): CommandOutcome {
|
|
158
158
|
const start = Date.now();
|
|
159
|
+
// A bare `pytest` otherwise hits a global one whose editable install may
|
|
160
|
+
// point at another worktree — tests the wrong code, silently.
|
|
161
|
+
// ponytail: .venv only; poetry/uv/conda when someone asks.
|
|
162
|
+
const venvBin = join(worktree, ".venv", "bin");
|
|
163
|
+
const env = existsSync(venvBin)
|
|
164
|
+
? { ...process.env, PATH: `${venvBin}:${process.env.PATH ?? ""}` }
|
|
165
|
+
: undefined;
|
|
159
166
|
try {
|
|
160
167
|
execSync(cmd, {
|
|
161
168
|
cwd: worktree,
|
|
169
|
+
env,
|
|
162
170
|
encoding: "utf-8",
|
|
163
171
|
stdio: ["pipe", "pipe", "pipe"],
|
|
164
172
|
timeout: timeoutMs,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// §0 rule: add-only — never remove or rename exported symbols.
|
|
8
8
|
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
|
-
import type { BlastEntry } from "../../analyze.js";
|
|
10
|
+
import type { BlastEntry } from "../../analyze/index.js";
|
|
11
11
|
import { ROOT } from "../../update.js";
|
|
12
12
|
|
|
13
13
|
// ─── Server build identity ─────────────────────────────────────────────
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/mcp/tools/check.ts — handoff_check tool
|
|
2
2
|
|
|
3
|
-
import { blastRadiusForWorktree } from "../../../analyze.js";
|
|
3
|
+
import { blastRadiusForWorktree } from "../../../analyze/index.js";
|
|
4
4
|
import type { CheckResult } from "../primitives.js";
|
|
5
5
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/mcp/tools/collect.ts — handoff_collect tool
|
|
2
2
|
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
-
import { isTestFile } from "../../../analyze.js";
|
|
4
|
+
import { isTestFile } from "../../../analyze/index.js";
|
|
5
5
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
6
|
|
|
7
7
|
// --- Git helper ---
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// run metrics + verdict into a single VerificationReport.
|
|
5
5
|
// Calls existing primitives — no duplicate parser/conformance logic.
|
|
6
6
|
|
|
7
|
-
import { blastRadiusForWorktree } from "../../../analyze.js";
|
|
7
|
+
import { blastRadiusForWorktree } from "../../../analyze/index.js";
|
|
8
8
|
import { loadConfig } from "../../../core/config.js";
|
|
9
9
|
import { getEvents, getRun, openDb } from "../../../db/store.js";
|
|
10
10
|
import { parseGateEventData } from "../../../parse.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/analyze/barrels.ts — exports seen *through* barrels.
|
|
2
|
+
//
|
|
3
|
+
// `export * from "./x"` is reported by Bun.Transpiler.scan as an IMPORT edge and
|
|
4
|
+
// never as an export, so a barrel file scans as having zero exports. Measured on
|
|
5
|
+
// vela 2026-09-18: 211 barrels out of 1,996 source files, and `@innominix/ui`
|
|
6
|
+
// alone is imported 462 times — the blind spot hides most of the cross-package
|
|
7
|
+
// graph, which is why a wrapper reached through a barrel reads as unused. Named
|
|
8
|
+
// re-exports (`export { x } from "./y"`) are already reported correctly; only the
|
|
9
|
+
// star form needs this. Cost to close it: 1.9ms on a 17-export barrel. tsc answers
|
|
10
|
+
// the same question type-accurately for 2,176ms — see SPEC-convention-debt.md §2.2
|
|
11
|
+
// for why that 1,145x is not worth paying here.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { extractExports } from "../map.js";
|
|
17
|
+
import { resolvePythonRelative } from "./python.js";
|
|
18
|
+
import { resolveRelative } from "./resolve-ts.js";
|
|
19
|
+
|
|
20
|
+
export const STAR_REEXPORT_RE =
|
|
21
|
+
/^[ \t]*export\s+\*\s+(?:as\s+[\w$]+\s+)?from\s*["'](\.[^"']+)["']/gm;
|
|
22
|
+
|
|
23
|
+
// `from .x import *` — the Python shape of a star re-export. Absolute star
|
|
24
|
+
// imports can't resolve (same bucket as TS bare specifiers), so only relative.
|
|
25
|
+
export const PY_STAR_REEXPORT_RE =
|
|
26
|
+
/^[ \t]*from\s*(\.+)((?:[\w.]*))\s+import\s+\*/gm;
|
|
27
|
+
|
|
28
|
+
export function exportsThroughBarrels(
|
|
29
|
+
absDir: string,
|
|
30
|
+
rel: string,
|
|
31
|
+
filesSet: Set<string>,
|
|
32
|
+
seen = new Set<string>(),
|
|
33
|
+
): string[] {
|
|
34
|
+
if (seen.has(rel)) return []; // barrels re-export each other; stop the cycle
|
|
35
|
+
seen.add(rel);
|
|
36
|
+
let source: string;
|
|
37
|
+
try {
|
|
38
|
+
source = readFileSync(join(absDir, rel), "utf-8");
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const out = extractExports(source, undefined, rel)
|
|
43
|
+
.symbols.filter((s) => s.name !== "*")
|
|
44
|
+
.map((s) => s.name);
|
|
45
|
+
STAR_REEXPORT_RE.lastIndex = 0;
|
|
46
|
+
for (const m of source.matchAll(STAR_REEXPORT_RE)) {
|
|
47
|
+
const hit = resolveRelative(rel, m[1], filesSet);
|
|
48
|
+
if (hit) out.push(...exportsThroughBarrels(absDir, hit, filesSet, seen));
|
|
49
|
+
}
|
|
50
|
+
PY_STAR_REEXPORT_RE.lastIndex = 0;
|
|
51
|
+
for (const m of source.matchAll(PY_STAR_REEXPORT_RE)) {
|
|
52
|
+
const hit = resolvePythonRelative(rel, m[1], m[2], filesSet);
|
|
53
|
+
if (hit) out.push(...exportsThroughBarrels(absDir, hit, filesSet, seen));
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(out)];
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/analyze/blast.ts — `blastRadius()`: per-file { dependents, tested }
|
|
2
|
+
// facts for handoff_check / verification_report.
|
|
3
|
+
|
|
4
|
+
import { isTestedThroughBarrels } from "./criteria.js";
|
|
5
|
+
import { buildGraph } from "./graph.js";
|
|
6
|
+
import type { BlastEntry, ImportGraph } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// BFS over the reverse-edge map — one grep-and-recurse chain collapsed into
|
|
9
|
+
// one walk. `seen` makes cycles a no-op instead of an infinite loop.
|
|
10
|
+
function transitiveDependentsCount(graph: ImportGraph, file: string): number {
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
let frontier = graph.dependents.get(file) ?? new Set<string>();
|
|
13
|
+
while (frontier.size > 0) {
|
|
14
|
+
const next = new Set<string>();
|
|
15
|
+
for (const f of frontier) {
|
|
16
|
+
if (seen.has(f)) continue;
|
|
17
|
+
seen.add(f);
|
|
18
|
+
for (const dep of graph.dependents.get(f) ?? []) {
|
|
19
|
+
if (!seen.has(dep)) next.add(dep);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
frontier = next;
|
|
23
|
+
}
|
|
24
|
+
// A cycle can walk back to `file` itself — it's not its own dependent.
|
|
25
|
+
seen.delete(file);
|
|
26
|
+
return seen.size;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function blastRadius(
|
|
30
|
+
graph: ImportGraph,
|
|
31
|
+
files: string[],
|
|
32
|
+
): Record<string, BlastEntry> {
|
|
33
|
+
const out: Record<string, BlastEntry> = {};
|
|
34
|
+
for (const f of files) {
|
|
35
|
+
const deps = graph.dependents.get(f) ?? new Set<string>();
|
|
36
|
+
out[f] = {
|
|
37
|
+
dependents: deps.size,
|
|
38
|
+
tested: isTestedThroughBarrels(graph, f),
|
|
39
|
+
transitive: transitiveDependentsCount(graph, f),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Convenience for the MCP tools: graph the worktree live, map files[] to
|
|
46
|
+
// blast radius. Null when there is nothing to map or the dir is unreadable —
|
|
47
|
+
// callers render "no blast data", never throw.
|
|
48
|
+
export function blastRadiusForWorktree(
|
|
49
|
+
worktree: string,
|
|
50
|
+
files: string[],
|
|
51
|
+
): Record<string, BlastEntry> | null {
|
|
52
|
+
if (files.length === 0) return null;
|
|
53
|
+
try {
|
|
54
|
+
return blastRadius(buildGraph(worktree), files);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// src/analyze/cache.ts — session-scoped graph cache.
|
|
2
|
+
//
|
|
3
|
+
// buildGraph is cheap on this repo (~50ms/150 files) but the Edit hint calls it
|
|
4
|
+
// on every Edit — and a Claude Code PreToolUse hook is a *fresh process per
|
|
5
|
+
// tool call*, so an in-process cache alone never survives to the next edit. The
|
|
6
|
+
// graph is therefore mirrored to <faponyDir>/graph-cache/<key>.json:
|
|
7
|
+
// - auto-build: every build is written through, best-effort (never blocks)
|
|
8
|
+
// - auto-invalidate: a fingerprint over the source-file set (rel path + size
|
|
9
|
+
// + mtimeMs) is stored beside the graph; a mismatch means rebuild
|
|
10
|
+
// - cost: the cache file is only stat-ed when there is one to validate, so a
|
|
11
|
+
// first-ever call pays build + write; later calls pay a walk + stat + parse,
|
|
12
|
+
// well under a rebuild on any repo large enough for this to matter
|
|
13
|
+
// Everything here is derived state — an unreadable, corrupt, stale or
|
|
14
|
+
// unwritable cache falls back to a live build and no code path trusts it.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
statSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { faponyDir } from "../core/config.js";
|
|
27
|
+
import { collectSourceFiles } from "./discover.js";
|
|
28
|
+
import { buildGraph } from "./graph.js";
|
|
29
|
+
import type { ImportGraph } from "./types.js";
|
|
30
|
+
|
|
31
|
+
const GRAPH_CACHE_VERSION = 1;
|
|
32
|
+
const GRAPH_CACHE_DIR = "graph-cache";
|
|
33
|
+
|
|
34
|
+
interface SerializedGraph {
|
|
35
|
+
v: number;
|
|
36
|
+
fp: string;
|
|
37
|
+
files: string[];
|
|
38
|
+
deps: Record<string, string[]>;
|
|
39
|
+
dependents: Record<string, string[]>;
|
|
40
|
+
unresolved: number;
|
|
41
|
+
external: number;
|
|
42
|
+
barrels: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let _graphCache: { dir: string; fp: string; graph: ImportGraph } | null = null;
|
|
46
|
+
|
|
47
|
+
/** Drop the in-process graph cache (tests simulate a fresh hook process). */
|
|
48
|
+
export function resetGraphCache(): void {
|
|
49
|
+
_graphCache = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Absolute path of a worktree's graph-cache file — may not exist. */
|
|
53
|
+
export function graphCachePath(dir: string): string {
|
|
54
|
+
const abs = resolve(dir);
|
|
55
|
+
const slug = abs.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 60);
|
|
56
|
+
return join(
|
|
57
|
+
faponyDir(),
|
|
58
|
+
GRAPH_CACHE_DIR,
|
|
59
|
+
`${slug}-${Bun.hash(abs).toString(36)}.json`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The graph changes only when the set of source files or their bytes change —
|
|
64
|
+
// size/mtime/ctime catch that without reading any file. ctime rides the same
|
|
65
|
+
// stat call for free and cannot be forged like mtime can (only the system
|
|
66
|
+
// moves it), so an mtime-preserving rewrite still invalidates.
|
|
67
|
+
function graphFingerprint(absDir: string): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
for (const rel of collectSourceFiles(absDir)) {
|
|
70
|
+
try {
|
|
71
|
+
const st = statSync(join(absDir, rel));
|
|
72
|
+
parts.push(
|
|
73
|
+
`${rel}\u0000${st.size}\u0000${st.mtimeMs}\u0000${st.ctimeMs}`,
|
|
74
|
+
);
|
|
75
|
+
} catch {
|
|
76
|
+
parts.push(`${rel}\u0000?\u0000?\u0000?`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Bun.hash(parts.join("\n")).toString(36);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function serializeGraph(graph: ImportGraph, fp: string): SerializedGraph {
|
|
83
|
+
const rec = (m: Map<string, Set<string>>): Record<string, string[]> => {
|
|
84
|
+
const out: Record<string, string[]> = {};
|
|
85
|
+
for (const [k, v] of m) out[k] = [...v];
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
v: GRAPH_CACHE_VERSION,
|
|
90
|
+
fp,
|
|
91
|
+
files: graph.files,
|
|
92
|
+
deps: rec(graph.deps),
|
|
93
|
+
dependents: rec(graph.dependents),
|
|
94
|
+
unresolved: graph.unresolved,
|
|
95
|
+
external: graph.external,
|
|
96
|
+
barrels: [...graph.barrels],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hydrateGraph(c: SerializedGraph): ImportGraph {
|
|
101
|
+
const toMap = (r: Record<string, string[]>): Map<string, Set<string>> => {
|
|
102
|
+
const m = new Map<string, Set<string>>();
|
|
103
|
+
for (const [k, v] of Object.entries(r)) m.set(k, new Set(v));
|
|
104
|
+
return m;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
files: c.files,
|
|
108
|
+
deps: toMap(c.deps),
|
|
109
|
+
dependents: toMap(c.dependents),
|
|
110
|
+
unresolved: c.unresolved,
|
|
111
|
+
external: c.external,
|
|
112
|
+
barrels: new Set(c.barrels),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readCachedGraph(path: string, fp: string): ImportGraph | null {
|
|
117
|
+
try {
|
|
118
|
+
const cached = JSON.parse(readFileSync(path, "utf-8")) as SerializedGraph;
|
|
119
|
+
if (cached.v !== GRAPH_CACHE_VERSION || cached.fp !== fp) return null;
|
|
120
|
+
return hydrateGraph(cached);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeCachedGraph(path: string, graph: ImportGraph, fp: string): void {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(join(faponyDir(), GRAPH_CACHE_DIR))) {
|
|
129
|
+
mkdirSync(join(faponyDir(), GRAPH_CACHE_DIR), { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
// pid-suffixed temp + rename: a reader never sees a half-written file even
|
|
132
|
+
// when two hook processes race.
|
|
133
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
134
|
+
writeFileSync(tmp, JSON.stringify(serializeGraph(graph, fp)), "utf-8");
|
|
135
|
+
renameSync(tmp, path);
|
|
136
|
+
} catch {
|
|
137
|
+
// best-effort — a cache that cannot be written must not break the caller
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildGraphCached(dir: string): ImportGraph {
|
|
142
|
+
const abs = resolve(dir);
|
|
143
|
+
// One walk per call: the fingerprint doubles as the in-process validity
|
|
144
|
+
// check, so a same-process second call after an edit rebuilds instead of
|
|
145
|
+
// serving the stale graph. A drift between this fp and the built graph
|
|
146
|
+
// self-heals — the next call recomputes and rebuilds again.
|
|
147
|
+
const fp = graphFingerprint(abs);
|
|
148
|
+
if (_graphCache?.dir === abs && _graphCache.fp === fp)
|
|
149
|
+
return _graphCache.graph;
|
|
150
|
+
const path = graphCachePath(abs);
|
|
151
|
+
if (existsSync(path)) {
|
|
152
|
+
const cached = readCachedGraph(path, fp);
|
|
153
|
+
if (cached) {
|
|
154
|
+
_graphCache = { dir: abs, fp, graph: cached };
|
|
155
|
+
return cached;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const graph = buildGraph(abs);
|
|
159
|
+
_graphCache = { dir: abs, fp, graph };
|
|
160
|
+
writeCachedGraph(path, graph, fp);
|
|
161
|
+
return graph;
|
|
162
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/analyze/cli.ts — `fapony analyze` CLI entry.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { diagnose } from "./diagnose.js";
|
|
7
|
+
import { formatAnalyze } from "./format.js";
|
|
8
|
+
import { buildGraph } from "./graph.js";
|
|
9
|
+
import type { ImportGraph } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export function cmdAnalyze(args: string[]): void {
|
|
12
|
+
const dir = args[0] ?? ".";
|
|
13
|
+
const absDir = resolve(dir);
|
|
14
|
+
if (!existsSync(absDir)) {
|
|
15
|
+
console.error(`fapony analyze: "${dir}" does not exist`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
let graph: ImportGraph;
|
|
19
|
+
try {
|
|
20
|
+
graph = buildGraph(absDir);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(
|
|
23
|
+
`fapony analyze: cannot scan "${dir}": ${e instanceof Error ? e.message : String(e)}`,
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
console.log(formatAnalyze(graph, diagnose(graph)));
|
|
28
|
+
}
|