@decantr/cli 2.5.0 → 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.
@@ -1,15 +1,17 @@
1
1
  import {
2
2
  collectCheckIssues
3
- } from "./chunk-NBJCO4G5.js";
3
+ } from "./chunk-3TH5PLFO.js";
4
4
  import {
5
5
  sendProjectHealthCiFailedTelemetry,
6
6
  sendProjectHealthPromptTelemetry,
7
7
  sendProjectHealthReportTelemetry
8
- } from "./chunk-IEW2QFYI.js";
8
+ } from "./chunk-KT2ROK2D.js";
9
9
 
10
10
  // src/commands/health.ts
11
- import { createRequire } from "module";
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 ["decantr refresh", "decantr registry get-pack review --write-context", "decantr health"];
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(0, Math.min(100, 100 - counts.errorCount * 15 - counts.warnCount * 5 - counts.infoCount));
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
- await page.screenshot({ path: join(projectRoot, relativePath), fullPage: true });
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
- browserFindings.push(`${route}: ${error.message}`);
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(projectRoot, options, declaredRoutes);
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 payload = options.evidence ? `${JSON.stringify(await createProjectEvidenceBundle(projectRoot, report, reportOptions), null, 2)}
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=")) {