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/README.md +26 -21
- 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 +1 -1
- package/src/adapters/hooks/edit-hint.ts +1 -1
- package/src/adapters/hooks/read-hint.ts +1 -1
- 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 +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/debt/scan.ts +1 -1
- package/src/install/antigravity.ts +3 -1
- 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
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 };
|
package/src/mem/selectors.ts
CHANGED
|
@@ -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
|
|
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 }))
|
package/src/seed/plan-seed.ts
CHANGED
|
@@ -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 {
|
|
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[] = [];
|
package/src/seed/review-seed.ts
CHANGED
|
@@ -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.**
|