@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.
- package/dist/package.json +1 -1
- package/dist/src/analyze/build-analyze-view-rows.d.ts +23 -0
- package/dist/src/analyze/build-analyze-view-rows.js +39 -0
- package/dist/src/analyze/command.js +16 -11
- package/dist/src/analyze/extract/validate.d.ts +16 -0
- package/dist/src/analyze/extract/validate.js +94 -0
- package/dist/src/analyze/select/command.d.ts +47 -0
- package/dist/src/analyze/select/command.js +238 -14
- package/dist/src/analyze/select/tui/App.js +15 -17
- package/dist/src/analyze/select/tui/components/Sidebar.d.ts +4 -1
- package/dist/src/analyze/select/tui/components/Sidebar.js +42 -6
- package/dist/src/analyze/select/types.d.ts +6 -0
- package/dist/src/analyze/select/types.js +24 -7
- package/dist/src/analyze/select-agent/command.js +43 -3
- package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
- package/dist/src/analyze/tui/AnalyzeView.js +5 -2
- package/dist/src/apply/api-client.d.ts +20 -0
- package/dist/src/apply/api-client.js +71 -5
- package/dist/src/generate/command.js +21 -2
- package/dist/src/import/command.js +2 -0
- package/dist/src/import/orchestrator.d.ts +22 -0
- package/dist/src/import/orchestrator.js +97 -14
- package/dist/src/import/tui/WizardApp.d.ts +13 -0
- package/dist/src/import/tui/WizardApp.js +224 -52
- package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
- package/dist/src/import/tui/steps/GateStep.js +4 -2
- package/dist/src/import/tui/steps/GenerateReviewStep.js +2 -0
- package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
- package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
- package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
- package/dist/src/import/tui/wizard-422-helpers.js +60 -0
- package/dist/src/program.d.ts +17 -0
- package/dist/src/program.js +27 -4
- package/dist/src/session/db.d.ts +13 -0
- package/dist/src/session/db.js +50 -0
- package/dist/src/types.d.ts +8 -0
- package/package.json +2 -2
- package/skills/generate-components.md +2 -0
package/dist/package.json
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
388
|
+
let rawComponentCount = 0;
|
|
177
389
|
try {
|
|
178
|
-
|
|
390
|
+
rawComponentCount = loadRawComponents(db, sessionId).length;
|
|
179
391
|
}
|
|
180
392
|
finally {
|
|
181
393
|
db.close();
|
|
182
394
|
}
|
|
183
|
-
if (
|
|
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
|
|
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', {
|
|
428
|
+
const stepId = createStep(stepDb, sessionId, 'analyze select', {
|
|
429
|
+
sessionId,
|
|
430
|
+
});
|
|
218
431
|
try {
|
|
219
|
-
await runNonInteractive(snapshot, {
|
|
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) {
|