@decantr/cli 3.0.2 → 3.4.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.
@@ -3,14 +3,14 @@ import {
3
3
  sendProjectHealthCiFailedTelemetry,
4
4
  sendProjectHealthPromptTelemetry,
5
5
  sendProjectHealthReportTelemetry
6
- } from "./chunk-7Z74ETDR.js";
6
+ } from "./chunk-XINHWP4T.js";
7
7
 
8
8
  // src/commands/health.ts
9
9
  import { execFileSync as execFileSync2 } from "child_process";
10
10
  import { createHash as createHash2 } from "crypto";
11
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
11
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, realpathSync, writeFileSync as writeFileSync4 } from "fs";
12
12
  import { createRequire } from "module";
13
- import { dirname as dirname3, isAbsolute as isAbsolute3, join as join5, relative as relative3, resolve as resolve2 } from "path";
13
+ import { dirname as dirname3, isAbsolute as isAbsolute4, join as join5, relative as relative3, resolve as resolve3 } from "path";
14
14
  import { fileURLToPath } from "url";
15
15
  import {
16
16
  anchorFindingsToGraph,
@@ -114,8 +114,8 @@ function resolveWorkspaceInfo(cwd, projectArg) {
114
114
 
115
115
  // src/commands/graph.ts
116
116
  import { createHash } from "crypto";
117
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
118
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2 } from "path";
117
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
118
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative as relative2 } from "path";
119
119
  import {
120
120
  buildContractCapsuleFromSnapshot,
121
121
  buildGraphImpactContext,
@@ -138,7 +138,7 @@ import {
138
138
  // src/local-law.ts
139
139
  import { execFileSync } from "child_process";
140
140
  import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync, writeFileSync } from "fs";
141
- import { basename, extname, join as join2, relative, sep } from "path";
141
+ import { basename, extname, isAbsolute as isAbsolute2, join as join2, relative, resolve as resolve2, sep } from "path";
142
142
  import { isV4 } from "@decantr/essence-spec";
143
143
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
144
144
  ".astro",
@@ -827,12 +827,13 @@ function createLocalLawTaskSummary(projectRoot) {
827
827
  function changedFiles(projectRoot, since) {
828
828
  const changed = /* @__PURE__ */ new Set();
829
829
  try {
830
+ const gitRoot = gitTopLevel(projectRoot) ?? projectRoot;
830
831
  const commands = since ? [
831
- ["diff", "--name-only", since, "--"],
832
- ["diff", "--name-only", "--cached"]
832
+ ["diff", "--name-only", "--relative", since, "--"],
833
+ ["diff", "--name-only", "--relative", "--cached"]
833
834
  ] : [
834
- ["diff", "--name-only"],
835
- ["diff", "--name-only", "--cached"]
835
+ ["diff", "--name-only", "--relative"],
836
+ ["diff", "--name-only", "--relative", "--cached"]
836
837
  ];
837
838
  for (const args of commands) {
838
839
  const output = execFileSync("git", args, {
@@ -842,13 +843,38 @@ function changedFiles(projectRoot, since) {
842
843
  });
843
844
  for (const line of output.split(/\r?\n/)) {
844
845
  const file = line.trim();
845
- if (file) changed.add(normalizePath(file));
846
+ const projectFile = changedFileForProject(projectRoot, gitRoot, file);
847
+ if (projectFile) changed.add(projectFile);
846
848
  }
847
849
  }
848
850
  } catch {
849
851
  }
850
852
  return [...changed].sort();
851
853
  }
854
+ function gitTopLevel(projectRoot) {
855
+ try {
856
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
857
+ cwd: projectRoot,
858
+ encoding: "utf-8",
859
+ stdio: ["ignore", "pipe", "ignore"]
860
+ }).trim();
861
+ } catch {
862
+ return null;
863
+ }
864
+ }
865
+ function changedFileForProject(projectRoot, gitRoot, file) {
866
+ if (!file) return null;
867
+ const absoluteProjectRoot = resolve2(projectRoot);
868
+ const candidateAbsoluteFiles = isAbsolute2(file) ? [file] : [join2(absoluteProjectRoot, file), join2(gitRoot, file)];
869
+ for (const absoluteFile of candidateAbsoluteFiles) {
870
+ const projectRelative = normalizePath(relative(absoluteProjectRoot, absoluteFile));
871
+ if (!projectRelative || projectRelative.startsWith("../") || projectRelative === "..") {
872
+ continue;
873
+ }
874
+ return projectRelative;
875
+ }
876
+ return null;
877
+ }
852
878
  function routeImpacts(projectRoot, files) {
853
879
  const analysis = readJsonFile(
854
880
  join2(projectRoot, ".decantr", "analysis.json")
@@ -1235,6 +1261,19 @@ function colorTokenNames(styling) {
1235
1261
  }
1236
1262
  return [...names].slice(0, 40);
1237
1263
  }
1264
+ function firstCssVariable(styling, terms) {
1265
+ return styling.cssVariables.find((name) => terms.test(name)) ?? null;
1266
+ }
1267
+ function firstClassHint(projectRoot, ids) {
1268
+ return classHintsForPattern(projectRoot, ids)[0] ?? null;
1269
+ }
1270
+ function nativeRefForMapping(input) {
1271
+ const cssVariable = firstCssVariable(input.styling, input.tokenTerms);
1272
+ if (cssVariable) return { kind: "css-var", ref: cssVariable };
1273
+ const classHint = firstClassHint(input.projectRoot, input.patternIds);
1274
+ if (classHint) return { kind: "class", ref: classHint };
1275
+ return { kind: "class", ref: input.fallbackClass };
1276
+ }
1238
1277
  function createStyleBridgeProposal(input) {
1239
1278
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1240
1279
  const theme = readThemeInventory(input.projectRoot);
@@ -1246,7 +1285,7 @@ function createStyleBridgeProposal(input) {
1246
1285
  const themeModes = theme.modes.length > 0 ? theme.modes : darkModeDetected ? ["base", "dark"] : ["base"];
1247
1286
  const themeVariantIds = theme.variantIds.length > 0 ? theme.variantIds : themeModes.filter((mode) => mode !== "base");
1248
1287
  return {
1249
- version: 1,
1288
+ version: 2,
1250
1289
  status: "proposal",
1251
1290
  generatedAt,
1252
1291
  source: "decantr codify --style-bridge",
@@ -1286,6 +1325,17 @@ function createStyleBridgeProposal(input) {
1286
1325
  label: "Surfaces and cards",
1287
1326
  decantrIntent: "surface background, card treatment, border, radius, depth, and hover state",
1288
1327
  projectAuthority: "Use the app card/surface primitives, tokens, and accepted local law.",
1328
+ native: nativeRefForMapping({
1329
+ projectRoot: input.projectRoot,
1330
+ styling: input.styling,
1331
+ patternIds: ["surface-card"],
1332
+ tokenTerms: /surface|card|panel|bg|background|border|shadow|radius/i,
1333
+ fallbackClass: "surface-card"
1334
+ }),
1335
+ essence: { kind: "treatment", ref: "surface" },
1336
+ confidence: 0.7,
1337
+ source: "inferred",
1338
+ property: "surface",
1289
1339
  tokenHints: tokenHints(
1290
1340
  input.styling,
1291
1341
  /surface|card|panel|bg|background|border|shadow|radius/i
@@ -1302,6 +1352,17 @@ function createStyleBridgeProposal(input) {
1302
1352
  label: "Actions and buttons",
1303
1353
  decantrIntent: "primary, secondary, tertiary, destructive, icon-only, loading, and disabled action states",
1304
1354
  projectAuthority: "Use the app button/action primitives and local variant names.",
1355
+ native: nativeRefForMapping({
1356
+ projectRoot: input.projectRoot,
1357
+ styling: input.styling,
1358
+ patternIds: ["button"],
1359
+ tokenTerms: /primary|secondary|accent|danger|error|destructive|focus/i,
1360
+ fallbackClass: "button"
1361
+ }),
1362
+ essence: { kind: "treatment", ref: "action" },
1363
+ confidence: 0.72,
1364
+ source: "inferred",
1365
+ property: "color",
1305
1366
  tokenHints: tokenHints(
1306
1367
  input.styling,
1307
1368
  /primary|secondary|accent|danger|error|destructive|focus/i
@@ -1318,6 +1379,17 @@ function createStyleBridgeProposal(input) {
1318
1379
  label: "Focus and accessibility",
1319
1380
  decantrIntent: "visible focus, keyboard clarity, contrast, and reduced-motion safety",
1320
1381
  projectAuthority: "Use the app accessibility classes, focus tokens, and framework conventions.",
1382
+ native: nativeRefForMapping({
1383
+ projectRoot: input.projectRoot,
1384
+ styling: input.styling,
1385
+ patternIds: [],
1386
+ tokenTerms: /focus|ring|outline|contrast|motion|duration/i,
1387
+ fallbackClass: "focus-visible"
1388
+ }),
1389
+ essence: { kind: "behavior", ref: "focus-accessibility" },
1390
+ confidence: 0.66,
1391
+ source: "inferred",
1392
+ property: "focus",
1321
1393
  tokenHints: tokenHints(input.styling, /focus|ring|outline|contrast|motion|duration/i),
1322
1394
  classHints: [],
1323
1395
  sourceEvidence: [],
@@ -1331,6 +1403,17 @@ function createStyleBridgeProposal(input) {
1331
1403
  label: "Layout density and spacing",
1332
1404
  decantrIntent: "route gutters, section gaps, component padding, density, and responsive rhythm",
1333
1405
  projectAuthority: "Use existing layout wrappers, shell primitives, and spacing tokens/classes.",
1406
+ native: nativeRefForMapping({
1407
+ projectRoot: input.projectRoot,
1408
+ styling: input.styling,
1409
+ patternIds: ["page-shell"],
1410
+ tokenTerms: /space|spacing|gap|gutter|container|radius/i,
1411
+ fallbackClass: "page-shell"
1412
+ }),
1413
+ essence: { kind: "layout", ref: "density-spacing" },
1414
+ confidence: 0.68,
1415
+ source: "inferred",
1416
+ property: "spacing",
1334
1417
  tokenHints: tokenHints(input.styling, /space|spacing|gap|gutter|container|radius/i),
1335
1418
  classHints: classHintsForPattern(input.projectRoot, ["page-shell"]),
1336
1419
  sourceEvidence: sourceEvidence(input.projectRoot, ["page-shell"]),
@@ -1344,6 +1427,17 @@ function createStyleBridgeProposal(input) {
1344
1427
  label: "Theme variants",
1345
1428
  decantrIntent: "light, dark, brand, tenant, seasonal, and density variants",
1346
1429
  projectAuthority: "Use the app theme provider, data attributes, CSS variables, or Tailwind mode strategy.",
1430
+ native: nativeRefForMapping({
1431
+ projectRoot: input.projectRoot,
1432
+ styling: input.styling,
1433
+ patternIds: ["theme-variant"],
1434
+ tokenTerms: /theme|dark|light|brand|tenant|mode|color/i,
1435
+ fallbackClass: "theme-variant"
1436
+ }),
1437
+ essence: { kind: "token", ref: "theme-variant" },
1438
+ confidence: 0.64,
1439
+ source: "inferred",
1440
+ property: "theme",
1347
1441
  tokenHints: tokenHints(input.styling, /theme|dark|light|brand|tenant|mode|color/i),
1348
1442
  classHints: classHintsForPattern(input.projectRoot, ["theme-variant"]),
1349
1443
  sourceEvidence: sourceEvidence(input.projectRoot, ["theme-variant"]),
@@ -1427,6 +1521,11 @@ function styleBridgeMatches(projectRoot, query) {
1427
1521
  mapping.label,
1428
1522
  mapping.decantrIntent,
1429
1523
  mapping.projectAuthority,
1524
+ mapping.native?.ref,
1525
+ mapping.native?.kind,
1526
+ mapping.essence?.ref,
1527
+ mapping.essence?.kind,
1528
+ mapping.property,
1430
1529
  ...mapping.tokenHints,
1431
1530
  ...mapping.classHints,
1432
1531
  ...mapping.guardrails
@@ -1647,17 +1746,24 @@ function readGraphSnapshotSelection(artifacts, snapshotId) {
1647
1746
  }
1648
1747
  function projectRelativePath(projectRoot, path) {
1649
1748
  if (!path) return null;
1650
- const absolutePath = isAbsolute2(path) ? path : join4(projectRoot, path);
1749
+ const absolutePath = isAbsolute3(path) ? path : join4(projectRoot, path);
1651
1750
  const relativePath = relative2(projectRoot, absolutePath).replace(/\\/g, "/");
1652
- if (!relativePath || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1751
+ if (!relativePath || relativePath.startsWith("..") || isAbsolute3(relativePath)) {
1653
1752
  return null;
1654
1753
  }
1655
1754
  return relativePath;
1656
1755
  }
1756
+ function pathIsFile(path) {
1757
+ try {
1758
+ return statSync2(path).isFile();
1759
+ } catch {
1760
+ return false;
1761
+ }
1762
+ }
1657
1763
  function existingProjectRelativePath(projectRoot, path) {
1658
1764
  const relativePath = projectRelativePath(projectRoot, path);
1659
1765
  if (!relativePath) return null;
1660
- return existsSync4(join4(projectRoot, relativePath)) ? relativePath : null;
1766
+ return pathIsFile(join4(projectRoot, relativePath)) ? relativePath : null;
1661
1767
  }
1662
1768
  function stripJsonComments(value) {
1663
1769
  return value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
@@ -1708,7 +1814,7 @@ function existingImportCandidate(projectRoot, candidate) {
1708
1814
  join4(relativeCandidate, "index.cts").replace(/\\/g, "/")
1709
1815
  ];
1710
1816
  for (const possible of candidates) {
1711
- if (existsSync4(join4(projectRoot, possible))) return possible;
1817
+ if (pathIsFile(join4(projectRoot, possible))) return possible;
1712
1818
  }
1713
1819
  return null;
1714
1820
  }
@@ -1841,7 +1947,7 @@ function sourceArtifacts(projectRoot, componentReuseAudit = null) {
1841
1947
  const routeFile = projectRelativePath(projectRoot, route.file);
1842
1948
  if (!routeFile) continue;
1843
1949
  const routeFilePath = join4(projectRoot, routeFile);
1844
- if (!existsSync4(routeFilePath)) continue;
1950
+ if (!pathIsFile(routeFilePath)) continue;
1845
1951
  sources.push({
1846
1952
  id: `src:${routeFile}`,
1847
1953
  kind: "route-source",
@@ -1860,7 +1966,7 @@ function sourceArtifacts(projectRoot, componentReuseAudit = null) {
1860
1966
  const componentFile = projectRelativePath(projectRoot, declaration.file);
1861
1967
  if (!componentFile) continue;
1862
1968
  const componentPath = join4(projectRoot, componentFile);
1863
- if (!existsSync4(componentPath)) continue;
1969
+ if (!pathIsFile(componentPath)) continue;
1864
1970
  if (sources.some((source) => source.id === `src:${componentFile}`)) continue;
1865
1971
  sources.push({
1866
1972
  id: `src:${componentFile}`,
@@ -1879,7 +1985,7 @@ function sourceArtifacts(projectRoot, componentReuseAudit = null) {
1879
1985
  const importingFile = projectRelativePath(projectRoot, importReference.file);
1880
1986
  if (!importingFile) continue;
1881
1987
  const importingPath = join4(projectRoot, importingFile);
1882
- if (existsSync4(importingPath) && !sources.some((source) => source.id === `src:${importingFile}`)) {
1988
+ if (pathIsFile(importingPath) && !sources.some((source) => source.id === `src:${importingFile}`)) {
1883
1989
  sources.push({
1884
1990
  id: `src:${importingFile}`,
1885
1991
  kind: "code-source",
@@ -1899,7 +2005,7 @@ function sourceArtifacts(projectRoot, componentReuseAudit = null) {
1899
2005
  );
1900
2006
  if (!importedFile) continue;
1901
2007
  const importedPath = join4(projectRoot, importedFile);
1902
- if (!existsSync4(importedPath)) continue;
2008
+ if (!pathIsFile(importedPath)) continue;
1903
2009
  if (sources.some((source) => source.id === `src:${importedFile}`)) continue;
1904
2010
  sources.push({
1905
2011
  id: `src:${importedFile}`,
@@ -1928,7 +2034,7 @@ function sourceArtifacts(projectRoot, componentReuseAudit = null) {
1928
2034
  for (const route of visualManifest.routes ?? []) {
1929
2035
  if (!route.screenshot) continue;
1930
2036
  const screenshotPath = join4(projectRoot, route.screenshot);
1931
- if (!existsSync4(screenshotPath)) continue;
2037
+ if (!pathIsFile(screenshotPath)) continue;
1932
2038
  sources.push({
1933
2039
  id: `src:${route.screenshot}`,
1934
2040
  kind: "visual-screenshot",
@@ -2596,7 +2702,7 @@ function augmentProjectGraph(snapshot, projectRoot, sources, componentReuseAudit
2596
2702
  function pathForDisplay(projectRoot, path, displayRoot) {
2597
2703
  if (!displayRoot) return path.replace(`${projectRoot}/`, "");
2598
2704
  const relativePath = relative2(displayRoot, path).replace(/\\/g, "/");
2599
- if (relativePath && !relativePath.startsWith("..") && !isAbsolute2(relativePath)) {
2705
+ if (relativePath && !relativePath.startsWith("..") && !isAbsolute3(relativePath)) {
2600
2706
  return relativePath;
2601
2707
  }
2602
2708
  return path;
@@ -2836,7 +2942,7 @@ function graphSourceNodeIdForFile(projectRoot, snapshot, file) {
2836
2942
  })?.id ?? null;
2837
2943
  }
2838
2944
  function resolvePathFromCwd(path) {
2839
- return isAbsolute2(path) ? path : join4(process.cwd(), path);
2945
+ return isAbsolute3(path) ? path : join4(process.cwd(), path);
2840
2946
  }
2841
2947
  function impactContextPayload(impactContext, selection) {
2842
2948
  if (!selection) return void 0;
@@ -3110,6 +3216,20 @@ var DEFAULT_HEALTH_CI_REPORT_PATH = "decantr-health.md";
3110
3216
  var DEFAULT_HEALTH_CI_JSON_PATH = "decantr-health.json";
3111
3217
  var DEFAULT_HEALTH_CI_CLI_VERSION = "latest";
3112
3218
  var __dirname = dirname3(fileURLToPath(import.meta.url));
3219
+ var HEALTH_BROWSER_RUNTIME_DIAGNOSTICS = [
3220
+ {
3221
+ rule: "browser-runtime-probes-failed",
3222
+ code: "RUNTIME010",
3223
+ repairId: "repair-browser-runtime-probes",
3224
+ family: "RUNTIME"
3225
+ },
3226
+ {
3227
+ rule: "browser-axe-violations",
3228
+ code: "A11Y020",
3229
+ repairId: "fix-rendered-accessibility",
3230
+ family: "A11Y"
3231
+ }
3232
+ ];
3113
3233
  function readProjectMetadata(projectRoot) {
3114
3234
  const projectJsonPath = join5(projectRoot, ".decantr", "project.json");
3115
3235
  if (!existsSync5(projectJsonPath)) {
@@ -3467,7 +3587,7 @@ function commandsForFinding(source) {
3467
3587
  case "runtime":
3468
3588
  return ["npm run build", "decantr health"];
3469
3589
  case "interaction":
3470
- return ["decantr check --strict", "decantr health"];
3590
+ return ["decantr verify --brownfield --local-patterns", "decantr verify --evidence"];
3471
3591
  case "assertion":
3472
3592
  return ["decantr refresh", "decantr health --evidence"];
3473
3593
  case "browser":
@@ -3477,7 +3597,7 @@ function commandsForFinding(source) {
3477
3597
  case "style-bridge":
3478
3598
  return ["decantr codify --style-bridge", "decantr verify --evidence"];
3479
3599
  case "graph":
3480
- return ["decantr graph", "decantr health"];
3600
+ return ["decantr graph", "decantr verify --evidence"];
3481
3601
  case "check":
3482
3602
  return ["decantr check", "decantr health"];
3483
3603
  default:
@@ -3531,7 +3651,7 @@ function commandContextForProject(projectRoot) {
3531
3651
  /\\/g,
3532
3652
  "/"
3533
3653
  );
3534
- const projectPath = relativeProjectPath && !relativeProjectPath.startsWith("..") && !isAbsolute3(relativeProjectPath) ? relativeProjectPath : null;
3654
+ const projectPath = relativeProjectPath && !relativeProjectPath.startsWith("..") && !isAbsolute4(relativeProjectPath) ? relativeProjectPath : null;
3535
3655
  const projectFlag = projectPath ? ` --project ${projectPath}` : "";
3536
3656
  const essencePath = projectPath ? `${projectPath}/decantr.essence.json` : "decantr.essence.json";
3537
3657
  return {
@@ -3566,6 +3686,10 @@ function rewriteHealthCommand(command, context) {
3566
3686
  /^decantr graph\b/,
3567
3687
  `decantr graph --project ${context.projectPath}`
3568
3688
  );
3689
+ rewritten = rewritten.replace(
3690
+ /^decantr verify\b/,
3691
+ `decantr verify --project ${context.projectPath}`
3692
+ );
3569
3693
  rewritten = rewritten.replace(/^decantr audit\b/, context.verifyCommand);
3570
3694
  rewritten = rewritten.replace(/^decantr health\b/, context.verifyCommand);
3571
3695
  return rewritten;
@@ -3796,7 +3920,7 @@ function isDuplicateFinding(existing, finding) {
3796
3920
  }
3797
3921
  function resolveOptionalPath(projectRoot, path) {
3798
3922
  if (!path) return void 0;
3799
- return isAbsolute3(path) ? path : resolve2(projectRoot, path);
3923
+ return isAbsolute4(path) ? path : resolve3(projectRoot, path);
3800
3924
  }
3801
3925
  function hasProjectPlaywright(projectRoot) {
3802
3926
  try {
@@ -3824,6 +3948,304 @@ function loadProjectPlaywright(projectRoot) {
3824
3948
  }
3825
3949
  return null;
3826
3950
  }
3951
+ var KNOWN_INTERACTION_STYLE_CLASSES = [
3952
+ "d-enter-fade",
3953
+ "d-enter-slide-up",
3954
+ "d-enter-scale",
3955
+ "d-stagger-children",
3956
+ "d-pulse",
3957
+ "d-pulse-ring",
3958
+ "d-shimmer",
3959
+ "d-float",
3960
+ "d-glow-hover",
3961
+ "d-lift-hover",
3962
+ "d-scale-hover",
3963
+ "d-ripple"
3964
+ ];
3965
+ function compactBrowserEvidence(value) {
3966
+ return value.replace(/\s+/g, " ").trim().slice(0, 240);
3967
+ }
3968
+ function browserErrorMessage(error) {
3969
+ return compactBrowserEvidence(error instanceof Error ? error.message : String(error));
3970
+ }
3971
+ function isPathInside(parentPath, childPath) {
3972
+ const parentRealPath = realpathSync(parentPath);
3973
+ const childRealPath = realpathSync(childPath);
3974
+ const relativePath = relative3(parentRealPath, childRealPath);
3975
+ return relativePath === "" || !relativePath.startsWith("..") && !isAbsolute4(relativePath);
3976
+ }
3977
+ function loadProjectAxeCore(projectRoot) {
3978
+ try {
3979
+ const requireFromProject = createRequire(join5(projectRoot, "package.json"));
3980
+ const resolved = requireFromProject.resolve("axe-core");
3981
+ const workspaceRoot = resolveWorkspaceInfo(projectRoot).workspaceRoot;
3982
+ const resolvedInProject = [projectRoot, workspaceRoot].some(
3983
+ (root) => isPathInside(root, resolved)
3984
+ );
3985
+ if (!resolvedInProject) return null;
3986
+ const loaded = requireFromProject("axe-core");
3987
+ return typeof loaded.source === "string" && loaded.source.length > 0 ? { source: loaded.source } : null;
3988
+ } catch {
3989
+ return null;
3990
+ }
3991
+ }
3992
+ function consoleErrorMessage(message) {
3993
+ try {
3994
+ const type = typeof message.type === "function" ? message.type() : "error";
3995
+ if (type !== "error") return null;
3996
+ const text = typeof message.text === "function" ? message.text() : "Console error";
3997
+ return compactBrowserEvidence(text || "Console error");
3998
+ } catch {
3999
+ return "Console error event could not be read.";
4000
+ }
4001
+ }
4002
+ function consoleProbe(messages) {
4003
+ return {
4004
+ status: messages.length > 0 ? "failed" : "passed",
4005
+ count: messages.length,
4006
+ messages: messages.slice(0, 5)
4007
+ };
4008
+ }
4009
+ function fallbackRuntimeDomSnapshot(reason) {
4010
+ return {
4011
+ routeRendered: {
4012
+ status: "failed",
4013
+ readyState: null,
4014
+ url: null,
4015
+ title: null,
4016
+ bodyPresent: false,
4017
+ appRootPresent: false,
4018
+ bodyChildCount: 0,
4019
+ reason
4020
+ },
4021
+ nonblankDom: {
4022
+ status: "failed",
4023
+ textLength: 0,
4024
+ meaningfulElementCount: 0,
4025
+ mediaElementCount: 0,
4026
+ controlElementCount: 0,
4027
+ reason
4028
+ },
4029
+ interactionStyles: {
4030
+ status: "skipped",
4031
+ checked: 0,
4032
+ matchedClasses: [],
4033
+ animatedOrTransitioned: 0,
4034
+ missing: [],
4035
+ reason
4036
+ }
4037
+ };
4038
+ }
4039
+ async function collectRuntimeDomSnapshot(page) {
4040
+ try {
4041
+ return await page.evaluate((knownInteractionClasses) => {
4042
+ const global = globalThis;
4043
+ const document = global.document;
4044
+ const body = document?.body;
4045
+ const readyState = typeof document?.readyState === "string" ? document.readyState : null;
4046
+ const title = typeof document?.title === "string" ? document.title : null;
4047
+ const url = typeof global.location?.href === "string" ? global.location.href : null;
4048
+ const querySelector = typeof document?.querySelector === "function" ? document.querySelector : null;
4049
+ const querySelectorAll = typeof document?.querySelectorAll === "function" ? document.querySelectorAll : null;
4050
+ const bodyChildCount = typeof body?.childElementCount === "number" ? body.childElementCount : 0;
4051
+ const appRootPresent = Boolean(
4052
+ querySelector?.('[data-decantr-root], #root, #app, main, [role="main"], body > *')
4053
+ );
4054
+ const routeRendered = Boolean(body) && readyState !== "loading" && (appRootPresent || bodyChildCount > 0);
4055
+ const rawElements = querySelectorAll ? querySelectorAll("body *") : [];
4056
+ const elements = Array.from(rawElements);
4057
+ const text = String(body?.innerText ?? body?.textContent ?? "").replace(/\s+/g, " ").trim();
4058
+ const mediaTags = /* @__PURE__ */ new Set(["IMG", "SVG", "CANVAS", "VIDEO", "PICTURE"]);
4059
+ const controlTags = /* @__PURE__ */ new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY"]);
4060
+ const elementTag = (element) => typeof element.tagName === "string" ? element.tagName.toUpperCase() : "";
4061
+ const elementText = (element) => String(element.textContent ?? "").replace(/\s+/g, " ").trim();
4062
+ const elementAttribute = (element, name) => typeof element.getAttribute === "function" ? String(element.getAttribute(name) ?? "").trim() : "";
4063
+ const mediaElementCount = elements.filter(
4064
+ (element) => mediaTags.has(elementTag(element))
4065
+ ).length;
4066
+ const controlElementCount = elements.filter(
4067
+ (element) => controlTags.has(elementTag(element))
4068
+ ).length;
4069
+ const meaningfulElementCount = elements.filter((element) => {
4070
+ const tag = elementTag(element);
4071
+ return elementText(element).length > 0 || mediaTags.has(tag) || controlTags.has(tag) || elementAttribute(element, "aria-label").length > 0 || elementAttribute(element, "alt").length > 0 || elementAttribute(element, "title").length > 0;
4072
+ }).length;
4073
+ const nonblank = text.length > 0 || meaningfulElementCount > 0 || mediaElementCount > 0 || controlElementCount > 0;
4074
+ const durationHasTime = (value) => String(value ?? "").split(",").some((part) => {
4075
+ const trimmed = part.trim();
4076
+ if (!trimmed) return false;
4077
+ if (trimmed.endsWith("ms")) return Number.parseFloat(trimmed) > 0;
4078
+ if (trimmed.endsWith("s")) return Number.parseFloat(trimmed) > 0;
4079
+ return Number.parseFloat(trimmed) > 0;
4080
+ });
4081
+ const classListContains = (element, className) => {
4082
+ const classList = element.classList;
4083
+ if (typeof classList?.contains === "function") return classList.contains(className);
4084
+ return String(element.className ?? "").split(/\s+/).includes(className);
4085
+ };
4086
+ const styleTargets = elements.map((element) => ({
4087
+ element,
4088
+ classes: knownInteractionClasses.filter(
4089
+ (className) => classListContains(element, className)
4090
+ )
4091
+ })).filter((entry) => entry.classes.length > 0);
4092
+ const matchedClasses = [...new Set(styleTargets.flatMap((entry) => entry.classes))].sort();
4093
+ const missing = /* @__PURE__ */ new Set();
4094
+ let animatedOrTransitioned = 0;
4095
+ for (const entry of styleTargets) {
4096
+ const computed = typeof global.getComputedStyle === "function" ? global.getComputedStyle(entry.element) : {};
4097
+ const animationName = String(computed.animationName ?? "none");
4098
+ const transitionProperty = String(computed.transitionProperty ?? "none");
4099
+ const hasAnimation = animationName !== "none" && durationHasTime(computed.animationDuration);
4100
+ const hasTransition = transitionProperty !== "none" && durationHasTime(computed.transitionDuration);
4101
+ if (hasAnimation || hasTransition) {
4102
+ animatedOrTransitioned += 1;
4103
+ } else {
4104
+ for (const className of entry.classes) missing.add(className);
4105
+ }
4106
+ }
4107
+ const interactionStatus = styleTargets.length === 0 ? "skipped" : missing.size === 0 ? "passed" : "failed";
4108
+ return {
4109
+ routeRendered: {
4110
+ status: routeRendered ? "passed" : "failed",
4111
+ readyState,
4112
+ url,
4113
+ title,
4114
+ bodyPresent: Boolean(body),
4115
+ appRootPresent,
4116
+ bodyChildCount,
4117
+ ...routeRendered ? {} : { reason: "No rendered app root or body content was detected after navigation." }
4118
+ },
4119
+ nonblankDom: {
4120
+ status: nonblank ? "passed" : "failed",
4121
+ textLength: text.length,
4122
+ meaningfulElementCount,
4123
+ mediaElementCount,
4124
+ controlElementCount,
4125
+ ...nonblank ? {} : {
4126
+ reason: "DOM rendered, but no meaningful text, media, controls, or labels were detected."
4127
+ }
4128
+ },
4129
+ interactionStyles: {
4130
+ status: interactionStatus,
4131
+ checked: styleTargets.length,
4132
+ matchedClasses,
4133
+ animatedOrTransitioned,
4134
+ missing: [...missing].slice(0, 8),
4135
+ ...styleTargets.length === 0 ? { reason: "No known Decantr interaction classes were present on this route." } : {}
4136
+ }
4137
+ };
4138
+ }, KNOWN_INTERACTION_STYLE_CLASSES);
4139
+ } catch (error) {
4140
+ return fallbackRuntimeDomSnapshot(`Runtime DOM probe failed: ${browserErrorMessage(error)}`);
4141
+ }
4142
+ }
4143
+ function skippedAccessibilityProbe(reason) {
4144
+ return {
4145
+ status: "skipped",
4146
+ engine: "axe-core",
4147
+ violations: 0,
4148
+ incomplete: 0,
4149
+ messages: [],
4150
+ reason
4151
+ };
4152
+ }
4153
+ async function collectAccessibilityProbe(page, axeCore) {
4154
+ if (!axeCore) return skippedAccessibilityProbe("axe-core is not installed in this project.");
4155
+ if (!page.addScriptTag) {
4156
+ return skippedAccessibilityProbe("The local Playwright adapter does not expose addScriptTag.");
4157
+ }
4158
+ try {
4159
+ await page.addScriptTag({ content: axeCore.source });
4160
+ const result = await page.evaluate(() => {
4161
+ const global = globalThis;
4162
+ if (!global.axe?.run) return { error: "axe-core did not expose window.axe.run." };
4163
+ return global.axe.run(global.document, { resultTypes: ["violations"] }).then((axeResult) => ({
4164
+ violations: (axeResult.violations ?? []).map((violation) => ({
4165
+ id: violation.id,
4166
+ impact: violation.impact,
4167
+ help: violation.help,
4168
+ description: violation.description,
4169
+ targets: (violation.nodes ?? []).flatMap((node) => node.target ?? []).slice(0, 3)
4170
+ })),
4171
+ incomplete: axeResult.incomplete?.length ?? 0
4172
+ }));
4173
+ });
4174
+ if (result.error) {
4175
+ return {
4176
+ status: "failed",
4177
+ engine: "axe-core",
4178
+ violations: 0,
4179
+ incomplete: 0,
4180
+ messages: [result.error],
4181
+ reason: result.error
4182
+ };
4183
+ }
4184
+ const violations = result.violations ?? [];
4185
+ const messages = violations.slice(0, 5).map((violation) => {
4186
+ const label = violation.id ?? "axe-violation";
4187
+ const detail = violation.help ?? violation.description ?? "Accessibility violation";
4188
+ const impact = violation.impact ? ` (${violation.impact})` : "";
4189
+ const targets = violation.targets && violation.targets.length > 0 ? ` [${violation.targets.join(", ")}]` : "";
4190
+ return compactBrowserEvidence(`${label}${impact}: ${detail}${targets}`);
4191
+ });
4192
+ return {
4193
+ status: violations.length > 0 ? "failed" : "passed",
4194
+ engine: "axe-core",
4195
+ violations: violations.length,
4196
+ incomplete: result.incomplete ?? 0,
4197
+ messages
4198
+ };
4199
+ } catch (error) {
4200
+ const message = `Axe probe failed: ${browserErrorMessage(error)}`;
4201
+ return {
4202
+ status: "failed",
4203
+ engine: "axe-core",
4204
+ violations: 0,
4205
+ incomplete: 0,
4206
+ messages: [message],
4207
+ reason: message
4208
+ };
4209
+ }
4210
+ }
4211
+ function browserRuntimeFailureMessages(route, runtime) {
4212
+ const messages = [];
4213
+ if (runtime.routeRendered.status === "failed") {
4214
+ messages.push(
4215
+ `${route}: route-rendered probe failed (${runtime.routeRendered.reason ?? "no rendered app root detected"})`
4216
+ );
4217
+ }
4218
+ if (runtime.nonblankDom.status === "failed") {
4219
+ messages.push(
4220
+ `${route}: nonblank DOM probe failed (${runtime.nonblankDom.reason ?? "blank DOM detected"})`
4221
+ );
4222
+ }
4223
+ if (runtime.consoleErrors.count > 0) {
4224
+ messages.push(
4225
+ `${route}: console errors (${runtime.consoleErrors.count}) ${runtime.consoleErrors.messages.join(" | ")}`
4226
+ );
4227
+ }
4228
+ if (runtime.pageErrors.count > 0) {
4229
+ messages.push(
4230
+ `${route}: page errors (${runtime.pageErrors.count}) ${runtime.pageErrors.messages.join(" | ")}`
4231
+ );
4232
+ }
4233
+ if (runtime.interactionStyles.status === "failed") {
4234
+ messages.push(
4235
+ `${route}: interaction style probe found known classes without computed animation/transition (${runtime.interactionStyles.missing.join(", ")})`
4236
+ );
4237
+ }
4238
+ return messages.map(compactBrowserEvidence);
4239
+ }
4240
+ function browserAccessibilityFailureMessages(route, runtime) {
4241
+ if (runtime.accessibility.status !== "failed") return [];
4242
+ const detail = runtime.accessibility.messages.length > 0 ? runtime.accessibility.messages.join(" | ") : runtime.accessibility.reason ?? "axe-core reported accessibility failures";
4243
+ return [
4244
+ compactBrowserEvidence(
4245
+ `${route}: axe-core violations (${runtime.accessibility.violations}) ${detail}`
4246
+ )
4247
+ ];
4248
+ }
3827
4249
  function browserRouteUrl(baseUrl, route) {
3828
4250
  return new URL(route || "/", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
3829
4251
  }
@@ -3845,7 +4267,7 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3845
4267
  baseId: "playwright-missing"
3846
4268
  });
3847
4269
  return {
3848
- finding,
4270
+ findings: [finding],
3849
4271
  evidence: {
3850
4272
  enabled: true,
3851
4273
  status: "unavailable",
@@ -3867,7 +4289,7 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3867
4289
  baseId: "base-url-missing"
3868
4290
  });
3869
4291
  return {
3870
- finding,
4292
+ findings: [finding],
3871
4293
  evidence: {
3872
4294
  enabled: true,
3873
4295
  status: "unavailable",
@@ -3890,7 +4312,7 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3890
4312
  baseId: "adapter-missing"
3891
4313
  });
3892
4314
  return {
3893
- finding,
4315
+ findings: [finding],
3894
4316
  evidence: {
3895
4317
  enabled: true,
3896
4318
  status: "unavailable",
@@ -3902,19 +4324,41 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3902
4324
  }
3903
4325
  const routes = (declaredRoutes.length > 0 ? declaredRoutes : ["/"]).slice(0, 12);
3904
4326
  const screenshots = [];
3905
- const browserFindings = [];
4327
+ const routeFailures = [];
4328
+ const runtimeFailures = [];
4329
+ const accessibilityFailures = [];
3906
4330
  const visualRoutes = [];
3907
4331
  const screenshotDir = join5(projectRoot, ".decantr", "evidence", "screenshots");
3908
4332
  mkdirSync4(screenshotDir, { recursive: true });
4333
+ const axeCore = loadProjectAxeCore(projectRoot);
3909
4334
  let browser = null;
3910
4335
  try {
3911
4336
  browser = await playwright.chromium.launch({ headless: true });
3912
- const page = await browser.newPage();
3913
4337
  for (const route of routes) {
3914
4338
  const url = browserRouteUrl(options.browserBaseUrl, route);
3915
4339
  const relativePath = browserScreenshotRelativePath(route);
4340
+ let page = null;
4341
+ const consoleErrors = [];
4342
+ const pageErrors = [];
3916
4343
  try {
4344
+ page = await browser.newPage();
4345
+ page.on?.("console", (message) => {
4346
+ const errorMessage = consoleErrorMessage(message);
4347
+ if (errorMessage) consoleErrors.push(errorMessage);
4348
+ });
4349
+ page.on?.("pageerror", (error) => {
4350
+ pageErrors.push(browserErrorMessage(error));
4351
+ });
3917
4352
  await page.goto(url, { waitUntil: "networkidle", timeout: 15e3 });
4353
+ const domSnapshot = await collectRuntimeDomSnapshot(page);
4354
+ const runtime = {
4355
+ ...domSnapshot,
4356
+ consoleErrors: consoleProbe(consoleErrors),
4357
+ pageErrors: consoleProbe(pageErrors),
4358
+ accessibility: await collectAccessibilityProbe(page, axeCore)
4359
+ };
4360
+ runtimeFailures.push(...browserRuntimeFailureMessages(route, runtime));
4361
+ accessibilityFailures.push(...browserAccessibilityFailureMessages(route, runtime));
3918
4362
  const absoluteScreenshotPath = join5(projectRoot, relativePath);
3919
4363
  await page.screenshot({ path: absoluteScreenshotPath, fullPage: true });
3920
4364
  screenshots.push(relativePath);
@@ -3923,11 +4367,12 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3923
4367
  url,
3924
4368
  screenshot: relativePath,
3925
4369
  screenshotHash: hashFile2(absoluteScreenshotPath),
3926
- status: "captured"
4370
+ status: "captured",
4371
+ runtime
3927
4372
  });
3928
4373
  } catch (error) {
3929
4374
  const message = error.message;
3930
- browserFindings.push(`${route}: ${message}`);
4375
+ routeFailures.push(`${route}: ${message}`);
3931
4376
  visualRoutes.push({
3932
4377
  route,
3933
4378
  url,
@@ -3936,10 +4381,15 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3936
4381
  status: "failed",
3937
4382
  error: message
3938
4383
  });
4384
+ } finally {
4385
+ try {
4386
+ await page?.close?.();
4387
+ } catch {
4388
+ }
3939
4389
  }
3940
4390
  }
3941
4391
  } catch (error) {
3942
- browserFindings.push(error.message);
4392
+ routeFailures.push(error.message);
3943
4393
  } finally {
3944
4394
  if (browser) await browser.close();
3945
4395
  }
@@ -3953,30 +4403,68 @@ async function collectBrowserVerification(projectRoot, options, declaredRoutes)
3953
4403
  const visualManifestPath = join5(projectRoot, ".decantr", "evidence", "visual-manifest.json");
3954
4404
  mkdirSync4(dirname3(visualManifestPath), { recursive: true });
3955
4405
  writeFileSync4(visualManifestPath, JSON.stringify(visualManifest, null, 2) + "\n", "utf-8");
3956
- if (browserFindings.length > 0) {
3957
- const finding = createHealthFinding({
3958
- source: "browser",
3959
- category: "Browser Verification",
3960
- severity: options.requireBrowser ? "error" : "warn",
3961
- message: "Browser verification could not render every declared route.",
3962
- evidence: browserFindings.slice(0, 5),
3963
- rule: "browser-route-verification-failed",
3964
- suggestedFix: "Start the app at the provided base URL, fix route render errors, and rerun `decantr health --browser --evidence`.",
3965
- baseId: "route-verification-failed"
3966
- });
4406
+ const findings = [];
4407
+ if (routeFailures.length > 0) {
4408
+ findings.push(
4409
+ createHealthFinding({
4410
+ source: "browser",
4411
+ category: "Browser Verification",
4412
+ severity: options.requireBrowser ? "error" : "warn",
4413
+ message: "Browser verification could not render every declared route.",
4414
+ evidence: routeFailures.slice(0, 5),
4415
+ rule: "browser-route-verification-failed",
4416
+ suggestedFix: "Start the app at the provided base URL, fix route render errors, and rerun `decantr health --browser --evidence`.",
4417
+ baseId: "route-verification-failed"
4418
+ })
4419
+ );
4420
+ }
4421
+ if (runtimeFailures.length > 0) {
4422
+ findings.push(
4423
+ createHealthFinding({
4424
+ source: "browser",
4425
+ category: "Browser Runtime",
4426
+ severity: options.requireBrowser ? "error" : "warn",
4427
+ message: "Browser runtime probes failed for one or more rendered routes.",
4428
+ evidence: runtimeFailures.slice(0, 5),
4429
+ rule: "browser-runtime-probes-failed",
4430
+ suggestedFix: "Inspect console/page errors and rendered DOM state, repair the route runtime behavior, and rerun `decantr health --browser --evidence`.",
4431
+ code: "RUNTIME010",
4432
+ repair: { id: "repair-browser-runtime-probes" },
4433
+ baseId: "runtime-probes-failed"
4434
+ })
4435
+ );
4436
+ }
4437
+ if (accessibilityFailures.length > 0) {
4438
+ findings.push(
4439
+ createHealthFinding({
4440
+ source: "browser",
4441
+ category: "Browser Accessibility",
4442
+ severity: options.requireBrowser ? "error" : "warn",
4443
+ message: "Axe reported accessibility violations in rendered browser evidence.",
4444
+ evidence: accessibilityFailures.slice(0, 5),
4445
+ rule: "browser-axe-violations",
4446
+ suggestedFix: "Repair the rendered accessibility violations and rerun `decantr health --browser --evidence`.",
4447
+ code: "A11Y020",
4448
+ repair: { id: "fix-rendered-accessibility" },
4449
+ baseId: "axe-violations"
4450
+ })
4451
+ );
4452
+ }
4453
+ if (findings.length > 0) {
4454
+ const evidenceFindings = [...routeFailures, ...runtimeFailures, ...accessibilityFailures];
3967
4455
  return {
3968
- finding,
4456
+ findings,
3969
4457
  evidence: {
3970
4458
  enabled: true,
3971
4459
  status: "failed",
3972
4460
  baseUrl: options.browserBaseUrl,
3973
4461
  screenshots,
3974
- findings: browserFindings
4462
+ findings: evidenceFindings
3975
4463
  }
3976
4464
  };
3977
4465
  }
3978
4466
  return {
3979
- finding: null,
4467
+ findings: [],
3980
4468
  evidence: {
3981
4469
  enabled: true,
3982
4470
  status: "passed",
@@ -4013,7 +4501,7 @@ function parseDecantrCssTokenNames(projectRoot) {
4013
4501
  function collectDesignTokenEvidence(projectRoot, designTokensPath) {
4014
4502
  const resolved = resolveOptionalPath(projectRoot, designTokensPath);
4015
4503
  if (!resolved) return void 0;
4016
- const sourceLabel = isAbsolute3(designTokensPath ?? "") ? "<design-tokens>" : designTokensPath ?? "<design-tokens>";
4504
+ const sourceLabel = isAbsolute4(designTokensPath ?? "") ? "<design-tokens>" : designTokensPath ?? "<design-tokens>";
4017
4505
  if (!existsSync5(resolved)) {
4018
4506
  return {
4019
4507
  source: sourceLabel,
@@ -4361,8 +4849,8 @@ async function createProjectHealthReport(projectRoot = process.cwd(), options =
4361
4849
  options,
4362
4850
  declaredRoutes
4363
4851
  );
4364
- if (browserVerification?.finding && !isDuplicateFinding(seen, browserVerification.finding)) {
4365
- findings.push(browserVerification.finding);
4852
+ for (const browserFinding of browserVerification?.findings ?? []) {
4853
+ if (!isDuplicateFinding(seen, browserFinding)) findings.push(browserFinding);
4366
4854
  }
4367
4855
  const anchoredFindings = anchorProjectHealthFindings(projectRoot, findings);
4368
4856
  const scopedFindings = scopeHealthFindingsToProject(projectRoot, anchoredFindings);
@@ -4524,8 +5012,12 @@ function formatProjectHealthJson(report) {
4524
5012
  `;
4525
5013
  }
4526
5014
  function diagnosticCatalogPayload() {
5015
+ const diagnosticsByRule = /* @__PURE__ */ new Map();
5016
+ for (const entry of [...KNOWN_VERIFICATION_DIAGNOSTICS, ...HEALTH_BROWSER_RUNTIME_DIAGNOSTICS]) {
5017
+ diagnosticsByRule.set(entry.rule, entry);
5018
+ }
4527
5019
  return {
4528
- diagnostics: KNOWN_VERIFICATION_DIAGNOSTICS.map((entry) => ({
5020
+ diagnostics: [...diagnosticsByRule.values()].map((entry) => ({
4529
5021
  code: entry.code,
4530
5022
  family: entry.family,
4531
5023
  rule: entry.rule,
@@ -4609,7 +5101,7 @@ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
4609
5101
  const format2 = resolveFormat(options);
4610
5102
  const payload2 = format2 === "json" ? formatDiagnosticCatalogJson() : format2 === "markdown" ? formatDiagnosticCatalogMarkdown() : formatDiagnosticCatalogText();
4611
5103
  if (options.output) {
4612
- const outputPath = isAbsolute3(options.output) ? options.output : join5(projectRoot, options.output);
5104
+ const outputPath = isAbsolute4(options.output) ? options.output : join5(projectRoot, options.output);
4613
5105
  mkdirSync4(dirname3(outputPath), { recursive: true });
4614
5106
  writeFileSync4(outputPath, payload2, "utf-8");
4615
5107
  if (!options.ci) {
@@ -4664,7 +5156,7 @@ async function cmdHealth(projectRoot = process.cwd(), options = {}) {
4664
5156
  ` : format === "json" ? formatProjectHealthJson(report) : format === "markdown" ? formatProjectHealthMarkdown(report) : formatProjectHealthText(report);
4665
5157
  const payload = baselineComparison && !options.evidence && format === "text" ? `${basePayload}${formatBaselineComparisonText(baselineComparison)}` : basePayload;
4666
5158
  if (options.output) {
4667
- const outputPath = isAbsolute3(options.output) ? options.output : join5(projectRoot, options.output);
5159
+ const outputPath = isAbsolute4(options.output) ? options.output : join5(projectRoot, options.output);
4668
5160
  mkdirSync4(dirname3(outputPath), { recursive: true });
4669
5161
  writeFileSync4(outputPath, payload, "utf-8");
4670
5162
  if (!options.ci) {