@decantr/cli 1.8.0 → 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.
- package/README.md +17 -0
- package/dist/bin.js +1 -1
- package/dist/{chunk-Y45MCRGI.js → chunk-PKJSI6IH.js} +14 -0
- package/dist/content-health-QQHBR6XG.js +1057 -0
- package/dist/index.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Brownfield analysis also writes `.decantr/doctrine-map.json`, a ranked source-pr
|
|
|
61
61
|
- generates execution-pack context files for AI coding assistants
|
|
62
62
|
- audits projects against Decantr contracts
|
|
63
63
|
- produces local Project Health reports and a localhost Studio dashboard for end-user drift triage
|
|
64
|
+
- audits local registry content repositories with Content Health reports for schema, reference, and quality coverage
|
|
64
65
|
- searches the registry and showcase benchmark corpus
|
|
65
66
|
- validates, refreshes, and maintains `decantr.essence.json`
|
|
66
67
|
|
|
@@ -80,6 +81,7 @@ decantr audit
|
|
|
80
81
|
decantr check
|
|
81
82
|
decantr health --ci --fail-on error
|
|
82
83
|
decantr studio --port 4319 --host 127.0.0.1
|
|
84
|
+
decantr content-health --ci --fail-on error
|
|
83
85
|
decantr registry summary --namespace @official --json
|
|
84
86
|
decantr showcase verification --json
|
|
85
87
|
```
|
|
@@ -108,6 +110,21 @@ decantr studio --port 4319 --host 127.0.0.1
|
|
|
108
110
|
|
|
109
111
|
Studio is for local triage, not Decantr admin telemetry. The tabs cover Overview, Routes, Drift, Findings, Remediation, CI, and Packs without uploading source code, prompts, file paths, or project data.
|
|
110
112
|
|
|
113
|
+
## Content Health
|
|
114
|
+
|
|
115
|
+
`decantr content-health` is the local supply-chain observability command for registry content repositories such as `decantr-content`. It is separate from Project Health: Project Health checks an end-user app against its Decantr contract, while Content Health checks published content inputs before they flow into the hosted registry.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
decantr content-health
|
|
119
|
+
decantr content-health --json
|
|
120
|
+
decantr content-health --markdown --output content-health.md
|
|
121
|
+
decantr content-health --ci --fail-on error
|
|
122
|
+
decantr content-health --ci --fail-on warn
|
|
123
|
+
decantr content-health --prompt <finding-id>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The report validates local `patterns/`, `themes/`, `blueprints/`, `archetypes/`, and `shells/` against the published registry schemas, checks hard references such as blueprint themes and composed archetypes, summarizes softer generation-coverage gaps such as missing pattern coverage, and emits AI-ready remediation prompts. It does not call the hosted registry by default; use the existing registry drift audits when you need live publish parity.
|
|
127
|
+
|
|
111
128
|
## Greenfield Certification
|
|
112
129
|
|
|
113
130
|
Use the built-in certification harness before releases when you want to prove that representative blueprints still scaffold into runnable starter projects:
|
package/dist/bin.js
CHANGED
|
@@ -6481,6 +6481,7 @@ ${YELLOW9}You're offline. Scaffolding Decantr default.${RESET13}`);
|
|
|
6481
6481
|
console.log(" Commands:");
|
|
6482
6482
|
console.log(` ${cyan3("decantr status")} Project health`);
|
|
6483
6483
|
console.log(` ${cyan3("decantr health")} Contract health report`);
|
|
6484
|
+
console.log(` ${cyan3("decantr content-health")} Registry content health report`);
|
|
6484
6485
|
console.log(` ${cyan3("decantr studio")} Local health dashboard`);
|
|
6485
6486
|
console.log(` ${cyan3("decantr search")} Search registry`);
|
|
6486
6487
|
console.log(` ${cyan3("decantr get")} Fetch content details`);
|
|
@@ -6926,6 +6927,7 @@ ${BOLD6}Usage:${RESET13}
|
|
|
6926
6927
|
decantr registry get-pack <manifest|scaffold|review|section|page|mutation> [id] [--namespace <namespace>] [--json] [--essence <path>] [--write-context]
|
|
6927
6928
|
decantr registry critique-file <file> [--namespace <namespace>] [--json] [--essence <path>] [--treatments <path>]
|
|
6928
6929
|
decantr registry audit-project [--namespace <namespace>] [--json] [--essence <path>] [--dist <path>] [--sources <dir>]
|
|
6930
|
+
decantr content-health [--json] [--markdown] [--ci]
|
|
6929
6931
|
decantr rules preview [--project=<path>]
|
|
6930
6932
|
decantr rules apply [--project=<path>]
|
|
6931
6933
|
decantr validate [path]
|
|
@@ -6964,6 +6966,7 @@ ${BOLD6}Commands:${RESET13}
|
|
|
6964
6966
|
${cyan3("init")} Attach Decantr contract/context files to an existing project or empty workspace
|
|
6965
6967
|
${cyan3("status")} Show project status, DNA axioms, and blueprint info
|
|
6966
6968
|
${cyan3("health")} Generate a local Project Health report [--json] [--markdown] [--ci]
|
|
6969
|
+
${cyan3("content-health")} Generate a local registry content health report [--json] [--markdown] [--ci]
|
|
6967
6970
|
${cyan3("studio")} Open a local Project Health dashboard backed by the same report
|
|
6968
6971
|
${cyan3("sync")} Sync registry content from API
|
|
6969
6972
|
${cyan3("audit")} Audit the project or critique a specific file against compiled packs
|
|
@@ -7003,6 +7006,7 @@ ${BOLD6}Examples:${RESET13}
|
|
|
7003
7006
|
decantr status
|
|
7004
7007
|
decantr health
|
|
7005
7008
|
decantr health --ci --fail-on error
|
|
7009
|
+
decantr content-health --ci --fail-on error
|
|
7006
7010
|
decantr studio
|
|
7007
7011
|
decantr audit
|
|
7008
7012
|
decantr audit src/pages/HomePage.tsx
|
|
@@ -7192,6 +7196,16 @@ async function main() {
|
|
|
7192
7196
|
}
|
|
7193
7197
|
break;
|
|
7194
7198
|
}
|
|
7199
|
+
case "content-health": {
|
|
7200
|
+
try {
|
|
7201
|
+
const { cmdContentHealth, parseContentHealthArgs } = await import("./content-health-QQHBR6XG.js");
|
|
7202
|
+
await cmdContentHealth(process.cwd(), parseContentHealthArgs(args));
|
|
7203
|
+
} catch (e) {
|
|
7204
|
+
console.error(error3(e.message));
|
|
7205
|
+
process.exitCode = 1;
|
|
7206
|
+
}
|
|
7207
|
+
break;
|
|
7208
|
+
}
|
|
7195
7209
|
case "studio": {
|
|
7196
7210
|
try {
|
|
7197
7211
|
const { cmdStudio, parseStudioArgs } = await import("./studio-BCTWKXFH.js");
|
|
@@ -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
|
+
};
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decantr/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Decantr CLI - scaffold, audit, inspect Project Health, and maintain Decantr projects from the terminal",
|
|
5
5
|
"author": "Decantr AI",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"ajv": "^8.18.0",
|
|
33
34
|
"@decantr/core": "1.0.6",
|
|
34
35
|
"@decantr/essence-spec": "1.0.7",
|
|
36
|
+
"@decantr/registry": "1.1.0",
|
|
35
37
|
"@decantr/verifier": "1.1.0",
|
|
36
|
-
"@decantr/registry": "1.0.4",
|
|
37
38
|
"@decantr/telemetry": "0.1.2"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|