@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.
@@ -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',
@@ -37,6 +37,6 @@ type GenerateReviewStepProps = {
37
37
  export declare function sortComponentsForSidebar<T extends {
38
38
  key: string;
39
39
  entry: CDFComponentEntry;
40
- }>(components: T[]): T[];
40
+ }>(components: T[], cycleParticipants?: Set<string>): T[];
41
41
  export declare function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, livePreview, spaceId, environmentId, cmaToken, host, tokensPath, initialFinalizeError, }: GenerateReviewStepProps): React.ReactElement;
42
42
  export {};
@@ -8,7 +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, } from '../../../session/db.js';
11
+ import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, loadSlotCycles, storeSlotCycles, } from '../../../session/db.js';
12
+ import { formatCyclePathSegments, findSlotCycles, suggestCycleBreakEdge } from '../../../analyze/cycle-detection.js';
12
13
  import { RationalePanel } from '../../../analyze/select/tui/components/RationalePanel.js';
13
14
  import { ComponentRationalePanel } from '../../../analyze/select/tui/components/ComponentRationalePanel.js';
14
15
  import { applyPreviewAnnotations } from '../../../analyze/select/preview-annotations.js';
@@ -24,13 +25,22 @@ import { computeNextScrollOffset } from '../../../analyze/select/tui/hooks/scrol
24
25
  *
25
26
  * Within each tier (empty / non-empty) we tie-break alphabetically by `key`.
26
27
  */
27
- export function sortComponentsForSidebar(components) {
28
+ export function sortComponentsForSidebar(components, cycleParticipants) {
28
29
  const isEmpty = (entry) => Object.keys(entry.$properties ?? {}).length === 0 && Object.keys(entry.$slots ?? {}).length === 0;
30
+ // Tier order: cycle members first (they block push — surface loudest),
31
+ // then empty (soft warning), then everything else. Ties broken alpha.
32
+ const tier = (c) => {
33
+ if (cycleParticipants?.has(c.key))
34
+ return 0;
35
+ if (isEmpty(c.entry))
36
+ return 1;
37
+ return 2;
38
+ };
29
39
  return [...components].sort((a, b) => {
30
- const aEmpty = isEmpty(a.entry);
31
- const bEmpty = isEmpty(b.entry);
32
- if (aEmpty !== bEmpty)
33
- return aEmpty ? -1 : 1;
40
+ const at = tier(a);
41
+ const bt = tier(b);
42
+ if (at !== bt)
43
+ return at - bt;
34
44
  return a.key.localeCompare(b.key);
35
45
  });
36
46
  }
@@ -76,6 +86,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
76
86
  // Tracks the first `g` of a potential `gg` double-tap (jumps to top in
77
87
  // JSON-view + panel-focused state). Reset on any non-`g` key.
78
88
  const pendingGRef = useRef(false);
89
+ // INTEG-4401: slot-dependency cycles loaded from the session DB. Non-empty
90
+ // triggers sidebar (cycle) badges, banner + [c] detail-panel affordance,
91
+ // and (at push time) a hard block.
92
+ const [slotCycles, setSlotCycles] = useState([]);
93
+ const [showCyclePanel, setShowCyclePanel] = useState(false);
94
+ const [cyclePanelScroll, setCyclePanelScroll] = useState(0);
79
95
  const handleLivePreviewResult = (response) => {
80
96
  if (!response)
81
97
  return;
@@ -107,8 +123,10 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
107
123
  async function load() {
108
124
  const db = openPipelineDb();
109
125
  let cdfComponents;
126
+ let cycles = [];
110
127
  try {
111
128
  cdfComponents = loadCDFComponents(db, extractSessionId);
129
+ cycles = loadSlotCycles(db, extractSessionId);
112
130
  }
113
131
  finally {
114
132
  db.close();
@@ -123,7 +141,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
123
141
  entry,
124
142
  status: 'needs-review',
125
143
  }));
126
- setComponents(sortComponentsForSidebar(reviewEntries));
144
+ const cycleParticipants = new Set();
145
+ for (const c of cycles)
146
+ for (const p of c.path)
147
+ cycleParticipants.add(p);
148
+ setSlotCycles(cycles);
149
+ setComponents(sortComponentsForSidebar(reviewEntries, cycleParticipants));
127
150
  setLoading(false);
128
151
  }
129
152
  load().catch((e) => {
@@ -230,6 +253,66 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
230
253
  }
231
254
  onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
232
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
+ };
233
316
  const handleEditSave = () => {
234
317
  const current = components[selectedIdx];
235
318
  if (!current)
@@ -245,7 +328,11 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
245
328
  setSaveError('Invalid CDF entry: must have $type: "component" and $properties object');
246
329
  return;
247
330
  }
248
- 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
+ });
249
336
  setDraftValue('');
250
337
  setSaveError(null);
251
338
  const db = openPipelineDb();
@@ -255,6 +342,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
255
342
  finally {
256
343
  db.close();
257
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);
258
351
  // Feature 2: re-fire the live preview now that pipeline.db reflects
259
352
  // the new state. The hook owns debounce + cred-missing short-circuit.
260
353
  livePreviewHook.trigger();
@@ -283,6 +376,31 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
283
376
  }
284
377
  return;
285
378
  }
379
+ // INTEG-4401: slot-cycle detail panel. Same modal-swallow rules as
380
+ // showRemovedPanel; q/Esc close, ↑↓ scroll.
381
+ if (showCyclePanel) {
382
+ if (input === 'c' || input === 'q' || key.escape) {
383
+ setShowCyclePanel(false);
384
+ setCyclePanelScroll(0);
385
+ return;
386
+ }
387
+ if (key.upArrow || input === 'k') {
388
+ setCyclePanelScroll((v) => Math.max(0, v - 1));
389
+ return;
390
+ }
391
+ if (key.downArrow || input === 'j') {
392
+ setCyclePanelScroll((v) => v + 1);
393
+ return;
394
+ }
395
+ return;
396
+ }
397
+ // Open the cycle panel from sidebar-focused state when there is at
398
+ // least one cycle to display.
399
+ if (input === 'c' && sidebarFocused && slotCycles.length > 0) {
400
+ setShowCyclePanel(true);
401
+ setCyclePanelScroll(0);
402
+ return;
403
+ }
286
404
  // `d` opens the panel only when live-preview is enabled and there is at
287
405
  // least one removed component to display. Sidebar-focused only so it
288
406
  // doesn't collide with FieldEditor input.
@@ -412,6 +530,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
412
530
  return;
413
531
  }
414
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
+ }
415
541
  setShowFinalize(true);
416
542
  return;
417
543
  }
@@ -421,7 +547,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
421
547
  return;
422
548
  }
423
549
  if (input === 'r') {
424
- 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);
425
558
  return;
426
559
  }
427
560
  if (input === 'A') {
@@ -476,30 +609,87 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
476
609
  const selectedJson = selected ? JSON.stringify({ [selected.key]: selected.entry }, null, 2) : '';
477
610
  const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0 && Object.keys(c.entry.$slots ?? {}).length === 0;
478
611
  const emptyCount = components.filter(isEmpty).length;
612
+ // INTEG-4401: cycle-participant set drives sidebar `(cycle)` badges plus
613
+ // the banner counts. Recomputed on every render — cheap for typical N.
614
+ const cycleParticipantSet = new Set();
615
+ for (const cycle of slotCycles)
616
+ for (const p of cycle.path)
617
+ cycleParticipantSet.add(p);
618
+ const sidebarSuffix = (c) => {
619
+ if (cycleParticipantSet.has(c.key))
620
+ return ' (cycle)';
621
+ if (isEmpty(c))
622
+ return ' (empty)';
623
+ return '';
624
+ };
479
625
  const sidebarItems = components.map((c) => ({
480
626
  id: c.key,
481
- name: isEmpty(c) ? `${c.key} (empty)` : c.key,
627
+ name: `${c.key}${sidebarSuffix(c)}`,
482
628
  status: c.status,
483
629
  previewAnnotation: previewAnnotations.get(c.key),
484
630
  extractionConfidence: null,
485
631
  needsReview: false,
486
632
  validationErrorCount: 0,
487
- validationWarningCount: isEmpty(c) ? 1 : 0,
633
+ validationWarningCount: sidebarSuffix(c) !== '' ? 1 : 0,
488
634
  }));
489
- // Account for the "(empty)" suffix added to zero-prop component names so the
490
- // sidebar doesn't truncate it.
491
- const longestName = components.reduce((m, c) => Math.max(m, c.key.length + (isEmpty(c) ? ' (empty)'.length : 0)), 0);
635
+ // Account for the "(cycle)" / "(empty)" suffix added to badged component
636
+ // names so the sidebar doesn't truncate them.
637
+ const longestName = components.reduce((m, c) => Math.max(m, c.key.length + sidebarSuffix(c).length), 0);
492
638
  // +5 = border (1) + status icon (1) + badge column (1) + space (1) + border (1).
493
639
  // The badge column is reserved even when no annotation is present so the
494
640
  // sidebar width doesn't jitter as live-preview annotations flip in/out.
495
641
  const sidebarWidth = Math.min(Math.max(longestName + 5, 14), 30);
496
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
+ }));
497
658
  const accepted = components.filter((c) => c.status === 'accepted').length;
498
659
  const rejected = components.filter((c) => c.status === 'rejected').length;
499
660
  const needsReview = components.filter((c) => c.status === 'needs-review').length;
500
661
  const propCount = selected ? Object.keys(selected.entry.$properties).length : 0;
501
662
  const slotCount = selected?.entry.$slots ? Object.keys(selected.entry.$slots).length : 0;
502
- return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showRemovedPanel && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `Removed components (${removedComponents.length})` }), _jsx(Text, { dimColor: true, children: "these will be DELETED from the target space" }), _jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: `- ${rc.name}${rc.id ? ` (${rc.id})` : ''}` }, rc.id))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press d or Esc to close" })] })), !dialogOpen &&
663
+ return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showRemovedPanel && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `Removed components (${removedComponents.length})` }), _jsx(Text, { dimColor: true, children: "these will be DELETED from the target space" }), _jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: `- ${rc.name}${rc.id ? ` (${rc.id})` : ''}` }, rc.id))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press d or Esc to close" })] })), showCyclePanel &&
664
+ !dialogOpen &&
665
+ (() => {
666
+ // Materialize the full panel body as a flat list of Text lines,
667
+ // then slice by cyclePanelScroll so ↑↓ can walk arbitrarily long
668
+ // content. Each cycle contributes 3-4 lines: heading, path,
669
+ // suggested fix (if any), and a blank separator.
670
+ const PANEL_H = 20;
671
+ const lines = [];
672
+ lines.push(_jsx(Text, { bold: true, color: "yellow", children: `SLOT DEPENDENCY CYCLES (${slotCycles.length})` }, "cyc-title"));
673
+ lines.push(_jsx(Text, { dimColor: true, children: 'push will fail until these are resolved' }, "cyc-sub"));
674
+ lines.push(_jsx(Text, { children: " " }, "cyc-space"));
675
+ slotCycles.forEach((cycle, idx) => {
676
+ const nodeCount = new Set(cycle.path).size;
677
+ lines.push(_jsx(Text, { bold: true, children: `▸ Cycle ${idx + 1} (${nodeCount} component${nodeCount === 1 ? '' : 's'}):` }, `cyc-h-${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}`));
684
+ if (cycle.suggestedBreak) {
685
+ const b = cycle.suggestedBreak;
686
+ lines.push(_jsx(Text, { dimColor: true, children: ` Suggested fix: remove '${b.toComponent}' from ${b.fromComponent}.$slots.${b.slotName}.$allowedComponents` }, `cyc-f-${idx}`));
687
+ }
688
+ lines.push(_jsx(Text, { children: " " }, `cyc-s-${idx}`));
689
+ });
690
+ const visible = lines.slice(cyclePanelScroll, cyclePanelScroll + PANEL_H);
691
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [visible, _jsx(Text, { dimColor: true, children: '[↑↓/j/k] scroll [c/q/Esc] close' })] }));
692
+ })(), !dialogOpen &&
503
693
  livePreview &&
504
694
  (() => {
505
695
  // Pilot-2026-06-23 R2: at-a-glance diff summary at the top of the
@@ -522,7 +712,10 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
522
712
  if (!hasCounts)
523
713
  return null;
524
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` })] }));
525
- })(), !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'} detectedpush 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) => {
526
719
  const idx = components.findIndex((c) => c.key === id);
527
720
  if (idx >= 0) {
528
721
  setSelectedIdx(idx);
@@ -571,11 +764,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
571
764
  }, onToggleSourceExternal: () => {
572
765
  setPanelOpen('source');
573
766
  setPanelScrollOffset(() => 0);
574
- }, 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
575
768
  ? ' [a] accept [r] reject [A] accept all [J] ' +
576
769
  (showJson ? 'hide JSON' : 'show JSON') +
577
770
  ' [F] finalize [e/Tab] focus panel' +
578
771
  (livePreview && removedComponents.length > 0 ? ' [d] removed list' : '') +
772
+ (slotCycles.length > 0 ? ' [c] cycles' : '') +
579
773
  ' [q] quit'
580
774
  : showJson
581
775
  ? ' [j/k] scroll [Ctrl+u/d] half-page [gg/G] top/bottom [Tab] focus list'
@@ -3,6 +3,7 @@ import type { RawComponentDefinition } from '../types.js';
3
3
  import type { CDFComponentEntry, DTCGTokenEntry, DTCGTokenGroup } from '@contentful/experience-design-system-types';
4
4
  import type { ToolCall, TokenToolCall } from '../generate/agent-runner.js';
5
5
  import type { ComponentTypeSummary } from '@contentful/experience-design-system-types';
6
+ import type { SlotCycle, SlotEdge } from '../analyze/cycle-detection.js';
6
7
  export type StepStatus = 'pending' | 'complete' | 'failed' | 'interrupted';
7
8
  export type CommandName = 'analyze extract' | 'analyze select' | 'generate components' | 'generate tokens' | 'generate edit' | 'apply preview' | 'apply select' | 'apply push' | 'print components' | 'print tokens' | 'import';
8
9
  export interface SessionRow {
@@ -176,6 +177,14 @@ export declare function lookupCacheByEntity(db: DatabaseSync, entityType: 'compo
176
177
  export declare function storeCache(db: DatabaseSync, inputHash: string, entityType: 'component' | 'token_set', entityId: string, sourceSessionId: string, humanEdited: boolean, promptHash?: string): void;
177
178
  export declare function storeScannedFiles(db: DatabaseSync, sessionId: string, filePaths: string[]): void;
178
179
  export declare function loadScannedFiles(db: DatabaseSync, sessionId: string): string[];
180
+ export declare function storeSlotCycles(db: DatabaseSync, sessionId: string, cycles: Array<SlotCycle & {
181
+ suggestedBreak?: SlotEdge | null;
182
+ }>): void;
183
+ export interface StoredSlotCycle extends SlotCycle {
184
+ suggestedBreak: SlotEdge | null;
185
+ }
186
+ export declare function loadSlotCycles(db: DatabaseSync, sessionId: string): StoredSlotCycle[];
187
+ export declare function clearSlotCycles(db: DatabaseSync, sessionId: string): void;
179
188
  export declare function markCacheHumanEdited(db: DatabaseSync, entityType: 'component' | 'token_set', entityId: string): void;
180
189
  export declare function copyComponentFromCache(db: DatabaseSync, sourceSessionId: string, targetSessionId: string, componentId: string): void;
181
190
  export declare function copyTokensFromCache(db: DatabaseSync, sourceSessionId: string, targetSessionId: string): void;
@@ -138,6 +138,15 @@ CREATE TABLE IF NOT EXISTS scanned_files (
138
138
  PRIMARY KEY (session_id, path)
139
139
  );
140
140
 
141
+ CREATE TABLE IF NOT EXISTS slot_cycles (
142
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
143
+ cycle_index INTEGER NOT NULL,
144
+ path_json TEXT NOT NULL,
145
+ edges_json TEXT NOT NULL,
146
+ suggested_break_json TEXT,
147
+ PRIMARY KEY (session_id, cycle_index)
148
+ );
149
+
141
150
  CREATE INDEX IF NOT EXISTS idx_steps_session ON steps(session_id);
142
151
  CREATE INDEX IF NOT EXISTS idx_steps_command ON steps(session_id, command);
143
152
  CREATE INDEX IF NOT EXISTS idx_raw_components_session ON raw_components(session_id);
@@ -1388,6 +1397,43 @@ export function loadScannedFiles(db, sessionId) {
1388
1397
  const rows = db.prepare('SELECT path FROM scanned_files WHERE session_id = ? ORDER BY path').all(sessionId);
1389
1398
  return rows.map((r) => r.path);
1390
1399
  }
1400
+ // --- Slot-dependency cycle persistence (INTEG-4401) ---
1401
+ //
1402
+ // The `slot_cycles` table caches the result of running the cycle detector
1403
+ // over the extractor's slot output so the TUI can render sidebar badges,
1404
+ // a banner, and an expandable detail panel without re-running the analysis
1405
+ // on every render. Rows are session-scoped and rewritten wholesale on each
1406
+ // re-extract via `storeSlotCycles`.
1407
+ export function storeSlotCycles(db, sessionId, cycles) {
1408
+ db.exec('BEGIN');
1409
+ try {
1410
+ db.prepare('DELETE FROM slot_cycles WHERE session_id = ?').run(sessionId);
1411
+ const insert = db.prepare(`INSERT INTO slot_cycles (session_id, cycle_index, path_json, edges_json, suggested_break_json)
1412
+ VALUES (?, ?, ?, ?, ?)`);
1413
+ for (let i = 0; i < cycles.length; i += 1) {
1414
+ const cycle = cycles[i];
1415
+ insert.run(sessionId, i, JSON.stringify(cycle.path), JSON.stringify(cycle.edges), cycle.suggestedBreak ? JSON.stringify(cycle.suggestedBreak) : null);
1416
+ }
1417
+ db.exec('COMMIT');
1418
+ }
1419
+ catch (e) {
1420
+ db.exec('ROLLBACK');
1421
+ throw e;
1422
+ }
1423
+ }
1424
+ export function loadSlotCycles(db, sessionId) {
1425
+ const rows = db
1426
+ .prepare('SELECT cycle_index, path_json, edges_json, suggested_break_json FROM slot_cycles WHERE session_id = ? ORDER BY cycle_index')
1427
+ .all(sessionId);
1428
+ return rows.map((r) => ({
1429
+ path: JSON.parse(r.path_json),
1430
+ edges: JSON.parse(r.edges_json),
1431
+ suggestedBreak: r.suggested_break_json ? JSON.parse(r.suggested_break_json) : null,
1432
+ }));
1433
+ }
1434
+ export function clearSlotCycles(db, sessionId) {
1435
+ db.prepare('DELETE FROM slot_cycles WHERE session_id = ?').run(sessionId);
1436
+ }
1391
1437
  export function markCacheHumanEdited(db, entityType, entityId) {
1392
1438
  const now = new Date().toISOString();
1393
1439
  db.prepare(`UPDATE generation_cache SET human_edited = 1, updated_at = ? WHERE entity_type = ? AND entity_id = ?`).run(now, entityType, entityId);
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-5aa6a69.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-5aa6a69.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",