@decantr/cli 2.13.1 → 3.0.0-next.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 +28 -13
- package/dist/bin.js +3 -3
- package/dist/{chunk-5MZH6XXQ.js → chunk-BDY4JCFH.js} +826 -1520
- package/dist/{chunk-YBSBAJ3E.js → chunk-GXC5IJQY.js} +1 -1
- package/dist/chunk-WUIFEHWM.js +4573 -0
- package/dist/{health-B4W7UJBZ.js → health-ASR7RWZJ.js} +7 -1
- package/dist/index.js +3 -3
- package/dist/{studio-JOEECLI6.js → studio-R65NXPJW.js} +2 -2
- package/dist/{workspace-7RU77ZZW.js → workspace-2FFQEEQQ.js} +2 -2
- package/package.json +10 -9
- package/src/templates/DECANTR.md.template +3 -3
- package/dist/chunk-3A2DLR47.js +0 -1565
package/dist/chunk-3A2DLR47.js
DELETED
|
@@ -1,1565 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
collectCheckIssues,
|
|
3
|
-
sendProjectHealthCiFailedTelemetry,
|
|
4
|
-
sendProjectHealthPromptTelemetry,
|
|
5
|
-
sendProjectHealthReportTelemetry
|
|
6
|
-
} from "./chunk-MNZKOZW7.js";
|
|
7
|
-
|
|
8
|
-
// src/commands/health.ts
|
|
9
|
-
import { execFileSync } from "child_process";
|
|
10
|
-
import { createHash } from "crypto";
|
|
11
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
12
|
-
import { createRequire } from "module";
|
|
13
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative, resolve as resolve2 } from "path";
|
|
14
|
-
import { fileURLToPath } from "url";
|
|
15
|
-
import {
|
|
16
|
-
auditProject,
|
|
17
|
-
createContractAssertions,
|
|
18
|
-
createEvidenceBundle
|
|
19
|
-
} from "@decantr/verifier";
|
|
20
|
-
|
|
21
|
-
// src/workspace.ts
|
|
22
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
23
|
-
import { dirname, isAbsolute, join, resolve } from "path";
|
|
24
|
-
function readPackageJson(dir) {
|
|
25
|
-
const path = join(dir, "package.json");
|
|
26
|
-
if (!existsSync(path)) return null;
|
|
27
|
-
try {
|
|
28
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
function hasWorkspaceMarker(dir) {
|
|
34
|
-
if (existsSync(join(dir, "pnpm-workspace.yaml")) || existsSync(join(dir, "turbo.json")) || existsSync(join(dir, "nx.json"))) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
const pkg = readPackageJson(dir);
|
|
38
|
-
return Boolean(pkg?.workspaces);
|
|
39
|
-
}
|
|
40
|
-
function findWorkspaceRoot(startDir) {
|
|
41
|
-
let current = resolve(startDir);
|
|
42
|
-
while (true) {
|
|
43
|
-
if (hasWorkspaceMarker(current)) return current;
|
|
44
|
-
const parent = dirname(current);
|
|
45
|
-
if (parent === current) return null;
|
|
46
|
-
current = parent;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function looksLikeApp(dir, options = {}) {
|
|
50
|
-
const allowSourceDirs = options.allowSourceDirs ?? true;
|
|
51
|
-
const allowPackageDeps = options.allowPackageDeps ?? true;
|
|
52
|
-
const pkg = readPackageJson(dir);
|
|
53
|
-
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
54
|
-
const hasFrontendDependency = Boolean(
|
|
55
|
-
deps.react || deps["react-dom"] || deps.next || deps.vue || deps.svelte || deps["@angular/core"] || deps.astro || deps.nuxt
|
|
56
|
-
);
|
|
57
|
-
const hasServerOnlyDependency = Boolean(
|
|
58
|
-
deps.hono || deps.express || deps.fastify || deps.koa || deps["@hapi/hapi"]
|
|
59
|
-
);
|
|
60
|
-
if (existsSync(join(dir, "next.config.js")) || existsSync(join(dir, "next.config.ts")) || existsSync(join(dir, "next.config.mjs")) || existsSync(join(dir, "vite.config.ts")) || existsSync(join(dir, "vite.config.js")) || existsSync(join(dir, "angular.json")) || existsSync(join(dir, "svelte.config.js")) || existsSync(join(dir, "svelte.config.ts")) || existsSync(join(dir, "astro.config.mjs"))) {
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
if (allowSourceDirs && (existsSync(join(dir, "src")) || existsSync(join(dir, "app")) || existsSync(join(dir, "pages")))) {
|
|
64
|
-
if (hasFrontendDependency) return true;
|
|
65
|
-
if (hasServerOnlyDependency) return false;
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
if (!allowPackageDeps) return false;
|
|
69
|
-
return hasFrontendDependency;
|
|
70
|
-
}
|
|
71
|
-
function listWorkspaceApps(workspaceRoot) {
|
|
72
|
-
const candidates = [];
|
|
73
|
-
for (const base of ["apps", "packages"]) {
|
|
74
|
-
const baseDir = join(workspaceRoot, base);
|
|
75
|
-
if (!existsSync(baseDir)) continue;
|
|
76
|
-
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
77
|
-
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
78
|
-
const candidate = join(baseDir, entry.name);
|
|
79
|
-
if (looksLikeApp(candidate, {
|
|
80
|
-
allowSourceDirs: base === "apps",
|
|
81
|
-
allowPackageDeps: base === "apps"
|
|
82
|
-
})) {
|
|
83
|
-
candidates.push(`${base}/${entry.name}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return candidates.sort();
|
|
88
|
-
}
|
|
89
|
-
function listWorkspaceAppCandidates(workspaceRoot) {
|
|
90
|
-
return listWorkspaceApps(resolve(workspaceRoot));
|
|
91
|
-
}
|
|
92
|
-
function resolveWorkspaceInfo(cwd, projectArg) {
|
|
93
|
-
const absoluteCwd = resolve(cwd);
|
|
94
|
-
const appRoot = projectArg ? resolve(
|
|
95
|
-
isAbsolute(projectArg) ? projectArg : join(findWorkspaceRoot(absoluteCwd) ?? absoluteCwd, projectArg)
|
|
96
|
-
) : absoluteCwd;
|
|
97
|
-
const workspaceRoot = projectArg && isAbsolute(projectArg) ? findWorkspaceRoot(appRoot) ?? appRoot : findWorkspaceRoot(absoluteCwd) ?? absoluteCwd;
|
|
98
|
-
const appCandidates = listWorkspaceApps(workspaceRoot);
|
|
99
|
-
const projectScope = workspaceRoot !== appRoot || appCandidates.length > 0 ? "workspace-app" : "single-app";
|
|
100
|
-
const requiresProjectSelection = !projectArg && workspaceRoot === absoluteCwd && appCandidates.length > 0;
|
|
101
|
-
return {
|
|
102
|
-
cwd: absoluteCwd,
|
|
103
|
-
workspaceRoot,
|
|
104
|
-
appRoot,
|
|
105
|
-
projectScope,
|
|
106
|
-
appCandidates,
|
|
107
|
-
requiresProjectSelection
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// src/commands/health.ts
|
|
112
|
-
var BOLD = "\x1B[1m";
|
|
113
|
-
var DIM = "\x1B[2m";
|
|
114
|
-
var RESET = "\x1B[0m";
|
|
115
|
-
var RED = "\x1B[31m";
|
|
116
|
-
var GREEN = "\x1B[32m";
|
|
117
|
-
var CYAN = "\x1B[36m";
|
|
118
|
-
var YELLOW = "\x1B[33m";
|
|
119
|
-
var PROJECT_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/project-health-report.v1.json";
|
|
120
|
-
var DEFAULT_HEALTH_CI_WORKFLOW_PATH = ".github/workflows/decantr-health.yml";
|
|
121
|
-
var DEFAULT_HEALTH_CI_REPORT_PATH = "decantr-health.md";
|
|
122
|
-
var DEFAULT_HEALTH_CI_JSON_PATH = "decantr-health.json";
|
|
123
|
-
var DEFAULT_HEALTH_CI_CLI_VERSION = "latest";
|
|
124
|
-
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
125
|
-
function readProjectMetadata(projectRoot) {
|
|
126
|
-
const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
|
|
127
|
-
if (!existsSync2(projectJsonPath)) {
|
|
128
|
-
return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
|
|
129
|
-
}
|
|
130
|
-
try {
|
|
131
|
-
const data = JSON.parse(readFileSync2(projectJsonPath, "utf-8"));
|
|
132
|
-
const workflowMode = typeof data.initialized?.workflowMode === "string" ? data.initialized.workflowMode : null;
|
|
133
|
-
const adoptionMode = typeof data.initialized?.adoptionMode === "string" ? data.initialized.adoptionMode : null;
|
|
134
|
-
return {
|
|
135
|
-
workflowMode,
|
|
136
|
-
adoptionMode,
|
|
137
|
-
autoBrownfield: workflowMode === "brownfield-attach"
|
|
138
|
-
};
|
|
139
|
-
} catch {
|
|
140
|
-
return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
function loadHealthTemplate(name) {
|
|
144
|
-
const fromDist = join2(__dirname, "..", "src", "templates", name);
|
|
145
|
-
if (existsSync2(fromDist)) return readFileSync2(fromDist, "utf-8");
|
|
146
|
-
const fromSrc = join2(__dirname, "..", "templates", name);
|
|
147
|
-
if (existsSync2(fromSrc)) return readFileSync2(fromSrc, "utf-8");
|
|
148
|
-
const fromCommandSrc = join2(__dirname, "..", "..", "templates", name);
|
|
149
|
-
if (existsSync2(fromCommandSrc)) return readFileSync2(fromCommandSrc, "utf-8");
|
|
150
|
-
throw new Error(`Template not found: ${name}`);
|
|
151
|
-
}
|
|
152
|
-
function renderTemplate(template, vars) {
|
|
153
|
-
let result = template;
|
|
154
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
155
|
-
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
|
|
156
|
-
}
|
|
157
|
-
return result;
|
|
158
|
-
}
|
|
159
|
-
function normalizeCliPackageSpecifier(version) {
|
|
160
|
-
const value = (version || DEFAULT_HEALTH_CI_CLI_VERSION).trim();
|
|
161
|
-
if (!value) return `@decantr/cli@${DEFAULT_HEALTH_CI_CLI_VERSION}`;
|
|
162
|
-
const versionToken = value.startsWith("@decantr/cli@") ? value.slice("@decantr/cli@".length) : value;
|
|
163
|
-
if (!/^[A-Za-z0-9._~^*-]+$/.test(versionToken)) {
|
|
164
|
-
throw new Error(
|
|
165
|
-
"Invalid --cli-version value. Use a package version or dist-tag such as latest, 2.0.0, or next."
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
return `@decantr/cli@${versionToken}`;
|
|
169
|
-
}
|
|
170
|
-
function normalizeHealthFailOn(value) {
|
|
171
|
-
const failOn = value ?? "error";
|
|
172
|
-
if (!["error", "warn", "none"].includes(failOn)) {
|
|
173
|
-
throw new Error("Invalid --fail-on value. Use error, warn, or none.");
|
|
174
|
-
}
|
|
175
|
-
return failOn;
|
|
176
|
-
}
|
|
177
|
-
function validateWorkflowPath(value) {
|
|
178
|
-
const normalized = value.trim();
|
|
179
|
-
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized)) {
|
|
180
|
-
throw new Error(
|
|
181
|
-
"Invalid --workflow-path value. Use a relative path without spaces or parent-directory segments."
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
return normalized;
|
|
185
|
-
}
|
|
186
|
-
function validateArtifactPath(value, flag) {
|
|
187
|
-
const normalized = value.trim();
|
|
188
|
-
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized)) {
|
|
189
|
-
throw new Error(
|
|
190
|
-
`Invalid ${flag} value. Use a relative artifact path without spaces or parent-directory segments.`
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
return normalized;
|
|
194
|
-
}
|
|
195
|
-
function validateProjectPath(value) {
|
|
196
|
-
if (value === void 0) return void 0;
|
|
197
|
-
const raw = value.trim();
|
|
198
|
-
if (!raw || raw === ".") return void 0;
|
|
199
|
-
const normalized = raw.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
200
|
-
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized) || !/^[A-Za-z0-9._@/-]+$/.test(normalized)) {
|
|
201
|
-
throw new Error(
|
|
202
|
-
"Invalid --project value. Use a relative project path without spaces or parent-directory segments."
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
const segments = normalized.split("/");
|
|
206
|
-
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
207
|
-
throw new Error(
|
|
208
|
-
"Invalid --project value. Use a relative project path without empty or parent-directory segments."
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
return normalized;
|
|
212
|
-
}
|
|
213
|
-
function prefixArtifactPath(projectPath, artifactPath) {
|
|
214
|
-
return projectPath ? `${projectPath}/${artifactPath}` : artifactPath;
|
|
215
|
-
}
|
|
216
|
-
function renderProjectHealthCiWorkflow(options = {}) {
|
|
217
|
-
const failOn = normalizeHealthFailOn(options.failOn);
|
|
218
|
-
const projectPath = options.workspace ? void 0 : validateProjectPath(options.projectPath);
|
|
219
|
-
const reportPath = validateArtifactPath(
|
|
220
|
-
options.reportPath || (options.workspace ? ".decantr/workspace-health.md" : DEFAULT_HEALTH_CI_REPORT_PATH),
|
|
221
|
-
"--report-path"
|
|
222
|
-
);
|
|
223
|
-
const jsonPath = validateArtifactPath(
|
|
224
|
-
options.jsonPath || (options.workspace ? ".decantr/workspace-health.json" : DEFAULT_HEALTH_CI_JSON_PATH),
|
|
225
|
-
"--json-path"
|
|
226
|
-
);
|
|
227
|
-
const template = loadHealthTemplate("decantr-health.workflow.yml.template");
|
|
228
|
-
return renderTemplate(template, {
|
|
229
|
-
CLI_PACKAGE: normalizeCliPackageSpecifier(options.cliVersion),
|
|
230
|
-
FAIL_ON: failOn,
|
|
231
|
-
HEALTH_COMMAND: options.workspace ? "workspace health" : "health",
|
|
232
|
-
PROJECT_WORKING_DIRECTORY: projectPath ? ` working-directory: ${projectPath}
|
|
233
|
-
` : "",
|
|
234
|
-
REPORT_PATH: reportPath,
|
|
235
|
-
JSON_PATH: jsonPath,
|
|
236
|
-
REPORT_ARTIFACT_PATH: prefixArtifactPath(projectPath, reportPath),
|
|
237
|
-
JSON_ARTIFACT_PATH: prefixArtifactPath(projectPath, jsonPath)
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
function writeProjectHealthCiWorkflow(projectRoot, options = {}) {
|
|
241
|
-
const workflowRelativePath = validateWorkflowPath(
|
|
242
|
-
options.workflowPath || DEFAULT_HEALTH_CI_WORKFLOW_PATH
|
|
243
|
-
);
|
|
244
|
-
const workflowPath = join2(projectRoot, workflowRelativePath);
|
|
245
|
-
const alreadyExists = existsSync2(workflowPath);
|
|
246
|
-
if (alreadyExists && !options.force) {
|
|
247
|
-
throw new Error(
|
|
248
|
-
`${workflowRelativePath} already exists. Re-run with --force to replace it, or use --workflow-path <file>.`
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
mkdirSync(dirname2(workflowPath), { recursive: true });
|
|
252
|
-
writeFileSync(workflowPath, renderProjectHealthCiWorkflow(options), "utf-8");
|
|
253
|
-
const projectPath = options.workspace ? void 0 : validateProjectPath(options.projectPath);
|
|
254
|
-
const result = {
|
|
255
|
-
path: workflowRelativePath,
|
|
256
|
-
created: !alreadyExists,
|
|
257
|
-
cliPackage: normalizeCliPackageSpecifier(options.cliVersion),
|
|
258
|
-
failOn: normalizeHealthFailOn(options.failOn)
|
|
259
|
-
};
|
|
260
|
-
if (projectPath) result.projectPath = projectPath;
|
|
261
|
-
if (options.workspace) result.workspace = true;
|
|
262
|
-
return result;
|
|
263
|
-
}
|
|
264
|
-
function collectDeclaredRoutes(essence) {
|
|
265
|
-
if (!essence || typeof essence !== "object") return [];
|
|
266
|
-
const record = essence;
|
|
267
|
-
const blueprint = record.blueprint;
|
|
268
|
-
if (!blueprint || typeof blueprint !== "object") return [];
|
|
269
|
-
const bp = blueprint;
|
|
270
|
-
const routes = /* @__PURE__ */ new Set();
|
|
271
|
-
if (bp.routes && typeof bp.routes === "object" && !Array.isArray(bp.routes)) {
|
|
272
|
-
for (const route of Object.keys(bp.routes)) {
|
|
273
|
-
routes.add(route);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const flatPages = Array.isArray(bp.pages) ? bp.pages : [];
|
|
277
|
-
for (const page of flatPages) {
|
|
278
|
-
if (page && typeof page === "object") {
|
|
279
|
-
const route = page.route;
|
|
280
|
-
if (typeof route === "string") routes.add(route);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const sections = Array.isArray(bp.sections) ? bp.sections : [];
|
|
284
|
-
for (const section of sections) {
|
|
285
|
-
if (!section || typeof section !== "object") continue;
|
|
286
|
-
const pages = section.pages;
|
|
287
|
-
if (!Array.isArray(pages)) continue;
|
|
288
|
-
for (const page of pages) {
|
|
289
|
-
if (page && typeof page === "object") {
|
|
290
|
-
const route = page.route;
|
|
291
|
-
if (typeof route === "string") routes.add(route);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return [...routes].sort();
|
|
296
|
-
}
|
|
297
|
-
function severityFromCheckIssue(issue) {
|
|
298
|
-
return issue.type === "error" ? "error" : "warn";
|
|
299
|
-
}
|
|
300
|
-
function sourceFromAuditFinding(finding) {
|
|
301
|
-
const category = finding.category.toLowerCase();
|
|
302
|
-
const id = finding.id.toLowerCase();
|
|
303
|
-
const rule = finding.rule?.toLowerCase() ?? "";
|
|
304
|
-
if (category.includes("runtime") || category.includes("document") || category.includes("performance")) {
|
|
305
|
-
return "runtime";
|
|
306
|
-
}
|
|
307
|
-
if (category.includes("pack") || category.includes("review contract")) {
|
|
308
|
-
return "pack";
|
|
309
|
-
}
|
|
310
|
-
if (category.includes("interaction") || id.includes("interaction") || rule.includes("interaction")) {
|
|
311
|
-
return "interaction";
|
|
312
|
-
}
|
|
313
|
-
return "audit";
|
|
314
|
-
}
|
|
315
|
-
function sourceFromCheckIssue(issue) {
|
|
316
|
-
if (issue.rule.startsWith("brownfield-")) return "brownfield";
|
|
317
|
-
if (issue.rule.includes("interaction")) return "interaction";
|
|
318
|
-
return "check";
|
|
319
|
-
}
|
|
320
|
-
function normalizeHealthCategory(category, source) {
|
|
321
|
-
const lower = category.toLowerCase();
|
|
322
|
-
if (source === "pack" || lower.includes("execution pack") || lower.includes("review contract") || lower.includes("context")) {
|
|
323
|
-
return "Generated Artifact";
|
|
324
|
-
}
|
|
325
|
-
if (source === "brownfield") return "Brownfield Contract";
|
|
326
|
-
if (source === "design-token" || lower.includes("design-token")) return "Design Token";
|
|
327
|
-
if (lower.includes("accessibility")) return "Accessibility";
|
|
328
|
-
if (source === "runtime") return "Runtime";
|
|
329
|
-
if (source === "browser") return "Visual Evidence";
|
|
330
|
-
if (source === "interaction") return "Interaction";
|
|
331
|
-
if (source === "assertion") return `Contract ${category}`;
|
|
332
|
-
return category;
|
|
333
|
-
}
|
|
334
|
-
function contractAssertionApplies(assertion, metadata) {
|
|
335
|
-
const projectOwnedStyling = metadata.adoptionMode === "contract-only" || metadata.adoptionMode === "style-bridge";
|
|
336
|
-
if (assertion.rule === "tokens-file-present" && projectOwnedStyling) {
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
if (projectOwnedStyling && (assertion.rule === "pack-manifest-present" || assertion.rule === "review-pack-present")) {
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
function slugify(value) {
|
|
345
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
346
|
-
}
|
|
347
|
-
function hashFile(path) {
|
|
348
|
-
if (!existsSync2(path)) return null;
|
|
349
|
-
try {
|
|
350
|
-
return createHash("sha256").update(readFileSync2(path)).digest("hex");
|
|
351
|
-
} catch {
|
|
352
|
-
return null;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
function readJsonFile(path) {
|
|
356
|
-
if (!existsSync2(path)) return null;
|
|
357
|
-
try {
|
|
358
|
-
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
359
|
-
} catch {
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
function commandsForFinding(source) {
|
|
364
|
-
switch (source) {
|
|
365
|
-
case "brownfield":
|
|
366
|
-
return ["decantr analyze", "decantr init --existing --merge-proposal", "decantr health"];
|
|
367
|
-
case "pack":
|
|
368
|
-
return [
|
|
369
|
-
"decantr refresh",
|
|
370
|
-
"decantr registry get-pack review --write-context",
|
|
371
|
-
"decantr health"
|
|
372
|
-
];
|
|
373
|
-
case "runtime":
|
|
374
|
-
return ["npm run build", "decantr health"];
|
|
375
|
-
case "interaction":
|
|
376
|
-
return ["decantr check --strict", "decantr health"];
|
|
377
|
-
case "assertion":
|
|
378
|
-
return ["decantr refresh", "decantr health --evidence"];
|
|
379
|
-
case "browser":
|
|
380
|
-
return ["decantr health --browser", "decantr health --evidence"];
|
|
381
|
-
case "design-token":
|
|
382
|
-
return ["decantr export --to figma-tokens", "decantr health --evidence"];
|
|
383
|
-
case "check":
|
|
384
|
-
return ["decantr check", "decantr health"];
|
|
385
|
-
default:
|
|
386
|
-
return ["decantr audit", "decantr health"];
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
function detectPackageManager(root) {
|
|
390
|
-
const pkg = readJsonFile(join2(root, "package.json"));
|
|
391
|
-
const declared = pkg?.packageManager?.split("@")[0];
|
|
392
|
-
if (declared === "pnpm" || declared === "npm" || declared === "yarn" || declared === "bun") {
|
|
393
|
-
return declared;
|
|
394
|
-
}
|
|
395
|
-
if (existsSync2(join2(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
396
|
-
if (existsSync2(join2(root, "package-lock.json"))) return "npm";
|
|
397
|
-
if (existsSync2(join2(root, "yarn.lock"))) return "yarn";
|
|
398
|
-
if (existsSync2(join2(root, "bun.lock")) || existsSync2(join2(root, "bun.lockb"))) return "bun";
|
|
399
|
-
return "unknown";
|
|
400
|
-
}
|
|
401
|
-
function buildCommandForProject(workspaceRoot, projectPath) {
|
|
402
|
-
const packageManager = detectPackageManager(workspaceRoot);
|
|
403
|
-
if (projectPath) {
|
|
404
|
-
switch (packageManager) {
|
|
405
|
-
case "pnpm":
|
|
406
|
-
return `pnpm --dir ${projectPath} build`;
|
|
407
|
-
case "yarn":
|
|
408
|
-
return `yarn --cwd ${projectPath} build`;
|
|
409
|
-
case "bun":
|
|
410
|
-
return `bun --cwd ${projectPath} run build`;
|
|
411
|
-
case "npm":
|
|
412
|
-
return `npm --prefix ${projectPath} run build`;
|
|
413
|
-
default:
|
|
414
|
-
return `cd ${projectPath} && npm run build`;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
switch (packageManager) {
|
|
418
|
-
case "pnpm":
|
|
419
|
-
return "pnpm build";
|
|
420
|
-
case "yarn":
|
|
421
|
-
return "yarn build";
|
|
422
|
-
case "bun":
|
|
423
|
-
return "bun run build";
|
|
424
|
-
case "npm":
|
|
425
|
-
return "npm run build";
|
|
426
|
-
default:
|
|
427
|
-
return "npm run build";
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
function commandContextForProject(projectRoot) {
|
|
431
|
-
const workspaceInfo = resolveWorkspaceInfo(projectRoot);
|
|
432
|
-
const relativeProjectPath = relative(workspaceInfo.workspaceRoot, projectRoot).replace(
|
|
433
|
-
/\\/g,
|
|
434
|
-
"/"
|
|
435
|
-
);
|
|
436
|
-
const projectPath = relativeProjectPath && !relativeProjectPath.startsWith("..") && !isAbsolute2(relativeProjectPath) ? relativeProjectPath : null;
|
|
437
|
-
const projectFlag = projectPath ? ` --project ${projectPath}` : "";
|
|
438
|
-
const essencePath = projectPath ? `${projectPath}/decantr.essence.json` : "decantr.essence.json";
|
|
439
|
-
return {
|
|
440
|
-
projectPath,
|
|
441
|
-
buildCommand: buildCommandForProject(workspaceInfo.workspaceRoot, projectPath),
|
|
442
|
-
compilePacksCommand: `decantr registry compile-packs ${essencePath} --write-context`,
|
|
443
|
-
verifyCommand: `decantr verify${projectFlag}`,
|
|
444
|
-
ciCommand: `decantr ci${projectFlag} --fail-on error`,
|
|
445
|
-
promptCommand: (id) => `decantr health${projectFlag} --prompt ${id}`
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
function rewriteHealthCommand(command, context) {
|
|
449
|
-
if (command === "npm run build") return context.buildCommand;
|
|
450
|
-
let rewritten = command.replace(
|
|
451
|
-
/decantr registry compile-packs decantr\.essence\.json --write-context/g,
|
|
452
|
-
context.compilePacksCommand
|
|
453
|
-
);
|
|
454
|
-
if (!context.projectPath) return rewritten;
|
|
455
|
-
rewritten = rewritten.replace(
|
|
456
|
-
/^decantr init --existing\b/,
|
|
457
|
-
`decantr init --project ${context.projectPath} --existing`
|
|
458
|
-
);
|
|
459
|
-
rewritten = rewritten.replace(
|
|
460
|
-
/^decantr analyze\b/,
|
|
461
|
-
`decantr analyze --project ${context.projectPath}`
|
|
462
|
-
);
|
|
463
|
-
rewritten = rewritten.replace(
|
|
464
|
-
/^decantr check\b/,
|
|
465
|
-
`decantr check --project ${context.projectPath}`
|
|
466
|
-
);
|
|
467
|
-
rewritten = rewritten.replace(/^decantr audit\b/, context.verifyCommand);
|
|
468
|
-
rewritten = rewritten.replace(/^decantr health\b/, context.verifyCommand);
|
|
469
|
-
return rewritten;
|
|
470
|
-
}
|
|
471
|
-
function rewriteSuggestedFixForProject(suggestedFix, context) {
|
|
472
|
-
if (!suggestedFix) return suggestedFix;
|
|
473
|
-
return suggestedFix.replace(
|
|
474
|
-
/decantr registry compile-packs decantr\.essence\.json --write-context/g,
|
|
475
|
-
context.compilePacksCommand
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
function commandsForProjectFinding(finding, context) {
|
|
479
|
-
const isPackHydrationFinding = finding.source === "pack" || /pack-manifest|review-pack|compile-packs/i.test(
|
|
480
|
-
`${finding.id} ${finding.rule ?? ""} ${finding.suggestedFix ?? ""}`
|
|
481
|
-
);
|
|
482
|
-
if (isPackHydrationFinding) {
|
|
483
|
-
return [context.compilePacksCommand, context.verifyCommand];
|
|
484
|
-
}
|
|
485
|
-
return [
|
|
486
|
-
...new Set(
|
|
487
|
-
finding.remediation.commands.map((command) => rewriteHealthCommand(command, context))
|
|
488
|
-
)
|
|
489
|
-
];
|
|
490
|
-
}
|
|
491
|
-
function scopeHealthFindingsToProject(projectRoot, findings) {
|
|
492
|
-
const context = commandContextForProject(projectRoot);
|
|
493
|
-
return findings.map((finding) => {
|
|
494
|
-
const suggestedFix = rewriteSuggestedFixForProject(finding.suggestedFix, context);
|
|
495
|
-
const commands = commandsForProjectFinding(finding, context);
|
|
496
|
-
return {
|
|
497
|
-
...finding,
|
|
498
|
-
suggestedFix,
|
|
499
|
-
remediation: {
|
|
500
|
-
summary: suggestedFix || finding.remediation.summary,
|
|
501
|
-
commands,
|
|
502
|
-
prompt: buildRemediationPrompt({
|
|
503
|
-
id: finding.id,
|
|
504
|
-
source: finding.source,
|
|
505
|
-
category: finding.category,
|
|
506
|
-
severity: finding.severity,
|
|
507
|
-
message: finding.message,
|
|
508
|
-
evidence: finding.evidence,
|
|
509
|
-
suggestedFix,
|
|
510
|
-
commands,
|
|
511
|
-
projectPath: context.projectPath
|
|
512
|
-
})
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
function buildRemediationPrompt(input) {
|
|
518
|
-
const prefix = input.projectPath ? `${input.projectPath}/` : "";
|
|
519
|
-
const readTargets = [
|
|
520
|
-
`${prefix}DECANTR.md`,
|
|
521
|
-
`${prefix}decantr.essence.json`,
|
|
522
|
-
`${prefix}.decantr/context/scaffold-pack.md`,
|
|
523
|
-
`${prefix}.decantr/context/scaffold.md`
|
|
524
|
-
];
|
|
525
|
-
return [
|
|
526
|
-
"You are fixing one Decantr Project Health finding in this local workspace.",
|
|
527
|
-
"",
|
|
528
|
-
`Read project-scoped Decantr files if they exist: ${readTargets.map((target) => `\`${target}\``).join(", ")}. For route or page work, read the matching page/section packs before editing.`,
|
|
529
|
-
"",
|
|
530
|
-
`Finding: ${input.id}`,
|
|
531
|
-
`Source: ${input.source}`,
|
|
532
|
-
`Severity: ${input.severity}`,
|
|
533
|
-
`Category: ${input.category}`,
|
|
534
|
-
`Message: ${input.message}`,
|
|
535
|
-
input.evidence.length > 0 ? `Evidence:
|
|
536
|
-
${input.evidence.map((entry) => `- ${entry}`).join("\n")}` : null,
|
|
537
|
-
input.suggestedFix ? `Suggested fix: ${input.suggestedFix}` : null,
|
|
538
|
-
"",
|
|
539
|
-
"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.",
|
|
540
|
-
"Do not rewrite unrelated routes, replace the styling system, remove existing product behavior, or regenerate Decantr artifacts unless the finding is about stale or missing generated context.",
|
|
541
|
-
"",
|
|
542
|
-
`After the fix, run:
|
|
543
|
-
${input.commands.map((command) => `- ${command}`).join("\n")}`
|
|
544
|
-
].filter((line) => Boolean(line)).join("\n");
|
|
545
|
-
}
|
|
546
|
-
function createHealthFinding(input) {
|
|
547
|
-
const idBase = input.baseId || input.rule || `${input.category}-${input.message}`;
|
|
548
|
-
const id = `${input.source}-${slugify(idBase)}`;
|
|
549
|
-
const commands = commandsForFinding(input.source);
|
|
550
|
-
const category = normalizeHealthCategory(input.category, input.source);
|
|
551
|
-
const remediation = {
|
|
552
|
-
summary: input.suggestedFix || `Resolve ${category.toLowerCase()} finding.`,
|
|
553
|
-
commands,
|
|
554
|
-
prompt: buildRemediationPrompt({
|
|
555
|
-
id,
|
|
556
|
-
source: input.source,
|
|
557
|
-
category,
|
|
558
|
-
severity: input.severity,
|
|
559
|
-
message: input.message,
|
|
560
|
-
evidence: input.evidence ?? [],
|
|
561
|
-
suggestedFix: input.suggestedFix,
|
|
562
|
-
commands
|
|
563
|
-
})
|
|
564
|
-
};
|
|
565
|
-
return {
|
|
566
|
-
id,
|
|
567
|
-
source: input.source,
|
|
568
|
-
category,
|
|
569
|
-
severity: input.severity,
|
|
570
|
-
message: input.message,
|
|
571
|
-
evidence: input.evidence ?? [],
|
|
572
|
-
target: input.target,
|
|
573
|
-
file: input.file,
|
|
574
|
-
rule: input.rule,
|
|
575
|
-
suggestedFix: input.suggestedFix,
|
|
576
|
-
remediation
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
function collectContractPackConsistencyFindings(projectRoot, essence, manifest) {
|
|
580
|
-
if (!essence || typeof essence !== "object") return [];
|
|
581
|
-
const record = essence;
|
|
582
|
-
const blueprint = record.blueprint;
|
|
583
|
-
if (!blueprint || typeof blueprint !== "object") return [];
|
|
584
|
-
const bp = blueprint;
|
|
585
|
-
const routes = bp.routes && typeof bp.routes === "object" && !Array.isArray(bp.routes) ? bp.routes : {};
|
|
586
|
-
const routeTargets = new Set(
|
|
587
|
-
Object.values(routes).filter(
|
|
588
|
-
(entry) => Boolean(entry) && typeof entry === "object"
|
|
589
|
-
).map((entry) => `${String(entry.section ?? "")}/${String(entry.page ?? "")}`)
|
|
590
|
-
);
|
|
591
|
-
const pages = [];
|
|
592
|
-
for (const section of Array.isArray(bp.sections) ? bp.sections : []) {
|
|
593
|
-
if (!section || typeof section !== "object") continue;
|
|
594
|
-
const sectionRecord = section;
|
|
595
|
-
const sectionId = typeof sectionRecord.id === "string" ? sectionRecord.id : "unknown";
|
|
596
|
-
for (const page of Array.isArray(sectionRecord.pages) ? sectionRecord.pages : []) {
|
|
597
|
-
if (!page || typeof page !== "object") continue;
|
|
598
|
-
const pageRecord = page;
|
|
599
|
-
const pageId = typeof pageRecord.id === "string" ? pageRecord.id : "unknown";
|
|
600
|
-
const route = typeof pageRecord.route === "string" ? pageRecord.route : null;
|
|
601
|
-
pages.push({ section: sectionId, page: pageId, route });
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
const findings = [];
|
|
605
|
-
const routeLess = pages.filter(
|
|
606
|
-
(page) => !page.route && !routeTargets.has(`${page.section}/${page.page}`)
|
|
607
|
-
);
|
|
608
|
-
if (routeLess.length > 0) {
|
|
609
|
-
findings.push(
|
|
610
|
-
createHealthFinding({
|
|
611
|
-
source: "assertion",
|
|
612
|
-
category: "Contract Route Topology",
|
|
613
|
-
severity: "error",
|
|
614
|
-
message: "One or more blueprint pages have no route and cannot be addressed by task-time context.",
|
|
615
|
-
evidence: routeLess.slice(0, 8).map(
|
|
616
|
-
(page) => `${page.section}/${page.page} has no page.route or blueprint.routes entry`
|
|
617
|
-
),
|
|
618
|
-
rule: "page-route-required",
|
|
619
|
-
suggestedFix: "Add a route for each page or rerun the add-page flow with a route-aware Decantr CLI.",
|
|
620
|
-
baseId: "page-route-required"
|
|
621
|
-
})
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
const pagePackCount = manifest && "pages" in manifest && Array.isArray(manifest.pages) ? manifest.pages.length : 0;
|
|
625
|
-
if (manifest && pages.length !== pagePackCount) {
|
|
626
|
-
const context = commandContextForProject(projectRoot);
|
|
627
|
-
findings.push(
|
|
628
|
-
createHealthFinding({
|
|
629
|
-
source: "pack",
|
|
630
|
-
category: "Generated Artifacts",
|
|
631
|
-
severity: "warn",
|
|
632
|
-
message: `Compiled page pack count (${pagePackCount}) does not match the contract page count (${pages.length}).`,
|
|
633
|
-
evidence: ["Page packs should be regenerated after adding, removing, or re-routing pages."],
|
|
634
|
-
rule: "page-pack-count-mismatch",
|
|
635
|
-
suggestedFix: context.compilePacksCommand,
|
|
636
|
-
baseId: "page-pack-count-mismatch"
|
|
637
|
-
})
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
return findings;
|
|
641
|
-
}
|
|
642
|
-
function countFindings(findings) {
|
|
643
|
-
return {
|
|
644
|
-
errorCount: findings.filter((finding) => finding.severity === "error").length,
|
|
645
|
-
warnCount: findings.filter((finding) => finding.severity === "warn").length,
|
|
646
|
-
infoCount: findings.filter((finding) => finding.severity === "info").length
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
function statusFromCounts(counts) {
|
|
650
|
-
if (counts.errorCount > 0) return "error";
|
|
651
|
-
if (counts.warnCount > 0) return "warning";
|
|
652
|
-
return "healthy";
|
|
653
|
-
}
|
|
654
|
-
function scoreFromCounts(counts) {
|
|
655
|
-
return Math.max(
|
|
656
|
-
0,
|
|
657
|
-
Math.min(100, 100 - counts.errorCount * 15 - counts.warnCount * 5 - counts.infoCount)
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
function routeIssuesFromFindings(findings) {
|
|
661
|
-
const issues = findings.filter(
|
|
662
|
-
(finding) => finding.category.toLowerCase().includes("route") || finding.rule?.toLowerCase().includes("route") || finding.id.toLowerCase().includes("route")
|
|
663
|
-
).map((finding) => finding.message);
|
|
664
|
-
return [...new Set(issues)];
|
|
665
|
-
}
|
|
666
|
-
function isDuplicateFinding(existing, finding) {
|
|
667
|
-
const key = `${finding.rule ?? finding.id}|${finding.message}`;
|
|
668
|
-
if (existing.has(key)) return true;
|
|
669
|
-
existing.add(key);
|
|
670
|
-
return false;
|
|
671
|
-
}
|
|
672
|
-
function resolveOptionalPath(projectRoot, path) {
|
|
673
|
-
if (!path) return void 0;
|
|
674
|
-
return isAbsolute2(path) ? path : resolve2(projectRoot, path);
|
|
675
|
-
}
|
|
676
|
-
function hasProjectPlaywright(projectRoot) {
|
|
677
|
-
try {
|
|
678
|
-
const requireFromProject = createRequire(join2(projectRoot, "package.json"));
|
|
679
|
-
requireFromProject.resolve("playwright");
|
|
680
|
-
return true;
|
|
681
|
-
} catch {
|
|
682
|
-
try {
|
|
683
|
-
const requireFromProject = createRequire(join2(projectRoot, "package.json"));
|
|
684
|
-
requireFromProject.resolve("@playwright/test");
|
|
685
|
-
return true;
|
|
686
|
-
} catch {
|
|
687
|
-
return false;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
function loadProjectPlaywright(projectRoot) {
|
|
692
|
-
const requireFromProject = createRequire(join2(projectRoot, "package.json"));
|
|
693
|
-
for (const packageName of ["playwright", "@playwright/test"]) {
|
|
694
|
-
try {
|
|
695
|
-
const loaded = requireFromProject(packageName);
|
|
696
|
-
if (loaded.chromium?.launch) return loaded;
|
|
697
|
-
} catch {
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
return null;
|
|
701
|
-
}
|
|
702
|
-
function browserRouteUrl(baseUrl, route) {
|
|
703
|
-
return new URL(route || "/", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
|
704
|
-
}
|
|
705
|
-
function browserScreenshotRelativePath(route) {
|
|
706
|
-
const name = slugify(route === "/" ? "root" : route) || "root";
|
|
707
|
-
return `.decantr/evidence/screenshots/${name}.png`;
|
|
708
|
-
}
|
|
709
|
-
async function collectBrowserVerification(projectRoot, options, declaredRoutes) {
|
|
710
|
-
if (!options.browser) return null;
|
|
711
|
-
if (!hasProjectPlaywright(projectRoot)) {
|
|
712
|
-
const finding = createHealthFinding({
|
|
713
|
-
source: "browser",
|
|
714
|
-
category: "Browser Verification",
|
|
715
|
-
severity: options.requireBrowser ? "error" : "warn",
|
|
716
|
-
message: "Browser verification was requested, but Playwright is not installed in this project.",
|
|
717
|
-
evidence: ["Expected dependency: playwright or @playwright/test"],
|
|
718
|
-
rule: "browser-playwright-missing",
|
|
719
|
-
suggestedFix: "Install Playwright in the project or omit browser/base-url evidence for static-only health checks.",
|
|
720
|
-
baseId: "playwright-missing"
|
|
721
|
-
});
|
|
722
|
-
return {
|
|
723
|
-
finding,
|
|
724
|
-
evidence: {
|
|
725
|
-
enabled: true,
|
|
726
|
-
status: "unavailable",
|
|
727
|
-
baseUrl: options.browserBaseUrl ?? null,
|
|
728
|
-
screenshots: [],
|
|
729
|
-
findings: [finding.message]
|
|
730
|
-
}
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
if (!options.browserBaseUrl) {
|
|
734
|
-
const finding = createHealthFinding({
|
|
735
|
-
source: "browser",
|
|
736
|
-
category: "Browser Verification",
|
|
737
|
-
severity: options.requireBrowser ? "error" : "warn",
|
|
738
|
-
message: "Browser verification was requested, but no base URL was provided for rendered route checks.",
|
|
739
|
-
evidence: ["Pass --base-url <url> or set DECANTR_BROWSER_BASE_URL."],
|
|
740
|
-
rule: "browser-base-url-missing",
|
|
741
|
-
suggestedFix: "Start the app and rerun with `decantr health --browser --base-url <url>`.",
|
|
742
|
-
baseId: "base-url-missing"
|
|
743
|
-
});
|
|
744
|
-
return {
|
|
745
|
-
finding,
|
|
746
|
-
evidence: {
|
|
747
|
-
enabled: true,
|
|
748
|
-
status: "unavailable",
|
|
749
|
-
baseUrl: null,
|
|
750
|
-
screenshots: [],
|
|
751
|
-
findings: [finding.message]
|
|
752
|
-
}
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
const playwright = loadProjectPlaywright(projectRoot);
|
|
756
|
-
if (!playwright) {
|
|
757
|
-
const finding = createHealthFinding({
|
|
758
|
-
source: "browser",
|
|
759
|
-
category: "Browser Verification",
|
|
760
|
-
severity: options.requireBrowser ? "error" : "warn",
|
|
761
|
-
message: "Playwright is installed, but Decantr could not load a Chromium browser adapter.",
|
|
762
|
-
evidence: ["Expected chromium.launch from playwright or @playwright/test."],
|
|
763
|
-
rule: "browser-adapter-missing",
|
|
764
|
-
suggestedFix: "Repair the local Playwright install and rerun `decantr health --browser`.",
|
|
765
|
-
baseId: "adapter-missing"
|
|
766
|
-
});
|
|
767
|
-
return {
|
|
768
|
-
finding,
|
|
769
|
-
evidence: {
|
|
770
|
-
enabled: true,
|
|
771
|
-
status: "unavailable",
|
|
772
|
-
baseUrl: options.browserBaseUrl,
|
|
773
|
-
screenshots: [],
|
|
774
|
-
findings: [finding.message]
|
|
775
|
-
}
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
const routes = (declaredRoutes.length > 0 ? declaredRoutes : ["/"]).slice(0, 12);
|
|
779
|
-
const screenshots = [];
|
|
780
|
-
const browserFindings = [];
|
|
781
|
-
const visualRoutes = [];
|
|
782
|
-
const screenshotDir = join2(projectRoot, ".decantr", "evidence", "screenshots");
|
|
783
|
-
mkdirSync(screenshotDir, { recursive: true });
|
|
784
|
-
let browser = null;
|
|
785
|
-
try {
|
|
786
|
-
browser = await playwright.chromium.launch({ headless: true });
|
|
787
|
-
const page = await browser.newPage();
|
|
788
|
-
for (const route of routes) {
|
|
789
|
-
const url = browserRouteUrl(options.browserBaseUrl, route);
|
|
790
|
-
const relativePath = browserScreenshotRelativePath(route);
|
|
791
|
-
try {
|
|
792
|
-
await page.goto(url, { waitUntil: "networkidle", timeout: 15e3 });
|
|
793
|
-
const absoluteScreenshotPath = join2(projectRoot, relativePath);
|
|
794
|
-
await page.screenshot({ path: absoluteScreenshotPath, fullPage: true });
|
|
795
|
-
screenshots.push(relativePath);
|
|
796
|
-
visualRoutes.push({
|
|
797
|
-
route,
|
|
798
|
-
url,
|
|
799
|
-
screenshot: relativePath,
|
|
800
|
-
screenshotHash: hashFile(absoluteScreenshotPath),
|
|
801
|
-
status: "captured"
|
|
802
|
-
});
|
|
803
|
-
} catch (error) {
|
|
804
|
-
const message = error.message;
|
|
805
|
-
browserFindings.push(`${route}: ${message}`);
|
|
806
|
-
visualRoutes.push({
|
|
807
|
-
route,
|
|
808
|
-
url,
|
|
809
|
-
screenshot: null,
|
|
810
|
-
screenshotHash: null,
|
|
811
|
-
status: "failed",
|
|
812
|
-
error: message
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
} catch (error) {
|
|
817
|
-
browserFindings.push(error.message);
|
|
818
|
-
} finally {
|
|
819
|
-
if (browser) await browser.close();
|
|
820
|
-
}
|
|
821
|
-
const visualManifest = {
|
|
822
|
-
version: 1,
|
|
823
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
824
|
-
localOnly: true,
|
|
825
|
-
baseUrl: options.browserBaseUrl,
|
|
826
|
-
routes: visualRoutes
|
|
827
|
-
};
|
|
828
|
-
const visualManifestPath = join2(projectRoot, ".decantr", "evidence", "visual-manifest.json");
|
|
829
|
-
mkdirSync(dirname2(visualManifestPath), { recursive: true });
|
|
830
|
-
writeFileSync(visualManifestPath, JSON.stringify(visualManifest, null, 2) + "\n", "utf-8");
|
|
831
|
-
if (browserFindings.length > 0) {
|
|
832
|
-
const finding = createHealthFinding({
|
|
833
|
-
source: "browser",
|
|
834
|
-
category: "Browser Verification",
|
|
835
|
-
severity: options.requireBrowser ? "error" : "warn",
|
|
836
|
-
message: "Browser verification could not render every declared route.",
|
|
837
|
-
evidence: browserFindings.slice(0, 5),
|
|
838
|
-
rule: "browser-route-verification-failed",
|
|
839
|
-
suggestedFix: "Start the app at the provided base URL, fix route render errors, and rerun `decantr health --browser --evidence`.",
|
|
840
|
-
baseId: "route-verification-failed"
|
|
841
|
-
});
|
|
842
|
-
return {
|
|
843
|
-
finding,
|
|
844
|
-
evidence: {
|
|
845
|
-
enabled: true,
|
|
846
|
-
status: "failed",
|
|
847
|
-
baseUrl: options.browserBaseUrl,
|
|
848
|
-
screenshots,
|
|
849
|
-
findings: browserFindings
|
|
850
|
-
}
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
return {
|
|
854
|
-
finding: null,
|
|
855
|
-
evidence: {
|
|
856
|
-
enabled: true,
|
|
857
|
-
status: "passed",
|
|
858
|
-
baseUrl: options.browserBaseUrl,
|
|
859
|
-
screenshots,
|
|
860
|
-
findings: []
|
|
861
|
-
}
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
function flattenDesignTokenKeys(value, prefix = "") {
|
|
865
|
-
const keys = /* @__PURE__ */ new Set();
|
|
866
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return keys;
|
|
867
|
-
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
868
|
-
const key = prefix ? `${prefix}.${rawKey}` : rawKey;
|
|
869
|
-
if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) && ("$value" in rawValue || "value" in rawValue)) {
|
|
870
|
-
keys.add(key);
|
|
871
|
-
keys.add(rawKey);
|
|
872
|
-
} else if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
|
|
873
|
-
for (const nested of flattenDesignTokenKeys(rawValue, key)) keys.add(nested);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
return keys;
|
|
877
|
-
}
|
|
878
|
-
function parseDecantrCssTokenNames(projectRoot) {
|
|
879
|
-
const tokensPath = join2(projectRoot, "src", "styles", "tokens.css");
|
|
880
|
-
if (!existsSync2(tokensPath)) return [];
|
|
881
|
-
const css = readFileSync2(tokensPath, "utf-8");
|
|
882
|
-
const names = /* @__PURE__ */ new Set();
|
|
883
|
-
for (const match of css.matchAll(/(--d-[\w-]+)\s*:/g)) {
|
|
884
|
-
names.add(match[1]);
|
|
885
|
-
}
|
|
886
|
-
return [...names].sort();
|
|
887
|
-
}
|
|
888
|
-
function collectDesignTokenEvidence(projectRoot, designTokensPath) {
|
|
889
|
-
const resolved = resolveOptionalPath(projectRoot, designTokensPath);
|
|
890
|
-
if (!resolved) return void 0;
|
|
891
|
-
const sourceLabel = isAbsolute2(designTokensPath ?? "") ? "<design-tokens>" : designTokensPath ?? "<design-tokens>";
|
|
892
|
-
if (!existsSync2(resolved)) {
|
|
893
|
-
return {
|
|
894
|
-
source: sourceLabel,
|
|
895
|
-
status: "error",
|
|
896
|
-
compared: 0,
|
|
897
|
-
matched: 0,
|
|
898
|
-
missing: ["design-token-source-missing"]
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
const decantrTokens = parseDecantrCssTokenNames(projectRoot);
|
|
902
|
-
const parsed = JSON.parse(readFileSync2(resolved, "utf-8"));
|
|
903
|
-
const designKeys = flattenDesignTokenKeys(parsed);
|
|
904
|
-
const missing = decantrTokens.filter((token) => {
|
|
905
|
-
const bare = token.replace(/^--/, "");
|
|
906
|
-
return !designKeys.has(token) && !designKeys.has(bare) && !designKeys.has(bare.replace(/^d-/, ""));
|
|
907
|
-
});
|
|
908
|
-
return {
|
|
909
|
-
source: sourceLabel,
|
|
910
|
-
status: missing.length === 0 ? "passed" : "warning",
|
|
911
|
-
compared: decantrTokens.length,
|
|
912
|
-
matched: decantrTokens.length - missing.length,
|
|
913
|
-
missing
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
function collectDesignTokenFinding(projectRoot, designTokensPath) {
|
|
917
|
-
const evidence = collectDesignTokenEvidence(projectRoot, designTokensPath);
|
|
918
|
-
if (!evidence) return null;
|
|
919
|
-
if (evidence.status === "passed") {
|
|
920
|
-
return createHealthFinding({
|
|
921
|
-
source: "design-token",
|
|
922
|
-
category: "Design Tokens",
|
|
923
|
-
severity: "info",
|
|
924
|
-
message: "Imported design-token source covers Decantr token names.",
|
|
925
|
-
evidence: [`matched=${evidence.matched}/${evidence.compared}`],
|
|
926
|
-
rule: "design-token-coverage",
|
|
927
|
-
baseId: "coverage-passed"
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
return createHealthFinding({
|
|
931
|
-
source: "design-token",
|
|
932
|
-
category: "Design Tokens",
|
|
933
|
-
severity: evidence.status === "error" ? "error" : "warn",
|
|
934
|
-
message: "Imported design-token source does not cover all Decantr token names.",
|
|
935
|
-
evidence: [
|
|
936
|
-
`matched=${evidence.matched}/${evidence.compared}`,
|
|
937
|
-
evidence.missing.slice(0, 12).join(", ") || "No Decantr CSS tokens found."
|
|
938
|
-
],
|
|
939
|
-
rule: "design-token-coverage",
|
|
940
|
-
suggestedFix: "Update the Figma/Tokens Studio export or Decantr token mapping so shared UI policy can be verified.",
|
|
941
|
-
baseId: "coverage-missing"
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
function baselinePath(projectRoot) {
|
|
945
|
-
return join2(projectRoot, ".decantr", "health-baseline.json");
|
|
946
|
-
}
|
|
947
|
-
function baselineDiffPath(projectRoot) {
|
|
948
|
-
return join2(projectRoot, ".decantr", "health-baseline-diff.json");
|
|
949
|
-
}
|
|
950
|
-
function screenshotHashes(projectRoot) {
|
|
951
|
-
const manifest = readJsonFile(
|
|
952
|
-
join2(projectRoot, ".decantr", "evidence", "visual-manifest.json")
|
|
953
|
-
);
|
|
954
|
-
if (manifest?.routes) {
|
|
955
|
-
return manifest.routes.filter((route) => typeof route.screenshot === "string").map((route) => ({
|
|
956
|
-
path: route.screenshot,
|
|
957
|
-
hash: route.screenshotHash ?? hashFile(join2(projectRoot, route.screenshot))
|
|
958
|
-
}));
|
|
959
|
-
}
|
|
960
|
-
return [];
|
|
961
|
-
}
|
|
962
|
-
function changedFilesSinceBaseline(projectRoot) {
|
|
963
|
-
const changed = /* @__PURE__ */ new Set();
|
|
964
|
-
try {
|
|
965
|
-
for (const args of [
|
|
966
|
-
["diff", "--name-only"],
|
|
967
|
-
["diff", "--name-only", "--cached"]
|
|
968
|
-
]) {
|
|
969
|
-
const output = execFileSync("git", args, {
|
|
970
|
-
cwd: projectRoot,
|
|
971
|
-
encoding: "utf-8",
|
|
972
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
973
|
-
});
|
|
974
|
-
for (const entry of output.split(/\r?\n/)) {
|
|
975
|
-
const file = entry.trim();
|
|
976
|
-
if (file) changed.add(file);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
} catch {
|
|
980
|
-
}
|
|
981
|
-
return [...changed].sort();
|
|
982
|
-
}
|
|
983
|
-
function routeImpactsFromChangedFiles(report, changedFiles) {
|
|
984
|
-
const analysis = readJsonFile(
|
|
985
|
-
join2(report.projectRoot, ".decantr", "analysis.json")
|
|
986
|
-
);
|
|
987
|
-
const routeEntries = analysis?.routes?.routes ?? [];
|
|
988
|
-
const impacted = /* @__PURE__ */ new Set();
|
|
989
|
-
for (const file of changedFiles) {
|
|
990
|
-
for (const route of routeEntries) {
|
|
991
|
-
if (route.file && (file === route.file || file.endsWith(route.file))) {
|
|
992
|
-
if (route.path) impacted.add(route.path);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return [...impacted].sort();
|
|
997
|
-
}
|
|
998
|
-
function createHealthBaseline(projectRoot, report) {
|
|
999
|
-
return {
|
|
1000
|
-
version: 1,
|
|
1001
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1002
|
-
status: report.status,
|
|
1003
|
-
score: report.score,
|
|
1004
|
-
findings: report.findings.map((finding) => ({
|
|
1005
|
-
id: finding.id,
|
|
1006
|
-
severity: finding.severity,
|
|
1007
|
-
source: finding.source,
|
|
1008
|
-
message: finding.message
|
|
1009
|
-
})),
|
|
1010
|
-
routes: report.routes.declared,
|
|
1011
|
-
packs: report.packs,
|
|
1012
|
-
screenshots: screenshotHashes(projectRoot),
|
|
1013
|
-
changedFilesCommand: "git diff --name-only + --cached"
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
function saveHealthBaseline(projectRoot, report) {
|
|
1017
|
-
const path = baselinePath(projectRoot);
|
|
1018
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
1019
|
-
writeFileSync(
|
|
1020
|
-
path,
|
|
1021
|
-
JSON.stringify(createHealthBaseline(projectRoot, report), null, 2) + "\n",
|
|
1022
|
-
"utf-8"
|
|
1023
|
-
);
|
|
1024
|
-
return path;
|
|
1025
|
-
}
|
|
1026
|
-
function compareHealthBaseline(projectRoot, report) {
|
|
1027
|
-
const path = baselinePath(projectRoot);
|
|
1028
|
-
const baseline = readJsonFile(path);
|
|
1029
|
-
const currentFindingIds = new Set(report.findings.map((finding) => finding.id));
|
|
1030
|
-
const baselineFindingIds = new Set(baseline?.findings.map((finding) => finding.id) ?? []);
|
|
1031
|
-
const changedFiles = changedFilesSinceBaseline(projectRoot);
|
|
1032
|
-
const currentScreenshots = new Map(
|
|
1033
|
-
screenshotHashes(projectRoot).map((entry) => [entry.path, entry.hash])
|
|
1034
|
-
);
|
|
1035
|
-
const changedScreenshots = baseline?.screenshots.filter(
|
|
1036
|
-
(entry) => currentScreenshots.has(entry.path) && currentScreenshots.get(entry.path) !== entry.hash
|
|
1037
|
-
).map((entry) => entry.path) ?? [];
|
|
1038
|
-
const contractDrift = [
|
|
1039
|
-
baseline && baseline.routes.join("\n") !== report.routes.declared.join("\n") ? "Declared route set changed since baseline." : null,
|
|
1040
|
-
baseline && baseline.packs.generatedAt !== report.packs.generatedAt ? "Execution-pack generation timestamp changed since baseline." : null
|
|
1041
|
-
].filter((entry) => Boolean(entry));
|
|
1042
|
-
return {
|
|
1043
|
-
baselinePath: path,
|
|
1044
|
-
savedAt: baseline?.generatedAt ?? null,
|
|
1045
|
-
statusChanged: baseline ? baseline.status !== report.status : false,
|
|
1046
|
-
scoreDelta: baseline ? report.score - baseline.score : null,
|
|
1047
|
-
addedFindings: [...currentFindingIds].filter((id) => !baselineFindingIds.has(id)).sort(),
|
|
1048
|
-
resolvedFindings: [...baselineFindingIds].filter((id) => !currentFindingIds.has(id)).sort(),
|
|
1049
|
-
changedFiles,
|
|
1050
|
-
changedRoutes: routeImpactsFromChangedFiles(report, changedFiles),
|
|
1051
|
-
changedScreenshots,
|
|
1052
|
-
contractDrift
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
function saveHealthBaselineComparison(projectRoot, comparison) {
|
|
1056
|
-
const path = baselineDiffPath(projectRoot);
|
|
1057
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
1058
|
-
writeFileSync(path, JSON.stringify(comparison, null, 2) + "\n", "utf-8");
|
|
1059
|
-
return path;
|
|
1060
|
-
}
|
|
1061
|
-
function formatBaselineComparisonText(comparison) {
|
|
1062
|
-
const lines = [
|
|
1063
|
-
"",
|
|
1064
|
-
`${BOLD}Continuity:${RESET}`,
|
|
1065
|
-
` Baseline: ${comparison.savedAt ?? "missing"} (${comparison.baselinePath})`,
|
|
1066
|
-
` Score delta: ${comparison.scoreDelta == null ? "n/a" : comparison.scoreDelta >= 0 ? `+${comparison.scoreDelta}` : String(comparison.scoreDelta)}`,
|
|
1067
|
-
` Added findings: ${comparison.addedFindings.length}`,
|
|
1068
|
-
` Resolved findings: ${comparison.resolvedFindings.length}`,
|
|
1069
|
-
` Changed files: ${comparison.changedFiles.length}`,
|
|
1070
|
-
` Route impact: ${comparison.changedRoutes.length > 0 ? comparison.changedRoutes.join(", ") : "none detected"}`,
|
|
1071
|
-
` Screenshot drift: ${comparison.changedScreenshots.length}`,
|
|
1072
|
-
` Contract drift: ${comparison.contractDrift.length > 0 ? comparison.contractDrift.join(" ") : "none detected"}`
|
|
1073
|
-
];
|
|
1074
|
-
return `${lines.join("\n")}
|
|
1075
|
-
`;
|
|
1076
|
-
}
|
|
1077
|
-
async function browserEvidenceFromOptions(projectRoot, options, declaredRoutes) {
|
|
1078
|
-
if (!options.browser) return void 0;
|
|
1079
|
-
const result = await collectBrowserVerification(projectRoot, options, declaredRoutes);
|
|
1080
|
-
return result?.evidence;
|
|
1081
|
-
}
|
|
1082
|
-
async function createProjectHealthReport(projectRoot = process.cwd(), options = {}) {
|
|
1083
|
-
const metadata = readProjectMetadata(projectRoot);
|
|
1084
|
-
const audit = await auditProject(projectRoot);
|
|
1085
|
-
const findings = [];
|
|
1086
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1087
|
-
for (const finding of audit.findings) {
|
|
1088
|
-
const healthFinding = createHealthFinding({
|
|
1089
|
-
source: sourceFromAuditFinding(finding),
|
|
1090
|
-
category: finding.category,
|
|
1091
|
-
severity: finding.severity,
|
|
1092
|
-
message: finding.message,
|
|
1093
|
-
evidence: finding.evidence,
|
|
1094
|
-
target: finding.target,
|
|
1095
|
-
file: finding.file,
|
|
1096
|
-
rule: finding.rule,
|
|
1097
|
-
suggestedFix: finding.suggestedFix,
|
|
1098
|
-
baseId: finding.id
|
|
1099
|
-
});
|
|
1100
|
-
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
1101
|
-
}
|
|
1102
|
-
for (const contractAssertion of createContractAssertions(projectRoot, audit)) {
|
|
1103
|
-
if (!contractAssertionApplies(contractAssertion, metadata)) continue;
|
|
1104
|
-
if (contractAssertion.status !== "failed") continue;
|
|
1105
|
-
const healthFinding = createHealthFinding({
|
|
1106
|
-
source: "assertion",
|
|
1107
|
-
category: `Contract ${contractAssertion.category}`,
|
|
1108
|
-
severity: contractAssertion.severity,
|
|
1109
|
-
message: contractAssertion.message,
|
|
1110
|
-
evidence: contractAssertion.evidence,
|
|
1111
|
-
target: contractAssertion.target,
|
|
1112
|
-
rule: contractAssertion.rule,
|
|
1113
|
-
suggestedFix: contractAssertion.suggestedFix,
|
|
1114
|
-
baseId: contractAssertion.id
|
|
1115
|
-
});
|
|
1116
|
-
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
1117
|
-
}
|
|
1118
|
-
const designTokenFinding = collectDesignTokenFinding(projectRoot, options.designTokensPath);
|
|
1119
|
-
if (designTokenFinding && !isDuplicateFinding(seen, designTokenFinding)) {
|
|
1120
|
-
findings.push(designTokenFinding);
|
|
1121
|
-
}
|
|
1122
|
-
try {
|
|
1123
|
-
const check = collectCheckIssues(projectRoot, { brownfield: metadata.autoBrownfield });
|
|
1124
|
-
for (const issue of check.issues) {
|
|
1125
|
-
const source = sourceFromCheckIssue(issue);
|
|
1126
|
-
const healthFinding = createHealthFinding({
|
|
1127
|
-
source,
|
|
1128
|
-
category: source === "brownfield" ? "Brownfield Drift" : "Contract Check",
|
|
1129
|
-
severity: severityFromCheckIssue(issue),
|
|
1130
|
-
message: issue.message,
|
|
1131
|
-
evidence: [`Rule: ${issue.rule}`],
|
|
1132
|
-
rule: issue.rule,
|
|
1133
|
-
suggestedFix: issue.suggestion,
|
|
1134
|
-
baseId: issue.rule
|
|
1135
|
-
});
|
|
1136
|
-
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
1137
|
-
}
|
|
1138
|
-
} catch (e) {
|
|
1139
|
-
const healthFinding = createHealthFinding({
|
|
1140
|
-
source: "check",
|
|
1141
|
-
category: "Contract Check",
|
|
1142
|
-
severity: "error",
|
|
1143
|
-
message: `Decantr check could not complete: ${e.message}`,
|
|
1144
|
-
evidence: ["The health command could not run the local check pass."],
|
|
1145
|
-
rule: "check-failed",
|
|
1146
|
-
suggestedFix: "Repair the local Decantr contract and rerun `decantr health`.",
|
|
1147
|
-
baseId: "check-failed"
|
|
1148
|
-
});
|
|
1149
|
-
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
1150
|
-
}
|
|
1151
|
-
if (!audit.valid && findings.every((finding) => finding.severity !== "error")) {
|
|
1152
|
-
findings.push(
|
|
1153
|
-
createHealthFinding({
|
|
1154
|
-
source: "audit",
|
|
1155
|
-
category: "Project Contract",
|
|
1156
|
-
severity: "error",
|
|
1157
|
-
message: "Project audit is not valid.",
|
|
1158
|
-
evidence: ["The verifier returned valid=false."],
|
|
1159
|
-
rule: "project-audit-invalid",
|
|
1160
|
-
suggestedFix: "Resolve blocking audit findings and rerun `decantr health`."
|
|
1161
|
-
})
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1164
|
-
const declaredRoutes = collectDeclaredRoutes(audit.essence);
|
|
1165
|
-
const manifest = audit.packManifest;
|
|
1166
|
-
for (const consistencyFinding of collectContractPackConsistencyFindings(
|
|
1167
|
-
projectRoot,
|
|
1168
|
-
audit.essence,
|
|
1169
|
-
manifest
|
|
1170
|
-
)) {
|
|
1171
|
-
if (!isDuplicateFinding(seen, consistencyFinding)) findings.push(consistencyFinding);
|
|
1172
|
-
}
|
|
1173
|
-
const browserVerification = await collectBrowserVerification(
|
|
1174
|
-
projectRoot,
|
|
1175
|
-
options,
|
|
1176
|
-
declaredRoutes
|
|
1177
|
-
);
|
|
1178
|
-
if (browserVerification?.finding && !isDuplicateFinding(seen, browserVerification.finding)) {
|
|
1179
|
-
findings.push(browserVerification.finding);
|
|
1180
|
-
}
|
|
1181
|
-
const scopedFindings = scopeHealthFindingsToProject(projectRoot, findings);
|
|
1182
|
-
const finalCounts = countFindings(scopedFindings);
|
|
1183
|
-
const commandContext = commandContextForProject(projectRoot);
|
|
1184
|
-
return {
|
|
1185
|
-
$schema: PROJECT_HEALTH_SCHEMA_URL,
|
|
1186
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1187
|
-
projectRoot,
|
|
1188
|
-
status: statusFromCounts(finalCounts),
|
|
1189
|
-
score: scoreFromCounts(finalCounts),
|
|
1190
|
-
summary: {
|
|
1191
|
-
...finalCounts,
|
|
1192
|
-
findingCount: findings.length,
|
|
1193
|
-
workflowMode: metadata.workflowMode,
|
|
1194
|
-
adoptionMode: metadata.adoptionMode,
|
|
1195
|
-
essenceVersion: audit.summary.essenceVersion,
|
|
1196
|
-
pageCount: audit.summary.pageCount,
|
|
1197
|
-
runtimeAuditChecked: audit.summary.runtimeAuditChecked,
|
|
1198
|
-
runtimePassed: audit.summary.runtimePassed,
|
|
1199
|
-
packManifestPresent: audit.summary.packManifestPresent,
|
|
1200
|
-
reviewPackPresent: audit.summary.reviewPackPresent
|
|
1201
|
-
},
|
|
1202
|
-
routes: {
|
|
1203
|
-
declared: declaredRoutes,
|
|
1204
|
-
runtimeChecked: audit.runtimeAudit.routeHintsChecked,
|
|
1205
|
-
runtimeMatched: audit.runtimeAudit.routeHintsMatched,
|
|
1206
|
-
runtimeCoverageOk: audit.summary.runtimeAuditChecked ? audit.runtimeAudit.routeHintsCoverageOk : null,
|
|
1207
|
-
issues: routeIssuesFromFindings(scopedFindings)
|
|
1208
|
-
},
|
|
1209
|
-
packs: {
|
|
1210
|
-
manifestPresent: Boolean(manifest),
|
|
1211
|
-
reviewPackPresent: Boolean(manifest?.review ?? audit.reviewPack),
|
|
1212
|
-
scaffoldPackPresent: Boolean(manifest?.scaffold),
|
|
1213
|
-
sectionPackCount: manifest?.sections.length ?? 0,
|
|
1214
|
-
pagePackCount: manifest?.pages.length ?? 0,
|
|
1215
|
-
mutationPackCount: manifest?.mutations?.length ?? 0,
|
|
1216
|
-
generatedAt: typeof manifest?.generatedAt === "string" ? manifest.generatedAt : null
|
|
1217
|
-
},
|
|
1218
|
-
ci: {
|
|
1219
|
-
recommendedCommand: commandContext.ciCommand,
|
|
1220
|
-
failOn: "error"
|
|
1221
|
-
},
|
|
1222
|
-
findings: scopedFindings
|
|
1223
|
-
};
|
|
1224
|
-
}
|
|
1225
|
-
function colorForStatus(status) {
|
|
1226
|
-
if (status === "healthy") return GREEN;
|
|
1227
|
-
if (status === "warning") return YELLOW;
|
|
1228
|
-
return RED;
|
|
1229
|
-
}
|
|
1230
|
-
function formatProjectHealthText(report) {
|
|
1231
|
-
const color = colorForStatus(report.status);
|
|
1232
|
-
const commandContext = commandContextForProject(report.projectRoot);
|
|
1233
|
-
const lines = [
|
|
1234
|
-
`${BOLD}Decantr Project Health${RESET}`,
|
|
1235
|
-
"",
|
|
1236
|
-
`${color}${report.status.toUpperCase()}${RESET} score ${report.score}/100`,
|
|
1237
|
-
`${DIM}${report.projectRoot}${RESET}`,
|
|
1238
|
-
"",
|
|
1239
|
-
`${BOLD}Summary:${RESET}`,
|
|
1240
|
-
` Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
|
|
1241
|
-
` Essence: ${report.summary.essenceVersion ?? "missing"} | pages ${report.summary.pageCount}`,
|
|
1242
|
-
` Workflow: ${report.summary.workflowMode ?? "unknown"} | adoption ${report.summary.adoptionMode ?? "unknown"}`,
|
|
1243
|
-
` Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
|
|
1244
|
-
` Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"} | review ${report.packs.reviewPackPresent ? "present" : "missing"} | pages ${report.packs.pagePackCount}`,
|
|
1245
|
-
"",
|
|
1246
|
-
`${BOLD}Findings:${RESET}`
|
|
1247
|
-
];
|
|
1248
|
-
if (report.findings.length === 0) {
|
|
1249
|
-
lines.push(` ${GREEN}No findings. Project is healthy.${RESET}`);
|
|
1250
|
-
} else {
|
|
1251
|
-
for (const finding of report.findings) {
|
|
1252
|
-
const findingColor = finding.severity === "error" ? RED : finding.severity === "warn" ? YELLOW : CYAN;
|
|
1253
|
-
lines.push(
|
|
1254
|
-
` ${findingColor}[${finding.severity.toUpperCase()}]${RESET} ${finding.id}: ${finding.message}`
|
|
1255
|
-
);
|
|
1256
|
-
if (finding.evidence.length > 0) {
|
|
1257
|
-
lines.push(` ${DIM}${finding.evidence[0]}${RESET}`);
|
|
1258
|
-
}
|
|
1259
|
-
if (finding.suggestedFix) {
|
|
1260
|
-
lines.push(` ${DIM}Fix: ${finding.suggestedFix}${RESET}`);
|
|
1261
|
-
}
|
|
1262
|
-
lines.push(` ${DIM}Prompt: ${commandContext.promptCommand(finding.id)}${RESET}`);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
lines.push("");
|
|
1266
|
-
lines.push(`${BOLD}CI:${RESET} ${report.ci.recommendedCommand}`);
|
|
1267
|
-
return `${lines.join("\n")}
|
|
1268
|
-
`;
|
|
1269
|
-
}
|
|
1270
|
-
function formatProjectHealthMarkdown(report) {
|
|
1271
|
-
const commandContext = commandContextForProject(report.projectRoot);
|
|
1272
|
-
const lines = [
|
|
1273
|
-
"# Decantr Project Health",
|
|
1274
|
-
"",
|
|
1275
|
-
`- Status: **${report.status}**`,
|
|
1276
|
-
`- Score: **${report.score}/100**`,
|
|
1277
|
-
`- Project: \`${report.projectRoot}\``,
|
|
1278
|
-
`- Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
|
|
1279
|
-
`- Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
|
|
1280
|
-
`- Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"}, review ${report.packs.reviewPackPresent ? "present" : "missing"}`,
|
|
1281
|
-
"",
|
|
1282
|
-
"## Findings",
|
|
1283
|
-
""
|
|
1284
|
-
];
|
|
1285
|
-
if (report.findings.length === 0) {
|
|
1286
|
-
lines.push("No findings. Project is healthy.");
|
|
1287
|
-
} else {
|
|
1288
|
-
for (const finding of report.findings) {
|
|
1289
|
-
lines.push(`### ${finding.id}`);
|
|
1290
|
-
lines.push("");
|
|
1291
|
-
lines.push(`- Severity: ${finding.severity}`);
|
|
1292
|
-
lines.push(`- Source: ${finding.source}`);
|
|
1293
|
-
lines.push(`- Category: ${finding.category}`);
|
|
1294
|
-
lines.push(`- Message: ${finding.message}`);
|
|
1295
|
-
if (finding.suggestedFix) lines.push(`- Fix: ${finding.suggestedFix}`);
|
|
1296
|
-
if (finding.evidence.length > 0) {
|
|
1297
|
-
lines.push("- Evidence:");
|
|
1298
|
-
for (const evidence of finding.evidence) lines.push(` - ${evidence}`);
|
|
1299
|
-
}
|
|
1300
|
-
lines.push(`- Prompt: \`${commandContext.promptCommand(finding.id)}\``);
|
|
1301
|
-
lines.push("");
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
lines.push("## CI");
|
|
1305
|
-
lines.push("");
|
|
1306
|
-
lines.push(`\`${report.ci.recommendedCommand}\``);
|
|
1307
|
-
return `${lines.join("\n")}
|
|
1308
|
-
`;
|
|
1309
|
-
}
|
|
1310
|
-
function formatProjectHealthJson(report) {
|
|
1311
|
-
return `${JSON.stringify(report, null, 2)}
|
|
1312
|
-
`;
|
|
1313
|
-
}
|
|
1314
|
-
async function createProjectEvidenceBundle(projectRoot, report, options = {}) {
|
|
1315
|
-
const audit = await auditProject(projectRoot);
|
|
1316
|
-
const assertions = createContractAssertions(projectRoot, audit);
|
|
1317
|
-
return createEvidenceBundle({
|
|
1318
|
-
projectRoot,
|
|
1319
|
-
report,
|
|
1320
|
-
audit,
|
|
1321
|
-
assertions,
|
|
1322
|
-
workspaceConfigPath: existsSync2(join2(projectRoot, ".decantr", "workspace.json")) ? join2(projectRoot, ".decantr", "workspace.json") : null,
|
|
1323
|
-
designTokensPath: resolveOptionalPath(projectRoot, options.designTokensPath) ?? null,
|
|
1324
|
-
browser: await browserEvidenceFromOptions(projectRoot, options, report.routes.declared),
|
|
1325
|
-
designTokens: collectDesignTokenEvidence(projectRoot, options.designTokensPath)
|
|
1326
|
-
});
|
|
1327
|
-
}
|
|
1328
|
-
function resolveFormat(options) {
|
|
1329
|
-
if (options.json) return "json";
|
|
1330
|
-
if (options.markdown) return "markdown";
|
|
1331
|
-
return options.format ?? "text";
|
|
1332
|
-
}
|
|
1333
|
-
function shouldFailHealth(report, failOn) {
|
|
1334
|
-
if (failOn === "none") return false;
|
|
1335
|
-
if (failOn === "warn") return report.summary.errorCount > 0 || report.summary.warnCount > 0;
|
|
1336
|
-
return report.summary.errorCount > 0;
|
|
1337
|
-
}
|
|
1338
|
-
async function cmdHealth(projectRoot = process.cwd(), options = {}) {
|
|
1339
|
-
if (options.initCi) {
|
|
1340
|
-
try {
|
|
1341
|
-
const result = writeProjectHealthCiWorkflow(projectRoot, options.initCi);
|
|
1342
|
-
const action = result.created ? "Created" : "Updated";
|
|
1343
|
-
console.log(`${GREEN}${action} Decantr Project Health workflow:${RESET} ${result.path}`);
|
|
1344
|
-
console.log(`${DIM}CLI package: ${result.cliPackage}${RESET}`);
|
|
1345
|
-
if (result.projectPath) {
|
|
1346
|
-
console.log(`${DIM}Project: ${result.projectPath}${RESET}`);
|
|
1347
|
-
}
|
|
1348
|
-
if (result.workspace) {
|
|
1349
|
-
console.log(`${DIM}Workspace mode enabled.${RESET}`);
|
|
1350
|
-
}
|
|
1351
|
-
console.log(
|
|
1352
|
-
`${DIM}CI gate: decantr ${result.workspace ? "workspace health" : "health"} --ci --fail-on ${result.failOn}${RESET}`
|
|
1353
|
-
);
|
|
1354
|
-
} catch (e) {
|
|
1355
|
-
console.error(`${RED}${e.message}${RESET}`);
|
|
1356
|
-
process.exitCode = 1;
|
|
1357
|
-
}
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
const startedAt = Date.now();
|
|
1361
|
-
const reportOptions = {
|
|
1362
|
-
browser: options.browser,
|
|
1363
|
-
requireBrowser: options.requireBrowser,
|
|
1364
|
-
browserBaseUrl: options.browserBaseUrl ?? process.env.DECANTR_BROWSER_BASE_URL,
|
|
1365
|
-
designTokensPath: options.designTokensPath
|
|
1366
|
-
};
|
|
1367
|
-
const report = await createProjectHealthReport(projectRoot, reportOptions);
|
|
1368
|
-
const baselineComparison = options.sinceBaseline ? compareHealthBaseline(projectRoot, report) : null;
|
|
1369
|
-
const baselineComparisonPath = baselineComparison ? saveHealthBaselineComparison(projectRoot, baselineComparison) : null;
|
|
1370
|
-
if (options.promptId) {
|
|
1371
|
-
const finding = report.findings.find((entry) => entry.id === options.promptId);
|
|
1372
|
-
await sendProjectHealthReportTelemetry({
|
|
1373
|
-
ci: options.ci ?? false,
|
|
1374
|
-
durationMs: Date.now() - startedAt,
|
|
1375
|
-
projectRoot,
|
|
1376
|
-
report
|
|
1377
|
-
});
|
|
1378
|
-
await sendProjectHealthPromptTelemetry({
|
|
1379
|
-
ci: options.ci ?? false,
|
|
1380
|
-
finding,
|
|
1381
|
-
projectRoot,
|
|
1382
|
-
report
|
|
1383
|
-
});
|
|
1384
|
-
if (!finding) {
|
|
1385
|
-
console.error(`${RED}No health finding found for id: ${options.promptId}${RESET}`);
|
|
1386
|
-
process.exitCode = 1;
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
console.log(finding.remediation.prompt);
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
const format = resolveFormat(options);
|
|
1393
|
-
const failOn = options.failOn ?? "error";
|
|
1394
|
-
const evidenceBundle = options.evidence ? await createProjectEvidenceBundle(projectRoot, report, reportOptions) : null;
|
|
1395
|
-
const basePayload = options.evidence ? `${JSON.stringify(evidenceBundle, null, 2)}
|
|
1396
|
-
` : format === "json" ? formatProjectHealthJson(report) : format === "markdown" ? formatProjectHealthMarkdown(report) : formatProjectHealthText(report);
|
|
1397
|
-
const payload = baselineComparison && !options.evidence && format === "text" ? `${basePayload}${formatBaselineComparisonText(baselineComparison)}` : basePayload;
|
|
1398
|
-
if (options.output) {
|
|
1399
|
-
const outputPath = isAbsolute2(options.output) ? options.output : join2(projectRoot, options.output);
|
|
1400
|
-
mkdirSync(dirname2(outputPath), { recursive: true });
|
|
1401
|
-
writeFileSync(outputPath, payload, "utf-8");
|
|
1402
|
-
if (!options.ci) {
|
|
1403
|
-
console.log(
|
|
1404
|
-
`${GREEN}Wrote Decantr ${options.evidence ? "evidence bundle" : "health report"}:${RESET} ${options.output}`
|
|
1405
|
-
);
|
|
1406
|
-
if (options.browser && evidenceBundle?.browser?.status === "unavailable") {
|
|
1407
|
-
const reason = evidenceBundle.browser.findings[0] ?? "Playwright is not available to Decantr in this project.";
|
|
1408
|
-
console.log(`${YELLOW}Browser evidence unavailable:${RESET} ${reason}`);
|
|
1409
|
-
console.log(
|
|
1410
|
-
`${DIM}Static evidence was still written. Install Playwright or omit --browser/--base-url evidence if screenshots are not needed.${RESET}`
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
} else {
|
|
1415
|
-
process.stdout.write(payload);
|
|
1416
|
-
}
|
|
1417
|
-
await sendProjectHealthReportTelemetry({
|
|
1418
|
-
ci: options.ci ?? false,
|
|
1419
|
-
durationMs: Date.now() - startedAt,
|
|
1420
|
-
failOn,
|
|
1421
|
-
format,
|
|
1422
|
-
outputWritten: Boolean(options.output),
|
|
1423
|
-
projectRoot,
|
|
1424
|
-
report
|
|
1425
|
-
});
|
|
1426
|
-
if (options.saveBaseline) {
|
|
1427
|
-
const path = saveHealthBaseline(projectRoot, report);
|
|
1428
|
-
if (!options.ci && !options.output && format !== "json" && !options.evidence) {
|
|
1429
|
-
console.log(`${GREEN}Saved Decantr health baseline:${RESET} ${path}`);
|
|
1430
|
-
} else if (!options.ci && options.output) {
|
|
1431
|
-
console.log(`${GREEN}Saved Decantr health baseline:${RESET} ${path}`);
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
if (baselineComparisonPath && !options.ci && options.output) {
|
|
1435
|
-
console.log(
|
|
1436
|
-
`${GREEN}Wrote Decantr health baseline comparison:${RESET} ${baselineComparisonPath}`
|
|
1437
|
-
);
|
|
1438
|
-
}
|
|
1439
|
-
if (options.ci && shouldFailHealth(report, failOn)) {
|
|
1440
|
-
if (failOn !== "none") {
|
|
1441
|
-
await sendProjectHealthCiFailedTelemetry({
|
|
1442
|
-
ci: true,
|
|
1443
|
-
durationMs: Date.now() - startedAt,
|
|
1444
|
-
failOn,
|
|
1445
|
-
format,
|
|
1446
|
-
outputWritten: Boolean(options.output),
|
|
1447
|
-
projectRoot,
|
|
1448
|
-
report
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
process.exitCode = 1;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
function parseHealthArgs(args) {
|
|
1455
|
-
const options = {};
|
|
1456
|
-
if (args[1] === "init-ci") {
|
|
1457
|
-
options.initCi = {};
|
|
1458
|
-
for (let index = 2; index < args.length; index += 1) {
|
|
1459
|
-
const arg = args[index];
|
|
1460
|
-
if (arg === "--force") {
|
|
1461
|
-
options.initCi.force = true;
|
|
1462
|
-
} else if (arg === "--fail-on" && args[index + 1]) {
|
|
1463
|
-
options.initCi.failOn = args[++index];
|
|
1464
|
-
} else if (arg.startsWith("--fail-on=")) {
|
|
1465
|
-
options.initCi.failOn = arg.split("=")[1];
|
|
1466
|
-
} else if ((arg === "--cli-version" || arg === "--cli") && args[index + 1]) {
|
|
1467
|
-
options.initCi.cliVersion = args[++index];
|
|
1468
|
-
} else if (arg.startsWith("--cli-version=")) {
|
|
1469
|
-
options.initCi.cliVersion = arg.split("=")[1];
|
|
1470
|
-
} else if (arg.startsWith("--cli=")) {
|
|
1471
|
-
options.initCi.cliVersion = arg.split("=")[1];
|
|
1472
|
-
} else if (arg === "--workflow-path" && args[index + 1]) {
|
|
1473
|
-
options.initCi.workflowPath = args[++index];
|
|
1474
|
-
} else if (arg.startsWith("--workflow-path=")) {
|
|
1475
|
-
options.initCi.workflowPath = arg.split("=")[1];
|
|
1476
|
-
} else if (arg === "--report-path" && args[index + 1]) {
|
|
1477
|
-
options.initCi.reportPath = args[++index];
|
|
1478
|
-
} else if (arg.startsWith("--report-path=")) {
|
|
1479
|
-
options.initCi.reportPath = arg.split("=")[1];
|
|
1480
|
-
} else if (arg === "--json-path" && args[index + 1]) {
|
|
1481
|
-
options.initCi.jsonPath = args[++index];
|
|
1482
|
-
} else if (arg.startsWith("--json-path=")) {
|
|
1483
|
-
options.initCi.jsonPath = arg.split("=")[1];
|
|
1484
|
-
} else if (arg === "--project" && args[index + 1]) {
|
|
1485
|
-
options.initCi.projectPath = args[++index];
|
|
1486
|
-
} else if (arg.startsWith("--project=")) {
|
|
1487
|
-
options.initCi.projectPath = arg.split("=")[1];
|
|
1488
|
-
} else if (arg === "--workspace") {
|
|
1489
|
-
options.initCi.workspace = true;
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
normalizeHealthFailOn(options.initCi.failOn);
|
|
1493
|
-
if (!options.initCi.workspace) validateProjectPath(options.initCi.projectPath);
|
|
1494
|
-
return options;
|
|
1495
|
-
}
|
|
1496
|
-
for (let index = 1; index < args.length; index += 1) {
|
|
1497
|
-
const arg = args[index];
|
|
1498
|
-
if (arg === "--json") {
|
|
1499
|
-
options.json = true;
|
|
1500
|
-
} else if (arg === "--markdown") {
|
|
1501
|
-
options.markdown = true;
|
|
1502
|
-
} else if (arg === "--evidence") {
|
|
1503
|
-
options.evidence = true;
|
|
1504
|
-
options.json = true;
|
|
1505
|
-
} else if (arg === "--browser") {
|
|
1506
|
-
options.browser = true;
|
|
1507
|
-
} else if (arg === "--require-browser") {
|
|
1508
|
-
options.browser = true;
|
|
1509
|
-
options.requireBrowser = true;
|
|
1510
|
-
} else if (arg === "--save-baseline") {
|
|
1511
|
-
options.saveBaseline = true;
|
|
1512
|
-
} else if (arg === "--since-baseline") {
|
|
1513
|
-
options.sinceBaseline = true;
|
|
1514
|
-
} else if (arg === "--base-url" && args[index + 1]) {
|
|
1515
|
-
options.browserBaseUrl = args[++index];
|
|
1516
|
-
} else if (arg.startsWith("--base-url=")) {
|
|
1517
|
-
options.browserBaseUrl = arg.split("=")[1];
|
|
1518
|
-
} else if (arg === "--design-tokens" && args[index + 1]) {
|
|
1519
|
-
options.designTokensPath = args[++index];
|
|
1520
|
-
} else if (arg.startsWith("--design-tokens=")) {
|
|
1521
|
-
options.designTokensPath = arg.split("=")[1];
|
|
1522
|
-
} else if (arg === "--ci") {
|
|
1523
|
-
options.ci = true;
|
|
1524
|
-
} else if (arg === "--format" && args[index + 1]) {
|
|
1525
|
-
options.format = args[++index];
|
|
1526
|
-
} else if (arg.startsWith("--format=")) {
|
|
1527
|
-
options.format = arg.split("=")[1];
|
|
1528
|
-
} else if (arg === "--output" && args[index + 1]) {
|
|
1529
|
-
options.output = args[++index];
|
|
1530
|
-
} else if (arg.startsWith("--output=")) {
|
|
1531
|
-
options.output = arg.split("=")[1];
|
|
1532
|
-
} else if (arg === "--fail-on" && args[index + 1]) {
|
|
1533
|
-
options.failOn = args[++index];
|
|
1534
|
-
} else if (arg.startsWith("--fail-on=")) {
|
|
1535
|
-
options.failOn = arg.split("=")[1];
|
|
1536
|
-
} else if (arg === "--prompt" && args[index + 1]) {
|
|
1537
|
-
options.promptId = args[++index];
|
|
1538
|
-
} else if (arg.startsWith("--prompt=")) {
|
|
1539
|
-
options.promptId = arg.split("=")[1];
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
if (options.format && !["text", "json", "markdown"].includes(options.format)) {
|
|
1543
|
-
throw new Error("Invalid --format value. Use text, json, or markdown.");
|
|
1544
|
-
}
|
|
1545
|
-
if (options.failOn && !["error", "warn", "none"].includes(options.failOn)) {
|
|
1546
|
-
throw new Error("Invalid --fail-on value. Use error, warn, or none.");
|
|
1547
|
-
}
|
|
1548
|
-
return options;
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
export {
|
|
1552
|
-
listWorkspaceAppCandidates,
|
|
1553
|
-
resolveWorkspaceInfo,
|
|
1554
|
-
renderProjectHealthCiWorkflow,
|
|
1555
|
-
writeProjectHealthCiWorkflow,
|
|
1556
|
-
collectDesignTokenEvidence,
|
|
1557
|
-
createProjectHealthReport,
|
|
1558
|
-
formatProjectHealthText,
|
|
1559
|
-
formatProjectHealthMarkdown,
|
|
1560
|
-
formatProjectHealthJson,
|
|
1561
|
-
createProjectEvidenceBundle,
|
|
1562
|
-
shouldFailHealth,
|
|
1563
|
-
cmdHealth,
|
|
1564
|
-
parseHealthArgs
|
|
1565
|
-
};
|