@contentful/experience-design-system-cli 2.12.4-dev-build-5aa6a69.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.
@@ -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,6 +1,39 @@
1
1
  import type { Command } from 'commander';
2
- import type { DTCGTokenEntry } from '@contentful/experience-design-system-types';
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[];
22
+ export declare function assertNoSlotCycles(components: Array<{
23
+ key: string;
24
+ entry: CDFComponentEntry;
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
+ }>;
5
38
  export declare function hasBreakingChangesWithImpact(preview: ServerPreviewResponse): boolean;
6
39
  export declare function registerApplyCommand(program: Command): void;
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { validateCDF, flattenDTCG, validateDTCG, buildManifest, buildFilteredManifest, } from '@contentful/experience-design-system-types';
6
6
  import { ApiError, ImportApiClient } from './api-client.js';
7
7
  import { openPipelineDb, loadCDFComponents } from '../session/db.js';
8
+ import { findSlotCycles, suggestCycleBreakEdge, formatCyclePath } from '../analyze/cycle-detection.js';
8
9
  import { isEmptyPreview } from './preview-utils.js';
9
10
  import { ServerPreviewApp, ServerPreviewConfirm, ServerApplyProgress, ServerApplyDone } from './tui/ServerApplyView.js';
10
11
  import { SelectView, makeSelectKey } from './tui/SelectView.js';
@@ -170,6 +171,75 @@ async function resolveSharedInputs(opts) {
170
171
  });
171
172
  return { components, tokens, client };
172
173
  }
174
+ // --- INTEG-4401: pre-push slot-cycle hard block ---
175
+ //
176
+ // The backend apply worker rejects cyclic slot graphs at topo-sort time, but
177
+ // by then the request has already been serialized, transported, and partially
178
+ // applied — leading to confusing partial-failure states. Detecting the same
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 AND
181
+ // from the wizard's `experiences import` push path (via `detectSlotCycles`).
182
+ // `apply preview` is read-only and still runs (its diff is used to warn but
183
+ // not to block).
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) {
191
+ const cycleInput = components.map(({ key, entry }) => ({
192
+ name: key,
193
+ slots: Object.entries(entry.$slots ?? {}).map(([slotName, slotDef]) => ({
194
+ name: slotName,
195
+ allowedComponents: slotDef.$allowedComponents ?? [],
196
+ })),
197
+ }));
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) {
206
+ const lines = [];
207
+ lines.push(`Error: manifest:components/slot-cycles — ${cycles.length} slot dependency cycle(s) detected. Push refused.`);
208
+ for (let i = 0; i < cycles.length; i += 1) {
209
+ const cycle = cycles[i];
210
+ lines.push(` Cycle ${i + 1}: ${formatCyclePath(cycle)}`);
211
+ const suggested = suggestCycleBreakEdge(cycle, cycles);
212
+ lines.push(` Fix: remove '${suggested.toComponent}' from ${suggested.fromComponent}.$slots.${suggested.slotName}.$allowedComponents`);
213
+ }
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');
221
+ process.exit(1);
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
+ }
173
243
  // --- Output helpers ---
174
244
  export function hasBreakingChangesWithImpact(preview) {
175
245
  const allChanged = [...preview.components.changed, ...preview.tokens.changed];
@@ -445,6 +515,12 @@ export function registerApplyCommand(program) {
445
515
  throw e;
446
516
  }
447
517
  const { components, tokens, client } = inputs;
518
+ // INTEG-4401: refuse before spending any credentials or bytes on a
519
+ // manifest the backend will reject. Cycle detection is deterministic
520
+ // and cheap so we run it here (not just at extract time) — this also
521
+ // guards `apply push --components` where extract-time cycles are not
522
+ // in the session DB.
523
+ assertNoSlotCycles(components);
448
524
  try {
449
525
  await client.validateToken();
450
526
  }
@@ -623,6 +699,11 @@ export function registerApplyCommand(program) {
623
699
  throw e;
624
700
  }
625
701
  const { components, tokens, client } = inputs;
702
+ // INTEG-4401: same pre-push cycle block as `apply push`. Running the
703
+ // check on the FULL component set (not the filtered selection) is
704
+ // deliberate: partial selection cannot resolve a cycle whose
705
+ // participants are all still present in the target space.
706
+ assertNoSlotCycles(components);
626
707
  try {
627
708
  await client.validateToken();
628
709
  }
@@ -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
+ }