fapony 0.4.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 +201 -431
- package/package.json +1 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/plan-with-pony/SKILL.md +3 -1
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/cli.ts +1 -1
- package/src/adapters/hooks/bug-markers.ts +19 -9
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +32 -6
- package/src/adapters/hooks/edit-hint.ts +11 -1
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +11 -1
- package/src/adapters/hooks/stop.ts +347 -7
- 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/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/tools/report.ts +1 -1
- package/src/adapters/mcp/transport.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/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/debt/scan.ts +1 -1
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +21 -8
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +6 -0
- package/src/install.ts +6 -4
- package/src/map.ts +220 -0
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +16 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +146 -13
- package/src/seed/review-seed.ts +144 -57
- package/templates/PLAN.md +1 -0
- package/src/analyze.ts +0 -688
package/src/seed/plan-seed.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
|
|
1
|
+
// src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]...`
|
|
2
2
|
//
|
|
3
3
|
// Writes PLAN + SPEC straight into planDir/specDir. What it pre-fills is the
|
|
4
4
|
// structure (frontmatter, the 8 sections, prior art, ledger context) — the
|
|
@@ -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,
|
|
@@ -43,8 +47,9 @@ import {
|
|
|
43
47
|
planDir,
|
|
44
48
|
specDir,
|
|
45
49
|
} from "../core/config.js";
|
|
50
|
+
import { MEM_TEXT_MAX } from "../core/mem-log.js";
|
|
46
51
|
import { extractExports } from "../map.js";
|
|
47
|
-
import { readRecentMemDecisions } from "../memory.js";
|
|
52
|
+
import { readMemLog, readRecentMemDecisions } from "../memory.js";
|
|
48
53
|
import { capLines, execGit, SIG_MAX } from "./primitives.js";
|
|
49
54
|
|
|
50
55
|
// One chunk = one module's signatures — past ~40 lines a module is its own
|
|
@@ -220,7 +225,7 @@ function renderExistingInScope(
|
|
|
220
225
|
} catch {
|
|
221
226
|
continue;
|
|
222
227
|
}
|
|
223
|
-
const scan = extractExports(source);
|
|
228
|
+
const scan = extractExports(source, undefined, rel);
|
|
224
229
|
if (scan.error || scan.symbols.length === 0) continue;
|
|
225
230
|
// Re-export-only files scan as one `*` per line — dedupe to a single `*`.
|
|
226
231
|
const names = [
|
|
@@ -301,6 +306,7 @@ One step = one chunk = one session: finish it, close it, **stop** — starting t
|
|
|
301
306
|
1. _(agent fills in — each step must be verifiable)_
|
|
302
307
|
|
|
303
308
|
**Closing a step:** tick TL;DR with sha · \`git commit\` files only · \`fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}\` · next opens with \`kickoff ${planRel}\` (or \`kickoff PLAN-${name}.md\` — kickoff resolves by filename too).
|
|
309
|
+
- [ ] handoff: the mem note is the handoff — this box only opts the plan into the Stop-hook check
|
|
304
310
|
|
|
305
311
|
## 7. Examples
|
|
306
312
|
${
|
|
@@ -345,7 +351,7 @@ function fileLines(absFile: string): string[] {
|
|
|
345
351
|
} catch {
|
|
346
352
|
return ["_(unreadable)_"];
|
|
347
353
|
}
|
|
348
|
-
const scan = extractExports(source);
|
|
354
|
+
const scan = extractExports(source, undefined, absFile);
|
|
349
355
|
if (scan.error) return [`⚠ ${scan.error} — symbols not extractable`];
|
|
350
356
|
if (scan.symbols.length === 0) return ["_(no exports)_"];
|
|
351
357
|
let srcLines: string[] = [];
|
|
@@ -507,6 +513,7 @@ function specTemplate(
|
|
|
507
513
|
name: string,
|
|
508
514
|
chunks: Chunk[],
|
|
509
515
|
scopeEcho: string | null,
|
|
516
|
+
traps: string[],
|
|
510
517
|
): string {
|
|
511
518
|
const index = chunks.map((c) => `- [${c.title}](#${c.slug})`).join("\n");
|
|
512
519
|
// The index is one string with a newline per chunk; budgeting it as one line
|
|
@@ -534,12 +541,18 @@ function specTemplate(
|
|
|
534
541
|
const tail = [
|
|
535
542
|
"## (agent fills in — wireframes / edge cases / API shapes the plan references)",
|
|
536
543
|
];
|
|
537
|
-
const bodyLines =
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
"",
|
|
542
|
-
|
|
544
|
+
const bodyLines = [
|
|
545
|
+
// Known traps sit after the index, before the first chunk body — budgeted
|
|
546
|
+
// with the bodies so the whole-SPEC cap still cuts from the tail and the
|
|
547
|
+
// count line above survives (a silent section would teach skipping it).
|
|
548
|
+
...(traps.length > 0 ? [...traps, ""] : []),
|
|
549
|
+
...chunks.flatMap((c) => [
|
|
550
|
+
`## <a id="${c.slug}"></a>${c.title}`,
|
|
551
|
+
"",
|
|
552
|
+
...c.body.split("\n"),
|
|
553
|
+
"",
|
|
554
|
+
]),
|
|
555
|
+
];
|
|
543
556
|
// Whole-file cap runs last and the agent section survives it, same way
|
|
544
557
|
// review-seed reserves its disclaimer: reserve the tail, cut the middle,
|
|
545
558
|
// say how much was dropped.
|
|
@@ -552,10 +565,107 @@ function specTemplate(
|
|
|
552
565
|
return `${[...head, ...cappedBody, ...tail].join("\n")}\n`;
|
|
553
566
|
}
|
|
554
567
|
|
|
568
|
+
// --- Known traps (PLAN-active-pain chunk 2): mem rows on this scope ---
|
|
569
|
+
//
|
|
570
|
+
// The one place a seed is allowed to be opinionated: past pain about exactly
|
|
571
|
+
// these files. bug rows first, then decision (note carries no "this hurt"
|
|
572
|
+
// signal), newest first within a kind. Match mirrors mem_find: files[] first;
|
|
573
|
+
// the text/spec fallback runs ONLY for rows with no files[] at all — a row
|
|
574
|
+
// that named files already spoke, its text may quote any path.
|
|
575
|
+
//
|
|
576
|
+
// Measured base rate on this repo before building (rule 2, 2026-09-23):
|
|
577
|
+
// 248 rows total; scope src/seed → 5 matched (3 bug/2 decision, 0 lacked) ·
|
|
578
|
+
// src/mem → 12 (4/8, 0) · src/adapters/hooks → 6 (2/4, 0) · whole repo → 61
|
|
579
|
+
// (20/41, 0). The "M lacked files[]" fallback layer fired 0/4 scopes here —
|
|
580
|
+
// kept because a pre-files[] repo (vela's 2,672 rows) depends on it.
|
|
581
|
+
const MAX_TRAP_ROWS = 5;
|
|
582
|
+
|
|
583
|
+
interface TrapHit {
|
|
584
|
+
row: {
|
|
585
|
+
ts: string;
|
|
586
|
+
kind: string;
|
|
587
|
+
text: string;
|
|
588
|
+
files?: string[];
|
|
589
|
+
spec?: string;
|
|
590
|
+
};
|
|
591
|
+
/** First in-scope file[] entry that matched — null for the text fallback. */
|
|
592
|
+
file: string | null;
|
|
593
|
+
viaText: boolean;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function renderKnownTraps(
|
|
597
|
+
worktree: string,
|
|
598
|
+
cwd: string,
|
|
599
|
+
roots: string[],
|
|
600
|
+
scoped: boolean,
|
|
601
|
+
): { lines: string[]; matched: number; lacked: number } {
|
|
602
|
+
const empty = { lines: [], matched: 0, lacked: 0 };
|
|
603
|
+
try {
|
|
604
|
+
const { rows, filesFound } = readMemLog(worktree);
|
|
605
|
+
if (filesFound === 0 || rows.length === 0) return empty;
|
|
606
|
+
|
|
607
|
+
const scopeFiles = new Set<string>();
|
|
608
|
+
for (const r of roots)
|
|
609
|
+
for (const f of scopeSourceFiles(r)) scopeFiles.add(relative(cwd, f));
|
|
610
|
+
if (scopeFiles.size === 0) return empty;
|
|
611
|
+
const scopeKeys = roots
|
|
612
|
+
.map((r) => relative(cwd, r))
|
|
613
|
+
.filter((k) => k !== "" && k !== ".");
|
|
614
|
+
|
|
615
|
+
const hits: TrapHit[] = [];
|
|
616
|
+
for (const row of rows) {
|
|
617
|
+
if (row.kind !== "bug" && row.kind !== "decision") continue;
|
|
618
|
+
const files = row.files ?? [];
|
|
619
|
+
const inScope = files.find((f) => scopeFiles.has(f));
|
|
620
|
+
if (inScope) {
|
|
621
|
+
hits.push({ row, file: inScope, viaText: false });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
// Fallback: only a row with NO files[] — see comment above.
|
|
625
|
+
if (files.length === 0 && scoped) {
|
|
626
|
+
const hay = `${row.text}\n${row.spec ?? ""}`;
|
|
627
|
+
if (scopeKeys.some((k) => hay.includes(k))) {
|
|
628
|
+
hits.push({ row, file: null, viaText: true });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (hits.length === 0) return empty;
|
|
633
|
+
|
|
634
|
+
// Stable sort: bug before decision, recency preserved inside each kind.
|
|
635
|
+
hits.sort((a, b) =>
|
|
636
|
+
a.row.kind === b.row.kind ? 0 : a.row.kind === "bug" ? -1 : 1,
|
|
637
|
+
);
|
|
638
|
+
const lacked = hits.filter((h) => h.viaText).length;
|
|
639
|
+
const lines = [
|
|
640
|
+
"## Known traps (fapony mem)",
|
|
641
|
+
"",
|
|
642
|
+
`- ${hits.length} relevant row(s) on this scope (${lacked} lacked files[]${lacked > 0 ? " — matched via text" : ""})`,
|
|
643
|
+
];
|
|
644
|
+
for (const h of hits.slice(0, MAX_TRAP_ROWS)) {
|
|
645
|
+
const text =
|
|
646
|
+
h.row.text.length > MEM_TEXT_MAX
|
|
647
|
+
? `${h.row.text.slice(0, MEM_TEXT_MAX - 1)}…`
|
|
648
|
+
: h.row.text;
|
|
649
|
+
lines.push(
|
|
650
|
+
`- ${h.row.ts.slice(0, 10)} ${h.row.kind} — ${text}${h.file ? ` (${h.file})` : ""}`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (hits.length > MAX_TRAP_ROWS) {
|
|
654
|
+
lines.push(
|
|
655
|
+
`- … +${hits.length - MAX_TRAP_ROWS} more at cap ${MAX_TRAP_ROWS} (bug first, then decision)`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return { lines, matched: hits.length, lacked };
|
|
659
|
+
} catch {
|
|
660
|
+
return empty; // no mem log / unreadable = a seed with no traps, never an error
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
555
664
|
// --- CLI entry ---
|
|
556
665
|
|
|
557
666
|
export function cmdPlanSeed(args: string[]): void {
|
|
558
|
-
const usage =
|
|
667
|
+
const usage =
|
|
668
|
+
"usage: fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]...";
|
|
559
669
|
// Positional parse, not args.find(!startsWith("--")) — a --scope VALUE is
|
|
560
670
|
// a non-flag argument and must never be mistaken for the plan name.
|
|
561
671
|
let name: string | undefined;
|
|
@@ -571,7 +681,20 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
571
681
|
console.error(`plan-seed: --scope needs a path\n${usage}`);
|
|
572
682
|
process.exit(1);
|
|
573
683
|
}
|
|
574
|
-
|
|
684
|
+
// Agents expect `--scope a,b,c` — same comma shape as `--files` — so a
|
|
685
|
+
// comma list is split here instead of failing on a path that never is.
|
|
686
|
+
// A real repo dir can't contain a literal ",x" tail; any comma splits.
|
|
687
|
+
// Trim each segment too: `a, b` is the same habit as `a,b`, and the
|
|
688
|
+
// space must not become part of the path (PLAN-comma-x).
|
|
689
|
+
const syms = v
|
|
690
|
+
.split(",")
|
|
691
|
+
.map((s) => s.trim())
|
|
692
|
+
.filter(Boolean);
|
|
693
|
+
if (syms.length === 0) {
|
|
694
|
+
console.error(`plan-seed: --scope needs a path\n${usage}`);
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
scopeArgs.push(...syms);
|
|
575
698
|
i++;
|
|
576
699
|
} else if (a.startsWith("--")) {
|
|
577
700
|
console.error(`plan-seed: unknown flag "${a}"\n${usage}`);
|
|
@@ -643,6 +766,7 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
643
766
|
);
|
|
644
767
|
|
|
645
768
|
let specLink: string | null = null;
|
|
769
|
+
let traps = { lines: [] as string[], matched: 0, lacked: 0 };
|
|
646
770
|
if (withSpec) {
|
|
647
771
|
const specDirAbs = join(cwd, specDir());
|
|
648
772
|
const specPath = join(specDirAbs, `SPEC-${name}.md`);
|
|
@@ -652,6 +776,7 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
652
776
|
);
|
|
653
777
|
process.exit(1);
|
|
654
778
|
}
|
|
779
|
+
traps = renderKnownTraps(worktree, cwd, roots, scoped);
|
|
655
780
|
const chunks = buildChunks(roots, cwd);
|
|
656
781
|
mkdirSync(specDirAbs, { recursive: true });
|
|
657
782
|
writeFileSync(
|
|
@@ -662,6 +787,7 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
662
787
|
requested.length > 0
|
|
663
788
|
? roots.map((r) => relative(cwd, r) || ".").join(", ")
|
|
664
789
|
: null,
|
|
790
|
+
traps.lines,
|
|
665
791
|
),
|
|
666
792
|
);
|
|
667
793
|
specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
|
|
@@ -691,6 +817,13 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
691
817
|
}
|
|
692
818
|
writeFileSync(planPath, planBody);
|
|
693
819
|
console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
|
|
820
|
+
// Measurement surface (plan §3): a silent section is indistinguishable from
|
|
821
|
+
// "no mem log" — say what was injected so a week of seeds is countable.
|
|
822
|
+
if (traps.matched > 0) {
|
|
823
|
+
console.log(
|
|
824
|
+
`Known traps: ${traps.matched} row(s) injected (${traps.lacked} lacked files[])`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
694
827
|
console.log("Existing plans:");
|
|
695
828
|
for (const l of listExistingPlans(cwd, config, `PLAN-${name}.md`))
|
|
696
829
|
console.log(l);
|
package/src/seed/review-seed.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Stats } from "node:fs";
|
|
23
23
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
24
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
24
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
25
25
|
import {
|
|
26
26
|
buildGraph,
|
|
27
27
|
collectSourceFiles,
|
|
@@ -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";
|
|
@@ -61,7 +61,7 @@ const LOOKUP_OUTPUT_CAP = 120;
|
|
|
61
61
|
const DISCLAIMER =
|
|
62
62
|
"static graph only — seed is where to enter, not what is verified";
|
|
63
63
|
const USAGE =
|
|
64
|
-
"usage: fapony review-seed [--staged | --commit <sha> | --range <a...b> | --files f1,f2,dir | --plan <PLAN.md>] [--body sym[,sym]] [--callers sym]";
|
|
64
|
+
"usage: fapony review-seed [--staged | --commit <sha> | --range <a...b> | --files f1,f2,dir | --plan <PLAN.md>] [--body sym[,sym]] [--callers sym[,sym]]";
|
|
65
65
|
// --body / --callers are the executor's lookup, not the reviewer's seed: when
|
|
66
66
|
// either is present the output is only those sections (plus worktree line and
|
|
67
67
|
// disclaimer) — the standard sections would be a wall around the one answer.
|
|
@@ -82,13 +82,13 @@ type Scope =
|
|
|
82
82
|
interface LookupFlags {
|
|
83
83
|
/** --body sym[,sym] — declaration slices from the named file(s). */
|
|
84
84
|
body: string[];
|
|
85
|
-
/** --callers sym — symbol→symbol grep over importer files. */
|
|
86
|
-
callers: string
|
|
85
|
+
/** --callers sym[,sym] — symbol→symbol grep over importer files. */
|
|
86
|
+
callers: string[];
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function parseLookup(args: string[]): LookupFlags {
|
|
90
90
|
const body: string[] = [];
|
|
91
|
-
|
|
91
|
+
const callers: string[] = [];
|
|
92
92
|
for (let i = 0; i < args.length; i++) {
|
|
93
93
|
const a = args[i];
|
|
94
94
|
if (a === "--body" || a === "--callers") {
|
|
@@ -97,29 +97,21 @@ function parseLookup(args: string[]): LookupFlags {
|
|
|
97
97
|
throw new SeedError(`review-seed: ${a} needs a value\n${USAGE}`);
|
|
98
98
|
}
|
|
99
99
|
i++;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
throw new SeedError(`review-seed:
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
if (!/^[A-Za-z_$][\w$]*$/.test(v)) {
|
|
115
|
-
throw new SeedError(`review-seed: invalid symbol: ${v}`);
|
|
116
|
-
}
|
|
117
|
-
if (callers) {
|
|
118
|
-
throw new SeedError(
|
|
119
|
-
`review-seed: --callers takes one symbol\n${USAGE}`,
|
|
120
|
-
);
|
|
100
|
+
// Both flags take the same comma shape --files takes: split, trim,
|
|
101
|
+
// drop empties, validate each symbol (PLAN-comma-x).
|
|
102
|
+
const syms = v
|
|
103
|
+
.split(",")
|
|
104
|
+
.map((s) => s.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (syms.length === 0) {
|
|
107
|
+
throw new SeedError(`review-seed: ${a} needs a symbol\n${USAGE}`);
|
|
108
|
+
}
|
|
109
|
+
for (const s of syms) {
|
|
110
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(s)) {
|
|
111
|
+
throw new SeedError(`review-seed: invalid symbol: ${s}`);
|
|
121
112
|
}
|
|
122
|
-
|
|
113
|
+
if (a === "--body") body.push(s);
|
|
114
|
+
else callers.push(s);
|
|
123
115
|
}
|
|
124
116
|
}
|
|
125
117
|
}
|
|
@@ -176,12 +168,32 @@ function parseScope(args: string[]): Scope {
|
|
|
176
168
|
}
|
|
177
169
|
}
|
|
178
170
|
if (flags.length === 0) return { kind: "default" };
|
|
179
|
-
|
|
171
|
+
// Repeats of the SAME kind: --files occurrences merge into one scope list
|
|
172
|
+
// (an agent splitting a lookup across two --files tokens is the same shape
|
|
173
|
+
// as the comma list it already accepts); an identical scalar repeat is
|
|
174
|
+
// idempotent; a scalar repeated with a DIFFERENT value is ambiguous and
|
|
175
|
+
// errors — never last-wins (PLAN-comma-x chunk 2). The mixed-scope guard
|
|
176
|
+
// below counts distinct kinds, not occurrences, so `--files a --files b`
|
|
177
|
+
// is one scope, not "files, files".
|
|
178
|
+
const merged = new Map<string, Scope>();
|
|
179
|
+
for (const f of flags) {
|
|
180
|
+
const prev = merged.get(f.kind);
|
|
181
|
+
if (prev === undefined) {
|
|
182
|
+
merged.set(f.kind, f);
|
|
183
|
+
} else if (f.kind === "files" && prev.kind === "files") {
|
|
184
|
+
prev.list = [...new Set([...prev.list, ...f.list])];
|
|
185
|
+
} else if (JSON.stringify(prev) !== JSON.stringify(f)) {
|
|
186
|
+
throw new SeedError(
|
|
187
|
+
`review-seed: --${f.kind} given twice with different values\n${USAGE}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (merged.size > 1) {
|
|
180
192
|
throw new SeedError(
|
|
181
|
-
`review-seed: one scope flag at a time (got ${
|
|
193
|
+
`review-seed: one scope flag at a time (got ${[...merged.keys()].join(", ")})\n${USAGE}`,
|
|
182
194
|
);
|
|
183
195
|
}
|
|
184
|
-
return
|
|
196
|
+
return [...merged.values()][0];
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
// --- Scope resolution: one flag = one declared git call ---
|
|
@@ -376,6 +388,57 @@ function resolvePlanPath(arg: string, cwd: string, worktree: string): string {
|
|
|
376
388
|
throw new SeedError(`review-seed: plan file not found: ${arg}`);
|
|
377
389
|
}
|
|
378
390
|
|
|
391
|
+
// Fallback commit resolution for --plan without files[] frontmatter.
|
|
392
|
+
// Tries two sources in order:
|
|
393
|
+
// 1. `> **Commits:** sha1 sha2 …` line in the plan header
|
|
394
|
+
// 2. `git log --grep <PLAN-filename> --format=%h`
|
|
395
|
+
// Returns the first source that yields commits, with metadata for the label.
|
|
396
|
+
function isCommitObject(sha: string, cwd: string): boolean {
|
|
397
|
+
return execGit(`git cat-file -e ${sha}^{commit}`, cwd).ok;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function planFallbackCommits(
|
|
401
|
+
planText: string,
|
|
402
|
+
planBase: string,
|
|
403
|
+
cwd: string,
|
|
404
|
+
): { sha: string; short: string; source: "header" | "git log grep" }[] {
|
|
405
|
+
// Source 1: > **Commits:** line in the plan header (first 2048 bytes)
|
|
406
|
+
const head = planText.slice(0, 2048);
|
|
407
|
+
const commitsLine = />\s*\*?\*?Commits:?\*?\*?\s+(.+)/i.exec(head);
|
|
408
|
+
if (commitsLine) {
|
|
409
|
+
const shas = commitsLine[1]
|
|
410
|
+
.match(/\b[0-9a-f]{7,12}\b/g)
|
|
411
|
+
?.filter((s) => isCommitObject(s, cwd))
|
|
412
|
+
.map((s) => ({
|
|
413
|
+
sha: s,
|
|
414
|
+
short: s,
|
|
415
|
+
source: "header" as const,
|
|
416
|
+
}));
|
|
417
|
+
if (shas && shas.length > 0) return shas;
|
|
418
|
+
}
|
|
419
|
+
// Source 2: git log --grep for the plan name. Chunk commits cite the plan
|
|
420
|
+
// as "(PLAN-x chunk N)" — never with the .md suffix — so grep the stem:
|
|
421
|
+
// it still matches messages that do carry the suffix (substring).
|
|
422
|
+
const stem = planBase.replace(/\.md$/, "");
|
|
423
|
+
const logResult = execGit(
|
|
424
|
+
`git log --grep=${stem} --format=%h --max-count=10`,
|
|
425
|
+
cwd,
|
|
426
|
+
);
|
|
427
|
+
if (logResult.ok && logResult.output.trim()) {
|
|
428
|
+
const shas = logResult.output
|
|
429
|
+
.trim()
|
|
430
|
+
.split("\n")
|
|
431
|
+
.filter(Boolean)
|
|
432
|
+
.map((s) => ({
|
|
433
|
+
sha: s,
|
|
434
|
+
short: s,
|
|
435
|
+
source: "git log grep" as const,
|
|
436
|
+
}));
|
|
437
|
+
if (shas.length > 0) return shas;
|
|
438
|
+
}
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
|
|
379
442
|
function resolveScope(
|
|
380
443
|
scope: Scope,
|
|
381
444
|
cwd: string,
|
|
@@ -394,15 +457,33 @@ function resolveScope(
|
|
|
394
457
|
}
|
|
395
458
|
if (scope.kind === "plan") {
|
|
396
459
|
const planPath = resolvePlanPath(scope.path, cwd, worktree);
|
|
397
|
-
const
|
|
460
|
+
const planText = readFileSync(planPath, "utf-8");
|
|
461
|
+
const planFiles = planFrontFiles(planText);
|
|
398
462
|
if (planFiles === null) {
|
|
399
|
-
// No files[]
|
|
463
|
+
// No files[] — try fallback commit sources before default diff.
|
|
464
|
+
const planBase = basename(planPath);
|
|
465
|
+
const fallbackCommits = planFallbackCommits(planText, planBase, cwd);
|
|
466
|
+
if (fallbackCommits.length > 0) {
|
|
467
|
+
// Use the fallback commits as the scope: get the files touched by those commits.
|
|
468
|
+
const shaList = fallbackCommits.map((c) => c.sha).join(" ");
|
|
469
|
+
const entries = parseNumstat(
|
|
470
|
+
execGit(`git diff-tree --no-commit-id --numstat -r ${shaList}`, cwd)
|
|
471
|
+
.output,
|
|
472
|
+
);
|
|
473
|
+
const source = fallbackCommits[0].source;
|
|
474
|
+
return {
|
|
475
|
+
label: `--plan ${scope.path} (commits via ${source}: ${fallbackCommits.map((c) => c.short).join(", ")})`,
|
|
476
|
+
entries,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
// No commits found either — fall back to the default diff, say so.
|
|
400
480
|
const entries = [
|
|
401
481
|
...parseNumstat(execGit("git diff HEAD --numstat -M", cwd).output),
|
|
402
482
|
...untrackedFiles(execGit("git status --porcelain -uall", cwd).output),
|
|
403
483
|
];
|
|
404
484
|
return {
|
|
405
|
-
label:
|
|
485
|
+
label:
|
|
486
|
+
"--plan (no files: frontmatter, no commits found) — diff HEAD + untracked",
|
|
406
487
|
entries,
|
|
407
488
|
};
|
|
408
489
|
}
|
|
@@ -597,7 +678,7 @@ function renderLookup(
|
|
|
597
678
|
lines.push(`worktree: ${worktree} (${lookupLabel(flags)})`);
|
|
598
679
|
|
|
599
680
|
let resolved: ResolvedScope | null = null;
|
|
600
|
-
if (flags.callers) {
|
|
681
|
+
if (flags.callers.length > 0) {
|
|
601
682
|
resolved = resolveScope(scope, cwd, worktree);
|
|
602
683
|
}
|
|
603
684
|
|
|
@@ -626,7 +707,7 @@ function renderLookup(
|
|
|
626
707
|
} catch {
|
|
627
708
|
continue;
|
|
628
709
|
}
|
|
629
|
-
const scan = extractExports(source);
|
|
710
|
+
const scan = extractExports(source, undefined, path);
|
|
630
711
|
if (scan.error) continue;
|
|
631
712
|
for (const sym of scan.symbols) {
|
|
632
713
|
if (!flags.body.includes(sym.name)) continue;
|
|
@@ -651,30 +732,34 @@ function renderLookup(
|
|
|
651
732
|
}
|
|
652
733
|
}
|
|
653
734
|
|
|
654
|
-
if (flags.callers) {
|
|
735
|
+
if (flags.callers.length > 0) {
|
|
655
736
|
const graph = buildGraph(worktree);
|
|
656
737
|
const targets = (resolved?.entries ?? [])
|
|
657
738
|
.map((e) => e.path)
|
|
658
739
|
.filter(hasGraph);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
`callers of ${
|
|
665
|
-
)
|
|
666
|
-
} else {
|
|
667
|
-
lines.push(
|
|
668
|
-
`callers of ${flags.callers} (textual hits, may be comments/strings):`,
|
|
669
|
-
);
|
|
670
|
-
for (const f of found.rows) {
|
|
671
|
-
const more = f.more > 0 ? ` (+${f.more} more hits)` : "";
|
|
672
|
-
lines.push(` ${f.file}:${f.hits.join(",")}${more}`);
|
|
673
|
-
}
|
|
674
|
-
if (found.filesCapped) {
|
|
740
|
+
// One section per symbol — a merged any-of scan would lose which
|
|
741
|
+
// symbol hit, and a single symbol's output stays byte-identical.
|
|
742
|
+
for (const sym of flags.callers) {
|
|
743
|
+
const found = findCallers(sym, targets, graph, worktree);
|
|
744
|
+
if (targets.length === 0) {
|
|
745
|
+
lines.push(`callers of ${sym}: no source files in scope`);
|
|
746
|
+
} else if (found.rows.length === 0) {
|
|
675
747
|
lines.push(
|
|
676
|
-
`
|
|
748
|
+
`callers of ${sym}: none found in static importers (dynamic or non-importing use is out of reach)`,
|
|
677
749
|
);
|
|
750
|
+
} else {
|
|
751
|
+
lines.push(
|
|
752
|
+
`callers of ${sym} (textual hits, may be comments/strings):`,
|
|
753
|
+
);
|
|
754
|
+
for (const f of found.rows) {
|
|
755
|
+
const more = f.more > 0 ? ` (+${f.more} more hits)` : "";
|
|
756
|
+
lines.push(` ${f.file}:${f.hits.join(",")}${more}`);
|
|
757
|
+
}
|
|
758
|
+
if (found.filesCapped) {
|
|
759
|
+
lines.push(
|
|
760
|
+
` ⚠ more importer files matched — capped at ${MAX_CALLER_FILES}`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
678
763
|
}
|
|
679
764
|
}
|
|
680
765
|
}
|
|
@@ -686,7 +771,9 @@ function renderLookup(
|
|
|
686
771
|
function lookupLabel(flags: LookupFlags): string {
|
|
687
772
|
const parts: string[] = [];
|
|
688
773
|
if (flags.body.length > 0) parts.push(`--body ${flags.body.join(",")}`);
|
|
689
|
-
if (flags.callers)
|
|
774
|
+
if (flags.callers.length > 0) {
|
|
775
|
+
parts.push(`--callers ${flags.callers.join(",")}`);
|
|
776
|
+
}
|
|
690
777
|
return parts.join(" ");
|
|
691
778
|
}
|
|
692
779
|
|
|
@@ -709,7 +796,7 @@ export function renderSeed(args: string[], cwd: string): string {
|
|
|
709
796
|
// feeds --callers its targets), but the standard sections are suppressed —
|
|
710
797
|
// the caller asked for one answer, not the review seed around it.
|
|
711
798
|
const lookup = parseLookup(args);
|
|
712
|
-
if (lookup.body.length > 0 || lookup.callers) {
|
|
799
|
+
if (lookup.body.length > 0 || lookup.callers.length > 0) {
|
|
713
800
|
return renderLookup(lookup, scope, cwd, worktree);
|
|
714
801
|
}
|
|
715
802
|
|
|
@@ -815,7 +902,7 @@ export function renderSeed(args: string[], cwd: string): string {
|
|
|
815
902
|
} catch {
|
|
816
903
|
continue;
|
|
817
904
|
}
|
|
818
|
-
const scan = extractExports(source);
|
|
905
|
+
const scan = extractExports(source, undefined, e.path);
|
|
819
906
|
if (scan.error) {
|
|
820
907
|
sigLines.push(` ${e.path} — ⚠ ${scan.error}`);
|
|
821
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.**
|