@contentful/experience-design-system-cli 2.7.5-dev-build-752dc9c.0 → 2.10.1-dev-build-90df010.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.
Files changed (34) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/analyze/build-analyze-view-rows.d.ts +23 -0
  3. package/dist/src/analyze/build-analyze-view-rows.js +39 -0
  4. package/dist/src/analyze/command.js +13 -10
  5. package/dist/src/analyze/extract/validate.d.ts +13 -1
  6. package/dist/src/analyze/extract/validate.js +32 -5
  7. package/dist/src/analyze/select/command.d.ts +37 -0
  8. package/dist/src/analyze/select/command.js +200 -9
  9. package/dist/src/analyze/select/tui/App.js +1 -9
  10. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +1 -1
  11. package/dist/src/analyze/select/tui/components/Sidebar.js +26 -3
  12. package/dist/src/analyze/select-agent/command.js +22 -10
  13. package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
  14. package/dist/src/analyze/tui/AnalyzeView.js +5 -2
  15. package/dist/src/apply/api-client.d.ts +20 -0
  16. package/dist/src/apply/api-client.js +71 -5
  17. package/dist/src/generate/command.js +21 -2
  18. package/dist/src/import/orchestrator.d.ts +21 -0
  19. package/dist/src/import/orchestrator.js +95 -14
  20. package/dist/src/import/tui/WizardApp.d.ts +13 -0
  21. package/dist/src/import/tui/WizardApp.js +224 -52
  22. package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
  23. package/dist/src/import/tui/steps/GateStep.js +4 -2
  24. package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
  25. package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
  26. package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
  27. package/dist/src/import/tui/wizard-422-helpers.js +60 -0
  28. package/dist/src/program.d.ts +17 -0
  29. package/dist/src/program.js +27 -4
  30. package/dist/src/session/db.d.ts +13 -0
  31. package/dist/src/session/db.js +50 -0
  32. package/dist/src/types.d.ts +1 -1
  33. package/package.json +2 -2
  34. package/skills/generate-components.md +2 -0
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.7.5-dev-build-752dc9c.0",
3
+ "version": "2.10.1-dev-build-90df010.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,23 @@
1
+ import type { RawComponentDefinition } from '../types.js';
2
+ import type { AnalyzeViewResult } from './tui/AnalyzeView.js';
3
+ type AnalyzeViewRow = AnalyzeViewResult['components'][number];
4
+ /**
5
+ * Build the per-component rows shown in the analyze TUI / non-TTY summary,
6
+ * pairing each filtered component with its corresponding validation result.
7
+ *
8
+ * IMPORTANT — positional indexing is intentional and load-bearing.
9
+ * `validateExtractedComponents` is implemented as `.map()` over its input,
10
+ * so `validatedComponents[i]` always corresponds to `filteredComponents[i]`.
11
+ *
12
+ * We previously keyed validated components by `name` to "avoid a positional
13
+ * dependency" — that got the tradeoff exactly backwards. `Map.set` overwrites
14
+ * by key, so for two components named "Button" only the second's
15
+ * `validationIssues` survived in the Map. The first row's errors then
16
+ * disappeared from `analyzeResult` entirely: no Errors-section entry, no
17
+ * row badge, no count. Pairing by index restores correctness.
18
+ */
19
+ export declare function buildAnalyzeViewRows(filteredComponents: RawComponentDefinition[], validatedComponents: RawComponentDefinition[], allWarnings: string[]): {
20
+ rows: AnalyzeViewRow[];
21
+ totalErrors: number;
22
+ };
23
+ export {};
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Build the per-component rows shown in the analyze TUI / non-TTY summary,
3
+ * pairing each filtered component with its corresponding validation result.
4
+ *
5
+ * IMPORTANT — positional indexing is intentional and load-bearing.
6
+ * `validateExtractedComponents` is implemented as `.map()` over its input,
7
+ * so `validatedComponents[i]` always corresponds to `filteredComponents[i]`.
8
+ *
9
+ * We previously keyed validated components by `name` to "avoid a positional
10
+ * dependency" — that got the tradeoff exactly backwards. `Map.set` overwrites
11
+ * by key, so for two components named "Button" only the second's
12
+ * `validationIssues` survived in the Map. The first row's errors then
13
+ * disappeared from `analyzeResult` entirely: no Errors-section entry, no
14
+ * row badge, no count. Pairing by index restores correctness.
15
+ */
16
+ export function buildAnalyzeViewRows(filteredComponents, validatedComponents, allWarnings) {
17
+ if (validatedComponents.length !== filteredComponents.length) {
18
+ // Defensive: caller must pass aligned arrays. If this ever fires we want
19
+ // to know loudly rather than silently mis-pair rows with their issues.
20
+ throw new Error(`buildAnalyzeViewRows: validatedComponents length (${validatedComponents.length}) does not match filteredComponents length (${filteredComponents.length})`);
21
+ }
22
+ let totalErrors = 0;
23
+ const rows = filteredComponents.map((c, i) => {
24
+ const validated = validatedComponents[i];
25
+ const errorIssues = (validated.validationIssues ?? []).filter((issue) => issue.severity === 'error');
26
+ totalErrors += errorIssues.length;
27
+ return {
28
+ name: c.name,
29
+ framework: c.framework,
30
+ propCount: c.props.length,
31
+ slotCount: c.slots.length,
32
+ warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
33
+ errors: errorIssues.map((issue) => issue.message),
34
+ extractionConfidence: c.extractionConfidence ?? null,
35
+ needsReview: c.needsReview ?? false,
36
+ };
37
+ });
38
+ return { rows, totalErrors };
39
+ }
@@ -12,6 +12,7 @@ import { isNonAuthorableComponent } from './extract/non-authorable-filter.js';
12
12
  import { computeExtractionScore, deriveNeedsReview } from './extract/scoring.js';
13
13
  import { describeReviewReasons, inspectComponentSource } from './extract/source-inspection.js';
14
14
  import { validateExtractedComponents } from './extract/validate.js';
15
+ import { buildAnalyzeViewRows } from './build-analyze-view-rows.js';
15
16
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
16
17
  const IGNORED_DIRECTORY_NAMES = new Set([
17
18
  '.git',
@@ -175,20 +176,14 @@ export function registerAnalyzeCommand(program) {
175
176
  updateStep(db, stepId, 'complete', { sessionId });
176
177
  db.close();
177
178
  const allWarnings = [...extraction.warnings, ...filterWarnings];
179
+ const { rows: componentRows, totalErrors } = buildAnalyzeViewRows(filteredComponents, validatedComponents, allWarnings);
178
180
  const analyzeResult = {
179
181
  sourceDirectory,
180
182
  sessionId,
181
183
  fileCount: sourceFiles.length,
182
- components: filteredComponents.map((c) => ({
183
- name: c.name,
184
- framework: c.framework,
185
- propCount: c.props.length,
186
- slotCount: c.slots.length,
187
- warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
188
- extractionConfidence: c.extractionConfidence ?? null,
189
- needsReview: c.needsReview ?? false,
190
- })),
184
+ components: componentRows,
191
185
  totalWarnings: allWarnings.length,
186
+ totalErrors,
192
187
  };
193
188
  if (process.stdout.isTTY) {
194
189
  const { waitUntilExit } = render(createElement(AnalyzeView, {
@@ -204,11 +199,19 @@ export function registerAnalyzeCommand(program) {
204
199
  `Scanned ${pluralize(sourceFiles.length, 'source file')} in ${sourceDirectory}`,
205
200
  `Extracted ${pluralize(extraction.components.length, 'component')}`,
206
201
  ];
202
+ if (totalErrors > 0) {
203
+ summaryLines.push(`Errors (${totalErrors}):`);
204
+ for (const c of componentRows) {
205
+ for (const e of c.errors) {
206
+ summaryLines.push(`- ${c.name}: ${e}`);
207
+ }
208
+ }
209
+ }
207
210
  if (allWarnings.length > 0) {
208
211
  summaryLines.push(`Warnings (${allWarnings.length}):`);
209
212
  summaryLines.push(...allWarnings.map((w) => `- ${w}`));
210
213
  }
211
- else {
214
+ else if (totalErrors === 0) {
212
215
  summaryLines.push('Warnings: none');
213
216
  }
214
217
  process.stderr.write(summaryLines.join('\n') + '\n');
@@ -1,4 +1,16 @@
1
- import type { RawComponentDefinition } from '../../types.js';
1
+ import type { RawComponentDefinition, ExtractionValidationIssue } from '../../types.js';
2
2
  export type { ExtractionValidationIssue } from '../../types.js';
3
3
  export declare function validateExtractedComponents(components: RawComponentDefinition[]): RawComponentDefinition[];
4
4
  export declare function shouldExcludeDueToValidation(component: RawComponentDefinition): boolean;
5
+ /**
6
+ * Format a stderr-ready warning describing components auto-rejected by the
7
+ * extraction gate. Used by `analyze select --select-all --exclude-invalid`
8
+ * and `analyze select-agent --exclude-invalid` so both opt-in paths emit a
9
+ * consistent message — non-interactive callers (CI, orchestrator, scripted
10
+ * pipeline) need to see WHICH components were excluded and WHY, not just
11
+ * the bare counts.
12
+ */
13
+ export declare function formatExclusionWarning(rejected: Array<{
14
+ name: string;
15
+ validationIssues?: ExtractionValidationIssue[];
16
+ }>): string;
@@ -25,21 +25,27 @@ export function validateExtractedComponents(components) {
25
25
  }
26
26
  for (let i = 0; i < component.slots.length; i++) {
27
27
  if (!component.slots[i].name.trim()) {
28
+ // Warning, not error: SP-2's renameEmptySlots auto-recovers empty slot
29
+ // names to children/slot_<n> before the LLM prompt, so the component
30
+ // can still be generated. Asymmetric with EMPTY_PROP_NAME (still error)
31
+ // because that path silently drops empty-named props in
32
+ // loadCDFComponents — real data loss — instead of recovering them.
28
33
  issues.push({
29
- severity: 'error',
34
+ severity: 'warning',
30
35
  code: 'EMPTY_SLOT_NAME',
31
36
  message: `Slot at index ${i} has an empty name`,
32
37
  field: `slots[${i}].name`,
33
38
  });
34
39
  }
35
40
  }
36
- const propNames = new Set(component.props.map((p) => p.name));
41
+ const propNames = new Set(component.props.map((p) => p.name.trim()).filter(Boolean));
37
42
  for (let i = 0; i < component.slots.length; i++) {
38
- if (component.slots[i].name && propNames.has(component.slots[i].name)) {
43
+ const slotName = component.slots[i].name.trim();
44
+ if (slotName && propNames.has(slotName)) {
39
45
  issues.push({
40
46
  severity: 'error',
41
47
  code: 'PROP_SLOT_NAME_COLLISION',
42
- message: `"${component.slots[i].name}" is used as both a prop name and a slot name`,
48
+ message: `"${slotName}" is used as both a prop name and a slot name`,
43
49
  field: `slots[${i}].name`,
44
50
  });
45
51
  }
@@ -47,7 +53,7 @@ export function validateExtractedComponents(components) {
47
53
  const nameKey = component.name.trim();
48
54
  if (nameKey && (nameCounts.get(nameKey) ?? 0) > 1) {
49
55
  issues.push({
50
- severity: 'warning',
56
+ severity: 'error',
51
57
  code: 'DUPLICATE_COMPONENT_NAME',
52
58
  message: `Component name "${component.name}" appears more than once in the extracted set`,
53
59
  });
@@ -65,3 +71,24 @@ export function validateExtractedComponents(components) {
65
71
  export function shouldExcludeDueToValidation(component) {
66
72
  return (component.validationIssues ?? []).some((i) => i.severity === 'error');
67
73
  }
74
+ /**
75
+ * Format a stderr-ready warning describing components auto-rejected by the
76
+ * extraction gate. Used by `analyze select --select-all --exclude-invalid`
77
+ * and `analyze select-agent --exclude-invalid` so both opt-in paths emit a
78
+ * consistent message — non-interactive callers (CI, orchestrator, scripted
79
+ * pipeline) need to see WHICH components were excluded and WHY, not just
80
+ * the bare counts.
81
+ */
82
+ export function formatExclusionWarning(rejected) {
83
+ if (rejected.length === 0)
84
+ return '';
85
+ const lines = [`Warning: ${rejected.length} component(s) excluded due to validation errors:`];
86
+ for (const comp of rejected) {
87
+ const codes = (comp.validationIssues ?? [])
88
+ .filter((i) => i.severity === 'error')
89
+ .map((i) => i.code)
90
+ .join(', ');
91
+ lines.push(` ✗ ${comp.name} ${codes}`);
92
+ }
93
+ return lines.join('\n') + '\n';
94
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { ReviewSessionSnapshot } from './types.js';
3
+ import type { PreviewValidationError } from '../../apply/api-client.js';
3
4
  /**
4
5
  * Load components from the pipeline DB and re-run extraction validation.
5
6
  *
@@ -9,4 +10,40 @@ import type { ReviewSessionSnapshot } from './types.js';
9
10
  * review snapshot — otherwise the TUI sees no validation errors at all.
10
11
  */
11
12
  export declare function loadAndValidateForReview(sessionId: string, projectRoot: string | undefined): Promise<ReviewSessionSnapshot>;
13
+ /**
14
+ * Synthesize SERVER_VALIDATION_FAILED issues onto the review state file's
15
+ * matching components. Used by the wizard's 422 recovery path: after a
16
+ * preview-API validation error, this runs before re-launching the
17
+ * analyze-select TUI so the Sidebar's existing red-warning rendering
18
+ * pins the offenders to the top.
19
+ *
20
+ * Idempotent on identical input is NOT a contract: each call appends.
21
+ * The wizard's 422 path is a one-shot per preview attempt, so duplicate
22
+ * issues only matter if a caller invokes this twice without the user
23
+ * round-tripping through the TUI in between.
24
+ */
25
+ export declare function patchReviewStateWithValidationErrors(sessionId: string, errors: PreviewValidationError[], opts?: {
26
+ artifactsRoot?: string;
27
+ }): Promise<{
28
+ patchedNames: string[];
29
+ missingNames: string[];
30
+ }>;
31
+ /**
32
+ * Exclude components whose names appear in `names` from the next preview/push.
33
+ * Used by the wizard's "skip and retry" path after a 422.
34
+ *
35
+ * Two stores are updated:
36
+ * - Pipeline DB (`raw_components.status` → `'generate-rejected'`) so that
37
+ * `loadCDFComponents()` excludes them from the next manifest. This is the
38
+ * load-bearing change — preview reads from the DB, not the JSON file.
39
+ * - Review state JSON (`status` → `'rejected'`) so that if the user re-enters
40
+ * the analyze-select TUI later, the rejected status is reflected.
41
+ *
42
+ * `'generate-rejected'` matches the existing convention for "component was
43
+ * generated successfully but later excluded" (see GenerateReviewStep) — the
44
+ * component generated fine locally but failed server-side validation.
45
+ */
46
+ export declare function rejectComponentsByName(sessionId: string, names: string[], opts?: {
47
+ artifactsRoot?: string;
48
+ }): Promise<void>;
12
49
  export declare function registerAnalyzeEditCommand(program: Command): void;
@@ -1,12 +1,12 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
3
  import { createElement } from 'react';
4
4
  import { render } from 'ink';
5
5
  import { getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState } from './persistence.js';
6
6
  import { loadReviewInput } from './parser.js';
7
7
  import { App } from './tui/App.js';
8
8
  import { openPipelineDb, loadRawComponents, storeRawComponents, createStep, updateStep } from '../../session/db.js';
9
- import { validateExtractedComponents, shouldExcludeDueToValidation } from '../extract/validate.js';
9
+ import { validateExtractedComponents, shouldExcludeDueToValidation, formatExclusionWarning, } from '../extract/validate.js';
10
10
  const SAFE_PATH_RE = /^[a-zA-Z0-9_.$[\]=]+$/;
11
11
  const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
12
12
  function applyDotPath(obj, path, value) {
@@ -73,10 +73,66 @@ function applyPatch(snapshot, ops) {
73
73
  return { ...snapshot, components };
74
74
  }
75
75
  async function runNonInteractive(snapshot, opts, paths, sessionId) {
76
+ // --exclude-components on its own (no --select-all / --select / --deselect /
77
+ // --reject / --patch) is the orchestrator's 422 retry-loop invocation: only
78
+ // the named components should change. The rebuild path below calls
79
+ // storeRawComponents which DELETEs all rows and re-inserts with default
80
+ // status='extracted', wiping the post-generate state required by
81
+ // loadCDFComponents (status='generated' + raw_props.cdf_type populated).
82
+ // Short-circuit through rejectComponentsByName, which is a pure UPDATE.
83
+ const onlyExcludeComponents = !!opts.excludeComponents &&
84
+ !opts.acceptAll &&
85
+ !opts.selectAll &&
86
+ !(opts.select ?? []).length &&
87
+ !(opts.deselect ?? []).length &&
88
+ !(opts.reject ?? []).length &&
89
+ !opts.patch;
90
+ if (onlyExcludeComponents) {
91
+ const names = opts
92
+ .excludeComponents.split(',')
93
+ .map((n) => n.trim())
94
+ .filter(Boolean);
95
+ if (names.length > 0) {
96
+ // paths.sessionDir = resolve(artifactsRoot, sessionId), so dirname gives
97
+ // back the artifactsRoot — keeps rejectComponentsByName from re-resolving
98
+ // it via env var when the caller already knows it.
99
+ const artifactsRoot = dirname(paths.sessionDir);
100
+ await rejectComponentsByName(sessionId, names, { artifactsRoot });
101
+ }
102
+ const accepted = snapshot.components.filter((c) => c.status === 'accepted').length;
103
+ const rejected = snapshot.components.filter((c) => c.status === 'rejected').length + (names.length || 0);
104
+ process.stderr.write(`Accepted: ${accepted} Rejected: ${rejected}\n`);
105
+ return;
106
+ }
76
107
  let result = { ...snapshot };
77
108
  const rejectPatterns = [...(opts.reject ?? []), ...(opts.deselect ?? [])].map((p) => p.toLowerCase());
78
109
  const selectPatterns = (opts.select ?? []).map((p) => p.toLowerCase());
79
110
  const selectAll = opts.acceptAll || opts.selectAll;
111
+ // Fail-loud gate: --select-all stops with a non-zero exit when ANY component
112
+ // has error-severity validation issues. The user must opt in to auto-rejection
113
+ // with --exclude-invalid. Silent exclusion in CI / orchestrator contexts is
114
+ // dangerous — the caller should see and acknowledge that components are
115
+ // being dropped from the import.
116
+ if (selectAll && !opts.excludeInvalid) {
117
+ const invalid = result.components.filter((c) => shouldExcludeDueToValidation(c.originalProposal));
118
+ if (invalid.length > 0) {
119
+ const lines = [
120
+ `Error: ${invalid.length} component(s) failed validation; refusing --select-all without --exclude-invalid:`,
121
+ ];
122
+ for (const c of invalid) {
123
+ const codes = (c.originalProposal.validationIssues ?? [])
124
+ .filter((i) => i.severity === 'error')
125
+ .map((i) => i.code)
126
+ .join(', ');
127
+ lines.push(` ✗ ${c.name} ${codes}`);
128
+ }
129
+ lines.push('');
130
+ lines.push('Re-run with --exclude-invalid to auto-reject these components, or run analyze select interactively to fix them.');
131
+ process.stderr.write(lines.join('\n') + '\n');
132
+ process.exit(1);
133
+ return;
134
+ }
135
+ }
80
136
  if (selectAll || rejectPatterns.length > 0 || selectPatterns.length > 0) {
81
137
  result = {
82
138
  ...result,
@@ -95,6 +151,21 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
95
151
  }),
96
152
  };
97
153
  }
154
+ // Apply --exclude-components: force-reject the named components regardless of
155
+ // how --select-all / --select / --deselect classified them. Used by the
156
+ // orchestrator's 422 retry loop to exclude server-validation offenders.
157
+ if (opts.excludeComponents) {
158
+ const excludeNames = new Set(opts.excludeComponents
159
+ .split(',')
160
+ .map((n) => n.trim())
161
+ .filter(Boolean));
162
+ if (excludeNames.size > 0) {
163
+ result = {
164
+ ...result,
165
+ components: result.components.map((c) => excludeNames.has(c.name) ? { ...c, status: 'rejected' } : c),
166
+ };
167
+ }
168
+ }
98
169
  // Apply --patch
99
170
  if (opts.patch) {
100
171
  let patchOps;
@@ -124,10 +195,31 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
124
195
  }
125
196
  const accepted = result.components.filter((c) => c.status === 'accepted');
126
197
  const rejected = result.components.filter((c) => c.status === 'rejected');
198
+ // Surface what was excluded by --select-all --exclude-invalid so the
199
+ // non-interactive caller (CI, orchestrator, scripted pipeline) doesn't
200
+ // have to guess what failed validation. The warning is printed BEFORE
201
+ // saveReviewState so the message lands ahead of the bare counts.
202
+ if (selectAll && opts.excludeInvalid) {
203
+ const autoRejected = rejected
204
+ .filter((c) => shouldExcludeDueToValidation(c.originalProposal))
205
+ .map((c) => ({ name: c.name, validationIssues: c.originalProposal.validationIssues }));
206
+ process.stderr.write(formatExclusionWarning(autoRejected));
207
+ }
127
208
  // Persist decisions to session state so pipeline orchestrator can read them
128
209
  await saveReviewState(paths.statePath, result);
129
- // Sync edited proposals back to the DB so generation uses the user's edits
130
- if (accepted.length > 0) {
210
+ // Sync edited proposals back to the DB so generation uses the user's edits.
211
+ //
212
+ // Only --patch can mutate editedProposal — the other non-interactive flags
213
+ // (--select-all / --select / --deselect / --reject / --exclude-invalid)
214
+ // change status only. Calling storeRawComponents in the no-edit case is
215
+ // not just wasteful: it's destructive. storeRawComponents starts with
216
+ // `DELETE FROM raw_components WHERE session_id = ?` and re-inserts only
217
+ // the components passed in — so on a --select-all run, every rejected
218
+ // component (including its raw_slots / raw_props children) is physically
219
+ // removed. A second invocation of the same command would then see a
220
+ // smaller snapshot, lose the rejected component(s), and report different
221
+ // counts.
222
+ if (opts.patch && accepted.length > 0) {
131
223
  const db = openPipelineDb();
132
224
  try {
133
225
  storeRawComponents(db, sessionId, accepted.map((c) => c.editedProposal));
@@ -158,6 +250,100 @@ export async function loadAndValidateForReview(sessionId, projectRoot) {
158
250
  const validatedComponents = validateExtractedComponents(rawComponents);
159
251
  return loadReviewInput(validatedComponents, { reviewRoot: projectRoot });
160
252
  }
253
+ /**
254
+ * Synthesize SERVER_VALIDATION_FAILED issues onto the review state file's
255
+ * matching components. Used by the wizard's 422 recovery path: after a
256
+ * preview-API validation error, this runs before re-launching the
257
+ * analyze-select TUI so the Sidebar's existing red-warning rendering
258
+ * pins the offenders to the top.
259
+ *
260
+ * Idempotent on identical input is NOT a contract: each call appends.
261
+ * The wizard's 422 path is a one-shot per preview attempt, so duplicate
262
+ * issues only matter if a caller invokes this twice without the user
263
+ * round-tripping through the TUI in between.
264
+ */
265
+ export async function patchReviewStateWithValidationErrors(sessionId, errors, opts = {}) {
266
+ const artifactsRoot = opts.artifactsRoot ?? getRefineArtifactsRoot();
267
+ const paths = await getRefineSessionPaths(sessionId, artifactsRoot);
268
+ let snapshot;
269
+ try {
270
+ await access(paths.statePath);
271
+ snapshot = JSON.parse(await readFile(paths.statePath, 'utf8'));
272
+ }
273
+ catch {
274
+ snapshot = await loadAndValidateForReview(sessionId, undefined);
275
+ snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
276
+ }
277
+ const errorsByName = new Map();
278
+ for (const err of errors) {
279
+ const list = errorsByName.get(err.componentName) ?? [];
280
+ list.push(err);
281
+ errorsByName.set(err.componentName, list);
282
+ }
283
+ const patchedNames = [];
284
+ const knownNames = new Set(snapshot.components.map((c) => c.name));
285
+ const components = snapshot.components.map((c) => {
286
+ const matched = errorsByName.get(c.name);
287
+ if (!matched || matched.length === 0)
288
+ return c;
289
+ patchedNames.push(c.name);
290
+ const newIssues = matched.map((err) => ({
291
+ severity: 'error',
292
+ code: 'SERVER_VALIDATION_FAILED',
293
+ message: err.message,
294
+ }));
295
+ const existing = c.originalProposal.validationIssues ?? [];
296
+ return {
297
+ ...c,
298
+ originalProposal: {
299
+ ...c.originalProposal,
300
+ validationIssues: [...existing, ...newIssues],
301
+ },
302
+ };
303
+ });
304
+ const missingNames = [...errorsByName.keys()].filter((n) => !knownNames.has(n));
305
+ await saveReviewState(paths.statePath, { ...snapshot, components });
306
+ return { patchedNames, missingNames };
307
+ }
308
+ /**
309
+ * Exclude components whose names appear in `names` from the next preview/push.
310
+ * Used by the wizard's "skip and retry" path after a 422.
311
+ *
312
+ * Two stores are updated:
313
+ * - Pipeline DB (`raw_components.status` → `'generate-rejected'`) so that
314
+ * `loadCDFComponents()` excludes them from the next manifest. This is the
315
+ * load-bearing change — preview reads from the DB, not the JSON file.
316
+ * - Review state JSON (`status` → `'rejected'`) so that if the user re-enters
317
+ * the analyze-select TUI later, the rejected status is reflected.
318
+ *
319
+ * `'generate-rejected'` matches the existing convention for "component was
320
+ * generated successfully but later excluded" (see GenerateReviewStep) — the
321
+ * component generated fine locally but failed server-side validation.
322
+ */
323
+ export async function rejectComponentsByName(sessionId, names, opts = {}) {
324
+ if (names.length === 0)
325
+ return;
326
+ const db = openPipelineDb();
327
+ try {
328
+ const placeholders = names.map(() => '?').join(',');
329
+ db.prepare(`UPDATE raw_components SET status = 'generate-rejected' WHERE session_id = ? AND name IN (${placeholders})`).run(sessionId, ...names);
330
+ }
331
+ finally {
332
+ db.close();
333
+ }
334
+ const artifactsRoot = opts.artifactsRoot ?? getRefineArtifactsRoot();
335
+ const paths = await getRefineSessionPaths(sessionId, artifactsRoot);
336
+ const nameSet = new Set(names);
337
+ let snapshot;
338
+ try {
339
+ snapshot = JSON.parse(await readFile(paths.statePath, 'utf8'));
340
+ }
341
+ catch {
342
+ return;
343
+ }
344
+ const components = snapshot.components.map((c) => (nameSet.has(c.name) ? { ...c, status: 'rejected' } : c));
345
+ await saveReviewState(paths.statePath, { ...snapshot, components });
346
+ }
161
347
  function resolveSessionId(sessionFlag) {
162
348
  if (sessionFlag)
163
349
  return sessionFlag;
@@ -194,8 +380,9 @@ export function registerAnalyzeEditCommand(program) {
194
380
  .option('--accept-all', 'Alias for --select-all', false)
195
381
  .option('--reject <pattern>', 'Alias for --deselect <pattern> (repeatable)', collect, [])
196
382
  .option('--patch <path>', 'Path to a JSON patch file for structured component overrides')
197
- .option('--exclude-invalid', 'Auto-reject components with validation errors when bulk-selecting (no-op without --select-all)')
198
- .action(async ({ session: sessionFlag, projectRoot, acceptAll, selectAll, reject, deselect, select, patch, excludeInvalid, }) => {
383
+ .option('--exclude-invalid', 'With --select-all: auto-reject components with validation errors instead of failing loud (opt-in bypass for the gate)')
384
+ .option('--exclude-components <names>', 'Comma-separated component names to force-reject, regardless of other selection flags')
385
+ .action(async ({ session: sessionFlag, projectRoot, acceptAll, selectAll, reject, deselect, select, patch, excludeInvalid, excludeComponents, }) => {
199
386
  const sessionId = resolveSessionId(sessionFlag);
200
387
  const db = openPipelineDb();
201
388
  let rawComponentCount = 0;
@@ -216,7 +403,8 @@ export function registerAnalyzeEditCommand(program) {
216
403
  (reject ?? []).length > 0 ||
217
404
  (deselect ?? []).length > 0 ||
218
405
  (select ?? []).length > 0 ||
219
- !!patch;
406
+ !!patch ||
407
+ !!excludeComponents;
220
408
  let paths;
221
409
  let snapshot;
222
410
  try {
@@ -237,7 +425,9 @@ export function registerAnalyzeEditCommand(program) {
237
425
  // Non-interactive path
238
426
  if (nonInteractive) {
239
427
  const stepDb = openPipelineDb();
240
- const stepId = createStep(stepDb, sessionId, 'analyze select', { sessionId });
428
+ const stepId = createStep(stepDb, sessionId, 'analyze select', {
429
+ sessionId,
430
+ });
241
431
  try {
242
432
  await runNonInteractive(snapshot, {
243
433
  session: sessionFlag,
@@ -249,6 +439,7 @@ export function registerAnalyzeEditCommand(program) {
249
439
  select,
250
440
  patch,
251
441
  excludeInvalid,
442
+ excludeComponents,
252
443
  }, paths, sessionId);
253
444
  updateStep(stepDb, stepId, 'complete', { sessionId });
254
445
  }
@@ -317,8 +317,7 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
317
317
  onToggleHelp: () => setShowHelp((prev) => !prev),
318
318
  });
319
319
  // Must be before early returns — Rules of Hooks
320
- const sessionSummary = useMemo(() => sortComponentsForSidebar((session?.components ?? [])
321
- .map((c) => {
320
+ const sessionSummary = useMemo(() => sortComponentsForSidebar((session?.components ?? []).map((c) => {
322
321
  const counts = countValidationIssues(c.originalProposal);
323
322
  return {
324
323
  id: c.id,
@@ -330,13 +329,6 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
330
329
  validationErrorCount: counts.errors,
331
330
  validationWarningCount: counts.warnings,
332
331
  };
333
- })
334
- .sort((a, b) => {
335
- const aF = a.needsReview && a.status === 'needs-review' ? 0 : 1;
336
- const bF = b.needsReview && b.status === 'needs-review' ? 0 : 1;
337
- if (aF !== bF)
338
- return aF - bF;
339
- return (a.extractionConfidence ?? 6) - (b.extractionConfidence ?? 6);
340
332
  })), [session?.components, previewAnnotations]);
341
333
  if (loading) {
342
334
  return _jsx(Text, { children: "Loading session..." });
@@ -11,7 +11,7 @@ type SidebarProps = {
11
11
  collapsed?: boolean;
12
12
  width?: number;
13
13
  };
14
- export declare function statusIcon(status: ReviewComponentStatus, validationErrorCount: number, validationWarningCount: number): string;
14
+ export declare function statusIcon(status: ReviewComponentStatus, validationErrorCount: number, _validationWarningCount: number): string;
15
15
  export declare function statusColor(status: ReviewComponentStatus, validationErrorCount: number, validationWarningCount: number): string;
16
16
  export declare function sortComponentsForSidebar(components: ReviewComponentSummary[]): ReviewComponentSummary[];
17
17
  export declare function Sidebar({ components, selectedId, focused, scrollOffset, visibleCount, collapsed, width: widthProp, }: SidebarProps): React.ReactElement;
@@ -1,7 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- export function statusIcon(status, validationErrorCount, validationWarningCount) {
4
- if (validationErrorCount > 0 || validationWarningCount > 0)
3
+ export function statusIcon(status, validationErrorCount,
4
+ // Warnings deliberately do NOT override the icon — the user's accept/reject
5
+ // decision must remain visible. Color is already yellow via statusColor for
6
+ // warning-only components, so the warning cue is preserved.
7
+ _validationWarningCount) {
8
+ // Errors override — a structurally broken component should never render as ✓/✗.
9
+ if (validationErrorCount > 0)
5
10
  return '⚠';
6
11
  switch (status) {
7
12
  case 'accepted':
@@ -35,11 +40,29 @@ function truncateName(name, maxLen) {
35
40
  return name;
36
41
  return name.slice(0, maxLen) + '…';
37
42
  }
43
+ /**
44
+ * Sort key for stable secondary ordering within a validation tier:
45
+ * needs-review components first, then by extractionConfidence ascending
46
+ * (lowest confidence first, null treated as highest = last). Used to be
47
+ * applied inline in App.tsx, then this helper sorted again — pulled in
48
+ * here so the sort happens once.
49
+ */
50
+ function withinTierComparator(a, b) {
51
+ const aPending = a.needsReview && a.status === 'needs-review' ? 0 : 1;
52
+ const bPending = b.needsReview && b.status === 'needs-review' ? 0 : 1;
53
+ if (aPending !== bPending)
54
+ return aPending - bPending;
55
+ return (a.extractionConfidence ?? 6) - (b.extractionConfidence ?? 6);
56
+ }
38
57
  export function sortComponentsForSidebar(components) {
39
58
  const withErrors = components.filter((c) => c.validationErrorCount > 0);
40
59
  const withWarnings = components.filter((c) => c.validationErrorCount === 0 && c.validationWarningCount > 0);
41
60
  const clean = components.filter((c) => c.validationErrorCount === 0 && c.validationWarningCount === 0);
42
- return [...withErrors, ...withWarnings, ...clean];
61
+ return [
62
+ ...[...withErrors].sort(withinTierComparator),
63
+ ...[...withWarnings].sort(withinTierComparator),
64
+ ...[...clean].sort(withinTierComparator),
65
+ ];
43
66
  }
44
67
  export function Sidebar({ components, selectedId, focused, scrollOffset, visibleCount, collapsed = false, width: widthProp, }) {
45
68
  const sorted = sortComponentsForSidebar(components);