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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # fapony
6
6
 
7
- [![npm](https://img.shields.io/npm/v/fapony.svg)](https://www.npmjs.com/package/fapony)
7
+ [![npm](https://img.shields.io/npm/v/fapony.svg)](https://www.npmjs.com/package/fapony) [![GitHub](https://img.shields.io/github/stars/kire21b/fapony.svg)](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 five clients (Claude Code, OpenCode, Cursor, ZCode, Codex). MCP is the only
123
- piece all of them get — the hooks and in-process hints are per-client, and the read/edit hints
124
- arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate channel
125
- is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still answers.
126
-
127
- | | Claude Code | OpenCode | Cursor | ZCode | Codex |
128
- |---|---|---|---|---|---|
129
- | MCP tools — `mem_find` `mem_add` `mem_close` | ✅ | ✅ | ✅ | ✅ | ✅ |
130
- | Stop hook — refuse to end a turn with commits but no new mem row | ✅ | — | ✅ | — | ✅ after trust |
131
- | Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — |
132
- | Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — |
133
- | Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — |
134
- | Commit hint — `git commit` → record-a-mem-row nudge | — | ✅ after | — | — | — |
135
- | Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — |
136
- | Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ |
137
- | `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ |
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. The hints live on hooks rather than MCP on purpose — they must
141
- fire mid-turn without the agent deciding to call anything.
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 Codex — Cursor has no such hook and
301
- the other two expose no in-process hook surface for read/edit hints. A hosted or shared ledger —
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.5.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
 
@@ -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/i,
26
- /\b(?:this|that|it)(?:'s| is) a bug\b/i,
27
- /\bbug\b\s*:/i,
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 = text.match(re);
37
- if (m) return m[0];
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
+ }