@contentful/experience-design-system-cli 2.7.3-dev-build-a02165e.0 → 2.7.3-dev-build-2a7c81e.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.7.3-dev-build-a02165e.0",
3
+ "version": "2.7.3-dev-build-2a7c81e.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,7 @@ import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawCom
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,22 +129,43 @@ export function registerAnalyzeCommand(program) {
121
129
  inputPath: projectRoot,
122
130
  outDir,
123
131
  });
124
- const stepId = createStep(db, sessionId, 'analyze extract', { project: projectRoot });
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 classifiedComponents) {
142
+ for (const { component, inspection } of inspectedComponents) {
129
143
  const verdict = isNonAuthorableComponent(component);
130
- if (verdict.skip) {
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
- const { confidence, reasons } = computeExtractionScore(component);
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);
@@ -4,5 +4,9 @@ export type ExtractionScore = {
4
4
  confidence: ExtractionConfidence;
5
5
  reasons: string[];
6
6
  };
7
- export declare function computeExtractionScore(component: RawComponentDefinition): ExtractionScore;
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,186 @@
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
+ if (GQL_FILENAME_PATTERN.test(component.source)) {
107
+ reviewReasons.push('data-wrapper:gql-filename');
108
+ wrapperScore += 1;
109
+ }
110
+ if (GENERATED_IMPORT_PATTERN.test(sourceText) && GENERATED_QUERY_HOOK_PATTERN.test(sourceText)) {
111
+ reviewReasons.push('data-wrapper:generated-query-hook');
112
+ wrapperScore += 3;
113
+ }
114
+ const siblingImports = collectSiblingRendererImports(sourceText);
115
+ if (siblingImports.length > 0) {
116
+ reviewReasons.push('data-wrapper:sibling-renderer-import');
117
+ wrapperScore += 2;
118
+ }
119
+ if (hasSiblingForwardRender(sourceText, siblingImports)) {
120
+ reviewReasons.push('data-wrapper:fetch-forward-render');
121
+ wrapperScore += 3;
122
+ }
123
+ if (sourceText.includes('useContentfulLiveUpdates(') || sourceText.includes('useContentfulContext(')) {
124
+ reviewReasons.push('data-wrapper:contentful-runtime');
125
+ wrapperScore += 1;
126
+ }
127
+ const infraProps = infraPropNames(component);
128
+ if (infraProps.length > 0) {
129
+ reviewReasons.push('data-wrapper:infra-props');
130
+ wrapperScore += 2;
131
+ }
132
+ if (LOADING_NULL_GUARD_PATTERN.test(sourceText) || /return\s+null\b/.test(sourceText)) {
133
+ reviewReasons.push('data-wrapper:loading-null-guard');
134
+ wrapperScore += 1;
135
+ }
136
+ const wrapperConfidence = scoreToConfidence(wrapperScore);
137
+ if (wrapperConfidence >= 4) {
138
+ reviewReasons.unshift(HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON);
139
+ }
140
+ else if (wrapperConfidence === 3) {
141
+ reviewReasons.unshift(POSSIBLE_DATA_FETCH_WRAPPER_REASON);
142
+ }
143
+ const keepDespiteZeroSurface = component.props.length === 0 && component.slots.length === 0 && hasVisibleUiRender(sourceText);
144
+ if (keepDespiteZeroSurface) {
145
+ reviewReasons.push(ZERO_SURFACE_RENDERED_UI_REASON);
146
+ }
147
+ return {
148
+ wrapperConfidence,
149
+ reviewReasons: dedupeReasons(reviewReasons),
150
+ keepDespiteZeroSurface,
151
+ };
152
+ }
153
+ export function isDataWrapperReviewReason(reason) {
154
+ return (reason === HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON ||
155
+ reason === POSSIBLE_DATA_FETCH_WRAPPER_REASON ||
156
+ reason.startsWith(DATA_WRAPPER_REASON_PREFIX));
157
+ }
158
+ export function describeReviewReason(reason) {
159
+ switch (reason) {
160
+ case HIGH_CONFIDENCE_DATA_FETCH_WRAPPER_REASON:
161
+ return 'high-confidence data-fetch wrapper';
162
+ case POSSIBLE_DATA_FETCH_WRAPPER_REASON:
163
+ return 'possible data-fetch wrapper';
164
+ case 'data-wrapper:gql-filename':
165
+ return 'source file follows a gql wrapper naming pattern';
166
+ case 'data-wrapper:generated-query-hook':
167
+ return 'imports and calls a generated query hook';
168
+ case 'data-wrapper:sibling-renderer-import':
169
+ return 'imports a sibling renderer from the same folder';
170
+ case 'data-wrapper:fetch-forward-render':
171
+ return 'forwards fetched data into a sibling renderer';
172
+ case 'data-wrapper:contentful-runtime':
173
+ return 'uses Contentful runtime hooks';
174
+ case 'data-wrapper:infra-props':
175
+ return 'only exposes infra-fetch props';
176
+ case 'data-wrapper:loading-null-guard':
177
+ return 'returns early while loading or when fetched data is missing';
178
+ case ZERO_SURFACE_RENDERED_UI_REASON:
179
+ return 'source renders visible/compositional UI despite zero extracted props and slots';
180
+ default:
181
+ return reason;
182
+ }
183
+ }
184
+ export function describeReviewReasons(reasons) {
185
+ return dedupeReasons(reasons.map(describeReviewReason));
186
+ }
@@ -1,10 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createRequire } from 'node:module';
3
2
  import { Box, Text } from 'ink';
4
- // createRequire is needed because this is an ESM package — require() doesn't
5
- // exist natively, but it's the simplest way to read a JSON file at runtime.
6
- const _require = createRequire(import.meta.url);
7
- const VERSION = _require('../../../../../package.json').version;
8
3
  export function TopBar({ subcommand, hints }) {
9
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: 'experience-design-system-cli ' + subcommand }), _jsxs(Text, { dimColor: true, children: [hints.map((h) => `[${h.key}] ${h.label}`).join(' '), ' v' + VERSION] })] }));
4
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: 'experience-design-system-cli ' + subcommand }), _jsx(Text, { dimColor: true, children: hints.map((h) => `[${h.key}] ${h.label}`).join(' ') })] }));
10
5
  }
@@ -1,4 +1,5 @@
1
1
  import type { RawComponentDefinition } from '../../types.js';
2
+ import type { SelectionAudit } from '../select-agent/consensus.js';
2
3
  export type PreviewAnnotation = 'new' | 'changed' | 'removed' | 'breaking';
3
4
  export type ReviewComponentStatus = 'needs-review' | 'reviewed' | 'accepted' | 'rejected';
4
5
  export type ReviewComponentRecord = {
@@ -9,6 +10,7 @@ export type ReviewComponentRecord = {
9
10
  originalProposal: RawComponentDefinition;
10
11
  editedProposal: RawComponentDefinition;
11
12
  status: ReviewComponentStatus;
13
+ selectionAudit?: SelectionAudit;
12
14
  };
13
15
  export type ReviewComponentDetail = {
14
16
  id: string;
@@ -16,6 +18,7 @@ export type ReviewComponentDetail = {
16
18
  originalProposal: RawComponentDefinition;
17
19
  editedProposal: RawComponentDefinition;
18
20
  status: ReviewComponentStatus;
21
+ selectionAudit?: SelectionAudit;
19
22
  };
20
23
  export type ReviewComponentSummary = {
21
24
  id: string;
@@ -17,6 +17,7 @@ export function createReviewSessionDetail(session) {
17
17
  originalProposal: component.originalProposal,
18
18
  editedProposal: component.editedProposal,
19
19
  status: component.status,
20
+ selectionAudit: component.selectionAudit,
20
21
  })),
21
22
  };
22
23
  }
@@ -1,12 +1,17 @@
1
1
  import { openPipelineDb, loadRawComponents, createStep, updateStep } from '../../session/db.js';
2
- import { getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState, } from '../select/persistence.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 { DEFAULT_REVIEW_VOTE_COUNT, summarizeSelectionVotes, } from './consensus.js';
8
+ import { buildRepoContextIndex, buildSelectionContext, summarizeSelectionContext, } from './context-builder.js';
9
+ import { resolve } from 'node:path';
7
10
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
8
11
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
9
12
  const DEFAULT_CONCURRENCY = 5;
13
+ const REVIEW_VOTE_COUNT = Math.max(1, Number(process.env.EDS_SELECT_VOTE_COUNT ?? DEFAULT_REVIEW_VOTE_COUNT));
14
+ const SINGLE_PASS_VOTE_COUNT = 1;
10
15
  function resolveSessionId(sessionFlag) {
11
16
  if (sessionFlag)
12
17
  return sessionFlag;
@@ -30,8 +35,12 @@ function resolveSessionId(sessionFlag) {
30
35
  db.close();
31
36
  }
32
37
  }
33
- function buildComponentData(component) {
34
- return {
38
+ function componentKey(component) {
39
+ return `${component.name}::${component.source}`;
40
+ }
41
+ function buildComponentData(candidate) {
42
+ const { component, selectionContext } = candidate;
43
+ const payload = {
35
44
  name: component.name,
36
45
  source: component.source,
37
46
  framework: component.framework,
@@ -40,57 +49,126 @@ function buildComponentData(component) {
40
49
  propNames: component.props.slice(0, 8).map((p) => p.name),
41
50
  props: component.props,
42
51
  slots: component.slots,
52
+ extractionConfidence: component.extractionConfidence ?? null,
53
+ needsReview: component.needsReview ?? false,
43
54
  };
55
+ if (component.reviewReasons && component.reviewReasons.length > 0) {
56
+ payload.reviewReasons = component.reviewReasons;
57
+ }
58
+ if (selectionContext) {
59
+ payload.selectionContext = selectionContext;
60
+ }
61
+ return payload;
62
+ }
63
+ function getSelectionVoteCount(component) {
64
+ if (component.needsReview === true) {
65
+ return REVIEW_VOTE_COUNT;
66
+ }
67
+ if (typeof component.extractionConfidence === 'number' && component.extractionConfidence <= 3) {
68
+ return REVIEW_VOTE_COUNT;
69
+ }
70
+ return SINGLE_PASS_VOTE_COUNT;
44
71
  }
45
- async function selectOneComponent(agent, model, component, index, total, verbose) {
72
+ function resolveProjectRoot(sessionId, projectRootFlag) {
73
+ if (projectRootFlag)
74
+ return resolve(projectRootFlag);
75
+ const db = openPipelineDb();
76
+ try {
77
+ const row = db
78
+ .prepare(`SELECT inputs FROM steps
79
+ WHERE session_id = ?
80
+ AND command = 'analyze extract'
81
+ ORDER BY started_at DESC
82
+ LIMIT 1`)
83
+ .get(sessionId);
84
+ if (!row?.inputs)
85
+ return null;
86
+ const parsed = JSON.parse(row.inputs);
87
+ return parsed.project ? resolve(parsed.project) : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ finally {
93
+ db.close();
94
+ }
95
+ }
96
+ async function selectOneComponent(agent, model, candidate, index, total, verbose) {
97
+ const { component, selectionContext } = candidate;
98
+ const voteCount = getSelectionVoteCount(component);
46
99
  const prompt = await buildPrompt({
47
100
  skill: 'select',
48
101
  mode: 'autonomous',
49
- rawComponentsInline: JSON.stringify([buildComponentData(component)], null, 2),
102
+ rawComponentsInline: JSON.stringify([buildComponentData(candidate)], null, 2),
50
103
  outDir: process.cwd(),
51
104
  });
52
105
  const pos = c.dim(`[${index + 1}/${total}]`);
53
- let outputBuf = '';
54
- const formatter = new OutputFormatter(verbose, (s) => {
55
- outputBuf += s;
56
- });
57
- const result = await runAgent({
58
- agent,
59
- model,
60
- prompt,
61
- interactive: false,
62
- timeoutMs: DEFAULT_TIMEOUT_MS,
63
- onOutput: (chunk) => formatter.push(chunk),
64
- });
65
- formatter.flush();
66
- process.stderr.write(` ${pos} ${c.bold(component.name)}\n${outputBuf}`);
67
- if (result.timedOut) {
68
- return { componentName: component.name, decision: null, failed: true, error: 'timed out' };
69
- }
70
- if (result.exitCode !== 0) {
71
- return {
72
- componentName: component.name,
73
- decision: null,
74
- failed: true,
75
- error: `agent exited with code ${result.exitCode}`,
76
- };
77
- }
78
- const { calls, warnings } = parseSelectToolCallLines(result.stdout);
79
- if (warnings.length > 0) {
80
- for (const w of warnings)
81
- process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${w}\n`);
82
- }
83
- const call = calls.find((call) => call.name === component.name) ?? calls[0];
84
- if (!call) {
85
- return {
86
- componentName: component.name,
87
- decision: null,
88
- failed: true,
89
- error: 'agent produced no tool call for this component',
90
- };
106
+ const votes = [];
107
+ const contextSummary = summarizeSelectionContext(selectionContext);
108
+ for (let attempt = 1; attempt <= voteCount; attempt++) {
109
+ let outputBuf = '';
110
+ const formatter = new OutputFormatter(verbose, (s) => {
111
+ outputBuf += s;
112
+ });
113
+ const result = await runAgent({
114
+ agent,
115
+ model,
116
+ prompt,
117
+ interactive: false,
118
+ timeoutMs: DEFAULT_TIMEOUT_MS,
119
+ onOutput: (chunk) => formatter.push(chunk),
120
+ });
121
+ formatter.flush();
122
+ if (verbose) {
123
+ process.stderr.write(` ${pos} ${c.bold(component.name)} ${c.dim(`vote ${attempt}/${voteCount}`)}\n${outputBuf}`);
124
+ }
125
+ if (result.timedOut) {
126
+ votes.push({ attempt, decision: null, error: 'timed out' });
127
+ continue;
128
+ }
129
+ if (result.exitCode !== 0) {
130
+ votes.push({
131
+ attempt,
132
+ decision: null,
133
+ error: `agent exited with code ${result.exitCode}`,
134
+ });
135
+ continue;
136
+ }
137
+ const { calls, warnings } = parseSelectToolCallLines(result.stdout);
138
+ if (warnings.length > 0) {
139
+ for (const warning of warnings) {
140
+ process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${warning}\n`);
141
+ }
142
+ }
143
+ const call = calls.find((toolCall) => toolCall.name === component.name) ?? calls[0];
144
+ if (!call) {
145
+ votes.push({
146
+ attempt,
147
+ decision: null,
148
+ error: 'agent produced no tool call for this component',
149
+ });
150
+ continue;
151
+ }
152
+ votes.push({
153
+ attempt,
154
+ decision: call.tool === 'select_component' ? 'accepted' : 'rejected',
155
+ reason: call.reason,
156
+ confidence: call.confidence,
157
+ });
91
158
  }
92
- const decision = call.tool === 'select_component' ? 'accepted' : 'rejected';
93
- return { componentName: component.name, decision, reason: call.reason, failed: false };
159
+ const consensus = summarizeSelectionVotes(votes, contextSummary);
160
+ const finalColor = consensus.decision === 'accepted' ? c.green : consensus.decision === 'rejected' ? c.red : c.yellow;
161
+ const finalLabel = consensus.decision === 'accepted' ? 'accepted' : consensus.decision === 'rejected' ? 'rejected' : 'needs review';
162
+ const detail = consensus.audit.winningReason;
163
+ process.stderr.write(` ${pos} ${c.bold(component.name)} ${finalColor(finalLabel)}${detail ? ` ${c.dim(detail)}` : ''}\n`);
164
+ return {
165
+ componentKey: componentKey(component),
166
+ componentName: component.name,
167
+ decision: consensus.decision,
168
+ audit: consensus.audit,
169
+ reason: consensus.audit.winningReason,
170
+ failed: consensus.failed,
171
+ };
94
172
  }
95
173
  async function selectAllComponents(agent, model, components, verbose) {
96
174
  const concurrency = Number(process.env.EDS_GENERATE_CONCURRENCY ?? DEFAULT_CONCURRENCY);
@@ -126,6 +204,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
126
204
  }
127
205
  const agent = opts.agent;
128
206
  const sessionId = resolveSessionId(opts.session);
207
+ const selectionRoot = resolveProjectRoot(sessionId, opts.projectRoot);
129
208
  const db = openPipelineDb();
130
209
  let rawComponents;
131
210
  try {
@@ -139,8 +218,18 @@ export function registerAnalyzeSelectAgentCommand(program) {
139
218
  process.exit(1);
140
219
  return;
141
220
  }
221
+ let selectionCandidates = rawComponents.map((component) => ({ component }));
222
+ if (selectionRoot) {
223
+ const repoIndex = await buildRepoContextIndex(selectionRoot).catch(() => null);
224
+ if (repoIndex) {
225
+ selectionCandidates = rawComponents.map((component) => ({
226
+ component,
227
+ selectionContext: buildSelectionContext(repoIndex, component),
228
+ }));
229
+ }
230
+ }
142
231
  if (opts.dryRun) {
143
- const first = rawComponents[0];
232
+ const first = selectionCandidates[0];
144
233
  const prompt = await buildPrompt({
145
234
  skill: 'select',
146
235
  mode: 'autonomous',
@@ -151,20 +240,20 @@ export function registerAnalyzeSelectAgentCommand(program) {
151
240
  process.exit(0);
152
241
  return;
153
242
  }
154
- const selectResults = await selectAllComponents(agent, opts.model, rawComponents, opts.verbose ?? false);
243
+ const selectResults = await selectAllComponents(agent, opts.model, selectionCandidates, opts.verbose ?? false);
155
244
  // Build decision map from results
156
245
  const decisions = new Map();
246
+ const audits = new Map();
157
247
  for (const r of selectResults) {
158
- if (!r.failed && r.decision) {
159
- decisions.set(r.componentName, r.decision);
160
- }
248
+ decisions.set(r.componentKey, r.decision);
249
+ audits.set(r.componentKey, r.audit);
161
250
  }
162
251
  // Load existing snapshot and apply decisions
163
252
  const artifactsRoot = getRefineArtifactsRoot();
164
253
  let snapshot;
165
254
  try {
166
255
  snapshot = await loadReviewInput(rawComponents, {
167
- reviewRoot: opts.projectRoot,
256
+ reviewRoot: selectionRoot ?? undefined,
168
257
  });
169
258
  snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
170
259
  }
@@ -177,25 +266,42 @@ export function registerAnalyzeSelectAgentCommand(program) {
177
266
  const updated = {
178
267
  ...snapshot,
179
268
  components: snapshot.components.map((comp) => {
180
- const decision = decisions.get(comp.name);
269
+ const key = componentKey(comp.originalProposal);
270
+ const decision = decisions.get(key);
271
+ const selectionAudit = audits.get(key);
181
272
  if (!decision)
182
273
  return comp;
183
- return { ...comp, status: decision };
274
+ const status = decision === 'needs-review' ? 'needs-review' : decision;
275
+ return { ...comp, status, selectionAudit };
184
276
  }),
185
277
  };
186
278
  await saveReviewState(paths.statePath, updated);
279
+ await Promise.all(updated.components
280
+ .filter((component) => component.selectionAudit)
281
+ .map((component) => appendReviewEvent(paths.eventsPath, {
282
+ type: 'select_agent_decision',
283
+ payload: {
284
+ component: component.name,
285
+ source: component.originalProposal.source,
286
+ status: component.status,
287
+ selectionAudit: component.selectionAudit,
288
+ },
289
+ })));
187
290
  const accepted = updated.components.filter((comp) => comp.status === 'accepted');
188
291
  const rejected = updated.components.filter((comp) => comp.status === 'rejected');
292
+ const unresolved = updated.components.filter((comp) => comp.status === 'needs-review');
189
293
  const failed = selectResults.filter((r) => r.failed);
190
294
  if (failed.length > 0) {
191
295
  process.stderr.write(c.red(`Failed (${failed.length}/${selectResults.length}):`) + '\n');
192
296
  for (const f of failed) {
193
- process.stderr.write(` ${c.red('✗')} ${f.componentName} ${c.dim(f.error ?? 'unknown error')}\n`);
297
+ process.stderr.write(` ${c.red('✗')} ${f.componentName} ${c.dim('all votes failed')}\n`);
194
298
  }
195
299
  }
196
300
  // Write step to DB
197
301
  const stepDb = openPipelineDb();
198
- const stepId = createStep(stepDb, sessionId, 'analyze select', { sessionId });
302
+ const stepId = createStep(stepDb, sessionId, 'analyze select', {
303
+ sessionId,
304
+ });
199
305
  try {
200
306
  const status = failed.length === selectResults.length ? 'failed' : 'complete';
201
307
  updateStep(stepDb, stepId, status, { sessionId });
@@ -203,6 +309,6 @@ export function registerAnalyzeSelectAgentCommand(program) {
203
309
  finally {
204
310
  stepDb.close();
205
311
  }
206
- process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length}\n`);
312
+ process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length} Needs review: ${unresolved.length}\n`);
207
313
  });
208
314
  }
@@ -0,0 +1,32 @@
1
+ export type SelectionDecision = 'accepted' | 'rejected' | 'needs-review';
2
+ export type SelectionVote = {
3
+ attempt: number;
4
+ decision: 'accepted' | 'rejected' | null;
5
+ reason?: string;
6
+ confidence?: number;
7
+ error?: string;
8
+ };
9
+ export type SelectionContextSummary = {
10
+ boundaryRoot: string;
11
+ siblingFileCount: number;
12
+ resolverReferenceCount: number;
13
+ hasParentUsageSite: boolean;
14
+ };
15
+ export type SelectionAudit = {
16
+ strategy: 'single-pass' | 'multi-vote-consensus';
17
+ voteCount: number;
18
+ acceptedVotes: number;
19
+ rejectedVotes: number;
20
+ failedVotes: number;
21
+ finalDecision: SelectionDecision;
22
+ winningReason?: string;
23
+ votes: SelectionVote[];
24
+ contextSummary?: SelectionContextSummary;
25
+ };
26
+ export type ConsensusResult = {
27
+ decision: SelectionDecision;
28
+ audit: SelectionAudit;
29
+ failed: boolean;
30
+ };
31
+ export declare const DEFAULT_REVIEW_VOTE_COUNT = 5;
32
+ export declare function summarizeSelectionVotes(votes: SelectionVote[], contextSummary?: SelectionContextSummary): ConsensusResult;
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_REVIEW_VOTE_COUNT = 5;
2
+ function preferredReason(votes, decision) {
3
+ const candidates = votes.filter((vote) => vote.decision === decision && vote.reason);
4
+ if (candidates.length === 0)
5
+ return undefined;
6
+ const ranked = new Map();
7
+ for (const vote of candidates) {
8
+ const reason = vote.reason;
9
+ const entry = ranked.get(reason) ?? { count: 0, bestConfidence: 0 };
10
+ entry.count += 1;
11
+ entry.bestConfidence = Math.max(entry.bestConfidence, vote.confidence ?? 0);
12
+ ranked.set(reason, entry);
13
+ }
14
+ return [...ranked.entries()].sort((a, b) => {
15
+ if (b[1].count !== a[1].count)
16
+ return b[1].count - a[1].count;
17
+ return b[1].bestConfidence - a[1].bestConfidence;
18
+ })[0]?.[0];
19
+ }
20
+ function decideFromVotes(votes, acceptedVotes, rejectedVotes) {
21
+ if (votes.length === 1) {
22
+ if (acceptedVotes === 1)
23
+ return 'accepted';
24
+ if (rejectedVotes === 1)
25
+ return 'rejected';
26
+ return 'needs-review';
27
+ }
28
+ // A 3-2 split is still too unstable for auto-selection.
29
+ if (votes.length >= DEFAULT_REVIEW_VOTE_COUNT &&
30
+ ((acceptedVotes === 3 && rejectedVotes === 2) || (acceptedVotes === 2 && rejectedVotes === 3))) {
31
+ return 'needs-review';
32
+ }
33
+ if (acceptedVotes > rejectedVotes)
34
+ return 'accepted';
35
+ if (rejectedVotes > acceptedVotes)
36
+ return 'rejected';
37
+ return 'needs-review';
38
+ }
39
+ export function summarizeSelectionVotes(votes, contextSummary) {
40
+ const acceptedVotes = votes.filter((vote) => vote.decision === 'accepted').length;
41
+ const rejectedVotes = votes.filter((vote) => vote.decision === 'rejected').length;
42
+ const failedVotes = votes.filter((vote) => vote.decision === null).length;
43
+ const decision = decideFromVotes(votes, acceptedVotes, rejectedVotes);
44
+ const winningReason = decision === 'accepted' || decision === 'rejected' ? preferredReason(votes, decision) : undefined;
45
+ return {
46
+ decision,
47
+ failed: failedVotes === votes.length,
48
+ audit: {
49
+ strategy: votes.length === 1 ? 'single-pass' : 'multi-vote-consensus',
50
+ voteCount: votes.length,
51
+ acceptedVotes,
52
+ rejectedVotes,
53
+ failedVotes,
54
+ finalDecision: decision,
55
+ winningReason,
56
+ votes,
57
+ contextSummary,
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,45 @@
1
+ import type { RawComponentDefinition } from '../../types.js';
2
+ import type { SelectionContextSummary } from './consensus.js';
3
+ export type SelectionImportSummary = {
4
+ source: string;
5
+ names: string[];
6
+ local: boolean;
7
+ resolvedPath?: string;
8
+ };
9
+ export type SelectionFileSummary = {
10
+ path: string;
11
+ exports: string[];
12
+ codeSnippet: string;
13
+ };
14
+ export type SelectionReference = {
15
+ path: string;
16
+ snippet: string;
17
+ };
18
+ export type SelectionContext = {
19
+ boundaryRoot: string;
20
+ componentFile: {
21
+ path: string;
22
+ code: string;
23
+ };
24
+ imports: SelectionImportSummary[];
25
+ exports: string[];
26
+ siblingFiles: SelectionFileSummary[];
27
+ resolverReferences: SelectionReference[];
28
+ parentUsageSite?: SelectionReference;
29
+ };
30
+ type IndexedFile = {
31
+ absolutePath: string;
32
+ relativePath: string;
33
+ directory: string;
34
+ text: string;
35
+ };
36
+ export type RepoContextIndex = {
37
+ root: string;
38
+ files: IndexedFile[];
39
+ byDirectory: Map<string, IndexedFile[]>;
40
+ filePaths: Set<string>;
41
+ };
42
+ export declare function buildRepoContextIndex(root: string): Promise<RepoContextIndex | null>;
43
+ export declare function buildSelectionContext(index: RepoContextIndex, component: RawComponentDefinition): SelectionContext | undefined;
44
+ export declare function summarizeSelectionContext(context: SelectionContext | undefined): SelectionContextSummary | undefined;
45
+ export {};
@@ -0,0 +1,253 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
4
+ const IGNORED_DIRECTORY_NAMES = new Set([
5
+ '.git',
6
+ '.next',
7
+ '.nuxt',
8
+ 'build',
9
+ 'coverage',
10
+ 'demo',
11
+ 'demos',
12
+ 'dist',
13
+ 'example',
14
+ 'examples',
15
+ 'node_modules',
16
+ 'out',
17
+ 'storybook-static',
18
+ ]);
19
+ const IGNORED_FILE_SUFFIXES = new Set([
20
+ '.stories.ts',
21
+ '.stories.tsx',
22
+ '.stories.js',
23
+ '.stories.jsx',
24
+ '.story.ts',
25
+ '.story.tsx',
26
+ '.story.js',
27
+ '.story.jsx',
28
+ '.spec.ts',
29
+ '.spec.tsx',
30
+ '.test.ts',
31
+ '.test.tsx',
32
+ ]);
33
+ const IMPORT_PATTERN = /import\s+(?:type\s+)?(.+?)\s+from\s+['"]([^'"]+)['"]/g;
34
+ const EXPORT_NAMED_PATTERN = /export\s+(?:const|function|class|type|interface|enum)\s+([A-Za-z0-9_]+)/g;
35
+ const EXPORT_DEFAULT_PATTERN = /export\s+default\s+([A-Za-z0-9_]+)/g;
36
+ const EXPORT_LIST_PATTERN = /export\s*\{([^}]+)\}/g;
37
+ const RESOLVER_HINT_PATTERN = /(resolver|registry|componentmap|component-map|componentresolver|resolvecomponent)/i;
38
+ const MAX_COMPONENT_SOURCE_CHARS = 8_000;
39
+ const MAX_SIBLING_FILES = 5;
40
+ const MAX_SIBLING_SNIPPET_CHARS = 1_200;
41
+ const MAX_REFERENCE_SNIPPETS = 3;
42
+ const MAX_REFERENCE_CHARS = 800;
43
+ function truncateText(text, maxChars) {
44
+ if (text.length <= maxChars)
45
+ return text;
46
+ return text.slice(0, maxChars) + '\n/* truncated */';
47
+ }
48
+ function isWithinRoot(path, root) {
49
+ const relativePath = relative(root, path);
50
+ return relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
51
+ }
52
+ async function collectSourceFiles(directory) {
53
+ const files = [];
54
+ async function visit(currentDirectory) {
55
+ const entries = await readdir(currentDirectory, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ const fullPath = join(currentDirectory, entry.name);
58
+ if (entry.isDirectory()) {
59
+ if (!IGNORED_DIRECTORY_NAMES.has(entry.name)) {
60
+ await visit(fullPath);
61
+ }
62
+ continue;
63
+ }
64
+ if (!entry.isFile())
65
+ continue;
66
+ const extension = extname(entry.name);
67
+ if (!SCANNED_FILE_EXTENSIONS.has(extension) || entry.name.endsWith('.d.ts'))
68
+ continue;
69
+ if ([...IGNORED_FILE_SUFFIXES].some((suffix) => entry.name.endsWith(suffix)))
70
+ continue;
71
+ files.push(fullPath);
72
+ }
73
+ }
74
+ await visit(directory);
75
+ return files.sort();
76
+ }
77
+ function parseImportedNames(importClause) {
78
+ const names = [];
79
+ const parts = importClause.split(',').map((part) => part.trim());
80
+ for (const part of parts) {
81
+ if (!part)
82
+ continue;
83
+ if (part.startsWith('{') && part.endsWith('}')) {
84
+ for (const named of part
85
+ .slice(1, -1)
86
+ .split(',')
87
+ .map((item) => item.trim())
88
+ .filter(Boolean)) {
89
+ const [local] = named.split(/\s+as\s+/i);
90
+ if (local)
91
+ names.push(local.trim());
92
+ }
93
+ continue;
94
+ }
95
+ const [local] = part.split(/\s+as\s+/i);
96
+ if (local)
97
+ names.push(local.trim());
98
+ }
99
+ return [...new Set(names)];
100
+ }
101
+ function resolveLocalImportPath(source, componentDirectory, root, filePaths) {
102
+ const basePath = resolve(componentDirectory, source);
103
+ const candidates = [
104
+ basePath,
105
+ ...[...SCANNED_FILE_EXTENSIONS].map((extension) => `${basePath}${extension}`),
106
+ ...[...SCANNED_FILE_EXTENSIONS].map((extension) => join(basePath, `index${extension}`)),
107
+ ];
108
+ for (const candidate of candidates) {
109
+ if (filePaths.has(candidate) && isWithinRoot(candidate, root)) {
110
+ return relative(root, candidate);
111
+ }
112
+ }
113
+ if (isWithinRoot(basePath, root)) {
114
+ return relative(root, basePath);
115
+ }
116
+ return undefined;
117
+ }
118
+ function parseImports(sourceText, componentDirectory, root, filePaths) {
119
+ const imports = [];
120
+ for (const match of sourceText.matchAll(IMPORT_PATTERN)) {
121
+ const importClause = match[1]?.trim() ?? '';
122
+ const source = match[2]?.trim() ?? '';
123
+ const local = source.startsWith('.');
124
+ const names = parseImportedNames(importClause);
125
+ const record = { source, names, local };
126
+ if (local) {
127
+ record.resolvedPath = resolveLocalImportPath(source, componentDirectory, root, filePaths);
128
+ }
129
+ imports.push(record);
130
+ }
131
+ return imports;
132
+ }
133
+ function parseExports(sourceText) {
134
+ const exports = new Set();
135
+ for (const match of sourceText.matchAll(EXPORT_NAMED_PATTERN)) {
136
+ if (match[1])
137
+ exports.add(match[1]);
138
+ }
139
+ for (const match of sourceText.matchAll(EXPORT_DEFAULT_PATTERN)) {
140
+ if (match[1])
141
+ exports.add(`default:${match[1]}`);
142
+ }
143
+ for (const match of sourceText.matchAll(EXPORT_LIST_PATTERN)) {
144
+ const items = match[1]
145
+ ?.split(',')
146
+ .map((item) => item.trim())
147
+ .filter(Boolean) ?? [];
148
+ for (const item of items)
149
+ exports.add(item);
150
+ }
151
+ return [...exports];
152
+ }
153
+ function extractSnippet(text, token, maxChars) {
154
+ const index = text.indexOf(token);
155
+ if (index === -1)
156
+ return truncateText(text, maxChars);
157
+ const start = Math.max(0, text.lastIndexOf('\n', Math.max(0, index - Math.floor(maxChars / 2))) + 1);
158
+ const endBoundary = text.indexOf('\n', index + Math.floor(maxChars / 2));
159
+ const end = endBoundary === -1 ? text.length : endBoundary;
160
+ return truncateText(text.slice(start, end).trim(), maxChars);
161
+ }
162
+ function summarizeSiblingFiles(root, files, currentFile) {
163
+ return files
164
+ .filter((file) => file.absolutePath !== currentFile)
165
+ .slice(0, MAX_SIBLING_FILES)
166
+ .map((file) => ({
167
+ path: relative(root, file.absolutePath),
168
+ exports: parseExports(file.text),
169
+ codeSnippet: truncateText(file.text, MAX_SIBLING_SNIPPET_CHARS),
170
+ }));
171
+ }
172
+ function findResolverReferences(root, files, componentName, currentFile) {
173
+ return files
174
+ .filter((file) => file.absolutePath !== currentFile)
175
+ .filter((file) => file.text.includes(componentName))
176
+ .filter((file) => RESOLVER_HINT_PATTERN.test(file.absolutePath) || RESOLVER_HINT_PATTERN.test(file.text))
177
+ .slice(0, MAX_REFERENCE_SNIPPETS)
178
+ .map((file) => ({
179
+ path: relative(root, file.absolutePath),
180
+ snippet: extractSnippet(file.text, componentName, MAX_REFERENCE_CHARS),
181
+ }));
182
+ }
183
+ function findParentUsageSite(root, files, componentName, currentFile) {
184
+ const usagePattern = new RegExp(`<${componentName}\\b|\\b${componentName}\\s*[),}]|\\b${componentName}\\s*:`);
185
+ const candidates = files.filter((file) => file.absolutePath !== currentFile &&
186
+ usagePattern.test(file.text) &&
187
+ !file.text.startsWith('// stub') &&
188
+ !RESOLVER_HINT_PATTERN.test(file.absolutePath) &&
189
+ !RESOLVER_HINT_PATTERN.test(file.text));
190
+ const match = candidates[0];
191
+ if (!match)
192
+ return undefined;
193
+ return {
194
+ path: relative(root, match.absolutePath),
195
+ snippet: extractSnippet(match.text, componentName, MAX_REFERENCE_CHARS),
196
+ };
197
+ }
198
+ export async function buildRepoContextIndex(root) {
199
+ const resolvedRoot = resolve(root);
200
+ const rootStats = await stat(resolvedRoot).catch(() => null);
201
+ if (!rootStats?.isDirectory())
202
+ return null;
203
+ const filePaths = await collectSourceFiles(resolvedRoot);
204
+ const files = await Promise.all(filePaths.map(async (absolutePath) => ({
205
+ absolutePath,
206
+ relativePath: relative(resolvedRoot, absolutePath),
207
+ directory: dirname(absolutePath),
208
+ text: await readFile(absolutePath, 'utf8').catch(() => ''),
209
+ })));
210
+ const byDirectory = new Map();
211
+ for (const file of files) {
212
+ const bucket = byDirectory.get(file.directory) ?? [];
213
+ bucket.push(file);
214
+ byDirectory.set(file.directory, bucket);
215
+ }
216
+ return {
217
+ root: resolvedRoot,
218
+ files,
219
+ byDirectory,
220
+ filePaths: new Set(files.map((file) => file.absolutePath)),
221
+ };
222
+ }
223
+ export function buildSelectionContext(index, component) {
224
+ const absolutePath = isAbsolute(component.source) ? component.source : resolve(index.root, component.source);
225
+ if (!isWithinRoot(absolutePath, index.root))
226
+ return undefined;
227
+ const componentFile = index.files.find((file) => file.absolutePath === absolutePath);
228
+ if (!componentFile)
229
+ return undefined;
230
+ const siblings = index.byDirectory.get(componentFile.directory) ?? [];
231
+ return {
232
+ boundaryRoot: index.root,
233
+ componentFile: {
234
+ path: componentFile.relativePath,
235
+ code: truncateText(componentFile.text, MAX_COMPONENT_SOURCE_CHARS),
236
+ },
237
+ imports: parseImports(componentFile.text, componentFile.directory, index.root, index.filePaths),
238
+ exports: parseExports(componentFile.text),
239
+ siblingFiles: summarizeSiblingFiles(index.root, siblings, componentFile.absolutePath),
240
+ resolverReferences: findResolverReferences(index.root, index.files, component.name, componentFile.absolutePath),
241
+ parentUsageSite: findParentUsageSite(index.root, index.files, component.name, componentFile.absolutePath),
242
+ };
243
+ }
244
+ export function summarizeSelectionContext(context) {
245
+ if (!context)
246
+ return undefined;
247
+ return {
248
+ boundaryRoot: context.boundaryRoot,
249
+ siblingFileCount: context.siblingFiles.length,
250
+ resolverReferenceCount: context.resolverReferences.length,
251
+ hasParentUsageSite: Boolean(context.parentUsageSite),
252
+ };
253
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.7.3-dev-build-a02165e.0",
3
+ "version": "2.7.3-dev-build-2a7c81e.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.3-dev-build-a02165e.0"
39
+ "@contentful/experience-design-system-types": "2.7.3-dev-build-2a7c81e.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: does it render visible UI?
23
+ ## The one rule: is this the author-facing UI component?
24
24
 
25
- **Accept** the component if it renders visible UI — 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.
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 | Why it is wrong |
41
- |---|---|
42
- | "Only atoms/low-level" | Atoms are first-class Component Types in Contentful Experience Orchestration |
43
- | "Tightly coupled to a parent component" | Contentful Experience Orchestration handles composition at the experience layer |
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" | Marketers are not the only users; designers and developers configure components too |
46
- | "Domain-specific or feature-level" | Press releases, newsrooms, search — all valid content components |
47
- | "Server-side or SSR" | Server components that render visible UI are valid |
48
- | "Few configurable props" | One or two props is fine |
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 | Why |
55
- |---|---|
56
- | **React hooks** | `useXxx` / `UseXxx` — functions, not renderable components. Zero visual output. |
57
- | **Pure context providers** | Wrap children to pass context but render no UI themselves |
58
- | **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 |
59
- | **Analytics and event tracking** | Components that only fire analytics events and render nothing visible |
60
- | **Security utilities** | Non-visual security primitives with no rendered output |
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
- > **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.
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"}