@contentful/experience-design-system-cli 2.7.4 → 2.11.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.
Files changed (38) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/analyze/build-analyze-view-rows.d.ts +23 -0
  3. package/dist/src/analyze/build-analyze-view-rows.js +39 -0
  4. package/dist/src/analyze/command.js +16 -11
  5. package/dist/src/analyze/extract/validate.d.ts +16 -0
  6. package/dist/src/analyze/extract/validate.js +94 -0
  7. package/dist/src/analyze/select/command.d.ts +47 -0
  8. package/dist/src/analyze/select/command.js +238 -14
  9. package/dist/src/analyze/select/tui/App.js +15 -17
  10. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +4 -1
  11. package/dist/src/analyze/select/tui/components/Sidebar.js +42 -6
  12. package/dist/src/analyze/select/types.d.ts +6 -0
  13. package/dist/src/analyze/select/types.js +24 -7
  14. package/dist/src/analyze/select-agent/command.js +43 -3
  15. package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
  16. package/dist/src/analyze/tui/AnalyzeView.js +5 -2
  17. package/dist/src/apply/api-client.d.ts +20 -0
  18. package/dist/src/apply/api-client.js +71 -5
  19. package/dist/src/generate/command.js +21 -2
  20. package/dist/src/import/command.js +2 -0
  21. package/dist/src/import/orchestrator.d.ts +22 -0
  22. package/dist/src/import/orchestrator.js +97 -14
  23. package/dist/src/import/tui/WizardApp.d.ts +13 -0
  24. package/dist/src/import/tui/WizardApp.js +224 -52
  25. package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
  26. package/dist/src/import/tui/steps/GateStep.js +4 -2
  27. package/dist/src/import/tui/steps/GenerateReviewStep.js +2 -0
  28. package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
  29. package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
  30. package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
  31. package/dist/src/import/tui/wizard-422-helpers.js +60 -0
  32. package/dist/src/program.d.ts +17 -0
  33. package/dist/src/program.js +27 -4
  34. package/dist/src/session/db.d.ts +13 -0
  35. package/dist/src/session/db.js +50 -0
  36. package/dist/src/types.d.ts +8 -0
  37. package/package.json +2 -2
  38. package/skills/generate-components.md +2 -0
@@ -4,7 +4,8 @@ import { Box, Text, useStdout } from 'ink';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { createReviewSessionDetail } from '../types.js';
6
6
  import { TopBar } from './components/TopBar.js';
7
- import { Sidebar } from './components/Sidebar.js';
7
+ import { Sidebar, sortComponentsForSidebar } from './components/Sidebar.js';
8
+ import { countValidationIssues } from '../types.js';
8
9
  import { ComponentDetail } from './components/ComponentDetail.js';
9
10
  import { StatusBar } from './components/StatusBar.js';
10
11
  import { HelpOverlay } from './components/HelpOverlay.js';
@@ -316,22 +317,19 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
316
317
  onToggleHelp: () => setShowHelp((prev) => !prev),
317
318
  });
318
319
  // Must be before early returns — Rules of Hooks
319
- const sessionSummary = useMemo(() => (session?.components ?? [])
320
- .map((c) => ({
321
- id: c.id,
322
- name: c.name,
323
- status: c.status,
324
- previewAnnotation: previewAnnotations[c.name],
325
- extractionConfidence: c.originalProposal.extractionConfidence ?? null,
326
- needsReview: c.originalProposal.needsReview ?? false,
327
- }))
328
- .sort((a, b) => {
329
- const aF = a.needsReview && a.status === 'needs-review' ? 0 : 1;
330
- const bF = b.needsReview && b.status === 'needs-review' ? 0 : 1;
331
- if (aF !== bF)
332
- return aF - bF;
333
- return (a.extractionConfidence ?? 6) - (b.extractionConfidence ?? 6);
334
- }), [session?.components, previewAnnotations]);
320
+ const sessionSummary = useMemo(() => sortComponentsForSidebar((session?.components ?? []).map((c) => {
321
+ const counts = countValidationIssues(c.originalProposal);
322
+ return {
323
+ id: c.id,
324
+ name: c.name,
325
+ status: c.status,
326
+ previewAnnotation: previewAnnotations[c.name],
327
+ extractionConfidence: c.originalProposal.extractionConfidence ?? null,
328
+ needsReview: c.originalProposal.needsReview ?? false,
329
+ validationErrorCount: counts.errors,
330
+ validationWarningCount: counts.warnings,
331
+ };
332
+ })), [session?.components, previewAnnotations]);
335
333
  if (loading) {
336
334
  return _jsx(Text, { children: "Loading session..." });
337
335
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { ReviewComponentSummary } from '../../types.js';
2
+ import type { ReviewComponentSummary, ReviewComponentStatus } from '../../types.js';
3
3
  type SidebarProps = {
4
4
  components: ReviewComponentSummary[];
5
5
  selectedId: string | null;
@@ -11,5 +11,8 @@ type SidebarProps = {
11
11
  collapsed?: boolean;
12
12
  width?: number;
13
13
  };
14
+ export declare function statusIcon(status: ReviewComponentStatus, validationErrorCount: number, _validationWarningCount: number): string;
15
+ export declare function statusColor(status: ReviewComponentStatus, validationErrorCount: number, validationWarningCount: number): string;
16
+ export declare function sortComponentsForSidebar(components: ReviewComponentSummary[]): ReviewComponentSummary[];
14
17
  export declare function Sidebar({ components, selectedId, focused, scrollOffset, visibleCount, collapsed, width: widthProp, }: SidebarProps): React.ReactElement;
15
18
  export {};
@@ -1,6 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- function statusIcon(status) {
3
+ export function statusIcon(status, validationErrorCount,
4
+ // Warnings deliberately do NOT override the icon — the user's accept/reject
5
+ // decision must remain visible. Color is already yellow via statusColor for
6
+ // warning-only components, so the warning cue is preserved.
7
+ _validationWarningCount) {
8
+ // Errors override — a structurally broken component should never render as ✓/✗.
9
+ if (validationErrorCount > 0)
10
+ return '⚠';
4
11
  switch (status) {
5
12
  case 'accepted':
6
13
  return '✓';
@@ -12,7 +19,11 @@ function statusIcon(status) {
12
19
  return '·';
13
20
  }
14
21
  }
15
- function statusColor(status) {
22
+ export function statusColor(status, validationErrorCount, validationWarningCount) {
23
+ if (validationErrorCount > 0)
24
+ return 'red';
25
+ if (validationWarningCount > 0)
26
+ return 'yellow';
16
27
  switch (status) {
17
28
  case 'accepted':
18
29
  return 'green';
@@ -29,15 +40,40 @@ function truncateName(name, maxLen) {
29
40
  return name;
30
41
  return name.slice(0, maxLen) + '…';
31
42
  }
43
+ /**
44
+ * Sort key for stable secondary ordering within a validation tier:
45
+ * needs-review components first, then by extractionConfidence ascending
46
+ * (lowest confidence first, null treated as highest = last). Used to be
47
+ * applied inline in App.tsx, then this helper sorted again — pulled in
48
+ * here so the sort happens once.
49
+ */
50
+ function withinTierComparator(a, b) {
51
+ const aPending = a.needsReview && a.status === 'needs-review' ? 0 : 1;
52
+ const bPending = b.needsReview && b.status === 'needs-review' ? 0 : 1;
53
+ if (aPending !== bPending)
54
+ return aPending - bPending;
55
+ return (a.extractionConfidence ?? 6) - (b.extractionConfidence ?? 6);
56
+ }
57
+ export function sortComponentsForSidebar(components) {
58
+ const withErrors = components.filter((c) => c.validationErrorCount > 0);
59
+ const withWarnings = components.filter((c) => c.validationErrorCount === 0 && c.validationWarningCount > 0);
60
+ const clean = components.filter((c) => c.validationErrorCount === 0 && c.validationWarningCount === 0);
61
+ return [
62
+ ...[...withErrors].sort(withinTierComparator),
63
+ ...[...withWarnings].sort(withinTierComparator),
64
+ ...[...clean].sort(withinTierComparator),
65
+ ];
66
+ }
32
67
  export function Sidebar({ components, selectedId, focused, scrollOffset, visibleCount, collapsed = false, width: widthProp, }) {
33
- const visible = components.slice(scrollOffset, scrollOffset + visibleCount);
68
+ const sorted = sortComponentsForSidebar(components);
69
+ const visible = sorted.slice(scrollOffset, scrollOffset + visibleCount);
34
70
  const showScrollUp = scrollOffset > 0;
35
- const showScrollDown = scrollOffset + visibleCount < components.length;
71
+ const showScrollDown = scrollOffset + visibleCount < sorted.length;
36
72
  const width = collapsed ? 3 : (widthProp ?? 18);
37
73
  return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: focused ? 'white' : undefined, children: [showScrollUp && !collapsed && _jsx(Text, { dimColor: true, children: "\u25B2" }), visible.map((component) => {
38
74
  const isSelected = component.id === selectedId;
39
- const icon = statusIcon(component.status);
40
- const color = statusColor(component.status);
75
+ const icon = statusIcon(component.status, component.validationErrorCount, component.validationWarningCount);
76
+ const color = statusColor(component.status, component.validationErrorCount, component.validationWarningCount);
41
77
  const maxNameLen = Math.max(1, width - 4);
42
78
  const name = truncateName(component.name, maxNameLen);
43
79
  if (collapsed) {
@@ -24,6 +24,8 @@ export type ReviewComponentSummary = {
24
24
  previewAnnotation?: PreviewAnnotation;
25
25
  extractionConfidence: number | null;
26
26
  needsReview: boolean;
27
+ validationErrorCount: number;
28
+ validationWarningCount: number;
27
29
  };
28
30
  export type ReviewSessionSnapshot = {
29
31
  components: ReviewComponentRecord[];
@@ -44,5 +46,9 @@ export type ReviewSessionPaths = {
44
46
  eventsPath: string;
45
47
  statePath: string;
46
48
  };
49
+ export declare function countValidationIssues(component: RawComponentDefinition): {
50
+ errors: number;
51
+ warnings: number;
52
+ };
47
53
  export declare function createReviewSessionSummary(session: ReviewSessionSnapshot): ReviewSessionSummary;
48
54
  export declare function createReviewSessionDetail(session: ReviewSessionSnapshot): ReviewSessionDetail;
@@ -1,12 +1,29 @@
1
+ export function countValidationIssues(component) {
2
+ const issues = component.validationIssues ?? [];
3
+ let errors = 0;
4
+ let warnings = 0;
5
+ for (const issue of issues) {
6
+ if (issue.severity === 'error')
7
+ errors++;
8
+ else if (issue.severity === 'warning')
9
+ warnings++;
10
+ }
11
+ return { errors, warnings };
12
+ }
1
13
  export function createReviewSessionSummary(session) {
2
14
  return {
3
- components: session.components.map((component) => ({
4
- id: component.id,
5
- name: component.name,
6
- status: component.status,
7
- extractionConfidence: component.originalProposal.extractionConfidence ?? null,
8
- needsReview: component.originalProposal.needsReview ?? false,
9
- })),
15
+ components: session.components.map((component) => {
16
+ const counts = countValidationIssues(component.originalProposal);
17
+ return {
18
+ id: component.id,
19
+ name: component.name,
20
+ status: component.status,
21
+ extractionConfidence: component.originalProposal.extractionConfidence ?? null,
22
+ needsReview: component.originalProposal.needsReview ?? false,
23
+ validationErrorCount: counts.errors,
24
+ validationWarningCount: counts.warnings,
25
+ };
26
+ }),
10
27
  };
11
28
  }
12
29
  export function createReviewSessionDetail(session) {
@@ -6,6 +6,7 @@ import { parseSelectToolCallLines, runAgent } from '../../generate/agent-runner.
6
6
  import { OutputFormatter, c } from '../../output/format.js';
7
7
  import { buildRepoContextIndex, buildSelectionContext } from './context-builder.js';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
+ import { validateExtractedComponents, shouldExcludeDueToValidation, formatExclusionWarning, } from '../extract/validate.js';
9
10
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
10
11
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
11
12
  const DEFAULT_CONCURRENCY = 5;
@@ -177,6 +178,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
177
178
  .option('--model <name>', 'Model to use (defaults to a small/fast model per agent)')
178
179
  .option('--verbose', 'Show full agent output including reasoning text')
179
180
  .option('--dry-run', 'Print the prompt for the first component without invoking the agent')
181
+ .option('--exclude-invalid', 'Auto-reject components with validation errors instead of failing loud (LLM cannot fix structural issues)')
180
182
  .action(async (opts) => {
181
183
  if (!VALID_AGENTS.has(opts.agent)) {
182
184
  process.stderr.write(`Error: unknown agent '${opts.agent}'. Accepted values: claude, codex, opencode, cursor\n`);
@@ -201,6 +203,35 @@ export function registerAnalyzeSelectAgentCommand(program) {
201
203
  process.exit(1);
202
204
  return;
203
205
  }
206
+ // Re-run validation (not persisted to DB, so always recompute).
207
+ const validatedComponents = validateExtractedComponents(rawComponents);
208
+ // Fail-loud gate: if any component has validation errors, refuse to
209
+ // proceed unless --exclude-invalid is set. The LLM cannot fix
210
+ // structural issues (empty names, slot/prop collisions, etc.) — silent
211
+ // exclusion in this CI-style command would drop components without the
212
+ // caller noticing.
213
+ const invalidComponents = validatedComponents.filter(shouldExcludeDueToValidation);
214
+ if (invalidComponents.length > 0 && !opts.excludeInvalid) {
215
+ const lines = [
216
+ `Error: ${invalidComponents.length} component(s) failed validation; refusing select-agent without --exclude-invalid:`,
217
+ ];
218
+ for (const comp of invalidComponents) {
219
+ const codes = (comp.validationIssues ?? [])
220
+ .filter((i) => i.severity === 'error')
221
+ .map((i) => i.code)
222
+ .join(', ');
223
+ lines.push(` ✗ ${comp.name} ${codes}`);
224
+ }
225
+ lines.push('');
226
+ lines.push('Re-run with --exclude-invalid to auto-reject these components, or fix them in source first.');
227
+ process.stderr.write(lines.join('\n') + '\n');
228
+ process.exit(1);
229
+ return;
230
+ }
231
+ const componentsForAgent = validatedComponents.filter((comp) => !shouldExcludeDueToValidation(comp));
232
+ if (invalidComponents.length > 0) {
233
+ process.stderr.write(c.yellow(formatExclusionWarning(invalidComponents)));
234
+ }
204
235
  if (selectionRoot && scannedFiles.length > 0) {
205
236
  scannedFiles = scannedFiles.map((f) => (isAbsolute(f) ? f : resolve(selectionRoot, f)));
206
237
  }
@@ -208,17 +239,22 @@ export function registerAnalyzeSelectAgentCommand(program) {
208
239
  process.stderr.write('warn: session has no scanned-files index (likely extracted on an older CLI version). ' +
209
240
  'Re-run `analyze extract` to enable data-fetch wrapper detection during selection.\n');
210
241
  }
211
- let selectionCandidates = rawComponents.map((component) => ({ component }));
242
+ let selectionCandidates = componentsForAgent.map((component) => ({ component }));
212
243
  if (selectionRoot && scannedFiles.length > 0) {
213
244
  const repoIndex = await buildRepoContextIndex(selectionRoot, scannedFiles).catch(() => null);
214
245
  if (repoIndex) {
215
- selectionCandidates = rawComponents.map((component) => ({
246
+ selectionCandidates = componentsForAgent.map((component) => ({
216
247
  component,
217
248
  selectionContext: buildSelectionContext(repoIndex, component),
218
249
  }));
219
250
  }
220
251
  }
221
252
  if (opts.dryRun) {
253
+ if (selectionCandidates.length === 0) {
254
+ process.stderr.write('No valid components to preview — all components were excluded due to validation errors.\n');
255
+ process.exit(0);
256
+ return;
257
+ }
222
258
  const first = selectionCandidates[0];
223
259
  const prompt = await buildPrompt({
224
260
  skill: 'select',
@@ -231,14 +267,18 @@ export function registerAnalyzeSelectAgentCommand(program) {
231
267
  return;
232
268
  }
233
269
  const selectResults = await selectAllComponents(agent, opts.model, selectionCandidates, opts.verbose ?? false);
270
+ // Build decision map from results. Seed auto-rejections from validation exclusions first.
234
271
  const decisions = new Map();
272
+ for (const comp of invalidComponents) {
273
+ decisions.set(componentKey(comp), 'rejected');
274
+ }
235
275
  for (const r of selectResults) {
236
276
  decisions.set(r.componentKey, r.decision);
237
277
  }
238
278
  const artifactsRoot = getRefineArtifactsRoot();
239
279
  let snapshot;
240
280
  try {
241
- snapshot = await loadReviewInput(rawComponents, {
281
+ snapshot = await loadReviewInput(validatedComponents, {
242
282
  reviewRoot: selectionRoot ?? undefined,
243
283
  });
244
284
  snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
@@ -9,10 +9,18 @@ export type AnalyzeViewResult = {
9
9
  propCount: number;
10
10
  slotCount: number;
11
11
  warnings: string[];
12
+ /**
13
+ * Error-severity validation messages. Components with any errors will be
14
+ * auto-rejected by the next step (`analyze select --select-all`), so the
15
+ * extract TUI surfaces them prominently — both with a per-row ✗ badge
16
+ * and a dedicated Errors section below the component list.
17
+ */
18
+ errors: string[];
12
19
  extractionConfidence: number | null;
13
20
  needsReview: boolean;
14
21
  }>;
15
22
  totalWarnings: number;
23
+ totalErrors: number;
16
24
  };
17
25
  type AnalyzeViewProps = {
18
26
  result: AnalyzeViewResult;
@@ -53,8 +53,11 @@ export function AnalyzeView({ result, onExit }) {
53
53
  const conf = component.extractionConfidence;
54
54
  const confColor = conf === null ? 'gray' : component.needsReview ? 'red' : conf >= 4 ? 'white' : conf >= 3 ? 'yellow' : 'red';
55
55
  const confLabel = conf === null ? '—' : (component.needsReview ? '⚑ ' : '') + String(conf);
56
- return (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: (component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots')).padEnd(8) }), _jsx(Text, { color: confColor, children: confLabel }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ⚠ ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name));
57
- }), showScrollDown && _jsx(Text, { dimColor: true, children: " \u25BC scroll down" }), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
56
+ return (_jsxs(Box, { children: [component.errors.length > 0 && _jsx(Text, { color: "red", children: "\u2717 " }), component.errors.length === 0 && component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.errors.length === 0 && component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: (component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots')).padEnd(8) }), _jsx(Text, { color: confColor, children: confLabel }), component.errors.length > 0 && (_jsx(Text, { color: "red", children: ' ✗ ' + component.errors.length + ' error' + (component.errors.length === 1 ? '' : 's') })), component.errors.length === 0 && component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ⚠ ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name));
57
+ }), showScrollDown && _jsx(Text, { dimColor: true, children: " \u25BC scroll down" }), result.totalErrors > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "red", children: 'Errors (' + result.totalErrors + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
58
+ .filter((c) => c.errors.length > 0)
59
+ .flatMap((c) => c.errors.map((e) => ({ component: c.name, error: e })))
60
+ .map((e, i) => (_jsx(Text, { color: "red", children: ' ✗ ' + e.component + ': ' + e.error }, i)))] })), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
58
61
  .filter((c) => c.warnings.length > 0)
59
62
  .flatMap((c) => c.warnings.map((w) => ({ component: c.name, warning: w })))
60
63
  .map((w, i) => (_jsx(Text, { color: "yellow", children: ' ⚠ ' + w.component + ': ' + w.warning }, i)))] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: 'Run: analyze select --session ' + result.sessionId }), _jsx(Text, { children: " " })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or q to exit" }) })] }));
@@ -1,5 +1,8 @@
1
1
  import type { ManifestPayload, ServerPreviewResponse, ApplyOperationResponse } from '@contentful/experience-design-system-types';
2
2
  export declare const DEFAULT_HOST = "https://api.contentful.com";
3
+ export declare const PREVIEW_ERROR_PREFIX = "preview failed:";
4
+ export declare const APPLY_ERROR_PREFIX = "apply failed:";
5
+ export declare const VALIDATION_FAILED_CODE = "\"ValidationFailed\"";
3
6
  export interface ApiClientOptions {
4
7
  host?: string;
5
8
  cmaToken: string;
@@ -11,6 +14,23 @@ export declare class ApiError extends Error {
11
14
  readonly body: string;
12
15
  constructor(message: string, status: number, body: string);
13
16
  }
17
+ export interface PreviewValidationError {
18
+ componentName: string;
19
+ path: string;
20
+ message: string;
21
+ }
22
+ /**
23
+ * Parse the JSON body of a 422 from `previewImport()` into structured
24
+ * per-component validation errors. Returns [] for any malformed input
25
+ * so callers can fall back to the generic error path without try/catch.
26
+ *
27
+ * Path shape: `manifest:components/<Name>/$slots/<key>` or
28
+ * `manifest:components/<Name>/$properties/<key>`. Only the component
29
+ * name is extracted today; `path` and `message` are kept verbatim so
30
+ * future surfaces (debug logging, headless retry in SP-4) can render
31
+ * the field-level detail.
32
+ */
33
+ export declare function parsePreviewValidationErrors(body: string): PreviewValidationError[];
14
34
  export declare class ImportApiClient {
15
35
  private host;
16
36
  private token;
@@ -1,5 +1,26 @@
1
1
  import { DEFAULT_API_HOST, toApiHost } from '../host-utils.js';
2
2
  export const DEFAULT_HOST = DEFAULT_API_HOST;
3
+ // Phase-prefix constants used at the two ApiError throw sites below and
4
+ // imported by orchestrator.ts to identify preview-phase 422s for retry.
5
+ export const PREVIEW_ERROR_PREFIX = 'preview failed:';
6
+ export const APPLY_ERROR_PREFIX = 'apply failed:';
7
+ // Substring match the orchestrator uses to distinguish a parseable
8
+ // component-level validation failure from generic 422s. Quoted because the
9
+ // match runs against the raw JSON body (which contains `"code":"ValidationFailed"`).
10
+ // If the server ever changes the casing or naming, isPreviewValidationError
11
+ // silently returns false and the retry loop never fires — so this lives next
12
+ // to the prefixes as a deliberate, named contract rather than an inline
13
+ // magic string in the orchestrator.
14
+ export const VALIDATION_FAILED_CODE = '"ValidationFailed"';
15
+ // Cap on the body slice appended to ApiError.message. Bumped from 1000 →
16
+ // 16384 so realistic 422 ValidationFailed reports (which list every
17
+ // offending component, ~100 chars per error, easily exceeds 1KB once you
18
+ // cross ~10 components) survive intact through subprocess stderr. The
19
+ // orchestrator's parseOffendingComponentNames does JSON.parse on this slice
20
+ // and silently fails to recover any offenders if the JSON is mid-truncated.
21
+ // The cap stays in place to keep a runaway server response from blowing up
22
+ // log output.
23
+ const ERROR_BODY_LOG_CAP = 16384;
3
24
  export class ApiError extends Error {
4
25
  status;
5
26
  body;
@@ -8,13 +29,58 @@ export class ApiError extends Error {
8
29
  this.status = status;
9
30
  this.body = body;
10
31
  if (body) {
11
- // Append a trimmed version of the response body so callers that only
12
- // log e.message don't silently swallow the server's error detail.
13
- const trimmed = body.length > 1000 ? body.slice(0, 1000) + '…' : body;
32
+ // Append a (possibly trimmed) version of the response body so callers
33
+ // that only log e.message don't silently swallow the server's error
34
+ // detail.
35
+ const trimmed = body.length > ERROR_BODY_LOG_CAP ? body.slice(0, ERROR_BODY_LOG_CAP) + '…' : body;
14
36
  this.message = `${message}\n${trimmed}`;
15
37
  }
16
38
  }
17
39
  }
40
+ const COMPONENT_PATH_PREFIX = 'manifest:components/';
41
+ /**
42
+ * Parse the JSON body of a 422 from `previewImport()` into structured
43
+ * per-component validation errors. Returns [] for any malformed input
44
+ * so callers can fall back to the generic error path without try/catch.
45
+ *
46
+ * Path shape: `manifest:components/<Name>/$slots/<key>` or
47
+ * `manifest:components/<Name>/$properties/<key>`. Only the component
48
+ * name is extracted today; `path` and `message` are kept verbatim so
49
+ * future surfaces (debug logging, headless retry in SP-4) can render
50
+ * the field-level detail.
51
+ */
52
+ export function parsePreviewValidationErrors(body) {
53
+ if (!body)
54
+ return [];
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(body);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const details = parsed?.details;
63
+ const errors = details?.errors;
64
+ if (!Array.isArray(errors))
65
+ return [];
66
+ const out = [];
67
+ for (const raw of errors) {
68
+ if (typeof raw !== 'object' || raw === null)
69
+ continue;
70
+ const entry = raw;
71
+ if (typeof entry.path !== 'string' || typeof entry.message !== 'string')
72
+ continue;
73
+ if (!entry.path.startsWith(COMPONENT_PATH_PREFIX))
74
+ continue;
75
+ const tail = entry.path.slice(COMPONENT_PATH_PREFIX.length);
76
+ const slash = tail.indexOf('/');
77
+ const componentName = slash === -1 ? tail : tail.slice(0, slash);
78
+ if (!componentName)
79
+ continue;
80
+ out.push({ componentName, path: entry.path, message: entry.message });
81
+ }
82
+ return out;
83
+ }
18
84
  async function request(url, options) {
19
85
  const headers = {
20
86
  Authorization: `Bearer ${options.token}`,
@@ -68,7 +134,7 @@ export class ImportApiClient {
68
134
  body: JSON.stringify(manifest),
69
135
  });
70
136
  if (!res.ok) {
71
- throw new ApiError(`preview failed: ${res.status}`, res.status, await res.text());
137
+ throw new ApiError(`${PREVIEW_ERROR_PREFIX} ${res.status}`, res.status, await res.text());
72
138
  }
73
139
  return (await res.json());
74
140
  }
@@ -80,7 +146,7 @@ export class ImportApiClient {
80
146
  body: JSON.stringify({ ...manifest, acknowledgeBreakingChanges }),
81
147
  });
82
148
  if (!res.ok) {
83
- throw new ApiError(`apply failed: ${res.status}`, res.status, await res.text());
149
+ throw new ApiError(`${APPLY_ERROR_PREFIX} ${res.status}`, res.status, await res.text());
84
150
  }
85
151
  return (await res.json());
86
152
  }
@@ -9,7 +9,7 @@ import { OutputFormatter, c } from '../output/format.js';
9
9
  import { buildPrompt, resolveSkillPath } from './prompt-builder.js';
10
10
  import { GenerateView } from './tui/GenerateView.js';
11
11
  import { registerGenerateEditCommand } from './edit/command.js';
12
- import { openPipelineDb, loadRawComponents, applyToolCalls, applyTokenToolCalls, computeComponentInputHash, computeTokenInputHash, lookupCache, lookupCacheByEntity, storeCache, copyComponentFromCache, copyTokensFromCache, } from '../session/db.js';
12
+ import { openPipelineDb, loadRawComponents, applyToolCalls, applyTokenToolCalls, computeComponentInputHash, computeTokenInputHash, lookupCache, lookupCacheByEntity, storeCache, copyComponentFromCache, copyTokensFromCache, renameEmptySlots, } from '../session/db.js';
13
13
  import { getRefineArtifactsRoot, getRefineSessionPaths } from '../analyze/select/persistence.js';
14
14
  const execFileAsync = promisify(execFile);
15
15
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
@@ -119,6 +119,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
119
119
  warnings: [],
120
120
  failed: false,
121
121
  cached: true,
122
+ renamedSlotsCount: 0,
122
123
  };
123
124
  }
124
125
  // Check for pinned (human-edited) entry with a different hash
@@ -134,16 +135,28 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
134
135
  warnings: [`${component.name}: source changed but human edits preserved`],
135
136
  failed: false,
136
137
  cached: true,
138
+ renamedSlotsCount: 0,
137
139
  };
138
140
  }
139
141
  }
142
+ // Rename empty-named slots in the DB and patch the in-memory component so the
143
+ // prompt sees the heuristic names. applyToolCalls matches by name — a row with
144
+ // name="" would never be reachable by a classify_slot call otherwise.
145
+ const { renames, warnings: renameWarnings } = renameEmptySlots(db, sessionId, component.component_id, component.name, component.slots.length);
146
+ let effectiveSlots = component.slots;
147
+ if (renames.length > 0) {
148
+ const renameMap = new Map(renames.map((r) => [r.oldName, r.newName]));
149
+ effectiveSlots = component.slots.map((s) => (renameMap.has(s.name) ? { ...s, name: renameMap.get(s.name) } : s));
150
+ for (const w of renameWarnings)
151
+ process.stderr.write(` ${c.yellow('⚠')} ${w}\n`);
152
+ }
140
153
  const rawComponentsInline = JSON.stringify([
141
154
  {
142
155
  name: component.name,
143
156
  source: component.source,
144
157
  framework: component.framework,
145
158
  props: component.props,
146
- slots: component.slots,
159
+ slots: effectiveSlots,
147
160
  },
148
161
  ], null, 2);
149
162
  const prompt = await buildPrompt({
@@ -186,6 +199,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
186
199
  warnings: [],
187
200
  failed: true,
188
201
  error: `timed out after ${DEFAULT_TIMEOUT_MS / 60000} minutes`,
202
+ renamedSlotsCount: renames.length,
189
203
  };
190
204
  }
191
205
  if (result.exitCode !== 0) {
@@ -209,6 +223,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
209
223
  slots: applied.slots,
210
224
  warnings: applied.warnings,
211
225
  failed: false,
226
+ renamedSlotsCount: renames.length,
212
227
  };
213
228
  }
214
229
  return {
@@ -219,6 +234,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
219
234
  warnings: [],
220
235
  failed: true,
221
236
  error: lastError,
237
+ renamedSlotsCount: renames.length,
222
238
  };
223
239
  }
224
240
  async function runAllComponents(agent, model, db, sessionId, components, tokensInline, tokenMapInline, verbose, noCache) {
@@ -390,6 +406,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
390
406
  }
391
407
  const totalClassified = generated.reduce((s, r) => s + r.classified, 0);
392
408
  const totalExcluded = generated.reduce((s, r) => s + r.excluded, 0);
409
+ const totalRenamedSlots = componentResults.reduce((s, r) => s + r.renamedSlotsCount, 0);
393
410
  const allOk = failed.length === 0;
394
411
  const cachedNote = cachedResults.length > 0 ? c.dim(` (${cachedResults.length} cached)`) : '';
395
412
  process.stderr.write((allOk ? c.green('✓') : c.yellow('⚠')) +
@@ -397,6 +414,8 @@ async function runGenerateSkill(skill, opts, verbose = false) {
397
414
  cachedNote +
398
415
  c.dim(` ${totalClassified} classified, ${totalExcluded} excluded`) +
399
416
  '\n');
417
+ // Machine-parseable summary on stdout for the wizard / orchestrator.
418
+ process.stdout.write(`renamed-slots: ${totalRenamedSlots}\n`);
400
419
  if (generated.length === 0 && cachedResults.length === 0) {
401
420
  die('Error: all components failed to generate — check agent output above');
402
421
  }
@@ -24,6 +24,7 @@ export function registerImportCommand(program) {
24
24
  .option('--no-cache', 'Re-run all steps even if output already exists')
25
25
  .option('--yes', 'Skip interactive confirmation in apply push')
26
26
  .option('--verbose', 'Show full agent output and all entity progress')
27
+ .option('--exclude-invalid', 'Automatically reject components with validation errors (empty names, collisions)')
27
28
  .option('--viewports <path>', 'JSON file with viewport array (passed to apply push)')
28
29
  .option('--host <url>', 'Override API base URL (passed to apply push)')
29
30
  .option('--dry-run', 'Print generate components prompt without invoking the agent')
@@ -84,6 +85,7 @@ export function registerImportCommand(program) {
84
85
  noCache: opts.cache === false,
85
86
  yes: opts.yes ?? false,
86
87
  verbose: opts.verbose ?? false,
88
+ excludeInvalid: opts.excludeInvalid ?? false,
87
89
  viewports: opts.viewports,
88
90
  host: opts.host,
89
91
  dryRun: opts.dryRun,
@@ -17,6 +17,7 @@ export interface PipelineOptions {
17
17
  viewports?: string;
18
18
  host?: string;
19
19
  dryRun?: boolean;
20
+ excludeInvalid?: boolean;
20
21
  selectAll?: boolean;
21
22
  select?: string[];
22
23
  deselect?: string[];
@@ -34,4 +35,25 @@ export interface PipelineResult {
34
35
  project: string;
35
36
  steps: StepResult[];
36
37
  }
38
+ export declare function isPreviewValidationError(result: {
39
+ exitCode: number;
40
+ stderr: string;
41
+ }): boolean;
42
+ export declare function parseOffendingComponentNames(output: string): string[];
43
+ /**
44
+ * Build the apply-push StepResult record. Centralizes the success/failure
45
+ * shape so excludedByValidationRetry is recorded consistently in both
46
+ * branches — previously the total-failure branch dropped it, leaving users
47
+ * with a failed pipeline and no audit trail of what was auto-excluded
48
+ * before the retry loop gave up.
49
+ */
50
+ export declare function buildPushStepResult(args: {
51
+ created: number;
52
+ updated: number;
53
+ failed: number;
54
+ durationMs: number;
55
+ stderr: string;
56
+ excludedByRetry: string[];
57
+ totalFailure?: boolean;
58
+ }): StepResult;
37
59
  export declare function runPipeline(opts: PipelineOptions, progressWriter: (line: string) => void, cliPathOverride?: string): Promise<PipelineResult>;