fapony 0.1.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,932 @@
1
+ // src/review-seed.ts — `fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>]`
2
+ //
3
+ // Seeds a code review with the deterministic facts of the scope the agent
4
+ // asked about: which files changed (per the exact git expression, echoed),
5
+ // who statically imports them, whether any test file touches them, and what
6
+ // the current signatures are. The agent is left with verification only —
7
+ // walk the diff, run the tests, kill findings as normal.
8
+ //
9
+ // Read-only stdout: no file writes, no cache, no state.db read — the seed is
10
+ // a lens, not a delivery (rule 5b — a command that reads code writes only to a
11
+ // path the user pointed at, and this one accepts no such path). This used to
12
+ // cite rule 5 "never write into a target worktree", dropped 2026-09-17 because
13
+ // four commands broke it; being read-only was always a property of this
14
+ // command, never of that rule. Facts only: nothing here says broken/fixed
15
+ // — judgment lives in the reviewer and the ledger (verdict_submit), never in
16
+ // this output. Deterministic: same input, same bytes, no LLM.
17
+ //
18
+ // Composes existing producers — buildGraph (analyze.ts) for importers/untested,
19
+ // extractExports (map.ts) for signatures. One flag = one declared git call;
20
+ // no magic parsing.
21
+
22
+ import { execSync } from "node:child_process";
23
+ import type { Stats } from "node:fs";
24
+ import { existsSync, readFileSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import {
27
+ buildGraph,
28
+ collectSourceFiles,
29
+ type ImportGraph,
30
+ isTestedThroughBarrels,
31
+ isTestFile,
32
+ SCAN_EXTS,
33
+ } from "./analyze.js";
34
+ import { extractBody, extractExports } from "./map.js";
35
+ import { assertSafe } from "./safety.js";
36
+
37
+ const WRAP_WIDTH = 88;
38
+ // The changed list is the review's scope boundary, not context: a file hidden
39
+ // here is a file the reviewer never walks. So it is capped by FILES, generously,
40
+ // and the overflow counts files — the other caps below are context and stay tight.
41
+ const MAX_CHANGED_FILES = 40;
42
+ const MAX_IMPORTER_LINES = 4;
43
+ const MAX_SIGNATURE_LINES = 4;
44
+ const MAX_IMPORTERS_SHOWN = 4;
45
+ const MAX_SIGNATURES_SHOWN = 5;
46
+ const MAX_DYNAMIC_LINES = 2;
47
+ const MAX_CROSS_CHECK_LINES = 2;
48
+ const OUTPUT_CAP = 30;
49
+ // `--files` is a lookup, not a review: the caller named the files, so the caps
50
+ // that keep a 40-file diff readable only hide the answer they asked for. This
51
+ // is the surface the deleted `fapony map <file>` used to be — an executor
52
+ // asking "what is in here and who breaks if I change it" before editing, at a
53
+ // fraction of reading the file. Still bounded: a hub with 60 importers is a
54
+ // wall of text, not an answer.
55
+ const LOOKUP_IMPORTERS_SHOWN = 12;
56
+ const LOOKUP_OUTPUT_CAP = 120;
57
+ // Dir expansion inside --files reuses the diff cap (MAX_CHANGED_FILES): 40
58
+ // files is already past "this component area" into "the whole tree" — cut
59
+ // there and say so, a folder-shaped wall is not an answer either.
60
+ // Signature text cap per symbol (same trim as map.ts's file view).
61
+ const SIG_MAX = 90;
62
+ const DISCLAIMER =
63
+ "static graph only — seed is where to enter, not what is verified";
64
+ const USAGE =
65
+ "usage: fapony review-seed [--staged | --commit <sha> | --range <a...b> | --files f1,f2,dir | --plan <PLAN.md>] [--body sym[,sym]] [--callers sym]";
66
+ // --body / --callers are the executor's lookup, not the reviewer's seed: when
67
+ // either is present the output is only those sections (plus worktree line and
68
+ // disclaimer) — the standard sections would be a wall around the one answer.
69
+ const MAX_BODY_LINES = 80;
70
+ const MAX_CALLER_FILES = 12;
71
+ const MAX_CALLER_HITS = 20;
72
+
73
+ export class SeedError extends Error {}
74
+
75
+ // --- Git helpers (same shape as collect.ts execGitSafe) ---
76
+
77
+ function execGit(
78
+ cmd: string,
79
+ cwd: string,
80
+ ): { ok: boolean; output: string; error?: string } {
81
+ try {
82
+ const output = execSync(cmd, {
83
+ cwd,
84
+ encoding: "utf-8",
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ timeout: 15_000,
87
+ });
88
+ return { ok: true, output: output.trim() };
89
+ } catch (e: unknown) {
90
+ const err = e as { stderr?: string; message?: string };
91
+ return {
92
+ ok: false,
93
+ output: "",
94
+ error: (err.stderr ?? err.message ?? "").trim(),
95
+ };
96
+ }
97
+ }
98
+
99
+ // A scope's primary git call failing must never masquerade as "nothing
100
+ // changed" — surface it. merge-base failures are tolerated (label fallback).
101
+ function gitOk(r: { ok: boolean; error?: string }, cmd: string): void {
102
+ if (!r.ok)
103
+ throw new SeedError(
104
+ `review-seed: git failed: ${cmd}\n${r.error ?? "unknown error"}`,
105
+ );
106
+ }
107
+
108
+ // Values interpolated into a git command line must be plain refs/paths —
109
+ // blocks shell metacharacters before execSync ever sees them.
110
+ const GIT_VALUE_RE = /^[A-Za-z0-9._/{}^~+-]+$/;
111
+
112
+ function gitValue(kind: string, value: string): string {
113
+ if (!GIT_VALUE_RE.test(value)) {
114
+ throw new SeedError(`review-seed: invalid ${kind}: ${value}`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ // --- Scope flags: exactly one source of scope ---
120
+
121
+ type Scope =
122
+ | { kind: "default" }
123
+ | { kind: "staged" }
124
+ | { kind: "commit"; sha: string }
125
+ | { kind: "range"; expr: string }
126
+ | { kind: "files"; list: string[] }
127
+ | { kind: "plan"; path: string };
128
+
129
+ interface LookupFlags {
130
+ /** --body sym[,sym] — declaration slices from the named file(s). */
131
+ body: string[];
132
+ /** --callers sym — symbol→symbol grep over importer files. */
133
+ callers: string | null;
134
+ }
135
+
136
+ function parseLookup(args: string[]): LookupFlags {
137
+ const body: string[] = [];
138
+ let callers: string | null = null;
139
+ for (let i = 0; i < args.length; i++) {
140
+ const a = args[i];
141
+ if (a === "--body" || a === "--callers") {
142
+ const v = args[i + 1];
143
+ if (v === undefined || v.startsWith("--")) {
144
+ throw new SeedError(`review-seed: ${a} needs a value\n${USAGE}`);
145
+ }
146
+ i++;
147
+ if (a === "--body") {
148
+ for (const s of v
149
+ .split(",")
150
+ .map((s) => s.trim())
151
+ .filter(Boolean)) {
152
+ if (!/^[A-Za-z_$][\w$]*$/.test(s)) {
153
+ throw new SeedError(`review-seed: invalid symbol: ${s}`);
154
+ }
155
+ body.push(s);
156
+ }
157
+ if (body.length === 0) {
158
+ throw new SeedError(`review-seed: --body needs a symbol\n${USAGE}`);
159
+ }
160
+ } else {
161
+ if (!/^[A-Za-z_$][\w$]*$/.test(v)) {
162
+ throw new SeedError(`review-seed: invalid symbol: ${v}`);
163
+ }
164
+ if (callers) {
165
+ throw new SeedError(
166
+ `review-seed: --callers takes one symbol\n${USAGE}`,
167
+ );
168
+ }
169
+ callers = v;
170
+ }
171
+ }
172
+ }
173
+ return { body, callers };
174
+ }
175
+
176
+ function parseScope(args: string[]): Scope {
177
+ const flags: Scope[] = [];
178
+ for (let i = 0; i < args.length; i++) {
179
+ const a = args[i];
180
+ const value = (): string => {
181
+ const v = args[i + 1];
182
+ if (v === undefined || v.startsWith("--")) {
183
+ throw new SeedError(`review-seed: ${a} needs a value\n${USAGE}`);
184
+ }
185
+ i++;
186
+ return v;
187
+ };
188
+ if (a === "--body" || a === "--callers") {
189
+ i++; // consumed by parseLookup — never a scope flag
190
+ continue;
191
+ }
192
+ if (a === "--staged") flags.push({ kind: "staged" });
193
+ else if (a === "--commit") {
194
+ const v = value();
195
+ gitValue("commit ref", v);
196
+ flags.push({ kind: "commit", sha: v });
197
+ } else if (a === "--range") {
198
+ const v = value();
199
+ gitValue("range", v);
200
+ if (!v.includes("...")) {
201
+ throw new SeedError(
202
+ `review-seed: --range wants three-dot (merge-base, PR semantics): a...b\n${USAGE}`,
203
+ );
204
+ }
205
+ flags.push({ kind: "range", expr: v });
206
+ } else if (a === "--files") {
207
+ const list = value()
208
+ .split(",")
209
+ .map((s) => s.trim().replace(/^\.\//, ""))
210
+ .filter(Boolean);
211
+ if (list.length === 0) {
212
+ throw new SeedError(
213
+ `review-seed: --files needs at least one path\n${USAGE}`,
214
+ );
215
+ }
216
+ flags.push({ kind: "files", list });
217
+ } else if (a === "--plan") {
218
+ flags.push({ kind: "plan", path: value() });
219
+ } else if (a === "-h" || a === "--help") {
220
+ throw new SeedError(USAGE);
221
+ } else {
222
+ throw new SeedError(`review-seed: unknown argument "${a}"\n${USAGE}`);
223
+ }
224
+ }
225
+ if (flags.length === 0) return { kind: "default" };
226
+ if (flags.length > 1) {
227
+ throw new SeedError(
228
+ `review-seed: one scope flag at a time (got ${flags.map((f) => f.kind).join(", ")})\n${USAGE}`,
229
+ );
230
+ }
231
+ return flags[0];
232
+ }
233
+
234
+ // --- Scope resolution: one flag = one declared git call ---
235
+
236
+ interface FileEntry {
237
+ path: string;
238
+ ins: number | null;
239
+ del: number | null;
240
+ untracked: boolean;
241
+ /** Set when -M paired this path with a deleted source (a rename). */
242
+ renamedFrom?: string;
243
+ /** Set when this path was found by expanding a --files directory. */
244
+ expanded?: true;
245
+ }
246
+
247
+ interface ResolvedScope {
248
+ label: string;
249
+ entries: FileEntry[];
250
+ /** For --plan: default-diff paths, for the cross-check. */
251
+ crossCheck?: { planFiles: string[]; changed: string[] };
252
+ /** For --files: dropped paths and expansion cuts, printed after changed. */
253
+ filesNotes?: string[];
254
+ }
255
+
256
+ // `--files` accepts directories: the caller thinks in zones ("this component
257
+ // area"), not file names — asking is how the names get learned. A dir expands
258
+ // to source files under it via the same walk buildGraph keys the graph by
259
+ // (collectSourceFiles — node_modules/.git/nested checkouts skipped, no
260
+ // hidden-dir filter, same as the graph), so importers and signatures still
261
+ // hit. A path that is neither file nor dir is dropped from the scope and
262
+ // reported, never silently counted as a one-row scope.
263
+ function expandFilesScope(
264
+ list: string[],
265
+ worktree: string,
266
+ ): { files: FileEntry[]; notes: string[]; dirExpanded: boolean } {
267
+ const files: FileEntry[] = [];
268
+ const notes: string[] = [];
269
+ const seen = new Set<string>();
270
+ const notFound: string[] = [];
271
+ const emptyDirs: string[] = [];
272
+ const dirs: string[] = [];
273
+ let dirExpanded = false;
274
+ let cutNamed = 0;
275
+ let cutExpanded = 0;
276
+ const add = (p: string, expanded: boolean): void => {
277
+ if (seen.has(p)) return;
278
+ seen.add(p);
279
+ if (files.length >= MAX_CHANGED_FILES) {
280
+ if (expanded) cutExpanded++;
281
+ else cutNamed++;
282
+ return;
283
+ }
284
+ files.push({
285
+ path: p,
286
+ ins: null,
287
+ del: null,
288
+ untracked: false,
289
+ ...(expanded ? { expanded: true } : {}),
290
+ });
291
+ };
292
+ // Named files are placed before any dir expands. A path the caller typed
293
+ // outranks one a walk inferred, so `--files src/,fapony.ts` can never spend
294
+ // the whole cap on src/ and drop fapony.ts — the quiet disappearance this
295
+ // flag exists to stop. Dirs are collected here, expanded in the pass below.
296
+ for (const raw of list) {
297
+ const p = raw.replace(/\/+$/, "");
298
+ let st: Stats;
299
+ try {
300
+ st = statSync(join(worktree, p));
301
+ } catch {
302
+ notFound.push(p);
303
+ continue;
304
+ }
305
+ if (st.isFile()) add(p, false);
306
+ else if (st.isDirectory()) dirs.push(p);
307
+ else notFound.push(p);
308
+ }
309
+ for (const p of dirs) {
310
+ dirExpanded = true;
311
+ const rels = collectSourceFiles(join(worktree, p));
312
+ if (rels.length === 0) emptyDirs.push(p);
313
+ for (const r of rels) add(p === "." ? r : `${p}/${r}`, true);
314
+ }
315
+ if (notFound.length > 0) {
316
+ notes.push(
317
+ `not found (${notFound.length}): ${notFound.join(", ")} — dropped from scope`,
318
+ );
319
+ }
320
+ for (const d of emptyDirs) {
321
+ notes.push(`${d} (dir) — no source files under it`);
322
+ }
323
+ if (cutNamed > 0) {
324
+ notes.push(
325
+ `… +${cutNamed} named file(s) past the ${MAX_CHANGED_FILES} cap — narrow the scope`,
326
+ );
327
+ }
328
+ if (cutExpanded > 0) {
329
+ notes.push(
330
+ `… +${cutExpanded} more file(s) under the expanded dirs — capped at ${MAX_CHANGED_FILES}, narrow the scope`,
331
+ );
332
+ }
333
+ return { files, notes, dirExpanded };
334
+ }
335
+
336
+ // numstat with -M reports renames as `old => new` (whole path) or git's
337
+ // brace form `prefix/{old => new}/suffix` (only the moved segment) — expand
338
+ // both back to full paths so downstream sections see the real new path plus
339
+ // a renamedFrom annotation, never git's internal syntax.
340
+ // Exported for plan-seed's changed-files feed — reuse, not a second parser.
341
+ export function parseNumstat(output: string): FileEntry[] {
342
+ const out: FileEntry[] = [];
343
+ for (const line of output.split("\n").filter(Boolean)) {
344
+ const [ins, del, ...rest] = line.split("\t");
345
+ let path = rest.join("\t");
346
+ if (!path) continue;
347
+ if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
348
+ let renamedFrom: string | undefined;
349
+ const brace = /\{([^{}]*) => ([^{}]*)\}/.exec(path);
350
+ if (brace) {
351
+ renamedFrom = path.replace(brace[0], brace[1]);
352
+ path = path.replace(brace[0], brace[2]);
353
+ } else if (path.includes(" => ")) {
354
+ const arrow = path.indexOf(" => ");
355
+ renamedFrom = path.slice(0, arrow);
356
+ path = path.slice(arrow + 4);
357
+ }
358
+ out.push({
359
+ path,
360
+ ins: ins === "-" ? null : Number.parseInt(ins, 10),
361
+ del: del === "-" ? null : Number.parseInt(del, 10),
362
+ untracked: false,
363
+ ...(renamedFrom !== undefined ? { renamedFrom } : {}),
364
+ });
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // untracked paths, one per file — -uall stops git collapsing an untracked
370
+ // directory to "dir/" (a seed wants file names, and the collapsed dir breaks
371
+ // plan cross-check path equality). Exported for plan-seed (same feed).
372
+ export function untrackedFiles(porcelain: string): FileEntry[] {
373
+ const out: FileEntry[] = [];
374
+ for (const line of porcelain.split("\n").filter(Boolean)) {
375
+ if (!line.startsWith("?? ")) continue;
376
+ let path = line.slice(3);
377
+ if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
378
+ out.push({ path, ins: null, del: null, untracked: true });
379
+ }
380
+ return out;
381
+ }
382
+
383
+ function shortSha(cwd: string, ref: string): string | null {
384
+ const r = execGit(`git rev-parse --short ${ref}`, cwd);
385
+ return r.ok ? r.output.split("\n")[0] : null;
386
+ }
387
+
388
+ function resolveScope(scope: Scope, cwd: string): ResolvedScope {
389
+ if (scope.kind === "files") {
390
+ const { files, notes, dirExpanded } = expandFilesScope(scope.list, cwd);
391
+ return {
392
+ label: dirExpanded ? "--files (dir-expanded)" : "--files (as given)",
393
+ entries: files,
394
+ ...(notes.length > 0 ? { filesNotes: notes } : {}),
395
+ };
396
+ }
397
+ if (scope.kind === "plan") {
398
+ const planPath = join(cwd, scope.path);
399
+ if (!existsSync(planPath)) {
400
+ throw new SeedError(`review-seed: plan file not found: ${scope.path}`);
401
+ }
402
+ const planFiles = planFrontFiles(readFileSync(planPath, "utf-8"));
403
+ if (planFiles === null) {
404
+ // No files[] to scope from — fall back to the default diff, say so.
405
+ const entries = [
406
+ ...parseNumstat(execGit("git diff HEAD --numstat -M", cwd).output),
407
+ ...untrackedFiles(execGit("git status --porcelain -uall", cwd).output),
408
+ ];
409
+ return {
410
+ label: "--plan (no files: frontmatter) — diff HEAD + untracked",
411
+ entries,
412
+ };
413
+ }
414
+ // Cross-check target is the default diff — one declared git call.
415
+ const changed = [
416
+ ...parseNumstat(execGit("git diff HEAD --numstat -M", cwd).output),
417
+ ...untrackedFiles(execGit("git status --porcelain -uall", cwd).output),
418
+ ].map((e) => e.path);
419
+ const shortA = shortSha(cwd, "HEAD");
420
+ return {
421
+ label: `--plan ${scope.path}${shortA ? ` (HEAD = ${shortA})` : ""}`,
422
+ entries: planFiles.map((p) => ({
423
+ path: p,
424
+ ins: null,
425
+ del: null,
426
+ untracked: false,
427
+ })),
428
+ crossCheck: { planFiles, changed: [...new Set(changed)].sort() },
429
+ };
430
+ }
431
+ if (scope.kind === "default") {
432
+ const diff = execGit("git diff HEAD --numstat -M", cwd);
433
+ gitOk(diff, "git diff HEAD --numstat -M");
434
+ const st = execGit("git status --porcelain -uall", cwd);
435
+ gitOk(st, "git status --porcelain -uall");
436
+ const entries = [
437
+ ...parseNumstat(diff.output),
438
+ ...untrackedFiles(st.output),
439
+ ];
440
+ return { label: "diff HEAD + untracked", entries };
441
+ }
442
+ if (scope.kind === "staged") {
443
+ const diff = execGit("git diff --cached --numstat -M", cwd);
444
+ gitOk(diff, "git diff --cached --numstat -M");
445
+ return {
446
+ label: "--staged (diff --cached)",
447
+ entries: parseNumstat(diff.output),
448
+ };
449
+ }
450
+ if (scope.kind === "commit") {
451
+ const sha = scope.sha;
452
+ const verify = execGit(`git rev-parse --verify ${sha}^{commit}`, cwd);
453
+ if (!verify.ok) throw new SeedError(`review-seed: not a commit: ${sha}`);
454
+ const short = shortSha(cwd, sha) ?? sha;
455
+ const parent = execGit(`git rev-parse --verify ${sha}^`, cwd);
456
+ if (!parent.ok) {
457
+ // Root commit — no parent to diff against; tree vs empty tree instead.
458
+ const entries = parseNumstat(
459
+ execGit(`git diff-tree --no-commit-id --numstat -r --root ${sha}`, cwd)
460
+ .output,
461
+ );
462
+ return {
463
+ label: `--commit ${short} (root commit — vs empty tree)`,
464
+ entries,
465
+ };
466
+ }
467
+ const parentShort = shortSha(cwd, `${sha}^`) ?? parent.output.slice(0, 7);
468
+ const diff = execGit(`git diff ${sha}^ ${sha} --numstat -M`, cwd);
469
+ gitOk(diff, `git diff ${sha}^ ${sha} --numstat -M`);
470
+ return {
471
+ label: `--commit ${short} (${parentShort}..${short})`,
472
+ entries: parseNumstat(diff.output),
473
+ };
474
+ }
475
+ // range
476
+ const expr = scope.expr;
477
+ const mb = execGit(`git merge-base ${expr.replace("...", " ")}`, cwd);
478
+ const tipShort = shortSha(cwd, expr.split("...")[1]) ?? expr.split("...")[1];
479
+ const baseShort = mb.ok ? (shortSha(cwd, mb.output) ?? "?") : null;
480
+ const diff = execGit(`git diff ${expr} --numstat -M`, cwd);
481
+ gitOk(diff, `git diff ${expr} --numstat -M`);
482
+ return {
483
+ label: baseShort ? `${expr} = ${baseShort}…${tipShort}` : `--range ${expr}`,
484
+ entries: parseNumstat(diff.output),
485
+ };
486
+ }
487
+
488
+ // --- Plan frontmatter files[] (same flat-key style as plans.ts parseFront) ---
489
+
490
+ function planFrontFiles(text: string): string[] | null {
491
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
492
+ if (!m) return null;
493
+ for (const line of m[1].split(/\r?\n/)) {
494
+ const kv = /^\s*([a-z_]+)\s*:\s*(.*?)\s*$/.exec(line);
495
+ if (kv?.[1] !== "files") continue;
496
+ const value = kv[2].replace(/\s+#.*$/, "").trim();
497
+ if (!value) return null;
498
+ const list = value
499
+ .split(",")
500
+ .map((s) => s.trim().replace(/^\.\//, ""))
501
+ .filter(Boolean);
502
+ return list.length > 0 ? list : null;
503
+ }
504
+ return null;
505
+ }
506
+
507
+ // --- Structure lines (facts from the static graph) ---
508
+
509
+ function wrap(parts: string[], joiner: string, indent: string): string[] {
510
+ const lines: string[] = [];
511
+ let cur = "";
512
+ for (const p of parts) {
513
+ const piece = cur ? `${cur}${joiner}${p}` : p;
514
+ if (piece.length > WRAP_WIDTH && cur) {
515
+ lines.push(`${indent}${cur}`);
516
+ cur = p;
517
+ } else {
518
+ cur = piece;
519
+ }
520
+ }
521
+ if (cur) lines.push(`${indent}${cur}`);
522
+ return lines;
523
+ }
524
+
525
+ function fmtCounts(e: FileEntry): string {
526
+ const rename = e.renamedFrom ? ` (renamed from ${e.renamedFrom})` : "";
527
+ if (e.untracked) return " (untracked)";
528
+ // Dir-expanded files were not named by the caller — "as given" would lie.
529
+ if (e.expanded) return rename;
530
+ if (e.ins === null || e.del === null) return rename || " (as given)";
531
+ if (e.ins === 0 && e.del === 0) return rename;
532
+ return ` +${e.ins}-${e.del}${rename}`;
533
+ }
534
+
535
+ function hasDynamicDispatch(absFile: string): boolean {
536
+ let content: string;
537
+ try {
538
+ content = readFileSync(absFile, "utf-8");
539
+ } catch {
540
+ return false;
541
+ }
542
+ return /\b(?:import|require)\s*\(/.test(content);
543
+ }
544
+
545
+ // --- --body: declaration slice (indent-out, no parser) ---
546
+ // extractBody lives in map.ts beside extractExports — the conventions seeder
547
+ // reuses the same slice for wrapper detection (one implementation, กฎ 1).
548
+
549
+ // --- --callers: symbol→symbol over importer files (identifier scan) ---
550
+
551
+ // Narrow file→file importers to symbol→symbol by scanning importer files for
552
+ // the identifier. Deliberately textual: a name in a comment or string counts
553
+ // as a hit — ~80% of "who calls this" for near-zero cost. Real call-graph
554
+ // precision is code-review-graph's job; this is the seed that says where to
555
+ // look. Only static importers are scanned (the graph's dependents); a caller
556
+ // that never imports the defining file is out of reach here.
557
+ function findCallers(
558
+ symbol: string,
559
+ targets: string[],
560
+ graph: ImportGraph,
561
+ worktree: string,
562
+ ): {
563
+ rows: { file: string; hits: number[]; more: number }[];
564
+ filesCapped: boolean;
565
+ } {
566
+ const all: { file: string; hits: number[]; more: number }[] = [];
567
+ const re = new RegExp(`\\b${symbol}\\b`);
568
+ for (const target of targets) {
569
+ for (const dep of graph.dependents.get(target) ?? new Set<string>()) {
570
+ if (all.some((o) => o.file === dep)) continue;
571
+ let source: string;
572
+ try {
573
+ source = readFileSync(join(worktree, dep), "utf-8");
574
+ } catch {
575
+ continue;
576
+ }
577
+ const found = source
578
+ .split("\n")
579
+ .map((l, i) => (re.test(l) ? i + 1 : 0))
580
+ .filter((n) => n > 0);
581
+ if (found.length === 0) continue;
582
+ const hits = found.slice(0, MAX_CALLER_HITS);
583
+ all.push({ file: dep, hits, more: found.length - hits.length });
584
+ }
585
+ }
586
+ // Sort before capping: capping first would show an arbitrary 12 of N.
587
+ all.sort((a, b) => (a.file < b.file ? -1 : 1));
588
+ const rows = all.slice(0, MAX_CALLER_FILES);
589
+ return { rows, filesCapped: all.length > rows.length };
590
+ }
591
+
592
+ // --body / --callers output: the answer the executor asked for, nothing else.
593
+ // Resolve the scope first (cheap for --files, one git call otherwise) because
594
+ // --callers needs the target file list to walk importers from.
595
+ function renderLookup(
596
+ flags: LookupFlags,
597
+ scope: Scope,
598
+ worktree: string,
599
+ ): string {
600
+ const lines: string[] = [];
601
+ lines.push(`worktree: ${worktree} (${lookupLabel(flags)})`);
602
+
603
+ let resolved: ResolvedScope | null = null;
604
+ if (flags.callers) {
605
+ resolved = resolveScope(scope, worktree);
606
+ }
607
+
608
+ const hasGraph = (f: string): boolean => {
609
+ const dot = f.lastIndexOf(".");
610
+ return dot >= 0 && SCAN_EXTS.has(f.slice(dot));
611
+ };
612
+
613
+ if (flags.body.length > 0) {
614
+ // Which files to search: scope entries when the scope names them, else
615
+ // the flag can be used bare — then scan every source file in the tree
616
+ // (same walk as buildGraph), capped, with the cut announced.
617
+ let targets: string[];
618
+ if (scope.kind === "files") {
619
+ targets = expandFilesScope(scope.list, worktree)
620
+ .files.map((e) => e.path)
621
+ .filter(hasGraph);
622
+ } else {
623
+ targets = collectSourceFiles(worktree);
624
+ }
625
+ const shown: string[] = [];
626
+ for (const path of targets) {
627
+ let source: string;
628
+ try {
629
+ source = readFileSync(join(worktree, path), "utf-8");
630
+ } catch {
631
+ continue;
632
+ }
633
+ const scan = extractExports(source);
634
+ if (scan.error) continue;
635
+ for (const sym of scan.symbols) {
636
+ if (!flags.body.includes(sym.name)) continue;
637
+ shown.push(`${path}:${sym.line}`);
638
+ lines.push(`${path}:${sym.line} ${sym.name}`);
639
+ const body = extractBody(source, sym.line);
640
+ if (body.length >= MAX_BODY_LINES) {
641
+ lines.push(
642
+ ` ⚠ body truncated at ${MAX_BODY_LINES} lines — read the file for the rest`,
643
+ );
644
+ }
645
+ for (const b of body) lines.push(` ${b}`);
646
+ }
647
+ }
648
+ if (shown.length === 0) {
649
+ lines.push(`body: no export named ${flags.body.join(", ")} in scope`);
650
+ }
651
+ if (scope.kind !== "files") {
652
+ lines.push(
653
+ "(no --files: scanned whole tree — pass --files <file> to narrow)",
654
+ );
655
+ }
656
+ }
657
+
658
+ if (flags.callers) {
659
+ const graph = buildGraph(worktree);
660
+ const targets = (resolved?.entries ?? [])
661
+ .map((e) => e.path)
662
+ .filter(hasGraph);
663
+ const found = findCallers(flags.callers, targets, graph, worktree);
664
+ if (targets.length === 0) {
665
+ lines.push(`callers of ${flags.callers}: no source files in scope`);
666
+ } else if (found.rows.length === 0) {
667
+ lines.push(
668
+ `callers of ${flags.callers}: none found in static importers (dynamic or non-importing use is out of reach)`,
669
+ );
670
+ } else {
671
+ lines.push(
672
+ `callers of ${flags.callers} (textual hits, may be comments/strings):`,
673
+ );
674
+ for (const f of found.rows) {
675
+ const more = f.more > 0 ? ` (+${f.more} more hits)` : "";
676
+ lines.push(` ${f.file}:${f.hits.join(",")}${more}`);
677
+ }
678
+ if (found.filesCapped) {
679
+ lines.push(
680
+ ` ⚠ more importer files matched — capped at ${MAX_CALLER_FILES}`,
681
+ );
682
+ }
683
+ }
684
+ }
685
+
686
+ lines.push(DISCLAIMER);
687
+ return lines.join("\n");
688
+ }
689
+
690
+ function lookupLabel(flags: LookupFlags): string {
691
+ const parts: string[] = [];
692
+ if (flags.body.length > 0) parts.push(`--body ${flags.body.join(",")}`);
693
+ if (flags.callers) parts.push(`--callers ${flags.callers}`);
694
+ return parts.join(" ");
695
+ }
696
+
697
+ export function renderSeed(args: string[], cwd: string): string {
698
+ const scope = parseScope(args);
699
+ const root = execGit("git rev-parse --show-toplevel", cwd);
700
+ if (!root.ok) {
701
+ throw new SeedError("review-seed: not a git repository (no worktree root)");
702
+ }
703
+ try {
704
+ assertSafe(args);
705
+ } catch (e) {
706
+ throw new SeedError(
707
+ e instanceof Error ? e.message : "refused dangerous argument",
708
+ );
709
+ }
710
+ const worktree = root.output.split("\n")[0];
711
+
712
+ // --body / --callers: lookup mode. Scope flags stay legal (a --files dir
713
+ // feeds --callers its targets), but the standard sections are suppressed —
714
+ // the caller asked for one answer, not the review seed around it.
715
+ const lookup = parseLookup(args);
716
+ if (lookup.body.length > 0 || lookup.callers) {
717
+ return renderLookup(lookup, scope, worktree);
718
+ }
719
+
720
+ const resolved = resolveScope(scope, worktree);
721
+ const entries = [...resolved.entries].sort((a, b) =>
722
+ a.path < b.path ? -1 : 1,
723
+ );
724
+
725
+ // Static graph over the whole worktree — same producer as `fapony analyze`.
726
+ let graph: ImportGraph | null = null;
727
+ try {
728
+ graph = buildGraph(worktree);
729
+ } catch {
730
+ graph = null;
731
+ }
732
+ const testFiles = graph ? graph.files.filter((f) => isTestFile(f)).length : 0;
733
+
734
+ const structureTargets = entries.filter((e) => {
735
+ if (isTestFile(e.path)) return false;
736
+ // SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
737
+ const dot = e.path.lastIndexOf(".");
738
+ if (dot < 0 || !SCAN_EXTS.has(e.path.slice(dot))) return false;
739
+ return existsSync(join(worktree, e.path));
740
+ });
741
+
742
+ // Lookup mode: caller named the files, so show them whole (see caps above).
743
+ const filesLookup = scope.kind === "files";
744
+ const importersShown = filesLookup
745
+ ? LOOKUP_IMPORTERS_SHOWN
746
+ : MAX_IMPORTERS_SHOWN;
747
+ const importerLineCap = filesLookup ? entries.length : MAX_IMPORTER_LINES;
748
+ const signaturesShown = filesLookup
749
+ ? Number.POSITIVE_INFINITY
750
+ : MAX_SIGNATURES_SHOWN;
751
+ const signatureLineCap = filesLookup ? entries.length : MAX_SIGNATURE_LINES;
752
+ const outputCap = filesLookup ? LOOKUP_OUTPUT_CAP : OUTPUT_CAP;
753
+
754
+ const lines: string[] = [];
755
+ lines.push(`worktree: ${worktree} (${resolved.label})`);
756
+
757
+ const shownEntries = entries.slice(0, MAX_CHANGED_FILES);
758
+ const changedParts = shownEntries.map((e) => `${e.path}${fmtCounts(e)}`);
759
+ if (entries.length === 0) {
760
+ lines.push("changed (0): nothing in this scope");
761
+ } else {
762
+ lines.push(`changed (${entries.length}):`);
763
+ lines.push(...wrap(changedParts, " · ", " "));
764
+ if (entries.length > shownEntries.length) {
765
+ lines.push(
766
+ ` … +${entries.length - shownEntries.length} more file(s) — narrow the scope to see them`,
767
+ );
768
+ }
769
+ }
770
+ for (const n of resolved.filesNotes ?? []) lines.push(n);
771
+
772
+ if (!graph) {
773
+ lines.push(
774
+ "graph: scan failed — importers/untested/signatures unavailable",
775
+ );
776
+ } else if (structureTargets.length > 0) {
777
+ const importerLines: string[] = [];
778
+ for (const e of structureTargets) {
779
+ const deps = [
780
+ ...(graph.dependents.get(e.path) ?? new Set<string>()),
781
+ ].sort();
782
+ if (deps.length === 0) continue;
783
+ const shown = deps.slice(0, importersShown).join(", ");
784
+ const rest =
785
+ deps.length > importersShown
786
+ ? ` (+${deps.length - importersShown})`
787
+ : "";
788
+ importerLines.push(` ${e.path} ← ${shown}${rest}`);
789
+ }
790
+ if (importerLines.length > 0) {
791
+ lines.push("importers (static):");
792
+ for (const l of importerLines.slice(0, importerLineCap)) lines.push(l);
793
+ if (importerLines.length > importerLineCap) {
794
+ lines.push(` … +${importerLines.length - importerLineCap} more`);
795
+ }
796
+ }
797
+
798
+ const untested = structureTargets.filter(
799
+ (e) => !(graph && isTestedThroughBarrels(graph, e.path)),
800
+ );
801
+ if (testFiles === 0) {
802
+ lines.push("untested: repo has no test files — flag uninformative");
803
+ } else if (untested.length > 0) {
804
+ const wrapped = wrap(
805
+ untested.map((e) => e.path),
806
+ ", ",
807
+ " ",
808
+ );
809
+ lines.push(`untested (${untested.length}):`);
810
+ lines.push(...wrapped.slice(0, 2));
811
+ if (wrapped.length > 2) lines.push(` … +${wrapped.length - 2} more`);
812
+ }
813
+
814
+ const sigLines: string[] = [];
815
+ for (const e of structureTargets) {
816
+ let source: string;
817
+ try {
818
+ source = readFileSync(join(worktree, e.path), "utf-8");
819
+ } catch {
820
+ continue;
821
+ }
822
+ const scan = extractExports(source);
823
+ if (scan.error) {
824
+ sigLines.push(` ${e.path} — ⚠ ${scan.error}`);
825
+ continue;
826
+ }
827
+ if (scan.symbols.length === 0) {
828
+ sigLines.push(` ${e.path} — (no exports)`);
829
+ continue;
830
+ }
831
+ // Real declaration text per symbol — the reviewer checks "did a param
832
+ // change" without opening the file. Same trim as map.ts's file view.
833
+ const srcLines = source.split("\n");
834
+ const shown = scan.symbols
835
+ .slice(0, signaturesShown)
836
+ .map((s) => {
837
+ const raw = (srcLines[s.line - 1] ?? "").trim();
838
+ const sig =
839
+ raw.length > SIG_MAX ? `${raw.slice(0, SIG_MAX - 1)}…` : raw;
840
+ return sig ? `${s.name}:${s.line} ${sig}` : `${s.name}:${s.line}`;
841
+ })
842
+ .join(" · ");
843
+ const rest =
844
+ scan.symbols.length > signaturesShown
845
+ ? ` (+${scan.symbols.length - signaturesShown})`
846
+ : "";
847
+ sigLines.push(` ${e.path} — ${shown}${rest}`);
848
+ }
849
+ if (sigLines.length > 0) {
850
+ lines.push("signatures (current):");
851
+ for (const l of sigLines.slice(0, signatureLineCap)) lines.push(l);
852
+ if (sigLines.length > signatureLineCap) {
853
+ lines.push(` … +${sigLines.length - signatureLineCap} more`);
854
+ }
855
+ }
856
+
857
+ const dynamic = structureTargets.filter((e) =>
858
+ hasDynamicDispatch(join(worktree, e.path)),
859
+ );
860
+ if (dynamic.length > 0) {
861
+ const wrapped = wrap(
862
+ dynamic.map((e) => e.path),
863
+ ", ",
864
+ " ",
865
+ );
866
+ lines.push(
867
+ "dynamic-dispatch hint (import(/require( — resolve at runtime):",
868
+ );
869
+ lines.push(...wrapped.slice(0, MAX_DYNAMIC_LINES));
870
+ if (wrapped.length > MAX_DYNAMIC_LINES) {
871
+ lines.push(` … +${wrapped.length - MAX_DYNAMIC_LINES} more lines`);
872
+ }
873
+ }
874
+ }
875
+
876
+ if (resolved.crossCheck) {
877
+ const planSet = new Set(resolved.crossCheck.planFiles);
878
+ const changedSet = new Set(resolved.crossCheck.changed);
879
+ const notInPlan = [...changedSet].filter((f) => !planSet.has(f)).sort();
880
+ const notChanged = resolved.crossCheck.planFiles
881
+ .filter((f) => !changedSet.has(f))
882
+ .sort();
883
+ if (notInPlan.length > 0 || notChanged.length > 0) {
884
+ if (notInPlan.length > 0) {
885
+ const wrapped = wrap(notInPlan, ", ", " ");
886
+ lines.push("plan cross-check: changed-not-in-plan:");
887
+ lines.push(...wrapped.slice(0, MAX_CROSS_CHECK_LINES));
888
+ if (wrapped.length > MAX_CROSS_CHECK_LINES) {
889
+ lines.push(
890
+ ` … +${wrapped.length - MAX_CROSS_CHECK_LINES} more lines`,
891
+ );
892
+ }
893
+ }
894
+ if (notChanged.length > 0) {
895
+ const wrapped = wrap(notChanged, ", ", " ");
896
+ lines.push("plan cross-check: in-plan-not-changed:");
897
+ lines.push(...wrapped.slice(0, MAX_CROSS_CHECK_LINES));
898
+ if (wrapped.length > MAX_CROSS_CHECK_LINES) {
899
+ lines.push(
900
+ ` … +${wrapped.length - MAX_CROSS_CHECK_LINES} more lines`,
901
+ );
902
+ }
903
+ }
904
+ }
905
+ } else if (scope.kind === "plan") {
906
+ lines.push("plan cross-check: plan has no files: frontmatter — skipped");
907
+ } else {
908
+ lines.push("plan cross-check: skipped — no --plan flag");
909
+ }
910
+
911
+ // Disclaimer is mandatory on every output — reserve its line so cap
912
+ // truncation (below) can never carry it off with the rest of the tail.
913
+ if (lines.length > outputCap - 1) {
914
+ const rest = lines.length - (outputCap - 2);
915
+ lines.length = outputCap - 2;
916
+ lines.push(`… (+${rest} lines truncated)`);
917
+ }
918
+ lines.push(DISCLAIMER);
919
+ return lines.join("\n");
920
+ }
921
+
922
+ export function cmdReviewSeed(args: string[]): void {
923
+ try {
924
+ console.log(renderSeed(args, process.cwd()));
925
+ } catch (e) {
926
+ if (e instanceof SeedError) {
927
+ console.error(e.message);
928
+ process.exit(1);
929
+ }
930
+ throw e;
931
+ }
932
+ }