mulch-cli 0.5.0 → 0.6.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 +12 -1
- package/package.json +11 -16
- package/src/api.ts +310 -0
- package/src/cli.ts +54 -0
- package/src/commands/add.ts +61 -0
- package/src/commands/compact.ts +924 -0
- package/src/commands/delete.ts +103 -0
- package/src/commands/diff.ts +209 -0
- package/src/commands/doctor.ts +586 -0
- package/src/commands/edit.ts +253 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/learn.ts +170 -0
- package/src/commands/onboard.ts +362 -0
- package/src/commands/prime.ts +327 -0
- package/src/commands/prune.ts +128 -0
- package/src/commands/query.ts +177 -0
- package/src/commands/ready.ts +194 -0
- package/src/commands/record.ts +959 -0
- package/src/commands/search.ts +234 -0
- package/src/commands/setup.ts +823 -0
- package/src/commands/status.ts +83 -0
- package/src/commands/sync.ts +224 -0
- package/src/commands/update.ts +112 -0
- package/src/commands/validate.ts +107 -0
- package/src/index.ts +50 -0
- package/src/schemas/config.ts +31 -0
- package/src/schemas/index.ts +18 -0
- package/src/schemas/record-schema.ts +177 -0
- package/src/schemas/record.ts +83 -0
- package/src/utils/bm25.ts +243 -0
- package/src/utils/budget.ts +157 -0
- package/src/utils/config.ts +117 -0
- package/src/utils/expertise.ts +379 -0
- package/src/utils/format.ts +767 -0
- package/src/utils/git.ts +89 -0
- package/src/utils/index.ts +54 -0
- package/src/utils/json-output.ts +13 -0
- package/src/utils/lock.ts +82 -0
- package/src/utils/markers.ts +51 -0
- package/{dist/utils/scoring.d.ts → src/utils/scoring.ts} +53 -9
- package/src/utils/version.ts +46 -0
- package/dist/api.d.ts +0 -65
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js +0 -196
- package/dist/api.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -50
- package/dist/cli.js.map +0 -1
- package/dist/commands/add.d.ts +0 -3
- package/dist/commands/add.d.ts.map +0 -1
- package/dist/commands/add.js +0 -47
- package/dist/commands/add.js.map +0 -1
- package/dist/commands/compact.d.ts +0 -5
- package/dist/commands/compact.d.ts.map +0 -1
- package/dist/commands/compact.js +0 -709
- package/dist/commands/compact.js.map +0 -1
- package/dist/commands/delete.d.ts +0 -3
- package/dist/commands/delete.d.ts.map +0 -1
- package/dist/commands/delete.js +0 -82
- package/dist/commands/delete.js.map +0 -1
- package/dist/commands/diff.d.ts +0 -11
- package/dist/commands/diff.d.ts.map +0 -1
- package/dist/commands/diff.js +0 -170
- package/dist/commands/diff.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -3
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -391
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/edit.d.ts +0 -3
- package/dist/commands/edit.d.ts.map +0 -1
- package/dist/commands/edit.js +0 -198
- package/dist/commands/edit.js.map +0 -1
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/init.js +0 -30
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/learn.d.ts +0 -12
- package/dist/commands/learn.d.ts.map +0 -1
- package/dist/commands/learn.js +0 -130
- package/dist/commands/learn.js.map +0 -1
- package/dist/commands/onboard.d.ts +0 -10
- package/dist/commands/onboard.d.ts.map +0 -1
- package/dist/commands/onboard.js +0 -286
- package/dist/commands/onboard.js.map +0 -1
- package/dist/commands/prime.d.ts +0 -3
- package/dist/commands/prime.d.ts.map +0 -1
- package/dist/commands/prime.js +0 -242
- package/dist/commands/prime.js.map +0 -1
- package/dist/commands/prune.d.ts +0 -8
- package/dist/commands/prune.d.ts.map +0 -1
- package/dist/commands/prune.js +0 -90
- package/dist/commands/prune.js.map +0 -1
- package/dist/commands/query.d.ts +0 -3
- package/dist/commands/query.d.ts.map +0 -1
- package/dist/commands/query.js +0 -133
- package/dist/commands/query.js.map +0 -1
- package/dist/commands/ready.d.ts +0 -3
- package/dist/commands/ready.d.ts.map +0 -1
- package/dist/commands/ready.js +0 -160
- package/dist/commands/ready.js.map +0 -1
- package/dist/commands/record.d.ts +0 -13
- package/dist/commands/record.d.ts.map +0 -1
- package/dist/commands/record.js +0 -689
- package/dist/commands/record.js.map +0 -1
- package/dist/commands/search.d.ts +0 -3
- package/dist/commands/search.d.ts.map +0 -1
- package/dist/commands/search.js +0 -163
- package/dist/commands/search.js.map +0 -1
- package/dist/commands/setup.d.ts +0 -29
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/setup.js +0 -548
- package/dist/commands/setup.js.map +0 -1
- package/dist/commands/status.d.ts +0 -3
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -61
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/sync.d.ts +0 -3
- package/dist/commands/sync.d.ts.map +0 -1
- package/dist/commands/sync.js +0 -176
- package/dist/commands/sync.js.map +0 -1
- package/dist/commands/update.d.ts +0 -3
- package/dist/commands/update.d.ts.map +0 -1
- package/dist/commands/update.js +0 -72
- package/dist/commands/update.js.map +0 -1
- package/dist/commands/validate.d.ts +0 -3
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/validate.js +0 -86
- package/dist/commands/validate.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -10
- package/dist/index.js.map +0 -1
- package/dist/schemas/config.d.ts +0 -17
- package/dist/schemas/config.d.ts.map +0 -1
- package/dist/schemas/config.js +0 -16
- package/dist/schemas/config.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -5
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -3
- package/dist/schemas/index.js.map +0 -1
- package/dist/schemas/record-schema.d.ts +0 -403
- package/dist/schemas/record-schema.d.ts.map +0 -1
- package/dist/schemas/record-schema.js +0 -150
- package/dist/schemas/record-schema.js.map +0 -1
- package/dist/schemas/record.d.ts +0 -62
- package/dist/schemas/record.d.ts.map +0 -1
- package/dist/schemas/record.js +0 -2
- package/dist/schemas/record.js.map +0 -1
- package/dist/utils/bm25.d.ts +0 -39
- package/dist/utils/bm25.d.ts.map +0 -1
- package/dist/utils/bm25.js +0 -171
- package/dist/utils/bm25.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -35
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js +0 -114
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/config.d.ts +0 -12
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -89
- package/dist/utils/config.js.map +0 -1
- package/dist/utils/expertise.d.ts +0 -57
- package/dist/utils/expertise.d.ts.map +0 -1
- package/dist/utils/expertise.js +0 -276
- package/dist/utils/expertise.js.map +0 -1
- package/dist/utils/format.d.ts +0 -31
- package/dist/utils/format.d.ts.map +0 -1
- package/dist/utils/format.js +0 -562
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/git.d.ts +0 -6
- package/dist/utils/git.d.ts.map +0 -1
- package/dist/utils/git.js +0 -81
- package/dist/utils/git.js.map +0 -1
- package/dist/utils/index.d.ts +0 -8
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -8
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/json-output.d.ts +0 -8
- package/dist/utils/json-output.d.ts.map +0 -1
- package/dist/utils/json-output.js +0 -7
- package/dist/utils/json-output.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -6
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js +0 -70
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/markers.d.ts +0 -22
- package/dist/utils/markers.d.ts.map +0 -1
- package/dist/utils/markers.js +0 -42
- package/dist/utils/markers.js.map +0 -1
- package/dist/utils/scoring.d.ts.map +0 -1
- package/dist/utils/scoring.js +0 -80
- package/dist/utils/scoring.js.map +0 -1
- package/dist/utils/version.d.ts +0 -15
- package/dist/utils/version.d.ts.map +0 -1
- package/dist/utils/version.js +0 -48
- package/dist/utils/version.js.map +0 -1
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { writeFile as fsWriteFile, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import Ajv from "ajv";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import type { Command } from "commander";
|
|
7
|
+
import type { MulchConfig } from "../schemas/config.ts";
|
|
8
|
+
import { recordSchema } from "../schemas/record-schema.ts";
|
|
9
|
+
import type { ExpertiseRecord } from "../schemas/record.ts";
|
|
10
|
+
import {
|
|
11
|
+
getExpertiseDir,
|
|
12
|
+
getExpertisePath,
|
|
13
|
+
getMulchDir,
|
|
14
|
+
readConfig,
|
|
15
|
+
writeConfig,
|
|
16
|
+
} from "../utils/config.ts";
|
|
17
|
+
import {
|
|
18
|
+
createExpertiseFile,
|
|
19
|
+
findDuplicate,
|
|
20
|
+
readExpertiseFile,
|
|
21
|
+
writeExpertiseFile,
|
|
22
|
+
} from "../utils/expertise.ts";
|
|
23
|
+
import { outputJson, outputJsonError } from "../utils/json-output.ts";
|
|
24
|
+
import { withFileLock } from "../utils/lock.ts";
|
|
25
|
+
import {
|
|
26
|
+
compareSemver,
|
|
27
|
+
getCurrentVersion,
|
|
28
|
+
getLatestVersion,
|
|
29
|
+
} from "../utils/version.ts";
|
|
30
|
+
import { isStale } from "./prune.ts";
|
|
31
|
+
|
|
32
|
+
interface DoctorCheck {
|
|
33
|
+
name: string;
|
|
34
|
+
status: "pass" | "warn" | "fail";
|
|
35
|
+
message: string;
|
|
36
|
+
fixable: boolean;
|
|
37
|
+
details: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function checkConfig(cwd?: string): Promise<DoctorCheck> {
|
|
41
|
+
try {
|
|
42
|
+
const mulchDir = getMulchDir(cwd);
|
|
43
|
+
if (!existsSync(mulchDir)) {
|
|
44
|
+
return {
|
|
45
|
+
name: "config",
|
|
46
|
+
status: "fail",
|
|
47
|
+
message: "No .mulch/ directory found",
|
|
48
|
+
fixable: false,
|
|
49
|
+
details: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
await readConfig(cwd);
|
|
53
|
+
return {
|
|
54
|
+
name: "config",
|
|
55
|
+
status: "pass",
|
|
56
|
+
message: "Config is valid",
|
|
57
|
+
fixable: false,
|
|
58
|
+
details: [],
|
|
59
|
+
};
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
name: "config",
|
|
63
|
+
status: "fail",
|
|
64
|
+
message: `Config error: ${(err as Error).message}`,
|
|
65
|
+
fixable: false,
|
|
66
|
+
details: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function checkJsonlIntegrity(
|
|
72
|
+
config: MulchConfig,
|
|
73
|
+
cwd?: string,
|
|
74
|
+
): Promise<DoctorCheck> {
|
|
75
|
+
const details: string[] = [];
|
|
76
|
+
for (const domain of config.domains) {
|
|
77
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
78
|
+
let content: string;
|
|
79
|
+
try {
|
|
80
|
+
content = await readFile(filePath, "utf-8");
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const lines = content.split("\n");
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i].trim();
|
|
87
|
+
if (line.length === 0) continue;
|
|
88
|
+
try {
|
|
89
|
+
JSON.parse(line);
|
|
90
|
+
} catch {
|
|
91
|
+
details.push(`${domain}:${i + 1} - Invalid JSON`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (details.length > 0) {
|
|
96
|
+
return {
|
|
97
|
+
name: "jsonl-integrity",
|
|
98
|
+
status: "fail",
|
|
99
|
+
message: `${details.length} invalid JSON line(s) found`,
|
|
100
|
+
fixable: true,
|
|
101
|
+
details,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
name: "jsonl-integrity",
|
|
106
|
+
status: "pass",
|
|
107
|
+
message: "All JSONL lines are valid JSON",
|
|
108
|
+
fixable: true,
|
|
109
|
+
details: [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function checkSchemaValidation(
|
|
114
|
+
config: MulchConfig,
|
|
115
|
+
cwd?: string,
|
|
116
|
+
): Promise<DoctorCheck> {
|
|
117
|
+
const ajv = new Ajv();
|
|
118
|
+
const validate = ajv.compile(recordSchema);
|
|
119
|
+
const details: string[] = [];
|
|
120
|
+
|
|
121
|
+
for (const domain of config.domains) {
|
|
122
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
123
|
+
let content: string;
|
|
124
|
+
try {
|
|
125
|
+
content = await readFile(filePath, "utf-8");
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const lines = content.split("\n");
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const line = lines[i].trim();
|
|
132
|
+
if (line.length === 0) continue;
|
|
133
|
+
let parsed: unknown;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(line);
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // Already caught by integrity check
|
|
138
|
+
}
|
|
139
|
+
if (!validate(parsed)) {
|
|
140
|
+
const errors = (validate.errors ?? [])
|
|
141
|
+
.map((e) => `${e.instancePath} ${e.message}`)
|
|
142
|
+
.join("; ");
|
|
143
|
+
details.push(`${domain}:${i + 1} - ${errors}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (details.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
name: "schema-validation",
|
|
150
|
+
status: "fail",
|
|
151
|
+
message: `${details.length} record(s) failed schema validation`,
|
|
152
|
+
fixable: true,
|
|
153
|
+
details,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
name: "schema-validation",
|
|
158
|
+
status: "pass",
|
|
159
|
+
message: "All records pass schema validation",
|
|
160
|
+
fixable: true,
|
|
161
|
+
details: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function checkStaleRecords(
|
|
166
|
+
config: MulchConfig,
|
|
167
|
+
cwd?: string,
|
|
168
|
+
): Promise<DoctorCheck> {
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const shelfLife = config.classification_defaults.shelf_life;
|
|
171
|
+
const details: string[] = [];
|
|
172
|
+
let staleCount = 0;
|
|
173
|
+
|
|
174
|
+
for (const domain of config.domains) {
|
|
175
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
176
|
+
const records = await readExpertiseFile(filePath);
|
|
177
|
+
for (const record of records) {
|
|
178
|
+
if (isStale(record, now, shelfLife)) {
|
|
179
|
+
staleCount++;
|
|
180
|
+
details.push(
|
|
181
|
+
`${domain}: stale ${record.type} (${record.classification})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (staleCount > 0) {
|
|
187
|
+
return {
|
|
188
|
+
name: "stale-records",
|
|
189
|
+
status: "warn",
|
|
190
|
+
message: `${staleCount} stale record(s) found`,
|
|
191
|
+
fixable: true,
|
|
192
|
+
details,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
name: "stale-records",
|
|
197
|
+
status: "pass",
|
|
198
|
+
message: "No stale records",
|
|
199
|
+
fixable: true,
|
|
200
|
+
details: [],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function checkOrphanedDomains(
|
|
205
|
+
config: MulchConfig,
|
|
206
|
+
cwd?: string,
|
|
207
|
+
): Promise<DoctorCheck> {
|
|
208
|
+
const expertiseDir = getExpertiseDir(cwd);
|
|
209
|
+
const details: string[] = [];
|
|
210
|
+
|
|
211
|
+
// Check for JSONL files not in config
|
|
212
|
+
try {
|
|
213
|
+
const files = await readdir(expertiseDir);
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
if (file.endsWith(".jsonl")) {
|
|
216
|
+
const domain = file.replace(".jsonl", "");
|
|
217
|
+
if (!config.domains.includes(domain)) {
|
|
218
|
+
details.push(
|
|
219
|
+
`File "${file}" exists but domain "${domain}" is not in config`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// expertise dir doesn't exist
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check for config domains without JSONL files
|
|
229
|
+
for (const domain of config.domains) {
|
|
230
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
231
|
+
if (!existsSync(filePath)) {
|
|
232
|
+
details.push(`Domain "${domain}" in config but no JSONL file exists`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (details.length > 0) {
|
|
237
|
+
return {
|
|
238
|
+
name: "orphaned-domains",
|
|
239
|
+
status: "warn",
|
|
240
|
+
message: `${details.length} orphaned domain issue(s)`,
|
|
241
|
+
fixable: true,
|
|
242
|
+
details,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
name: "orphaned-domains",
|
|
247
|
+
status: "pass",
|
|
248
|
+
message: "No orphaned domains",
|
|
249
|
+
fixable: true,
|
|
250
|
+
details: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function checkDuplicates(
|
|
255
|
+
config: MulchConfig,
|
|
256
|
+
cwd?: string,
|
|
257
|
+
): Promise<DoctorCheck> {
|
|
258
|
+
const details: string[] = [];
|
|
259
|
+
let dupCount = 0;
|
|
260
|
+
|
|
261
|
+
for (const domain of config.domains) {
|
|
262
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
263
|
+
const records = await readExpertiseFile(filePath);
|
|
264
|
+
for (let i = 1; i < records.length; i++) {
|
|
265
|
+
const dup = findDuplicate(records.slice(0, i), records[i]);
|
|
266
|
+
if (dup) {
|
|
267
|
+
dupCount++;
|
|
268
|
+
details.push(
|
|
269
|
+
`${domain}: duplicate ${records[i].type} at index ${i + 1} (matches #${dup.index + 1})`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (dupCount > 0) {
|
|
275
|
+
return {
|
|
276
|
+
name: "duplicates",
|
|
277
|
+
status: "warn",
|
|
278
|
+
message: `${dupCount} duplicate record(s) found`,
|
|
279
|
+
fixable: false,
|
|
280
|
+
details,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
name: "duplicates",
|
|
285
|
+
status: "pass",
|
|
286
|
+
message: "No duplicates",
|
|
287
|
+
fixable: false,
|
|
288
|
+
details: [],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function checkGovernance(
|
|
293
|
+
config: MulchConfig,
|
|
294
|
+
cwd?: string,
|
|
295
|
+
): Promise<DoctorCheck> {
|
|
296
|
+
const details: string[] = [];
|
|
297
|
+
let worstStatus: "pass" | "warn" | "fail" = "pass";
|
|
298
|
+
|
|
299
|
+
for (const domain of config.domains) {
|
|
300
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
301
|
+
const records = await readExpertiseFile(filePath);
|
|
302
|
+
const count = records.length;
|
|
303
|
+
|
|
304
|
+
if (count >= config.governance.hard_limit) {
|
|
305
|
+
details.push(
|
|
306
|
+
`${domain}: ${count} records (over hard limit of ${config.governance.hard_limit})`,
|
|
307
|
+
);
|
|
308
|
+
worstStatus = "fail";
|
|
309
|
+
} else if (count >= config.governance.warn_entries) {
|
|
310
|
+
details.push(
|
|
311
|
+
`${domain}: ${count} records (over warn threshold of ${config.governance.warn_entries})`,
|
|
312
|
+
);
|
|
313
|
+
if (worstStatus !== "fail") worstStatus = "warn";
|
|
314
|
+
} else if (count >= config.governance.max_entries) {
|
|
315
|
+
details.push(
|
|
316
|
+
`${domain}: ${count} records (approaching limit of ${config.governance.max_entries})`,
|
|
317
|
+
);
|
|
318
|
+
if (worstStatus !== "fail") worstStatus = "warn";
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (details.length > 0) {
|
|
323
|
+
return {
|
|
324
|
+
name: "governance",
|
|
325
|
+
status: worstStatus,
|
|
326
|
+
message: `${details.length} domain(s) over governance thresholds`,
|
|
327
|
+
fixable: false,
|
|
328
|
+
details,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
name: "governance",
|
|
333
|
+
status: "pass",
|
|
334
|
+
message: "All domains within governance limits",
|
|
335
|
+
fixable: false,
|
|
336
|
+
details: [],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function checkUpdateAvailable(): Promise<DoctorCheck> {
|
|
341
|
+
const current = getCurrentVersion();
|
|
342
|
+
const latest = getLatestVersion();
|
|
343
|
+
|
|
344
|
+
if (latest === null) {
|
|
345
|
+
return {
|
|
346
|
+
name: "update",
|
|
347
|
+
status: "pass",
|
|
348
|
+
message: `Version ${current} (unable to check registry)`,
|
|
349
|
+
fixable: false,
|
|
350
|
+
details: [],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const cmp = compareSemver(current, latest);
|
|
355
|
+
if (cmp >= 0) {
|
|
356
|
+
return {
|
|
357
|
+
name: "update",
|
|
358
|
+
status: "pass",
|
|
359
|
+
message: `Version ${current} is up to date`,
|
|
360
|
+
fixable: false,
|
|
361
|
+
details: [],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
name: "update",
|
|
367
|
+
status: "warn",
|
|
368
|
+
message: `Update available: ${current} → ${latest}`,
|
|
369
|
+
fixable: false,
|
|
370
|
+
details: ["Run `mulch update` to update"],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function applyFixes(
|
|
375
|
+
checks: DoctorCheck[],
|
|
376
|
+
config: MulchConfig,
|
|
377
|
+
cwd?: string,
|
|
378
|
+
): Promise<string[]> {
|
|
379
|
+
const fixed: string[] = [];
|
|
380
|
+
|
|
381
|
+
for (const check of checks) {
|
|
382
|
+
if (check.status === "pass" || !check.fixable) continue;
|
|
383
|
+
|
|
384
|
+
switch (check.name) {
|
|
385
|
+
case "jsonl-integrity": {
|
|
386
|
+
// Remove invalid JSON lines
|
|
387
|
+
for (const domain of config.domains) {
|
|
388
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
389
|
+
await withFileLock(filePath, async () => {
|
|
390
|
+
let content: string;
|
|
391
|
+
try {
|
|
392
|
+
content = await readFile(filePath, "utf-8");
|
|
393
|
+
} catch {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const lines = content.split("\n");
|
|
397
|
+
const valid: string[] = [];
|
|
398
|
+
let removed = 0;
|
|
399
|
+
for (const line of lines) {
|
|
400
|
+
const trimmed = line.trim();
|
|
401
|
+
if (trimmed.length === 0) continue;
|
|
402
|
+
try {
|
|
403
|
+
JSON.parse(trimmed);
|
|
404
|
+
valid.push(trimmed);
|
|
405
|
+
} catch {
|
|
406
|
+
removed++;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (removed > 0) {
|
|
410
|
+
await fsWriteFile(
|
|
411
|
+
filePath,
|
|
412
|
+
valid.map((l) => l).join("\n") + (valid.length > 0 ? "\n" : ""),
|
|
413
|
+
"utf-8",
|
|
414
|
+
);
|
|
415
|
+
fixed.push(
|
|
416
|
+
`Removed ${removed} invalid JSON line(s) from ${domain}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case "schema-validation": {
|
|
425
|
+
const ajv = new Ajv();
|
|
426
|
+
const validate = ajv.compile(recordSchema);
|
|
427
|
+
for (const domain of config.domains) {
|
|
428
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
429
|
+
await withFileLock(filePath, async () => {
|
|
430
|
+
const records = await readExpertiseFile(filePath);
|
|
431
|
+
const valid = records.filter((r) => validate(r));
|
|
432
|
+
const removed = records.length - valid.length;
|
|
433
|
+
if (removed > 0) {
|
|
434
|
+
await writeExpertiseFile(filePath, valid);
|
|
435
|
+
fixed.push(`Removed ${removed} invalid record(s) from ${domain}`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
case "stale-records": {
|
|
443
|
+
const now = new Date();
|
|
444
|
+
const shelfLife = config.classification_defaults.shelf_life;
|
|
445
|
+
for (const domain of config.domains) {
|
|
446
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
447
|
+
await withFileLock(filePath, async () => {
|
|
448
|
+
const records = await readExpertiseFile(filePath);
|
|
449
|
+
const kept = records.filter((r) => !isStale(r, now, shelfLife));
|
|
450
|
+
const pruned = records.length - kept.length;
|
|
451
|
+
if (pruned > 0) {
|
|
452
|
+
await writeExpertiseFile(filePath, kept);
|
|
453
|
+
fixed.push(`Pruned ${pruned} stale record(s) from ${domain}`);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case "orphaned-domains": {
|
|
461
|
+
const expertiseDir = getExpertiseDir(cwd);
|
|
462
|
+
// Add missing domains to config
|
|
463
|
+
try {
|
|
464
|
+
const files = await readdir(expertiseDir);
|
|
465
|
+
for (const file of files) {
|
|
466
|
+
if (file.endsWith(".jsonl")) {
|
|
467
|
+
const domain = file.replace(".jsonl", "");
|
|
468
|
+
if (!config.domains.includes(domain)) {
|
|
469
|
+
config.domains.push(domain);
|
|
470
|
+
fixed.push(`Added orphaned domain "${domain}" to config`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// expertise dir doesn't exist
|
|
476
|
+
}
|
|
477
|
+
// Create missing JSONL files
|
|
478
|
+
for (const domain of config.domains) {
|
|
479
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
480
|
+
if (!existsSync(filePath)) {
|
|
481
|
+
await createExpertiseFile(filePath);
|
|
482
|
+
fixed.push(`Created missing JSONL file for domain "${domain}"`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (fixed.length > 0) {
|
|
486
|
+
await writeConfig(config, cwd);
|
|
487
|
+
}
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return fixed;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function registerDoctorCommand(program: Command): void {
|
|
497
|
+
program
|
|
498
|
+
.command("doctor")
|
|
499
|
+
.description("Run health checks on expertise records")
|
|
500
|
+
.option("--fix", "auto-fix fixable issues")
|
|
501
|
+
.action(async (options: { fix?: boolean }) => {
|
|
502
|
+
const jsonMode = program.opts().json === true;
|
|
503
|
+
|
|
504
|
+
// Check config first — if it fails, we can't run other checks
|
|
505
|
+
const configCheck = await checkConfig();
|
|
506
|
+
if (configCheck.status === "fail") {
|
|
507
|
+
const checks = [configCheck];
|
|
508
|
+
const summary = { pass: 0, warn: 0, fail: 1 };
|
|
509
|
+
if (jsonMode) {
|
|
510
|
+
outputJson({ success: false, command: "doctor", checks, summary });
|
|
511
|
+
} else {
|
|
512
|
+
console.log("Mulch Doctor");
|
|
513
|
+
console.log(chalk.red(` ✘ ${configCheck.message}`));
|
|
514
|
+
console.log("\n0 passed, 0 warnings, 1 failed");
|
|
515
|
+
}
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const config = await readConfig();
|
|
521
|
+
|
|
522
|
+
const checks: DoctorCheck[] = [configCheck];
|
|
523
|
+
checks.push(await checkJsonlIntegrity(config));
|
|
524
|
+
checks.push(await checkSchemaValidation(config));
|
|
525
|
+
checks.push(await checkStaleRecords(config));
|
|
526
|
+
checks.push(await checkOrphanedDomains(config));
|
|
527
|
+
checks.push(await checkDuplicates(config));
|
|
528
|
+
checks.push(await checkGovernance(config));
|
|
529
|
+
checks.push(await checkUpdateAvailable());
|
|
530
|
+
|
|
531
|
+
const summary = {
|
|
532
|
+
pass: checks.filter((c) => c.status === "pass").length,
|
|
533
|
+
warn: checks.filter((c) => c.status === "warn").length,
|
|
534
|
+
fail: checks.filter((c) => c.status === "fail").length,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
let fixed: string[] = [];
|
|
538
|
+
if (options.fix) {
|
|
539
|
+
fixed = await applyFixes(checks, config);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (jsonMode) {
|
|
543
|
+
outputJson({
|
|
544
|
+
success: summary.fail === 0,
|
|
545
|
+
command: "doctor",
|
|
546
|
+
checks,
|
|
547
|
+
summary,
|
|
548
|
+
...(options.fix && { fixed }),
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
console.log("Mulch Doctor");
|
|
552
|
+
for (const check of checks) {
|
|
553
|
+
const icon =
|
|
554
|
+
check.status === "pass"
|
|
555
|
+
? chalk.green("✔")
|
|
556
|
+
: check.status === "warn"
|
|
557
|
+
? chalk.yellow("⚠")
|
|
558
|
+
: chalk.red("✘");
|
|
559
|
+
const msg =
|
|
560
|
+
check.status === "pass" ? check.message : `${check.message}`;
|
|
561
|
+
console.log(` ${icon} ${msg}`);
|
|
562
|
+
|
|
563
|
+
// Print details for non-pass checks
|
|
564
|
+
if (check.status !== "pass" && check.details.length > 0) {
|
|
565
|
+
for (const detail of check.details) {
|
|
566
|
+
console.log(` ${detail}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
console.log(
|
|
571
|
+
`\n${summary.pass} passed, ${summary.warn} warning(s), ${summary.fail} failed`,
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
if (fixed.length > 0) {
|
|
575
|
+
console.log(`\n${chalk.green("Fixed:")}`);
|
|
576
|
+
for (const f of fixed) {
|
|
577
|
+
console.log(` ${chalk.green("✔")} ${f}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (summary.fail > 0) {
|
|
583
|
+
process.exitCode = 1;
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|