fapony 0.2.1 → 0.3.3
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 +95 -72
- package/fapony.ts +12 -5
- package/package.json +5 -4
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +38 -61
- package/src/analyze.ts +1 -1
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/gate.ts +3 -3
- package/src/hook.ts +349 -100
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +16 -123
- package/src/install/codex.ts +58 -19
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +131 -6
- package/src/install.ts +12 -3
- package/src/lint-baseline.ts +2 -2
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +13 -102
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +8 -94
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +231 -141
- package/src/mem/index.ts +4 -13
- package/src/memory.ts +17 -8
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/debt.ts +0 -811
- package/src/math.ts +0 -13
- package/src/mcp/tools/usage.ts +0 -211
- package/src/mcp/tools/verdict.ts +0 -161
|
@@ -31,7 +31,7 @@ You are about to move a PLAN that has been shipped to the archive.
|
|
|
31
31
|
status: superseded
|
|
32
32
|
superseded_by: PLAN-bar.md
|
|
33
33
|
```
|
|
34
|
-
Then skip step 5 — no work shipped, so there is
|
|
34
|
+
Then skip step 5 — no work shipped, so there is nothing to record. Never archive a plan as
|
|
35
35
|
superseded on your own reading; the user says which plan replaced it.
|
|
36
36
|
|
|
37
37
|
A plan that is merely *waiting* (on a person, a customer, a decision) is **not** dead and does
|
|
@@ -60,29 +60,19 @@ You are about to move a PLAN that has been shipped to the archive.
|
|
|
60
60
|
chore(plan): archive PLAN-foo.md (shipped <hash>)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
5. **
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- `
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
- `
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
reader could not get from the diff: what the symptom looked like, where the cause actually
|
|
77
|
-
was, and the rule that follows. Standalone prose — it is read months later with no access
|
|
78
|
-
to this conversation.
|
|
79
|
-
- `worktree`: **absolute path** to this repo/worktree (`git rev-parse --show-toplevel`) —
|
|
80
|
-
every other fapony tool and query scopes by
|
|
81
|
-
absolute path too; a bare repo name won't match them
|
|
82
|
-
- `plan`: the archived plan's path (post-move, e.g. `.fapony/done/PLAN-foo.md`)
|
|
83
|
-
- `files`: repo-relative paths this plan touched (`git diff --name-only <base>..HEAD`) —
|
|
84
|
-
the only input to per-file risk history; without it the verdict says something happened
|
|
85
|
-
but not where
|
|
63
|
+
5. **Leave a note when the ship taught something** — `plan-sweep --apply` (step 2)
|
|
64
|
+
already logged the ship itself as a decision row, so a clean ship needs nothing
|
|
65
|
+
more. When the plan hit something a reader could not get from the diff, call the
|
|
66
|
+
`mem_add` MCP tool (fapony) once:
|
|
67
|
+
- `kind`: `note`
|
|
68
|
+
- `text`: what the symptom looked like, where the cause actually was, and the
|
|
69
|
+
rule that follows. Standalone prose — it is read months later with no access
|
|
70
|
+
to this conversation. Write one only then — "clean ship" files nothing, and a
|
|
71
|
+
note that repeats the diff teaches the next session nothing
|
|
72
|
+
- `files`: repo-relative paths this plan touched (`git diff --name-only <base>..HEAD`)
|
|
73
|
+
- `spec`: the archived plan's path (post-move, e.g. `.fapony/done/PLAN-foo.md`)
|
|
74
|
+
- `worktree`: **absolute path** (`git rev-parse --show-toplevel`) — every other
|
|
75
|
+
fapony tool scopes by absolute path too; a bare repo name won't match them
|
|
86
76
|
Skip only if fapony's MCP tools aren't available in this session — don't block the archive on it.
|
|
87
77
|
|
|
88
78
|
## Example
|
|
@@ -95,17 +85,16 @@ Steps:
|
|
|
95
85
|
→ moved, links rewritten, decision logged
|
|
96
86
|
3. spec: untouched, stays in .fapony/spec/
|
|
97
87
|
4. commit
|
|
98
|
-
5.
|
|
99
|
-
— clean ship, so no note
|
|
88
|
+
5. (clean ship — plan-sweep's decision row already recorded it, nothing more to file)
|
|
100
89
|
```
|
|
101
90
|
|
|
102
91
|
A ship worth a note looks like this instead:
|
|
103
92
|
|
|
104
93
|
```
|
|
105
|
-
5.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
5. mem_add(kind="note",
|
|
95
|
+
text="sheet scroll reset on open, not close — the restore hook was on the wrong side; the router's own scrollRestoration resets on every navigate(). Check the router option before writing a restore hook.",
|
|
96
|
+
files=["src/routes/expenses/index.tsx"], spec=".fapony/done/PLAN-quick-nav.md",
|
|
97
|
+
worktree="/Users/you/Project/vela")
|
|
109
98
|
```
|
|
110
99
|
|
|
111
100
|
## If fail
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: review-pony
|
|
3
|
-
description: Review a plan, PR, diff, or design doc as a verification rather than an opinion — scope first, walk the real path, break it on paper, cite everything. Takes optional effort (low|medium|high|max, widens the walk only, never skips a pass) and --fix (apply CONFIRMED blocker/major findings after the report). Records
|
|
3
|
+
description: Review a plan, PR, diff, or design doc as a verification rather than an opinion — scope first, walk the real path, break it on paper, cite everything. Takes optional effort (low|medium|high|max, widens the walk only, never skips a pass) and --fix (apply CONFIRMED blocker/major findings after the report). Records surviving findings to the project's mem log after the report. Trigger on /review-pony and proactively whenever the user asks to review, audit, scrutinize, sanity-check, or get a second opinion on a plan, PR, diff, design doc, or proposed code change.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Review Pony
|
|
@@ -83,10 +83,10 @@ Write down every place the walk surprises you. Surprises outrank style; chase th
|
|
|
83
83
|
- `low` — direct callers only, one hop. No test reading unless the diff touches a test file.
|
|
84
84
|
- `medium` (default) — as written above: full path, callers, tests on the path.
|
|
85
85
|
- `high` — also second-degree callers, and read the tests that exercise them, not just the path.
|
|
86
|
-
- `max` — also
|
|
87
|
-
|
|
86
|
+
- `max` — also grep every changed file for second-degree callers, and re-open
|
|
87
|
+
every `deferred` line from the last review of this scope, if fapony has one.
|
|
88
88
|
|
|
89
|
-
Whatever level stopped you, say so in the one-line coverage note (
|
|
89
|
+
Whatever level stopped you, say so in the one-line coverage note (see Report) — "walked to 1 hop"
|
|
90
90
|
is honest, "walked" alone at `low` is not.
|
|
91
91
|
|
|
92
92
|
## Pass 3 — A finding needs a failing input
|
|
@@ -117,10 +117,15 @@ them is how a review launders an assumption into a fact.
|
|
|
117
117
|
|
|
118
118
|
The reader has the diff and is deciding what to do next. Nothing else belongs here.
|
|
119
119
|
|
|
120
|
-
**Verdict first, then at most
|
|
120
|
+
**Verdict first, then at most 10 findings, at most 4 lines each, then one deferred line.**
|
|
121
121
|
Severity order: blocker → major → nit, and cut the nits entirely when anything structural
|
|
122
122
|
survived — they dilute the only thing worth reading.
|
|
123
123
|
|
|
124
|
+
**A capped report must not read as a complete one.** When more findings survived pass 3 than
|
|
125
|
+
the cap allows, the report ends with one line naming how many were held back and the worst
|
|
126
|
+
severity among them (`+ 4 more (major) — ask`) — never a silent stop at 10. A reader who
|
|
127
|
+
cannot tell "that's all" from "that's the cap" has been told a lie by omission.
|
|
128
|
+
|
|
124
129
|
```
|
|
125
130
|
<ship | fix-then-ship | rework | reject> — the single biggest reason, one sentence.
|
|
126
131
|
|
|
@@ -130,6 +135,7 @@ survived — they dilute the only thing worth reading.
|
|
|
130
135
|
fix: <the minimal change>
|
|
131
136
|
|
|
132
137
|
deferred: <thing> (<where it was specified>) · <thing>
|
|
138
|
+
+ <N> more (<worst severity>) ← required only when findings exceeded the cap
|
|
133
139
|
```
|
|
134
140
|
|
|
135
141
|
Four lines is a ceiling, not a quota — a finding that fits in two ships in two. Drop `repro:`
|
|
@@ -155,56 +161,25 @@ fixed: 1, 2 · skipped: 3 (PLAUSIBLE — could not reach the failing state)
|
|
|
155
161
|
```
|
|
156
162
|
|
|
157
163
|
Fixing changes what actually shipped, not what the review found — re-run pass 4's citation
|
|
158
|
-
check on the new state before calling it done, but don't re-run the whole review.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
let it change the report's content.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
reached the thing under review — the branch wouldn't build, the path is behind a
|
|
178
|
-
service you cannot run, every finding came out `PLAUSIBLE`. Say so in the report
|
|
179
|
-
too. Guessing `pass` there is the one outcome that makes the ledger lie.
|
|
180
|
-
|
|
181
|
-
`reason_code` — the *lead* (most severe) finding, not a generic bucket:
|
|
182
|
-
|
|
183
|
-
- **0 findings, or a clean pass → `none`** — never `other`. `other` means "a real
|
|
184
|
-
finding that none of these buckets name", so filing clean passes there puts them
|
|
185
|
-
in the recurring-fail-reasons list, where they crowd out the reasons that mean
|
|
186
|
-
something. It is the one value in this table that costs other people accuracy.
|
|
187
|
-
- missing or weak test coverage on the path you walked → `missing_test`
|
|
188
|
-
- change is narrower or wider than the plan / PR description claims → `scope_mismatch`
|
|
189
|
-
- a shell/eval/deploy command runs without the guard it needs → `unsafe_command`
|
|
190
|
-
- the plan or spec didn't cover a case the walk exposed → `spec_gap`
|
|
191
|
-
- the change stops short of what it set out to do → `incomplete`
|
|
192
|
-
- a real finding none of the above names → `other`, and then `note` is **required**
|
|
193
|
-
|
|
194
|
-
Always attach a one-line `note` — the only field a later review can act on. Say what broke or
|
|
195
|
-
was walked, not that a review happened.
|
|
196
|
-
|
|
197
|
-
Args: `verdict`, `reason_code`, `note`, `regime`, `worktree` — **absolute path** via
|
|
198
|
-
`git rev-parse --show-toplevel`, never a bare name (`runs.worktree` is free text; a bare name
|
|
199
|
-
writes where no query reads it and every fapony tool misses the run), `plan` (the PLAN file
|
|
200
|
-
path under review, omitted for a bare PR/diff), and `files` — the repo-relative paths you
|
|
201
|
-
actually walked. **Always send `files`.** It is the only input to per-file risk history; a
|
|
202
|
-
verdict without it tells the next session that something failed but not where. No `run_id` —
|
|
203
|
-
fapony reuses the latest still-open run for the same worktree+plan (so round 2+ counts toward
|
|
204
|
-
the round cap), creating a row only when none is open. `session_id` (optional) — the client
|
|
205
|
-
session id, only if the client exposes it; attribute the model, never block the submit on it.
|
|
206
|
-
If `verdict_submit` errors, say so in one line and move on — never re-run a review because
|
|
207
|
-
storage failed.
|
|
164
|
+
check on the new state before calling it done, but don't re-run the whole review. Record
|
|
165
|
+
what you found, not the post-fix state (the row's text can say the fix was applied).
|
|
166
|
+
|
|
167
|
+
## After: record what the next session needs (fapony)
|
|
168
|
+
|
|
169
|
+
If a blocker/major CONFIRMED finding survived, or the verdict is rework/reject,
|
|
170
|
+
call `mem_add` once, after the report is shown. Don't block the report on it,
|
|
171
|
+
and don't let it change the report's content. Clean reviews (ship, nit-only
|
|
172
|
+
findings) record nothing — there is nothing the next session needs to find.
|
|
173
|
+
|
|
174
|
+
kind is `bug` when a finding survived (something is broken), `decision` when
|
|
175
|
+
the verdict turns on scope alone (rework/reject from pass 1 — the review locks
|
|
176
|
+
a direction). text is the report's verdict line, standalone — what broke or was
|
|
177
|
+
decided, not that a review happened. files are the repo-relative paths actually
|
|
178
|
+
walked — required, a row without them is unfindable. worktree is the absolute
|
|
179
|
+
path (`git rev-parse --show-toplevel`), never a bare name. Only record into a
|
|
180
|
+
project that already has a mem log — never create one uninvited.
|
|
181
|
+
If `mem_add` errors, say so in one line and move on — never re-run a review
|
|
182
|
+
because storage failed.
|
|
208
183
|
|
|
209
184
|
---
|
|
210
185
|
|
|
@@ -214,9 +189,11 @@ The four passes are the rules. These three are what they fail on in practice:
|
|
|
214
189
|
|
|
215
190
|
- **Order is not optional.** No line-by-line notes before pass 1, no finding before passes 2-3
|
|
216
191
|
earned it, nothing stated as fact that pass 4 cannot cite.
|
|
217
|
-
- **The budget is binding.** Verdict, ≤
|
|
192
|
+
- **The budget is binding.** Verdict, ≤10 findings, ≤4 lines each, one deferred line. Over budget
|
|
218
193
|
means you are reporting process. "LGTM" is not an output either — finding nothing ships as the
|
|
219
|
-
verdict line plus one line naming what you walked.
|
|
194
|
+
verdict line plus one line naming what you walked. When the cap bites, say how much it bit:
|
|
195
|
+
a `+ N more (<severity>)` line is required — a silent stop at 10 is the same lie as a silent
|
|
196
|
+
stop at 3.
|
|
220
197
|
- **Forget who wrote it.** The author's reasoning is context, never evidence.
|
|
221
198
|
|
|
222
199
|
## Example
|
|
@@ -224,10 +201,10 @@ The four passes are the rules. These three are what they fail on in practice:
|
|
|
224
201
|
```
|
|
225
202
|
1-4. scope holds; walked the new gate branch; ran the evidence command — it exits 0
|
|
226
203
|
without running the suite (CONFIRMED: `bun test` with no test dir exits 0)
|
|
227
|
-
post.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
204
|
+
post. mem_add(kind="bug",
|
|
205
|
+
text="incremental scan replaces cached history with a delta — cache holds 500, truth 1500",
|
|
206
|
+
files=["cache.ts", "claude-code.ts"],
|
|
207
|
+
worktree="/Users/you/Project/fapony/wt-fapony")
|
|
231
208
|
```
|
|
232
209
|
|
|
233
210
|
A report in budget — same review that, narrated, ran five paragraphs:
|
package/src/analyze.ts
CHANGED
|
@@ -147,7 +147,7 @@ export const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
|
147
147
|
// have real importers here — scanning them produces false wrapper/orphan
|
|
148
148
|
// signals (measured: conventions-seed flagged 8 "wrappers" that were all
|
|
149
149
|
// src/mem/commands/*.ts helpers matched against unrelated identically-
|
|
150
|
-
// named calls elsewhere in the repo, e.g. "
|
|
150
|
+
// named calls elsewhere in the repo, e.g. "cmdDone() instead of done(").
|
|
151
151
|
const SKIP_DIRS = new Set([
|
|
152
152
|
"node_modules",
|
|
153
153
|
"dist",
|
package/src/debt/cli.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/debt/cli.ts — `fapony debt` CLI: arg parsing + worktree resolution.
|
|
2
|
+
//
|
|
3
|
+
// Read-only stdout: no file writes, no state.db, no cache (rule 5b).
|
|
4
|
+
|
|
5
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { CONVENTIONS_FILE } from "../db/defaults.js";
|
|
8
|
+
import { formatDebt } from "./format.js";
|
|
9
|
+
import { loadConventions } from "./load.js";
|
|
10
|
+
import { findPromotions, formatPromotions } from "./promotion.js";
|
|
11
|
+
import { debtForFile, debtScan } from "./scan.js";
|
|
12
|
+
import { ZONE_CAP } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage: fapony debt [path] [options]
|
|
15
|
+
--files f1,f2 check specific files instead of scanning
|
|
16
|
+
--id <conv> show only this convention
|
|
17
|
+
--where <path> narrow scope to files under this path
|
|
18
|
+
--all show all zones (default: cap at ${ZONE_CAP})
|
|
19
|
+
--json output raw JSON
|
|
20
|
+
-h, --help this help`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The dir `debt` measures: the nearest ancestor of `arg` (or cwd) that holds
|
|
24
|
+
* `.fapony/conventions.json`, bounded by the git root.
|
|
25
|
+
*
|
|
26
|
+
* Jumping straight to the git root was the bug: in a monorepo the root has no
|
|
27
|
+
* conventions.json and two apps have one each, so the mem resolver went
|
|
28
|
+
* ambiguous and `debt` said "nothing tracked yet" while
|
|
29
|
+
* apps/<x>/.fapony/conventions.json sat right there — and the positional path
|
|
30
|
+
* argument was silently ignored. Falling back to the git root keeps single
|
|
31
|
+
* repos run from a subdir scanning the whole repo.
|
|
32
|
+
*/
|
|
33
|
+
export function worktreeOf(arg: string | undefined): string {
|
|
34
|
+
const base = resolve(arg ?? ".");
|
|
35
|
+
let gitRoot: string | null = null;
|
|
36
|
+
try {
|
|
37
|
+
const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
38
|
+
cwd: base,
|
|
39
|
+
stdout: "pipe",
|
|
40
|
+
stderr: "pipe",
|
|
41
|
+
});
|
|
42
|
+
if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
|
|
43
|
+
} catch {
|
|
44
|
+
// not a repo — base is all we have
|
|
45
|
+
}
|
|
46
|
+
// `git rev-parse` returns a physical path (/var → /private/var on macOS),
|
|
47
|
+
// so the boundary check compares realpaths, same as the mem resolver.
|
|
48
|
+
const real = (d: string): string => {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(d);
|
|
51
|
+
} catch {
|
|
52
|
+
return d;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const boundary = gitRoot ? real(gitRoot) : null;
|
|
56
|
+
let dir = base;
|
|
57
|
+
while (true) {
|
|
58
|
+
if (existsSync(join(dir, CONVENTIONS_FILE))) return dir;
|
|
59
|
+
if (boundary && real(dir) === boundary) break;
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) break;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
return gitRoot ?? base;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cmdDebt(args: string[]): void {
|
|
68
|
+
let path: string | undefined;
|
|
69
|
+
let filesMode: string[] | null = null;
|
|
70
|
+
let json = false;
|
|
71
|
+
let filterId: string | undefined;
|
|
72
|
+
let wherePath: string | undefined;
|
|
73
|
+
let showAll = false;
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === "--files") {
|
|
77
|
+
const v = args[i + 1];
|
|
78
|
+
if (!v || v.startsWith("--")) {
|
|
79
|
+
console.error(`fapony debt: --files needs a value\n${USAGE}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
i++;
|
|
83
|
+
filesMode = v
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((s) => s.trim())
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
if (filesMode.length === 0) {
|
|
88
|
+
console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
} else if (a === "--id") {
|
|
92
|
+
const v = args[i + 1];
|
|
93
|
+
if (!v || v.startsWith("--")) {
|
|
94
|
+
console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
i++;
|
|
98
|
+
filterId = v;
|
|
99
|
+
} else if (a === "--where") {
|
|
100
|
+
const v = args[i + 1];
|
|
101
|
+
if (!v || v.startsWith("--")) {
|
|
102
|
+
console.error(`fapony debt: --where needs a path\n${USAGE}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
i++;
|
|
106
|
+
wherePath = v;
|
|
107
|
+
} else if (a === "--all") {
|
|
108
|
+
showAll = true;
|
|
109
|
+
} else if (a === "--json") {
|
|
110
|
+
json = true;
|
|
111
|
+
} else if (a === "-h" || a === "--help") {
|
|
112
|
+
console.log(USAGE);
|
|
113
|
+
return;
|
|
114
|
+
} else if (!a.startsWith("--")) {
|
|
115
|
+
path = a;
|
|
116
|
+
} else {
|
|
117
|
+
console.error(`fapony debt: unknown argument "${a}"\n${USAGE}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const worktree = worktreeOf(path);
|
|
123
|
+
const loaded = loadConventions(worktree);
|
|
124
|
+
|
|
125
|
+
if (filesMode) {
|
|
126
|
+
const out = filesMode.map((f) => {
|
|
127
|
+
const abs = isAbsolute(f) ? f : resolve(worktree, f);
|
|
128
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
129
|
+
return { file: f, debt: [], note: "not found" as const };
|
|
130
|
+
}
|
|
131
|
+
return { file: f, debt: debtForFile(worktree, abs, loaded) };
|
|
132
|
+
});
|
|
133
|
+
if (json) {
|
|
134
|
+
console.log(JSON.stringify({ worktree, files: out }, null, 2));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let any = false;
|
|
138
|
+
for (const r of out) {
|
|
139
|
+
for (const c of r.debt) {
|
|
140
|
+
any = true;
|
|
141
|
+
console.log(`${r.file} — ${c.id}: ${c.rule}`);
|
|
142
|
+
}
|
|
143
|
+
if ("note" in r) console.log(`${r.file} — ${r.note}`);
|
|
144
|
+
}
|
|
145
|
+
if (!any && out.every((r) => r.debt.length === 0)) {
|
|
146
|
+
console.log("no convention debt in the given file(s)");
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (loaded.path === null) {
|
|
152
|
+
// SPEC §6: no conventions.json = completely silent, no error, no prompt to create one
|
|
153
|
+
console.log(
|
|
154
|
+
`fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const report = debtScan(worktree, loaded);
|
|
159
|
+
|
|
160
|
+
// --id filter: keep only the named convention
|
|
161
|
+
if (filterId) {
|
|
162
|
+
report.entries = report.entries.filter((e) => e.conv.id === filterId);
|
|
163
|
+
report.declared = report.declared.filter((c) => c.id === filterId);
|
|
164
|
+
report.checkedCount = 0; // not relevant when filtering
|
|
165
|
+
report.dropped = report.dropped.filter((d) => d.id === filterId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --where filter: narrow file lists to paths under the given prefix
|
|
169
|
+
if (wherePath) {
|
|
170
|
+
const prefix = wherePath.replace(/\/+$/, "");
|
|
171
|
+
for (const e of report.entries) {
|
|
172
|
+
e.files = e.files.filter(
|
|
173
|
+
(f) => f === prefix || f.startsWith(`${prefix}/`),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (json) {
|
|
179
|
+
console.log(
|
|
180
|
+
JSON.stringify(
|
|
181
|
+
{ ...report, promotions: findPromotions(worktree, report) },
|
|
182
|
+
null,
|
|
183
|
+
2,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
console.log(formatDebt(report, showAll));
|
|
189
|
+
for (const w of loaded.warnings) console.log(`⚠ ${w}`);
|
|
190
|
+
for (const l of formatPromotions(findPromotions(worktree, report))) {
|
|
191
|
+
console.log(l);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/debt/format.ts — report rendering: zone grouping + text output.
|
|
2
|
+
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { type DebtReport, ZONE_CAP, ZONE_DEPTH } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** The zone of a file: its directory path, capped at `depth` segments. */
|
|
7
|
+
function zoneOf(file: string, depth: number): string {
|
|
8
|
+
const parts = dirname(file)
|
|
9
|
+
.split("/")
|
|
10
|
+
.filter((p) => p && p !== ".");
|
|
11
|
+
return parts.slice(0, depth).join("/") || ".";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Group files by their directory zone (see `zoneOf`). */
|
|
15
|
+
function groupFilesByZone(
|
|
16
|
+
files: string[],
|
|
17
|
+
depth: number,
|
|
18
|
+
): Map<string, string[]> {
|
|
19
|
+
const zones = new Map<string, string[]>();
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
const zone = zoneOf(f, depth);
|
|
22
|
+
const cur = zones.get(zone) ?? [];
|
|
23
|
+
cur.push(f);
|
|
24
|
+
zones.set(zone, cur);
|
|
25
|
+
}
|
|
26
|
+
// Sort zones by file count descending, then alphabetically
|
|
27
|
+
return new Map(
|
|
28
|
+
[...zones.entries()].sort((a, b) => {
|
|
29
|
+
const d = b[1].length - a[1].length;
|
|
30
|
+
return d !== 0 ? d : a[0].localeCompare(b[0]);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Escape a regex source for use in a shell grep command. */
|
|
36
|
+
function shellEscapeRe(src: string): string {
|
|
37
|
+
return src.replace(/'/g, "'\\''");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatDebt(report: DebtReport, showAll = false): string {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
lines.push(
|
|
43
|
+
`fapony debt — ${report.entries.length + report.declared.length + report.checkedCount} convention(s), ` +
|
|
44
|
+
`${report.scannedFiles} files scanned, ${report.ms}ms — derived fresh, not stored`,
|
|
45
|
+
);
|
|
46
|
+
for (const e of report.entries) {
|
|
47
|
+
const moved =
|
|
48
|
+
e.movedCount !== null && e.files.length > 0
|
|
49
|
+
? ` · moved ${e.movedCount} (${Math.round((e.movedCount / (e.files.length + e.movedCount)) * 100)}%)`
|
|
50
|
+
: e.movedCount !== null
|
|
51
|
+
? ` · moved ${e.movedCount}`
|
|
52
|
+
: "";
|
|
53
|
+
lines.push(`\n${e.conv.id} — ${e.conv.rule}`);
|
|
54
|
+
// Show the patterns actually used
|
|
55
|
+
const patterns: string[] = [];
|
|
56
|
+
if (e.conv.stale) patterns.push(`stale: ${e.conv.stale}`);
|
|
57
|
+
if (e.conv.ok) patterns.push(`ok: ${e.conv.ok}`);
|
|
58
|
+
if (e.conv.guard) patterns.push(`guard: ${e.conv.guard}`);
|
|
59
|
+
patterns.push(`where ${e.conv.where}`);
|
|
60
|
+
lines.push(` ${patterns.join(" · ")}`);
|
|
61
|
+
if (e.files.length === 0) {
|
|
62
|
+
lines.push(` debt 0${moved} — clean`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
lines.push(` debt ${e.files.length}${moved}`);
|
|
66
|
+
// Verify command derived from stale
|
|
67
|
+
if (e.conv.stale) {
|
|
68
|
+
lines.push(` verify: grep -rn '${shellEscapeRe(e.conv.stale)}' <zone>`);
|
|
69
|
+
}
|
|
70
|
+
// Zone grouping
|
|
71
|
+
const zones = groupFilesByZone(e.files, ZONE_DEPTH);
|
|
72
|
+
const zoneEntries = [...zones.entries()];
|
|
73
|
+
const cap = showAll
|
|
74
|
+
? zoneEntries.length
|
|
75
|
+
: Math.min(zoneEntries.length, ZONE_CAP);
|
|
76
|
+
let totalCapped = 0;
|
|
77
|
+
for (let i = 0; i < cap; i++) {
|
|
78
|
+
const [zone, zoneFiles] = zoneEntries[i];
|
|
79
|
+
const pad = " ".repeat(Math.max(0, 42 - zone.length));
|
|
80
|
+
lines.push(`\n ${zone}${pad}${zoneFiles.length} ไฟล์`);
|
|
81
|
+
lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
|
|
82
|
+
totalCapped += zoneFiles.length;
|
|
83
|
+
}
|
|
84
|
+
if (zoneEntries.length > cap) {
|
|
85
|
+
const remaining = e.files.length - totalCapped;
|
|
86
|
+
const remainingZones = zoneEntries.length - cap;
|
|
87
|
+
lines.push(
|
|
88
|
+
`\n … อีก ${remainingZones} โซน (${remaining} ไฟล์) — fapony debt --id ${e.conv.id} --all`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const c of report.declared) {
|
|
93
|
+
lines.push(`\n${c.id} — ${c.rule} (where ${c.where})`);
|
|
94
|
+
lines.push(
|
|
95
|
+
` declared, no checker, stale not filled in — fill "stale" in conventions.json`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (report.checkedCount > 0) {
|
|
99
|
+
lines.push(
|
|
100
|
+
`\n${report.checkedCount} convention(s) have a checker — fapony stays silent, the checker reports`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
for (const d of report.dropped) {
|
|
104
|
+
lines.push(`⚠ ${d.id}: ${d.reason}`);
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/debt/index.ts — barrel for `fapony debt`: which files have not moved
|
|
2
|
+
// to a shipped convention yet.
|
|
3
|
+
//
|
|
4
|
+
// The question nobody can answer: "which files have not moved" — rules files
|
|
5
|
+
// (CLAUDE.md, Cursor rules) can only say "what the rule is" (layer 2) and
|
|
6
|
+
// "which files were copied" (layer 1) — where the debt is (layer 3) lives in
|
|
7
|
+
// the owner's head and vanishes when forgotten (SPEC-convention-debt §1)
|
|
8
|
+
//
|
|
9
|
+
// The convention definition lives in the measured repo — fapony does not know
|
|
10
|
+
// React or Hono and must not. Layout mirrors src/mcp/: types + one file per
|
|
11
|
+
// concern, CLI entry in cli.ts (fapony.ts imports that directly, same as
|
|
12
|
+
// digest/cli.ts).
|
|
13
|
+
|
|
14
|
+
export * from "./cli.js";
|
|
15
|
+
export * from "./format.js";
|
|
16
|
+
export * from "./load.js";
|
|
17
|
+
export * from "./promotion.js";
|
|
18
|
+
export * from "./scan.js";
|
|
19
|
+
export * from "./types.js";
|
package/src/debt/load.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/debt/load.ts — conventions.json resolution + parsing.
|
|
2
|
+
//
|
|
3
|
+
// The convention definition lives in the measured repo
|
|
4
|
+
// (<repo>/.fapony/conventions.json — via the same resolver as the mem log).
|
|
5
|
+
// Missing file = empty + no error.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
CONVENTIONS_FILE,
|
|
11
|
+
CONVENTIONS_FILENAME,
|
|
12
|
+
FAPONY_DIR,
|
|
13
|
+
} from "../db/defaults.js";
|
|
14
|
+
import { resolveMemDir } from "../memory.js";
|
|
15
|
+
import type { Convention, LoadedConventions } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export function resolveConventionsPath(worktree: string): string | null {
|
|
18
|
+
// Conventions live in the same .fapony/ dir as the mem log — derive from
|
|
19
|
+
// the resolved mem dir so both resolvers cannot drift apart.
|
|
20
|
+
const memDir = resolveMemDir(worktree);
|
|
21
|
+
const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
|
|
22
|
+
const app = join(base, CONVENTIONS_FILENAME);
|
|
23
|
+
if (existsSync(app)) return app;
|
|
24
|
+
// Monorepo where the app has not scaffolded .fapony/ yet, and single repos
|
|
25
|
+
// that ran `fapony init` at the root — the root file still scopes fine
|
|
26
|
+
// because every `where` is repo-relative.
|
|
27
|
+
const root = join(worktree, CONVENTIONS_FILE);
|
|
28
|
+
return existsSync(root) ? root : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asString(v: unknown): string | undefined {
|
|
32
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parses .fapony/conventions.json. Missing file = empty + no error (SPEC §6). */
|
|
36
|
+
export function loadConventions(worktree: string): LoadedConventions {
|
|
37
|
+
const path = resolveConventionsPath(worktree);
|
|
38
|
+
if (!path) return { path: null, convs: [], warnings: [] };
|
|
39
|
+
let raw: string;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(path, "utf-8");
|
|
42
|
+
} catch {
|
|
43
|
+
return {
|
|
44
|
+
path,
|
|
45
|
+
convs: [],
|
|
46
|
+
warnings: [`conventions.json unreadable: ${path}`],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return {
|
|
54
|
+
path,
|
|
55
|
+
convs: [],
|
|
56
|
+
warnings: [
|
|
57
|
+
`conventions.json is not valid JSON — ${
|
|
58
|
+
e instanceof Error ? e.message.split("\n")[0] : "parse error"
|
|
59
|
+
}`,
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const rows: unknown[] = Array.isArray(parsed)
|
|
64
|
+
? parsed
|
|
65
|
+
: Array.isArray((parsed as { conventions?: unknown }).conventions)
|
|
66
|
+
? (parsed as { conventions: unknown[] }).conventions
|
|
67
|
+
: [];
|
|
68
|
+
const convs: Convention[] = [];
|
|
69
|
+
const warnings: string[] = [];
|
|
70
|
+
rows.forEach((r, i) => {
|
|
71
|
+
const o = r as Record<string, unknown>;
|
|
72
|
+
const id = asString(o.id);
|
|
73
|
+
const rule = asString(o.rule);
|
|
74
|
+
if (!id || !rule) {
|
|
75
|
+
warnings.push(
|
|
76
|
+
`conventions[${i}]: id and rule are required — row dropped`,
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
convs.push({
|
|
81
|
+
id,
|
|
82
|
+
rule,
|
|
83
|
+
where: asString(o.where) ?? ".",
|
|
84
|
+
stale: asString(o.stale) ?? null,
|
|
85
|
+
ok: asString(o.ok),
|
|
86
|
+
guard: asString(o.guard),
|
|
87
|
+
checker: asString(o.checker) ?? null,
|
|
88
|
+
decided: o.decided === "no-checker" ? "no-checker" : null,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
return { path, convs, warnings };
|
|
92
|
+
}
|