@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 +1 -1
- package/dist/src/analyze/command.js +5 -34
- package/dist/src/analyze/extract/scoring.d.ts +1 -5
- package/dist/src/analyze/extract/scoring.js +3 -7
- package/dist/src/analyze/select/tui/components/TopBar.js +6 -1
- package/dist/src/analyze/select/types.d.ts +0 -3
- package/dist/src/analyze/select/types.js +0 -1
- package/dist/src/analyze/select-agent/command.js +56 -162
- package/package.json +2 -2
- package/skills/select-components.md +19 -48
- package/dist/src/analyze/extract/source-inspection.d.ts +0 -13
- package/dist/src/analyze/extract/source-inspection.js +0 -186
- package/dist/src/analyze/select-agent/consensus.d.ts +0 -32
- package/dist/src/analyze/select-agent/consensus.js +0 -60
- package/dist/src/analyze/select-agent/context-builder.d.ts +0 -45
- package/dist/src/analyze/select-agent/context-builder.js +0 -253
package/dist/package.json
CHANGED
|
@@ -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
|
|
128
|
+
for (const component of classifiedComponents) {
|
|
143
129
|
const verdict = isNonAuthorableComponent(component);
|
|
144
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
99
|
-
const { count, reasons } = countIssues(component
|
|
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 }),
|
|
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;
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { openPipelineDb, loadRawComponents, createStep, updateStep } from '../../session/db.js';
|
|
2
|
-
import {
|
|
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
|
|
39
|
-
return
|
|
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
|
|
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(
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
249
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
|
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
|
|
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:
|
|
23
|
+
## The one rule: does it render visible UI?
|
|
24
24
|
|
|
25
|
-
**Accept** the component if it
|
|
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
|
|
43
|
-
|
|
44
|
-
| "Only atoms/low-level"
|
|
45
|
-
| "Tightly coupled to a parent component"
|
|
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"
|
|
48
|
-
| "Domain-specific or feature-level"
|
|
49
|
-
| "Server-side or SSR"
|
|
50
|
-
| "Few configurable props"
|
|
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
|
|
57
|
-
|
|
58
|
-
| **React hooks**
|
|
59
|
-
| **Pure context providers**
|
|
60
|
-
| **A/B testing or personalization wrappers** | Components whose
|
|
61
|
-
| **
|
|
62
|
-
| **
|
|
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
|
-
|
|
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
|
-
}
|