@emulsify/core 4.2.1 → 4.3.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.
Files changed (84) hide show
  1. package/.storybook/main-vite.js +102 -37
  2. package/.storybook/main.js +20 -0
  3. package/.storybook/ready-reporter.js +230 -0
  4. package/README.md +186 -56
  5. package/config/a11y.config.js +2 -1
  6. package/config/vite/entries.js +4 -4
  7. package/config/vite/plugins/assets/copy-src-assets.js +120 -0
  8. package/config/vite/plugins/assets/copy-twig-files.js +130 -0
  9. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  10. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  11. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  12. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  13. package/config/vite/plugins/index.js +23 -14
  14. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  15. package/config/vite/plugins/reporter/build-errors.js +284 -0
  16. package/config/vite/plugins/reporter/diagnostics.js +367 -0
  17. package/config/vite/plugins/reporter/format.js +240 -0
  18. package/config/vite/plugins/reporter/index.js +417 -0
  19. package/config/vite/plugins/reporter/render.js +1332 -0
  20. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  21. package/config/vite/plugins/reporter/source-roots.js +561 -0
  22. package/config/vite/plugins/reporter/verbosity.js +119 -0
  23. package/config/vite/plugins/reporter/vite-logger.js +249 -0
  24. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  25. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  26. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  27. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +197 -85
  28. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  29. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  30. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  31. package/config/vite/plugins.js +1 -1
  32. package/config/vite/project-config.js +1 -1
  33. package/config/vite/project-structure.js +1 -1
  34. package/config/vite/utils/lru.js +77 -0
  35. package/config/vite/utils/package-version.js +42 -0
  36. package/config/vite/utils/paths.js +1 -9
  37. package/config/vite/utils/react-singleton.js +1 -1
  38. package/config/vite/vite.config.js +91 -5
  39. package/package.json +95 -57
  40. package/scripts/a11y.js +115 -23
  41. package/scripts/audit/checks/core-imports.js +78 -0
  42. package/scripts/audit/checks/css-asset-references.js +99 -0
  43. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  44. package/scripts/audit/checks/files-outside-roots.js +53 -0
  45. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  46. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  47. package/scripts/audit/checks/package-overrides.js +91 -0
  48. package/scripts/audit/checks/project-config.js +71 -0
  49. package/scripts/audit/checks/story-discovery.js +35 -0
  50. package/scripts/audit/checks/twig-references.js +69 -0
  51. package/scripts/audit/checks/twig-volume.js +54 -0
  52. package/scripts/audit/checks/webpack-patterns.js +86 -0
  53. package/scripts/audit/index.js +177 -0
  54. package/scripts/audit/lib/css.js +165 -0
  55. package/scripts/audit/lib/files.js +168 -0
  56. package/scripts/audit/lib/findings.js +31 -0
  57. package/scripts/audit/lib/package-json.js +65 -0
  58. package/scripts/audit/lib/twig.js +227 -0
  59. package/scripts/audit/report.js +273 -0
  60. package/scripts/audit-twig-stories.js +115 -78
  61. package/scripts/audit.js +150 -1632
  62. package/scripts/check-node-version.js +136 -10
  63. package/scripts/inspect-components.js +456 -0
  64. package/scripts/lib/cli.js +179 -0
  65. package/scripts/lib/fs.js +31 -0
  66. package/scripts/lib/proc.js +78 -0
  67. package/scripts/lib/text.js +14 -0
  68. package/scripts/loadYaml.js +2 -2
  69. package/src/extensions/shared/attributes.js +3 -3
  70. package/src/extensions/shared/lists.js +2 -6
  71. package/src/extensions/shared/root-relative.js +38 -0
  72. package/src/storybook/index.js +4 -0
  73. package/src/storybook/render-twig.js +1 -1
  74. package/src/storybook/render-web-component.js +459 -0
  75. package/src/storybook/twig/asset-source-runtime.js +193 -0
  76. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  77. package/src/storybook/twig/reference-paths.js +2 -13
  78. package/src/storybook/twig/setup.js +12 -2
  79. package/src/storybook/twig/source-function.js +5 -2
  80. package/config/vite/plugins/copy-src-assets.js +0 -76
  81. package/config/vite/plugins/copy-twig-files.js +0 -84
  82. package/config/vite/utils/unique.js +0 -36
  83. package/src/storybook/twig/include.js +0 -28
  84. package/src/storybook/twig/source-events.js +0 -5
package/scripts/audit.js CHANGED
@@ -1,1655 +1,155 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * @file Combined Emulsify project readiness audit.
4
+ * @file Bin entry for the combined Emulsify project readiness audit.
5
5
  */
6
6
 
7
- import { lstatSync, readdirSync, statSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
8
  import {
9
- basename,
10
- dirname,
11
- isAbsolute,
12
- relative,
13
- resolve,
14
- sep,
15
- } from 'node:path';
16
- import { globSync } from 'glob';
17
- import { resolveProjectConfig } from '../config/vite/project-config.js';
9
+ createUsage,
10
+ isCliEntrypoint,
11
+ parseArgs as parseCliArgs,
12
+ } from './lib/cli.js';
13
+ import { DEFAULT_TWIG_THRESHOLD, runAudits } from './audit/index.js';
18
14
  import {
19
- compiledAssetOutputPath,
20
- storybookStyleOutputPath,
21
- } from '../config/vite/project-structure.js';
22
- import {
23
- firstExistingPath,
24
- safeExists,
25
- safeReadFile,
26
- safeReadJson,
27
- } from '../config/vite/utils/fs-safe.js';
28
- import { toPosixPath } from '../config/vite/utils/paths.js';
29
- import { candidateKeysForReference } from '../src/storybook/twig/reference-paths.js';
30
- import { analyzeStorySource, collectStoryFiles } from './audit-twig-stories.js';
31
-
32
- const STORY_GLOB = '**/*.stories.{js,jsx,ts,tsx}';
33
- const CODE_GLOB = '**/*.{js,jsx,ts,tsx,mjs,cjs}';
34
- const TWIG_GLOB = '**/*.twig';
35
- const STYLE_GLOB = '**/*.{css,scss,sass}';
36
- const DEFAULT_IGNORES = [
37
- '**/.coverage/**',
38
- '**/.git/**',
39
- '**/.github/**',
40
- '**/.out/**',
41
- '**/dist/**',
42
- '**/*.min.css',
43
- '**/*.test.{js,jsx,ts,tsx,mjs,cjs}',
44
- '**/node_modules/**',
45
- '**/scripts/audit.js',
46
- '**/vendor/**',
47
- ];
48
- const PUBLIC_CORE_IMPORTS = new Set([
49
- '@emulsify/core',
50
- '@emulsify/core/extensions',
51
- '@emulsify/core/extensions/react',
52
- '@emulsify/core/extensions/twig',
53
- '@emulsify/core/package.json',
54
- '@emulsify/core/storybook',
55
- '@emulsify/core/vite',
56
- '@emulsify/core/vite/plugins',
57
- ]);
58
- const DEFAULT_TWIG_THRESHOLD = 250;
59
- const RECOMMENDED_PACKAGE_OVERRIDES = [
60
- {
61
- label: 'glob',
62
- value: '^13.0.6',
63
- paths: [['glob']],
64
- },
65
- {
66
- label: 'locutus',
67
- value: '^3.0.36',
68
- paths: [['locutus']],
69
- },
70
- {
71
- label: 'minimatch@3.0.x',
72
- value: '^3.1.5',
73
- paths: [['minimatch@3.0.x']],
74
- },
75
- ];
76
- const GENERATED_PACKAGE_SCRIPT_DOCS =
77
- 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#manual-packagejson-updates';
78
- const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
79
-
80
- /**
81
- * Cache source file reads for one top-level audit run.
82
- *
83
- * @type {Map<string, string|null>}
84
- */
85
- const fileReadCache = new Map();
86
-
87
- /**
88
- * Clear the per-run source file read cache.
89
- *
90
- * @returns {void}
91
- */
92
- function resetFileReadCache() {
93
- fileReadCache.clear();
94
- }
95
-
96
- /**
97
- * Read a text source file once per top-level audit run.
98
- *
99
- * Missing files are cached as null internally but still return an empty string
100
- * to preserve safeReadFile() behavior for existing checks.
101
- *
102
- * @param {string} filePath - Absolute or relative file path.
103
- * @returns {string} File contents, or an empty string when unavailable.
104
- */
105
- function cachedReadFile(filePath) {
106
- const absPath = resolve(filePath);
107
- if (fileReadCache.has(absPath)) {
108
- return fileReadCache.get(absPath) ?? '';
109
- }
110
-
111
- const source = safeReadFile(absPath);
112
- const cachedSource = source === '' && !safeExists(absPath) ? null : source;
113
- fileReadCache.set(absPath, cachedSource);
114
-
115
- return cachedSource ?? '';
116
- }
117
-
118
- /**
119
- * Return a project-relative path for report output.
120
- *
121
- * @param {string} projectDir - Absolute project root.
122
- * @param {string} filePath - Absolute file path.
123
- * @returns {string} Project-relative POSIX path.
124
- */
125
- function displayPath(projectDir, filePath) {
126
- return toPosixPath(relative(projectDir, filePath));
127
- }
128
-
129
- /**
130
- * Determine whether a candidate is a directory.
131
- *
132
- * @param {string} filePath - Absolute path.
133
- * @returns {boolean} TRUE when the path is a directory.
134
- */
135
- function safeIsDirectory(filePath) {
136
- try {
137
- return lstatSync(filePath).isDirectory();
138
- } catch {
139
- return false;
140
- }
141
- }
142
-
143
- /**
144
- * Find the 1-based line number for a character index.
145
- *
146
- * @param {string} source - File source.
147
- * @param {number} index - Character index.
148
- * @returns {number} 1-based line number.
149
- */
150
- function lineNumberAt(source, index) {
151
- return source.slice(0, index).split('\n').length;
152
- }
153
-
154
- /**
155
- * Build a report finding.
156
- *
157
- * @param {object} finding - Finding details.
158
- * @returns {object} Normalized finding.
159
- */
160
- function makeFinding(finding) {
161
- return {
162
- severity: 'warn',
163
- docs: undefined,
164
- ...finding,
165
- };
166
- }
167
-
168
- /**
169
- * Collect files from a project.
170
- *
171
- * @param {string} projectDir - Absolute project root.
172
- * @param {string|string[]} patterns - Glob pattern or patterns.
173
- * @returns {string[]} Absolute file paths.
174
- */
175
- export function collectProjectFiles(projectDir, patterns) {
176
- return globSync(patterns, {
177
- cwd: projectDir,
178
- nodir: true,
179
- absolute: true,
180
- ignore: DEFAULT_IGNORES,
181
- })
182
- .map((filePath) => resolve(filePath))
183
- .sort();
184
- }
185
-
186
- /**
187
- * Return a normalized, project-contained root list.
188
- *
189
- * @param {string} projectDir - Absolute project root.
190
- * @param {string[]} roots - Absolute candidate roots.
191
- * @returns {string[]} Existing roots inside the project.
192
- */
193
- function normalizeAuditRoots(projectDir, roots = []) {
194
- const resolvedProject = resolve(projectDir);
195
-
196
- return Array.from(
197
- new Set(
198
- roots
199
- .filter(Boolean)
200
- .map((root) => resolve(root))
201
- .filter(
202
- (root) =>
203
- isSameOrInside(root, resolvedProject) && safeIsDirectory(root),
204
- ),
205
- ),
206
- ).sort();
207
- }
208
-
209
- /**
210
- * Collect files from normalized audit roots only.
211
- *
212
- * @param {string} projectDir - Absolute project root.
213
- * @param {string|string[]} patterns - Glob pattern or patterns.
214
- * @param {string[]} roots - Absolute roots to scan.
215
- * @returns {string[]} Absolute file paths.
216
- */
217
- function collectRootedProjectFiles(projectDir, patterns, roots = []) {
218
- const files = new Set();
219
-
220
- for (const root of normalizeAuditRoots(projectDir, roots)) {
221
- for (const filePath of globSync(patterns, {
222
- cwd: root,
223
- nodir: true,
224
- absolute: true,
225
- ignore: DEFAULT_IGNORES,
226
- })) {
227
- files.add(resolve(filePath));
228
- }
229
- }
230
-
231
- return Array.from(files).sort();
232
- }
233
-
234
- /**
235
- * Determine whether a file is inside one of the roots.
236
- *
237
- * @param {string} filePath - Absolute file path.
238
- * @param {string[]} roots - Absolute roots.
239
- * @returns {boolean} TRUE when inside a root.
240
- */
241
- function isInsideAnyRoot(filePath, roots = []) {
242
- return roots.some((root) => {
243
- const rel = relative(root, filePath);
244
- return Boolean(rel) && !rel.startsWith('..') && !rel.includes(`..${sep}`);
245
- });
246
- }
247
-
248
- /**
249
- * Determine whether a path is the same as, or inside, a root directory.
250
- *
251
- * @param {string} filePath - Absolute file path.
252
- * @param {string} root - Absolute root path.
253
- * @returns {boolean} TRUE when the path is inside or equal to the root.
254
- */
255
- function isSameOrInside(filePath, root) {
256
- const rel = relative(root, filePath);
257
- return !rel || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
258
- }
259
-
260
- /**
261
- * Return a nested object value.
262
- *
263
- * @param {object} obj - Object to inspect.
264
- * @param {string[]} pathParts - Nested object path.
265
- * @returns {*} Nested value.
266
- */
267
- function valueAtPath(obj, pathParts) {
268
- return pathParts.reduce(
269
- (current, key) =>
270
- current && typeof current === 'object' ? current[key] : undefined,
271
- obj,
272
- );
273
- }
274
-
275
- /**
276
- * Determine whether a package manifest depends on Emulsify Core.
277
- *
278
- * @param {object} packageJson - Parsed package.json.
279
- * @returns {boolean} TRUE when package.json is Core or consumes Core.
280
- */
281
- function packageUsesEmulsifyCore(packageJson = {}) {
282
- if (packageJson.name === '@emulsify/core') {
283
- return true;
284
- }
285
-
286
- return [
287
- 'dependencies',
288
- 'devDependencies',
289
- 'peerDependencies',
290
- 'optionalDependencies',
291
- ].some((section) =>
292
- Object.prototype.hasOwnProperty.call(
293
- packageJson[section] || {},
294
- '@emulsify/core',
295
- ),
296
- );
297
- }
298
-
299
- /**
300
- * Determine whether a package manifest is Emulsify Core itself.
301
- *
302
- * @param {object} packageJson - Parsed package.json.
303
- * @returns {boolean} TRUE when package.json is Core.
304
- */
305
- function packageIsEmulsifyCore(packageJson = {}) {
306
- return packageJson.name === '@emulsify/core';
307
- }
308
-
309
- /**
310
- * Determine whether a recommended override is already present.
311
- *
312
- * @param {object} overrides - package.json overrides object.
313
- * @param {{paths: string[][]}} recommendation - Override recommendation.
314
- * @returns {boolean} TRUE when any equivalent override path exists.
315
- */
316
- function hasRecommendedOverride(overrides = {}, recommendation) {
317
- return recommendation.paths.some(
318
- (pathParts) => valueAtPath(overrides, pathParts) !== undefined,
319
- );
320
- }
321
-
322
- /**
323
- * Normalize the project config, retaining any resolution failure.
324
- *
325
- * @param {string} projectDir - Absolute project root.
326
- * @returns {{env: object, configExists: boolean, error?: Error}}
327
- */
328
- function resolveAuditEnvironment(projectDir) {
329
- const configExists = safeExists(resolve(projectDir, 'project.emulsify.json'));
330
-
331
- try {
332
- return {
333
- env: resolveProjectConfig(projectDir, process.env),
334
- configExists,
335
- };
336
- } catch (error) {
337
- return {
338
- env: {
339
- projectDir,
340
- platform: 'none',
341
- namespaceRoots: {},
342
- projectStructure: {},
343
- },
344
- configExists,
345
- error,
346
- };
347
- }
348
- }
349
-
350
- /**
351
- * Audit basic project configuration and structure root health.
352
- *
353
- * @param {object} context - Audit context.
354
- * @returns {object[]} Findings.
355
- */
356
- function auditProjectConfig(context) {
357
- const { configExists, env, error, projectDir } = context;
358
- const findings = [];
359
-
360
- if (!configExists) {
361
- findings.push(
362
- makeFinding({
363
- id: 'missing-project-config',
364
- severity: 'error',
365
- message:
366
- 'project.emulsify.json is missing, so platform and structure defaults may not match the project.',
367
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
368
- }),
369
- );
370
- }
371
-
372
- if (error) {
373
- findings.push(
374
- makeFinding({
375
- id: 'project-config-resolution-failed',
376
- severity: 'error',
377
- message: `Unable to resolve project.emulsify.json: ${error.message || error}`,
378
- }),
379
- );
380
- }
381
-
382
- for (const implementation of env.structureImplementations || []) {
383
- if (!safeIsDirectory(implementation.directory)) {
384
- findings.push(
385
- makeFinding({
386
- id: 'missing-structure-implementation',
387
- severity: 'error',
388
- filePath: resolve(projectDir, 'project.emulsify.json'),
389
- message: `Configured structureImplementation "${implementation.name}" does not exist: ${displayPath(
390
- projectDir,
391
- implementation.directory,
392
- )}`,
393
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
394
- }),
395
- );
396
- }
397
- }
398
-
399
- for (const root of env.ignoredAssetRoots || []) {
400
- findings.push(
401
- makeFinding({
402
- id: 'invalid-asset-root',
403
- severity: 'warn',
404
- filePath: resolve(projectDir, 'project.emulsify.json'),
405
- message: `Configured asset root "${root}" was ignored because it resolves outside the project root.`,
406
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md#asset-roots',
407
- }),
408
- );
409
- }
410
-
411
- return findings;
412
- }
413
-
414
- /**
415
- * Audit package-level dependency override policy for installed projects.
416
- *
417
- * npm only applies `overrides` from the root package being installed. When
418
- * Emulsify Core is installed into a generated theme, Core's own overrides do
419
- * not protect that theme's transitive dependency graph.
420
- *
421
- * @param {object} context - Audit context.
422
- * @returns {object[]} Findings.
423
- */
424
- function auditPackageOverrides(context) {
425
- const { projectDir } = context;
426
- const packagePath = resolve(projectDir, 'package.json');
427
-
428
- if (!safeExists(packagePath)) {
429
- return [];
430
- }
431
-
432
- const { data: packageJson, error } = safeReadJson(packagePath);
433
- if (error) {
434
- return [
435
- makeFinding({
436
- id: 'package-json-unreadable',
437
- severity: 'warn',
438
- filePath: packagePath,
439
- message: `Unable to parse package.json: ${error.message || error}`,
440
- }),
441
- ];
442
- }
443
-
444
- if (!packageUsesEmulsifyCore(packageJson)) {
445
- return [];
446
- }
447
-
448
- const overrides = packageJson.overrides || {};
449
- const missing = RECOMMENDED_PACKAGE_OVERRIDES.filter(
450
- (recommendation) => !hasRecommendedOverride(overrides, recommendation),
451
- );
452
-
453
- if (!missing.length) {
454
- return [];
455
- }
456
-
457
- return [
458
- makeFinding({
459
- id: 'recommended-package-overrides-missing',
460
- severity: 'warn',
461
- filePath: packagePath,
462
- message:
463
- 'package.json is missing recommended root npm overrides for Emulsify Core transitive install warnings.',
464
- details: missing.map(
465
- (recommendation) =>
466
- `Add overrides.${recommendation.label}: ${recommendation.value}.`,
467
- ),
468
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#install-warning-controls',
469
- }),
470
- ];
471
- }
472
-
473
- /**
474
- * Audit generated-theme package scripts that must be updated manually.
475
- *
476
- * Generated themes copy their root package.json from the starter at creation
477
- * time. Whisk updates do not automatically flow into existing themes, so the
478
- * audit flags stale Webpack-era scripts and missing Core 4 audit/Vite scripts.
479
- *
480
- * @param {object} context - Audit context.
481
- * @returns {object[]} Findings.
482
- */
483
- function auditGeneratedPackageScripts(context) {
484
- const { env, projectDir } = context;
485
- const packagePath = resolve(projectDir, 'package.json');
486
-
487
- if (!safeExists(packagePath)) {
488
- return [];
489
- }
490
-
491
- const { data: packageJson, error } = safeReadJson(packagePath);
492
- if (error || !packageUsesEmulsifyCore(packageJson)) {
493
- return [];
494
- }
495
-
496
- if (packageIsEmulsifyCore(packageJson)) {
497
- return [];
498
- }
499
-
500
- const scripts = packageJson.scripts || {};
501
- const starterRepository = env.projectConfig?.starter?.repository;
502
- const fromGeneratedStarter =
503
- typeof starterRepository === 'string' &&
504
- /emulsify-(drupal|wordpress|craftcms|starter)|emulsify-ds/i.test(
505
- starterRepository,
506
- );
507
- const usesGeneratedCoreScripts = Object.values(scripts).some(
508
- (script) =>
509
- typeof script === 'string' &&
510
- /node_modules\/@emulsify\/core\/(?:config\/(?:webpack|vite)|scripts\/audit)/.test(
511
- script,
512
- ),
513
- );
514
-
515
- if (!fromGeneratedStarter && !usesGeneratedCoreScripts) {
516
- return [];
517
- }
518
-
519
- const findings = [];
520
- const details = [];
521
- const buildScript = scripts.build || '';
522
-
523
- if (/\bwebpack\b|config\/webpack/.test(buildScript)) {
524
- details.push('Replace scripts.build with the Vite build command.');
525
- } else if (
526
- /node_modules\/@emulsify\/core\/config\/vite\/vite\.config\.js/.test(
527
- buildScript,
528
- ) &&
529
- /\bvite\s+(?:--config|-c)\b/.test(buildScript)
530
- ) {
531
- details.push('Replace scripts.build with the Vite build command.');
532
- }
533
-
534
- if (Object.prototype.hasOwnProperty.call(scripts, 'build-dev')) {
535
- details.push('Remove scripts.build-dev; the Vite build replaces it.');
536
- }
537
-
538
- if (/\bwebpack\b|npm:webpack|config\/webpack/.test(scripts.develop || '')) {
539
- details.push('Replace scripts.develop with the Vite/Storybook watcher.');
540
- }
541
-
542
- if (Object.prototype.hasOwnProperty.call(scripts, 'webpack')) {
543
- details.push('Replace scripts.webpack with scripts.vite.');
544
- }
545
-
546
- for (const scriptName of ['audit', 'audit:twig-stories', 'vite']) {
547
- if (!Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
548
- details.push(`Add scripts.${scriptName}.`);
549
- }
550
- }
551
-
552
- if (details.length) {
553
- findings.push(
554
- makeFinding({
555
- id: 'generated-package-json-migration-needed',
556
- severity: 'warn',
557
- filePath: packagePath,
558
- message:
559
- 'package.json does not match the generated-theme scripts expected by Emulsify Core 4.',
560
- details,
561
- docs: GENERATED_PACKAGE_SCRIPT_DOCS,
562
- }),
563
- );
564
- }
565
-
566
- return findings;
567
- }
568
-
569
- /**
570
- * Audit story files that will not be discovered by Storybook.
571
- *
572
- * @param {object} context - Audit context.
573
- * @returns {object[]} Findings.
574
- */
575
- function auditStoryDiscovery(context) {
576
- const { projectDir, storyFiles } = context;
577
- const discovered = new Set(collectStoryFiles(projectDir));
578
- const findings = [];
579
-
580
- for (const storyFile of storyFiles) {
581
- if (discovered.has(storyFile)) continue;
582
-
583
- findings.push(
584
- makeFinding({
585
- id: 'story-outside-discovered-roots',
586
- severity: 'error',
587
- filePath: storyFile,
588
- message:
589
- 'Story file is outside the normalized Storybook roots and will not be discovered.',
590
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
591
- }),
592
- );
593
- }
594
-
595
- return findings;
596
- }
597
-
598
- /**
599
- * Add legacy Twig story migration findings.
600
- *
601
- * @param {object} context - Audit context.
602
- * @returns {object[]} Findings.
603
- */
604
- function auditLegacyTwigStories(context) {
605
- const { storyFiles } = context;
606
- const findings = storyFiles
607
- .map((filePath) => analyzeStorySource(cachedReadFile(filePath), filePath))
608
- .filter((result) => result.shouldUpgrade);
609
-
610
- return findings.map((finding) =>
611
- makeFinding({
612
- id: 'legacy-twig-story',
613
- severity: 'warn',
614
- filePath: finding.filePath,
615
- line: finding.directTemplateReturns[0]?.line,
616
- message:
617
- 'Twig story appears to return an HTML string directly. This remains compatible, but renderTwig() is preferred for active migrations.',
618
- details: finding.reasons,
619
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility',
620
- }),
621
- );
622
- }
623
-
624
- /**
625
- * Extract string arguments passed to include() or source().
626
- *
627
- * @param {string} source - Twig source.
628
- * @returns {{type: string, value: string, line: number}[]} References.
629
- */
630
- export function findTwigIncludeSourceReferences(source) {
631
- const references = [];
632
- const callPattern = /\b(include|source)\s*\(([\s\S]*?)\)/g;
633
-
634
- for (const callMatch of source.matchAll(callPattern)) {
635
- const type = callMatch[1];
636
- const args = firstArgumentText(callMatch[2]);
637
- const argsOffset = (callMatch.index || 0) + callMatch[0].indexOf(args);
638
- const stringPattern = /['"]([^'"]+)['"]/g;
639
-
640
- for (const stringMatch of args.matchAll(stringPattern)) {
641
- references.push({
642
- type,
643
- value: stringMatch[1],
644
- line: lineNumberAt(source, argsOffset + (stringMatch.index || 0)),
645
- });
646
- }
647
- }
648
-
649
- return references;
650
- }
651
-
652
- /**
653
- * Extract the first function argument, including array syntax.
654
- *
655
- * Twig include()/source() only use the first argument as the template/source
656
- * reference. Later object values may also be strings, but they are context
657
- * values and should not be treated as template references.
658
- *
659
- * @param {string} args - Function argument source.
660
- * @returns {string} First argument source.
661
- */
662
- function firstArgumentText(args) {
663
- let quote = '';
664
- let depth = 0;
665
-
666
- for (let index = 0; index < args.length; index += 1) {
667
- const char = args[index];
668
- const prev = args[index - 1];
669
-
670
- if (quote) {
671
- if (char === quote && prev !== '\\') {
672
- quote = '';
673
- }
674
- continue;
675
- }
676
-
677
- if (char === '"' || char.charCodeAt(0) === 39) {
678
- quote = char;
679
- continue;
680
- }
681
- if (char === '[' || char === '{' || char === '(') {
682
- depth += 1;
683
- continue;
684
- }
685
- if (char === ']' || char === '}' || char === ')') {
686
- depth = Math.max(0, depth - 1);
687
- continue;
688
- }
689
- if (char === ',' && depth === 0) {
690
- return args.slice(0, index);
691
- }
692
- }
693
-
694
- return args;
695
- }
696
-
697
- /**
698
- * Extract Twig namespace references such as @components/card/card.twig.
699
- *
700
- * @param {string} source - Twig source.
701
- * @returns {{namespace: string, value: string, line: number}[]} Namespace refs.
702
- */
703
- export function findTwigNamespaceReferences(source) {
704
- const references = [];
705
- const pattern = /@([A-Za-z][\w-]*)\/[A-Za-z0-9_./-]+/g;
706
-
707
- for (const match of source.matchAll(pattern)) {
708
- references.push({
709
- namespace: match[1],
710
- value: match[0],
711
- line: lineNumberAt(source, match.index || 0),
712
- });
713
- }
714
-
715
- return references;
716
- }
717
-
718
- /**
719
- * Build candidate paths for a relative Twig reference.
720
- *
721
- * @param {string} filePath - Referencing file.
722
- * @param {string} reference - Twig reference.
723
- * @returns {string[]} Absolute candidate paths.
724
- */
725
- function relativeTwigCandidates(filePath, reference) {
726
- const base = resolve(dirname(filePath), reference);
727
- if (/\.[A-Za-z0-9]+$/.test(reference)) {
728
- return [base];
729
- }
730
-
731
- return [`${base}.twig`, `${base}.html.twig`];
732
- }
733
-
734
- /**
735
- * Convert resolver candidate keys into absolute filesystem paths.
736
- *
737
- * @param {string[]} keys - Root-relative Vite keys.
738
- * @param {object} env - Normalized environment.
739
- * @returns {string[]} Absolute candidate paths.
740
- */
741
- function candidateKeysToFiles(keys, env) {
742
- const projectDir = env.projectDir || process.cwd();
743
-
744
- return keys.map((key) =>
745
- key.startsWith('/') ? resolve(projectDir, key.slice(1)) : resolve(key),
746
- );
747
- }
748
-
749
- /**
750
- * Resolve an audit asset root using Storybook's root-relative convention.
751
- *
752
- * @param {string} projectDir - Absolute project root.
753
- * @param {string} assetRoot - Configured, absolute, or project-relative root.
754
- * @returns {string} Absolute filesystem path, or an empty string.
755
- */
756
- function resolveAuditAssetRoot(projectDir, assetRoot) {
757
- if (typeof assetRoot !== 'string' || !assetRoot.trim()) return '';
758
-
759
- const normalizedProjectDir = resolve(projectDir || process.cwd());
760
- const normalizedRoot = assetRoot.trim();
761
-
762
- if (isAbsolute(normalizedRoot)) {
763
- const absoluteRoot = resolve(normalizedRoot);
764
-
765
- return safeExists(absoluteRoot)
766
- ? absoluteRoot
767
- : resolve(normalizedProjectDir, `.${normalizedRoot}`);
768
- }
769
-
770
- return resolve(normalizedProjectDir, normalizedRoot);
771
- }
772
-
773
- /**
774
- * Return filesystem roots that Storybook can use for @assets source() calls.
775
- *
776
- * @param {object} env - Normalized environment.
777
- * @param {object} [options={}] - Asset root options.
778
- * @param {boolean} [options.includeGenerated=false] - Include generated roots.
779
- * @returns {string[]} Absolute asset roots.
780
- */
781
- function auditAssetRoots(env = {}, { includeGenerated = false } = {}) {
782
- const projectDir = env.projectDir || process.cwd();
783
- const configuredRoots = Array.isArray(env?.projectStructure?.assetRoots)
784
- ? env.projectStructure.assetRoots
785
- : [];
786
- const fallbackRoots = ['assets', 'src/assets'];
787
- const generatedRoots = includeGenerated ? ['dist/assets'] : [];
788
-
789
- return Array.from(
790
- new Set(
791
- [...fallbackRoots, ...configuredRoots, ...generatedRoots]
792
- .map((root) => resolveAuditAssetRoot(projectDir, root))
793
- .filter(Boolean),
794
- ),
795
- );
796
- }
797
-
798
- /**
799
- * Determine whether an @assets reference resolves through Storybook asset roots.
800
- *
801
- * @param {string} reference - Twig @assets reference.
802
- * @param {object} env - Normalized environment.
803
- * @returns {boolean} TRUE when a candidate exists.
804
- */
805
- function resolvesAssetReference(reference, env) {
806
- const relAsset = reference.replace(/^@assets\//, '');
807
- if (!relAsset) return false;
808
- const includeGenerated = GENERATED_ASSET_ALIASES.has(relAsset);
809
-
810
- return auditAssetRoots(env, { includeGenerated }).some((root) => {
811
- const candidate = resolve(root, relAsset);
812
-
813
- return isSameOrInside(candidate, root) && safeExists(candidate);
814
- });
815
- }
816
-
817
- /**
818
- * Determine whether a Twig include/source reference resolves.
819
- *
820
- * @param {string} reference - Twig reference.
821
- * @param {string} filePath - Referencing file path.
822
- * @param {object} env - Normalized environment.
823
- * @returns {boolean} TRUE when a candidate exists.
824
- */
825
- export function resolvesTwigReference(reference, filePath, env) {
826
- if (!reference || /^https?:\/\//i.test(reference)) return true;
827
-
828
- if (reference.startsWith('@assets/')) {
829
- return resolvesAssetReference(reference, env);
830
- }
831
-
832
- const candidates =
833
- reference.startsWith('./') || reference.startsWith('../')
834
- ? relativeTwigCandidates(filePath, reference)
835
- : candidateKeysToFiles(candidateKeysForReference(reference, env), env);
836
-
837
- return candidates.some(safeExists);
838
- }
839
-
840
- /**
841
- * Audit Twig namespace and include/source resolution.
842
- *
843
- * @param {object} context - Audit context.
844
- * @returns {object[]} Findings.
845
- */
846
- function auditTwigReferences(context) {
847
- const { env, projectDir, twigFiles } = context;
848
- const namespaceRoots = env.namespaceRoots || {};
849
- const knownNamespaces = new Set([...Object.keys(namespaceRoots), 'assets']);
850
- const findings = [];
851
- const seen = new Set();
852
-
853
- for (const twigFile of twigFiles) {
854
- const source = cachedReadFile(twigFile);
855
-
856
- for (const ref of findTwigNamespaceReferences(source)) {
857
- if (knownNamespaces.has(ref.namespace)) continue;
858
-
859
- const key = `${twigFile}:${ref.line}:unknown:${ref.namespace}`;
860
- if (seen.has(key)) continue;
861
- seen.add(key);
862
-
863
- findings.push(
864
- makeFinding({
865
- id: 'unknown-twig-namespace',
866
- severity: 'warn',
867
- filePath: twigFile,
868
- line: ref.line,
869
- message: `Twig namespace "@${ref.namespace}" is not configured in the normalized project structure.`,
870
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md#twig-namespaces',
871
- }),
872
- );
873
- }
874
-
875
- for (const ref of findTwigIncludeSourceReferences(source)) {
876
- if (!resolvesTwigReference(ref.value, twigFile, env)) {
877
- findings.push(
878
- makeFinding({
879
- id: 'unresolved-twig-reference',
880
- severity: 'warn',
881
- filePath: twigFile,
882
- line: ref.line,
883
- message: `${ref.type}() reference "${ref.value}" could not be resolved from the normalized Twig roots.`,
884
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#include',
885
- }),
886
- );
887
- }
888
- }
889
- }
890
-
891
- return findings.map((finding) => ({
892
- ...finding,
893
- filePath: finding.filePath || resolve(projectDir, 'project.emulsify.json'),
894
- }));
895
- }
896
-
897
- /**
898
- * Extract simple same-file Sass string variables.
899
- *
900
- * @param {string} source - Stylesheet source.
901
- * @returns {Map<string, string>} Variable value map.
902
- */
903
- function findSassStringVariables(source) {
904
- const variables = new Map();
905
- const pattern = /^\s*\$([\w-]+)\s*:\s*(['"])(.*?)\2\s*;?/gm;
906
-
907
- for (const match of source.matchAll(pattern)) {
908
- variables.set(match[1], match[3]);
909
- }
910
-
911
- return variables;
912
- }
913
-
914
- /**
915
- * Resolve same-file Sass variable interpolation in a URL value.
916
- *
917
- * This intentionally handles only simple string variables. It is enough to make
918
- * common asset roots such as `#{$font-url}/Avenir.woff2` auditable without
919
- * pretending to be a Sass compiler.
920
- *
921
- * @param {string} value - Raw URL value.
922
- * @param {Map<string, string>} variables - Sass variable map.
923
- * @returns {string} URL value with known interpolations expanded.
924
- */
925
- function resolveSassUrlValue(value, variables) {
926
- return value.replace(/#\{\$([\w-]+)\}/g, (match, name) =>
927
- variables.has(name) ? variables.get(name) : match,
928
- );
929
- }
930
-
931
- /**
932
- * Mask style comments while preserving line and character positions.
933
- *
934
- * @param {string} source - Stylesheet source.
935
- * @returns {string} Source with comments replaced by whitespace.
936
- */
937
- function maskStyleComments(source) {
938
- const blank = (match) => match.replace(/[^\n]/g, ' ');
939
-
940
- return source
941
- .replace(/\/\*[\s\S]*?\*\//g, blank)
942
- .replace(/^[\t ]*\/\/.*$/gm, blank);
943
- }
944
-
945
- /**
946
- * Extract URL references from CSS or Sass source.
947
- *
948
- * @param {string} source - Stylesheet source.
949
- * @returns {{value: string, raw: string, line: number}[]} URL references.
950
- */
951
- export function findCssUrlReferences(source) {
952
- const scanSource = maskStyleComments(source);
953
- const variables = findSassStringVariables(scanSource);
954
- const references = [];
955
- const pattern = /url\(\s*(?:(['"])(.*?)\1|([^'")][^)]*?))\s*\)/g;
956
-
957
- for (const match of scanSource.matchAll(pattern)) {
958
- const raw = (match[2] ?? match[3] ?? '').trim();
959
- const value = resolveSassUrlValue(raw, variables).trim();
960
-
961
- references.push({
962
- value,
963
- raw,
964
- line: lineNumberAt(source, match.index || 0),
965
- });
966
- }
967
-
968
- return references;
969
- }
15
+ formatAuditJsonErrorReport,
16
+ formatAuditJsonReport,
17
+ formatAuditReport,
18
+ } from './audit/report.js';
19
+
20
+ export { auditProject, runAudits } from './audit/index.js';
21
+ export {
22
+ AUDIT_REPORT_SCHEMA_VERSION,
23
+ createAuditJsonErrorReport,
24
+ createAuditJsonReport,
25
+ formatAuditJsonErrorReport,
26
+ formatAuditJsonReport,
27
+ formatAuditReport,
28
+ } from './audit/report.js';
29
+ export { collectProjectFiles } from './audit/lib/files.js';
30
+ export { findCssUrlReferences } from './audit/lib/css.js';
31
+ export {
32
+ findTwigIncludeSourceReferences,
33
+ findTwigNamespaceReferences,
34
+ resolvesTwigReference,
35
+ } from './audit/lib/twig.js';
36
+
37
+ const failOnValues = ['error', 'warn', 'info', 'any'];
38
+ const findingSeverities = ['error', 'warn', 'info'];
39
+ const cliFailureExitCode = 2;
970
40
 
971
41
  /**
972
- * Determine whether a CSS URL should be skipped by filesystem checks.
42
+ * CLI usage text.
973
43
  *
974
- * @param {string} value - URL value.
975
- * @returns {boolean} TRUE when the URL is not a local relative asset path.
44
+ * @returns {string} Usage text.
976
45
  */
977
- function isNonFilesystemCssUrl(value) {
978
- return (
979
- !value ||
980
- value.startsWith('#') ||
981
- value.startsWith('/') ||
982
- value.startsWith('//') ||
983
- value.startsWith('$') ||
984
- value.startsWith('#{') ||
985
- /^[a-z][a-z0-9+.-]*:/i.test(value) ||
986
- /^var\(/i.test(value) ||
987
- /^env\(/i.test(value)
46
+ function usage() {
47
+ return createUsage(
48
+ 'Usage: emulsify-audit [--root <dir>] [--json] [--fail-on <severity>] [--fail-on-found] [--twig-threshold <count>]',
49
+ [
50
+ ' --root <dir> Project root to scan. Defaults to the current directory.',
51
+ ' --json Print machine-readable JSON.',
52
+ ' --fail-on <severity> Exit with code 1 for error, warn, info, or any findings at that threshold.',
53
+ ' --fail-on-found Compatibility alias for --fail-on any.',
54
+ ` --twig-threshold <count> Warn when Storybook roots contain more than this many Twig files. Default: ${DEFAULT_TWIG_THRESHOLD}.`,
55
+ ' --help Print this help text.',
56
+ ],
988
57
  );
989
58
  }
990
59
 
991
60
  /**
992
- * Remove query string and hash suffixes from a URL path.
993
- *
994
- * @param {string} value - URL value.
995
- * @returns {string} Path portion.
996
- */
997
- function cssUrlPath(value) {
998
- return value.split(/[?#]/)[0];
999
- }
1000
-
1001
- /**
1002
- * Resolve an emitted CSS output key to the actual CSS file path.
1003
- *
1004
- * Vite entry keys use `__style` internally to avoid JS/CSS collisions. The
1005
- * shared Vite config removes that suffix from emitted CSS file names.
1006
- *
1007
- * @param {string} key - Output key without extension.
1008
- * @returns {string} Emitted CSS file path relative to output root.
1009
- */
1010
- function emittedCssRelativePath(key) {
1011
- return `${key.replace(/__style$/i, '')}.css`;
1012
- }
1013
-
1014
- /**
1015
- * Return possible runtime directories for a style file's emitted CSS.
1016
- *
1017
- * @param {string} filePath - Source stylesheet.
1018
- * @param {object} env - Normalized environment.
1019
- * @param {string} projectDir - Project root.
1020
- * @returns {string[]} Absolute runtime directories.
1021
- */
1022
- function styleRuntimeDirectories(filePath, env, projectDir) {
1023
- if (!/\.(scss|sass|css)$/i.test(filePath)) return [];
1024
- if (basename(filePath).startsWith('_')) return [];
1025
-
1026
- const structure = env.projectStructure || {};
1027
- if (!structure.output) return [];
1028
-
1029
- const ctx = {
1030
- projectDir,
1031
- srcDir: env.srcDir || resolve(projectDir, 'src'),
1032
- SDC: Boolean(env.SDC),
1033
- };
1034
- const fileName = basename(filePath);
1035
- const isStorybookStyle = /^(cl-|sb-)/.test(fileName);
1036
- const key = isStorybookStyle
1037
- ? storybookStyleOutputPath(filePath, structure, ctx)
1038
- : compiledAssetOutputPath(filePath, 'css', structure, ctx);
1039
-
1040
- if (!key) return [];
1041
-
1042
- const relCss = emittedCssRelativePath(key);
1043
- const directories = [dirname(resolve(projectDir, 'dist', relCss))];
1044
-
1045
- if (structure.mirrorComponentOutput && relCss.startsWith('components/')) {
1046
- directories.push(dirname(resolve(projectDir, relCss)));
1047
- }
1048
-
1049
- return Array.from(new Set(directories));
1050
- }
1051
-
1052
- /**
1053
- * Audit local CSS/Sass asset URLs that Vite may leave to runtime resolution.
1054
- *
1055
- * @param {object} context - Audit context.
1056
- * @returns {object[]} Findings.
1057
- */
1058
- function auditCssAssetReferences(context) {
1059
- const { env, projectDir, styleFiles } = context;
1060
- const findings = [];
1061
- const projectAssetRoots = auditAssetRoots(env).filter(safeIsDirectory);
1062
- const styleSourceRoots = env.projectStructure?.sourceRoots || [];
1063
-
1064
- for (const filePath of styleFiles) {
1065
- if (
1066
- styleSourceRoots.length &&
1067
- !isInsideAnyRoot(filePath, styleSourceRoots)
1068
- ) {
1069
- continue;
1070
- }
1071
-
1072
- const source = cachedReadFile(filePath);
1073
- const runtimeDirs = styleRuntimeDirectories(filePath, env, projectDir);
1074
-
1075
- for (const ref of findCssUrlReferences(source)) {
1076
- if (isNonFilesystemCssUrl(ref.value)) continue;
1077
-
1078
- const assetPath = cssUrlPath(ref.value);
1079
- if (!assetPath) continue;
1080
-
1081
- const sourceAsset = firstExistingPath([
1082
- resolve(dirname(filePath), assetPath),
1083
- ]);
1084
- const runtimeAsset = firstExistingPath(
1085
- runtimeDirs.map((directory) => resolve(directory, assetPath)),
1086
- );
1087
- const resolvedAsset = sourceAsset || runtimeAsset;
1088
-
1089
- if (!resolvedAsset) {
1090
- findings.push(
1091
- makeFinding({
1092
- id: 'unresolved-css-asset-reference',
1093
- severity: 'warn',
1094
- filePath,
1095
- line: ref.line,
1096
- message: `CSS asset URL "${ref.raw}" could not be resolved from the source file or expected emitted CSS location.`,
1097
- details: [
1098
- 'Check for a typo, move the asset into a source-root-relative location Vite can resolve, or rewrite the URL to a stable Drupal/theme public path.',
1099
- ],
1100
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
1101
- }),
1102
- );
1103
- continue;
1104
- }
1105
-
1106
- if (
1107
- projectAssetRoots.some((root) => isSameOrInside(resolvedAsset, root)) &&
1108
- (!sourceAsset || runtimeAsset || assetPath.startsWith('..'))
1109
- ) {
1110
- findings.push(
1111
- makeFinding({
1112
- id: 'css-runtime-asset-reference',
1113
- severity: 'info',
1114
- filePath,
1115
- line: ref.line,
1116
- message: `CSS asset URL "${ref.raw}" resolves to project-level assets and may be left unchanged by Vite for runtime resolution.`,
1117
- details: [
1118
- `Resolved asset: ${displayPath(projectDir, resolvedAsset)}.`,
1119
- 'This is acceptable when Drupal serves the asset at that runtime URL. To make Vite bundle or rebase it, move the asset under a source root and reference it from the authored stylesheet.',
1120
- ],
1121
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
1122
- }),
1123
- );
1124
- }
1125
- }
1126
- }
1127
-
1128
- return findings;
1129
- }
1130
-
1131
- /**
1132
- * Audit Webpack-era files and code patterns.
61
+ * Parse command-line arguments.
1133
62
  *
1134
- * @param {object} context - Audit context.
1135
- * @returns {object[]} Findings.
63
+ * @param {string[]} argv - CLI arguments.
64
+ * @returns {object} Parsed options.
1136
65
  */
1137
- function auditWebpackPatterns(context) {
1138
- const { codeFiles, projectDir } = context;
1139
- const findings = [];
1140
- const webpackConfig = resolve(projectDir, '.storybook/webpack.config.js');
1141
- const webpackDir = resolve(projectDir, 'config/webpack');
1142
-
1143
- if (safeExists(webpackConfig)) {
1144
- findings.push(
1145
- makeFinding({
1146
- id: 'webpack-config-file',
1147
- severity: 'warn',
1148
- filePath: webpackConfig,
1149
- message:
1150
- 'Webpack-specific Storybook config is present and should be migrated to Vite/Storybook overrides.',
1151
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#vite-customization',
1152
- }),
1153
- );
1154
- }
1155
-
1156
- if (safeIsDirectory(webpackDir)) {
1157
- findings.push(
1158
- makeFinding({
1159
- id: 'webpack-config-directory',
1160
- severity: 'warn',
1161
- filePath: webpackDir,
1162
- message:
1163
- 'config/webpack exists. Webpack-specific customization should move to Vite plugins or extendConfig().',
1164
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/extension-points.md#vite-plugins-and-config-patches',
1165
- }),
1166
- );
1167
- }
1168
-
1169
- const patterns = [
1170
- {
1171
- regex: /\brequire\.context\s*\(/,
1172
- message: 'require.context() is Webpack-specific and should be migrated.',
66
+ function parseArgs(argv) {
67
+ return parseCliArgs(argv, {
68
+ defaults: {
69
+ projectDir: process.cwd(),
70
+ failOn: null,
71
+ json: false,
72
+ help: false,
73
+ twigThreshold: DEFAULT_TWIG_THRESHOLD,
1173
74
  },
1174
- {
1175
- regex:
1176
- /\b(raw-loader|twig-loader|style-loader|file-loader|sass-loader)\b/,
1177
- message: 'Webpack loader references should be migrated to Vite plugins.',
75
+ flags: {
76
+ '--fail-on-found': {
77
+ key: 'failOn',
78
+ value: 'any',
79
+ },
80
+ '--json': 'json',
1178
81
  },
1179
- {
1180
- regex: /from\s+['"][^'"]+![^'"]+['"]|import\s+['"][^'"]+![^'"]+['"]/,
1181
- message: 'Inline Webpack loader import syntax should be removed.',
82
+ options: {
83
+ '--fail-on': {
84
+ key: 'failOn',
85
+ validate: (value) => failOnValues.includes(value),
86
+ missingMessage: '--fail-on requires one of: error, warn, info, any.',
87
+ invalidMessage: '--fail-on must be one of: error, warn, info, any.',
88
+ },
89
+ '--root': {
90
+ key: 'projectDir',
91
+ missingMessage: '--root requires a project directory.',
92
+ },
93
+ '--twig-threshold': {
94
+ key: 'twigThreshold',
95
+ parse: Number,
96
+ validate: Number.isFinite,
97
+ rejectEmptyValue: false,
98
+ rejectOptionLikeValue: false,
99
+ missingMessage: '--twig-threshold requires a number.',
100
+ },
1182
101
  },
1183
- ];
1184
-
1185
- for (const filePath of codeFiles) {
1186
- const source = cachedReadFile(filePath);
1187
-
1188
- for (const pattern of patterns) {
1189
- const match = pattern.regex.exec(source);
1190
- if (!match) continue;
1191
-
1192
- findings.push(
1193
- makeFinding({
1194
- id: 'webpack-era-pattern',
1195
- severity: 'warn',
1196
- filePath,
1197
- line: lineNumberAt(source, match.index || 0),
1198
- message: pattern.message,
1199
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#vite-customization',
1200
- }),
1201
- );
1202
- }
1203
- }
1204
-
1205
- return findings;
102
+ });
1206
103
  }
1207
104
 
1208
105
  /**
1209
- * Extract import specifiers from JavaScript source.
106
+ * Decide whether completed findings meet a configured failure threshold.
1210
107
  *
1211
- * @param {string} source - JavaScript source.
1212
- * @returns {{specifier: string, index: number}[]} Import specifiers.
108
+ * @param {object[]} [findings=[]] - Audit findings.
109
+ * @param {'error'|'warn'|'info'|'any'|null} [failOn=null] - Threshold.
110
+ * @returns {boolean} TRUE when the completed scan should exit with code 1.
1213
111
  */
1214
- function findImportSpecifiers(source) {
1215
- const imports = [];
1216
- const patterns = [
1217
- /(?:import|export)\s+(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g,
1218
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
1219
- /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
1220
- ];
1221
-
1222
- for (const pattern of patterns) {
1223
- for (const match of source.matchAll(pattern)) {
1224
- imports.push({
1225
- specifier: match[1],
1226
- index: match.index || 0,
1227
- });
1228
- }
112
+ export function shouldFailAudit(findings = [], failOn = null) {
113
+ if (!failOn) {
114
+ return false;
1229
115
  }
1230
-
1231
- return imports;
1232
- }
1233
-
1234
- /**
1235
- * Audit direct imports of Emulsify Core internals.
1236
- *
1237
- * @param {object} context - Audit context.
1238
- * @returns {object[]} Findings.
1239
- */
1240
- function auditCoreImports(context) {
1241
- const { codeFiles } = context;
1242
- const findings = [];
1243
-
1244
- for (const filePath of codeFiles) {
1245
- const source = cachedReadFile(filePath);
1246
-
1247
- for (const item of findImportSpecifiers(source)) {
1248
- const { specifier } = item;
1249
- if (!specifier.startsWith('@emulsify/core/')) continue;
1250
- if (PUBLIC_CORE_IMPORTS.has(specifier)) continue;
1251
-
1252
- findings.push(
1253
- makeFinding({
1254
- id: 'internal-core-import',
1255
- severity: 'warn',
1256
- filePath,
1257
- line: lineNumberAt(source, item.index),
1258
- message: `Import "${specifier}" uses an internal Emulsify Core path. Prefer a public package export.`,
1259
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/README.md#public-imports',
1260
- }),
1261
- );
1262
- }
116
+ if (failOn === 'any') {
117
+ return findings.length > 0;
1263
118
  }
1264
119
 
1265
- return findings;
1266
- }
1267
-
1268
- /**
1269
- * Audit Drupal assumptions in non-Drupal projects.
1270
- *
1271
- * @param {object} context - Audit context.
1272
- * @returns {object[]} Findings.
1273
- */
1274
- function auditDrupalAssumptions(context) {
1275
- const { codeFiles, env } = context;
1276
- if (env.platform === 'drupal') return [];
1277
-
1278
- const findings = [];
1279
- const patterns = [
1280
- /\bDrupal\.attachBehaviors\b/,
1281
- /\bwindow\.Drupal\b/,
1282
- /\bglobalThis\.Drupal\b/,
1283
- /['"][^'"]*_drupal\.js['"]/,
1284
- /['"]twig-drupal-filters['"]/,
1285
- ];
1286
-
1287
- for (const filePath of codeFiles) {
1288
- const source = cachedReadFile(filePath);
1289
- const match = patterns.map((pattern) => pattern.exec(source)).find(Boolean);
120
+ const includedSeverities = {
121
+ error: ['error'],
122
+ warn: ['error', 'warn'],
123
+ info: ['error', 'warn', 'info'],
124
+ }[failOn];
1290
125
 
1291
- if (!match) continue;
1292
-
1293
- findings.push(
1294
- makeFinding({
1295
- id: 'drupal-assumption-non-drupal',
1296
- severity: 'warn',
1297
- filePath,
1298
- line: lineNumberAt(source, match.index || 0),
1299
- message:
1300
- 'Drupal-specific Storybook/runtime code was found, but the active platform is not drupal.',
1301
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/platform-adapters.md',
1302
- }),
1303
- );
1304
- }
1305
-
1306
- return findings;
126
+ return findings.some((finding) => {
127
+ const severity = findingSeverities.includes(finding.severity)
128
+ ? finding.severity
129
+ : 'warn';
130
+ return includedSeverities?.includes(severity);
131
+ });
1307
132
  }
1308
133
 
1309
134
  /**
1310
- * Audit files that look like component Twig files outside source roots.
135
+ * Print an argument failure in the requested output mode.
1311
136
  *
1312
- * @param {object} context - Audit context.
1313
- * @returns {object[]} Findings.
137
+ * @param {*} error - Argument failure.
138
+ * @param {boolean} json - Whether JSON output was requested.
139
+ * @returns {number} Exit code.
1314
140
  */
1315
- function auditFilesOutsideRoots(context) {
1316
- const { env, projectDir, twigFiles } = context;
1317
- const roots = [
1318
- ...(env.projectStructure?.twigRoots || []),
1319
- ...(env.projectStructure?.sourceRoots || []),
1320
- ];
1321
-
1322
- if (!roots.length) return [];
1323
-
1324
- return twigFiles
1325
- .filter((filePath) => !isInsideAnyRoot(filePath, roots))
1326
- .map((filePath) =>
1327
- makeFinding({
1328
- id: 'twig-file-outside-source-roots',
1329
- severity: 'info',
1330
- filePath,
1331
- message:
1332
- 'Twig file is outside normalized source roots and will not be available to Storybook include()/source() unless another integration loads it.',
1333
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
141
+ function reportArgumentFailure(error, json) {
142
+ if (json) {
143
+ console.log(
144
+ formatAuditJsonErrorReport(error, {
145
+ code: 'invalid-arguments',
1334
146
  }),
1335
- )
1336
- .filter((finding) => !isNonComponentTwigFile(projectDir, finding.filePath));
1337
- }
1338
-
1339
- /**
1340
- * Determine whether a Twig file is intentionally outside component roots.
1341
- *
1342
- * @param {string} projectDir - Absolute project root.
1343
- * @param {string} filePath - Absolute Twig file path.
1344
- * @returns {boolean} TRUE when the file should not be treated as component source.
1345
- */
1346
- function isNonComponentTwigFile(projectDir, filePath) {
1347
- const relPath = displayPath(projectDir, filePath);
1348
-
1349
- return (
1350
- relPath.startsWith('docs/') ||
1351
- relPath.startsWith('templates/') ||
1352
- relPath.includes('/templates/')
1353
- );
1354
- }
1355
-
1356
- /**
1357
- * Recursively measure a directory size.
1358
- *
1359
- * @param {string} directory - Directory path.
1360
- * @returns {number} Size in bytes.
1361
- */
1362
- function directorySize(directory) {
1363
- let total = 0;
1364
-
1365
- try {
1366
- for (const entry of readdirSync(directory)) {
1367
- const entryPath = resolve(directory, entry);
1368
- const stats = statSync(entryPath);
1369
- total += stats.isDirectory() ? directorySize(entryPath) : stats.size;
1370
- }
1371
- } catch {
1372
- return total;
1373
- }
1374
-
1375
- return total;
1376
- }
1377
-
1378
- /**
1379
- * Audit Twig volume under Storybook roots.
1380
- *
1381
- * @param {object} context - Audit context.
1382
- * @returns {object[]} Findings.
1383
- */
1384
- function auditTwigVolume(context) {
1385
- const { env, twigThreshold } = context;
1386
- const roots = Array.from(new Set(env.projectStructure?.twigRoots || []));
1387
- const twigFiles = new Set();
1388
-
1389
- for (const root of roots) {
1390
- if (!safeIsDirectory(root)) continue;
1391
- for (const filePath of globSync(TWIG_GLOB, {
1392
- cwd: root,
1393
- absolute: true,
1394
- nodir: true,
1395
- ignore: DEFAULT_IGNORES,
1396
- })) {
1397
- twigFiles.add(resolve(filePath));
1398
- }
1399
- }
1400
-
1401
- if (twigFiles.size <= twigThreshold) return [];
1402
-
1403
- const totalBytes = roots.reduce(
1404
- (total, root) => total + directorySize(root),
1405
- 0,
1406
- );
1407
-
1408
- return [
1409
- makeFinding({
1410
- id: 'large-twig-storybook-roots',
1411
- severity: 'info',
1412
- message: `${twigFiles.size} Twig files are under Storybook Twig roots. Eager Twig imports are reliable but can increase Storybook startup/build cost for large libraries.`,
1413
- details: [
1414
- `Approximate Twig root size: ${Math.round(totalBytes / 1024)} KB.`,
1415
- ],
1416
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/performance.md#storybook-twig-imports',
1417
- }),
1418
- ];
1419
- }
1420
-
1421
- /**
1422
- * Run the combined Emulsify audit.
1423
- *
1424
- * @param {{projectDir?: string, twigThreshold?: number}} [options={}] - Options.
1425
- * @returns {{projectDir: string, summary: object, findings: object[]}} Audit result.
1426
- */
1427
- export function auditProject(options = {}) {
1428
- resetFileReadCache();
1429
-
1430
- const projectDir = resolve(options.projectDir || process.cwd());
1431
- const envResult = resolveAuditEnvironment(projectDir);
1432
- const structure = envResult.env.projectStructure || {};
1433
- const sourceRoots = normalizeAuditRoots(
1434
- projectDir,
1435
- structure.sourceRoots || [],
1436
- );
1437
- const storyRoots = normalizeAuditRoots(
1438
- projectDir,
1439
- structure.storyRoots || sourceRoots,
1440
- );
1441
- const twigRoots = normalizeAuditRoots(
1442
- projectDir,
1443
- structure.twigRoots || sourceRoots,
1444
- );
1445
- const storyFiles = collectRootedProjectFiles(
1446
- projectDir,
1447
- STORY_GLOB,
1448
- storyRoots,
1449
- );
1450
- const codeFiles = collectRootedProjectFiles(
1451
- projectDir,
1452
- CODE_GLOB,
1453
- sourceRoots,
1454
- );
1455
- const twigFiles = collectRootedProjectFiles(projectDir, TWIG_GLOB, twigRoots);
1456
- const styleFiles = collectRootedProjectFiles(
1457
- projectDir,
1458
- STYLE_GLOB,
1459
- sourceRoots,
1460
- );
1461
- const context = {
1462
- ...envResult,
1463
- projectDir,
1464
- sourceRoots,
1465
- storyRoots,
1466
- twigRoots,
1467
- storyFiles,
1468
- codeFiles,
1469
- twigFiles,
1470
- styleFiles,
1471
- twigThreshold: Number.isFinite(options.twigThreshold)
1472
- ? options.twigThreshold
1473
- : DEFAULT_TWIG_THRESHOLD,
1474
- };
1475
- const findings = [
1476
- ...auditProjectConfig(context),
1477
- ...auditPackageOverrides(context),
1478
- ...auditGeneratedPackageScripts(context),
1479
- ...auditStoryDiscovery(context),
1480
- ...auditLegacyTwigStories(context),
1481
- ...auditTwigReferences(context),
1482
- ...auditCssAssetReferences(context),
1483
- ...auditWebpackPatterns(context),
1484
- ...auditCoreImports(context),
1485
- ...auditDrupalAssumptions(context),
1486
- ...auditFilesOutsideRoots(context),
1487
- ...auditTwigVolume(context),
1488
- ];
1489
- const summary = findings.reduce(
1490
- (totals, finding) => ({
1491
- ...totals,
1492
- [finding.severity]: (totals[finding.severity] || 0) + 1,
1493
- }),
1494
- {
1495
- error: 0,
1496
- warn: 0,
1497
- info: 0,
1498
- },
1499
- );
1500
-
1501
- return {
1502
- projectDir,
1503
- summary,
1504
- files: {
1505
- stories: storyFiles.length,
1506
- twig: twigFiles.length,
1507
- code: codeFiles.length,
1508
- styles: styleFiles.length,
1509
- },
1510
- findings,
1511
- };
1512
- }
1513
-
1514
- /**
1515
- * Format one finding for terminal output.
1516
- *
1517
- * @param {object} finding - Finding to format.
1518
- * @param {string} projectDir - Project root.
1519
- * @returns {string[]} Output lines.
1520
- */
1521
- function formatFinding(finding, projectDir) {
1522
- const location = finding.filePath
1523
- ? `${displayPath(projectDir, finding.filePath)}${
1524
- finding.line ? `:${finding.line}` : ''
1525
- }`
1526
- : 'project';
1527
- const lines = [
1528
- `[${finding.severity}] ${finding.id}`,
1529
- ` ${location}`,
1530
- ` ${finding.message}`,
1531
- ];
1532
-
1533
- for (const detail of finding.details || []) {
1534
- lines.push(` ${detail}`);
1535
- }
1536
- if (finding.docs) {
1537
- lines.push(` Docs: ${finding.docs}`);
1538
- }
1539
-
1540
- return lines;
1541
- }
1542
-
1543
- /**
1544
- * Format the combined audit report.
1545
- *
1546
- * @param {{projectDir: string, summary: object, files: object, findings: object[]}} result
1547
- * Audit result.
1548
- * @returns {string} Human-readable report.
1549
- */
1550
- export function formatAuditReport(result) {
1551
- const lines = [
1552
- 'Emulsify project audit',
1553
- `Project: ${result.projectDir}`,
1554
- `Scanned ${result.files.stories} story file(s), ${result.files.twig} Twig file(s), ${result.files.code} code file(s), and ${result.files.styles} style file(s).`,
1555
- `Findings: ${result.summary.error} error(s), ${result.summary.warn} warning(s), ${result.summary.info} info item(s).`,
1556
- ];
1557
-
1558
- if (!result.findings.length) {
1559
- lines.push('No audit findings found.');
1560
- return lines.join('\n');
1561
- }
1562
-
1563
- for (const finding of result.findings) {
1564
- lines.push('', ...formatFinding(finding, result.projectDir));
1565
- }
1566
-
1567
- return lines.join('\n');
1568
- }
1569
-
1570
- /**
1571
- * CLI usage text.
1572
- *
1573
- * @returns {string} Usage text.
1574
- */
1575
- function usage() {
1576
- return [
1577
- 'Usage: emulsify-audit [--root <dir>] [--json] [--fail-on-found] [--twig-threshold <count>]',
1578
- '',
1579
- 'Options:',
1580
- ' --root <dir> Project root to scan. Defaults to the current directory.',
1581
- ' --json Print machine-readable JSON.',
1582
- ' --fail-on-found Exit with code 1 when any finding is reported.',
1583
- ` --twig-threshold <count> Warn when Storybook roots contain more than this many Twig files. Default: ${DEFAULT_TWIG_THRESHOLD}.`,
1584
- ' --help Print this help text.',
1585
- ].join('\n');
1586
- }
1587
-
1588
- /**
1589
- * Parse command-line arguments.
1590
- *
1591
- * @param {string[]} argv - CLI arguments.
1592
- * @returns {object} Parsed options.
1593
- */
1594
- function parseArgs(argv) {
1595
- const options = {
1596
- projectDir: process.cwd(),
1597
- failOnFound: false,
1598
- json: false,
1599
- help: false,
1600
- twigThreshold: DEFAULT_TWIG_THRESHOLD,
1601
- };
1602
-
1603
- for (let index = 0; index < argv.length; index += 1) {
1604
- const arg = argv[index];
1605
-
1606
- if (arg === '--help' || arg === '-h') {
1607
- options.help = true;
1608
- continue;
1609
- }
1610
- if (arg === '--fail-on-found') {
1611
- options.failOnFound = true;
1612
- continue;
1613
- }
1614
- if (arg === '--json') {
1615
- options.json = true;
1616
- continue;
1617
- }
1618
- if (arg === '--root') {
1619
- const value = argv[index + 1];
1620
- if (!value || value.startsWith('--')) {
1621
- throw new Error('--root requires a project directory.');
1622
- }
1623
- options.projectDir = value;
1624
- index += 1;
1625
- continue;
1626
- }
1627
- if (arg.startsWith('--root=')) {
1628
- options.projectDir = arg.slice('--root='.length);
1629
- continue;
1630
- }
1631
- if (arg === '--twig-threshold') {
1632
- const value = Number(argv[index + 1]);
1633
- if (!Number.isFinite(value)) {
1634
- throw new Error('--twig-threshold requires a number.');
1635
- }
1636
- options.twigThreshold = value;
1637
- index += 1;
1638
- continue;
1639
- }
1640
- if (arg.startsWith('--twig-threshold=')) {
1641
- const value = Number(arg.slice('--twig-threshold='.length));
1642
- if (!Number.isFinite(value)) {
1643
- throw new Error('--twig-threshold requires a number.');
1644
- }
1645
- options.twigThreshold = value;
1646
- continue;
1647
- }
1648
-
1649
- throw new Error(`Unknown option: ${arg}`);
147
+ );
148
+ } else {
149
+ console.error(`${error.message || error}\n\n${usage()}`);
1650
150
  }
1651
151
 
1652
- return options;
152
+ return cliFailureExitCode;
1653
153
  }
1654
154
 
1655
155
  /**
@@ -1659,31 +159,49 @@ function parseArgs(argv) {
1659
159
  * @returns {number} Exit code.
1660
160
  */
1661
161
  export function runCli(argv = process.argv.slice(2)) {
1662
- const options = parseArgs(argv);
162
+ const jsonRequested = argv.includes('--json');
163
+ let options;
164
+
165
+ try {
166
+ options = parseArgs(argv);
167
+ if (options.help && options.json) {
168
+ throw new Error('--json cannot be combined with --help.');
169
+ }
170
+ } catch (error) {
171
+ return reportArgumentFailure(error, jsonRequested);
172
+ }
1663
173
 
1664
174
  if (options.help) {
1665
175
  console.log(usage());
1666
176
  return 0;
1667
177
  }
1668
178
 
1669
- const result = auditProject(options);
1670
-
1671
- if (options.json) {
1672
- console.log(JSON.stringify(result, null, 2));
1673
- } else {
1674
- console.log(formatAuditReport(result));
1675
- }
179
+ try {
180
+ const result = runAudits(options);
1676
181
 
1677
- return options.failOnFound && result.findings.length ? 1 : 0;
1678
- }
182
+ if (options.json) {
183
+ console.log(formatAuditJsonReport(result));
184
+ } else {
185
+ console.log(formatAuditReport(result));
186
+ }
1679
187
 
1680
- if (process.argv[1]?.split(/[\\/]/).pop() === 'audit.js') {
1681
- try {
1682
- process.exitCode = runCli();
188
+ return shouldFailAudit(result.findings, options.failOn) ? 1 : 0;
1683
189
  } catch (error) {
1684
- console.error(error.message || error);
1685
- console.error('');
1686
- console.error(usage());
1687
- process.exitCode = 1;
190
+ if (options.json) {
191
+ console.log(
192
+ formatAuditJsonErrorReport(error, {
193
+ code: 'audit-failed',
194
+ projectDir: resolve(options.projectDir),
195
+ }),
196
+ );
197
+ } else {
198
+ console.error(`Audit failed: ${error.message || error}`);
199
+ }
200
+
201
+ return cliFailureExitCode;
1688
202
  }
1689
203
  }
204
+
205
+ if (isCliEntrypoint(['audit.js', 'emulsify-audit'])) {
206
+ process.exitCode = runCli();
207
+ }