@decantr/cli 1.7.28 → 1.8.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.
@@ -0,0 +1,466 @@
1
+ import {
2
+ collectCheckIssues
3
+ } from "./chunk-RSDCWAHD.js";
4
+
5
+ // src/commands/health.ts
6
+ import { existsSync, readFileSync, writeFileSync } from "fs";
7
+ import { join } from "path";
8
+ import {
9
+ auditProject
10
+ } from "@decantr/verifier";
11
+ var BOLD = "\x1B[1m";
12
+ var DIM = "\x1B[2m";
13
+ var RESET = "\x1B[0m";
14
+ var RED = "\x1B[31m";
15
+ var GREEN = "\x1B[32m";
16
+ var CYAN = "\x1B[36m";
17
+ var YELLOW = "\x1B[33m";
18
+ var PROJECT_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/project-health-report.v1.json";
19
+ function readProjectMetadata(projectRoot) {
20
+ const projectJsonPath = join(projectRoot, ".decantr", "project.json");
21
+ if (!existsSync(projectJsonPath)) {
22
+ return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
23
+ }
24
+ try {
25
+ const data = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
26
+ const workflowMode = typeof data.initialized?.workflowMode === "string" ? data.initialized.workflowMode : null;
27
+ const adoptionMode = typeof data.initialized?.adoptionMode === "string" ? data.initialized.adoptionMode : null;
28
+ return {
29
+ workflowMode,
30
+ adoptionMode,
31
+ autoBrownfield: workflowMode === "brownfield-attach"
32
+ };
33
+ } catch {
34
+ return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
35
+ }
36
+ }
37
+ function collectDeclaredRoutes(essence) {
38
+ if (!essence || typeof essence !== "object") return [];
39
+ const record = essence;
40
+ const blueprint = record.blueprint;
41
+ if (!blueprint || typeof blueprint !== "object") return [];
42
+ const bp = blueprint;
43
+ const routes = /* @__PURE__ */ new Set();
44
+ if (bp.routes && typeof bp.routes === "object" && !Array.isArray(bp.routes)) {
45
+ for (const route of Object.keys(bp.routes)) {
46
+ routes.add(route);
47
+ }
48
+ }
49
+ const flatPages = Array.isArray(bp.pages) ? bp.pages : [];
50
+ for (const page of flatPages) {
51
+ if (page && typeof page === "object") {
52
+ const route = page.route;
53
+ if (typeof route === "string") routes.add(route);
54
+ }
55
+ }
56
+ const sections = Array.isArray(bp.sections) ? bp.sections : [];
57
+ for (const section of sections) {
58
+ if (!section || typeof section !== "object") continue;
59
+ const pages = section.pages;
60
+ if (!Array.isArray(pages)) continue;
61
+ for (const page of pages) {
62
+ if (page && typeof page === "object") {
63
+ const route = page.route;
64
+ if (typeof route === "string") routes.add(route);
65
+ }
66
+ }
67
+ }
68
+ return [...routes].sort();
69
+ }
70
+ function severityFromCheckIssue(issue) {
71
+ return issue.type === "error" ? "error" : "warn";
72
+ }
73
+ function sourceFromAuditFinding(finding) {
74
+ const category = finding.category.toLowerCase();
75
+ const id = finding.id.toLowerCase();
76
+ const rule = finding.rule?.toLowerCase() ?? "";
77
+ if (category.includes("runtime") || category.includes("document") || category.includes("performance")) {
78
+ return "runtime";
79
+ }
80
+ if (category.includes("pack") || category.includes("review contract")) {
81
+ return "pack";
82
+ }
83
+ if (category.includes("interaction") || id.includes("interaction") || rule.includes("interaction")) {
84
+ return "interaction";
85
+ }
86
+ return "audit";
87
+ }
88
+ function sourceFromCheckIssue(issue) {
89
+ if (issue.rule.startsWith("brownfield-")) return "brownfield";
90
+ if (issue.rule.includes("interaction")) return "interaction";
91
+ return "check";
92
+ }
93
+ function slugify(value) {
94
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
95
+ }
96
+ function commandsForFinding(source) {
97
+ switch (source) {
98
+ case "brownfield":
99
+ return ["decantr analyze", "decantr init --existing --merge-proposal", "decantr health"];
100
+ case "pack":
101
+ return ["decantr refresh", "decantr registry get-pack review --write-context", "decantr health"];
102
+ case "runtime":
103
+ return ["npm run build", "decantr health"];
104
+ case "interaction":
105
+ return ["decantr check --strict", "decantr health"];
106
+ case "check":
107
+ return ["decantr check", "decantr health"];
108
+ default:
109
+ return ["decantr audit", "decantr health"];
110
+ }
111
+ }
112
+ function buildRemediationPrompt(input) {
113
+ return [
114
+ "You are fixing one Decantr Project Health finding in this local workspace.",
115
+ "",
116
+ "Read `DECANTR.md`, `decantr.essence.json`, and `.decantr/context/scaffold-pack.md` if they exist. For route or page work, read the matching page/section packs before editing.",
117
+ "",
118
+ `Finding: ${input.id}`,
119
+ `Source: ${input.source}`,
120
+ `Severity: ${input.severity}`,
121
+ `Category: ${input.category}`,
122
+ `Message: ${input.message}`,
123
+ input.evidence.length > 0 ? `Evidence:
124
+ ${input.evidence.map((entry) => `- ${entry}`).join("\n")}` : null,
125
+ input.suggestedFix ? `Suggested fix: ${input.suggestedFix}` : null,
126
+ "",
127
+ "Make the smallest coherent code or contract change that resolves this finding. Preserve the existing framework, routing, styling system, and Decantr workflow mode unless the finding explicitly requires a contract update.",
128
+ "",
129
+ `After the fix, run:
130
+ ${input.commands.map((command) => `- ${command}`).join("\n")}`
131
+ ].filter((line) => Boolean(line)).join("\n");
132
+ }
133
+ function createHealthFinding(input) {
134
+ const idBase = input.baseId || input.rule || `${input.category}-${input.message}`;
135
+ const id = `${input.source}-${slugify(idBase)}`;
136
+ const commands = commandsForFinding(input.source);
137
+ const remediation = {
138
+ summary: input.suggestedFix || `Resolve ${input.category.toLowerCase()} finding.`,
139
+ commands,
140
+ prompt: buildRemediationPrompt({
141
+ id,
142
+ source: input.source,
143
+ category: input.category,
144
+ severity: input.severity,
145
+ message: input.message,
146
+ evidence: input.evidence ?? [],
147
+ suggestedFix: input.suggestedFix,
148
+ commands
149
+ })
150
+ };
151
+ return {
152
+ id,
153
+ source: input.source,
154
+ category: input.category,
155
+ severity: input.severity,
156
+ message: input.message,
157
+ evidence: input.evidence ?? [],
158
+ target: input.target,
159
+ file: input.file,
160
+ rule: input.rule,
161
+ suggestedFix: input.suggestedFix,
162
+ remediation
163
+ };
164
+ }
165
+ function countFindings(findings) {
166
+ return {
167
+ errorCount: findings.filter((finding) => finding.severity === "error").length,
168
+ warnCount: findings.filter((finding) => finding.severity === "warn").length,
169
+ infoCount: findings.filter((finding) => finding.severity === "info").length
170
+ };
171
+ }
172
+ function statusFromCounts(counts) {
173
+ if (counts.errorCount > 0) return "error";
174
+ if (counts.warnCount > 0) return "warning";
175
+ return "healthy";
176
+ }
177
+ function scoreFromCounts(counts) {
178
+ return Math.max(0, Math.min(100, 100 - counts.errorCount * 15 - counts.warnCount * 5 - counts.infoCount));
179
+ }
180
+ function routeIssuesFromFindings(findings) {
181
+ const issues = findings.filter(
182
+ (finding) => finding.category.toLowerCase().includes("route") || finding.rule?.toLowerCase().includes("route") || finding.id.toLowerCase().includes("route")
183
+ ).map((finding) => finding.message);
184
+ return [...new Set(issues)];
185
+ }
186
+ function isDuplicateFinding(existing, finding) {
187
+ const key = `${finding.rule ?? finding.id}|${finding.message}`;
188
+ if (existing.has(key)) return true;
189
+ existing.add(key);
190
+ return false;
191
+ }
192
+ async function createProjectHealthReport(projectRoot = process.cwd()) {
193
+ const metadata = readProjectMetadata(projectRoot);
194
+ const audit = await auditProject(projectRoot);
195
+ const findings = [];
196
+ const seen = /* @__PURE__ */ new Set();
197
+ for (const finding of audit.findings) {
198
+ const healthFinding = createHealthFinding({
199
+ source: sourceFromAuditFinding(finding),
200
+ category: finding.category,
201
+ severity: finding.severity,
202
+ message: finding.message,
203
+ evidence: finding.evidence,
204
+ target: finding.target,
205
+ file: finding.file,
206
+ rule: finding.rule,
207
+ suggestedFix: finding.suggestedFix,
208
+ baseId: finding.id
209
+ });
210
+ if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
211
+ }
212
+ try {
213
+ const check = collectCheckIssues(projectRoot, { brownfield: metadata.autoBrownfield });
214
+ for (const issue of check.issues) {
215
+ const source = sourceFromCheckIssue(issue);
216
+ const healthFinding = createHealthFinding({
217
+ source,
218
+ category: source === "brownfield" ? "Brownfield Drift" : "Contract Check",
219
+ severity: severityFromCheckIssue(issue),
220
+ message: issue.message,
221
+ evidence: [`Rule: ${issue.rule}`],
222
+ rule: issue.rule,
223
+ suggestedFix: issue.suggestion,
224
+ baseId: issue.rule
225
+ });
226
+ if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
227
+ }
228
+ } catch (e) {
229
+ const healthFinding = createHealthFinding({
230
+ source: "check",
231
+ category: "Contract Check",
232
+ severity: "error",
233
+ message: `Decantr check could not complete: ${e.message}`,
234
+ evidence: ["The health command could not run the local check pass."],
235
+ rule: "check-failed",
236
+ suggestedFix: "Repair the local Decantr contract and rerun `decantr health`.",
237
+ baseId: "check-failed"
238
+ });
239
+ if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
240
+ }
241
+ if (!audit.valid && findings.every((finding) => finding.severity !== "error")) {
242
+ findings.push(
243
+ createHealthFinding({
244
+ source: "audit",
245
+ category: "Project Contract",
246
+ severity: "error",
247
+ message: "Project audit is not valid.",
248
+ evidence: ["The verifier returned valid=false."],
249
+ rule: "project-audit-invalid",
250
+ suggestedFix: "Resolve blocking audit findings and rerun `decantr health`."
251
+ })
252
+ );
253
+ }
254
+ const counts = countFindings(findings);
255
+ const declaredRoutes = collectDeclaredRoutes(audit.essence);
256
+ const manifest = audit.packManifest;
257
+ return {
258
+ $schema: PROJECT_HEALTH_SCHEMA_URL,
259
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
260
+ projectRoot,
261
+ status: statusFromCounts(counts),
262
+ score: scoreFromCounts(counts),
263
+ summary: {
264
+ ...counts,
265
+ findingCount: findings.length,
266
+ workflowMode: metadata.workflowMode,
267
+ adoptionMode: metadata.adoptionMode,
268
+ essenceVersion: audit.summary.essenceVersion,
269
+ pageCount: audit.summary.pageCount,
270
+ runtimeAuditChecked: audit.summary.runtimeAuditChecked,
271
+ runtimePassed: audit.summary.runtimePassed,
272
+ packManifestPresent: audit.summary.packManifestPresent,
273
+ reviewPackPresent: audit.summary.reviewPackPresent
274
+ },
275
+ routes: {
276
+ declared: declaredRoutes,
277
+ runtimeChecked: audit.runtimeAudit.routeHintsChecked,
278
+ runtimeMatched: audit.runtimeAudit.routeHintsMatched,
279
+ runtimeCoverageOk: audit.summary.runtimeAuditChecked ? audit.runtimeAudit.routeHintsCoverageOk : null,
280
+ issues: routeIssuesFromFindings(findings)
281
+ },
282
+ packs: {
283
+ manifestPresent: Boolean(manifest),
284
+ reviewPackPresent: Boolean(manifest?.review ?? audit.reviewPack),
285
+ scaffoldPackPresent: Boolean(manifest?.scaffold),
286
+ sectionPackCount: manifest?.sections.length ?? 0,
287
+ pagePackCount: manifest?.pages.length ?? 0,
288
+ mutationPackCount: manifest?.mutations?.length ?? 0,
289
+ generatedAt: typeof manifest?.generatedAt === "string" ? manifest.generatedAt : null
290
+ },
291
+ ci: {
292
+ recommendedCommand: "decantr health --ci --fail-on error",
293
+ failOn: "error"
294
+ },
295
+ findings
296
+ };
297
+ }
298
+ function colorForStatus(status) {
299
+ if (status === "healthy") return GREEN;
300
+ if (status === "warning") return YELLOW;
301
+ return RED;
302
+ }
303
+ function formatProjectHealthText(report) {
304
+ const color = colorForStatus(report.status);
305
+ const lines = [
306
+ `${BOLD}Decantr Project Health${RESET}`,
307
+ "",
308
+ `${color}${report.status.toUpperCase()}${RESET} score ${report.score}/100`,
309
+ `${DIM}${report.projectRoot}${RESET}`,
310
+ "",
311
+ `${BOLD}Summary:${RESET}`,
312
+ ` Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
313
+ ` Essence: ${report.summary.essenceVersion ?? "missing"} | pages ${report.summary.pageCount}`,
314
+ ` Workflow: ${report.summary.workflowMode ?? "unknown"} | adoption ${report.summary.adoptionMode ?? "unknown"}`,
315
+ ` Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
316
+ ` Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"} | review ${report.packs.reviewPackPresent ? "present" : "missing"} | pages ${report.packs.pagePackCount}`,
317
+ "",
318
+ `${BOLD}Findings:${RESET}`
319
+ ];
320
+ if (report.findings.length === 0) {
321
+ lines.push(` ${GREEN}No findings. Project is healthy.${RESET}`);
322
+ } else {
323
+ for (const finding of report.findings) {
324
+ const findingColor = finding.severity === "error" ? RED : finding.severity === "warn" ? YELLOW : CYAN;
325
+ lines.push(
326
+ ` ${findingColor}[${finding.severity.toUpperCase()}]${RESET} ${finding.id}: ${finding.message}`
327
+ );
328
+ if (finding.evidence.length > 0) {
329
+ lines.push(` ${DIM}${finding.evidence[0]}${RESET}`);
330
+ }
331
+ if (finding.suggestedFix) {
332
+ lines.push(` ${DIM}Fix: ${finding.suggestedFix}${RESET}`);
333
+ }
334
+ lines.push(` ${DIM}Prompt: decantr health --prompt ${finding.id}${RESET}`);
335
+ }
336
+ }
337
+ lines.push("");
338
+ lines.push(`${BOLD}CI:${RESET} ${report.ci.recommendedCommand}`);
339
+ return `${lines.join("\n")}
340
+ `;
341
+ }
342
+ function formatProjectHealthMarkdown(report) {
343
+ const lines = [
344
+ "# Decantr Project Health",
345
+ "",
346
+ `- Status: **${report.status}**`,
347
+ `- Score: **${report.score}/100**`,
348
+ `- Project: \`${report.projectRoot}\``,
349
+ `- Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
350
+ `- Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
351
+ `- Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"}, review ${report.packs.reviewPackPresent ? "present" : "missing"}`,
352
+ "",
353
+ "## Findings",
354
+ ""
355
+ ];
356
+ if (report.findings.length === 0) {
357
+ lines.push("No findings. Project is healthy.");
358
+ } else {
359
+ for (const finding of report.findings) {
360
+ lines.push(`### ${finding.id}`);
361
+ lines.push("");
362
+ lines.push(`- Severity: ${finding.severity}`);
363
+ lines.push(`- Source: ${finding.source}`);
364
+ lines.push(`- Category: ${finding.category}`);
365
+ lines.push(`- Message: ${finding.message}`);
366
+ if (finding.suggestedFix) lines.push(`- Fix: ${finding.suggestedFix}`);
367
+ if (finding.evidence.length > 0) {
368
+ lines.push("- Evidence:");
369
+ for (const evidence of finding.evidence) lines.push(` - ${evidence}`);
370
+ }
371
+ lines.push(`- Prompt: \`decantr health --prompt ${finding.id}\``);
372
+ lines.push("");
373
+ }
374
+ }
375
+ lines.push("## CI");
376
+ lines.push("");
377
+ lines.push(`\`${report.ci.recommendedCommand}\``);
378
+ return `${lines.join("\n")}
379
+ `;
380
+ }
381
+ function formatProjectHealthJson(report) {
382
+ return `${JSON.stringify(report, null, 2)}
383
+ `;
384
+ }
385
+ function resolveFormat(options) {
386
+ if (options.json) return "json";
387
+ if (options.markdown) return "markdown";
388
+ return options.format ?? "text";
389
+ }
390
+ function shouldFailHealth(report, failOn) {
391
+ if (failOn === "none") return false;
392
+ if (failOn === "warn") return report.summary.errorCount > 0 || report.summary.warnCount > 0;
393
+ return report.summary.errorCount > 0;
394
+ }
395
+ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
396
+ const report = await createProjectHealthReport(projectRoot);
397
+ if (options.promptId) {
398
+ const finding = report.findings.find((entry) => entry.id === options.promptId);
399
+ if (!finding) {
400
+ console.error(`${RED}No health finding found for id: ${options.promptId}${RESET}`);
401
+ process.exitCode = 1;
402
+ return;
403
+ }
404
+ console.log(finding.remediation.prompt);
405
+ return;
406
+ }
407
+ const format = resolveFormat(options);
408
+ const payload = format === "json" ? formatProjectHealthJson(report) : format === "markdown" ? formatProjectHealthMarkdown(report) : formatProjectHealthText(report);
409
+ if (options.output) {
410
+ writeFileSync(options.output, payload, "utf-8");
411
+ if (!options.ci) {
412
+ console.log(`${GREEN}Wrote Decantr health report:${RESET} ${options.output}`);
413
+ }
414
+ } else {
415
+ process.stdout.write(payload);
416
+ }
417
+ if (options.ci && shouldFailHealth(report, options.failOn ?? "error")) {
418
+ process.exitCode = 1;
419
+ }
420
+ }
421
+ function parseHealthArgs(args) {
422
+ const options = {};
423
+ for (let index = 1; index < args.length; index += 1) {
424
+ const arg = args[index];
425
+ if (arg === "--json") {
426
+ options.json = true;
427
+ } else if (arg === "--markdown") {
428
+ options.markdown = true;
429
+ } else if (arg === "--ci") {
430
+ options.ci = true;
431
+ } else if (arg === "--format" && args[index + 1]) {
432
+ options.format = args[++index];
433
+ } else if (arg.startsWith("--format=")) {
434
+ options.format = arg.split("=")[1];
435
+ } else if (arg === "--output" && args[index + 1]) {
436
+ options.output = args[++index];
437
+ } else if (arg.startsWith("--output=")) {
438
+ options.output = arg.split("=")[1];
439
+ } else if (arg === "--fail-on" && args[index + 1]) {
440
+ options.failOn = args[++index];
441
+ } else if (arg.startsWith("--fail-on=")) {
442
+ options.failOn = arg.split("=")[1];
443
+ } else if (arg === "--prompt" && args[index + 1]) {
444
+ options.promptId = args[++index];
445
+ } else if (arg.startsWith("--prompt=")) {
446
+ options.promptId = arg.split("=")[1];
447
+ }
448
+ }
449
+ if (options.format && !["text", "json", "markdown"].includes(options.format)) {
450
+ throw new Error("Invalid --format value. Use text, json, or markdown.");
451
+ }
452
+ if (options.failOn && !["error", "warn", "none"].includes(options.failOn)) {
453
+ throw new Error("Invalid --fail-on value. Use error, warn, or none.");
454
+ }
455
+ return options;
456
+ }
457
+
458
+ export {
459
+ createProjectHealthReport,
460
+ formatProjectHealthText,
461
+ formatProjectHealthMarkdown,
462
+ formatProjectHealthJson,
463
+ shouldFailHealth,
464
+ cmdHealth,
465
+ parseHealthArgs
466
+ };