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 +26 -21
- 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 +1 -1
- package/src/adapters/hooks/edit-hint.ts +1 -1
- package/src/adapters/hooks/read-hint.ts +1 -1
- 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 +111 -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 +44 -0
- package/src/conventions-seed.ts +6 -2
- package/src/debt/scan.ts +1 -1
- package/src/install/antigravity.ts +3 -1
- 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
|
@@ -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
|
|
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
|
-
|
|
|
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.
|
|
141
|
-
|
|
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
|
|
301
|
-
the other two expose no in-process hook surface for read/edit hints
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|