@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.
- package/dist/package.json +1 -1
- package/dist/src/analyze/build-analyze-view-rows.d.ts +23 -0
- package/dist/src/analyze/build-analyze-view-rows.js +39 -0
- package/dist/src/analyze/command.js +16 -11
- package/dist/src/analyze/extract/validate.d.ts +16 -0
- package/dist/src/analyze/extract/validate.js +94 -0
- package/dist/src/analyze/select/command.d.ts +47 -0
- package/dist/src/analyze/select/command.js +238 -14
- package/dist/src/analyze/select/tui/App.js +15 -17
- package/dist/src/analyze/select/tui/components/Sidebar.d.ts +4 -1
- package/dist/src/analyze/select/tui/components/Sidebar.js +42 -6
- package/dist/src/analyze/select/types.d.ts +6 -0
- package/dist/src/analyze/select/types.js +24 -7
- package/dist/src/analyze/select-agent/command.js +43 -3
- package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
- package/dist/src/analyze/tui/AnalyzeView.js +5 -2
- package/dist/src/apply/api-client.d.ts +20 -0
- package/dist/src/apply/api-client.js +71 -5
- package/dist/src/generate/command.js +21 -2
- package/dist/src/import/command.js +2 -0
- package/dist/src/import/orchestrator.d.ts +22 -0
- package/dist/src/import/orchestrator.js +97 -14
- package/dist/src/import/tui/WizardApp.d.ts +13 -0
- package/dist/src/import/tui/WizardApp.js +224 -52
- package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
- package/dist/src/import/tui/steps/GateStep.js +4 -2
- package/dist/src/import/tui/steps/GenerateReviewStep.js +2 -0
- package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
- package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
- package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
- package/dist/src/import/tui/wizard-422-helpers.js +60 -0
- package/dist/src/program.d.ts +17 -0
- package/dist/src/program.js +27 -4
- package/dist/src/session/db.d.ts +13 -0
- package/dist/src/session/db.js +50 -0
- package/dist/src/types.d.ts +8 -0
- package/package.json +2 -2
- 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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
68
|
+
const sorted = sortComponentsForSidebar(components);
|
|
69
|
+
const visible = sorted.slice(scrollOffset, scrollOffset + visibleCount);
|
|
34
70
|
const showScrollUp = scrollOffset > 0;
|
|
35
|
-
const showScrollDown = scrollOffset + visibleCount <
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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.
|
|
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
|
|
12
|
-
// log e.message don't silently swallow the server's error
|
|
13
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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>;
|