sdtk-design-kit 0.1.1 → 0.2.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/package.json +1 -1
- package/src/commands/handoff.js +357 -203
- package/src/commands/help.js +3 -0
- package/src/commands/prototype.js +436 -26
- package/src/commands/review.js +230 -0
- package/src/commands/start.js +105 -8
- package/src/commands/status.js +37 -1
- package/src/lib/component-contract.js +142 -0
- package/src/lib/design-input-contract.js +683 -0
- package/src/lib/design-paths.js +27 -0
- package/src/lib/design-profiles.js +58 -0
- package/src/lib/prototype-density.js +147 -0
- package/src/lib/prototype-renderer.js +325 -0
- package/src/lib/screen-briefs.js +340 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { describeDesignPaths, resolveProjectPath } = require("./design-paths");
|
|
6
|
+
const { resolveProfile } = require("./design-profiles");
|
|
7
|
+
const { ValidationError } = require("./errors");
|
|
8
|
+
|
|
9
|
+
const EVALUATION_REFERENCE_SEGMENTS = ["docs", "ui"];
|
|
10
|
+
const TEMPLATE_ROLES = new Set([
|
|
11
|
+
"home",
|
|
12
|
+
"category",
|
|
13
|
+
"product-detail",
|
|
14
|
+
"search",
|
|
15
|
+
"cart",
|
|
16
|
+
"checkout",
|
|
17
|
+
"order-history",
|
|
18
|
+
"order-detail",
|
|
19
|
+
"account-info",
|
|
20
|
+
"configurator-bom",
|
|
21
|
+
"generic",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function toPosixRelative(basePath, targetPath) {
|
|
25
|
+
return path.relative(basePath, targetPath).split(path.sep).join("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function evaluationReferenceLabel() {
|
|
29
|
+
return EVALUATION_REFERENCE_SEGMENTS.join("/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasEvaluationReferenceSegments(value) {
|
|
33
|
+
const normalized = path
|
|
34
|
+
.normalize(String(value || ""))
|
|
35
|
+
.split(/[\\/]+/)
|
|
36
|
+
.map((segment) => segment.toLowerCase())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
for (let index = 0; index < normalized.length - 1; index++) {
|
|
39
|
+
if (
|
|
40
|
+
normalized[index] === EVALUATION_REFERENCE_SEGMENTS[0] &&
|
|
41
|
+
normalized[index + 1] === EVALUATION_REFERENCE_SEGMENTS[1]
|
|
42
|
+
) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findArtifactByPattern(rootPath, relativeDir, pattern) {
|
|
50
|
+
const absoluteDir = path.join(rootPath, relativeDir);
|
|
51
|
+
if (!fs.existsSync(absoluteDir) || !fs.statSync(absoluteDir).isDirectory()) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const entries = fs
|
|
55
|
+
.readdirSync(absoluteDir, { withFileTypes: true })
|
|
56
|
+
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
|
57
|
+
.map((entry) => entry.name)
|
|
58
|
+
.sort();
|
|
59
|
+
if (entries.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const fileName = entries[0];
|
|
63
|
+
const absolutePath = path.join(absoluteDir, fileName);
|
|
64
|
+
return {
|
|
65
|
+
relativePath: `${relativeDir.replace(/\\/g, "/")}/${fileName}`,
|
|
66
|
+
absolutePath,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveReferenceDirectory(value, projectPath) {
|
|
71
|
+
if (!value) {
|
|
72
|
+
return { provided: false, status: "NOT_PROVIDED", absolutePath: null };
|
|
73
|
+
}
|
|
74
|
+
if (hasEvaluationReferenceSegments(value)) {
|
|
75
|
+
throw new ValidationError(
|
|
76
|
+
`--reference-dir cannot point at ${evaluationReferenceLabel()}; that folder is evaluation-only and not a generation input. No project files were changed.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const absolutePath = path.resolve(projectPath, value);
|
|
80
|
+
if (hasEvaluationReferenceSegments(absolutePath)) {
|
|
81
|
+
throw new ValidationError(
|
|
82
|
+
`--reference-dir cannot point at ${evaluationReferenceLabel()}; that folder is evaluation-only and not a generation input. No project files were changed.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) {
|
|
86
|
+
throw new ValidationError(`--reference-dir is not a valid directory: ${absolutePath}. No project files were changed.`);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
provided: true,
|
|
90
|
+
status: "READY_READ_ONLY",
|
|
91
|
+
absolutePath,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function listFilesRecursive(rootDirectory) {
|
|
96
|
+
const results = [];
|
|
97
|
+
const stack = [rootDirectory];
|
|
98
|
+
while (stack.length > 0) {
|
|
99
|
+
const current = stack.pop();
|
|
100
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const absolutePath = path.join(current, entry.name);
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
stack.push(absolutePath);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry.isFile()) {
|
|
108
|
+
results.push(absolutePath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
results.sort();
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tokenSet(value) {
|
|
117
|
+
return new Set(
|
|
118
|
+
String(value || "")
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
121
|
+
.split(" ")
|
|
122
|
+
.map((token) => token.trim())
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readHtmlHints(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
130
|
+
const titleMatch = raw.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
131
|
+
const headingMatch = raw.match(/<h[1-2][^>]*>(.*?)<\/h[1-2]>/i);
|
|
132
|
+
return {
|
|
133
|
+
title: titleMatch ? titleMatch[1].replace(/<[^>]+>/g, " ").trim() : "",
|
|
134
|
+
heading: headingMatch ? headingMatch[1].replace(/<[^>]+>/g, " ").trim() : "",
|
|
135
|
+
};
|
|
136
|
+
} catch (_err) {
|
|
137
|
+
return { title: "", heading: "" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function confidenceFromScore(score) {
|
|
142
|
+
if (score >= 4) return "high";
|
|
143
|
+
if (score >= 2) return "medium";
|
|
144
|
+
if (score >= 1) return "low";
|
|
145
|
+
return "low";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mapReferenceFiles({ referenceDir, screens }) {
|
|
149
|
+
const files = listFilesRecursive(referenceDir);
|
|
150
|
+
const entries = [];
|
|
151
|
+
const screenTargets = Array.isArray(screens) ? screens : [];
|
|
152
|
+
const supportedExtensions = new Set([".html", ".htm", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]);
|
|
153
|
+
for (const absolutePath of files) {
|
|
154
|
+
const relativePath = toPosixRelative(referenceDir, absolutePath);
|
|
155
|
+
const extension = path.extname(absolutePath).toLowerCase();
|
|
156
|
+
if (!supportedExtensions.has(extension)) {
|
|
157
|
+
entries.push({
|
|
158
|
+
sourceFileRelativePath: relativePath,
|
|
159
|
+
fileType: extension || "unknown",
|
|
160
|
+
mappedScreenId: null,
|
|
161
|
+
mappedScreenTitle: null,
|
|
162
|
+
confidence: "low",
|
|
163
|
+
evidenceNotes: ["unsupported file type"],
|
|
164
|
+
status: "unmapped",
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const baseTokens = tokenSet(path.basename(absolutePath, extension));
|
|
170
|
+
const htmlHints = extension === ".html" || extension === ".htm" ? readHtmlHints(absolutePath) : { title: "", heading: "" };
|
|
171
|
+
const titleTokens = tokenSet(htmlHints.title);
|
|
172
|
+
const headingTokens = tokenSet(htmlHints.heading);
|
|
173
|
+
|
|
174
|
+
let bestMatch = null;
|
|
175
|
+
for (const screen of screenTargets) {
|
|
176
|
+
const screenTokens = tokenSet(`${screen.screenId} ${screen.title}`);
|
|
177
|
+
let score = 0;
|
|
178
|
+
const evidence = [];
|
|
179
|
+
for (const token of screenTokens) {
|
|
180
|
+
if (baseTokens.has(token)) {
|
|
181
|
+
score += 2;
|
|
182
|
+
evidence.push(`filename token match: ${token}`);
|
|
183
|
+
}
|
|
184
|
+
if (titleTokens.has(token)) {
|
|
185
|
+
score += 1;
|
|
186
|
+
evidence.push(`title token match: ${token}`);
|
|
187
|
+
}
|
|
188
|
+
if (headingTokens.has(token)) {
|
|
189
|
+
score += 1;
|
|
190
|
+
evidence.push(`heading token match: ${token}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!bestMatch || score > bestMatch.score) {
|
|
194
|
+
bestMatch = { score, screen, evidence };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!bestMatch || bestMatch.score <= 0) {
|
|
199
|
+
entries.push({
|
|
200
|
+
sourceFileRelativePath: relativePath,
|
|
201
|
+
fileType: extension,
|
|
202
|
+
mappedScreenId: null,
|
|
203
|
+
mappedScreenTitle: null,
|
|
204
|
+
confidence: "low",
|
|
205
|
+
evidenceNotes: ["no deterministic filename/title/heading match"],
|
|
206
|
+
status: "unmapped",
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
entries.push({
|
|
212
|
+
sourceFileRelativePath: relativePath,
|
|
213
|
+
fileType: extension,
|
|
214
|
+
mappedScreenId: bestMatch.screen.screenId,
|
|
215
|
+
mappedScreenTitle: bestMatch.screen.title,
|
|
216
|
+
confidence: confidenceFromScore(bestMatch.score),
|
|
217
|
+
evidenceNotes: bestMatch.evidence,
|
|
218
|
+
status: "mapped",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
totalFiles: entries.length,
|
|
224
|
+
mappedCount: entries.filter((entry) => entry.status === "mapped").length,
|
|
225
|
+
unmappedCount: entries.filter((entry) => entry.status !== "mapped").length,
|
|
226
|
+
entries,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function writeInputContractState(projectPath, statePayload) {
|
|
231
|
+
const paths = describeDesignPaths(projectPath);
|
|
232
|
+
fs.mkdirSync(paths.designStatePath, { recursive: true });
|
|
233
|
+
fs.writeFileSync(paths.designStartInputStatePath, `${JSON.stringify(statePayload, null, 2)}\n`, "utf-8");
|
|
234
|
+
return paths.designStartInputStatePath;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function toScreenId(value) {
|
|
238
|
+
return String(value || "")
|
|
239
|
+
.trim()
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
242
|
+
.replace(/^-+|-+$/g, "");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function compactString(value) {
|
|
246
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function firstString(raw, fieldNames) {
|
|
250
|
+
for (const fieldName of fieldNames) {
|
|
251
|
+
const value = raw && raw[fieldName];
|
|
252
|
+
const compacted = compactString(value);
|
|
253
|
+
if (compacted) {
|
|
254
|
+
return compacted;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeStringList(value) {
|
|
261
|
+
if (!Array.isArray(value)) {
|
|
262
|
+
const single = compactString(value);
|
|
263
|
+
return single ? [single] : [];
|
|
264
|
+
}
|
|
265
|
+
return value
|
|
266
|
+
.map((item) => {
|
|
267
|
+
if (typeof item === "string") {
|
|
268
|
+
return compactString(item);
|
|
269
|
+
}
|
|
270
|
+
if (item && typeof item === "object") {
|
|
271
|
+
return compactString(item.title || item.name || item.label || item.id || item.description);
|
|
272
|
+
}
|
|
273
|
+
return "";
|
|
274
|
+
})
|
|
275
|
+
.filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function firstStringList(raw, fieldNames) {
|
|
279
|
+
for (const fieldName of fieldNames) {
|
|
280
|
+
const values = normalizeStringList(raw && raw[fieldName]);
|
|
281
|
+
if (values.length > 0) {
|
|
282
|
+
return values;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeSectionRecord(value, index) {
|
|
289
|
+
if (typeof value === "string") {
|
|
290
|
+
const title = compactString(value);
|
|
291
|
+
return title
|
|
292
|
+
? {
|
|
293
|
+
id: toScreenId(title) || `section-${index + 1}`,
|
|
294
|
+
title,
|
|
295
|
+
purpose: null,
|
|
296
|
+
components: [],
|
|
297
|
+
states: [],
|
|
298
|
+
interactions: [],
|
|
299
|
+
data_slots: [],
|
|
300
|
+
}
|
|
301
|
+
: null;
|
|
302
|
+
}
|
|
303
|
+
if (!value || typeof value !== "object") {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const title = firstString(value, ["title", "name", "label", "heading"]);
|
|
307
|
+
if (!title) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
id: toScreenId(firstString(value, ["id", "slug"]) || title) || `section-${index + 1}`,
|
|
312
|
+
title,
|
|
313
|
+
purpose: firstString(value, ["purpose", "intent", "description"]) || null,
|
|
314
|
+
components: firstStringList(value, ["components", "requiredComponents", "required_components"]),
|
|
315
|
+
states: firstStringList(value, ["states", "requiredStates", "required_states"]),
|
|
316
|
+
interactions: firstStringList(value, ["interactions", "interactionNotes", "interaction_notes"]),
|
|
317
|
+
data_slots: firstStringList(value, ["dataSlots", "data_slots", "contentSlots", "content_slots"]),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function firstSectionList(raw) {
|
|
322
|
+
const sectionCandidates = [
|
|
323
|
+
raw && raw.sections,
|
|
324
|
+
raw && raw.majorSections,
|
|
325
|
+
raw && raw.major_sections,
|
|
326
|
+
raw && raw.sectionsTopToBottom,
|
|
327
|
+
raw && raw.sections_top_to_bottom,
|
|
328
|
+
].filter(Array.isArray);
|
|
329
|
+
for (const candidate of sectionCandidates) {
|
|
330
|
+
const sections = candidate.map((item, index) => normalizeSectionRecord(item, index)).filter(Boolean);
|
|
331
|
+
if (sections.length > 0) {
|
|
332
|
+
return sections;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeVisualTokenUsage(value) {
|
|
339
|
+
const payload = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
340
|
+
return {
|
|
341
|
+
palette_keys: firstStringList(payload, ["palette_keys", "paletteKeys"]),
|
|
342
|
+
typography_keys: firstStringList(payload, ["typography_keys", "typographyKeys"]),
|
|
343
|
+
spacing_keys: firstStringList(payload, ["spacing_keys", "spacingKeys"]),
|
|
344
|
+
radius_keys: firstStringList(payload, ["radius_keys", "radiusKeys"]),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeTemplateRole(value) {
|
|
349
|
+
const normalized = toScreenId(value);
|
|
350
|
+
if (!normalized) {
|
|
351
|
+
return "";
|
|
352
|
+
}
|
|
353
|
+
const aliases = {
|
|
354
|
+
product: "product-detail",
|
|
355
|
+
pdp: "product-detail",
|
|
356
|
+
productdetail: "product-detail",
|
|
357
|
+
orders: "order-history",
|
|
358
|
+
order: "order-detail",
|
|
359
|
+
account: "account-info",
|
|
360
|
+
configurator: "configurator-bom",
|
|
361
|
+
bom: "configurator-bom",
|
|
362
|
+
"mode-b-configurator": "configurator-bom",
|
|
363
|
+
};
|
|
364
|
+
const role = aliases[normalized] || normalized;
|
|
365
|
+
return TEMPLATE_ROLES.has(role) ? role : "";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function inferTemplateRole(raw, screenId, title) {
|
|
369
|
+
const explicit = normalizeTemplateRole(
|
|
370
|
+
firstString(raw, ["templateRole", "template_role", "screenRole", "screen_role", "role"])
|
|
371
|
+
);
|
|
372
|
+
if (explicit) {
|
|
373
|
+
return explicit;
|
|
374
|
+
}
|
|
375
|
+
const combined = toScreenId(`${screenId} ${title}`);
|
|
376
|
+
const roleMatchers = [
|
|
377
|
+
["product-detail", ["product-detail", "productdetail", "pdp"]],
|
|
378
|
+
["order-history", ["order-history", "orderhistory", "orders"]],
|
|
379
|
+
["order-detail", ["order-detail", "orderdetail"]],
|
|
380
|
+
["account-info", ["account-info", "accountinfo", "account"]],
|
|
381
|
+
["configurator-bom", ["configurator", "bom", "mode-b"]],
|
|
382
|
+
["checkout", ["checkout"]],
|
|
383
|
+
["category", ["category", "catalog"]],
|
|
384
|
+
["search", ["search"]],
|
|
385
|
+
["cart", ["cart"]],
|
|
386
|
+
["home", ["home", "landing"]],
|
|
387
|
+
];
|
|
388
|
+
for (const [role, needles] of roleMatchers) {
|
|
389
|
+
if (needles.some((needle) => combined.includes(needle))) {
|
|
390
|
+
return role;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return "generic";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeSourceEvidence(raw, sourceArtifact) {
|
|
397
|
+
const payload =
|
|
398
|
+
raw && raw.sourceEvidence && typeof raw.sourceEvidence === "object"
|
|
399
|
+
? raw.sourceEvidence
|
|
400
|
+
: raw && raw.source_evidence && typeof raw.source_evidence === "object"
|
|
401
|
+
? raw.source_evidence
|
|
402
|
+
: {};
|
|
403
|
+
return {
|
|
404
|
+
source_artifact: firstString(payload, ["source_artifact", "sourceArtifact"]) || sourceArtifact || null,
|
|
405
|
+
source_status: firstString(raw, ["sourceStatus", "source_status"]) || "explicit",
|
|
406
|
+
source_quote: firstString(raw, ["source_quote", "sourceQuote"]) || firstString(payload, ["source_quote", "sourceQuote"]) || null,
|
|
407
|
+
source_line_range: Array.isArray(raw && raw.source_line_range)
|
|
408
|
+
? raw.source_line_range
|
|
409
|
+
: Array.isArray(raw && raw.sourceLineRange)
|
|
410
|
+
? raw.sourceLineRange
|
|
411
|
+
: Array.isArray(payload.source_line_range)
|
|
412
|
+
? payload.source_line_range
|
|
413
|
+
: Array.isArray(payload.sourceLineRange)
|
|
414
|
+
? payload.sourceLineRange
|
|
415
|
+
: null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function normalizedScreenRecord(raw, sourceArtifact, fallbackTitle) {
|
|
420
|
+
const title = String(
|
|
421
|
+
(raw && (raw.title || raw.name || raw.screen || raw.label || raw.id)) || fallbackTitle || ""
|
|
422
|
+
).trim();
|
|
423
|
+
if (!title) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const screenId = toScreenId((raw && (raw.id || raw.slug)) || title);
|
|
427
|
+
const route = firstString(raw, ["route", "path", "url"]);
|
|
428
|
+
const purpose = firstString(raw, ["purpose", "screenPurpose", "screen_purpose", "goal"]);
|
|
429
|
+
const userIntent = firstString(raw, ["userIntent", "user_intent", "intent"]);
|
|
430
|
+
const primaryAction = firstString(raw, ["primaryAction", "primary_action", "cta"]);
|
|
431
|
+
const sections = firstSectionList(raw);
|
|
432
|
+
const majorSections = sections.map((section) => section.title);
|
|
433
|
+
const layoutModel = firstString(raw, ["layoutModel", "layout_model"]);
|
|
434
|
+
const density = firstString(raw, ["density", "layoutDensity", "layout_density"]);
|
|
435
|
+
const requiredComponents = firstStringList(raw, ["components", "requiredComponents", "required_components", "componentRequirements", "component_requirements"]);
|
|
436
|
+
const requiredStates = firstStringList(raw, ["states", "requiredStates", "required_states"]);
|
|
437
|
+
const interactions = firstStringList(raw, ["interactions", "interactionNotes", "interaction_notes"]);
|
|
438
|
+
const dataSlots = firstStringList(raw, ["dataSlots", "data_slots", "contentSlots", "content_slots"]);
|
|
439
|
+
const accessibilityNotes = firstStringList(raw, ["accessibilityNotes", "accessibility_notes", "a11y"]);
|
|
440
|
+
const visualTokenUsage = normalizeVisualTokenUsage((raw && (raw.visualTokenUsage || raw.visual_token_usage)) || {});
|
|
441
|
+
const acceptanceCriteria = firstStringList(raw, ["acceptanceCriteria", "acceptance_criteria", "criteria"]);
|
|
442
|
+
const templateRole = inferTemplateRole(raw || {}, screenId, title);
|
|
443
|
+
const sourceEvidence = normalizeSourceEvidence(raw || {}, sourceArtifact);
|
|
444
|
+
const populatedCount =
|
|
445
|
+
[route, purpose, userIntent, primaryAction, layoutModel, density].filter(Boolean).length +
|
|
446
|
+
(sections.length > 0 ? 1 : 0) +
|
|
447
|
+
(requiredComponents.length > 0 ? 1 : 0) +
|
|
448
|
+
(requiredStates.length > 0 ? 1 : 0) +
|
|
449
|
+
(interactions.length > 0 ? 1 : 0) +
|
|
450
|
+
(dataSlots.length > 0 ? 1 : 0) +
|
|
451
|
+
(accessibilityNotes.length > 0 ? 1 : 0) +
|
|
452
|
+
(acceptanceCriteria.length > 0 ? 1 : 0) +
|
|
453
|
+
(sourceEvidence.source_quote ? 1 : 0);
|
|
454
|
+
const confidence = populatedCount >= 3 ? "high" : populatedCount >= 1 ? "medium" : "low";
|
|
455
|
+
return {
|
|
456
|
+
screenId,
|
|
457
|
+
title,
|
|
458
|
+
route: route || null,
|
|
459
|
+
purpose: purpose || null,
|
|
460
|
+
userIntent: userIntent || null,
|
|
461
|
+
primaryAction: primaryAction || null,
|
|
462
|
+
sections,
|
|
463
|
+
majorSections,
|
|
464
|
+
layoutModel: layoutModel || null,
|
|
465
|
+
density: density || null,
|
|
466
|
+
requiredComponents,
|
|
467
|
+
requiredStates,
|
|
468
|
+
interactions,
|
|
469
|
+
dataSlots,
|
|
470
|
+
accessibilityNotes,
|
|
471
|
+
visualTokenUsage,
|
|
472
|
+
acceptanceCriteria,
|
|
473
|
+
templateRole,
|
|
474
|
+
sourceEvidence,
|
|
475
|
+
sourceArtifact,
|
|
476
|
+
sourceStatus: "explicit",
|
|
477
|
+
confidence,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function extractScreensFromHandoffJson(handoffPath, relativePath) {
|
|
482
|
+
const raw = fs.readFileSync(handoffPath, "utf-8");
|
|
483
|
+
let payload = null;
|
|
484
|
+
try {
|
|
485
|
+
payload = JSON.parse(raw);
|
|
486
|
+
} catch (_err) {
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
const candidateArrays = [
|
|
490
|
+
payload && payload.screens,
|
|
491
|
+
payload && payload.screenInventory,
|
|
492
|
+
payload && payload.screen_inventory,
|
|
493
|
+
payload && payload.screenList,
|
|
494
|
+
payload && payload.screen_list,
|
|
495
|
+
].filter(Array.isArray);
|
|
496
|
+
const screens = [];
|
|
497
|
+
for (const candidateArray of candidateArrays) {
|
|
498
|
+
for (const item of candidateArray) {
|
|
499
|
+
const record =
|
|
500
|
+
typeof item === "string"
|
|
501
|
+
? normalizedScreenRecord({ title: item }, relativePath, item)
|
|
502
|
+
: normalizedScreenRecord(item || {}, relativePath, "");
|
|
503
|
+
if (record) {
|
|
504
|
+
screens.push(record);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return screens;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function extractScreensFromInventoryMarkdown(inventoryPath, relativePath) {
|
|
512
|
+
const lines = fs.readFileSync(inventoryPath, "utf-8").split(/\r?\n/);
|
|
513
|
+
const screens = [];
|
|
514
|
+
for (const line of lines) {
|
|
515
|
+
const headingMatch = line.match(/^###\s+(.+?)\s*$/);
|
|
516
|
+
if (headingMatch) {
|
|
517
|
+
const record = normalizedScreenRecord({ title: headingMatch[1] }, relativePath, headingMatch[1]);
|
|
518
|
+
if (record) screens.push(record);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const listMatch = line.match(/^\s*-\s+(.+?)\s*$/);
|
|
522
|
+
if (listMatch) {
|
|
523
|
+
const candidate = listMatch[1].replace(/`/g, "").trim();
|
|
524
|
+
if (!candidate || candidate.includes(":")) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const record = normalizedScreenRecord({ title: candidate }, relativePath, candidate);
|
|
528
|
+
if (record) screens.push(record);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return screens;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function dedupeScreens(screens) {
|
|
535
|
+
const byId = new Map();
|
|
536
|
+
for (const screen of screens) {
|
|
537
|
+
if (!screen || !screen.screenId) {
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (!byId.has(screen.screenId)) {
|
|
541
|
+
byId.set(screen.screenId, screen);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const existing = byId.get(screen.screenId);
|
|
545
|
+
const existingScore = (existing.route ? 1 : 0) + (existing.purpose ? 1 : 0) + (existing.primaryAction ? 1 : 0) + existing.majorSections.length + existing.requiredStates.length;
|
|
546
|
+
const newScore = (screen.route ? 1 : 0) + (screen.purpose ? 1 : 0) + (screen.primaryAction ? 1 : 0) + screen.majorSections.length + screen.requiredStates.length;
|
|
547
|
+
if (newScore > existingScore) {
|
|
548
|
+
byId.set(screen.screenId, screen);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return Array.from(byId.values());
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildInputContractState({
|
|
555
|
+
fromSpecPath,
|
|
556
|
+
projectPath,
|
|
557
|
+
designBriefPath,
|
|
558
|
+
referenceDir,
|
|
559
|
+
profile,
|
|
560
|
+
}) {
|
|
561
|
+
const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
|
|
562
|
+
const resolvedSpecPath = resolveProjectPath(fromSpecPath || resolvedProjectPath);
|
|
563
|
+
if (!fs.existsSync(resolvedSpecPath) || !fs.statSync(resolvedSpecPath).isDirectory()) {
|
|
564
|
+
throw new ValidationError(`--from-spec is not a valid directory: ${resolvedSpecPath}. No project files were changed.`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const selectedProfile = resolveProfile(profile);
|
|
568
|
+
|
|
569
|
+
const artifactRules = [
|
|
570
|
+
{ key: "designHandoff", relativeDir: "docs/design", pattern: /^SPEC_TO_DESIGN_HANDOFF_.*\.json$/i, blocker: "NEEDS_DESIGN_HANDOFF" },
|
|
571
|
+
{ key: "screenInventory", relativeDir: "docs/design", pattern: /^SCREEN_INVENTORY_.*\.md$/i, blocker: null },
|
|
572
|
+
{ key: "userFlows", relativeDir: "docs/design", pattern: /^USER_FLOWS_.*\.md$/i, blocker: "NEEDS_USER_FLOWS" },
|
|
573
|
+
{ key: "domainModel", relativeDir: "docs/specs", pattern: /^DOMAIN_MODEL_.*\.md$/i, blocker: "NEEDS_DOMAIN_MODEL" },
|
|
574
|
+
{ key: "archDesign", relativeDir: "docs/architecture", pattern: /^ARCH_DESIGN_.*\.md$/i, blocker: null },
|
|
575
|
+
{ key: "baSpec", relativeDir: "docs/specs", pattern: /^BA_SPEC_.*\.md$/i, blocker: null },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
const artifacts = {};
|
|
579
|
+
for (const rule of artifactRules) {
|
|
580
|
+
artifacts[rule.key] = findArtifactByPattern(resolvedSpecPath, rule.relativeDir, rule.pattern);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let explicitDesignBrief = null;
|
|
584
|
+
if (designBriefPath) {
|
|
585
|
+
const absoluteDesignBrief = path.resolve(resolvedProjectPath, designBriefPath);
|
|
586
|
+
if (!fs.existsSync(absoluteDesignBrief) || !fs.statSync(absoluteDesignBrief).isFile()) {
|
|
587
|
+
throw new ValidationError(`--design-brief is not a valid file: ${absoluteDesignBrief}. No project files were changed.`);
|
|
588
|
+
}
|
|
589
|
+
explicitDesignBrief = {
|
|
590
|
+
absolutePath: absoluteDesignBrief,
|
|
591
|
+
relativeToProject: toPosixRelative(resolvedProjectPath, absoluteDesignBrief),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const reference = resolveReferenceDirectory(referenceDir, resolvedProjectPath);
|
|
596
|
+
const parsedScreens = [];
|
|
597
|
+
if (artifacts.designHandoff) {
|
|
598
|
+
parsedScreens.push(
|
|
599
|
+
...extractScreensFromHandoffJson(artifacts.designHandoff.absolutePath, artifacts.designHandoff.relativePath)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (artifacts.screenInventory) {
|
|
603
|
+
parsedScreens.push(
|
|
604
|
+
...extractScreensFromInventoryMarkdown(artifacts.screenInventory.absolutePath, artifacts.screenInventory.relativePath)
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const screens = dedupeScreens(parsedScreens);
|
|
608
|
+
|
|
609
|
+
const blockers = artifactRules.filter((rule) => rule.blocker && !artifacts[rule.key]).map((rule) => rule.blocker);
|
|
610
|
+
if (screens.length === 0) {
|
|
611
|
+
blockers.push("NEEDS_SCREEN_INVENTORY");
|
|
612
|
+
}
|
|
613
|
+
const analysisStatus = blockers.length === 0 ? "INPUT_CONTRACT_READY" : "INPUT_CONTRACT_BLOCKED";
|
|
614
|
+
|
|
615
|
+
const artifactSummary = {};
|
|
616
|
+
for (const rule of artifactRules) {
|
|
617
|
+
const value = artifacts[rule.key];
|
|
618
|
+
artifactSummary[rule.key] = value
|
|
619
|
+
? {
|
|
620
|
+
found: true,
|
|
621
|
+
relativeToSpecRoot: value.relativePath,
|
|
622
|
+
absolutePath: value.absolutePath,
|
|
623
|
+
}
|
|
624
|
+
: {
|
|
625
|
+
found: false,
|
|
626
|
+
relativeToSpecRoot: null,
|
|
627
|
+
absolutePath: null,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
schema: "sdtk.design.input-contract.v1",
|
|
633
|
+
mode: "from-spec",
|
|
634
|
+
projectPath: resolvedProjectPath,
|
|
635
|
+
fromSpecPath: resolvedSpecPath,
|
|
636
|
+
profileSelection: selectedProfile ? selectedProfile.name : null,
|
|
637
|
+
profilePrimitives: selectedProfile
|
|
638
|
+
? {
|
|
639
|
+
name: selectedProfile.name,
|
|
640
|
+
summary: selectedProfile.summary,
|
|
641
|
+
primitives: selectedProfile.primitives,
|
|
642
|
+
screenRoleHints: selectedProfile.screenRoleHints,
|
|
643
|
+
}
|
|
644
|
+
: null,
|
|
645
|
+
explicitDesignBrief,
|
|
646
|
+
referenceDirectory: {
|
|
647
|
+
provided: reference.provided,
|
|
648
|
+
status: reference.status,
|
|
649
|
+
absolutePath: reference.absolutePath,
|
|
650
|
+
},
|
|
651
|
+
referenceMap: reference.provided
|
|
652
|
+
? mapReferenceFiles({ referenceDir: reference.absolutePath, screens })
|
|
653
|
+
: { totalFiles: 0, mappedCount: 0, unmappedCount: 0, entries: [] },
|
|
654
|
+
artifacts: artifactSummary,
|
|
655
|
+
screenModel: {
|
|
656
|
+
totalScreens: screens.length,
|
|
657
|
+
missingMetadataCount: screens.filter(
|
|
658
|
+
(screen) =>
|
|
659
|
+
!screen.route ||
|
|
660
|
+
!screen.purpose ||
|
|
661
|
+
!screen.userIntent ||
|
|
662
|
+
screen.sections.length === 0 ||
|
|
663
|
+
screen.requiredComponents.length === 0 ||
|
|
664
|
+
screen.requiredStates.length === 0 ||
|
|
665
|
+
screen.interactions.length === 0 ||
|
|
666
|
+
screen.dataSlots.length === 0 ||
|
|
667
|
+
screen.acceptanceCriteria.length === 0
|
|
668
|
+
).length,
|
|
669
|
+
legacyFallbackUsed: false,
|
|
670
|
+
readiness: screens.length > 0 ? "MULTI_SCREEN_READY" : "NEEDS_EXPLICIT_SCREEN_INPUT",
|
|
671
|
+
screens,
|
|
672
|
+
},
|
|
673
|
+
blockers,
|
|
674
|
+
analysisStatus,
|
|
675
|
+
nextRecommendedCommand:
|
|
676
|
+
blockers.length > 0 ? "Provide missing explicit SPEC/design artifacts and re-run sdtk-design start --from-spec." : "sdtk-design prototype",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
module.exports = {
|
|
681
|
+
buildInputContractState,
|
|
682
|
+
writeInputContractState,
|
|
683
|
+
};
|