fapony 0.5.0 → 0.6.0

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
@@ -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]
@@ -119,26 +121,28 @@ Stated up front, because the gap between these two things is where most tooling
119
121
 
120
122
  ## What runs where
121
123
 
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 | ✅ | ✅ | — | ✅ | ✅ |
124
+ `fapony install` wires six clients (Claude Code, OpenCode, Cursor, ZCode, Codex, Antigravity). MCP is
125
+ the only piece all of them get — the hooks and in-process hints are per-client, and the read/edit
126
+ hints arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate
127
+ channel is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still
128
+ answers.
129
+
130
+ | | Claude Code | OpenCode | Cursor | ZCode | Codex | Antigravity |
131
+ |---|---|---|---|---|---|---|
132
+ | MCP tools — `mem_find` `mem_add` `mem_close` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
133
+ | Stop hook — refuse to end a turn with commits but no new mem row | ✅ | — | ✅ | — | ✅ after trust | — |
134
+ | Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — | — |
135
+ | Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — | — |
136
+ | Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — | — |
137
+ | Commit hint — `git commit` → record-a-mem-row nudge | — | ✅ after | — | — | — | — |
138
+ | Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — | — |
139
+ | Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ | ✅ |
140
+ | `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ | — |
138
141
 
139
142
  `—` 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.
143
+ `fapony install` tells you when. Antigravity gets MCP + skills now; its hook surface is still
144
+ evolving, and `usage-scan` can't read its session log yet. The hints live on hooks rather than MCP
145
+ on purpose — they must fire mid-turn without the agent deciding to call anything.
142
146
 
143
147
  ## The ledger — one habit, 3 tools
144
148
 
@@ -235,7 +239,7 @@ fapony price-scan # fetch model price table → prices.
235
239
  fapony usage-web [port] # usage comparison dashboard from cache
236
240
 
237
241
  # lookup (read-only, never touches state)
238
- fapony analyze [path] # live repo graph: hubs, orphans, cycles, changed-untested
242
+ fapony analyze [path] # live repo graph: hubs, orphans, cycles, changed-untested (TS/JS + Python .py/.pyi; stdlib→external, no sys.path)
239
243
  fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>] # scope facts for a review
240
244
  fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]... # write PLAN (+SPEC): frontmatter, capped sections, prior-art list
241
245
 
@@ -297,8 +301,9 @@ vendor-neutral skills (anything that reads stdin) · opt-in telemetry, off by de
297
301
  ([TELEMETRY.md](https://github.com/kire21b/fapony/blob/main/TELEMETRY.md) lists exactly what
298
302
  leaves the machine) · Bun-only; run state in SQLite via `bun:sqlite` (WAL mode).
299
303
 
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 —
304
+ **Not supported (yet):** PreToolUse hints on Cursor, ZCode, Codex or Antigravity — Cursor has no
305
+ such hook, the other two expose no in-process hook surface for read/edit hints, and Antigravity's
306
+ hook surface is still evolving. A hosted or shared ledger —
302
307
  `FAPONY_STATE_DIR` on a synced folder works as an experiment only; SQLite's WAL mode does not
303
308
  tolerate concurrent writers over NFS/Dropbox/iCloud Drive and can corrupt the db under real
304
309
  contention.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
 
@@ -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";
@@ -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";
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ // src/analyze/criteria.ts — "is this file tested?" criteria shared by
2
+ // diagnose, blast radius, and review-seed.
3
+ //
4
+ // Same regex collect.ts has always used for test detection — single source of
5
+ // truth now (collect.ts imports isTestFile from here). Do NOT diverge.
6
+
7
+ import type { ImportGraph } from "./types.js";
8
+
9
+ // Word-boundary aware: "test"/"spec" match only as whole path segments, not as
10
+ // substrings of other words (e.g. testing.ts, contest.ts, vitest/ do NOT match).
11
+ export const TEST_PATH_RE =
12
+ /(?:^|[/._-])(?:test|spec)(?:$|[/._-])|__tests__|\.test\.|\.spec\./i;
13
+
14
+ export function isTestFile(p: string): boolean {
15
+ return TEST_PATH_RE.test(p);
16
+ }
17
+
18
+ // A file whose entire body is `export ... from "..."`. Detected because a test
19
+ // importing a barrel is still a test importing everything behind it — without
20
+ // this, every module under src/db/index.ts or src/stats.ts reads as untested.
21
+ const EXPORT_FROM_RE =
22
+ /export\s+(?:\*|\{[^}]*\}|type\s+\*|type\s+\{[^}]*\})(?:\s+as\s+[\w$]+)?\s+from\s*["'][^"']+["']\s*;?/g;
23
+
24
+ export function isBarrelSource(content: string, rel?: string): boolean {
25
+ if (rel?.endsWith("__init__.py") || rel?.endsWith("__init__.pyi")) {
26
+ // A Python barrel re-exports instead of defining: every logical line is
27
+ // an import, a from-import, or the `__all__` assignment. Docstrings are
28
+ // stripped first (nearly every `__init__.py` has one); `#` is cut per
29
+ // line, which is safe here because import/`__all__` lines carry no `#`
30
+ // inside strings — only identifiers, dots, commas, and parens.
31
+ const code = content.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, "");
32
+ const logical: string[] = [];
33
+ let buf = "";
34
+ let open = 0;
35
+ const push = () => {
36
+ const t = buf.trim();
37
+ if (t) logical.push(t);
38
+ buf = "";
39
+ open = 0;
40
+ };
41
+ for (const rawLine of code.split("\n")) {
42
+ const line = rawLine.split("#")[0].trim();
43
+ if (!line) continue;
44
+ buf += (buf ? " " : "") + line;
45
+ open +=
46
+ (line.match(/[[(]/g) ?? []).length -
47
+ (line.match(/[\])]/g) ?? []).length;
48
+ if (!line.endsWith("\\") && open <= 0 && !line.endsWith(",")) push();
49
+ }
50
+ push();
51
+ if (logical.length === 0) return false;
52
+ return logical.every((l) =>
53
+ /^(?:import\s+[\w.]+|from\s+\.*[\w.]*\s+import\s+\S|__all__\s*=)/.test(l),
54
+ );
55
+ }
56
+ const code = content
57
+ .replace(/\/\*[\s\S]*?\*\//g, "")
58
+ .replace(/^[ \t]*\/\/.*$/gm, "");
59
+ if (!/\bexport\b/.test(code)) return false;
60
+ EXPORT_FROM_RE.lastIndex = 0;
61
+ return code.replace(EXPORT_FROM_RE, "").trim() === "";
62
+ }
63
+
64
+ // Is any test file an importer of `file`, walking *through* barrels only?
65
+ // Barrels can nest (db/*.ts → db/index.ts → db.ts), so recurse, but never
66
+ // through a normal module — that would make every file in a tested repo
67
+ // count as tested and kill the signal entirely.
68
+ export function isTestedThroughBarrels(
69
+ graph: ImportGraph,
70
+ file: string,
71
+ seen = new Set<string>(),
72
+ ): boolean {
73
+ if (seen.has(file)) return false;
74
+ seen.add(file);
75
+ for (const dep of graph.dependents.get(file) ?? []) {
76
+ if (isTestFile(dep)) return true;
77
+ if (graph.barrels.has(dep) && isTestedThroughBarrels(graph, dep, seen))
78
+ return true;
79
+ }
80
+ return false;
81
+ }