@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 +1 -1
- package/dist/src/analyze/command.js +16 -1
- package/dist/src/analyze/cycle-detection.d.ts +93 -0
- package/dist/src/analyze/cycle-detection.js +326 -0
- package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +24 -1
- package/dist/src/analyze/select/tui/components/FieldEditor.js +136 -6
- package/dist/src/apply/command.d.ts +34 -1
- package/dist/src/apply/command.js +81 -0
- package/dist/src/apply/error-parser.d.ts +43 -0
- package/dist/src/apply/error-parser.js +163 -0
- package/dist/src/import/tui/WizardApp.js +59 -10
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +1 -1
- package/dist/src/import/tui/steps/GenerateReviewStep.js +211 -17
- package/dist/src/session/db.d.ts +9 -0
- package/dist/src/session/db.js +46 -0
- package/package.json +2 -2
|
@@ -33,6 +33,8 @@ import { nextStateAfterPrint } from './run-print-files-helpers.js';
|
|
|
33
33
|
import { PushDecisionGateStep } from './steps/PushDecisionGateStep.js';
|
|
34
34
|
import { chooseGateAction } from './push-decision-gate-helpers.js';
|
|
35
35
|
import { ImportApiClient, ApiError } from '../../apply/api-client.js';
|
|
36
|
+
import { detectSlotCycles, extractComponentsFromManifest, formatSlotCycleReport } from '../../apply/command.js';
|
|
37
|
+
import { parseEdsiError, formatParsedEdsiError } from '../../apply/error-parser.js';
|
|
36
38
|
import { handlePreview422, applySkipValidationErrors, clearedValidationErrorState } from './wizard-422-helpers.js';
|
|
37
39
|
import { parseGenerateStderrChunk } from './wizard-generate-progress.js';
|
|
38
40
|
import { spawnGenerateChild } from './spawn-generate.js';
|
|
@@ -1034,6 +1036,23 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
|
|
|
1034
1036
|
return;
|
|
1035
1037
|
}
|
|
1036
1038
|
}
|
|
1039
|
+
// INTEG-4401 Fix A — pre-push slot-cycle hard block for the wizard's
|
|
1040
|
+
// direct-API push path. The standalone `apply push` / `apply select`
|
|
1041
|
+
// commands run `assertNoSlotCycles` before ever constructing an API
|
|
1042
|
+
// client, but `experiences import` calls `client.applyImport` from here
|
|
1043
|
+
// without shelling out — so the guard has to run again on this path.
|
|
1044
|
+
// Otherwise a cyclic graph reaches EDSI and the operator sees a raw
|
|
1045
|
+
// Lambda error dump (see Fix C) instead of the clear local report.
|
|
1046
|
+
const cycles = detectSlotCycles(extractComponentsFromManifest(manifest));
|
|
1047
|
+
if (cycles.length > 0) {
|
|
1048
|
+
update({
|
|
1049
|
+
step: 'error',
|
|
1050
|
+
errorStep: 'apply push',
|
|
1051
|
+
errorMessage: formatSlotCycleReport(cycles).join('\n'),
|
|
1052
|
+
errorAllowCredentialRetry: false,
|
|
1053
|
+
});
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1037
1056
|
update({ step: 'pushing', pushProgress: null });
|
|
1038
1057
|
try {
|
|
1039
1058
|
const resolvedHost = resolveWizardHost(host);
|
|
@@ -1124,27 +1143,57 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
|
|
|
1124
1143
|
};
|
|
1125
1144
|
}
|
|
1126
1145
|
else {
|
|
1127
|
-
// API didn't return items
|
|
1146
|
+
// API didn't return items. INTEG-4401 Fix B — do NOT report preview
|
|
1147
|
+
// counts as "created": if the server said something failed, echo it
|
|
1148
|
+
// truthfully so the summary can't lie. Historically this branch used
|
|
1149
|
+
// preview.new/changed/removed as success counts and only surfaced
|
|
1150
|
+
// failed:0, which meant a cycle rejection (summary: 0 succeeded /
|
|
1151
|
+
// 11 failed, empty items) rendered as "Done ✓ 2 Component Types
|
|
1152
|
+
// created" — the exact regression this fix guards against.
|
|
1153
|
+
const summary = operation.summary ?? { total: 0, pending: 0, succeeded: 0, failed: 0 };
|
|
1154
|
+
const anyFailure = summary.failed > 0 || operation.sys.status === 'failed' || operation.sys.status === 'partial';
|
|
1128
1155
|
pushResult = {
|
|
1129
1156
|
componentTypes: {
|
|
1130
|
-
created: preview?.components.new.length ?? 0,
|
|
1131
|
-
updated: preview?.components.changed.length ?? 0,
|
|
1132
|
-
removed: preview?.components.removed.length ?? 0,
|
|
1133
|
-
|
|
1157
|
+
created: anyFailure ? 0 : (preview?.components.new.length ?? 0),
|
|
1158
|
+
updated: anyFailure ? 0 : (preview?.components.changed.length ?? 0),
|
|
1159
|
+
removed: anyFailure ? 0 : (preview?.components.removed.length ?? 0),
|
|
1160
|
+
// Attribute failures to component types by default — without a
|
|
1161
|
+
// per-item breakdown we can't split components vs tokens, and
|
|
1162
|
+
// components are the dominant entity in an import.
|
|
1163
|
+
failed: summary.failed,
|
|
1134
1164
|
},
|
|
1135
1165
|
designTokens: {
|
|
1136
|
-
created: preview?.tokens.new.length ?? 0,
|
|
1137
|
-
updated: preview?.tokens.changed.length ?? 0,
|
|
1138
|
-
removed: preview?.tokens.removed.length ?? 0,
|
|
1166
|
+
created: anyFailure ? 0 : (preview?.tokens.new.length ?? 0),
|
|
1167
|
+
updated: anyFailure ? 0 : (preview?.tokens.changed.length ?? 0),
|
|
1168
|
+
removed: anyFailure ? 0 : (preview?.tokens.removed.length ?? 0),
|
|
1139
1169
|
failed: 0,
|
|
1140
1170
|
},
|
|
1141
|
-
summary
|
|
1171
|
+
summary,
|
|
1142
1172
|
};
|
|
1143
1173
|
}
|
|
1144
1174
|
update({ step: 'done', pushResult });
|
|
1145
1175
|
}
|
|
1146
1176
|
catch (e) {
|
|
1147
|
-
|
|
1177
|
+
// INTEG-4401 Fix C — parse EDSI error bodies into a `[CODE] message`
|
|
1178
|
+
// block before handing off to ErrorStep, so cycle rejections and other
|
|
1179
|
+
// structured failures don't render as raw Lambda log lines
|
|
1180
|
+
// (timestamp / request-id / dd.trace_id / etc.).
|
|
1181
|
+
let msg;
|
|
1182
|
+
if (e instanceof ApiError) {
|
|
1183
|
+
const parsed = parseEdsiError(e.body || e.message);
|
|
1184
|
+
msg = formatParsedEdsiError(parsed, {
|
|
1185
|
+
verbose: process.env['EDSI_VERBOSE_ERRORS'] === '1',
|
|
1186
|
+
raw: e.body,
|
|
1187
|
+
});
|
|
1188
|
+
if (!msg)
|
|
1189
|
+
msg = e.message;
|
|
1190
|
+
}
|
|
1191
|
+
else if (e instanceof Error) {
|
|
1192
|
+
msg = e.message;
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
msg = 'Push failed';
|
|
1196
|
+
}
|
|
1148
1197
|
update({
|
|
1149
1198
|
step: 'error',
|
|
1150
1199
|
errorStep: 'apply push',
|
|
@@ -37,6 +37,6 @@ type GenerateReviewStepProps = {
|
|
|
37
37
|
export declare function sortComponentsForSidebar<T extends {
|
|
38
38
|
key: string;
|
|
39
39
|
entry: CDFComponentEntry;
|
|
40
|
-
}>(components: T[]): T[];
|
|
40
|
+
}>(components: T[], cycleParticipants?: Set<string>): T[];
|
|
41
41
|
export declare function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, livePreview, spaceId, environmentId, cmaToken, host, tokensPath, initialFinalizeError, }: GenerateReviewStepProps): React.ReactElement;
|
|
42
42
|
export {};
|
|
@@ -8,7 +8,8 @@ import { StatusBar } from '../../../analyze/select/tui/components/StatusBar.js';
|
|
|
8
8
|
import { FinalizeDialog } from '../../../analyze/select/tui/components/FinalizeDialog.js';
|
|
9
9
|
import { QuitDialog } from '../../../analyze/select/tui/components/QuitDialog.js';
|
|
10
10
|
import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
|
|
11
|
-
import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, } from '../../../session/db.js';
|
|
11
|
+
import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, loadSlotCycles, storeSlotCycles, } from '../../../session/db.js';
|
|
12
|
+
import { formatCyclePathSegments, findSlotCycles, suggestCycleBreakEdge } from '../../../analyze/cycle-detection.js';
|
|
12
13
|
import { RationalePanel } from '../../../analyze/select/tui/components/RationalePanel.js';
|
|
13
14
|
import { ComponentRationalePanel } from '../../../analyze/select/tui/components/ComponentRationalePanel.js';
|
|
14
15
|
import { applyPreviewAnnotations } from '../../../analyze/select/preview-annotations.js';
|
|
@@ -24,13 +25,22 @@ import { computeNextScrollOffset } from '../../../analyze/select/tui/hooks/scrol
|
|
|
24
25
|
*
|
|
25
26
|
* Within each tier (empty / non-empty) we tie-break alphabetically by `key`.
|
|
26
27
|
*/
|
|
27
|
-
export function sortComponentsForSidebar(components) {
|
|
28
|
+
export function sortComponentsForSidebar(components, cycleParticipants) {
|
|
28
29
|
const isEmpty = (entry) => Object.keys(entry.$properties ?? {}).length === 0 && Object.keys(entry.$slots ?? {}).length === 0;
|
|
30
|
+
// Tier order: cycle members first (they block push — surface loudest),
|
|
31
|
+
// then empty (soft warning), then everything else. Ties broken alpha.
|
|
32
|
+
const tier = (c) => {
|
|
33
|
+
if (cycleParticipants?.has(c.key))
|
|
34
|
+
return 0;
|
|
35
|
+
if (isEmpty(c.entry))
|
|
36
|
+
return 1;
|
|
37
|
+
return 2;
|
|
38
|
+
};
|
|
29
39
|
return [...components].sort((a, b) => {
|
|
30
|
-
const
|
|
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) => {
|
|
@@ -230,6 +253,66 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
230
253
|
}
|
|
231
254
|
onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
|
|
232
255
|
};
|
|
256
|
+
/**
|
|
257
|
+
* INTEG-4401 (Fix 3/4): re-run cycle detection against the current in-memory
|
|
258
|
+
* component state and, if the result differs from the persisted `slotCycles`
|
|
259
|
+
* state, update both React state and the session DB. Called from:
|
|
260
|
+
* - `handleEditSave` after a FieldEditor save mutates a slot's
|
|
261
|
+
* `$allowedComponents`, so the banner / sidebar badges / [c] panel reflect
|
|
262
|
+
* reality instead of the stale extract-time snapshot.
|
|
263
|
+
* - The `r` reject keystroke, since dropping a component from the manifest
|
|
264
|
+
* can collapse cycles that routed through it.
|
|
265
|
+
*
|
|
266
|
+
* Rejected components are excluded from the graph — they will never ship, so
|
|
267
|
+
* they cannot contribute to a real push-time cycle even if their slot config
|
|
268
|
+
* still references other components locally.
|
|
269
|
+
*
|
|
270
|
+
* Cheap for typical N: findSlotCycles is O((V+E)(C+1)) and most manifests
|
|
271
|
+
* have zero or a handful of cycles. Wrapped in try/catch so a malformed
|
|
272
|
+
* $slots shape can't crash the render pipeline.
|
|
273
|
+
*/
|
|
274
|
+
const recomputeCycles = (currentComponents) => {
|
|
275
|
+
try {
|
|
276
|
+
const graph = currentComponents
|
|
277
|
+
.filter((c) => c.status !== 'rejected')
|
|
278
|
+
.map((c) => ({
|
|
279
|
+
name: c.key,
|
|
280
|
+
slots: Object.entries(c.entry.$slots ?? {}).map(([slotName, slotDef]) => ({
|
|
281
|
+
name: slotName,
|
|
282
|
+
allowedComponents: Array.isArray(slotDef?.$allowedComponents)
|
|
283
|
+
? slotDef.$allowedComponents.filter((v) => typeof v === 'string')
|
|
284
|
+
: [],
|
|
285
|
+
})),
|
|
286
|
+
}));
|
|
287
|
+
const rawCycles = findSlotCycles(graph);
|
|
288
|
+
const next = rawCycles.map((cycle) => ({
|
|
289
|
+
path: cycle.path,
|
|
290
|
+
edges: cycle.edges,
|
|
291
|
+
suggestedBreak: cycle.edges.length > 0 ? suggestCycleBreakEdge(cycle, rawCycles) : null,
|
|
292
|
+
}));
|
|
293
|
+
// Cheap structural equality via JSON.stringify — cycle count is tiny and
|
|
294
|
+
// this only fires on user actions, not on every render.
|
|
295
|
+
const prevSerialized = JSON.stringify(slotCycles);
|
|
296
|
+
const nextSerialized = JSON.stringify(next);
|
|
297
|
+
if (prevSerialized === nextSerialized)
|
|
298
|
+
return;
|
|
299
|
+
setSlotCycles(next);
|
|
300
|
+
const db = openPipelineDb();
|
|
301
|
+
try {
|
|
302
|
+
storeSlotCycles(db, extractSessionId, next);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
db.close();
|
|
306
|
+
}
|
|
307
|
+
// If the new cycle set is empty, clear any lingering finalize error the
|
|
308
|
+
// [F] guard may have surfaced. Otherwise leave it alone.
|
|
309
|
+
if (next.length === 0)
|
|
310
|
+
setFinalizeError(null);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Defensive: swallow — never let cycle detection crash the review UI.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
233
316
|
const handleEditSave = () => {
|
|
234
317
|
const current = components[selectedIdx];
|
|
235
318
|
if (!current)
|
|
@@ -245,7 +328,11 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
245
328
|
setSaveError('Invalid CDF entry: must have $type: "component" and $properties object');
|
|
246
329
|
return;
|
|
247
330
|
}
|
|
248
|
-
|
|
331
|
+
let updatedComponents = [];
|
|
332
|
+
setComponents((prev) => {
|
|
333
|
+
updatedComponents = prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c);
|
|
334
|
+
return updatedComponents;
|
|
335
|
+
});
|
|
249
336
|
setDraftValue('');
|
|
250
337
|
setSaveError(null);
|
|
251
338
|
const db = openPipelineDb();
|
|
@@ -255,6 +342,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
255
342
|
finally {
|
|
256
343
|
db.close();
|
|
257
344
|
}
|
|
345
|
+
// INTEG-4401 (Fix 3/4): recompute cycles against the freshly-edited
|
|
346
|
+
// component set so banner/badges/[c]-panel stay in sync. The FieldEditor
|
|
347
|
+
// picker filters cycle-forming candidates, but free-text edits + JSON
|
|
348
|
+
// pastes still slip through, and edits that BREAK an existing cycle
|
|
349
|
+
// won't propagate without this call.
|
|
350
|
+
recomputeCycles(updatedComponents);
|
|
258
351
|
// Feature 2: re-fire the live preview now that pipeline.db reflects
|
|
259
352
|
// the new state. The hook owns debounce + cred-missing short-circuit.
|
|
260
353
|
livePreviewHook.trigger();
|
|
@@ -283,6 +376,31 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
283
376
|
}
|
|
284
377
|
return;
|
|
285
378
|
}
|
|
379
|
+
// INTEG-4401: slot-cycle detail panel. Same modal-swallow rules as
|
|
380
|
+
// showRemovedPanel; q/Esc close, ↑↓ scroll.
|
|
381
|
+
if (showCyclePanel) {
|
|
382
|
+
if (input === 'c' || input === 'q' || key.escape) {
|
|
383
|
+
setShowCyclePanel(false);
|
|
384
|
+
setCyclePanelScroll(0);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (key.upArrow || input === 'k') {
|
|
388
|
+
setCyclePanelScroll((v) => Math.max(0, v - 1));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (key.downArrow || input === 'j') {
|
|
392
|
+
setCyclePanelScroll((v) => v + 1);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Open the cycle panel from sidebar-focused state when there is at
|
|
398
|
+
// least one cycle to display.
|
|
399
|
+
if (input === 'c' && sidebarFocused && slotCycles.length > 0) {
|
|
400
|
+
setShowCyclePanel(true);
|
|
401
|
+
setCyclePanelScroll(0);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
286
404
|
// `d` opens the panel only when live-preview is enabled and there is at
|
|
287
405
|
// least one removed component to display. Sidebar-focused only so it
|
|
288
406
|
// doesn't collide with FieldEditor input.
|
|
@@ -412,6 +530,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
412
530
|
return;
|
|
413
531
|
}
|
|
414
532
|
if (input === 'F') {
|
|
533
|
+
// INTEG-4401: refuse to open the finalize dialog while any slot cycle
|
|
534
|
+
// is unresolved. The push-time hard block (`assertNoSlotCycles`) would
|
|
535
|
+
// catch this downstream, but showing an inline banner here saves the
|
|
536
|
+
// operator from confirming the dialog only to hit a stderr crash.
|
|
537
|
+
if (slotCycles.length > 0) {
|
|
538
|
+
setFinalizeError('Cannot finalize — resolve slot dependency cycle(s) first (press [c] for detail)');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
415
541
|
setShowFinalize(true);
|
|
416
542
|
return;
|
|
417
543
|
}
|
|
@@ -421,7 +547,14 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
421
547
|
return;
|
|
422
548
|
}
|
|
423
549
|
if (input === 'r') {
|
|
424
|
-
|
|
550
|
+
// INTEG-4401 (Fix 4): rejecting removes the component from the effective
|
|
551
|
+
// manifest, so recompute cycles across the reduced graph — any cycle
|
|
552
|
+
// that routed through this component collapses. We build the "next"
|
|
553
|
+
// components array inline (mirroring updateStatus) so recomputeCycles
|
|
554
|
+
// sees the post-update graph without waiting for a render tick.
|
|
555
|
+
const next = components.map((c, i) => i === selectedIdx ? { ...c, status: 'rejected' } : c);
|
|
556
|
+
setComponents(next);
|
|
557
|
+
recomputeCycles(next);
|
|
425
558
|
return;
|
|
426
559
|
}
|
|
427
560
|
if (input === 'A') {
|
|
@@ -476,30 +609,87 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
476
609
|
const selectedJson = selected ? JSON.stringify({ [selected.key]: selected.entry }, null, 2) : '';
|
|
477
610
|
const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0 && Object.keys(c.entry.$slots ?? {}).length === 0;
|
|
478
611
|
const emptyCount = components.filter(isEmpty).length;
|
|
612
|
+
// INTEG-4401: cycle-participant set drives sidebar `(cycle)` badges plus
|
|
613
|
+
// the banner counts. Recomputed on every render — cheap for typical N.
|
|
614
|
+
const cycleParticipantSet = new Set();
|
|
615
|
+
for (const cycle of slotCycles)
|
|
616
|
+
for (const p of cycle.path)
|
|
617
|
+
cycleParticipantSet.add(p);
|
|
618
|
+
const sidebarSuffix = (c) => {
|
|
619
|
+
if (cycleParticipantSet.has(c.key))
|
|
620
|
+
return ' (cycle)';
|
|
621
|
+
if (isEmpty(c))
|
|
622
|
+
return ' (empty)';
|
|
623
|
+
return '';
|
|
624
|
+
};
|
|
479
625
|
const sidebarItems = components.map((c) => ({
|
|
480
626
|
id: c.key,
|
|
481
|
-
name:
|
|
627
|
+
name: `${c.key}${sidebarSuffix(c)}`,
|
|
482
628
|
status: c.status,
|
|
483
629
|
previewAnnotation: previewAnnotations.get(c.key),
|
|
484
630
|
extractionConfidence: null,
|
|
485
631
|
needsReview: false,
|
|
486
632
|
validationErrorCount: 0,
|
|
487
|
-
validationWarningCount:
|
|
633
|
+
validationWarningCount: sidebarSuffix(c) !== '' ? 1 : 0,
|
|
488
634
|
}));
|
|
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 + (
|
|
635
|
+
// Account for the "(cycle)" / "(empty)" suffix added to badged component
|
|
636
|
+
// names so the sidebar doesn't truncate them.
|
|
637
|
+
const longestName = components.reduce((m, c) => Math.max(m, c.key.length + sidebarSuffix(c).length), 0);
|
|
492
638
|
// +5 = border (1) + status icon (1) + badge column (1) + space (1) + border (1).
|
|
493
639
|
// The badge column is reserved even when no annotation is present so the
|
|
494
640
|
// sidebar width doesn't jitter as live-preview annotations flip in/out.
|
|
495
641
|
const sidebarWidth = Math.min(Math.max(longestName + 5, 14), 30);
|
|
496
642
|
const panelWidth = Math.max(10, terminalWidth - sidebarWidth - 4);
|
|
643
|
+
// INTEG-4401: project-wide slot graph passed to FieldEditor so its
|
|
644
|
+
// $allowedComponents picker can filter out cycle-forming candidates. Built
|
|
645
|
+
// from every review entry's $slots — includes accepted, rejected, and
|
|
646
|
+
// needs-review components so the graph reflects what will actually be
|
|
647
|
+
// pushed. The FieldEditor replaces its own entry in-simulation with its
|
|
648
|
+
// live editor state, so pending edits are always reflected.
|
|
649
|
+
const projectSlotGraph = components.map((c) => ({
|
|
650
|
+
name: c.key,
|
|
651
|
+
slots: Object.entries(c.entry.$slots ?? {}).map(([slotName, slotDef]) => ({
|
|
652
|
+
name: slotName,
|
|
653
|
+
allowedComponents: Array.isArray(slotDef?.$allowedComponents)
|
|
654
|
+
? slotDef.$allowedComponents.filter((v) => typeof v === 'string')
|
|
655
|
+
: [],
|
|
656
|
+
})),
|
|
657
|
+
}));
|
|
497
658
|
const accepted = components.filter((c) => c.status === 'accepted').length;
|
|
498
659
|
const rejected = components.filter((c) => c.status === 'rejected').length;
|
|
499
660
|
const needsReview = components.filter((c) => c.status === 'needs-review').length;
|
|
500
661
|
const propCount = selected ? Object.keys(selected.entry.$properties).length : 0;
|
|
501
662
|
const slotCount = selected?.entry.$slots ? Object.keys(selected.entry.$slots).length : 0;
|
|
502
|
-
return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showRemovedPanel && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `Removed components (${removedComponents.length})` }), _jsx(Text, { dimColor: true, children: "these will be DELETED from the target space" }), _jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: `- ${rc.name}${rc.id ? ` (${rc.id})` : ''}` }, rc.id))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press d or Esc to close" })] })),
|
|
663
|
+
return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showRemovedPanel && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `Removed components (${removedComponents.length})` }), _jsx(Text, { dimColor: true, children: "these will be DELETED from the target space" }), _jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: `- ${rc.name}${rc.id ? ` (${rc.id})` : ''}` }, rc.id))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press d or Esc to close" })] })), showCyclePanel &&
|
|
664
|
+
!dialogOpen &&
|
|
665
|
+
(() => {
|
|
666
|
+
// Materialize the full panel body as a flat list of Text lines,
|
|
667
|
+
// then slice by cyclePanelScroll so ↑↓ can walk arbitrarily long
|
|
668
|
+
// content. Each cycle contributes 3-4 lines: heading, path,
|
|
669
|
+
// suggested fix (if any), and a blank separator.
|
|
670
|
+
const PANEL_H = 20;
|
|
671
|
+
const lines = [];
|
|
672
|
+
lines.push(_jsx(Text, { bold: true, color: "yellow", children: `SLOT DEPENDENCY CYCLES (${slotCycles.length})` }, "cyc-title"));
|
|
673
|
+
lines.push(_jsx(Text, { dimColor: true, children: 'push will fail until these are resolved' }, "cyc-sub"));
|
|
674
|
+
lines.push(_jsx(Text, { children: " " }, "cyc-space"));
|
|
675
|
+
slotCycles.forEach((cycle, idx) => {
|
|
676
|
+
const nodeCount = new Set(cycle.path).size;
|
|
677
|
+
lines.push(_jsx(Text, { bold: true, children: `▸ Cycle ${idx + 1} (${nodeCount} component${nodeCount === 1 ? '' : 's'}):` }, `cyc-h-${idx}`));
|
|
678
|
+
// Colorize slots (cyan, bracketed) distinctly from components so
|
|
679
|
+
// the operator can visually parse the alternating structure.
|
|
680
|
+
// Brackets on slot names ensure the distinction survives when
|
|
681
|
+
// ANSI is stripped (logs, redirected output).
|
|
682
|
+
const segs = formatCyclePathSegments(cycle, 16);
|
|
683
|
+
lines.push(_jsxs(Text, { children: [' ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: "cyan", children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { children: seg.text }, si)))] }, `cyc-p-${idx}`));
|
|
684
|
+
if (cycle.suggestedBreak) {
|
|
685
|
+
const b = cycle.suggestedBreak;
|
|
686
|
+
lines.push(_jsx(Text, { dimColor: true, children: ` Suggested fix: remove '${b.toComponent}' from ${b.fromComponent}.$slots.${b.slotName}.$allowedComponents` }, `cyc-f-${idx}`));
|
|
687
|
+
}
|
|
688
|
+
lines.push(_jsx(Text, { children: " " }, `cyc-s-${idx}`));
|
|
689
|
+
});
|
|
690
|
+
const visible = lines.slice(cyclePanelScroll, cyclePanelScroll + PANEL_H);
|
|
691
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [visible, _jsx(Text, { dimColor: true, children: '[↑↓/j/k] scroll [c/q/Esc] close' })] }));
|
|
692
|
+
})(), !dialogOpen &&
|
|
503
693
|
livePreview &&
|
|
504
694
|
(() => {
|
|
505
695
|
// Pilot-2026-06-23 R2: at-a-glance diff summary at the top of the
|
|
@@ -522,7 +712,10 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
522
712
|
if (!hasCounts)
|
|
523
713
|
return null;
|
|
524
714
|
return (_jsxs(Box, { children: [_jsx(Text, { children: 'Preview: ' }), _jsx(Text, { color: "green", children: `${counts.new} new` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "yellow", children: `${counts.changed} changed` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { dimColor: true, children: `${counts.removed} removed` }), removedComponents.length > 0 && _jsx(Text, { dimColor: true, children: ' ([d] removed list)' }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "red", bold: true, children: `${counts.breaking} breaking` })] }));
|
|
525
|
-
})(), !dialogOpen &&
|
|
715
|
+
})(), !dialogOpen && slotCycles.length > 0 && !showCyclePanel && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: `⚠ ${slotCycles.length} slot dependency cycle${slotCycles.length === 1 ? '' : 's'} detected — push will fail` }), slotCycles.slice(0, 3).map((cycle, idx) => {
|
|
716
|
+
const segs = formatCyclePathSegments(cycle);
|
|
717
|
+
return (_jsxs(Text, { color: "yellow", children: [' Cycle: ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: "cyan", children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { color: "yellow", children: seg.text }, si)))] }, `cyc-banner-${idx}`));
|
|
718
|
+
}), slotCycles.length > 3 && _jsx(Text, { color: "yellow", children: ` …${slotCycles.length - 3} more` }), _jsx(Text, { dimColor: true, children: ' press [c] for detail' })] })), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: "yellow", children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && finalizeError && _jsx(Text, { color: "red", children: `⚠ ${finalizeError}` }), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
|
|
526
719
|
const idx = components.findIndex((c) => c.key === id);
|
|
527
720
|
if (idx >= 0) {
|
|
528
721
|
setSelectedIdx(idx);
|
|
@@ -571,11 +764,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
|
|
|
571
764
|
}, onToggleSourceExternal: () => {
|
|
572
765
|
setPanelOpen('source');
|
|
573
766
|
setPanelScrollOffset(() => 0);
|
|
574
|
-
}, onTextEntryActiveChange: setTextEntryActive }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
|
|
767
|
+
}, onTextEntryActiveChange: setTextEntryActive, projectSlotGraph: projectSlotGraph, currentComponentName: selected.key }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
|
|
575
768
|
? ' [a] accept [r] reject [A] accept all [J] ' +
|
|
576
769
|
(showJson ? 'hide JSON' : 'show JSON') +
|
|
577
770
|
' [F] finalize [e/Tab] focus panel' +
|
|
578
771
|
(livePreview && removedComponents.length > 0 ? ' [d] removed list' : '') +
|
|
772
|
+
(slotCycles.length > 0 ? ' [c] cycles' : '') +
|
|
579
773
|
' [q] quit'
|
|
580
774
|
: showJson
|
|
581
775
|
? ' [j/k] scroll [Ctrl+u/d] half-page [gg/G] top/bottom [Tab] focus list'
|
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-ff57340.0",
|
|
4
4
|
"description": "Contentful Experiences design system import CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"svelte": "^5.56.4",
|
|
38
38
|
"ts-morph": "^27.0.2",
|
|
39
39
|
"typescript": "^5.9.3",
|
|
40
|
-
"@contentful/experience-design-system-types": "2.12.4-dev-build-
|
|
40
|
+
"@contentful/experience-design-system-types": "2.12.4-dev-build-ff57340.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@tsconfig/node24": "^24.0.3",
|