sdtk-design-kit 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/commands/prototype.js +20 -6
- package/src/commands/review.js +68 -1
- package/src/lib/anti-slop-lint.js +210 -0
- package/src/lib/prototype-briefs.js +125 -0
- package/src/lib/prototype-component-map.js +219 -0
- package/src/lib/prototype-density.js +286 -56
- package/src/lib/prototype-renderer.js +101 -44
package/package.json
CHANGED
|
@@ -736,11 +736,25 @@ function cmdPrototype(args) {
|
|
|
736
736
|
console.log(`[design] Style: ${result.style}`);
|
|
737
737
|
console.log(`[design] Mode: ${result.mode} (${result.screenCount} screen(s))`);
|
|
738
738
|
if (result.densityReport) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
739
|
+
if (result.densityReport.mode === "bytes") {
|
|
740
|
+
console.log("[design] Density mode: bytes (deprecated rollback; report shape only, no filler emission)");
|
|
741
|
+
console.log(
|
|
742
|
+
`[design] Density: ${result.densityReport.pass ? "PASS" : "FAIL"} total=${result.densityReport.totalBytes}/${result.densityReport.minTotalBytes} bytes`
|
|
743
|
+
);
|
|
744
|
+
for (const page of result.densityReport.pages) {
|
|
745
|
+
console.log(`[design] Density page: ${page.screenId} ${page.byteLength}/${page.minBytes} bytes ${page.pass ? "PASS" : "FAIL"}`);
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
const aggregate = result.densityReport.aggregate || {};
|
|
749
|
+
console.log(
|
|
750
|
+
`[design] Density: ${result.densityReport.pass ? "PASS" : "FAIL"} mode=semantic sections=${aggregate.sectionCount || 0} components=${aggregate.componentCount || 0} dataSlots=${aggregate.dataSlotCount || 0} states=${aggregate.stateCount || 0}`
|
|
751
|
+
);
|
|
752
|
+
for (const page of result.densityReport.pages) {
|
|
753
|
+
const missing = Array.isArray(page.findings) && page.findings.length > 0 ? ` (${page.findings.join("; ")})` : "";
|
|
754
|
+
console.log(
|
|
755
|
+
`[design] Density page: ${page.screenId} sections=${page.sectionCount} components=${page.componentCount} dataSlots=${page.dataSlotCount} states=${page.stateCount} ${page.pass ? "PASS" : "FAIL"}${missing}`
|
|
756
|
+
);
|
|
757
|
+
}
|
|
744
758
|
}
|
|
745
759
|
}
|
|
746
760
|
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
@@ -750,7 +764,7 @@ function cmdPrototype(args) {
|
|
|
750
764
|
console.log("[design] Prototype mode: static HTML/CSS preview only.");
|
|
751
765
|
console.log("[design] No app code outside docs/design/prototype, server, network, .sdtk/atlas, or SDTK-WIKI output was modified.");
|
|
752
766
|
console.log("[design] Next: sdtk-design review --artifact docs/design/prototype/index.html");
|
|
753
|
-
return 0;
|
|
767
|
+
return result.densityReport && result.densityReport.mode !== "bytes" && !result.densityReport.pass ? 1 : 0;
|
|
754
768
|
}
|
|
755
769
|
|
|
756
770
|
module.exports = {
|
package/src/commands/review.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { parseFlags } = require("../lib/args");
|
|
6
|
+
const { lintArtifactTree, summarizeLintFindings } = require("../lib/anti-slop-lint");
|
|
6
7
|
const { describeDesignPaths, isPathInsideOrEqual, resolveProjectPath } = require("../lib/design-paths");
|
|
7
8
|
const { ValidationError } = require("../lib/errors");
|
|
8
9
|
|
|
@@ -146,6 +147,36 @@ function combinedPrototypeReviewContent({ artifactPath, artifactContent, paths }
|
|
|
146
147
|
return parts.join("\n");
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
function resolveAntiSlopLintMode(env = process.env) {
|
|
151
|
+
const value = String(env.SDTK_DESIGN_ANTI_SLOP_LINT || "warn").toLowerCase();
|
|
152
|
+
return value === "on" || value === "off" || value === "warn" ? value : "warn";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }) {
|
|
156
|
+
const map = new Map();
|
|
157
|
+
const add = (screenId, html) => {
|
|
158
|
+
if (!map.has(screenId)) map.set(screenId, html);
|
|
159
|
+
};
|
|
160
|
+
add(path.basename(artifactPath, ".html") || "artifact", artifactContent);
|
|
161
|
+
if (fs.existsSync(paths.prototypeIndexPath)) {
|
|
162
|
+
add("index", fs.readFileSync(paths.prototypeIndexPath, "utf-8"));
|
|
163
|
+
}
|
|
164
|
+
for (const filePath of listPrototypeScreenFiles(paths)) {
|
|
165
|
+
add(path.basename(filePath, ".html"), fs.readFileSync(filePath, "utf-8"));
|
|
166
|
+
}
|
|
167
|
+
return map;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyAntiSlopVerdict({ structuralVerdict, lintFindings, lintMode }) {
|
|
171
|
+
const lintSummary = summarizeLintFindings(lintFindings);
|
|
172
|
+
const lintBlocking = lintMode === "on" && lintSummary.p0 > 0;
|
|
173
|
+
return {
|
|
174
|
+
verdict: lintBlocking ? "FAIL_WITH_RENDERER_SLOP" : structuralVerdict,
|
|
175
|
+
lintSummary,
|
|
176
|
+
lintBlocking,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
149
180
|
function evaluateHighFidelityGate({ artifactContent, statePayload, projectPath, paths }) {
|
|
150
181
|
const html = String(artifactContent || "").toLowerCase();
|
|
151
182
|
const screens = statePayload.screenModel.screens || [];
|
|
@@ -256,6 +287,17 @@ function fidelityReviewContent(result) {
|
|
|
256
287
|
"",
|
|
257
288
|
...(result.blockers.length > 0 ? result.blockers.map((item) => `- ${item}`) : ["- No blocking fidelity gaps found."]),
|
|
258
289
|
"",
|
|
290
|
+
"## Anti-Slop Lint Findings",
|
|
291
|
+
"",
|
|
292
|
+
`- Mode: ${result.lintMode || "warn"}`,
|
|
293
|
+
`- Summary: P0=${result.lintSummary ? result.lintSummary.p0 : 0}, P1=${result.lintSummary ? result.lintSummary.p1 : 0}, P2=${result.lintSummary ? result.lintSummary.p2 : 0}`,
|
|
294
|
+
"",
|
|
295
|
+
"| Severity | ID | Screen | Message | Fix | Snippet |",
|
|
296
|
+
"|---|---|---|---|---|---|",
|
|
297
|
+
...(Array.isArray(result.lintFindings) && result.lintFindings.length > 0
|
|
298
|
+
? result.lintFindings.map((item) => `| ${item.severity} | ${item.id} | ${item.screenId || "artifact"} | ${item.message} | ${item.fix} | ${String(item.snippet || "").replace(/\|/g, "/")} |`)
|
|
299
|
+
: ["| - | - | - | No lint findings. | - | - |"]),
|
|
300
|
+
"",
|
|
259
301
|
"## Actions",
|
|
260
302
|
"",
|
|
261
303
|
"- Fill missing component markers per failed screen.",
|
|
@@ -396,6 +438,7 @@ function runDesignReview({ artifact, projectPath, force = false }) {
|
|
|
396
438
|
|
|
397
439
|
let fidelityReportRelativePath = null;
|
|
398
440
|
let fidelityVerdict = null;
|
|
441
|
+
let antiSlopLint = null;
|
|
399
442
|
if (shouldRunFidelityGate) {
|
|
400
443
|
const fidelityArtifactContent = combinedPrototypeReviewContent({
|
|
401
444
|
artifactPath,
|
|
@@ -408,6 +451,19 @@ function runDesignReview({ artifact, projectPath, force = false }) {
|
|
|
408
451
|
projectPath: resolvedProjectPath,
|
|
409
452
|
paths,
|
|
410
453
|
});
|
|
454
|
+
const lintMode = resolveAntiSlopLintMode();
|
|
455
|
+
const lintFindings = lintMode === "off" ? [] : lintArtifactTree(collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }));
|
|
456
|
+
const lintVerdict = applyAntiSlopVerdict({
|
|
457
|
+
structuralVerdict: fidelityResult.verdict,
|
|
458
|
+
lintFindings,
|
|
459
|
+
lintMode,
|
|
460
|
+
});
|
|
461
|
+
fidelityResult.verdict = lintVerdict.verdict;
|
|
462
|
+
fidelityResult.lintMode = lintMode;
|
|
463
|
+
fidelityResult.lintFindings = lintFindings;
|
|
464
|
+
fidelityResult.lintSummary = lintVerdict.lintSummary;
|
|
465
|
+
fidelityResult.lintBlocking = lintVerdict.lintBlocking;
|
|
466
|
+
antiSlopLint = { mode: lintMode, findings: lintFindings, summary: lintVerdict.lintSummary, blocking: lintVerdict.lintBlocking };
|
|
411
467
|
const fidelityName = `DESIGN_FIDELITY_REVIEW_${formatDateYYYYMMDD()}.md`;
|
|
412
468
|
const fidelityPath = path.join(paths.reviewsPath, fidelityName);
|
|
413
469
|
if (fs.existsSync(fidelityPath) && !force) {
|
|
@@ -423,6 +479,7 @@ function runDesignReview({ artifact, projectPath, force = false }) {
|
|
|
423
479
|
relativeReportPath: `docs/design/reviews/${reportName}`,
|
|
424
480
|
fidelityReportRelativePath,
|
|
425
481
|
fidelityVerdict,
|
|
482
|
+
antiSlopLint,
|
|
426
483
|
forced: Boolean(force),
|
|
427
484
|
};
|
|
428
485
|
}
|
|
@@ -441,17 +498,27 @@ function cmdReview(args) {
|
|
|
441
498
|
if (result.fidelityReportRelativePath) {
|
|
442
499
|
console.log(`[design] Wrote ${result.fidelityReportRelativePath}: ${result.projectPath}`);
|
|
443
500
|
console.log(`[design] Fidelity verdict: ${result.fidelityVerdict}`);
|
|
501
|
+
if (result.antiSlopLint) {
|
|
502
|
+
if (result.antiSlopLint.mode === "off") {
|
|
503
|
+
console.log("[design] Anti-slop lint: OFF (deprecated emergency rollback)");
|
|
504
|
+
} else if (result.antiSlopLint.summary.p0 > 0) {
|
|
505
|
+
console.log(`[design] Anti-slop lint: ${result.antiSlopLint.summary.p0} P0 findings; see review report`);
|
|
506
|
+
} else {
|
|
507
|
+
console.log(`[design] Anti-slop lint: PASS (P1=${result.antiSlopLint.summary.p1}, P2=${result.antiSlopLint.summary.p2})`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
444
510
|
}
|
|
445
511
|
console.log(`[design] Review mode: local artifact`);
|
|
446
512
|
console.log(`[design] Overwrite: ${result.forced ? "enabled by --force" : "not needed"}`);
|
|
447
513
|
console.log("[design] No URL, browser, screenshot, vision, network, .sdtk/atlas, or SDTK-WIKI output was used.");
|
|
448
|
-
return 0;
|
|
514
|
+
return result.antiSlopLint && result.antiSlopLint.blocking ? 1 : 0;
|
|
449
515
|
}
|
|
450
516
|
|
|
451
517
|
module.exports = {
|
|
452
518
|
cmdReview,
|
|
453
519
|
cmdReviewHelp,
|
|
454
520
|
formatDateYYYYMMDD,
|
|
521
|
+
resolveAntiSlopLintMode,
|
|
455
522
|
isPrototypeHtmlArtifact,
|
|
456
523
|
reviewContent,
|
|
457
524
|
runDesignReview,
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function stripHtmlComments(html) {
|
|
4
|
+
return String(html || "").replace(/<!--[\s\S]*?-->/g, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function stripTags(html) {
|
|
8
|
+
return String(html || "")
|
|
9
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
10
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
11
|
+
.replace(/<[^>]+>/g, " ")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function snippet(value, index = 0, length = 100) {
|
|
17
|
+
const text = stripTags(value);
|
|
18
|
+
if (text) return text.slice(0, length);
|
|
19
|
+
return String(value || "").slice(Math.max(0, index - 30), index + length).replace(/\s+/g, " ").trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function finding(severity, id, message, fix, text, screenId) {
|
|
23
|
+
return { severity, id, message, fix, snippet: snippet(text), ...(screenId ? { screenId } : {}) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addRegexFindings(findings, clean, regex, severity, id, message, fix, screenId) {
|
|
27
|
+
let match;
|
|
28
|
+
const pattern = new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
|
|
29
|
+
while ((match = pattern.exec(clean))) {
|
|
30
|
+
findings.push(finding(severity, id, message, fix, match[0], screenId));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function articleTexts(clean) {
|
|
35
|
+
const result = [];
|
|
36
|
+
const regex = /<article\b[^>]*>([\s\S]*?)<\/article>/gi;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = regex.exec(clean))) {
|
|
39
|
+
const text = stripTags(match[1]).toLowerCase();
|
|
40
|
+
if (text) result.push(text);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function articleBodyTexts(clean) {
|
|
46
|
+
const result = [];
|
|
47
|
+
const regex = /<article\b[^>]*>([\s\S]*?)<\/article>/gi;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = regex.exec(clean))) {
|
|
50
|
+
const bodyOnly = match[1].replace(/<h[1-6]\b[\s\S]*?<\/h[1-6]>/i, "");
|
|
51
|
+
const text = stripTags(bodyOnly).toLowerCase().replace(/\s+/g, " ").trim();
|
|
52
|
+
if (text.length >= 20) result.push(text);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lintArtifactHtml(html, options = {}) {
|
|
58
|
+
const screenId = options.screenId;
|
|
59
|
+
const clean = stripHtmlComments(html);
|
|
60
|
+
const findings = [];
|
|
61
|
+
|
|
62
|
+
addRegexFindings(
|
|
63
|
+
findings,
|
|
64
|
+
clean,
|
|
65
|
+
/\b(line\s*item|product\s*card|material|result\s*item|feature)\s+(one|two|three|[1-9]|\d+)\b|\blorem\s+ipsum\b|\bdolor\s+sit\s+amet\b|\bsample\s+content\b|\bplaceholder\s+text\b/i,
|
|
66
|
+
"P0",
|
|
67
|
+
"filler-copy",
|
|
68
|
+
"Auto-numbered filler copy detected.",
|
|
69
|
+
"Replace auto-numbered filler with brief-derived data_slot placeholders or grey blocks.",
|
|
70
|
+
screenId
|
|
71
|
+
);
|
|
72
|
+
addRegexFindings(
|
|
73
|
+
findings,
|
|
74
|
+
clean,
|
|
75
|
+
/#[ ]*(6366f1|4f46e5|4338ca|3730a3|8b5cf6|7c3aed|a855f7)\b/i,
|
|
76
|
+
"P0",
|
|
77
|
+
"ai-default-indigo",
|
|
78
|
+
"AI-default indigo/purple hex detected.",
|
|
79
|
+
"Replace with var(--primary), var(--accent), or an explicit brand token override.",
|
|
80
|
+
screenId
|
|
81
|
+
);
|
|
82
|
+
addRegexFindings(
|
|
83
|
+
findings,
|
|
84
|
+
clean,
|
|
85
|
+
/<h[1-6]\b[^>]*>[^<]*✨[^<]*<\/h[1-6]>|<button\b[^>]*>[^<]*✨[^<]*<\/button>|<a\b[^>]*class="[^"]*\bbtn\b[^"]*"[^>]*>[^<]*✨[^<]*<\/a>/i,
|
|
86
|
+
"P0",
|
|
87
|
+
"slop-emoji",
|
|
88
|
+
"Decorative emoji used in heading, button, CTA, or icon surface.",
|
|
89
|
+
"Use a 1.6-1.8px stroke monoline SVG with currentColor, or remove the icon.",
|
|
90
|
+
screenId
|
|
91
|
+
);
|
|
92
|
+
addRegexFindings(
|
|
93
|
+
findings,
|
|
94
|
+
clean,
|
|
95
|
+
/<(h[1-6]|button|a\b[^>]*class="[^"]*\bbtn\b[^"]*"|[^>]*class="[^"]*\bicon\b[^"]*")[^>]*>[\s\S]*?(✨|笨ィ|噫|識|笞。|櫨|庁)[\s\S]*?<\/\1>/i,
|
|
96
|
+
"P0",
|
|
97
|
+
"slop-emoji",
|
|
98
|
+
"Decorative emoji used in heading, button, CTA, or icon surface.",
|
|
99
|
+
"Use a 1.6-1.8px stroke monoline SVG with currentColor, or remove the icon.",
|
|
100
|
+
screenId
|
|
101
|
+
);
|
|
102
|
+
addRegexFindings(
|
|
103
|
+
findings,
|
|
104
|
+
clean,
|
|
105
|
+
/\b\d+x\s+(faster|better|easier)\b|\b99\.\d+%\s+uptime\b|\bzero[- ]downtime\b|\b3x\s+more\s+(productive|efficient)\b/i,
|
|
106
|
+
"P0",
|
|
107
|
+
"invented-metric",
|
|
108
|
+
"Unsourced marketing metric detected.",
|
|
109
|
+
"Pull the metric from a real source or remove the claim.",
|
|
110
|
+
screenId
|
|
111
|
+
);
|
|
112
|
+
addRegexFindings(
|
|
113
|
+
findings,
|
|
114
|
+
clean,
|
|
115
|
+
/class="[^"]*\bdensity-evidence\b[^"]*"/i,
|
|
116
|
+
"P0",
|
|
117
|
+
"density-evidence-residue",
|
|
118
|
+
"Legacy density-evidence filler residue detected.",
|
|
119
|
+
"Remove filler section emitters from the renderer.",
|
|
120
|
+
screenId
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const counts = new Map();
|
|
124
|
+
for (const text of articleTexts(clean)) counts.set(text, (counts.get(text) || 0) + 1);
|
|
125
|
+
const fullDuplicates = new Set();
|
|
126
|
+
for (const [text, count] of counts.entries()) {
|
|
127
|
+
if (count >= 3) {
|
|
128
|
+
fullDuplicates.add(text);
|
|
129
|
+
findings.push(finding("P0", "duplicate-article-text", "Three or more article nodes share identical text.", "Replace duplicated articles with brief-derived data or a single labelled state card.", text, screenId));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (fullDuplicates.size === 0) {
|
|
133
|
+
const bodyCounts = new Map();
|
|
134
|
+
for (const text of articleBodyTexts(clean)) bodyCounts.set(text, (bodyCounts.get(text) || 0) + 1);
|
|
135
|
+
for (const [text, count] of bodyCounts.entries()) {
|
|
136
|
+
if (count >= 3) {
|
|
137
|
+
findings.push(finding("P1", "near-duplicate-article-body", "Three or more article elements share identical body text while headings differ.", "Pull each article body from a distinct data_slot purpose or remove the repeated paragraph.", text, screenId));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const accentCount = (clean.match(/var\(--(accent|primary)\)/gi) || []).length;
|
|
143
|
+
if (accentCount >= 6) {
|
|
144
|
+
findings.push(finding("P1", "accent-overuse", "Primary/accent token is overused in one page.", "Reduce accent use to primary actions and key state markers.", `accent count ${accentCount}`, screenId));
|
|
145
|
+
}
|
|
146
|
+
const withoutRoot = clean.replace(/:root\s*\{[\s\S]*?\}/gi, "");
|
|
147
|
+
const rawHexCount = (withoutRoot.match(/#[0-9a-fA-F]{3,8}\b/g) || []).length;
|
|
148
|
+
if (rawHexCount > 12) {
|
|
149
|
+
findings.push(finding("P1", "raw-hex-outside-root", "Too many raw hex values outside :root.", "Move colors into design tokens or CSS variables.", `raw hex count ${rawHexCount}`, screenId));
|
|
150
|
+
}
|
|
151
|
+
if (options.tokens && options.tokens.typography && /serif/i.test(String(options.tokens.typography.displayFamily || ""))) {
|
|
152
|
+
addRegexFindings(
|
|
153
|
+
findings,
|
|
154
|
+
clean,
|
|
155
|
+
/<h[1-3]\b[^>]*style="[^"]*font-family:\s*(Inter|Roboto|Arial|system-ui|-apple-system)[^"]*"/i,
|
|
156
|
+
"P1",
|
|
157
|
+
"display-sans-on-serif-binding",
|
|
158
|
+
"Heading overrides serif display token with sans font.",
|
|
159
|
+
"Use the active display typography token for headings.",
|
|
160
|
+
screenId
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
addRegexFindings(
|
|
164
|
+
findings,
|
|
165
|
+
clean,
|
|
166
|
+
/<section\b(?![^>]*(data-section-id|data-slot-id|data-component-id|aria-label|id=))[^>]*>/i,
|
|
167
|
+
"P2",
|
|
168
|
+
"missing-data-od-id",
|
|
169
|
+
"Section is missing a stable target attribute.",
|
|
170
|
+
"Add data-section-id or an analogous stable target attribute.",
|
|
171
|
+
screenId
|
|
172
|
+
);
|
|
173
|
+
addRegexFindings(
|
|
174
|
+
findings,
|
|
175
|
+
clean,
|
|
176
|
+
/<svg\b(?![^>]*class="[^"]*(icon|semantic)[^"]*")[\s\S]*?<path\b[^>]*d="[^"]*Q[^"]*Q[^"]*"/i,
|
|
177
|
+
"P2",
|
|
178
|
+
"decorative-blob-svg",
|
|
179
|
+
"Decorative blob SVG detected.",
|
|
180
|
+
"Use semantic icons or remove decorative blob geometry.",
|
|
181
|
+
screenId
|
|
182
|
+
);
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function lintArtifactTree(perScreenHtmlMap, options = {}) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
for (const [screenId, html] of perScreenHtmlMap.entries()) {
|
|
189
|
+
findings.push(...lintArtifactHtml(html, { ...options, screenId }));
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function summarizeLintFindings(findings) {
|
|
195
|
+
const summary = { p0: 0, p1: 0, p2: 0, blocking: false };
|
|
196
|
+
for (const item of Array.isArray(findings) ? findings : []) {
|
|
197
|
+
if (item.severity === "P0") summary.p0 += 1;
|
|
198
|
+
if (item.severity === "P1") summary.p1 += 1;
|
|
199
|
+
if (item.severity === "P2") summary.p2 += 1;
|
|
200
|
+
}
|
|
201
|
+
summary.blocking = summary.p0 > 0;
|
|
202
|
+
return summary;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
lintArtifactHtml,
|
|
207
|
+
lintArtifactTree,
|
|
208
|
+
stripHtmlComments,
|
|
209
|
+
summarizeLintFindings,
|
|
210
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
function slugify(value, fallback = "item") {
|
|
7
|
+
const slug = String(value == null ? "" : value)
|
|
8
|
+
.trim()
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
return slug || fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeSlot(slot, fallbackId) {
|
|
16
|
+
if (slot && typeof slot === "object") {
|
|
17
|
+
const id = slugify(slot.id || slot.key || slot.name || slot.label || fallbackId, fallbackId);
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
label: String(slot.label || slot.name || slot.title || slot.id || id),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const label = String(slot == null ? fallbackId : slot);
|
|
24
|
+
return { id: slugify(label, fallbackId), label };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeComponent(component, fallbackId) {
|
|
28
|
+
if (component && typeof component === "object") {
|
|
29
|
+
const id = slugify(component.id || component.type || component.name || component.label || fallbackId, fallbackId);
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
label: String(component.label || component.name || component.title || component.type || component.id || id),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const label = String(component == null ? fallbackId : component);
|
|
36
|
+
return { id: slugify(label, fallbackId), label };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSection(section, index) {
|
|
40
|
+
const source = section && typeof section === "object" ? section : {};
|
|
41
|
+
const title = source.title || source.name || `Section ${index + 1}`;
|
|
42
|
+
const id = slugify(source.id || title, `section-${index + 1}`);
|
|
43
|
+
const slots = source.data_slots || source.dataSlots || [];
|
|
44
|
+
const components = source.components || [];
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
title: String(title),
|
|
48
|
+
purpose: String(source.purpose || source.description || "Brief-defined section."),
|
|
49
|
+
components: (Array.isArray(components) ? components : []).map((component, componentIndex) =>
|
|
50
|
+
normalizeComponent(component, `${id}-component-${componentIndex + 1}`)
|
|
51
|
+
),
|
|
52
|
+
states: Array.isArray(source.states) ? source.states.map((state) => String(state)) : [],
|
|
53
|
+
interactions: Array.isArray(source.interactions) ? source.interactions.map((interaction) => String(interaction)) : [],
|
|
54
|
+
data_slots: (Array.isArray(slots) ? slots : []).map((slot, slotIndex) => normalizeSlot(slot, `${id}-slot-${slotIndex + 1}`)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeBrief(raw, filePath) {
|
|
59
|
+
const source = raw && typeof raw === "object" ? raw : {};
|
|
60
|
+
const screenId = slugify(source.screen_id || source.screenId || source.id || path.basename(filePath || "screen", ".json"), "screen");
|
|
61
|
+
const sections = Array.isArray(source.sections) ? source.sections.map(normalizeSection) : [];
|
|
62
|
+
const topLevelSlots = source.data_slots || source.dataSlots || [];
|
|
63
|
+
const topLevelComponents = source.components || [];
|
|
64
|
+
return {
|
|
65
|
+
screen_id: screenId,
|
|
66
|
+
screen_title: String(source.screen_title || source.screenTitle || source.title || screenId),
|
|
67
|
+
purpose: String(source.purpose || ""),
|
|
68
|
+
user_intent: String(source.user_intent || source.userIntent || ""),
|
|
69
|
+
route: String(source.route || ""),
|
|
70
|
+
primary_action: String(source.primary_action || source.primaryAction || "Continue"),
|
|
71
|
+
template_role: source.template_role || source.templateRole || null,
|
|
72
|
+
sections,
|
|
73
|
+
components: (Array.isArray(topLevelComponents) ? topLevelComponents : []).map((component, index) =>
|
|
74
|
+
normalizeComponent(component, `${screenId}-component-${index + 1}`)
|
|
75
|
+
),
|
|
76
|
+
states: Array.isArray(source.states || source.required_states || source.requiredStates)
|
|
77
|
+
? (source.states || source.required_states || source.requiredStates).map((state) => String(state))
|
|
78
|
+
: [],
|
|
79
|
+
interactions: Array.isArray(source.interactions) ? source.interactions.map((interaction) => String(interaction)) : [],
|
|
80
|
+
data_slots: (Array.isArray(topLevelSlots) ? topLevelSlots : []).map((slot, index) => normalizeSlot(slot, `${screenId}-slot-${index + 1}`)),
|
|
81
|
+
acceptance_criteria: Array.isArray(source.acceptance_criteria || source.acceptanceCriteria)
|
|
82
|
+
? (source.acceptance_criteria || source.acceptanceCriteria).map((item) => String(item))
|
|
83
|
+
: [],
|
|
84
|
+
source_evidence: source.source_evidence || source.sourceEvidence || null,
|
|
85
|
+
file_path: filePath,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadPrototypeBriefIndex({ paths }) {
|
|
90
|
+
const briefsByScreenId = new Map();
|
|
91
|
+
const findings = [];
|
|
92
|
+
const root = paths && paths.designScreenBriefsStatePath ? paths.designScreenBriefsStatePath : null;
|
|
93
|
+
if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
94
|
+
return {
|
|
95
|
+
briefsByScreenId,
|
|
96
|
+
findings: [{ code: "BRIEF_DIR_MISSING", message: ".sdtk/design/screen-briefs directory is missing." }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
for (const filePath of fs.readdirSync(root).filter((name) => name.toLowerCase().endsWith(".json")).sort()) {
|
|
100
|
+
const absolutePath = path.join(root, filePath);
|
|
101
|
+
try {
|
|
102
|
+
const brief = normalizeBrief(JSON.parse(fs.readFileSync(absolutePath, "utf-8")), absolutePath);
|
|
103
|
+
if (briefsByScreenId.has(brief.screen_id)) {
|
|
104
|
+
findings.push({ screenId: brief.screen_id, code: "BRIEF_DUPLICATE", message: `Duplicate brief skipped: ${filePath}` });
|
|
105
|
+
} else {
|
|
106
|
+
briefsByScreenId.set(brief.screen_id, brief);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
findings.push({ code: "BRIEF_INVALID_JSON", message: `${filePath}: ${err.message}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { briefsByScreenId, findings };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function briefForScreen({ screen, briefIndex }) {
|
|
116
|
+
if (!screen || !briefIndex || !briefIndex.briefsByScreenId) return null;
|
|
117
|
+
return briefIndex.briefsByScreenId.get(slugify(screen.screenId || screen.id || screen.title, "screen")) || null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
briefForScreen,
|
|
122
|
+
loadPrototypeBriefIndex,
|
|
123
|
+
normalizeBrief,
|
|
124
|
+
slugify,
|
|
125
|
+
};
|