fapony 0.2.0 → 0.3.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 +50 -15
- package/fapony.ts +12 -3
- package/package.json +1 -1
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/review-pony/SKILL.md +11 -3
- package/src/analyze.ts +162 -4
- package/src/conventions-seed.ts +3 -2
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +8 -6
- package/src/db/load.ts +2 -2
- package/src/db/types.ts +1 -3
- 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/digest/collect.ts +3 -2
- package/src/hook.ts +412 -17
- package/src/init-mem.ts +5 -5
- package/src/init.ts +6 -5
- package/src/install/claude.ts +51 -3
- package/src/install/codex.ts +144 -13
- package/src/install/opencode.ts +209 -1
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +4 -3
- package/src/mcp/evidence.ts +14 -2
- package/src/mcp/tools/index.ts +13 -28
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +12 -63
- package/src/mem/commands/read.ts +18 -1
- package/src/mem/store.ts +6 -4
- package/src/memory.ts +23 -13
- package/src/plan-seed.ts +7 -6
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +4 -3
- package/src/stats/data.ts +6 -18
- package/src/debt.ts +0 -806
- package/src/mcp/tools/usage.ts +0 -211
package/src/debt/scan.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/debt/scan.ts — the scan (fresh every call — derive, never store).
|
|
2
|
+
//
|
|
3
|
+
// Debt is computed live every time, never written anywhere (same as analyze:
|
|
4
|
+
// a cache is pure debt — a frozen list goes stale silently like MASTER.md).
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
8
|
+
import { collectSourceFiles } from "../analyze.js";
|
|
9
|
+
import {
|
|
10
|
+
type Convention,
|
|
11
|
+
DEBT_FILE_CAP,
|
|
12
|
+
type DebtEntry,
|
|
13
|
+
type DebtReport,
|
|
14
|
+
type LoadedConventions,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
interface Compiled {
|
|
18
|
+
conv: Convention;
|
|
19
|
+
staleRe: RegExp | null;
|
|
20
|
+
okRe: RegExp | null;
|
|
21
|
+
guardRe: RegExp | null;
|
|
22
|
+
whereDir: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compile(conv: Convention): { c: Compiled; error?: string } {
|
|
26
|
+
const re = (
|
|
27
|
+
src: string | null | undefined,
|
|
28
|
+
what: string,
|
|
29
|
+
): { re: RegExp | null; error?: string } => {
|
|
30
|
+
if (!src) return { re: null };
|
|
31
|
+
try {
|
|
32
|
+
return { re: new RegExp(src) };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return {
|
|
35
|
+
re: null,
|
|
36
|
+
error: `${what} regex broken (${e instanceof Error ? e.message.split("\n")[0] : "?"})`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const stale = re(conv.stale, `${conv.id}: stale`);
|
|
41
|
+
if (stale.error)
|
|
42
|
+
return {
|
|
43
|
+
c: {
|
|
44
|
+
conv,
|
|
45
|
+
staleRe: null,
|
|
46
|
+
okRe: null,
|
|
47
|
+
guardRe: null,
|
|
48
|
+
whereDir: conv.where,
|
|
49
|
+
},
|
|
50
|
+
error: stale.error,
|
|
51
|
+
};
|
|
52
|
+
const ok = re(conv.ok, `${conv.id}: ok`);
|
|
53
|
+
if (ok.error)
|
|
54
|
+
return {
|
|
55
|
+
c: {
|
|
56
|
+
conv,
|
|
57
|
+
staleRe: null,
|
|
58
|
+
okRe: null,
|
|
59
|
+
guardRe: null,
|
|
60
|
+
whereDir: conv.where,
|
|
61
|
+
},
|
|
62
|
+
error: ok.error,
|
|
63
|
+
};
|
|
64
|
+
const guard = re(conv.guard, `${conv.id}: guard`);
|
|
65
|
+
if (guard.error)
|
|
66
|
+
return {
|
|
67
|
+
c: {
|
|
68
|
+
conv,
|
|
69
|
+
staleRe: null,
|
|
70
|
+
okRe: null,
|
|
71
|
+
guardRe: null,
|
|
72
|
+
whereDir: conv.where,
|
|
73
|
+
},
|
|
74
|
+
error: guard.error,
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
c: {
|
|
78
|
+
conv,
|
|
79
|
+
staleRe: stale.re,
|
|
80
|
+
okRe: ok.re,
|
|
81
|
+
guardRe: guard.re,
|
|
82
|
+
// where="src" must scope src/ and src/x/y.ts but not src-other/;
|
|
83
|
+
// where="." scopes everything.
|
|
84
|
+
whereDir: conv.where === "." ? "" : conv.where.replace(/\/+$/, ""),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inScope(whereDir: string, file: string): boolean {
|
|
90
|
+
return whereDir === "" || file.startsWith(`${whereDir}/`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function debtScan(
|
|
94
|
+
worktree: string,
|
|
95
|
+
loaded: LoadedConventions,
|
|
96
|
+
): DebtReport {
|
|
97
|
+
const t0 = performance.now();
|
|
98
|
+
const entries: DebtEntry[] = [];
|
|
99
|
+
const declared: Convention[] = [];
|
|
100
|
+
const dropped: { id: string; reason: string }[] = [];
|
|
101
|
+
let checkedCount = 0;
|
|
102
|
+
|
|
103
|
+
const compiled: Compiled[] = [];
|
|
104
|
+
for (const conv of loaded.convs) {
|
|
105
|
+
if (conv.checker) {
|
|
106
|
+
// Iron rule — fapony stays silent, leave it to the checker (SPEC §2)
|
|
107
|
+
checkedCount++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!conv.stale) {
|
|
111
|
+
// The one slot a human fills (SPEC §2.2) — show it as pending, don't guess
|
|
112
|
+
declared.push(conv);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const { c, error } = compile(conv);
|
|
116
|
+
if (error || !c.staleRe) {
|
|
117
|
+
dropped.push({ id: conv.id, reason: error ?? "uncompilable" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!existsSync(join(worktree, c.whereDir || "."))) {
|
|
121
|
+
dropped.push({
|
|
122
|
+
id: conv.id,
|
|
123
|
+
reason: `where: ${conv.where} does not exist`,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
compiled.push(c);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const files = collectSourceFiles(worktree);
|
|
131
|
+
const debt: Map<string, string[]> = new Map(
|
|
132
|
+
compiled.map((c) => [c.conv.id, []]),
|
|
133
|
+
);
|
|
134
|
+
const moved: Map<string, number> = new Map(
|
|
135
|
+
compiled.map((c) => [c.conv.id, 0]),
|
|
136
|
+
);
|
|
137
|
+
const tooBroad: Map<string, number> = new Map();
|
|
138
|
+
|
|
139
|
+
for (const rel of files) {
|
|
140
|
+
let content: string;
|
|
141
|
+
try {
|
|
142
|
+
content = readFileSync(join(worktree, rel), "utf-8");
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const c of compiled) {
|
|
147
|
+
if (!c.staleRe) continue; // filtered at compile; narrows the type
|
|
148
|
+
if (!inScope(c.whereDir, rel)) continue;
|
|
149
|
+
if (c.guardRe && !c.guardRe.test(content)) continue;
|
|
150
|
+
if (c.staleRe.test(content)) {
|
|
151
|
+
const cur = debt.get(c.conv.id) ?? [];
|
|
152
|
+
cur.push(rel);
|
|
153
|
+
debt.set(c.conv.id, cur);
|
|
154
|
+
// Stop counting a runaway regex early — the entry will be dropped.
|
|
155
|
+
if (cur.length > DEBT_FILE_CAP) tooBroad.set(c.conv.id, cur.length);
|
|
156
|
+
}
|
|
157
|
+
if (c.okRe?.test(content)) {
|
|
158
|
+
moved.set(c.conv.id, (moved.get(c.conv.id) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const c of compiled) {
|
|
164
|
+
const n = tooBroad.get(c.conv.id);
|
|
165
|
+
if (n !== undefined) {
|
|
166
|
+
dropped.push({
|
|
167
|
+
id: c.conv.id,
|
|
168
|
+
reason: `stale regex matches ${n}+ files — too broad, entry dropped (narrow stale/where/guard)`,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
entries.push({
|
|
173
|
+
conv: c.conv,
|
|
174
|
+
files: (debt.get(c.conv.id) ?? []).sort(),
|
|
175
|
+
movedCount: c.okRe ? (moved.get(c.conv.id) ?? 0) : null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
worktree,
|
|
181
|
+
scannedFiles: files.length,
|
|
182
|
+
ms: Math.round(performance.now() - t0),
|
|
183
|
+
entries,
|
|
184
|
+
declared,
|
|
185
|
+
dropped,
|
|
186
|
+
checkedCount,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Per-file lookup (hook-read-hint + --files): which conventions flag this file. */
|
|
191
|
+
export function debtForFile(
|
|
192
|
+
worktree: string,
|
|
193
|
+
absFile: string,
|
|
194
|
+
loaded: LoadedConventions,
|
|
195
|
+
): Convention[] {
|
|
196
|
+
const rel = relative(worktree, absFile).split("\\").join("/");
|
|
197
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return [];
|
|
198
|
+
let content: string;
|
|
199
|
+
try {
|
|
200
|
+
content = readFileSync(absFile, "utf-8");
|
|
201
|
+
} catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const out: Convention[] = [];
|
|
205
|
+
for (const conv of loaded.convs) {
|
|
206
|
+
if (conv.checker || !conv.stale) continue;
|
|
207
|
+
const { c, error } = compile(conv);
|
|
208
|
+
if (error || !c.staleRe) continue;
|
|
209
|
+
if (!inScope(c.whereDir, rel)) continue;
|
|
210
|
+
if (c.guardRe && !c.guardRe.test(content)) continue;
|
|
211
|
+
if (c.staleRe.test(content)) out.push(conv);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/debt/types.ts — shared types + caps for `fapony debt`.
|
|
2
|
+
//
|
|
3
|
+
// One convention = pattern to use (ok) + pattern meaning not-yet-migrated
|
|
4
|
+
// (stale) + scope (where) + file condition (guard). Iron rule: checker not
|
|
5
|
+
// null = fapony never reports that debt item — reporting twice with eslint
|
|
6
|
+
// is an abstraction with one implementation (rule 1).
|
|
7
|
+
|
|
8
|
+
export interface Convention {
|
|
9
|
+
id: string;
|
|
10
|
+
rule: string;
|
|
11
|
+
/** Repo-relative dir scope ("." = whole repo). */
|
|
12
|
+
where: string;
|
|
13
|
+
/** Regex source: a match means the file still has the debt. null = not derivable (checker rows) or not filled in yet. */
|
|
14
|
+
stale: string | null;
|
|
15
|
+
/** Regex source: files that already moved (informational count). */
|
|
16
|
+
ok?: string;
|
|
17
|
+
/** Regex source a file must ALSO match to be in scope (e.g. "extends Base"). */
|
|
18
|
+
guard?: string;
|
|
19
|
+
/** Non-null = a checker (eslint rule / script) exists → fapony never reports this debt. */
|
|
20
|
+
checker?: string | null;
|
|
21
|
+
/** Human answered "no checker" on the promotion question — never ask again. */
|
|
22
|
+
decided?: "no-checker" | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LoadedConventions {
|
|
26
|
+
path: string | null;
|
|
27
|
+
convs: Convention[];
|
|
28
|
+
/** Rows kept for display but not scannable, plus invalid rows — said out loud, never silent. */
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DebtEntry {
|
|
33
|
+
conv: Convention;
|
|
34
|
+
/** Files with the debt (stale match), sorted. */
|
|
35
|
+
files: string[];
|
|
36
|
+
/** Files that already moved (ok match) — null when ok is not set. */
|
|
37
|
+
movedCount: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DebtReport {
|
|
41
|
+
worktree: string;
|
|
42
|
+
scannedFiles: number;
|
|
43
|
+
ms: number;
|
|
44
|
+
/** Scannable conventions with their debt list (checker rows never land here). */
|
|
45
|
+
entries: DebtEntry[];
|
|
46
|
+
/** Declared but not fillable by fapony: checker null + no stale — the human/agent fills `stale`. */
|
|
47
|
+
declared: Convention[];
|
|
48
|
+
/** Skipped-with-reason: checker rows are silent by design (not dropped), these are real drops. */
|
|
49
|
+
dropped: { id: string; reason: string }[];
|
|
50
|
+
/** Silent-by-design count: checker non-null — reported as a number, not a list. */
|
|
51
|
+
checkedCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Promotion {
|
|
55
|
+
convId: string;
|
|
56
|
+
rule: string;
|
|
57
|
+
occurrences: number;
|
|
58
|
+
dates: string[];
|
|
59
|
+
debtCount: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A stale regex matching more than this many files is not a convention — it is
|
|
63
|
+
// a broken/wide regex (stale="e" would flag the repo). SPEC §6: drop the entry
|
|
64
|
+
// and say so, never report 600 files.
|
|
65
|
+
export const DEBT_FILE_CAP = 250;
|
|
66
|
+
|
|
67
|
+
export const PROMOTION_THRESHOLD = 3;
|
|
68
|
+
export const PROMOTION_MAX = 3;
|
|
69
|
+
/** Identifiers shorter than this are too generic to match prose on ("throw", "Error"). */
|
|
70
|
+
export const WORD_MIN = 6;
|
|
71
|
+
|
|
72
|
+
// Zone grouping: a zone is a file's *directory*, capped at this many leading
|
|
73
|
+
// segments — never a fixed-depth prefix of the path (which would cut into the
|
|
74
|
+
// filename) and never the filename itself. SPEC §4 shows zones at depth 5
|
|
75
|
+
// (`apps/mdl/src/server/services`) and depth 3 (`packages/cache/src`) in the
|
|
76
|
+
// same report, so the cap must follow the directory, not a constant.
|
|
77
|
+
export const ZONE_DEPTH = 5;
|
|
78
|
+
// Default cap on zones shown per convention — more than this is a wall, not an answer.
|
|
79
|
+
export const ZONE_CAP = 6;
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/detect.ts — read a repo's package.json to detect its test runner.
|
|
2
|
+
//
|
|
3
|
+
// The Stop hook fires in every repo but used to hardcode fapony-specific
|
|
4
|
+
// commands in its block message (bug class: setup.ts, hook.ts). Now the message
|
|
5
|
+
// derives the command from the worktree at runtime — one source of truth, reused
|
|
6
|
+
// by the hook (decideStop) and by install (what it reports).
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export type TestRunner = "bun" | "npm" | "pnpm" | "yarn";
|
|
12
|
+
|
|
13
|
+
export interface TestRunnerInfo {
|
|
14
|
+
runner: TestRunner;
|
|
15
|
+
testCmd: string;
|
|
16
|
+
typecheckCmd: string | null;
|
|
17
|
+
lockfile: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RUNNER_LOCKFILE: Record<TestRunner, string> = {
|
|
21
|
+
bun: "bun.lock",
|
|
22
|
+
npm: "package-lock.json",
|
|
23
|
+
pnpm: "pnpm-lock.yaml",
|
|
24
|
+
yarn: "yarn.lock",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const RUNNER_TEST_CMD: Record<TestRunner, string> = {
|
|
28
|
+
bun: "bun test",
|
|
29
|
+
npm: "npm test",
|
|
30
|
+
pnpm: "pnpm test",
|
|
31
|
+
yarn: "yarn test",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const RUNNER_TYPECHECK_CMD: Record<TestRunner, string> = {
|
|
35
|
+
bun: "bun run typecheck",
|
|
36
|
+
npm: "npm run typecheck",
|
|
37
|
+
pnpm: "pnpm run typecheck",
|
|
38
|
+
yarn: "yarn run typecheck",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect a repo's test runner from its package.json + lockfile.
|
|
43
|
+
* Returns null when the worktree has no package.json or no recognizable runner.
|
|
44
|
+
*
|
|
45
|
+
* Priority: `packageManager` field (explicit) → lockfile presence (fallback).
|
|
46
|
+
* A repo with neither is foreign to us — the caller falls back to a generic
|
|
47
|
+
* "this repo's test suite" message rather than guessing.
|
|
48
|
+
*/
|
|
49
|
+
export function detectTestRunner(worktree: string): TestRunnerInfo | null {
|
|
50
|
+
const pkgPath = join(worktree, "package.json");
|
|
51
|
+
if (!existsSync(pkgPath)) return null;
|
|
52
|
+
|
|
53
|
+
let pkg: { packageManager?: string; scripts?: Record<string, string> };
|
|
54
|
+
try {
|
|
55
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const runner = detectRunner(worktree, pkg);
|
|
61
|
+
if (!runner) return null;
|
|
62
|
+
|
|
63
|
+
const scripts = pkg.scripts ?? {};
|
|
64
|
+
const typecheckCmd =
|
|
65
|
+
"typecheck" in scripts ? RUNNER_TYPECHECK_CMD[runner] : null;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
runner,
|
|
69
|
+
testCmd: RUNNER_TEST_CMD[runner],
|
|
70
|
+
typecheckCmd,
|
|
71
|
+
lockfile: RUNNER_LOCKFILE[runner],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectRunner(
|
|
76
|
+
worktree: string,
|
|
77
|
+
pkg: { packageManager?: string },
|
|
78
|
+
): TestRunner | null {
|
|
79
|
+
const pm = pkg.packageManager;
|
|
80
|
+
if (typeof pm === "string") {
|
|
81
|
+
if (pm.startsWith("bun@")) return "bun";
|
|
82
|
+
if (pm.startsWith("pnpm@")) return "pnpm";
|
|
83
|
+
if (pm.startsWith("yarn@")) return "yarn";
|
|
84
|
+
if (pm.startsWith("npm@")) return "npm";
|
|
85
|
+
}
|
|
86
|
+
if (existsSync(join(worktree, "bun.lock"))) return "bun";
|
|
87
|
+
if (existsSync(join(worktree, "bun.lockb"))) return "bun";
|
|
88
|
+
if (existsSync(join(worktree, "pnpm-lock.yaml"))) return "pnpm";
|
|
89
|
+
if (existsSync(join(worktree, "yarn.lock"))) return "yarn";
|
|
90
|
+
if (existsSync(join(worktree, "package-lock.json"))) return "npm";
|
|
91
|
+
return null;
|
|
92
|
+
}
|
package/src/digest/collect.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import {
|
|
9
|
+
CONFIG_FILENAME,
|
|
9
10
|
doneDir,
|
|
10
11
|
type Event,
|
|
11
12
|
loadConfig,
|
|
@@ -165,8 +166,8 @@ function readPlans(worktree: string): {
|
|
|
165
166
|
ok: boolean;
|
|
166
167
|
detail: string;
|
|
167
168
|
} {
|
|
168
|
-
const config = loadConfig(join(worktree,
|
|
169
|
-
const pDir = join(worktree, planDir(
|
|
169
|
+
const config = loadConfig(join(worktree, CONFIG_FILENAME));
|
|
170
|
+
const pDir = join(worktree, planDir());
|
|
170
171
|
const dDir = join(worktree, doneDir(config));
|
|
171
172
|
|
|
172
173
|
if (!existsSync(pDir)) {
|