@contentful/experience-design-system-cli 2.12.4-dev-build-a1a979f.0 → 2.12.4-dev-build-9c819d8.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-a1a979f.0",
3
+ "version": "2.12.4-dev-build-9c819d8.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,7 +6,8 @@ import { extractComponents } from './extract/pipeline.js';
6
6
  import { AnalyzeView } from './tui/AnalyzeView.js';
7
7
  import { registerAnalyzeEditCommand } from './select/command.js';
8
8
  import { registerAnalyzeSelectAgentCommand } from './select-agent/command.js';
9
- import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents, storeScannedFiles, } from '../session/db.js';
9
+ import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents, storeScannedFiles, storeSlotCycles, } from '../session/db.js';
10
+ import { findSlotCycles, suggestCycleBreakEdge } from './cycle-detection.js';
10
11
  import { preClassifyComponent } from './pre-classify.js';
11
12
  import { isNonAuthorableComponent } from './extract/non-authorable-filter.js';
12
13
  import { computeExtractionScore, deriveNeedsReview } from './extract/scoring.js';
@@ -191,6 +192,20 @@ export function registerAnalyzeCommand(program) {
191
192
  }
192
193
  const validatedComponents = validateExtractedComponents(filteredComponents);
193
194
  storeRawComponents(db, sessionId, validatedComponents);
195
+ // INTEG-4401: post-extract slot-dependency cycle detection. The apply
196
+ // worker rejects cyclic slot graphs at push time, so we surface the
197
+ // same violation locally as a soft warning here (TUI reads
198
+ // slot_cycles) and as a hard block at manifest finalization.
199
+ const cycleInput = validatedComponents.map((c) => ({
200
+ name: c.name,
201
+ slots: c.slots.map((s) => ({ name: s.name, allowedComponents: s.allowedComponents })),
202
+ }));
203
+ const cycles = findSlotCycles(cycleInput);
204
+ const withBreaks = cycles.map((cycle) => ({
205
+ ...cycle,
206
+ suggestedBreak: suggestCycleBreakEdge(cycle, cycles),
207
+ }));
208
+ storeSlotCycles(db, sessionId, withBreaks);
194
209
  storeScannedFiles(db, sessionId, sourceFiles.map((f) => relative(projectRoot, f)));
195
210
  updateStep(db, stepId, 'complete', { sessionId });
196
211
  db.close();
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Slot-dependency cycle detection.
3
+ *
4
+ * The Contentful Experience Design System backend rejects component manifests
5
+ * that contain slot-dependency cycles at apply time — a topo-sort in the
6
+ * apply worker refuses to create component types whose slot's
7
+ * `$allowedComponents` transitively point back at themselves. Reference:
8
+ * `experience-design-system-integrations` / services/design-system-sources-apply-worker/src/topo-sort.ts.
9
+ *
10
+ * We surface the same check locally so the wizard can:
11
+ * 1. warn the operator at extract time (soft) with sidebar badges + banner,
12
+ * 2. hard-block the push at manifest-finalization time before any API call.
13
+ *
14
+ * The algorithm is Johnson's — an O((V + E)(C + 1)) enumeration of all
15
+ * elementary (simple) cycles in a directed graph. We need *elementary*
16
+ * cycles rather than just cycle-existence because an operator can have
17
+ * multiple independent cycles they need to address separately, and each
18
+ * gets its own suggested fix.
19
+ *
20
+ * Johnson's algorithm reference: Donald B. Johnson, "Finding all the
21
+ * elementary circuits of a directed graph." SIAM J. Comput., 4(1):77-84, 1975.
22
+ */
23
+ /** A directed edge in the slot-dependency graph. */
24
+ export interface SlotEdge {
25
+ fromComponent: string;
26
+ slotName: string;
27
+ toComponent: string;
28
+ }
29
+ /** A single elementary cycle, with `path[0] === path[path.length - 1]`. */
30
+ export interface SlotCycle {
31
+ /** Component names visited, with the first repeated at the end. */
32
+ path: string[];
33
+ /** Ordered edges forming the cycle. `edges.length === path.length - 1`. */
34
+ edges: SlotEdge[];
35
+ }
36
+ /** Input shape mirroring the slot definition on CDFComponentEntry. */
37
+ export interface ComponentSlotInfo {
38
+ name: string;
39
+ slots: Array<{
40
+ name: string;
41
+ allowedComponents?: string[];
42
+ }>;
43
+ }
44
+ /**
45
+ * Johnson's algorithm — enumerates all elementary (simple) cycles in the
46
+ * slot-dependency graph. Self-loops are handled as a degenerate first pass
47
+ * because Tarjan's SCC decomposition assigns a self-looping node to its own
48
+ * single-node SCC, which Johnson's inner loop otherwise skips.
49
+ */
50
+ export declare function findSlotCycles(components: ComponentSlotInfo[]): SlotCycle[];
51
+ /**
52
+ * Pick which edge is the best candidate to remove to break a cycle.
53
+ * Heuristic: prefer removing the edge whose `toComponent` appears most often
54
+ * as a destination across all cycles — that "hub" node is the biggest
55
+ * contributor and removing an inbound edge to it likely breaks multiple
56
+ * cycles at once. Falls back to the first edge in the cycle for
57
+ * deterministic output when all candidates are tied.
58
+ */
59
+ export declare function suggestCycleBreakEdge(cycle: SlotCycle, allCycles: SlotCycle[]): SlotEdge;
60
+ /**
61
+ * Format a cycle for the compact banner:
62
+ * `CardA → header → CardB → footer → CardA`
63
+ *
64
+ * Truncates at `maxHops` edges (default 8), inserting `…` mid-path so the
65
+ * beginning and end remain visible. A hop is one edge; the returned string
66
+ * for an n-edge cycle contains n+1 component names.
67
+ */
68
+ export declare function formatCyclePath(cycle: SlotCycle, maxHops?: number): string;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Slot-dependency cycle detection.
3
+ *
4
+ * The Contentful Experience Design System backend rejects component manifests
5
+ * that contain slot-dependency cycles at apply time — a topo-sort in the
6
+ * apply worker refuses to create component types whose slot's
7
+ * `$allowedComponents` transitively point back at themselves. Reference:
8
+ * `experience-design-system-integrations` / services/design-system-sources-apply-worker/src/topo-sort.ts.
9
+ *
10
+ * We surface the same check locally so the wizard can:
11
+ * 1. warn the operator at extract time (soft) with sidebar badges + banner,
12
+ * 2. hard-block the push at manifest-finalization time before any API call.
13
+ *
14
+ * The algorithm is Johnson's — an O((V + E)(C + 1)) enumeration of all
15
+ * elementary (simple) cycles in a directed graph. We need *elementary*
16
+ * cycles rather than just cycle-existence because an operator can have
17
+ * multiple independent cycles they need to address separately, and each
18
+ * gets its own suggested fix.
19
+ *
20
+ * Johnson's algorithm reference: Donald B. Johnson, "Finding all the
21
+ * elementary circuits of a directed graph." SIAM J. Comput., 4(1):77-84, 1975.
22
+ */
23
+ function buildGraph(components) {
24
+ const nodes = [];
25
+ const seen = new Set();
26
+ for (const c of components) {
27
+ if (seen.has(c.name))
28
+ continue;
29
+ seen.add(c.name);
30
+ nodes.push(c.name);
31
+ }
32
+ const adjacency = new Map();
33
+ for (const node of nodes)
34
+ adjacency.set(node, []);
35
+ for (const comp of components) {
36
+ const outgoing = adjacency.get(comp.name);
37
+ if (!outgoing)
38
+ continue;
39
+ for (const slot of comp.slots) {
40
+ const allowed = slot.allowedComponents ?? [];
41
+ for (const target of allowed) {
42
+ if (!seen.has(target))
43
+ continue; // unknown / external — ignore
44
+ outgoing.push({ target, slotName: slot.name });
45
+ }
46
+ }
47
+ }
48
+ return { nodes, adjacency };
49
+ }
50
+ /**
51
+ * Tarjan's strongly-connected-components algorithm — used by Johnson's
52
+ * algorithm to constrain cycle search to one SCC at a time. Returns SCCs in
53
+ * reverse-topological order.
54
+ */
55
+ function stronglyConnectedComponents(nodes, adjacency) {
56
+ const index = new Map();
57
+ const lowlink = new Map();
58
+ const onStack = new Set();
59
+ const stack = [];
60
+ const sccs = [];
61
+ let counter = 0;
62
+ const nodeSet = new Set(nodes);
63
+ function strongconnect(v) {
64
+ index.set(v, counter);
65
+ lowlink.set(v, counter);
66
+ counter += 1;
67
+ stack.push(v);
68
+ onStack.add(v);
69
+ const neighbours = adjacency.get(v) ?? [];
70
+ for (const { target } of neighbours) {
71
+ if (!nodeSet.has(target))
72
+ continue;
73
+ if (!index.has(target)) {
74
+ strongconnect(target);
75
+ lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(target)));
76
+ }
77
+ else if (onStack.has(target)) {
78
+ lowlink.set(v, Math.min(lowlink.get(v), index.get(target)));
79
+ }
80
+ }
81
+ if (lowlink.get(v) === index.get(v)) {
82
+ const scc = [];
83
+ let w;
84
+ do {
85
+ w = stack.pop();
86
+ onStack.delete(w);
87
+ scc.push(w);
88
+ } while (w !== v);
89
+ sccs.push(scc);
90
+ }
91
+ }
92
+ for (const node of nodes) {
93
+ if (!index.has(node))
94
+ strongconnect(node);
95
+ }
96
+ return sccs;
97
+ }
98
+ /**
99
+ * Given a specific traversal `path` (component names) and the edge multiset
100
+ * we walked to build it, materialize a `SlotCycle`. Because multiple parallel
101
+ * edges may exist between the same two components (different slot names), we
102
+ * carry the exact edges along the search rather than reconstructing them from
103
+ * the node path.
104
+ */
105
+ function makeCycle(path, edges) {
106
+ return { path: [...path, path[0]], edges: [...edges] };
107
+ }
108
+ /**
109
+ * Johnson's algorithm — enumerates all elementary (simple) cycles in the
110
+ * slot-dependency graph. Self-loops are handled as a degenerate first pass
111
+ * because Tarjan's SCC decomposition assigns a self-looping node to its own
112
+ * single-node SCC, which Johnson's inner loop otherwise skips.
113
+ */
114
+ export function findSlotCycles(components) {
115
+ if (components.length === 0)
116
+ return [];
117
+ const { nodes, adjacency } = buildGraph(components);
118
+ if (nodes.length === 0)
119
+ return [];
120
+ const cycles = [];
121
+ // Degenerate case: self-loops. For any component whose slot allows itself,
122
+ // emit a length-1 cycle before invoking Johnson's inner loop — Tarjan's
123
+ // trivial single-node SCC test in Johnson's original paper only counts as
124
+ // an SCC if the node participates in an edge, which we replicate here.
125
+ for (const node of nodes) {
126
+ const outgoing = adjacency.get(node) ?? [];
127
+ for (const edge of outgoing) {
128
+ if (edge.target === node) {
129
+ cycles.push({
130
+ path: [node, node],
131
+ edges: [{ fromComponent: node, slotName: edge.slotName, toComponent: node }],
132
+ });
133
+ }
134
+ }
135
+ }
136
+ // Johnson's main algorithm — iterate over remaining nodes in a fixed order,
137
+ // decomposing the subgraph induced by `{node, ...higherOrder}` into SCCs,
138
+ // and running the blocked-list circuit search rooted at the least node of
139
+ // each non-trivial SCC. This is the classic formulation from the paper.
140
+ const nodeOrder = [...nodes].sort();
141
+ let remaining = [...nodeOrder];
142
+ while (remaining.length > 0) {
143
+ const subgraphNodes = remaining;
144
+ const sccs = stronglyConnectedComponents(subgraphNodes, adjacency).filter((scc) => {
145
+ if (scc.length > 1)
146
+ return true;
147
+ // Non-trivial single-node SCC iff the node has a self-loop within the
148
+ // subgraph. We already emitted those above, so exclude here.
149
+ return false;
150
+ });
151
+ if (sccs.length === 0)
152
+ break;
153
+ // Pick the SCC containing the lexicographically-smallest node — mirrors
154
+ // the "least vertex" root selection in Johnson's paper.
155
+ let startNode = null;
156
+ let startScc = null;
157
+ for (const scc of sccs) {
158
+ const min = [...scc].sort()[0];
159
+ if (startNode === null || min < startNode) {
160
+ startNode = min;
161
+ startScc = scc;
162
+ }
163
+ }
164
+ if (startNode === null || startScc === null)
165
+ break;
166
+ const sccSet = new Set(startScc);
167
+ const blocked = new Set();
168
+ const blockedMap = new Map();
169
+ const pathStack = [];
170
+ const edgeStack = [];
171
+ function unblock(u) {
172
+ blocked.delete(u);
173
+ const dependents = blockedMap.get(u);
174
+ if (!dependents)
175
+ return;
176
+ for (const w of dependents) {
177
+ if (blocked.has(w))
178
+ unblock(w);
179
+ }
180
+ dependents.clear();
181
+ }
182
+ function circuit(v, root) {
183
+ let foundCycle = false;
184
+ pathStack.push(v);
185
+ blocked.add(v);
186
+ const outgoing = adjacency.get(v) ?? [];
187
+ for (const { target: w, slotName } of outgoing) {
188
+ if (!sccSet.has(w))
189
+ continue;
190
+ edgeStack.push({ fromComponent: v, slotName, toComponent: w });
191
+ if (w === root) {
192
+ cycles.push(makeCycle(pathStack, edgeStack));
193
+ foundCycle = true;
194
+ }
195
+ else if (!blocked.has(w)) {
196
+ if (circuit(w, root))
197
+ foundCycle = true;
198
+ }
199
+ edgeStack.pop();
200
+ }
201
+ if (foundCycle) {
202
+ unblock(v);
203
+ }
204
+ else {
205
+ for (const { target: w } of outgoing) {
206
+ if (!sccSet.has(w))
207
+ continue;
208
+ let deps = blockedMap.get(w);
209
+ if (!deps) {
210
+ deps = new Set();
211
+ blockedMap.set(w, deps);
212
+ }
213
+ deps.add(v);
214
+ }
215
+ }
216
+ pathStack.pop();
217
+ return foundCycle;
218
+ }
219
+ circuit(startNode, startNode);
220
+ // Remove `startNode` from consideration and repeat with the reduced
221
+ // subgraph. In classic Johnson's this is done by re-running SCC on the
222
+ // subgraph induced by the remaining nodes.
223
+ remaining = remaining.filter((n) => n !== startNode);
224
+ }
225
+ return cycles;
226
+ }
227
+ /**
228
+ * Pick which edge is the best candidate to remove to break a cycle.
229
+ * Heuristic: prefer removing the edge whose `toComponent` appears most often
230
+ * as a destination across all cycles — that "hub" node is the biggest
231
+ * contributor and removing an inbound edge to it likely breaks multiple
232
+ * cycles at once. Falls back to the first edge in the cycle for
233
+ * deterministic output when all candidates are tied.
234
+ */
235
+ export function suggestCycleBreakEdge(cycle, allCycles) {
236
+ if (cycle.edges.length === 0) {
237
+ throw new Error('suggestCycleBreakEdge: cycle has no edges');
238
+ }
239
+ const indegree = new Map();
240
+ for (const c of allCycles) {
241
+ for (const edge of c.edges) {
242
+ indegree.set(edge.toComponent, (indegree.get(edge.toComponent) ?? 0) + 1);
243
+ }
244
+ }
245
+ let best = cycle.edges[0];
246
+ let bestScore = indegree.get(best.toComponent) ?? 0;
247
+ for (const edge of cycle.edges) {
248
+ const score = indegree.get(edge.toComponent) ?? 0;
249
+ if (score > bestScore) {
250
+ best = edge;
251
+ bestScore = score;
252
+ }
253
+ }
254
+ return best;
255
+ }
256
+ /**
257
+ * Format a cycle for the compact banner:
258
+ * `CardA → header → CardB → footer → CardA`
259
+ *
260
+ * Truncates at `maxHops` edges (default 8), inserting `…` mid-path so the
261
+ * beginning and end remain visible. A hop is one edge; the returned string
262
+ * for an n-edge cycle contains n+1 component names.
263
+ */
264
+ export function formatCyclePath(cycle, maxHops = 8) {
265
+ const arrow = ' → ';
266
+ const parts = [];
267
+ // Interleave: component, slot, component, slot, ..., final component
268
+ // path.length === edges.length + 1.
269
+ for (let i = 0; i < cycle.edges.length; i += 1) {
270
+ parts.push(cycle.path[i]);
271
+ parts.push(cycle.edges[i].slotName);
272
+ }
273
+ parts.push(cycle.path[cycle.path.length - 1]);
274
+ if (cycle.edges.length <= maxHops) {
275
+ return parts.join(arrow);
276
+ }
277
+ // Truncated: keep the first `keep` edges' worth of tokens and the final
278
+ // component. Two "tokens per hop" (component + slot) plus the trailing
279
+ // component name.
280
+ const keepHops = Math.max(1, maxHops - 1);
281
+ const keepTokens = keepHops * 2;
282
+ const head = parts.slice(0, keepTokens).join(arrow);
283
+ const tail = parts[parts.length - 1];
284
+ return `${head}${arrow}…${arrow}${tail}`;
285
+ }
@@ -2,25 +2,26 @@
2
2
  // expands the second generic argument to string | JSXElementConstructor<any>).
3
3
  // Only the first generic argument (the props type name) is captured.
4
4
  const REACT_ELEMENT_GENERIC = /(?:React\.)?ReactElement\s*<\s*([A-Za-z_$][\w$.]*)(?![\w$.])/g;
5
- // Svelte 5 typed snippets: `Snippet<[XProps]>`. The type argument is a tuple
6
- // listing render args; when a snippet is authored to render a nested
7
- // ComponentType, the single tuple element is that component's Props type.
8
- // We only match the single-element tuple form — non-props render args
9
- // (e.g. `Snippet<[year: number]>`) don't reference a ComponentType and are
10
- // filtered out below via propsToComponent lookup.
11
- const SVELTE_SNIPPET_GENERIC = /Snippet\s*<\s*\[\s*([A-Za-z_$][\w$.]*)\s*\]\s*>/g;
5
+ const SVELTE_SNIPPET_TUPLE = /Snippet\s*<\s*\[([^\]]*)\]\s*>/g;
6
+ const IDENTIFIER = /[A-Za-z_$][\w$.]*/g;
12
7
  export function extractAllowedComponentsFromTypeText(typeText, ctx) {
13
8
  const found = new Set();
14
- for (const re of [REACT_ELEMENT_GENERIC, SVELTE_SNIPPET_GENERIC]) {
15
- re.lastIndex = 0;
16
- let m;
17
- while ((m = re.exec(typeText)) !== null) {
18
- const propsTypeName = m[1];
19
- const componentName = ctx.propsToComponent.get(propsTypeName);
20
- if (componentName && ctx.componentNames.has(componentName)) {
21
- found.add(componentName);
22
- }
23
- }
9
+ const record = (propsTypeName) => {
10
+ const componentName = ctx.propsToComponent.get(propsTypeName);
11
+ if (componentName && ctx.componentNames.has(componentName))
12
+ found.add(componentName);
13
+ };
14
+ REACT_ELEMENT_GENERIC.lastIndex = 0;
15
+ let m;
16
+ while ((m = REACT_ELEMENT_GENERIC.exec(typeText)) !== null)
17
+ record(m[1]);
18
+ SVELTE_SNIPPET_TUPLE.lastIndex = 0;
19
+ while ((m = SVELTE_SNIPPET_TUPLE.exec(typeText)) !== null) {
20
+ const tupleBody = m[1];
21
+ IDENTIFIER.lastIndex = 0;
22
+ let id;
23
+ while ((id = IDENTIFIER.exec(tupleBody)) !== null)
24
+ record(id[0]);
24
25
  }
25
26
  return [...found].sort();
26
27
  }
@@ -1,6 +1,10 @@
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
3
  import type { ServerPreviewResponse } from '@contentful/experience-design-system-types';
4
4
  export declare function readTokensFromPath(flag: string, p: string): Promise<DTCGTokenEntry[]>;
5
+ export declare function assertNoSlotCycles(components: Array<{
6
+ key: string;
7
+ entry: CDFComponentEntry;
8
+ }>): void;
5
9
  export declare function hasBreakingChangesWithImpact(preview: ServerPreviewResponse): boolean;
6
10
  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,37 @@ 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 only;
181
+ // `apply preview` is read-only and still runs (its diff is used to warn but
182
+ // not to block).
183
+ export function assertNoSlotCycles(components) {
184
+ const cycleInput = components.map(({ key, entry }) => ({
185
+ name: key,
186
+ slots: Object.entries(entry.$slots ?? {}).map(([slotName, slotDef]) => ({
187
+ name: slotName,
188
+ allowedComponents: slotDef.$allowedComponents ?? [],
189
+ })),
190
+ }));
191
+ const cycles = findSlotCycles(cycleInput);
192
+ if (cycles.length === 0)
193
+ return;
194
+ const lines = [];
195
+ lines.push(`Error: manifest:components/slot-cycles — ${cycles.length} slot dependency cycle(s) detected. Push refused.`);
196
+ for (let i = 0; i < cycles.length; i += 1) {
197
+ const cycle = cycles[i];
198
+ lines.push(` Cycle ${i + 1}: ${formatCyclePath(cycle)}`);
199
+ const suggested = suggestCycleBreakEdge(cycle, cycles);
200
+ lines.push(` Fix: remove '${suggested.toComponent}' from ${suggested.fromComponent}.$slots.${suggested.slotName}.$allowedComponents`);
201
+ }
202
+ process.stderr.write(lines.join('\n') + '\n');
203
+ process.exit(1);
204
+ }
173
205
  // --- Output helpers ---
174
206
  export function hasBreakingChangesWithImpact(preview) {
175
207
  const allChanged = [...preview.components.changed, ...preview.tokens.changed];
@@ -445,6 +477,12 @@ export function registerApplyCommand(program) {
445
477
  throw e;
446
478
  }
447
479
  const { components, tokens, client } = inputs;
480
+ // INTEG-4401: refuse before spending any credentials or bytes on a
481
+ // manifest the backend will reject. Cycle detection is deterministic
482
+ // and cheap so we run it here (not just at extract time) — this also
483
+ // guards `apply push --components` where extract-time cycles are not
484
+ // in the session DB.
485
+ assertNoSlotCycles(components);
448
486
  try {
449
487
  await client.validateToken();
450
488
  }
@@ -623,6 +661,11 @@ export function registerApplyCommand(program) {
623
661
  throw e;
624
662
  }
625
663
  const { components, tokens, client } = inputs;
664
+ // INTEG-4401: same pre-push cycle block as `apply push`. Running the
665
+ // check on the FULL component set (not the filtered selection) is
666
+ // deliberate: partial selection cannot resolve a cycle whose
667
+ // participants are all still present in the target space.
668
+ assertNoSlotCycles(components);
626
669
  try {
627
670
  await client.validateToken();
628
671
  }
@@ -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, } from '../../../session/db.js';
12
+ import { formatCyclePath } 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) => {
@@ -283,6 +306,31 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
283
306
  }
284
307
  return;
285
308
  }
309
+ // INTEG-4401: slot-cycle detail panel. Same modal-swallow rules as
310
+ // showRemovedPanel; q/Esc close, ↑↓ scroll.
311
+ if (showCyclePanel) {
312
+ if (input === 'c' || input === 'q' || key.escape) {
313
+ setShowCyclePanel(false);
314
+ setCyclePanelScroll(0);
315
+ return;
316
+ }
317
+ if (key.upArrow || input === 'k') {
318
+ setCyclePanelScroll((v) => Math.max(0, v - 1));
319
+ return;
320
+ }
321
+ if (key.downArrow || input === 'j') {
322
+ setCyclePanelScroll((v) => v + 1);
323
+ return;
324
+ }
325
+ return;
326
+ }
327
+ // Open the cycle panel from sidebar-focused state when there is at
328
+ // least one cycle to display.
329
+ if (input === 'c' && sidebarFocused && slotCycles.length > 0) {
330
+ setShowCyclePanel(true);
331
+ setCyclePanelScroll(0);
332
+ return;
333
+ }
286
334
  // `d` opens the panel only when live-preview is enabled and there is at
287
335
  // least one removed component to display. Sidebar-focused only so it
288
336
  // doesn't collide with FieldEditor input.
@@ -476,19 +524,32 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
476
524
  const selectedJson = selected ? JSON.stringify({ [selected.key]: selected.entry }, null, 2) : '';
477
525
  const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0 && Object.keys(c.entry.$slots ?? {}).length === 0;
478
526
  const emptyCount = components.filter(isEmpty).length;
527
+ // INTEG-4401: cycle-participant set drives sidebar `(cycle)` badges plus
528
+ // the banner counts. Recomputed on every render — cheap for typical N.
529
+ const cycleParticipantSet = new Set();
530
+ for (const cycle of slotCycles)
531
+ for (const p of cycle.path)
532
+ cycleParticipantSet.add(p);
533
+ const sidebarSuffix = (c) => {
534
+ if (cycleParticipantSet.has(c.key))
535
+ return ' (cycle)';
536
+ if (isEmpty(c))
537
+ return ' (empty)';
538
+ return '';
539
+ };
479
540
  const sidebarItems = components.map((c) => ({
480
541
  id: c.key,
481
- name: isEmpty(c) ? `${c.key} (empty)` : c.key,
542
+ name: `${c.key}${sidebarSuffix(c)}`,
482
543
  status: c.status,
483
544
  previewAnnotation: previewAnnotations.get(c.key),
484
545
  extractionConfidence: null,
485
546
  needsReview: false,
486
547
  validationErrorCount: 0,
487
- validationWarningCount: isEmpty(c) ? 1 : 0,
548
+ validationWarningCount: sidebarSuffix(c) !== '' ? 1 : 0,
488
549
  }));
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);
550
+ // Account for the "(cycle)" / "(empty)" suffix added to badged component
551
+ // names so the sidebar doesn't truncate them.
552
+ const longestName = components.reduce((m, c) => Math.max(m, c.key.length + sidebarSuffix(c).length), 0);
492
553
  // +5 = border (1) + status icon (1) + badge column (1) + space (1) + border (1).
493
554
  // The badge column is reserved even when no annotation is present so the
494
555
  // sidebar width doesn't jitter as live-preview annotations flip in/out.
@@ -499,7 +560,31 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
499
560
  const needsReview = components.filter((c) => c.status === 'needs-review').length;
500
561
  const propCount = selected ? Object.keys(selected.entry.$properties).length : 0;
501
562
  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 &&
563
+ 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 &&
564
+ !dialogOpen &&
565
+ (() => {
566
+ // Materialize the full panel body as a flat list of Text lines,
567
+ // then slice by cyclePanelScroll so ↑↓ can walk arbitrarily long
568
+ // content. Each cycle contributes 3-4 lines: heading, path,
569
+ // suggested fix (if any), and a blank separator.
570
+ const PANEL_H = 20;
571
+ const lines = [];
572
+ lines.push(_jsx(Text, { bold: true, color: "yellow", children: `SLOT DEPENDENCY CYCLES (${slotCycles.length})` }, "cyc-title"));
573
+ lines.push(_jsx(Text, { dimColor: true, children: 'push will fail until these are resolved' }, "cyc-sub"));
574
+ lines.push(_jsx(Text, { children: " " }, "cyc-space"));
575
+ slotCycles.forEach((cycle, idx) => {
576
+ const nodeCount = new Set(cycle.path).size;
577
+ 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}`));
579
+ if (cycle.suggestedBreak) {
580
+ const b = cycle.suggestedBreak;
581
+ lines.push(_jsx(Text, { dimColor: true, children: ` Suggested fix: remove '${b.toComponent}' from ${b.fromComponent}.$slots.${b.slotName}.$allowedComponents` }, `cyc-f-${idx}`));
582
+ }
583
+ lines.push(_jsx(Text, { children: " " }, `cyc-s-${idx}`));
584
+ });
585
+ const visible = lines.slice(cyclePanelScroll, cyclePanelScroll + PANEL_H);
586
+ 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' })] }));
587
+ })(), !dialogOpen &&
503
588
  livePreview &&
504
589
  (() => {
505
590
  // Pilot-2026-06-23 R2: at-a-glance diff summary at the top of the
@@ -522,7 +607,7 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
522
607
  if (!hasCounts)
523
608
  return null;
524
609
  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) => {
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) => {
526
611
  const idx = components.findIndex((c) => c.key === id);
527
612
  if (idx >= 0) {
528
613
  setSelectedIdx(idx);
@@ -576,6 +661,7 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
576
661
  (showJson ? 'hide JSON' : 'show JSON') +
577
662
  ' [F] finalize [e/Tab] focus panel' +
578
663
  (livePreview && removedComponents.length > 0 ? ' [d] removed list' : '') +
664
+ (slotCycles.length > 0 ? ' [c] cycles' : '') +
579
665
  ' [q] quit'
580
666
  : showJson
581
667
  ? ' [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-a1a979f.0",
3
+ "version": "2.12.4-dev-build-9c819d8.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-a1a979f.0"
40
+ "@contentful/experience-design-system-types": "2.12.4-dev-build-9c819d8.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@tsconfig/node24": "^24.0.3",