@decantr/cli 2.5.1 → 2.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 +6 -2
- package/dist/bin.js +2 -2
- package/dist/{chunk-NBJCO4G5.js → chunk-3TH5PLFO.js} +1 -1
- package/dist/{chunk-AUQXYJ7T.js → chunk-ICSLIYSX.js} +1 -1
- package/dist/{chunk-IEW2QFYI.js → chunk-KT2ROK2D.js} +553 -486
- package/dist/{chunk-OD46PCR6.js → chunk-PAF4PBD3.js} +219 -9
- package/dist/{chunk-BZWDPAHL.js → chunk-Q5OFRQY6.js} +847 -361
- package/dist/{heal-M6PRCIIF.js → heal-ZYD6NVGE.js} +2 -2
- package/dist/{health-ZXOPGNBZ.js → health-ETZXWGTW.js} +3 -3
- package/dist/index.js +2 -2
- package/dist/{studio-LHQXHBE7.js → studio-MKLBUC3A.js} +4 -4
- package/dist/{workspace-MOLAGT2B.js → workspace-KSFWRZEX.js} +4 -4
- package/package.json +5 -5
- package/src/templates/DECANTR.md.template +5 -5
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
collectCheckIssues
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-3TH5PLFO.js";
|
|
4
4
|
import {
|
|
5
5
|
sendProjectHealthCiFailedTelemetry,
|
|
6
6
|
sendProjectHealthPromptTelemetry,
|
|
7
7
|
sendProjectHealthReportTelemetry
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-KT2ROK2D.js";
|
|
9
9
|
|
|
10
10
|
// src/commands/health.ts
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import { createHash } from "crypto";
|
|
12
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { createRequire } from "module";
|
|
13
15
|
import { dirname, isAbsolute, join, resolve } from "path";
|
|
14
16
|
import { fileURLToPath } from "url";
|
|
15
17
|
import {
|
|
@@ -228,12 +230,32 @@ function sourceFromCheckIssue(issue) {
|
|
|
228
230
|
function slugify(value) {
|
|
229
231
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
230
232
|
}
|
|
233
|
+
function hashFile(path) {
|
|
234
|
+
if (!existsSync(path)) return null;
|
|
235
|
+
try {
|
|
236
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function readJsonFile(path) {
|
|
242
|
+
if (!existsSync(path)) return null;
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
231
249
|
function commandsForFinding(source) {
|
|
232
250
|
switch (source) {
|
|
233
251
|
case "brownfield":
|
|
234
252
|
return ["decantr analyze", "decantr init --existing --merge-proposal", "decantr health"];
|
|
235
253
|
case "pack":
|
|
236
|
-
return [
|
|
254
|
+
return [
|
|
255
|
+
"decantr refresh",
|
|
256
|
+
"decantr registry get-pack review --write-context",
|
|
257
|
+
"decantr health"
|
|
258
|
+
];
|
|
237
259
|
case "runtime":
|
|
238
260
|
return ["npm run build", "decantr health"];
|
|
239
261
|
case "interaction":
|
|
@@ -317,7 +339,10 @@ function statusFromCounts(counts) {
|
|
|
317
339
|
return "healthy";
|
|
318
340
|
}
|
|
319
341
|
function scoreFromCounts(counts) {
|
|
320
|
-
return Math.max(
|
|
342
|
+
return Math.max(
|
|
343
|
+
0,
|
|
344
|
+
Math.min(100, 100 - counts.errorCount * 15 - counts.warnCount * 5 - counts.infoCount)
|
|
345
|
+
);
|
|
321
346
|
}
|
|
322
347
|
function routeIssuesFromFindings(findings) {
|
|
323
348
|
const issues = findings.filter(
|
|
@@ -440,6 +465,7 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
|
|
|
440
465
|
const routes = (declaredRoutes.length > 0 ? declaredRoutes : ["/"]).slice(0, 12);
|
|
441
466
|
const screenshots = [];
|
|
442
467
|
const browserFindings = [];
|
|
468
|
+
const visualRoutes = [];
|
|
443
469
|
const screenshotDir = join(projectRoot, ".decantr", "evidence", "screenshots");
|
|
444
470
|
mkdirSync(screenshotDir, { recursive: true });
|
|
445
471
|
let browser = null;
|
|
@@ -451,10 +477,27 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
|
|
|
451
477
|
const relativePath = browserScreenshotRelativePath(route);
|
|
452
478
|
try {
|
|
453
479
|
await page.goto(url, { waitUntil: "networkidle", timeout: 15e3 });
|
|
454
|
-
|
|
480
|
+
const absoluteScreenshotPath = join(projectRoot, relativePath);
|
|
481
|
+
await page.screenshot({ path: absoluteScreenshotPath, fullPage: true });
|
|
455
482
|
screenshots.push(relativePath);
|
|
483
|
+
visualRoutes.push({
|
|
484
|
+
route,
|
|
485
|
+
url,
|
|
486
|
+
screenshot: relativePath,
|
|
487
|
+
screenshotHash: hashFile(absoluteScreenshotPath),
|
|
488
|
+
status: "captured"
|
|
489
|
+
});
|
|
456
490
|
} catch (error) {
|
|
457
|
-
|
|
491
|
+
const message = error.message;
|
|
492
|
+
browserFindings.push(`${route}: ${message}`);
|
|
493
|
+
visualRoutes.push({
|
|
494
|
+
route,
|
|
495
|
+
url,
|
|
496
|
+
screenshot: null,
|
|
497
|
+
screenshotHash: null,
|
|
498
|
+
status: "failed",
|
|
499
|
+
error: message
|
|
500
|
+
});
|
|
458
501
|
}
|
|
459
502
|
}
|
|
460
503
|
} catch (error) {
|
|
@@ -462,6 +505,16 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
|
|
|
462
505
|
} finally {
|
|
463
506
|
if (browser) await browser.close();
|
|
464
507
|
}
|
|
508
|
+
const visualManifest = {
|
|
509
|
+
version: 1,
|
|
510
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
511
|
+
localOnly: true,
|
|
512
|
+
baseUrl: options.browserBaseUrl,
|
|
513
|
+
routes: visualRoutes
|
|
514
|
+
};
|
|
515
|
+
const visualManifestPath = join(projectRoot, ".decantr", "evidence", "visual-manifest.json");
|
|
516
|
+
mkdirSync(dirname(visualManifestPath), { recursive: true });
|
|
517
|
+
writeFileSync(visualManifestPath, JSON.stringify(visualManifest, null, 2) + "\n", "utf-8");
|
|
465
518
|
if (browserFindings.length > 0) {
|
|
466
519
|
const finding = createHealthFinding({
|
|
467
520
|
source: "browser",
|
|
@@ -575,6 +628,139 @@ function collectDesignTokenFinding(projectRoot, designTokensPath) {
|
|
|
575
628
|
baseId: "coverage-missing"
|
|
576
629
|
});
|
|
577
630
|
}
|
|
631
|
+
function baselinePath(projectRoot) {
|
|
632
|
+
return join(projectRoot, ".decantr", "health-baseline.json");
|
|
633
|
+
}
|
|
634
|
+
function baselineDiffPath(projectRoot) {
|
|
635
|
+
return join(projectRoot, ".decantr", "health-baseline-diff.json");
|
|
636
|
+
}
|
|
637
|
+
function screenshotHashes(projectRoot) {
|
|
638
|
+
const manifest = readJsonFile(
|
|
639
|
+
join(projectRoot, ".decantr", "evidence", "visual-manifest.json")
|
|
640
|
+
);
|
|
641
|
+
if (manifest?.routes) {
|
|
642
|
+
return manifest.routes.filter((route) => typeof route.screenshot === "string").map((route) => ({
|
|
643
|
+
path: route.screenshot,
|
|
644
|
+
hash: route.screenshotHash ?? hashFile(join(projectRoot, route.screenshot))
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
function changedFilesSinceBaseline(projectRoot) {
|
|
650
|
+
const changed = /* @__PURE__ */ new Set();
|
|
651
|
+
try {
|
|
652
|
+
for (const args of [
|
|
653
|
+
["diff", "--name-only"],
|
|
654
|
+
["diff", "--name-only", "--cached"]
|
|
655
|
+
]) {
|
|
656
|
+
const output = execFileSync("git", args, {
|
|
657
|
+
cwd: projectRoot,
|
|
658
|
+
encoding: "utf-8",
|
|
659
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
660
|
+
});
|
|
661
|
+
for (const entry of output.split(/\r?\n/)) {
|
|
662
|
+
const file = entry.trim();
|
|
663
|
+
if (file) changed.add(file);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} catch {
|
|
667
|
+
}
|
|
668
|
+
return [...changed].sort();
|
|
669
|
+
}
|
|
670
|
+
function routeImpactsFromChangedFiles(report, changedFiles) {
|
|
671
|
+
const analysis = readJsonFile(
|
|
672
|
+
join(report.projectRoot, ".decantr", "analysis.json")
|
|
673
|
+
);
|
|
674
|
+
const routeEntries = analysis?.routes?.routes ?? [];
|
|
675
|
+
const impacted = /* @__PURE__ */ new Set();
|
|
676
|
+
for (const file of changedFiles) {
|
|
677
|
+
for (const route of routeEntries) {
|
|
678
|
+
if (route.file && (file === route.file || file.endsWith(route.file))) {
|
|
679
|
+
if (route.path) impacted.add(route.path);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return [...impacted].sort();
|
|
684
|
+
}
|
|
685
|
+
function createHealthBaseline(projectRoot, report) {
|
|
686
|
+
return {
|
|
687
|
+
version: 1,
|
|
688
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
689
|
+
status: report.status,
|
|
690
|
+
score: report.score,
|
|
691
|
+
findings: report.findings.map((finding) => ({
|
|
692
|
+
id: finding.id,
|
|
693
|
+
severity: finding.severity,
|
|
694
|
+
source: finding.source,
|
|
695
|
+
message: finding.message
|
|
696
|
+
})),
|
|
697
|
+
routes: report.routes.declared,
|
|
698
|
+
packs: report.packs,
|
|
699
|
+
screenshots: screenshotHashes(projectRoot),
|
|
700
|
+
changedFilesCommand: "git diff --name-only + --cached"
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function saveHealthBaseline(projectRoot, report) {
|
|
704
|
+
const path = baselinePath(projectRoot);
|
|
705
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
706
|
+
writeFileSync(
|
|
707
|
+
path,
|
|
708
|
+
JSON.stringify(createHealthBaseline(projectRoot, report), null, 2) + "\n",
|
|
709
|
+
"utf-8"
|
|
710
|
+
);
|
|
711
|
+
return path;
|
|
712
|
+
}
|
|
713
|
+
function compareHealthBaseline(projectRoot, report) {
|
|
714
|
+
const path = baselinePath(projectRoot);
|
|
715
|
+
const baseline = readJsonFile(path);
|
|
716
|
+
const currentFindingIds = new Set(report.findings.map((finding) => finding.id));
|
|
717
|
+
const baselineFindingIds = new Set(baseline?.findings.map((finding) => finding.id) ?? []);
|
|
718
|
+
const changedFiles = changedFilesSinceBaseline(projectRoot);
|
|
719
|
+
const currentScreenshots = new Map(
|
|
720
|
+
screenshotHashes(projectRoot).map((entry) => [entry.path, entry.hash])
|
|
721
|
+
);
|
|
722
|
+
const changedScreenshots = baseline?.screenshots.filter(
|
|
723
|
+
(entry) => currentScreenshots.has(entry.path) && currentScreenshots.get(entry.path) !== entry.hash
|
|
724
|
+
).map((entry) => entry.path) ?? [];
|
|
725
|
+
const contractDrift = [
|
|
726
|
+
baseline && baseline.routes.join("\n") !== report.routes.declared.join("\n") ? "Declared route set changed since baseline." : null,
|
|
727
|
+
baseline && baseline.packs.generatedAt !== report.packs.generatedAt ? "Execution-pack generation timestamp changed since baseline." : null
|
|
728
|
+
].filter((entry) => Boolean(entry));
|
|
729
|
+
return {
|
|
730
|
+
baselinePath: path,
|
|
731
|
+
savedAt: baseline?.generatedAt ?? null,
|
|
732
|
+
statusChanged: baseline ? baseline.status !== report.status : false,
|
|
733
|
+
scoreDelta: baseline ? report.score - baseline.score : null,
|
|
734
|
+
addedFindings: [...currentFindingIds].filter((id) => !baselineFindingIds.has(id)).sort(),
|
|
735
|
+
resolvedFindings: [...baselineFindingIds].filter((id) => !currentFindingIds.has(id)).sort(),
|
|
736
|
+
changedFiles,
|
|
737
|
+
changedRoutes: routeImpactsFromChangedFiles(report, changedFiles),
|
|
738
|
+
changedScreenshots,
|
|
739
|
+
contractDrift
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function saveHealthBaselineComparison(projectRoot, comparison) {
|
|
743
|
+
const path = baselineDiffPath(projectRoot);
|
|
744
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
745
|
+
writeFileSync(path, JSON.stringify(comparison, null, 2) + "\n", "utf-8");
|
|
746
|
+
return path;
|
|
747
|
+
}
|
|
748
|
+
function formatBaselineComparisonText(comparison) {
|
|
749
|
+
const lines = [
|
|
750
|
+
"",
|
|
751
|
+
`${BOLD}Continuity:${RESET}`,
|
|
752
|
+
` Baseline: ${comparison.savedAt ?? "missing"} (${comparison.baselinePath})`,
|
|
753
|
+
` Score delta: ${comparison.scoreDelta == null ? "n/a" : comparison.scoreDelta >= 0 ? `+${comparison.scoreDelta}` : String(comparison.scoreDelta)}`,
|
|
754
|
+
` Added findings: ${comparison.addedFindings.length}`,
|
|
755
|
+
` Resolved findings: ${comparison.resolvedFindings.length}`,
|
|
756
|
+
` Changed files: ${comparison.changedFiles.length}`,
|
|
757
|
+
` Route impact: ${comparison.changedRoutes.length > 0 ? comparison.changedRoutes.join(", ") : "none detected"}`,
|
|
758
|
+
` Screenshot drift: ${comparison.changedScreenshots.length}`,
|
|
759
|
+
` Contract drift: ${comparison.contractDrift.length > 0 ? comparison.contractDrift.join(" ") : "none detected"}`
|
|
760
|
+
];
|
|
761
|
+
return `${lines.join("\n")}
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
578
764
|
async function browserEvidenceFromOptions(projectRoot, options, declaredRoutes) {
|
|
579
765
|
if (!options.browser) return void 0;
|
|
580
766
|
const result = await collectBrowserVerification(projectRoot, options, declaredRoutes);
|
|
@@ -663,7 +849,11 @@ async function createProjectHealthReport(projectRoot = process.cwd(), options =
|
|
|
663
849
|
}
|
|
664
850
|
const declaredRoutes = collectDeclaredRoutes(audit.essence);
|
|
665
851
|
const manifest = audit.packManifest;
|
|
666
|
-
const browserVerification = await collectBrowserVerification(
|
|
852
|
+
const browserVerification = await collectBrowserVerification(
|
|
853
|
+
projectRoot,
|
|
854
|
+
options,
|
|
855
|
+
declaredRoutes
|
|
856
|
+
);
|
|
667
857
|
if (browserVerification?.finding && !isDuplicateFinding(seen, browserVerification.finding)) {
|
|
668
858
|
findings.push(browserVerification.finding);
|
|
669
859
|
}
|
|
@@ -850,6 +1040,8 @@ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
|
|
|
850
1040
|
designTokensPath: options.designTokensPath
|
|
851
1041
|
};
|
|
852
1042
|
const report = await createProjectHealthReport(projectRoot, reportOptions);
|
|
1043
|
+
const baselineComparison = options.sinceBaseline ? compareHealthBaseline(projectRoot, report) : null;
|
|
1044
|
+
const baselineComparisonPath = baselineComparison ? saveHealthBaselineComparison(projectRoot, baselineComparison) : null;
|
|
853
1045
|
if (options.promptId) {
|
|
854
1046
|
const finding = report.findings.find((entry) => entry.id === options.promptId);
|
|
855
1047
|
await sendProjectHealthReportTelemetry({
|
|
@@ -874,8 +1066,9 @@ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
|
|
|
874
1066
|
}
|
|
875
1067
|
const format = resolveFormat(options);
|
|
876
1068
|
const failOn = options.failOn ?? "error";
|
|
877
|
-
const
|
|
1069
|
+
const basePayload = options.evidence ? `${JSON.stringify(await createProjectEvidenceBundle(projectRoot, report, reportOptions), null, 2)}
|
|
878
1070
|
` : format === "json" ? formatProjectHealthJson(report) : format === "markdown" ? formatProjectHealthMarkdown(report) : formatProjectHealthText(report);
|
|
1071
|
+
const payload = baselineComparison && !options.evidence && format === "text" ? `${basePayload}${formatBaselineComparisonText(baselineComparison)}` : basePayload;
|
|
879
1072
|
if (options.output) {
|
|
880
1073
|
const outputPath = isAbsolute(options.output) ? options.output : join(projectRoot, options.output);
|
|
881
1074
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
@@ -897,6 +1090,19 @@ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
|
|
|
897
1090
|
projectRoot,
|
|
898
1091
|
report
|
|
899
1092
|
});
|
|
1093
|
+
if (options.saveBaseline) {
|
|
1094
|
+
const path = saveHealthBaseline(projectRoot, report);
|
|
1095
|
+
if (!options.ci && !options.output && format !== "json" && !options.evidence) {
|
|
1096
|
+
console.log(`${GREEN}Saved Decantr health baseline:${RESET} ${path}`);
|
|
1097
|
+
} else if (!options.ci && options.output) {
|
|
1098
|
+
console.log(`${GREEN}Saved Decantr health baseline:${RESET} ${path}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (baselineComparisonPath && !options.ci && options.output) {
|
|
1102
|
+
console.log(
|
|
1103
|
+
`${GREEN}Wrote Decantr health baseline comparison:${RESET} ${baselineComparisonPath}`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
900
1106
|
if (options.ci && shouldFailHealth(report, failOn)) {
|
|
901
1107
|
if (failOn !== "none") {
|
|
902
1108
|
await sendProjectHealthCiFailedTelemetry({
|
|
@@ -968,6 +1174,10 @@ function parseHealthArgs(args) {
|
|
|
968
1174
|
} else if (arg === "--require-browser") {
|
|
969
1175
|
options.browser = true;
|
|
970
1176
|
options.requireBrowser = true;
|
|
1177
|
+
} else if (arg === "--save-baseline") {
|
|
1178
|
+
options.saveBaseline = true;
|
|
1179
|
+
} else if (arg === "--since-baseline") {
|
|
1180
|
+
options.sinceBaseline = true;
|
|
971
1181
|
} else if (arg === "--base-url" && args[index + 1]) {
|
|
972
1182
|
options.browserBaseUrl = args[++index];
|
|
973
1183
|
} else if (arg.startsWith("--base-url=")) {
|