fapony 0.4.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.
Files changed (56) hide show
  1. package/README.md +201 -431
  2. package/package.json +1 -1
  3. package/skill/lookup-before-edit/SKILL.md +2 -2
  4. package/skill/plan-with-pony/SKILL.md +3 -1
  5. package/skill/review-pony/SKILL.md +2 -2
  6. package/src/adapters/cli.ts +1 -1
  7. package/src/adapters/hooks/bug-markers.ts +19 -9
  8. package/src/adapters/hooks/compute-hint-impact.ts +12 -1
  9. package/src/adapters/hooks/context-data.ts +32 -6
  10. package/src/adapters/hooks/edit-hint.ts +11 -1
  11. package/src/adapters/hooks/index.ts +15 -0
  12. package/src/adapters/hooks/read-hint.ts +11 -1
  13. package/src/adapters/hooks/stop.ts +347 -7
  14. package/src/adapters/mcp/primitives.ts +1 -1
  15. package/src/adapters/mcp/tools/check.ts +1 -1
  16. package/src/adapters/mcp/tools/collect.ts +1 -1
  17. package/src/adapters/mcp/tools/index.ts +13 -1
  18. package/src/adapters/mcp/tools/mem.ts +30 -5
  19. package/src/adapters/mcp/tools/report.ts +1 -1
  20. package/src/adapters/mcp/transport.ts +1 -1
  21. package/src/analyze/barrels.ts +56 -0
  22. package/src/analyze/blast.ts +58 -0
  23. package/src/analyze/cache.ts +162 -0
  24. package/src/analyze/cli.ts +28 -0
  25. package/src/analyze/criteria.ts +81 -0
  26. package/src/analyze/diagnose.ts +113 -0
  27. package/src/analyze/discover.ts +84 -0
  28. package/src/analyze/format.ts +38 -0
  29. package/src/analyze/graph.ts +111 -0
  30. package/src/analyze/index.ts +18 -0
  31. package/src/analyze/python.ts +411 -0
  32. package/src/analyze/resolve-ts.ts +39 -0
  33. package/src/analyze/types.ts +44 -0
  34. package/src/conventions-seed.ts +6 -2
  35. package/src/core/hint-log.ts +16 -1
  36. package/src/core/mem-log.ts +18 -0
  37. package/src/debt/cli.ts +25 -10
  38. package/src/debt/scan.ts +1 -1
  39. package/src/hook.ts +15 -0
  40. package/src/install/antigravity.ts +21 -8
  41. package/src/install/detect.ts +8 -5
  42. package/src/install/opencode.ts +6 -0
  43. package/src/install.ts +6 -4
  44. package/src/map.ts +220 -0
  45. package/src/mem/commands/plan.ts +70 -0
  46. package/src/mem/commands/read.ts +73 -18
  47. package/src/mem/commands/write.ts +75 -18
  48. package/src/mem/engine.ts +68 -2
  49. package/src/mem/index.ts +7 -5
  50. package/src/mem/render.ts +4 -1
  51. package/src/mem/selectors.ts +16 -0
  52. package/src/mem/store.ts +2 -0
  53. package/src/seed/plan-seed.ts +146 -13
  54. package/src/seed/review-seed.ts +144 -57
  55. package/templates/PLAN.md +1 -0
  56. package/src/analyze.ts +0 -688
@@ -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 ---
@@ -23,7 +23,8 @@ export const TOOLS = [
23
23
  "memDir shows which log dir was resolved (walked up from the given " +
24
24
  "worktree — in a monorepo pass the app directory to read its log). " +
25
25
  "Returns {rows, total, filesFound, " +
26
- "skipped, memDir}: memDir:null = no mem at all, not 'nothing matched'.",
26
+ "skipped, memDir}: memDir:null = no mem at all, not 'nothing matched'. " +
27
+ "A key query that misses adds knownKeys (every key in the log).",
27
28
  inputSchema: {
28
29
  type: "object" as const,
29
30
  properties: {
@@ -64,6 +65,11 @@ export const TOOLS = [
64
65
  description:
65
66
  "true = unresolved work only: drops close/claim/release/synced rows and work rows already closed. Omit = every row. With kind:[bug] answers 'what bugs remain?'",
66
67
  },
68
+ key: {
69
+ type: "string",
70
+ description:
71
+ "Exact problem-identity match — miss returns knownKeys (every key in the log). No pattern: a wrong-pattern query must reach the server",
72
+ },
67
73
  },
68
74
  required: ["worktree"],
69
75
  },
@@ -104,6 +110,12 @@ export const TOOLS = [
104
110
  type: "string",
105
111
  description: "Optional spec/plan .md path",
106
112
  },
113
+ key: {
114
+ type: "string",
115
+ pattern: "^[a-z0-9-]{3,40}$",
116
+ description:
117
+ "Problem identity (not kind, not files) — same problem, same key",
118
+ },
107
119
  },
108
120
  required: ["worktree", "kind", "text", "files"],
109
121
  },
@@ -23,6 +23,8 @@ export interface MemFindResult {
23
23
  filesFound: number;
24
24
  skipped: number;
25
25
  memDir: string | null;
26
+ /** Present only when a key query missed — every distinct key in the log. */
27
+ knownKeys?: string[];
26
28
  }
27
29
 
28
30
  export function memFind(args: {
@@ -33,6 +35,7 @@ export function memFind(args: {
33
35
  since?: string;
34
36
  limit?: number;
35
37
  open?: boolean;
38
+ key?: string;
36
39
  }): MemFindResult {
37
40
  // Query logic lives in the shared engine (src/mem/engine.ts) — this wrapper
38
41
  // owns only the read (readMemLog sees live + rotated archives via its loose
@@ -40,22 +43,24 @@ export function memFind(args: {
40
43
  // omitting kind returns every kind (locked by test). CLI cmdFind calls the
41
44
  // same engine with its own bookkeeping exclude.
42
45
  const read = readMemLog(args.worktree);
43
- const { rows: matched, total } = engineFind(read.rows, {
46
+ const found = engineFind(read.rows, {
44
47
  text: args.text,
45
48
  files: args.files,
46
49
  kind: args.kind,
47
50
  sinceIso: args.since,
51
+ key: args.key,
48
52
  limit: args.limit,
49
53
  open: args.open,
50
54
  });
51
55
  return {
52
- rows: matched,
53
- total,
56
+ rows: found.rows,
57
+ total: found.total,
54
58
  filesFound: read.filesFound,
55
59
  skipped: read.skipped,
56
60
  // memDir separates "no mem at all" from "nothing matched" (spec §3) and
57
61
  // makes the monorepo single-log limit visible (spec §5.1).
58
62
  memDir: resolveMemDir(args.worktree),
63
+ ...(found.knownKeys ? { knownKeys: found.knownKeys } : {}),
59
64
  };
60
65
  }
61
66
 
@@ -93,9 +98,18 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
93
98
  }
94
99
  const limit = typeof args.limit === "number" ? args.limit : undefined;
95
100
  const open = typeof args.open === "boolean" ? args.open : undefined;
101
+ // Shape gate like mem_add — a non-string key must not coerce to undefined
102
+ // (silent drop = reject-without-saying). Pattern is NOT checked here: find
103
+ // answers a wrong-pattern key with knownKeys, not a reject (SPEC fail example).
104
+ if (args.key !== undefined && typeof args.key !== "string") {
105
+ return errorResult(
106
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
107
+ );
108
+ }
109
+ const key = typeof args.key === "string" ? args.key : undefined;
96
110
 
97
111
  return jsonResult(
98
- memFind({ worktree, files, text, kind, since, limit, open }),
112
+ memFind({ worktree, files, text, kind, since, limit, open, key }),
99
113
  );
100
114
  }
101
115
 
@@ -112,6 +126,7 @@ export function memAdd(args: {
112
126
  text: string;
113
127
  files: string[];
114
128
  spec?: string;
129
+ key?: string;
115
130
  }): MemAddResult {
116
131
  initStore(args.worktree);
117
132
  return engineAdd({
@@ -119,6 +134,7 @@ export function memAdd(args: {
119
134
  text: args.text,
120
135
  files: args.files,
121
136
  spec: args.spec,
137
+ key: args.key,
122
138
  });
123
139
  }
124
140
 
@@ -166,8 +182,17 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
166
182
  ? args.spec.trim()
167
183
  : undefined;
168
184
 
185
+ // Shape gate here, pattern check in engineAdd — a non-string key must not
186
+ // slip through as undefined (silent drop = reject-without-saying, SPEC §Validation).
187
+ if (args.key !== undefined && typeof args.key !== "string") {
188
+ return errorResult(
189
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
190
+ );
191
+ }
192
+ const key = typeof args.key === "string" ? args.key : undefined;
193
+
169
194
  try {
170
- const result = memAdd({ worktree, kind, text, files, spec });
195
+ const result = memAdd({ worktree, kind, text, files, spec, key });
171
196
  return jsonResult(result);
172
197
  } catch (e) {
173
198
  return errorResult(e instanceof Error ? e.message : String(e));
@@ -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";
@@ -19,7 +19,7 @@ import { errorResult, type ToolResult } from "./types.js";
19
19
 
20
20
  const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
21
21
 
22
- When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
22
+ When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session. Name a known problem with --key <a-z0-9-id> and recall every row for it via mem_find key.
23
23
 
24
24
  worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
25
25
 
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // src/analyze/diagnose.ts — `diagnose()`: hub-untested / orphan / cycle /
2
+ // changed-untested findings over a built graph.
3
+
4
+ import { isTestedThroughBarrels, isTestFile } from "./criteria.js";
5
+ import { isEntryPoint } from "./discover.js";
6
+ import type { Finding, FindingKind, ImportGraph } from "./types.js";
7
+
8
+ function findCycles(graph: ImportGraph): string[][] {
9
+ const cycles: string[][] = [];
10
+ const seen = new Set<string>();
11
+ const color = new Map<string, number>(); // 1 = on stack, 2 = done
12
+ const stack: string[] = [];
13
+
14
+ function visit(node: string): void {
15
+ if (cycles.length >= 20) return; // bound work, display caps at 5 anyway
16
+ color.set(node, 1);
17
+ stack.push(node);
18
+ for (const next of graph.deps.get(node) ?? []) {
19
+ if (cycles.length >= 20) break;
20
+ const c = color.get(next) ?? 0;
21
+ if (c === 1) {
22
+ const cycle = stack.slice(stack.indexOf(next));
23
+ const key = [...cycle].sort().join("|");
24
+ if (!seen.has(key)) {
25
+ seen.add(key);
26
+ cycles.push(cycle);
27
+ }
28
+ } else if (c === 0) {
29
+ visit(next);
30
+ }
31
+ }
32
+ stack.pop();
33
+ color.set(node, 2);
34
+ }
35
+
36
+ for (const f of graph.files) {
37
+ if ((color.get(f) ?? 0) === 0) visit(f);
38
+ }
39
+ return cycles;
40
+ }
41
+
42
+ const KIND_RANK: Record<FindingKind, number> = {
43
+ cycle: 0,
44
+ "hub-untested": 1,
45
+ "changed-untested": 2,
46
+ orphan: 3,
47
+ };
48
+
49
+ export function diagnose(
50
+ graph: ImportGraph,
51
+ changed: string[] = [],
52
+ ): Finding[] {
53
+ const findings: Finding[] = [];
54
+ const filesSet = new Set(graph.files);
55
+
56
+ for (const cycle of findCycles(graph)) {
57
+ findings.push({
58
+ kind: "cycle",
59
+ file: cycle.join(" ↔ "),
60
+ detail: "circular imports — refactoring either side breaks the other",
61
+ evidence: [...cycle, cycle[0]].join(" → "),
62
+ });
63
+ }
64
+
65
+ const hubUntested: { file: string; n: number }[] = [];
66
+ for (const f of graph.files) {
67
+ const deps = graph.dependents.get(f) ?? new Set<string>();
68
+ if (deps.size === 0) {
69
+ if (!isEntryPoint(f) && !isTestFile(f)) {
70
+ findings.push({
71
+ kind: "orphan",
72
+ file: f,
73
+ detail:
74
+ "no one imports it and it is not an entry point — dead code candidate",
75
+ evidence: "0 dependents",
76
+ });
77
+ }
78
+ } else if (deps.size >= 3 && !isTestedThroughBarrels(graph, f)) {
79
+ hubUntested.push({ file: f, n: deps.size });
80
+ }
81
+ }
82
+ hubUntested.sort((a, b) => b.n - a.n || (a.file < b.file ? -1 : 1));
83
+ for (const { file, n } of hubUntested) {
84
+ const deps = [...(graph.dependents.get(file) ?? [])].sort();
85
+ const shown = deps.slice(0, 3).join(", ");
86
+ const rest = n > 3 ? ` … (+${n - 3})` : "";
87
+ findings.push({
88
+ kind: "hub-untested",
89
+ file,
90
+ detail: `${n} files depend on it; no test imports it — edit here and nothing catches the break`,
91
+ evidence: `dependents: ${shown}${rest}`,
92
+ });
93
+ }
94
+
95
+ for (const c of changed) {
96
+ if (!filesSet.has(c)) continue;
97
+ const deps = graph.dependents.get(c) ?? new Set<string>();
98
+ if (!isTestedThroughBarrels(graph, c)) {
99
+ findings.push({
100
+ kind: "changed-untested",
101
+ file: c,
102
+ detail: `recently changed but no test depends on it (${deps.size} dependent) — nothing catches it if it breaks`,
103
+ evidence:
104
+ deps.size > 0
105
+ ? `dependents: ${[...deps].sort().join(", ")}`
106
+ : "0 dependents",
107
+ });
108
+ }
109
+ }
110
+
111
+ findings.sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind]);
112
+ return findings;
113
+ }