fapony 0.5.0 → 0.6.1
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 +32 -23
- 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 +7 -2
- package/src/adapters/hooks/edit-hint.ts +11 -1
- package/src/adapters/hooks/read-hint.ts +2 -1
- package/src/adapters/mcp/evidence.ts +8 -0
- 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 +170 -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 +47 -0
- package/src/conventions-seed.ts +6 -2
- package/src/debt/scan.ts +1 -1
- package/src/init.ts +116 -54
- package/src/install/antigravity.ts +3 -1
- package/src/install/opencode.ts +2 -2
- 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
|
@@ -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) && !graph.entries?.has(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,170 @@
|
|
|
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 { existsSync, 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
|
+
// Root pyproject.toml only: declared deps (normalized to import-name guesses)
|
|
24
|
+
// and `[project.scripts]` targets. A dist whose import name differs
|
|
25
|
+
// (scikit-learn → sklearn) stays unresolved — fail-safe toward "don't know".
|
|
26
|
+
function readPyproject(absDir: string): {
|
|
27
|
+
deps: Set<string>;
|
|
28
|
+
scripts: string[];
|
|
29
|
+
} {
|
|
30
|
+
const deps = new Set<string>();
|
|
31
|
+
const scripts: string[] = [];
|
|
32
|
+
const path = join(absDir, "pyproject.toml");
|
|
33
|
+
if (!existsSync(path)) return { deps, scripts };
|
|
34
|
+
let project: {
|
|
35
|
+
dependencies?: unknown;
|
|
36
|
+
"optional-dependencies"?: Record<string, unknown>;
|
|
37
|
+
scripts?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
project =
|
|
41
|
+
(
|
|
42
|
+
Bun.TOML.parse(readFileSync(path, "utf-8")) as {
|
|
43
|
+
project?: typeof project;
|
|
44
|
+
}
|
|
45
|
+
).project ?? {};
|
|
46
|
+
} catch {
|
|
47
|
+
return { deps, scripts };
|
|
48
|
+
}
|
|
49
|
+
const specs = [
|
|
50
|
+
project.dependencies,
|
|
51
|
+
...Object.values(project["optional-dependencies"] ?? {}),
|
|
52
|
+
].flatMap((v) => (Array.isArray(v) ? v : []));
|
|
53
|
+
for (const spec of specs) {
|
|
54
|
+
const name =
|
|
55
|
+
typeof spec === "string" && spec.match(/^\s*([A-Za-z0-9._-]+)/)?.[1];
|
|
56
|
+
if (name) deps.add(name.toLowerCase().replace(/[-.]/g, "_"));
|
|
57
|
+
}
|
|
58
|
+
for (const target of Object.values(project.scripts ?? {})) {
|
|
59
|
+
if (typeof target === "string") scripts.push(target.split(":")[0].trim());
|
|
60
|
+
}
|
|
61
|
+
return { deps, scripts };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PY_MAIN_RE = /^if\s+__name__\s*==\s*["']__main__["']\s*:/m;
|
|
65
|
+
|
|
66
|
+
export function buildGraph(dir: string): ImportGraph {
|
|
67
|
+
const absDir = resolve(dir);
|
|
68
|
+
const files = collectSourceFiles(absDir);
|
|
69
|
+
const filesSet = new Set(files);
|
|
70
|
+
const deps = new Map<string, Set<string>>();
|
|
71
|
+
const dependents = new Map<string, Set<string>>();
|
|
72
|
+
for (const f of files) dependents.set(f, new Set());
|
|
73
|
+
const barrels = new Set<string>();
|
|
74
|
+
let unresolved = 0;
|
|
75
|
+
let external = 0;
|
|
76
|
+
// Built lazily — a TS-only repo never pays for it.
|
|
77
|
+
let pyIndex: Map<string, string> | null = null;
|
|
78
|
+
let pyproject: ReturnType<typeof readPyproject> | null = null;
|
|
79
|
+
const entries = new Set<string>();
|
|
80
|
+
|
|
81
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
82
|
+
|
|
83
|
+
for (const rel of files) {
|
|
84
|
+
let content: string;
|
|
85
|
+
try {
|
|
86
|
+
content = readFileSync(join(absDir, rel), "utf-8");
|
|
87
|
+
} catch {
|
|
88
|
+
unresolved++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (isBarrelSource(content, rel)) barrels.add(rel);
|
|
92
|
+
if (rel.endsWith(".py") || rel.endsWith(".pyi")) {
|
|
93
|
+
// No Transpiler here — it can't parse Python. Relative imports resolve
|
|
94
|
+
// against the importer's package; same-repo absolute imports resolve
|
|
95
|
+
// against the module index. An absolute miss whose root is in PY_STDLIB
|
|
96
|
+
// is external; a third-party package or a real miss stays unresolved.
|
|
97
|
+
pyIndex ??= buildPyModuleIndex(filesSet);
|
|
98
|
+
pyproject ??= readPyproject(absDir);
|
|
99
|
+
if (PY_MAIN_RE.test(content)) entries.add(rel);
|
|
100
|
+
const pyEdges = new Set<string>();
|
|
101
|
+
for (const imp of scanPythonImports(content)) {
|
|
102
|
+
const hits = resolvePythonImport(rel, imp, filesSet, pyIndex);
|
|
103
|
+
if (hits.size > 0) for (const h of hits) pyEdges.add(h);
|
|
104
|
+
else if (
|
|
105
|
+
!imp.dots &&
|
|
106
|
+
imp.mod &&
|
|
107
|
+
(PY_STDLIB.has(pyRootSegment(imp.mod)) ||
|
|
108
|
+
pyproject.deps.has(pyRootSegment(imp.mod).toLowerCase()))
|
|
109
|
+
)
|
|
110
|
+
external++;
|
|
111
|
+
else unresolved++;
|
|
112
|
+
}
|
|
113
|
+
deps.set(rel, pyEdges);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const raws: string[] = [];
|
|
117
|
+
try {
|
|
118
|
+
const scanned = transpiler.scan(content) as {
|
|
119
|
+
imports: { path: string }[];
|
|
120
|
+
};
|
|
121
|
+
for (const imp of scanned.imports) raws.push(imp.path);
|
|
122
|
+
} catch {
|
|
123
|
+
// Syntax-broken file: skip it, count once — never fail the whole run.
|
|
124
|
+
unresolved++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Transpiler.scan blind spots: require() and `import type` are real edges
|
|
128
|
+
// (changing a type still shakes dependents), so pick them up by regex.
|
|
129
|
+
// No overlap with scan output above — scan reports neither form.
|
|
130
|
+
REQUIRE_RE.lastIndex = 0;
|
|
131
|
+
IMPORT_TYPE_RE.lastIndex = 0;
|
|
132
|
+
for (const m of content.matchAll(REQUIRE_RE)) raws.push(m[1]);
|
|
133
|
+
for (const m of content.matchAll(IMPORT_TYPE_RE)) raws.push(m[1]);
|
|
134
|
+
|
|
135
|
+
const edges = new Set<string>();
|
|
136
|
+
for (const raw of raws) {
|
|
137
|
+
if (raw.startsWith(".")) {
|
|
138
|
+
const hit = resolveRelative(rel, raw, filesSet);
|
|
139
|
+
if (hit) edges.add(hit);
|
|
140
|
+
else unresolved++;
|
|
141
|
+
} else if (raw.startsWith("node:") || raw.startsWith("bun:")) {
|
|
142
|
+
// A builtin is never an edge between two project files, so it can
|
|
143
|
+
// never be the reason a dependent count came out low. Lumping it in
|
|
144
|
+
// made the warning fire on every repo — 311 of this one's 312 were
|
|
145
|
+
// builtins — and a warning that always fires is not read.
|
|
146
|
+
external++;
|
|
147
|
+
} else {
|
|
148
|
+
// Package name or path alias — out of scope in v1, and unlike a
|
|
149
|
+
// builtin this one CAN be a project edge (`@app/core` in a monorepo).
|
|
150
|
+
unresolved++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
deps.set(rel, edges);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const [file, edgeSet] of deps) {
|
|
157
|
+
for (const dep of edgeSet) {
|
|
158
|
+
dependents.get(dep)?.add(file);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (pyIndex && pyproject) {
|
|
163
|
+
for (const mod of pyproject.scripts) {
|
|
164
|
+
const hit = pyIndex.get(mod);
|
|
165
|
+
if (hit) entries.add(hit);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { files, deps, dependents, unresolved, external, barrels, entries };
|
|
170
|
+
}
|
|
@@ -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";
|