fapony 0.3.3 → 0.3.5

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.
Files changed (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -0,0 +1,217 @@
1
+ // src/price/resolve.ts — normalize model id + compute list-price equivalent
2
+ //
3
+ // Hard rule: an unmappable model must be unpriced, never silently counted as 0 (the main failure mode
4
+ // of this feature) · free only applies to things that truly cost 0 (local / :free rows in the table)
5
+
6
+ import type {
7
+ ModelBreakdown,
8
+ ModelRates,
9
+ PassiveUsageResult,
10
+ PriceTable,
11
+ } from "./types.js";
12
+
13
+ export type PriceStatus = "priced" | "free" | "unpriced";
14
+
15
+ export interface PriceResolution {
16
+ status: PriceStatus;
17
+ rates: ModelRates | null;
18
+ }
19
+
20
+ /** client prefix prepended to every model id — strip it, then look up again */
21
+ const CLIENT_PREFIXES = ["openrouter/", "opencode-go/", "opencode/"];
22
+
23
+ /** OpenRouter's trailing tier — :free is a genuinely free endpoint, :batch is a discount */
24
+ function stripTier(id: string): string {
25
+ return id.endsWith(":free") || id.endsWith(":batch")
26
+ ? id.slice(0, id.lastIndexOf(":"))
27
+ : id;
28
+ }
29
+
30
+ /**
31
+ * Candidate ids ordered by specificity: exact first, broad later
32
+ * (an exact match always wins — the bare slug is the broadest match and goes last)
33
+ */
34
+ export function candidateIds(provider: string, model: string): string[] {
35
+ const full = provider ? `${provider}/${model}` : model;
36
+ const out: string[] = [full];
37
+ let rest = full;
38
+ for (const p of CLIENT_PREFIXES) {
39
+ if (rest.startsWith(p)) {
40
+ rest = rest.slice(p.length);
41
+ out.push(rest);
42
+ break;
43
+ }
44
+ }
45
+ // opencode appends -free to the slug of a free model (deepseek-v4-flash-free)
46
+ if (rest.endsWith("-free")) out.push(rest.slice(0, -"-free".length));
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Find rates for one model — null only for local, which skips the table
52
+ * (local never had a price to begin with, it is not "not found")
53
+ */
54
+ function isLocalProvider(provider: string): boolean {
55
+ return provider === "lmstudio_local";
56
+ }
57
+
58
+ export function resolvePrice(
59
+ provider: string,
60
+ model: string,
61
+ table: PriceTable,
62
+ ): PriceResolution {
63
+ if (!model || model === "(no model id)" || model === "(unknown)")
64
+ return { status: "unpriced", rates: null };
65
+ if (isLocalProvider(provider)) return { status: "free", rates: null };
66
+ // ends with :free or -free = used a free endpoint / the client's free model
67
+ // the real price is 0 (not list price), whether or not OpenRouter maps it
68
+ if (model.endsWith(":free") || model.endsWith("-free"))
69
+ return { status: "free", rates: null };
70
+ for (const id of candidateIds(provider, model)) {
71
+ const rates = table.models[id] ?? table.models[stripTier(id)];
72
+ if (rates) {
73
+ // an all-zero rate row (:free / free model) = genuinely free, not unpriced
74
+ if (
75
+ rates.input === 0 &&
76
+ rates.output === 0 &&
77
+ rates.cacheRead === 0 &&
78
+ (rates.cacheWrite ?? 0) === 0
79
+ )
80
+ return { status: "free", rates };
81
+ return { status: "priced", rates };
82
+ }
83
+ }
84
+ // zcode stores only the slug with no vendor (GLM-5.3-Flash) — compare the suffix / exact
85
+ // case-insensitive match (exact, not fuzzy: the whole string must be equal in length)
86
+ // the table side strips the tier (:free/:batch) before comparing — so a bare slug hits the :free
87
+ // row that is truly 0 (e.g. ling-3.0-flash-fin) and becomes free, not unpriced
88
+ const slug = stripTier(
89
+ candidateIds(provider, model).at(-1) ?? "",
90
+ ).toLowerCase();
91
+ if (slug) {
92
+ const ids = Object.keys(table.models).sort();
93
+ for (const id of ids) {
94
+ const rawSuffix = id.includes("/")
95
+ ? id.slice(id.lastIndexOf("/") + 1)
96
+ : id;
97
+ if (stripTier(rawSuffix).toLowerCase() === slug) {
98
+ const rates = table.models[id];
99
+ if (
100
+ rates.input === 0 &&
101
+ rates.output === 0 &&
102
+ rates.cacheRead === 0 &&
103
+ (rates.cacheWrite ?? 0) === 0
104
+ )
105
+ return { status: "free", rates };
106
+ return { status: "priced", rates };
107
+ }
108
+ }
109
+ }
110
+ return { status: "unpriced", rates: null };
111
+ }
112
+
113
+ export interface TokenCounts {
114
+ input: number;
115
+ cacheRead: number;
116
+ cacheWrite: number;
117
+ output: number;
118
+ }
119
+
120
+ /**
121
+ * Pure costing: separate rates for input / cache-read / cache-write — never one rate for all
122
+ *
123
+ * reasoning is not multiplied separately: Anthropic-family thinking is already included in output
124
+ * (the reader keeps it separately just for visibility); multiplying separately = double counting · cache_write absent from the table
125
+ * → fall back to the input rate (writing cache costs more than or equal to a fresh read, never less)
126
+ */
127
+ export function calcCost(t: TokenCounts, rates: ModelRates): number {
128
+ return (
129
+ t.input * rates.input +
130
+ t.cacheRead * rates.cacheRead +
131
+ t.cacheWrite * (rates.cacheWrite ?? rates.input) +
132
+ t.output * rates.output
133
+ );
134
+ }
135
+
136
+ export interface ImputedModel {
137
+ provider: string;
138
+ model: string;
139
+ session_count: number;
140
+ tokens_input: number;
141
+ tokens_output: number;
142
+ tokens_cache_read: number;
143
+ tokens_cache_write: number;
144
+ status: PriceStatus;
145
+ /** dollars at list-price — 0 when free/unpriced (check status, do not read the number alone) */
146
+ imputed_cost: number;
147
+ }
148
+
149
+ export interface ImputeSummary {
150
+ total_imputed: number;
151
+ priced_sessions: number;
152
+ free_sessions: number;
153
+ unpriced_sessions: number;
154
+ unpriced_tokens: number;
155
+ by_model: ImputedModel[];
156
+ }
157
+
158
+ /**
159
+ * Price an entire PassiveUsageResult — works on live results (stats/usage) or cache rows
160
+ * (usage-web) alike, because it only takes tokens per model
161
+ */
162
+ export function imputeResult(
163
+ result: PassiveUsageResult,
164
+ table: PriceTable,
165
+ ): ImputeSummary {
166
+ const by_model: ImputedModel[] = [];
167
+ let total_imputed = 0;
168
+ let priced_sessions = 0;
169
+ let free_sessions = 0;
170
+ let unpriced_sessions = 0;
171
+ let unpriced_tokens = 0;
172
+ for (const m of result.by_model as ModelBreakdown[]) {
173
+ const r = resolvePrice(m.provider, m.model, table);
174
+ let cost = 0;
175
+ if (r.status === "priced" && r.rates) {
176
+ cost = calcCost(
177
+ {
178
+ input: m.tokens_input,
179
+ cacheRead: m.tokens_cache_read,
180
+ cacheWrite: m.tokens_cache_write,
181
+ output: m.tokens_output,
182
+ },
183
+ r.rates,
184
+ );
185
+ total_imputed += cost;
186
+ priced_sessions += m.session_count;
187
+ } else if (r.status === "free") {
188
+ free_sessions += m.session_count;
189
+ } else {
190
+ unpriced_sessions += m.session_count;
191
+ unpriced_tokens +=
192
+ m.tokens_input +
193
+ m.tokens_output +
194
+ m.tokens_cache_read +
195
+ m.tokens_cache_write;
196
+ }
197
+ by_model.push({
198
+ provider: m.provider,
199
+ model: m.model,
200
+ session_count: m.session_count,
201
+ tokens_input: m.tokens_input,
202
+ tokens_output: m.tokens_output,
203
+ tokens_cache_read: m.tokens_cache_read,
204
+ tokens_cache_write: m.tokens_cache_write,
205
+ status: r.status,
206
+ imputed_cost: cost,
207
+ });
208
+ }
209
+ return {
210
+ total_imputed,
211
+ priced_sessions,
212
+ free_sessions,
213
+ unpriced_sessions,
214
+ unpriced_tokens,
215
+ by_model,
216
+ };
217
+ }
@@ -0,0 +1,18 @@
1
+ // src/safety.ts — deny-list for dangerous git commands.
2
+ // Rule: assertSafe() must be called on every command before spawn, including
3
+ // ones built from config templates (memory claim/close/add, evidence
4
+ // collector commands, install's `claude mcp add`).
5
+ // Patterns come from config.safety.deny (regex sources); built-in default
6
+ // covers the 4 known-destructive git invocations.
7
+
8
+ import { DEFAULT_SAFETY_DENY } from "./defaults.js";
9
+
10
+ export function assertSafe(argv: string[], denySources?: string[]): void {
11
+ const sources = denySources ?? DEFAULT_SAFETY_DENY;
12
+ const joined = argv.join(" ");
13
+ for (const src of sources) {
14
+ if (new RegExp(src).test(joined)) {
15
+ throw new Error(`refusing to run dangerous command: ${joined}`);
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,142 @@
1
+ // src/core/types.ts — shared pure types + constants (no runtime deps)
2
+
3
+ export interface ModelRates {
4
+ input: number;
5
+ output: number;
6
+ cacheRead: number;
7
+ /** null = the table does not provide it → use the input rate instead (see calcCost) */
8
+ cacheWrite: number | null;
9
+ }
10
+
11
+ export interface PriceTable {
12
+ fetched_at: string;
13
+ models: Record<string, ModelRates>;
14
+ }
15
+
16
+ export interface ModelBreakdown {
17
+ provider: string;
18
+ model: string;
19
+ session_count: number;
20
+ tokens_input: number;
21
+ tokens_output: number;
22
+ tokens_reasoning: number;
23
+ tokens_cache_read: number;
24
+ tokens_cache_write: number;
25
+ cost: number;
26
+ }
27
+
28
+ export interface SessionDetail {
29
+ session_id: string;
30
+ model: string;
31
+ steps: number;
32
+ tools: Record<string, number>;
33
+ }
34
+
35
+ export interface ToolLatencyStat {
36
+ count: number;
37
+ avgMs: number;
38
+ }
39
+
40
+ export interface StepTimingSummary {
41
+ /** step-finish row count (mirrors UsageDetail.steps for SQLite providers). */
42
+ steps: number;
43
+ /** Mean per-part duration from embedded data.time (row-timestamp fallback when absent). */
44
+ avgStepMs: number | null;
45
+ /** How many durations were actually measured (vs. steps with no time signal). */
46
+ stepSamples: number;
47
+ /** Mean per-step tokens — averages only, never summed (sums overlap). */
48
+ avgStepInput: number | null;
49
+ avgStepOutput: number | null;
50
+ avgStepCost: number | null;
51
+ /** Mean tool latency grouped by tool name (from data.state.time). */
52
+ toolLatencyMsByType: Record<string, ToolLatencyStat>;
53
+ note: string;
54
+ }
55
+
56
+ export interface StaleReadFile {
57
+ file: string;
58
+ /** How many distinct sessions read this file (diversity, not just count). */
59
+ sessions: number;
60
+ /** Total Read calls across those sessions. */
61
+ reads: number;
62
+ }
63
+
64
+ export interface UsageDetail {
65
+ /** Global tool-call counts across the filtered sessions (activity signal, not quality). */
66
+ tool_breakdown: Record<string, number>;
67
+ /** Context bytes per tool — size of tool_result content blocks. */
68
+ bytes_by_tool?: Record<string, number>;
69
+ /** Total step-finish parts across the filtered sessions. */
70
+ steps: number;
71
+ /** Per-session breakdown (SQL-aggregated, never raw part rows). */
72
+ by_session: SessionDetail[];
73
+ stale_reads?: StaleReadFile[];
74
+ note: string;
75
+ /** Per-step timing/token/latency signal — present only when detail:true was requested. */
76
+ timing?: StepTimingSummary | null;
77
+ }
78
+
79
+ export interface PassiveUsageResult {
80
+ total_tokens_input: number;
81
+ total_tokens_output: number;
82
+ total_tokens_reasoning: number;
83
+ total_tokens_cache_read: number;
84
+ total_tokens_cache_write: number;
85
+ total_cost: number;
86
+ session_count: number;
87
+ by_model: ModelBreakdown[];
88
+ detail?: UsageDetail | null;
89
+ zcode?: PassiveUsageResult | null;
90
+ claude_code?: PassiveUsageResult | null;
91
+ error?: string;
92
+ }
93
+
94
+ export const EMPTY_RESULT: PassiveUsageResult = {
95
+ total_tokens_input: 0,
96
+ total_tokens_output: 0,
97
+ total_tokens_reasoning: 0,
98
+ total_tokens_cache_read: 0,
99
+ total_tokens_cache_write: 0,
100
+ total_cost: 0,
101
+ session_count: 0,
102
+ by_model: [],
103
+ };
104
+
105
+ /** Common shape every client reader implements — see registry.ts. */
106
+ export type PassiveUsageReader = (
107
+ worktree?: string,
108
+ since?: number,
109
+ until?: number,
110
+ detail?: boolean,
111
+ full?: boolean,
112
+ ) => PassiveUsageResult;
113
+
114
+ export const STEP_TOKENS_NOTE =
115
+ "step tokens overlap (per-step context window) — SUM(step tokens) != session tokens; steps is a count only";
116
+
117
+ export const TIMING_NOTE =
118
+ "timing from embedded part fields only (data.time/data.state.time/step-finish tokens) with row-timestamp fallback; averages, never raw I/O";
119
+
120
+ // --- Tool result types (PLAN-lib-layer chunk 2d) ---
121
+
122
+ export interface ToolResult {
123
+ content: { type: "text"; text: string }[];
124
+ isError?: boolean;
125
+ }
126
+
127
+ export function jsonResult(data: unknown): ToolResult {
128
+ return {
129
+ content: [{ type: "text", text: JSON.stringify(data) }],
130
+ };
131
+ }
132
+
133
+ export function errorResult(message: string): ToolResult {
134
+ return {
135
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
136
+ isError: true,
137
+ };
138
+ }
139
+
140
+ export function parseToolResult(result: ToolResult): unknown {
141
+ return JSON.parse(result.content[0].text);
142
+ }
@@ -0,0 +1,105 @@
1
+ // Replacement values are inserted via a function so `$&`, `$'` etc. in the
2
+ // value are treated as literal text, not replace() special patterns.
3
+ export function templateArgs(
4
+ arr: string[],
5
+ vars: Record<string, string>,
6
+ ): string[] {
7
+ return arr.map((s) => {
8
+ let out = s;
9
+ for (const [k, v] of Object.entries(vars)) {
10
+ out = out.replaceAll(`{${k}}`, () => v);
11
+ }
12
+ return out;
13
+ });
14
+ }
15
+
16
+ /** Fill a prompt template with {{VARS}} (all occurrences). Missing vars → "". */
17
+ export function fillPrompt(
18
+ template: string,
19
+ vars: Record<string, string>,
20
+ ): string {
21
+ let out = template;
22
+ for (const [k, v] of Object.entries(vars)) {
23
+ out = out.replaceAll(`{{${k}}}`, () => v);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
29
+ export function minutesBetween(a: string, b: string): number {
30
+ const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
31
+ const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
32
+ return (t1 - t0) / 60000;
33
+ }
34
+
35
+ /** Arithmetic mean of a numeric array. 0 for empty arrays. */
36
+ export function avg(xs: number[]): number {
37
+ return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
38
+ }
39
+
40
+ /** True for "y"/"yes" (case-insensitive, trimmed) — the only affirmative answers. */
41
+ export function isAffirmative(answer: string): boolean {
42
+ const normalized = answer.trim().toLowerCase();
43
+ return normalized === "y" || normalized === "yes";
44
+ }
45
+
46
+ // --- Output capping ---
47
+
48
+ // Keep the head, always say how much was cut — a silent cut is
49
+ // indistinguishable from "that was everything".
50
+ export function capLines(lines: string[], cap: number, what: string): string[] {
51
+ if (lines.length <= cap) return lines;
52
+ const rest = lines.length - (cap - 1);
53
+ const kept = lines.slice(0, cap - 1);
54
+ kept.push(`… +${rest} more ${what}`);
55
+ return kept;
56
+ }
57
+
58
+ // --- Recursive directory walker ---
59
+
60
+ import { readdirSync } from "node:fs";
61
+ import { join } from "node:path";
62
+
63
+ export interface WalkDirOpts {
64
+ /** Max recursion depth (default 4). */
65
+ depth?: number;
66
+ /** Skip node_modules (default true). */
67
+ skipNodeModules?: boolean;
68
+ /** Dot-directories to descend into (default []). Others starting with . are skipped. */
69
+ includeDotDirs?: string[];
70
+ /** Extra filter: return true to collect this dir. Omit to collect all reached dirs. */
71
+ predicate?: (dirPath: string) => boolean;
72
+ }
73
+
74
+ /**
75
+ * Recursively walk directories under `root`.
76
+ * Returns absolute paths of dirs for which `predicate` returned true (or all
77
+ * reached dirs when no predicate is given). Skips node_modules by default.
78
+ */
79
+ export function walkDir(root: string, opts?: WalkDirOpts): string[] {
80
+ const maxDepth = opts?.depth ?? 4;
81
+ const skipNm = opts?.skipNodeModules ?? true;
82
+ const includeDot = new Set(opts?.includeDotDirs ?? []);
83
+ const pred = opts?.predicate;
84
+ const result: string[] = [];
85
+
86
+ const walk = (dir: string, depth: number): void => {
87
+ if (depth > maxDepth) return;
88
+ if (!pred || pred(dir)) result.push(dir);
89
+ try {
90
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
91
+ if (!entry.isDirectory()) continue;
92
+ if (entry.isSymbolicLink()) continue;
93
+ if (skipNm && entry.name === "node_modules") continue;
94
+ if (entry.name.startsWith(".") && !includeDot.has(entry.name)) {
95
+ continue;
96
+ }
97
+ walk(join(dir, entry.name), depth + 1);
98
+ }
99
+ } catch {
100
+ // ignore unreadable dirs
101
+ }
102
+ };
103
+ walk(root, 0);
104
+ return result;
105
+ }
package/src/db/store.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
- import { faponyDir } from "./load.js";
4
- import type { Config, Event, Run, RunStatus } from "./types.js";
3
+ import type { Config, Event, Run, RunStatus } from "../core/config.js";
4
+ import { faponyDir } from "../core/config.js";
5
5
 
6
6
  /**
7
7
  * Schema version for state.db — tracked via `PRAGMA user_version`.
package/src/debt/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, resolve } from "node:path";
7
- import { CONVENTIONS_FILE } from "../db/defaults.js";
7
+ import { CONVENTIONS_FILE } from "../core/config.js";
8
8
  import { formatDebt } from "./format.js";
9
9
  import { loadConventions } from "./load.js";
10
10
  import { findPromotions, formatPromotions } from "./promotion.js";
@@ -1,107 +1,2 @@
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
- }
1
+ // shim — moved to src/core/debt-format.ts
2
+ export { formatDebt } from "../core/debt-format.js";
package/src/debt/load.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  CONVENTIONS_FILE,
11
11
  CONVENTIONS_FILENAME,
12
12
  FAPONY_DIR,
13
- } from "../db/defaults.js";
13
+ } from "../core/config.js";
14
14
  import { resolveMemDir } from "../memory.js";
15
15
  import type { Convention, LoadedConventions } from "./types.js";
16
16
 
@@ -11,7 +11,7 @@
11
11
  // ≥ 6 chars from stale such as toLocaleString/useMutation — "throw"/"Error" are too
12
12
  // short and don't count, to avoid over-matching)
13
13
 
14
- import { openDb } from "../db/index.js";
14
+ import { openDb } from "../db/store.js";
15
15
  import { readMemLog } from "../memory.js";
16
16
  import {
17
17
  type Convention,