@contentful/experience-design-system-cli 2.5.3-dev-build-727fd69.0 → 2.5.3-dev-build-b2e98f1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @contentful/experience-design-system-cli
2
2
 
3
- CLI for extracting, reviewing, generating, validating, and pushing Contentful Experience Design System component definitions into [Experience Orchestration (ExO)](https://www.contentful.com/help/experience-orchestration/).
4
-
5
- **Binary names:** `experiences` (primary), `exo` (alias), `experience-design-system-cli` (long-form). All three point to the same executable.
3
+ CLI for extracting, reviewing, generating, validating, and pushing Contentful Experience Design System component definitions.
6
4
 
7
5
  ## Pipeline Overview
8
6
 
@@ -14,6 +12,8 @@ analyze extract → analyze select-agent → generate components →
14
12
 
15
13
  `analyze select-agent` uses an AI agent to decide which extracted components belong in Contentful Experience Orchestration. You can substitute it with `analyze select` for manual/pattern-based selection.
16
14
 
15
+ **Determinism boundary.** `analyze extract` is fully deterministic: ts-morph AST parsing produces the same component list and prop shape on every run, then a deterministic pre-classifier and a structural non-authorable filter (see below) shape the output. AI enters the pipeline at `analyze select-agent` and `generate components`, where coding agents make per-component decisions. This split keeps the extracted artifact reproducible and inspectable — if an extracted component looks wrong, the cause is in the rules, not in agent variability.
16
+
17
17
  All intermediate data flows through a local SQLite session database (`~/.contentful/experience-design-system-cli/pipeline.db`). No JSON files are written between steps — each command reads its inputs from the session and writes its outputs back to it. Use `print` to export session data to JSON files on demand (e.g. for inspection or manual validation).
18
18
 
19
19
  ---
@@ -93,6 +93,18 @@ Scans `.tsx`, `.ts`, `.jsx`, `.js`, `.vue`, and `.astro` files. Ignores `node_mo
93
93
 
94
94
  Writes extracted components to the session database and prints `session=<id>` to stdout. In an interactive terminal, a scrollable TUI displays the extraction summary (including a warning for any components with 0 props and 0 slots); press `q` or `Enter` to exit.
95
95
 
96
+ #### Non-authorable component filter
97
+
98
+ Before storing components, `analyze extract` runs a deterministic filter that drops infrastructure components which have no authoring surface (Context providers, analytics shims, security utilities, layout helpers). The filter uses prop-shape signals only — no component-name or source-path patterns — so it works regardless of how a host repo organizes its design system. A component is dropped if **any** of:
99
+
100
+ 1. Zero props and zero slots.
101
+ 2. Source calls `createContext()` and the component has a prop literally named `value`.
102
+ 3. Source calls `createContext()` and the component has zero props.
103
+ 4. Source calls `createContext()` and the component has exactly one non-handler prop.
104
+ 5. Every prop is a handler or ref (function-typed, `EventHandler`, `Dispatch`, `SetStateAction`, `Ref<>`, name starts with `on`/`set`, or named `ref`/`innerRef`).
105
+
106
+ Each dropped component is reported as a warning (`Skipped non-authorable component: <Name> (<reason>)`) so the operator can audit. The rule set was selected via Monte-Carlo evaluation against a hand-labelled corpus to maximize precision (zero false positives) over recall — components that look like normal authoring surface but are actually infrastructure are deferred to the AI selection stage rather than dropped here.
107
+
96
108
  ---
97
109
 
98
110
  ### `analyze select`
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.5.3-dev-build-727fd69.0",
3
+ "version": "2.5.3-dev-build-b2e98f1.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import { registerAnalyzeEditCommand } from './select/command.js';
8
8
  import { registerAnalyzeSelectAgentCommand } from './select-agent/command.js';
9
9
  import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents } from '../session/db.js';
10
10
  import { preClassifyComponent } from './pre-classify.js';
11
+ import { isNonAuthorableComponent } from './extract/non-authorable-filter.js';
11
12
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
12
13
  const IGNORED_DIRECTORY_NAMES = new Set([
13
14
  '.git',
@@ -121,23 +122,32 @@ export function registerAnalyzeCommand(program) {
121
122
  });
122
123
  const stepId = createStep(db, sessionId, 'analyze extract', { project: projectRoot });
123
124
  const classifiedComponents = extraction.components.map(preClassifyComponent);
124
- storeRawComponents(db, sessionId, classifiedComponents);
125
+ const filteredComponents = [];
126
+ const filterWarnings = [];
127
+ for (const component of classifiedComponents) {
128
+ const verdict = isNonAuthorableComponent(component);
129
+ if (verdict.skip) {
130
+ filterWarnings.push(`Skipped non-authorable component: ${component.name} (${verdict.reason})`);
131
+ continue;
132
+ }
133
+ filteredComponents.push(component);
134
+ }
135
+ storeRawComponents(db, sessionId, filteredComponents);
125
136
  updateStep(db, stepId, 'complete', { sessionId });
126
137
  db.close();
127
- const zeroPropComponents = classifiedComponents.filter((c) => c.props.length === 0 && c.slots.length === 0);
138
+ const allWarnings = [...extraction.warnings, ...filterWarnings];
128
139
  const analyzeResult = {
129
140
  sourceDirectory,
130
141
  sessionId,
131
142
  fileCount: sourceFiles.length,
132
- components: classifiedComponents.map((c) => ({
143
+ components: filteredComponents.map((c) => ({
133
144
  name: c.name,
134
145
  framework: c.framework,
135
146
  propCount: c.props.length,
136
147
  slotCount: c.slots.length,
137
- warnings: extraction.warnings.filter((w) => w.startsWith(c.name + ':')),
148
+ warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
138
149
  })),
139
- totalWarnings: extraction.warnings.length,
140
- zeroPropComponents: zeroPropComponents.map((c) => ({ name: c.name, source: c.source })),
150
+ totalWarnings: allWarnings.length,
141
151
  };
142
152
  if (process.stdout.isTTY) {
143
153
  const { waitUntilExit } = render(createElement(AnalyzeView, {
@@ -153,15 +163,9 @@ export function registerAnalyzeCommand(program) {
153
163
  `Scanned ${pluralize(sourceFiles.length, 'source file')} in ${sourceDirectory}`,
154
164
  `Extracted ${pluralize(extraction.components.length, 'component')}`,
155
165
  ];
156
- if (zeroPropComponents.length > 0) {
157
- summaryLines.push(`Warning: ${pluralize(zeroPropComponents.length, 'component')} extracted with 0 props and 0 slots:`);
158
- summaryLines.push(...zeroPropComponents.map((c) => ` ${c.name} (${c.source})`));
159
- summaryLines.push('These may be Storybook stories, context providers, or SSR utilities.');
160
- summaryLines.push("Review them in 'analyze select' before generating.");
161
- }
162
- if (extraction.warnings.length > 0) {
163
- summaryLines.push(`Warnings (${extraction.warnings.length}):`);
164
- summaryLines.push(...extraction.warnings.map((w) => `- ${w}`));
166
+ if (allWarnings.length > 0) {
167
+ summaryLines.push(`Warnings (${allWarnings.length}):`);
168
+ summaryLines.push(...allWarnings.map((w) => `- ${w}`));
165
169
  }
166
170
  else {
167
171
  summaryLines.push('Warnings: none');
@@ -0,0 +1,16 @@
1
+ import type { RawComponentDefinition } from '../../types.js';
2
+ export interface NonAuthorableResult {
3
+ skip: boolean;
4
+ reason?: string;
5
+ }
6
+ /**
7
+ * Decides whether a component is non-authorable infrastructure (Context.Provider,
8
+ * analytics shim, layout-only utility, etc.) and should be filtered out of the
9
+ * analyze TUI before authoring-token generation.
10
+ *
11
+ * Uses prop-shape signals only — no component-name or source-path patterns.
12
+ * A design system can live anywhere under any naming convention; relying on
13
+ * suffixes like `*Provider` or paths like `src/lib/` would silently fail in
14
+ * other repos.
15
+ */
16
+ export declare function isNonAuthorableComponent(component: RawComponentDefinition): NonAuthorableResult;
@@ -0,0 +1,60 @@
1
+ const HANDLER_TYPE_PATTERN = /=>|EventHandler|Dispatch<|SetStateAction/;
2
+ const REF_TYPE_PATTERN = /Ref<|RefObject<|MutableRefObject/;
3
+ function isHandlerOrRefProp(prop) {
4
+ if (HANDLER_TYPE_PATTERN.test(prop.type))
5
+ return true;
6
+ if (REF_TYPE_PATTERN.test(prop.type))
7
+ return true;
8
+ if (/^on[A-Z]/.test(prop.name) || /^set[A-Z]/.test(prop.name))
9
+ return true;
10
+ if (prop.name === 'ref' || prop.name === 'innerRef')
11
+ return true;
12
+ return false;
13
+ }
14
+ /**
15
+ * Decides whether a component is non-authorable infrastructure (Context.Provider,
16
+ * analytics shim, layout-only utility, etc.) and should be filtered out of the
17
+ * analyze TUI before authoring-token generation.
18
+ *
19
+ * Uses prop-shape signals only — no component-name or source-path patterns.
20
+ * A design system can live anywhere under any naming convention; relying on
21
+ * suffixes like `*Provider` or paths like `src/lib/` would silently fail in
22
+ * other repos.
23
+ */
24
+ export function isNonAuthorableComponent(component) {
25
+ const { props, slots, usesCreateContext } = component;
26
+ // R1: zero props AND zero slots — nothing for an editor to author.
27
+ // Catches analytics scripts, GTM tags, layout fixers, security tokens, etc.
28
+ if (props.length === 0 && slots.length === 0) {
29
+ return { skip: true, reason: 'component has no props and no slots' };
30
+ }
31
+ // R2: createContext source + prop literally named `value` — canonical
32
+ // `<Context.Provider value={...}>` call site.
33
+ if (usesCreateContext && props.some((p) => p.name === 'value')) {
34
+ return {
35
+ skip: true,
36
+ reason: 'source uses createContext and component exposes a Context.Provider value prop',
37
+ };
38
+ }
39
+ // R3: createContext source + zero props — Provider wrapper that hard-codes
40
+ // the context value internally (e.g. FontProvider, BottomSheetProvider).
41
+ if (usesCreateContext && props.length === 0) {
42
+ return { skip: true, reason: 'source uses createContext and component has no props' };
43
+ }
44
+ // R4: createContext source + exactly one non-handler prop — Provider that
45
+ // takes the context value as its sole data prop, named after the data
46
+ // (e.g. `LocaleProvider({ locale })`, `NavigationProvider({ navItems })`).
47
+ if (usesCreateContext && props.length === 1 && !isHandlerOrRefProp(props[0])) {
48
+ return {
49
+ skip: true,
50
+ reason: 'source uses createContext and component has a single non-handler prop',
51
+ };
52
+ }
53
+ // R5: every prop is a handler/ref — pure data plumbing, no authoring surface.
54
+ // Catches components like `OsanoCookiePlaceholder({ onBannerLoaded })` or
55
+ // `FeedbackCard({ setShowModal })`.
56
+ if (props.length > 0 && props.every(isHandlerOrRefProp)) {
57
+ return { skip: true, reason: 'every prop is a handler or ref' };
58
+ }
59
+ return { skip: false };
60
+ }
@@ -233,6 +233,14 @@ function getBoundedImportedJsxDomSurface(tagNameNode) {
233
233
  function isStencilFile(sourceFile) {
234
234
  return sourceFile.getImportDeclarations().some((imp) => imp.getModuleSpecifierValue() === '@stencil/core');
235
235
  }
236
+ function sourceFileUsesCreateContext(sourceFile) {
237
+ // Match React.createContext(...) and createContext(...) call expressions
238
+ return sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => {
239
+ const expr = call.getExpression();
240
+ const text = expr.getText();
241
+ return text === 'createContext' || text === 'React.createContext';
242
+ });
243
+ }
236
244
  function isNextJsComponent(filePath, exportedNames) {
237
245
  const normalized = filePath.replace(/\\/g, '/');
238
246
  const isAppRouterFile = /\/app\/.*\/(page|layout)\.[jt]sx?$/.test(normalized);
@@ -1634,6 +1642,7 @@ export async function extractReactComponents(filePaths) {
1634
1642
  function extractFromSourceFile(sourceFile, isNext) {
1635
1643
  const components = [];
1636
1644
  const exported = sourceFile.getExportedDeclarations();
1645
+ const usesCreateContext = sourceFileUsesCreateContext(sourceFile);
1637
1646
  for (const [exportKey, declarations] of exported) {
1638
1647
  let name = exportKey;
1639
1648
  if (exportKey === 'default') {
@@ -1668,6 +1677,7 @@ function extractFromSourceFile(sourceFile, isNext) {
1668
1677
  framework: isNext ? 'next' : 'react',
1669
1678
  props: [],
1670
1679
  slots: [],
1680
+ ...(usesCreateContext && { usesCreateContext: true }),
1671
1681
  });
1672
1682
  continue;
1673
1683
  }
@@ -1752,6 +1762,7 @@ function extractFromSourceFile(sourceFile, isNext) {
1752
1762
  framework: isNext ? 'next' : 'react',
1753
1763
  props: propsAfterSlotExpansion,
1754
1764
  slots: finalSlots,
1765
+ ...(usesCreateContext && { usesCreateContext: true }),
1755
1766
  });
1756
1767
  }
1757
1768
  return components;
@@ -11,10 +11,6 @@ export type AnalyzeViewResult = {
11
11
  warnings: string[];
12
12
  }>;
13
13
  totalWarnings: number;
14
- zeroPropComponents?: Array<{
15
- name: string;
16
- source: string;
17
- }>;
18
14
  };
19
15
  type AnalyzeViewProps = {
20
16
  result: AnalyzeViewResult;
@@ -31,7 +31,7 @@ export function AnalyzeView({ result, onExit }) {
31
31
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze", hints: [
32
32
  { key: '?', label: 'help' },
33
33
  { key: 'q', label: 'quit' },
34
- ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: "Components" }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components.slice(scrollOffset).map((component) => (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots') }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name))), result.zeroPropComponents && result.zeroPropComponents.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Zero-prop components (' + result.zeroPropComponents.length + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { color: "yellow", children: ' These may be Storybook stories, context providers, or SSR utilities.' }), _jsx(Text, { color: "yellow", children: ' Review them in `analyze select` before generating.' }), _jsx(Text, { children: " " }), result.zeroPropComponents.map((c) => (_jsx(Text, { color: "yellow", children: ' ' + c.name + ' (' + c.source + ')' }, c.name)))] })), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
34
+ ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: "Components" }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components.slice(scrollOffset).map((component) => (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots') }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name))), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
35
35
  .filter((c) => c.warnings.length > 0)
36
36
  .flatMap((c) => c.warnings.map((w) => ({ component: c.name, warning: w })))
37
37
  .map((w, i) => (_jsx(Text, { color: "yellow", children: ' ⚠ ' + w.component + ': ' + w.warning }, i)))] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: 'Run: analyze select --session ' + result.sessionId }), _jsx(Text, { children: " " })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or q to exit" }) })] }));
@@ -1,5 +1,5 @@
1
- import { createElement } from 'react';
2
- import { render } from 'ink';
1
+ import { createElement, useState } from 'react';
2
+ import { render, useInput } from 'ink';
3
3
  import { access, readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { validateCDF, flattenDTCG, validateDTCG, buildManifest, buildFilteredManifest, } from '@contentful/experience-design-system-types';
@@ -7,8 +7,6 @@ import { ApiError, ImportApiClient } from './api-client.js';
7
7
  import { openPipelineDb, loadCDFComponents } from '../session/db.js';
8
8
  import { ServerPreviewApp, ServerPreviewConfirm, ServerApplyProgress, ServerApplyDone } from './tui/ServerApplyView.js';
9
9
  import { SelectView, makeSelectKey } from './tui/SelectView.js';
10
- import { useState } from 'react';
11
- import { useInput } from 'ink';
12
10
  function die(message) {
13
11
  process.stderr.write(`${message}\n`);
14
12
  process.exit(1);
@@ -28,6 +28,12 @@ export interface RawComponentDefinition {
28
28
  framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil';
29
29
  props: RawPropDefinition[];
30
30
  slots: RawSlotDefinition[];
31
+ /**
32
+ * True when the source file declaring this component calls
33
+ * React.createContext / createContext. Used downstream to filter
34
+ * non-authorable context-provider components.
35
+ */
36
+ usesCreateContext?: boolean;
31
37
  }
32
38
  export interface ComponentExtractionResult {
33
39
  components: RawComponentDefinition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.5.3-dev-build-727fd69.0",
3
+ "version": "2.5.3-dev-build-b2e98f1.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "react-dom": "^18.3.1",
33
33
  "ts-morph": "^27.0.2",
34
34
  "typescript": "^5.9.3",
35
- "@contentful/experience-design-system-types": "2.5.3-dev-build-727fd69.0"
35
+ "@contentful/experience-design-system-types": "2.5.3-dev-build-b2e98f1.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@tsconfig/node24": "^24.0.3",