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.
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ // src/analyze/types.ts — shared types for the analyze graph + diagnosis.
2
+ //
3
+ // Pure types only, no imports — every analyze module depends on this one,
4
+ // nothing here depends back (same shape as src/install/types.ts).
5
+
6
+ export interface ImportGraph {
7
+ /** Relative POSIX paths scanned. */
8
+ files: string[];
9
+ /** file → files it imports. */
10
+ deps: Map<string, Set<string>>;
11
+ /** file → files that import it (reverse). */
12
+ dependents: Map<string, Set<string>>;
13
+ /** Imports that could not be resolved (bare specifier / alias / builtin). */
14
+ /** Imports that may hide a real edge: relative misses, path aliases, bare
15
+ * package names, and files too broken to read. Worth warning about. */
16
+ unresolved: number;
17
+ /** `node:` / `bun:` builtins — never an edge between two project files, so
18
+ * counting them as "unresolved" only inflates the warning. */
19
+ external: number;
20
+ /** Files that only re-export (`export ... from`) — they hide the real importer. */
21
+ barrels: Set<string>;
22
+ /** Python files that run on their own: `__main__` block or `[project.scripts]`.
23
+ * Optional — the graph cache doesn't carry it; only diagnose reads it. */
24
+ entries?: Set<string>;
25
+ }
26
+
27
+ export type FindingKind =
28
+ | "hub-untested"
29
+ | "orphan"
30
+ | "cycle"
31
+ | "changed-untested";
32
+
33
+ export interface Finding {
34
+ kind: FindingKind;
35
+ file: string;
36
+ /** Human sentence — the thing people read. */
37
+ detail: string;
38
+ /** How to re-check it yourself (file names, cycle path). */
39
+ evidence: string;
40
+ }
41
+
42
+ export interface BlastEntry {
43
+ dependents: number;
44
+ tested: boolean;
45
+ /** Dependents of dependents, transitively (cycle-safe, excludes the file itself). */
46
+ transitive: number;
47
+ }
@@ -14,7 +14,11 @@
14
14
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, relative } from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
- import { collectSourceFiles, isSkippedDir, isTestFile } from "./analyze.js";
17
+ import {
18
+ collectSourceFiles,
19
+ isSkippedDir,
20
+ isTestFile,
21
+ } from "./analyze/index.js";
18
22
  import { CONVENTIONS_FILE, FAPONY_DIR } from "./core/config.js";
19
23
  import { extractBody, extractExports } from "./map.js";
20
24
 
@@ -300,7 +304,7 @@ function detectWrappers(root: string): SeedRow[] {
300
304
  } catch {
301
305
  continue;
302
306
  }
303
- const scan = extractExports(source);
307
+ const scan = extractExports(source, undefined, rel);
304
308
  if (scan.error) continue;
305
309
  for (const sym of scan.symbols) {
306
310
  if (sym.kind !== "const" && sym.kind !== "fn") continue;
package/src/debt/scan.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { isAbsolute, join, relative } from "node:path";
8
- import { collectSourceFiles } from "../analyze.js";
8
+ import { collectSourceFiles } from "../analyze/index.js";
9
9
  import {
10
10
  type Convention,
11
11
  DEBT_FILE_CAP,