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
package/src/debt.ts
DELETED
|
@@ -1,811 +0,0 @@
|
|
|
1
|
-
// src/debt.ts — `fapony debt`: which files have not moved to a shipped convention yet.
|
|
2
|
-
//
|
|
3
|
-
// The question nobody can answer: "which files have not moved" — rules files
|
|
4
|
-
// (CLAUDE.md, Cursor rules) can only say "what the rule is" (layer 2) and
|
|
5
|
-
// "which files were copied" (layer 1) — where the debt is (layer 3) lives in
|
|
6
|
-
// the owner's head and vanishes when forgotten (SPEC-convention-debt §1)
|
|
7
|
-
//
|
|
8
|
-
// The convention definition lives in the measured repo (<repo>/.fapony/conventions.json
|
|
9
|
-
// — via the same resolver as the mem log, SPEC §2.1) — fapony does not know React
|
|
10
|
-
// or Hono and must not · one convention = pattern to use (ok) + pattern meaning
|
|
11
|
-
// not-yet-migrated (stale) + scope (where) + file condition (guard, e.g. extends Base)
|
|
12
|
-
//
|
|
13
|
-
// Debt is computed live every time, never written anywhere (same as analyze:
|
|
14
|
-
// a cache is pure debt — a frozen list goes stale silently like MASTER.md) ·
|
|
15
|
-
// Iron rule: checker not null = fapony does not report that debt item — reporting
|
|
16
|
-
// twice with eslint is an abstraction with one implementation (rule 1) and
|
|
17
|
-
// teaches the agent to skip both (SPEC §2)
|
|
18
|
-
//
|
|
19
|
-
// Read-only stdout: no file writes, no state.db, no cache (rule 5b).
|
|
20
|
-
|
|
21
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
22
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
-
import { collectSourceFiles } from "./analyze.js";
|
|
24
|
-
import {
|
|
25
|
-
CONVENTIONS_FILE,
|
|
26
|
-
CONVENTIONS_FILENAME,
|
|
27
|
-
FAPONY_DIR,
|
|
28
|
-
} from "./db/defaults.js";
|
|
29
|
-
import { openDb } from "./db/index.js";
|
|
30
|
-
import { readMemLog, resolveMemDir } from "./memory.js";
|
|
31
|
-
|
|
32
|
-
// A stale regex matching more than this many files is not a convention — it is
|
|
33
|
-
// a broken/wide regex (stale="e" would flag the repo). SPEC §6: drop the entry
|
|
34
|
-
// and say so, never report 600 files.
|
|
35
|
-
const DEBT_FILE_CAP = 250;
|
|
36
|
-
|
|
37
|
-
export interface Convention {
|
|
38
|
-
id: string;
|
|
39
|
-
rule: string;
|
|
40
|
-
/** Repo-relative dir scope ("." = whole repo). */
|
|
41
|
-
where: string;
|
|
42
|
-
/** Regex source: a match means the file still has the debt. null = not derivable (checker rows) or not filled in yet. */
|
|
43
|
-
stale: string | null;
|
|
44
|
-
/** Regex source: files that already moved (informational count). */
|
|
45
|
-
ok?: string;
|
|
46
|
-
/** Regex source a file must ALSO match to be in scope (e.g. "extends Base"). */
|
|
47
|
-
guard?: string;
|
|
48
|
-
/** Non-null = a checker (eslint rule / script) exists → fapony never reports this debt. */
|
|
49
|
-
checker?: string | null;
|
|
50
|
-
/** Human answered "no checker" on the promotion question — never ask again. */
|
|
51
|
-
decided?: "no-checker" | null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- conventions.json resolution (same guess as the mem log, SPEC §2.1) ---
|
|
55
|
-
|
|
56
|
-
export function resolveConventionsPath(worktree: string): string | null {
|
|
57
|
-
// Conventions live in the same .fapony/ dir as the mem log — derive from
|
|
58
|
-
// the resolved mem dir so both resolvers cannot drift apart.
|
|
59
|
-
const memDir = resolveMemDir(worktree);
|
|
60
|
-
const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
|
|
61
|
-
const app = join(base, CONVENTIONS_FILENAME);
|
|
62
|
-
if (existsSync(app)) return app;
|
|
63
|
-
// Monorepo where the app has not scaffolded .fapony/ yet, and single repos
|
|
64
|
-
// that ran `fapony init` at the root — the root file still scopes fine
|
|
65
|
-
// because every `where` is repo-relative.
|
|
66
|
-
const root = join(worktree, CONVENTIONS_FILE);
|
|
67
|
-
return existsSync(root) ? root : null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface LoadedConventions {
|
|
71
|
-
path: string | null;
|
|
72
|
-
convs: Convention[];
|
|
73
|
-
/** Rows kept for display but not scannable, plus invalid rows — said out loud, never silent. */
|
|
74
|
-
warnings: string[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function asString(v: unknown): string | undefined {
|
|
78
|
-
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Parses .fapony/conventions.json. Missing file = empty + no error (SPEC §6). */
|
|
82
|
-
export function loadConventions(worktree: string): LoadedConventions {
|
|
83
|
-
const path = resolveConventionsPath(worktree);
|
|
84
|
-
if (!path) return { path: null, convs: [], warnings: [] };
|
|
85
|
-
let raw: string;
|
|
86
|
-
try {
|
|
87
|
-
raw = readFileSync(path, "utf-8");
|
|
88
|
-
} catch {
|
|
89
|
-
return {
|
|
90
|
-
path,
|
|
91
|
-
convs: [],
|
|
92
|
-
warnings: [`conventions.json unreadable: ${path}`],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
let parsed: unknown;
|
|
96
|
-
try {
|
|
97
|
-
parsed = JSON.parse(raw);
|
|
98
|
-
} catch (e) {
|
|
99
|
-
return {
|
|
100
|
-
path,
|
|
101
|
-
convs: [],
|
|
102
|
-
warnings: [
|
|
103
|
-
`conventions.json is not valid JSON — ${
|
|
104
|
-
e instanceof Error ? e.message.split("\n")[0] : "parse error"
|
|
105
|
-
}`,
|
|
106
|
-
],
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
const rows: unknown[] = Array.isArray(parsed)
|
|
110
|
-
? parsed
|
|
111
|
-
: Array.isArray((parsed as { conventions?: unknown }).conventions)
|
|
112
|
-
? (parsed as { conventions: unknown[] }).conventions
|
|
113
|
-
: [];
|
|
114
|
-
const convs: Convention[] = [];
|
|
115
|
-
const warnings: string[] = [];
|
|
116
|
-
rows.forEach((r, i) => {
|
|
117
|
-
const o = r as Record<string, unknown>;
|
|
118
|
-
const id = asString(o.id);
|
|
119
|
-
const rule = asString(o.rule);
|
|
120
|
-
if (!id || !rule) {
|
|
121
|
-
warnings.push(
|
|
122
|
-
`conventions[${i}]: id and rule are required — row dropped`,
|
|
123
|
-
);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
convs.push({
|
|
127
|
-
id,
|
|
128
|
-
rule,
|
|
129
|
-
where: asString(o.where) ?? ".",
|
|
130
|
-
stale: asString(o.stale) ?? null,
|
|
131
|
-
ok: asString(o.ok),
|
|
132
|
-
guard: asString(o.guard),
|
|
133
|
-
checker: asString(o.checker) ?? null,
|
|
134
|
-
decided: o.decided === "no-checker" ? "no-checker" : null,
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
return { path, convs, warnings };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// --- The scan (fresh every call — derive, never store) ---
|
|
141
|
-
|
|
142
|
-
interface Compiled {
|
|
143
|
-
conv: Convention;
|
|
144
|
-
staleRe: RegExp | null;
|
|
145
|
-
okRe: RegExp | null;
|
|
146
|
-
guardRe: RegExp | null;
|
|
147
|
-
whereDir: string;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function compile(conv: Convention): { c: Compiled; error?: string } {
|
|
151
|
-
const re = (
|
|
152
|
-
src: string | null | undefined,
|
|
153
|
-
what: string,
|
|
154
|
-
): { re: RegExp | null; error?: string } => {
|
|
155
|
-
if (!src) return { re: null };
|
|
156
|
-
try {
|
|
157
|
-
return { re: new RegExp(src) };
|
|
158
|
-
} catch (e) {
|
|
159
|
-
return {
|
|
160
|
-
re: null,
|
|
161
|
-
error: `${what} regex broken (${e instanceof Error ? e.message.split("\n")[0] : "?"})`,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
const stale = re(conv.stale, `${conv.id}: stale`);
|
|
166
|
-
if (stale.error)
|
|
167
|
-
return {
|
|
168
|
-
c: {
|
|
169
|
-
conv,
|
|
170
|
-
staleRe: null,
|
|
171
|
-
okRe: null,
|
|
172
|
-
guardRe: null,
|
|
173
|
-
whereDir: conv.where,
|
|
174
|
-
},
|
|
175
|
-
error: stale.error,
|
|
176
|
-
};
|
|
177
|
-
const ok = re(conv.ok, `${conv.id}: ok`);
|
|
178
|
-
if (ok.error)
|
|
179
|
-
return {
|
|
180
|
-
c: {
|
|
181
|
-
conv,
|
|
182
|
-
staleRe: null,
|
|
183
|
-
okRe: null,
|
|
184
|
-
guardRe: null,
|
|
185
|
-
whereDir: conv.where,
|
|
186
|
-
},
|
|
187
|
-
error: ok.error,
|
|
188
|
-
};
|
|
189
|
-
const guard = re(conv.guard, `${conv.id}: guard`);
|
|
190
|
-
if (guard.error)
|
|
191
|
-
return {
|
|
192
|
-
c: {
|
|
193
|
-
conv,
|
|
194
|
-
staleRe: null,
|
|
195
|
-
okRe: null,
|
|
196
|
-
guardRe: null,
|
|
197
|
-
whereDir: conv.where,
|
|
198
|
-
},
|
|
199
|
-
error: guard.error,
|
|
200
|
-
};
|
|
201
|
-
return {
|
|
202
|
-
c: {
|
|
203
|
-
conv,
|
|
204
|
-
staleRe: stale.re,
|
|
205
|
-
okRe: ok.re,
|
|
206
|
-
guardRe: guard.re,
|
|
207
|
-
// where="src" must scope src/ and src/x/y.ts but not src-other/;
|
|
208
|
-
// where="." scopes everything.
|
|
209
|
-
whereDir: conv.where === "." ? "" : conv.where.replace(/\/+$/, ""),
|
|
210
|
-
},
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function inScope(whereDir: string, file: string): boolean {
|
|
215
|
-
return whereDir === "" || file.startsWith(`${whereDir}/`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export interface DebtEntry {
|
|
219
|
-
conv: Convention;
|
|
220
|
-
/** Files with the debt (stale match), sorted. */
|
|
221
|
-
files: string[];
|
|
222
|
-
/** Files that already moved (ok match) — null when ok is not set. */
|
|
223
|
-
movedCount: number | null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export interface DebtReport {
|
|
227
|
-
worktree: string;
|
|
228
|
-
scannedFiles: number;
|
|
229
|
-
ms: number;
|
|
230
|
-
/** Scannable conventions with their debt list (checker rows never land here). */
|
|
231
|
-
entries: DebtEntry[];
|
|
232
|
-
/** Declared but not fillable by fapony: checker null + no stale — the human/agent fills `stale`. */
|
|
233
|
-
declared: Convention[];
|
|
234
|
-
/** Skipped-with-reason: checker rows are silent by design (not dropped), these are real drops. */
|
|
235
|
-
dropped: { id: string; reason: string }[];
|
|
236
|
-
/** Silent-by-design count: checker non-null — reported as a number, not a list. */
|
|
237
|
-
checkedCount: number;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function debtScan(
|
|
241
|
-
worktree: string,
|
|
242
|
-
loaded: LoadedConventions,
|
|
243
|
-
): DebtReport {
|
|
244
|
-
const t0 = performance.now();
|
|
245
|
-
const entries: DebtEntry[] = [];
|
|
246
|
-
const declared: Convention[] = [];
|
|
247
|
-
const dropped: { id: string; reason: string }[] = [];
|
|
248
|
-
let checkedCount = 0;
|
|
249
|
-
|
|
250
|
-
const compiled: Compiled[] = [];
|
|
251
|
-
for (const conv of loaded.convs) {
|
|
252
|
-
if (conv.checker) {
|
|
253
|
-
// Iron rule — fapony stays silent, leave it to the checker (SPEC §2)
|
|
254
|
-
checkedCount++;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (!conv.stale) {
|
|
258
|
-
// The one slot a human fills (SPEC §2.2) — show it as pending, don't guess
|
|
259
|
-
declared.push(conv);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
const { c, error } = compile(conv);
|
|
263
|
-
if (error || !c.staleRe) {
|
|
264
|
-
dropped.push({ id: conv.id, reason: error ?? "uncompilable" });
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
if (!existsSync(join(worktree, c.whereDir || "."))) {
|
|
268
|
-
dropped.push({
|
|
269
|
-
id: conv.id,
|
|
270
|
-
reason: `where: ${conv.where} does not exist`,
|
|
271
|
-
});
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
compiled.push(c);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const files = collectSourceFiles(worktree);
|
|
278
|
-
const debt: Map<string, string[]> = new Map(
|
|
279
|
-
compiled.map((c) => [c.conv.id, []]),
|
|
280
|
-
);
|
|
281
|
-
const moved: Map<string, number> = new Map(
|
|
282
|
-
compiled.map((c) => [c.conv.id, 0]),
|
|
283
|
-
);
|
|
284
|
-
const tooBroad: Map<string, number> = new Map();
|
|
285
|
-
|
|
286
|
-
for (const rel of files) {
|
|
287
|
-
let content: string;
|
|
288
|
-
try {
|
|
289
|
-
content = readFileSync(join(worktree, rel), "utf-8");
|
|
290
|
-
} catch {
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
for (const c of compiled) {
|
|
294
|
-
if (!c.staleRe) continue; // filtered at compile; narrows the type
|
|
295
|
-
if (!inScope(c.whereDir, rel)) continue;
|
|
296
|
-
if (c.guardRe && !c.guardRe.test(content)) continue;
|
|
297
|
-
if (c.staleRe.test(content)) {
|
|
298
|
-
const cur = debt.get(c.conv.id) ?? [];
|
|
299
|
-
cur.push(rel);
|
|
300
|
-
debt.set(c.conv.id, cur);
|
|
301
|
-
// Stop counting a runaway regex early — the entry will be dropped.
|
|
302
|
-
if (cur.length > DEBT_FILE_CAP) tooBroad.set(c.conv.id, cur.length);
|
|
303
|
-
}
|
|
304
|
-
if (c.okRe?.test(content)) {
|
|
305
|
-
moved.set(c.conv.id, (moved.get(c.conv.id) ?? 0) + 1);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
for (const c of compiled) {
|
|
311
|
-
const n = tooBroad.get(c.conv.id);
|
|
312
|
-
if (n !== undefined) {
|
|
313
|
-
dropped.push({
|
|
314
|
-
id: c.conv.id,
|
|
315
|
-
reason: `stale regex matches ${n}+ files — too broad, entry dropped (narrow stale/where/guard)`,
|
|
316
|
-
});
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
entries.push({
|
|
320
|
-
conv: c.conv,
|
|
321
|
-
files: (debt.get(c.conv.id) ?? []).sort(),
|
|
322
|
-
movedCount: c.okRe ? (moved.get(c.conv.id) ?? 0) : null,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return {
|
|
327
|
-
worktree,
|
|
328
|
-
scannedFiles: files.length,
|
|
329
|
-
ms: Math.round(performance.now() - t0),
|
|
330
|
-
entries,
|
|
331
|
-
declared,
|
|
332
|
-
dropped,
|
|
333
|
-
checkedCount,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** Per-file lookup (hook-read-hint + --files): which conventions flag this file. */
|
|
338
|
-
export function debtForFile(
|
|
339
|
-
worktree: string,
|
|
340
|
-
absFile: string,
|
|
341
|
-
loaded: LoadedConventions,
|
|
342
|
-
): Convention[] {
|
|
343
|
-
const rel = relative(worktree, absFile).split("\\").join("/");
|
|
344
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return [];
|
|
345
|
-
let content: string;
|
|
346
|
-
try {
|
|
347
|
-
content = readFileSync(absFile, "utf-8");
|
|
348
|
-
} catch {
|
|
349
|
-
return [];
|
|
350
|
-
}
|
|
351
|
-
const out: Convention[] = [];
|
|
352
|
-
for (const conv of loaded.convs) {
|
|
353
|
-
if (conv.checker || !conv.stale) continue;
|
|
354
|
-
const { c, error } = compile(conv);
|
|
355
|
-
if (error || !c.staleRe) continue;
|
|
356
|
-
if (!inScope(c.whereDir, rel)) continue;
|
|
357
|
-
if (c.guardRe && !c.guardRe.test(content)) continue;
|
|
358
|
-
if (c.staleRe.test(content)) out.push(conv);
|
|
359
|
-
}
|
|
360
|
-
return out;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// --- Promotion signal (chunk 5) — "this recurred N times, time for a checker?" ---
|
|
364
|
-
//
|
|
365
|
-
// "I'll write eslint when I think of it" — the "think of it" moment is what goes
|
|
366
|
-
// missing (SPEC §3) · fapony sees history across sessions (mem + verdicts), so it
|
|
367
|
-
// can count how often the same thing was fixed, then put the question to a human —
|
|
368
|
-
// it does not decide, does not write the eslint rule itself (SPEC §6 fail list)
|
|
369
|
-
//
|
|
370
|
-
// Matching "the same thing" — only as precise as the data allows (SPEC §7: old rows
|
|
371
|
-
// lack files[], still undecided): a row with files[] must intersect the debt list ·
|
|
372
|
-
// the text must mention a convention symbol (ok such as fmtMoney, or an identifier
|
|
373
|
-
// ≥ 6 chars from stale such as toLocaleString/useMutation — "throw"/"Error" are too
|
|
374
|
-
// short and don't count, to avoid over-matching)
|
|
375
|
-
|
|
376
|
-
export const PROMOTION_THRESHOLD = 3;
|
|
377
|
-
const PROMOTION_MAX = 3;
|
|
378
|
-
/** Identifiers shorter than this are too generic to match prose on ("throw", "Error"). */
|
|
379
|
-
const WORD_MIN = 6;
|
|
380
|
-
|
|
381
|
-
export interface Promotion {
|
|
382
|
-
convId: string;
|
|
383
|
-
rule: string;
|
|
384
|
-
occurrences: number;
|
|
385
|
-
dates: string[];
|
|
386
|
-
debtCount: number;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function conventionWords(conv: Convention): string[] {
|
|
390
|
-
const words = new Set<string>();
|
|
391
|
-
for (const src of [conv.ok, conv.stale]) {
|
|
392
|
-
if (!src) continue;
|
|
393
|
-
for (const m of src.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
394
|
-
if (m[0].length >= WORD_MIN) words.add(m[0]);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return [...words];
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function rowMatchesConv(
|
|
401
|
-
hay: string,
|
|
402
|
-
files: string[] | undefined,
|
|
403
|
-
debtFiles: Set<string>,
|
|
404
|
-
words: string[],
|
|
405
|
-
): boolean {
|
|
406
|
-
if (files && files.length > 0) {
|
|
407
|
-
if (files.some((f) => debtFiles.has(f))) return true;
|
|
408
|
-
}
|
|
409
|
-
const lower = hay.toLowerCase();
|
|
410
|
-
return words.some((w) => lower.includes(w.toLowerCase()));
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
interface EvidenceRow {
|
|
414
|
-
ts: string;
|
|
415
|
-
files?: string[];
|
|
416
|
-
hay: string;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function gatherEvidence(worktree: string): EvidenceRow[] {
|
|
420
|
-
const out: EvidenceRow[] = [];
|
|
421
|
-
try {
|
|
422
|
-
for (const r of readMemLog(worktree).rows) {
|
|
423
|
-
if (r.kind !== "bug" && r.kind !== "decision") continue;
|
|
424
|
-
out.push({ ts: r.ts, files: r.files, hay: `${r.text}\n${r.spec ?? ""}` });
|
|
425
|
-
}
|
|
426
|
-
} catch {
|
|
427
|
-
// mem missing — verdicts alone still count
|
|
428
|
-
}
|
|
429
|
-
try {
|
|
430
|
-
const db = openDb();
|
|
431
|
-
const events = db
|
|
432
|
-
.prepare(
|
|
433
|
-
`SELECT e.ts AS ts, e.data AS data FROM events e
|
|
434
|
-
JOIN runs r ON r.id = e.run_id
|
|
435
|
-
WHERE r.worktree = ? AND e.kind = 'gate' ORDER BY e.id`,
|
|
436
|
-
)
|
|
437
|
-
.all(worktree) as { ts: string; data: string | null }[];
|
|
438
|
-
for (const e of events) {
|
|
439
|
-
if (!e.data) continue;
|
|
440
|
-
try {
|
|
441
|
-
const d = JSON.parse(e.data) as {
|
|
442
|
-
verdict?: string;
|
|
443
|
-
reason_code?: string;
|
|
444
|
-
note?: string;
|
|
445
|
-
files?: string[];
|
|
446
|
-
};
|
|
447
|
-
const countsAsFix =
|
|
448
|
-
d.verdict === "fail" ||
|
|
449
|
-
d.reason_code === "scope_mismatch" ||
|
|
450
|
-
d.reason_code === "spec_gap";
|
|
451
|
-
if (!countsAsFix) continue;
|
|
452
|
-
out.push({ ts: e.ts, files: d.files, hay: d.note ?? "" });
|
|
453
|
-
} catch {}
|
|
454
|
-
}
|
|
455
|
-
} catch {
|
|
456
|
-
// no ledger yet — mem alone still counts
|
|
457
|
-
}
|
|
458
|
-
return out;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/** Repeated-fix questions for conventions that have no checker and no "no-checker" decision. */
|
|
462
|
-
export function findPromotions(
|
|
463
|
-
worktree: string,
|
|
464
|
-
report: DebtReport,
|
|
465
|
-
): Promotion[] {
|
|
466
|
-
const evidence = gatherEvidence(worktree);
|
|
467
|
-
if (evidence.length === 0) return [];
|
|
468
|
-
const out: Promotion[] = [];
|
|
469
|
-
for (const entry of report.entries) {
|
|
470
|
-
const { conv } = entry;
|
|
471
|
-
if (conv.checker || conv.decided === "no-checker") continue;
|
|
472
|
-
if (entry.files.length === 0) continue;
|
|
473
|
-
const debtFiles = new Set(entry.files);
|
|
474
|
-
const words = conventionWords(conv);
|
|
475
|
-
const hits = evidence.filter((r) =>
|
|
476
|
-
rowMatchesConv(r.hay, r.files, debtFiles, words),
|
|
477
|
-
);
|
|
478
|
-
if (hits.length < PROMOTION_THRESHOLD) continue;
|
|
479
|
-
const dates = [...new Set(hits.map((h) => h.ts.slice(0, 10)))].sort();
|
|
480
|
-
out.push({
|
|
481
|
-
convId: conv.id,
|
|
482
|
-
rule: conv.rule,
|
|
483
|
-
occurrences: hits.length,
|
|
484
|
-
dates,
|
|
485
|
-
debtCount: entry.files.length,
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
// Newest first, capped — three questions are already a conversation.
|
|
489
|
-
out.sort((a, b) => b.occurrences - a.occurrences);
|
|
490
|
-
return out.slice(0, PROMOTION_MAX);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
export function formatPromotions(promotions: Promotion[]): string[] {
|
|
494
|
-
if (promotions.length === 0) return [];
|
|
495
|
-
const lines: string[] = [
|
|
496
|
-
"",
|
|
497
|
-
"promotion — repeated fixes on conventions with no checker:",
|
|
498
|
-
];
|
|
499
|
-
for (const p of promotions) {
|
|
500
|
-
lines.push(
|
|
501
|
-
`\n"${p.convId}" (${p.debtCount} file(s) still wrong) came up ${p.occurrences}× ` +
|
|
502
|
-
`(${p.dates.slice(0, 3).join(", ")}${p.dates.length > 3 ? ", …" : ""})`,
|
|
503
|
-
);
|
|
504
|
-
lines.push(` ${p.rule}`);
|
|
505
|
-
lines.push(
|
|
506
|
-
" [1] make a checker — an agent drafts the eslint rule in this repo, you review",
|
|
507
|
-
);
|
|
508
|
-
lines.push(
|
|
509
|
-
' [2] one-off, no checker — record "decided": "no-checker" on this entry, never asked again',
|
|
510
|
-
);
|
|
511
|
-
lines.push(" [3] later — ask again when this comes up a few more times");
|
|
512
|
-
}
|
|
513
|
-
return lines;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// --- Formatting ---
|
|
517
|
-
|
|
518
|
-
// Zone grouping: a zone is a file's *directory*, capped at this many leading
|
|
519
|
-
// segments — never a fixed-depth prefix of the path (which would cut into the
|
|
520
|
-
// filename) and never the filename itself. SPEC §4 shows zones at depth 5
|
|
521
|
-
// (`apps/mdl/src/server/services`) and depth 3 (`packages/cache/src`) in the
|
|
522
|
-
// same report, so the cap must follow the directory, not a constant.
|
|
523
|
-
const ZONE_DEPTH = 5;
|
|
524
|
-
// Default cap on zones shown per convention — more than this is a wall, not an answer.
|
|
525
|
-
const ZONE_CAP = 6;
|
|
526
|
-
|
|
527
|
-
/** The zone of a file: its directory path, capped at `depth` segments. */
|
|
528
|
-
function zoneOf(file: string, depth: number): string {
|
|
529
|
-
const parts = dirname(file)
|
|
530
|
-
.split("/")
|
|
531
|
-
.filter((p) => p && p !== ".");
|
|
532
|
-
return parts.slice(0, depth).join("/") || ".";
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/** Group files by their directory zone (see `zoneOf`). */
|
|
536
|
-
function groupFilesByZone(
|
|
537
|
-
files: string[],
|
|
538
|
-
depth: number,
|
|
539
|
-
): Map<string, string[]> {
|
|
540
|
-
const zones = new Map<string, string[]>();
|
|
541
|
-
for (const f of files) {
|
|
542
|
-
const zone = zoneOf(f, depth);
|
|
543
|
-
const cur = zones.get(zone) ?? [];
|
|
544
|
-
cur.push(f);
|
|
545
|
-
zones.set(zone, cur);
|
|
546
|
-
}
|
|
547
|
-
// Sort zones by file count descending, then alphabetically
|
|
548
|
-
return new Map(
|
|
549
|
-
[...zones.entries()].sort((a, b) => {
|
|
550
|
-
const d = b[1].length - a[1].length;
|
|
551
|
-
return d !== 0 ? d : a[0].localeCompare(b[0]);
|
|
552
|
-
}),
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** Escape a regex source for use in a shell grep command. */
|
|
557
|
-
function shellEscapeRe(src: string): string {
|
|
558
|
-
return src.replace(/'/g, "'\\''");
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
export function formatDebt(report: DebtReport, showAll = false): string {
|
|
562
|
-
const lines: string[] = [];
|
|
563
|
-
lines.push(
|
|
564
|
-
`fapony debt — ${report.entries.length + report.declared.length + report.checkedCount} convention(s), ` +
|
|
565
|
-
`${report.scannedFiles} files scanned, ${report.ms}ms — derived fresh, not stored`,
|
|
566
|
-
);
|
|
567
|
-
for (const e of report.entries) {
|
|
568
|
-
const moved =
|
|
569
|
-
e.movedCount !== null && e.files.length > 0
|
|
570
|
-
? ` · moved ${e.movedCount} (${Math.round((e.movedCount / (e.files.length + e.movedCount)) * 100)}%)`
|
|
571
|
-
: e.movedCount !== null
|
|
572
|
-
? ` · moved ${e.movedCount}`
|
|
573
|
-
: "";
|
|
574
|
-
lines.push(`\n${e.conv.id} — ${e.conv.rule}`);
|
|
575
|
-
// Show the patterns actually used
|
|
576
|
-
const patterns: string[] = [];
|
|
577
|
-
if (e.conv.stale) patterns.push(`stale: ${e.conv.stale}`);
|
|
578
|
-
if (e.conv.ok) patterns.push(`ok: ${e.conv.ok}`);
|
|
579
|
-
if (e.conv.guard) patterns.push(`guard: ${e.conv.guard}`);
|
|
580
|
-
patterns.push(`where ${e.conv.where}`);
|
|
581
|
-
lines.push(` ${patterns.join(" · ")}`);
|
|
582
|
-
if (e.files.length === 0) {
|
|
583
|
-
lines.push(` debt 0${moved} — clean`);
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
lines.push(` debt ${e.files.length}${moved}`);
|
|
587
|
-
// Verify command derived from stale
|
|
588
|
-
if (e.conv.stale) {
|
|
589
|
-
lines.push(` verify: grep -rn '${shellEscapeRe(e.conv.stale)}' <zone>`);
|
|
590
|
-
}
|
|
591
|
-
// Zone grouping
|
|
592
|
-
const zones = groupFilesByZone(e.files, ZONE_DEPTH);
|
|
593
|
-
const zoneEntries = [...zones.entries()];
|
|
594
|
-
const cap = showAll
|
|
595
|
-
? zoneEntries.length
|
|
596
|
-
: Math.min(zoneEntries.length, ZONE_CAP);
|
|
597
|
-
let totalCapped = 0;
|
|
598
|
-
for (let i = 0; i < cap; i++) {
|
|
599
|
-
const [zone, zoneFiles] = zoneEntries[i];
|
|
600
|
-
const pad = " ".repeat(Math.max(0, 42 - zone.length));
|
|
601
|
-
lines.push(`\n ${zone}${pad}${zoneFiles.length} ไฟล์`);
|
|
602
|
-
lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
|
|
603
|
-
totalCapped += zoneFiles.length;
|
|
604
|
-
}
|
|
605
|
-
if (zoneEntries.length > cap) {
|
|
606
|
-
const remaining = e.files.length - totalCapped;
|
|
607
|
-
const remainingZones = zoneEntries.length - cap;
|
|
608
|
-
lines.push(
|
|
609
|
-
`\n … อีก ${remainingZones} โซน (${remaining} ไฟล์) — fapony debt --id ${e.conv.id} --all`,
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
for (const c of report.declared) {
|
|
614
|
-
lines.push(`\n${c.id} — ${c.rule} (where ${c.where})`);
|
|
615
|
-
lines.push(
|
|
616
|
-
` declared, no checker, stale not filled in — fill "stale" in conventions.json`,
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
if (report.checkedCount > 0) {
|
|
620
|
-
lines.push(
|
|
621
|
-
`\n${report.checkedCount} convention(s) have a checker — fapony stays silent, the checker reports`,
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
for (const d of report.dropped) {
|
|
625
|
-
lines.push(`⚠ ${d.id}: ${d.reason}`);
|
|
626
|
-
}
|
|
627
|
-
return lines.join("\n");
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// --- CLI ---
|
|
631
|
-
|
|
632
|
-
const USAGE = `usage: fapony debt [path] [options]
|
|
633
|
-
--files f1,f2 check specific files instead of scanning
|
|
634
|
-
--id <conv> show only this convention
|
|
635
|
-
--where <path> narrow scope to files under this path
|
|
636
|
-
--all show all zones (default: cap at ${ZONE_CAP})
|
|
637
|
-
--json output raw JSON
|
|
638
|
-
-h, --help this help`;
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* The dir `debt` measures: the nearest ancestor of `arg` (or cwd) that holds
|
|
642
|
-
* `.fapony/conventions.json`, bounded by the git root.
|
|
643
|
-
*
|
|
644
|
-
* Jumping straight to the git root was the bug: in a monorepo the root has no
|
|
645
|
-
* conventions.json and two apps have one each, so the mem resolver went
|
|
646
|
-
* ambiguous and `debt` said "nothing tracked yet" while
|
|
647
|
-
* apps/<x>/.fapony/conventions.json sat right there — and the positional path
|
|
648
|
-
* argument was silently ignored. Falling back to the git root keeps single
|
|
649
|
-
* repos run from a subdir scanning the whole repo.
|
|
650
|
-
*/
|
|
651
|
-
export function worktreeOf(arg: string | undefined): string {
|
|
652
|
-
const base = resolve(arg ?? ".");
|
|
653
|
-
let gitRoot: string | null = null;
|
|
654
|
-
try {
|
|
655
|
-
const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
656
|
-
cwd: base,
|
|
657
|
-
stdout: "pipe",
|
|
658
|
-
stderr: "pipe",
|
|
659
|
-
});
|
|
660
|
-
if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
|
|
661
|
-
} catch {
|
|
662
|
-
// not a repo — base is all we have
|
|
663
|
-
}
|
|
664
|
-
// `git rev-parse` returns a physical path (/var → /private/var on macOS),
|
|
665
|
-
// so the boundary check compares realpaths, same as the mem resolver.
|
|
666
|
-
const real = (d: string): string => {
|
|
667
|
-
try {
|
|
668
|
-
return realpathSync(d);
|
|
669
|
-
} catch {
|
|
670
|
-
return d;
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
const boundary = gitRoot ? real(gitRoot) : null;
|
|
674
|
-
let dir = base;
|
|
675
|
-
while (true) {
|
|
676
|
-
if (existsSync(join(dir, CONVENTIONS_FILE))) return dir;
|
|
677
|
-
if (boundary && real(dir) === boundary) break;
|
|
678
|
-
const parent = dirname(dir);
|
|
679
|
-
if (parent === dir) break;
|
|
680
|
-
dir = parent;
|
|
681
|
-
}
|
|
682
|
-
return gitRoot ?? base;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
export function cmdDebt(args: string[]): void {
|
|
686
|
-
let path: string | undefined;
|
|
687
|
-
let filesMode: string[] | null = null;
|
|
688
|
-
let json = false;
|
|
689
|
-
let filterId: string | undefined;
|
|
690
|
-
let wherePath: string | undefined;
|
|
691
|
-
let showAll = false;
|
|
692
|
-
for (let i = 0; i < args.length; i++) {
|
|
693
|
-
const a = args[i];
|
|
694
|
-
if (a === "--files") {
|
|
695
|
-
const v = args[i + 1];
|
|
696
|
-
if (!v || v.startsWith("--")) {
|
|
697
|
-
console.error(`fapony debt: --files needs a value\n${USAGE}`);
|
|
698
|
-
process.exit(1);
|
|
699
|
-
}
|
|
700
|
-
i++;
|
|
701
|
-
filesMode = v
|
|
702
|
-
.split(",")
|
|
703
|
-
.map((s) => s.trim())
|
|
704
|
-
.filter(Boolean);
|
|
705
|
-
if (filesMode.length === 0) {
|
|
706
|
-
console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
|
|
707
|
-
process.exit(1);
|
|
708
|
-
}
|
|
709
|
-
} else if (a === "--id") {
|
|
710
|
-
const v = args[i + 1];
|
|
711
|
-
if (!v || v.startsWith("--")) {
|
|
712
|
-
console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
|
|
713
|
-
process.exit(1);
|
|
714
|
-
}
|
|
715
|
-
i++;
|
|
716
|
-
filterId = v;
|
|
717
|
-
} else if (a === "--where") {
|
|
718
|
-
const v = args[i + 1];
|
|
719
|
-
if (!v || v.startsWith("--")) {
|
|
720
|
-
console.error(`fapony debt: --where needs a path\n${USAGE}`);
|
|
721
|
-
process.exit(1);
|
|
722
|
-
}
|
|
723
|
-
i++;
|
|
724
|
-
wherePath = v;
|
|
725
|
-
} else if (a === "--all") {
|
|
726
|
-
showAll = true;
|
|
727
|
-
} else if (a === "--json") {
|
|
728
|
-
json = true;
|
|
729
|
-
} else if (a === "-h" || a === "--help") {
|
|
730
|
-
console.log(USAGE);
|
|
731
|
-
return;
|
|
732
|
-
} else if (!a.startsWith("--")) {
|
|
733
|
-
path = a;
|
|
734
|
-
} else {
|
|
735
|
-
console.error(`fapony debt: unknown argument "${a}"\n${USAGE}`);
|
|
736
|
-
process.exit(1);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const worktree = worktreeOf(path);
|
|
741
|
-
const loaded = loadConventions(worktree);
|
|
742
|
-
|
|
743
|
-
if (filesMode) {
|
|
744
|
-
const out = filesMode.map((f) => {
|
|
745
|
-
const abs = isAbsolute(f) ? f : resolve(worktree, f);
|
|
746
|
-
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
747
|
-
return { file: f, debt: [], note: "not found" as const };
|
|
748
|
-
}
|
|
749
|
-
return { file: f, debt: debtForFile(worktree, abs, loaded) };
|
|
750
|
-
});
|
|
751
|
-
if (json) {
|
|
752
|
-
console.log(JSON.stringify({ worktree, files: out }, null, 2));
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
let any = false;
|
|
756
|
-
for (const r of out) {
|
|
757
|
-
for (const c of r.debt) {
|
|
758
|
-
any = true;
|
|
759
|
-
console.log(`${r.file} — ${c.id}: ${c.rule}`);
|
|
760
|
-
}
|
|
761
|
-
if ("note" in r) console.log(`${r.file} — ${r.note}`);
|
|
762
|
-
}
|
|
763
|
-
if (!any && out.every((r) => r.debt.length === 0)) {
|
|
764
|
-
console.log("no convention debt in the given file(s)");
|
|
765
|
-
}
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
if (loaded.path === null) {
|
|
770
|
-
// SPEC §6: no conventions.json = completely silent, no error, no prompt to create one
|
|
771
|
-
console.log(
|
|
772
|
-
`fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
|
|
773
|
-
);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
const report = debtScan(worktree, loaded);
|
|
777
|
-
|
|
778
|
-
// --id filter: keep only the named convention
|
|
779
|
-
if (filterId) {
|
|
780
|
-
report.entries = report.entries.filter((e) => e.conv.id === filterId);
|
|
781
|
-
report.declared = report.declared.filter((c) => c.id === filterId);
|
|
782
|
-
report.checkedCount = 0; // not relevant when filtering
|
|
783
|
-
report.dropped = report.dropped.filter((d) => d.id === filterId);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// --where filter: narrow file lists to paths under the given prefix
|
|
787
|
-
if (wherePath) {
|
|
788
|
-
const prefix = wherePath.replace(/\/+$/, "");
|
|
789
|
-
for (const e of report.entries) {
|
|
790
|
-
e.files = e.files.filter(
|
|
791
|
-
(f) => f === prefix || f.startsWith(`${prefix}/`),
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
if (json) {
|
|
797
|
-
console.log(
|
|
798
|
-
JSON.stringify(
|
|
799
|
-
{ ...report, promotions: findPromotions(worktree, report) },
|
|
800
|
-
null,
|
|
801
|
-
2,
|
|
802
|
-
),
|
|
803
|
-
);
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
console.log(formatDebt(report, showAll));
|
|
807
|
-
for (const w of loaded.warnings) console.log(`⚠ ${w}`);
|
|
808
|
-
for (const l of formatPromotions(findPromotions(worktree, report))) {
|
|
809
|
-
console.log(l);
|
|
810
|
-
}
|
|
811
|
-
}
|