@contentful/experience-design-system-cli 2.7.3 → 2.7.4-dev-build-595febb.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/analyze/command.js +37 -7
- package/dist/src/analyze/extract/scoring.d.ts +5 -1
- package/dist/src/analyze/extract/scoring.js +7 -3
- package/dist/src/analyze/extract/source-inspection.d.ts +13 -0
- package/dist/src/analyze/extract/source-inspection.js +189 -0
- package/dist/src/analyze/select-agent/command.js +115 -27
- package/dist/src/analyze/select-agent/context-builder.d.ts +50 -0
- package/dist/src/analyze/select-agent/context-builder.js +197 -0
- package/dist/src/session/db.d.ts +2 -0
- package/dist/src/session/db.js +26 -0
- package/package.json +2 -2
- package/skills/select-components.md +48 -19
package/dist/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { render } from 'ink';
|
|
3
3
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
4
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { extractComponents } from './extract/pipeline.js';
|
|
6
6
|
import { AnalyzeView } from './tui/AnalyzeView.js';
|
|
7
7
|
import { registerAnalyzeEditCommand } from './select/command.js';
|
|
8
8
|
import { registerAnalyzeSelectAgentCommand } from './select-agent/command.js';
|
|
9
|
-
import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents } from '../session/db.js';
|
|
9
|
+
import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents, storeScannedFiles, } from '../session/db.js';
|
|
10
10
|
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
|
+
import { describeReviewReasons, inspectComponentSource } from './extract/source-inspection.js';
|
|
13
14
|
const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
|
|
14
15
|
const IGNORED_DIRECTORY_NAMES = new Set([
|
|
15
16
|
'.git',
|
|
@@ -46,6 +47,13 @@ function pluralize(count, singular, plural = `${singular}s`) {
|
|
|
46
47
|
function resolveFromProjectRoot(projectRoot, inputPath) {
|
|
47
48
|
return isAbsolute(inputPath) ? inputPath : resolve(projectRoot, inputPath);
|
|
48
49
|
}
|
|
50
|
+
function wrapperConfidenceToIssueCount(confidence) {
|
|
51
|
+
if (confidence >= 4)
|
|
52
|
+
return 2;
|
|
53
|
+
if (confidence === 3)
|
|
54
|
+
return 1;
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
49
57
|
async function pathExists(path) {
|
|
50
58
|
return Boolean(await stat(path).catch(() => null));
|
|
51
59
|
}
|
|
@@ -121,25 +129,47 @@ export function registerAnalyzeCommand(program) {
|
|
|
121
129
|
inputPath: projectRoot,
|
|
122
130
|
outDir,
|
|
123
131
|
});
|
|
124
|
-
const stepId = createStep(db, sessionId, 'analyze extract', {
|
|
132
|
+
const stepId = createStep(db, sessionId, 'analyze extract', {
|
|
133
|
+
project: projectRoot,
|
|
134
|
+
});
|
|
125
135
|
const classifiedComponents = extraction.components.map(preClassifyComponent);
|
|
136
|
+
const inspectedComponents = await Promise.all(classifiedComponents.map(async (component) => ({
|
|
137
|
+
component,
|
|
138
|
+
inspection: await inspectComponentSource(component),
|
|
139
|
+
})));
|
|
126
140
|
const filteredComponents = [];
|
|
127
141
|
const filterWarnings = [];
|
|
128
|
-
for (const component of
|
|
142
|
+
for (const { component, inspection } of inspectedComponents) {
|
|
129
143
|
const verdict = isNonAuthorableComponent(component);
|
|
130
|
-
|
|
144
|
+
const keepDespiteZeroSurface = verdict.skip && verdict.reason === 'component has no props and no slots' && inspection.keepDespiteZeroSurface;
|
|
145
|
+
if (verdict.skip && !keepDespiteZeroSurface) {
|
|
131
146
|
filterWarnings.push(`Skipped non-authorable component: ${component.name} (${verdict.reason})`);
|
|
132
147
|
continue;
|
|
133
148
|
}
|
|
134
|
-
|
|
149
|
+
if (keepDespiteZeroSurface) {
|
|
150
|
+
filterWarnings.push(`${component.name}: retained despite 0 props/slots because the source renders visible or compositional UI`);
|
|
151
|
+
}
|
|
152
|
+
if (inspection.reviewReasons.length > 0) {
|
|
153
|
+
const reviewNotes = describeReviewReasons(inspection.reviewReasons)
|
|
154
|
+
.filter((note) => note !== 'high-confidence data-fetch wrapper')
|
|
155
|
+
.join('; ');
|
|
156
|
+
if (reviewNotes) {
|
|
157
|
+
filterWarnings.push(`${component.name}: ${reviewNotes}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const { confidence, reasons } = computeExtractionScore(component, {
|
|
161
|
+
additionalIssueCount: wrapperConfidenceToIssueCount(inspection.wrapperConfidence),
|
|
162
|
+
additionalReasons: inspection.reviewReasons,
|
|
163
|
+
});
|
|
135
164
|
filteredComponents.push({
|
|
136
165
|
...component,
|
|
137
166
|
extractionConfidence: confidence,
|
|
138
167
|
reviewReasons: reasons,
|
|
139
|
-
needsReview: deriveNeedsReview(confidence),
|
|
168
|
+
needsReview: deriveNeedsReview(confidence) || inspection.wrapperConfidence >= 4 || inspection.keepDespiteZeroSurface,
|
|
140
169
|
});
|
|
141
170
|
}
|
|
142
171
|
storeRawComponents(db, sessionId, filteredComponents);
|
|
172
|
+
storeScannedFiles(db, sessionId, sourceFiles.map((f) => relative(projectRoot, f)));
|
|
143
173
|
updateStep(db, stepId, 'complete', { sessionId });
|
|
144
174
|
db.close();
|
|
145
175
|
const allWarnings = [...extraction.warnings, ...filterWarnings];
|
|
@@ -4,5 +4,9 @@ export type ExtractionScore = {
|
|
|
4
4
|
confidence: ExtractionConfidence;
|
|
5
5
|
reasons: string[];
|
|
6
6
|
};
|
|
7
|
-
export
|
|
7
|
+
export interface ExtractionScoreOptions {
|
|
8
|
+
additionalIssueCount?: number;
|
|
9
|
+
additionalReasons?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function computeExtractionScore(component: RawComponentDefinition, options?: ExtractionScoreOptions): ExtractionScore;
|
|
8
12
|
export declare function deriveNeedsReview(confidence: ExtractionConfidence): boolean;
|
|
@@ -44,7 +44,7 @@ function isWidePrimitiveUnion(type) {
|
|
|
44
44
|
return baseCount >= 3 || (baseCount >= 2 && nullabilityCount > 0 && baseCount + nullabilityCount >= 3);
|
|
45
45
|
}
|
|
46
46
|
// Count the number of issues found for scoring
|
|
47
|
-
function countIssues(component) {
|
|
47
|
+
function countIssues(component, options = {}) {
|
|
48
48
|
let count = 0;
|
|
49
49
|
const reasons = [];
|
|
50
50
|
if (component.props.length === 0 && component.slots.length === 0) {
|
|
@@ -72,6 +72,10 @@ function countIssues(component) {
|
|
|
72
72
|
break;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
count += options.additionalIssueCount ?? 0;
|
|
76
|
+
if (options.additionalReasons && options.additionalReasons.length > 0) {
|
|
77
|
+
reasons.push(...options.additionalReasons);
|
|
78
|
+
}
|
|
75
79
|
return { count, reasons: [...new Set(reasons)] };
|
|
76
80
|
}
|
|
77
81
|
// Maps issue count to a 1–5 confidence scale:
|
|
@@ -91,8 +95,8 @@ function issueCountToConfidence(count) {
|
|
|
91
95
|
return 2;
|
|
92
96
|
return 1;
|
|
93
97
|
}
|
|
94
|
-
export function computeExtractionScore(component) {
|
|
95
|
-
const { count, reasons } = countIssues(component);
|
|
98
|
+
export function computeExtractionScore(component, options = {}) {
|
|
99
|
+
const { count, reasons } = countIssues(component, options);
|
|
96
100
|
return {
|
|
97
101
|
confidence: issueCountToConfidence(count),
|
|
98
102
|
reasons,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RawComponentDefinition } from '../../types.js';
|
|
2
|
+
export declare const HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON = "data-fetch-wrapper";
|
|
3
|
+
export declare const POSSIBLE_DATA_FETCH_WRAPPER_REASON = "possible-data-fetch-wrapper";
|
|
4
|
+
export declare const ZERO_SURFACE_RENDERED_UI_REASON = "zero-surface:rendered-ui";
|
|
5
|
+
export interface ComponentSourceInspection {
|
|
6
|
+
wrapperConfidence: 0 | 1 | 2 | 3 | 4 | 5;
|
|
7
|
+
reviewReasons: string[];
|
|
8
|
+
keepDespiteZeroSurface: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function inspectComponentSource(component: RawComponentDefinition): Promise<ComponentSourceInspection>;
|
|
11
|
+
export declare function isDataWrapperReviewReason(reason: string): boolean;
|
|
12
|
+
export declare function describeReviewReason(reason: string): string;
|
|
13
|
+
export declare function describeReviewReasons(reasons: string[]): string[];
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
export const HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON = 'data-fetch-wrapper';
|
|
3
|
+
export const POSSIBLE_DATA_FETCH_WRAPPER_REASON = 'possible-data-fetch-wrapper';
|
|
4
|
+
export const ZERO_SURFACE_RENDERED_UI_REASON = 'zero-surface:rendered-ui';
|
|
5
|
+
const DATA_WRAPPER_REASON_PREFIX = 'data-wrapper:';
|
|
6
|
+
const INFRA_PROP_NAMES = new Set(['id', 'locale', 'preview', 'slug', 'topic', 'previousComponent', '__typename']);
|
|
7
|
+
const VISIBLE_UI_TAG_PATTERN = /<(?:[A-Z][A-Za-z0-9_.]*|div|span|section|main|article|header|footer|nav|aside|img|video|p|h[1-6]|ul|ol|li|button|input|textarea|select|form|label|table|tbody|thead|tr|td|th)\b/;
|
|
8
|
+
const GENERATED_IMPORT_PATTERN = /from\s+['"][^'"]*__generated[^'"]*['"]/;
|
|
9
|
+
const GENERATED_QUERY_HOOK_PATTERN = /\buse[A-Z][A-Za-z0-9]*(?:Lazy|Suspense)?Query\s*\(/;
|
|
10
|
+
const GQL_FILENAME_PATTERN = /(?:-gql|-ggl)\.[cm]?[jt]sx?$/i;
|
|
11
|
+
const LOADING_NULL_GUARD_PATTERN = /if\s*\([^)]*(?:isLoading|loading|!data|!\w+Collection|!\w+Item|!\w+)\s*[^)]*\)\s*return\s+null\b/;
|
|
12
|
+
const IMPORT_PATTERN = /import\s+(?:type\s+)?(.+?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
13
|
+
function escapeRegExp(value) {
|
|
14
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
}
|
|
16
|
+
function parseImportedNames(importClause) {
|
|
17
|
+
const trimmed = importClause.trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
return [];
|
|
20
|
+
const names = [];
|
|
21
|
+
const defaultAndNamed = trimmed.split(',').map((part) => part.trim());
|
|
22
|
+
for (const part of defaultAndNamed) {
|
|
23
|
+
if (!part)
|
|
24
|
+
continue;
|
|
25
|
+
if (part.startsWith('{') && part.endsWith('}')) {
|
|
26
|
+
for (const named of part
|
|
27
|
+
.slice(1, -1)
|
|
28
|
+
.split(',')
|
|
29
|
+
.map((item) => item.trim())
|
|
30
|
+
.filter(Boolean)) {
|
|
31
|
+
const [local] = named.split(/\s+as\s+/i);
|
|
32
|
+
if (local)
|
|
33
|
+
names.push(local.trim());
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const [local] = part.split(/\s+as\s+/i);
|
|
38
|
+
if (local)
|
|
39
|
+
names.push(local.trim());
|
|
40
|
+
}
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
43
|
+
function collectSiblingRendererImports(sourceText) {
|
|
44
|
+
const names = new Set();
|
|
45
|
+
for (const match of sourceText.matchAll(IMPORT_PATTERN)) {
|
|
46
|
+
const importClause = match[1]?.trim() ?? '';
|
|
47
|
+
const importPath = match[2]?.trim() ?? '';
|
|
48
|
+
if (!importPath.startsWith('./'))
|
|
49
|
+
continue;
|
|
50
|
+
if (importPath.includes('__generated'))
|
|
51
|
+
continue;
|
|
52
|
+
if (/-g(?:ql|gl)(?:$|\.)/i.test(importPath))
|
|
53
|
+
continue;
|
|
54
|
+
for (const name of parseImportedNames(importClause)) {
|
|
55
|
+
if (name)
|
|
56
|
+
names.add(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...names];
|
|
60
|
+
}
|
|
61
|
+
function hasSiblingForwardRender(sourceText, siblingImports) {
|
|
62
|
+
return siblingImports.some((name) => {
|
|
63
|
+
const renderPattern = new RegExp(`<${escapeRegExp(name)}\\b[\\s\\S]*?(?:/>|</${escapeRegExp(name)}>)`);
|
|
64
|
+
if (!renderPattern.test(sourceText))
|
|
65
|
+
return false;
|
|
66
|
+
const siblingSpreadPattern = new RegExp(`<${escapeRegExp(name)}\\b[^>]*\\{\\.\\.\\.(?!props\\b)[^}]+\\}`);
|
|
67
|
+
const propsPlusSiblingSpreadPattern = new RegExp(`<${escapeRegExp(name)}\\b[^>]*\\{\\.\\.\\.props\\}[^>]*\\{\\.\\.\\.(?!props\\b)[^}]+\\}`);
|
|
68
|
+
return siblingSpreadPattern.test(sourceText) || propsPlusSiblingSpreadPattern.test(sourceText);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function hasVisibleUiRender(sourceText) {
|
|
72
|
+
return /return\s*(?:\(|<)/.test(sourceText) && VISIBLE_UI_TAG_PATTERN.test(sourceText);
|
|
73
|
+
}
|
|
74
|
+
function infraPropNames(component) {
|
|
75
|
+
const props = component.props.map((prop) => prop.name);
|
|
76
|
+
return props.length > 0 && props.every((prop) => INFRA_PROP_NAMES.has(prop)) ? props : [];
|
|
77
|
+
}
|
|
78
|
+
function scoreToConfidence(score) {
|
|
79
|
+
if (score <= 0)
|
|
80
|
+
return 0;
|
|
81
|
+
if (score <= 2)
|
|
82
|
+
return 2;
|
|
83
|
+
if (score <= 4)
|
|
84
|
+
return 3;
|
|
85
|
+
if (score <= 6)
|
|
86
|
+
return 4;
|
|
87
|
+
return 5;
|
|
88
|
+
}
|
|
89
|
+
function dedupeReasons(reasons) {
|
|
90
|
+
return [...new Set(reasons)];
|
|
91
|
+
}
|
|
92
|
+
export async function inspectComponentSource(component) {
|
|
93
|
+
let sourceText = '';
|
|
94
|
+
try {
|
|
95
|
+
sourceText = await readFile(component.source, 'utf8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {
|
|
99
|
+
wrapperConfidence: 0,
|
|
100
|
+
reviewReasons: [],
|
|
101
|
+
keepDespiteZeroSurface: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const reviewReasons = [];
|
|
105
|
+
let wrapperScore = 0;
|
|
106
|
+
const hasGeneratedQueryHook = GENERATED_IMPORT_PATTERN.test(sourceText) && GENERATED_QUERY_HOOK_PATTERN.test(sourceText);
|
|
107
|
+
const infraProps = infraPropNames(component);
|
|
108
|
+
const siblingImports = collectSiblingRendererImports(sourceText);
|
|
109
|
+
if (GQL_FILENAME_PATTERN.test(component.source)) {
|
|
110
|
+
reviewReasons.push('data-wrapper:gql-filename');
|
|
111
|
+
wrapperScore += 1;
|
|
112
|
+
}
|
|
113
|
+
if (hasGeneratedQueryHook) {
|
|
114
|
+
reviewReasons.push('data-wrapper:generated-query-hook');
|
|
115
|
+
wrapperScore += 3;
|
|
116
|
+
}
|
|
117
|
+
// Require corroboration from a stronger signal before counting sibling imports —
|
|
118
|
+
// otherwise any composed component that imports two sub-components scores +1 here.
|
|
119
|
+
if (siblingImports.length > 0 && (hasGeneratedQueryHook || infraProps.length > 0)) {
|
|
120
|
+
reviewReasons.push('data-wrapper:sibling-renderer-import');
|
|
121
|
+
wrapperScore += 1;
|
|
122
|
+
}
|
|
123
|
+
if (hasSiblingForwardRender(sourceText, siblingImports)) {
|
|
124
|
+
reviewReasons.push('data-wrapper:fetch-forward-render');
|
|
125
|
+
wrapperScore += 3;
|
|
126
|
+
}
|
|
127
|
+
if (sourceText.includes('useContentfulLiveUpdates(') || sourceText.includes('useContentfulContext(')) {
|
|
128
|
+
reviewReasons.push('data-wrapper:contentful-runtime');
|
|
129
|
+
wrapperScore += 1;
|
|
130
|
+
}
|
|
131
|
+
if (infraProps.length > 0) {
|
|
132
|
+
reviewReasons.push('data-wrapper:infra-props');
|
|
133
|
+
wrapperScore += 2;
|
|
134
|
+
}
|
|
135
|
+
if (LOADING_NULL_GUARD_PATTERN.test(sourceText)) {
|
|
136
|
+
reviewReasons.push('data-wrapper:loading-null-guard');
|
|
137
|
+
wrapperScore += 1;
|
|
138
|
+
}
|
|
139
|
+
const wrapperConfidence = scoreToConfidence(wrapperScore);
|
|
140
|
+
if (wrapperConfidence >= 4) {
|
|
141
|
+
reviewReasons.unshift(HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON);
|
|
142
|
+
}
|
|
143
|
+
else if (wrapperConfidence === 3) {
|
|
144
|
+
reviewReasons.unshift(POSSIBLE_DATA_FETCH_WRAPPER_REASON);
|
|
145
|
+
}
|
|
146
|
+
const keepDespiteZeroSurface = component.props.length === 0 && component.slots.length === 0 && hasVisibleUiRender(sourceText);
|
|
147
|
+
if (keepDespiteZeroSurface) {
|
|
148
|
+
reviewReasons.push(ZERO_SURFACE_RENDERED_UI_REASON);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
wrapperConfidence,
|
|
152
|
+
reviewReasons: dedupeReasons(reviewReasons),
|
|
153
|
+
keepDespiteZeroSurface,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function isDataWrapperReviewReason(reason) {
|
|
157
|
+
return (reason === HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON ||
|
|
158
|
+
reason === POSSIBLE_DATA_FETCH_WRAPPER_REASON ||
|
|
159
|
+
reason.startsWith(DATA_WRAPPER_REASON_PREFIX));
|
|
160
|
+
}
|
|
161
|
+
export function describeReviewReason(reason) {
|
|
162
|
+
switch (reason) {
|
|
163
|
+
case HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON:
|
|
164
|
+
return 'high-confidence data-fetch wrapper';
|
|
165
|
+
case POSSIBLE_DATA_FETCH_WRAPPER_REASON:
|
|
166
|
+
return 'possible data-fetch wrapper';
|
|
167
|
+
case 'data-wrapper:gql-filename':
|
|
168
|
+
return 'source file follows a gql wrapper naming pattern';
|
|
169
|
+
case 'data-wrapper:generated-query-hook':
|
|
170
|
+
return 'imports and calls a generated query hook';
|
|
171
|
+
case 'data-wrapper:sibling-renderer-import':
|
|
172
|
+
return 'imports a sibling renderer from the same folder';
|
|
173
|
+
case 'data-wrapper:fetch-forward-render':
|
|
174
|
+
return 'forwards fetched data into a sibling renderer';
|
|
175
|
+
case 'data-wrapper:contentful-runtime':
|
|
176
|
+
return 'uses Contentful runtime hooks';
|
|
177
|
+
case 'data-wrapper:infra-props':
|
|
178
|
+
return 'only exposes infra-fetch props';
|
|
179
|
+
case 'data-wrapper:loading-null-guard':
|
|
180
|
+
return 'returns early while loading or when fetched data is missing';
|
|
181
|
+
case ZERO_SURFACE_RENDERED_UI_REASON:
|
|
182
|
+
return 'source renders visible/compositional UI despite zero extracted props and slots';
|
|
183
|
+
default:
|
|
184
|
+
return reason;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function describeReviewReasons(reasons) {
|
|
188
|
+
return dedupeReasons(reasons.map(describeReviewReason));
|
|
189
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { openPipelineDb, loadRawComponents, createStep, updateStep } from '../../session/db.js';
|
|
2
|
-
import { getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState, } from '../select/persistence.js';
|
|
1
|
+
import { openPipelineDb, loadRawComponents, loadScannedFiles, createStep, updateStep } from '../../session/db.js';
|
|
2
|
+
import { appendReviewEvent, getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState, } from '../select/persistence.js';
|
|
3
3
|
import { loadReviewInput } from '../select/parser.js';
|
|
4
4
|
import { buildPrompt } from '../../generate/prompt-builder.js';
|
|
5
5
|
import { parseSelectToolCallLines, runAgent } from '../../generate/agent-runner.js';
|
|
6
6
|
import { OutputFormatter, c } from '../../output/format.js';
|
|
7
|
+
import { buildRepoContextIndex, buildSelectionContext } from './context-builder.js';
|
|
8
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
7
9
|
const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
|
|
8
10
|
const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
|
|
9
11
|
const DEFAULT_CONCURRENCY = 5;
|
|
@@ -30,8 +32,12 @@ function resolveSessionId(sessionFlag) {
|
|
|
30
32
|
db.close();
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
|
-
function
|
|
34
|
-
return {
|
|
35
|
+
function componentKey(component) {
|
|
36
|
+
return `${component.name}::${component.source}`;
|
|
37
|
+
}
|
|
38
|
+
function buildComponentData(candidate) {
|
|
39
|
+
const { component, selectionContext } = candidate;
|
|
40
|
+
const payload = {
|
|
35
41
|
name: component.name,
|
|
36
42
|
source: component.source,
|
|
37
43
|
framework: component.framework,
|
|
@@ -40,13 +46,47 @@ function buildComponentData(component) {
|
|
|
40
46
|
propNames: component.props.slice(0, 8).map((p) => p.name),
|
|
41
47
|
props: component.props,
|
|
42
48
|
slots: component.slots,
|
|
49
|
+
extractionConfidence: component.extractionConfidence ?? null,
|
|
50
|
+
needsReview: component.needsReview ?? false,
|
|
43
51
|
};
|
|
52
|
+
if (component.reviewReasons && component.reviewReasons.length > 0) {
|
|
53
|
+
payload.reviewReasons = component.reviewReasons;
|
|
54
|
+
}
|
|
55
|
+
if (selectionContext) {
|
|
56
|
+
payload.selectionContext = selectionContext;
|
|
57
|
+
}
|
|
58
|
+
return payload;
|
|
59
|
+
}
|
|
60
|
+
function resolveProjectRoot(sessionId, projectRootFlag) {
|
|
61
|
+
if (projectRootFlag)
|
|
62
|
+
return resolve(projectRootFlag);
|
|
63
|
+
const db = openPipelineDb();
|
|
64
|
+
try {
|
|
65
|
+
const row = db
|
|
66
|
+
.prepare(`SELECT inputs FROM steps
|
|
67
|
+
WHERE session_id = ?
|
|
68
|
+
AND command = 'analyze extract'
|
|
69
|
+
ORDER BY started_at DESC
|
|
70
|
+
LIMIT 1`)
|
|
71
|
+
.get(sessionId);
|
|
72
|
+
if (!row?.inputs)
|
|
73
|
+
return null;
|
|
74
|
+
const parsed = JSON.parse(row.inputs);
|
|
75
|
+
return parsed.project ? resolve(parsed.project) : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
db.close();
|
|
82
|
+
}
|
|
44
83
|
}
|
|
45
|
-
async function selectOneComponent(agent, model,
|
|
84
|
+
async function selectOneComponent(agent, model, candidate, index, total, verbose) {
|
|
85
|
+
const { component } = candidate;
|
|
46
86
|
const prompt = await buildPrompt({
|
|
47
87
|
skill: 'select',
|
|
48
88
|
mode: 'autonomous',
|
|
49
|
-
rawComponentsInline: JSON.stringify([buildComponentData(
|
|
89
|
+
rawComponentsInline: JSON.stringify([buildComponentData(candidate)], null, 2),
|
|
50
90
|
outDir: process.cwd(),
|
|
51
91
|
});
|
|
52
92
|
const pos = c.dim(`[${index + 1}/${total}]`);
|
|
@@ -63,34 +103,53 @@ async function selectOneComponent(agent, model, component, index, total, verbose
|
|
|
63
103
|
onOutput: (chunk) => formatter.push(chunk),
|
|
64
104
|
});
|
|
65
105
|
formatter.flush();
|
|
66
|
-
|
|
106
|
+
if (verbose) {
|
|
107
|
+
process.stderr.write(` ${pos} ${c.bold(component.name)}\n${outputBuf}`);
|
|
108
|
+
}
|
|
67
109
|
if (result.timedOut) {
|
|
68
|
-
|
|
110
|
+
process.stderr.write(` ${pos} ${c.bold(component.name)} ${c.yellow('timed out')}\n`);
|
|
111
|
+
return {
|
|
112
|
+
componentKey: componentKey(component),
|
|
113
|
+
componentName: component.name,
|
|
114
|
+
decision: null,
|
|
115
|
+
failed: true,
|
|
116
|
+
};
|
|
69
117
|
}
|
|
70
118
|
if (result.exitCode !== 0) {
|
|
119
|
+
process.stderr.write(` ${pos} ${c.bold(component.name)} ${c.red(`agent exited with code ${result.exitCode}`)}\n`);
|
|
71
120
|
return {
|
|
121
|
+
componentKey: componentKey(component),
|
|
72
122
|
componentName: component.name,
|
|
73
123
|
decision: null,
|
|
74
124
|
failed: true,
|
|
75
|
-
error: `agent exited with code ${result.exitCode}`,
|
|
76
125
|
};
|
|
77
126
|
}
|
|
78
127
|
const { calls, warnings } = parseSelectToolCallLines(result.stdout);
|
|
79
128
|
if (warnings.length > 0) {
|
|
80
|
-
for (const
|
|
81
|
-
process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${
|
|
129
|
+
for (const warning of warnings) {
|
|
130
|
+
process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${warning}\n`);
|
|
131
|
+
}
|
|
82
132
|
}
|
|
83
|
-
const call = calls.find((
|
|
133
|
+
const call = calls.find((toolCall) => toolCall.name === component.name) ?? calls[0];
|
|
84
134
|
if (!call) {
|
|
135
|
+
process.stderr.write(` ${pos} ${c.bold(component.name)} ${c.yellow('no tool call')}\n`);
|
|
85
136
|
return {
|
|
137
|
+
componentKey: componentKey(component),
|
|
86
138
|
componentName: component.name,
|
|
87
139
|
decision: null,
|
|
88
140
|
failed: true,
|
|
89
|
-
error: 'agent produced no tool call for this component',
|
|
90
141
|
};
|
|
91
142
|
}
|
|
92
143
|
const decision = call.tool === 'select_component' ? 'accepted' : 'rejected';
|
|
93
|
-
|
|
144
|
+
const finalColor = decision === 'accepted' ? c.green : c.red;
|
|
145
|
+
process.stderr.write(` ${pos} ${c.bold(component.name)} ${finalColor(decision)}${call.reason ? ` ${c.dim(call.reason)}` : ''}\n`);
|
|
146
|
+
return {
|
|
147
|
+
componentKey: componentKey(component),
|
|
148
|
+
componentName: component.name,
|
|
149
|
+
decision,
|
|
150
|
+
reason: call.reason,
|
|
151
|
+
failed: false,
|
|
152
|
+
};
|
|
94
153
|
}
|
|
95
154
|
async function selectAllComponents(agent, model, components, verbose) {
|
|
96
155
|
const concurrency = Number(process.env.EDS_GENERATE_CONCURRENCY ?? DEFAULT_CONCURRENCY);
|
|
@@ -126,10 +185,13 @@ export function registerAnalyzeSelectAgentCommand(program) {
|
|
|
126
185
|
}
|
|
127
186
|
const agent = opts.agent;
|
|
128
187
|
const sessionId = resolveSessionId(opts.session);
|
|
188
|
+
const selectionRoot = resolveProjectRoot(sessionId, opts.projectRoot);
|
|
129
189
|
const db = openPipelineDb();
|
|
130
190
|
let rawComponents;
|
|
191
|
+
let scannedFiles = [];
|
|
131
192
|
try {
|
|
132
193
|
rawComponents = loadRawComponents(db, sessionId);
|
|
194
|
+
scannedFiles = loadScannedFiles(db, sessionId);
|
|
133
195
|
}
|
|
134
196
|
finally {
|
|
135
197
|
db.close();
|
|
@@ -139,8 +201,25 @@ export function registerAnalyzeSelectAgentCommand(program) {
|
|
|
139
201
|
process.exit(1);
|
|
140
202
|
return;
|
|
141
203
|
}
|
|
204
|
+
if (selectionRoot && scannedFiles.length > 0) {
|
|
205
|
+
scannedFiles = scannedFiles.map((f) => (isAbsolute(f) ? f : resolve(selectionRoot, f)));
|
|
206
|
+
}
|
|
207
|
+
if (selectionRoot && scannedFiles.length === 0 && rawComponents.length > 0) {
|
|
208
|
+
process.stderr.write('warn: session has no scanned-files index (likely extracted on an older CLI version). ' +
|
|
209
|
+
'Re-run `analyze extract` to enable data-fetch wrapper detection during selection.\n');
|
|
210
|
+
}
|
|
211
|
+
let selectionCandidates = rawComponents.map((component) => ({ component }));
|
|
212
|
+
if (selectionRoot && scannedFiles.length > 0) {
|
|
213
|
+
const repoIndex = await buildRepoContextIndex(selectionRoot, scannedFiles).catch(() => null);
|
|
214
|
+
if (repoIndex) {
|
|
215
|
+
selectionCandidates = rawComponents.map((component) => ({
|
|
216
|
+
component,
|
|
217
|
+
selectionContext: buildSelectionContext(repoIndex, component),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
142
221
|
if (opts.dryRun) {
|
|
143
|
-
const first =
|
|
222
|
+
const first = selectionCandidates[0];
|
|
144
223
|
const prompt = await buildPrompt({
|
|
145
224
|
skill: 'select',
|
|
146
225
|
mode: 'autonomous',
|
|
@@ -151,20 +230,16 @@ export function registerAnalyzeSelectAgentCommand(program) {
|
|
|
151
230
|
process.exit(0);
|
|
152
231
|
return;
|
|
153
232
|
}
|
|
154
|
-
const selectResults = await selectAllComponents(agent, opts.model,
|
|
155
|
-
// Build decision map from results
|
|
233
|
+
const selectResults = await selectAllComponents(agent, opts.model, selectionCandidates, opts.verbose ?? false);
|
|
156
234
|
const decisions = new Map();
|
|
157
235
|
for (const r of selectResults) {
|
|
158
|
-
|
|
159
|
-
decisions.set(r.componentName, r.decision);
|
|
160
|
-
}
|
|
236
|
+
decisions.set(r.componentKey, r.decision);
|
|
161
237
|
}
|
|
162
|
-
// Load existing snapshot and apply decisions
|
|
163
238
|
const artifactsRoot = getRefineArtifactsRoot();
|
|
164
239
|
let snapshot;
|
|
165
240
|
try {
|
|
166
241
|
snapshot = await loadReviewInput(rawComponents, {
|
|
167
|
-
reviewRoot:
|
|
242
|
+
reviewRoot: selectionRoot ?? undefined,
|
|
168
243
|
});
|
|
169
244
|
snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
|
|
170
245
|
}
|
|
@@ -177,25 +252,38 @@ export function registerAnalyzeSelectAgentCommand(program) {
|
|
|
177
252
|
const updated = {
|
|
178
253
|
...snapshot,
|
|
179
254
|
components: snapshot.components.map((comp) => {
|
|
180
|
-
const
|
|
255
|
+
const key = componentKey(comp.originalProposal);
|
|
256
|
+
const decision = decisions.get(key);
|
|
181
257
|
if (!decision)
|
|
182
258
|
return comp;
|
|
183
259
|
return { ...comp, status: decision };
|
|
184
260
|
}),
|
|
185
261
|
};
|
|
186
262
|
await saveReviewState(paths.statePath, updated);
|
|
263
|
+
await Promise.all(updated.components
|
|
264
|
+
.filter((component) => component.status === 'accepted' || component.status === 'rejected')
|
|
265
|
+
.map((component) => appendReviewEvent(paths.eventsPath, {
|
|
266
|
+
type: 'select_agent_decision',
|
|
267
|
+
payload: {
|
|
268
|
+
component: component.name,
|
|
269
|
+
source: component.originalProposal.source,
|
|
270
|
+
status: component.status,
|
|
271
|
+
},
|
|
272
|
+
})));
|
|
187
273
|
const accepted = updated.components.filter((comp) => comp.status === 'accepted');
|
|
188
274
|
const rejected = updated.components.filter((comp) => comp.status === 'rejected');
|
|
275
|
+
const unresolved = updated.components.filter((comp) => comp.status === 'needs-review');
|
|
189
276
|
const failed = selectResults.filter((r) => r.failed);
|
|
190
277
|
if (failed.length > 0) {
|
|
191
278
|
process.stderr.write(c.red(`Failed (${failed.length}/${selectResults.length}):`) + '\n');
|
|
192
279
|
for (const f of failed) {
|
|
193
|
-
process.stderr.write(` ${c.red('✗')} ${f.componentName}
|
|
280
|
+
process.stderr.write(` ${c.red('✗')} ${f.componentName}\n`);
|
|
194
281
|
}
|
|
195
282
|
}
|
|
196
|
-
// Write step to DB
|
|
197
283
|
const stepDb = openPipelineDb();
|
|
198
|
-
const stepId = createStep(stepDb, sessionId, 'analyze select', {
|
|
284
|
+
const stepId = createStep(stepDb, sessionId, 'analyze select', {
|
|
285
|
+
sessionId,
|
|
286
|
+
});
|
|
199
287
|
try {
|
|
200
288
|
const status = failed.length === selectResults.length ? 'failed' : 'complete';
|
|
201
289
|
updateStep(stepDb, stepId, status, { sessionId });
|
|
@@ -203,6 +291,6 @@ export function registerAnalyzeSelectAgentCommand(program) {
|
|
|
203
291
|
finally {
|
|
204
292
|
stepDb.close();
|
|
205
293
|
}
|
|
206
|
-
process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length}\n`);
|
|
294
|
+
process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length} Needs review: ${unresolved.length}\n`);
|
|
207
295
|
});
|
|
208
296
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RawComponentDefinition } from '../../types.js';
|
|
2
|
+
export type SelectionContextSummary = {
|
|
3
|
+
boundaryRoot: string;
|
|
4
|
+
siblingFileCount: number;
|
|
5
|
+
resolverReferenceCount: number;
|
|
6
|
+
hasParentUsageSite: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type SelectionImportSummary = {
|
|
9
|
+
source: string;
|
|
10
|
+
names: string[];
|
|
11
|
+
local: boolean;
|
|
12
|
+
resolvedPath?: string;
|
|
13
|
+
};
|
|
14
|
+
export type SelectionFileSummary = {
|
|
15
|
+
path: string;
|
|
16
|
+
exports: string[];
|
|
17
|
+
codeSnippet: string;
|
|
18
|
+
};
|
|
19
|
+
export type SelectionReference = {
|
|
20
|
+
path: string;
|
|
21
|
+
snippet: string;
|
|
22
|
+
};
|
|
23
|
+
export type SelectionContext = {
|
|
24
|
+
boundaryRoot: string;
|
|
25
|
+
componentFile: {
|
|
26
|
+
path: string;
|
|
27
|
+
code: string;
|
|
28
|
+
};
|
|
29
|
+
imports: SelectionImportSummary[];
|
|
30
|
+
exports: string[];
|
|
31
|
+
siblingFiles: SelectionFileSummary[];
|
|
32
|
+
resolverReferences: SelectionReference[];
|
|
33
|
+
parentUsageSite?: SelectionReference;
|
|
34
|
+
};
|
|
35
|
+
type IndexedFile = {
|
|
36
|
+
absolutePath: string;
|
|
37
|
+
relativePath: string;
|
|
38
|
+
directory: string;
|
|
39
|
+
text: string;
|
|
40
|
+
};
|
|
41
|
+
export type RepoContextIndex = {
|
|
42
|
+
root: string;
|
|
43
|
+
files: IndexedFile[];
|
|
44
|
+
byDirectory: Map<string, IndexedFile[]>;
|
|
45
|
+
filePaths: Set<string>;
|
|
46
|
+
};
|
|
47
|
+
export declare function buildRepoContextIndex(root: string, filePaths: string[]): Promise<RepoContextIndex | null>;
|
|
48
|
+
export declare function buildSelectionContext(index: RepoContextIndex, component: RawComponentDefinition): SelectionContext | undefined;
|
|
49
|
+
export declare function summarizeSelectionContext(context: SelectionContext | undefined): SelectionContextSummary | undefined;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
|
|
4
|
+
const IMPORT_PATTERN = /import\s+(?:type\s+)?(.+?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
5
|
+
const EXPORT_NAMED_PATTERN = /export\s+(?:const|function|class|type|interface|enum)\s+([A-Za-z0-9_]+)/g;
|
|
6
|
+
const EXPORT_DEFAULT_PATTERN = /export\s+default\s+([A-Za-z0-9_]+)/g;
|
|
7
|
+
const EXPORT_LIST_PATTERN = /export\s*\{([^}]+)\}/g;
|
|
8
|
+
const RESOLVER_HINT_PATTERN = /(resolver|registry|componentmap|component-map|componentresolver|resolvecomponent)/i;
|
|
9
|
+
const MAX_COMPONENT_SOURCE_CHARS = 8_000;
|
|
10
|
+
const MAX_SIBLING_FILES = 5;
|
|
11
|
+
const MAX_SIBLING_SNIPPET_CHARS = 1_200;
|
|
12
|
+
const MAX_REFERENCE_SNIPPETS = 3;
|
|
13
|
+
const MAX_REFERENCE_CHARS = 800;
|
|
14
|
+
function truncateText(text, maxChars) {
|
|
15
|
+
if (text.length <= maxChars)
|
|
16
|
+
return text;
|
|
17
|
+
return text.slice(0, maxChars) + '\n/* truncated */';
|
|
18
|
+
}
|
|
19
|
+
function isWithinRoot(path, root) {
|
|
20
|
+
const relativePath = relative(root, path);
|
|
21
|
+
return relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
|
|
22
|
+
}
|
|
23
|
+
function parseImportedNames(importClause) {
|
|
24
|
+
const names = [];
|
|
25
|
+
const parts = importClause.split(',').map((part) => part.trim());
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
if (!part)
|
|
28
|
+
continue;
|
|
29
|
+
if (part.startsWith('{') && part.endsWith('}')) {
|
|
30
|
+
for (const named of part
|
|
31
|
+
.slice(1, -1)
|
|
32
|
+
.split(',')
|
|
33
|
+
.map((item) => item.trim())
|
|
34
|
+
.filter(Boolean)) {
|
|
35
|
+
const [local] = named.split(/\s+as\s+/i);
|
|
36
|
+
if (local)
|
|
37
|
+
names.push(local.trim());
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [local] = part.split(/\s+as\s+/i);
|
|
42
|
+
if (local)
|
|
43
|
+
names.push(local.trim());
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(names)];
|
|
46
|
+
}
|
|
47
|
+
function resolveLocalImportPath(source, componentDirectory, root, filePaths) {
|
|
48
|
+
const basePath = resolve(componentDirectory, source);
|
|
49
|
+
const candidates = [
|
|
50
|
+
basePath,
|
|
51
|
+
...[...SCANNED_FILE_EXTENSIONS].map((extension) => `${basePath}${extension}`),
|
|
52
|
+
...[...SCANNED_FILE_EXTENSIONS].map((extension) => join(basePath, `index${extension}`)),
|
|
53
|
+
];
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (filePaths.has(candidate) && isWithinRoot(candidate, root)) {
|
|
56
|
+
return relative(root, candidate);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (isWithinRoot(basePath, root)) {
|
|
60
|
+
return relative(root, basePath);
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function parseImports(sourceText, componentDirectory, root, filePaths) {
|
|
65
|
+
const imports = [];
|
|
66
|
+
for (const match of sourceText.matchAll(IMPORT_PATTERN)) {
|
|
67
|
+
const importClause = match[1]?.trim() ?? '';
|
|
68
|
+
const source = match[2]?.trim() ?? '';
|
|
69
|
+
const local = source.startsWith('.');
|
|
70
|
+
const names = parseImportedNames(importClause);
|
|
71
|
+
const record = { source, names, local };
|
|
72
|
+
if (local) {
|
|
73
|
+
record.resolvedPath = resolveLocalImportPath(source, componentDirectory, root, filePaths);
|
|
74
|
+
}
|
|
75
|
+
imports.push(record);
|
|
76
|
+
}
|
|
77
|
+
return imports;
|
|
78
|
+
}
|
|
79
|
+
function parseExports(sourceText) {
|
|
80
|
+
const exports = new Set();
|
|
81
|
+
for (const match of sourceText.matchAll(EXPORT_NAMED_PATTERN)) {
|
|
82
|
+
if (match[1])
|
|
83
|
+
exports.add(match[1]);
|
|
84
|
+
}
|
|
85
|
+
for (const match of sourceText.matchAll(EXPORT_DEFAULT_PATTERN)) {
|
|
86
|
+
if (match[1])
|
|
87
|
+
exports.add(`default:${match[1]}`);
|
|
88
|
+
}
|
|
89
|
+
for (const match of sourceText.matchAll(EXPORT_LIST_PATTERN)) {
|
|
90
|
+
const items = match[1]
|
|
91
|
+
?.split(',')
|
|
92
|
+
.map((item) => item.trim())
|
|
93
|
+
.filter(Boolean) ?? [];
|
|
94
|
+
for (const item of items)
|
|
95
|
+
exports.add(item);
|
|
96
|
+
}
|
|
97
|
+
return [...exports];
|
|
98
|
+
}
|
|
99
|
+
function extractSnippet(text, token, maxChars) {
|
|
100
|
+
const index = text.indexOf(token);
|
|
101
|
+
if (index === -1)
|
|
102
|
+
return truncateText(text, maxChars);
|
|
103
|
+
const start = Math.max(0, text.lastIndexOf('\n', Math.max(0, index - Math.floor(maxChars / 2))) + 1);
|
|
104
|
+
const endBoundary = text.indexOf('\n', index + Math.floor(maxChars / 2));
|
|
105
|
+
const end = endBoundary === -1 ? text.length : endBoundary;
|
|
106
|
+
return truncateText(text.slice(start, end).trim(), maxChars);
|
|
107
|
+
}
|
|
108
|
+
function summarizeSiblingFiles(root, files, currentFile) {
|
|
109
|
+
return files
|
|
110
|
+
.filter((file) => file.absolutePath !== currentFile)
|
|
111
|
+
.slice(0, MAX_SIBLING_FILES)
|
|
112
|
+
.map((file) => ({
|
|
113
|
+
path: relative(root, file.absolutePath),
|
|
114
|
+
exports: parseExports(file.text),
|
|
115
|
+
codeSnippet: truncateText(file.text, MAX_SIBLING_SNIPPET_CHARS),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
function findResolverReferences(root, files, componentName, currentFile) {
|
|
119
|
+
return files
|
|
120
|
+
.filter((file) => file.absolutePath !== currentFile)
|
|
121
|
+
.filter((file) => file.text.includes(componentName))
|
|
122
|
+
.filter((file) => RESOLVER_HINT_PATTERN.test(file.absolutePath) || RESOLVER_HINT_PATTERN.test(file.text))
|
|
123
|
+
.slice(0, MAX_REFERENCE_SNIPPETS)
|
|
124
|
+
.map((file) => ({
|
|
125
|
+
path: relative(root, file.absolutePath),
|
|
126
|
+
snippet: extractSnippet(file.text, componentName, MAX_REFERENCE_CHARS),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
function findParentUsageSite(root, files, componentName, currentFile) {
|
|
130
|
+
const usagePattern = new RegExp(`<${componentName}\\b|\\b${componentName}\\s*[),}]|\\b${componentName}\\s*:`);
|
|
131
|
+
const candidates = files.filter((file) => file.absolutePath !== currentFile &&
|
|
132
|
+
usagePattern.test(file.text) &&
|
|
133
|
+
!file.text.startsWith('// stub') &&
|
|
134
|
+
!RESOLVER_HINT_PATTERN.test(file.absolutePath) &&
|
|
135
|
+
!RESOLVER_HINT_PATTERN.test(file.text));
|
|
136
|
+
const match = candidates[0];
|
|
137
|
+
if (!match)
|
|
138
|
+
return undefined;
|
|
139
|
+
return {
|
|
140
|
+
path: relative(root, match.absolutePath),
|
|
141
|
+
snippet: extractSnippet(match.text, componentName, MAX_REFERENCE_CHARS),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export async function buildRepoContextIndex(root, filePaths) {
|
|
145
|
+
if (filePaths.length === 0)
|
|
146
|
+
return null;
|
|
147
|
+
const resolvedRoot = resolve(root);
|
|
148
|
+
const files = await Promise.all(filePaths.map(async (absolutePath) => ({
|
|
149
|
+
absolutePath,
|
|
150
|
+
relativePath: relative(resolvedRoot, absolutePath),
|
|
151
|
+
directory: dirname(absolutePath),
|
|
152
|
+
text: await readFile(absolutePath, 'utf8').catch(() => ''),
|
|
153
|
+
})));
|
|
154
|
+
const byDirectory = new Map();
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const bucket = byDirectory.get(file.directory) ?? [];
|
|
157
|
+
bucket.push(file);
|
|
158
|
+
byDirectory.set(file.directory, bucket);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
root: resolvedRoot,
|
|
162
|
+
files,
|
|
163
|
+
byDirectory,
|
|
164
|
+
filePaths: new Set(files.map((file) => file.absolutePath)),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function buildSelectionContext(index, component) {
|
|
168
|
+
const absolutePath = isAbsolute(component.source) ? component.source : resolve(index.root, component.source);
|
|
169
|
+
if (!isWithinRoot(absolutePath, index.root))
|
|
170
|
+
return undefined;
|
|
171
|
+
const componentFile = index.files.find((file) => file.absolutePath === absolutePath);
|
|
172
|
+
if (!componentFile)
|
|
173
|
+
return undefined;
|
|
174
|
+
const siblings = index.byDirectory.get(componentFile.directory) ?? [];
|
|
175
|
+
return {
|
|
176
|
+
boundaryRoot: index.root,
|
|
177
|
+
componentFile: {
|
|
178
|
+
path: componentFile.relativePath,
|
|
179
|
+
code: truncateText(componentFile.text, MAX_COMPONENT_SOURCE_CHARS),
|
|
180
|
+
},
|
|
181
|
+
imports: parseImports(componentFile.text, componentFile.directory, index.root, index.filePaths),
|
|
182
|
+
exports: parseExports(componentFile.text),
|
|
183
|
+
siblingFiles: summarizeSiblingFiles(index.root, siblings, componentFile.absolutePath),
|
|
184
|
+
resolverReferences: findResolverReferences(index.root, index.files, component.name, componentFile.absolutePath),
|
|
185
|
+
parentUsageSite: findParentUsageSite(index.root, index.files, component.name, componentFile.absolutePath),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function summarizeSelectionContext(context) {
|
|
189
|
+
if (!context)
|
|
190
|
+
return undefined;
|
|
191
|
+
return {
|
|
192
|
+
boundaryRoot: context.boundaryRoot,
|
|
193
|
+
siblingFileCount: context.siblingFiles.length,
|
|
194
|
+
resolverReferenceCount: context.resolverReferences.length,
|
|
195
|
+
hasParentUsageSite: Boolean(context.parentUsageSite),
|
|
196
|
+
};
|
|
197
|
+
}
|
package/dist/src/session/db.d.ts
CHANGED
|
@@ -106,6 +106,8 @@ export declare function computeTokenInputHash(rawTokenContent: string): string;
|
|
|
106
106
|
export declare function lookupCache(db: DatabaseSync, inputHash: string, entityType: 'component' | 'token_set', entityId: string): CacheEntry | null;
|
|
107
107
|
export declare function lookupCacheByEntity(db: DatabaseSync, entityType: 'component' | 'token_set', entityId: string): CacheEntry | null;
|
|
108
108
|
export declare function storeCache(db: DatabaseSync, inputHash: string, entityType: 'component' | 'token_set', entityId: string, sourceSessionId: string, humanEdited: boolean): void;
|
|
109
|
+
export declare function storeScannedFiles(db: DatabaseSync, sessionId: string, filePaths: string[]): void;
|
|
110
|
+
export declare function loadScannedFiles(db: DatabaseSync, sessionId: string): string[];
|
|
109
111
|
export declare function markCacheHumanEdited(db: DatabaseSync, entityType: 'component' | 'token_set', entityId: string): void;
|
|
110
112
|
export declare function copyComponentFromCache(db: DatabaseSync, sourceSessionId: string, targetSessionId: string, componentId: string): void;
|
|
111
113
|
export declare function copyTokensFromCache(db: DatabaseSync, sourceSessionId: string, targetSessionId: string): void;
|
package/dist/src/session/db.js
CHANGED
|
@@ -127,6 +127,12 @@ CREATE TABLE IF NOT EXISTS generation_cache (
|
|
|
127
127
|
PRIMARY KEY (input_hash, entity_type, entity_id)
|
|
128
128
|
);
|
|
129
129
|
|
|
130
|
+
CREATE TABLE IF NOT EXISTS scanned_files (
|
|
131
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
132
|
+
path TEXT NOT NULL,
|
|
133
|
+
PRIMARY KEY (session_id, path)
|
|
134
|
+
);
|
|
135
|
+
|
|
130
136
|
CREATE INDEX IF NOT EXISTS idx_steps_session ON steps(session_id);
|
|
131
137
|
CREATE INDEX IF NOT EXISTS idx_steps_command ON steps(session_id, command);
|
|
132
138
|
CREATE INDEX IF NOT EXISTS idx_raw_components_session ON raw_components(session_id);
|
|
@@ -136,6 +142,7 @@ CREATE INDEX IF NOT EXISTS idx_raw_tokens_session ON raw_tokens(session_id
|
|
|
136
142
|
CREATE INDEX IF NOT EXISTS idx_raw_token_groups_session ON raw_token_groups(session_id);
|
|
137
143
|
CREATE INDEX IF NOT EXISTS idx_generation_cache_entity ON generation_cache(entity_type, entity_id);
|
|
138
144
|
CREATE INDEX IF NOT EXISTS idx_generation_cache_session ON generation_cache(source_session_id);
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_files_session ON scanned_files(session_id);
|
|
139
146
|
`;
|
|
140
147
|
export function getPipelineDbPath() {
|
|
141
148
|
if (process.env.EDS_PIPELINE_DB_PATH) {
|
|
@@ -1067,6 +1074,25 @@ export function storeCache(db, inputHash, entityType, entityId, sourceSessionId,
|
|
|
1067
1074
|
human_edited = CASE WHEN generation_cache.human_edited = 1 THEN 1 ELSE excluded.human_edited END,
|
|
1068
1075
|
updated_at = excluded.updated_at`).run(inputHash, entityType, entityId, sourceSessionId, humanEdited ? 1 : 0, now, now);
|
|
1069
1076
|
}
|
|
1077
|
+
export function storeScannedFiles(db, sessionId, filePaths) {
|
|
1078
|
+
db.exec('BEGIN');
|
|
1079
|
+
try {
|
|
1080
|
+
db.prepare('DELETE FROM scanned_files WHERE session_id = ?').run(sessionId);
|
|
1081
|
+
const insert = db.prepare('INSERT INTO scanned_files (session_id, path) VALUES (?, ?)');
|
|
1082
|
+
for (const path of filePaths) {
|
|
1083
|
+
insert.run(sessionId, path);
|
|
1084
|
+
}
|
|
1085
|
+
db.exec('COMMIT');
|
|
1086
|
+
}
|
|
1087
|
+
catch (e) {
|
|
1088
|
+
db.exec('ROLLBACK');
|
|
1089
|
+
throw e;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
export function loadScannedFiles(db, sessionId) {
|
|
1093
|
+
const rows = db.prepare('SELECT path FROM scanned_files WHERE session_id = ? ORDER BY path').all(sessionId);
|
|
1094
|
+
return rows.map((r) => r.path);
|
|
1095
|
+
}
|
|
1070
1096
|
export function markCacheHumanEdited(db, entityType, entityId) {
|
|
1071
1097
|
const now = new Date().toISOString();
|
|
1072
1098
|
db.prepare(`UPDATE generation_cache SET human_edited = 1, updated_at = ? WHERE entity_type = ? AND entity_id = ?`).run(now, entityType, entityId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentful/experience-design-system-cli",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.4-dev-build-595febb.0",
|
|
4
4
|
"description": "Contentful Experiences design system import CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"react-dom": "^18.3.1",
|
|
37
37
|
"ts-morph": "^27.0.2",
|
|
38
38
|
"typescript": "^5.9.3",
|
|
39
|
-
"@contentful/experience-design-system-types": "2.7.
|
|
39
|
+
"@contentful/experience-design-system-types": "2.7.4-dev-build-595febb.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@tsconfig/node24": "^24.0.3",
|
|
@@ -20,14 +20,16 @@ The entity being defined — a **Component Type** — is the schema that tells C
|
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
23
|
-
## The one rule:
|
|
23
|
+
## The one rule: is this the author-facing UI component?
|
|
24
24
|
|
|
25
|
-
**Accept** the component if it
|
|
25
|
+
**Accept** the component if it is the component that directly defines the author-facing UI surface — regardless of whether it is an atom, molecule, or organism, and regardless of whether it has many or few configurable props. A footer icon with two props (`icon`, `label`) is just as valid as a parallax hero with fifteen props.
|
|
26
|
+
|
|
27
|
+
**Reject** the component if its primary purpose is framework or data-loading infrastructure rather than the author-facing UI surface:
|
|
26
28
|
|
|
27
|
-
**Reject** the component only if its primary purpose produces zero visual output:
|
|
28
29
|
- It is a React hook (name starts `use` or `Use`)
|
|
29
30
|
- It is a pure context provider with no visual output
|
|
30
31
|
- It is a Ninetailed/personalization platform wrapper (its job is routing to variants, not rendering content)
|
|
32
|
+
- It is a data-fetch wrapper that loads data for a sibling renderer and then forwards that data into the sibling renderer
|
|
31
33
|
- It is an analytics/event-tracking component (fires events, renders nothing)
|
|
32
34
|
- It is a security or infrastructure utility (no UI at all)
|
|
33
35
|
|
|
@@ -37,29 +39,49 @@ The entity being defined — a **Component Type** — is the schema that tells C
|
|
|
37
39
|
|
|
38
40
|
These are **not** valid reasons to reject a component:
|
|
39
41
|
|
|
40
|
-
| Invalid reason
|
|
41
|
-
|
|
42
|
-
| "Only atoms/low-level"
|
|
43
|
-
| "Tightly coupled to a parent component"
|
|
42
|
+
| Invalid reason | Why it is wrong |
|
|
43
|
+
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| "Only atoms/low-level" | Atoms are first-class Component Types in Contentful Experience Orchestration |
|
|
45
|
+
| "Tightly coupled to a parent component" | Contentful Experience Orchestration handles composition at the experience layer |
|
|
44
46
|
| "Has A/B testing or personalization-related props" | These props are classified as `state` or excluded in the generate step — their presence does not disqualify the component |
|
|
45
|
-
| "Has no marketer-configurable props"
|
|
46
|
-
| "Domain-specific or feature-level"
|
|
47
|
-
| "Server-side or SSR"
|
|
48
|
-
| "Few configurable props"
|
|
47
|
+
| "Has no marketer-configurable props" | Marketers are not the only users; designers and developers configure components too |
|
|
48
|
+
| "Domain-specific or feature-level" | Press releases, newsrooms, search — all valid content components |
|
|
49
|
+
| "Server-side or SSR" | Server components that render visible UI are valid |
|
|
50
|
+
| "Few configurable props" | One or two props is fine |
|
|
49
51
|
|
|
50
52
|
---
|
|
51
53
|
|
|
52
54
|
## Reject only these categories
|
|
53
55
|
|
|
54
|
-
| Category
|
|
55
|
-
|
|
56
|
-
| **React hooks**
|
|
57
|
-
| **Pure context providers**
|
|
58
|
-
| **A/B testing or personalization wrappers** | Components whose
|
|
59
|
-
| **
|
|
60
|
-
| **
|
|
56
|
+
| Category | Why |
|
|
57
|
+
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| **React hooks** | `useXxx` / `UseXxx` — functions, not renderable components. Zero visual output. |
|
|
59
|
+
| **Pure context providers** | Wrap children to pass context but render no UI themselves |
|
|
60
|
+
| **A/B testing or personalization wrappers** | Components whose _entire purpose_ is routing users to content variants or tracking experiment participation — they render no UI of their own |
|
|
61
|
+
| **Data-fetch wrappers** | Components whose job is to load or resolve data for a sibling renderer. Even if they eventually return visible UI, the sibling renderer is the real Component Type. |
|
|
62
|
+
| **Analytics and event tracking** | Components that only fire analytics events and render nothing visible |
|
|
63
|
+
| **Security utilities** | Non-visual security primitives with no rendered output |
|
|
64
|
+
|
|
65
|
+
> **Variant routing rule**: Reject a component if its entire purpose is deciding _which_ content variant to show — that is framework infrastructure, not a Component Type. Do **not** reject a component merely because it _contains_ some A/B testing or personalization-related props — those props are handled as `state` in the generate step.
|
|
66
|
+
|
|
67
|
+
> **Data-fetch wrapper rule**: Reject a component if it imports or calls a generated query hook, loads data, and then forwards that data into a sibling renderer. The sibling renderer is the Component Type; the data-loader wrapper is not.
|
|
68
|
+
|
|
69
|
+
## Using `selectionContext`
|
|
70
|
+
|
|
71
|
+
If the input includes `selectionContext`, treat it as the only repo-level context you may use. It is already bounded to the customer-provided project files and may include:
|
|
72
|
+
|
|
73
|
+
- the component source file
|
|
74
|
+
- sibling files in the same folder
|
|
75
|
+
- import/export summaries
|
|
76
|
+
- resolver or registry references
|
|
77
|
+
- one likely parent usage site
|
|
61
78
|
|
|
62
|
-
|
|
79
|
+
Use that bounded context to distinguish the author-facing renderer from infrastructure wrappers. In particular:
|
|
80
|
+
|
|
81
|
+
- If the component imports a sibling renderer and mainly forwards fetched data into it, reject the wrapper and prefer the sibling renderer.
|
|
82
|
+
- If sibling files show a presentation-focused renderer with the real authoring props, that renderer is the Component Type.
|
|
83
|
+
- Resolver or parent-usage references help show how the repo treats the component, but they do not override the renderer-vs-wrapper rule.
|
|
84
|
+
- Do not assume access to any files outside `selectionContext`.
|
|
63
85
|
|
|
64
86
|
---
|
|
65
87
|
|
|
@@ -76,6 +98,7 @@ Emit one JSON object on a single line. Lines not starting with `{` are ignored b
|
|
|
76
98
|
```
|
|
77
99
|
|
|
78
100
|
**Rules:**
|
|
101
|
+
|
|
79
102
|
- Emit exactly one JSON object, on one line. No multi-line JSON. No markdown fences.
|
|
80
103
|
- The `name` must match the component name in the input.
|
|
81
104
|
- `reason` is a brief phrase documenting your decision.
|
|
@@ -136,6 +159,12 @@ Providers — React context provider with no visual output
|
|
|
136
159
|
{"tool":"reject_component","name":"Providers","reason":"pure context provider — no visual output"}
|
|
137
160
|
```
|
|
138
161
|
|
|
162
|
+
```
|
|
163
|
+
HeroBannerGql — fetch wrapper that loads data and forwards it into HeroBanner
|
|
164
|
+
It may eventually return visible UI, but the author-facing component is HeroBanner, not HeroBannerGql.
|
|
165
|
+
{"tool":"reject_component","name":"HeroBannerGql","reason":"data-fetch wrapper — loads data for a sibling renderer rather than defining the author-facing UI surface"}
|
|
166
|
+
```
|
|
167
|
+
|
|
139
168
|
```
|
|
140
169
|
FooterIcon — atom: renders an icon with optional label in the footer
|
|
141
170
|
{"tool":"select_component","name":"FooterIcon","reason":"UI atom — renders icon with configurable icon and label"}
|