@suss/contract-storybook 0.33.1 → 0.34.1

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,35 +1,37 @@
1
1
  # @suss/contract-storybook
2
2
 
3
- Part of [suss](https://github.com/nimbuscloud-ai/suss), which reads both sides of every call in a repository and says where the two disagree.
3
+ Part of [suss](https://github.com/nimbuscloud-ai/suss), which reads both sides of every call in a repository and reports where the two disagree.
4
4
 
5
- Generate suss `BehavioralSummary[]` from [Storybook](https://storybook.js.org/) CSF3 story files. A story is a specification somebody wrote by hand: this component supports this prop configuration. Reading stories as contracts lets you ask whether a component accepts the args every story supplies, and whether every branch inferred from the component has a story that reaches it.
5
+ This package builds suss `BehavioralSummary[]` from [Storybook](https://storybook.js.org/) CSF3 story files. A story is a specification somebody wrote by hand: this component supports this set of props. Reading stories as contracts lets you check whether a component accepts the args every story passes, and whether every branch inferred from the component has a story that reaches it.
6
6
 
7
7
  ## What this package reads
8
8
 
9
- `@suss/contract-storybook` parses `.stories.ts` and `.stories.tsx` files with ts-morph and looks at two things in each one.
9
+ `@suss/contract-storybook` parses `.stories.ts` and `.stories.tsx` files with ts-morph, and reads two things in each one.
10
10
 
11
- The default export gives the component under test. The reader walks the default-export symbol's declarations for an object literal with a `component` property, which covers `export default { component: Button }`, `const meta = { component: Button }; export default meta;`, and `export default { ... } satisfies Meta<typeof Button>`. Parentheses, `as` expressions, and an identifier pointing at a local variable are followed through to the literal. A file whose default export never resolves to such an object is skipped, and the rest of the run continues.
11
+ The default export gives the component under test. The reader walks the declarations of the default export's symbol, looking for an object literal with a `component` property. That covers `export default { component: Button }`, `const meta = { component: Button }; export default meta;` and `export default { ... } satisfies Meta<typeof Button>`. Parentheses, `as` expressions, and an identifier that points at a local variable are followed through to the literal. A file whose default export never resolves to such an object is skipped, and the rest of the run continues.
12
12
 
13
- Every other named export whose initializer resolves to an object literal is a story. Its `args` object literal becomes the story's arguments, with each property's value recorded as the source text you wrote. A shorthand property (`{ disabled }`) records its own name.
13
+ The `component` identifier is followed through its import, any renames and any barrel re-exports, to the file that declares it. Each story file is read under the tsconfig nearest to it, so an import through one of its path aliases (`@ui/Chip/Chip`) resolves. The declaring file is written relative to the root an extract of that file's project measures from, with that project's workspace name, so the checker can find the extracted component by its own location. When the import leads to no file, for example because the module is generated or comes from a package that is not installed, no module is recorded, and the checker leaves the story unpaired.
14
+
15
+ Every other named export whose initializer resolves to an object literal is a story. Its `args` object literal becomes the story's arguments, and each property's value is recorded as the source text you wrote. A shorthand property (`{ disabled }`) records its own name. Storybook gives every story the `args` set on the default export, so those are recorded on each story too, and a story's own arg replaces the meta's arg of the same name.
14
16
 
15
17
  ## What it produces
16
18
 
17
19
  One `component`-kind summary per named story export:
18
20
 
19
- - `identity.name` is `Component.Story`, with `exportPath` set to the story's export name and a function-call boundary binding: `transport: "in-process"`, `recognition: "react"`, and the component identifier as the export name.
20
- - One input per arg. The input's `role` is the arg name, and its shape is a `ref` whose name is the arg's source text, so a reader can see the concrete value that was written.
21
+ - `identity.name` is `Component.Story`, with `exportPath` set to the story's export name, and a function-call boundary binding with `transport: "in-process"`, `recognition: "react"`, and the component identifier as the export name.
22
+ - One input per arg. The input's `role` is the arg name, and its shape is a `ref` whose name is the arg's source text, so a reader can see the value that was written.
21
23
  - One default transition whose output is a render of the component. The reader does not evaluate the render, so the rendered tree is left unset.
22
- - `metadata.component.storybook` with the story name, the component name, the args map, and `provenance: "independent"`, which is what makes a story usable as a check against an inferred component summary rather than a restatement of it.
23
- - Confidence is `derived` at `medium`. Stories are written by people and are authoritative where they speak, and they do not enumerate everything a component does.
24
+ - `metadata.component.storybook` with the story name, the component name, the component's module (`componentModule`, with `workspace`, `file` and `name`) and the specifier it is imported from (`componentImport`), the args map, `argReaders` when the story or its meta has `decorators` or a `render` function, `renderProps` with the props a render function passes the component as its own attributes or children, and `provenance: "independent"`. `renderProps` is left out when the render function renders the component through another element or spreads anything besides its args into it. A decorator or a render function receives the args before the component does, and the reader does not follow either, so the checker does not judge those args against the component. The provenance is what lets a story act as a check on an inferred component summary, since it was written separately from the component.
25
+ - Confidence is `derived` at `medium`. People write stories, and a story is authoritative about what it covers, but stories do not list everything a component does.
24
26
 
25
27
  ## What it does not read
26
28
 
27
- - **`play` functions.** The event sequence that exercises an interactive story is not captured.
28
- - **`argTypes`.** Per-arg control types and option lists would inform stricter type checking and are not read today.
29
- - **`decorators` and `parameters`.** Storybook runtime plumbing rather than component behavior.
29
+ - **`play` functions.** The sequence of events that drives an interactive story is not recorded.
30
+ - **`argTypes`.** Control types and option lists per arg could support stricter type checking, but they are not read today.
31
+ - **What `decorators`, `render` and `parameters` do.** Their presence is recorded, as above. A render function is read only for the attributes and children it gives the component's element, and the other bodies are not read. Decorators set in `.storybook/preview` apply to every story and are not seen at all.
30
32
  - **CSF1 and MDX stories.** CSF3 is the supported format.
31
- - **The component's own module.** The reader keeps the `component` identifier as written and does not follow the import to where the component is defined.
32
- - **Arg values as structured shapes.** An arg's value stays source text inside a `ref` shape rather than being parsed into a `TypeShape`.
33
+ - **A `component` that is not a plain identifier**, such as `Icons.Chip`. No module is recorded for it.
34
+ - **Arg values as structured shapes.** An arg's value stays as source text inside a `ref` shape, and is not parsed into a `TypeShape`.
33
35
 
34
36
  ## Worked example
35
37
 
@@ -53,9 +55,9 @@ suss contract --from storybook src/components -o summaries/stories.json
53
55
  suss check summaries/app.json summaries/stories.json
54
56
  ```
55
57
 
56
- The path may be one `.stories.ts[x]` file or a directory, which the CLI walks recursively for story files. Two summaries come out of the file above, `Button.Primary` and `Button.Disabled`, each with `variant` and `disabled` inputs.
58
+ The path can be one `.stories.ts[x]` file or a directory, which the CLI searches recursively for story files. The file above produces two summaries, `Button.Primary` and `Button.Disabled`, each with `variant` and `disabled` inputs.
57
59
 
58
- Or programmatically, when you want control over the file set or the root that relative paths are computed against:
60
+ To control the set of files, or the root that relative paths are computed from, call it from code:
59
61
 
60
62
  ```ts
61
63
  import { generateSummariesFromStories } from "@suss/contract-storybook";
@@ -68,7 +70,7 @@ const summaries = generateSummariesFromStories(
68
70
 
69
71
  ## Where it fits in suss
70
72
 
71
- Depends on `@suss/behavioral-ir` (for the IR types it produces) and `@suss/adapter-typescript` (for export resolution), with `ts-morph` as a peer dependency. This is the one contract reader that parses TypeScript, because CSF is TypeScript. It still reads a declared artifact rather than inferring behavior from a component's implementation.
73
+ The package depends on `@suss/behavioral-ir` for the IR types it produces and on `@suss/adapter-typescript` for export resolution, with `ts-morph` as a peer dependency. It is the only contract reader that parses TypeScript, because CSF is TypeScript. It still reads a declared artifact, and does not infer behavior from a component's implementation.
72
74
 
73
75
  ## More
74
76
 
package/dist/index.cjs CHANGED
@@ -38,38 +38,136 @@ var import_ts_morph = require("ts-morph");
38
38
  var import_adapter_typescript = require("@suss/adapter-typescript");
39
39
  var import_behavioral_ir = require("@suss/behavioral-ir");
40
40
  function generateSummariesFromStories(filePaths, options = {}) {
41
- const project = new import_ts_morph.Project({
42
- skipAddingFilesFromTsConfig: true,
43
- compilerOptions: {
44
- target: 99,
45
- module: 99,
46
- moduleResolution: 100,
47
- skipLibCheck: true,
48
- allowJs: true,
49
- jsx: 4
41
+ const projectRoot = options.projectRoot ?? process.cwd();
42
+ const componentModules = options.componentModules ?? true;
43
+ const byFile = /* @__PURE__ */ new Map();
44
+ const groups = componentModules ? filesByTsconfig(filePaths) : /* @__PURE__ */ new Map([[null, filePaths]]);
45
+ for (const [tsconfig, files] of groups) {
46
+ const project = projectFor(tsconfig);
47
+ const resolution = new import_adapter_typescript.ResolutionStore();
48
+ for (const sf of files.map((fp) => project.addSourceFileAtPath(fp))) {
49
+ const relPath = import_node_path.default.relative(projectRoot, sf.getFilePath());
50
+ const meta = extractMeta(sf, resolution, componentModules);
51
+ if (meta === null) {
52
+ continue;
53
+ }
54
+ byFile.set(
55
+ sf.getFilePath(),
56
+ extractStories(sf, resolution, meta).map(
57
+ (story) => buildSummary(story, meta, relPath)
58
+ )
59
+ );
50
60
  }
51
- });
61
+ }
62
+ return filePaths.flatMap(
63
+ (fp) => byFile.get(import_node_path.default.resolve(fp).replace(/\\/g, "/")) ?? []
64
+ );
65
+ }
66
+ function filesByTsconfig(filePaths) {
67
+ const groups = /* @__PURE__ */ new Map();
52
68
  for (const fp of filePaths) {
53
- project.addSourceFileAtPath(fp);
69
+ const tsconfig = (0, import_adapter_typescript.findNearestTsconfig)(import_node_path.default.dirname(fp));
70
+ const group = groups.get(tsconfig) ?? [];
71
+ group.push(fp);
72
+ groups.set(tsconfig, group);
54
73
  }
55
- const projectRoot = options.projectRoot ?? process.cwd();
56
- const summaries = [];
57
- const resolution = new import_adapter_typescript.ResolutionStore();
58
- for (const sf of project.getSourceFiles()) {
59
- const absPath = sf.getFilePath();
60
- const relPath = import_node_path.default.relative(projectRoot, absPath);
61
- const meta = extractMeta(sf, resolution);
62
- if (meta === null) {
74
+ return groups;
75
+ }
76
+ var COMPILER_OPTIONS = {
77
+ target: 99,
78
+ module: 99,
79
+ moduleResolution: 100,
80
+ skipLibCheck: true,
81
+ allowJs: true,
82
+ jsx: 4,
83
+ // Ambient types say nothing about a story, and a missing types folder
84
+ // makes every program build fail.
85
+ types: []
86
+ };
87
+ function projectFor(tsconfig) {
88
+ if (tsconfig !== null) {
89
+ try {
90
+ return new import_ts_morph.Project({
91
+ tsConfigFilePath: tsconfig,
92
+ skipAddingFilesFromTsConfig: true,
93
+ compilerOptions: { allowJs: true, jsx: 4, types: [] }
94
+ });
95
+ } catch {
96
+ }
97
+ }
98
+ return new import_ts_morph.Project({
99
+ skipAddingFilesFromTsConfig: true,
100
+ compilerOptions: COMPILER_OPTIONS
101
+ });
102
+ }
103
+ var ARG_READERS = ["decorators", "render"];
104
+ function argReadersOf(storyOrMeta, resolution) {
105
+ const names = new Set(
106
+ (0, import_adapter_typescript.propertiesOf)(storyOrMeta, resolution).map(
107
+ (property) => (0, import_adapter_typescript.propertyNameOf)(property)
108
+ )
109
+ );
110
+ return ARG_READERS.filter((name) => names.has(name));
111
+ }
112
+ function renderPropsOf(storyOrMeta, componentName, resolution) {
113
+ const property = (0, import_adapter_typescript.propertiesOf)(storyOrMeta, resolution).find(
114
+ (candidate) => (0, import_adapter_typescript.propertyNameOf)(candidate) === "render"
115
+ );
116
+ const render = property === void 0 ? null : (0, import_adapter_typescript.propertyFunctionOf)(property, resolution);
117
+ if (render === null) {
118
+ return null;
119
+ }
120
+ const argNames = argNamesOf(render.getParameters()[0]);
121
+ const elements = [
122
+ ...render.getDescendantsOfKind(import_ts_morph.SyntaxKind.JsxOpeningElement),
123
+ ...render.getDescendantsOfKind(import_ts_morph.SyntaxKind.JsxSelfClosingElement)
124
+ ].filter((element) => element.getTagNameNode().getText() === componentName);
125
+ if (elements.length === 0) {
126
+ return null;
127
+ }
128
+ const passed = /* @__PURE__ */ new Set();
129
+ if (elements.some(hasJsxChildren)) {
130
+ passed.add("children");
131
+ }
132
+ for (const attribute of elements.flatMap((e) => e.getAttributes())) {
133
+ if (import_ts_morph.Node.isJsxAttribute(attribute)) {
134
+ passed.add(attribute.getNameNode().getText());
63
135
  continue;
64
136
  }
65
- const stories = extractStories(sf, resolution);
66
- for (const story of stories) {
67
- summaries.push(buildSummary(story, meta, relPath));
137
+ if (!argNames.has(attribute.getExpression().getText())) {
138
+ return null;
139
+ }
140
+ }
141
+ return [...passed].sort();
142
+ }
143
+ function hasJsxChildren(element) {
144
+ const parent = element.getParent();
145
+ if (!import_ts_morph.Node.isJsxOpeningElement(element) || !import_ts_morph.Node.isJsxElement(parent)) {
146
+ return false;
147
+ }
148
+ return parent.getJsxChildren().some(
149
+ (child) => !(import_ts_morph.Node.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces())
150
+ );
151
+ }
152
+ function argNamesOf(parameter) {
153
+ const names = /* @__PURE__ */ new Set();
154
+ const nameNode = parameter?.getNameNode();
155
+ if (nameNode === void 0) {
156
+ return names;
157
+ }
158
+ if (import_ts_morph.Node.isIdentifier(nameNode)) {
159
+ names.add(nameNode.getText());
160
+ return names;
161
+ }
162
+ const elements = import_ts_morph.Node.isObjectBindingPattern(nameNode) ? nameNode.getDescendantsOfKind(import_ts_morph.SyntaxKind.BindingElement).filter((element) => element.getParent() === nameNode) : [];
163
+ for (const element of elements) {
164
+ if (element.getDotDotDotToken() !== void 0) {
165
+ names.add(element.getName());
68
166
  }
69
167
  }
70
- return summaries;
168
+ return names;
71
169
  }
72
- function extractMeta(sf, resolution) {
170
+ function extractMeta(sf, resolution, componentModules) {
73
171
  for (const exported of resolution.exportsOf(sf).get("default") ?? []) {
74
172
  const meta = objectBehind(exported, resolution);
75
173
  if (meta === null) {
@@ -77,16 +175,63 @@ function extractMeta(sf, resolution) {
77
175
  }
78
176
  const component = (0, import_adapter_typescript.propertyOf)(meta, "component", resolution);
79
177
  if (component !== null) {
80
- return { componentName: component.getText() };
178
+ return {
179
+ componentName: component.getText(),
180
+ componentModule: componentModules ? declaredModuleOf(component) : void 0,
181
+ componentImport: importSpecifierOf(component),
182
+ args: argsOf(meta, resolution),
183
+ argReaders: argReadersOf(meta, resolution),
184
+ renderProps: renderPropsOf(meta, component.getText(), resolution)
185
+ };
81
186
  }
82
187
  }
83
188
  return null;
84
189
  }
190
+ function declaredModuleOf(component) {
191
+ if (!import_ts_morph.Node.isIdentifier(component)) {
192
+ return void 0;
193
+ }
194
+ const declaration = componentDeclarationOf(component);
195
+ if (declaration === void 0) {
196
+ return void 0;
197
+ }
198
+ const file = declaration.getSourceFile().getFilePath();
199
+ const tsconfig = (0, import_adapter_typescript.findNearestTsconfig)(import_node_path.default.dirname(file));
200
+ const root = (0, import_adapter_typescript.workspaceRootFor)(
201
+ tsconfig === null ? import_node_path.default.dirname(file) : import_node_path.default.dirname(tsconfig)
202
+ );
203
+ const workspace = (0, import_adapter_typescript.workspaceNameFor)(root);
204
+ return {
205
+ ...workspace === null ? {} : { workspace },
206
+ file: import_node_path.default.relative(root, file).replace(/\\/g, "/"),
207
+ name: declaredNameOf(declaration) ?? component.getText()
208
+ };
209
+ }
210
+ function componentDeclarationOf(component) {
211
+ return (0, import_adapter_typescript.declarationsBehind)((0, import_adapter_typescript.symbolBehind)(component)).find(
212
+ (declaration) => !isImportBinding(declaration)
213
+ );
214
+ }
215
+ function isImportBinding(node) {
216
+ return import_ts_morph.Node.isImportSpecifier(node) || import_ts_morph.Node.isImportClause(node) || import_ts_morph.Node.isNamespaceImport(node) || import_ts_morph.Node.isImportEqualsDeclaration(node);
217
+ }
218
+ function declaredNameOf(declaration) {
219
+ if (import_ts_morph.Node.isVariableDeclaration(declaration) || import_ts_morph.Node.isFunctionDeclaration(declaration) || import_ts_morph.Node.isClassDeclaration(declaration)) {
220
+ return declaration.getName();
221
+ }
222
+ return void 0;
223
+ }
224
+ function importSpecifierOf(component) {
225
+ if (!import_ts_morph.Node.isIdentifier(component)) {
226
+ return void 0;
227
+ }
228
+ return (0, import_adapter_typescript.specifierImportedAs)(component.getSourceFile(), component.getText()) ?? void 0;
229
+ }
85
230
  function objectBehind(value, resolution) {
86
231
  const resolved = resolution.resolveObject(value);
87
232
  return resolved !== null && import_ts_morph.Node.isObjectLiteralExpression(resolved) ? resolved : null;
88
233
  }
89
- function extractStories(sf, resolution) {
234
+ function extractStories(sf, resolution, meta) {
90
235
  const results = [];
91
236
  for (const [name, decls] of (0, import_adapter_typescript.exportedDeclarationsOf)(sf, resolution)) {
92
237
  if (name === "default") {
@@ -97,18 +242,23 @@ function extractStories(sf, resolution) {
97
242
  if (story === null) {
98
243
  continue;
99
244
  }
245
+ const ownReaders = argReadersOf(story, resolution);
100
246
  results.push({
101
247
  name,
102
- args: storyArgs(story, resolution),
248
+ args: { ...meta.args, ...argsOf(story, resolution) },
249
+ argReaders: ARG_READERS.filter(
250
+ (reader) => meta.argReaders.includes(reader) || ownReaders.includes(reader)
251
+ ),
252
+ renderProps: ownReaders.includes("render") ? renderPropsOf(story, meta.componentName, resolution) : meta.renderProps,
103
253
  line: decl.getStartLineNumber()
104
254
  });
105
255
  }
106
256
  }
107
257
  return results;
108
258
  }
109
- function storyArgs(story, resolution) {
259
+ function argsOf(storyOrMeta, resolution) {
110
260
  const args = {};
111
- const written = (0, import_adapter_typescript.propertyOf)(story, "args", resolution);
261
+ const written = (0, import_adapter_typescript.propertyOf)(storyOrMeta, "args", resolution);
112
262
  const object = written === null ? null : (0, import_adapter_typescript.objectLiteralOf)(written, resolution);
113
263
  if (object === null) {
114
264
  return args;
@@ -129,11 +279,8 @@ function buildSummary(story, meta, filePath) {
129
279
  name,
130
280
  position: 0,
131
281
  role: name,
132
- // Args are authored literals; record the source text in the shape's
133
- // `ref.name` so consumers can see the concrete value. Promoting
134
- // this to a structured literal shape is a follow-up (would need to
135
- // parse each arg's source into a TypeShape; for v0 we surface the
136
- // text).
282
+ // The arg's value goes in `ref.name` so a reader can see what the
283
+ // story passes. It is never parsed into a structured TypeShape.
137
284
  shape: { type: "ref", name: value }
138
285
  }));
139
286
  const transition = {
@@ -170,7 +317,11 @@ function buildSummary(story, meta, filePath) {
170
317
  storybook: {
171
318
  story: story.name,
172
319
  component: meta.componentName,
320
+ ...meta.componentModule === void 0 ? {} : { componentModule: meta.componentModule },
321
+ ...meta.componentImport === void 0 ? {} : { componentImport: meta.componentImport },
173
322
  args: story.args,
323
+ ...story.argReaders.length === 0 ? {} : { argReaders: story.argReaders },
324
+ ...story.renderProps === null ? {} : { renderProps: story.renderProps },
174
325
  provenance: "independent"
175
326
  }
176
327
  }
package/dist/index.d.cts CHANGED
@@ -1,15 +1,35 @@
1
1
  import { BehavioralSummary } from '@suss/behavioral-ir';
2
2
 
3
+ /**
4
+ * Reads Storybook CSF3 story files into one component summary per story.
5
+ * A story is a hand-written claim that its component supports one set of
6
+ * props. Comparing stories with the component's inferred summary shows
7
+ * whether the component accepts every story's args, and whether each
8
+ * inferred branch has a story that reaches it.
9
+ *
10
+ * Confidence is `medium`, because a story is accurate about what it covers
11
+ * but stories never list everything a component does. The README lists
12
+ * what the reader leaves out.
13
+ */
14
+
3
15
  interface StorybookStubOptions {
4
16
  /**
5
- * Project root: used to compute portable relative paths in each
6
- * summary's `location.file`. Defaults to the cwd.
17
+ * Each summary's `location.file` is relative to this, so it is the same
18
+ * on every machine. Defaults to the working directory.
7
19
  */
8
20
  projectRoot?: string;
21
+ /**
22
+ * False leaves out `componentModule`, the file each component is
23
+ * declared in. Finding it means reading the story under its tsconfig,
24
+ * whose path aliases make the type checker load most of the app, so a
25
+ * caller that only asks whether a file has stories passes false.
26
+ */
27
+ componentModules?: boolean;
9
28
  }
10
29
  /**
11
- * Read one or more `.stories.ts[x]` files and emit one
12
- * BehavioralSummary per named story export.
30
+ * Reads `.stories.ts` and `.stories.tsx` files and returns one summary per
31
+ * named story export. A file whose default export has no `component` is
32
+ * skipped.
13
33
  */
14
34
  declare function generateSummariesFromStories(filePaths: string[], options?: StorybookStubOptions): BehavioralSummary[];
15
35
 
package/dist/index.d.ts CHANGED
@@ -1,15 +1,35 @@
1
1
  import { BehavioralSummary } from '@suss/behavioral-ir';
2
2
 
3
+ /**
4
+ * Reads Storybook CSF3 story files into one component summary per story.
5
+ * A story is a hand-written claim that its component supports one set of
6
+ * props. Comparing stories with the component's inferred summary shows
7
+ * whether the component accepts every story's args, and whether each
8
+ * inferred branch has a story that reaches it.
9
+ *
10
+ * Confidence is `medium`, because a story is accurate about what it covers
11
+ * but stories never list everything a component does. The README lists
12
+ * what the reader leaves out.
13
+ */
14
+
3
15
  interface StorybookStubOptions {
4
16
  /**
5
- * Project root: used to compute portable relative paths in each
6
- * summary's `location.file`. Defaults to the cwd.
17
+ * Each summary's `location.file` is relative to this, so it is the same
18
+ * on every machine. Defaults to the working directory.
7
19
  */
8
20
  projectRoot?: string;
21
+ /**
22
+ * False leaves out `componentModule`, the file each component is
23
+ * declared in. Finding it means reading the story under its tsconfig,
24
+ * whose path aliases make the type checker load most of the app, so a
25
+ * caller that only asks whether a file has stories passes false.
26
+ */
27
+ componentModules?: boolean;
9
28
  }
10
29
  /**
11
- * Read one or more `.stories.ts[x]` files and emit one
12
- * BehavioralSummary per named story export.
30
+ * Reads `.stories.ts` and `.stories.tsx` files and returns one summary per
31
+ * named story export. A file whose default export has no `component` is
32
+ * skipped.
13
33
  */
14
34
  declare function generateSummariesFromStories(filePaths: string[], options?: StorybookStubOptions): BehavioralSummary[];
15
35
 
package/dist/index.js CHANGED
@@ -2,52 +2,158 @@
2
2
  import path from "path";
3
3
  import {
4
4
  Node as N,
5
- Project
5
+ Project,
6
+ SyntaxKind
6
7
  } from "ts-morph";
7
8
  import {
9
+ declarationsBehind,
8
10
  exportedDeclarationsOf,
11
+ findNearestTsconfig,
9
12
  objectLiteralOf,
10
13
  propertiesOf,
14
+ propertyFunctionOf,
11
15
  propertyNameOf,
12
16
  propertyOf,
13
17
  propertyValueOf,
14
18
  ResolutionStore,
15
- stringValueOf
19
+ specifierImportedAs,
20
+ stringValueOf,
21
+ symbolBehind,
22
+ workspaceNameFor,
23
+ workspaceRootFor
16
24
  } from "@suss/adapter-typescript";
17
25
  import { functionCallBinding } from "@suss/behavioral-ir";
18
26
  function generateSummariesFromStories(filePaths, options = {}) {
19
- const project = new Project({
20
- skipAddingFilesFromTsConfig: true,
21
- compilerOptions: {
22
- target: 99,
23
- module: 99,
24
- moduleResolution: 100,
25
- skipLibCheck: true,
26
- allowJs: true,
27
- jsx: 4
27
+ const projectRoot = options.projectRoot ?? process.cwd();
28
+ const componentModules = options.componentModules ?? true;
29
+ const byFile = /* @__PURE__ */ new Map();
30
+ const groups = componentModules ? filesByTsconfig(filePaths) : /* @__PURE__ */ new Map([[null, filePaths]]);
31
+ for (const [tsconfig, files] of groups) {
32
+ const project = projectFor(tsconfig);
33
+ const resolution = new ResolutionStore();
34
+ for (const sf of files.map((fp) => project.addSourceFileAtPath(fp))) {
35
+ const relPath = path.relative(projectRoot, sf.getFilePath());
36
+ const meta = extractMeta(sf, resolution, componentModules);
37
+ if (meta === null) {
38
+ continue;
39
+ }
40
+ byFile.set(
41
+ sf.getFilePath(),
42
+ extractStories(sf, resolution, meta).map(
43
+ (story) => buildSummary(story, meta, relPath)
44
+ )
45
+ );
28
46
  }
29
- });
47
+ }
48
+ return filePaths.flatMap(
49
+ (fp) => byFile.get(path.resolve(fp).replace(/\\/g, "/")) ?? []
50
+ );
51
+ }
52
+ function filesByTsconfig(filePaths) {
53
+ const groups = /* @__PURE__ */ new Map();
30
54
  for (const fp of filePaths) {
31
- project.addSourceFileAtPath(fp);
55
+ const tsconfig = findNearestTsconfig(path.dirname(fp));
56
+ const group = groups.get(tsconfig) ?? [];
57
+ group.push(fp);
58
+ groups.set(tsconfig, group);
32
59
  }
33
- const projectRoot = options.projectRoot ?? process.cwd();
34
- const summaries = [];
35
- const resolution = new ResolutionStore();
36
- for (const sf of project.getSourceFiles()) {
37
- const absPath = sf.getFilePath();
38
- const relPath = path.relative(projectRoot, absPath);
39
- const meta = extractMeta(sf, resolution);
40
- if (meta === null) {
60
+ return groups;
61
+ }
62
+ var COMPILER_OPTIONS = {
63
+ target: 99,
64
+ module: 99,
65
+ moduleResolution: 100,
66
+ skipLibCheck: true,
67
+ allowJs: true,
68
+ jsx: 4,
69
+ // Ambient types say nothing about a story, and a missing types folder
70
+ // makes every program build fail.
71
+ types: []
72
+ };
73
+ function projectFor(tsconfig) {
74
+ if (tsconfig !== null) {
75
+ try {
76
+ return new Project({
77
+ tsConfigFilePath: tsconfig,
78
+ skipAddingFilesFromTsConfig: true,
79
+ compilerOptions: { allowJs: true, jsx: 4, types: [] }
80
+ });
81
+ } catch {
82
+ }
83
+ }
84
+ return new Project({
85
+ skipAddingFilesFromTsConfig: true,
86
+ compilerOptions: COMPILER_OPTIONS
87
+ });
88
+ }
89
+ var ARG_READERS = ["decorators", "render"];
90
+ function argReadersOf(storyOrMeta, resolution) {
91
+ const names = new Set(
92
+ propertiesOf(storyOrMeta, resolution).map(
93
+ (property) => propertyNameOf(property)
94
+ )
95
+ );
96
+ return ARG_READERS.filter((name) => names.has(name));
97
+ }
98
+ function renderPropsOf(storyOrMeta, componentName, resolution) {
99
+ const property = propertiesOf(storyOrMeta, resolution).find(
100
+ (candidate) => propertyNameOf(candidate) === "render"
101
+ );
102
+ const render = property === void 0 ? null : propertyFunctionOf(property, resolution);
103
+ if (render === null) {
104
+ return null;
105
+ }
106
+ const argNames = argNamesOf(render.getParameters()[0]);
107
+ const elements = [
108
+ ...render.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
109
+ ...render.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
110
+ ].filter((element) => element.getTagNameNode().getText() === componentName);
111
+ if (elements.length === 0) {
112
+ return null;
113
+ }
114
+ const passed = /* @__PURE__ */ new Set();
115
+ if (elements.some(hasJsxChildren)) {
116
+ passed.add("children");
117
+ }
118
+ for (const attribute of elements.flatMap((e) => e.getAttributes())) {
119
+ if (N.isJsxAttribute(attribute)) {
120
+ passed.add(attribute.getNameNode().getText());
41
121
  continue;
42
122
  }
43
- const stories = extractStories(sf, resolution);
44
- for (const story of stories) {
45
- summaries.push(buildSummary(story, meta, relPath));
123
+ if (!argNames.has(attribute.getExpression().getText())) {
124
+ return null;
125
+ }
126
+ }
127
+ return [...passed].sort();
128
+ }
129
+ function hasJsxChildren(element) {
130
+ const parent = element.getParent();
131
+ if (!N.isJsxOpeningElement(element) || !N.isJsxElement(parent)) {
132
+ return false;
133
+ }
134
+ return parent.getJsxChildren().some(
135
+ (child) => !(N.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces())
136
+ );
137
+ }
138
+ function argNamesOf(parameter) {
139
+ const names = /* @__PURE__ */ new Set();
140
+ const nameNode = parameter?.getNameNode();
141
+ if (nameNode === void 0) {
142
+ return names;
143
+ }
144
+ if (N.isIdentifier(nameNode)) {
145
+ names.add(nameNode.getText());
146
+ return names;
147
+ }
148
+ const elements = N.isObjectBindingPattern(nameNode) ? nameNode.getDescendantsOfKind(SyntaxKind.BindingElement).filter((element) => element.getParent() === nameNode) : [];
149
+ for (const element of elements) {
150
+ if (element.getDotDotDotToken() !== void 0) {
151
+ names.add(element.getName());
46
152
  }
47
153
  }
48
- return summaries;
154
+ return names;
49
155
  }
50
- function extractMeta(sf, resolution) {
156
+ function extractMeta(sf, resolution, componentModules) {
51
157
  for (const exported of resolution.exportsOf(sf).get("default") ?? []) {
52
158
  const meta = objectBehind(exported, resolution);
53
159
  if (meta === null) {
@@ -55,16 +161,63 @@ function extractMeta(sf, resolution) {
55
161
  }
56
162
  const component = propertyOf(meta, "component", resolution);
57
163
  if (component !== null) {
58
- return { componentName: component.getText() };
164
+ return {
165
+ componentName: component.getText(),
166
+ componentModule: componentModules ? declaredModuleOf(component) : void 0,
167
+ componentImport: importSpecifierOf(component),
168
+ args: argsOf(meta, resolution),
169
+ argReaders: argReadersOf(meta, resolution),
170
+ renderProps: renderPropsOf(meta, component.getText(), resolution)
171
+ };
59
172
  }
60
173
  }
61
174
  return null;
62
175
  }
176
+ function declaredModuleOf(component) {
177
+ if (!N.isIdentifier(component)) {
178
+ return void 0;
179
+ }
180
+ const declaration = componentDeclarationOf(component);
181
+ if (declaration === void 0) {
182
+ return void 0;
183
+ }
184
+ const file = declaration.getSourceFile().getFilePath();
185
+ const tsconfig = findNearestTsconfig(path.dirname(file));
186
+ const root = workspaceRootFor(
187
+ tsconfig === null ? path.dirname(file) : path.dirname(tsconfig)
188
+ );
189
+ const workspace = workspaceNameFor(root);
190
+ return {
191
+ ...workspace === null ? {} : { workspace },
192
+ file: path.relative(root, file).replace(/\\/g, "/"),
193
+ name: declaredNameOf(declaration) ?? component.getText()
194
+ };
195
+ }
196
+ function componentDeclarationOf(component) {
197
+ return declarationsBehind(symbolBehind(component)).find(
198
+ (declaration) => !isImportBinding(declaration)
199
+ );
200
+ }
201
+ function isImportBinding(node) {
202
+ return N.isImportSpecifier(node) || N.isImportClause(node) || N.isNamespaceImport(node) || N.isImportEqualsDeclaration(node);
203
+ }
204
+ function declaredNameOf(declaration) {
205
+ if (N.isVariableDeclaration(declaration) || N.isFunctionDeclaration(declaration) || N.isClassDeclaration(declaration)) {
206
+ return declaration.getName();
207
+ }
208
+ return void 0;
209
+ }
210
+ function importSpecifierOf(component) {
211
+ if (!N.isIdentifier(component)) {
212
+ return void 0;
213
+ }
214
+ return specifierImportedAs(component.getSourceFile(), component.getText()) ?? void 0;
215
+ }
63
216
  function objectBehind(value, resolution) {
64
217
  const resolved = resolution.resolveObject(value);
65
218
  return resolved !== null && N.isObjectLiteralExpression(resolved) ? resolved : null;
66
219
  }
67
- function extractStories(sf, resolution) {
220
+ function extractStories(sf, resolution, meta) {
68
221
  const results = [];
69
222
  for (const [name, decls] of exportedDeclarationsOf(sf, resolution)) {
70
223
  if (name === "default") {
@@ -75,18 +228,23 @@ function extractStories(sf, resolution) {
75
228
  if (story === null) {
76
229
  continue;
77
230
  }
231
+ const ownReaders = argReadersOf(story, resolution);
78
232
  results.push({
79
233
  name,
80
- args: storyArgs(story, resolution),
234
+ args: { ...meta.args, ...argsOf(story, resolution) },
235
+ argReaders: ARG_READERS.filter(
236
+ (reader) => meta.argReaders.includes(reader) || ownReaders.includes(reader)
237
+ ),
238
+ renderProps: ownReaders.includes("render") ? renderPropsOf(story, meta.componentName, resolution) : meta.renderProps,
81
239
  line: decl.getStartLineNumber()
82
240
  });
83
241
  }
84
242
  }
85
243
  return results;
86
244
  }
87
- function storyArgs(story, resolution) {
245
+ function argsOf(storyOrMeta, resolution) {
88
246
  const args = {};
89
- const written = propertyOf(story, "args", resolution);
247
+ const written = propertyOf(storyOrMeta, "args", resolution);
90
248
  const object = written === null ? null : objectLiteralOf(written, resolution);
91
249
  if (object === null) {
92
250
  return args;
@@ -107,11 +265,8 @@ function buildSummary(story, meta, filePath) {
107
265
  name,
108
266
  position: 0,
109
267
  role: name,
110
- // Args are authored literals; record the source text in the shape's
111
- // `ref.name` so consumers can see the concrete value. Promoting
112
- // this to a structured literal shape is a follow-up (would need to
113
- // parse each arg's source into a TypeShape; for v0 we surface the
114
- // text).
268
+ // The arg's value goes in `ref.name` so a reader can see what the
269
+ // story passes. It is never parsed into a structured TypeShape.
115
270
  shape: { type: "ref", name: value }
116
271
  }));
117
272
  const transition = {
@@ -148,7 +303,11 @@ function buildSummary(story, meta, filePath) {
148
303
  storybook: {
149
304
  story: story.name,
150
305
  component: meta.componentName,
306
+ ...meta.componentModule === void 0 ? {} : { componentModule: meta.componentModule },
307
+ ...meta.componentImport === void 0 ? {} : { componentImport: meta.componentImport },
151
308
  args: story.args,
309
+ ...story.argReaders.length === 0 ? {} : { argReaders: story.argReaders },
310
+ ...story.renderProps === null ? {} : { renderProps: story.renderProps },
152
311
  provenance: "independent"
153
312
  }
154
313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/contract-storybook",
3
- "version": "0.33.1",
3
+ "version": "0.34.1",
4
4
  "type": "module",
5
5
  "description": "Turn Storybook CSF stories into a suss contract and check the props they pass against the React components that receive them.",
6
6
  "keywords": [
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "license": "Apache-2.0",
16
16
  "author": "Nimbus Cloud AI LLC",
17
- "homepage": "https://github.com/nimbuscloud-ai/suss/tree/main/packages/contract/storybook#readme",
17
+ "homepage": "https://suss.sh",
18
18
  "repository": {
19
19
  "type": "git",
20
20
  "url": "git+https://github.com/nimbuscloud-ai/suss.git",
@@ -47,8 +47,8 @@
47
47
  "typecheck": "tsc --noEmit"
48
48
  },
49
49
  "dependencies": {
50
- "@suss/adapter-typescript": "0.33.1",
51
- "@suss/behavioral-ir": "0.33.1"
50
+ "@suss/adapter-typescript": "0.34.1",
51
+ "@suss/behavioral-ir": "0.34.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "*",