@decantr/cli 1.7.29 → 1.9.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.
@@ -0,0 +1,1057 @@
1
+ // src/commands/content-health.ts
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { createRequire } from "module";
4
+ import { basename, join } from "path";
5
+ import Ajv2020 from "ajv/dist/2020.js";
6
+ var BOLD = "\x1B[1m";
7
+ var DIM = "\x1B[2m";
8
+ var RESET = "\x1B[0m";
9
+ var RED = "\x1B[31m";
10
+ var GREEN = "\x1B[32m";
11
+ var CYAN = "\x1B[36m";
12
+ var YELLOW = "\x1B[33m";
13
+ var CONTENT_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/content-health-report.v1.json";
14
+ var DEFAULT_IGNORED_LOCAL_PREFIXES = ["recipefork"];
15
+ var CONTENT_DIRECTORIES = [
16
+ {
17
+ type: "pattern",
18
+ directory: "patterns",
19
+ schemaSpecifier: "@decantr/registry/schema/pattern.v2.json",
20
+ expectedSchema: "https://decantr.ai/schemas/pattern.v2.json"
21
+ },
22
+ {
23
+ type: "theme",
24
+ directory: "themes",
25
+ schemaSpecifier: "@decantr/registry/schema/theme.v1.json",
26
+ expectedSchema: "https://decantr.ai/schemas/theme.v1.json"
27
+ },
28
+ {
29
+ type: "blueprint",
30
+ directory: "blueprints",
31
+ schemaSpecifier: "@decantr/registry/schema/blueprint.v1.json",
32
+ expectedSchema: "https://decantr.ai/schemas/blueprint.v1.json"
33
+ },
34
+ {
35
+ type: "archetype",
36
+ directory: "archetypes",
37
+ schemaSpecifier: "@decantr/registry/schema/archetype.v2.json",
38
+ expectedSchema: "https://decantr.ai/schemas/archetype.v2.json"
39
+ },
40
+ {
41
+ type: "shell",
42
+ directory: "shells",
43
+ schemaSpecifier: "@decantr/registry/schema/shell.v1.json",
44
+ expectedSchema: "https://decantr.ai/schemas/shell.v1.json"
45
+ }
46
+ ];
47
+ var TYPE_DIRECTORY = Object.fromEntries(
48
+ CONTENT_DIRECTORIES.map((entry) => [entry.type, entry.directory])
49
+ );
50
+ var require2 = createRequire(import.meta.url);
51
+ function loadJsonSchema(specifier) {
52
+ return JSON.parse(readFileSync(require2.resolve(specifier), "utf-8"));
53
+ }
54
+ function createValidators() {
55
+ const ajv = new Ajv2020({
56
+ allErrors: true,
57
+ strict: false,
58
+ allowUnionTypes: true
59
+ });
60
+ ajv.addSchema(loadJsonSchema("@decantr/registry/schema/common.v1.json"));
61
+ return Object.fromEntries(
62
+ CONTENT_DIRECTORIES.map((entry) => [
63
+ entry.type,
64
+ ajv.compile(loadJsonSchema(entry.schemaSpecifier))
65
+ ])
66
+ );
67
+ }
68
+ function isRecord(value) {
69
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
70
+ }
71
+ function isNonEmptyString(value) {
72
+ return typeof value === "string" && value.trim().length > 0;
73
+ }
74
+ function toStringArray(value) {
75
+ return Array.isArray(value) ? value.filter((entry) => isNonEmptyString(entry)) : [];
76
+ }
77
+ function formatSchemaError(error) {
78
+ const instancePath = error.instancePath || "/";
79
+ return `${instancePath} ${error.message}`.trim();
80
+ }
81
+ function slugify(value) {
82
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
83
+ }
84
+ function isIgnoredLocalContentFile(fileName) {
85
+ return DEFAULT_IGNORED_LOCAL_PREFIXES.some((prefix) => fileName.startsWith(prefix));
86
+ }
87
+ function commandsForFinding(source) {
88
+ switch (source) {
89
+ case "schema":
90
+ return ["npm run validate", "decantr content-health"];
91
+ case "reference":
92
+ return ["decantr content-health", "npm run validate"];
93
+ case "quality":
94
+ case "coverage":
95
+ return ["decantr content-health --markdown --output content-health.md"];
96
+ default:
97
+ return ["decantr content-health"];
98
+ }
99
+ }
100
+ function buildRemediationPrompt(input) {
101
+ return [
102
+ "You are fixing one Decantr Content Health finding in a registry content repository.",
103
+ "",
104
+ "Read the referenced JSON content file and the matching Decantr schema before editing. Preserve the item id, published intent, and registry content type unless the finding explicitly says the id or type is wrong.",
105
+ "",
106
+ `Finding: ${input.id}`,
107
+ `Source: ${input.source}`,
108
+ `Severity: ${input.severity}`,
109
+ `Category: ${input.category}`,
110
+ input.type ? `Content type: ${input.type}` : null,
111
+ input.itemId ? `Item id: ${input.itemId}` : null,
112
+ input.file ? `File: ${input.file}` : null,
113
+ `Message: ${input.message}`,
114
+ input.evidence.length > 0 ? `Evidence:
115
+ ${input.evidence.map((entry) => `- ${entry}`).join("\n")}` : null,
116
+ input.suggestedFix ? `Suggested fix: ${input.suggestedFix}` : null,
117
+ "",
118
+ "Make the smallest coherent content change that resolves this finding. Do not add new source-code runtime dependencies for content-only fixes.",
119
+ "",
120
+ `After the fix, run:
121
+ ${input.commands.map((command) => `- ${command}`).join("\n")}`
122
+ ].filter((line) => Boolean(line)).join("\n");
123
+ }
124
+ function createContentFinding(input) {
125
+ const idBase = input.baseId || input.rule || `${input.category}-${input.file ?? ""}-${input.message}`;
126
+ const id = `${input.source}-${slugify(idBase)}`;
127
+ const commands = commandsForFinding(input.source);
128
+ const remediation = {
129
+ summary: input.suggestedFix || `Resolve ${input.category.toLowerCase()} finding.`,
130
+ commands,
131
+ prompt: buildRemediationPrompt({
132
+ id,
133
+ source: input.source,
134
+ category: input.category,
135
+ severity: input.severity,
136
+ message: input.message,
137
+ evidence: input.evidence ?? [],
138
+ file: input.file,
139
+ type: input.type,
140
+ itemId: input.itemId,
141
+ suggestedFix: input.suggestedFix,
142
+ commands
143
+ })
144
+ };
145
+ return {
146
+ id,
147
+ source: input.source,
148
+ category: input.category,
149
+ severity: input.severity,
150
+ message: input.message,
151
+ evidence: input.evidence ?? [],
152
+ file: input.file,
153
+ type: input.type,
154
+ itemId: input.itemId,
155
+ rule: input.rule,
156
+ suggestedFix: input.suggestedFix,
157
+ remediation
158
+ };
159
+ }
160
+ function countFindings(findings) {
161
+ return {
162
+ errorCount: findings.filter((finding) => finding.severity === "error").length,
163
+ warnCount: findings.filter((finding) => finding.severity === "warn").length,
164
+ infoCount: findings.filter((finding) => finding.severity === "info").length
165
+ };
166
+ }
167
+ function statusFromCounts(counts) {
168
+ if (counts.errorCount > 0) return "error";
169
+ if (counts.warnCount > 0) return "warning";
170
+ return "healthy";
171
+ }
172
+ function scoreFromCounts(counts) {
173
+ const warningPenalty = Math.min(counts.warnCount * 2, 75);
174
+ const infoPenalty = Math.min(counts.infoCount * 0.5, 10);
175
+ return Math.round(Math.max(0, Math.min(100, 100 - counts.errorCount * 15 - warningPenalty - infoPenalty)));
176
+ }
177
+ function percentage(count, total) {
178
+ if (total === 0) return 1;
179
+ return Math.round(count / total * 1e3) / 1e3;
180
+ }
181
+ function patternIdsFromReference(value) {
182
+ if (isNonEmptyString(value)) return [value];
183
+ if (!isRecord(value)) return [];
184
+ if (isNonEmptyString(value.pattern)) return [value.pattern];
185
+ if (Array.isArray(value.cols)) {
186
+ return value.cols.flatMap((item) => patternIdsFromReference(item));
187
+ }
188
+ return [];
189
+ }
190
+ function collectDependencyReferences(data) {
191
+ if (!isRecord(data.dependencies)) return [];
192
+ const map = {
193
+ patterns: "pattern",
194
+ themes: "theme",
195
+ blueprints: "blueprint",
196
+ archetypes: "archetype",
197
+ shells: "shell"
198
+ };
199
+ const refs = [];
200
+ for (const [group, values] of Object.entries(data.dependencies)) {
201
+ const referencedType = map[group];
202
+ if (!referencedType || !isRecord(values)) continue;
203
+ for (const id of Object.keys(values)) {
204
+ refs.push({
205
+ referencedType,
206
+ id,
207
+ rule: `dependency-${referencedType}`,
208
+ suggestedFix: `Add ${referencedType} "${id}" or remove the stale dependency reference.`
209
+ });
210
+ }
211
+ }
212
+ return refs;
213
+ }
214
+ function collectBlueprintReferences(item) {
215
+ const refs = [];
216
+ const data = item.data;
217
+ if (isRecord(data.theme) && isNonEmptyString(data.theme.id)) {
218
+ refs.push({
219
+ referencedType: "theme",
220
+ id: data.theme.id,
221
+ rule: "blueprint-theme",
222
+ severity: "error",
223
+ suggestedFix: `Add theme "${data.theme.id}" or choose an existing theme id.`
224
+ });
225
+ }
226
+ if (isNonEmptyString(data.archetype)) {
227
+ refs.push({
228
+ referencedType: "archetype",
229
+ id: data.archetype,
230
+ rule: "blueprint-archetype",
231
+ severity: "error",
232
+ suggestedFix: `Add archetype "${data.archetype}" or update the blueprint archetype field.`
233
+ });
234
+ }
235
+ for (const entry of Array.isArray(data.compose) ? data.compose : []) {
236
+ const archetype = isNonEmptyString(entry) ? entry : isRecord(entry) && isNonEmptyString(entry.archetype) ? entry.archetype : null;
237
+ if (archetype) {
238
+ refs.push({
239
+ referencedType: "archetype",
240
+ id: archetype,
241
+ rule: "blueprint-compose-archetype",
242
+ severity: "error",
243
+ suggestedFix: `Add archetype "${archetype}" or remove it from blueprint compose.`
244
+ });
245
+ }
246
+ }
247
+ for (const theme of toStringArray(data.suggested_themes)) {
248
+ refs.push({
249
+ referencedType: "theme",
250
+ id: theme,
251
+ rule: "blueprint-suggested-theme",
252
+ severity: "warn",
253
+ suggestedFix: `Add suggested theme "${theme}" or remove it from suggested_themes.`
254
+ });
255
+ }
256
+ if (isRecord(data.routes)) {
257
+ for (const [route, routeConfig] of Object.entries(data.routes)) {
258
+ if (!isRecord(routeConfig)) continue;
259
+ if (isNonEmptyString(routeConfig.archetype)) {
260
+ refs.push({
261
+ referencedType: "archetype",
262
+ id: routeConfig.archetype,
263
+ rule: "blueprint-route-archetype",
264
+ severity: "error",
265
+ suggestedFix: `Add archetype "${routeConfig.archetype}" or update route "${route}".`
266
+ });
267
+ }
268
+ if (isNonEmptyString(routeConfig.shell)) {
269
+ refs.push({
270
+ referencedType: "shell",
271
+ id: routeConfig.shell,
272
+ rule: "blueprint-route-shell",
273
+ severity: "error",
274
+ suggestedFix: `Add shell "${routeConfig.shell}" or update route "${route}".`
275
+ });
276
+ }
277
+ }
278
+ }
279
+ for (const dependency of collectDependencyReferences(data)) {
280
+ refs.push({
281
+ ...dependency,
282
+ severity: "error"
283
+ });
284
+ }
285
+ return refs;
286
+ }
287
+ function collectArchetypeReferences(item) {
288
+ const refs = [];
289
+ const data = item.data;
290
+ if (Array.isArray(data.pages)) {
291
+ for (const page of data.pages) {
292
+ if (!isRecord(page)) continue;
293
+ if (isNonEmptyString(page.shell) && page.shell !== "inherit") {
294
+ refs.push({
295
+ referencedType: "shell",
296
+ id: page.shell,
297
+ rule: "archetype-page-shell",
298
+ severity: "error",
299
+ suggestedFix: `Add shell "${page.shell}" or update the page shell reference.`
300
+ });
301
+ }
302
+ for (const patternId of Array.isArray(page.default_layout) ? page.default_layout.flatMap((entry) => patternIdsFromReference(entry)) : []) {
303
+ refs.push({
304
+ referencedType: "pattern",
305
+ id: patternId,
306
+ rule: "archetype-page-layout-pattern",
307
+ severity: "warn",
308
+ suggestedFix: `Add pattern "${patternId}" for stronger generation guidance or update the page default_layout reference to an existing pattern.`
309
+ });
310
+ }
311
+ for (const patternId of Array.isArray(page.patterns) ? page.patterns.flatMap((entry) => patternIdsFromReference(entry)) : []) {
312
+ refs.push({
313
+ referencedType: "pattern",
314
+ id: patternId,
315
+ rule: "archetype-page-pattern",
316
+ severity: "warn",
317
+ suggestedFix: `Add pattern "${patternId}" for stronger generation guidance or update the page patterns reference to an existing pattern.`
318
+ });
319
+ }
320
+ }
321
+ }
322
+ if (isRecord(data.suggested_theme)) {
323
+ for (const theme of toStringArray(data.suggested_theme.ids)) {
324
+ refs.push({
325
+ referencedType: "theme",
326
+ id: theme,
327
+ rule: "archetype-suggested-theme",
328
+ severity: "warn",
329
+ suggestedFix: `Add suggested theme "${theme}" or remove it from suggested_theme.ids.`
330
+ });
331
+ }
332
+ }
333
+ for (const dependency of collectDependencyReferences(data)) {
334
+ refs.push({
335
+ ...dependency,
336
+ severity: "error"
337
+ });
338
+ }
339
+ return refs;
340
+ }
341
+ function collectItemReferences(item) {
342
+ if (item.type === "blueprint") return collectBlueprintReferences(item);
343
+ if (item.type === "archetype") return collectArchetypeReferences(item);
344
+ return collectDependencyReferences(item.data).map((dependency) => ({
345
+ ...dependency,
346
+ severity: "error"
347
+ }));
348
+ }
349
+ function addQualityFindings(item, findings) {
350
+ const { data, file, type, id } = item;
351
+ if (type === "pattern") {
352
+ if (!data.visual_brief && !data.layout_hints) {
353
+ findings.push(
354
+ createContentFinding({
355
+ source: "quality",
356
+ category: "Pattern Guidance",
357
+ severity: "warn",
358
+ message: "Pattern is missing both visual_brief and layout_hints.",
359
+ evidence: ["AI scaffolds rely on visual guidance to avoid generic layouts."],
360
+ file,
361
+ type,
362
+ itemId: id,
363
+ rule: "pattern-guidance-missing",
364
+ suggestedFix: "Add a visual_brief or layout_hints that describes the intended composition.",
365
+ baseId: `${file}-pattern-guidance-missing`
366
+ })
367
+ );
368
+ }
369
+ if (!Array.isArray(data.components) || data.components.length === 0) {
370
+ findings.push(
371
+ createContentFinding({
372
+ source: "quality",
373
+ category: "Pattern Components",
374
+ severity: "warn",
375
+ message: "Pattern has no component inventory.",
376
+ evidence: ["components[] is empty or missing."],
377
+ file,
378
+ type,
379
+ itemId: id,
380
+ rule: "pattern-components-missing",
381
+ suggestedFix: "Add a compact components array naming the expected UI building blocks.",
382
+ baseId: `${file}-pattern-components-missing`
383
+ })
384
+ );
385
+ }
386
+ if (isRecord(data.presets)) {
387
+ for (const [presetName, preset] of Object.entries(data.presets)) {
388
+ if (isRecord(preset) && isNonEmptyString(preset.description) && preset.description.length < 30) {
389
+ findings.push(
390
+ createContentFinding({
391
+ source: "quality",
392
+ category: "Preset Guidance",
393
+ severity: "warn",
394
+ message: `Preset "${presetName}" description is too short to guide generation.`,
395
+ evidence: [`Description length: ${preset.description.length}`],
396
+ file,
397
+ type,
398
+ itemId: id,
399
+ rule: "preset-description-short",
400
+ suggestedFix: "Expand the preset description with layout, density, and usage intent.",
401
+ baseId: `${file}-${presetName}-preset-description-short`
402
+ })
403
+ );
404
+ }
405
+ }
406
+ }
407
+ }
408
+ if (type === "theme") {
409
+ const paletteSize = isRecord(data.palette) ? Object.keys(data.palette).length : 0;
410
+ if (paletteSize > 0 && paletteSize < 5) {
411
+ findings.push(
412
+ createContentFinding({
413
+ source: "quality",
414
+ category: "Theme Palette",
415
+ severity: "warn",
416
+ message: "Theme palette has fewer than five semantic colors.",
417
+ evidence: [`Palette entries: ${paletteSize}`],
418
+ file,
419
+ type,
420
+ itemId: id,
421
+ rule: "theme-palette-shallow",
422
+ suggestedFix: "Add semantic palette entries for background, surface, text, muted text, and accent roles.",
423
+ baseId: `${file}-theme-palette-shallow`
424
+ })
425
+ );
426
+ }
427
+ if (!isRecord(data.decorators) || Object.keys(data.decorators).length === 0) {
428
+ findings.push(
429
+ createContentFinding({
430
+ source: "quality",
431
+ category: "Theme Decorators",
432
+ severity: "warn",
433
+ message: "Theme has no decorator definitions.",
434
+ evidence: ["decorators is missing or empty."],
435
+ file,
436
+ type,
437
+ itemId: id,
438
+ rule: "theme-decorators-missing",
439
+ suggestedFix: "Add theme-specific decorator classes that can be rendered into DECANTR.md and section packs.",
440
+ baseId: `${file}-theme-decorators-missing`
441
+ })
442
+ );
443
+ } else {
444
+ for (const [decorator, description] of Object.entries(data.decorators)) {
445
+ if (typeof description === "string" && description.length < 20) {
446
+ findings.push(
447
+ createContentFinding({
448
+ source: "quality",
449
+ category: "Theme Decorators",
450
+ severity: "warn",
451
+ message: `Decorator "${decorator}" description is too short.`,
452
+ evidence: [`Description length: ${description.length}`],
453
+ file,
454
+ type,
455
+ itemId: id,
456
+ rule: "theme-decorator-description-short",
457
+ suggestedFix: "Describe where and how this decorator should be applied.",
458
+ baseId: `${file}-${decorator}-theme-decorator-description-short`
459
+ })
460
+ );
461
+ }
462
+ }
463
+ }
464
+ }
465
+ if (type === "blueprint") {
466
+ const personality = data.personality;
467
+ const personalityMissing = personality === void 0 || personality === null || Array.isArray(personality) && personality.length === 0 || typeof personality === "string" && personality.trim().length === 0;
468
+ if (personalityMissing) {
469
+ findings.push(
470
+ createContentFinding({
471
+ source: "quality",
472
+ category: "Blueprint Personality",
473
+ severity: "warn",
474
+ message: "Blueprint is missing personality guidance.",
475
+ evidence: ["personality is missing or empty."],
476
+ file,
477
+ type,
478
+ itemId: id,
479
+ rule: "blueprint-personality-missing",
480
+ suggestedFix: "Add a concise but specific personality string or trait array.",
481
+ baseId: `${file}-blueprint-personality-missing`
482
+ })
483
+ );
484
+ } else if (typeof personality === "string" && personality.length < 100) {
485
+ findings.push(
486
+ createContentFinding({
487
+ source: "quality",
488
+ category: "Blueprint Personality",
489
+ severity: "warn",
490
+ message: "Blueprint personality is shorter than 100 characters.",
491
+ evidence: [`Length: ${personality.length}`],
492
+ file,
493
+ type,
494
+ itemId: id,
495
+ rule: "blueprint-personality-short",
496
+ suggestedFix: "Expand personality with visual direction, tone, density, and interaction posture.",
497
+ baseId: `${file}-blueprint-personality-short`
498
+ })
499
+ );
500
+ }
501
+ if (!isRecord(data.voice)) {
502
+ findings.push(
503
+ createContentFinding({
504
+ source: "coverage",
505
+ category: "Blueprint Voice",
506
+ severity: "info",
507
+ message: "Blueprint has no voice guidance.",
508
+ evidence: ["voice is missing."],
509
+ file,
510
+ type,
511
+ itemId: id,
512
+ rule: "blueprint-voice-missing",
513
+ suggestedFix: "Add voice.tone, cta_verbs, avoid words, and state copy guidance when this blueprint needs product copy consistency.",
514
+ baseId: `${file}-blueprint-voice-missing`
515
+ })
516
+ );
517
+ }
518
+ }
519
+ if (type === "archetype" && !isRecord(data.page_briefs)) {
520
+ findings.push(
521
+ createContentFinding({
522
+ source: "coverage",
523
+ category: "Archetype Page Briefs",
524
+ severity: "info",
525
+ message: "Archetype has no page_briefs.",
526
+ evidence: ["page_briefs is missing."],
527
+ file,
528
+ type,
529
+ itemId: id,
530
+ rule: "archetype-page-briefs-missing",
531
+ suggestedFix: "Add page_briefs when route-level visual direction should be more specific than page names.",
532
+ baseId: `${file}-archetype-page-briefs-missing`
533
+ })
534
+ );
535
+ }
536
+ }
537
+ function typeSummary(config, items, findings, invalidFiles, ignoredCount) {
538
+ const typeFindings = findings.filter((finding) => finding.type === config.type);
539
+ return {
540
+ type: config.type,
541
+ directory: config.directory,
542
+ itemCount: items.length,
543
+ validCount: items.filter((item) => !invalidFiles.has(item.file)).length,
544
+ ...countFindings(typeFindings),
545
+ ignoredCount
546
+ };
547
+ }
548
+ function missingByTypeInitial() {
549
+ return {
550
+ pattern: 0,
551
+ theme: 0,
552
+ blueprint: 0,
553
+ archetype: 0,
554
+ shell: 0
555
+ };
556
+ }
557
+ async function createContentHealthReport(contentRoot = process.cwd(), options = {}) {
558
+ const validators = createValidators();
559
+ const findings = [];
560
+ const invalidFiles = /* @__PURE__ */ new Set();
561
+ const allItems = [];
562
+ const itemsByType = /* @__PURE__ */ new Map();
563
+ const ignoredCounts = /* @__PURE__ */ new Map();
564
+ let contentDirectoryCount = 0;
565
+ for (const config of CONTENT_DIRECTORIES) {
566
+ const directoryPath = join(contentRoot, config.directory);
567
+ const typeItems = /* @__PURE__ */ new Map();
568
+ itemsByType.set(config.type, typeItems);
569
+ ignoredCounts.set(config.type, 0);
570
+ if (!existsSync(directoryPath)) {
571
+ findings.push(
572
+ createContentFinding({
573
+ source: "content",
574
+ category: "Content Directory",
575
+ severity: "warn",
576
+ message: `Missing ${config.directory}/ directory.`,
577
+ evidence: [`Expected ${config.directory}/ under the content root.`],
578
+ type: config.type,
579
+ rule: "content-directory-missing",
580
+ suggestedFix: `Create ${config.directory}/ when this repository is expected to publish ${config.type} content.`,
581
+ baseId: `${config.directory}-content-directory-missing`
582
+ })
583
+ );
584
+ continue;
585
+ }
586
+ contentDirectoryCount += 1;
587
+ const files = readdirSync(directoryPath).filter((file) => file.endsWith(".json")).sort();
588
+ for (const fileName of files) {
589
+ if (!options.includeIgnored && isIgnoredLocalContentFile(fileName)) {
590
+ ignoredCounts.set(config.type, (ignoredCounts.get(config.type) ?? 0) + 1);
591
+ continue;
592
+ }
593
+ const relativeFile = `${config.directory}/${fileName}`;
594
+ const expectedId = basename(fileName, ".json");
595
+ let data;
596
+ try {
597
+ data = JSON.parse(readFileSync(join(contentRoot, relativeFile), "utf-8"));
598
+ } catch (e) {
599
+ invalidFiles.add(relativeFile);
600
+ findings.push(
601
+ createContentFinding({
602
+ source: "schema",
603
+ category: "JSON Parse",
604
+ severity: "error",
605
+ message: `Invalid JSON: ${e.message}`,
606
+ evidence: [`File: ${relativeFile}`],
607
+ file: relativeFile,
608
+ type: config.type,
609
+ itemId: expectedId,
610
+ rule: "json-invalid",
611
+ suggestedFix: "Repair the JSON syntax.",
612
+ baseId: `${relativeFile}-json-invalid`
613
+ })
614
+ );
615
+ continue;
616
+ }
617
+ if (!isRecord(data)) {
618
+ invalidFiles.add(relativeFile);
619
+ findings.push(
620
+ createContentFinding({
621
+ source: "schema",
622
+ category: "Content Shape",
623
+ severity: "error",
624
+ message: "Content item must be a JSON object.",
625
+ evidence: [`File: ${relativeFile}`],
626
+ file: relativeFile,
627
+ type: config.type,
628
+ itemId: expectedId,
629
+ rule: "content-object-required",
630
+ suggestedFix: "Replace the file with a JSON object matching the content schema.",
631
+ baseId: `${relativeFile}-content-object-required`
632
+ })
633
+ );
634
+ continue;
635
+ }
636
+ const id = isNonEmptyString(data.id) ? data.id : isNonEmptyString(data.slug) ? data.slug : expectedId;
637
+ const item = {
638
+ type: config.type,
639
+ directory: config.directory,
640
+ file: relativeFile,
641
+ id,
642
+ data
643
+ };
644
+ allItems.push(item);
645
+ if (typeItems.has(id)) {
646
+ invalidFiles.add(relativeFile);
647
+ findings.push(
648
+ createContentFinding({
649
+ source: "schema",
650
+ category: "Duplicate Content ID",
651
+ severity: "error",
652
+ message: `${config.type} id "${id}" is declared more than once.`,
653
+ evidence: [`Duplicate file: ${relativeFile}`],
654
+ file: relativeFile,
655
+ type: config.type,
656
+ itemId: id,
657
+ rule: "content-id-duplicate",
658
+ suggestedFix: "Make ids unique within each content type.",
659
+ baseId: `${relativeFile}-content-id-duplicate`
660
+ })
661
+ );
662
+ } else {
663
+ typeItems.set(id, item);
664
+ }
665
+ if (data.$schema !== config.expectedSchema) {
666
+ invalidFiles.add(relativeFile);
667
+ findings.push(
668
+ createContentFinding({
669
+ source: "schema",
670
+ category: "Schema URL",
671
+ severity: "error",
672
+ message: `$schema must be "${config.expectedSchema}".`,
673
+ evidence: [`Found: ${typeof data.$schema === "string" ? data.$schema : "missing"}`],
674
+ file: relativeFile,
675
+ type: config.type,
676
+ itemId: id,
677
+ rule: "schema-url-mismatch",
678
+ suggestedFix: `Set $schema to ${config.expectedSchema}.`,
679
+ baseId: `${relativeFile}-schema-url-mismatch`
680
+ })
681
+ );
682
+ }
683
+ if (id !== expectedId) {
684
+ invalidFiles.add(relativeFile);
685
+ findings.push(
686
+ createContentFinding({
687
+ source: "schema",
688
+ category: "Content ID",
689
+ severity: "error",
690
+ message: `id must match filename (${expectedId}).`,
691
+ evidence: [`Found id: ${id}`],
692
+ file: relativeFile,
693
+ type: config.type,
694
+ itemId: id,
695
+ rule: "content-id-filename-mismatch",
696
+ suggestedFix: `Rename the file or set id to "${expectedId}".`,
697
+ baseId: `${relativeFile}-content-id-filename-mismatch`
698
+ })
699
+ );
700
+ }
701
+ const validate = validators[config.type];
702
+ if (!validate(data)) {
703
+ invalidFiles.add(relativeFile);
704
+ for (const schemaError of (validate.errors || []).slice(0, 6)) {
705
+ findings.push(
706
+ createContentFinding({
707
+ source: "schema",
708
+ category: "Schema Validation",
709
+ severity: "error",
710
+ message: `Schema validation failed: ${formatSchemaError(schemaError)}`,
711
+ evidence: [`File: ${relativeFile}`],
712
+ file: relativeFile,
713
+ type: config.type,
714
+ itemId: id,
715
+ rule: "schema-validation-failed",
716
+ suggestedFix: "Update the content item to match the published schema.",
717
+ baseId: `${relativeFile}-${formatSchemaError(schemaError)}`
718
+ })
719
+ );
720
+ }
721
+ }
722
+ }
723
+ }
724
+ if (contentDirectoryCount === 0 || allItems.length === 0) {
725
+ findings.push(
726
+ createContentFinding({
727
+ source: "content",
728
+ category: "Content Root",
729
+ severity: "error",
730
+ message: "No Decantr registry content was found in this directory.",
731
+ evidence: ["Expected one or more of patterns/, themes/, blueprints/, archetypes/, shells/."],
732
+ rule: "content-root-empty",
733
+ suggestedFix: "Run this command from a decantr-content style repository.",
734
+ baseId: "content-root-empty"
735
+ })
736
+ );
737
+ }
738
+ let referencesChecked = 0;
739
+ const missingByType = missingByTypeInitial();
740
+ const missingReferenceGroups = /* @__PURE__ */ new Map();
741
+ for (const item of allItems) {
742
+ for (const reference of collectItemReferences(item)) {
743
+ referencesChecked += 1;
744
+ const referenced = itemsByType.get(reference.referencedType)?.has(reference.id);
745
+ if (referenced) continue;
746
+ missingByType[reference.referencedType] += 1;
747
+ const key = `${item.file}|${reference.rule}|${reference.severity}|${reference.referencedType}`;
748
+ const group = missingReferenceGroups.get(key);
749
+ if (group) {
750
+ group.ids.push(reference.id);
751
+ } else {
752
+ missingReferenceGroups.set(key, {
753
+ item,
754
+ referencedType: reference.referencedType,
755
+ rule: reference.rule,
756
+ severity: reference.severity,
757
+ ids: [reference.id],
758
+ suggestedFix: reference.suggestedFix
759
+ });
760
+ }
761
+ }
762
+ }
763
+ for (const group of missingReferenceGroups.values()) {
764
+ const missingList = [...new Set(group.ids)].sort();
765
+ const preview = missingList.slice(0, 12);
766
+ findings.push(
767
+ createContentFinding({
768
+ source: "reference",
769
+ category: "Missing Reference",
770
+ severity: group.severity,
771
+ message: missingList.length === 1 ? `${group.item.type} "${group.item.id}" references missing ${group.referencedType} "${missingList[0]}".` : `${group.item.type} "${group.item.id}" references ${missingList.length} missing ${group.referencedType} items.`,
772
+ evidence: [
773
+ `Reference directory: ${TYPE_DIRECTORY[group.referencedType]}/`,
774
+ `Rule: ${group.rule}`,
775
+ `Missing ${group.referencedType}: ${preview.join(", ")}${missingList.length > preview.length ? `, and ${missingList.length - preview.length} more` : ""}`
776
+ ],
777
+ file: group.item.file,
778
+ type: group.item.type,
779
+ itemId: group.item.id,
780
+ rule: group.rule,
781
+ suggestedFix: missingList.length === 1 ? group.suggestedFix : `Add the missing ${group.referencedType} items or update stale ${group.rule} references.`,
782
+ baseId: `${group.item.file}-${group.rule}-${group.referencedType}-missing`
783
+ })
784
+ );
785
+ }
786
+ for (const item of allItems) {
787
+ addQualityFindings(item, findings);
788
+ }
789
+ const byType = CONTENT_DIRECTORIES.map(
790
+ (config) => typeSummary(
791
+ config,
792
+ allItems.filter((item) => item.type === config.type),
793
+ findings,
794
+ invalidFiles,
795
+ ignoredCounts.get(config.type) ?? 0
796
+ )
797
+ );
798
+ const counts = countFindings(findings);
799
+ const validCount = allItems.filter((item) => !invalidFiles.has(item.file)).length;
800
+ const ignoredCount = [...ignoredCounts.values()].reduce((sum, count) => sum + count, 0);
801
+ const patterns = allItems.filter((item) => item.type === "pattern");
802
+ const themes = allItems.filter((item) => item.type === "theme");
803
+ const blueprints = allItems.filter((item) => item.type === "blueprint");
804
+ const archetypes = allItems.filter((item) => item.type === "archetype");
805
+ return {
806
+ $schema: CONTENT_HEALTH_SCHEMA_URL,
807
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
808
+ contentRoot,
809
+ status: statusFromCounts(counts),
810
+ score: scoreFromCounts(counts),
811
+ summary: {
812
+ itemCount: allItems.length,
813
+ validCount,
814
+ ...counts,
815
+ findingCount: findings.length,
816
+ ignoredCount,
817
+ contentDirectoryCount
818
+ },
819
+ content: byType,
820
+ references: {
821
+ checked: referencesChecked,
822
+ missing: Object.values(missingByType).reduce((sum, count) => sum + count, 0),
823
+ missingByType
824
+ },
825
+ quality: {
826
+ patternVisualBriefCoverage: percentage(
827
+ patterns.filter((item) => item.data.visual_brief || item.data.layout_hints).length,
828
+ patterns.length
829
+ ),
830
+ patternInteractionCoverage: percentage(
831
+ patterns.filter((item) => Array.isArray(item.data.interactions) && item.data.interactions.length > 0).length,
832
+ patterns.length
833
+ ),
834
+ themeDecoratorCoverage: percentage(
835
+ themes.filter((item) => isRecord(item.data.decorators) && Object.keys(item.data.decorators).length > 0).length,
836
+ themes.length
837
+ ),
838
+ blueprintPersonalityCoverage: percentage(
839
+ blueprints.filter((item) => {
840
+ const personality = item.data.personality;
841
+ return isNonEmptyString(personality) || Array.isArray(personality) && personality.some((entry) => isNonEmptyString(entry));
842
+ }).length,
843
+ blueprints.length
844
+ ),
845
+ blueprintVoiceCoverage: percentage(blueprints.filter((item) => isRecord(item.data.voice)).length, blueprints.length),
846
+ archetypePageBriefCoverage: percentage(
847
+ archetypes.filter((item) => isRecord(item.data.page_briefs)).length,
848
+ archetypes.length
849
+ )
850
+ },
851
+ ci: {
852
+ recommendedCommand: "decantr content-health --ci --fail-on error",
853
+ failOn: "error"
854
+ },
855
+ findings
856
+ };
857
+ }
858
+ function colorForStatus(status) {
859
+ if (status === "healthy") return GREEN;
860
+ if (status === "warning") return YELLOW;
861
+ return RED;
862
+ }
863
+ function percentLabel(value) {
864
+ return `${Math.round(value * 100)}%`;
865
+ }
866
+ function formatContentHealthText(report) {
867
+ const color = colorForStatus(report.status);
868
+ const lines = [
869
+ `${BOLD}Decantr Content Health${RESET}`,
870
+ "",
871
+ `${color}${report.status.toUpperCase()}${RESET} score ${report.score}/100`,
872
+ `${DIM}${report.contentRoot}${RESET}`,
873
+ "",
874
+ `${BOLD}Summary:${RESET}`,
875
+ ` Items: ${report.summary.itemCount} total, ${report.summary.validCount} valid, ${report.summary.ignoredCount} ignored`,
876
+ ` Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
877
+ ` References: ${report.references.checked} checked, ${report.references.missing} missing`,
878
+ ` Quality: pattern guidance ${percentLabel(report.quality.patternVisualBriefCoverage)} | theme decorators ${percentLabel(report.quality.themeDecoratorCoverage)} | blueprint voice ${percentLabel(report.quality.blueprintVoiceCoverage)}`,
879
+ "",
880
+ `${BOLD}Content:${RESET}`
881
+ ];
882
+ for (const entry of report.content) {
883
+ lines.push(
884
+ ` ${entry.directory.padEnd(10)} ${entry.itemCount} item(s), ${entry.validCount} valid, ${entry.errorCount} error(s), ${entry.warnCount} warn(s), ${entry.ignoredCount} ignored`
885
+ );
886
+ }
887
+ lines.push("");
888
+ lines.push(`${BOLD}Findings:${RESET}`);
889
+ if (report.findings.length === 0) {
890
+ lines.push(` ${GREEN}No findings. Content supply chain is healthy.${RESET}`);
891
+ } else {
892
+ for (const finding of report.findings.slice(0, 40)) {
893
+ const findingColor = finding.severity === "error" ? RED : finding.severity === "warn" ? YELLOW : CYAN;
894
+ lines.push(
895
+ ` ${findingColor}[${finding.severity.toUpperCase()}]${RESET} ${finding.id}: ${finding.message}`
896
+ );
897
+ if (finding.file) lines.push(` ${DIM}${finding.file}${RESET}`);
898
+ if (finding.suggestedFix) lines.push(` ${DIM}Fix: ${finding.suggestedFix}${RESET}`);
899
+ lines.push(` ${DIM}Prompt: decantr content-health --prompt ${finding.id}${RESET}`);
900
+ }
901
+ if (report.findings.length > 40) {
902
+ lines.push(` ${DIM}Showing first 40 of ${report.findings.length} findings. Use --json for the full report.${RESET}`);
903
+ }
904
+ }
905
+ lines.push("");
906
+ lines.push(`${BOLD}CI:${RESET} ${report.ci.recommendedCommand}`);
907
+ return `${lines.join("\n")}
908
+ `;
909
+ }
910
+ function formatContentHealthMarkdown(report) {
911
+ const lines = [
912
+ "# Decantr Content Health",
913
+ "",
914
+ `- Status: **${report.status}**`,
915
+ `- Score: **${report.score}/100**`,
916
+ `- Content root: \`${report.contentRoot}\``,
917
+ `- Items: ${report.summary.itemCount} total, ${report.summary.validCount} valid, ${report.summary.ignoredCount} ignored`,
918
+ `- Findings: ${report.summary.errorCount} error(s), ${report.summary.warnCount} warn(s), ${report.summary.infoCount} info`,
919
+ `- References: ${report.references.checked} checked, ${report.references.missing} missing`,
920
+ "",
921
+ "## Content",
922
+ "",
923
+ "| Type | Items | Valid | Errors | Warnings | Info | Ignored |",
924
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"
925
+ ];
926
+ for (const entry of report.content) {
927
+ lines.push(
928
+ `| ${entry.type} | ${entry.itemCount} | ${entry.validCount} | ${entry.errorCount} | ${entry.warnCount} | ${entry.infoCount} | ${entry.ignoredCount} |`
929
+ );
930
+ }
931
+ lines.push("");
932
+ lines.push("## Quality Coverage");
933
+ lines.push("");
934
+ lines.push(`- Pattern visual guidance: ${percentLabel(report.quality.patternVisualBriefCoverage)}`);
935
+ lines.push(`- Pattern interactions: ${percentLabel(report.quality.patternInteractionCoverage)}`);
936
+ lines.push(`- Theme decorators: ${percentLabel(report.quality.themeDecoratorCoverage)}`);
937
+ lines.push(`- Blueprint personality: ${percentLabel(report.quality.blueprintPersonalityCoverage)}`);
938
+ lines.push(`- Blueprint voice: ${percentLabel(report.quality.blueprintVoiceCoverage)}`);
939
+ lines.push(`- Archetype page briefs: ${percentLabel(report.quality.archetypePageBriefCoverage)}`);
940
+ lines.push("");
941
+ lines.push("## Findings");
942
+ lines.push("");
943
+ if (report.findings.length === 0) {
944
+ lines.push("No findings. Content supply chain is healthy.");
945
+ } else {
946
+ for (const finding of report.findings) {
947
+ lines.push(`### ${finding.id}`);
948
+ lines.push("");
949
+ lines.push(`- Severity: ${finding.severity}`);
950
+ lines.push(`- Source: ${finding.source}`);
951
+ lines.push(`- Category: ${finding.category}`);
952
+ if (finding.file) lines.push(`- File: \`${finding.file}\``);
953
+ if (finding.type) lines.push(`- Type: ${finding.type}`);
954
+ if (finding.itemId) lines.push(`- Item: \`${finding.itemId}\``);
955
+ lines.push(`- Message: ${finding.message}`);
956
+ if (finding.suggestedFix) lines.push(`- Fix: ${finding.suggestedFix}`);
957
+ if (finding.evidence.length > 0) {
958
+ lines.push("- Evidence:");
959
+ for (const evidence of finding.evidence) lines.push(` - ${evidence}`);
960
+ }
961
+ lines.push(`- Prompt: \`decantr content-health --prompt ${finding.id}\``);
962
+ lines.push("");
963
+ }
964
+ }
965
+ lines.push("## CI");
966
+ lines.push("");
967
+ lines.push(`\`${report.ci.recommendedCommand}\``);
968
+ return `${lines.join("\n")}
969
+ `;
970
+ }
971
+ function formatContentHealthJson(report) {
972
+ return `${JSON.stringify(report, null, 2)}
973
+ `;
974
+ }
975
+ function resolveFormat(options) {
976
+ if (options.json) return "json";
977
+ if (options.markdown) return "markdown";
978
+ return options.format ?? "text";
979
+ }
980
+ function shouldFailContentHealth(report, failOn) {
981
+ if (failOn === "none") return false;
982
+ if (failOn === "warn") return report.summary.errorCount > 0 || report.summary.warnCount > 0;
983
+ return report.summary.errorCount > 0;
984
+ }
985
+ async function cmdContentHealth(contentRoot = process.cwd(), options = {}) {
986
+ const report = await createContentHealthReport(contentRoot, options);
987
+ if (options.promptId) {
988
+ const finding = report.findings.find((entry) => entry.id === options.promptId);
989
+ if (!finding) {
990
+ console.error(`${RED}No content health finding found for id: ${options.promptId}${RESET}`);
991
+ process.exitCode = 1;
992
+ return;
993
+ }
994
+ console.log(finding.remediation.prompt);
995
+ return;
996
+ }
997
+ const format = resolveFormat(options);
998
+ const payload = format === "json" ? formatContentHealthJson(report) : format === "markdown" ? formatContentHealthMarkdown(report) : formatContentHealthText(report);
999
+ if (options.output) {
1000
+ writeFileSync(options.output, payload, "utf-8");
1001
+ if (!options.ci) {
1002
+ console.log(`${GREEN}Wrote Decantr content health report:${RESET} ${options.output}`);
1003
+ }
1004
+ } else {
1005
+ process.stdout.write(payload);
1006
+ }
1007
+ if (options.ci && shouldFailContentHealth(report, options.failOn ?? "error")) {
1008
+ process.exitCode = 1;
1009
+ }
1010
+ }
1011
+ function parseContentHealthArgs(args) {
1012
+ const options = {};
1013
+ for (let index = 1; index < args.length; index += 1) {
1014
+ const arg = args[index];
1015
+ if (arg === "--json") {
1016
+ options.json = true;
1017
+ } else if (arg === "--markdown") {
1018
+ options.markdown = true;
1019
+ } else if (arg === "--ci") {
1020
+ options.ci = true;
1021
+ } else if (arg === "--include-ignored") {
1022
+ options.includeIgnored = true;
1023
+ } else if (arg === "--format" && args[index + 1]) {
1024
+ options.format = args[++index];
1025
+ } else if (arg.startsWith("--format=")) {
1026
+ options.format = arg.split("=")[1];
1027
+ } else if (arg === "--output" && args[index + 1]) {
1028
+ options.output = args[++index];
1029
+ } else if (arg.startsWith("--output=")) {
1030
+ options.output = arg.split("=")[1];
1031
+ } else if (arg === "--fail-on" && args[index + 1]) {
1032
+ options.failOn = args[++index];
1033
+ } else if (arg.startsWith("--fail-on=")) {
1034
+ options.failOn = arg.split("=")[1];
1035
+ } else if (arg === "--prompt" && args[index + 1]) {
1036
+ options.promptId = args[++index];
1037
+ } else if (arg.startsWith("--prompt=")) {
1038
+ options.promptId = arg.split("=")[1];
1039
+ }
1040
+ }
1041
+ if (options.format && !["text", "json", "markdown"].includes(options.format)) {
1042
+ throw new Error("Invalid --format value. Use text, json, or markdown.");
1043
+ }
1044
+ if (options.failOn && !["error", "warn", "none"].includes(options.failOn)) {
1045
+ throw new Error("Invalid --fail-on value. Use error, warn, or none.");
1046
+ }
1047
+ return options;
1048
+ }
1049
+ export {
1050
+ cmdContentHealth,
1051
+ createContentHealthReport,
1052
+ formatContentHealthJson,
1053
+ formatContentHealthMarkdown,
1054
+ formatContentHealthText,
1055
+ parseContentHealthArgs,
1056
+ shouldFailContentHealth
1057
+ };