fapony 0.1.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/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
package/src/analyze.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// src/analyze.ts — `fapony analyze`: structural health diagnosis for a TS/JS project.
|
|
2
|
+
//
|
|
3
|
+
// One file on purpose (plan cap: ≤1 new file in src/). Computes a file-level
|
|
4
|
+
// import graph live with Bun.Transpiler.scan() — never persisted, no new table.
|
|
5
|
+
// Read-only: never writes
|
|
6
|
+
// into the analyzed directory.
|
|
7
|
+
//
|
|
8
|
+
// Same module also serves handoff_check / verification_report: blastRadius()
|
|
9
|
+
// turns facts.files[] into per-file { dependents, tested } facts.
|
|
10
|
+
|
|
11
|
+
import type { Dirent } from "node:fs";
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
dirname as posixDirname,
|
|
16
|
+
join as posixJoin,
|
|
17
|
+
normalize as posixNormalize,
|
|
18
|
+
} from "node:path/posix";
|
|
19
|
+
|
|
20
|
+
import { extractExports } from "./map.js";
|
|
21
|
+
|
|
22
|
+
// --- Types (mirror SPEC-analyze-checkup.md) ---
|
|
23
|
+
|
|
24
|
+
export interface ImportGraph {
|
|
25
|
+
/** Relative POSIX paths scanned. */
|
|
26
|
+
files: string[];
|
|
27
|
+
/** file → files it imports. */
|
|
28
|
+
deps: Map<string, Set<string>>;
|
|
29
|
+
/** file → files that import it (reverse). */
|
|
30
|
+
dependents: Map<string, Set<string>>;
|
|
31
|
+
/** Imports that could not be resolved (bare specifier / alias / builtin). */
|
|
32
|
+
/** Imports that may hide a real edge: relative misses, path aliases, bare
|
|
33
|
+
* package names, and files too broken to read. Worth warning about. */
|
|
34
|
+
unresolved: number;
|
|
35
|
+
/** `node:` / `bun:` builtins — never an edge between two project files, so
|
|
36
|
+
* counting them as "unresolved" only inflates the warning. */
|
|
37
|
+
external: number;
|
|
38
|
+
/** Files that only re-export (`export ... from`) — they hide the real importer. */
|
|
39
|
+
barrels: Set<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FindingKind =
|
|
43
|
+
| "hub-untested"
|
|
44
|
+
| "orphan"
|
|
45
|
+
| "cycle"
|
|
46
|
+
| "changed-untested";
|
|
47
|
+
|
|
48
|
+
export interface Finding {
|
|
49
|
+
kind: FindingKind;
|
|
50
|
+
file: string;
|
|
51
|
+
/** Human sentence — the thing people read. */
|
|
52
|
+
detail: string;
|
|
53
|
+
/** How to re-check it yourself (file names, cycle path). */
|
|
54
|
+
evidence: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BlastEntry {
|
|
58
|
+
dependents: number;
|
|
59
|
+
tested: boolean;
|
|
60
|
+
/** Dependents of dependents, transitively (cycle-safe, excludes the file itself). */
|
|
61
|
+
transitive: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// BFS over the reverse-edge map — one grep-and-recurse chain collapsed into
|
|
65
|
+
// one walk. `seen` makes cycles a no-op instead of an infinite loop.
|
|
66
|
+
function transitiveDependentsCount(graph: ImportGraph, file: string): number {
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
let frontier = graph.dependents.get(file) ?? new Set<string>();
|
|
69
|
+
while (frontier.size > 0) {
|
|
70
|
+
const next = new Set<string>();
|
|
71
|
+
for (const f of frontier) {
|
|
72
|
+
if (seen.has(f)) continue;
|
|
73
|
+
seen.add(f);
|
|
74
|
+
for (const dep of graph.dependents.get(f) ?? []) {
|
|
75
|
+
if (!seen.has(dep)) next.add(dep);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
frontier = next;
|
|
79
|
+
}
|
|
80
|
+
// A cycle can walk back to `file` itself — it's not its own dependent.
|
|
81
|
+
seen.delete(file);
|
|
82
|
+
return seen.size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Shared criteria ---
|
|
86
|
+
|
|
87
|
+
// Same regex collect.ts has always used for test detection — single source of
|
|
88
|
+
// truth now (collect.ts imports isTestFile from here). Do NOT diverge.
|
|
89
|
+
// Word-boundary aware: "test"/"spec" match only as whole path segments, not as
|
|
90
|
+
// substrings of other words (e.g. testing.ts, contest.ts, vitest/ do NOT match).
|
|
91
|
+
export const TEST_PATH_RE =
|
|
92
|
+
/(?:^|[/._-])(?:test|spec)(?:$|[/._-])|__tests__|\.test\.|\.spec\./i;
|
|
93
|
+
|
|
94
|
+
export function isTestFile(p: string): boolean {
|
|
95
|
+
return TEST_PATH_RE.test(p);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A file whose entire body is `export ... from "..."`. Detected because a test
|
|
99
|
+
// importing a barrel is still a test importing everything behind it — without
|
|
100
|
+
// this, every module under src/db/index.ts or src/stats.ts reads as untested.
|
|
101
|
+
const EXPORT_FROM_RE =
|
|
102
|
+
/export\s+(?:\*|\{[^}]*\}|type\s+\*|type\s+\{[^}]*\})(?:\s+as\s+[\w$]+)?\s+from\s*["'][^"']+["']\s*;?/g;
|
|
103
|
+
|
|
104
|
+
export function isBarrelSource(content: string): boolean {
|
|
105
|
+
const code = content
|
|
106
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
107
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
108
|
+
if (!/\bexport\b/.test(code)) return false;
|
|
109
|
+
EXPORT_FROM_RE.lastIndex = 0;
|
|
110
|
+
return code.replace(EXPORT_FROM_RE, "").trim() === "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Is any test file an importer of `file`, walking *through* barrels only?
|
|
114
|
+
// Barrels can nest (db/*.ts → db/index.ts → db.ts), so recurse, but never
|
|
115
|
+
// through a normal module — that would make every file in a tested repo
|
|
116
|
+
// count as tested and kill the signal entirely.
|
|
117
|
+
export function isTestedThroughBarrels(
|
|
118
|
+
graph: ImportGraph,
|
|
119
|
+
file: string,
|
|
120
|
+
seen = new Set<string>(),
|
|
121
|
+
): boolean {
|
|
122
|
+
if (seen.has(file)) return false;
|
|
123
|
+
seen.add(file);
|
|
124
|
+
for (const dep of graph.dependents.get(file) ?? []) {
|
|
125
|
+
if (isTestFile(dep)) return true;
|
|
126
|
+
if (graph.barrels.has(dep) && isTestedThroughBarrels(graph, dep, seen))
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
133
|
+
|
|
134
|
+
// Always skipped, hardcoded — no config (per plan: no .faponyignore in v1).
|
|
135
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
|
|
136
|
+
|
|
137
|
+
// A nested checkout (clone or `git worktree add`) is a different project that
|
|
138
|
+
// happens to live inside this one — walking it doubles the graph and makes every
|
|
139
|
+
// single-directory pattern look like it repeats across two. `parentDir` is
|
|
140
|
+
// required so this can be detected rather than guessed from the name: prefixes
|
|
141
|
+
// like `wt-`/`cl-` are one person's convention, `.git` is the actual invariant
|
|
142
|
+
// (a dir for a clone, a file for a worktree — existsSync covers both).
|
|
143
|
+
export function isSkippedDir(name: string, parentDir: string): boolean {
|
|
144
|
+
if (SKIP_DIRS.has(name)) return true;
|
|
145
|
+
return existsSync(join(parentDir, name, ".git"));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isEntryPoint(rel: string): boolean {
|
|
149
|
+
const base = rel.slice(rel.lastIndexOf("/") + 1);
|
|
150
|
+
return base === "fapony.ts" || base === "index.ts";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- File discovery (manual walk, not Bun.Glob) ---
|
|
154
|
+
|
|
155
|
+
export function collectSourceFiles(
|
|
156
|
+
absDir: string,
|
|
157
|
+
opts?: { skipHidden?: boolean },
|
|
158
|
+
): string[] {
|
|
159
|
+
const out: string[] = [];
|
|
160
|
+
const stack: string[] = [absDir];
|
|
161
|
+
while (stack.length > 0) {
|
|
162
|
+
const dir = stack.pop() as string;
|
|
163
|
+
let entries: Dirent[];
|
|
164
|
+
try {
|
|
165
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
166
|
+
} catch {
|
|
167
|
+
continue; // unreadable dir → skip, never throw
|
|
168
|
+
}
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
// Never follow symlinks — loop-proof without extra code.
|
|
171
|
+
if (e.isSymbolicLink()) continue;
|
|
172
|
+
if (e.isDirectory()) {
|
|
173
|
+
if (isSkippedDir(e.name, dir)) continue;
|
|
174
|
+
if (opts?.skipHidden && e.name.startsWith(".")) continue;
|
|
175
|
+
stack.push(join(dir, e.name));
|
|
176
|
+
} else if (e.isFile()) {
|
|
177
|
+
const dot = e.name.lastIndexOf(".");
|
|
178
|
+
if (dot >= 0 && SCAN_EXTS.has(e.name.slice(dot))) {
|
|
179
|
+
out.push(relative(absDir, join(dir, e.name)).split(sep).join("/"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out.sort();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Import resolution ---
|
|
188
|
+
|
|
189
|
+
const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;
|
|
190
|
+
const IMPORT_TYPE_RE = /\bimport\s+type\s+[^;]*?\bfrom\s*["']([^"']+)["']/g;
|
|
191
|
+
|
|
192
|
+
const RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx"];
|
|
193
|
+
|
|
194
|
+
function resolveRelative(
|
|
195
|
+
importerRel: string,
|
|
196
|
+
raw: string,
|
|
197
|
+
filesSet: Set<string>,
|
|
198
|
+
): string | null {
|
|
199
|
+
const base = posixNormalize(posixJoin(posixDirname(importerRel), raw));
|
|
200
|
+
const candidates = new Set<string>([base, `${base}/index.ts`]);
|
|
201
|
+
for (const e of RESOLVE_EXTS) candidates.add(base + e);
|
|
202
|
+
// TS files import the compiled path ("./foo.js") — try the stem too.
|
|
203
|
+
const dot = base.lastIndexOf(".");
|
|
204
|
+
if (dot > base.lastIndexOf("/")) {
|
|
205
|
+
const stem = base.slice(0, dot);
|
|
206
|
+
for (const e of RESOLVE_EXTS) candidates.add(stem + e);
|
|
207
|
+
candidates.add(`${stem}/index.ts`);
|
|
208
|
+
}
|
|
209
|
+
for (const c of candidates) {
|
|
210
|
+
if (filesSet.has(c)) return c;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Exports, seen through barrels ---
|
|
216
|
+
|
|
217
|
+
// `export * from "./x"` is reported by Bun.Transpiler.scan as an IMPORT edge and
|
|
218
|
+
// never as an export, so a barrel file scans as having zero exports. Measured on
|
|
219
|
+
// vela 2026-09-18: 211 barrels out of 1,996 source files, and `@innominix/ui`
|
|
220
|
+
// alone is imported 462 times — the blind spot hides most of the cross-package
|
|
221
|
+
// graph, which is why a wrapper reached through a barrel reads as unused. Named
|
|
222
|
+
// re-exports (`export { x } from "./y"`) are already reported correctly; only the
|
|
223
|
+
// star form needs this. Cost to close it: 1.9ms on a 17-export barrel. tsc answers
|
|
224
|
+
// the same question type-accurately for 2,176ms — see SPEC-convention-debt.md §2.2
|
|
225
|
+
// for why that 1,145x is not worth paying here.
|
|
226
|
+
const STAR_REEXPORT_RE =
|
|
227
|
+
/^[ \t]*export\s+\*\s+(?:as\s+[\w$]+\s+)?from\s*["'](\.[^"']+)["']/gm;
|
|
228
|
+
|
|
229
|
+
export function exportsThroughBarrels(
|
|
230
|
+
absDir: string,
|
|
231
|
+
rel: string,
|
|
232
|
+
filesSet: Set<string>,
|
|
233
|
+
seen = new Set<string>(),
|
|
234
|
+
): string[] {
|
|
235
|
+
if (seen.has(rel)) return []; // barrels re-export each other; stop the cycle
|
|
236
|
+
seen.add(rel);
|
|
237
|
+
let source: string;
|
|
238
|
+
try {
|
|
239
|
+
source = readFileSync(join(absDir, rel), "utf-8");
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const out = extractExports(source)
|
|
244
|
+
.symbols.filter((s) => s.name !== "*")
|
|
245
|
+
.map((s) => s.name);
|
|
246
|
+
STAR_REEXPORT_RE.lastIndex = 0;
|
|
247
|
+
for (const m of source.matchAll(STAR_REEXPORT_RE)) {
|
|
248
|
+
const hit = resolveRelative(rel, m[1], filesSet);
|
|
249
|
+
if (hit) out.push(...exportsThroughBarrels(absDir, hit, filesSet, seen));
|
|
250
|
+
}
|
|
251
|
+
return [...new Set(out)];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function buildGraph(dir: string): ImportGraph {
|
|
255
|
+
const absDir = resolve(dir);
|
|
256
|
+
const files = collectSourceFiles(absDir);
|
|
257
|
+
const filesSet = new Set(files);
|
|
258
|
+
const deps = new Map<string, Set<string>>();
|
|
259
|
+
const dependents = new Map<string, Set<string>>();
|
|
260
|
+
for (const f of files) dependents.set(f, new Set());
|
|
261
|
+
const barrels = new Set<string>();
|
|
262
|
+
let unresolved = 0;
|
|
263
|
+
let external = 0;
|
|
264
|
+
|
|
265
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
266
|
+
|
|
267
|
+
for (const rel of files) {
|
|
268
|
+
let content: string;
|
|
269
|
+
try {
|
|
270
|
+
content = readFileSync(join(absDir, rel), "utf-8");
|
|
271
|
+
} catch {
|
|
272
|
+
unresolved++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (isBarrelSource(content)) barrels.add(rel);
|
|
276
|
+
const raws: string[] = [];
|
|
277
|
+
try {
|
|
278
|
+
const scanned = transpiler.scan(content) as {
|
|
279
|
+
imports: { path: string }[];
|
|
280
|
+
};
|
|
281
|
+
for (const imp of scanned.imports) raws.push(imp.path);
|
|
282
|
+
} catch {
|
|
283
|
+
// Syntax-broken file: skip it, count once — never fail the whole run.
|
|
284
|
+
unresolved++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Transpiler.scan blind spots: require() and `import type` are real edges
|
|
288
|
+
// (changing a type still shakes dependents), so pick them up by regex.
|
|
289
|
+
// No overlap with scan output above — scan reports neither form.
|
|
290
|
+
REQUIRE_RE.lastIndex = 0;
|
|
291
|
+
IMPORT_TYPE_RE.lastIndex = 0;
|
|
292
|
+
for (const m of content.matchAll(REQUIRE_RE)) raws.push(m[1]);
|
|
293
|
+
for (const m of content.matchAll(IMPORT_TYPE_RE)) raws.push(m[1]);
|
|
294
|
+
|
|
295
|
+
const edges = new Set<string>();
|
|
296
|
+
for (const raw of raws) {
|
|
297
|
+
if (raw.startsWith(".")) {
|
|
298
|
+
const hit = resolveRelative(rel, raw, filesSet);
|
|
299
|
+
if (hit) edges.add(hit);
|
|
300
|
+
else unresolved++;
|
|
301
|
+
} else if (raw.startsWith("node:") || raw.startsWith("bun:")) {
|
|
302
|
+
// A builtin is never an edge between two project files, so it can
|
|
303
|
+
// never be the reason a dependent count came out low. Lumping it in
|
|
304
|
+
// made the warning fire on every repo — 311 of this one's 312 were
|
|
305
|
+
// builtins — and a warning that always fires is not read.
|
|
306
|
+
external++;
|
|
307
|
+
} else {
|
|
308
|
+
// Package name or path alias — out of scope in v1, and unlike a
|
|
309
|
+
// builtin this one CAN be a project edge (`@app/core` in a monorepo).
|
|
310
|
+
unresolved++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
deps.set(rel, edges);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const [file, edgeSet] of deps) {
|
|
317
|
+
for (const dep of edgeSet) {
|
|
318
|
+
dependents.get(dep)?.add(file);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { files, deps, dependents, unresolved, external, barrels };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- Diagnosis ---
|
|
326
|
+
|
|
327
|
+
function findCycles(graph: ImportGraph): string[][] {
|
|
328
|
+
const cycles: string[][] = [];
|
|
329
|
+
const seen = new Set<string>();
|
|
330
|
+
const color = new Map<string, number>(); // 1 = on stack, 2 = done
|
|
331
|
+
const stack: string[] = [];
|
|
332
|
+
|
|
333
|
+
function visit(node: string): void {
|
|
334
|
+
if (cycles.length >= 20) return; // bound work, display caps at 5 anyway
|
|
335
|
+
color.set(node, 1);
|
|
336
|
+
stack.push(node);
|
|
337
|
+
for (const next of graph.deps.get(node) ?? []) {
|
|
338
|
+
if (cycles.length >= 20) break;
|
|
339
|
+
const c = color.get(next) ?? 0;
|
|
340
|
+
if (c === 1) {
|
|
341
|
+
const cycle = stack.slice(stack.indexOf(next));
|
|
342
|
+
const key = [...cycle].sort().join("|");
|
|
343
|
+
if (!seen.has(key)) {
|
|
344
|
+
seen.add(key);
|
|
345
|
+
cycles.push(cycle);
|
|
346
|
+
}
|
|
347
|
+
} else if (c === 0) {
|
|
348
|
+
visit(next);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
stack.pop();
|
|
352
|
+
color.set(node, 2);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const f of graph.files) {
|
|
356
|
+
if ((color.get(f) ?? 0) === 0) visit(f);
|
|
357
|
+
}
|
|
358
|
+
return cycles;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const KIND_RANK: Record<FindingKind, number> = {
|
|
362
|
+
cycle: 0,
|
|
363
|
+
"hub-untested": 1,
|
|
364
|
+
"changed-untested": 2,
|
|
365
|
+
orphan: 3,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export function diagnose(
|
|
369
|
+
graph: ImportGraph,
|
|
370
|
+
changed: string[] = [],
|
|
371
|
+
): Finding[] {
|
|
372
|
+
const findings: Finding[] = [];
|
|
373
|
+
const filesSet = new Set(graph.files);
|
|
374
|
+
|
|
375
|
+
for (const cycle of findCycles(graph)) {
|
|
376
|
+
findings.push({
|
|
377
|
+
kind: "cycle",
|
|
378
|
+
file: cycle.join(" ↔ "),
|
|
379
|
+
detail: "import วนกลับหากัน — refactor ฝั่งไหนก่อนก็พังอีกฝั่ง",
|
|
380
|
+
evidence: [...cycle, cycle[0]].join(" → "),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const hubUntested: { file: string; n: number }[] = [];
|
|
385
|
+
for (const f of graph.files) {
|
|
386
|
+
const deps = graph.dependents.get(f) ?? new Set<string>();
|
|
387
|
+
if (deps.size === 0) {
|
|
388
|
+
if (!isEntryPoint(f) && !isTestFile(f)) {
|
|
389
|
+
findings.push({
|
|
390
|
+
kind: "orphan",
|
|
391
|
+
file: f,
|
|
392
|
+
detail: "ไม่มีใคร import และไม่ใช่ entry point — dead code candidate",
|
|
393
|
+
evidence: "0 dependents",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
} else if (deps.size >= 3 && !isTestedThroughBarrels(graph, f)) {
|
|
397
|
+
hubUntested.push({ file: f, n: deps.size });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
hubUntested.sort((a, b) => b.n - a.n || (a.file < b.file ? -1 : 1));
|
|
401
|
+
for (const { file, n } of hubUntested) {
|
|
402
|
+
const deps = [...(graph.dependents.get(file) ?? [])].sort();
|
|
403
|
+
const shown = deps.slice(0, 3).join(", ");
|
|
404
|
+
const rest = n > 3 ? ` … (+${n - 3})` : "";
|
|
405
|
+
findings.push({
|
|
406
|
+
kind: "hub-untested",
|
|
407
|
+
file,
|
|
408
|
+
detail: `${n} ไฟล์พึ่งอยู่ ไม่มีเทสไหน import มันเลย — แก้ตรงนี้ไม่มีอะไรจับตอนพัง`,
|
|
409
|
+
evidence: `พึ่งอยู่: ${shown}${rest}`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const c of changed) {
|
|
414
|
+
if (!filesSet.has(c)) continue;
|
|
415
|
+
const deps = graph.dependents.get(c) ?? new Set<string>();
|
|
416
|
+
if (!isTestedThroughBarrels(graph, c)) {
|
|
417
|
+
findings.push({
|
|
418
|
+
kind: "changed-untested",
|
|
419
|
+
file: c,
|
|
420
|
+
detail: `เพิ่งแก้แต่ไม่มีเทสไหนพึ่งอยู่ (${deps.size} dependent) — พังแล้วไม่มีอะไรจับ`,
|
|
421
|
+
evidence:
|
|
422
|
+
deps.size > 0
|
|
423
|
+
? `พึ่งอยู่: ${[...deps].sort().join(", ")}`
|
|
424
|
+
: "0 dependents",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
findings.sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind]);
|
|
430
|
+
return findings;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --- Blast radius (for handoff_check / verification_report) ---
|
|
434
|
+
|
|
435
|
+
export function blastRadius(
|
|
436
|
+
graph: ImportGraph,
|
|
437
|
+
files: string[],
|
|
438
|
+
): Record<string, BlastEntry> {
|
|
439
|
+
const out: Record<string, BlastEntry> = {};
|
|
440
|
+
for (const f of files) {
|
|
441
|
+
const deps = graph.dependents.get(f) ?? new Set<string>();
|
|
442
|
+
out[f] = {
|
|
443
|
+
dependents: deps.size,
|
|
444
|
+
tested: isTestedThroughBarrels(graph, f),
|
|
445
|
+
transitive: transitiveDependentsCount(graph, f),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Convenience for the MCP tools: graph the worktree live, map files[] to
|
|
452
|
+
// blast radius. Null when there is nothing to map or the dir is unreadable —
|
|
453
|
+
// callers render "no blast data", never throw.
|
|
454
|
+
export function blastRadiusForWorktree(
|
|
455
|
+
worktree: string,
|
|
456
|
+
files: string[],
|
|
457
|
+
): Record<string, BlastEntry> | null {
|
|
458
|
+
if (files.length === 0) return null;
|
|
459
|
+
try {
|
|
460
|
+
return blastRadius(buildGraph(worktree), files);
|
|
461
|
+
} catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- CLI rendering (plain text only — no ANSI, pipes cleanly) ---
|
|
467
|
+
|
|
468
|
+
export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
|
|
469
|
+
let imports = 0;
|
|
470
|
+
for (const s of graph.deps.values()) imports += s.size;
|
|
471
|
+
const lines: string[] = [];
|
|
472
|
+
lines.push(
|
|
473
|
+
`fapony analyze — ${graph.files.length} files scanned (.ts/.tsx/.js/.jsx), ${imports} imports, ${graph.external} builtin, ${graph.unresolved} unresolved`,
|
|
474
|
+
);
|
|
475
|
+
lines.push("");
|
|
476
|
+
|
|
477
|
+
if (findings.length === 0) {
|
|
478
|
+
lines.push("no findings — โครงสร้างไม่มีอะไรน่าห่วง");
|
|
479
|
+
} else {
|
|
480
|
+
for (const f of findings.slice(0, 5)) {
|
|
481
|
+
const icon = f.kind === "orphan" ? "·" : "⚠";
|
|
482
|
+
lines.push(` ${icon} ${f.file}`);
|
|
483
|
+
lines.push(` ${f.detail}`);
|
|
484
|
+
lines.push(` ${f.evidence}`);
|
|
485
|
+
lines.push("");
|
|
486
|
+
}
|
|
487
|
+
const rest = findings.length - Math.min(findings.length, 5);
|
|
488
|
+
if (rest > 0) lines.push(`… and ${rest} more`);
|
|
489
|
+
lines.push(
|
|
490
|
+
graph.unresolved > 0
|
|
491
|
+
? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) — ตัวเลข dependent อาจต่ำกว่าจริง`
|
|
492
|
+
: `${findings.length} findings.`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return lines.join("\n");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- CLI entry ---
|
|
499
|
+
|
|
500
|
+
export function cmdAnalyze(args: string[]): void {
|
|
501
|
+
const dir = args[0] ?? ".";
|
|
502
|
+
const absDir = resolve(dir);
|
|
503
|
+
if (!existsSync(absDir)) {
|
|
504
|
+
console.error(`fapony analyze: "${dir}" does not exist`);
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
let graph: ImportGraph;
|
|
508
|
+
try {
|
|
509
|
+
graph = buildGraph(absDir);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
console.error(
|
|
512
|
+
`fapony analyze: cannot scan "${dir}": ${e instanceof Error ? e.message : String(e)}`,
|
|
513
|
+
);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
console.log(formatAnalyze(graph, diagnose(graph)));
|
|
517
|
+
}
|