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.
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ // src/analyze/discover.ts — file discovery: which files enter the graph.
2
+ //
3
+ // Manual walk, not Bun.Glob. Always skipped dirs are hardcoded — no config
4
+ // (per plan: no .faponyignore in v1).
5
+
6
+ import type { Dirent } from "node:fs";
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { join, relative, sep } from "node:path";
9
+
10
+ export const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".pyi"]);
11
+
12
+ // "templates" for the same reason knip.json ignores templates/**: those files
13
+ // ship as a template copied into other repos by `fapony init-mem` and never
14
+ // have real importers here — scanning them produces false wrapper/orphan
15
+ // signals (measured: conventions-seed flagged 8 "wrappers" that were all
16
+ // src/mem/commands/*.ts helpers matched against unrelated identically-
17
+ // named calls elsewhere in the repo, e.g. "cmdDone() instead of done(").
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules",
20
+ "dist",
21
+ "build",
22
+ ".git",
23
+ "templates",
24
+ // A `.venv` holds thousands of third-party `.py` files — walking it would
25
+ // drown the graph the way node_modules would (which is skipped above).
26
+ // `__pycache__` needs no entry: it holds only `.pyc`, never scanned.
27
+ ".venv",
28
+ ]);
29
+
30
+ // A nested checkout (clone or `git worktree add`) is a different project that
31
+ // happens to live inside this one — walking it doubles the graph and makes every
32
+ // single-directory pattern look like it repeats across two. `parentDir` is
33
+ // required so this can be detected rather than guessed from the name: prefixes
34
+ // like `wt-`/`cl-` are one person's convention, `.git` is the actual invariant
35
+ // (a dir for a clone, a file for a worktree — existsSync covers both).
36
+ export function isSkippedDir(name: string, parentDir: string): boolean {
37
+ if (SKIP_DIRS.has(name)) return true;
38
+ return existsSync(join(parentDir, name, ".git"));
39
+ }
40
+
41
+ export function isEntryPoint(rel: string): boolean {
42
+ const base = rel.slice(rel.lastIndexOf("/") + 1);
43
+ return base === "fapony.ts" || base === "index.ts" || base === "__main__.py";
44
+ }
45
+
46
+ export function collectSourceFiles(
47
+ absDir: string,
48
+ opts?: { skipHidden?: boolean },
49
+ ): string[] {
50
+ const out: string[] = [];
51
+ const stack: string[] = [absDir];
52
+ while (stack.length > 0) {
53
+ const dir = stack.pop() as string;
54
+ let entries: Dirent[];
55
+ try {
56
+ entries = readdirSync(dir, { withFileTypes: true });
57
+ } catch {
58
+ continue; // unreadable dir → skip, never throw
59
+ }
60
+ for (const e of entries) {
61
+ // Never follow symlinks — loop-proof without extra code.
62
+ if (e.isSymbolicLink()) continue;
63
+ if (e.isDirectory()) {
64
+ if (isSkippedDir(e.name, dir)) continue;
65
+ if (opts?.skipHidden && e.name.startsWith(".")) continue;
66
+ stack.push(join(dir, e.name));
67
+ } else if (e.isFile()) {
68
+ const dot = e.name.lastIndexOf(".");
69
+ if (dot >= 0 && SCAN_EXTS.has(e.name.slice(dot))) {
70
+ out.push(relative(absDir, join(dir, e.name)).split(sep).join("/"));
71
+ }
72
+ }
73
+ }
74
+ }
75
+ out.sort();
76
+ // Shadow rule: `x.py` wins over `x.pyi` — the stub is a fallback for
77
+ // stub-only distributions, never a second node (otherwise dependents
78
+ // double-count). The dropped `.pyi` still resolves via candidates.
79
+ if (out.some((f) => f.endsWith(".pyi"))) {
80
+ const has = new Set(out);
81
+ return out.filter((f) => !f.endsWith(".pyi") || !has.has(f.slice(0, -1)));
82
+ }
83
+ return out;
84
+ }
@@ -0,0 +1,38 @@
1
+ // src/analyze/format.ts — CLI rendering (plain text only — no ANSI, pipes cleanly).
2
+
3
+ import type { Finding, ImportGraph } from "./types.js";
4
+
5
+ export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
6
+ let imports = 0;
7
+ for (const s of graph.deps.values()) imports += s.size;
8
+ const lines: string[] = [];
9
+ lines.push(
10
+ `fapony analyze — ${graph.files.length} files scanned (.ts/.tsx/.js/.jsx/.py), ${imports} imports, ${graph.external} builtin, ${graph.unresolved} unresolved`,
11
+ );
12
+ lines.push("");
13
+
14
+ if (graph.files.length === 0) {
15
+ // Nothing read is not "healthy" — an unsupported-only repo used to get a clean bill here.
16
+ lines.push(
17
+ "no supported source files found — nothing analyzed (only .ts/.tsx/.js/.jsx/.py are read)",
18
+ );
19
+ } else if (findings.length === 0) {
20
+ lines.push("no findings — structure looks healthy");
21
+ } else {
22
+ for (const f of findings.slice(0, 5)) {
23
+ const icon = f.kind === "orphan" ? "·" : "⚠";
24
+ lines.push(` ${icon} ${f.file}`);
25
+ lines.push(` ${f.detail}`);
26
+ lines.push(` ${f.evidence}`);
27
+ lines.push("");
28
+ }
29
+ const rest = findings.length - Math.min(findings.length, 5);
30
+ if (rest > 0) lines.push(`… and ${rest} more`);
31
+ lines.push(
32
+ graph.unresolved > 0
33
+ ? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) — dependent counts may be lower than reality`
34
+ : `${findings.length} findings.`,
35
+ );
36
+ }
37
+ return lines.join("\n");
38
+ }
@@ -0,0 +1,111 @@
1
+ // src/analyze/graph.ts — `buildGraph()`: file-level import graph, computed
2
+ // live with Bun.Transpiler.scan() — no new table. Read-only: never writes
3
+ // into the analyzed directory.
4
+ //
5
+ // Same module also serves handoff_check / verification_report: blastRadius()
6
+ // (see blast.ts) turns facts.files[] into per-file { dependents, tested } facts.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+
11
+ import { isBarrelSource } from "./criteria.js";
12
+ import { collectSourceFiles } from "./discover.js";
13
+ import {
14
+ buildPyModuleIndex,
15
+ PY_STDLIB,
16
+ pyRootSegment,
17
+ resolvePythonImport,
18
+ scanPythonImports,
19
+ } from "./python.js";
20
+ import { IMPORT_TYPE_RE, REQUIRE_RE, resolveRelative } from "./resolve-ts.js";
21
+ import type { ImportGraph } from "./types.js";
22
+
23
+ export function buildGraph(dir: string): ImportGraph {
24
+ const absDir = resolve(dir);
25
+ const files = collectSourceFiles(absDir);
26
+ const filesSet = new Set(files);
27
+ const deps = new Map<string, Set<string>>();
28
+ const dependents = new Map<string, Set<string>>();
29
+ for (const f of files) dependents.set(f, new Set());
30
+ const barrels = new Set<string>();
31
+ let unresolved = 0;
32
+ let external = 0;
33
+ // Built lazily — a TS-only repo never pays for it.
34
+ let pyIndex: Map<string, string> | null = null;
35
+
36
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
37
+
38
+ for (const rel of files) {
39
+ let content: string;
40
+ try {
41
+ content = readFileSync(join(absDir, rel), "utf-8");
42
+ } catch {
43
+ unresolved++;
44
+ continue;
45
+ }
46
+ if (isBarrelSource(content, rel)) barrels.add(rel);
47
+ if (rel.endsWith(".py") || rel.endsWith(".pyi")) {
48
+ // No Transpiler here — it can't parse Python. Relative imports resolve
49
+ // against the importer's package; same-repo absolute imports resolve
50
+ // against the module index. An absolute miss whose root is in PY_STDLIB
51
+ // is external; a third-party package or a real miss stays unresolved.
52
+ pyIndex ??= buildPyModuleIndex(filesSet);
53
+ const pyEdges = new Set<string>();
54
+ for (const imp of scanPythonImports(content)) {
55
+ const hits = resolvePythonImport(rel, imp, filesSet, pyIndex);
56
+ if (hits.size > 0) for (const h of hits) pyEdges.add(h);
57
+ else if (!imp.dots && imp.mod && PY_STDLIB.has(pyRootSegment(imp.mod)))
58
+ external++;
59
+ else unresolved++;
60
+ }
61
+ deps.set(rel, pyEdges);
62
+ continue;
63
+ }
64
+ const raws: string[] = [];
65
+ try {
66
+ const scanned = transpiler.scan(content) as {
67
+ imports: { path: string }[];
68
+ };
69
+ for (const imp of scanned.imports) raws.push(imp.path);
70
+ } catch {
71
+ // Syntax-broken file: skip it, count once — never fail the whole run.
72
+ unresolved++;
73
+ continue;
74
+ }
75
+ // Transpiler.scan blind spots: require() and `import type` are real edges
76
+ // (changing a type still shakes dependents), so pick them up by regex.
77
+ // No overlap with scan output above — scan reports neither form.
78
+ REQUIRE_RE.lastIndex = 0;
79
+ IMPORT_TYPE_RE.lastIndex = 0;
80
+ for (const m of content.matchAll(REQUIRE_RE)) raws.push(m[1]);
81
+ for (const m of content.matchAll(IMPORT_TYPE_RE)) raws.push(m[1]);
82
+
83
+ const edges = new Set<string>();
84
+ for (const raw of raws) {
85
+ if (raw.startsWith(".")) {
86
+ const hit = resolveRelative(rel, raw, filesSet);
87
+ if (hit) edges.add(hit);
88
+ else unresolved++;
89
+ } else if (raw.startsWith("node:") || raw.startsWith("bun:")) {
90
+ // A builtin is never an edge between two project files, so it can
91
+ // never be the reason a dependent count came out low. Lumping it in
92
+ // made the warning fire on every repo — 311 of this one's 312 were
93
+ // builtins — and a warning that always fires is not read.
94
+ external++;
95
+ } else {
96
+ // Package name or path alias — out of scope in v1, and unlike a
97
+ // builtin this one CAN be a project edge (`@app/core` in a monorepo).
98
+ unresolved++;
99
+ }
100
+ }
101
+ deps.set(rel, edges);
102
+ }
103
+
104
+ for (const [file, edgeSet] of deps) {
105
+ for (const dep of edgeSet) {
106
+ dependents.get(dep)?.add(file);
107
+ }
108
+ }
109
+
110
+ return { files, deps, dependents, unresolved, external, barrels };
111
+ }
@@ -0,0 +1,18 @@
1
+ // src/analyze/index.ts — barrel for `fapony analyze`: structural health
2
+ // diagnosis for a TS/JS project.
3
+ //
4
+ // Layout mirrors src/install/ and src/debt/: types + one file per concern,
5
+ // CLI entry in cli.ts. Callers import this barrel directly (same as debt/).
6
+
7
+ export * from "./barrels.js";
8
+ export * from "./blast.js";
9
+ export * from "./cache.js";
10
+ export * from "./cli.js";
11
+ export * from "./criteria.js";
12
+ export * from "./diagnose.js";
13
+ export * from "./discover.js";
14
+ export * from "./format.js";
15
+ export * from "./graph.js";
16
+ export * from "./python.js";
17
+ export * from "./resolve-ts.js";
18
+ export * from "./types.js";