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.
- package/README.md +201 -431
- package/package.json +1 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/plan-with-pony/SKILL.md +3 -1
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/cli.ts +1 -1
- package/src/adapters/hooks/bug-markers.ts +19 -9
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +32 -6
- package/src/adapters/hooks/edit-hint.ts +11 -1
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +11 -1
- package/src/adapters/hooks/stop.ts +347 -7
- 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/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/tools/report.ts +1 -1
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/analyze/barrels.ts +56 -0
- package/src/analyze/blast.ts +58 -0
- package/src/analyze/cache.ts +162 -0
- package/src/analyze/cli.ts +28 -0
- package/src/analyze/criteria.ts +81 -0
- package/src/analyze/diagnose.ts +113 -0
- package/src/analyze/discover.ts +84 -0
- package/src/analyze/format.ts +38 -0
- package/src/analyze/graph.ts +111 -0
- package/src/analyze/index.ts +18 -0
- package/src/analyze/python.ts +411 -0
- package/src/analyze/resolve-ts.ts +39 -0
- package/src/analyze/types.ts +44 -0
- package/src/conventions-seed.ts +6 -2
- package/src/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/debt/scan.ts +1 -1
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +21 -8
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +6 -0
- package/src/install.ts +6 -4
- package/src/map.ts +220 -0
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +16 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +146 -13
- package/src/seed/review-seed.ts +144 -57
- package/templates/PLAN.md +1 -0
- package/src/analyze.ts +0 -688
|
@@ -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";
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// src/analyze/python.ts — Python import scanning + resolution.
|
|
2
|
+
//
|
|
3
|
+
// Line-based: Bun.Transpiler can't parse .py (see map.ts for why
|
|
4
|
+
// `python -c "import ast"` is not the answer). The module part always sits on
|
|
5
|
+
// the first line of a `from … import`, so a multi-line name list is a
|
|
6
|
+
// non-issue for the edge itself; the imported names only matter to resolve a
|
|
7
|
+
// submodule (`from . import sib` → `sib.py`), so a parenthesized list is
|
|
8
|
+
// joined across lines before its names are read.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
dirname as posixDirname,
|
|
12
|
+
join as posixJoin,
|
|
13
|
+
normalize as posixNormalize,
|
|
14
|
+
} from "node:path/posix";
|
|
15
|
+
|
|
16
|
+
import { maskPyBlocks } from "../map.js";
|
|
17
|
+
|
|
18
|
+
export interface PyImport {
|
|
19
|
+
/** Leading dots (`from ..x import`) — null for absolute imports. */
|
|
20
|
+
dots: string | null;
|
|
21
|
+
/** Dotted module path after the dots ("" for `from . import y`). */
|
|
22
|
+
mod: string;
|
|
23
|
+
/** Names bound by a `from … import`; empty for a plain `import`. */
|
|
24
|
+
names: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Frozen at the CPython 3.12 stdlib top-level set
|
|
28
|
+
// (https://docs.python.org/3.12/library/ — ~210 public names).
|
|
29
|
+
// Anything outside = `unresolved` on purpose: without `sys.path` a
|
|
30
|
+
// third-party package (`requests`) is indistinguishable from a miss.
|
|
31
|
+
export const PY_STDLIB: Set<string> = new Set([
|
|
32
|
+
"__future__",
|
|
33
|
+
"abc",
|
|
34
|
+
"aifc",
|
|
35
|
+
"argparse",
|
|
36
|
+
"array",
|
|
37
|
+
"ast",
|
|
38
|
+
"asynchat",
|
|
39
|
+
"asyncio",
|
|
40
|
+
"asyncore",
|
|
41
|
+
"atexit",
|
|
42
|
+
"audioop",
|
|
43
|
+
"base64",
|
|
44
|
+
"bdb",
|
|
45
|
+
"binascii",
|
|
46
|
+
"binhex",
|
|
47
|
+
"bisect",
|
|
48
|
+
"builtins",
|
|
49
|
+
"bz2",
|
|
50
|
+
"calendar",
|
|
51
|
+
"cgi",
|
|
52
|
+
"cgitb",
|
|
53
|
+
"chunk",
|
|
54
|
+
"cmath",
|
|
55
|
+
"cmd",
|
|
56
|
+
"code",
|
|
57
|
+
"codecs",
|
|
58
|
+
"codeop",
|
|
59
|
+
"collections",
|
|
60
|
+
"colorsys",
|
|
61
|
+
"compileall",
|
|
62
|
+
"concurrent",
|
|
63
|
+
"configparser",
|
|
64
|
+
"contextlib",
|
|
65
|
+
"contextvars",
|
|
66
|
+
"copy",
|
|
67
|
+
"copyreg",
|
|
68
|
+
"cProfile",
|
|
69
|
+
"crypt",
|
|
70
|
+
"csv",
|
|
71
|
+
"ctypes",
|
|
72
|
+
"curses",
|
|
73
|
+
"dataclasses",
|
|
74
|
+
"datetime",
|
|
75
|
+
"dbm",
|
|
76
|
+
"decimal",
|
|
77
|
+
"difflib",
|
|
78
|
+
"dis",
|
|
79
|
+
"doctest",
|
|
80
|
+
"email",
|
|
81
|
+
"encodings",
|
|
82
|
+
"ensurepip",
|
|
83
|
+
"enum",
|
|
84
|
+
"errno",
|
|
85
|
+
"faulthandler",
|
|
86
|
+
"fcntl",
|
|
87
|
+
"filecmp",
|
|
88
|
+
"fileinput",
|
|
89
|
+
"fnmatch",
|
|
90
|
+
"fractions",
|
|
91
|
+
"ftplib",
|
|
92
|
+
"functools",
|
|
93
|
+
"gc",
|
|
94
|
+
"getopt",
|
|
95
|
+
"getpass",
|
|
96
|
+
"gettext",
|
|
97
|
+
"glob",
|
|
98
|
+
"graphlib",
|
|
99
|
+
"grp",
|
|
100
|
+
"gzip",
|
|
101
|
+
"hashlib",
|
|
102
|
+
"heapq",
|
|
103
|
+
"hmac",
|
|
104
|
+
"html",
|
|
105
|
+
"http",
|
|
106
|
+
"idlelib",
|
|
107
|
+
"imaplib",
|
|
108
|
+
"imghdr",
|
|
109
|
+
"imp",
|
|
110
|
+
"importlib",
|
|
111
|
+
"inspect",
|
|
112
|
+
"io",
|
|
113
|
+
"ipaddress",
|
|
114
|
+
"itertools",
|
|
115
|
+
"json",
|
|
116
|
+
"keyword",
|
|
117
|
+
"lib2to3",
|
|
118
|
+
"linecache",
|
|
119
|
+
"locale",
|
|
120
|
+
"logging",
|
|
121
|
+
"lzma",
|
|
122
|
+
"mailbox",
|
|
123
|
+
"mailcap",
|
|
124
|
+
"marshal",
|
|
125
|
+
"math",
|
|
126
|
+
"mimetypes",
|
|
127
|
+
"mmap",
|
|
128
|
+
"modulefinder",
|
|
129
|
+
"multiprocessing",
|
|
130
|
+
"netrc",
|
|
131
|
+
"nis",
|
|
132
|
+
"nntplib",
|
|
133
|
+
"numbers",
|
|
134
|
+
"operator",
|
|
135
|
+
"opcode",
|
|
136
|
+
"optparse",
|
|
137
|
+
"os",
|
|
138
|
+
"ossaudiodev",
|
|
139
|
+
"pathlib",
|
|
140
|
+
"pdb",
|
|
141
|
+
"pickle",
|
|
142
|
+
"pickletools",
|
|
143
|
+
"pipes",
|
|
144
|
+
"pkgutil",
|
|
145
|
+
"platform",
|
|
146
|
+
"plistlib",
|
|
147
|
+
"poplib",
|
|
148
|
+
"posix",
|
|
149
|
+
"pprint",
|
|
150
|
+
"profile",
|
|
151
|
+
"pstats",
|
|
152
|
+
"pty",
|
|
153
|
+
"pwd",
|
|
154
|
+
"py_compile",
|
|
155
|
+
"pyclbr",
|
|
156
|
+
"pydoc",
|
|
157
|
+
"pyexpat",
|
|
158
|
+
"queue",
|
|
159
|
+
"quopri",
|
|
160
|
+
"random",
|
|
161
|
+
"re",
|
|
162
|
+
"readline",
|
|
163
|
+
"reprlib",
|
|
164
|
+
"resource",
|
|
165
|
+
"rlcompleter",
|
|
166
|
+
"runpy",
|
|
167
|
+
"sched",
|
|
168
|
+
"secrets",
|
|
169
|
+
"select",
|
|
170
|
+
"selectors",
|
|
171
|
+
"shelve",
|
|
172
|
+
"shlex",
|
|
173
|
+
"shutil",
|
|
174
|
+
"signal",
|
|
175
|
+
"site",
|
|
176
|
+
"smtpd",
|
|
177
|
+
"smtplib",
|
|
178
|
+
"sndhdr",
|
|
179
|
+
"socket",
|
|
180
|
+
"socketserver",
|
|
181
|
+
"sqlite3",
|
|
182
|
+
"ssl",
|
|
183
|
+
"stat",
|
|
184
|
+
"statistics",
|
|
185
|
+
"string",
|
|
186
|
+
"stringprep",
|
|
187
|
+
"struct",
|
|
188
|
+
"subprocess",
|
|
189
|
+
"sunau",
|
|
190
|
+
"symtable",
|
|
191
|
+
"sys",
|
|
192
|
+
"sysconfig",
|
|
193
|
+
"syslog",
|
|
194
|
+
"tabnanny",
|
|
195
|
+
"tarfile",
|
|
196
|
+
"telnetlib",
|
|
197
|
+
"tempfile",
|
|
198
|
+
"termios",
|
|
199
|
+
"test",
|
|
200
|
+
"textwrap",
|
|
201
|
+
"threading",
|
|
202
|
+
"time",
|
|
203
|
+
"timeit",
|
|
204
|
+
"tkinter",
|
|
205
|
+
"token",
|
|
206
|
+
"tokenize",
|
|
207
|
+
"tomllib",
|
|
208
|
+
"trace",
|
|
209
|
+
"traceback",
|
|
210
|
+
"tracemalloc",
|
|
211
|
+
"tty",
|
|
212
|
+
"turtle",
|
|
213
|
+
"types",
|
|
214
|
+
"typing",
|
|
215
|
+
"unicodedata",
|
|
216
|
+
"unittest",
|
|
217
|
+
"urllib",
|
|
218
|
+
"uuid",
|
|
219
|
+
"venv",
|
|
220
|
+
"warnings",
|
|
221
|
+
"wave",
|
|
222
|
+
"weakref",
|
|
223
|
+
"webbrowser",
|
|
224
|
+
"wsgiref",
|
|
225
|
+
"xdrlib",
|
|
226
|
+
"xml",
|
|
227
|
+
"xmlrpc",
|
|
228
|
+
"zipapp",
|
|
229
|
+
"zipfile",
|
|
230
|
+
"zipimport",
|
|
231
|
+
"zlib",
|
|
232
|
+
"zoneinfo",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
/** Root segment of an absolute import (`os.path` → `os`). */
|
|
236
|
+
export function pyRootSegment(mod: string): string {
|
|
237
|
+
const dot = mod.indexOf(".");
|
|
238
|
+
return dot === -1 ? mod : mod.slice(0, dot);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Imported names, before any `as` alias — the submodule is the name on the
|
|
242
|
+
// left, not the alias. `*` and non-identifiers are dropped.
|
|
243
|
+
export function pyImportNames(rest: string): string[] {
|
|
244
|
+
const clean = rest.replace(/[()]/g, " ");
|
|
245
|
+
const out: string[] = [];
|
|
246
|
+
for (let part of clean.split(",")) {
|
|
247
|
+
part = part.trim().split("#")[0].trim();
|
|
248
|
+
if (!part || part === "*") continue;
|
|
249
|
+
const name = part.split(/\s+as\s+/)[0].trim();
|
|
250
|
+
if (/^[A-Za-z_]\w*$/.test(name)) out.push(name);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function scanPythonImports(content: string): PyImport[] {
|
|
256
|
+
const out: PyImport[] = [];
|
|
257
|
+
// Blank strings/docstrings first — a `from .x import y` written inside one
|
|
258
|
+
// is sample text, not an edge.
|
|
259
|
+
const lines = maskPyBlocks(content).split("\n");
|
|
260
|
+
const unbalanced = (s: string): boolean =>
|
|
261
|
+
(s.match(/\(/g) ?? []).length > (s.match(/\)/g) ?? []).length;
|
|
262
|
+
for (let i = 0; i < lines.length; i++) {
|
|
263
|
+
// Safe to cut at the first `#`: import/from lines carry only
|
|
264
|
+
// identifiers, dots, commas, parens, `as`, and `*` — never a `#` string.
|
|
265
|
+
const line = lines[i].split("#")[0];
|
|
266
|
+
let m: RegExpMatchArray | null;
|
|
267
|
+
if ((m = line.match(/^[ \t]*from\s*(\.+)?([\w.]*)\s+import\s+(.+)/))) {
|
|
268
|
+
// A parenthesized name list often spans lines — join until it closes
|
|
269
|
+
// (cap 20), the same shape the export scan uses. Without this a
|
|
270
|
+
// `from . import (sub)` loses `sub` and the submodule reads as orphan.
|
|
271
|
+
let rest = m[3];
|
|
272
|
+
let j = i;
|
|
273
|
+
while (unbalanced(rest) && j + 1 < lines.length && j - i < 20) {
|
|
274
|
+
j++;
|
|
275
|
+
rest += ` ${lines[j].split("#")[0].trim()}`;
|
|
276
|
+
}
|
|
277
|
+
out.push({ dots: m[1] ?? null, mod: m[2], names: pyImportNames(rest) });
|
|
278
|
+
i = j;
|
|
279
|
+
} else if ((m = line.match(/^[ \t]*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/))) {
|
|
280
|
+
// Plain `import` is always absolute in Python 3 (relative needs `from`).
|
|
281
|
+
for (const part of m[1].split(",")) {
|
|
282
|
+
const name = part.trim().split(/\s+/)[0];
|
|
283
|
+
if (name) out.push({ dots: null, mod: name, names: [] });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Dots count folder levels: 1 = the importer's own dir, 2 = its parent, and
|
|
291
|
+
// so on. Each level then tries `x.py` and `x/__init__.py`.
|
|
292
|
+
export function resolvePythonRelative(
|
|
293
|
+
importerRel: string,
|
|
294
|
+
dots: string,
|
|
295
|
+
mod: string,
|
|
296
|
+
filesSet: Set<string>,
|
|
297
|
+
): string | null {
|
|
298
|
+
let dir = posixDirname(importerRel);
|
|
299
|
+
for (let i = 1; i < dots.length; i++) {
|
|
300
|
+
if (dir === ".") return null; // climbs above the scanned root
|
|
301
|
+
dir = posixDirname(dir);
|
|
302
|
+
}
|
|
303
|
+
const base = mod ? posixJoin(dir, ...mod.split(".")) : dir;
|
|
304
|
+
const norm = posixNormalize(base);
|
|
305
|
+
const candidates =
|
|
306
|
+
norm === "."
|
|
307
|
+
? ["__init__.py", "__init__.pyi"]
|
|
308
|
+
: mod
|
|
309
|
+
? [
|
|
310
|
+
`${norm}.py`,
|
|
311
|
+
`${norm}.pyi`,
|
|
312
|
+
`${norm}/__init__.py`,
|
|
313
|
+
`${norm}/__init__.pyi`,
|
|
314
|
+
]
|
|
315
|
+
: [`${norm}/__init__.py`, `${norm}/__init__.pyi`];
|
|
316
|
+
for (const c of candidates) {
|
|
317
|
+
if (filesSet.has(c)) return c;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Dotted module path → file, so an absolute `import pkg.mod` / `from pkg.mod
|
|
323
|
+
// import x` resolves when the target is in this repo. A file is indexed under
|
|
324
|
+
// each ancestor dir that is not itself a package (no `__init__.py`) — the repo
|
|
325
|
+
// root and a `src`-style root both qualify, so `mypkg.core` finds
|
|
326
|
+
// `src/mypkg/core.py` while a bare `core` never does. Shortest path wins a clash
|
|
327
|
+
// (which also prefers `x.py` over `x.pyi` when both are present).
|
|
328
|
+
export function buildPyModuleIndex(filesSet: Set<string>): Map<string, string> {
|
|
329
|
+
const index = new Map<string, string>();
|
|
330
|
+
const put = (mod: string, file: string): void => {
|
|
331
|
+
const prev = index.get(mod);
|
|
332
|
+
if (!prev || file.length < prev.length) index.set(mod, file);
|
|
333
|
+
};
|
|
334
|
+
for (const file of filesSet) {
|
|
335
|
+
const ext = file.endsWith(".pyi")
|
|
336
|
+
? ".pyi"
|
|
337
|
+
: file.endsWith(".py")
|
|
338
|
+
? ".py"
|
|
339
|
+
: null;
|
|
340
|
+
if (!ext) continue;
|
|
341
|
+
const parts = file.slice(0, -ext.length).split("/");
|
|
342
|
+
if (parts[parts.length - 1] === "__init__") parts.pop();
|
|
343
|
+
for (let start = 0; start < parts.length; start++) {
|
|
344
|
+
const rootDir = parts.slice(0, start).join("/");
|
|
345
|
+
const prefix = rootDir ? `${rootDir}/` : "";
|
|
346
|
+
if (
|
|
347
|
+
filesSet.has(`${prefix}__init__.py`) ||
|
|
348
|
+
filesSet.has(`${prefix}__init__.pyi`)
|
|
349
|
+
)
|
|
350
|
+
continue; // a package, not a root
|
|
351
|
+
put(parts.slice(start).join("."), file);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return index;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Every file a single import reaches: the module itself, plus each imported
|
|
358
|
+
// name that is a real submodule (`from . import sib` → `sib.py`; the package
|
|
359
|
+
// `__init__.py` is imported too, so it stays an edge). Self-edges are dropped
|
|
360
|
+
// — `from . import x` inside `__init__.py` must not point at itself.
|
|
361
|
+
export function resolvePythonImport(
|
|
362
|
+
importerRel: string,
|
|
363
|
+
imp: PyImport,
|
|
364
|
+
filesSet: Set<string>,
|
|
365
|
+
moduleIndex: Map<string, string>,
|
|
366
|
+
): Set<string> {
|
|
367
|
+
const hits = new Set<string>();
|
|
368
|
+
if (imp.dots) {
|
|
369
|
+
let dir = posixDirname(importerRel);
|
|
370
|
+
for (let i = 1; i < imp.dots.length; i++) {
|
|
371
|
+
if (dir === ".") return hits; // climbs above the scanned root
|
|
372
|
+
dir = posixDirname(dir);
|
|
373
|
+
}
|
|
374
|
+
const base = imp.mod
|
|
375
|
+
? posixNormalize(posixJoin(dir, ...imp.mod.split(".")))
|
|
376
|
+
: dir;
|
|
377
|
+
for (const name of imp.names) {
|
|
378
|
+
for (const c of [
|
|
379
|
+
`${base}/${name}.py`,
|
|
380
|
+
`${base}/${name}.pyi`,
|
|
381
|
+
`${base}/${name}/__init__.py`,
|
|
382
|
+
`${base}/${name}/__init__.pyi`,
|
|
383
|
+
]) {
|
|
384
|
+
if (filesSet.has(c)) hits.add(c);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const selfCands = imp.mod
|
|
388
|
+
? [
|
|
389
|
+
`${base}.py`,
|
|
390
|
+
`${base}.pyi`,
|
|
391
|
+
`${base}/__init__.py`,
|
|
392
|
+
`${base}/__init__.pyi`,
|
|
393
|
+
]
|
|
394
|
+
: [`${base}/__init__.py`, `${base}/__init__.pyi`];
|
|
395
|
+
for (const c of selfCands) {
|
|
396
|
+
if (filesSet.has(c)) {
|
|
397
|
+
hits.add(c);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
for (const name of imp.names) {
|
|
403
|
+
const hit = moduleIndex.get(imp.mod ? `${imp.mod}.${name}` : name);
|
|
404
|
+
if (hit) hits.add(hit);
|
|
405
|
+
}
|
|
406
|
+
const self = moduleIndex.get(imp.mod);
|
|
407
|
+
if (self) hits.add(self);
|
|
408
|
+
}
|
|
409
|
+
hits.delete(importerRel);
|
|
410
|
+
return hits;
|
|
411
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/analyze/resolve-ts.ts — TS/JS import resolution.
|
|
2
|
+
//
|
|
3
|
+
// Bun.Transpiler.scan() finds static imports; the two regexes below cover its
|
|
4
|
+
// blind spots (require() and `import type` are real edges — changing a type
|
|
5
|
+
// still shakes dependents). No overlap with scan output — scan reports
|
|
6
|
+
// neither form.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
dirname as posixDirname,
|
|
10
|
+
join as posixJoin,
|
|
11
|
+
normalize as posixNormalize,
|
|
12
|
+
} from "node:path/posix";
|
|
13
|
+
|
|
14
|
+
export const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;
|
|
15
|
+
export const IMPORT_TYPE_RE =
|
|
16
|
+
/\bimport\s+type\s+[^;]*?\bfrom\s*["']([^"']+)["']/g;
|
|
17
|
+
|
|
18
|
+
const RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx"];
|
|
19
|
+
|
|
20
|
+
export function resolveRelative(
|
|
21
|
+
importerRel: string,
|
|
22
|
+
raw: string,
|
|
23
|
+
filesSet: Set<string>,
|
|
24
|
+
): string | null {
|
|
25
|
+
const base = posixNormalize(posixJoin(posixDirname(importerRel), raw));
|
|
26
|
+
const candidates = new Set<string>([base, `${base}/index.ts`]);
|
|
27
|
+
for (const e of RESOLVE_EXTS) candidates.add(base + e);
|
|
28
|
+
// TS files import the compiled path ("./foo.js") — try the stem too.
|
|
29
|
+
const dot = base.lastIndexOf(".");
|
|
30
|
+
if (dot > base.lastIndexOf("/")) {
|
|
31
|
+
const stem = base.slice(0, dot);
|
|
32
|
+
for (const e of RESOLVE_EXTS) candidates.add(stem + e);
|
|
33
|
+
candidates.add(`${stem}/index.ts`);
|
|
34
|
+
}
|
|
35
|
+
for (const c of candidates) {
|
|
36
|
+
if (filesSet.has(c)) return c;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|