@contentful/experience-design-system-cli 2.12.4-dev-build-5aa6a69.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 +1 -1
- package/dist/src/analyze/command.js +16 -1
- package/dist/src/analyze/cycle-detection.d.ts +68 -0
- package/dist/src/analyze/cycle-detection.js +285 -0
- package/dist/src/apply/command.d.ts +5 -1
- package/dist/src/apply/command.js +43 -0
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +1 -1
- package/dist/src/import/tui/steps/GenerateReviewStep.js +100 -14
- package/dist/src/session/db.d.ts +9 -0
- package/dist/src/session/db.js +46 -0
- package/package.json +2 -2
package/dist/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
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
|
-
|
|
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:
|
|
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:
|
|
548
|
+
validationWarningCount: sidebarSuffix(c) !== '' ? 1 : 0,
|
|
488
549
|
}));
|
|
489
|
-
// Account for the "(empty)" suffix added to
|
|
490
|
-
// sidebar doesn't truncate
|
|
491
|
-
const longestName = components.reduce((m, c) => Math.max(m, c.key.length + (
|
|
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" })] })),
|
|
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'
|
package/dist/src/session/db.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/session/db.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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",
|