@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
|
@@ -0,0 +1,4573 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectCheckIssues,
|
|
3
|
+
sendProjectHealthCiFailedTelemetry,
|
|
4
|
+
sendProjectHealthPromptTelemetry,
|
|
5
|
+
sendProjectHealthReportTelemetry
|
|
6
|
+
} from "./chunk-MNZKOZW7.js";
|
|
7
|
+
|
|
8
|
+
// src/commands/health.ts
|
|
9
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
10
|
+
import { createHash as createHash2 } from "crypto";
|
|
11
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
12
|
+
import { createRequire } from "module";
|
|
13
|
+
import { dirname as dirname3, isAbsolute as isAbsolute3, join as join5, relative as relative3, resolve as resolve2 } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import {
|
|
16
|
+
anchorFindingsToGraph,
|
|
17
|
+
auditProject,
|
|
18
|
+
buildProjectHealthRepairPlan,
|
|
19
|
+
createContractAssertions,
|
|
20
|
+
createEvidenceBundle,
|
|
21
|
+
deriveVerificationDiagnostic,
|
|
22
|
+
KNOWN_VERIFICATION_DIAGNOSTICS
|
|
23
|
+
} from "@decantr/verifier";
|
|
24
|
+
|
|
25
|
+
// src/workspace.ts
|
|
26
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
27
|
+
import { dirname, isAbsolute, join, resolve } from "path";
|
|
28
|
+
function readPackageJson(dir) {
|
|
29
|
+
const path = join(dir, "package.json");
|
|
30
|
+
if (!existsSync(path)) return null;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function hasWorkspaceMarker(dir) {
|
|
38
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml")) || existsSync(join(dir, "turbo.json")) || existsSync(join(dir, "nx.json"))) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
const pkg = readPackageJson(dir);
|
|
42
|
+
return Boolean(pkg?.workspaces);
|
|
43
|
+
}
|
|
44
|
+
function findWorkspaceRoot(startDir) {
|
|
45
|
+
let current = resolve(startDir);
|
|
46
|
+
while (true) {
|
|
47
|
+
if (hasWorkspaceMarker(current)) return current;
|
|
48
|
+
const parent = dirname(current);
|
|
49
|
+
if (parent === current) return null;
|
|
50
|
+
current = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function looksLikeApp(dir, options = {}) {
|
|
54
|
+
const allowSourceDirs = options.allowSourceDirs ?? true;
|
|
55
|
+
const allowPackageDeps = options.allowPackageDeps ?? true;
|
|
56
|
+
const pkg = readPackageJson(dir);
|
|
57
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
58
|
+
const hasFrontendDependency = Boolean(
|
|
59
|
+
deps.react || deps["react-dom"] || deps.next || deps.vue || deps.svelte || deps["@angular/core"] || deps.astro || deps.nuxt
|
|
60
|
+
);
|
|
61
|
+
const hasServerOnlyDependency = Boolean(
|
|
62
|
+
deps.hono || deps.express || deps.fastify || deps.koa || deps["@hapi/hapi"]
|
|
63
|
+
);
|
|
64
|
+
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"))) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (allowSourceDirs && (existsSync(join(dir, "src")) || existsSync(join(dir, "app")) || existsSync(join(dir, "pages")))) {
|
|
68
|
+
if (hasFrontendDependency) return true;
|
|
69
|
+
if (hasServerOnlyDependency) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (!allowPackageDeps) return false;
|
|
73
|
+
return hasFrontendDependency;
|
|
74
|
+
}
|
|
75
|
+
function listWorkspaceApps(workspaceRoot) {
|
|
76
|
+
const candidates = [];
|
|
77
|
+
for (const base of ["apps", "packages"]) {
|
|
78
|
+
const baseDir = join(workspaceRoot, base);
|
|
79
|
+
if (!existsSync(baseDir)) continue;
|
|
80
|
+
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
81
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
82
|
+
const candidate = join(baseDir, entry.name);
|
|
83
|
+
if (looksLikeApp(candidate, {
|
|
84
|
+
allowSourceDirs: base === "apps",
|
|
85
|
+
allowPackageDeps: base === "apps"
|
|
86
|
+
})) {
|
|
87
|
+
candidates.push(`${base}/${entry.name}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return candidates.sort();
|
|
92
|
+
}
|
|
93
|
+
function listWorkspaceAppCandidates(workspaceRoot) {
|
|
94
|
+
return listWorkspaceApps(resolve(workspaceRoot));
|
|
95
|
+
}
|
|
96
|
+
function resolveWorkspaceInfo(cwd, projectArg) {
|
|
97
|
+
const absoluteCwd = resolve(cwd);
|
|
98
|
+
const appRoot = projectArg ? resolve(
|
|
99
|
+
isAbsolute(projectArg) ? projectArg : join(findWorkspaceRoot(absoluteCwd) ?? absoluteCwd, projectArg)
|
|
100
|
+
) : absoluteCwd;
|
|
101
|
+
const workspaceRoot = projectArg && isAbsolute(projectArg) ? findWorkspaceRoot(appRoot) ?? appRoot : findWorkspaceRoot(absoluteCwd) ?? absoluteCwd;
|
|
102
|
+
const appCandidates = listWorkspaceApps(workspaceRoot);
|
|
103
|
+
const projectScope = workspaceRoot !== appRoot || appCandidates.length > 0 ? "workspace-app" : "single-app";
|
|
104
|
+
const requiresProjectSelection = !projectArg && workspaceRoot === absoluteCwd && appCandidates.length > 0;
|
|
105
|
+
return {
|
|
106
|
+
cwd: absoluteCwd,
|
|
107
|
+
workspaceRoot,
|
|
108
|
+
appRoot,
|
|
109
|
+
projectScope,
|
|
110
|
+
appCandidates,
|
|
111
|
+
requiresProjectSelection
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/commands/graph.ts
|
|
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";
|
|
119
|
+
import {
|
|
120
|
+
buildGraphImpactContext,
|
|
121
|
+
buildGraphRouteContext,
|
|
122
|
+
buildContractCapsuleFromSnapshot,
|
|
123
|
+
buildGraphSnapshotFromEssence,
|
|
124
|
+
diffGraphSnapshots,
|
|
125
|
+
GRAPH_DIFF_SCHEMA_URL,
|
|
126
|
+
GRAPH_MANIFEST_SCHEMA_URL,
|
|
127
|
+
GRAPH_SCHEMA_VERSION,
|
|
128
|
+
graphPayloadString,
|
|
129
|
+
normalizeGraphSnapshot,
|
|
130
|
+
summarizeGraphDiff
|
|
131
|
+
} from "@decantr/core";
|
|
132
|
+
import { isV4 as isV42 } from "@decantr/essence-spec";
|
|
133
|
+
import {
|
|
134
|
+
auditComponentReuse,
|
|
135
|
+
collectProjectSourceFiles
|
|
136
|
+
} from "@decantr/verifier";
|
|
137
|
+
|
|
138
|
+
// src/local-law.ts
|
|
139
|
+
import { execFileSync } from "child_process";
|
|
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";
|
|
142
|
+
import { isV4 } from "@decantr/essence-spec";
|
|
143
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
144
|
+
".astro",
|
|
145
|
+
".html",
|
|
146
|
+
".js",
|
|
147
|
+
".jsx",
|
|
148
|
+
".svelte",
|
|
149
|
+
".ts",
|
|
150
|
+
".tsx",
|
|
151
|
+
".vue"
|
|
152
|
+
]);
|
|
153
|
+
var UI_TEMPLATE_EXTENSIONS = /* @__PURE__ */ new Set([".astro", ".html", ".jsx", ".svelte", ".tsx", ".vue"]);
|
|
154
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
155
|
+
".decantr",
|
|
156
|
+
".git",
|
|
157
|
+
".next",
|
|
158
|
+
".nuxt",
|
|
159
|
+
".svelte-kit",
|
|
160
|
+
"build",
|
|
161
|
+
"coverage",
|
|
162
|
+
"dist",
|
|
163
|
+
"node_modules",
|
|
164
|
+
"out"
|
|
165
|
+
]);
|
|
166
|
+
var DEFAULT_RULE_EXTENSIONS = [".astro", ".html", ".jsx", ".svelte", ".tsx", ".vue"];
|
|
167
|
+
function localPatternsProposalPath(projectRoot) {
|
|
168
|
+
return join2(projectRoot, ".decantr", "local-patterns.proposal.json");
|
|
169
|
+
}
|
|
170
|
+
function localPatternsPath(projectRoot) {
|
|
171
|
+
return join2(projectRoot, ".decantr", "local-patterns.json");
|
|
172
|
+
}
|
|
173
|
+
function localRulesProposalPath(projectRoot) {
|
|
174
|
+
return join2(projectRoot, ".decantr", "rules.proposal.json");
|
|
175
|
+
}
|
|
176
|
+
function localRulesPath(projectRoot) {
|
|
177
|
+
return join2(projectRoot, ".decantr", "rules.json");
|
|
178
|
+
}
|
|
179
|
+
function readLocalPatternPack(projectRoot) {
|
|
180
|
+
return readJsonFile(localPatternsPath(projectRoot));
|
|
181
|
+
}
|
|
182
|
+
function readLocalRuleManifest(projectRoot) {
|
|
183
|
+
return readJsonFile(localRulesPath(projectRoot));
|
|
184
|
+
}
|
|
185
|
+
function createBrownfieldCodifyProposal(input) {
|
|
186
|
+
const sourceFiles = input.fromAudit ? listSourceFiles(input.projectRoot, 800) : [];
|
|
187
|
+
const evidence = summarizeSourceEvidence(input.projectRoot, sourceFiles);
|
|
188
|
+
const routes = input.essence && isV4(input.essence) ? Object.keys(input.essence.blueprint.routes ?? {}).sort() : [];
|
|
189
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
190
|
+
const patternPack = {
|
|
191
|
+
version: 2,
|
|
192
|
+
generatedAt,
|
|
193
|
+
status: "proposal",
|
|
194
|
+
source: input.fromAudit ? "decantr codify --from-audit" : "decantr codify",
|
|
195
|
+
project: {
|
|
196
|
+
framework: input.detected.framework,
|
|
197
|
+
packageManager: input.detected.packageManager,
|
|
198
|
+
hasTailwind: input.detected.hasTailwind,
|
|
199
|
+
ruleFiles: input.detected.existingRuleFiles,
|
|
200
|
+
routeCount: routes.length
|
|
201
|
+
},
|
|
202
|
+
purpose: "Project-owned Brownfield/Hybrid UI law. Review and edit before accepting; Decantr treats this as authoritative only after it is copied to .decantr/local-patterns.json.",
|
|
203
|
+
hybrid: {
|
|
204
|
+
intent: "This local pattern pack is the first Hybrid authority layer: it maps Decantr concepts onto project-owned components, tokens, classes, and rules without replacing the app runtime.",
|
|
205
|
+
authorityPrecedence: [
|
|
206
|
+
"existing production source",
|
|
207
|
+
"accepted local patterns and rules",
|
|
208
|
+
"Decantr Essence V4 contract",
|
|
209
|
+
"hosted registry patterns and execution packs as optional guidance"
|
|
210
|
+
],
|
|
211
|
+
hostedPatternMapping: "Use hosted patterns as vocabulary and review guidance. Before enforcing one, map it to a project-owned component path, class recipe, token recipe, or explicit exception here."
|
|
212
|
+
},
|
|
213
|
+
patterns: [
|
|
214
|
+
{
|
|
215
|
+
id: "button",
|
|
216
|
+
label: "Button primitives",
|
|
217
|
+
role: "Actions and command triggers",
|
|
218
|
+
appliesTo: [
|
|
219
|
+
"primary action",
|
|
220
|
+
"secondary action",
|
|
221
|
+
"tertiary action",
|
|
222
|
+
"destructive action",
|
|
223
|
+
"icon action"
|
|
224
|
+
],
|
|
225
|
+
componentPaths: evidence.buttonComponents,
|
|
226
|
+
decide: "Define primary, secondary, tertiary, destructive, icon-only, disabled, and loading button variants from this app.",
|
|
227
|
+
classHints: evidence.buttonClassHints,
|
|
228
|
+
variants: evidence.buttonVariants,
|
|
229
|
+
sourceEvidence: evidence.buttonSourceEvidence,
|
|
230
|
+
confidence: confidenceForEvidence({
|
|
231
|
+
componentCount: evidence.buttonComponents.length,
|
|
232
|
+
classHintCount: evidence.buttonClassHints.length,
|
|
233
|
+
variantCount: evidence.buttonVariants.length,
|
|
234
|
+
sourceEvidenceCount: evidence.buttonSourceEvidence.length
|
|
235
|
+
}),
|
|
236
|
+
enforcement: {
|
|
237
|
+
level: "warn",
|
|
238
|
+
status: "proposal",
|
|
239
|
+
notes: [
|
|
240
|
+
"Treat as advisory until the team confirms variant names and wrapper paths.",
|
|
241
|
+
"Raise related rules to error only after raw button usage can be fixed without churn."
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
evidence: evidence.buttonComponents.length ? evidence.buttonComponents : evidence.buttonClassHints.length ? evidence.buttonClassHints : [
|
|
245
|
+
"No obvious Button wrapper found yet. Add the project-owned wrapper path before strict enforcement."
|
|
246
|
+
],
|
|
247
|
+
forbiddenAlternatives: ["New one-off button variants without updating this manifest."]
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "surface-card",
|
|
251
|
+
label: "Cards and surfaces",
|
|
252
|
+
role: "Cards, panels, and reusable content surfaces",
|
|
253
|
+
appliesTo: ["cards", "panels", "modals", "list items", "dashboard tiles"],
|
|
254
|
+
componentPaths: evidence.cardComponents,
|
|
255
|
+
decide: "Define the canonical card background, border, radius, shadow, padding, density, and hover treatment.",
|
|
256
|
+
classHints: evidence.cardClassHints,
|
|
257
|
+
variants: evidence.cardVariants,
|
|
258
|
+
sourceEvidence: evidence.cardSourceEvidence,
|
|
259
|
+
confidence: confidenceForEvidence({
|
|
260
|
+
componentCount: evidence.cardComponents.length,
|
|
261
|
+
classHintCount: evidence.cardClassHints.length,
|
|
262
|
+
variantCount: evidence.cardVariants.length,
|
|
263
|
+
sourceEvidenceCount: evidence.cardSourceEvidence.length
|
|
264
|
+
}),
|
|
265
|
+
enforcement: {
|
|
266
|
+
level: "warn",
|
|
267
|
+
status: "proposal",
|
|
268
|
+
notes: [
|
|
269
|
+
"Use this family to converge repeated surface recipes before enforcing card rules.",
|
|
270
|
+
"Document route-specific card exceptions before asking CI to block them."
|
|
271
|
+
]
|
|
272
|
+
},
|
|
273
|
+
evidence: evidence.cardComponents.length ? evidence.cardComponents : [
|
|
274
|
+
"No obvious Card wrapper found yet. Add the project-owned wrapper path or class recipe."
|
|
275
|
+
],
|
|
276
|
+
forbiddenAlternatives: ["Flat ad hoc cards with unique color/radius/shadow recipes."]
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "page-shell",
|
|
280
|
+
label: "Page shell and spacing",
|
|
281
|
+
role: "Route shell, navigation, gutters, max-width, and scroll ownership",
|
|
282
|
+
appliesTo: ["routes", "layouts", "navigation shells", "scroll containers"],
|
|
283
|
+
componentPaths: evidence.shellComponents,
|
|
284
|
+
decide: "Define which layout owns max width, gutters, sticky chrome, responsive breakpoints, and scroll containers.",
|
|
285
|
+
sourceEvidence: collectSourceEvidence(input.projectRoot, sourceFiles, [
|
|
286
|
+
"layout",
|
|
287
|
+
"shell",
|
|
288
|
+
"nav",
|
|
289
|
+
"sidebar",
|
|
290
|
+
"container"
|
|
291
|
+
]),
|
|
292
|
+
confidence: confidenceForEvidence({
|
|
293
|
+
componentCount: evidence.shellComponents.length,
|
|
294
|
+
sourceEvidenceCount: evidence.shellComponents.length
|
|
295
|
+
}),
|
|
296
|
+
enforcement: {
|
|
297
|
+
level: "advisory",
|
|
298
|
+
status: "proposal",
|
|
299
|
+
notes: [
|
|
300
|
+
"Layout ownership is usually reviewed by humans first because route shells can legitimately differ."
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
evidence: evidence.shellComponents.length ? evidence.shellComponents : ["Add root layout, shell, or app frame files that establish route chrome and spacing."],
|
|
304
|
+
forbiddenAlternatives: [
|
|
305
|
+
"Each page inventing independent max-width, padding, or sticky nav rules."
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "form-control",
|
|
310
|
+
label: "Form controls",
|
|
311
|
+
role: "Inputs, labels, validation, and form actions",
|
|
312
|
+
appliesTo: ["inputs", "selects", "textareas", "validation messages", "form actions"],
|
|
313
|
+
componentPaths: evidence.formComponents,
|
|
314
|
+
decide: "Define input height, label placement, error copy, disabled state, required state, and focus treatment.",
|
|
315
|
+
variants: evidence.formVariants,
|
|
316
|
+
sourceEvidence: evidence.formSourceEvidence,
|
|
317
|
+
confidence: confidenceForEvidence({
|
|
318
|
+
componentCount: evidence.formComponents.length,
|
|
319
|
+
variantCount: evidence.formVariants.length,
|
|
320
|
+
sourceEvidenceCount: evidence.formSourceEvidence.length
|
|
321
|
+
}),
|
|
322
|
+
enforcement: {
|
|
323
|
+
level: "warn",
|
|
324
|
+
status: "proposal",
|
|
325
|
+
notes: [
|
|
326
|
+
"Start by mapping field wrappers, labels, error states, and disabled states from source."
|
|
327
|
+
]
|
|
328
|
+
},
|
|
329
|
+
evidence: evidence.formComponents.length ? evidence.formComponents : ["Add form field wrapper paths and validation examples."],
|
|
330
|
+
forbiddenAlternatives: [
|
|
331
|
+
"Unlabeled one-off inputs or validation states that do not match the app standard."
|
|
332
|
+
]
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "theme-variant",
|
|
336
|
+
label: "Theme variants",
|
|
337
|
+
role: "Light, dark, brand, density, and tenant/theme variants observed in the app",
|
|
338
|
+
appliesTo: ["theme toggles", "mode-specific classes", "brand variants", "tenant variants"],
|
|
339
|
+
componentPaths: evidence.themeComponents,
|
|
340
|
+
decide: "Document which theme variants exist, where they are toggled, and which tokens/classes are legal per variant.",
|
|
341
|
+
variants: evidence.themeVariants,
|
|
342
|
+
sourceEvidence: evidence.themeSourceEvidence,
|
|
343
|
+
confidence: confidenceForEvidence({
|
|
344
|
+
componentCount: evidence.themeComponents.length,
|
|
345
|
+
variantCount: evidence.themeVariants.length,
|
|
346
|
+
sourceEvidenceCount: evidence.themeSourceEvidence.length
|
|
347
|
+
}),
|
|
348
|
+
enforcement: {
|
|
349
|
+
level: "advisory",
|
|
350
|
+
status: "proposal",
|
|
351
|
+
notes: [
|
|
352
|
+
"Theme variant law should preserve the existing theme provider/tokens before adding any new mode."
|
|
353
|
+
]
|
|
354
|
+
},
|
|
355
|
+
evidence: evidence.themeComponents.length ? evidence.themeComponents : ["If the app has dark/light or brand variants, add the toggles/providers here."],
|
|
356
|
+
forbiddenAlternatives: [
|
|
357
|
+
"Component-local theme forks that bypass shared theme providers or tokens."
|
|
358
|
+
]
|
|
359
|
+
}
|
|
360
|
+
],
|
|
361
|
+
starterRules: [
|
|
362
|
+
"Prefer project-owned wrappers for repeated primitives once they exist.",
|
|
363
|
+
"Avoid raw hex/rgb values in component templates unless documented as dynamic data.",
|
|
364
|
+
"Avoid static inline styles for reusable visual treatment.",
|
|
365
|
+
"When adding a new route, map it to an existing local pattern before inventing a new visual variant.",
|
|
366
|
+
"When adding a theme variant, update .decantr/theme-inventory.json and this local pattern pack.",
|
|
367
|
+
"Map hosted Decantr patterns into project-owned local law before making them enforceable."
|
|
368
|
+
],
|
|
369
|
+
nextSteps: [
|
|
370
|
+
"Edit this proposal with real component paths and token/class recipes.",
|
|
371
|
+
"Run decantr codify --accept after review.",
|
|
372
|
+
"Use decantr task <route> before LLM edits so local law appears in task context.",
|
|
373
|
+
"Run decantr verify --brownfield --local-patterns after edits.",
|
|
374
|
+
"For Hybrid adoption, start with warn-level local rules and raise severities only after the team agrees the law is stable.",
|
|
375
|
+
"Wire deterministic project rules into ESLint, Biome, Storybook, visual tests, or CI where Decantr should not guess."
|
|
376
|
+
]
|
|
377
|
+
};
|
|
378
|
+
const ruleManifest = {
|
|
379
|
+
version: 1,
|
|
380
|
+
status: "proposal",
|
|
381
|
+
generatedAt,
|
|
382
|
+
source: input.fromAudit ? "decantr codify --from-audit" : "decantr codify",
|
|
383
|
+
purpose: "Mechanical Brownfield/Hybrid checks owned by this project. These rules are intentionally local and stack-agnostic; edit before accepting.",
|
|
384
|
+
enforcement: {
|
|
385
|
+
defaultSeverity: "warn",
|
|
386
|
+
mode: "warn",
|
|
387
|
+
notes: [
|
|
388
|
+
"Decantr local rules are a guardrail, not a replacement for ESLint, Biome, type checks, tests, or visual regression.",
|
|
389
|
+
"Keep rules narrow enough that an LLM can fix findings without rewriting the app.",
|
|
390
|
+
"Use error severity only after the team agrees the rule is stable."
|
|
391
|
+
]
|
|
392
|
+
},
|
|
393
|
+
rules: [
|
|
394
|
+
{
|
|
395
|
+
id: "no-inline-style",
|
|
396
|
+
type: "forbid-regex",
|
|
397
|
+
enabled: true,
|
|
398
|
+
severity: "warn",
|
|
399
|
+
description: "Reusable UI should not add static inline style attributes.",
|
|
400
|
+
includeExtensions: DEFAULT_RULE_EXTENSIONS,
|
|
401
|
+
pattern: "\\bstyle\\s*=",
|
|
402
|
+
message: "Inline style found in UI template.",
|
|
403
|
+
suggestedFix: "Move reusable visual treatment into the project style system, component wrapper, token, or documented local pattern.",
|
|
404
|
+
maxFindings: 25
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
id: "no-raw-color-literals",
|
|
408
|
+
type: "forbid-regex",
|
|
409
|
+
enabled: true,
|
|
410
|
+
severity: "warn",
|
|
411
|
+
description: "Component templates should not introduce raw hex/rgb color literals.",
|
|
412
|
+
includeExtensions: DEFAULT_RULE_EXTENSIONS,
|
|
413
|
+
pattern: "#(?:[0-9a-fA-F]{3,8})\\b|rgba?\\s*\\(",
|
|
414
|
+
message: "Raw color literal found in UI template.",
|
|
415
|
+
suggestedFix: "Use an existing project token/class, or document the exception in .decantr/local-patterns.json if the value is data-driven.",
|
|
416
|
+
maxFindings: 25
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
id: "prefer-button-wrapper",
|
|
420
|
+
type: "forbid-regex",
|
|
421
|
+
enabled: evidence.buttonComponents.length > 0,
|
|
422
|
+
severity: "info",
|
|
423
|
+
description: "Prefer the project-owned button primitive instead of new raw button markup.",
|
|
424
|
+
includeExtensions: DEFAULT_RULE_EXTENSIONS,
|
|
425
|
+
pattern: "<button[\\s>]",
|
|
426
|
+
message: "Raw <button> usage found outside the detected button wrapper.",
|
|
427
|
+
suggestedFix: "Use the project-owned Button primitive, or add this file to allowedPaths if it is the primitive implementation.",
|
|
428
|
+
allowedPaths: evidence.buttonComponents,
|
|
429
|
+
maxFindings: 50
|
|
430
|
+
}
|
|
431
|
+
]
|
|
432
|
+
};
|
|
433
|
+
return { patternPack, ruleManifest };
|
|
434
|
+
}
|
|
435
|
+
function writeBrownfieldCodifyProposal(projectRoot, proposal) {
|
|
436
|
+
const decantrDir = join2(projectRoot, ".decantr");
|
|
437
|
+
mkdirSync(decantrDir, { recursive: true });
|
|
438
|
+
const patternPath = localPatternsProposalPath(projectRoot);
|
|
439
|
+
const rulesPath = localRulesProposalPath(projectRoot);
|
|
440
|
+
writeFileSync(patternPath, `${JSON.stringify(proposal.patternPack, null, 2)}
|
|
441
|
+
`, "utf-8");
|
|
442
|
+
writeFileSync(rulesPath, `${JSON.stringify(proposal.ruleManifest, null, 2)}
|
|
443
|
+
`, "utf-8");
|
|
444
|
+
return { patternPath, rulesPath };
|
|
445
|
+
}
|
|
446
|
+
function writeHostedPatternMappingProposal(input) {
|
|
447
|
+
const decantrDir = join2(input.projectRoot, ".decantr");
|
|
448
|
+
mkdirSync(decantrDir, { recursive: true });
|
|
449
|
+
const existing = readJsonFile(localPatternsProposalPath(input.projectRoot)) ?? readJsonFile(localPatternsPath(input.projectRoot));
|
|
450
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
451
|
+
const patternPack = {
|
|
452
|
+
version: 2,
|
|
453
|
+
generatedAt,
|
|
454
|
+
status: "proposal",
|
|
455
|
+
source: "decantr codify --map-pattern",
|
|
456
|
+
purpose: existing?.purpose ?? "Project-owned Brownfield/Hybrid UI law. Review and edit before accepting; Decantr treats this as authoritative only after it is copied to .decantr/local-patterns.json.",
|
|
457
|
+
hybrid: existing?.hybrid ?? {
|
|
458
|
+
intent: "This local pattern pack maps Decantr concepts onto project-owned components, tokens, classes, and rules without replacing the app runtime.",
|
|
459
|
+
authorityPrecedence: [
|
|
460
|
+
"existing production source",
|
|
461
|
+
"accepted local patterns and rules",
|
|
462
|
+
"Decantr Essence V4 contract",
|
|
463
|
+
"hosted registry patterns and execution packs as optional guidance"
|
|
464
|
+
],
|
|
465
|
+
hostedPatternMapping: "Hosted registry patterns are advisory until mapped to project-owned component paths, token recipes, class recipes, and explicit exceptions."
|
|
466
|
+
},
|
|
467
|
+
patterns: Array.isArray(existing?.patterns) ? [...existing.patterns] : [],
|
|
468
|
+
starterRules: Array.isArray(existing?.starterRules) ? existing.starterRules : [],
|
|
469
|
+
nextSteps: Array.isArray(existing?.nextSteps) ? existing.nextSteps : [
|
|
470
|
+
"Review this proposal with the team.",
|
|
471
|
+
"Add project-owned component paths, token hints, class recipes, and exceptions.",
|
|
472
|
+
"Run decantr codify --accept after review.",
|
|
473
|
+
"Use decantr task <route> before LLM edits so local law appears in task context."
|
|
474
|
+
]
|
|
475
|
+
};
|
|
476
|
+
const slug = input.hostedPattern.slug.trim();
|
|
477
|
+
const existingIds = new Set(
|
|
478
|
+
(patternPack.patterns ?? []).map((pattern) => typeof pattern.id === "string" ? pattern.id : "").filter(Boolean)
|
|
479
|
+
);
|
|
480
|
+
const localPatternId = existingIds.has(slug) ? `${slug}-registry-map` : slug;
|
|
481
|
+
const mappedPattern = {
|
|
482
|
+
id: localPatternId,
|
|
483
|
+
label: input.hostedPattern.name ? `${input.hostedPattern.name} registry mapping` : `${slug} registry mapping`,
|
|
484
|
+
role: "Hosted registry guidance mapped into project-owned Hybrid law",
|
|
485
|
+
appliesTo: [
|
|
486
|
+
...input.hostedPattern.components ?? [],
|
|
487
|
+
...input.hostedPattern.interactions ?? [],
|
|
488
|
+
...input.hostedPattern.tags ?? []
|
|
489
|
+
].slice(0, 24),
|
|
490
|
+
componentPaths: [],
|
|
491
|
+
tokenHints: [],
|
|
492
|
+
classHints: [],
|
|
493
|
+
decide: `Map hosted pattern "${slug}" into this app's existing components, tokens, classes, and exceptions before treating it as enforceable.`,
|
|
494
|
+
hostedPatternRefs: [input.hostedPattern],
|
|
495
|
+
confidence: {
|
|
496
|
+
tier: "low",
|
|
497
|
+
score: 0.25,
|
|
498
|
+
rationale: [
|
|
499
|
+
"A hosted registry pattern was selected intentionally.",
|
|
500
|
+
"No project-owned component path or token recipe has been mapped yet."
|
|
501
|
+
]
|
|
502
|
+
},
|
|
503
|
+
enforcement: {
|
|
504
|
+
level: "advisory",
|
|
505
|
+
status: "needs-mapping",
|
|
506
|
+
notes: [
|
|
507
|
+
"This proposal does not change source files.",
|
|
508
|
+
"Do not enforce this hosted pattern until project-owned implementation details are filled in.",
|
|
509
|
+
"Use accepted local rules, ESLint, Biome, Storybook, or visual regression for deterministic blocking checks."
|
|
510
|
+
]
|
|
511
|
+
},
|
|
512
|
+
evidence: [
|
|
513
|
+
`Hosted pattern ${input.hostedPattern.source}/${slug}${input.hostedPattern.description ? `: ${input.hostedPattern.description}` : ""}`,
|
|
514
|
+
input.hostedPattern.visualBrief ? `Visual brief: ${input.hostedPattern.visualBrief}` : "Visual brief not provided by the hosted pattern."
|
|
515
|
+
],
|
|
516
|
+
evidenceToCollect: [
|
|
517
|
+
"Project-owned component path(s) that implement this pattern.",
|
|
518
|
+
"Allowed token/class recipe(s) for this app.",
|
|
519
|
+
"Variant names and states that are legal in this project.",
|
|
520
|
+
"Explicit exceptions where this hosted pattern should not apply."
|
|
521
|
+
],
|
|
522
|
+
forbiddenAlternatives: [
|
|
523
|
+
"Replacing existing app components solely because a hosted pattern exists.",
|
|
524
|
+
"Treating hosted registry guidance as enforceable without accepted local law."
|
|
525
|
+
]
|
|
526
|
+
};
|
|
527
|
+
const index = (patternPack.patterns ?? []).findIndex((pattern) => pattern.id === localPatternId);
|
|
528
|
+
const replacedExisting = index >= 0;
|
|
529
|
+
if (replacedExisting) {
|
|
530
|
+
patternPack.patterns[index] = mappedPattern;
|
|
531
|
+
} else {
|
|
532
|
+
patternPack.patterns = [...patternPack.patterns ?? [], mappedPattern];
|
|
533
|
+
}
|
|
534
|
+
patternPack.nextSteps = [
|
|
535
|
+
.../* @__PURE__ */ new Set([
|
|
536
|
+
...patternPack.nextSteps ?? [],
|
|
537
|
+
"Fill the hosted mapping with project-owned component paths, token/class recipes, variants, and exceptions.",
|
|
538
|
+
"Run decantr codify --accept only after the mapping reflects the existing app.",
|
|
539
|
+
"Use decantr verify --brownfield --local-patterns after edits to keep mapped local law visible."
|
|
540
|
+
])
|
|
541
|
+
];
|
|
542
|
+
const patternPath = localPatternsProposalPath(input.projectRoot);
|
|
543
|
+
writeFileSync(patternPath, `${JSON.stringify(patternPack, null, 2)}
|
|
544
|
+
`, "utf-8");
|
|
545
|
+
return { patternPath, localPatternId, replacedExisting };
|
|
546
|
+
}
|
|
547
|
+
function acceptBrownfieldLocalLaw(projectRoot) {
|
|
548
|
+
const patternProposal = readJsonFile(localPatternsProposalPath(projectRoot));
|
|
549
|
+
const ruleProposal = readJsonFile(localRulesProposalPath(projectRoot));
|
|
550
|
+
const acceptedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
551
|
+
let patternAcceptedPath = null;
|
|
552
|
+
let rulesAcceptedPath = null;
|
|
553
|
+
if (patternProposal) {
|
|
554
|
+
patternProposal.status = "accepted";
|
|
555
|
+
patternProposal.acceptedAt = acceptedAt;
|
|
556
|
+
patternAcceptedPath = localPatternsPath(projectRoot);
|
|
557
|
+
writeFileSync(patternAcceptedPath, `${JSON.stringify(patternProposal, null, 2)}
|
|
558
|
+
`, "utf-8");
|
|
559
|
+
}
|
|
560
|
+
if (ruleProposal) {
|
|
561
|
+
ruleProposal.status = "accepted";
|
|
562
|
+
ruleProposal.acceptedAt = acceptedAt;
|
|
563
|
+
rulesAcceptedPath = localRulesPath(projectRoot);
|
|
564
|
+
writeFileSync(rulesAcceptedPath, `${JSON.stringify(ruleProposal, null, 2)}
|
|
565
|
+
`, "utf-8");
|
|
566
|
+
}
|
|
567
|
+
return { patternAcceptedPath, rulesAcceptedPath };
|
|
568
|
+
}
|
|
569
|
+
function validateLocalLaw(projectRoot) {
|
|
570
|
+
const patternsPath = localPatternsPath(projectRoot);
|
|
571
|
+
const rulesPath = localRulesPath(projectRoot);
|
|
572
|
+
const patternPack = readJsonFile(patternsPath);
|
|
573
|
+
const ruleManifest = readJsonFile(rulesPath);
|
|
574
|
+
const warnings = [];
|
|
575
|
+
if (patternPack) {
|
|
576
|
+
const patternIds = /* @__PURE__ */ new Set();
|
|
577
|
+
const patterns = Array.isArray(patternPack.patterns) ? patternPack.patterns : [];
|
|
578
|
+
if (patterns.length === 0) {
|
|
579
|
+
warnings.push(".decantr/local-patterns.json has no patterns.");
|
|
580
|
+
}
|
|
581
|
+
for (const pattern of patterns) {
|
|
582
|
+
const id = typeof pattern.id === "string" ? pattern.id.trim() : "";
|
|
583
|
+
if (!id) warnings.push("A local pattern is missing an id.");
|
|
584
|
+
if (id && patternIds.has(id)) warnings.push(`Duplicate local pattern id: ${id}`);
|
|
585
|
+
if (id) patternIds.add(id);
|
|
586
|
+
const paths = Array.isArray(pattern.componentPaths) ? pattern.componentPaths : [];
|
|
587
|
+
const evidence = Array.isArray(pattern.evidence) ? pattern.evidence : [];
|
|
588
|
+
const todoEvidence = Array.isArray(pattern.evidenceToCollect) ? pattern.evidenceToCollect : [];
|
|
589
|
+
const hostedRefs = Array.isArray(pattern.hostedPatternRefs) ? pattern.hostedPatternRefs : [];
|
|
590
|
+
if (id && paths.length === 0 && evidence.length === 0 && todoEvidence.length > 0) {
|
|
591
|
+
warnings.push(
|
|
592
|
+
`Local pattern ${id} still reads like a TODO; add concrete component paths or evidence.`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (id && hostedRefs.length > 0 && paths.length === 0) {
|
|
596
|
+
warnings.push(
|
|
597
|
+
`Local pattern ${id} maps hosted registry guidance but has no project-owned component path yet.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (ruleManifest && !Array.isArray(ruleManifest.rules)) {
|
|
603
|
+
warnings.push(".decantr/rules.json has no rules array.");
|
|
604
|
+
}
|
|
605
|
+
const findings = ruleManifest ? scanLocalRules(projectRoot, ruleManifest) : [];
|
|
606
|
+
return {
|
|
607
|
+
patternsPath,
|
|
608
|
+
rulesPath,
|
|
609
|
+
patternPackPresent: Boolean(patternPack),
|
|
610
|
+
ruleManifestPresent: Boolean(ruleManifest),
|
|
611
|
+
warnings,
|
|
612
|
+
findings
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function createLocalLawTaskSummary(projectRoot) {
|
|
616
|
+
const patternPack = readLocalPatternPack(projectRoot);
|
|
617
|
+
const ruleManifest = readLocalRuleManifest(projectRoot);
|
|
618
|
+
const patterns = (patternPack?.patterns ?? []).map((pattern) => ({
|
|
619
|
+
id: typeof pattern.id === "string" ? pattern.id : "unknown",
|
|
620
|
+
role: typeof pattern.role === "string" ? pattern.role : null,
|
|
621
|
+
componentPaths: Array.isArray(pattern.componentPaths) ? pattern.componentPaths.filter((entry) => typeof entry === "string") : [],
|
|
622
|
+
confidenceTier: typeof pattern.confidence === "object" && pattern.confidence !== null && typeof pattern.confidence.tier === "string" ? pattern.confidence.tier : null,
|
|
623
|
+
enforcementLevel: typeof pattern.enforcement === "object" && pattern.enforcement !== null && typeof pattern.enforcement.level === "string" ? pattern.enforcement.level : null,
|
|
624
|
+
hostedPatternRefs: Array.isArray(pattern.hostedPatternRefs) ? pattern.hostedPatternRefs.map((ref) => typeof ref?.slug === "string" ? ref.slug : null).filter((slug) => Boolean(slug)) : []
|
|
625
|
+
}));
|
|
626
|
+
const rules = (ruleManifest?.rules ?? []).map((rule) => ({
|
|
627
|
+
id: rule.id,
|
|
628
|
+
severity: rule.severity,
|
|
629
|
+
enabled: rule.enabled,
|
|
630
|
+
description: rule.description
|
|
631
|
+
}));
|
|
632
|
+
return {
|
|
633
|
+
patternsPath: patternPack ? ".decantr/local-patterns.json" : null,
|
|
634
|
+
rulesPath: ruleManifest ? ".decantr/rules.json" : null,
|
|
635
|
+
patternCount: patterns.length,
|
|
636
|
+
ruleCount: rules.length,
|
|
637
|
+
patterns,
|
|
638
|
+
rules
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function changedFiles(projectRoot, since) {
|
|
642
|
+
const changed = /* @__PURE__ */ new Set();
|
|
643
|
+
try {
|
|
644
|
+
const commands = since ? [
|
|
645
|
+
["diff", "--name-only", since, "--"],
|
|
646
|
+
["diff", "--name-only", "--cached"]
|
|
647
|
+
] : [
|
|
648
|
+
["diff", "--name-only"],
|
|
649
|
+
["diff", "--name-only", "--cached"]
|
|
650
|
+
];
|
|
651
|
+
for (const args of commands) {
|
|
652
|
+
const output = execFileSync("git", args, {
|
|
653
|
+
cwd: projectRoot,
|
|
654
|
+
encoding: "utf-8",
|
|
655
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
656
|
+
});
|
|
657
|
+
for (const line of output.split(/\r?\n/)) {
|
|
658
|
+
const file = line.trim();
|
|
659
|
+
if (file) changed.add(normalizePath(file));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
return [...changed].sort();
|
|
665
|
+
}
|
|
666
|
+
function routeImpacts(projectRoot, files) {
|
|
667
|
+
const analysis = readJsonFile(
|
|
668
|
+
join2(projectRoot, ".decantr", "analysis.json")
|
|
669
|
+
);
|
|
670
|
+
const routeEntries = analysis?.routes?.routes ?? [];
|
|
671
|
+
const impacted = /* @__PURE__ */ new Set();
|
|
672
|
+
for (const file of files) {
|
|
673
|
+
for (const route of routeEntries) {
|
|
674
|
+
if (route.file && pathMatches(file, route.file)) {
|
|
675
|
+
if (route.path) impacted.add(route.path);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return [...impacted].sort();
|
|
680
|
+
}
|
|
681
|
+
function scanLocalRules(projectRoot, manifest) {
|
|
682
|
+
const findings = [];
|
|
683
|
+
const files = listSourceFiles(projectRoot, 1200);
|
|
684
|
+
for (const rule of manifest.rules ?? []) {
|
|
685
|
+
if (!rule.enabled || rule.type !== "forbid-regex") continue;
|
|
686
|
+
const extensions = new Set(
|
|
687
|
+
rule.includeExtensions?.length ? rule.includeExtensions : DEFAULT_RULE_EXTENSIONS
|
|
688
|
+
);
|
|
689
|
+
let regex;
|
|
690
|
+
try {
|
|
691
|
+
regex = new RegExp(rule.pattern, "g");
|
|
692
|
+
} catch {
|
|
693
|
+
findings.push({
|
|
694
|
+
ruleId: rule.id,
|
|
695
|
+
severity: "error",
|
|
696
|
+
file: ".decantr/rules.json",
|
|
697
|
+
line: 1,
|
|
698
|
+
column: 1,
|
|
699
|
+
excerpt: rule.pattern,
|
|
700
|
+
message: `Invalid regex for local rule ${rule.id}.`,
|
|
701
|
+
suggestedFix: "Edit .decantr/rules.json so the pattern is a valid JavaScript regular expression."
|
|
702
|
+
});
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
let ruleFindingCount = 0;
|
|
706
|
+
for (const file of files) {
|
|
707
|
+
if (!extensions.has(extname(file.absolute))) continue;
|
|
708
|
+
if (pathAllowed(file.relative, rule.allowedPaths ?? [])) continue;
|
|
709
|
+
const contents = readFileSync2(file.absolute, "utf-8");
|
|
710
|
+
for (const match of contents.matchAll(regex)) {
|
|
711
|
+
const index = match.index ?? 0;
|
|
712
|
+
const position = lineColumnAt(contents, index);
|
|
713
|
+
findings.push({
|
|
714
|
+
ruleId: rule.id,
|
|
715
|
+
severity: rule.severity,
|
|
716
|
+
file: file.relative,
|
|
717
|
+
line: position.line,
|
|
718
|
+
column: position.column,
|
|
719
|
+
excerpt: lineAt(contents, position.line).trim().slice(0, 180),
|
|
720
|
+
message: rule.message,
|
|
721
|
+
suggestedFix: rule.suggestedFix
|
|
722
|
+
});
|
|
723
|
+
ruleFindingCount += 1;
|
|
724
|
+
if (rule.maxFindings && ruleFindingCount >= rule.maxFindings) break;
|
|
725
|
+
}
|
|
726
|
+
if (rule.maxFindings && ruleFindingCount >= rule.maxFindings) break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return findings;
|
|
730
|
+
}
|
|
731
|
+
function summarizeSourceEvidence(projectRoot, files) {
|
|
732
|
+
const componentPaths = files.filter((file) => /(^|[/\\])components?([/\\]|$)|(^|[/\\])ui([/\\]|$)/i.test(file.relative)).map((file) => file.relative);
|
|
733
|
+
const byName = (terms) => componentPaths.filter((file) => terms.some((term) => basename(file).toLowerCase().includes(term))).slice(0, 12);
|
|
734
|
+
const themeComponents = componentPaths.filter((file) => /theme|provider|mode|appearance|tenant|brand/i.test(file)).slice(0, 12);
|
|
735
|
+
const shellComponents = files.filter((file) => /layout|shell|frame|app|root|nav|sidebar/i.test(basename(file.relative))).map((file) => file.relative).slice(0, 12);
|
|
736
|
+
return {
|
|
737
|
+
buttonComponents: byName(["button", "action"]),
|
|
738
|
+
cardComponents: byName(["card", "panel", "surface", "tile"]),
|
|
739
|
+
formComponents: byName(["input", "field", "form", "select", "textarea"]),
|
|
740
|
+
shellComponents,
|
|
741
|
+
themeComponents,
|
|
742
|
+
buttonVariants: detectVariants(projectRoot, files, {
|
|
743
|
+
family: "button",
|
|
744
|
+
terms: ["primary", "secondary", "tertiary", "ghost", "link", "destructive", "icon"]
|
|
745
|
+
}),
|
|
746
|
+
cardVariants: detectVariants(projectRoot, files, {
|
|
747
|
+
family: "surface",
|
|
748
|
+
terms: ["card", "panel", "surface", "tile", "elevated", "interactive", "glass"]
|
|
749
|
+
}),
|
|
750
|
+
formVariants: detectVariants(projectRoot, files, {
|
|
751
|
+
family: "form",
|
|
752
|
+
terms: ["error", "invalid", "disabled", "required", "success", "helper"]
|
|
753
|
+
}),
|
|
754
|
+
themeVariants: detectVariants(projectRoot, files, {
|
|
755
|
+
family: "theme",
|
|
756
|
+
terms: ["dark", "light", "brand", "tenant", "theme", "mode", "density"]
|
|
757
|
+
}),
|
|
758
|
+
buttonSourceEvidence: collectSourceEvidence(projectRoot, files, [
|
|
759
|
+
"button",
|
|
760
|
+
"primary",
|
|
761
|
+
"secondary",
|
|
762
|
+
"tertiary",
|
|
763
|
+
"destructive"
|
|
764
|
+
]),
|
|
765
|
+
cardSourceEvidence: collectSourceEvidence(projectRoot, files, [
|
|
766
|
+
"card",
|
|
767
|
+
"panel",
|
|
768
|
+
"surface",
|
|
769
|
+
"rounded",
|
|
770
|
+
"shadow"
|
|
771
|
+
]),
|
|
772
|
+
formSourceEvidence: collectSourceEvidence(projectRoot, files, [
|
|
773
|
+
"input",
|
|
774
|
+
"label",
|
|
775
|
+
"error",
|
|
776
|
+
"field",
|
|
777
|
+
"select"
|
|
778
|
+
]),
|
|
779
|
+
themeSourceEvidence: collectSourceEvidence(projectRoot, files, [
|
|
780
|
+
"theme",
|
|
781
|
+
"dark",
|
|
782
|
+
"light",
|
|
783
|
+
"brand",
|
|
784
|
+
"tenant"
|
|
785
|
+
]),
|
|
786
|
+
buttonClassHints: collectClassHints(projectRoot, files, [
|
|
787
|
+
"button",
|
|
788
|
+
"btn",
|
|
789
|
+
"action",
|
|
790
|
+
"primary",
|
|
791
|
+
"secondary",
|
|
792
|
+
"tertiary",
|
|
793
|
+
"ghost",
|
|
794
|
+
"link"
|
|
795
|
+
]),
|
|
796
|
+
cardClassHints: collectClassHints(projectRoot, files, ["card", "panel", "surface", "tile"])
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function detectVariants(projectRoot, files, input) {
|
|
800
|
+
const variants = /* @__PURE__ */ new Map();
|
|
801
|
+
const termPattern = new RegExp(
|
|
802
|
+
`\\b(${input.terms.map(escapeRegExp).join("|")})(?:[-_:][a-z0-9-]+)?\\b`,
|
|
803
|
+
"gi"
|
|
804
|
+
);
|
|
805
|
+
for (const file of files) {
|
|
806
|
+
if (!UI_TEMPLATE_EXTENSIONS.has(extname(file.absolute))) continue;
|
|
807
|
+
const content = readFileSync2(join2(projectRoot, file.relative), "utf-8");
|
|
808
|
+
for (const match of content.matchAll(termPattern)) {
|
|
809
|
+
const raw = (match[0] ?? "").toLowerCase();
|
|
810
|
+
const id = raw.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
811
|
+
if (!id || id.length < 3) continue;
|
|
812
|
+
const position = lineColumnAt(content, match.index ?? 0);
|
|
813
|
+
const line = lineAt(content, position.line).trim().slice(0, 160);
|
|
814
|
+
const entry = variants.get(id) ?? { evidence: /* @__PURE__ */ new Set(), count: 0 };
|
|
815
|
+
entry.count += 1;
|
|
816
|
+
entry.evidence.add(`${file.relative}:${position.line} ${line}`);
|
|
817
|
+
variants.set(id, entry);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return [...variants.entries()].sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0])).slice(0, 8).map(([id, value]) => ({
|
|
821
|
+
id,
|
|
822
|
+
label: `${input.family} ${id}`,
|
|
823
|
+
evidence: [...value.evidence].slice(0, 4),
|
|
824
|
+
confidence: value.count >= 4 ? "high" : value.count >= 2 ? "medium" : "low"
|
|
825
|
+
}));
|
|
826
|
+
}
|
|
827
|
+
function collectSourceEvidence(projectRoot, files, terms) {
|
|
828
|
+
const results = [];
|
|
829
|
+
const termPattern = new RegExp(terms.map(escapeRegExp).join("|"), "i");
|
|
830
|
+
for (const file of files) {
|
|
831
|
+
if (!UI_TEMPLATE_EXTENSIONS.has(extname(file.absolute))) continue;
|
|
832
|
+
const content = readFileSync2(join2(projectRoot, file.relative), "utf-8");
|
|
833
|
+
const lines = content.split(/\r?\n/);
|
|
834
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
835
|
+
const line = lines[index];
|
|
836
|
+
if (!termPattern.test(line)) continue;
|
|
837
|
+
const signals = terms.filter((term) => line.toLowerCase().includes(term.toLowerCase()));
|
|
838
|
+
results.push({
|
|
839
|
+
file: file.relative,
|
|
840
|
+
line: index + 1,
|
|
841
|
+
excerpt: line.trim().slice(0, 180),
|
|
842
|
+
signals: signals.slice(0, 6)
|
|
843
|
+
});
|
|
844
|
+
if (results.length >= 16) return results;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return results;
|
|
849
|
+
}
|
|
850
|
+
function confidenceForEvidence(input) {
|
|
851
|
+
const score = Math.min(
|
|
852
|
+
1,
|
|
853
|
+
input.componentCount * 0.28 + (input.classHintCount ?? 0) * 0.08 + (input.variantCount ?? 0) * 0.08 + (input.sourceEvidenceCount ?? 0) * 0.05
|
|
854
|
+
);
|
|
855
|
+
const tier = score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low";
|
|
856
|
+
const rationale = [
|
|
857
|
+
input.componentCount > 0 ? `${input.componentCount} likely component path(s) detected` : "no likely component wrapper detected yet",
|
|
858
|
+
(input.classHintCount ?? 0) > 0 ? `${input.classHintCount} class recipe hint(s) detected` : "no strong class recipe hints detected yet",
|
|
859
|
+
(input.variantCount ?? 0) > 0 ? `${input.variantCount} possible variant signal(s) detected` : "variant names still need team confirmation"
|
|
860
|
+
];
|
|
861
|
+
return { tier, score: Number(score.toFixed(2)), rationale };
|
|
862
|
+
}
|
|
863
|
+
function collectClassHints(projectRoot, files, terms) {
|
|
864
|
+
const hints = /* @__PURE__ */ new Map();
|
|
865
|
+
const wantsButton = terms.some(
|
|
866
|
+
(term) => /^(button|btn|action|primary|secondary|tertiary|ghost|link)$/i.test(term)
|
|
867
|
+
);
|
|
868
|
+
const wantsSurface = terms.some((term) => /^(card|panel|surface|tile)$/i.test(term));
|
|
869
|
+
const buttonSignal = /button|btn|action|primary|secondary|tertiary|ghost|link|destructive|icon/i;
|
|
870
|
+
const surfaceSignal = /card|panel|surface|tile|rounded|shadow|border|bg-|p-\d|px-|py-/i;
|
|
871
|
+
for (const file of files) {
|
|
872
|
+
if (!UI_TEMPLATE_EXTENSIONS.has(extname(file.absolute))) continue;
|
|
873
|
+
const content = readFileSync2(join2(projectRoot, file.relative), "utf-8");
|
|
874
|
+
if (!terms.some((term) => content.toLowerCase().includes(term))) continue;
|
|
875
|
+
if (wantsButton) {
|
|
876
|
+
const openingTags = content.matchAll(
|
|
877
|
+
/<([A-Za-z][\w.:/-]*)\b[^>]*\bclass(?:Name)?\s*=\s*["'`]([^"'`]+)["'`][^>]*>/g
|
|
878
|
+
);
|
|
879
|
+
for (const match of openingTags) {
|
|
880
|
+
const tag = match[1];
|
|
881
|
+
const value = match[2].trim();
|
|
882
|
+
const tagLooksInteractive = /^(button|a|Link)$/i.test(tag) || /(^|\.)(Button|IconButton|LinkButton|Action)$/i.test(tag);
|
|
883
|
+
const fileLooksInteractive = /button|action|link/i.test(basename(file.relative));
|
|
884
|
+
if (!tagLooksInteractive && !fileLooksInteractive) continue;
|
|
885
|
+
if (!buttonSignal.test(value) && !fileLooksInteractive) continue;
|
|
886
|
+
hints.set(value, (hints.get(value) ?? 0) + 1);
|
|
887
|
+
}
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const matches = content.matchAll(/\bclass(?:Name)?\s*=\s*["'`]([^"'`]+)["'`]/g);
|
|
891
|
+
for (const match of matches) {
|
|
892
|
+
const value = match[1].trim();
|
|
893
|
+
const keep = wantsSurface && surfaceSignal.test(value) || !wantsButton && !wantsSurface && (buttonSignal.test(value) || surfaceSignal.test(value));
|
|
894
|
+
if (!keep) continue;
|
|
895
|
+
hints.set(value, (hints.get(value) ?? 0) + 1);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return [...hints.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([hint]) => hint);
|
|
899
|
+
}
|
|
900
|
+
function listSourceFiles(projectRoot, maxFiles) {
|
|
901
|
+
const files = [];
|
|
902
|
+
const visit = (dir) => {
|
|
903
|
+
if (files.length >= maxFiles) return;
|
|
904
|
+
let entries;
|
|
905
|
+
try {
|
|
906
|
+
entries = readdirSync2(dir);
|
|
907
|
+
} catch {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
for (const entry of entries) {
|
|
911
|
+
if (files.length >= maxFiles) return;
|
|
912
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
913
|
+
const absolute = join2(dir, entry);
|
|
914
|
+
let stat;
|
|
915
|
+
try {
|
|
916
|
+
stat = statSync(absolute);
|
|
917
|
+
} catch {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (stat.isDirectory()) {
|
|
921
|
+
visit(absolute);
|
|
922
|
+
} else if (stat.isFile() && SOURCE_EXTENSIONS.has(extname(entry))) {
|
|
923
|
+
files.push({ absolute, relative: normalizePath(relative(projectRoot, absolute)) });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
visit(projectRoot);
|
|
928
|
+
return files.sort((a, b) => a.relative.localeCompare(b.relative));
|
|
929
|
+
}
|
|
930
|
+
function readJsonFile(path) {
|
|
931
|
+
if (!existsSync2(path)) return null;
|
|
932
|
+
try {
|
|
933
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
934
|
+
} catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function pathAllowed(file, allowedPaths) {
|
|
939
|
+
return allowedPaths.some((allowedPath) => pathMatches(file, allowedPath));
|
|
940
|
+
}
|
|
941
|
+
function pathMatches(file, pattern) {
|
|
942
|
+
const normalizedFile = normalizePath(file);
|
|
943
|
+
const normalizedPattern = normalizePath(pattern);
|
|
944
|
+
return normalizedFile === normalizedPattern || normalizedFile.endsWith(`/${normalizedPattern}`);
|
|
945
|
+
}
|
|
946
|
+
function normalizePath(path) {
|
|
947
|
+
return path.split(sep).join("/").replace(/\\/g, "/");
|
|
948
|
+
}
|
|
949
|
+
function lineColumnAt(contents, index) {
|
|
950
|
+
const before = contents.slice(0, index);
|
|
951
|
+
const lines = before.split(/\r?\n/);
|
|
952
|
+
return {
|
|
953
|
+
line: lines.length,
|
|
954
|
+
column: lines[lines.length - 1].length + 1
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function escapeRegExp(value) {
|
|
958
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
959
|
+
}
|
|
960
|
+
function lineAt(contents, line) {
|
|
961
|
+
return contents.split(/\r?\n/)[line - 1] ?? "";
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/style-bridge.ts
|
|
965
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
966
|
+
import { join as join3 } from "path";
|
|
967
|
+
function styleBridgeProposalPath(projectRoot) {
|
|
968
|
+
return join3(projectRoot, ".decantr", "style-bridge.proposal.json");
|
|
969
|
+
}
|
|
970
|
+
function styleBridgePath(projectRoot) {
|
|
971
|
+
return join3(projectRoot, ".decantr", "style-bridge.json");
|
|
972
|
+
}
|
|
973
|
+
function readJsonFile2(path) {
|
|
974
|
+
if (!existsSync3(path)) return null;
|
|
975
|
+
try {
|
|
976
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function writeJsonFile(path, value) {
|
|
982
|
+
writeFileSync2(path, `${JSON.stringify(value, null, 2)}
|
|
983
|
+
`, "utf-8");
|
|
984
|
+
}
|
|
985
|
+
function readStyleBridge(projectRoot) {
|
|
986
|
+
return readJsonFile2(styleBridgePath(projectRoot));
|
|
987
|
+
}
|
|
988
|
+
function readStyleBridgeProposal(projectRoot) {
|
|
989
|
+
return readJsonFile2(styleBridgeProposalPath(projectRoot));
|
|
990
|
+
}
|
|
991
|
+
function stringArray(value) {
|
|
992
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
993
|
+
}
|
|
994
|
+
function readThemeInventory(projectRoot) {
|
|
995
|
+
const inventory = readJsonFile2(join3(projectRoot, ".decantr", "theme-inventory.json"));
|
|
996
|
+
return {
|
|
997
|
+
modes: stringArray(inventory?.modes),
|
|
998
|
+
variantIds: Array.isArray(inventory?.variants) ? inventory.variants.map((variant) => variant.id).filter((id) => typeof id === "string") : [],
|
|
999
|
+
darkModeDetected: typeof inventory?.darkModeDetected === "boolean" ? inventory.darkModeDetected : null
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
function tokenHints(styling, terms) {
|
|
1003
|
+
return styling.cssVariables.filter((name) => terms.test(name)).slice(0, 12);
|
|
1004
|
+
}
|
|
1005
|
+
function readProjectPatternPack(projectRoot) {
|
|
1006
|
+
return readLocalPatternPack(projectRoot) ?? readJsonFile2(
|
|
1007
|
+
join3(projectRoot, ".decantr", "local-patterns.proposal.json")
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
function classHintsForPattern(projectRoot, ids) {
|
|
1011
|
+
const pack = readProjectPatternPack(projectRoot);
|
|
1012
|
+
const classes = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const pattern of pack?.patterns ?? []) {
|
|
1014
|
+
if (!ids.includes(String(pattern.id))) continue;
|
|
1015
|
+
for (const hint of pattern.classHints ?? []) classes.add(hint);
|
|
1016
|
+
}
|
|
1017
|
+
return [...classes].slice(0, 12);
|
|
1018
|
+
}
|
|
1019
|
+
function sourceEvidence(projectRoot, ids) {
|
|
1020
|
+
const pack = readProjectPatternPack(projectRoot);
|
|
1021
|
+
const evidence = /* @__PURE__ */ new Set();
|
|
1022
|
+
for (const pattern of pack?.patterns ?? []) {
|
|
1023
|
+
if (!ids.includes(String(pattern.id))) continue;
|
|
1024
|
+
for (const path of pattern.componentPaths ?? []) evidence.add(path);
|
|
1025
|
+
for (const item of pattern.evidence ?? []) evidence.add(item);
|
|
1026
|
+
}
|
|
1027
|
+
return [...evidence].slice(0, 12);
|
|
1028
|
+
}
|
|
1029
|
+
function colorTokenNames(styling) {
|
|
1030
|
+
const names = new Set(Object.keys(styling.colors ?? {}));
|
|
1031
|
+
for (const variable of styling.cssVariables) {
|
|
1032
|
+
if (/color|bg|background|surface|text|foreground|border|primary|secondary|accent|muted/i.test(variable)) {
|
|
1033
|
+
names.add(variable);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return [...names].slice(0, 40);
|
|
1037
|
+
}
|
|
1038
|
+
function createStyleBridgeProposal(input) {
|
|
1039
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1040
|
+
const theme = readThemeInventory(input.projectRoot);
|
|
1041
|
+
const routeCount = input.essence && typeof input.essence === "object" && "blueprint" in input.essence ? Object.keys(input.essence.blueprint?.routes ?? {}).length : 0;
|
|
1042
|
+
const target = input.essence && typeof input.essence === "object" && "meta" in input.essence ? String(input.essence.meta?.target ?? "") || null : null;
|
|
1043
|
+
const darkModeDetected = theme.darkModeDetected ?? input.styling.darkMode;
|
|
1044
|
+
const themeModes = theme.modes.length > 0 ? theme.modes : darkModeDetected ? ["base", "dark"] : ["base"];
|
|
1045
|
+
const themeVariantIds = theme.variantIds.length > 0 ? theme.variantIds : themeModes.filter((mode) => mode !== "base");
|
|
1046
|
+
return {
|
|
1047
|
+
version: 1,
|
|
1048
|
+
status: "proposal",
|
|
1049
|
+
generatedAt,
|
|
1050
|
+
source: "decantr codify --style-bridge",
|
|
1051
|
+
purpose: "Project-owned Hybrid style bridge. It maps Decantr design intent to the existing app styling system without installing Decantr CSS or taking over source.",
|
|
1052
|
+
adoption: {
|
|
1053
|
+
mode: "style-bridge",
|
|
1054
|
+
workflowMode: "brownfield-attach",
|
|
1055
|
+
sourceAuthority: "Existing production source stays authoritative.",
|
|
1056
|
+
styleAuthority: "Use these mappings to translate Decantr concepts into project-owned tokens, classes, and components.",
|
|
1057
|
+
notRuntimeTakeover: true,
|
|
1058
|
+
authorityPrecedence: [
|
|
1059
|
+
"existing production source",
|
|
1060
|
+
"accepted style bridge",
|
|
1061
|
+
"accepted local patterns and rules",
|
|
1062
|
+
"Essence V4 contract",
|
|
1063
|
+
"hosted registry patterns and execution packs as optional guidance"
|
|
1064
|
+
]
|
|
1065
|
+
},
|
|
1066
|
+
project: {
|
|
1067
|
+
framework: input.detected.framework,
|
|
1068
|
+
packageManager: input.detected.packageManager,
|
|
1069
|
+
target,
|
|
1070
|
+
routeCount
|
|
1071
|
+
},
|
|
1072
|
+
styling: {
|
|
1073
|
+
approach: input.styling.approach,
|
|
1074
|
+
configFile: input.styling.configFile ?? null,
|
|
1075
|
+
darkModeDetected,
|
|
1076
|
+
cssVariables: input.styling.cssVariables.slice(0, 80),
|
|
1077
|
+
colorTokenNames: colorTokenNames(input.styling),
|
|
1078
|
+
themeModes,
|
|
1079
|
+
themeVariantIds
|
|
1080
|
+
},
|
|
1081
|
+
mappings: [
|
|
1082
|
+
{
|
|
1083
|
+
id: "surface",
|
|
1084
|
+
label: "Surfaces and cards",
|
|
1085
|
+
decantrIntent: "surface background, card treatment, border, radius, depth, and hover state",
|
|
1086
|
+
projectAuthority: "Use the app card/surface primitives, tokens, and accepted local law.",
|
|
1087
|
+
tokenHints: tokenHints(input.styling, /surface|card|panel|bg|background|border|shadow|radius/i),
|
|
1088
|
+
classHints: classHintsForPattern(input.projectRoot, ["surface-card"]),
|
|
1089
|
+
sourceEvidence: sourceEvidence(input.projectRoot, ["surface-card"]),
|
|
1090
|
+
guardrails: [
|
|
1091
|
+
"Do not invent a new card color/radius/shadow recipe without updating local law.",
|
|
1092
|
+
"Do not add Decantr CSS d-* classes unless adoption mode changes to decantr-css."
|
|
1093
|
+
]
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
id: "action",
|
|
1097
|
+
label: "Actions and buttons",
|
|
1098
|
+
decantrIntent: "primary, secondary, tertiary, destructive, icon-only, loading, and disabled action states",
|
|
1099
|
+
projectAuthority: "Use the app button/action primitives and local variant names.",
|
|
1100
|
+
tokenHints: tokenHints(input.styling, /primary|secondary|accent|danger|error|destructive|focus/i),
|
|
1101
|
+
classHints: classHintsForPattern(input.projectRoot, ["button"]),
|
|
1102
|
+
sourceEvidence: sourceEvidence(input.projectRoot, ["button"]),
|
|
1103
|
+
guardrails: [
|
|
1104
|
+
"Map any new action style to primary/secondary/tertiary/destructive/icon before coding.",
|
|
1105
|
+
"Avoid raw button markup when a project-owned primitive exists."
|
|
1106
|
+
]
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
id: "focus-accessibility",
|
|
1110
|
+
label: "Focus and accessibility",
|
|
1111
|
+
decantrIntent: "visible focus, keyboard clarity, contrast, and reduced-motion safety",
|
|
1112
|
+
projectAuthority: "Use the app accessibility classes, focus tokens, and framework conventions.",
|
|
1113
|
+
tokenHints: tokenHints(input.styling, /focus|ring|outline|contrast|motion|duration/i),
|
|
1114
|
+
classHints: [],
|
|
1115
|
+
sourceEvidence: [],
|
|
1116
|
+
guardrails: [
|
|
1117
|
+
"Every new interactive control needs visible focus evidence.",
|
|
1118
|
+
"Motion should honor prefers-reduced-motion or an existing app motion helper."
|
|
1119
|
+
]
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
id: "layout-density",
|
|
1123
|
+
label: "Layout density and spacing",
|
|
1124
|
+
decantrIntent: "route gutters, section gaps, component padding, density, and responsive rhythm",
|
|
1125
|
+
projectAuthority: "Use existing layout wrappers, shell primitives, and spacing tokens/classes.",
|
|
1126
|
+
tokenHints: tokenHints(input.styling, /space|spacing|gap|gutter|container|radius/i),
|
|
1127
|
+
classHints: classHintsForPattern(input.projectRoot, ["page-shell"]),
|
|
1128
|
+
sourceEvidence: sourceEvidence(input.projectRoot, ["page-shell"]),
|
|
1129
|
+
guardrails: [
|
|
1130
|
+
"Do not let each page invent independent max-width, gutters, or scroll ownership.",
|
|
1131
|
+
"Use the detected shell/layout authority before adding a new wrapper."
|
|
1132
|
+
]
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
id: "theme-variant",
|
|
1136
|
+
label: "Theme variants",
|
|
1137
|
+
decantrIntent: "light, dark, brand, tenant, seasonal, and density variants",
|
|
1138
|
+
projectAuthority: "Use the app theme provider, data attributes, CSS variables, or Tailwind mode strategy.",
|
|
1139
|
+
tokenHints: tokenHints(input.styling, /theme|dark|light|brand|tenant|mode|color/i),
|
|
1140
|
+
classHints: classHintsForPattern(input.projectRoot, ["theme-variant"]),
|
|
1141
|
+
sourceEvidence: sourceEvidence(input.projectRoot, ["theme-variant"]),
|
|
1142
|
+
guardrails: [
|
|
1143
|
+
"Keep theme modes documented in .decantr/theme-inventory.json and this bridge.",
|
|
1144
|
+
"Do not treat theme switcher container classes as standalone theme variants."
|
|
1145
|
+
]
|
|
1146
|
+
}
|
|
1147
|
+
],
|
|
1148
|
+
rules: [
|
|
1149
|
+
"The bridge is advisory until accepted; after acceptance, task/doctor/CI should surface it as Hybrid authority.",
|
|
1150
|
+
"The bridge does not make hosted registry patterns enforceable. Map hosted concepts into local law first.",
|
|
1151
|
+
"Keep deterministic blocking checks in .decantr/rules.json, ESLint, Biome, tests, or visual regression."
|
|
1152
|
+
],
|
|
1153
|
+
nextSteps: [
|
|
1154
|
+
"Review token and class hints. Replace generic hints with the exact project tokens/classes the team owns.",
|
|
1155
|
+
"Run decantr codify --accept to promote the proposal to .decantr/style-bridge.json.",
|
|
1156
|
+
"Use decantr task <route> before LLM edits so the style bridge appears in task context.",
|
|
1157
|
+
"Run decantr ci or decantr verify after edits to keep local law and Project Health visible."
|
|
1158
|
+
]
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function writeStyleBridgeProposal(projectRoot, proposal) {
|
|
1162
|
+
const decantrDir = join3(projectRoot, ".decantr");
|
|
1163
|
+
mkdirSync2(decantrDir, { recursive: true });
|
|
1164
|
+
const path = styleBridgeProposalPath(projectRoot);
|
|
1165
|
+
writeJsonFile(path, proposal);
|
|
1166
|
+
return path;
|
|
1167
|
+
}
|
|
1168
|
+
function acceptStyleBridge(projectRoot) {
|
|
1169
|
+
const proposal = readStyleBridgeProposal(projectRoot);
|
|
1170
|
+
if (!proposal) return null;
|
|
1171
|
+
const accepted = {
|
|
1172
|
+
...proposal,
|
|
1173
|
+
status: "accepted",
|
|
1174
|
+
acceptedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1175
|
+
};
|
|
1176
|
+
const path = styleBridgePath(projectRoot);
|
|
1177
|
+
writeJsonFile(path, accepted);
|
|
1178
|
+
const projectJsonPath = join3(projectRoot, ".decantr", "project.json");
|
|
1179
|
+
const projectJson = readJsonFile2(projectJsonPath) ?? {};
|
|
1180
|
+
const initialized = typeof projectJson.initialized === "object" && projectJson.initialized !== null ? projectJson.initialized : {};
|
|
1181
|
+
writeJsonFile(projectJsonPath, {
|
|
1182
|
+
...projectJson,
|
|
1183
|
+
initialized: {
|
|
1184
|
+
...initialized,
|
|
1185
|
+
workflowMode: initialized.workflowMode ?? "brownfield-attach",
|
|
1186
|
+
adoptionMode: "style-bridge"
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
return path;
|
|
1190
|
+
}
|
|
1191
|
+
function createStyleBridgeTaskSummary(projectRoot) {
|
|
1192
|
+
const bridge = readStyleBridge(projectRoot);
|
|
1193
|
+
return {
|
|
1194
|
+
path: bridge ? ".decantr/style-bridge.json" : null,
|
|
1195
|
+
status: bridge?.status ?? null,
|
|
1196
|
+
mappingCount: bridge?.mappings?.length ?? 0,
|
|
1197
|
+
stylingApproach: bridge?.styling?.approach ?? null,
|
|
1198
|
+
themeModes: bridge?.styling?.themeModes ?? [],
|
|
1199
|
+
mappings: bridge?.mappings?.map((mapping) => ({
|
|
1200
|
+
id: mapping.id,
|
|
1201
|
+
label: mapping.label,
|
|
1202
|
+
tokenHints: mapping.tokenHints.slice(0, 6),
|
|
1203
|
+
classHints: mapping.classHints.slice(0, 4),
|
|
1204
|
+
guardrails: mapping.guardrails.slice(0, 3)
|
|
1205
|
+
})) ?? []
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function styleBridgeMatches(projectRoot, query) {
|
|
1209
|
+
if (!projectRoot) return [];
|
|
1210
|
+
const bridge = readStyleBridge(projectRoot);
|
|
1211
|
+
if (!bridge) return [];
|
|
1212
|
+
const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length > 1).flatMap((term) => term.endsWith("s") && term.length > 3 ? [term, term.slice(0, -1)] : [term]);
|
|
1213
|
+
if (terms.length === 0) return [];
|
|
1214
|
+
return bridge.mappings.map((mapping) => {
|
|
1215
|
+
const haystack = [
|
|
1216
|
+
mapping.id,
|
|
1217
|
+
mapping.label,
|
|
1218
|
+
mapping.decantrIntent,
|
|
1219
|
+
mapping.projectAuthority,
|
|
1220
|
+
...mapping.tokenHints,
|
|
1221
|
+
...mapping.classHints,
|
|
1222
|
+
...mapping.guardrails
|
|
1223
|
+
].join(" ").toLowerCase();
|
|
1224
|
+
const score = terms.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0);
|
|
1225
|
+
return {
|
|
1226
|
+
id: mapping.id,
|
|
1227
|
+
label: mapping.label,
|
|
1228
|
+
score,
|
|
1229
|
+
tokenHints: mapping.tokenHints.slice(0, 4),
|
|
1230
|
+
classHints: mapping.classHints.slice(0, 3)
|
|
1231
|
+
};
|
|
1232
|
+
}).filter((match) => match.score > 0).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 5);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/commands/graph.ts
|
|
1236
|
+
var GREEN = "\x1B[32m";
|
|
1237
|
+
var RED = "\x1B[31m";
|
|
1238
|
+
var DIM = "\x1B[2m";
|
|
1239
|
+
var RESET = "\x1B[0m";
|
|
1240
|
+
function hashBuffer(value) {
|
|
1241
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1242
|
+
}
|
|
1243
|
+
function hashFile(path) {
|
|
1244
|
+
return `sha256:${hashBuffer(readFileSync4(path))}`;
|
|
1245
|
+
}
|
|
1246
|
+
function hashJson(value) {
|
|
1247
|
+
return `sha256:${hashBuffer(stableJson(value))}`;
|
|
1248
|
+
}
|
|
1249
|
+
function formatJson(value) {
|
|
1250
|
+
return `${JSON.stringify(value, null, 2)}
|
|
1251
|
+
`;
|
|
1252
|
+
}
|
|
1253
|
+
function stableJson(value) {
|
|
1254
|
+
if (Array.isArray(value)) {
|
|
1255
|
+
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
1256
|
+
}
|
|
1257
|
+
if (value && typeof value === "object") {
|
|
1258
|
+
const record = value;
|
|
1259
|
+
return `{${Object.keys(record).sort().filter((key) => record[key] !== void 0).map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
|
|
1260
|
+
}
|
|
1261
|
+
return JSON.stringify(value);
|
|
1262
|
+
}
|
|
1263
|
+
function readJsonFile3(path) {
|
|
1264
|
+
if (!existsSync4(path)) return null;
|
|
1265
|
+
try {
|
|
1266
|
+
return JSON.parse(readFileSync4(path, "utf-8"));
|
|
1267
|
+
} catch {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
function visualManifestSourceHash(manifest) {
|
|
1272
|
+
return hashJson({
|
|
1273
|
+
version: manifest.version,
|
|
1274
|
+
localOnly: manifest.localOnly,
|
|
1275
|
+
baseUrl: manifest.baseUrl ?? null,
|
|
1276
|
+
routes: (manifest.routes ?? []).map((route) => ({
|
|
1277
|
+
route: route.route,
|
|
1278
|
+
url: route.url,
|
|
1279
|
+
screenshot: route.screenshot,
|
|
1280
|
+
screenshotHash: route.screenshotHash ?? null,
|
|
1281
|
+
status: route.status,
|
|
1282
|
+
error: route.error
|
|
1283
|
+
}))
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
function stableFindingGraphAnchor(finding) {
|
|
1287
|
+
if (!finding.graph) return void 0;
|
|
1288
|
+
return {
|
|
1289
|
+
node_id: finding.graph.node_id,
|
|
1290
|
+
node_type: finding.graph.node_type,
|
|
1291
|
+
route: finding.graph.route,
|
|
1292
|
+
confidence: finding.graph.confidence,
|
|
1293
|
+
reason: finding.graph.reason
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function evidenceBundleSourceHash(bundle) {
|
|
1297
|
+
return hashJson({
|
|
1298
|
+
health: bundle.health ? {
|
|
1299
|
+
status: bundle.health.status,
|
|
1300
|
+
score: bundle.health.score,
|
|
1301
|
+
errorCount: bundle.health.errorCount,
|
|
1302
|
+
warnCount: bundle.health.warnCount,
|
|
1303
|
+
infoCount: bundle.health.infoCount,
|
|
1304
|
+
findingCount: bundle.health.findingCount
|
|
1305
|
+
} : null,
|
|
1306
|
+
provenance: Object.entries(bundle.provenance ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => ({
|
|
1307
|
+
key,
|
|
1308
|
+
path: entry.path,
|
|
1309
|
+
present: entry.present,
|
|
1310
|
+
hash: entry.hash ?? null
|
|
1311
|
+
})),
|
|
1312
|
+
findings: (bundle.findings ?? []).map((finding) => ({
|
|
1313
|
+
id: finding.id,
|
|
1314
|
+
code: finding.code,
|
|
1315
|
+
source: finding.source,
|
|
1316
|
+
category: finding.category,
|
|
1317
|
+
severity: finding.severity,
|
|
1318
|
+
message: finding.message,
|
|
1319
|
+
target: finding.target,
|
|
1320
|
+
rule: finding.rule,
|
|
1321
|
+
suggestedFix: finding.suggestedFix,
|
|
1322
|
+
graph: stableFindingGraphAnchor(finding),
|
|
1323
|
+
repair: finding.repair?.id,
|
|
1324
|
+
repairPlan: finding.repairPlan ? {
|
|
1325
|
+
id: finding.repairPlan.id,
|
|
1326
|
+
actions: finding.repairPlan.actions,
|
|
1327
|
+
readTargets: finding.repairPlan.readTargets,
|
|
1328
|
+
commands: finding.repairPlan.commands
|
|
1329
|
+
} : void 0,
|
|
1330
|
+
evidence: finding.evidence,
|
|
1331
|
+
commands: finding.commands
|
|
1332
|
+
}))
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
function healthBaselineDiffSourceHash(diff) {
|
|
1336
|
+
return hashJson({
|
|
1337
|
+
savedAt: diff.savedAt ?? null,
|
|
1338
|
+
statusChanged: diff.statusChanged ?? false,
|
|
1339
|
+
scoreDelta: diff.scoreDelta ?? null,
|
|
1340
|
+
addedFindings: diff.addedFindings ?? [],
|
|
1341
|
+
resolvedFindings: diff.resolvedFindings ?? [],
|
|
1342
|
+
changedFiles: diff.changedFiles ?? [],
|
|
1343
|
+
changedRoutes: diff.changedRoutes ?? [],
|
|
1344
|
+
changedScreenshots: diff.changedScreenshots ?? [],
|
|
1345
|
+
contractDrift: diff.contractDrift ?? []
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
function analysisSourceHash(analysis) {
|
|
1349
|
+
return hashJson({
|
|
1350
|
+
project: {
|
|
1351
|
+
framework: analysis.project?.framework,
|
|
1352
|
+
frameworkVersion: analysis.project?.frameworkVersion,
|
|
1353
|
+
packageManager: analysis.project?.packageManager,
|
|
1354
|
+
hasTypeScript: analysis.project?.hasTypeScript,
|
|
1355
|
+
hasTailwind: analysis.project?.hasTailwind,
|
|
1356
|
+
projectScope: analysis.project?.projectScope
|
|
1357
|
+
},
|
|
1358
|
+
routes: {
|
|
1359
|
+
strategy: analysis.routes?.strategy,
|
|
1360
|
+
routes: (analysis.routes?.routes ?? []).map((route) => ({
|
|
1361
|
+
path: route.path,
|
|
1362
|
+
file: route.file,
|
|
1363
|
+
hasLayout: route.hasLayout
|
|
1364
|
+
}))
|
|
1365
|
+
},
|
|
1366
|
+
styling: {
|
|
1367
|
+
approach: analysis.styling?.approach,
|
|
1368
|
+
configFile: analysis.styling?.configFile,
|
|
1369
|
+
darkMode: analysis.styling?.darkMode,
|
|
1370
|
+
cssVariables: analysis.styling?.cssVariables
|
|
1371
|
+
},
|
|
1372
|
+
layout: {
|
|
1373
|
+
shellPattern: analysis.layout?.shellPattern
|
|
1374
|
+
},
|
|
1375
|
+
features: {
|
|
1376
|
+
detected: analysis.features?.detected
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
function graphPaths(projectRoot) {
|
|
1381
|
+
const graphDir = join4(projectRoot, ".decantr", "graph");
|
|
1382
|
+
return {
|
|
1383
|
+
graphDir,
|
|
1384
|
+
snapshotsDir: join4(graphDir, "snapshots"),
|
|
1385
|
+
snapshot: join4(graphDir, "graph.snapshot.json"),
|
|
1386
|
+
snapshotHistory: join4(graphDir, "snapshots", "pending.json"),
|
|
1387
|
+
manifest: join4(graphDir, "graph.manifest.json"),
|
|
1388
|
+
diff: join4(graphDir, "graph.diff.json"),
|
|
1389
|
+
capsule: join4(graphDir, "contract-capsule.json")
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
function graphSnapshotHistoryFileName(snapshotId) {
|
|
1393
|
+
return `${snapshotId.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.json`;
|
|
1394
|
+
}
|
|
1395
|
+
function withSnapshotHistoryPath(paths, snapshotId) {
|
|
1396
|
+
return {
|
|
1397
|
+
...paths,
|
|
1398
|
+
snapshotHistory: join4(paths.snapshotsDir, graphSnapshotHistoryFileName(snapshotId))
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function graphSnapshotPathForId(projectRoot, snapshotId) {
|
|
1402
|
+
const paths = graphPaths(projectRoot);
|
|
1403
|
+
if (!snapshotId || snapshotId === "current") return paths.snapshot;
|
|
1404
|
+
return join4(paths.snapshotsDir, graphSnapshotHistoryFileName(snapshotId));
|
|
1405
|
+
}
|
|
1406
|
+
function readGraphSnapshotSelection(artifacts, snapshotId) {
|
|
1407
|
+
const selector = snapshotId || "current";
|
|
1408
|
+
if (selector === "current") {
|
|
1409
|
+
return {
|
|
1410
|
+
selector,
|
|
1411
|
+
path: artifacts.paths.snapshot,
|
|
1412
|
+
snapshot: artifacts.snapshot
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
if (selector === artifacts.snapshot.id) {
|
|
1416
|
+
return {
|
|
1417
|
+
selector,
|
|
1418
|
+
path: artifacts.paths.snapshotHistory,
|
|
1419
|
+
snapshot: artifacts.snapshot
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
const path = graphSnapshotPathForId(artifacts.projectRoot, selector);
|
|
1423
|
+
const snapshot = readJsonFile3(path);
|
|
1424
|
+
if (!snapshot) {
|
|
1425
|
+
throw new Error(
|
|
1426
|
+
`Graph snapshot not found: ${selector}. Expected ${pathForDisplay(
|
|
1427
|
+
artifacts.projectRoot,
|
|
1428
|
+
path
|
|
1429
|
+
)}. Run \`decantr graph --json\` to see the current snapshot id and history path.`
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
return {
|
|
1433
|
+
selector,
|
|
1434
|
+
path,
|
|
1435
|
+
snapshot: normalizeGraphSnapshot(snapshot)
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function projectRelativePath(projectRoot, path) {
|
|
1439
|
+
if (!path) return null;
|
|
1440
|
+
const absolutePath = isAbsolute2(path) ? path : join4(projectRoot, path);
|
|
1441
|
+
const relativePath = relative2(projectRoot, absolutePath).replace(/\\/g, "/");
|
|
1442
|
+
if (!relativePath || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
return relativePath;
|
|
1446
|
+
}
|
|
1447
|
+
function existingProjectRelativePath(projectRoot, path) {
|
|
1448
|
+
const relativePath = projectRelativePath(projectRoot, path);
|
|
1449
|
+
if (!relativePath) return null;
|
|
1450
|
+
return existsSync4(join4(projectRoot, relativePath)) ? relativePath : null;
|
|
1451
|
+
}
|
|
1452
|
+
function stripJsonComments(value) {
|
|
1453
|
+
return value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
1454
|
+
}
|
|
1455
|
+
function readCompilerImportResolutionConfig(projectRoot) {
|
|
1456
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
1457
|
+
const path = join4(projectRoot, name);
|
|
1458
|
+
if (!existsSync4(path)) continue;
|
|
1459
|
+
try {
|
|
1460
|
+
const parsed = JSON.parse(stripJsonComments(readFileSync4(path, "utf-8")));
|
|
1461
|
+
const options = parsed.compilerOptions ?? {};
|
|
1462
|
+
const paths = [];
|
|
1463
|
+
if (options.paths && typeof options.paths === "object" && !Array.isArray(options.paths)) {
|
|
1464
|
+
for (const [pattern, targets] of Object.entries(options.paths)) {
|
|
1465
|
+
if (!Array.isArray(targets)) continue;
|
|
1466
|
+
paths.push({
|
|
1467
|
+
pattern,
|
|
1468
|
+
targets: targets.filter((target) => typeof target === "string")
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return {
|
|
1473
|
+
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : null,
|
|
1474
|
+
paths
|
|
1475
|
+
};
|
|
1476
|
+
} catch {
|
|
1477
|
+
return { baseUrl: null, paths: [] };
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return { baseUrl: null, paths: [] };
|
|
1481
|
+
}
|
|
1482
|
+
function existingImportCandidate(projectRoot, candidate) {
|
|
1483
|
+
const relativeCandidate = projectRelativePath(projectRoot, candidate);
|
|
1484
|
+
if (!relativeCandidate) return null;
|
|
1485
|
+
const candidates = [
|
|
1486
|
+
relativeCandidate,
|
|
1487
|
+
`${relativeCandidate}.ts`,
|
|
1488
|
+
`${relativeCandidate}.tsx`,
|
|
1489
|
+
`${relativeCandidate}.js`,
|
|
1490
|
+
`${relativeCandidate}.jsx`,
|
|
1491
|
+
`${relativeCandidate}.mts`,
|
|
1492
|
+
`${relativeCandidate}.cts`,
|
|
1493
|
+
join4(relativeCandidate, "index.ts").replace(/\\/g, "/"),
|
|
1494
|
+
join4(relativeCandidate, "index.tsx").replace(/\\/g, "/"),
|
|
1495
|
+
join4(relativeCandidate, "index.js").replace(/\\/g, "/"),
|
|
1496
|
+
join4(relativeCandidate, "index.jsx").replace(/\\/g, "/"),
|
|
1497
|
+
join4(relativeCandidate, "index.mts").replace(/\\/g, "/"),
|
|
1498
|
+
join4(relativeCandidate, "index.cts").replace(/\\/g, "/")
|
|
1499
|
+
];
|
|
1500
|
+
for (const possible of candidates) {
|
|
1501
|
+
if (existsSync4(join4(projectRoot, possible))) return possible;
|
|
1502
|
+
}
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
function pathAliasTargetCandidates(source, config) {
|
|
1506
|
+
const candidates = [];
|
|
1507
|
+
for (const alias of config.paths) {
|
|
1508
|
+
const starIndex = alias.pattern.indexOf("*");
|
|
1509
|
+
if (starIndex === -1) {
|
|
1510
|
+
if (alias.pattern !== source) continue;
|
|
1511
|
+
candidates.push(...alias.targets);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
const prefix = alias.pattern.slice(0, starIndex);
|
|
1515
|
+
const suffix = alias.pattern.slice(starIndex + 1);
|
|
1516
|
+
if (!source.startsWith(prefix) || !source.endsWith(suffix)) continue;
|
|
1517
|
+
const matched = source.slice(prefix.length, source.length - suffix.length);
|
|
1518
|
+
for (const target of alias.targets) {
|
|
1519
|
+
candidates.push(target.replace("*", matched));
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (config.baseUrl) {
|
|
1523
|
+
candidates.push(join4(config.baseUrl, source).replace(/\\/g, "/"));
|
|
1524
|
+
}
|
|
1525
|
+
if (source.startsWith("@/")) {
|
|
1526
|
+
const withoutAlias = source.slice(2);
|
|
1527
|
+
candidates.push(`src/${withoutAlias}`, withoutAlias);
|
|
1528
|
+
}
|
|
1529
|
+
return [...new Set(candidates)];
|
|
1530
|
+
}
|
|
1531
|
+
function resolveImportSourcePath(projectRoot, fromFile, source) {
|
|
1532
|
+
if (source.startsWith(".")) {
|
|
1533
|
+
return existingImportCandidate(projectRoot, join4(dirname2(fromFile), source));
|
|
1534
|
+
}
|
|
1535
|
+
const config = readCompilerImportResolutionConfig(projectRoot);
|
|
1536
|
+
for (const candidate of pathAliasTargetCandidates(source, config)) {
|
|
1537
|
+
const resolved = existingImportCandidate(projectRoot, candidate);
|
|
1538
|
+
if (resolved) return resolved;
|
|
1539
|
+
}
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
function projectPathCandidatesFromText(text) {
|
|
1543
|
+
if (!text) return [];
|
|
1544
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1545
|
+
const pathPattern = /(?:^|[\s([`"'])((?:\.\/|\.\.\/|\/|[A-Za-z0-9_.-]+\/)[A-Za-z0-9_./@-]+\.(?:[cm]?[jt]sx?|css|scss|sass|less|html|json|mdx?|png|jpe?g|webp|svg|gif))(?:[:)\]`"',\s]|$)/g;
|
|
1546
|
+
for (const match of text.matchAll(pathPattern)) {
|
|
1547
|
+
candidates.add(match[1]);
|
|
1548
|
+
}
|
|
1549
|
+
return [...candidates];
|
|
1550
|
+
}
|
|
1551
|
+
function evidenceBundleFindingSourcePaths(projectRoot, finding) {
|
|
1552
|
+
const candidates = [
|
|
1553
|
+
finding.target,
|
|
1554
|
+
...(finding.evidence ?? []).flatMap(projectPathCandidatesFromText),
|
|
1555
|
+
...finding.repairPlan?.readTargets ?? []
|
|
1556
|
+
];
|
|
1557
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1558
|
+
for (const candidate of candidates) {
|
|
1559
|
+
const relativePath = existingProjectRelativePath(projectRoot, candidate);
|
|
1560
|
+
if (relativePath) paths.add(relativePath);
|
|
1561
|
+
}
|
|
1562
|
+
return [...paths].sort();
|
|
1563
|
+
}
|
|
1564
|
+
function graphSlug(value, fallback) {
|
|
1565
|
+
const slug = value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1566
|
+
return slug || fallback;
|
|
1567
|
+
}
|
|
1568
|
+
function graphEdgeKey(edge) {
|
|
1569
|
+
return [edge.src, edge.relation, edge.dst, String(edge.idx ?? "")].join("\0");
|
|
1570
|
+
}
|
|
1571
|
+
function addNode(nodes, node) {
|
|
1572
|
+
if (!nodes.has(node.id)) {
|
|
1573
|
+
nodes.set(node.id, node);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function addEdge(edges, edge) {
|
|
1577
|
+
edges.set(graphEdgeKey(edge), edge);
|
|
1578
|
+
}
|
|
1579
|
+
function sourceArtifacts(projectRoot, componentReuseAudit = null) {
|
|
1580
|
+
const sources = [];
|
|
1581
|
+
const essencePath = join4(projectRoot, "decantr.essence.json");
|
|
1582
|
+
sources.push({
|
|
1583
|
+
id: "src:decantr.essence.json",
|
|
1584
|
+
kind: "essence",
|
|
1585
|
+
path: "decantr.essence.json",
|
|
1586
|
+
hash: hashFile(essencePath)
|
|
1587
|
+
});
|
|
1588
|
+
const rulesPath = localRulesPath(projectRoot);
|
|
1589
|
+
if (existsSync4(rulesPath)) {
|
|
1590
|
+
sources.push({
|
|
1591
|
+
id: "src:.decantr/rules.json",
|
|
1592
|
+
kind: "local-rule-manifest",
|
|
1593
|
+
path: ".decantr/rules.json",
|
|
1594
|
+
hash: hashFile(rulesPath)
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
const bridgePath = styleBridgePath(projectRoot);
|
|
1598
|
+
if (existsSync4(bridgePath)) {
|
|
1599
|
+
sources.push({
|
|
1600
|
+
id: "src:.decantr/style-bridge.json",
|
|
1601
|
+
kind: "style-bridge-manifest",
|
|
1602
|
+
path: ".decantr/style-bridge.json",
|
|
1603
|
+
hash: hashFile(bridgePath)
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
const analysisPath = join4(projectRoot, ".decantr", "analysis.json");
|
|
1607
|
+
const analysis = readJsonFile3(analysisPath);
|
|
1608
|
+
if (analysis) {
|
|
1609
|
+
sources.push({
|
|
1610
|
+
id: "src:.decantr/analysis.json",
|
|
1611
|
+
kind: "brownfield-analysis",
|
|
1612
|
+
path: ".decantr/analysis.json",
|
|
1613
|
+
hash: analysisSourceHash(analysis),
|
|
1614
|
+
payload: {
|
|
1615
|
+
framework: analysis.project?.framework,
|
|
1616
|
+
routeStrategy: analysis.routes?.strategy,
|
|
1617
|
+
routes: analysis.routes?.routes?.length ?? 0,
|
|
1618
|
+
styling: analysis.styling?.approach
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
for (const route of analysis.routes?.routes ?? []) {
|
|
1622
|
+
const routeFile = projectRelativePath(projectRoot, route.file);
|
|
1623
|
+
if (!routeFile) continue;
|
|
1624
|
+
const routeFilePath = join4(projectRoot, routeFile);
|
|
1625
|
+
if (!existsSync4(routeFilePath)) continue;
|
|
1626
|
+
sources.push({
|
|
1627
|
+
id: `src:${routeFile}`,
|
|
1628
|
+
kind: "route-source",
|
|
1629
|
+
path: routeFile,
|
|
1630
|
+
hash: hashFile(routeFilePath),
|
|
1631
|
+
payload: {
|
|
1632
|
+
route: route.path,
|
|
1633
|
+
hasLayout: route.hasLayout ?? false,
|
|
1634
|
+
strategy: analysis.routes?.strategy
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
for (const declaration of componentReuseAudit?.declarations ?? []) {
|
|
1640
|
+
if (!declaration.reusable) continue;
|
|
1641
|
+
const componentFile = projectRelativePath(projectRoot, declaration.file);
|
|
1642
|
+
if (!componentFile) continue;
|
|
1643
|
+
const componentPath = join4(projectRoot, componentFile);
|
|
1644
|
+
if (!existsSync4(componentPath)) continue;
|
|
1645
|
+
if (sources.some((source) => source.id === `src:${componentFile}`)) continue;
|
|
1646
|
+
sources.push({
|
|
1647
|
+
id: `src:${componentFile}`,
|
|
1648
|
+
kind: "component-source",
|
|
1649
|
+
path: componentFile,
|
|
1650
|
+
hash: hashFile(componentPath),
|
|
1651
|
+
payload: {
|
|
1652
|
+
component: declaration.name,
|
|
1653
|
+
exported: declaration.exported,
|
|
1654
|
+
kind: declaration.kind,
|
|
1655
|
+
line: declaration.line
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
for (const importReference of componentReuseAudit?.imports ?? []) {
|
|
1660
|
+
const importingFile = projectRelativePath(projectRoot, importReference.file);
|
|
1661
|
+
if (!importingFile) continue;
|
|
1662
|
+
const importingPath = join4(projectRoot, importingFile);
|
|
1663
|
+
if (existsSync4(importingPath) && !sources.some((source) => source.id === `src:${importingFile}`)) {
|
|
1664
|
+
sources.push({
|
|
1665
|
+
id: `src:${importingFile}`,
|
|
1666
|
+
kind: "code-source",
|
|
1667
|
+
path: importingFile,
|
|
1668
|
+
hash: hashFile(importingPath),
|
|
1669
|
+
payload: {
|
|
1670
|
+
imports: (componentReuseAudit?.imports ?? []).filter(
|
|
1671
|
+
(entry) => projectRelativePath(projectRoot, entry.file) === importingFile
|
|
1672
|
+
).length
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
const importedFile = resolveImportSourcePath(projectRoot, importingFile, importReference.source);
|
|
1677
|
+
if (!importedFile) continue;
|
|
1678
|
+
const importedPath = join4(projectRoot, importedFile);
|
|
1679
|
+
if (!existsSync4(importedPath)) continue;
|
|
1680
|
+
if (sources.some((source) => source.id === `src:${importedFile}`)) continue;
|
|
1681
|
+
sources.push({
|
|
1682
|
+
id: `src:${importedFile}`,
|
|
1683
|
+
kind: "code-source",
|
|
1684
|
+
path: importedFile,
|
|
1685
|
+
hash: hashFile(importedPath),
|
|
1686
|
+
payload: {
|
|
1687
|
+
importedBy: importingFile
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
const visualManifestPath = join4(projectRoot, ".decantr", "evidence", "visual-manifest.json");
|
|
1692
|
+
const visualManifest = readJsonFile3(visualManifestPath);
|
|
1693
|
+
if (visualManifest) {
|
|
1694
|
+
sources.push({
|
|
1695
|
+
id: "src:.decantr/evidence/visual-manifest.json",
|
|
1696
|
+
kind: "visual-manifest",
|
|
1697
|
+
path: ".decantr/evidence/visual-manifest.json",
|
|
1698
|
+
hash: visualManifestSourceHash(visualManifest),
|
|
1699
|
+
payload: {
|
|
1700
|
+
localOnly: visualManifest.localOnly,
|
|
1701
|
+
baseUrl: visualManifest.baseUrl ?? null,
|
|
1702
|
+
routes: visualManifest.routes?.length ?? 0
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
for (const route of visualManifest.routes ?? []) {
|
|
1706
|
+
if (!route.screenshot) continue;
|
|
1707
|
+
const screenshotPath = join4(projectRoot, route.screenshot);
|
|
1708
|
+
if (!existsSync4(screenshotPath)) continue;
|
|
1709
|
+
sources.push({
|
|
1710
|
+
id: `src:${route.screenshot}`,
|
|
1711
|
+
kind: "visual-screenshot",
|
|
1712
|
+
path: route.screenshot,
|
|
1713
|
+
hash: hashFile(screenshotPath),
|
|
1714
|
+
payload: {
|
|
1715
|
+
route: route.route,
|
|
1716
|
+
status: route.status,
|
|
1717
|
+
screenshotHash: route.screenshotHash ?? null
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const evidenceBundlePath = join4(projectRoot, ".decantr", "evidence", "latest.json");
|
|
1723
|
+
const evidenceBundle = readJsonFile3(evidenceBundlePath);
|
|
1724
|
+
if (evidenceBundle) {
|
|
1725
|
+
sources.push({
|
|
1726
|
+
id: "src:.decantr/evidence/latest.json",
|
|
1727
|
+
kind: "evidence-bundle",
|
|
1728
|
+
path: ".decantr/evidence/latest.json",
|
|
1729
|
+
hash: evidenceBundleSourceHash(evidenceBundle),
|
|
1730
|
+
payload: {
|
|
1731
|
+
status: evidenceBundle.health?.status,
|
|
1732
|
+
score: evidenceBundle.health?.score,
|
|
1733
|
+
findings: evidenceBundle.findings?.length ?? 0,
|
|
1734
|
+
provenance: Object.keys(evidenceBundle.provenance ?? {}).length,
|
|
1735
|
+
graphSnapshotPresent: evidenceBundle.provenance?.graphSnapshot?.present,
|
|
1736
|
+
graphSnapshotHash: evidenceBundle.provenance?.graphSnapshot?.hash ?? null,
|
|
1737
|
+
contractCapsulePresent: evidenceBundle.provenance?.contractCapsule?.present,
|
|
1738
|
+
contractCapsuleHash: evidenceBundle.provenance?.contractCapsule?.hash ?? null
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
const findingSourcePaths = /* @__PURE__ */ new Map();
|
|
1742
|
+
for (const finding of evidenceBundle.findings ?? []) {
|
|
1743
|
+
for (const sourcePath of evidenceBundleFindingSourcePaths(projectRoot, finding)) {
|
|
1744
|
+
findingSourcePaths.set(sourcePath, [
|
|
1745
|
+
...findingSourcePaths.get(sourcePath) ?? [],
|
|
1746
|
+
finding
|
|
1747
|
+
]);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
for (const [sourcePath, findings] of findingSourcePaths) {
|
|
1751
|
+
if (sources.some((source) => source.id === `src:${sourcePath}`)) continue;
|
|
1752
|
+
sources.push({
|
|
1753
|
+
id: `src:${sourcePath}`,
|
|
1754
|
+
kind: "finding-source",
|
|
1755
|
+
path: sourcePath,
|
|
1756
|
+
hash: hashFile(join4(projectRoot, sourcePath)),
|
|
1757
|
+
payload: {
|
|
1758
|
+
findings: findings.map((finding) => finding.id),
|
|
1759
|
+
codes: [
|
|
1760
|
+
...new Set(
|
|
1761
|
+
findings.map((finding) => finding.code).filter((code) => typeof code === "string" && code.length > 0)
|
|
1762
|
+
)
|
|
1763
|
+
]
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const healthBaselineDiffPath = join4(projectRoot, ".decantr", "health-baseline-diff.json");
|
|
1769
|
+
const healthBaselineDiff = readJsonFile3(healthBaselineDiffPath);
|
|
1770
|
+
if (healthBaselineDiff) {
|
|
1771
|
+
for (const changedFile of healthBaselineDiff.changedFiles ?? []) {
|
|
1772
|
+
const sourcePath = existingProjectRelativePath(projectRoot, changedFile);
|
|
1773
|
+
if (!sourcePath) continue;
|
|
1774
|
+
if (sources.some((source) => source.id === `src:${sourcePath}`)) continue;
|
|
1775
|
+
sources.push({
|
|
1776
|
+
id: `src:${sourcePath}`,
|
|
1777
|
+
kind: "baseline-changed-source",
|
|
1778
|
+
path: sourcePath,
|
|
1779
|
+
hash: hashFile(join4(projectRoot, sourcePath)),
|
|
1780
|
+
payload: {
|
|
1781
|
+
role: "baseline-changed-file"
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
sources.push({
|
|
1786
|
+
id: "src:.decantr/health-baseline-diff.json",
|
|
1787
|
+
kind: "health-baseline-diff",
|
|
1788
|
+
path: ".decantr/health-baseline-diff.json",
|
|
1789
|
+
hash: healthBaselineDiffSourceHash(healthBaselineDiff),
|
|
1790
|
+
payload: {
|
|
1791
|
+
changedFiles: healthBaselineDiff.changedFiles?.length ?? 0,
|
|
1792
|
+
changedRoutes: healthBaselineDiff.changedRoutes?.length ?? 0,
|
|
1793
|
+
changedScreenshots: healthBaselineDiff.changedScreenshots?.length ?? 0,
|
|
1794
|
+
contractDrift: healthBaselineDiff.contractDrift?.length ?? 0
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
return dedupeSourceArtifacts(sources);
|
|
1799
|
+
}
|
|
1800
|
+
function dedupeSourceArtifacts(sources) {
|
|
1801
|
+
return [...new Map(sources.map((source) => [source.id, source])).values()];
|
|
1802
|
+
}
|
|
1803
|
+
function sourceHash(sources) {
|
|
1804
|
+
return hashJson(
|
|
1805
|
+
sources.map((source) => ({
|
|
1806
|
+
id: source.id,
|
|
1807
|
+
kind: source.kind,
|
|
1808
|
+
path: source.path,
|
|
1809
|
+
hash: source.hash
|
|
1810
|
+
}))
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
function evidenceBundleFindingNodeId(finding) {
|
|
1814
|
+
return `find:${graphSlug(finding.id, "finding")}`;
|
|
1815
|
+
}
|
|
1816
|
+
function evidenceBundleEvidenceNodeId(finding, index) {
|
|
1817
|
+
return `ev:finding:${graphSlug(finding.id, "finding")}:${index + 1}`;
|
|
1818
|
+
}
|
|
1819
|
+
function firstFindingSourceArtifactId(nodes, projectRoot, finding) {
|
|
1820
|
+
for (const sourcePath of evidenceBundleFindingSourcePaths(projectRoot, finding)) {
|
|
1821
|
+
const sourceId = `src:${sourcePath}`;
|
|
1822
|
+
if (nodes.has(sourceId)) return sourceId;
|
|
1823
|
+
}
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
function routeForScreenshotPath(visualManifest, screenshot) {
|
|
1827
|
+
return visualManifest?.routes?.find((route) => route.screenshot === screenshot)?.route ?? null;
|
|
1828
|
+
}
|
|
1829
|
+
function augmentProjectGraph(snapshot, projectRoot, sources, componentReuseAudit = null) {
|
|
1830
|
+
const nodes = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
|
1831
|
+
const edges = new Map(snapshot.edges.map((edge) => [graphEdgeKey(edge), edge]));
|
|
1832
|
+
for (const source of sources) {
|
|
1833
|
+
addNode(nodes, {
|
|
1834
|
+
id: source.id,
|
|
1835
|
+
type: "SourceArtifact",
|
|
1836
|
+
payload: source
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
const localRules = readLocalRuleManifest(projectRoot);
|
|
1840
|
+
if (localRules) {
|
|
1841
|
+
const sourceId = "src:.decantr/rules.json";
|
|
1842
|
+
for (const rule of localRules.rules ?? []) {
|
|
1843
|
+
const ruleId = `rule:${graphSlug(rule.id, "local-rule")}`;
|
|
1844
|
+
addNode(nodes, {
|
|
1845
|
+
id: ruleId,
|
|
1846
|
+
type: "LocalRule",
|
|
1847
|
+
payload: {
|
|
1848
|
+
...rule,
|
|
1849
|
+
manifest: {
|
|
1850
|
+
status: localRules.status,
|
|
1851
|
+
source: localRules.source,
|
|
1852
|
+
enforcement: localRules.enforcement
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
addEdge(edges, {
|
|
1857
|
+
src: ruleId,
|
|
1858
|
+
dst: snapshot.project_id,
|
|
1859
|
+
relation: "LOCAL_RULE_APPLIES_TO"
|
|
1860
|
+
});
|
|
1861
|
+
addEdge(edges, {
|
|
1862
|
+
src: ruleId,
|
|
1863
|
+
dst: sourceId,
|
|
1864
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
const styleBridge = readStyleBridge(projectRoot);
|
|
1869
|
+
if (styleBridge) {
|
|
1870
|
+
const sourceId = "src:.decantr/style-bridge.json";
|
|
1871
|
+
for (const mapping of styleBridge.mappings ?? []) {
|
|
1872
|
+
const bridgeId = `bridge:${graphSlug(mapping.id, "style-bridge")}`;
|
|
1873
|
+
addNode(nodes, {
|
|
1874
|
+
id: bridgeId,
|
|
1875
|
+
type: "StyleBridge",
|
|
1876
|
+
payload: {
|
|
1877
|
+
...mapping,
|
|
1878
|
+
manifest: {
|
|
1879
|
+
status: styleBridge.status,
|
|
1880
|
+
source: styleBridge.source,
|
|
1881
|
+
adoption: styleBridge.adoption
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
addEdge(edges, {
|
|
1886
|
+
src: bridgeId,
|
|
1887
|
+
dst: snapshot.project_id,
|
|
1888
|
+
relation: "STYLE_BRIDGE_MAPS_TO"
|
|
1889
|
+
});
|
|
1890
|
+
addEdge(edges, {
|
|
1891
|
+
src: bridgeId,
|
|
1892
|
+
dst: sourceId,
|
|
1893
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
1894
|
+
});
|
|
1895
|
+
for (const tokenHint of mapping.tokenHints ?? []) {
|
|
1896
|
+
const tokenName = tokenHint.trim();
|
|
1897
|
+
if (!tokenName) continue;
|
|
1898
|
+
const tokenGraphName = tokenName.replace(/^var\((--[^),\s]+).*$/, "$1").replace(/^--/, "");
|
|
1899
|
+
const tokenId = `tkn:${graphSlug(tokenGraphName, "style-token")}`;
|
|
1900
|
+
addNode(nodes, {
|
|
1901
|
+
id: tokenId,
|
|
1902
|
+
type: "Token",
|
|
1903
|
+
payload: {
|
|
1904
|
+
name: tokenName,
|
|
1905
|
+
source: "style-bridge",
|
|
1906
|
+
mapping: mapping.id,
|
|
1907
|
+
intent: mapping.decantrIntent,
|
|
1908
|
+
projectAuthority: mapping.projectAuthority
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
addEdge(edges, {
|
|
1912
|
+
src: bridgeId,
|
|
1913
|
+
dst: tokenId,
|
|
1914
|
+
relation: "STYLE_BRIDGE_MAPS_TO",
|
|
1915
|
+
payload: {
|
|
1916
|
+
kind: "token-hint",
|
|
1917
|
+
token: tokenName
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
addEdge(edges, {
|
|
1921
|
+
src: tokenId,
|
|
1922
|
+
dst: sourceId,
|
|
1923
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
const analysis = readJsonFile3(
|
|
1929
|
+
join4(projectRoot, ".decantr", "analysis.json")
|
|
1930
|
+
);
|
|
1931
|
+
if (analysis) {
|
|
1932
|
+
const sourceId = "src:.decantr/analysis.json";
|
|
1933
|
+
addEdge(edges, {
|
|
1934
|
+
src: snapshot.project_id,
|
|
1935
|
+
dst: sourceId,
|
|
1936
|
+
relation: "NODE_DERIVED_FROM_SOURCE",
|
|
1937
|
+
payload: {
|
|
1938
|
+
role: "brownfield-analysis"
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
for (const route of analysis.routes?.routes ?? []) {
|
|
1942
|
+
if (!route.path) continue;
|
|
1943
|
+
const routeFile = projectRelativePath(projectRoot, route.file);
|
|
1944
|
+
if (!routeFile || !nodes.has(`src:${routeFile}`)) continue;
|
|
1945
|
+
const routeId = `rt:${route.path}`;
|
|
1946
|
+
if (nodes.has(routeId)) {
|
|
1947
|
+
addEdge(edges, {
|
|
1948
|
+
src: routeId,
|
|
1949
|
+
dst: `src:${routeFile}`,
|
|
1950
|
+
relation: "NODE_DERIVED_FROM_SOURCE",
|
|
1951
|
+
payload: {
|
|
1952
|
+
role: "route-implementation",
|
|
1953
|
+
strategy: analysis.routes?.strategy,
|
|
1954
|
+
hasLayout: route.hasLayout ?? false
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
for (const edge of [...edges.values()]) {
|
|
1959
|
+
if (edge.relation !== "PAGE_ROUTED_AT_ROUTE" || edge.dst !== routeId) continue;
|
|
1960
|
+
addEdge(edges, {
|
|
1961
|
+
src: edge.src,
|
|
1962
|
+
dst: `src:${routeFile}`,
|
|
1963
|
+
relation: "NODE_DERIVED_FROM_SOURCE",
|
|
1964
|
+
payload: {
|
|
1965
|
+
role: "page-implementation",
|
|
1966
|
+
route: route.path,
|
|
1967
|
+
strategy: analysis.routes?.strategy
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
for (const declaration of componentReuseAudit?.declarations ?? []) {
|
|
1974
|
+
if (!declaration.reusable) continue;
|
|
1975
|
+
const componentFile = projectRelativePath(projectRoot, declaration.file);
|
|
1976
|
+
if (!componentFile || !nodes.has(`src:${componentFile}`)) continue;
|
|
1977
|
+
const componentId = `cmp:${graphSlug(declaration.name, "component")}`;
|
|
1978
|
+
addNode(nodes, {
|
|
1979
|
+
id: componentId,
|
|
1980
|
+
type: "Component",
|
|
1981
|
+
payload: {
|
|
1982
|
+
name: declaration.name,
|
|
1983
|
+
source: "code",
|
|
1984
|
+
exported: declaration.exported,
|
|
1985
|
+
kind: declaration.kind
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
addEdge(edges, {
|
|
1989
|
+
src: componentId,
|
|
1990
|
+
dst: `src:${componentFile}`,
|
|
1991
|
+
relation: "NODE_DERIVED_FROM_SOURCE",
|
|
1992
|
+
payload: {
|
|
1993
|
+
role: "component-implementation",
|
|
1994
|
+
line: declaration.line
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
for (const importReference of componentReuseAudit?.imports ?? []) {
|
|
1999
|
+
const importingFile = projectRelativePath(projectRoot, importReference.file);
|
|
2000
|
+
if (!importingFile || !nodes.has(`src:${importingFile}`)) continue;
|
|
2001
|
+
const importedFile = resolveImportSourcePath(projectRoot, importingFile, importReference.source);
|
|
2002
|
+
if (!importedFile || !nodes.has(`src:${importedFile}`)) continue;
|
|
2003
|
+
addEdge(edges, {
|
|
2004
|
+
src: `src:${importingFile}`,
|
|
2005
|
+
dst: `src:${importedFile}`,
|
|
2006
|
+
relation: "SOURCE_IMPORTS_SOURCE",
|
|
2007
|
+
payload: {
|
|
2008
|
+
source: importReference.source,
|
|
2009
|
+
line: importReference.line,
|
|
2010
|
+
defaultImport: importReference.defaultImport,
|
|
2011
|
+
namespaceImport: importReference.namespaceImport,
|
|
2012
|
+
imported: importReference.imported,
|
|
2013
|
+
localNames: importReference.localNames
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
const visualManifest = readJsonFile3(
|
|
2018
|
+
join4(projectRoot, ".decantr", "evidence", "visual-manifest.json")
|
|
2019
|
+
);
|
|
2020
|
+
if (visualManifest) {
|
|
2021
|
+
const sourceId = "src:.decantr/evidence/visual-manifest.json";
|
|
2022
|
+
for (const [index, route] of (visualManifest.routes ?? []).entries()) {
|
|
2023
|
+
const routeSlug = route.route === "/" ? "root" : graphSlug(route.route || `route-${index + 1}`, `route-${index + 1}`);
|
|
2024
|
+
const evidenceId = `ev:visual:${routeSlug}`;
|
|
2025
|
+
const routeId = `rt:${route.route}`;
|
|
2026
|
+
const anchorId = nodes.has(routeId) ? routeId : snapshot.project_id;
|
|
2027
|
+
addNode(nodes, {
|
|
2028
|
+
id: evidenceId,
|
|
2029
|
+
type: "Evidence",
|
|
2030
|
+
payload: {
|
|
2031
|
+
kind: "route-screenshot",
|
|
2032
|
+
route: route.route,
|
|
2033
|
+
url: route.url,
|
|
2034
|
+
screenshot: route.screenshot,
|
|
2035
|
+
screenshotHash: route.screenshotHash ?? null,
|
|
2036
|
+
status: route.status,
|
|
2037
|
+
error: route.error,
|
|
2038
|
+
localOnly: visualManifest.localOnly ?? true
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
addEdge(edges, {
|
|
2042
|
+
src: evidenceId,
|
|
2043
|
+
dst: sourceId,
|
|
2044
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2045
|
+
});
|
|
2046
|
+
if (route.screenshot && nodes.has(`src:${route.screenshot}`)) {
|
|
2047
|
+
addEdge(edges, {
|
|
2048
|
+
src: evidenceId,
|
|
2049
|
+
dst: `src:${route.screenshot}`,
|
|
2050
|
+
relation: "NODE_DERIVED_FROM_SOURCE",
|
|
2051
|
+
payload: {
|
|
2052
|
+
role: "screenshot"
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
addEdge(edges, {
|
|
2057
|
+
src: evidenceId,
|
|
2058
|
+
dst: anchorId,
|
|
2059
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2060
|
+
payload: {
|
|
2061
|
+
kind: "visual-route",
|
|
2062
|
+
route: route.route,
|
|
2063
|
+
status: route.status
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
if (nodes.has(routeId)) {
|
|
2067
|
+
for (const edge of [...edges.values()]) {
|
|
2068
|
+
if (edge.relation === "PAGE_ROUTED_AT_ROUTE" && edge.dst === routeId) {
|
|
2069
|
+
addEdge(edges, {
|
|
2070
|
+
src: evidenceId,
|
|
2071
|
+
dst: edge.src,
|
|
2072
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2073
|
+
payload: {
|
|
2074
|
+
kind: "visual-page",
|
|
2075
|
+
route: route.route,
|
|
2076
|
+
status: route.status
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
const evidenceBundle = readJsonFile3(
|
|
2085
|
+
join4(projectRoot, ".decantr", "evidence", "latest.json")
|
|
2086
|
+
);
|
|
2087
|
+
if (evidenceBundle) {
|
|
2088
|
+
const sourceId = "src:.decantr/evidence/latest.json";
|
|
2089
|
+
for (const finding of evidenceBundle.findings ?? []) {
|
|
2090
|
+
const findingId = evidenceBundleFindingNodeId(finding);
|
|
2091
|
+
const fallbackSourceAnchorId = firstFindingSourceArtifactId(nodes, projectRoot, finding);
|
|
2092
|
+
const anchoredAt = finding.graph?.node_id && nodes.has(finding.graph.node_id) ? finding.graph.node_id : fallbackSourceAnchorId ?? finding.graph?.node_id;
|
|
2093
|
+
addNode(nodes, {
|
|
2094
|
+
id: findingId,
|
|
2095
|
+
type: "Finding",
|
|
2096
|
+
payload: {
|
|
2097
|
+
id: finding.id,
|
|
2098
|
+
code: finding.code,
|
|
2099
|
+
source: finding.source,
|
|
2100
|
+
category: finding.category,
|
|
2101
|
+
severity: finding.severity,
|
|
2102
|
+
message: finding.message,
|
|
2103
|
+
target: finding.target,
|
|
2104
|
+
rule: finding.rule,
|
|
2105
|
+
suggestedFix: finding.suggestedFix,
|
|
2106
|
+
remediationSummary: finding.remediationSummary,
|
|
2107
|
+
commands: finding.commands,
|
|
2108
|
+
promptCommand: finding.promptCommand,
|
|
2109
|
+
anchored_at: anchoredAt,
|
|
2110
|
+
graph: stableFindingGraphAnchor(finding),
|
|
2111
|
+
repair_id: finding.repair?.id,
|
|
2112
|
+
repair_plan_id: finding.repairPlan?.id
|
|
2113
|
+
}
|
|
2114
|
+
});
|
|
2115
|
+
addEdge(edges, {
|
|
2116
|
+
src: findingId,
|
|
2117
|
+
dst: sourceId,
|
|
2118
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2119
|
+
});
|
|
2120
|
+
if (finding.graph?.node_id && nodes.has(finding.graph.node_id)) {
|
|
2121
|
+
addEdge(edges, {
|
|
2122
|
+
src: findingId,
|
|
2123
|
+
dst: finding.graph.node_id,
|
|
2124
|
+
relation: "FINDING_ANCHORED_AT",
|
|
2125
|
+
payload: {
|
|
2126
|
+
confidence: finding.graph.confidence,
|
|
2127
|
+
reason: finding.graph.reason
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
} else if (fallbackSourceAnchorId) {
|
|
2131
|
+
addEdge(edges, {
|
|
2132
|
+
src: findingId,
|
|
2133
|
+
dst: fallbackSourceAnchorId,
|
|
2134
|
+
relation: "FINDING_ANCHORED_AT",
|
|
2135
|
+
payload: {
|
|
2136
|
+
confidence: "inferred",
|
|
2137
|
+
reason: "finding repair targets or evidence matched a SourceArtifact node"
|
|
2138
|
+
}
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
if (finding.rule) {
|
|
2142
|
+
const ruleId = `rule:${graphSlug(finding.rule, "local-rule")}`;
|
|
2143
|
+
if (nodes.has(ruleId)) {
|
|
2144
|
+
addEdge(edges, {
|
|
2145
|
+
src: findingId,
|
|
2146
|
+
dst: ruleId,
|
|
2147
|
+
relation: "FINDING_VIOLATES_RULE"
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (finding.repair?.id) {
|
|
2152
|
+
const repairId = `repair:${graphSlug(finding.repair.id, "repair")}`;
|
|
2153
|
+
addNode(nodes, {
|
|
2154
|
+
id: repairId,
|
|
2155
|
+
type: "Repair",
|
|
2156
|
+
payload: {
|
|
2157
|
+
id: finding.repair.id,
|
|
2158
|
+
payload: finding.repair.payload,
|
|
2159
|
+
plan_id: finding.repairPlan?.id,
|
|
2160
|
+
actions: finding.repairPlan?.actions,
|
|
2161
|
+
readTargets: finding.repairPlan?.readTargets,
|
|
2162
|
+
commands: finding.repairPlan?.commands
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
addEdge(edges, {
|
|
2166
|
+
src: repairId,
|
|
2167
|
+
dst: findingId,
|
|
2168
|
+
relation: "REPAIR_FIXES_FINDING"
|
|
2169
|
+
});
|
|
2170
|
+
addEdge(edges, {
|
|
2171
|
+
src: repairId,
|
|
2172
|
+
dst: sourceId,
|
|
2173
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
for (const [index, evidenceText] of (finding.evidence ?? []).entries()) {
|
|
2177
|
+
const evidenceId = evidenceBundleEvidenceNodeId(finding, index);
|
|
2178
|
+
addNode(nodes, {
|
|
2179
|
+
id: evidenceId,
|
|
2180
|
+
type: "Evidence",
|
|
2181
|
+
payload: {
|
|
2182
|
+
kind: "finding-evidence",
|
|
2183
|
+
text: evidenceText,
|
|
2184
|
+
finding: finding.id
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
addEdge(edges, {
|
|
2188
|
+
src: evidenceId,
|
|
2189
|
+
dst: findingId,
|
|
2190
|
+
relation: "EVIDENCE_SUPPORTS_FINDING"
|
|
2191
|
+
});
|
|
2192
|
+
addEdge(edges, {
|
|
2193
|
+
src: evidenceId,
|
|
2194
|
+
dst: sourceId,
|
|
2195
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
const healthBaselineDiff = readJsonFile3(
|
|
2201
|
+
join4(projectRoot, ".decantr", "health-baseline-diff.json")
|
|
2202
|
+
);
|
|
2203
|
+
if (healthBaselineDiff) {
|
|
2204
|
+
const sourceId = "src:.decantr/health-baseline-diff.json";
|
|
2205
|
+
const manifestForScreenshotRoutes = readJsonFile3(
|
|
2206
|
+
join4(projectRoot, ".decantr", "evidence", "visual-manifest.json")
|
|
2207
|
+
);
|
|
2208
|
+
for (const route of healthBaselineDiff.changedRoutes ?? []) {
|
|
2209
|
+
const evidenceId = `ev:baseline:route:${graphSlug(route, "route")}`;
|
|
2210
|
+
const routeId = `rt:${route}`;
|
|
2211
|
+
const anchorId = nodes.has(routeId) ? routeId : snapshot.project_id;
|
|
2212
|
+
addNode(nodes, {
|
|
2213
|
+
id: evidenceId,
|
|
2214
|
+
type: "Evidence",
|
|
2215
|
+
payload: {
|
|
2216
|
+
kind: "baseline-route-impact",
|
|
2217
|
+
route
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
addEdge(edges, {
|
|
2221
|
+
src: evidenceId,
|
|
2222
|
+
dst: sourceId,
|
|
2223
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2224
|
+
});
|
|
2225
|
+
addEdge(edges, {
|
|
2226
|
+
src: evidenceId,
|
|
2227
|
+
dst: anchorId,
|
|
2228
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2229
|
+
payload: { kind: "baseline-route-impact" }
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
for (const changedFile of healthBaselineDiff.changedFiles ?? []) {
|
|
2233
|
+
const sourcePath = projectRelativePath(projectRoot, changedFile);
|
|
2234
|
+
if (!sourcePath) continue;
|
|
2235
|
+
const sourceArtifactId = `src:${sourcePath}`;
|
|
2236
|
+
if (!nodes.has(sourceArtifactId)) continue;
|
|
2237
|
+
const evidenceId = `ev:baseline:file:${graphSlug(sourcePath, "file")}`;
|
|
2238
|
+
addNode(nodes, {
|
|
2239
|
+
id: evidenceId,
|
|
2240
|
+
type: "Evidence",
|
|
2241
|
+
payload: {
|
|
2242
|
+
kind: "baseline-file-impact",
|
|
2243
|
+
file: sourcePath
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
addEdge(edges, {
|
|
2247
|
+
src: evidenceId,
|
|
2248
|
+
dst: sourceId,
|
|
2249
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2250
|
+
});
|
|
2251
|
+
addEdge(edges, {
|
|
2252
|
+
src: evidenceId,
|
|
2253
|
+
dst: sourceArtifactId,
|
|
2254
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2255
|
+
payload: { kind: "baseline-file-impact" }
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
for (const [index, screenshot] of (healthBaselineDiff.changedScreenshots ?? []).entries()) {
|
|
2259
|
+
const route = routeForScreenshotPath(manifestForScreenshotRoutes, screenshot);
|
|
2260
|
+
const routeId = route ? `rt:${route}` : null;
|
|
2261
|
+
const evidenceId = `ev:baseline:screenshot:${index + 1}`;
|
|
2262
|
+
addNode(nodes, {
|
|
2263
|
+
id: evidenceId,
|
|
2264
|
+
type: "Evidence",
|
|
2265
|
+
payload: {
|
|
2266
|
+
kind: "baseline-screenshot-drift",
|
|
2267
|
+
screenshot,
|
|
2268
|
+
route
|
|
2269
|
+
}
|
|
2270
|
+
});
|
|
2271
|
+
addEdge(edges, {
|
|
2272
|
+
src: evidenceId,
|
|
2273
|
+
dst: sourceId,
|
|
2274
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2275
|
+
});
|
|
2276
|
+
addEdge(edges, {
|
|
2277
|
+
src: evidenceId,
|
|
2278
|
+
dst: routeId && nodes.has(routeId) ? routeId : snapshot.project_id,
|
|
2279
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2280
|
+
payload: { kind: "baseline-screenshot-drift", route }
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
for (const [index, drift] of (healthBaselineDiff.contractDrift ?? []).entries()) {
|
|
2284
|
+
const evidenceId = `ev:baseline:contract:${index + 1}`;
|
|
2285
|
+
addNode(nodes, {
|
|
2286
|
+
id: evidenceId,
|
|
2287
|
+
type: "Evidence",
|
|
2288
|
+
payload: {
|
|
2289
|
+
kind: "baseline-contract-drift",
|
|
2290
|
+
text: drift
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
addEdge(edges, {
|
|
2294
|
+
src: evidenceId,
|
|
2295
|
+
dst: sourceId,
|
|
2296
|
+
relation: "NODE_DERIVED_FROM_SOURCE"
|
|
2297
|
+
});
|
|
2298
|
+
addEdge(edges, {
|
|
2299
|
+
src: evidenceId,
|
|
2300
|
+
dst: snapshot.project_id,
|
|
2301
|
+
relation: "EVIDENCE_CAPTURED_FOR",
|
|
2302
|
+
payload: { kind: "baseline-contract-drift" }
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
return normalizeGraphSnapshot({
|
|
2307
|
+
...snapshot,
|
|
2308
|
+
nodes: [...nodes.values()],
|
|
2309
|
+
edges: [...edges.values()]
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
function pathForDisplay(projectRoot, path, displayRoot) {
|
|
2313
|
+
if (!displayRoot) return path.replace(`${projectRoot}/`, "");
|
|
2314
|
+
const relativePath = relative2(displayRoot, path).replace(/\\/g, "/");
|
|
2315
|
+
if (relativePath && !relativePath.startsWith("..") && !isAbsolute2(relativePath)) {
|
|
2316
|
+
return relativePath;
|
|
2317
|
+
}
|
|
2318
|
+
return path;
|
|
2319
|
+
}
|
|
2320
|
+
function artifactMatches(path, value) {
|
|
2321
|
+
const existing = readJsonFile3(path);
|
|
2322
|
+
return existing ? stableJson(existing) === stableJson(value) : false;
|
|
2323
|
+
}
|
|
2324
|
+
function buildGraphArtifacts(projectRoot, options = {}) {
|
|
2325
|
+
const essencePath = join4(projectRoot, "decantr.essence.json");
|
|
2326
|
+
if (!existsSync4(essencePath)) {
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
const parsed = JSON.parse(readFileSync4(essencePath, "utf-8"));
|
|
2330
|
+
if (!isV42(parsed)) {
|
|
2331
|
+
throw new Error("Active graph workflows require Essence v4.0.0.");
|
|
2332
|
+
}
|
|
2333
|
+
const essence = parsed;
|
|
2334
|
+
let paths = graphPaths(projectRoot);
|
|
2335
|
+
const componentReuseAudit = auditComponentReuse(
|
|
2336
|
+
projectRoot,
|
|
2337
|
+
collectProjectSourceFiles(projectRoot)
|
|
2338
|
+
);
|
|
2339
|
+
const sources = sourceArtifacts(projectRoot, componentReuseAudit);
|
|
2340
|
+
const combinedSourceHash = sourceHash(sources);
|
|
2341
|
+
const previousSnapshot = readJsonFile3(paths.snapshot);
|
|
2342
|
+
const createdAt = previousSnapshot?.source_hash === combinedSourceHash ? previousSnapshot.created_at : (/* @__PURE__ */ new Date()).toISOString();
|
|
2343
|
+
const snapshotId = `graph:${combinedSourceHash.replace(/^sha256:/, "").slice(0, 12)}`;
|
|
2344
|
+
paths = withSnapshotHistoryPath(paths, snapshotId);
|
|
2345
|
+
const baseSnapshot = buildGraphSnapshotFromEssence(essence, {
|
|
2346
|
+
snapshotId,
|
|
2347
|
+
parentId: previousSnapshot && previousSnapshot.id !== snapshotId ? previousSnapshot.id : void 0,
|
|
2348
|
+
sourceHash: combinedSourceHash,
|
|
2349
|
+
createdAt,
|
|
2350
|
+
sourceArtifact: sources[0]
|
|
2351
|
+
});
|
|
2352
|
+
const snapshot = augmentProjectGraph(baseSnapshot, projectRoot, sources, componentReuseAudit);
|
|
2353
|
+
const diff = previousSnapshot ? diffGraphSnapshots(previousSnapshot, snapshot) : {
|
|
2354
|
+
$schema: GRAPH_DIFF_SCHEMA_URL,
|
|
2355
|
+
id: `diff:${snapshot.id}:${snapshot.id}`,
|
|
2356
|
+
from: snapshot.id,
|
|
2357
|
+
to: snapshot.id,
|
|
2358
|
+
ops: []
|
|
2359
|
+
};
|
|
2360
|
+
const capsule = buildContractCapsuleFromSnapshot(snapshot, {
|
|
2361
|
+
createdAt: snapshot.created_at,
|
|
2362
|
+
sourceArtifactLimit: options.capsuleSourceLimit
|
|
2363
|
+
});
|
|
2364
|
+
const manifest = {
|
|
2365
|
+
$schema: GRAPH_MANIFEST_SCHEMA_URL,
|
|
2366
|
+
schema_version: GRAPH_SCHEMA_VERSION,
|
|
2367
|
+
snapshot_id: snapshot.id,
|
|
2368
|
+
project_id: snapshot.project_id,
|
|
2369
|
+
generated_at: snapshot.created_at,
|
|
2370
|
+
sources,
|
|
2371
|
+
outputs: {
|
|
2372
|
+
snapshot: ".decantr/graph/graph.snapshot.json",
|
|
2373
|
+
history: ".decantr/graph/snapshots",
|
|
2374
|
+
diff: ".decantr/graph/graph.diff.json"
|
|
2375
|
+
},
|
|
2376
|
+
warnings: []
|
|
2377
|
+
};
|
|
2378
|
+
const staleArtifacts = [
|
|
2379
|
+
[paths.snapshot, snapshot],
|
|
2380
|
+
[paths.snapshotHistory, snapshot],
|
|
2381
|
+
[paths.manifest, manifest],
|
|
2382
|
+
[paths.diff, diff],
|
|
2383
|
+
[paths.capsule, capsule]
|
|
2384
|
+
].filter(([path, value]) => !artifactMatches(path, value)).map(([path]) => path);
|
|
2385
|
+
return {
|
|
2386
|
+
projectRoot,
|
|
2387
|
+
paths,
|
|
2388
|
+
snapshot,
|
|
2389
|
+
manifest,
|
|
2390
|
+
diff,
|
|
2391
|
+
capsule,
|
|
2392
|
+
staleArtifacts
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
function writeGraphArtifacts(artifacts) {
|
|
2396
|
+
mkdirSync3(artifacts.paths.graphDir, { recursive: true });
|
|
2397
|
+
mkdirSync3(artifacts.paths.snapshotsDir, { recursive: true });
|
|
2398
|
+
writeFileSync3(artifacts.paths.snapshot, formatJson(artifacts.snapshot), "utf-8");
|
|
2399
|
+
writeFileSync3(artifacts.paths.snapshotHistory, formatJson(artifacts.snapshot), "utf-8");
|
|
2400
|
+
writeFileSync3(artifacts.paths.manifest, formatJson(artifacts.manifest), "utf-8");
|
|
2401
|
+
writeFileSync3(artifacts.paths.diff, formatJson(artifacts.diff), "utf-8");
|
|
2402
|
+
writeFileSync3(artifacts.paths.capsule, formatJson(artifacts.capsule), "utf-8");
|
|
2403
|
+
}
|
|
2404
|
+
function comparisonPayload(diff, includeOps = false, limit = void 0) {
|
|
2405
|
+
const boundedLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : 25;
|
|
2406
|
+
return {
|
|
2407
|
+
from: diff.from,
|
|
2408
|
+
to: diff.to,
|
|
2409
|
+
summary: summarizeGraphDiff(diff),
|
|
2410
|
+
...includeOps ? {
|
|
2411
|
+
ops: diff.ops.slice(0, boundedLimit),
|
|
2412
|
+
truncated: diff.ops.length > boundedLimit,
|
|
2413
|
+
limit: boundedLimit
|
|
2414
|
+
} : {}
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
function summaryPayload(artifacts, routeContext, impactContext, selected, comparison, impactSelection) {
|
|
2418
|
+
return {
|
|
2419
|
+
projectRoot: artifacts.projectRoot,
|
|
2420
|
+
graphDir: artifacts.paths.graphDir,
|
|
2421
|
+
snapshot: {
|
|
2422
|
+
id: artifacts.snapshot.id,
|
|
2423
|
+
schema_version: artifacts.snapshot.schema_version,
|
|
2424
|
+
source_hash: artifacts.snapshot.source_hash,
|
|
2425
|
+
nodes: artifacts.snapshot.summary.nodes,
|
|
2426
|
+
edges: artifacts.snapshot.summary.edges,
|
|
2427
|
+
findings: artifacts.snapshot.summary.findings,
|
|
2428
|
+
evidence: artifacts.snapshot.summary.evidence,
|
|
2429
|
+
sourceArtifacts: artifacts.snapshot.nodes.filter((node) => node.type === "SourceArtifact").length,
|
|
2430
|
+
history: pathForDisplay(artifacts.projectRoot, artifacts.paths.snapshotHistory)
|
|
2431
|
+
},
|
|
2432
|
+
capsule: {
|
|
2433
|
+
cache_key: artifacts.capsule.cache_key,
|
|
2434
|
+
routes: artifacts.capsule.summary.routes,
|
|
2435
|
+
components: artifacts.capsule.summary.components,
|
|
2436
|
+
tokens: artifacts.capsule.summary.tokens,
|
|
2437
|
+
local_rules: artifacts.capsule.summary.local_rules,
|
|
2438
|
+
style_bridge: artifacts.capsule.summary.style_bridge,
|
|
2439
|
+
source_artifacts: artifacts.capsule.summary.source_artifacts,
|
|
2440
|
+
source_artifact_limit: artifacts.capsule.source_artifact_limit,
|
|
2441
|
+
source_artifacts_truncated: artifacts.capsule.source_artifacts_truncated,
|
|
2442
|
+
open_findings: artifacts.capsule.summary.open_findings
|
|
2443
|
+
},
|
|
2444
|
+
diff: {
|
|
2445
|
+
id: artifacts.diff.id,
|
|
2446
|
+
from: artifacts.diff.from,
|
|
2447
|
+
to: artifacts.diff.to,
|
|
2448
|
+
ops: artifacts.diff.ops.length,
|
|
2449
|
+
summary: summarizeGraphDiff(artifacts.diff)
|
|
2450
|
+
},
|
|
2451
|
+
sources: artifacts.manifest.sources.length,
|
|
2452
|
+
staleArtifacts: artifacts.staleArtifacts.map(
|
|
2453
|
+
(path) => pathForDisplay(artifacts.projectRoot, path)
|
|
2454
|
+
),
|
|
2455
|
+
selectedSnapshot: selected ? {
|
|
2456
|
+
selector: selected.selector,
|
|
2457
|
+
path: pathForDisplay(artifacts.projectRoot, selected.path),
|
|
2458
|
+
id: selected.snapshot.id,
|
|
2459
|
+
source_hash: selected.snapshot.source_hash,
|
|
2460
|
+
nodes: selected.snapshot.summary.nodes,
|
|
2461
|
+
edges: selected.snapshot.summary.edges,
|
|
2462
|
+
findings: selected.snapshot.summary.findings,
|
|
2463
|
+
evidence: selected.snapshot.summary.evidence
|
|
2464
|
+
} : void 0,
|
|
2465
|
+
comparison,
|
|
2466
|
+
routeContext: routeContextPayload(routeContext ?? null, routeContext ? optionsRoute(routeContext) : void 0),
|
|
2467
|
+
impactContext: impactContextPayload(impactContext ?? null, impactSelection)
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
function optionsRoute(routeContext) {
|
|
2471
|
+
return graphPayloadString(routeContext.routeNode.payload, "path") ?? routeContext.routeNode.id.replace(/^rt:/, "");
|
|
2472
|
+
}
|
|
2473
|
+
function printGraphSummary(artifacts, displayRoot) {
|
|
2474
|
+
console.log(`${GREEN}Generated Decantr typed graph artifacts:${RESET}`);
|
|
2475
|
+
for (const path of [
|
|
2476
|
+
artifacts.paths.snapshot,
|
|
2477
|
+
artifacts.paths.snapshotHistory,
|
|
2478
|
+
artifacts.paths.manifest,
|
|
2479
|
+
artifacts.paths.diff,
|
|
2480
|
+
artifacts.paths.capsule
|
|
2481
|
+
]) {
|
|
2482
|
+
console.log(` ${DIM}${pathForDisplay(artifacts.projectRoot, path, displayRoot)}${RESET}`);
|
|
2483
|
+
}
|
|
2484
|
+
console.log("");
|
|
2485
|
+
console.log(
|
|
2486
|
+
`${GREEN}Snapshot:${RESET} ${artifacts.snapshot.summary.nodes} nodes, ${artifacts.snapshot.summary.edges} edges, ${artifacts.diff.ops.length} diff ops`
|
|
2487
|
+
);
|
|
2488
|
+
const diffSummary = summarizeGraphDiff(artifacts.diff);
|
|
2489
|
+
const typedDiffHints = [
|
|
2490
|
+
diffSummary.findings.added > 0 ? `${diffSummary.findings.added} finding added` : null,
|
|
2491
|
+
diffSummary.findings.resolved > 0 ? `${diffSummary.findings.resolved} finding resolved` : null,
|
|
2492
|
+
diffSummary.evidence.added > 0 ? `${diffSummary.evidence.added} evidence added` : null
|
|
2493
|
+
].filter(Boolean);
|
|
2494
|
+
if (typedDiffHints.length > 0) {
|
|
2495
|
+
console.log(`${GREEN}Diff:${RESET} ${typedDiffHints.join(", ")}`);
|
|
2496
|
+
}
|
|
2497
|
+
console.log(`${GREEN}Sources:${RESET} ${artifacts.manifest.sources.length} local artifact(s)`);
|
|
2498
|
+
console.log(
|
|
2499
|
+
`${GREEN}Capsule:${RESET} ${artifacts.capsule.summary.routes} routes, ${artifacts.capsule.summary.local_rules} local rules, ${artifacts.capsule.summary.style_bridge} style bridge mappings`
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
function graphAvailableRoutes(snapshot) {
|
|
2503
|
+
return snapshot.nodes.filter((node) => node.type === "Route").map((node) => graphPayloadString(node.payload, "path") ?? node.id.replace(/^rt:/, "")).sort();
|
|
2504
|
+
}
|
|
2505
|
+
function routeContextPayload(routeContext, route) {
|
|
2506
|
+
if (!route) return void 0;
|
|
2507
|
+
if (!routeContext) {
|
|
2508
|
+
return {
|
|
2509
|
+
route,
|
|
2510
|
+
found: false
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
return {
|
|
2514
|
+
route,
|
|
2515
|
+
found: true,
|
|
2516
|
+
snapshotId: routeContext.snapshotId,
|
|
2517
|
+
sourceHash: routeContext.sourceHash,
|
|
2518
|
+
ranking: routeContext.ranking,
|
|
2519
|
+
summary: routeContext.summary,
|
|
2520
|
+
ids: routeContext.ids,
|
|
2521
|
+
ranked: routeContext.ranked,
|
|
2522
|
+
nodes: routeContext.nodes,
|
|
2523
|
+
edges: routeContext.edges
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
function graphSourceNodeIdForFile(projectRoot, snapshot, file) {
|
|
2527
|
+
if (!file) return null;
|
|
2528
|
+
const trimmed = file.trim();
|
|
2529
|
+
if (!trimmed) return null;
|
|
2530
|
+
if (trimmed.startsWith("src:") && snapshot.nodes.some((node) => node.id === trimmed)) {
|
|
2531
|
+
return trimmed;
|
|
2532
|
+
}
|
|
2533
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
2534
|
+
const directRelative = projectRelativePath(projectRoot, trimmed);
|
|
2535
|
+
if (directRelative) candidates.add(directRelative);
|
|
2536
|
+
try {
|
|
2537
|
+
const workspaceRelative = projectRelativePath(projectRoot, resolvePathFromCwd(trimmed));
|
|
2538
|
+
if (workspaceRelative) candidates.add(workspaceRelative);
|
|
2539
|
+
} catch {
|
|
2540
|
+
}
|
|
2541
|
+
for (const candidate of candidates) {
|
|
2542
|
+
const nodeId = `src:${candidate}`;
|
|
2543
|
+
if (snapshot.nodes.some((node) => node.id === nodeId)) return nodeId;
|
|
2544
|
+
}
|
|
2545
|
+
return snapshot.nodes.find((node) => {
|
|
2546
|
+
if (node.type !== "SourceArtifact") return false;
|
|
2547
|
+
const path = graphPayloadString(node.payload, "path");
|
|
2548
|
+
return Boolean(path && (path === trimmed || candidates.has(path)));
|
|
2549
|
+
})?.id ?? null;
|
|
2550
|
+
}
|
|
2551
|
+
function resolvePathFromCwd(path) {
|
|
2552
|
+
return isAbsolute2(path) ? path : join4(process.cwd(), path);
|
|
2553
|
+
}
|
|
2554
|
+
function impactContextPayload(impactContext, selection) {
|
|
2555
|
+
if (!selection) return void 0;
|
|
2556
|
+
if (!impactContext) {
|
|
2557
|
+
return {
|
|
2558
|
+
node: selection.node,
|
|
2559
|
+
file: selection.file,
|
|
2560
|
+
resolvedNodeIds: selection.resolvedNodeIds,
|
|
2561
|
+
found: false
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
return {
|
|
2565
|
+
node: selection.node,
|
|
2566
|
+
file: selection.file,
|
|
2567
|
+
resolvedNodeIds: selection.resolvedNodeIds,
|
|
2568
|
+
found: true,
|
|
2569
|
+
snapshotId: impactContext.snapshotId,
|
|
2570
|
+
sourceHash: impactContext.sourceHash,
|
|
2571
|
+
missingNodeIds: impactContext.missingNodeIds,
|
|
2572
|
+
seedNodes: impactContext.seedNodes,
|
|
2573
|
+
ranking: impactContext.ranking,
|
|
2574
|
+
summary: impactContext.summary,
|
|
2575
|
+
ids: impactContext.ids,
|
|
2576
|
+
ranked: impactContext.ranked,
|
|
2577
|
+
nodes: impactContext.nodes,
|
|
2578
|
+
edges: impactContext.edges
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
function printRouteContextSummary(routeContext) {
|
|
2582
|
+
const routeSummary = routeContext.summary;
|
|
2583
|
+
const hints = [
|
|
2584
|
+
routeContext.ids.patterns.length > 0 ? `patterns ${routeContext.ids.patterns.join(", ")}` : null,
|
|
2585
|
+
routeSummary.openFindings > 0 ? `${routeSummary.openFindings} finding(s)` : null,
|
|
2586
|
+
routeSummary.evidence > 0 ? `${routeSummary.evidence} evidence node(s)` : null,
|
|
2587
|
+
routeSummary.sourceArtifacts > 0 ? `${routeSummary.sourceArtifacts} source artifact(s)` : null
|
|
2588
|
+
].filter(Boolean);
|
|
2589
|
+
console.log("");
|
|
2590
|
+
console.log(
|
|
2591
|
+
`${GREEN}Route subgraph:${RESET} ${routeSummary.nodes} nodes, ${routeSummary.edges} edges${hints.length > 0 ? `; ${hints.join("; ")}` : ""}`
|
|
2592
|
+
);
|
|
2593
|
+
const ranked = routeContext.ranked.slice(0, 8);
|
|
2594
|
+
if (ranked.length > 0) {
|
|
2595
|
+
console.log(
|
|
2596
|
+
`${GREEN}Top nodes:${RESET} ${ranked.map((node) => `${node.id} (${node.reason})`).join(", ")}`
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
function printImpactContextSummary(impactContext) {
|
|
2601
|
+
const summary = impactContext.summary;
|
|
2602
|
+
const hints = [
|
|
2603
|
+
summary.routes > 0 ? `${summary.routes} route(s)` : null,
|
|
2604
|
+
summary.pages > 0 ? `${summary.pages} page(s)` : null,
|
|
2605
|
+
summary.components > 0 ? `${summary.components} component(s)` : null,
|
|
2606
|
+
summary.openFindings > 0 ? `${summary.openFindings} finding(s)` : null,
|
|
2607
|
+
summary.evidence > 0 ? `${summary.evidence} evidence node(s)` : null
|
|
2608
|
+
].filter(Boolean);
|
|
2609
|
+
console.log("");
|
|
2610
|
+
console.log(
|
|
2611
|
+
`${GREEN}Impact subgraph:${RESET} ${summary.nodes} nodes, ${summary.edges} edges${summary.truncated ? ` (truncated from ${summary.totalNodes}/${summary.totalEdges})` : ""}${hints.length > 0 ? `; ${hints.join("; ")}` : ""}`
|
|
2612
|
+
);
|
|
2613
|
+
const ranked = impactContext.ranked.slice(0, 8);
|
|
2614
|
+
if (ranked.length > 0) {
|
|
2615
|
+
console.log(
|
|
2616
|
+
`${GREEN}Top impact nodes:${RESET} ${ranked.map((node) => `${node.id} (${node.reason})`).join(", ")}`
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
function printSelectedSnapshotSummary(artifacts, selected, comparison, displayRoot) {
|
|
2621
|
+
if (selected.selector !== "current") {
|
|
2622
|
+
console.log("");
|
|
2623
|
+
console.log(
|
|
2624
|
+
`${GREEN}Selected snapshot:${RESET} ${selected.snapshot.id} (${selected.snapshot.summary.nodes} nodes, ${selected.snapshot.summary.edges} edges)`
|
|
2625
|
+
);
|
|
2626
|
+
console.log(
|
|
2627
|
+
` ${DIM}${pathForDisplay(artifacts.projectRoot, selected.path, displayRoot)}${RESET}`
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
if (comparison) {
|
|
2631
|
+
console.log("");
|
|
2632
|
+
console.log(
|
|
2633
|
+
`${GREEN}Snapshot diff:${RESET} ${comparison.from ?? "empty"} -> ${comparison.to}, ${comparison.summary.total} op(s)`
|
|
2634
|
+
);
|
|
2635
|
+
const hints = [
|
|
2636
|
+
comparison.summary.findings.added > 0 ? `${comparison.summary.findings.added} finding added` : null,
|
|
2637
|
+
comparison.summary.findings.resolved > 0 ? `${comparison.summary.findings.resolved} finding resolved` : null,
|
|
2638
|
+
comparison.summary.evidence.added > 0 ? `${comparison.summary.evidence.added} evidence added` : null
|
|
2639
|
+
].filter(Boolean);
|
|
2640
|
+
if (hints.length > 0) {
|
|
2641
|
+
console.log(`${GREEN}Diff detail:${RESET} ${hints.join(", ")}`);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
function cmdGraphHelp() {
|
|
2646
|
+
console.log(`decantr graph [--project <path>] [--route <route>] [--node <id>] [--file <path>] [--task <text>] [--snapshot-id <id>] [--compare-to <id>] [--capsule-source-limit <count>] [--check] [--json]
|
|
2647
|
+
|
|
2648
|
+
Build Decantr's typed Contract graph artifacts from the project-owned Essence,
|
|
2649
|
+
accepted local rules, accepted style bridge, Brownfield analysis, and local evidence.
|
|
2650
|
+
|
|
2651
|
+
Outputs:
|
|
2652
|
+
.decantr/graph/graph.snapshot.json
|
|
2653
|
+
.decantr/graph/snapshots/<snapshot-id>.json
|
|
2654
|
+
.decantr/graph/graph.manifest.json
|
|
2655
|
+
.decantr/graph/graph.diff.json
|
|
2656
|
+
.decantr/graph/contract-capsule.json
|
|
2657
|
+
|
|
2658
|
+
Options:
|
|
2659
|
+
--project <path> Run against a workspace app/package
|
|
2660
|
+
--route <route> Include the route-scoped graph subgraph in output
|
|
2661
|
+
--node <id> Include impact context for a graph node, such as cmp:button
|
|
2662
|
+
--file <path> Include impact context for a source file, such as src/app/page.tsx
|
|
2663
|
+
--impact Require impact context when --node or --file is present
|
|
2664
|
+
--task <text> Boost route/impact ranking with task keywords
|
|
2665
|
+
--snapshot-id <id> Inspect "current" or a snapshot from .decantr/graph/snapshots
|
|
2666
|
+
--compare-to <id> Diff the selected snapshot against another snapshot or "current"
|
|
2667
|
+
--include-diff-ops Include bounded diff operations in JSON output
|
|
2668
|
+
--limit <count> Limit JSON diff operations or impact nodes
|
|
2669
|
+
--capsule-source-limit <count>
|
|
2670
|
+
Limit SourceArtifact path handles in contract-capsule.json
|
|
2671
|
+
--check Verify artifacts are present and current without writing
|
|
2672
|
+
--json Print machine-readable summary
|
|
2673
|
+
`);
|
|
2674
|
+
}
|
|
2675
|
+
async function cmdGraph(projectRoot = process.cwd(), options = {}) {
|
|
2676
|
+
let artifacts = null;
|
|
2677
|
+
try {
|
|
2678
|
+
artifacts = buildGraphArtifacts(projectRoot, {
|
|
2679
|
+
capsuleSourceLimit: options.capsuleSourceLimit
|
|
2680
|
+
});
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
console.error(`${RED}${error.message}${RESET}`);
|
|
2683
|
+
process.exitCode = 1;
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
if (!artifacts) {
|
|
2687
|
+
console.error(`${RED}No decantr.essence.json found. Run \`decantr init\` first.${RESET}`);
|
|
2688
|
+
process.exitCode = 1;
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
let selectedSnapshot;
|
|
2692
|
+
let comparison;
|
|
2693
|
+
try {
|
|
2694
|
+
selectedSnapshot = readGraphSnapshotSelection(artifacts, options.snapshotId);
|
|
2695
|
+
if (options.compareTo) {
|
|
2696
|
+
const baseline = readGraphSnapshotSelection(artifacts, options.compareTo);
|
|
2697
|
+
comparison = comparisonPayload(
|
|
2698
|
+
diffGraphSnapshots(baseline.snapshot, selectedSnapshot.snapshot),
|
|
2699
|
+
options.includeDiffOps,
|
|
2700
|
+
options.limit
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
} catch (error) {
|
|
2704
|
+
console.error(`${RED}${error.message}${RESET}`);
|
|
2705
|
+
process.exitCode = 1;
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
const routeContext = options.route ? buildGraphRouteContext(selectedSnapshot.snapshot, options.route, { task: options.task }) : void 0;
|
|
2709
|
+
if (options.route && !routeContext) {
|
|
2710
|
+
console.error(`${RED}Route not found in Decantr typed graph: ${options.route}${RESET}`);
|
|
2711
|
+
console.error(
|
|
2712
|
+
`${DIM}Known routes: ${graphAvailableRoutes(selectedSnapshot.snapshot).join(", ") || "none"}${RESET}`
|
|
2713
|
+
);
|
|
2714
|
+
process.exitCode = 1;
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
const fileNodeId = graphSourceNodeIdForFile(projectRoot, selectedSnapshot.snapshot, options.file);
|
|
2718
|
+
if (options.file && !fileNodeId) {
|
|
2719
|
+
console.error(`${RED}Source file not found in Decantr typed graph: ${options.file}${RESET}`);
|
|
2720
|
+
console.error(
|
|
2721
|
+
`${DIM}Run \`decantr analyze\` and \`decantr graph\` after the file is visible in Brownfield route or component evidence.${RESET}`
|
|
2722
|
+
);
|
|
2723
|
+
process.exitCode = 1;
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (options.impact && !options.node && !options.file) {
|
|
2727
|
+
console.error(`${RED}--impact requires --node <graph-node-id> or --file <path>.${RESET}`);
|
|
2728
|
+
process.exitCode = 1;
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const impactSeedIds = [
|
|
2732
|
+
...new Set([options.node, fileNodeId].filter((value) => Boolean(value)))
|
|
2733
|
+
];
|
|
2734
|
+
const impactSelection = impactSeedIds.length > 0 ? {
|
|
2735
|
+
node: options.node,
|
|
2736
|
+
file: options.file,
|
|
2737
|
+
resolvedNodeIds: impactSeedIds
|
|
2738
|
+
} : void 0;
|
|
2739
|
+
const impactContext = impactSeedIds.length > 0 ? buildGraphImpactContext(selectedSnapshot.snapshot, impactSeedIds, {
|
|
2740
|
+
task: options.task,
|
|
2741
|
+
limit: options.limit
|
|
2742
|
+
}) : void 0;
|
|
2743
|
+
if (impactSeedIds.length > 0 && !impactContext) {
|
|
2744
|
+
console.error(
|
|
2745
|
+
`${RED}Impact seed not found in Decantr typed graph: ${impactSeedIds.join(", ")}${RESET}`
|
|
2746
|
+
);
|
|
2747
|
+
process.exitCode = 1;
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (options.check) {
|
|
2751
|
+
const stale = artifacts.staleArtifacts.length > 0;
|
|
2752
|
+
if (options.json) {
|
|
2753
|
+
console.log(
|
|
2754
|
+
JSON.stringify(
|
|
2755
|
+
{
|
|
2756
|
+
...summaryPayload(
|
|
2757
|
+
artifacts,
|
|
2758
|
+
routeContext,
|
|
2759
|
+
impactContext,
|
|
2760
|
+
selectedSnapshot,
|
|
2761
|
+
comparison,
|
|
2762
|
+
impactSelection
|
|
2763
|
+
),
|
|
2764
|
+
stale
|
|
2765
|
+
},
|
|
2766
|
+
null,
|
|
2767
|
+
2
|
|
2768
|
+
)
|
|
2769
|
+
);
|
|
2770
|
+
} else if (stale) {
|
|
2771
|
+
console.log(`${RED}Decantr typed graph artifacts are stale.${RESET}`);
|
|
2772
|
+
for (const path of artifacts.staleArtifacts) {
|
|
2773
|
+
console.log(` ${pathForDisplay(artifacts.projectRoot, path, options.displayRoot)}`);
|
|
2774
|
+
}
|
|
2775
|
+
console.log(`${DIM}Run \`decantr graph\` to regenerate graph artifacts.${RESET}`);
|
|
2776
|
+
} else {
|
|
2777
|
+
console.log(`${GREEN}Decantr typed graph artifacts are current.${RESET}`);
|
|
2778
|
+
}
|
|
2779
|
+
printSelectedSnapshotSummary(artifacts, selectedSnapshot, comparison, options.displayRoot);
|
|
2780
|
+
if (routeContext) printRouteContextSummary(routeContext);
|
|
2781
|
+
if (impactContext) printImpactContextSummary(impactContext);
|
|
2782
|
+
if (stale) process.exitCode = 1;
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
writeGraphArtifacts(artifacts);
|
|
2786
|
+
if (options.json) {
|
|
2787
|
+
console.log(
|
|
2788
|
+
JSON.stringify(
|
|
2789
|
+
{
|
|
2790
|
+
...summaryPayload(
|
|
2791
|
+
artifacts,
|
|
2792
|
+
routeContext,
|
|
2793
|
+
impactContext,
|
|
2794
|
+
selectedSnapshot,
|
|
2795
|
+
comparison,
|
|
2796
|
+
impactSelection
|
|
2797
|
+
),
|
|
2798
|
+
wrote: true
|
|
2799
|
+
},
|
|
2800
|
+
null,
|
|
2801
|
+
2
|
|
2802
|
+
)
|
|
2803
|
+
);
|
|
2804
|
+
} else {
|
|
2805
|
+
printGraphSummary(artifacts, options.displayRoot);
|
|
2806
|
+
printSelectedSnapshotSummary(artifacts, selectedSnapshot, comparison, options.displayRoot);
|
|
2807
|
+
if (routeContext) printRouteContextSummary(routeContext);
|
|
2808
|
+
if (impactContext) printImpactContextSummary(impactContext);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
// src/commands/health.ts
|
|
2813
|
+
var BOLD = "\x1B[1m";
|
|
2814
|
+
var DIM2 = "\x1B[2m";
|
|
2815
|
+
var RESET2 = "\x1B[0m";
|
|
2816
|
+
var RED2 = "\x1B[31m";
|
|
2817
|
+
var GREEN2 = "\x1B[32m";
|
|
2818
|
+
var CYAN = "\x1B[36m";
|
|
2819
|
+
var YELLOW = "\x1B[33m";
|
|
2820
|
+
var PROJECT_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/project-health-report.v1.json";
|
|
2821
|
+
var DEFAULT_HEALTH_CI_WORKFLOW_PATH = ".github/workflows/decantr-health.yml";
|
|
2822
|
+
var DEFAULT_HEALTH_CI_REPORT_PATH = "decantr-health.md";
|
|
2823
|
+
var DEFAULT_HEALTH_CI_JSON_PATH = "decantr-health.json";
|
|
2824
|
+
var DEFAULT_HEALTH_CI_CLI_VERSION = "latest";
|
|
2825
|
+
var __dirname = dirname3(fileURLToPath(import.meta.url));
|
|
2826
|
+
function readProjectMetadata(projectRoot) {
|
|
2827
|
+
const projectJsonPath = join5(projectRoot, ".decantr", "project.json");
|
|
2828
|
+
if (!existsSync5(projectJsonPath)) {
|
|
2829
|
+
return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
|
|
2830
|
+
}
|
|
2831
|
+
try {
|
|
2832
|
+
const data = JSON.parse(readFileSync5(projectJsonPath, "utf-8"));
|
|
2833
|
+
const workflowMode = typeof data.initialized?.workflowMode === "string" ? data.initialized.workflowMode : null;
|
|
2834
|
+
const adoptionMode = typeof data.initialized?.adoptionMode === "string" ? data.initialized.adoptionMode : null;
|
|
2835
|
+
return {
|
|
2836
|
+
workflowMode,
|
|
2837
|
+
adoptionMode,
|
|
2838
|
+
autoBrownfield: workflowMode === "brownfield-attach"
|
|
2839
|
+
};
|
|
2840
|
+
} catch {
|
|
2841
|
+
return { workflowMode: null, adoptionMode: null, autoBrownfield: false };
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
function loadHealthTemplate(name) {
|
|
2845
|
+
const fromDist = join5(__dirname, "..", "src", "templates", name);
|
|
2846
|
+
if (existsSync5(fromDist)) return readFileSync5(fromDist, "utf-8");
|
|
2847
|
+
const fromSrc = join5(__dirname, "..", "templates", name);
|
|
2848
|
+
if (existsSync5(fromSrc)) return readFileSync5(fromSrc, "utf-8");
|
|
2849
|
+
const fromCommandSrc = join5(__dirname, "..", "..", "templates", name);
|
|
2850
|
+
if (existsSync5(fromCommandSrc)) return readFileSync5(fromCommandSrc, "utf-8");
|
|
2851
|
+
throw new Error(`Template not found: ${name}`);
|
|
2852
|
+
}
|
|
2853
|
+
function renderTemplate(template, vars) {
|
|
2854
|
+
let result = template;
|
|
2855
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
2856
|
+
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
|
|
2857
|
+
}
|
|
2858
|
+
return result;
|
|
2859
|
+
}
|
|
2860
|
+
function normalizeCliPackageSpecifier(version) {
|
|
2861
|
+
const value = (version || DEFAULT_HEALTH_CI_CLI_VERSION).trim();
|
|
2862
|
+
if (!value) return `@decantr/cli@${DEFAULT_HEALTH_CI_CLI_VERSION}`;
|
|
2863
|
+
const versionToken = value.startsWith("@decantr/cli@") ? value.slice("@decantr/cli@".length) : value;
|
|
2864
|
+
if (!/^[A-Za-z0-9._~^*-]+$/.test(versionToken)) {
|
|
2865
|
+
throw new Error(
|
|
2866
|
+
"Invalid --cli-version value. Use a package version or dist-tag such as latest, 2.0.0, or next."
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
return `@decantr/cli@${versionToken}`;
|
|
2870
|
+
}
|
|
2871
|
+
function normalizeHealthFailOn(value) {
|
|
2872
|
+
const failOn = value ?? "error";
|
|
2873
|
+
if (!["error", "warn", "none"].includes(failOn)) {
|
|
2874
|
+
throw new Error("Invalid --fail-on value. Use error, warn, or none.");
|
|
2875
|
+
}
|
|
2876
|
+
return failOn;
|
|
2877
|
+
}
|
|
2878
|
+
function validateWorkflowPath(value) {
|
|
2879
|
+
const normalized = value.trim();
|
|
2880
|
+
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized)) {
|
|
2881
|
+
throw new Error(
|
|
2882
|
+
"Invalid --workflow-path value. Use a relative path without spaces or parent-directory segments."
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2885
|
+
return normalized;
|
|
2886
|
+
}
|
|
2887
|
+
function validateArtifactPath(value, flag) {
|
|
2888
|
+
const normalized = value.trim();
|
|
2889
|
+
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized)) {
|
|
2890
|
+
throw new Error(
|
|
2891
|
+
`Invalid ${flag} value. Use a relative artifact path without spaces or parent-directory segments.`
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
return normalized;
|
|
2895
|
+
}
|
|
2896
|
+
function validateProjectPath(value) {
|
|
2897
|
+
if (value === void 0) return void 0;
|
|
2898
|
+
const raw = value.trim();
|
|
2899
|
+
if (!raw || raw === ".") return void 0;
|
|
2900
|
+
const normalized = raw.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
2901
|
+
if (!normalized || normalized.startsWith("/") || normalized.startsWith("-") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized) || !/^[A-Za-z0-9._@/-]+$/.test(normalized)) {
|
|
2902
|
+
throw new Error(
|
|
2903
|
+
"Invalid --project value. Use a relative project path without spaces or parent-directory segments."
|
|
2904
|
+
);
|
|
2905
|
+
}
|
|
2906
|
+
const segments = normalized.split("/");
|
|
2907
|
+
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
2908
|
+
throw new Error(
|
|
2909
|
+
"Invalid --project value. Use a relative project path without empty or parent-directory segments."
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2912
|
+
return normalized;
|
|
2913
|
+
}
|
|
2914
|
+
function prefixArtifactPath(projectPath, artifactPath) {
|
|
2915
|
+
return projectPath ? `${projectPath}/${artifactPath}` : artifactPath;
|
|
2916
|
+
}
|
|
2917
|
+
function renderProjectHealthCiWorkflow(options = {}) {
|
|
2918
|
+
const failOn = normalizeHealthFailOn(options.failOn);
|
|
2919
|
+
const projectPath = options.workspace ? void 0 : validateProjectPath(options.projectPath);
|
|
2920
|
+
const reportPath = validateArtifactPath(
|
|
2921
|
+
options.reportPath || (options.workspace ? ".decantr/workspace-health.md" : DEFAULT_HEALTH_CI_REPORT_PATH),
|
|
2922
|
+
"--report-path"
|
|
2923
|
+
);
|
|
2924
|
+
const jsonPath = validateArtifactPath(
|
|
2925
|
+
options.jsonPath || (options.workspace ? ".decantr/workspace-health.json" : DEFAULT_HEALTH_CI_JSON_PATH),
|
|
2926
|
+
"--json-path"
|
|
2927
|
+
);
|
|
2928
|
+
const template = loadHealthTemplate("decantr-health.workflow.yml.template");
|
|
2929
|
+
return renderTemplate(template, {
|
|
2930
|
+
CLI_PACKAGE: normalizeCliPackageSpecifier(options.cliVersion),
|
|
2931
|
+
FAIL_ON: failOn,
|
|
2932
|
+
HEALTH_COMMAND: options.workspace ? "workspace health" : "health",
|
|
2933
|
+
PROJECT_WORKING_DIRECTORY: projectPath ? ` working-directory: ${projectPath}
|
|
2934
|
+
` : "",
|
|
2935
|
+
REPORT_PATH: reportPath,
|
|
2936
|
+
JSON_PATH: jsonPath,
|
|
2937
|
+
REPORT_ARTIFACT_PATH: prefixArtifactPath(projectPath, reportPath),
|
|
2938
|
+
JSON_ARTIFACT_PATH: prefixArtifactPath(projectPath, jsonPath)
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
function writeProjectHealthCiWorkflow(projectRoot, options = {}) {
|
|
2942
|
+
const workflowRelativePath = validateWorkflowPath(
|
|
2943
|
+
options.workflowPath || DEFAULT_HEALTH_CI_WORKFLOW_PATH
|
|
2944
|
+
);
|
|
2945
|
+
const workflowPath = join5(projectRoot, workflowRelativePath);
|
|
2946
|
+
const alreadyExists = existsSync5(workflowPath);
|
|
2947
|
+
if (alreadyExists && !options.force) {
|
|
2948
|
+
throw new Error(
|
|
2949
|
+
`${workflowRelativePath} already exists. Re-run with --force to replace it, or use --workflow-path <file>.`
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
mkdirSync4(dirname3(workflowPath), { recursive: true });
|
|
2953
|
+
writeFileSync4(workflowPath, renderProjectHealthCiWorkflow(options), "utf-8");
|
|
2954
|
+
const projectPath = options.workspace ? void 0 : validateProjectPath(options.projectPath);
|
|
2955
|
+
const result = {
|
|
2956
|
+
path: workflowRelativePath,
|
|
2957
|
+
created: !alreadyExists,
|
|
2958
|
+
cliPackage: normalizeCliPackageSpecifier(options.cliVersion),
|
|
2959
|
+
failOn: normalizeHealthFailOn(options.failOn)
|
|
2960
|
+
};
|
|
2961
|
+
if (projectPath) result.projectPath = projectPath;
|
|
2962
|
+
if (options.workspace) result.workspace = true;
|
|
2963
|
+
return result;
|
|
2964
|
+
}
|
|
2965
|
+
function collectDeclaredRoutes(essence) {
|
|
2966
|
+
if (!essence || typeof essence !== "object") return [];
|
|
2967
|
+
const record = essence;
|
|
2968
|
+
const blueprint = record.blueprint;
|
|
2969
|
+
if (!blueprint || typeof blueprint !== "object") return [];
|
|
2970
|
+
const bp = blueprint;
|
|
2971
|
+
const routes = /* @__PURE__ */ new Set();
|
|
2972
|
+
if (bp.routes && typeof bp.routes === "object" && !Array.isArray(bp.routes)) {
|
|
2973
|
+
for (const route of Object.keys(bp.routes)) {
|
|
2974
|
+
routes.add(route);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
const flatPages = Array.isArray(bp.pages) ? bp.pages : [];
|
|
2978
|
+
for (const page of flatPages) {
|
|
2979
|
+
if (page && typeof page === "object") {
|
|
2980
|
+
const route = page.route;
|
|
2981
|
+
if (typeof route === "string") routes.add(route);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
const sections = Array.isArray(bp.sections) ? bp.sections : [];
|
|
2985
|
+
for (const section of sections) {
|
|
2986
|
+
if (!section || typeof section !== "object") continue;
|
|
2987
|
+
const pages = section.pages;
|
|
2988
|
+
if (!Array.isArray(pages)) continue;
|
|
2989
|
+
for (const page of pages) {
|
|
2990
|
+
if (page && typeof page === "object") {
|
|
2991
|
+
const route = page.route;
|
|
2992
|
+
if (typeof route === "string") routes.add(route);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
return [...routes].sort();
|
|
2997
|
+
}
|
|
2998
|
+
function severityFromCheckIssue(issue) {
|
|
2999
|
+
return issue.type === "error" ? "error" : "warn";
|
|
3000
|
+
}
|
|
3001
|
+
function sourceFromAuditFinding(finding) {
|
|
3002
|
+
const category = finding.category.toLowerCase();
|
|
3003
|
+
const id = finding.id.toLowerCase();
|
|
3004
|
+
const rule = finding.rule?.toLowerCase() ?? "";
|
|
3005
|
+
if (category.includes("runtime") || category.includes("document") || category.includes("performance")) {
|
|
3006
|
+
return "runtime";
|
|
3007
|
+
}
|
|
3008
|
+
if (category.includes("pack") || category.includes("review contract")) {
|
|
3009
|
+
return "pack";
|
|
3010
|
+
}
|
|
3011
|
+
if (category.includes("interaction") || id.includes("interaction") || rule.includes("interaction")) {
|
|
3012
|
+
return "interaction";
|
|
3013
|
+
}
|
|
3014
|
+
if (category.includes("style bridge") || id.includes("style-bridge") || rule.includes("style-bridge")) {
|
|
3015
|
+
return "style-bridge";
|
|
3016
|
+
}
|
|
3017
|
+
return "audit";
|
|
3018
|
+
}
|
|
3019
|
+
function sourceFromCheckIssue(issue) {
|
|
3020
|
+
if (issue.rule.startsWith("brownfield-")) return "brownfield";
|
|
3021
|
+
if (issue.rule.includes("interaction")) return "interaction";
|
|
3022
|
+
return "check";
|
|
3023
|
+
}
|
|
3024
|
+
function normalizeHealthCategory(category, source) {
|
|
3025
|
+
const lower = category.toLowerCase();
|
|
3026
|
+
if (source === "pack" || lower.includes("execution pack") || lower.includes("review contract") || lower.includes("context")) {
|
|
3027
|
+
return "Generated Artifact";
|
|
3028
|
+
}
|
|
3029
|
+
if (source === "brownfield") return "Brownfield Contract";
|
|
3030
|
+
if (source === "design-token" || lower.includes("design-token")) return "Design Token";
|
|
3031
|
+
if (source === "style-bridge" || lower.includes("style bridge")) return "Style Bridge";
|
|
3032
|
+
if (source === "graph") return "Typed Contract Graph";
|
|
3033
|
+
if (lower.includes("accessibility")) return "Accessibility";
|
|
3034
|
+
if (source === "runtime") return "Runtime";
|
|
3035
|
+
if (source === "browser") return "Visual Evidence";
|
|
3036
|
+
if (source === "interaction") return "Interaction";
|
|
3037
|
+
if (source === "assertion") return `Contract ${category}`;
|
|
3038
|
+
return category;
|
|
3039
|
+
}
|
|
3040
|
+
function contractAssertionApplies(assertion, metadata) {
|
|
3041
|
+
const projectOwnedStyling = metadata.adoptionMode === "contract-only" || metadata.adoptionMode === "style-bridge";
|
|
3042
|
+
if (assertion.rule === "tokens-file-present" && projectOwnedStyling) {
|
|
3043
|
+
return false;
|
|
3044
|
+
}
|
|
3045
|
+
if (projectOwnedStyling && (assertion.rule === "pack-manifest-present" || assertion.rule === "review-pack-present")) {
|
|
3046
|
+
return false;
|
|
3047
|
+
}
|
|
3048
|
+
return true;
|
|
3049
|
+
}
|
|
3050
|
+
function slugify(value) {
|
|
3051
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
3052
|
+
}
|
|
3053
|
+
function hashFile2(path) {
|
|
3054
|
+
if (!existsSync5(path)) return null;
|
|
3055
|
+
try {
|
|
3056
|
+
return createHash2("sha256").update(readFileSync5(path)).digest("hex");
|
|
3057
|
+
} catch {
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
function readJsonFile4(path) {
|
|
3062
|
+
if (!existsSync5(path)) return null;
|
|
3063
|
+
try {
|
|
3064
|
+
return JSON.parse(readFileSync5(path, "utf-8"));
|
|
3065
|
+
} catch {
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
function readProjectGraphSnapshot(projectRoot) {
|
|
3070
|
+
return readJsonFile4(
|
|
3071
|
+
join5(projectRoot, ".decantr", "graph", "graph.snapshot.json")
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
function anchorProjectHealthFindings(projectRoot, findings) {
|
|
3075
|
+
return anchorFindingsToGraph(readProjectGraphSnapshot(projectRoot), findings);
|
|
3076
|
+
}
|
|
3077
|
+
function inspectProjectHealthGraph(projectRoot) {
|
|
3078
|
+
const graphDir = join5(projectRoot, ".decantr", "graph");
|
|
3079
|
+
const snapshotPath = join5(graphDir, "graph.snapshot.json");
|
|
3080
|
+
const manifestPath = join5(graphDir, "graph.manifest.json");
|
|
3081
|
+
const diffPath = join5(graphDir, "graph.diff.json");
|
|
3082
|
+
const capsulePath = join5(graphDir, "contract-capsule.json");
|
|
3083
|
+
const projectMetadataPresent = existsSync5(join5(projectRoot, ".decantr", "project.json"));
|
|
3084
|
+
const graphDirPresent = existsSync5(graphDir);
|
|
3085
|
+
const snapshot = readProjectGraphSnapshot(projectRoot);
|
|
3086
|
+
const capsule = readJsonFile4(capsulePath);
|
|
3087
|
+
try {
|
|
3088
|
+
const artifacts = buildGraphArtifacts(projectRoot);
|
|
3089
|
+
const current = artifacts ? artifacts.staleArtifacts.length === 0 : projectMetadataPresent ? false : null;
|
|
3090
|
+
return {
|
|
3091
|
+
present: graphDirPresent,
|
|
3092
|
+
ready: current === true && existsSync5(snapshotPath) && existsSync5(capsulePath),
|
|
3093
|
+
current,
|
|
3094
|
+
snapshotPresent: existsSync5(snapshotPath),
|
|
3095
|
+
manifestPresent: existsSync5(manifestPath),
|
|
3096
|
+
diffPresent: existsSync5(diffPath),
|
|
3097
|
+
capsulePresent: existsSync5(capsulePath),
|
|
3098
|
+
snapshotId: snapshot?.id ?? artifacts?.snapshot.id ?? null,
|
|
3099
|
+
sourceHash: snapshot?.source_hash ?? artifacts?.snapshot.source_hash ?? null,
|
|
3100
|
+
contractHash: capsule?.contract_hash ?? artifacts?.capsule.contract_hash ?? null,
|
|
3101
|
+
contractCacheKey: capsule?.contract_cache_key ?? artifacts?.capsule.contract_cache_key ?? null,
|
|
3102
|
+
sourceArtifactCount: snapshot?.nodes.filter((node) => node.type === "SourceArtifact").length ?? artifacts?.snapshot.nodes.filter((node) => node.type === "SourceArtifact").length ?? capsule?.summary?.source_artifacts ?? 0,
|
|
3103
|
+
capsuleSourceArtifactLimit: capsule?.source_artifact_limit ?? artifacts?.capsule.source_artifact_limit ?? null,
|
|
3104
|
+
capsuleSourceArtifactsTruncated: capsule?.source_artifacts_truncated ?? artifacts?.capsule.source_artifacts_truncated ?? null,
|
|
3105
|
+
staleArtifacts: artifacts ? artifacts.staleArtifacts.map((path) => relative3(projectRoot, path).replace(/\\/g, "/")) : [],
|
|
3106
|
+
error: null
|
|
3107
|
+
};
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
return {
|
|
3110
|
+
present: graphDirPresent,
|
|
3111
|
+
ready: false,
|
|
3112
|
+
current: graphDirPresent || projectMetadataPresent ? false : null,
|
|
3113
|
+
snapshotPresent: existsSync5(snapshotPath),
|
|
3114
|
+
manifestPresent: existsSync5(manifestPath),
|
|
3115
|
+
diffPresent: existsSync5(diffPath),
|
|
3116
|
+
capsulePresent: existsSync5(capsulePath),
|
|
3117
|
+
snapshotId: snapshot?.id ?? null,
|
|
3118
|
+
sourceHash: snapshot?.source_hash ?? null,
|
|
3119
|
+
contractHash: capsule?.contract_hash ?? null,
|
|
3120
|
+
contractCacheKey: capsule?.contract_cache_key ?? null,
|
|
3121
|
+
sourceArtifactCount: snapshot?.nodes.filter((node) => node.type === "SourceArtifact").length ?? capsule?.summary?.source_artifacts ?? 0,
|
|
3122
|
+
capsuleSourceArtifactLimit: capsule?.source_artifact_limit ?? null,
|
|
3123
|
+
capsuleSourceArtifactsTruncated: capsule?.source_artifacts_truncated ?? null,
|
|
3124
|
+
staleArtifacts: [],
|
|
3125
|
+
error: error.message
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
function collectGraphArtifactFindings(projectRoot, graph) {
|
|
3130
|
+
const graphDirPresent = existsSync5(join5(projectRoot, ".decantr", "graph"));
|
|
3131
|
+
const projectMetadataPresent = existsSync5(join5(projectRoot, ".decantr", "project.json"));
|
|
3132
|
+
if (!graphDirPresent && !projectMetadataPresent) {
|
|
3133
|
+
return [];
|
|
3134
|
+
}
|
|
3135
|
+
if (graph.error) {
|
|
3136
|
+
return [
|
|
3137
|
+
createHealthFinding({
|
|
3138
|
+
source: "graph",
|
|
3139
|
+
category: "Typed Contract Graph",
|
|
3140
|
+
severity: "warn",
|
|
3141
|
+
message: `Typed Contract graph could not be derived: ${graph.error}`,
|
|
3142
|
+
evidence: [
|
|
3143
|
+
"Graph derivation reads decantr.essence.json, local rules, style bridge, Brownfield analysis, reusable component declarations, visual manifest, and saved evidence bundle artifacts."
|
|
3144
|
+
],
|
|
3145
|
+
target: ".decantr/graph",
|
|
3146
|
+
rule: "typed-graph-current",
|
|
3147
|
+
suggestedFix: graph.error.includes("Essence v4") ? "Run `decantr migrate --to v4`, then run `decantr graph`." : "Repair the Decantr contract, then run `decantr graph`.",
|
|
3148
|
+
baseId: "typed-graph-current"
|
|
3149
|
+
})
|
|
3150
|
+
];
|
|
3151
|
+
}
|
|
3152
|
+
if (graph.current !== false || graph.staleArtifacts.length === 0) {
|
|
3153
|
+
return [];
|
|
3154
|
+
}
|
|
3155
|
+
return [
|
|
3156
|
+
createHealthFinding({
|
|
3157
|
+
source: "graph",
|
|
3158
|
+
category: "Typed Contract Graph",
|
|
3159
|
+
severity: "warn",
|
|
3160
|
+
message: "Typed Contract graph artifacts are missing or stale.",
|
|
3161
|
+
evidence: graph.staleArtifacts.slice(0, 8),
|
|
3162
|
+
target: ".decantr/graph",
|
|
3163
|
+
rule: "typed-graph-current",
|
|
3164
|
+
suggestedFix: "Run `decantr graph` to regenerate graph snapshot, history, diff, manifest, and capsule.",
|
|
3165
|
+
baseId: "typed-graph-current"
|
|
3166
|
+
})
|
|
3167
|
+
];
|
|
3168
|
+
}
|
|
3169
|
+
function commandsForFinding(source) {
|
|
3170
|
+
switch (source) {
|
|
3171
|
+
case "brownfield":
|
|
3172
|
+
return ["decantr analyze", "decantr init --existing --merge-proposal", "decantr health"];
|
|
3173
|
+
case "pack":
|
|
3174
|
+
return [
|
|
3175
|
+
"decantr refresh",
|
|
3176
|
+
"decantr registry get-pack review --write-context",
|
|
3177
|
+
"decantr health"
|
|
3178
|
+
];
|
|
3179
|
+
case "runtime":
|
|
3180
|
+
return ["npm run build", "decantr health"];
|
|
3181
|
+
case "interaction":
|
|
3182
|
+
return ["decantr check --strict", "decantr health"];
|
|
3183
|
+
case "assertion":
|
|
3184
|
+
return ["decantr refresh", "decantr health --evidence"];
|
|
3185
|
+
case "browser":
|
|
3186
|
+
return ["decantr health --browser", "decantr health --evidence"];
|
|
3187
|
+
case "design-token":
|
|
3188
|
+
return ["decantr export --to figma-tokens", "decantr health --evidence"];
|
|
3189
|
+
case "style-bridge":
|
|
3190
|
+
return ["decantr codify --style-bridge", "decantr verify --evidence"];
|
|
3191
|
+
case "graph":
|
|
3192
|
+
return ["decantr graph", "decantr health"];
|
|
3193
|
+
case "check":
|
|
3194
|
+
return ["decantr check", "decantr health"];
|
|
3195
|
+
default:
|
|
3196
|
+
return ["decantr audit", "decantr health"];
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
function detectPackageManager(root) {
|
|
3200
|
+
const pkg = readJsonFile4(join5(root, "package.json"));
|
|
3201
|
+
const declared = pkg?.packageManager?.split("@")[0];
|
|
3202
|
+
if (declared === "pnpm" || declared === "npm" || declared === "yarn" || declared === "bun") {
|
|
3203
|
+
return declared;
|
|
3204
|
+
}
|
|
3205
|
+
if (existsSync5(join5(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
3206
|
+
if (existsSync5(join5(root, "package-lock.json"))) return "npm";
|
|
3207
|
+
if (existsSync5(join5(root, "yarn.lock"))) return "yarn";
|
|
3208
|
+
if (existsSync5(join5(root, "bun.lock")) || existsSync5(join5(root, "bun.lockb"))) return "bun";
|
|
3209
|
+
return "unknown";
|
|
3210
|
+
}
|
|
3211
|
+
function buildCommandForProject(workspaceRoot, projectPath) {
|
|
3212
|
+
const packageManager = detectPackageManager(workspaceRoot);
|
|
3213
|
+
if (projectPath) {
|
|
3214
|
+
switch (packageManager) {
|
|
3215
|
+
case "pnpm":
|
|
3216
|
+
return `pnpm --dir ${projectPath} build`;
|
|
3217
|
+
case "yarn":
|
|
3218
|
+
return `yarn --cwd ${projectPath} build`;
|
|
3219
|
+
case "bun":
|
|
3220
|
+
return `bun --cwd ${projectPath} run build`;
|
|
3221
|
+
case "npm":
|
|
3222
|
+
return `npm --prefix ${projectPath} run build`;
|
|
3223
|
+
default:
|
|
3224
|
+
return `cd ${projectPath} && npm run build`;
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
switch (packageManager) {
|
|
3228
|
+
case "pnpm":
|
|
3229
|
+
return "pnpm build";
|
|
3230
|
+
case "yarn":
|
|
3231
|
+
return "yarn build";
|
|
3232
|
+
case "bun":
|
|
3233
|
+
return "bun run build";
|
|
3234
|
+
case "npm":
|
|
3235
|
+
return "npm run build";
|
|
3236
|
+
default:
|
|
3237
|
+
return "npm run build";
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
function commandContextForProject(projectRoot) {
|
|
3241
|
+
const workspaceInfo = resolveWorkspaceInfo(projectRoot);
|
|
3242
|
+
const relativeProjectPath = relative3(workspaceInfo.workspaceRoot, projectRoot).replace(
|
|
3243
|
+
/\\/g,
|
|
3244
|
+
"/"
|
|
3245
|
+
);
|
|
3246
|
+
const projectPath = relativeProjectPath && !relativeProjectPath.startsWith("..") && !isAbsolute3(relativeProjectPath) ? relativeProjectPath : null;
|
|
3247
|
+
const projectFlag = projectPath ? ` --project ${projectPath}` : "";
|
|
3248
|
+
const essencePath = projectPath ? `${projectPath}/decantr.essence.json` : "decantr.essence.json";
|
|
3249
|
+
return {
|
|
3250
|
+
projectPath,
|
|
3251
|
+
buildCommand: buildCommandForProject(workspaceInfo.workspaceRoot, projectPath),
|
|
3252
|
+
compilePacksCommand: `decantr registry compile-packs ${essencePath} --write-context`,
|
|
3253
|
+
verifyCommand: `decantr verify${projectFlag}`,
|
|
3254
|
+
ciCommand: `decantr ci${projectFlag} --fail-on error`,
|
|
3255
|
+
promptCommand: (id) => `decantr health${projectFlag} --prompt ${id}`
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
function rewriteHealthCommand(command, context) {
|
|
3259
|
+
if (command === "npm run build") return context.buildCommand;
|
|
3260
|
+
let rewritten = command.replace(
|
|
3261
|
+
/decantr registry compile-packs decantr\.essence\.json --write-context/g,
|
|
3262
|
+
context.compilePacksCommand
|
|
3263
|
+
);
|
|
3264
|
+
if (!context.projectPath) return rewritten;
|
|
3265
|
+
rewritten = rewritten.replace(
|
|
3266
|
+
/^decantr init --existing\b/,
|
|
3267
|
+
`decantr init --project ${context.projectPath} --existing`
|
|
3268
|
+
);
|
|
3269
|
+
rewritten = rewritten.replace(
|
|
3270
|
+
/^decantr analyze\b/,
|
|
3271
|
+
`decantr analyze --project ${context.projectPath}`
|
|
3272
|
+
);
|
|
3273
|
+
rewritten = rewritten.replace(
|
|
3274
|
+
/^decantr check\b/,
|
|
3275
|
+
`decantr check --project ${context.projectPath}`
|
|
3276
|
+
);
|
|
3277
|
+
rewritten = rewritten.replace(
|
|
3278
|
+
/^decantr graph\b/,
|
|
3279
|
+
`decantr graph --project ${context.projectPath}`
|
|
3280
|
+
);
|
|
3281
|
+
rewritten = rewritten.replace(/^decantr audit\b/, context.verifyCommand);
|
|
3282
|
+
rewritten = rewritten.replace(/^decantr health\b/, context.verifyCommand);
|
|
3283
|
+
return rewritten;
|
|
3284
|
+
}
|
|
3285
|
+
function rewriteSuggestedFixForProject(suggestedFix, context) {
|
|
3286
|
+
if (!suggestedFix) return suggestedFix;
|
|
3287
|
+
return suggestedFix.replace(
|
|
3288
|
+
/decantr registry compile-packs decantr\.essence\.json --write-context/g,
|
|
3289
|
+
context.compilePacksCommand
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
function commandsForProjectFinding(finding, context) {
|
|
3293
|
+
const isPackHydrationFinding = finding.source === "pack" || /pack-manifest|review-pack|compile-packs/i.test(
|
|
3294
|
+
`${finding.id} ${finding.rule ?? ""} ${finding.suggestedFix ?? ""}`
|
|
3295
|
+
);
|
|
3296
|
+
if (isPackHydrationFinding) {
|
|
3297
|
+
return [context.compilePacksCommand, context.verifyCommand];
|
|
3298
|
+
}
|
|
3299
|
+
return [
|
|
3300
|
+
...new Set(
|
|
3301
|
+
finding.remediation.commands.map((command) => rewriteHealthCommand(command, context))
|
|
3302
|
+
)
|
|
3303
|
+
];
|
|
3304
|
+
}
|
|
3305
|
+
function scopeHealthFindingsToProject(projectRoot, findings) {
|
|
3306
|
+
const context = commandContextForProject(projectRoot);
|
|
3307
|
+
return findings.map((finding) => {
|
|
3308
|
+
const suggestedFix = rewriteSuggestedFixForProject(finding.suggestedFix, context);
|
|
3309
|
+
const commands = commandsForProjectFinding(finding, context);
|
|
3310
|
+
return {
|
|
3311
|
+
...finding,
|
|
3312
|
+
suggestedFix,
|
|
3313
|
+
remediation: {
|
|
3314
|
+
summary: suggestedFix || finding.remediation.summary,
|
|
3315
|
+
commands,
|
|
3316
|
+
prompt: buildRemediationPrompt({
|
|
3317
|
+
id: finding.id,
|
|
3318
|
+
source: finding.source,
|
|
3319
|
+
category: finding.category,
|
|
3320
|
+
severity: finding.severity,
|
|
3321
|
+
message: finding.message,
|
|
3322
|
+
code: finding.code,
|
|
3323
|
+
evidence: finding.evidence,
|
|
3324
|
+
suggestedFix,
|
|
3325
|
+
graph: finding.graph,
|
|
3326
|
+
repair: finding.repair,
|
|
3327
|
+
commands,
|
|
3328
|
+
projectPath: context.projectPath
|
|
3329
|
+
})
|
|
3330
|
+
}
|
|
3331
|
+
};
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
function buildRemediationPrompt(input) {
|
|
3335
|
+
const prefix = input.projectPath ? `${input.projectPath}/` : "";
|
|
3336
|
+
const readTargets = [
|
|
3337
|
+
`${prefix}DECANTR.md`,
|
|
3338
|
+
`${prefix}decantr.essence.json`,
|
|
3339
|
+
`${prefix}.decantr/context/scaffold-pack.md`,
|
|
3340
|
+
`${prefix}.decantr/context/scaffold.md`
|
|
3341
|
+
];
|
|
3342
|
+
return [
|
|
3343
|
+
"You are fixing one Decantr Project Health finding in this local workspace.",
|
|
3344
|
+
"",
|
|
3345
|
+
`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.`,
|
|
3346
|
+
"",
|
|
3347
|
+
`Finding: ${input.id}`,
|
|
3348
|
+
`Source: ${input.source}`,
|
|
3349
|
+
`Severity: ${input.severity}`,
|
|
3350
|
+
`Category: ${input.category}`,
|
|
3351
|
+
input.code ? `Code: ${input.code}` : null,
|
|
3352
|
+
`Message: ${input.message}`,
|
|
3353
|
+
input.graph ? `Graph anchor: ${input.graph.node_type} ${input.graph.node_id} (${input.graph.confidence}; snapshot ${input.graph.snapshot_id})` : null,
|
|
3354
|
+
input.repair ? `Repair: ${input.repair.id}` : null,
|
|
3355
|
+
input.evidence.length > 0 ? `Evidence:
|
|
3356
|
+
${input.evidence.map((entry) => `- ${entry}`).join("\n")}` : null,
|
|
3357
|
+
input.suggestedFix ? `Suggested fix: ${input.suggestedFix}` : null,
|
|
3358
|
+
"",
|
|
3359
|
+
"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.",
|
|
3360
|
+
"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.",
|
|
3361
|
+
"",
|
|
3362
|
+
`After the fix, run:
|
|
3363
|
+
${input.commands.map((command) => `- ${command}`).join("\n")}`
|
|
3364
|
+
].filter((line) => Boolean(line)).join("\n");
|
|
3365
|
+
}
|
|
3366
|
+
function createHealthFinding(input) {
|
|
3367
|
+
const idBase = input.baseId || input.rule || `${input.category}-${input.message}`;
|
|
3368
|
+
const id = `${input.source}-${slugify(idBase)}`;
|
|
3369
|
+
const commands = commandsForFinding(input.source);
|
|
3370
|
+
const category = normalizeHealthCategory(input.category, input.source);
|
|
3371
|
+
const diagnostic = deriveVerificationDiagnostic({
|
|
3372
|
+
id,
|
|
3373
|
+
source: input.source,
|
|
3374
|
+
category,
|
|
3375
|
+
message: input.message,
|
|
3376
|
+
rule: input.rule,
|
|
3377
|
+
target: input.target,
|
|
3378
|
+
file: input.file,
|
|
3379
|
+
suggestedFix: input.suggestedFix,
|
|
3380
|
+
evidence: input.evidence
|
|
3381
|
+
});
|
|
3382
|
+
const code = input.code ?? diagnostic.code;
|
|
3383
|
+
const repair = input.repair ?? diagnostic.repair;
|
|
3384
|
+
const remediation = {
|
|
3385
|
+
summary: input.suggestedFix || `Resolve ${category.toLowerCase()} finding.`,
|
|
3386
|
+
commands,
|
|
3387
|
+
prompt: buildRemediationPrompt({
|
|
3388
|
+
id,
|
|
3389
|
+
source: input.source,
|
|
3390
|
+
category,
|
|
3391
|
+
severity: input.severity,
|
|
3392
|
+
message: input.message,
|
|
3393
|
+
code,
|
|
3394
|
+
evidence: input.evidence ?? [],
|
|
3395
|
+
suggestedFix: input.suggestedFix,
|
|
3396
|
+
repair,
|
|
3397
|
+
commands
|
|
3398
|
+
})
|
|
3399
|
+
};
|
|
3400
|
+
return {
|
|
3401
|
+
id,
|
|
3402
|
+
code,
|
|
3403
|
+
source: input.source,
|
|
3404
|
+
category,
|
|
3405
|
+
severity: input.severity,
|
|
3406
|
+
message: input.message,
|
|
3407
|
+
evidence: input.evidence ?? [],
|
|
3408
|
+
target: input.target,
|
|
3409
|
+
file: input.file,
|
|
3410
|
+
rule: input.rule,
|
|
3411
|
+
suggestedFix: input.suggestedFix,
|
|
3412
|
+
repair,
|
|
3413
|
+
remediation
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
function collectContractPackConsistencyFindings(projectRoot, essence, manifest) {
|
|
3417
|
+
if (!essence || typeof essence !== "object") return [];
|
|
3418
|
+
const record = essence;
|
|
3419
|
+
const blueprint = record.blueprint;
|
|
3420
|
+
if (!blueprint || typeof blueprint !== "object") return [];
|
|
3421
|
+
const bp = blueprint;
|
|
3422
|
+
const routes = bp.routes && typeof bp.routes === "object" && !Array.isArray(bp.routes) ? bp.routes : {};
|
|
3423
|
+
const routeTargets = new Set(
|
|
3424
|
+
Object.values(routes).filter(
|
|
3425
|
+
(entry) => Boolean(entry) && typeof entry === "object"
|
|
3426
|
+
).map((entry) => `${String(entry.section ?? "")}/${String(entry.page ?? "")}`)
|
|
3427
|
+
);
|
|
3428
|
+
const pages = [];
|
|
3429
|
+
for (const section of Array.isArray(bp.sections) ? bp.sections : []) {
|
|
3430
|
+
if (!section || typeof section !== "object") continue;
|
|
3431
|
+
const sectionRecord = section;
|
|
3432
|
+
const sectionId = typeof sectionRecord.id === "string" ? sectionRecord.id : "unknown";
|
|
3433
|
+
for (const page of Array.isArray(sectionRecord.pages) ? sectionRecord.pages : []) {
|
|
3434
|
+
if (!page || typeof page !== "object") continue;
|
|
3435
|
+
const pageRecord = page;
|
|
3436
|
+
const pageId = typeof pageRecord.id === "string" ? pageRecord.id : "unknown";
|
|
3437
|
+
const route = typeof pageRecord.route === "string" ? pageRecord.route : null;
|
|
3438
|
+
pages.push({ section: sectionId, page: pageId, route });
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
const findings = [];
|
|
3442
|
+
const routeLess = pages.filter(
|
|
3443
|
+
(page) => !page.route && !routeTargets.has(`${page.section}/${page.page}`)
|
|
3444
|
+
);
|
|
3445
|
+
if (routeLess.length > 0) {
|
|
3446
|
+
findings.push(
|
|
3447
|
+
createHealthFinding({
|
|
3448
|
+
source: "assertion",
|
|
3449
|
+
category: "Contract Route Topology",
|
|
3450
|
+
severity: "error",
|
|
3451
|
+
message: "One or more blueprint pages have no route and cannot be addressed by task-time context.",
|
|
3452
|
+
evidence: routeLess.slice(0, 8).map(
|
|
3453
|
+
(page) => `${page.section}/${page.page} has no page.route or blueprint.routes entry`
|
|
3454
|
+
),
|
|
3455
|
+
rule: "page-route-required",
|
|
3456
|
+
suggestedFix: "Add a route for each page or rerun the add-page flow with a route-aware Decantr CLI.",
|
|
3457
|
+
baseId: "page-route-required"
|
|
3458
|
+
})
|
|
3459
|
+
);
|
|
3460
|
+
}
|
|
3461
|
+
const pagePackCount = manifest && "pages" in manifest && Array.isArray(manifest.pages) ? manifest.pages.length : 0;
|
|
3462
|
+
if (manifest && pages.length !== pagePackCount) {
|
|
3463
|
+
const context = commandContextForProject(projectRoot);
|
|
3464
|
+
findings.push(
|
|
3465
|
+
createHealthFinding({
|
|
3466
|
+
source: "pack",
|
|
3467
|
+
category: "Generated Artifacts",
|
|
3468
|
+
severity: "warn",
|
|
3469
|
+
message: `Compiled page pack count (${pagePackCount}) does not match the contract page count (${pages.length}).`,
|
|
3470
|
+
evidence: ["Page packs should be regenerated after adding, removing, or re-routing pages."],
|
|
3471
|
+
rule: "page-pack-count-mismatch",
|
|
3472
|
+
suggestedFix: context.compilePacksCommand,
|
|
3473
|
+
baseId: "page-pack-count-mismatch"
|
|
3474
|
+
})
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3477
|
+
return findings;
|
|
3478
|
+
}
|
|
3479
|
+
function countFindings(findings) {
|
|
3480
|
+
return {
|
|
3481
|
+
errorCount: findings.filter((finding) => finding.severity === "error").length,
|
|
3482
|
+
warnCount: findings.filter((finding) => finding.severity === "warn").length,
|
|
3483
|
+
infoCount: findings.filter((finding) => finding.severity === "info").length
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
function statusFromCounts(counts) {
|
|
3487
|
+
if (counts.errorCount > 0) return "error";
|
|
3488
|
+
if (counts.warnCount > 0) return "warning";
|
|
3489
|
+
return "healthy";
|
|
3490
|
+
}
|
|
3491
|
+
function scoreFromCounts(counts) {
|
|
3492
|
+
return Math.max(
|
|
3493
|
+
0,
|
|
3494
|
+
Math.min(100, 100 - counts.errorCount * 15 - counts.warnCount * 5 - counts.infoCount)
|
|
3495
|
+
);
|
|
3496
|
+
}
|
|
3497
|
+
function routeIssuesFromFindings(findings) {
|
|
3498
|
+
const issues = findings.filter(
|
|
3499
|
+
(finding) => finding.category.toLowerCase().includes("route") || finding.rule?.toLowerCase().includes("route") || finding.id.toLowerCase().includes("route")
|
|
3500
|
+
).map((finding) => finding.message);
|
|
3501
|
+
return [...new Set(issues)];
|
|
3502
|
+
}
|
|
3503
|
+
function isDuplicateFinding(existing, finding) {
|
|
3504
|
+
const key = `${finding.rule ?? finding.id}|${finding.message}`;
|
|
3505
|
+
if (existing.has(key)) return true;
|
|
3506
|
+
existing.add(key);
|
|
3507
|
+
return false;
|
|
3508
|
+
}
|
|
3509
|
+
function resolveOptionalPath(projectRoot, path) {
|
|
3510
|
+
if (!path) return void 0;
|
|
3511
|
+
return isAbsolute3(path) ? path : resolve2(projectRoot, path);
|
|
3512
|
+
}
|
|
3513
|
+
function hasProjectPlaywright(projectRoot) {
|
|
3514
|
+
try {
|
|
3515
|
+
const requireFromProject = createRequire(join5(projectRoot, "package.json"));
|
|
3516
|
+
requireFromProject.resolve("playwright");
|
|
3517
|
+
return true;
|
|
3518
|
+
} catch {
|
|
3519
|
+
try {
|
|
3520
|
+
const requireFromProject = createRequire(join5(projectRoot, "package.json"));
|
|
3521
|
+
requireFromProject.resolve("@playwright/test");
|
|
3522
|
+
return true;
|
|
3523
|
+
} catch {
|
|
3524
|
+
return false;
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
function loadProjectPlaywright(projectRoot) {
|
|
3529
|
+
const requireFromProject = createRequire(join5(projectRoot, "package.json"));
|
|
3530
|
+
for (const packageName of ["playwright", "@playwright/test"]) {
|
|
3531
|
+
try {
|
|
3532
|
+
const loaded = requireFromProject(packageName);
|
|
3533
|
+
if (loaded.chromium?.launch) return loaded;
|
|
3534
|
+
} catch {
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
return null;
|
|
3538
|
+
}
|
|
3539
|
+
function browserRouteUrl(baseUrl, route) {
|
|
3540
|
+
return new URL(route || "/", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
|
3541
|
+
}
|
|
3542
|
+
function browserScreenshotRelativePath(route) {
|
|
3543
|
+
const name = slugify(route === "/" ? "root" : route) || "root";
|
|
3544
|
+
return `.decantr/evidence/screenshots/${name}.png`;
|
|
3545
|
+
}
|
|
3546
|
+
async function collectBrowserVerification(projectRoot, options, declaredRoutes) {
|
|
3547
|
+
if (!options.browser) return null;
|
|
3548
|
+
if (!hasProjectPlaywright(projectRoot)) {
|
|
3549
|
+
const finding = createHealthFinding({
|
|
3550
|
+
source: "browser",
|
|
3551
|
+
category: "Browser Verification",
|
|
3552
|
+
severity: options.requireBrowser ? "error" : "warn",
|
|
3553
|
+
message: "Browser verification was requested, but Playwright is not installed in this project.",
|
|
3554
|
+
evidence: ["Expected dependency: playwright or @playwright/test"],
|
|
3555
|
+
rule: "browser-playwright-missing",
|
|
3556
|
+
suggestedFix: "Install Playwright in the project or omit browser/base-url evidence for static-only health checks.",
|
|
3557
|
+
baseId: "playwright-missing"
|
|
3558
|
+
});
|
|
3559
|
+
return {
|
|
3560
|
+
finding,
|
|
3561
|
+
evidence: {
|
|
3562
|
+
enabled: true,
|
|
3563
|
+
status: "unavailable",
|
|
3564
|
+
baseUrl: options.browserBaseUrl ?? null,
|
|
3565
|
+
screenshots: [],
|
|
3566
|
+
findings: [finding.message]
|
|
3567
|
+
}
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
if (!options.browserBaseUrl) {
|
|
3571
|
+
const finding = createHealthFinding({
|
|
3572
|
+
source: "browser",
|
|
3573
|
+
category: "Browser Verification",
|
|
3574
|
+
severity: options.requireBrowser ? "error" : "warn",
|
|
3575
|
+
message: "Browser verification was requested, but no base URL was provided for rendered route checks.",
|
|
3576
|
+
evidence: ["Pass --base-url <url> or set DECANTR_BROWSER_BASE_URL."],
|
|
3577
|
+
rule: "browser-base-url-missing",
|
|
3578
|
+
suggestedFix: "Start the app and rerun with `decantr health --browser --base-url <url>`.",
|
|
3579
|
+
baseId: "base-url-missing"
|
|
3580
|
+
});
|
|
3581
|
+
return {
|
|
3582
|
+
finding,
|
|
3583
|
+
evidence: {
|
|
3584
|
+
enabled: true,
|
|
3585
|
+
status: "unavailable",
|
|
3586
|
+
baseUrl: null,
|
|
3587
|
+
screenshots: [],
|
|
3588
|
+
findings: [finding.message]
|
|
3589
|
+
}
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
const playwright = loadProjectPlaywright(projectRoot);
|
|
3593
|
+
if (!playwright) {
|
|
3594
|
+
const finding = createHealthFinding({
|
|
3595
|
+
source: "browser",
|
|
3596
|
+
category: "Browser Verification",
|
|
3597
|
+
severity: options.requireBrowser ? "error" : "warn",
|
|
3598
|
+
message: "Playwright is installed, but Decantr could not load a Chromium browser adapter.",
|
|
3599
|
+
evidence: ["Expected chromium.launch from playwright or @playwright/test."],
|
|
3600
|
+
rule: "browser-adapter-missing",
|
|
3601
|
+
suggestedFix: "Repair the local Playwright install and rerun `decantr health --browser`.",
|
|
3602
|
+
baseId: "adapter-missing"
|
|
3603
|
+
});
|
|
3604
|
+
return {
|
|
3605
|
+
finding,
|
|
3606
|
+
evidence: {
|
|
3607
|
+
enabled: true,
|
|
3608
|
+
status: "unavailable",
|
|
3609
|
+
baseUrl: options.browserBaseUrl,
|
|
3610
|
+
screenshots: [],
|
|
3611
|
+
findings: [finding.message]
|
|
3612
|
+
}
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
const routes = (declaredRoutes.length > 0 ? declaredRoutes : ["/"]).slice(0, 12);
|
|
3616
|
+
const screenshots = [];
|
|
3617
|
+
const browserFindings = [];
|
|
3618
|
+
const visualRoutes = [];
|
|
3619
|
+
const screenshotDir = join5(projectRoot, ".decantr", "evidence", "screenshots");
|
|
3620
|
+
mkdirSync4(screenshotDir, { recursive: true });
|
|
3621
|
+
let browser = null;
|
|
3622
|
+
try {
|
|
3623
|
+
browser = await playwright.chromium.launch({ headless: true });
|
|
3624
|
+
const page = await browser.newPage();
|
|
3625
|
+
for (const route of routes) {
|
|
3626
|
+
const url = browserRouteUrl(options.browserBaseUrl, route);
|
|
3627
|
+
const relativePath = browserScreenshotRelativePath(route);
|
|
3628
|
+
try {
|
|
3629
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 15e3 });
|
|
3630
|
+
const absoluteScreenshotPath = join5(projectRoot, relativePath);
|
|
3631
|
+
await page.screenshot({ path: absoluteScreenshotPath, fullPage: true });
|
|
3632
|
+
screenshots.push(relativePath);
|
|
3633
|
+
visualRoutes.push({
|
|
3634
|
+
route,
|
|
3635
|
+
url,
|
|
3636
|
+
screenshot: relativePath,
|
|
3637
|
+
screenshotHash: hashFile2(absoluteScreenshotPath),
|
|
3638
|
+
status: "captured"
|
|
3639
|
+
});
|
|
3640
|
+
} catch (error) {
|
|
3641
|
+
const message = error.message;
|
|
3642
|
+
browserFindings.push(`${route}: ${message}`);
|
|
3643
|
+
visualRoutes.push({
|
|
3644
|
+
route,
|
|
3645
|
+
url,
|
|
3646
|
+
screenshot: null,
|
|
3647
|
+
screenshotHash: null,
|
|
3648
|
+
status: "failed",
|
|
3649
|
+
error: message
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
} catch (error) {
|
|
3654
|
+
browserFindings.push(error.message);
|
|
3655
|
+
} finally {
|
|
3656
|
+
if (browser) await browser.close();
|
|
3657
|
+
}
|
|
3658
|
+
const visualManifest = {
|
|
3659
|
+
version: 1,
|
|
3660
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3661
|
+
localOnly: true,
|
|
3662
|
+
baseUrl: options.browserBaseUrl,
|
|
3663
|
+
routes: visualRoutes
|
|
3664
|
+
};
|
|
3665
|
+
const visualManifestPath = join5(projectRoot, ".decantr", "evidence", "visual-manifest.json");
|
|
3666
|
+
mkdirSync4(dirname3(visualManifestPath), { recursive: true });
|
|
3667
|
+
writeFileSync4(visualManifestPath, JSON.stringify(visualManifest, null, 2) + "\n", "utf-8");
|
|
3668
|
+
if (browserFindings.length > 0) {
|
|
3669
|
+
const finding = createHealthFinding({
|
|
3670
|
+
source: "browser",
|
|
3671
|
+
category: "Browser Verification",
|
|
3672
|
+
severity: options.requireBrowser ? "error" : "warn",
|
|
3673
|
+
message: "Browser verification could not render every declared route.",
|
|
3674
|
+
evidence: browserFindings.slice(0, 5),
|
|
3675
|
+
rule: "browser-route-verification-failed",
|
|
3676
|
+
suggestedFix: "Start the app at the provided base URL, fix route render errors, and rerun `decantr health --browser --evidence`.",
|
|
3677
|
+
baseId: "route-verification-failed"
|
|
3678
|
+
});
|
|
3679
|
+
return {
|
|
3680
|
+
finding,
|
|
3681
|
+
evidence: {
|
|
3682
|
+
enabled: true,
|
|
3683
|
+
status: "failed",
|
|
3684
|
+
baseUrl: options.browserBaseUrl,
|
|
3685
|
+
screenshots,
|
|
3686
|
+
findings: browserFindings
|
|
3687
|
+
}
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
return {
|
|
3691
|
+
finding: null,
|
|
3692
|
+
evidence: {
|
|
3693
|
+
enabled: true,
|
|
3694
|
+
status: "passed",
|
|
3695
|
+
baseUrl: options.browserBaseUrl,
|
|
3696
|
+
screenshots,
|
|
3697
|
+
findings: []
|
|
3698
|
+
}
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
function flattenDesignTokenKeys(value, prefix = "") {
|
|
3702
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3703
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return keys;
|
|
3704
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
3705
|
+
const key = prefix ? `${prefix}.${rawKey}` : rawKey;
|
|
3706
|
+
if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) && ("$value" in rawValue || "value" in rawValue)) {
|
|
3707
|
+
keys.add(key);
|
|
3708
|
+
keys.add(rawKey);
|
|
3709
|
+
} else if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
|
|
3710
|
+
for (const nested of flattenDesignTokenKeys(rawValue, key)) keys.add(nested);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
return keys;
|
|
3714
|
+
}
|
|
3715
|
+
function parseDecantrCssTokenNames(projectRoot) {
|
|
3716
|
+
const tokensPath = join5(projectRoot, "src", "styles", "tokens.css");
|
|
3717
|
+
if (!existsSync5(tokensPath)) return [];
|
|
3718
|
+
const css = readFileSync5(tokensPath, "utf-8");
|
|
3719
|
+
const names = /* @__PURE__ */ new Set();
|
|
3720
|
+
for (const match of css.matchAll(/(--d-[\w-]+)\s*:/g)) {
|
|
3721
|
+
names.add(match[1]);
|
|
3722
|
+
}
|
|
3723
|
+
return [...names].sort();
|
|
3724
|
+
}
|
|
3725
|
+
function collectDesignTokenEvidence(projectRoot, designTokensPath) {
|
|
3726
|
+
const resolved = resolveOptionalPath(projectRoot, designTokensPath);
|
|
3727
|
+
if (!resolved) return void 0;
|
|
3728
|
+
const sourceLabel = isAbsolute3(designTokensPath ?? "") ? "<design-tokens>" : designTokensPath ?? "<design-tokens>";
|
|
3729
|
+
if (!existsSync5(resolved)) {
|
|
3730
|
+
return {
|
|
3731
|
+
source: sourceLabel,
|
|
3732
|
+
status: "error",
|
|
3733
|
+
compared: 0,
|
|
3734
|
+
matched: 0,
|
|
3735
|
+
missing: ["design-token-source-missing"]
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
const decantrTokens = parseDecantrCssTokenNames(projectRoot);
|
|
3739
|
+
const parsed = JSON.parse(readFileSync5(resolved, "utf-8"));
|
|
3740
|
+
const designKeys = flattenDesignTokenKeys(parsed);
|
|
3741
|
+
const missing = decantrTokens.filter((token) => {
|
|
3742
|
+
const bare = token.replace(/^--/, "");
|
|
3743
|
+
return !designKeys.has(token) && !designKeys.has(bare) && !designKeys.has(bare.replace(/^d-/, ""));
|
|
3744
|
+
});
|
|
3745
|
+
return {
|
|
3746
|
+
source: sourceLabel,
|
|
3747
|
+
status: missing.length === 0 ? "passed" : "warning",
|
|
3748
|
+
compared: decantrTokens.length,
|
|
3749
|
+
matched: decantrTokens.length - missing.length,
|
|
3750
|
+
missing
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
function collectDesignTokenFinding(projectRoot, designTokensPath) {
|
|
3754
|
+
const evidence = collectDesignTokenEvidence(projectRoot, designTokensPath);
|
|
3755
|
+
if (!evidence) return null;
|
|
3756
|
+
if (evidence.status === "passed") {
|
|
3757
|
+
return createHealthFinding({
|
|
3758
|
+
source: "design-token",
|
|
3759
|
+
category: "Design Tokens",
|
|
3760
|
+
severity: "info",
|
|
3761
|
+
message: "Imported design-token source covers Decantr token names.",
|
|
3762
|
+
evidence: [`matched=${evidence.matched}/${evidence.compared}`],
|
|
3763
|
+
rule: "design-token-coverage",
|
|
3764
|
+
baseId: "coverage-passed"
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3767
|
+
return createHealthFinding({
|
|
3768
|
+
source: "design-token",
|
|
3769
|
+
category: "Design Tokens",
|
|
3770
|
+
severity: evidence.status === "error" ? "error" : "warn",
|
|
3771
|
+
message: "Imported design-token source does not cover all Decantr token names.",
|
|
3772
|
+
evidence: [
|
|
3773
|
+
`matched=${evidence.matched}/${evidence.compared}`,
|
|
3774
|
+
evidence.missing.slice(0, 12).join(", ") || "No Decantr CSS tokens found."
|
|
3775
|
+
],
|
|
3776
|
+
rule: "design-token-coverage",
|
|
3777
|
+
suggestedFix: "Update the Figma/Tokens Studio export or Decantr token mapping so shared UI policy can be verified.",
|
|
3778
|
+
baseId: "coverage-missing"
|
|
3779
|
+
});
|
|
3780
|
+
}
|
|
3781
|
+
function baselinePath(projectRoot) {
|
|
3782
|
+
return join5(projectRoot, ".decantr", "health-baseline.json");
|
|
3783
|
+
}
|
|
3784
|
+
function baselineDiffPath(projectRoot) {
|
|
3785
|
+
return join5(projectRoot, ".decantr", "health-baseline-diff.json");
|
|
3786
|
+
}
|
|
3787
|
+
function screenshotHashes(projectRoot) {
|
|
3788
|
+
const manifest = readJsonFile4(
|
|
3789
|
+
join5(projectRoot, ".decantr", "evidence", "visual-manifest.json")
|
|
3790
|
+
);
|
|
3791
|
+
if (manifest?.routes) {
|
|
3792
|
+
return manifest.routes.filter((route) => typeof route.screenshot === "string").map((route) => ({
|
|
3793
|
+
path: route.screenshot,
|
|
3794
|
+
hash: route.screenshotHash ?? hashFile2(join5(projectRoot, route.screenshot))
|
|
3795
|
+
}));
|
|
3796
|
+
}
|
|
3797
|
+
return [];
|
|
3798
|
+
}
|
|
3799
|
+
function changedFilesSinceBaseline(projectRoot) {
|
|
3800
|
+
const changed = /* @__PURE__ */ new Set();
|
|
3801
|
+
try {
|
|
3802
|
+
for (const args of [
|
|
3803
|
+
["diff", "--name-only"],
|
|
3804
|
+
["diff", "--name-only", "--cached"]
|
|
3805
|
+
]) {
|
|
3806
|
+
const output = execFileSync2("git", args, {
|
|
3807
|
+
cwd: projectRoot,
|
|
3808
|
+
encoding: "utf-8",
|
|
3809
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
3810
|
+
});
|
|
3811
|
+
for (const entry of output.split(/\r?\n/)) {
|
|
3812
|
+
const file = entry.trim();
|
|
3813
|
+
if (file) changed.add(file);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
} catch {
|
|
3817
|
+
}
|
|
3818
|
+
return [...changed].sort();
|
|
3819
|
+
}
|
|
3820
|
+
function routeImpactsFromChangedFiles(report, changedFiles2) {
|
|
3821
|
+
const analysis = readJsonFile4(
|
|
3822
|
+
join5(report.projectRoot, ".decantr", "analysis.json")
|
|
3823
|
+
);
|
|
3824
|
+
const routeEntries = analysis?.routes?.routes ?? [];
|
|
3825
|
+
const impacted = /* @__PURE__ */ new Set();
|
|
3826
|
+
for (const file of changedFiles2) {
|
|
3827
|
+
for (const route of routeEntries) {
|
|
3828
|
+
if (route.file && (file === route.file || file.endsWith(route.file))) {
|
|
3829
|
+
if (route.path) impacted.add(route.path);
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
return [...impacted].sort();
|
|
3834
|
+
}
|
|
3835
|
+
function createHealthBaseline(projectRoot, report) {
|
|
3836
|
+
return {
|
|
3837
|
+
version: 1,
|
|
3838
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3839
|
+
status: report.status,
|
|
3840
|
+
score: report.score,
|
|
3841
|
+
findings: report.findings.map((finding) => ({
|
|
3842
|
+
id: finding.id,
|
|
3843
|
+
severity: finding.severity,
|
|
3844
|
+
source: finding.source,
|
|
3845
|
+
message: finding.message
|
|
3846
|
+
})),
|
|
3847
|
+
routes: report.routes.declared,
|
|
3848
|
+
packs: report.packs,
|
|
3849
|
+
screenshots: screenshotHashes(projectRoot),
|
|
3850
|
+
changedFilesCommand: "git diff --name-only + --cached"
|
|
3851
|
+
};
|
|
3852
|
+
}
|
|
3853
|
+
function saveHealthBaseline(projectRoot, report) {
|
|
3854
|
+
const path = baselinePath(projectRoot);
|
|
3855
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
3856
|
+
writeFileSync4(
|
|
3857
|
+
path,
|
|
3858
|
+
JSON.stringify(createHealthBaseline(projectRoot, report), null, 2) + "\n",
|
|
3859
|
+
"utf-8"
|
|
3860
|
+
);
|
|
3861
|
+
return path;
|
|
3862
|
+
}
|
|
3863
|
+
function compareHealthBaseline(projectRoot, report) {
|
|
3864
|
+
const path = baselinePath(projectRoot);
|
|
3865
|
+
const baseline = readJsonFile4(path);
|
|
3866
|
+
const currentFindingIds = new Set(report.findings.map((finding) => finding.id));
|
|
3867
|
+
const baselineFindingIds = new Set(baseline?.findings.map((finding) => finding.id) ?? []);
|
|
3868
|
+
const changedFiles2 = changedFilesSinceBaseline(projectRoot);
|
|
3869
|
+
const currentScreenshots = new Map(
|
|
3870
|
+
screenshotHashes(projectRoot).map((entry) => [entry.path, entry.hash])
|
|
3871
|
+
);
|
|
3872
|
+
const changedScreenshots = baseline?.screenshots.filter(
|
|
3873
|
+
(entry) => currentScreenshots.has(entry.path) && currentScreenshots.get(entry.path) !== entry.hash
|
|
3874
|
+
).map((entry) => entry.path) ?? [];
|
|
3875
|
+
const contractDrift = [
|
|
3876
|
+
baseline && baseline.routes.join("\n") !== report.routes.declared.join("\n") ? "Declared route set changed since baseline." : null,
|
|
3877
|
+
baseline && baseline.packs.generatedAt !== report.packs.generatedAt ? "Execution-pack generation timestamp changed since baseline." : null
|
|
3878
|
+
].filter((entry) => Boolean(entry));
|
|
3879
|
+
return {
|
|
3880
|
+
baselinePath: path,
|
|
3881
|
+
savedAt: baseline?.generatedAt ?? null,
|
|
3882
|
+
statusChanged: baseline ? baseline.status !== report.status : false,
|
|
3883
|
+
scoreDelta: baseline ? report.score - baseline.score : null,
|
|
3884
|
+
addedFindings: [...currentFindingIds].filter((id) => !baselineFindingIds.has(id)).sort(),
|
|
3885
|
+
resolvedFindings: [...baselineFindingIds].filter((id) => !currentFindingIds.has(id)).sort(),
|
|
3886
|
+
changedFiles: changedFiles2,
|
|
3887
|
+
changedRoutes: routeImpactsFromChangedFiles(report, changedFiles2),
|
|
3888
|
+
changedScreenshots,
|
|
3889
|
+
contractDrift
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
function saveHealthBaselineComparison(projectRoot, comparison) {
|
|
3893
|
+
const path = baselineDiffPath(projectRoot);
|
|
3894
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
3895
|
+
writeFileSync4(path, JSON.stringify(comparison, null, 2) + "\n", "utf-8");
|
|
3896
|
+
return path;
|
|
3897
|
+
}
|
|
3898
|
+
function visualBaselineFindings(comparison) {
|
|
3899
|
+
if (!comparison || comparison.changedScreenshots.length === 0) return [];
|
|
3900
|
+
return [
|
|
3901
|
+
createHealthFinding({
|
|
3902
|
+
source: "browser",
|
|
3903
|
+
category: "Visual Baseline",
|
|
3904
|
+
severity: "warn",
|
|
3905
|
+
message: "Screenshot hashes changed since the saved Decantr health baseline.",
|
|
3906
|
+
evidence: [
|
|
3907
|
+
`Baseline: ${comparison.baselinePath}`,
|
|
3908
|
+
`Changed screenshots: ${comparison.changedScreenshots.join(", ")}`
|
|
3909
|
+
],
|
|
3910
|
+
target: comparison.changedScreenshots[0],
|
|
3911
|
+
rule: "visual-baseline-screenshot-drift",
|
|
3912
|
+
suggestedFix: "Review the changed screenshots. If the visual change is intentional, save a new baseline; otherwise repair the UI drift and rerun browser evidence.",
|
|
3913
|
+
repair: {
|
|
3914
|
+
id: "review-visual-baseline-drift",
|
|
3915
|
+
payload: {
|
|
3916
|
+
baseline_path: comparison.baselinePath,
|
|
3917
|
+
changed_screenshots: comparison.changedScreenshots
|
|
3918
|
+
}
|
|
3919
|
+
},
|
|
3920
|
+
baseId: "visual-baseline-screenshot-drift"
|
|
3921
|
+
})
|
|
3922
|
+
];
|
|
3923
|
+
}
|
|
3924
|
+
function withAdditionalHealthFindings(projectRoot, report, additions) {
|
|
3925
|
+
if (additions.length === 0) return report;
|
|
3926
|
+
const seen = new Set(
|
|
3927
|
+
report.findings.map((finding) => `${finding.rule ?? finding.id}|${finding.message}`)
|
|
3928
|
+
);
|
|
3929
|
+
const nextFindings = [
|
|
3930
|
+
...report.findings,
|
|
3931
|
+
...additions.filter((finding) => !isDuplicateFinding(seen, finding)).map((finding) => ({
|
|
3932
|
+
...finding,
|
|
3933
|
+
repairPlan: buildProjectHealthRepairPlan(projectRoot, finding)
|
|
3934
|
+
}))
|
|
3935
|
+
];
|
|
3936
|
+
const counts = countFindings(nextFindings);
|
|
3937
|
+
return {
|
|
3938
|
+
...report,
|
|
3939
|
+
status: statusFromCounts(counts),
|
|
3940
|
+
score: scoreFromCounts(counts),
|
|
3941
|
+
summary: {
|
|
3942
|
+
...report.summary,
|
|
3943
|
+
...counts,
|
|
3944
|
+
findingCount: nextFindings.length
|
|
3945
|
+
},
|
|
3946
|
+
routes: {
|
|
3947
|
+
...report.routes,
|
|
3948
|
+
issues: routeIssuesFromFindings(nextFindings)
|
|
3949
|
+
},
|
|
3950
|
+
findings: nextFindings
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
function formatBaselineComparisonText(comparison) {
|
|
3954
|
+
const lines = [
|
|
3955
|
+
"",
|
|
3956
|
+
`${BOLD}Continuity:${RESET2}`,
|
|
3957
|
+
` Baseline: ${comparison.savedAt ?? "missing"} (${comparison.baselinePath})`,
|
|
3958
|
+
` Score delta: ${comparison.scoreDelta == null ? "n/a" : comparison.scoreDelta >= 0 ? `+${comparison.scoreDelta}` : String(comparison.scoreDelta)}`,
|
|
3959
|
+
` Added findings: ${comparison.addedFindings.length}`,
|
|
3960
|
+
` Resolved findings: ${comparison.resolvedFindings.length}`,
|
|
3961
|
+
` Changed files: ${comparison.changedFiles.length}`,
|
|
3962
|
+
` Route impact: ${comparison.changedRoutes.length > 0 ? comparison.changedRoutes.join(", ") : "none detected"}`,
|
|
3963
|
+
` Screenshot drift: ${comparison.changedScreenshots.length}`,
|
|
3964
|
+
` Contract drift: ${comparison.contractDrift.length > 0 ? comparison.contractDrift.join(" ") : "none detected"}`
|
|
3965
|
+
];
|
|
3966
|
+
return `${lines.join("\n")}
|
|
3967
|
+
`;
|
|
3968
|
+
}
|
|
3969
|
+
async function browserEvidenceFromOptions(projectRoot, options, declaredRoutes) {
|
|
3970
|
+
if (!options.browser) return void 0;
|
|
3971
|
+
const result = await collectBrowserVerification(projectRoot, options, declaredRoutes);
|
|
3972
|
+
return result?.evidence;
|
|
3973
|
+
}
|
|
3974
|
+
async function createProjectHealthReport(projectRoot = process.cwd(), options = {}) {
|
|
3975
|
+
const metadata = readProjectMetadata(projectRoot);
|
|
3976
|
+
const audit = await auditProject(projectRoot);
|
|
3977
|
+
const findings = [];
|
|
3978
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3979
|
+
for (const finding of audit.findings) {
|
|
3980
|
+
const healthFinding = createHealthFinding({
|
|
3981
|
+
source: sourceFromAuditFinding(finding),
|
|
3982
|
+
category: finding.category,
|
|
3983
|
+
severity: finding.severity,
|
|
3984
|
+
message: finding.message,
|
|
3985
|
+
evidence: finding.evidence,
|
|
3986
|
+
target: finding.target,
|
|
3987
|
+
file: finding.file,
|
|
3988
|
+
rule: finding.rule,
|
|
3989
|
+
suggestedFix: finding.suggestedFix,
|
|
3990
|
+
code: finding.code,
|
|
3991
|
+
repair: finding.repair,
|
|
3992
|
+
baseId: finding.id
|
|
3993
|
+
});
|
|
3994
|
+
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
3995
|
+
}
|
|
3996
|
+
for (const contractAssertion of createContractAssertions(projectRoot, audit)) {
|
|
3997
|
+
if (!contractAssertionApplies(contractAssertion, metadata)) continue;
|
|
3998
|
+
if (contractAssertion.status !== "failed") continue;
|
|
3999
|
+
const healthFinding = createHealthFinding({
|
|
4000
|
+
source: "assertion",
|
|
4001
|
+
category: `Contract ${contractAssertion.category}`,
|
|
4002
|
+
severity: contractAssertion.severity,
|
|
4003
|
+
message: contractAssertion.message,
|
|
4004
|
+
evidence: contractAssertion.evidence,
|
|
4005
|
+
target: contractAssertion.target,
|
|
4006
|
+
rule: contractAssertion.rule,
|
|
4007
|
+
suggestedFix: contractAssertion.suggestedFix,
|
|
4008
|
+
baseId: contractAssertion.id
|
|
4009
|
+
});
|
|
4010
|
+
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
4011
|
+
}
|
|
4012
|
+
const designTokenFinding = collectDesignTokenFinding(projectRoot, options.designTokensPath);
|
|
4013
|
+
if (designTokenFinding && !isDuplicateFinding(seen, designTokenFinding)) {
|
|
4014
|
+
findings.push(designTokenFinding);
|
|
4015
|
+
}
|
|
4016
|
+
try {
|
|
4017
|
+
const check = collectCheckIssues(projectRoot, { brownfield: metadata.autoBrownfield });
|
|
4018
|
+
for (const issue of check.issues) {
|
|
4019
|
+
const source = sourceFromCheckIssue(issue);
|
|
4020
|
+
const healthFinding = createHealthFinding({
|
|
4021
|
+
source,
|
|
4022
|
+
category: source === "brownfield" ? "Brownfield Drift" : "Contract Check",
|
|
4023
|
+
severity: severityFromCheckIssue(issue),
|
|
4024
|
+
message: issue.message,
|
|
4025
|
+
evidence: [`Rule: ${issue.rule}`],
|
|
4026
|
+
rule: issue.rule,
|
|
4027
|
+
suggestedFix: issue.suggestion,
|
|
4028
|
+
baseId: issue.rule
|
|
4029
|
+
});
|
|
4030
|
+
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
4031
|
+
}
|
|
4032
|
+
} catch (e) {
|
|
4033
|
+
const healthFinding = createHealthFinding({
|
|
4034
|
+
source: "check",
|
|
4035
|
+
category: "Contract Check",
|
|
4036
|
+
severity: "error",
|
|
4037
|
+
message: `Decantr check could not complete: ${e.message}`,
|
|
4038
|
+
evidence: ["The health command could not run the local check pass."],
|
|
4039
|
+
rule: "check-failed",
|
|
4040
|
+
suggestedFix: "Repair the local Decantr contract and rerun `decantr health`.",
|
|
4041
|
+
baseId: "check-failed"
|
|
4042
|
+
});
|
|
4043
|
+
if (!isDuplicateFinding(seen, healthFinding)) findings.push(healthFinding);
|
|
4044
|
+
}
|
|
4045
|
+
if (!audit.valid && findings.every((finding) => finding.severity !== "error")) {
|
|
4046
|
+
findings.push(
|
|
4047
|
+
createHealthFinding({
|
|
4048
|
+
source: "audit",
|
|
4049
|
+
category: "Project Contract",
|
|
4050
|
+
severity: "error",
|
|
4051
|
+
message: "Project audit is not valid.",
|
|
4052
|
+
evidence: ["The verifier returned valid=false."],
|
|
4053
|
+
rule: "project-audit-invalid",
|
|
4054
|
+
suggestedFix: "Resolve blocking audit findings and rerun `decantr health`."
|
|
4055
|
+
})
|
|
4056
|
+
);
|
|
4057
|
+
}
|
|
4058
|
+
const declaredRoutes = collectDeclaredRoutes(audit.essence);
|
|
4059
|
+
const manifest = audit.packManifest;
|
|
4060
|
+
for (const consistencyFinding of collectContractPackConsistencyFindings(
|
|
4061
|
+
projectRoot,
|
|
4062
|
+
audit.essence,
|
|
4063
|
+
manifest
|
|
4064
|
+
)) {
|
|
4065
|
+
if (!isDuplicateFinding(seen, consistencyFinding)) findings.push(consistencyFinding);
|
|
4066
|
+
}
|
|
4067
|
+
const graph = inspectProjectHealthGraph(projectRoot);
|
|
4068
|
+
for (const graphFinding of collectGraphArtifactFindings(projectRoot, graph)) {
|
|
4069
|
+
if (!isDuplicateFinding(seen, graphFinding)) findings.push(graphFinding);
|
|
4070
|
+
}
|
|
4071
|
+
const browserVerification = await collectBrowserVerification(
|
|
4072
|
+
projectRoot,
|
|
4073
|
+
options,
|
|
4074
|
+
declaredRoutes
|
|
4075
|
+
);
|
|
4076
|
+
if (browserVerification?.finding && !isDuplicateFinding(seen, browserVerification.finding)) {
|
|
4077
|
+
findings.push(browserVerification.finding);
|
|
4078
|
+
}
|
|
4079
|
+
const anchoredFindings = anchorProjectHealthFindings(projectRoot, findings);
|
|
4080
|
+
const scopedFindings = scopeHealthFindingsToProject(projectRoot, anchoredFindings);
|
|
4081
|
+
const repairPlanFindings = scopedFindings.map((finding) => ({
|
|
4082
|
+
...finding,
|
|
4083
|
+
repairPlan: buildProjectHealthRepairPlan(projectRoot, finding)
|
|
4084
|
+
}));
|
|
4085
|
+
const finalCounts = countFindings(repairPlanFindings);
|
|
4086
|
+
const commandContext = commandContextForProject(projectRoot);
|
|
4087
|
+
return {
|
|
4088
|
+
$schema: PROJECT_HEALTH_SCHEMA_URL,
|
|
4089
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4090
|
+
projectRoot,
|
|
4091
|
+
status: statusFromCounts(finalCounts),
|
|
4092
|
+
score: scoreFromCounts(finalCounts),
|
|
4093
|
+
summary: {
|
|
4094
|
+
...finalCounts,
|
|
4095
|
+
findingCount: repairPlanFindings.length,
|
|
4096
|
+
workflowMode: metadata.workflowMode,
|
|
4097
|
+
adoptionMode: metadata.adoptionMode,
|
|
4098
|
+
essenceVersion: audit.summary.essenceVersion,
|
|
4099
|
+
pageCount: audit.summary.pageCount,
|
|
4100
|
+
runtimeAuditChecked: audit.summary.runtimeAuditChecked,
|
|
4101
|
+
runtimePassed: audit.summary.runtimePassed,
|
|
4102
|
+
packManifestPresent: audit.summary.packManifestPresent,
|
|
4103
|
+
reviewPackPresent: audit.summary.reviewPackPresent
|
|
4104
|
+
},
|
|
4105
|
+
routes: {
|
|
4106
|
+
declared: declaredRoutes,
|
|
4107
|
+
runtimeChecked: audit.runtimeAudit.routeHintsChecked,
|
|
4108
|
+
runtimeMatched: audit.runtimeAudit.routeHintsMatched,
|
|
4109
|
+
runtimeCoverageOk: audit.summary.runtimeAuditChecked ? audit.runtimeAudit.routeHintsCoverageOk : null,
|
|
4110
|
+
issues: routeIssuesFromFindings(repairPlanFindings)
|
|
4111
|
+
},
|
|
4112
|
+
packs: {
|
|
4113
|
+
manifestPresent: Boolean(manifest),
|
|
4114
|
+
reviewPackPresent: Boolean(manifest?.review ?? audit.reviewPack),
|
|
4115
|
+
scaffoldPackPresent: Boolean(manifest?.scaffold),
|
|
4116
|
+
sectionPackCount: manifest?.sections.length ?? 0,
|
|
4117
|
+
pagePackCount: manifest?.pages.length ?? 0,
|
|
4118
|
+
mutationPackCount: manifest?.mutations?.length ?? 0,
|
|
4119
|
+
generatedAt: typeof manifest?.generatedAt === "string" ? manifest.generatedAt : null
|
|
4120
|
+
},
|
|
4121
|
+
graph,
|
|
4122
|
+
ci: {
|
|
4123
|
+
recommendedCommand: commandContext.ciCommand,
|
|
4124
|
+
failOn: "error"
|
|
4125
|
+
},
|
|
4126
|
+
findings: repairPlanFindings
|
|
4127
|
+
};
|
|
4128
|
+
}
|
|
4129
|
+
function colorForStatus(status) {
|
|
4130
|
+
if (status === "healthy") return GREEN2;
|
|
4131
|
+
if (status === "warning") return YELLOW;
|
|
4132
|
+
return RED2;
|
|
4133
|
+
}
|
|
4134
|
+
function formatProjectHealthText(report) {
|
|
4135
|
+
const color = colorForStatus(report.status);
|
|
4136
|
+
const commandContext = commandContextForProject(report.projectRoot);
|
|
4137
|
+
const lines = [
|
|
4138
|
+
`${BOLD}Decantr Project Health${RESET2}`,
|
|
4139
|
+
"",
|
|
4140
|
+
`${color}${report.status.toUpperCase()}${RESET2} score ${report.score}/100`,
|
|
4141
|
+
`${DIM2}${report.projectRoot}${RESET2}`,
|
|
4142
|
+
"",
|
|
4143
|
+
`${BOLD}Summary:${RESET2}`,
|
|
4144
|
+
` Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
|
|
4145
|
+
` Essence: ${report.summary.essenceVersion ?? "missing"} | pages ${report.summary.pageCount}`,
|
|
4146
|
+
` Workflow: ${report.summary.workflowMode ?? "unknown"} | adoption ${report.summary.adoptionMode ?? "unknown"}`,
|
|
4147
|
+
` Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
|
|
4148
|
+
` Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"} | review ${report.packs.reviewPackPresent ? "present" : "missing"} | pages ${report.packs.pagePackCount}`,
|
|
4149
|
+
` Graph: ${report.graph.current === null ? "not attached" : report.graph.current ? "current" : "stale"} | capsule ${report.graph.capsulePresent ? "present" : "missing"} | sources ${report.graph.sourceArtifactCount}`,
|
|
4150
|
+
"",
|
|
4151
|
+
`${BOLD}Findings:${RESET2}`
|
|
4152
|
+
];
|
|
4153
|
+
if (report.findings.length === 0) {
|
|
4154
|
+
lines.push(` ${GREEN2}No findings. Project is healthy.${RESET2}`);
|
|
4155
|
+
} else {
|
|
4156
|
+
for (const finding of report.findings) {
|
|
4157
|
+
const findingColor = finding.severity === "error" ? RED2 : finding.severity === "warn" ? YELLOW : CYAN;
|
|
4158
|
+
lines.push(
|
|
4159
|
+
` ${findingColor}[${finding.severity.toUpperCase()}]${RESET2} ${finding.id}: ${finding.message}`
|
|
4160
|
+
);
|
|
4161
|
+
if (finding.evidence.length > 0) {
|
|
4162
|
+
lines.push(` ${DIM2}${finding.evidence[0]}${RESET2}`);
|
|
4163
|
+
}
|
|
4164
|
+
if (finding.code) {
|
|
4165
|
+
lines.push(` ${DIM2}Code: ${finding.code}${RESET2}`);
|
|
4166
|
+
}
|
|
4167
|
+
if (finding.graph) {
|
|
4168
|
+
lines.push(
|
|
4169
|
+
` ${DIM2}Graph: ${finding.graph.node_type} ${finding.graph.node_id} (${finding.graph.confidence})${RESET2}`
|
|
4170
|
+
);
|
|
4171
|
+
}
|
|
4172
|
+
if (finding.repair) {
|
|
4173
|
+
lines.push(` ${DIM2}Repair: ${finding.repair.id}${RESET2}`);
|
|
4174
|
+
}
|
|
4175
|
+
if (finding.suggestedFix) {
|
|
4176
|
+
lines.push(` ${DIM2}Fix: ${finding.suggestedFix}${RESET2}`);
|
|
4177
|
+
}
|
|
4178
|
+
lines.push(` ${DIM2}Prompt: ${commandContext.promptCommand(finding.id)}${RESET2}`);
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
lines.push("");
|
|
4182
|
+
lines.push(`${BOLD}CI:${RESET2} ${report.ci.recommendedCommand}`);
|
|
4183
|
+
return `${lines.join("\n")}
|
|
4184
|
+
`;
|
|
4185
|
+
}
|
|
4186
|
+
function formatProjectHealthMarkdown(report) {
|
|
4187
|
+
const commandContext = commandContextForProject(report.projectRoot);
|
|
4188
|
+
const lines = [
|
|
4189
|
+
"# Decantr Project Health",
|
|
4190
|
+
"",
|
|
4191
|
+
`- Status: **${report.status}**`,
|
|
4192
|
+
`- Score: **${report.score}/100**`,
|
|
4193
|
+
`- Project: \`${report.projectRoot}\``,
|
|
4194
|
+
`- Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
|
|
4195
|
+
`- Runtime audit: ${report.summary.runtimeAuditChecked ? report.summary.runtimePassed ? "passed" : "failed" : "not checked"}`,
|
|
4196
|
+
`- Packs: manifest ${report.packs.manifestPresent ? "present" : "missing"}, review ${report.packs.reviewPackPresent ? "present" : "missing"}`,
|
|
4197
|
+
`- Graph: ${report.graph.current === null ? "not attached" : report.graph.current ? "current" : "stale"}, capsule ${report.graph.capsulePresent ? "present" : "missing"}, sources ${report.graph.sourceArtifactCount}`,
|
|
4198
|
+
"",
|
|
4199
|
+
"## Findings",
|
|
4200
|
+
""
|
|
4201
|
+
];
|
|
4202
|
+
if (report.findings.length === 0) {
|
|
4203
|
+
lines.push("No findings. Project is healthy.");
|
|
4204
|
+
} else {
|
|
4205
|
+
for (const finding of report.findings) {
|
|
4206
|
+
lines.push(`### ${finding.id}`);
|
|
4207
|
+
lines.push("");
|
|
4208
|
+
lines.push(`- Severity: ${finding.severity}`);
|
|
4209
|
+
lines.push(`- Source: ${finding.source}`);
|
|
4210
|
+
lines.push(`- Category: ${finding.category}`);
|
|
4211
|
+
if (finding.code) lines.push(`- Code: ${finding.code}`);
|
|
4212
|
+
lines.push(`- Message: ${finding.message}`);
|
|
4213
|
+
if (finding.suggestedFix) lines.push(`- Fix: ${finding.suggestedFix}`);
|
|
4214
|
+
if (finding.graph) {
|
|
4215
|
+
lines.push(
|
|
4216
|
+
`- Graph: \`${finding.graph.node_type} ${finding.graph.node_id}\` (${finding.graph.confidence})`
|
|
4217
|
+
);
|
|
4218
|
+
}
|
|
4219
|
+
if (finding.repair) lines.push(`- Repair: \`${finding.repair.id}\``);
|
|
4220
|
+
if (finding.evidence.length > 0) {
|
|
4221
|
+
lines.push("- Evidence:");
|
|
4222
|
+
for (const evidence of finding.evidence) lines.push(` - ${evidence}`);
|
|
4223
|
+
}
|
|
4224
|
+
lines.push(`- Prompt: \`${commandContext.promptCommand(finding.id)}\``);
|
|
4225
|
+
lines.push("");
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
lines.push("## CI");
|
|
4229
|
+
lines.push("");
|
|
4230
|
+
lines.push(`\`${report.ci.recommendedCommand}\``);
|
|
4231
|
+
return `${lines.join("\n")}
|
|
4232
|
+
`;
|
|
4233
|
+
}
|
|
4234
|
+
function formatProjectHealthJson(report) {
|
|
4235
|
+
return `${JSON.stringify(report, null, 2)}
|
|
4236
|
+
`;
|
|
4237
|
+
}
|
|
4238
|
+
function diagnosticCatalogPayload() {
|
|
4239
|
+
return {
|
|
4240
|
+
diagnostics: KNOWN_VERIFICATION_DIAGNOSTICS.map((entry) => ({
|
|
4241
|
+
code: entry.code,
|
|
4242
|
+
family: entry.family,
|
|
4243
|
+
rule: entry.rule,
|
|
4244
|
+
repairId: entry.repairId
|
|
4245
|
+
})).sort((a, b) => a.code.localeCompare(b.code) || a.rule.localeCompare(b.rule))
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
function formatDiagnosticCatalogJson() {
|
|
4249
|
+
return `${JSON.stringify(diagnosticCatalogPayload(), null, 2)}
|
|
4250
|
+
`;
|
|
4251
|
+
}
|
|
4252
|
+
function formatDiagnosticCatalogMarkdown() {
|
|
4253
|
+
const lines = [
|
|
4254
|
+
"# Decantr Diagnostic Codes",
|
|
4255
|
+
"",
|
|
4256
|
+
"| Code | Family | Rule | Repair ID |",
|
|
4257
|
+
"| --- | --- | --- | --- |",
|
|
4258
|
+
...diagnosticCatalogPayload().diagnostics.map(
|
|
4259
|
+
(entry) => `| \`${entry.code}\` | \`${entry.family}\` | \`${entry.rule}\` | \`${entry.repairId}\` |`
|
|
4260
|
+
),
|
|
4261
|
+
""
|
|
4262
|
+
];
|
|
4263
|
+
return lines.join("\n");
|
|
4264
|
+
}
|
|
4265
|
+
function formatDiagnosticCatalogText() {
|
|
4266
|
+
return [
|
|
4267
|
+
"Decantr Diagnostic Codes",
|
|
4268
|
+
...diagnosticCatalogPayload().diagnostics.map(
|
|
4269
|
+
(entry) => `${entry.code.padEnd(12)} ${entry.rule.padEnd(42)} ${entry.repairId}`
|
|
4270
|
+
),
|
|
4271
|
+
""
|
|
4272
|
+
].join("\n");
|
|
4273
|
+
}
|
|
4274
|
+
async function createProjectEvidenceBundle(projectRoot, report, options = {}) {
|
|
4275
|
+
const audit = await auditProject(projectRoot);
|
|
4276
|
+
const assertions = createContractAssertions(projectRoot, audit);
|
|
4277
|
+
return createEvidenceBundle({
|
|
4278
|
+
projectRoot,
|
|
4279
|
+
report,
|
|
4280
|
+
audit,
|
|
4281
|
+
assertions,
|
|
4282
|
+
workspaceConfigPath: existsSync5(join5(projectRoot, ".decantr", "workspace.json")) ? join5(projectRoot, ".decantr", "workspace.json") : null,
|
|
4283
|
+
designTokensPath: resolveOptionalPath(projectRoot, options.designTokensPath) ?? null,
|
|
4284
|
+
browser: await browserEvidenceFromOptions(projectRoot, options, report.routes.declared),
|
|
4285
|
+
designTokens: collectDesignTokenEvidence(projectRoot, options.designTokensPath)
|
|
4286
|
+
});
|
|
4287
|
+
}
|
|
4288
|
+
function resolveFormat(options) {
|
|
4289
|
+
if (options.json) return "json";
|
|
4290
|
+
if (options.markdown) return "markdown";
|
|
4291
|
+
return options.format ?? "text";
|
|
4292
|
+
}
|
|
4293
|
+
function shouldFailHealth(report, failOn) {
|
|
4294
|
+
if (failOn === "none") return false;
|
|
4295
|
+
if (failOn === "warn") return report.summary.errorCount > 0 || report.summary.warnCount > 0;
|
|
4296
|
+
return report.summary.errorCount > 0;
|
|
4297
|
+
}
|
|
4298
|
+
async function cmdHealth(projectRoot = process.cwd(), options = {}) {
|
|
4299
|
+
if (options.initCi) {
|
|
4300
|
+
try {
|
|
4301
|
+
const result = writeProjectHealthCiWorkflow(projectRoot, options.initCi);
|
|
4302
|
+
const action = result.created ? "Created" : "Updated";
|
|
4303
|
+
console.log(`${GREEN2}${action} Decantr Project Health workflow:${RESET2} ${result.path}`);
|
|
4304
|
+
console.log(`${DIM2}CLI package: ${result.cliPackage}${RESET2}`);
|
|
4305
|
+
if (result.projectPath) {
|
|
4306
|
+
console.log(`${DIM2}Project: ${result.projectPath}${RESET2}`);
|
|
4307
|
+
}
|
|
4308
|
+
if (result.workspace) {
|
|
4309
|
+
console.log(`${DIM2}Workspace mode enabled.${RESET2}`);
|
|
4310
|
+
}
|
|
4311
|
+
console.log(
|
|
4312
|
+
`${DIM2}CI gate: decantr ${result.workspace ? "workspace health" : "health"} --ci --fail-on ${result.failOn}${RESET2}`
|
|
4313
|
+
);
|
|
4314
|
+
} catch (e) {
|
|
4315
|
+
console.error(`${RED2}${e.message}${RESET2}`);
|
|
4316
|
+
process.exitCode = 1;
|
|
4317
|
+
}
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
if (options.diagnostics) {
|
|
4321
|
+
const format2 = resolveFormat(options);
|
|
4322
|
+
const payload2 = format2 === "json" ? formatDiagnosticCatalogJson() : format2 === "markdown" ? formatDiagnosticCatalogMarkdown() : formatDiagnosticCatalogText();
|
|
4323
|
+
if (options.output) {
|
|
4324
|
+
const outputPath = isAbsolute3(options.output) ? options.output : join5(projectRoot, options.output);
|
|
4325
|
+
mkdirSync4(dirname3(outputPath), { recursive: true });
|
|
4326
|
+
writeFileSync4(outputPath, payload2, "utf-8");
|
|
4327
|
+
if (!options.ci) {
|
|
4328
|
+
console.log(`${GREEN2}Wrote Decantr diagnostic catalog:${RESET2} ${options.output}`);
|
|
4329
|
+
}
|
|
4330
|
+
} else {
|
|
4331
|
+
process.stdout.write(payload2);
|
|
4332
|
+
}
|
|
4333
|
+
return;
|
|
4334
|
+
}
|
|
4335
|
+
const startedAt = Date.now();
|
|
4336
|
+
const reportOptions = {
|
|
4337
|
+
browser: options.browser,
|
|
4338
|
+
requireBrowser: options.requireBrowser,
|
|
4339
|
+
browserBaseUrl: options.browserBaseUrl ?? process.env.DECANTR_BROWSER_BASE_URL,
|
|
4340
|
+
designTokensPath: options.designTokensPath
|
|
4341
|
+
};
|
|
4342
|
+
let report = await createProjectHealthReport(projectRoot, reportOptions);
|
|
4343
|
+
const baselineComparison = options.sinceBaseline ? compareHealthBaseline(projectRoot, report) : null;
|
|
4344
|
+
report = withAdditionalHealthFindings(
|
|
4345
|
+
projectRoot,
|
|
4346
|
+
report,
|
|
4347
|
+
visualBaselineFindings(baselineComparison)
|
|
4348
|
+
);
|
|
4349
|
+
const baselineComparisonPath = baselineComparison ? saveHealthBaselineComparison(projectRoot, baselineComparison) : null;
|
|
4350
|
+
if (options.promptId) {
|
|
4351
|
+
const finding = report.findings.find((entry) => entry.id === options.promptId);
|
|
4352
|
+
await sendProjectHealthReportTelemetry({
|
|
4353
|
+
ci: options.ci ?? false,
|
|
4354
|
+
durationMs: Date.now() - startedAt,
|
|
4355
|
+
projectRoot,
|
|
4356
|
+
report
|
|
4357
|
+
});
|
|
4358
|
+
await sendProjectHealthPromptTelemetry({
|
|
4359
|
+
ci: options.ci ?? false,
|
|
4360
|
+
finding,
|
|
4361
|
+
projectRoot,
|
|
4362
|
+
report
|
|
4363
|
+
});
|
|
4364
|
+
if (!finding) {
|
|
4365
|
+
console.error(`${RED2}No health finding found for id: ${options.promptId}${RESET2}`);
|
|
4366
|
+
process.exitCode = 1;
|
|
4367
|
+
return;
|
|
4368
|
+
}
|
|
4369
|
+
console.log(finding.remediation.prompt);
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
const format = resolveFormat(options);
|
|
4373
|
+
const failOn = options.failOn ?? "error";
|
|
4374
|
+
const evidenceBundle = options.evidence ? await createProjectEvidenceBundle(projectRoot, report, reportOptions) : null;
|
|
4375
|
+
const basePayload = options.evidence ? `${JSON.stringify(evidenceBundle, null, 2)}
|
|
4376
|
+
` : format === "json" ? formatProjectHealthJson(report) : format === "markdown" ? formatProjectHealthMarkdown(report) : formatProjectHealthText(report);
|
|
4377
|
+
const payload = baselineComparison && !options.evidence && format === "text" ? `${basePayload}${formatBaselineComparisonText(baselineComparison)}` : basePayload;
|
|
4378
|
+
if (options.output) {
|
|
4379
|
+
const outputPath = isAbsolute3(options.output) ? options.output : join5(projectRoot, options.output);
|
|
4380
|
+
mkdirSync4(dirname3(outputPath), { recursive: true });
|
|
4381
|
+
writeFileSync4(outputPath, payload, "utf-8");
|
|
4382
|
+
if (!options.ci) {
|
|
4383
|
+
console.log(
|
|
4384
|
+
`${GREEN2}Wrote Decantr ${options.evidence ? "evidence bundle" : "health report"}:${RESET2} ${options.output}`
|
|
4385
|
+
);
|
|
4386
|
+
if (options.browser && evidenceBundle?.browser?.status === "unavailable") {
|
|
4387
|
+
const reason = evidenceBundle.browser.findings[0] ?? "Playwright is not available to Decantr in this project.";
|
|
4388
|
+
console.log(`${YELLOW}Browser evidence unavailable:${RESET2} ${reason}`);
|
|
4389
|
+
console.log(
|
|
4390
|
+
`${DIM2}Static evidence was still written. Install Playwright or omit --browser/--base-url evidence if screenshots are not needed.${RESET2}`
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
} else {
|
|
4395
|
+
process.stdout.write(payload);
|
|
4396
|
+
}
|
|
4397
|
+
await sendProjectHealthReportTelemetry({
|
|
4398
|
+
ci: options.ci ?? false,
|
|
4399
|
+
durationMs: Date.now() - startedAt,
|
|
4400
|
+
failOn,
|
|
4401
|
+
format,
|
|
4402
|
+
outputWritten: Boolean(options.output),
|
|
4403
|
+
projectRoot,
|
|
4404
|
+
report
|
|
4405
|
+
});
|
|
4406
|
+
if (options.saveBaseline) {
|
|
4407
|
+
const path = saveHealthBaseline(projectRoot, report);
|
|
4408
|
+
if (!options.ci && !options.output && format !== "json" && !options.evidence) {
|
|
4409
|
+
console.log(`${GREEN2}Saved Decantr health baseline:${RESET2} ${path}`);
|
|
4410
|
+
} else if (!options.ci && options.output) {
|
|
4411
|
+
console.log(`${GREEN2}Saved Decantr health baseline:${RESET2} ${path}`);
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
if (baselineComparisonPath && !options.ci && options.output) {
|
|
4415
|
+
console.log(
|
|
4416
|
+
`${GREEN2}Wrote Decantr health baseline comparison:${RESET2} ${baselineComparisonPath}`
|
|
4417
|
+
);
|
|
4418
|
+
}
|
|
4419
|
+
if (options.ci && shouldFailHealth(report, failOn)) {
|
|
4420
|
+
if (failOn !== "none") {
|
|
4421
|
+
await sendProjectHealthCiFailedTelemetry({
|
|
4422
|
+
ci: true,
|
|
4423
|
+
durationMs: Date.now() - startedAt,
|
|
4424
|
+
failOn,
|
|
4425
|
+
format,
|
|
4426
|
+
outputWritten: Boolean(options.output),
|
|
4427
|
+
projectRoot,
|
|
4428
|
+
report
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
process.exitCode = 1;
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
function parseHealthArgs(args) {
|
|
4435
|
+
const options = {};
|
|
4436
|
+
if (args[1] === "init-ci") {
|
|
4437
|
+
options.initCi = {};
|
|
4438
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
4439
|
+
const arg = args[index];
|
|
4440
|
+
if (arg === "--force") {
|
|
4441
|
+
options.initCi.force = true;
|
|
4442
|
+
} else if (arg === "--fail-on" && args[index + 1]) {
|
|
4443
|
+
options.initCi.failOn = args[++index];
|
|
4444
|
+
} else if (arg.startsWith("--fail-on=")) {
|
|
4445
|
+
options.initCi.failOn = arg.split("=")[1];
|
|
4446
|
+
} else if ((arg === "--cli-version" || arg === "--cli") && args[index + 1]) {
|
|
4447
|
+
options.initCi.cliVersion = args[++index];
|
|
4448
|
+
} else if (arg.startsWith("--cli-version=")) {
|
|
4449
|
+
options.initCi.cliVersion = arg.split("=")[1];
|
|
4450
|
+
} else if (arg.startsWith("--cli=")) {
|
|
4451
|
+
options.initCi.cliVersion = arg.split("=")[1];
|
|
4452
|
+
} else if (arg === "--workflow-path" && args[index + 1]) {
|
|
4453
|
+
options.initCi.workflowPath = args[++index];
|
|
4454
|
+
} else if (arg.startsWith("--workflow-path=")) {
|
|
4455
|
+
options.initCi.workflowPath = arg.split("=")[1];
|
|
4456
|
+
} else if (arg === "--report-path" && args[index + 1]) {
|
|
4457
|
+
options.initCi.reportPath = args[++index];
|
|
4458
|
+
} else if (arg.startsWith("--report-path=")) {
|
|
4459
|
+
options.initCi.reportPath = arg.split("=")[1];
|
|
4460
|
+
} else if (arg === "--json-path" && args[index + 1]) {
|
|
4461
|
+
options.initCi.jsonPath = args[++index];
|
|
4462
|
+
} else if (arg.startsWith("--json-path=")) {
|
|
4463
|
+
options.initCi.jsonPath = arg.split("=")[1];
|
|
4464
|
+
} else if (arg === "--project" && args[index + 1]) {
|
|
4465
|
+
options.initCi.projectPath = args[++index];
|
|
4466
|
+
} else if (arg.startsWith("--project=")) {
|
|
4467
|
+
options.initCi.projectPath = arg.split("=")[1];
|
|
4468
|
+
} else if (arg === "--workspace") {
|
|
4469
|
+
options.initCi.workspace = true;
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
normalizeHealthFailOn(options.initCi.failOn);
|
|
4473
|
+
if (!options.initCi.workspace) validateProjectPath(options.initCi.projectPath);
|
|
4474
|
+
return options;
|
|
4475
|
+
}
|
|
4476
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
4477
|
+
const arg = args[index];
|
|
4478
|
+
if (arg === "--json") {
|
|
4479
|
+
options.json = true;
|
|
4480
|
+
} else if (arg === "--markdown") {
|
|
4481
|
+
options.markdown = true;
|
|
4482
|
+
} else if (arg === "--evidence") {
|
|
4483
|
+
options.evidence = true;
|
|
4484
|
+
options.json = true;
|
|
4485
|
+
} else if (arg === "--browser") {
|
|
4486
|
+
options.browser = true;
|
|
4487
|
+
} else if (arg === "--require-browser") {
|
|
4488
|
+
options.browser = true;
|
|
4489
|
+
options.requireBrowser = true;
|
|
4490
|
+
} else if (arg === "--save-baseline") {
|
|
4491
|
+
options.saveBaseline = true;
|
|
4492
|
+
} else if (arg === "--since-baseline") {
|
|
4493
|
+
options.sinceBaseline = true;
|
|
4494
|
+
} else if (arg === "--diagnostics") {
|
|
4495
|
+
options.diagnostics = true;
|
|
4496
|
+
} else if (arg === "--base-url" && args[index + 1]) {
|
|
4497
|
+
options.browserBaseUrl = args[++index];
|
|
4498
|
+
} else if (arg.startsWith("--base-url=")) {
|
|
4499
|
+
options.browserBaseUrl = arg.split("=")[1];
|
|
4500
|
+
} else if (arg === "--design-tokens" && args[index + 1]) {
|
|
4501
|
+
options.designTokensPath = args[++index];
|
|
4502
|
+
} else if (arg.startsWith("--design-tokens=")) {
|
|
4503
|
+
options.designTokensPath = arg.split("=")[1];
|
|
4504
|
+
} else if (arg === "--ci") {
|
|
4505
|
+
options.ci = true;
|
|
4506
|
+
} else if (arg === "--format" && args[index + 1]) {
|
|
4507
|
+
options.format = args[++index];
|
|
4508
|
+
} else if (arg.startsWith("--format=")) {
|
|
4509
|
+
options.format = arg.split("=")[1];
|
|
4510
|
+
} else if (arg === "--output" && args[index + 1]) {
|
|
4511
|
+
options.output = args[++index];
|
|
4512
|
+
} else if (arg.startsWith("--output=")) {
|
|
4513
|
+
options.output = arg.split("=")[1];
|
|
4514
|
+
} else if (arg === "--fail-on" && args[index + 1]) {
|
|
4515
|
+
options.failOn = args[++index];
|
|
4516
|
+
} else if (arg.startsWith("--fail-on=")) {
|
|
4517
|
+
options.failOn = arg.split("=")[1];
|
|
4518
|
+
} else if (arg === "--prompt" && args[index + 1]) {
|
|
4519
|
+
options.promptId = args[++index];
|
|
4520
|
+
} else if (arg.startsWith("--prompt=")) {
|
|
4521
|
+
options.promptId = arg.split("=")[1];
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
if (options.format && !["text", "json", "markdown"].includes(options.format)) {
|
|
4525
|
+
throw new Error("Invalid --format value. Use text, json, or markdown.");
|
|
4526
|
+
}
|
|
4527
|
+
if (options.failOn && !["error", "warn", "none"].includes(options.failOn)) {
|
|
4528
|
+
throw new Error("Invalid --fail-on value. Use error, warn, or none.");
|
|
4529
|
+
}
|
|
4530
|
+
return options;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
export {
|
|
4534
|
+
localPatternsProposalPath,
|
|
4535
|
+
localPatternsPath,
|
|
4536
|
+
localRulesProposalPath,
|
|
4537
|
+
localRulesPath,
|
|
4538
|
+
readLocalPatternPack,
|
|
4539
|
+
createBrownfieldCodifyProposal,
|
|
4540
|
+
writeBrownfieldCodifyProposal,
|
|
4541
|
+
writeHostedPatternMappingProposal,
|
|
4542
|
+
acceptBrownfieldLocalLaw,
|
|
4543
|
+
validateLocalLaw,
|
|
4544
|
+
createLocalLawTaskSummary,
|
|
4545
|
+
changedFiles,
|
|
4546
|
+
routeImpacts,
|
|
4547
|
+
styleBridgeProposalPath,
|
|
4548
|
+
styleBridgePath,
|
|
4549
|
+
createStyleBridgeProposal,
|
|
4550
|
+
writeStyleBridgeProposal,
|
|
4551
|
+
acceptStyleBridge,
|
|
4552
|
+
createStyleBridgeTaskSummary,
|
|
4553
|
+
styleBridgeMatches,
|
|
4554
|
+
listWorkspaceAppCandidates,
|
|
4555
|
+
resolveWorkspaceInfo,
|
|
4556
|
+
buildGraphArtifacts,
|
|
4557
|
+
cmdGraphHelp,
|
|
4558
|
+
cmdGraph,
|
|
4559
|
+
renderProjectHealthCiWorkflow,
|
|
4560
|
+
writeProjectHealthCiWorkflow,
|
|
4561
|
+
collectDesignTokenEvidence,
|
|
4562
|
+
createProjectHealthReport,
|
|
4563
|
+
formatProjectHealthText,
|
|
4564
|
+
formatProjectHealthMarkdown,
|
|
4565
|
+
formatProjectHealthJson,
|
|
4566
|
+
formatDiagnosticCatalogJson,
|
|
4567
|
+
formatDiagnosticCatalogMarkdown,
|
|
4568
|
+
formatDiagnosticCatalogText,
|
|
4569
|
+
createProjectEvidenceBundle,
|
|
4570
|
+
shouldFailHealth,
|
|
4571
|
+
cmdHealth,
|
|
4572
|
+
parseHealthArgs
|
|
4573
|
+
};
|