@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.
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-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",
@@ -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,93 @@
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
+ * Kept for backend/log/error use where a single string is required (see
69
+ * `assertNoSlotCycles` in `apply/command.ts`). The TUI banner + detail panel
70
+ * use `formatCyclePathSegments` instead so slot names can be visually
71
+ * distinguished from component names.
72
+ */
73
+ export declare function formatCyclePath(cycle: SlotCycle, maxHops?: number): string;
74
+ /**
75
+ * A single token in the rendered cycle path — either a component name, a
76
+ * slot name (wrapped in brackets for monochrome legibility), or the arrow
77
+ * separator between them. The TUI colorizes each kind independently.
78
+ *
79
+ * Slots are rendered as `[slotName]` so the visual distinction survives when
80
+ * ANSI colors are stripped (log files, redirection, dumb terminals).
81
+ */
82
+ export interface CyclePathSegment {
83
+ kind: 'component' | 'slot' | 'arrow';
84
+ text: string;
85
+ }
86
+ /**
87
+ * Segment-oriented counterpart to `formatCyclePath`. Preserves the same
88
+ * 8-hop truncation semantics but returns each token classified so the
89
+ * renderer can color slots (cyan) and components (default) independently.
90
+ * The truncation marker `…` is emitted as a bare component segment because
91
+ * it stands in for one or more component nodes.
92
+ */
93
+ export declare function formatCyclePathSegments(cycle: SlotCycle, maxHops?: number): CyclePathSegment[];
@@ -0,0 +1,326 @@
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
+ * Kept for backend/log/error use where a single string is required (see
265
+ * `assertNoSlotCycles` in `apply/command.ts`). The TUI banner + detail panel
266
+ * use `formatCyclePathSegments` instead so slot names can be visually
267
+ * distinguished from component names.
268
+ */
269
+ export function formatCyclePath(cycle, maxHops = 8) {
270
+ const arrow = ' → ';
271
+ const parts = [];
272
+ // Interleave: component, slot, component, slot, ..., final component
273
+ // path.length === edges.length + 1.
274
+ for (let i = 0; i < cycle.edges.length; i += 1) {
275
+ parts.push(cycle.path[i]);
276
+ parts.push(cycle.edges[i].slotName);
277
+ }
278
+ parts.push(cycle.path[cycle.path.length - 1]);
279
+ if (cycle.edges.length <= maxHops) {
280
+ return parts.join(arrow);
281
+ }
282
+ // Truncated: keep the first `keep` edges' worth of tokens and the final
283
+ // component. Two "tokens per hop" (component + slot) plus the trailing
284
+ // component name.
285
+ const keepHops = Math.max(1, maxHops - 1);
286
+ const keepTokens = keepHops * 2;
287
+ const head = parts.slice(0, keepTokens).join(arrow);
288
+ const tail = parts[parts.length - 1];
289
+ return `${head}${arrow}…${arrow}${tail}`;
290
+ }
291
+ /**
292
+ * Segment-oriented counterpart to `formatCyclePath`. Preserves the same
293
+ * 8-hop truncation semantics but returns each token classified so the
294
+ * renderer can color slots (cyan) and components (default) independently.
295
+ * The truncation marker `…` is emitted as a bare component segment because
296
+ * it stands in for one or more component nodes.
297
+ */
298
+ export function formatCyclePathSegments(cycle, maxHops = 8) {
299
+ // Interleaved [component, slot, component, slot, ..., component] token list
300
+ // matching `formatCyclePath`'s parts array, but classified.
301
+ const raw = [];
302
+ for (let i = 0; i < cycle.edges.length; i += 1) {
303
+ raw.push({ kind: 'component', text: cycle.path[i] });
304
+ raw.push({ kind: 'slot', text: `[${cycle.edges[i].slotName}]` });
305
+ }
306
+ raw.push({ kind: 'component', text: cycle.path[cycle.path.length - 1] });
307
+ const withArrows = (segs) => {
308
+ const out = [];
309
+ for (let i = 0; i < segs.length; i += 1) {
310
+ if (i > 0)
311
+ out.push({ kind: 'arrow', text: ' → ' });
312
+ out.push(segs[i]);
313
+ }
314
+ return out;
315
+ };
316
+ if (cycle.edges.length <= maxHops) {
317
+ return withArrows(raw);
318
+ }
319
+ // Match `formatCyclePath` truncation: keep the first (maxHops-1)*2 tokens
320
+ // plus a "…" placeholder plus the trailing component.
321
+ const keepHops = Math.max(1, maxHops - 1);
322
+ const keepTokens = keepHops * 2;
323
+ const head = raw.slice(0, keepTokens);
324
+ const tail = raw[raw.length - 1];
325
+ return withArrows([...head, { kind: 'component', text: '…' }, tail]);
326
+ }
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { findSlotCycles, type ComponentSlotInfo } from '../../../cycle-detection.js';
2
3
  /** Per-prop / per-component metadata captured by the extractor + generate phase.
3
4
  * Optional; when omitted, the FieldEditor renders without rationale/source
4
5
  * affordances (backwards-compatible with mounts that pre-date Feature 1). */
@@ -63,6 +64,28 @@ type FieldEditorProps = {
63
64
  * description editors, string default editors, and value-list text entry.
64
65
  */
65
66
  onTextEntryActiveChange?: (active: boolean) => void;
67
+ /**
68
+ * INTEG-4401: project-wide slot graph. When supplied, the
69
+ * $allowedComponents add-mode shows a cycle-filtered picker. Optional.
70
+ */
71
+ projectSlotGraph?: ComponentSlotInfo[];
72
+ /**
73
+ * INTEG-4401: name of the component being edited. Self-loop guard +
74
+ * self-entry replacement in the cycle simulation.
75
+ */
76
+ currentComponentName?: string;
66
77
  };
67
- export declare function FieldEditor({ value, width, height, active, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, }: FieldEditorProps): React.ReactElement;
78
+ /** Simulate adding a candidate to a slot; replaces self-entry with live edits. */
79
+ export declare function simulateGraphWithCandidate(projectSlotGraph: ComponentSlotInfo[], selfName: string, currentSlots: {
80
+ name: string;
81
+ allowedComponents: string[];
82
+ }[], targetSlotName: string, candidate: string): ComponentSlotInfo[];
83
+ /** True iff `next` contains a cycle whose canonical edge set is absent in `before`. */
84
+ export declare function introducesNewCycle(before: ReturnType<typeof findSlotCycles>, next: ReturnType<typeof findSlotCycles>): boolean;
85
+ /** Candidate names that can be added to a slot without creating a new cycle. */
86
+ export declare function computeAllowedComponentCandidates(projectSlotGraph: ComponentSlotInfo[], selfName: string, currentSlots: {
87
+ name: string;
88
+ allowedComponents: string[];
89
+ }[], targetSlotName: string): string[];
90
+ export declare function FieldEditor({ value, width, height, active, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, projectSlotGraph, currentComponentName, }: FieldEditorProps): React.ReactElement;
68
91
  export {};