@contentful/experience-design-system-cli 2.7.4 → 2.11.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 (38) 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 +16 -11
  5. package/dist/src/analyze/extract/validate.d.ts +16 -0
  6. package/dist/src/analyze/extract/validate.js +94 -0
  7. package/dist/src/analyze/select/command.d.ts +47 -0
  8. package/dist/src/analyze/select/command.js +238 -14
  9. package/dist/src/analyze/select/tui/App.js +15 -17
  10. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +4 -1
  11. package/dist/src/analyze/select/tui/components/Sidebar.js +42 -6
  12. package/dist/src/analyze/select/types.d.ts +6 -0
  13. package/dist/src/analyze/select/types.js +24 -7
  14. package/dist/src/analyze/select-agent/command.js +43 -3
  15. package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
  16. package/dist/src/analyze/tui/AnalyzeView.js +5 -2
  17. package/dist/src/apply/api-client.d.ts +20 -0
  18. package/dist/src/apply/api-client.js +71 -5
  19. package/dist/src/generate/command.js +21 -2
  20. package/dist/src/import/command.js +2 -0
  21. package/dist/src/import/orchestrator.d.ts +22 -0
  22. package/dist/src/import/orchestrator.js +97 -14
  23. package/dist/src/import/tui/WizardApp.d.ts +13 -0
  24. package/dist/src/import/tui/WizardApp.js +224 -52
  25. package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
  26. package/dist/src/import/tui/steps/GateStep.js +4 -2
  27. package/dist/src/import/tui/steps/GenerateReviewStep.js +2 -0
  28. package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
  29. package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
  30. package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
  31. package/dist/src/import/tui/wizard-422-helpers.js +60 -0
  32. package/dist/src/program.d.ts +17 -0
  33. package/dist/src/program.js +27 -4
  34. package/dist/src/session/db.d.ts +13 -0
  35. package/dist/src/session/db.js +50 -0
  36. package/dist/src/types.d.ts +8 -0
  37. package/package.json +2 -2
  38. 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.4",
3
+ "version": "2.11.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
+ }
@@ -11,6 +11,8 @@ import { preClassifyComponent } from './pre-classify.js';
11
11
  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
+ import { validateExtractedComponents } from './extract/validate.js';
15
+ import { buildAnalyzeViewRows } from './build-analyze-view-rows.js';
14
16
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
15
17
  const IGNORED_DIRECTORY_NAMES = new Set([
16
18
  '.git',
@@ -168,25 +170,20 @@ export function registerAnalyzeCommand(program) {
168
170
  needsReview: deriveNeedsReview(confidence) || inspection.wrapperConfidence >= 4 || inspection.keepDespiteZeroSurface,
169
171
  });
170
172
  }
171
- storeRawComponents(db, sessionId, filteredComponents);
173
+ const validatedComponents = validateExtractedComponents(filteredComponents);
174
+ storeRawComponents(db, sessionId, validatedComponents);
172
175
  storeScannedFiles(db, sessionId, sourceFiles.map((f) => relative(projectRoot, f)));
173
176
  updateStep(db, stepId, 'complete', { sessionId });
174
177
  db.close();
175
178
  const allWarnings = [...extraction.warnings, ...filterWarnings];
179
+ const { rows: componentRows, totalErrors } = buildAnalyzeViewRows(filteredComponents, validatedComponents, allWarnings);
176
180
  const analyzeResult = {
177
181
  sourceDirectory,
178
182
  sessionId,
179
183
  fileCount: sourceFiles.length,
180
- components: filteredComponents.map((c) => ({
181
- name: c.name,
182
- framework: c.framework,
183
- propCount: c.props.length,
184
- slotCount: c.slots.length,
185
- warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
186
- extractionConfidence: c.extractionConfidence ?? null,
187
- needsReview: c.needsReview ?? false,
188
- })),
184
+ components: componentRows,
189
185
  totalWarnings: allWarnings.length,
186
+ totalErrors,
190
187
  };
191
188
  if (process.stdout.isTTY) {
192
189
  const { waitUntilExit } = render(createElement(AnalyzeView, {
@@ -202,11 +199,19 @@ export function registerAnalyzeCommand(program) {
202
199
  `Scanned ${pluralize(sourceFiles.length, 'source file')} in ${sourceDirectory}`,
203
200
  `Extracted ${pluralize(extraction.components.length, 'component')}`,
204
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
+ }
205
210
  if (allWarnings.length > 0) {
206
211
  summaryLines.push(`Warnings (${allWarnings.length}):`);
207
212
  summaryLines.push(...allWarnings.map((w) => `- ${w}`));
208
213
  }
209
- else {
214
+ else if (totalErrors === 0) {
210
215
  summaryLines.push('Warnings: none');
211
216
  }
212
217
  process.stderr.write(summaryLines.join('\n') + '\n');
@@ -0,0 +1,16 @@
1
+ import type { RawComponentDefinition, ExtractionValidationIssue } from '../../types.js';
2
+ export type { ExtractionValidationIssue } from '../../types.js';
3
+ export declare function validateExtractedComponents(components: RawComponentDefinition[]): RawComponentDefinition[];
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;
@@ -0,0 +1,94 @@
1
+ export function validateExtractedComponents(components) {
2
+ const nameCounts = new Map();
3
+ for (const component of components) {
4
+ const key = component.name.trim();
5
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
6
+ }
7
+ return components.map((component) => {
8
+ const issues = [];
9
+ if (!component.name.trim()) {
10
+ issues.push({
11
+ severity: 'error',
12
+ code: 'EMPTY_COMPONENT_NAME',
13
+ message: 'Component name must not be empty',
14
+ });
15
+ }
16
+ for (let i = 0; i < component.props.length; i++) {
17
+ if (!component.props[i].name.trim()) {
18
+ issues.push({
19
+ severity: 'error',
20
+ code: 'EMPTY_PROP_NAME',
21
+ message: `Prop at index ${i} has an empty name`,
22
+ field: `props[${i}].name`,
23
+ });
24
+ }
25
+ }
26
+ for (let i = 0; i < component.slots.length; i++) {
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.
33
+ issues.push({
34
+ severity: 'warning',
35
+ code: 'EMPTY_SLOT_NAME',
36
+ message: `Slot at index ${i} has an empty name`,
37
+ field: `slots[${i}].name`,
38
+ });
39
+ }
40
+ }
41
+ const propNames = new Set(component.props.map((p) => p.name.trim()).filter(Boolean));
42
+ for (let i = 0; i < component.slots.length; i++) {
43
+ const slotName = component.slots[i].name.trim();
44
+ if (slotName && propNames.has(slotName)) {
45
+ issues.push({
46
+ severity: 'error',
47
+ code: 'PROP_SLOT_NAME_COLLISION',
48
+ message: `"${slotName}" is used as both a prop name and a slot name`,
49
+ field: `slots[${i}].name`,
50
+ });
51
+ }
52
+ }
53
+ const nameKey = component.name.trim();
54
+ if (nameKey && (nameCounts.get(nameKey) ?? 0) > 1) {
55
+ issues.push({
56
+ severity: 'error',
57
+ code: 'DUPLICATE_COMPONENT_NAME',
58
+ message: `Component name "${component.name}" appears more than once in the extracted set`,
59
+ });
60
+ }
61
+ if (component.props.length === 0 && component.slots.length === 0) {
62
+ issues.push({
63
+ severity: 'warning',
64
+ code: 'EMPTY_COMPONENT',
65
+ message: 'Component has no props or slots and will be filtered out during generation',
66
+ });
67
+ }
68
+ return { ...component, validationIssues: issues };
69
+ });
70
+ }
71
+ export function shouldExcludeDueToValidation(component) {
72
+ return (component.validationIssues ?? []).some((i) => i.severity === 'error');
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,2 +1,49 @@
1
1
  import type { Command } from 'commander';
2
+ import type { ReviewSessionSnapshot } from './types.js';
3
+ import type { PreviewValidationError } from '../../apply/api-client.js';
4
+ /**
5
+ * Load components from the pipeline DB and re-run extraction validation.
6
+ *
7
+ * `validationIssues` is intentionally not persisted (the validator is pure
8
+ * and cheap to re-run), so any cold-start of `analyze select` from a prior
9
+ * `analyze extract` session needs to recompute it before building the
10
+ * review snapshot — otherwise the TUI sees no validation errors at all.
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>;
2
49
  export declare function registerAnalyzeEditCommand(program: Command): void;
@@ -1,11 +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, formatExclusionWarning, } from '../extract/validate.js';
9
10
  const SAFE_PATH_RE = /^[a-zA-Z0-9_.$[\]=]+$/;
10
11
  const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
11
12
  function applyDotPath(obj, path, value) {
@@ -72,10 +73,66 @@ function applyPatch(snapshot, ops) {
72
73
  return { ...snapshot, components };
73
74
  }
74
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
+ }
75
107
  let result = { ...snapshot };
76
108
  const rejectPatterns = [...(opts.reject ?? []), ...(opts.deselect ?? [])].map((p) => p.toLowerCase());
77
109
  const selectPatterns = (opts.select ?? []).map((p) => p.toLowerCase());
78
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
+ }
79
136
  if (selectAll || rejectPatterns.length > 0 || selectPatterns.length > 0) {
80
137
  result = {
81
138
  ...result,
@@ -84,6 +141,9 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
84
141
  if (rejectPatterns.some((p) => nameLower.includes(p))) {
85
142
  return { ...c, status: 'rejected' };
86
143
  }
144
+ if (selectAll && opts.excludeInvalid && shouldExcludeDueToValidation(c.originalProposal)) {
145
+ return { ...c, status: 'rejected' };
146
+ }
87
147
  if (selectAll || selectPatterns.some((p) => nameLower.includes(p))) {
88
148
  return { ...c, status: 'accepted' };
89
149
  }
@@ -91,6 +151,21 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
91
151
  }),
92
152
  };
93
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
+ }
94
169
  // Apply --patch
95
170
  if (opts.patch) {
96
171
  let patchOps;
@@ -120,10 +195,31 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
120
195
  }
121
196
  const accepted = result.components.filter((c) => c.status === 'accepted');
122
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
+ }
123
208
  // Persist decisions to session state so pipeline orchestrator can read them
124
209
  await saveReviewState(paths.statePath, result);
125
- // Sync edited proposals back to the DB so generation uses the user's edits
126
- 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) {
127
223
  const db = openPipelineDb();
128
224
  try {
129
225
  storeRawComponents(db, sessionId, accepted.map((c) => c.editedProposal));
@@ -134,6 +230,120 @@ async function runNonInteractive(snapshot, opts, paths, sessionId) {
134
230
  }
135
231
  process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length}\n`);
136
232
  }
233
+ /**
234
+ * Load components from the pipeline DB and re-run extraction validation.
235
+ *
236
+ * `validationIssues` is intentionally not persisted (the validator is pure
237
+ * and cheap to re-run), so any cold-start of `analyze select` from a prior
238
+ * `analyze extract` session needs to recompute it before building the
239
+ * review snapshot — otherwise the TUI sees no validation errors at all.
240
+ */
241
+ export async function loadAndValidateForReview(sessionId, projectRoot) {
242
+ const db = openPipelineDb();
243
+ let rawComponents;
244
+ try {
245
+ rawComponents = loadRawComponents(db, sessionId);
246
+ }
247
+ finally {
248
+ db.close();
249
+ }
250
+ const validatedComponents = validateExtractedComponents(rawComponents);
251
+ return loadReviewInput(validatedComponents, { reviewRoot: projectRoot });
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
+ }
137
347
  function resolveSessionId(sessionFlag) {
138
348
  if (sessionFlag)
139
349
  return sessionFlag;
@@ -170,17 +380,19 @@ export function registerAnalyzeEditCommand(program) {
170
380
  .option('--accept-all', 'Alias for --select-all', false)
171
381
  .option('--reject <pattern>', 'Alias for --deselect <pattern> (repeatable)', collect, [])
172
382
  .option('--patch <path>', 'Path to a JSON patch file for structured component overrides')
173
- .action(async ({ session: sessionFlag, projectRoot, acceptAll, selectAll, reject, deselect, select, patch, }) => {
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, }) => {
174
386
  const sessionId = resolveSessionId(sessionFlag);
175
387
  const db = openPipelineDb();
176
- let rawComponents;
388
+ let rawComponentCount = 0;
177
389
  try {
178
- rawComponents = loadRawComponents(db, sessionId);
390
+ rawComponentCount = loadRawComponents(db, sessionId).length;
179
391
  }
180
392
  finally {
181
393
  db.close();
182
394
  }
183
- if (rawComponents.length === 0) {
395
+ if (rawComponentCount === 0) {
184
396
  process.stderr.write(`Error: session '${sessionId}' has no raw components. Run analyze extract first.\n`);
185
397
  process.exit(1);
186
398
  return;
@@ -191,13 +403,12 @@ export function registerAnalyzeEditCommand(program) {
191
403
  (reject ?? []).length > 0 ||
192
404
  (deselect ?? []).length > 0 ||
193
405
  (select ?? []).length > 0 ||
194
- !!patch;
406
+ !!patch ||
407
+ !!excludeComponents;
195
408
  let paths;
196
409
  let snapshot;
197
410
  try {
198
- snapshot = await loadReviewInput(rawComponents, {
199
- reviewRoot: projectRoot,
200
- });
411
+ snapshot = await loadAndValidateForReview(sessionId, projectRoot);
201
412
  paths = await getRefineSessionPaths(sessionId, artifactsRoot);
202
413
  if (!nonInteractive) {
203
414
  snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
@@ -214,9 +425,22 @@ export function registerAnalyzeEditCommand(program) {
214
425
  // Non-interactive path
215
426
  if (nonInteractive) {
216
427
  const stepDb = openPipelineDb();
217
- const stepId = createStep(stepDb, sessionId, 'analyze select', { sessionId });
428
+ const stepId = createStep(stepDb, sessionId, 'analyze select', {
429
+ sessionId,
430
+ });
218
431
  try {
219
- await runNonInteractive(snapshot, { session: sessionFlag, projectRoot, acceptAll, selectAll, reject, deselect, select, patch }, paths, sessionId);
432
+ await runNonInteractive(snapshot, {
433
+ session: sessionFlag,
434
+ projectRoot,
435
+ acceptAll,
436
+ selectAll,
437
+ reject,
438
+ deselect,
439
+ select,
440
+ patch,
441
+ excludeInvalid,
442
+ excludeComponents,
443
+ }, paths, sessionId);
220
444
  updateStep(stepDb, stepId, 'complete', { sessionId });
221
445
  }
222
446
  catch (err) {