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.
package/src/map.ts CHANGED
@@ -167,10 +167,230 @@ function braceNames(
167
167
 
168
168
  const VAR_DECL_RE = /^export\s+(?:const|let|var)\b/;
169
169
 
170
+ // --- Python exports (no parser: Bun.Transpiler can't read .py, and shelling
171
+ // out to `python -c "import ast"` was cut — a subprocess per file blows the
172
+ // measured budgets (buildGraph ~50ms/150 files, review-seed 0.31–0.51s
173
+ // uncached) and breaks the Bun-only constraint. Line-based, top level only.)
174
+
175
+ const PY_DEF_RE = /^(?:async\s+)?def\s+([A-Za-z_]\w*)/;
176
+ const PY_CLASS_RE = /^class\s+([A-Za-z_]\w*)/;
177
+ // `x = …` and `x: T = …` — `=(?!=)` keeps `==`/`!=`/`>=` comparisons out.
178
+ const PY_ASSIGN_RE = /^([A-Za-z_]\w*)\s*(?::\s*[^=;#]+?)?=(?!=)/;
179
+ const PY_FROM_RE = /^from\s+(\S+)\s+import\s+(.+)$/;
180
+ const PY_ALL_RE = /^__all__\s*=/;
181
+
182
+ // Walk one line tracking quote state; returns the code before a `#` comment.
183
+ // Triple quotes are handled for the single-line case (`x = """a#b"""`) —
184
+ // multi-line strings are skipped by the block tracker below, not here.
185
+ function stripPyComment(line: string): string {
186
+ let quote: string | null = null;
187
+ for (let i = 0; i < line.length; i++) {
188
+ const c = line[i];
189
+ if (quote) {
190
+ if (c === "\\") {
191
+ i++;
192
+ continue;
193
+ }
194
+ if (line.startsWith(quote, i)) {
195
+ i += quote.length - 1;
196
+ quote = null;
197
+ }
198
+ continue;
199
+ }
200
+ if (c === "#") return line.slice(0, i);
201
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
202
+ quote = line.slice(i, i + 3);
203
+ i += 2;
204
+ } else if (c === '"' || c === "'") {
205
+ quote = c;
206
+ }
207
+ }
208
+ return line;
209
+ }
210
+
211
+ // Blank out triple-quoted string blocks, preserving the line count, so a
212
+ // line-based scan never reads code (`def`, `from .x import y`) out of a
213
+ // docstring or a multi-line string. Code before an opening `"""` on the same
214
+ // line is kept; the block itself and its closing line become empty. Single
215
+ // source of truth for both the export scan (below) and analyze's import scan.
216
+ // First triple quote that is NOT inside a single/double-quoted string on this
217
+ // line — so `x = 'has """ inside'` never opens a block. `stripPyComment` walks
218
+ // the same quote state; this reports where a block actually starts.
219
+ function unquotedTriple(code: string): { at: number; q: string } | null {
220
+ let quote: string | null = null;
221
+ for (let i = 0; i < code.length; i++) {
222
+ const c = code[i];
223
+ if (quote) {
224
+ if (c === "\\") {
225
+ i++;
226
+ continue;
227
+ }
228
+ if (code.startsWith(quote, i)) {
229
+ i += quote.length - 1;
230
+ quote = null;
231
+ }
232
+ continue;
233
+ }
234
+ if (code.startsWith('"""', i) || code.startsWith("'''", i))
235
+ return { at: i, q: code.slice(i, i + 3) };
236
+ if (c === '"' || c === "'") quote = c;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ export function maskPyBlocks(source: string): string {
242
+ const out: string[] = [];
243
+ let block: string | null = null;
244
+ for (const raw of source.split("\n")) {
245
+ if (block) {
246
+ const end = raw.indexOf(block);
247
+ if (end < 0) {
248
+ out.push("");
249
+ continue;
250
+ }
251
+ block = null;
252
+ out.push(raw.slice(end + 3));
253
+ continue;
254
+ }
255
+ const code = stripPyComment(raw);
256
+ const triple = unquotedTriple(code);
257
+ if (triple) {
258
+ const { at, q } = triple;
259
+ if (code.indexOf(q, at + 3) < 0) {
260
+ block = q;
261
+ out.push(code.slice(0, at));
262
+ continue;
263
+ }
264
+ }
265
+ out.push(raw);
266
+ }
267
+ return out.join("\n");
268
+ }
269
+
270
+ // String literals inside an `__all__ = [...]` (or `(...)`) assignment,
271
+ // possibly spanning lines. Anything dynamic (`append`, `+=`, a variable)
272
+ // yields nothing — the caller falls back to every top-level name.
273
+ function pyAllNames(text: string): string[] {
274
+ const open =
275
+ text.indexOf("[") >= 0 ? "[" : text.indexOf("(") >= 0 ? "(" : null;
276
+ if (!open) return [];
277
+ const close = open === "[" ? "]" : ")";
278
+ const body = text.slice(text.indexOf(open) + 1);
279
+ if (!body.includes(close)) return [];
280
+ const out: string[] = [];
281
+ for (const m of body.matchAll(/["']([A-Za-z_]\w*)["']/g)) out.push(m[1]);
282
+ return out;
283
+ }
284
+
285
+ function pyFromNames(rest: string): string[] {
286
+ const clean = rest.replace(/[()]/g, " ");
287
+ const out: string[] = [];
288
+ for (let part of clean.split(",")) {
289
+ part = part.trim().split("#")[0].trim();
290
+ if (!part || part === "*") continue;
291
+ const as = part.split(/\s+as\s+/);
292
+ const name = as[as.length - 1].trim();
293
+ if (/^[A-Za-z_]\w*$/.test(name)) out.push(name);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ export function extractPythonExports(source: string): ExportScan {
299
+ // Blank docstrings/strings first, preserving line numbers — a `def` inside
300
+ // a docstring is sample text, the same blind spot the TS path closes with
301
+ // the parse gate.
302
+ const lines = maskPyBlocks(source).split("\n");
303
+ const found = new Map<string, { line: number; kind: ExportKind }>();
304
+ let allNames: string[] = [];
305
+ let allLine = 0;
306
+
307
+ const remember = (name: string, line: number, kind: ExportKind): void => {
308
+ if (!found.has(name)) found.set(name, { line, kind });
309
+ };
310
+
311
+ for (let i = 0; i < lines.length; i++) {
312
+ const raw = lines[i];
313
+ const code = stripPyComment(raw).trimEnd();
314
+ if (code === "") continue;
315
+ if (/^\s/.test(raw)) continue; // indented — not top level
316
+ const t = code.trim();
317
+ let m: RegExpMatchArray | null;
318
+ if ((m = t.match(PY_DEF_RE))) {
319
+ remember(m[1], i + 1, "fn");
320
+ continue;
321
+ }
322
+ if ((m = t.match(PY_CLASS_RE))) {
323
+ remember(m[1], i + 1, "class");
324
+ continue;
325
+ }
326
+ if (PY_ALL_RE.test(t)) {
327
+ // `__all__` may span lines — join until the bracket closes (cap 20).
328
+ let joined = t;
329
+ let j = i;
330
+ while (!/[\])]/.test(joined) && j + 1 < lines.length && j - i < 20) {
331
+ j++;
332
+ joined += ` ${stripPyComment(lines[j]).trim()}`;
333
+ }
334
+ allNames = pyAllNames(joined);
335
+ allLine = i + 1;
336
+ i = j;
337
+ continue;
338
+ }
339
+ if ((m = t.match(PY_FROM_RE))) {
340
+ // Parenthesized lists often span lines — join until they close (cap 20),
341
+ // the same shape as the TS brace-block join above.
342
+ let rest = m[2];
343
+ let j = i;
344
+ const unbalanced = (s: string): boolean =>
345
+ (s.match(/\(/g) ?? []).length > (s.match(/\)/g) ?? []).length;
346
+ while (unbalanced(rest) && j + 1 < lines.length && j - i < 20) {
347
+ j++;
348
+ rest += ` ${stripPyComment(lines[j]).trim()}`;
349
+ }
350
+ for (const name of pyFromNames(rest)) remember(name, i + 1, "re-export");
351
+ i = j;
352
+ continue;
353
+ }
354
+ if (/^import\s+/.test(t)) continue; // graph data, not an export
355
+ if ((m = t.match(PY_ASSIGN_RE))) {
356
+ if (m[1] === "__all__") continue;
357
+ remember(m[1], i + 1, "const");
358
+ }
359
+ }
360
+
361
+ if (allNames.length > 0) {
362
+ // `__all__` is authoritative: undocumented underscore names listed there
363
+ // are public, and anything not listed is not — including real defs.
364
+ const out: ExportSymbol[] = [];
365
+ const seen = new Set<string>();
366
+ for (const name of allNames) {
367
+ if (seen.has(name)) continue;
368
+ seen.add(name);
369
+ const f = found.get(name);
370
+ out.push(
371
+ f
372
+ ? { name, line: f.line, kind: f.kind }
373
+ : { name, line: allLine, kind: "re-export" },
374
+ );
375
+ }
376
+ return { symbols: out, error: null };
377
+ }
378
+ // No `__all__`: underscore-prefixed names are private by convention.
379
+ return {
380
+ symbols: [...found]
381
+ .filter(([name]) => !name.startsWith("_"))
382
+ .map(([name, f]) => ({ name, line: f.line, kind: f.kind })),
383
+ error: null,
384
+ };
385
+ }
386
+
170
387
  export function extractExports(
171
388
  source: string,
172
389
  scanner?: ExportScanner,
390
+ filename?: string,
173
391
  ): ExportScan {
392
+ if (filename?.endsWith(".py") || filename?.endsWith(".pyi"))
393
+ return extractPythonExports(source);
174
394
  const s = scanner ?? getDefaultScanner();
175
395
  const scanned = scanSource(source, s);
176
396
  if (scanned.error) return { symbols: [], error: scanned.error };
@@ -28,10 +28,13 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
28
28
  // Distinct keys on open work rows, sorted — the important-index ingredient
29
29
  // (PLAN-mem-keys chunk 3). A key is open while ≥1 keyed row survives the
30
30
  // tombstone set; computed live like every other selector, never persisted.
31
+ // Decisions are skipped: nobody closes them, so a keyed decision would sit in
32
+ // the index forever as a fake "open problem".
31
33
  export const openKeys = (all: LogRow[]): { key: string; open: number }[] => {
32
34
  const counts = new Map<string, number>();
33
35
  for (const r of openRows(all)) {
34
- if (r.key) counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
36
+ if (r.key && r.kind !== "decision")
37
+ counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
35
38
  }
36
39
  return [...counts.entries()]
37
40
  .map(([key, open]) => ({ key, open }))
@@ -34,7 +34,11 @@ import {
34
34
  writeFileSync,
35
35
  } from "node:fs";
36
36
  import { basename, join, relative, resolve, sep } from "node:path";
37
- import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "../analyze.js";
37
+ import {
38
+ collectSourceFiles,
39
+ isSkippedDir,
40
+ SCAN_EXTS,
41
+ } from "../analyze/index.js";
38
42
  import {
39
43
  CONFIG_FILENAME,
40
44
  type Config,
@@ -221,7 +225,7 @@ function renderExistingInScope(
221
225
  } catch {
222
226
  continue;
223
227
  }
224
- const scan = extractExports(source);
228
+ const scan = extractExports(source, undefined, rel);
225
229
  if (scan.error || scan.symbols.length === 0) continue;
226
230
  // Re-export-only files scan as one `*` per line — dedupe to a single `*`.
227
231
  const names = [
@@ -347,7 +351,7 @@ function fileLines(absFile: string): string[] {
347
351
  } catch {
348
352
  return ["_(unreadable)_"];
349
353
  }
350
- const scan = extractExports(source);
354
+ const scan = extractExports(source, undefined, absFile);
351
355
  if (scan.error) return [`⚠ ${scan.error} — symbols not extractable`];
352
356
  if (scan.symbols.length === 0) return ["_(no exports)_"];
353
357
  let srcLines: string[] = [];
@@ -29,7 +29,7 @@ import {
29
29
  isTestedThroughBarrels,
30
30
  isTestFile,
31
31
  SCAN_EXTS,
32
- } from "../analyze.js";
32
+ } from "../analyze/index.js";
33
33
  import { extractBody, extractExports } from "../map.js";
34
34
  import { assertSafe } from "../safety.js";
35
35
  import { execGit, gitOk, gitValue, SeedError, SIG_MAX } from "./primitives.js";
@@ -707,7 +707,7 @@ function renderLookup(
707
707
  } catch {
708
708
  continue;
709
709
  }
710
- const scan = extractExports(source);
710
+ const scan = extractExports(source, undefined, path);
711
711
  if (scan.error) continue;
712
712
  for (const sym of scan.symbols) {
713
713
  if (!flags.body.includes(sym.name)) continue;
@@ -902,7 +902,7 @@ export function renderSeed(args: string[], cwd: string): string {
902
902
  } catch {
903
903
  continue;
904
904
  }
905
- const scan = extractExports(source);
905
+ const scan = extractExports(source, undefined, e.path);
906
906
  if (scan.error) {
907
907
  sigLines.push(` ${e.path} — ⚠ ${scan.error}`);
908
908
  continue;
package/templates/PLAN.md CHANGED
@@ -51,6 +51,7 @@ Table with 3–5 rows: risk | likelihood | impact | escape hatch
51
51
  1. **<Step 1>** — has a clear deliverable
52
52
  2. **<Step 2>** — ...
53
53
  Each step must be verifiable before moving to the next
54
+ A step needing state the system doesn't store yet must say where it lives, who writes it, who reads it
54
55
 
55
56
  ## 7. Examples (make it concrete)
56
57
  bash examples: before / after — **link into spec/, don't paste it.**