@contentful/experience-design-system-cli 2.7.3-dev-build-2a7c81e.0 → 2.7.3

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-2a7c81e.0",
3
+ "version": "2.7.3",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,6 @@ 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';
14
13
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
15
14
  const IGNORED_DIRECTORY_NAMES = new Set([
16
15
  '.git',
@@ -47,13 +46,6 @@ function pluralize(count, singular, plural = `${singular}s`) {
47
46
  function resolveFromProjectRoot(projectRoot, inputPath) {
48
47
  return isAbsolute(inputPath) ? inputPath : resolve(projectRoot, inputPath);
49
48
  }
50
- function wrapperConfidenceToIssueCount(confidence) {
51
- if (confidence >= 4)
52
- return 2;
53
- if (confidence === 3)
54
- return 1;
55
- return 0;
56
- }
57
49
  async function pathExists(path) {
58
50
  return Boolean(await stat(path).catch(() => null));
59
51
  }
@@ -129,43 +121,22 @@ export function registerAnalyzeCommand(program) {
129
121
  inputPath: projectRoot,
130
122
  outDir,
131
123
  });
132
- const stepId = createStep(db, sessionId, 'analyze extract', {
133
- project: projectRoot,
134
- });
124
+ const stepId = createStep(db, sessionId, 'analyze extract', { project: projectRoot });
135
125
  const classifiedComponents = extraction.components.map(preClassifyComponent);
136
- const inspectedComponents = await Promise.all(classifiedComponents.map(async (component) => ({
137
- component,
138
- inspection: await inspectComponentSource(component),
139
- })));
140
126
  const filteredComponents = [];
141
127
  const filterWarnings = [];
142
- for (const { component, inspection } of inspectedComponents) {
128
+ for (const component of classifiedComponents) {
143
129
  const verdict = isNonAuthorableComponent(component);
144
- const keepDespiteZeroSurface = verdict.skip && verdict.reason === 'component has no props and no slots' && inspection.keepDespiteZeroSurface;
145
- if (verdict.skip && !keepDespiteZeroSurface) {
130
+ if (verdict.skip) {
146
131
  filterWarnings.push(`Skipped non-authorable component: ${component.name} (${verdict.reason})`);
147
132
  continue;
148
133
  }
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
- });
134
+ const { confidence, reasons } = computeExtractionScore(component);
164
135
  filteredComponents.push({
165
136
  ...component,
166
137
  extractionConfidence: confidence,
167
138
  reviewReasons: reasons,
168
- needsReview: deriveNeedsReview(confidence) || inspection.wrapperConfidence >= 4 || inspection.keepDespiteZeroSurface,
139
+ needsReview: deriveNeedsReview(confidence),
169
140
  });
170
141
  }
171
142
  storeRawComponents(db, sessionId, filteredComponents);
@@ -4,9 +4,5 @@ export type ExtractionScore = {
4
4
  confidence: ExtractionConfidence;
5
5
  reasons: string[];
6
6
  };
7
- export interface ExtractionScoreOptions {
8
- additionalIssueCount?: number;
9
- additionalReasons?: string[];
10
- }
11
- export declare function computeExtractionScore(component: RawComponentDefinition, options?: ExtractionScoreOptions): ExtractionScore;
7
+ export declare function computeExtractionScore(component: RawComponentDefinition): ExtractionScore;
12
8
  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, options = {}) {
47
+ function countIssues(component) {
48
48
  let count = 0;
49
49
  const reasons = [];
50
50
  if (component.props.length === 0 && component.slots.length === 0) {
@@ -72,10 +72,6 @@ function countIssues(component, options = {}) {
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
- }
79
75
  return { count, reasons: [...new Set(reasons)] };
80
76
  }
81
77
  // Maps issue count to a 1–5 confidence scale:
@@ -95,8 +91,8 @@ function issueCountToConfidence(count) {
95
91
  return 2;
96
92
  return 1;
97
93
  }
98
- export function computeExtractionScore(component, options = {}) {
99
- const { count, reasons } = countIssues(component, options);
94
+ export function computeExtractionScore(component) {
95
+ const { count, reasons } = countIssues(component);
100
96
  return {
101
97
  confidence: issueCountToConfidence(count),
102
98
  reasons,
@@ -1,5 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createRequire } from 'node:module';
2
3
  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;
3
8
  export function TopBar({ subcommand, hints }) {
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(' ') })] }));
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] })] }));
5
10
  }
@@ -1,5 +1,4 @@
1
1
  import type { RawComponentDefinition } from '../../types.js';
2
- import type { SelectionAudit } from '../select-agent/consensus.js';
3
2
  export type PreviewAnnotation = 'new' | 'changed' | 'removed' | 'breaking';
4
3
  export type ReviewComponentStatus = 'needs-review' | 'reviewed' | 'accepted' | 'rejected';
5
4
  export type ReviewComponentRecord = {
@@ -10,7 +9,6 @@ export type ReviewComponentRecord = {
10
9
  originalProposal: RawComponentDefinition;
11
10
  editedProposal: RawComponentDefinition;
12
11
  status: ReviewComponentStatus;
13
- selectionAudit?: SelectionAudit;
14
12
  };
15
13
  export type ReviewComponentDetail = {
16
14
  id: string;
@@ -18,7 +16,6 @@ export type ReviewComponentDetail = {
18
16
  originalProposal: RawComponentDefinition;
19
17
  editedProposal: RawComponentDefinition;
20
18
  status: ReviewComponentStatus;
21
- selectionAudit?: SelectionAudit;
22
19
  };
23
20
  export type ReviewComponentSummary = {
24
21
  id: string;
@@ -17,7 +17,6 @@ export function createReviewSessionDetail(session) {
17
17
  originalProposal: component.originalProposal,
18
18
  editedProposal: component.editedProposal,
19
19
  status: component.status,
20
- selectionAudit: component.selectionAudit,
21
20
  })),
22
21
  };
23
22
  }
@@ -1,17 +1,12 @@
1
1
  import { openPipelineDb, loadRawComponents, createStep, updateStep } from '../../session/db.js';
2
- import { appendReviewEvent, getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState, } from '../select/persistence.js';
2
+ import { 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';
10
7
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
11
8
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
12
9
  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;
15
10
  function resolveSessionId(sessionFlag) {
16
11
  if (sessionFlag)
17
12
  return sessionFlag;
@@ -35,12 +30,8 @@ function resolveSessionId(sessionFlag) {
35
30
  db.close();
36
31
  }
37
32
  }
38
- function componentKey(component) {
39
- return `${component.name}::${component.source}`;
40
- }
41
- function buildComponentData(candidate) {
42
- const { component, selectionContext } = candidate;
43
- const payload = {
33
+ function buildComponentData(component) {
34
+ return {
44
35
  name: component.name,
45
36
  source: component.source,
46
37
  framework: component.framework,
@@ -49,126 +40,57 @@ function buildComponentData(candidate) {
49
40
  propNames: component.props.slice(0, 8).map((p) => p.name),
50
41
  props: component.props,
51
42
  slots: component.slots,
52
- extractionConfidence: component.extractionConfidence ?? null,
53
- needsReview: component.needsReview ?? false,
54
43
  };
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;
71
44
  }
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);
45
+ async function selectOneComponent(agent, model, component, index, total, verbose) {
99
46
  const prompt = await buildPrompt({
100
47
  skill: 'select',
101
48
  mode: 'autonomous',
102
- rawComponentsInline: JSON.stringify([buildComponentData(candidate)], null, 2),
49
+ rawComponentsInline: JSON.stringify([buildComponentData(component)], null, 2),
103
50
  outDir: process.cwd(),
104
51
  });
105
52
  const pos = c.dim(`[${index + 1}/${total}]`);
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
- });
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' };
158
69
  }
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
- };
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
+ };
91
+ }
92
+ const decision = call.tool === 'select_component' ? 'accepted' : 'rejected';
93
+ return { componentName: component.name, decision, reason: call.reason, failed: false };
172
94
  }
173
95
  async function selectAllComponents(agent, model, components, verbose) {
174
96
  const concurrency = Number(process.env.EDS_GENERATE_CONCURRENCY ?? DEFAULT_CONCURRENCY);
@@ -204,7 +126,6 @@ export function registerAnalyzeSelectAgentCommand(program) {
204
126
  }
205
127
  const agent = opts.agent;
206
128
  const sessionId = resolveSessionId(opts.session);
207
- const selectionRoot = resolveProjectRoot(sessionId, opts.projectRoot);
208
129
  const db = openPipelineDb();
209
130
  let rawComponents;
210
131
  try {
@@ -218,18 +139,8 @@ export function registerAnalyzeSelectAgentCommand(program) {
218
139
  process.exit(1);
219
140
  return;
220
141
  }
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
- }
231
142
  if (opts.dryRun) {
232
- const first = selectionCandidates[0];
143
+ const first = rawComponents[0];
233
144
  const prompt = await buildPrompt({
234
145
  skill: 'select',
235
146
  mode: 'autonomous',
@@ -240,20 +151,20 @@ export function registerAnalyzeSelectAgentCommand(program) {
240
151
  process.exit(0);
241
152
  return;
242
153
  }
243
- const selectResults = await selectAllComponents(agent, opts.model, selectionCandidates, opts.verbose ?? false);
154
+ const selectResults = await selectAllComponents(agent, opts.model, rawComponents, opts.verbose ?? false);
244
155
  // Build decision map from results
245
156
  const decisions = new Map();
246
- const audits = new Map();
247
157
  for (const r of selectResults) {
248
- decisions.set(r.componentKey, r.decision);
249
- audits.set(r.componentKey, r.audit);
158
+ if (!r.failed && r.decision) {
159
+ decisions.set(r.componentName, r.decision);
160
+ }
250
161
  }
251
162
  // Load existing snapshot and apply decisions
252
163
  const artifactsRoot = getRefineArtifactsRoot();
253
164
  let snapshot;
254
165
  try {
255
166
  snapshot = await loadReviewInput(rawComponents, {
256
- reviewRoot: selectionRoot ?? undefined,
167
+ reviewRoot: opts.projectRoot,
257
168
  });
258
169
  snapshot = await ensureRefineSession(sessionId, artifactsRoot, snapshot);
259
170
  }
@@ -266,42 +177,25 @@ export function registerAnalyzeSelectAgentCommand(program) {
266
177
  const updated = {
267
178
  ...snapshot,
268
179
  components: snapshot.components.map((comp) => {
269
- const key = componentKey(comp.originalProposal);
270
- const decision = decisions.get(key);
271
- const selectionAudit = audits.get(key);
180
+ const decision = decisions.get(comp.name);
272
181
  if (!decision)
273
182
  return comp;
274
- const status = decision === 'needs-review' ? 'needs-review' : decision;
275
- return { ...comp, status, selectionAudit };
183
+ return { ...comp, status: decision };
276
184
  }),
277
185
  };
278
186
  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
- })));
290
187
  const accepted = updated.components.filter((comp) => comp.status === 'accepted');
291
188
  const rejected = updated.components.filter((comp) => comp.status === 'rejected');
292
- const unresolved = updated.components.filter((comp) => comp.status === 'needs-review');
293
189
  const failed = selectResults.filter((r) => r.failed);
294
190
  if (failed.length > 0) {
295
191
  process.stderr.write(c.red(`Failed (${failed.length}/${selectResults.length}):`) + '\n');
296
192
  for (const f of failed) {
297
- process.stderr.write(` ${c.red('✗')} ${f.componentName} ${c.dim('all votes failed')}\n`);
193
+ process.stderr.write(` ${c.red('✗')} ${f.componentName} ${c.dim(f.error ?? 'unknown error')}\n`);
298
194
  }
299
195
  }
300
196
  // Write step to DB
301
197
  const stepDb = openPipelineDb();
302
- const stepId = createStep(stepDb, sessionId, 'analyze select', {
303
- sessionId,
304
- });
198
+ const stepId = createStep(stepDb, sessionId, 'analyze select', { sessionId });
305
199
  try {
306
200
  const status = failed.length === selectResults.length ? 'failed' : 'complete';
307
201
  updateStep(stepDb, stepId, status, { sessionId });
@@ -309,6 +203,6 @@ export function registerAnalyzeSelectAgentCommand(program) {
309
203
  finally {
310
204
  stepDb.close();
311
205
  }
312
- process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length} Needs review: ${unresolved.length}\n`);
206
+ process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length}\n`);
313
207
  });
314
208
  }
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-2a7c81e.0",
3
+ "version": "2.7.3",
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-2a7c81e.0"
39
+ "@contentful/experience-design-system-types": "2.7.3"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@tsconfig/node24": "^24.0.3",
@@ -20,16 +20,14 @@ The entity being defined — a **Component Type** — is the schema that tells C
20
20
 
21
21
  ---
22
22
 
23
- ## The one rule: is this the author-facing UI component?
23
+ ## The one rule: does it render visible UI?
24
24
 
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:
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.
28
26
 
27
+ **Reject** the component only if its primary purpose produces zero visual output:
29
28
  - It is a React hook (name starts `use` or `Use`)
30
29
  - It is a pure context provider with no visual output
31
30
  - 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
33
31
  - It is an analytics/event-tracking component (fires events, renders nothing)
34
32
  - It is a security or infrastructure utility (no UI at all)
35
33
 
@@ -39,49 +37,29 @@ The entity being defined — a **Component Type** — is the schema that tells C
39
37
 
40
38
  These are **not** valid reasons to reject a component:
41
39
 
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 |
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 |
46
44
  | "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 |
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 |
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 |
51
49
 
52
50
  ---
53
51
 
54
52
  ## Reject only these categories
55
53
 
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
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 |
78
61
 
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`.
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.
85
63
 
86
64
  ---
87
65
 
@@ -98,7 +76,6 @@ Emit one JSON object on a single line. Lines not starting with `{` are ignored b
98
76
  ```
99
77
 
100
78
  **Rules:**
101
-
102
79
  - Emit exactly one JSON object, on one line. No multi-line JSON. No markdown fences.
103
80
  - The `name` must match the component name in the input.
104
81
  - `reason` is a brief phrase documenting your decision.
@@ -159,12 +136,6 @@ Providers — React context provider with no visual output
159
136
  {"tool":"reject_component","name":"Providers","reason":"pure context provider — no visual output"}
160
137
  ```
161
138
 
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
-
168
139
  ```
169
140
  FooterIcon — atom: renders an icon with optional label in the footer
170
141
  {"tool":"select_component","name":"FooterIcon","reason":"UI atom — renders icon with configurable icon and label"}
@@ -1,13 +0,0 @@
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[];
@@ -1,186 +0,0 @@
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,32 +0,0 @@
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;
@@ -1,60 +0,0 @@
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
- }
@@ -1,45 +0,0 @@
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 {};
@@ -1,253 +0,0 @@
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
- }