@contentful/experience-design-system-cli 2.12.4-dev-build-9c819d8.0 → 2.12.4-dev-build-ff57340.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.12.4-dev-build-9c819d8.0",
3
+ "version": "2.12.4-dev-build-ff57340.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,5 +64,30 @@ export declare function suggestCycleBreakEdge(cycle: SlotCycle, allCycles: SlotC
64
64
  * Truncates at `maxHops` edges (default 8), inserting `…` mid-path so the
65
65
  * beginning and end remain visible. A hop is one edge; the returned string
66
66
  * for an n-edge cycle contains n+1 component names.
67
+ *
68
+ * Kept for backend/log/error use where a single string is required (see
69
+ * `assertNoSlotCycles` in `apply/command.ts`). The TUI banner + detail panel
70
+ * use `formatCyclePathSegments` instead so slot names can be visually
71
+ * distinguished from component names.
67
72
  */
68
73
  export declare function formatCyclePath(cycle: SlotCycle, maxHops?: number): string;
74
+ /**
75
+ * A single token in the rendered cycle path — either a component name, a
76
+ * slot name (wrapped in brackets for monochrome legibility), or the arrow
77
+ * separator between them. The TUI colorizes each kind independently.
78
+ *
79
+ * Slots are rendered as `[slotName]` so the visual distinction survives when
80
+ * ANSI colors are stripped (log files, redirection, dumb terminals).
81
+ */
82
+ export interface CyclePathSegment {
83
+ kind: 'component' | 'slot' | 'arrow';
84
+ text: string;
85
+ }
86
+ /**
87
+ * Segment-oriented counterpart to `formatCyclePath`. Preserves the same
88
+ * 8-hop truncation semantics but returns each token classified so the
89
+ * renderer can color slots (cyan) and components (default) independently.
90
+ * The truncation marker `…` is emitted as a bare component segment because
91
+ * it stands in for one or more component nodes.
92
+ */
93
+ export declare function formatCyclePathSegments(cycle: SlotCycle, maxHops?: number): CyclePathSegment[];
@@ -260,6 +260,11 @@ export function suggestCycleBreakEdge(cycle, allCycles) {
260
260
  * Truncates at `maxHops` edges (default 8), inserting `…` mid-path so the
261
261
  * beginning and end remain visible. A hop is one edge; the returned string
262
262
  * for an n-edge cycle contains n+1 component names.
263
+ *
264
+ * Kept for backend/log/error use where a single string is required (see
265
+ * `assertNoSlotCycles` in `apply/command.ts`). The TUI banner + detail panel
266
+ * use `formatCyclePathSegments` instead so slot names can be visually
267
+ * distinguished from component names.
263
268
  */
264
269
  export function formatCyclePath(cycle, maxHops = 8) {
265
270
  const arrow = ' → ';
@@ -283,3 +288,39 @@ export function formatCyclePath(cycle, maxHops = 8) {
283
288
  const tail = parts[parts.length - 1];
284
289
  return `${head}${arrow}…${arrow}${tail}`;
285
290
  }
291
+ /**
292
+ * Segment-oriented counterpart to `formatCyclePath`. Preserves the same
293
+ * 8-hop truncation semantics but returns each token classified so the
294
+ * renderer can color slots (cyan) and components (default) independently.
295
+ * The truncation marker `…` is emitted as a bare component segment because
296
+ * it stands in for one or more component nodes.
297
+ */
298
+ export function formatCyclePathSegments(cycle, maxHops = 8) {
299
+ // Interleaved [component, slot, component, slot, ..., component] token list
300
+ // matching `formatCyclePath`'s parts array, but classified.
301
+ const raw = [];
302
+ for (let i = 0; i < cycle.edges.length; i += 1) {
303
+ raw.push({ kind: 'component', text: cycle.path[i] });
304
+ raw.push({ kind: 'slot', text: `[${cycle.edges[i].slotName}]` });
305
+ }
306
+ raw.push({ kind: 'component', text: cycle.path[cycle.path.length - 1] });
307
+ const withArrows = (segs) => {
308
+ const out = [];
309
+ for (let i = 0; i < segs.length; i += 1) {
310
+ if (i > 0)
311
+ out.push({ kind: 'arrow', text: ' → ' });
312
+ out.push(segs[i]);
313
+ }
314
+ return out;
315
+ };
316
+ if (cycle.edges.length <= maxHops) {
317
+ return withArrows(raw);
318
+ }
319
+ // Match `formatCyclePath` truncation: keep the first (maxHops-1)*2 tokens
320
+ // plus a "…" placeholder plus the trailing component.
321
+ const keepHops = Math.max(1, maxHops - 1);
322
+ const keepTokens = keepHops * 2;
323
+ const head = raw.slice(0, keepTokens);
324
+ const tail = raw[raw.length - 1];
325
+ return withArrows([...head, { kind: 'component', text: '…' }, tail]);
326
+ }
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { findSlotCycles, type ComponentSlotInfo } from '../../../cycle-detection.js';
2
3
  /** Per-prop / per-component metadata captured by the extractor + generate phase.
3
4
  * Optional; when omitted, the FieldEditor renders without rationale/source
4
5
  * affordances (backwards-compatible with mounts that pre-date Feature 1). */
@@ -63,6 +64,28 @@ type FieldEditorProps = {
63
64
  * description editors, string default editors, and value-list text entry.
64
65
  */
65
66
  onTextEntryActiveChange?: (active: boolean) => void;
67
+ /**
68
+ * INTEG-4401: project-wide slot graph. When supplied, the
69
+ * $allowedComponents add-mode shows a cycle-filtered picker. Optional.
70
+ */
71
+ projectSlotGraph?: ComponentSlotInfo[];
72
+ /**
73
+ * INTEG-4401: name of the component being edited. Self-loop guard +
74
+ * self-entry replacement in the cycle simulation.
75
+ */
76
+ currentComponentName?: string;
66
77
  };
67
- export declare function FieldEditor({ value, width, height, active, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, }: FieldEditorProps): React.ReactElement;
78
+ /** Simulate adding a candidate to a slot; replaces self-entry with live edits. */
79
+ export declare function simulateGraphWithCandidate(projectSlotGraph: ComponentSlotInfo[], selfName: string, currentSlots: {
80
+ name: string;
81
+ allowedComponents: string[];
82
+ }[], targetSlotName: string, candidate: string): ComponentSlotInfo[];
83
+ /** True iff `next` contains a cycle whose canonical edge set is absent in `before`. */
84
+ export declare function introducesNewCycle(before: ReturnType<typeof findSlotCycles>, next: ReturnType<typeof findSlotCycles>): boolean;
85
+ /** Candidate names that can be added to a slot without creating a new cycle. */
86
+ export declare function computeAllowedComponentCandidates(projectSlotGraph: ComponentSlotInfo[], selfName: string, currentSlots: {
87
+ name: string;
88
+ allowedComponents: string[];
89
+ }[], targetSlotName: string): string[];
90
+ export declare function FieldEditor({ value, width, height, active, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, projectSlotGraph, currentComponentName, }: FieldEditorProps): React.ReactElement;
68
91
  export {};
@@ -4,6 +4,7 @@ import { Box, Text } from 'ink';
4
4
  import { CDF_PROPERTY_TYPES, CDF_PROPERTY_CATEGORIES, DESIGN_TOKEN_TYPES, } from '@contentful/experience-design-system-types';
5
5
  import { useImmediateInput } from '../hooks/useImmediateInput.js';
6
6
  import { computeNextScrollOffset } from '../hooks/scroll-offset.js';
7
+ import { findSlotCycles } from '../../../cycle-detection.js';
7
8
  // Section indices for top-level navigation
8
9
  // 0 = component $description row
9
10
  // 1 = $properties header (then props[0..n-1])
@@ -201,18 +202,34 @@ function PropRow({ prop, selected, activeField, textCursor, valueCursor, cursorV
201
202
  return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
202
203
  }), editingValue?.mode === 'add' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] }))] }));
203
204
  }
204
- function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, }) {
205
+ function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, pickerCandidates, pickerCursor, }) {
205
206
  const cursor = cursorVisible ? '█' : ' ';
206
207
  const bg = selected ? 'blue' : undefined;
207
208
  const nameDisplay = slot.name.length > 14 ? slot.name.slice(0, 13) + '…' : slot.name.padEnd(14);
208
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any — press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
209
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), !selected && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "allowed:" }), slot.allowedComponents.length === 0 ? (_jsx(Text, { dimColor: true, children: "(any)" })) : (_jsx(Text, { color: "cyan", children: slot.allowedComponents.join(', ') }))] })), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any — press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
209
210
  const isActiveCursor = activeField === 'allowedComponents' && valueCursor === i;
210
211
  const isBeingEdited = editingValue?.mode === 'edit' && editingValue.index === i;
211
212
  if (isBeingEdited) {
212
213
  return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
213
214
  }
214
215
  return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
215
- }), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] })), selected && activeField === 'description' && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: slot.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: slot.description[textCursor] ?? cursor }), _jsx(Text, { children: slot.description.slice(textCursor + 1) })] })] })), selected && activeField !== 'description' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: "green", children: slot.description || '—' })] }))] }));
216
+ }), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] })), editingValue?.mode === 'add' && activeField === 'allowedComponents' && pickerCandidates !== null && (_jsx(Box, { paddingLeft: 2, flexDirection: "column", children: pickerCandidates.length === 0 ? (_jsx(Text, { dimColor: true, children: '(no valid components to add all remaining candidates would create cycles)' })) : ((() => {
217
+ const filtered = valueText.length === 0
218
+ ? pickerCandidates
219
+ : pickerCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()));
220
+ if (filtered.length === 0) {
221
+ return _jsx(Text, { dimColor: true, children: '(no candidates match — Enter to add as free text)' });
222
+ }
223
+ const cursor = pickerCursor % filtered.length;
224
+ const MAX_VISIBLE = 5;
225
+ const start = Math.max(0, Math.min(filtered.length - MAX_VISIBLE, cursor - 2));
226
+ const slice = filtered.slice(start, start + MAX_VISIBLE);
227
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ' candidates (↑↓ cycle, Enter to add):' }), slice.map((name, i) => {
228
+ const absIdx = start + i;
229
+ const isCursor = absIdx === cursor;
230
+ return (_jsx(Text, { color: isCursor ? 'cyan' : undefined, dimColor: !isCursor, children: isCursor ? ` ▶ ${name}` : ` ${name}` }, name));
231
+ })] }));
232
+ })()) }))] })), selected && activeField === 'description' && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: slot.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: slot.description[textCursor] ?? cursor }), _jsx(Text, { children: slot.description.slice(textCursor + 1) })] })] })), selected && activeField !== 'description' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: "green", children: slot.description || '—' })] }))] }));
216
233
  }
217
234
  // Field order is intentional: description is LAST so it's the "edge" of the
218
235
  // field cycle. The user reaches description by walking through type → category
@@ -238,8 +255,59 @@ function propFields(prop) {
238
255
  // components is a list-typed field that swallows j/k similarly to enum $values
239
256
  // — placing it before description preserves description-as-last.
240
257
  const SLOT_FIELDS = ['required', 'allowedComponents', 'description'];
258
+ // ── INTEG-4401: cycle-aware $allowedComponents picker helpers ─────────────
259
+ /** Simulate adding a candidate to a slot; replaces self-entry with live edits. */
260
+ export function simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate) {
261
+ const selfEntry = {
262
+ name: selfName,
263
+ slots: currentSlots.map((s) => ({
264
+ name: s.name,
265
+ allowedComponents: s.name === targetSlotName ? [...s.allowedComponents, candidate] : [...s.allowedComponents],
266
+ })),
267
+ };
268
+ const withoutSelf = projectSlotGraph.filter((c) => c.name !== selfName);
269
+ return [...withoutSelf, selfEntry];
270
+ }
271
+ /** True iff `next` contains a cycle whose canonical edge set is absent in `before`. */
272
+ export function introducesNewCycle(before, next) {
273
+ const key = (edges) => {
274
+ if (edges.length === 0)
275
+ return '';
276
+ const encoded = edges.map((e) => `${e.fromComponent}|${e.slotName}|${e.toComponent}`);
277
+ let minIdx = 0;
278
+ for (let i = 1; i < encoded.length; i += 1)
279
+ if (encoded[i] < encoded[minIdx])
280
+ minIdx = i;
281
+ return [...encoded.slice(minIdx), ...encoded.slice(0, minIdx)].join(';');
282
+ };
283
+ const beforeSet = new Set(before.map((c) => key(c.edges)));
284
+ for (const cycle of next)
285
+ if (!beforeSet.has(key(cycle.edges)))
286
+ return true;
287
+ return false;
288
+ }
289
+ /** Candidate names that can be added to a slot without creating a new cycle. */
290
+ export function computeAllowedComponentCandidates(projectSlotGraph, selfName, currentSlots, targetSlotName) {
291
+ const targetSlot = currentSlots.find((s) => s.name === targetSlotName);
292
+ const alreadyAdded = new Set(targetSlot?.allowedComponents ?? []);
293
+ const universe = new Set();
294
+ for (const c of projectSlotGraph)
295
+ universe.add(c.name);
296
+ universe.delete(selfName);
297
+ for (const added of alreadyAdded)
298
+ universe.delete(added);
299
+ const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, '', ''));
300
+ const safe = [];
301
+ for (const candidate of universe) {
302
+ const nextCycles = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate));
303
+ if (!introducesNewCycle(baseline, nextCycles))
304
+ safe.push(candidate);
305
+ }
306
+ safe.sort((a, b) => a.localeCompare(b));
307
+ return safe;
308
+ }
241
309
  // ── Main component ────────────────────────────────────────────────────────────
242
- export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, }) {
310
+ export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, projectSlotGraph, currentComponentName, }) {
243
311
  const { state: initialState, error: parseError } = parseToState(value);
244
312
  const [editorState, setEditorState] = useState(initialState);
245
313
  const [parseErr] = useState(parseError);
@@ -291,6 +359,8 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
291
359
  // mode='add' — append on Enter; mode='edit' — replace at index on Enter.
292
360
  const [editingValue, setEditingValue] = useState(null);
293
361
  const [valueText, setValueText] = useState('');
362
+ // INTEG-4401: picker cursor for the cycle-filtered $allowedComponents list.
363
+ const [pickerCursor, setPickerCursor] = useState(0);
294
364
  const [validationError, setValidationError] = useState(null);
295
365
  const [cursorVisible] = useState(true);
296
366
  // Feature 1: source-view panel toggle. Opens with `s`, closes with `s` or Esc.
@@ -359,11 +429,57 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
359
429
  // same add/edit/remove/reorder list shape.
360
430
  const isPropValuesEntry = editingValue && currentProp && activeField === 'values' && !inSlots;
361
431
  const isSlotAllowedEntry = editingValue && currentSlot && activeField === 'allowedComponents' && inSlots;
432
+ // INTEG-4401: cycle-filtered candidates for slot-add.
433
+ const slotAddCandidates = isSlotAllowedEntry && editingValue?.mode === 'add' && projectSlotGraph && currentSlot && currentComponentName
434
+ ? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, currentSlot.name)
435
+ : null;
436
+ const filteredCandidates = slotAddCandidates
437
+ ? valueText.length === 0
438
+ ? slotAddCandidates
439
+ : slotAddCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()))
440
+ : null;
362
441
  if (isPropValuesEntry || isSlotAllowedEntry) {
363
442
  const vals = isPropValuesEntry ? currentProp.values : currentSlot.allowedComponents;
443
+ const pickerActive = isSlotAllowedEntry && editingValue?.mode === 'add' && slotAddCandidates !== null;
444
+ if (pickerActive && (key.upArrow || key.downArrow)) {
445
+ const len = filteredCandidates.length;
446
+ if (len > 0) {
447
+ setPickerCursor((c) => (key.upArrow ? (c - 1 + len) % len : (c + 1) % len));
448
+ }
449
+ return;
450
+ }
364
451
  if (key.return) {
365
- const trimmed = valueText.trim();
452
+ let chosen = null;
453
+ if (pickerActive && filteredCandidates && filteredCandidates.length > 0) {
454
+ if (valueText.trim() === '') {
455
+ chosen = filteredCandidates[pickerCursor % filteredCandidates.length] ?? null;
456
+ }
457
+ else {
458
+ const exact = filteredCandidates.find((n) => n === valueText.trim());
459
+ if (exact)
460
+ chosen = exact;
461
+ }
462
+ }
463
+ const trimmed = chosen ?? valueText.trim();
366
464
  if (trimmed) {
465
+ // Cycle-aware free-text validation. Picker path is safe by construction.
466
+ if (isSlotAllowedEntry &&
467
+ editingValue?.mode === 'add' &&
468
+ projectSlotGraph &&
469
+ currentSlot &&
470
+ currentComponentName &&
471
+ chosen === null) {
472
+ if (trimmed === currentComponentName) {
473
+ setValidationError(`"${trimmed}" cannot be added to its own slot (self-loop).`);
474
+ return;
475
+ }
476
+ const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, '', ''));
477
+ const next = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, currentSlot.name, trimmed));
478
+ if (introducesNewCycle(baseline, next)) {
479
+ setValidationError(`Adding "${trimmed}" to slot "${currentSlot.name}" would create a slot-dependency cycle. Choose a different component.`);
480
+ return;
481
+ }
482
+ }
367
483
  let nextVals;
368
484
  let cursorAfter;
369
485
  if (editingValue.mode === 'add') {
@@ -384,22 +500,28 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
384
500
  commit({ ...editorState, slots: nextSlots });
385
501
  }
386
502
  setValueCursor(cursorAfter);
503
+ setValidationError(null);
387
504
  }
388
505
  setEditingValue(null);
389
506
  setValueText('');
507
+ setPickerCursor(0);
390
508
  return;
391
509
  }
392
510
  if (key.escape) {
393
511
  setEditingValue(null);
394
512
  setValueText('');
513
+ setPickerCursor(0);
514
+ setValidationError(null);
395
515
  return;
396
516
  }
397
517
  if (key.backspace) {
398
518
  setValueText((t) => t.slice(0, -1));
519
+ setPickerCursor(0);
399
520
  return;
400
521
  }
401
522
  if (input && input.length === 1 && !key.ctrl && !key.meta) {
402
523
  setValueText((t) => t + input);
524
+ setPickerCursor(0);
403
525
  return;
404
526
  }
405
527
  return;
@@ -985,7 +1107,15 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
985
1107
  // slot row
986
1108
  const s = slots[row.idx];
987
1109
  const isSelected = inSlots && row.idx === slotIdx;
988
- return (_jsx(SlotRow, { slot: s, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth }, `slot-${row.idx}`));
1110
+ // INTEG-4401: compute picker candidates for selected slot in add-mode.
1111
+ const slotPickerCandidates = isSelected &&
1112
+ editingValue?.mode === 'add' &&
1113
+ activeField === 'allowedComponents' &&
1114
+ projectSlotGraph &&
1115
+ currentComponentName
1116
+ ? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, s.name)
1117
+ : null;
1118
+ return (_jsx(SlotRow, { slot: s, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth, pickerCandidates: slotPickerCandidates, pickerCursor: pickerCursor }, `slot-${row.idx}`));
989
1119
  }) }), sourceOpen &&
990
1120
  !onToggleSourceExternal &&
991
1121
  (() => {
@@ -1,10 +1,39 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { CDFComponentEntry, DTCGTokenEntry } from '@contentful/experience-design-system-types';
3
+ import { findSlotCycles } from '../analyze/cycle-detection.js';
3
4
  import type { ServerPreviewResponse } from '@contentful/experience-design-system-types';
4
5
  export declare function readTokensFromPath(flag: string, p: string): Promise<DTCGTokenEntry[]>;
6
+ /**
7
+ * Pure cycle detection — returns [] when the graph is acyclic. Callers own
8
+ * how to react (CLI standalone uses `assertNoSlotCycles` which exits 1; the
9
+ * wizard uses `detectSlotCycles` directly to route to an in-TUI error step
10
+ * without ever POSTing to EDSI).
11
+ */
12
+ export declare function detectSlotCycles(components: Array<{
13
+ key: string;
14
+ entry: CDFComponentEntry;
15
+ }>): ReturnType<typeof findSlotCycles>;
16
+ /**
17
+ * Build a stderr-style message block for a set of cycles. Extracted so the
18
+ * wizard can render the same text in an in-TUI error panel and any future
19
+ * headless surface can log identical output.
20
+ */
21
+ export declare function formatSlotCycleReport(cycles: ReturnType<typeof findSlotCycles>): string[];
5
22
  export declare function assertNoSlotCycles(components: Array<{
6
23
  key: string;
7
24
  entry: CDFComponentEntry;
8
25
  }>): void;
26
+ /**
27
+ * Reconstruct the `{key, entry}` shape from a built `ManifestPayload`. The
28
+ * wizard's push path holds a serialized manifest rather than the underlying
29
+ * CDF records, so we unwrap it here to feed `detectSlotCycles`. Skips the
30
+ * `$schema` sentinel key. Safe on undefined/empty manifests (returns []).
31
+ */
32
+ export declare function extractComponentsFromManifest(manifest: {
33
+ componentsManifest?: Record<string, unknown>;
34
+ } | null | undefined): Array<{
35
+ key: string;
36
+ entry: CDFComponentEntry;
37
+ }>;
9
38
  export declare function hasBreakingChangesWithImpact(preview: ServerPreviewResponse): boolean;
10
39
  export declare function registerApplyCommand(program: Command): void;
@@ -177,10 +177,17 @@ async function resolveSharedInputs(opts) {
177
177
  // by then the request has already been serialized, transported, and partially
178
178
  // applied — leading to confusing partial-failure states. Detecting the same
179
179
  // violation locally lets us fail fast with a clear message and zero
180
- // side-effects. Called from the `apply push` and `apply select` flows only;
180
+ // side-effects. Called from the `apply push` and `apply select` flows AND
181
+ // from the wizard's `experiences import` push path (via `detectSlotCycles`).
181
182
  // `apply preview` is read-only and still runs (its diff is used to warn but
182
183
  // not to block).
183
- export function assertNoSlotCycles(components) {
184
+ /**
185
+ * Pure cycle detection — returns [] when the graph is acyclic. Callers own
186
+ * how to react (CLI standalone uses `assertNoSlotCycles` which exits 1; the
187
+ * wizard uses `detectSlotCycles` directly to route to an in-TUI error step
188
+ * without ever POSTing to EDSI).
189
+ */
190
+ export function detectSlotCycles(components) {
184
191
  const cycleInput = components.map(({ key, entry }) => ({
185
192
  name: key,
186
193
  slots: Object.entries(entry.$slots ?? {}).map(([slotName, slotDef]) => ({
@@ -188,9 +195,14 @@ export function assertNoSlotCycles(components) {
188
195
  allowedComponents: slotDef.$allowedComponents ?? [],
189
196
  })),
190
197
  }));
191
- const cycles = findSlotCycles(cycleInput);
192
- if (cycles.length === 0)
193
- return;
198
+ return findSlotCycles(cycleInput);
199
+ }
200
+ /**
201
+ * Build a stderr-style message block for a set of cycles. Extracted so the
202
+ * wizard can render the same text in an in-TUI error panel and any future
203
+ * headless surface can log identical output.
204
+ */
205
+ export function formatSlotCycleReport(cycles) {
194
206
  const lines = [];
195
207
  lines.push(`Error: manifest:components/slot-cycles — ${cycles.length} slot dependency cycle(s) detected. Push refused.`);
196
208
  for (let i = 0; i < cycles.length; i += 1) {
@@ -199,9 +211,35 @@ export function assertNoSlotCycles(components) {
199
211
  const suggested = suggestCycleBreakEdge(cycle, cycles);
200
212
  lines.push(` Fix: remove '${suggested.toComponent}' from ${suggested.fromComponent}.$slots.${suggested.slotName}.$allowedComponents`);
201
213
  }
202
- process.stderr.write(lines.join('\n') + '\n');
214
+ return lines;
215
+ }
216
+ export function assertNoSlotCycles(components) {
217
+ const cycles = detectSlotCycles(components);
218
+ if (cycles.length === 0)
219
+ return;
220
+ process.stderr.write(formatSlotCycleReport(cycles).join('\n') + '\n');
203
221
  process.exit(1);
204
222
  }
223
+ /**
224
+ * Reconstruct the `{key, entry}` shape from a built `ManifestPayload`. The
225
+ * wizard's push path holds a serialized manifest rather than the underlying
226
+ * CDF records, so we unwrap it here to feed `detectSlotCycles`. Skips the
227
+ * `$schema` sentinel key. Safe on undefined/empty manifests (returns []).
228
+ */
229
+ export function extractComponentsFromManifest(manifest) {
230
+ const componentsManifest = manifest?.componentsManifest;
231
+ if (!componentsManifest)
232
+ return [];
233
+ const out = [];
234
+ for (const [key, value] of Object.entries(componentsManifest)) {
235
+ if (key === '$schema')
236
+ continue;
237
+ if (!value || typeof value !== 'object')
238
+ continue;
239
+ out.push({ key, entry: value });
240
+ }
241
+ return out;
242
+ }
205
243
  // --- Output helpers ---
206
244
  export function hasBreakingChangesWithImpact(preview) {
207
245
  const allChanged = [...preview.components.changed, ...preview.tokens.changed];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * INTEG-4401 Fix C — parse EDSI failure bodies into a structured shape the
3
+ * TUI can render without dumping timestamps + trace ids at operators.
4
+ *
5
+ * EDSI failure bodies come in three broad shapes today:
6
+ *
7
+ * 1. Plain JSON error:
8
+ * `{"code":"TopoSortCycleError","message":"...","cycle":["CycleA","CycleB"]}`
9
+ *
10
+ * 2. Wrapper JSON with `details`:
11
+ * `{"sys":{"type":"Error"},"message":"...","details":{"code":"...","cycle":[...]}}`
12
+ *
13
+ * 3. Lambda log-line spill (the response body is the CloudWatch log
14
+ * formatting the worker's uncaught exception):
15
+ * `2026-07-07T22:26:26.479Z\t<request-id>\tERROR\t[dd.trace_id=... dd.span_id=...] <message> {\n operationId: '...',\n code: 'TopoSortCycleError',\n cycle: [ 'CycleA', 'CycleB' ]\n}`
16
+ *
17
+ * We parse each shape best-effort and fall back to a cleaned raw message so
18
+ * the ErrorStep never has to show the operator a timestamp + trace id.
19
+ */
20
+ export interface ParsedEdsiError {
21
+ /** Server-side error code, if we could extract one. */
22
+ code: string | null;
23
+ /** Human-readable message, stripped of log/trace decoration. */
24
+ message: string;
25
+ /** Cycle participants, when `code === 'TopoSortCycleError'`. */
26
+ cycle: string[] | null;
27
+ /** True when the message survived cleaning as-is (no parseable structure). */
28
+ raw: boolean;
29
+ }
30
+ /**
31
+ * Strip the Lambda/CloudWatch log-line decoration from the head of a body so
32
+ * the operator-facing message reads as an error sentence, not a log entry.
33
+ */
34
+ export declare function stripLambdaLogPrefix(body: string): string;
35
+ export declare function parseEdsiError(rawInput: string | undefined | null): ParsedEdsiError;
36
+ /**
37
+ * Render a parsed error into a short multi-line block for the ErrorStep.
38
+ * Callers can pass `verbose: true` to include the raw body underneath.
39
+ */
40
+ export declare function formatParsedEdsiError(parsed: ParsedEdsiError, opts?: {
41
+ verbose?: boolean;
42
+ raw?: string;
43
+ }): string;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * INTEG-4401 Fix C — parse EDSI failure bodies into a structured shape the
3
+ * TUI can render without dumping timestamps + trace ids at operators.
4
+ *
5
+ * EDSI failure bodies come in three broad shapes today:
6
+ *
7
+ * 1. Plain JSON error:
8
+ * `{"code":"TopoSortCycleError","message":"...","cycle":["CycleA","CycleB"]}`
9
+ *
10
+ * 2. Wrapper JSON with `details`:
11
+ * `{"sys":{"type":"Error"},"message":"...","details":{"code":"...","cycle":[...]}}`
12
+ *
13
+ * 3. Lambda log-line spill (the response body is the CloudWatch log
14
+ * formatting the worker's uncaught exception):
15
+ * `2026-07-07T22:26:26.479Z\t<request-id>\tERROR\t[dd.trace_id=... dd.span_id=...] <message> {\n operationId: '...',\n code: 'TopoSortCycleError',\n cycle: [ 'CycleA', 'CycleB' ]\n}`
16
+ *
17
+ * We parse each shape best-effort and fall back to a cleaned raw message so
18
+ * the ErrorStep never has to show the operator a timestamp + trace id.
19
+ */
20
+ const LAMBDA_LOG_PREFIX_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+[0-9a-f-]+\s+ERROR\s+(?:\[dd\.[^\]]*\]\s*)?/;
21
+ const DD_TAG_RE = /\[dd\.(?:trace_id|span_id)=[^\]]*\]\s*/g;
22
+ /**
23
+ * Strip the Lambda/CloudWatch log-line decoration from the head of a body so
24
+ * the operator-facing message reads as an error sentence, not a log entry.
25
+ */
26
+ export function stripLambdaLogPrefix(body) {
27
+ let out = body.replace(LAMBDA_LOG_PREFIX_RE, '');
28
+ out = out.replace(DD_TAG_RE, '');
29
+ return out.trim();
30
+ }
31
+ /**
32
+ * Extract a JS-object-literal tail like `{ operationId: '...', code: '...', cycle: [ 'A', 'B' ] }`
33
+ * that Lambda-spilled errors trail with. Returns `null` if no such structure
34
+ * is present or the fields we care about can't be recovered.
35
+ */
36
+ function parseObjectLiteralTail(body) {
37
+ const braceStart = body.lastIndexOf('{');
38
+ if (braceStart === -1)
39
+ return null;
40
+ const tail = body.slice(braceStart);
41
+ const codeMatch = tail.match(/code:\s*['"]([^'"]+)['"]/);
42
+ const cycleMatch = tail.match(/cycle:\s*\[\s*([^\]]*)\s*\]/);
43
+ if (!codeMatch && !cycleMatch)
44
+ return null;
45
+ const code = codeMatch ? codeMatch[1] : null;
46
+ let cycle = null;
47
+ if (cycleMatch) {
48
+ cycle = cycleMatch[1]
49
+ .split(',')
50
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
51
+ .filter((s) => s.length > 0);
52
+ if (cycle.length === 0)
53
+ cycle = null;
54
+ }
55
+ return { code, cycle };
56
+ }
57
+ /**
58
+ * Best-effort JSON parse walking one level deep into `details`. Fields we
59
+ * accept anywhere in the top-level or `details` object: `code`, `message`,
60
+ * `cycle`. Returns `null` when the input isn't JSON.
61
+ */
62
+ function parseJsonBody(body) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(body);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (!parsed || typeof parsed !== 'object')
71
+ return null;
72
+ const p = parsed;
73
+ const details = (p.details && typeof p.details === 'object' ? p.details : {});
74
+ const pick = (k) => (p[k] !== undefined ? p[k] : details[k]);
75
+ const codeRaw = pick('code');
76
+ const messageRaw = pick('message');
77
+ const cycleRaw = pick('cycle');
78
+ const out = {};
79
+ if (typeof codeRaw === 'string')
80
+ out.code = codeRaw;
81
+ if (typeof messageRaw === 'string')
82
+ out.message = messageRaw;
83
+ if (Array.isArray(cycleRaw)) {
84
+ const strs = cycleRaw.filter((x) => typeof x === 'string');
85
+ if (strs.length > 0)
86
+ out.cycle = strs;
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * Parse an EDSI error body (the raw response body, or an `ApiError.message`
92
+ * that has the body appended). Always returns a value — the fallback is the
93
+ * cleaned raw message with `code: null`.
94
+ */
95
+ // ApiError.message shape: `${phasePrefix}\n${body}` where phasePrefix looks
96
+ // like `apply failed: 400`, `preview failed: 422`, `poll failed: 500`. We
97
+ // only strip the prefix line when the first line matches this shape —
98
+ // otherwise a raw body that happens to contain a newline (e.g. a Lambda log
99
+ // spill with a multi-line object literal) gets truncated mid-structure.
100
+ const API_ERROR_PREFIX_RE = /^(?:apply|preview|poll) failed: \d+$/;
101
+ export function parseEdsiError(rawInput) {
102
+ if (!rawInput)
103
+ return { code: null, message: '', cycle: null, raw: true };
104
+ const nlIndex = rawInput.indexOf('\n');
105
+ let body = rawInput;
106
+ let prefix = '';
107
+ if (nlIndex !== -1) {
108
+ const firstLine = rawInput.slice(0, nlIndex);
109
+ if (API_ERROR_PREFIX_RE.test(firstLine)) {
110
+ prefix = firstLine;
111
+ body = rawInput.slice(nlIndex + 1);
112
+ }
113
+ }
114
+ const cleaned = stripLambdaLogPrefix(body);
115
+ const json = parseJsonBody(cleaned) ?? parseJsonBody(body);
116
+ if (json && (json.code || json.message || json.cycle)) {
117
+ return {
118
+ code: json.code ?? null,
119
+ message: json.message ?? (cleaned || prefix),
120
+ cycle: json.cycle ?? null,
121
+ raw: false,
122
+ };
123
+ }
124
+ const literal = parseObjectLiteralTail(cleaned);
125
+ if (literal && (literal.code || literal.cycle)) {
126
+ // Grab the human-readable head — text before the trailing `{`. This is
127
+ // the sentence part of a Lambda log-line spill (e.g. "Apply operation
128
+ // <id> rejected: ComponentType slot dependency cycle detected among:
129
+ // CycleA, CycleB. Break the cycle by...").
130
+ const braceStart = cleaned.lastIndexOf('{');
131
+ const head = braceStart === -1 ? cleaned : cleaned.slice(0, braceStart).trim();
132
+ return {
133
+ code: literal.code ?? null,
134
+ message: head || cleaned,
135
+ cycle: literal.cycle ?? null,
136
+ raw: false,
137
+ };
138
+ }
139
+ return { code: null, message: cleaned || prefix || rawInput, cycle: null, raw: true };
140
+ }
141
+ /**
142
+ * Render a parsed error into a short multi-line block for the ErrorStep.
143
+ * Callers can pass `verbose: true` to include the raw body underneath.
144
+ */
145
+ export function formatParsedEdsiError(parsed, opts = {}) {
146
+ const lines = [];
147
+ if (parsed.code) {
148
+ lines.push(`[${parsed.code}]`);
149
+ }
150
+ if (parsed.message) {
151
+ lines.push(parsed.message);
152
+ }
153
+ if (parsed.cycle && parsed.cycle.length > 0) {
154
+ lines.push(`Cycle: ${parsed.cycle.join(' → ')} → ${parsed.cycle[0]}`);
155
+ lines.push('Break the cycle by removing at least one $allowedComponents entry.');
156
+ }
157
+ if (opts.verbose && opts.raw) {
158
+ lines.push('');
159
+ lines.push('--- raw ---');
160
+ lines.push(opts.raw);
161
+ }
162
+ return lines.filter(Boolean).join('\n');
163
+ }
@@ -33,6 +33,8 @@ import { nextStateAfterPrint } from './run-print-files-helpers.js';
33
33
  import { PushDecisionGateStep } from './steps/PushDecisionGateStep.js';
34
34
  import { chooseGateAction } from './push-decision-gate-helpers.js';
35
35
  import { ImportApiClient, ApiError } from '../../apply/api-client.js';
36
+ import { detectSlotCycles, extractComponentsFromManifest, formatSlotCycleReport } from '../../apply/command.js';
37
+ import { parseEdsiError, formatParsedEdsiError } from '../../apply/error-parser.js';
36
38
  import { handlePreview422, applySkipValidationErrors, clearedValidationErrorState } from './wizard-422-helpers.js';
37
39
  import { parseGenerateStderrChunk } from './wizard-generate-progress.js';
38
40
  import { spawnGenerateChild } from './spawn-generate.js';
@@ -1034,6 +1036,23 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
1034
1036
  return;
1035
1037
  }
1036
1038
  }
1039
+ // INTEG-4401 Fix A — pre-push slot-cycle hard block for the wizard's
1040
+ // direct-API push path. The standalone `apply push` / `apply select`
1041
+ // commands run `assertNoSlotCycles` before ever constructing an API
1042
+ // client, but `experiences import` calls `client.applyImport` from here
1043
+ // without shelling out — so the guard has to run again on this path.
1044
+ // Otherwise a cyclic graph reaches EDSI and the operator sees a raw
1045
+ // Lambda error dump (see Fix C) instead of the clear local report.
1046
+ const cycles = detectSlotCycles(extractComponentsFromManifest(manifest));
1047
+ if (cycles.length > 0) {
1048
+ update({
1049
+ step: 'error',
1050
+ errorStep: 'apply push',
1051
+ errorMessage: formatSlotCycleReport(cycles).join('\n'),
1052
+ errorAllowCredentialRetry: false,
1053
+ });
1054
+ return;
1055
+ }
1037
1056
  update({ step: 'pushing', pushProgress: null });
1038
1057
  try {
1039
1058
  const resolvedHost = resolveWizardHost(host);
@@ -1124,27 +1143,57 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
1124
1143
  };
1125
1144
  }
1126
1145
  else {
1127
- // API didn't return items fall back to summary + preview counts
1146
+ // API didn't return items. INTEG-4401 Fix B do NOT report preview
1147
+ // counts as "created": if the server said something failed, echo it
1148
+ // truthfully so the summary can't lie. Historically this branch used
1149
+ // preview.new/changed/removed as success counts and only surfaced
1150
+ // failed:0, which meant a cycle rejection (summary: 0 succeeded /
1151
+ // 11 failed, empty items) rendered as "Done ✓ 2 Component Types
1152
+ // created" — the exact regression this fix guards against.
1153
+ const summary = operation.summary ?? { total: 0, pending: 0, succeeded: 0, failed: 0 };
1154
+ const anyFailure = summary.failed > 0 || operation.sys.status === 'failed' || operation.sys.status === 'partial';
1128
1155
  pushResult = {
1129
1156
  componentTypes: {
1130
- created: preview?.components.new.length ?? 0,
1131
- updated: preview?.components.changed.length ?? 0,
1132
- removed: preview?.components.removed.length ?? 0,
1133
- failed: 0,
1157
+ created: anyFailure ? 0 : (preview?.components.new.length ?? 0),
1158
+ updated: anyFailure ? 0 : (preview?.components.changed.length ?? 0),
1159
+ removed: anyFailure ? 0 : (preview?.components.removed.length ?? 0),
1160
+ // Attribute failures to component types by default — without a
1161
+ // per-item breakdown we can't split components vs tokens, and
1162
+ // components are the dominant entity in an import.
1163
+ failed: summary.failed,
1134
1164
  },
1135
1165
  designTokens: {
1136
- created: preview?.tokens.new.length ?? 0,
1137
- updated: preview?.tokens.changed.length ?? 0,
1138
- removed: preview?.tokens.removed.length ?? 0,
1166
+ created: anyFailure ? 0 : (preview?.tokens.new.length ?? 0),
1167
+ updated: anyFailure ? 0 : (preview?.tokens.changed.length ?? 0),
1168
+ removed: anyFailure ? 0 : (preview?.tokens.removed.length ?? 0),
1139
1169
  failed: 0,
1140
1170
  },
1141
- summary: operation.summary,
1171
+ summary,
1142
1172
  };
1143
1173
  }
1144
1174
  update({ step: 'done', pushResult });
1145
1175
  }
1146
1176
  catch (e) {
1147
- const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : 'Push failed';
1177
+ // INTEG-4401 Fix C parse EDSI error bodies into a `[CODE] message`
1178
+ // block before handing off to ErrorStep, so cycle rejections and other
1179
+ // structured failures don't render as raw Lambda log lines
1180
+ // (timestamp / request-id / dd.trace_id / etc.).
1181
+ let msg;
1182
+ if (e instanceof ApiError) {
1183
+ const parsed = parseEdsiError(e.body || e.message);
1184
+ msg = formatParsedEdsiError(parsed, {
1185
+ verbose: process.env['EDSI_VERBOSE_ERRORS'] === '1',
1186
+ raw: e.body,
1187
+ });
1188
+ if (!msg)
1189
+ msg = e.message;
1190
+ }
1191
+ else if (e instanceof Error) {
1192
+ msg = e.message;
1193
+ }
1194
+ else {
1195
+ msg = 'Push failed';
1196
+ }
1148
1197
  update({
1149
1198
  step: 'error',
1150
1199
  errorStep: 'apply push',
@@ -8,8 +8,8 @@ import { StatusBar } from '../../../analyze/select/tui/components/StatusBar.js';
8
8
  import { FinalizeDialog } from '../../../analyze/select/tui/components/FinalizeDialog.js';
9
9
  import { QuitDialog } from '../../../analyze/select/tui/components/QuitDialog.js';
10
10
  import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
11
- import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, loadSlotCycles, } from '../../../session/db.js';
12
- import { formatCyclePath } from '../../../analyze/cycle-detection.js';
11
+ import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, loadSlotCycles, storeSlotCycles, } from '../../../session/db.js';
12
+ import { formatCyclePathSegments, findSlotCycles, suggestCycleBreakEdge } from '../../../analyze/cycle-detection.js';
13
13
  import { RationalePanel } from '../../../analyze/select/tui/components/RationalePanel.js';
14
14
  import { ComponentRationalePanel } from '../../../analyze/select/tui/components/ComponentRationalePanel.js';
15
15
  import { applyPreviewAnnotations } from '../../../analyze/select/preview-annotations.js';
@@ -253,6 +253,66 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
253
253
  }
254
254
  onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
255
255
  };
256
+ /**
257
+ * INTEG-4401 (Fix 3/4): re-run cycle detection against the current in-memory
258
+ * component state and, if the result differs from the persisted `slotCycles`
259
+ * state, update both React state and the session DB. Called from:
260
+ * - `handleEditSave` after a FieldEditor save mutates a slot's
261
+ * `$allowedComponents`, so the banner / sidebar badges / [c] panel reflect
262
+ * reality instead of the stale extract-time snapshot.
263
+ * - The `r` reject keystroke, since dropping a component from the manifest
264
+ * can collapse cycles that routed through it.
265
+ *
266
+ * Rejected components are excluded from the graph — they will never ship, so
267
+ * they cannot contribute to a real push-time cycle even if their slot config
268
+ * still references other components locally.
269
+ *
270
+ * Cheap for typical N: findSlotCycles is O((V+E)(C+1)) and most manifests
271
+ * have zero or a handful of cycles. Wrapped in try/catch so a malformed
272
+ * $slots shape can't crash the render pipeline.
273
+ */
274
+ const recomputeCycles = (currentComponents) => {
275
+ try {
276
+ const graph = currentComponents
277
+ .filter((c) => c.status !== 'rejected')
278
+ .map((c) => ({
279
+ name: c.key,
280
+ slots: Object.entries(c.entry.$slots ?? {}).map(([slotName, slotDef]) => ({
281
+ name: slotName,
282
+ allowedComponents: Array.isArray(slotDef?.$allowedComponents)
283
+ ? slotDef.$allowedComponents.filter((v) => typeof v === 'string')
284
+ : [],
285
+ })),
286
+ }));
287
+ const rawCycles = findSlotCycles(graph);
288
+ const next = rawCycles.map((cycle) => ({
289
+ path: cycle.path,
290
+ edges: cycle.edges,
291
+ suggestedBreak: cycle.edges.length > 0 ? suggestCycleBreakEdge(cycle, rawCycles) : null,
292
+ }));
293
+ // Cheap structural equality via JSON.stringify — cycle count is tiny and
294
+ // this only fires on user actions, not on every render.
295
+ const prevSerialized = JSON.stringify(slotCycles);
296
+ const nextSerialized = JSON.stringify(next);
297
+ if (prevSerialized === nextSerialized)
298
+ return;
299
+ setSlotCycles(next);
300
+ const db = openPipelineDb();
301
+ try {
302
+ storeSlotCycles(db, extractSessionId, next);
303
+ }
304
+ finally {
305
+ db.close();
306
+ }
307
+ // If the new cycle set is empty, clear any lingering finalize error the
308
+ // [F] guard may have surfaced. Otherwise leave it alone.
309
+ if (next.length === 0)
310
+ setFinalizeError(null);
311
+ }
312
+ catch {
313
+ // Defensive: swallow — never let cycle detection crash the review UI.
314
+ }
315
+ };
256
316
  const handleEditSave = () => {
257
317
  const current = components[selectedIdx];
258
318
  if (!current)
@@ -268,7 +328,11 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
268
328
  setSaveError('Invalid CDF entry: must have $type: "component" and $properties object');
269
329
  return;
270
330
  }
271
- setComponents((prev) => prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c));
331
+ let updatedComponents = [];
332
+ setComponents((prev) => {
333
+ updatedComponents = prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c);
334
+ return updatedComponents;
335
+ });
272
336
  setDraftValue('');
273
337
  setSaveError(null);
274
338
  const db = openPipelineDb();
@@ -278,6 +342,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
278
342
  finally {
279
343
  db.close();
280
344
  }
345
+ // INTEG-4401 (Fix 3/4): recompute cycles against the freshly-edited
346
+ // component set so banner/badges/[c]-panel stay in sync. The FieldEditor
347
+ // picker filters cycle-forming candidates, but free-text edits + JSON
348
+ // pastes still slip through, and edits that BREAK an existing cycle
349
+ // won't propagate without this call.
350
+ recomputeCycles(updatedComponents);
281
351
  // Feature 2: re-fire the live preview now that pipeline.db reflects
282
352
  // the new state. The hook owns debounce + cred-missing short-circuit.
283
353
  livePreviewHook.trigger();
@@ -460,6 +530,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
460
530
  return;
461
531
  }
462
532
  if (input === 'F') {
533
+ // INTEG-4401: refuse to open the finalize dialog while any slot cycle
534
+ // is unresolved. The push-time hard block (`assertNoSlotCycles`) would
535
+ // catch this downstream, but showing an inline banner here saves the
536
+ // operator from confirming the dialog only to hit a stderr crash.
537
+ if (slotCycles.length > 0) {
538
+ setFinalizeError('Cannot finalize — resolve slot dependency cycle(s) first (press [c] for detail)');
539
+ return;
540
+ }
463
541
  setShowFinalize(true);
464
542
  return;
465
543
  }
@@ -469,7 +547,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
469
547
  return;
470
548
  }
471
549
  if (input === 'r') {
472
- updateStatus(selectedIdx, 'rejected');
550
+ // INTEG-4401 (Fix 4): rejecting removes the component from the effective
551
+ // manifest, so recompute cycles across the reduced graph — any cycle
552
+ // that routed through this component collapses. We build the "next"
553
+ // components array inline (mirroring updateStatus) so recomputeCycles
554
+ // sees the post-update graph without waiting for a render tick.
555
+ const next = components.map((c, i) => i === selectedIdx ? { ...c, status: 'rejected' } : c);
556
+ setComponents(next);
557
+ recomputeCycles(next);
473
558
  return;
474
559
  }
475
560
  if (input === 'A') {
@@ -555,6 +640,21 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
555
640
  // sidebar width doesn't jitter as live-preview annotations flip in/out.
556
641
  const sidebarWidth = Math.min(Math.max(longestName + 5, 14), 30);
557
642
  const panelWidth = Math.max(10, terminalWidth - sidebarWidth - 4);
643
+ // INTEG-4401: project-wide slot graph passed to FieldEditor so its
644
+ // $allowedComponents picker can filter out cycle-forming candidates. Built
645
+ // from every review entry's $slots — includes accepted, rejected, and
646
+ // needs-review components so the graph reflects what will actually be
647
+ // pushed. The FieldEditor replaces its own entry in-simulation with its
648
+ // live editor state, so pending edits are always reflected.
649
+ const projectSlotGraph = components.map((c) => ({
650
+ name: c.key,
651
+ slots: Object.entries(c.entry.$slots ?? {}).map(([slotName, slotDef]) => ({
652
+ name: slotName,
653
+ allowedComponents: Array.isArray(slotDef?.$allowedComponents)
654
+ ? slotDef.$allowedComponents.filter((v) => typeof v === 'string')
655
+ : [],
656
+ })),
657
+ }));
558
658
  const accepted = components.filter((c) => c.status === 'accepted').length;
559
659
  const rejected = components.filter((c) => c.status === 'rejected').length;
560
660
  const needsReview = components.filter((c) => c.status === 'needs-review').length;
@@ -575,7 +675,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
575
675
  slotCycles.forEach((cycle, idx) => {
576
676
  const nodeCount = new Set(cycle.path).size;
577
677
  lines.push(_jsx(Text, { bold: true, children: `▸ Cycle ${idx + 1} (${nodeCount} component${nodeCount === 1 ? '' : 's'}):` }, `cyc-h-${idx}`));
578
- lines.push(_jsx(Text, { children: ` ${formatCyclePath(cycle, 16)}` }, `cyc-p-${idx}`));
678
+ // Colorize slots (cyan, bracketed) distinctly from components so
679
+ // the operator can visually parse the alternating structure.
680
+ // Brackets on slot names ensure the distinction survives when
681
+ // ANSI is stripped (logs, redirected output).
682
+ const segs = formatCyclePathSegments(cycle, 16);
683
+ lines.push(_jsxs(Text, { children: [' ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: "cyan", children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { children: seg.text }, si)))] }, `cyc-p-${idx}`));
579
684
  if (cycle.suggestedBreak) {
580
685
  const b = cycle.suggestedBreak;
581
686
  lines.push(_jsx(Text, { dimColor: true, children: ` Suggested fix: remove '${b.toComponent}' from ${b.fromComponent}.$slots.${b.slotName}.$allowedComponents` }, `cyc-f-${idx}`));
@@ -607,7 +712,10 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
607
712
  if (!hasCounts)
608
713
  return null;
609
714
  return (_jsxs(Box, { children: [_jsx(Text, { children: 'Preview: ' }), _jsx(Text, { color: "green", children: `${counts.new} new` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "yellow", children: `${counts.changed} changed` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { dimColor: true, children: `${counts.removed} removed` }), removedComponents.length > 0 && _jsx(Text, { dimColor: true, children: ' ([d] removed list)' }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "red", bold: true, children: `${counts.breaking} breaking` })] }));
610
- })(), !dialogOpen && slotCycles.length > 0 && !showCyclePanel && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: `⚠ ${slotCycles.length} slot dependency cycle${slotCycles.length === 1 ? '' : 's'} detected — push will fail` }), slotCycles.slice(0, 3).map((cycle, idx) => (_jsx(Text, { color: "yellow", children: ` Cycle: ${formatCyclePath(cycle)}` }, `cyc-banner-${idx}`))), slotCycles.length > 3 && _jsx(Text, { color: "yellow", children: ` …${slotCycles.length - 3} more` }), _jsx(Text, { dimColor: true, children: ' press [c] for detail' })] })), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: "yellow", children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && finalizeError && _jsx(Text, { color: "red", children: `⚠ ${finalizeError}` }), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
715
+ })(), !dialogOpen && slotCycles.length > 0 && !showCyclePanel && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: `⚠ ${slotCycles.length} slot dependency cycle${slotCycles.length === 1 ? '' : 's'} detected — push will fail` }), slotCycles.slice(0, 3).map((cycle, idx) => {
716
+ const segs = formatCyclePathSegments(cycle);
717
+ return (_jsxs(Text, { color: "yellow", children: [' Cycle: ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: "cyan", children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { color: "yellow", children: seg.text }, si)))] }, `cyc-banner-${idx}`));
718
+ }), slotCycles.length > 3 && _jsx(Text, { color: "yellow", children: ` …${slotCycles.length - 3} more` }), _jsx(Text, { dimColor: true, children: ' press [c] for detail' })] })), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: "yellow", children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && finalizeError && _jsx(Text, { color: "red", children: `⚠ ${finalizeError}` }), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
611
719
  const idx = components.findIndex((c) => c.key === id);
612
720
  if (idx >= 0) {
613
721
  setSelectedIdx(idx);
@@ -656,7 +764,7 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
656
764
  }, onToggleSourceExternal: () => {
657
765
  setPanelOpen('source');
658
766
  setPanelScrollOffset(() => 0);
659
- }, onTextEntryActiveChange: setTextEntryActive }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
767
+ }, onTextEntryActiveChange: setTextEntryActive, projectSlotGraph: projectSlotGraph, currentComponentName: selected.key }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
660
768
  ? ' [a] accept [r] reject [A] accept all [J] ' +
661
769
  (showJson ? 'hide JSON' : 'show JSON') +
662
770
  ' [F] finalize [e/Tab] focus panel' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.12.4-dev-build-9c819d8.0",
3
+ "version": "2.12.4-dev-build-ff57340.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "svelte": "^5.56.4",
38
38
  "ts-morph": "^27.0.2",
39
39
  "typescript": "^5.9.3",
40
- "@contentful/experience-design-system-types": "2.12.4-dev-build-9c819d8.0"
40
+ "@contentful/experience-design-system-types": "2.12.4-dev-build-ff57340.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@tsconfig/node24": "^24.0.3",