@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.7.3",
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",
@@ -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', { 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);
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 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,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 buildComponentData(component) {
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, component, index, total, verbose) {
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(component)], null, 2),
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
- process.stderr.write(` ${pos} ${c.bold(component.name)}\n${outputBuf}`);
106
+ if (verbose) {
107
+ process.stderr.write(` ${pos} ${c.bold(component.name)}\n${outputBuf}`);
108
+ }
67
109
  if (result.timedOut) {
68
- return { componentName: component.name, decision: null, failed: true, error: 'timed out' };
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 w of warnings)
81
- process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${w}\n`);
129
+ for (const warning of warnings) {
130
+ process.stderr.write(` ${c.yellow('⚠')} ${component.name}: ${warning}\n`);
131
+ }
82
132
  }
83
- const call = calls.find((call) => call.name === component.name) ?? calls[0];
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
- return { componentName: component.name, decision, reason: call.reason, failed: false };
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 = rawComponents[0];
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, rawComponents, opts.verbose ?? false);
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
- if (!r.failed && r.decision) {
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: opts.projectRoot,
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 decision = decisions.get(comp.name);
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} ${c.dim(f.error ?? 'unknown error')}\n`);
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', { sessionId });
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
+ }
@@ -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;
@@ -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",
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.3"
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: 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"}