@emulsify/core 4.4.0 → 4.5.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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @file Node-safe component template resolution shared by Twig compilation and audits.
3
+ *
4
+ * Callers own the grouping-directory cache so a build can invalidate it on
5
+ * filesystem changes and an audit can discard it after each pass.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import { basename, isAbsolute, relative, resolve } from 'node:path';
10
+ import { toPosixPath } from './paths.js';
11
+ import { DEFAULT_SKIP_DIRS } from './source-directory-skips.js';
12
+ import { unique } from '../../../src/extensions/shared/lists.js';
13
+
14
+ /**
15
+ * Build likely filesystem candidates for a Twig template reference.
16
+ *
17
+ * @param {string} baseDir - Directory used as the resolution root.
18
+ * @param {string} templatePath - Template path from Twig source.
19
+ * @returns {string[]} Candidate absolute paths.
20
+ */
21
+ export const buildTemplateFileCandidates = (baseDir, templatePath) => {
22
+ const normalizedTemplatePath = toPosixPath(templatePath);
23
+ const withoutTwigExt = normalizedTemplatePath.replace(/\.twig$/i, '');
24
+ const stem = basename(withoutTwigExt);
25
+
26
+ return unique(
27
+ [
28
+ resolve(baseDir, normalizedTemplatePath),
29
+ resolve(baseDir, `${normalizedTemplatePath}.twig`),
30
+ resolve(baseDir, `${normalizedTemplatePath}.html.twig`),
31
+ resolve(baseDir, withoutTwigExt, `${stem}.twig`),
32
+ resolve(baseDir, withoutTwigExt, `${stem}.html.twig`),
33
+ ].filter(Boolean),
34
+ );
35
+ };
36
+
37
+ /**
38
+ * Determine whether a file path is equal to or below a candidate root.
39
+ *
40
+ * @param {string} root - Absolute root path.
41
+ * @param {string} filePath - Absolute file path.
42
+ * @returns {boolean} TRUE when the file belongs to the root.
43
+ */
44
+ export const isWithinRoot = (root, filePath) => {
45
+ const rootRelativePath = relative(root, filePath);
46
+ return (
47
+ rootRelativePath === '' ||
48
+ (!!rootRelativePath &&
49
+ !rootRelativePath.startsWith('..') &&
50
+ !isAbsolute(rootRelativePath))
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Return the first component template candidate contained by its configured root.
56
+ *
57
+ * Both lexical and real paths are checked so `..` segments and symlinks cannot
58
+ * escape the component root.
59
+ *
60
+ * @param {Iterable<string>} paths - Candidate absolute paths in precedence order.
61
+ * @param {string} componentRoot - Absolute component root path.
62
+ * @returns {string|undefined} Existing component template path.
63
+ */
64
+ const findExistingComponentTemplateFile = (paths, componentRoot) => {
65
+ const absoluteRoot = resolve(componentRoot);
66
+ let realRoot;
67
+
68
+ try {
69
+ realRoot = fs.realpathSync(absoluteRoot);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+
74
+ for (const filePath of paths) {
75
+ if (!filePath) continue;
76
+ const absoluteFilePath = resolve(filePath);
77
+ if (!isWithinRoot(absoluteRoot, absoluteFilePath)) {
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ if (
83
+ fs.statSync(absoluteFilePath).isFile() &&
84
+ isWithinRoot(realRoot, fs.realpathSync(absoluteFilePath))
85
+ ) {
86
+ return filePath;
87
+ }
88
+ } catch {
89
+ // A missing or unreadable candidate does not prevent later matches.
90
+ }
91
+ }
92
+ return undefined;
93
+ };
94
+
95
+ /**
96
+ * Resolve Twig namespace syntax to a namespace root and relative path.
97
+ *
98
+ * @param {string} templatePath - Template reference from Twig source.
99
+ * @param {Record<string, string>} [namespaces={}] - Namespace root map.
100
+ * @returns {{ namespace: string, root: string, path: string }|null}
101
+ * Namespace lookup result.
102
+ */
103
+ export const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
104
+ const namespaceNames = Object.keys(namespaces);
105
+ const atNamespace = templatePath.match(/^@([^/]+)\/(.+)$/);
106
+ if (atNamespace && namespaces[atNamespace[1]]) {
107
+ return {
108
+ namespace: atNamespace[1],
109
+ root: namespaces[atNamespace[1]],
110
+ path: atNamespace[2],
111
+ };
112
+ }
113
+
114
+ const doubleColon = templatePath.match(/^([^:]+)::(.+)$/);
115
+ if (doubleColon && namespaces[doubleColon[1]]) {
116
+ return {
117
+ namespace: doubleColon[1],
118
+ root: namespaces[doubleColon[1]],
119
+ path: doubleColon[2],
120
+ };
121
+ }
122
+
123
+ const singleColon = templatePath.match(/^([^:/.]+):(.+)$/);
124
+ if (singleColon && namespaces[singleColon[1]]) {
125
+ return {
126
+ namespace: singleColon[1],
127
+ root: namespaces[singleColon[1]],
128
+ path: singleColon[2],
129
+ };
130
+ }
131
+
132
+ const slashNamespace = namespaceNames.find((namespace) =>
133
+ templatePath.startsWith(`${namespace}/`),
134
+ );
135
+ if (slashNamespace) {
136
+ return {
137
+ namespace: slashNamespace,
138
+ // Namespace names come from the normalized Twig namespace map.
139
+ root: namespaces[slashNamespace],
140
+ path: templatePath.slice(slashNamespace.length + 1),
141
+ };
142
+ }
143
+
144
+ return null;
145
+ };
146
+
147
+ /**
148
+ * Return grouping directories below the configured component root.
149
+ *
150
+ * Breadth-first traversal preserves direct and one-level behavior before
151
+ * searching deeper groups. Siblings use UTF-16 code-unit order so duplicate
152
+ * shorthand names resolve consistently across filesystems.
153
+ *
154
+ * @param {string} componentRoot - Absolute component root path.
155
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
156
+ * @returns {string[]} Absolute grouping directory paths.
157
+ */
158
+ const componentGroupRoots = (componentRoot, componentGroupRootsCache) => {
159
+ if (!componentRoot) return [];
160
+
161
+ const absoluteRoot = resolve(componentRoot);
162
+ if (componentGroupRootsCache.has(absoluteRoot)) {
163
+ return componentGroupRootsCache.get(absoluteRoot);
164
+ }
165
+
166
+ const groupRoots = [];
167
+ const pendingDirectories = [absoluteRoot];
168
+
169
+ for (let index = 0; index < pendingDirectories.length; index += 1) {
170
+ const directory = pendingDirectories[index];
171
+ let entries;
172
+
173
+ try {
174
+ entries = fs.readdirSync(directory, { withFileTypes: true });
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ const childDirectories = entries
180
+ .filter(
181
+ (entry) =>
182
+ entry.isDirectory() && !DEFAULT_SKIP_DIRS.includes(entry.name),
183
+ )
184
+ .sort(({ name: left }, { name: right }) =>
185
+ left === right ? 0 : left < right ? -1 : 1,
186
+ )
187
+ .map((entry) => resolve(directory, entry.name))
188
+ .filter((childDirectory) => isWithinRoot(absoluteRoot, childDirectory));
189
+
190
+ groupRoots.push(...childDirectories);
191
+ pendingDirectories.push(...childDirectories);
192
+ }
193
+
194
+ componentGroupRootsCache.set(absoluteRoot, groupRoots);
195
+ return groupRoots;
196
+ };
197
+
198
+ /**
199
+ * Resolve a component reference through recursively grouped directories.
200
+ *
201
+ * Project-scoped component IDs can use the component name (`project:button`)
202
+ * even when projects organize components under grouping paths such as
203
+ * `atoms/text`.
204
+ *
205
+ * @param {string} templatePath - Component-relative template reference.
206
+ * @param {string} componentRoot - Absolute component root path.
207
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
208
+ * @returns {string|null} Existing template path when found.
209
+ */
210
+ const resolveGroupedComponentTemplate = (
211
+ templatePath,
212
+ componentRoot,
213
+ componentGroupRootsCache,
214
+ ) => {
215
+ const groupRoots = componentGroupRoots(
216
+ componentRoot,
217
+ componentGroupRootsCache,
218
+ );
219
+ function* candidates() {
220
+ for (const groupRoot of groupRoots) {
221
+ yield* buildTemplateFileCandidates(groupRoot, templatePath);
222
+ }
223
+ }
224
+
225
+ return findExistingComponentTemplateFile(candidates(), componentRoot) || null;
226
+ };
227
+
228
+ /**
229
+ * Resolve shorthand component references against the components namespace.
230
+ *
231
+ * @param {string} templatePath - Template reference from Twig source.
232
+ * @param {string} componentRoot - Absolute component root path.
233
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
234
+ * @returns {string|null} Existing template path when found.
235
+ */
236
+ const resolveComponentShorthandReference = (
237
+ templatePath,
238
+ componentRoot,
239
+ componentGroupRootsCache,
240
+ ) => {
241
+ if (!componentRoot || templatePath.startsWith('.')) return null;
242
+
243
+ const shorthandPath =
244
+ templatePath.startsWith('@') && !templatePath.includes('/')
245
+ ? templatePath.slice(1)
246
+ : templatePath;
247
+ const directComponentPath = findExistingComponentTemplateFile(
248
+ buildTemplateFileCandidates(componentRoot, shorthandPath),
249
+ componentRoot,
250
+ );
251
+ if (directComponentPath) {
252
+ return directComponentPath;
253
+ }
254
+
255
+ // A bare directory path keeps every segment; only explicit namespace syntax
256
+ // can drop its prefix before searching component groups.
257
+ const genericNamespace = templatePath.match(/^(?:@[^/:]+\/|@?[^/:]+:)(.+)$/);
258
+ if (!genericNamespace) {
259
+ return null;
260
+ }
261
+
262
+ const genericComponentPath = genericNamespace[1];
263
+
264
+ return (
265
+ findExistingComponentTemplateFile(
266
+ buildTemplateFileCandidates(componentRoot, genericComponentPath),
267
+ componentRoot,
268
+ ) ||
269
+ resolveGroupedComponentTemplate(
270
+ genericComponentPath,
271
+ componentRoot,
272
+ componentGroupRootsCache,
273
+ )
274
+ );
275
+ };
276
+
277
+ /**
278
+ * Resolve component namespace paths and project-scoped component shorthand.
279
+ *
280
+ * Configured non-component namespaces remain scoped to their own roots. Direct
281
+ * candidates precede grouped candidates, which retain breadth-first/UTF-16
282
+ * order for duplicate component names.
283
+ *
284
+ * @param {string} templatePath - Template reference from Twig source.
285
+ * @param {Record<string, string>} namespaces - Normalized namespace root map.
286
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
287
+ * @returns {string|null} Existing component template path when found.
288
+ */
289
+ export const resolveComponentReference = (
290
+ templatePath,
291
+ namespaces,
292
+ componentGroupRootsCache,
293
+ ) => {
294
+ const namespaced = parseTwigNamespaceReference(templatePath, namespaces);
295
+ if (namespaced) {
296
+ if (namespaced.namespace !== 'components') return null;
297
+
298
+ return (
299
+ findExistingComponentTemplateFile(
300
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
301
+ namespaced.root,
302
+ ) ||
303
+ resolveGroupedComponentTemplate(
304
+ namespaced.path,
305
+ namespaced.root,
306
+ componentGroupRootsCache,
307
+ )
308
+ );
309
+ }
310
+
311
+ return resolveComponentShorthandReference(
312
+ templatePath,
313
+ namespaces?.components,
314
+ componentGroupRootsCache,
315
+ );
316
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emulsify/core",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Bundled tooling for Storybook development + Vite Build",
5
5
  "keywords": [
6
6
  "component library",
@@ -52,6 +52,7 @@
52
52
  "assets/**/*",
53
53
  "config/.prettierrc.json",
54
54
  "config/.stylelintrc.json",
55
+ "config/a11y-wcag22.js",
55
56
  "config/a11y.config.js",
56
57
  "config/babel.config.js",
57
58
  "config/eslint.config.js",
@@ -103,7 +104,9 @@
103
104
  "config/vite/utils/package-version.js",
104
105
  "config/vite/utils/paths.js",
105
106
  "config/vite/utils/react-singleton.js",
107
+ "config/vite/utils/source-directory-skips.js",
106
108
  "config/vite/utils/source-maps.js",
109
+ "config/vite/utils/twig-component-resolver.js",
107
110
  "config/vite/vite.config.js",
108
111
  "scripts/a11y.js",
109
112
  "scripts/audit-twig-stories.js",
@@ -161,6 +164,7 @@
161
164
  "./storybook/twig/include-function": "./src/storybook/twig/include-function.js",
162
165
  "./storybook/twig/source-function": "./src/storybook/twig/source-function.js",
163
166
  "./storybook/twig/source": "./src/storybook/twig/source.js",
167
+ "./a11y/wcag22": "./config/a11y-wcag22.js",
164
168
  "./vite": "./config/vite/vite.config.js",
165
169
  "./vite/plugins": "./config/vite/plugins.js",
166
170
  "./vite/platforms": "./config/vite/platforms.js",
@@ -195,7 +199,7 @@
195
199
  "prettier": "npm run check-node-version && prettier --check --config config/.prettierrc.json --ignore-unknown \"**/*.{js,mjs,cjs,jsx,json,yml,yaml,scss,md,twig}\"",
196
200
  "prettier-fix": "npm run check-node-version && prettier --config config/.prettierrc.json --write --ignore-unknown \"**/*.{js,mjs,cjs,jsx,json,yml,yaml,scss,md,twig}\"",
197
201
  "release:analyze": "npm run check-node-version && node scripts/verify-release-analysis.js",
198
- "release:verify": "npm run check-node-version && npm run lint && npm test && npm run storybook-build && npm run fixtures:release && npm run fixtures:consumer && npm run pack:dry-run && npm run smoke:pack && npm run release:analyze",
202
+ "release:verify": "node scripts/release-evidence.js verify",
199
203
  "semantic-release": "npm run check-node-version && semantic-release --config ./release.config.cjs",
200
204
  "smoke:pack": "npm run check-node-version && node scripts/smoke-pack.js",
201
205
  "version:develop": "npm run check-node-version && node scripts/bump-version-from-commits.js",
@@ -208,45 +212,46 @@
208
212
  "dependencies": {
209
213
  "@babel/core": "^7.29.7",
210
214
  "@babel/eslint-parser": "^7.29.7",
215
+ "@babel/parser": "^7.29.8",
211
216
  "@babel/preset-env": "^7.29.7",
212
- "@emulsify/cli": "^2.2.0",
217
+ "@emulsify/cli": "^2.4.1",
213
218
  "@eslint/js": "^9.39.5",
214
219
  "@mlnop/vite-plugin-sass-glob-import": "^6.2.0",
215
- "@storybook/addon-a11y": "^10.5.8",
216
- "@storybook/addon-links": "^10.5.8",
217
- "@storybook/addon-themes": "^10.5.8",
218
- "@storybook/react": "^10.5.8",
219
- "@storybook/react-vite": "^10.5.8",
220
+ "@storybook/addon-a11y": "^10.6.0",
221
+ "@storybook/addon-links": "^10.6.0",
222
+ "@storybook/addon-themes": "^10.6.0",
223
+ "@storybook/react": "^10.6.0",
224
+ "@storybook/react-vite": "^10.6.0",
220
225
  "@vituum/vite-plugin-twig": "^2.0.1",
221
- "autoprefixer": "^10.5.4",
226
+ "autoprefixer": "^10.5.6",
222
227
  "axe-core": "^4.13.0",
223
228
  "babel-preset-minify": "^0.5.2",
224
229
  "concurrently": "^10.0.5",
225
230
  "eslint": "^9.39.5",
226
231
  "eslint-config-prettier": "^10.1.8",
227
232
  "eslint-plugin-import": "^2.32.0",
228
- "eslint-plugin-jest": "^29.16.1",
233
+ "eslint-plugin-jest": "^29.16.6",
229
234
  "eslint-plugin-prettier": "^5.5.6",
230
235
  "eslint-plugin-security": "^4.0.1",
231
- "eslint-plugin-storybook": "^10.5.8",
236
+ "eslint-plugin-storybook": "^10.6.0",
232
237
  "glob": "^13.0.6",
233
- "jest": "^30.4.2",
234
- "jest-environment-jsdom": "^30.4.1",
235
- "js-yaml": "^5.3.0",
238
+ "jest": "^30.5.1",
239
+ "jest-environment-jsdom": "^30.5.1",
240
+ "js-yaml": "^5.4.1",
236
241
  "normalize.css": "^8.0.1",
237
242
  "open-cli": "^9.0.0",
238
243
  "pa11y": "^9.1.1",
239
- "postcss": "^8.5.26",
244
+ "postcss": "^8.5.28",
240
245
  "postcss-scss": "^4.0.9",
241
- "sass": "^1.102.0",
242
- "storybook": "^10.5.8",
243
- "stylelint": "^17.14.1",
246
+ "sass": "^1.104.0",
247
+ "storybook": "^10.6.0",
248
+ "stylelint": "^17.15.0",
244
249
  "stylelint-config-standard-scss": "^17.0.0",
245
250
  "stylelint-prettier": "^5.0.3",
246
251
  "stylelint-selector-bem-pattern": "^5.0.0",
247
252
  "twig": "^3.0.0",
248
253
  "twig-drupal-filters": "^3.2.0",
249
- "vite": "^8.2.1"
254
+ "vite": "^8.3.0"
250
255
  },
251
256
  "devDependencies": {
252
257
  "@commitlint/cli": "^21.2.2",
@@ -256,10 +261,10 @@
256
261
  "@semantic-release/npm": "^13.1.5",
257
262
  "@semantic-release/release-notes-generator": "^14.1.1",
258
263
  "husky": "^9.1.7",
259
- "lint-staged": "^17.3.0",
260
- "puppeteer": "^25.7.0",
261
- "react": "^19.2.8",
262
- "react-dom": "^19.2.8",
264
+ "lint-staged": "^17.5.1",
265
+ "puppeteer": "^25.10.0",
266
+ "react": "^19.3.0",
267
+ "react-dom": "^19.3.0",
263
268
  "semantic-release": "^25.0.9"
264
269
  },
265
270
  "peerDependencies": {
@@ -272,13 +277,11 @@
272
277
  "minimatch@3.0.x": "^3.1.5"
273
278
  },
274
279
  "allowScripts": {
280
+ "fsevents@2.3.3": true,
281
+ "unrs-resolver@1.12.2": true,
275
282
  "@parcel/watcher@2.6.0": true,
276
283
  "esbuild@0.28.1": true,
277
- "fsevents@2.3.3": true,
278
284
  "puppeteer@24.43.1": true,
279
- "unrs-resolver@1.12.2": true,
280
- "puppeteer@25.4.0": true,
281
- "puppeteer@25.5.0": true,
282
- "puppeteer@25.7.0": true
285
+ "puppeteer@25.10.0": true
283
286
  }
284
287
  }
package/scripts/a11y.js CHANGED
@@ -15,6 +15,7 @@ import a11yConfig from '../config/a11y.config.js';
15
15
 
16
16
  // Project-specific configuration.
17
17
  let {
18
+ concurrency = 2,
18
19
  ignore = {},
19
20
  components = [],
20
21
  discoverStories = true,
@@ -49,10 +50,16 @@ const loadProjectA11yConfig = async (projectDir = process.cwd()) => {
49
50
  /**
50
51
  * Apply project-specific a11y config values over shared defaults.
51
52
  *
52
- * @param {{ignore?: object, components?: string[], discoverStories?: boolean, storybookBuildDir?: string, pa11y?: object}} config - Project config.
53
+ * @param {{concurrency?: number, ignore?: object, components?: string[], discoverStories?: boolean, storybookBuildDir?: string, pa11y?: object}} config - Project config.
53
54
  * @returns {void}
54
55
  */
55
56
  const applyProjectA11yConfig = (config = {}) => {
57
+ if (config.concurrency !== undefined) {
58
+ if (!Number.isSafeInteger(config.concurrency) || config.concurrency < 1) {
59
+ throw new Error('Accessibility concurrency must be a positive integer.');
60
+ }
61
+ concurrency = config.concurrency;
62
+ }
56
63
  ignore = config.ignore || ignore;
57
64
  components = Array.isArray(config.components)
58
65
  ? config.components
@@ -353,14 +360,23 @@ const logReport = ({ issues, pageUrl }) => {
353
360
  return hasIssues;
354
361
  };
355
362
 
363
+ /**
364
+ * Build the requested URL consistently for scans and execution-failure reports.
365
+ * @param {string} name - Story ID.
366
+ * @param {{baseUrl?: string}} [options={}] - Storybook origin options.
367
+ * @returns {string} Requested story URL.
368
+ */
369
+ const storyUrl = (name, { baseUrl } = {}) =>
370
+ `${baseUrl || resolveStorybookIframe()}?id=${name}`;
371
+
356
372
  /**
357
373
  * Run pa11y on a single Storybook story by its ID.
358
374
  * @param {string} name - Story ID (e.g., "components-button--primary").
359
375
  * @param {{baseUrl?: string}} [options={}] - Storybook origin options.
360
376
  * @returns {Promise<{ issues: Pa11yIssue[], pageUrl: string }>} Pa11y result.
361
377
  */
362
- const lintComponent = async (name, { baseUrl } = {}) =>
363
- pa11y(`${baseUrl || resolveStorybookIframe()}?id=${name}`, {
378
+ const lintComponent = async (name, options = {}) =>
379
+ pa11y(storyUrl(name, options), {
364
380
  includeNotices: true,
365
381
  includeWarnings: true,
366
382
  runners: ['axe'],
@@ -368,19 +384,82 @@ const lintComponent = async (name, { baseUrl } = {}) =>
368
384
  });
369
385
 
370
386
  /**
371
- * Lint a list of components, log reports, and exit(1) if any have issues.
387
+ * @typedef {Object} StoryOutcome
388
+ * @property {string} storyId - Selected Storybook story ID.
389
+ * @property {string} url - Requested story URL, even when navigation fails.
390
+ * @property {'completed'|'failed'} status - Scan execution outcome.
391
+ * @property {object} [report] - Completed Pa11y report.
392
+ * @property {Error} [error] - Contextual execution error with the original cause.
393
+ */
394
+
395
+ /**
396
+ * Lint every selected story and report all outcomes before rejecting failures.
397
+ *
398
+ * Reportable findings set a nonzero exit status. Execution failures also reject
399
+ * with an AggregateError whose errors preserve story context and original causes.
372
400
  * @param {string[]} names - List of Storybook story IDs.
373
401
  * @param {{baseUrl?: string}} [options={}] - Storybook origin options.
374
402
  * @returns {Promise<void>}
375
403
  */
376
404
  const lintReportAndExit = async (names, options = {}) => {
377
- const results = await Promise.all(
378
- names.map((name) => lintComponent(name, options)),
405
+ /** @type {StoryOutcome[]} */
406
+ const outcomes = new Array(names.length);
407
+ let nextIndex = 0;
408
+ const worker = async () => {
409
+ while (nextIndex < names.length) {
410
+ const index = nextIndex;
411
+ nextIndex += 1;
412
+ const storyId = names[index];
413
+ const url = storyUrl(storyId, options);
414
+ try {
415
+ const report = await lintComponent(storyId, options);
416
+ outcomes[index] = { storyId, url, status: 'completed', report };
417
+ } catch (cause) {
418
+ const error = new Error(
419
+ `Accessibility check failed for story "${storyId}" (${url}): ${cause?.message || cause}`,
420
+ { cause },
421
+ );
422
+ Object.assign(error, { storyId, url });
423
+ outcomes[index] = { storyId, url, status: 'failed', error };
424
+ }
425
+ }
426
+ };
427
+ // Pa11y releases its browsers before settling. Drain every worker before
428
+ // reporting or rejecting so the caller can safely close the Storybook server.
429
+ await Promise.all(
430
+ Array.from({ length: Math.min(concurrency, names.length) }, worker),
379
431
  );
380
- const hasIssues = results.map(logReport).some(Boolean);
381
432
 
382
- if (hasIssues) {
383
- process.exit(1);
433
+ const failures = [];
434
+ let clean = 0;
435
+ let withFindings = 0;
436
+ for (const outcome of outcomes) {
437
+ if (outcome.status === 'failed') {
438
+ failures.push(outcome.error);
439
+ // Use the same output stream as completed reports to retain input order.
440
+ // Logging the original error object keeps its stack and nested causes.
441
+ console.log(
442
+ `Execution failed for story: ${outcome.storyId}\nURL: ${outcome.url}`,
443
+ outcome.error.cause,
444
+ );
445
+ } else if (logReport(outcome.report)) {
446
+ withFindings += 1;
447
+ } else {
448
+ clean += 1;
449
+ }
450
+ }
451
+ console.log(
452
+ `Accessibility summary: ${outcomes.length} attempted, ${clean} clean, ${withFindings} with findings, ${failures.length} failed to execute.`,
453
+ );
454
+
455
+ if ((withFindings || failures.length) && !process.exitCode) {
456
+ process.exitCode = 1;
457
+ }
458
+ if (failures.length) {
459
+ throw new AggregateError(
460
+ failures,
461
+ `Accessibility execution failed for ${failures.length} ${failures.length === 1 ? 'story' : 'stories'}.`,
462
+ );
384
463
  }
385
464
  };
386
465
 
@@ -6,7 +6,7 @@ import { resolve } from 'node:path';
6
6
  import { makeFinding } from '../lib/findings.js';
7
7
  import { cachedReadFile } from '../lib/files.js';
8
8
  import {
9
- findTwigIncludeSourceReferences,
9
+ findTwigReferenceCalls,
10
10
  findTwigNamespaceReferences,
11
11
  resolvesTwigReference,
12
12
  } from '../lib/twig.js';
@@ -23,6 +23,7 @@ export function auditTwigReferences(context) {
23
23
  const knownNamespaces = new Set([...Object.keys(namespaceRoots), 'assets']);
24
24
  const findings = [];
25
25
  const seen = new Set();
26
+ const componentGroupRootsCache = new Map();
26
27
 
27
28
  for (const twigFile of twigFiles) {
28
29
  const source = cachedReadFile(twigFile);
@@ -46,15 +47,25 @@ export function auditTwigReferences(context) {
46
47
  );
47
48
  }
48
49
 
49
- for (const ref of findTwigIncludeSourceReferences(source)) {
50
- if (!resolvesTwigReference(ref.value, twigFile, env)) {
50
+ for (const call of findTwigReferenceCalls(source)) {
51
+ // Optional or uncertain calls cannot establish a required missing target.
52
+ if (call.ignoreMissing !== false || call.hasDynamicCandidates) continue;
53
+ const resolved = call.candidates.some(
54
+ ({ value }) =>
55
+ value !== '' &&
56
+ resolvesTwigReference(value, twigFile, env, componentGroupRootsCache),
57
+ );
58
+ if (!resolved) {
59
+ const description = call.isFallbackArray
60
+ ? `fallback candidates ${JSON.stringify(call.candidates.map(({ value }) => value))}`
61
+ : `reference "${call.candidates[0].value}"`;
51
62
  findings.push(
52
63
  makeFinding({
53
64
  id: 'unresolved-twig-reference',
54
65
  severity: 'warn',
55
66
  filePath: twigFile,
56
- line: ref.line,
57
- message: `${ref.type}() reference "${ref.value}" could not be resolved from the normalized Twig roots.`,
67
+ line: call.line,
68
+ message: `${call.type}() ${description} could not be resolved from the normalized Twig roots.`,
58
69
  docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#include',
59
70
  }),
60
71
  );