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/src/db/types.ts CHANGED
@@ -55,9 +55,7 @@ export interface Config {
55
55
  // state dir override (default: $XDG_CONFIG_HOME/fapony or ~/.config/fapony).
56
56
  // $FAPONY_STATE_DIR env wins over this when set.
57
57
  stateDir?: string;
58
- // plan/spec/memory layout inside each worktree (relative to worktree root).
59
- planDir?: string;
60
- specDir?: string;
58
+ // plan/spec live in .fapony/{plan,spec} — not configurable (gitignored = private).
61
59
  doneDir?: string;
62
60
  memDir?: string;
63
61
  evidenceFile?: string;
@@ -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";
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ // src/debt/promotion.ts — promotion signal: "this recurred N times, time for a checker?"
2
+ //
3
+ // "I'll write eslint when I think of it" — the "think of it" moment is what goes
4
+ // missing (SPEC §3) · fapony sees history across sessions (mem + verdicts), so it
5
+ // can count how often the same thing was fixed, then put the question to a human —
6
+ // it does not decide, does not write the eslint rule itself (SPEC §6 fail list)
7
+ //
8
+ // Matching "the same thing" — only as precise as the data allows (SPEC §7: old rows
9
+ // lack files[], still undecided): a row with files[] must intersect the debt list ·
10
+ // the text must mention a convention symbol (ok such as fmtMoney, or an identifier
11
+ // ≥ 6 chars from stale such as toLocaleString/useMutation — "throw"/"Error" are too
12
+ // short and don't count, to avoid over-matching)
13
+
14
+ import { openDb } from "../db/index.js";
15
+ import { readMemLog } from "../memory.js";
16
+ import {
17
+ type Convention,
18
+ type DebtReport,
19
+ PROMOTION_MAX,
20
+ PROMOTION_THRESHOLD,
21
+ type Promotion,
22
+ WORD_MIN,
23
+ } from "./types.js";
24
+
25
+ export type { Promotion };
26
+
27
+ function conventionWords(conv: Convention): string[] {
28
+ const words = new Set<string>();
29
+ for (const src of [conv.ok, conv.stale]) {
30
+ if (!src) continue;
31
+ for (const m of src.matchAll(/[A-Za-z_$][\w$]*/g)) {
32
+ if (m[0].length >= WORD_MIN) words.add(m[0]);
33
+ }
34
+ }
35
+ return [...words];
36
+ }
37
+
38
+ function rowMatchesConv(
39
+ hay: string,
40
+ files: string[] | undefined,
41
+ debtFiles: Set<string>,
42
+ words: string[],
43
+ ): boolean {
44
+ if (files && files.length > 0) {
45
+ if (files.some((f) => debtFiles.has(f))) return true;
46
+ }
47
+ const lower = hay.toLowerCase();
48
+ return words.some((w) => lower.includes(w.toLowerCase()));
49
+ }
50
+
51
+ interface EvidenceRow {
52
+ ts: string;
53
+ files?: string[];
54
+ hay: string;
55
+ }
56
+
57
+ function gatherEvidence(worktree: string): EvidenceRow[] {
58
+ const out: EvidenceRow[] = [];
59
+ try {
60
+ for (const r of readMemLog(worktree).rows) {
61
+ if (r.kind !== "bug" && r.kind !== "decision") continue;
62
+ out.push({ ts: r.ts, files: r.files, hay: `${r.text}\n${r.spec ?? ""}` });
63
+ }
64
+ } catch {
65
+ // mem missing — verdicts alone still count
66
+ }
67
+ try {
68
+ const db = openDb();
69
+ const events = db
70
+ .prepare(
71
+ `SELECT e.ts AS ts, e.data AS data FROM events e
72
+ JOIN runs r ON r.id = e.run_id
73
+ WHERE r.worktree = ? AND e.kind = 'gate' ORDER BY e.id`,
74
+ )
75
+ .all(worktree) as { ts: string; data: string | null }[];
76
+ for (const e of events) {
77
+ if (!e.data) continue;
78
+ try {
79
+ const d = JSON.parse(e.data) as {
80
+ verdict?: string;
81
+ reason_code?: string;
82
+ note?: string;
83
+ files?: string[];
84
+ };
85
+ const countsAsFix =
86
+ d.verdict === "fail" ||
87
+ d.reason_code === "scope_mismatch" ||
88
+ d.reason_code === "spec_gap";
89
+ if (!countsAsFix) continue;
90
+ out.push({ ts: e.ts, files: d.files, hay: d.note ?? "" });
91
+ } catch {}
92
+ }
93
+ } catch {
94
+ // no ledger yet — mem alone still counts
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Repeated-fix questions for conventions that have no checker and no "no-checker" decision. */
100
+ export function findPromotions(
101
+ worktree: string,
102
+ report: DebtReport,
103
+ ): Promotion[] {
104
+ const evidence = gatherEvidence(worktree);
105
+ if (evidence.length === 0) return [];
106
+ const out: Promotion[] = [];
107
+ for (const entry of report.entries) {
108
+ const { conv } = entry;
109
+ if (conv.checker || conv.decided === "no-checker") continue;
110
+ if (entry.files.length === 0) continue;
111
+ const debtFiles = new Set(entry.files);
112
+ const words = conventionWords(conv);
113
+ const hits = evidence.filter((r) =>
114
+ rowMatchesConv(r.hay, r.files, debtFiles, words),
115
+ );
116
+ if (hits.length < PROMOTION_THRESHOLD) continue;
117
+ const dates = [...new Set(hits.map((h) => h.ts.slice(0, 10)))].sort();
118
+ out.push({
119
+ convId: conv.id,
120
+ rule: conv.rule,
121
+ occurrences: hits.length,
122
+ dates,
123
+ debtCount: entry.files.length,
124
+ });
125
+ }
126
+ // Newest first, capped — three questions are already a conversation.
127
+ out.sort((a, b) => b.occurrences - a.occurrences);
128
+ return out.slice(0, PROMOTION_MAX);
129
+ }
130
+
131
+ export function formatPromotions(promotions: Promotion[]): string[] {
132
+ if (promotions.length === 0) return [];
133
+ const lines: string[] = [
134
+ "",
135
+ "promotion — repeated fixes on conventions with no checker:",
136
+ ];
137
+ for (const p of promotions) {
138
+ lines.push(
139
+ `\n"${p.convId}" (${p.debtCount} file(s) still wrong) came up ${p.occurrences}× ` +
140
+ `(${p.dates.slice(0, 3).join(", ")}${p.dates.length > 3 ? ", …" : ""})`,
141
+ );
142
+ lines.push(` ${p.rule}`);
143
+ lines.push(
144
+ " [1] make a checker — an agent drafts the eslint rule in this repo, you review",
145
+ );
146
+ lines.push(
147
+ ' [2] one-off, no checker — record "decided": "no-checker" on this entry, never asked again',
148
+ );
149
+ lines.push(" [3] later — ask again when this comes up a few more times");
150
+ }
151
+ return lines;
152
+ }