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
package/src/init.ts
CHANGED
|
@@ -58,22 +58,23 @@ const EVIDENCE_JSON = `{
|
|
|
58
58
|
}
|
|
59
59
|
`;
|
|
60
60
|
|
|
61
|
-
// Rules snippet for the user's own agent-rules file.
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
const RULES_SNIPPET =
|
|
69
|
-
() => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
|
|
61
|
+
// Rules snippet for the user's own agent-rules file. Nothing writes
|
|
62
|
+
// log.<person>.jsonl on its own — an agent does, because the rules file it already
|
|
63
|
+
// reads says to. That file is the user's, so init asks before writing it (rule 6c)
|
|
64
|
+
// and never writes it twice (RULES_MARKER). Not in SERVER_INSTRUCTIONS either: that
|
|
65
|
+
// reaches every MCP session of every user, and most of them never ran `fapony init`
|
|
66
|
+
// — it would tell them to run a command that does not exist.
|
|
67
|
+
const RULES_MARKER = "## Memory: .fapony/.memory";
|
|
68
|
+
const RULES_SNIPPET = () => `${RULES_MARKER}/log.<you>.jsonl (append-only)
|
|
70
69
|
|
|
71
70
|
The log is this project's shared brain — it lives in git, so anyone who clones the
|
|
72
71
|
repo gets every decision, bug and note with it. The filename comes from
|
|
73
72
|
\`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
|
|
74
73
|
.gitattributes keeps both sides when two people end up sharing a name anyway.
|
|
75
74
|
|
|
76
|
-
Log as you work — do not wait to be asked. Nothing writes it for you
|
|
75
|
+
Log as you work — do not wait to be asked. Nothing writes it for you. With the
|
|
76
|
+
fapony MCP server connected, call mem_add / mem_find / mem_close directly;
|
|
77
|
+
otherwise the CLI:
|
|
77
78
|
|
|
78
79
|
fapony mem kickoff <plan.md> # start a session with this
|
|
79
80
|
fapony mem add decision "what was locked, and why" --files src/x.ts
|
|
@@ -82,6 +83,14 @@ Log as you work — do not wait to be asked. Nothing writes it for you:
|
|
|
82
83
|
fapony mem close <id> "fixed in <sha>"
|
|
83
84
|
fapony mem find "<text>"
|
|
84
85
|
|
|
86
|
+
What goes in — would someone cloning this repo tomorrow need it, and can they not
|
|
87
|
+
find it anywhere else?
|
|
88
|
+
- bug — something broken, even when found mid-task on something else: add it now,
|
|
89
|
+
not at the end. Once fixed, close it — only close closes a bug.
|
|
90
|
+
- decision — something agreed or locked that git and the plan do not say, with why.
|
|
91
|
+
- note — state the next session needs (where a chunk stopped, what is half-done).
|
|
92
|
+
Before ending a turn that committed work: at least one row about it.
|
|
93
|
+
|
|
85
94
|
Write each entry standalone — it is read months later with no chat to refer to.
|
|
86
95
|
--files is required: rows that name no file cannot be recalled when that file is
|
|
87
96
|
touched later (add refuses without it).
|
|
@@ -188,64 +197,117 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
188
197
|
);
|
|
189
198
|
console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
|
|
190
199
|
console.log(
|
|
191
|
-
`\
|
|
200
|
+
`\nThe memory log only fills up if the rules your agent already reads tell it\nto write — these go into CLAUDE.md / AGENTS.md next:\n`,
|
|
192
201
|
);
|
|
193
202
|
console.log(RULES_SNIPPET());
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
197
206
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
/** Which rules files init would touch: existing ones lacking the rules, or create. */
|
|
208
|
+
export function rulesTargets(targetPath: string): {
|
|
209
|
+
create: boolean;
|
|
210
|
+
append: string[];
|
|
211
|
+
} {
|
|
212
|
+
const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter(
|
|
213
|
+
existsSync,
|
|
214
|
+
);
|
|
215
|
+
const has = (f: string) => readFileSync(f, "utf-8").includes(RULES_MARKER);
|
|
216
|
+
const agents = join(targetPath, "AGENTS.md");
|
|
217
|
+
// a CLAUDE.md that imports a rules-carrying AGENTS.md already has them
|
|
218
|
+
const covered = (f: string) =>
|
|
219
|
+
has(f) ||
|
|
220
|
+
(/^@AGENTS\.md\s*$/m.test(readFileSync(f, "utf-8")) &&
|
|
221
|
+
existsSync(agents) &&
|
|
222
|
+
has(agents));
|
|
223
|
+
return {
|
|
224
|
+
create: found.length === 0,
|
|
225
|
+
append: found.filter((f) => !covered(f)),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Write the memory rules into the agent-rules files. No file yet = AGENTS.md
|
|
231
|
+
* (read by OpenCode/Codex/Cursor) + a CLAUDE.md that imports it, so the rules
|
|
232
|
+
* exist once. Files that already carry the rules are left alone.
|
|
233
|
+
*/
|
|
234
|
+
export function writeRules(targetPath: string): void {
|
|
235
|
+
const { create, append } = rulesTargets(targetPath);
|
|
236
|
+
if (create) {
|
|
237
|
+
writeFileSync(join(targetPath, "AGENTS.md"), `${RULES_SNIPPET()}\n`);
|
|
238
|
+
writeFileSync(join(targetPath, "CLAUDE.md"), "@AGENTS.md\n");
|
|
239
|
+
console.log(" + AGENTS.md (memory rules) · CLAUDE.md → @AGENTS.md");
|
|
240
|
+
return;
|
|
203
241
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
242
|
+
for (const f of append) {
|
|
243
|
+
const body = readFileSync(f, "utf-8");
|
|
244
|
+
appendFileSync(
|
|
245
|
+
f,
|
|
246
|
+
`${body.endsWith("\n") ? "\n" : "\n\n"}${RULES_SNIPPET()}\n`,
|
|
247
|
+
);
|
|
248
|
+
console.log(` appended memory rules to ${relative(targetPath, f)}`);
|
|
209
249
|
}
|
|
250
|
+
}
|
|
210
251
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
console.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
} else {
|
|
220
|
-
console.log(
|
|
221
|
-
` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
|
|
222
|
-
);
|
|
223
|
-
console.log(
|
|
224
|
-
` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
|
|
252
|
+
export async function cmdInit(args: string[]): Promise<void> {
|
|
253
|
+
const rulesOnly = args.includes("--rules");
|
|
254
|
+
const yes = args.includes("--yes");
|
|
255
|
+
const targetPath = args.find((a) => !a.startsWith("--"));
|
|
256
|
+
if (!targetPath) {
|
|
257
|
+
console.error("usage: fapony init <path> [--rules] [--yes]");
|
|
258
|
+
console.error(
|
|
259
|
+
" --rules only write the memory rules into CLAUDE.md / AGENTS.md (repo already set up)",
|
|
225
260
|
);
|
|
261
|
+
console.error(" --yes write them without asking");
|
|
262
|
+
process.exit(1);
|
|
226
263
|
}
|
|
227
|
-
|
|
264
|
+
if (!rulesOnly) {
|
|
265
|
+
try {
|
|
266
|
+
initProject(targetPath);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.error((e as Error).message);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
228
271
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
272
|
+
// --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
|
|
273
|
+
// eslint no-restricted-* rows carry their checker; the wrapper detector adds
|
|
274
|
+
// live-migration candidates. Nothing derivable = empty file, never an error.
|
|
275
|
+
const seed = await seedConventionsFile(targetPath);
|
|
276
|
+
if (seed.kept) {
|
|
277
|
+
console.log(
|
|
278
|
+
` ${relative(targetPath, seed.file)} — already exists, left untouched`,
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
console.log(
|
|
282
|
+
` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
|
|
283
|
+
);
|
|
284
|
+
console.log(
|
|
285
|
+
` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
|
|
289
|
+
}
|
|
233
290
|
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
291
|
+
const { create, append } = rulesTargets(targetPath);
|
|
292
|
+
if (!create && append.length === 0) {
|
|
293
|
+
console.log(" memory rules already in the agent-rules file");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!yes) {
|
|
297
|
+
const what = create
|
|
298
|
+
? "Create AGENTS.md with the memory-logging rules (+ CLAUDE.md → @AGENTS.md)"
|
|
299
|
+
: `Append the memory-logging rules to ${append.map((f) => relative(targetPath, f)).join(" and ")}`;
|
|
300
|
+
const rl = createInterface({
|
|
301
|
+
input: process.stdin,
|
|
302
|
+
output: process.stdout,
|
|
303
|
+
});
|
|
304
|
+
const answer = await new Promise<string>((resolve) => {
|
|
305
|
+
rl.question(`\n${what}? [y/N] `, (a) => {
|
|
239
306
|
rl.close();
|
|
240
307
|
resolve(a.trim());
|
|
241
|
-
}
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
if (!isAffirmative(answer)) return;
|
|
245
|
-
|
|
246
|
-
const snippet = `\n\n${RULES_SNIPPET()}\n`;
|
|
247
|
-
for (const f of found) {
|
|
248
|
-
appendFileSync(f, snippet);
|
|
249
|
-
console.log(` appended to ${relative(targetPath, f)}`);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
if (!isAffirmative(answer)) return;
|
|
250
311
|
}
|
|
312
|
+
writeRules(targetPath);
|
|
251
313
|
}
|
|
@@ -34,7 +34,9 @@ function isFaponyEntry(entry: unknown): boolean {
|
|
|
34
34
|
|
|
35
35
|
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
36
36
|
try {
|
|
37
|
-
const
|
|
37
|
+
const raw = readFileSync(path, "utf-8").trim();
|
|
38
|
+
if (!raw) return {};
|
|
39
|
+
const parsed: unknown = JSON.parse(raw);
|
|
38
40
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
39
41
|
return null;
|
|
40
42
|
}
|
package/src/install/opencode.ts
CHANGED
|
@@ -269,7 +269,7 @@ export const FaponyReadHint = async ({ directory }) => {
|
|
|
269
269
|
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
|
|
270
270
|
}
|
|
271
271
|
if (ctx.memLines.length > 0) {
|
|
272
|
-
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
|
|
272
|
+
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
|
|
273
273
|
}
|
|
274
274
|
if (ctx.openBugIds.length > 0) {
|
|
275
275
|
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
|
|
@@ -413,7 +413,7 @@ export const FaponyEditHint = async ({ directory }) => {
|
|
|
413
413
|
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
|
|
414
414
|
}
|
|
415
415
|
if (ctx?.memLines?.length) {
|
|
416
|
-
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
|
|
416
|
+
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
|
|
417
417
|
}
|
|
418
418
|
if (ctx?.openBugIds?.length) {
|
|
419
419
|
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
|
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.**
|