sdtk-design-kit 0.2.1 → 0.3.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.
@@ -43,36 +43,107 @@ function listOrNeeds(values, marker) {
43
43
  return `- ${needs(marker)}`;
44
44
  }
45
45
 
46
- function tokenUsageOrDefault(value) {
46
+ // BK-227: bounded enum — sections with repeated-slot content render NEEDS_* as <a> skeleton.
47
+ const INTERACTIVE_COMPONENT_PATTERNS = [
48
+ "product-card", "product-row", "order-row", "order-card",
49
+ "search-result", "search-row", "result-row",
50
+ "nav-item", "list-item", "category-card", "catalog-card",
51
+ ];
52
+
53
+ const INTERACTIVE_TEMPLATE_CONTEXTS = {
54
+ category: ["grid", "products", "items", "list", "catalog"],
55
+ search: ["results", "items", "matches"],
56
+ "order-history": ["orders", "list", "rows", "history"],
57
+ home: ["categories", "featured", "grid", "cards"],
58
+ "product-detail": ["related", "recommendations"],
59
+ };
60
+
61
+ // BK-230: normalize identifier for naming-convention-agnostic comparison
62
+ // (matches PascalCase, kebab-case, snake_case, spaced — all collapse to lower-alphanum).
63
+ function normalizeIdentifier(value) {
64
+ return String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
65
+ }
66
+
67
+ // BK-230: precompute normalized pattern + keyword sets at module load.
68
+ const INTERACTIVE_COMPONENT_PATTERNS_NORM = INTERACTIVE_COMPONENT_PATTERNS.map(normalizeIdentifier);
69
+ const INTERACTIVE_TEMPLATE_CONTEXTS_NORM = Object.fromEntries(
70
+ Object.entries(INTERACTIVE_TEMPLATE_CONTEXTS).map(([k, kws]) => [
71
+ normalizeIdentifier(k),
72
+ kws.map(normalizeIdentifier),
73
+ ])
74
+ );
75
+
76
+ function isInteractiveSection(section, screen) {
77
+ if (!section || !screen) return false;
78
+ const components = Array.isArray(section.components)
79
+ ? section.components.map(normalizeIdentifier)
80
+ : [];
81
+ // Rule 1: explicit component pattern match (normalized both sides).
82
+ for (const pattern of INTERACTIVE_COMPONENT_PATTERNS_NORM) {
83
+ if (components.some((c) => c.includes(pattern))) return true;
84
+ }
85
+ // Rule 2: template_role + section title/components keyword (normalized).
86
+ const templateRole = normalizeIdentifier(screen.templateRole || screen.template_role || "");
87
+ const contextKeywords = INTERACTIVE_TEMPLATE_CONTEXTS_NORM[templateRole] || [];
88
+ if (contextKeywords.length === 0) return false;
89
+ const title = normalizeIdentifier(section.title || "");
90
+ for (const kw of contextKeywords) {
91
+ if (title.includes(kw)) return true;
92
+ if (components.some((c) => c.includes(kw))) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function interactiveDataSlotsForSection(section, screen) {
98
+ if (!isInteractiveSection(section, screen)) return [];
99
+ return Array.isArray(section.data_slots) ? cleanArray(section.data_slots) : [];
100
+ }
101
+
102
+ // BK-225: v3 visual_token_usage aligned to BK-223 v2 token schema key paths.
103
+ const TOKEN_USAGE_V3_DEFAULTS = {
104
+ palette_keys: [
105
+ "palette.surface", "palette.surfaceAlt", "palette.textPrimary", "palette.textMuted",
106
+ "palette.primary", "palette.accent", "palette.border",
107
+ "palette.state.success.base", "palette.state.error.base",
108
+ ],
109
+ typography_keys: [
110
+ "typography.fontFamily.body",
111
+ "typography.ramp.h1", "typography.ramp.h2", "typography.ramp.h3",
112
+ "typography.ramp.body", "typography.ramp.caption",
113
+ ],
114
+ section_keys: [
115
+ "section.containerMaxWidth", "section.contentMaxWidth",
116
+ "section.sectionGap", "section.heroMinHeight", "section.gridColumns",
117
+ ],
118
+ component_keys: [
119
+ "component.radius.card", "component.radius.control",
120
+ "component.shadow.sm", "component.shadow.md",
121
+ "component.borderWidth.default",
122
+ ],
123
+ };
124
+
125
+ function tokenUsageV3OrDefault(value) {
47
126
  const payload = value && typeof value === "object" && !Array.isArray(value) ? value : {};
48
127
  const tokenUsage = {
49
128
  palette_keys: cleanArray(payload.palette_keys || payload.paletteKeys),
50
129
  typography_keys: cleanArray(payload.typography_keys || payload.typographyKeys),
51
- spacing_keys: cleanArray(payload.spacing_keys || payload.spacingKeys),
52
- radius_keys: cleanArray(payload.radius_keys || payload.radiusKeys),
130
+ section_keys: cleanArray(payload.section_keys || payload.sectionKeys),
131
+ component_keys: cleanArray(payload.component_keys || payload.componentKeys),
53
132
  };
54
- if (
55
- tokenUsage.palette_keys.length === 0 &&
56
- tokenUsage.typography_keys.length === 0 &&
57
- tokenUsage.spacing_keys.length === 0 &&
58
- tokenUsage.radius_keys.length === 0
59
- ) {
60
- return {
61
- palette_keys: ["color.surface", "color.textPrimary", "color.accentPrimary"],
62
- typography_keys: ["typography.fontFamily", "typography.uiWeight"],
63
- spacing_keys: ["spacing.md", "spacing.lg", "spacing.sectionGap"],
64
- radius_keys: ["radius.card", "radius.control"],
65
- };
66
- }
67
- return tokenUsage;
133
+ const hasAny =
134
+ tokenUsage.palette_keys.length > 0 ||
135
+ tokenUsage.typography_keys.length > 0 ||
136
+ tokenUsage.section_keys.length > 0 ||
137
+ tokenUsage.component_keys.length > 0;
138
+ return hasAny ? tokenUsage : { ...TOKEN_USAGE_V3_DEFAULTS };
68
139
  }
69
140
 
70
141
  function tokenUsageMarkdown(tokenUsage) {
71
142
  return [
72
143
  `- Palette keys: ${tokenUsage.palette_keys.length > 0 ? tokenUsage.palette_keys.join(", ") : needs("PALETTE_KEYS")}`,
73
144
  `- Typography keys: ${tokenUsage.typography_keys.length > 0 ? tokenUsage.typography_keys.join(", ") : needs("TYPOGRAPHY_KEYS")}`,
74
- `- Spacing keys: ${tokenUsage.spacing_keys.length > 0 ? tokenUsage.spacing_keys.join(", ") : needs("SPACING_KEYS")}`,
75
- `- Radius keys: ${tokenUsage.radius_keys.length > 0 ? tokenUsage.radius_keys.join(", ") : needs("RADIUS_KEYS")}`,
145
+ `- Section keys: ${tokenUsage.section_keys.length > 0 ? tokenUsage.section_keys.join(", ") : needs("SECTION_KEYS")}`,
146
+ `- Component keys: ${tokenUsage.component_keys.length > 0 ? tokenUsage.component_keys.join(", ") : needs("COMPONENT_KEYS")}`,
76
147
  ].join("\n");
77
148
  }
78
149
 
@@ -100,6 +171,90 @@ function densityForRole(templateRole) {
100
171
  return null;
101
172
  }
102
173
 
174
+ // BK-225: derive height_band from section position and role.
175
+ function heightBandForSection(index, total, templateRole) {
176
+ if (index === 0 && templateRole === "home") return "hero";
177
+ if (index === 0) return "standard";
178
+ if (index === total - 1) return "compact";
179
+ return "standard";
180
+ }
181
+
182
+ // BK-225: build visual_direction block for a section.
183
+ // Only uses explicit handoff fields from the normalized section record.
184
+ function visualDirectionForSection(section, screen, index) {
185
+ const totalSections = Array.isArray(screen.sections) ? screen.sections.length : 1;
186
+ const templateRole = screen.templateRole || "generic";
187
+
188
+ // Dimension
189
+ const heightBand = heightBandForSection(index, totalSections, templateRole);
190
+ const hasColumns = typeof section.column_count === "number" || typeof section.columnCount === "number";
191
+ const columnCount = hasColumns ? (section.column_count || section.columnCount) : null;
192
+ const splitRatio = section.split_ratio || section.splitRatio || null;
193
+ const dimension = {
194
+ height_band: heightBand,
195
+ column_count: columnCount !== null ? columnCount : needs("COLUMN_COUNT"),
196
+ split_ratio: splitRatio || needs("SPLIT_RATIO"),
197
+ };
198
+
199
+ // Layout intent: derive from explicit layout_model or section components.
200
+ const hasComponents = Array.isArray(section.components) && section.components.length > 0;
201
+ const sectionLayoutIntent = section.layout_intent || section.layoutIntent || null;
202
+ let layoutIntent;
203
+ if (sectionLayoutIntent) {
204
+ layoutIntent = sectionLayoutIntent;
205
+ } else if (hasComponents) {
206
+ layoutIntent = `Render ${section.components.slice(0, 2).join(" and ")} in section.`;
207
+ } else {
208
+ layoutIntent = needs("LAYOUT_INTENT");
209
+ }
210
+
211
+ // Content direction: sourced from explicit handoff fields only.
212
+ const heading = section.title ? section.title : needs("CONTENT_HEADING");
213
+ const body = section.purpose ? section.purpose : needs("CONTENT_BODY");
214
+ const hasDataSlots = Array.isArray(section.data_slots) && section.data_slots.length > 0;
215
+ const dataDirection = hasDataSlots
216
+ ? `Map data_slots into scannable rows: ${section.data_slots.slice(0, 3).join(", ")}.`
217
+ : needs("DATA_DIRECTION");
218
+ const contentDirection = {
219
+ heading,
220
+ body,
221
+ data_direction: dataDirection,
222
+ };
223
+
224
+ // Imagery: sourced from explicit handoff fields only.
225
+ const imageryPlacement = section.imagery_placement || section.imageryPlacement || needs("IMAGERY_PLACEMENT");
226
+ const imagerySubject = section.imagery_subject || section.imagerySubject || needs("IMAGERY_SUBJECT");
227
+ const imageryAspectRatio = section.imagery_aspect_ratio || section.imageryAspectRatio || "16:9";
228
+ const imageryTreatment = section.imagery_treatment || section.imageryTreatment || "token-bounded, non-decorative";
229
+ const imagery = {
230
+ placement: imageryPlacement,
231
+ subject: imagerySubject,
232
+ aspect_ratio: imageryAspectRatio,
233
+ treatment: imageryTreatment,
234
+ };
235
+
236
+ // Source evidence: screen-level is sufficient when section-specific is unavailable.
237
+ const screenEvidence = screen.sourceEvidence || {};
238
+ const sourceStatus = screenEvidence.source_status || "explicit";
239
+ const sourceQuote = screenEvidence.source_quote || needs("SOURCE_QUOTE");
240
+ const sourceLineRange = Array.isArray(screenEvidence.source_line_range)
241
+ ? screenEvidence.source_line_range
242
+ : [1, 1];
243
+ const sourceEvidence = {
244
+ source_status: sourceStatus,
245
+ source_quote: sourceQuote,
246
+ source_line_range: sourceLineRange,
247
+ };
248
+
249
+ return {
250
+ dimension,
251
+ layout_intent: layoutIntent,
252
+ content_direction: contentDirection,
253
+ imagery,
254
+ source_evidence: sourceEvidence,
255
+ };
256
+ }
257
+
103
258
  function sectionMarkdown(sections) {
104
259
  if (!Array.isArray(sections) || sections.length === 0) {
105
260
  return `- ${needs("SECTIONS")}`;
@@ -115,6 +270,10 @@ function sectionMarkdown(sections) {
115
270
  if (Array.isArray(section.data_slots) && section.data_slots.length > 0) {
116
271
  details.push(`data=${section.data_slots.map(escapeMarkdown).join(", ")}`);
117
272
  }
273
+ // BK-227: surface interactive slot list for renderer skill consumption.
274
+ if (Array.isArray(section.interactive_data_slots) && section.interactive_data_slots.length > 0) {
275
+ details.push(`interactive=${section.interactive_data_slots.map(escapeMarkdown).join(", ")}`);
276
+ }
118
277
  if (Array.isArray(section.states) && section.states.length > 0) {
119
278
  details.push(`states=${section.states.map(escapeMarkdown).join(", ")}`);
120
279
  }
@@ -156,14 +315,23 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
156
315
  const dataSlots = uniqueStrings([...(screen.dataSlots || []), ...sectionDataSlots]);
157
316
  const sourceEvidence = screen.sourceEvidence || {};
158
317
  const supportingArtifacts = cleanArray(sourceArtifacts);
318
+
319
+ // BK-225: add visual_direction per section.
320
+ // BK-227: add interactive_data_slots per section (subset of data_slots for repeated-slot contexts).
321
+ const sectionsWithVisualDirection = sections.map((section, index) => ({
322
+ ...section,
323
+ visual_direction: visualDirectionForSection(section, screen, index),
324
+ interactive_data_slots: interactiveDataSlotsForSection(section, screen),
325
+ }));
326
+
159
327
  const model = {
160
- schema: "sdtk.design.screen-brief.v2",
328
+ schema: "sdtk.design.screen-brief.v3",
161
329
  screen_id: screen.screenId,
162
330
  screen_title: screen.title,
163
331
  purpose: screen.purpose || needs("SCREEN_PURPOSE"),
164
332
  user_intent: screen.userIntent || needs("USER_INTENT"),
165
333
  route: screen.route || needs("ROUTE"),
166
- sections,
334
+ sections: sectionsWithVisualDirection,
167
335
  section_titles: sections.map((section) => section.title).filter(Boolean),
168
336
  layout_model: screen.layoutModel || layoutModelForRole(templateRole) || needs("LAYOUT_MODEL"),
169
337
  density: screen.density || densityForRole(templateRole) || needs("DENSITY"),
@@ -179,7 +347,7 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
179
347
  "Expose keyboard focus order for navigation, filters, forms, and primary actions.",
180
348
  "Keep contrast and touch target sizing aligned with DESIGN_TOKENS guidance.",
181
349
  ],
182
- visual_token_usage: tokenUsageOrDefault(screen.visualTokenUsage),
350
+ visual_token_usage: tokenUsageV3OrDefault(screen.visualTokenUsage),
183
351
  acceptance_criteria:
184
352
  cleanArray(screen.acceptanceCriteria).length > 0
185
353
  ? cleanArray(screen.acceptanceCriteria)
@@ -197,7 +365,8 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
197
365
  profile: profileSelection || "generic",
198
366
  confidence: screen.confidence || "low",
199
367
  };
200
- model.gaps = [
368
+
369
+ const gapSources = [
201
370
  model.purpose,
202
371
  model.user_intent,
203
372
  model.route,
@@ -209,10 +378,49 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
209
378
  ...model.data_slots,
210
379
  ...model.acceptance_criteria,
211
380
  model.source_evidence.source_quote,
212
- ].filter((value) => String(value).startsWith("NEEDS_"));
381
+ ];
382
+ // BK-225: include visual_direction NEEDS_* markers in gaps.
383
+ for (const section of sectionsWithVisualDirection) {
384
+ const vd = section.visual_direction;
385
+ if (vd) {
386
+ if (String(vd.layout_intent).startsWith("NEEDS_")) gapSources.push(vd.layout_intent);
387
+ if (String(vd.dimension && vd.dimension.column_count).startsWith("NEEDS_")) gapSources.push(String(vd.dimension.column_count));
388
+ if (String(vd.content_direction && vd.content_direction.heading).startsWith("NEEDS_")) gapSources.push(String(vd.content_direction.heading));
389
+ if (String(vd.imagery && vd.imagery.subject).startsWith("NEEDS_")) gapSources.push(String(vd.imagery.subject));
390
+ }
391
+ }
392
+ model.gaps = gapSources.filter((value) => String(value).startsWith("NEEDS_"));
213
393
  return model;
214
394
  }
215
395
 
396
+ function renderVisualDirectionMarkdown(sections) {
397
+ if (!Array.isArray(sections) || sections.length === 0) {
398
+ return `- ${needs("VISUAL_DIRECTION")}`;
399
+ }
400
+ return sections
401
+ .map((section, index) => {
402
+ const vd = section.visual_direction;
403
+ if (!vd) return `- Section ${index + 1}: ${needs("VISUAL_DIRECTION")}`;
404
+ const title = escapeMarkdown(section.title || `Section ${index + 1}`);
405
+ const dim = vd.dimension || {};
406
+ return [
407
+ `### ${title}`,
408
+ `- Height band: ${escapeMarkdown(dim.height_band || needs("HEIGHT_BAND"))}`,
409
+ `- Column count: ${escapeMarkdown(String(dim.column_count || needs("COLUMN_COUNT")))}`,
410
+ `- Split ratio: ${escapeMarkdown(dim.split_ratio || needs("SPLIT_RATIO"))}`,
411
+ `- Layout intent: ${escapeMarkdown(vd.layout_intent || needs("LAYOUT_INTENT"))}`,
412
+ `- Content heading: ${escapeMarkdown((vd.content_direction && vd.content_direction.heading) || needs("CONTENT_HEADING"))}`,
413
+ `- Content body: ${escapeMarkdown((vd.content_direction && vd.content_direction.body) || needs("CONTENT_BODY"))}`,
414
+ `- Data direction: ${escapeMarkdown((vd.content_direction && vd.content_direction.data_direction) || needs("DATA_DIRECTION"))}`,
415
+ `- Imagery placement: ${escapeMarkdown((vd.imagery && vd.imagery.placement) || needs("IMAGERY_PLACEMENT"))}`,
416
+ `- Imagery subject: ${escapeMarkdown((vd.imagery && vd.imagery.subject) || needs("IMAGERY_SUBJECT"))}`,
417
+ `- Imagery aspect ratio: ${escapeMarkdown((vd.imagery && vd.imagery.aspect_ratio) || "16:9")}`,
418
+ `- Imagery treatment: ${escapeMarkdown((vd.imagery && vd.imagery.treatment) || "token-bounded, non-decorative")}`,
419
+ ].join("\n");
420
+ })
421
+ .join("\n\n");
422
+ }
423
+
216
424
  function renderScreenBrief({ screen, profileSelection, profilePrimitives, sourceArtifacts }) {
217
425
  const model = buildBriefModel({ screen, profileSelection, profilePrimitives, sourceArtifacts });
218
426
 
@@ -254,6 +462,9 @@ ${listOrNeeds(model.data_slots, "DATA_SLOTS")}
254
462
  ## Visual Token Usage
255
463
  ${tokenUsageMarkdown(model.visual_token_usage)}
256
464
 
465
+ ## Section Visual Direction
466
+ ${renderVisualDirectionMarkdown(model.sections)}
467
+
257
468
  ## Accessibility Notes
258
469
  ${listOrNeeds(model.accessibility_notes, "ACCESSIBILITY_NOTES")}
259
470
 
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+
3
+ const { spawn } = require("child_process");
4
+ const path = require("path");
5
+ const { parseFlags } = require("./args");
6
+ const { CliError, ValidationError } = require("./errors");
7
+
8
+ const PACKAGE_NAME = "sdtk-design-kit";
9
+ const PRODUCT_NAME = "SDTK-DESIGN";
10
+ const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
11
+ const NPM_DISPLAY = "npm";
12
+ const NPM_VIEW_ARGS = ["view", PACKAGE_NAME, "version"];
13
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
14
+ const FLAG_DEFS = {
15
+ version: { type: "string" },
16
+ "project-path": { type: "string" },
17
+ "check-only": { type: "boolean" },
18
+ "skip-project-files": { type: "boolean" },
19
+ verbose: { type: "boolean" },
20
+ };
21
+ const pkg = require("../../package.json");
22
+
23
+ let commandExecutor = defaultCommandExecutor;
24
+
25
+ function defaultCommandExecutor(command, args, options = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(command, args, {
28
+ cwd: options.cwd || process.cwd(),
29
+ env: options.env || process.env,
30
+ shell: options.shell || false,
31
+ windowsHide: true,
32
+ });
33
+ let stdout = "";
34
+ let stderr = "";
35
+
36
+ child.stdout.on("data", (chunk) => {
37
+ const text = chunk.toString();
38
+ stdout += text;
39
+ if (options.verbose) process.stdout.write(text);
40
+ });
41
+ child.stderr.on("data", (chunk) => {
42
+ const text = chunk.toString();
43
+ stderr += text;
44
+ if (options.verbose) process.stderr.write(text);
45
+ });
46
+ child.on("error", (error) => {
47
+ if (error && error.code === "ENOENT") {
48
+ reject(new CliError(`Required command not found in PATH: ${command}`));
49
+ return;
50
+ }
51
+ reject(error);
52
+ });
53
+ child.on("close", (exitCode) => {
54
+ resolve({ exitCode: typeof exitCode === "number" ? exitCode : 1, stdout, stderr });
55
+ });
56
+ });
57
+ }
58
+
59
+ function setCommandExecutorForTests(executor) {
60
+ commandExecutor = executor || defaultCommandExecutor;
61
+ }
62
+
63
+ function resetCommandExecutorForTests() {
64
+ commandExecutor = defaultCommandExecutor;
65
+ }
66
+
67
+ function quote(value) {
68
+ const text = String(value);
69
+ return /[\s"]/u.test(text) ? JSON.stringify(text) : text;
70
+ }
71
+
72
+ function formatCommand(command, args) {
73
+ return [command, ...args].map((value) => quote(value)).join(" ");
74
+ }
75
+
76
+ function validateVersion(targetVersion) {
77
+ if (targetVersion !== "latest" && !VERSION_PATTERN.test(targetVersion)) {
78
+ throw new ValidationError(`Invalid value for --version: "${targetVersion}". Must be "latest" or x.y.z.`);
79
+ }
80
+ }
81
+
82
+ function extractResolvedVersion(stdout) {
83
+ const lines = String(stdout || "")
84
+ .split(/\r?\n/u)
85
+ .map((line) => line.trim())
86
+ .filter(Boolean);
87
+ const candidate = (lines[lines.length - 1] || "").replace(/^['"]|['"]$/gu, "");
88
+ if (!VERSION_PATTERN.test(candidate)) {
89
+ throw new CliError(`npm registry lookup returned an invalid version for ${PACKAGE_NAME}: "${candidate || "<empty>"}"`);
90
+ }
91
+ return candidate;
92
+ }
93
+
94
+ async function resolveTargetVersion(options) {
95
+ if (options.requestedVersion !== "latest") {
96
+ return options.requestedVersion;
97
+ }
98
+
99
+ let result;
100
+ try {
101
+ result = await commandExecutor(NPM_BIN, NPM_VIEW_ARGS, {
102
+ verbose: options.verbose,
103
+ shell: process.platform === "win32",
104
+ });
105
+ } catch (error) {
106
+ throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest.\n${error.message}`, error.exitCode || 4);
107
+ }
108
+
109
+ if (result.exitCode !== 0) {
110
+ const detail = (result.stderr || result.stdout || "").trim();
111
+ throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest (exit code ${result.exitCode}).${detail ? `\n${detail}` : ""}`);
112
+ }
113
+
114
+ try {
115
+ return extractResolvedVersion(result.stdout);
116
+ } catch (error) {
117
+ throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest.\n${error.message}`, error.exitCode || 4);
118
+ }
119
+ }
120
+
121
+ function buildPlan(options) {
122
+ const npmArgs = ["install", "-g", `${PACKAGE_NAME}@${options.targetVersion}`];
123
+ return {
124
+ installedVersion: pkg.version,
125
+ requestedVersion: options.requestedVersion,
126
+ targetVersion: options.targetVersion,
127
+ updateNeeded: pkg.version !== options.targetVersion,
128
+ npmArgs,
129
+ npmCommand: formatCommand(NPM_DISPLAY, npmArgs),
130
+ projectPath: options.projectPath,
131
+ checkOnly: options.checkOnly,
132
+ skipProjectFiles: options.skipProjectFiles,
133
+ projectRefreshCommand: options.skipProjectFiles
134
+ ? "skipped (--skip-project-files)"
135
+ : "skipped (R1 package-only update; project files are never mutated)",
136
+ runtimeRefreshCommand: "skipped (no runtime asset update in R1)",
137
+ };
138
+ }
139
+
140
+ function parseUpdateOptions(args) {
141
+ const parsed = parseFlags(args || [], FLAG_DEFS);
142
+ const flags = parsed.flags || {};
143
+ const positional = parsed.positional || parsed.positionals || [];
144
+
145
+ if (positional.length > 0) {
146
+ throw new ValidationError(`Unexpected arguments: ${positional.join(" ")}`);
147
+ }
148
+
149
+ const requestedVersion = flags.version || "latest";
150
+ validateVersion(requestedVersion);
151
+
152
+ return {
153
+ requestedVersion,
154
+ projectPath: path.resolve(flags["project-path"] || process.cwd()),
155
+ checkOnly: Boolean(flags["check-only"]),
156
+ skipProjectFiles: Boolean(flags["skip-project-files"]),
157
+ verbose: Boolean(flags.verbose),
158
+ };
159
+ }
160
+
161
+ function printPlan(plan) {
162
+ console.log(`${PRODUCT_NAME} update plan`);
163
+ console.log(` Installed package version: ${plan.installedVersion}`);
164
+ console.log(` Requested package version: ${plan.requestedVersion}`);
165
+ console.log(` Target package version: ${plan.targetVersion}`);
166
+ console.log(` Package update needed: ${plan.updateNeeded ? "yes" : `no (already installed: ${plan.installedVersion})`}`);
167
+ console.log(` Package refresh command: ${plan.npmCommand}`);
168
+ console.log(` Project path: ${plan.projectPath}`);
169
+ console.log(` Project file refresh: ${plan.projectRefreshCommand}`);
170
+ console.log(` Runtime asset refresh: ${plan.runtimeRefreshCommand}`);
171
+ console.log(` Mode: ${plan.checkOnly ? "check-only (no changes applied)" : "apply"}`);
172
+ }
173
+
174
+ async function runCommand(label, command, args, options) {
175
+ const result = await commandExecutor(command, args, options);
176
+ if (result.exitCode !== 0) {
177
+ const detail = (result.stderr || result.stdout || "").trim();
178
+ throw new CliError(`${label} failed (exit code ${result.exitCode}).${detail ? `\n${detail}` : ""}`);
179
+ }
180
+ return result;
181
+ }
182
+
183
+ async function applyPlan(plan, options) {
184
+ console.log("");
185
+ console.log(`Applying ${PRODUCT_NAME} update...`);
186
+ console.log(` npm refresh: ${plan.npmCommand}`);
187
+ await runCommand("npm package refresh", NPM_BIN, plan.npmArgs, {
188
+ verbose: options.verbose,
189
+ shell: process.platform === "win32",
190
+ });
191
+ console.log(` project refresh: ${plan.projectRefreshCommand}`);
192
+ console.log(` runtime refresh: ${plan.runtimeRefreshCommand}`);
193
+ console.log("");
194
+ console.log(`${PRODUCT_NAME} update completed successfully.`);
195
+ }
196
+
197
+ async function executeUpdate(args) {
198
+ const options = parseUpdateOptions(args);
199
+ const targetVersion = await resolveTargetVersion(options);
200
+ const plan = buildPlan({ ...options, targetVersion });
201
+ printPlan(plan);
202
+
203
+ if (options.checkOnly) {
204
+ return 0;
205
+ }
206
+
207
+ await applyPlan(plan, options);
208
+ return 0;
209
+ }
210
+
211
+ module.exports = {
212
+ buildPlan,
213
+ executeUpdate,
214
+ formatCommand,
215
+ parseUpdateOptions,
216
+ resetCommandExecutorForTests,
217
+ resolveTargetVersion,
218
+ setCommandExecutorForTests,
219
+ };
@@ -1,125 +0,0 @@
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
- };