Skip to content

Commit 8bcc7ae

Browse files
Merge pull request #35808 from storybookjs/valentin/angular-docgen-harness-acm
Harness: Gate the Angular component meta analyzer against Compodoc
2 parents 9d54543 + ccb5ab4 commit 8bcc7ae

75 files changed

Lines changed: 2614 additions & 453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

code/lib/cli-storybook/src/sandbox-templates.ts

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,45 +1261,35 @@ export const daily: TemplateKey[] = [
12611261

12621262
export const templatesByCadence = { normal, merged, daily };
12631263

1264-
/**
1265-
* Features a sandbox must enable for the docgen service to write the per-component static snapshots
1266-
* that the recorded docgen baselines are read from. Both are required: without
1267-
* `componentsManifest`, `experimentalDocgenServer` writes nothing to disk.
1268-
*/
1264+
// Both are required: without `componentsManifest`, `experimentalDocgenServer` writes nothing to disk
1265+
// for the recorded baselines to read.
12691266
const DOCGEN_SERVER_FEATURES = ['experimentalDocgenServer', 'componentsManifest'] as const;
12701267

1271-
const mainConfigFeatures = (template: Template): Record<string, unknown> | undefined => {
1268+
// Templates whose `mainConfig` is a function of the generated `ConfigFile`, so its features cannot be
1269+
// read without running the sandbox generator. Listed by name so a new function-form template throws
1270+
// below instead of silently dropping out of docgen baseline coverage.
1271+
const UNREADABLE_MAIN_CONFIG_TEMPLATES = new Set<string>(['cra/default-js']);
1272+
1273+
const enablesDocgenServer = (key: string, template: Template): boolean => {
12721274
const { mainConfig } = template.modifications ?? {};
1273-
if (!mainConfig) {
1274-
return undefined;
1275-
}
1276-
if (typeof mainConfig !== 'function') {
1277-
return mainConfig.features;
1278-
}
1279-
// Templates that rewrite the generated config read from the `ConfigFile` they are handed; the
1280-
// ones that only declare features ignore it. Reading features out of the latter is worth a stub;
1281-
// the former are expected to throw here and simply do not declare these flags.
1282-
try {
1283-
return mainConfig({ getFieldValue: () => undefined } as never)?.features;
1284-
} catch {
1285-
return undefined;
1275+
if (typeof mainConfig === 'function') {
1276+
if (!UNREADABLE_MAIN_CONFIG_TEMPLATES.has(key)) {
1277+
// eslint-disable-next-line local-rules/no-uncategorized-errors
1278+
throw new Error(
1279+
`Template "${key}" declares mainConfig as a function, whose features cannot be read here. ` +
1280+
`Move ${DOCGEN_SERVER_FEATURES.join(' and ')} into the object form to opt into docgen ` +
1281+
`baseline coverage, or add the key to UNREADABLE_MAIN_CONFIG_TEMPLATES to stay out of it.`
1282+
);
1283+
}
1284+
return false;
12861285
}
1287-
};
1288-
1289-
/** Whether a template's sandbox runs with server-side docgen, and so carries docgen baselines. */
1290-
const enablesDocgenServer = (template: Template): boolean => {
1291-
const features = mainConfigFeatures(template);
1286+
const features = mainConfig?.features;
12921287
return DOCGEN_SERVER_FEATURES.every((feature) => features?.[feature] === true);
12931288
};
12941289

1295-
/**
1296-
* Templates whose sandbox runs with server-side docgen enabled.
1297-
*
1298-
* Derived from the flags rather than kept as a second list, so turning them on for a template is all
1299-
* it takes to bring it into docgen baseline coverage — both the recorder and the CI step that
1300-
* verifies it read this.
1301-
*/
1290+
// Derived from the flags rather than kept as a second list, so turning them on for a template is all
1291+
// it takes to bring it into docgen baseline coverage.
13021292
export const docgenServerTemplates = (): TemplateKey[] =>
13031293
(Object.entries(allTemplates) as [TemplateKey, Template][])
1304-
.filter(([, template]) => enablesDocgenServer(template))
1294+
.filter(([key, template]) => enablesDocgenServer(key, template))
13051295
.map(([key]) => key);

code/lib/docgen-harness/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,31 @@ src/
6767
- argTypes: every baseline key, description, default value, and type must survive.
6868
A type may only change by normalized deep equality or a clear improvement - a catch-all becoming structured, a literal union gaining members.
6969
About half the corpus records `other`, where the legacy engine parked free text it could not resolve (`TreeNode`, `Array([object Object])`, `{ theme: string; dense: boolean }`).
70-
Such a stub accepts a candidate that adds structure or resolves it to the scalar it already named; an unrelated scalar is a lateral change and fails.
70+
Such a stub accepts a candidate that adds populated structure (an empty enum/union/object is not an improvement) or resolves it to the scalar or single literal it already named; an unrelated scalar or literal is a lateral change and fails.
7171
Only the three markers that record nothing at all accept any candidate: `empty-enum`, `undefined`, and the empty string - today's Angular and Vue spellings, so adding a framework means revisiting that list.
7272
A resolution the rule cannot recognize (legacy `TSFunctionType` becoming a `function` sbType, say) fails rather than guessing; re-record and review the diff.
73-
`required`, `table.category`, `jsDocTags`, `control`/`action`, and description/default contents are deliberately not compared; each would lock in a recorded lie (#28706) or engine-specific vocabulary.
73+
A recorded `table.type.summary` must survive (dropping it is a violation), but its text may change freely outside `strictTable`.
74+
`required`, `table.category`, `jsDocTags`, `control`/`action`, and description/default contents are deliberately not compared (except `required` under `strictTable`); each would lock in a recorded lie (#28706) or engine-specific vocabulary.
7475
- Snippets: represented binding names are compared as sets, so formatting can never fail, but a lost binding does.
7576
Directive spelling is normalized, so `:x`/`v-bind:x`, `@x`/`v-on:x`, `#x`/`v-slot:x`, and any `.modifier` all read as the same name.
77+
The Angular comparison additionally gates root-element identity: the tag name must match and bare (valueless) attributes - the mangled attribute-selector markers - must survive.
7678
- Acceptance: there is no allowlist file.
7779
The committed baseline is the allowlist - accept an intentional change by re-recording with `-u` and reviewing the diff.
78-
- The recorders read each committed file before its snapshot call, so a `-u` re-record still compares the fresh output against the last committed text.
80+
- The recorders read each committed file and run every gate BEFORE the snapshot call, so a `-u` run refuses to queue a regressed recording and stays red until the code is fixed.
7981
Regressions fail with named violations; improvements pass.
8082
- The committed `argtypes*.snapshot` files are pretty-format text, not JSON.
81-
`parseArgTypesSnapshot` reads them back and verifies itself by re-serializing every parse byte-for-byte; anything outside that grammar throws.
83+
`parseArgTypesSnapshot` reads them back, verifies itself by re-serializing every parse byte-for-byte, and rejects any parsed string carrying the writer-ambiguous entry-boundary shape.
84+
A string whose unescaped write is byte-identical to real entry boundaries cannot be detected at parse time; the recorders' parsed-vs-live proofs guard that case on normal and CI runs, and on `-u` runs against the exact bytes queued for writing.
8285
- Adding a framework: extend the `Framework` union and compilation fails at the switch in `snippets.ts` until the new matcher exists.
8386

87+
### Trust model
88+
89+
The comparator machine-checks a deliberate subset: baseline arg names, description presence, default presence, `table.type.summary` presence, and type fidelity for argTypes; represented binding names, root-element identity, and bare-attribute survival for Angular snippets.
90+
Everything else - description/default/summary text, `table.category`, `control`/`action`, per-arg `jsDocTags`, added args - is caught only by the byte-exact snapshot diffs reviewed at `-u` time, or by the sandbox gate's `change` findings.
91+
Two flags scope trust to where the baseline earns it: `legacyBaseline` (only on legs whose baseline is a legacy compodoc recording) waives the raw `false`/`NaN`/`null` defaults that pipeline invents, and `strictTable` (only on the ACM self-ratchet, whose baseline the same engine recorded) additionally gates `table.type.summary` text changes and `table.type.required` true->false flips.
92+
The sandbox baseline gate runs in the daily CI tier, so a whole-project regression can merge green and surface up to a day later, detached from the offending PR.
93+
Known-accepted blind spots: enum members whose quoted and bare spellings collide normalize to the same member (`'"small"'` reads as `small`), and `\r`/`\r\n` in extracted strings are LF-normalized by vitest at write time, so a CR-bearing extraction can never record green (perma-loud, never silent).
94+
8495
## The vue-component-meta recorder (vue3)
8596

8697
`vue3-component-meta-baselines.test.ts` replicates the vue3-vite vite plugin's meta processing exactly - checker options, empty-meta skip, nested-schema pruning, exposed de-duplication, and the vue-docgen-api event-description backfill - so the `cm-` snapshots show what a `vue-component-meta` user actually gets today.

code/lib/docgen-harness/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@angular/platform-browser": "^21.2.14",
3939
"@angular/platform-browser-dynamic": "^21.2.14",
4040
"@compodoc/compodoc": "2.0.0",
41+
"@storybook/angular-cm": "workspace:*",
4142
"@storybook/angular-compodoc": "workspace:*",
4243
"@storybook/angular-vite": "workspace:*",
4344
"@storybook/react": "workspace:*",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"emphasis": {
3+
"description": undefined,
4+
"name": "emphasis",
5+
"table": {
6+
"category": "inputs",
7+
"defaultValue": {
8+
"summary": false,
9+
},
10+
"type": {
11+
"required": true,
12+
"summary": "boolean",
13+
},
14+
},
15+
"type": {
16+
"name": "boolean",
17+
},
18+
},
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"emphasis": {
3+
"description": undefined,
4+
"name": "emphasis",
5+
"table": {
6+
"category": "inputs",
7+
"defaultValue": {
8+
"summary": false,
9+
},
10+
"type": {
11+
"required": true,
12+
"summary": "boolean",
13+
},
14+
},
15+
"type": {
16+
"name": "boolean",
17+
},
18+
},
19+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<button sb-harness-action [emphasis]="true"></button>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"dismissible": {
3+
"description": "Whether the alert shows a close button.",
4+
"name": "dismissible",
5+
"table": {
6+
"category": "inputs",
7+
"defaultValue": {
8+
"summary": false,
9+
},
10+
"type": {
11+
"required": true,
12+
"summary": "boolean",
13+
},
14+
},
15+
"type": {
16+
"name": "boolean",
17+
},
18+
},
19+
"heading": {
20+
"description": undefined,
21+
"name": "heading",
22+
"table": {
23+
"category": "inputs",
24+
"defaultValue": {
25+
"summary": "",
26+
},
27+
"type": {
28+
"required": true,
29+
"summary": "string",
30+
},
31+
},
32+
"type": {
33+
"name": "string",
34+
},
35+
},
36+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"dismissed": {
3+
"action": "dismissed",
4+
"description": undefined,
5+
"name": "dismissed",
6+
"table": {
7+
"category": "outputs",
8+
"defaultValue": {
9+
"summary": undefined,
10+
},
11+
"type": {
12+
"required": true,
13+
"summary": "EventEmitter",
14+
},
15+
},
16+
"type": {
17+
"name": "other",
18+
"value": "void",
19+
},
20+
},
21+
"dismissible": {
22+
"description": "Whether the alert shows a close button.",
23+
"name": "dismissible",
24+
"table": {
25+
"category": "inputs",
26+
"defaultValue": {
27+
"summary": false,
28+
},
29+
"type": {
30+
"required": true,
31+
"summary": "boolean",
32+
},
33+
},
34+
"type": {
35+
"name": "boolean",
36+
},
37+
},
38+
"heading": {
39+
"description": undefined,
40+
"name": "heading",
41+
"table": {
42+
"category": "inputs",
43+
"defaultValue": {
44+
"summary": "",
45+
},
46+
"type": {
47+
"required": true,
48+
"summary": "string",
49+
},
50+
},
51+
"type": {
52+
"name": "string",
53+
},
54+
},
55+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<sb-cross-file-inheritance [dismissible]="true" [heading]="'Storage almost full'" (dismissed)="dismissed($event)"></sb-cross-file-inheritance>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"items": {
3+
"description": undefined,
4+
"name": "items",
5+
"table": {
6+
"category": "inputs",
7+
"defaultValue": {
8+
"summary": "[]",
9+
},
10+
"type": {
11+
"required": true,
12+
"summary": "T[]",
13+
},
14+
},
15+
"type": {
16+
"name": "other",
17+
"value": "empty-enum",
18+
},
19+
},
20+
"selected": {
21+
"description": undefined,
22+
"name": "selected",
23+
"table": {
24+
"category": "inputs",
25+
"defaultValue": {
26+
"summary": undefined,
27+
},
28+
"type": {
29+
"required": false,
30+
"summary": "T",
31+
},
32+
},
33+
"type": {
34+
"name": "other",
35+
"value": "empty-enum",
36+
},
37+
},
38+
}

0 commit comments

Comments
 (0)