@emulsify/core 4.3.2 → 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.
Files changed (54) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +14 -6
  4. package/config/a11y-wcag22.js +11 -0
  5. package/config/vite/entries.js +7 -2
  6. package/config/vite/environment.js +4 -0
  7. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  8. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  9. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  10. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  11. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  12. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  13. package/config/vite/plugins/assets/mirror-components.js +98 -82
  14. package/config/vite/plugins/assets/output-freshness.js +235 -0
  15. package/config/vite/plugins/assets/source-file-index.js +13 -13
  16. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  17. package/config/vite/plugins/assets/storybook-output.js +27 -0
  18. package/config/vite/plugins/index.js +95 -9
  19. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  20. package/config/vite/plugins/reporter/build-errors.js +7 -3
  21. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  22. package/config/vite/plugins/reporter/index.js +380 -75
  23. package/config/vite/plugins/reporter/render.js +297 -44
  24. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  25. package/config/vite/plugins/reporter/source-roots.js +101 -21
  26. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  27. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  28. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  29. package/config/vite/plugins/twig/twig-module.js +35 -258
  30. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  31. package/config/vite/project-config.js +121 -21
  32. package/config/vite/project-structure.js +6 -0
  33. package/config/vite/utils/asset-roots.js +205 -0
  34. package/config/vite/utils/css-urls.js +350 -0
  35. package/config/vite/utils/fs-safe.js +38 -1
  36. package/config/vite/utils/source-directory-skips.js +13 -0
  37. package/config/vite/utils/source-maps.js +88 -0
  38. package/config/vite/utils/twig-component-resolver.js +316 -0
  39. package/config/vite/vite.config.js +106 -42
  40. package/package.json +54 -40
  41. package/scripts/a11y.js +88 -9
  42. package/scripts/audit/checks/css-asset-references.js +256 -24
  43. package/scripts/audit/checks/twig-references.js +16 -5
  44. package/scripts/audit/fix.js +836 -0
  45. package/scripts/audit/index.js +10 -2
  46. package/scripts/audit/lib/css.js +41 -35
  47. package/scripts/audit/lib/story-ast.js +392 -0
  48. package/scripts/audit/lib/story-render-paths.js +600 -0
  49. package/scripts/audit/lib/story-selection.js +190 -0
  50. package/scripts/audit/lib/twig.js +372 -80
  51. package/scripts/audit/report.js +83 -5
  52. package/scripts/audit-twig-stories.js +73 -3
  53. package/scripts/audit.js +87 -2
  54. package/src/storybook/twig/source-function.js +14 -10
@@ -2,8 +2,13 @@
2
2
  * @file Twig reference parsing and resolution helpers for the project audit.
3
3
  */
4
4
 
5
- import { dirname, isAbsolute, resolve } from 'node:path';
5
+ import { dirname, resolve } from 'node:path';
6
+ import {
7
+ resolveAssetRoots,
8
+ toAbsoluteAssetRoot,
9
+ } from '../../../config/vite/utils/asset-roots.js';
6
10
  import { safeExists } from '../../../config/vite/utils/fs-safe.js';
11
+ import { resolveComponentReference } from '../../../config/vite/utils/twig-component-resolver.js';
7
12
  import { candidateKeysForReference } from '../../../src/storybook/twig/reference-paths.js';
8
13
  import { lineNumberAt } from '../../lib/text.js';
9
14
  import { isSameOrInside } from './files.js';
@@ -11,76 +16,370 @@ import { isSameOrInside } from './files.js';
11
16
  const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
12
17
 
13
18
  /**
14
- * Extract string arguments passed to include() or source().
19
+ * Mask Twig comments without changing offsets or line breaks.
20
+ *
21
+ * Quotes inside Twig expressions protect literal comment markers. HTML quotes
22
+ * do not, because Twig comments can also appear inside HTML attributes.
15
23
  *
16
24
  * @param {string} source - Twig source.
17
- * @returns {{type: string, value: string, line: number}[]} References.
25
+ * @param {boolean} [codeOnly=false] - Also mask quoted strings and non-Twig text.
26
+ * @returns {string} Source with comment text replaced by whitespace.
18
27
  */
19
- export function findTwigIncludeSourceReferences(source) {
20
- const references = [];
21
- const callPattern = /\b(include|source)\s*\(([\s\S]*?)\)/g;
22
-
23
- for (const callMatch of source.matchAll(callPattern)) {
24
- const type = callMatch[1];
25
- const args = firstArgumentText(callMatch[2]);
26
- const argsOffset = (callMatch.index || 0) + callMatch[0].indexOf(args);
27
- const stringPattern = /['"]([^'"]+)['"]/g;
28
-
29
- for (const stringMatch of args.matchAll(stringPattern)) {
30
- references.push({
31
- type,
32
- value: stringMatch[1],
33
- line: lineNumberAt(source, argsOffset + (stringMatch.index || 0)),
34
- });
28
+ function maskTwigSource(source, codeOnly = false) {
29
+ const characters = source.split('');
30
+ let closingTag = '';
31
+ let quote = '';
32
+
33
+ for (let index = 0; index < source.length; index += 1) {
34
+ const char = source[index];
35
+ const pair = source.slice(index, index + 2);
36
+ if (codeOnly && (quote || !closingTag) && char !== '\n' && char !== '\r') {
37
+ characters[index] = ' ';
38
+ }
39
+ if (quote) {
40
+ if (char === '\\') {
41
+ index += 1;
42
+ if (
43
+ codeOnly &&
44
+ index < source.length &&
45
+ source[index] !== '\n' &&
46
+ source[index] !== '\r'
47
+ ) {
48
+ characters[index] = ' ';
49
+ }
50
+ } else if (char === quote) quote = '';
51
+ } else if (pair === '{#') {
52
+ const close = source.indexOf('#}', index + 2);
53
+ const end = close === -1 ? source.length : close + 2;
54
+ for (; index < end; index += 1) {
55
+ if (source[index] !== '\n' && source[index] !== '\r') {
56
+ characters[index] = ' ';
57
+ }
58
+ }
59
+ index -= 1;
60
+ } else if (!closingTag && (pair === '{{' || pair === '{%')) {
61
+ closingTag = pair === '{{' ? '}}' : '%}';
62
+ index += 1;
63
+ } else if (closingTag && pair === closingTag) {
64
+ closingTag = '';
65
+ index += 1;
66
+ } else if (closingTag && (char === '"' || char.charCodeAt(0) === 39)) {
67
+ quote = char;
68
+ if (codeOnly) characters[index] = ' ';
35
69
  }
36
70
  }
37
71
 
38
- return references;
72
+ return characters.join('');
39
73
  }
40
74
 
41
75
  /**
42
- * Extract the first function argument, including array syntax.
76
+ * Read comma-separated call arguments or array elements and their offsets.
43
77
  *
44
- * Twig include()/source() only use the first argument as the template/source
45
- * reference. Later object values may also be strings, but they are context
46
- * values and should not be treated as template references.
78
+ * Nested delimiters and quoted punctuation do not terminate a value. An
79
+ * incomplete or mismatched list is ignored rather than audited in fragments.
47
80
  *
48
- * @param {string} args - Function argument source.
49
- * @returns {string} First argument source.
81
+ * @param {string} source - Comment-masked Twig source.
82
+ * @param {number} start - Offset immediately after the opening delimiter.
83
+ * @param {string} closing - Closing delimiter for this list.
84
+ * @returns {{values: {text: string, offset: number}[], end: number}|null} List.
50
85
  */
51
- function firstArgumentText(args) {
86
+ function readTwigList(source, start, closing) {
87
+ const values = [];
88
+ const closings = { '(': ')', '[': ']', '{': '}' };
89
+ const stack = [];
52
90
  let quote = '';
53
- let depth = 0;
54
-
55
- for (let index = 0; index < args.length; index += 1) {
56
- const char = args[index];
57
- const prev = args[index - 1];
91
+ let offset = start;
58
92
 
93
+ for (let index = start; index < source.length; index += 1) {
94
+ const char = source[index];
59
95
  if (quote) {
60
- if (char === quote && prev !== '\\') {
61
- quote = '';
62
- }
96
+ if (char === '\\') index += 1;
97
+ else if (char === quote) quote = '';
63
98
  continue;
64
99
  }
65
-
66
100
  if (char === '"' || char.charCodeAt(0) === 39) {
67
101
  quote = char;
68
- continue;
102
+ } else if (closings[char]) {
103
+ stack.push(closings[char]);
104
+ } else if (!stack.length && (char === ',' || char === closing)) {
105
+ values.push({ text: source.slice(offset, index), offset });
106
+ if (char === closing) return { values, end: index + 1 };
107
+ offset = index + 1;
108
+ } else if (')]}'.includes(char) && stack.pop() !== char) {
109
+ return null;
69
110
  }
70
- if (char === '[' || char === '{' || char === '(') {
71
- depth += 1;
72
- continue;
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Return a complete static string literal, excluding Twig interpolation.
118
+ *
119
+ * @param {string} expression - One complete argument or array element.
120
+ * @returns {string|null} Literal value, or null for a dynamic expression.
121
+ */
122
+ function staticTwigString(expression) {
123
+ const literal = expression
124
+ .trim()
125
+ .match(/^(['"])((?:\\[\s\S]|(?!\1)[^\\])*)\1$/);
126
+ if (!literal) return null;
127
+
128
+ const [, quote, value] = literal;
129
+ if (quote === '"' && value.replace(/\\[\s\S]/g, '').includes('#{')) {
130
+ return null;
131
+ }
132
+
133
+ // Match Twig.js's string token decoding without compiling the template.
134
+ return value
135
+ .replace(`\\${quote}`, quote)
136
+ .replace(/\\n/g, '\n')
137
+ .replace(/\\r/g, '\r');
138
+ }
139
+
140
+ /**
141
+ * Read complete literal candidates and retain uncertainty within a fallback list.
142
+ *
143
+ * @param {object} argument - Argument text and source offset.
144
+ * @param {string} source - Comment-masked Twig source.
145
+ * @param {boolean} [allowArray=true] - Whether the function accepts fallbacks.
146
+ * @returns {object} Static candidates, array status, and dynamic-candidate flag.
147
+ */
148
+ function readTwigCandidates(argument, source, allowArray = true) {
149
+ const result = {
150
+ candidates: [],
151
+ isFallbackArray: false,
152
+ hasDynamicCandidates: false,
153
+ };
154
+ if (!argument) return { ...result, hasDynamicCandidates: true };
155
+ let values = [argument];
156
+ if (argument.text.trimStart().startsWith('[')) {
157
+ const arrayStart = argument.offset + argument.text.indexOf('[');
158
+ const array = readTwigList(source, arrayStart + 1, ']');
159
+ const argumentEnd = argument.offset + argument.text.length;
160
+ if (!allowArray || !array || source.slice(array.end, argumentEnd).trim()) {
161
+ return { ...result, hasDynamicCandidates: true };
73
162
  }
74
- if (char === ']' || char === '}' || char === ')') {
75
- depth = Math.max(0, depth - 1);
163
+ result.isFallbackArray = true;
164
+ values = array.values;
165
+ }
166
+
167
+ for (const [index, { text, offset }] of values.entries()) {
168
+ // An empty array and a trailing comma do not introduce a dynamic candidate.
169
+ if (result.isFallbackArray && index === values.length - 1 && !text.trim()) {
76
170
  continue;
77
171
  }
78
- if (char === ',' && depth === 0) {
79
- return args.slice(0, index);
172
+ const value = staticTwigString(text);
173
+ if (value === null) {
174
+ result.hasDynamicCandidates = true;
175
+ } else {
176
+ result.candidates.push({
177
+ value,
178
+ line: lineNumberAt(
179
+ source,
180
+ offset + text.length - text.trimStart().length,
181
+ ),
182
+ });
80
183
  }
81
184
  }
185
+ return result;
186
+ }
82
187
 
83
- return args;
188
+ /**
189
+ * Recognize literal optionality using Core's JavaScript truthiness coercion.
190
+ *
191
+ * @param {object|undefined|null} argument - Argument, absent value, or unknown.
192
+ * @returns {boolean|null} Static boolean, or null when unknown.
193
+ */
194
+ function staticTwigBoolean(argument) {
195
+ if (argument === undefined) return false;
196
+ const text = argument?.text.trim();
197
+ if (text === undefined) return null;
198
+ if (/^(?:true|TRUE)$/.test(text)) return true;
199
+ if (/^(?:false|FALSE|null|NULL|none|NONE)$/.test(text)) return false;
200
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) return Boolean(Number(text));
201
+ const literal = staticTwigString(text);
202
+ if (literal !== null) return Boolean(literal);
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Read an option from a complete object literal in Twig.js property order.
208
+ *
209
+ * @param {object|undefined|null} argument - Object argument or unknown value.
210
+ * @param {string} name - Option key.
211
+ * @param {string} source - Comment-masked Twig source.
212
+ * @param {boolean} [isVariablesArgument=false] - Ignore opaque variables bags.
213
+ * @returns {object|undefined|null} Option expression, absent option, or unknown.
214
+ */
215
+ function readTwigObjectOption(
216
+ argument,
217
+ name,
218
+ source,
219
+ isVariablesArgument = false,
220
+ ) {
221
+ if (argument === undefined) return undefined;
222
+ if (argument === null) return null;
223
+ const text = argument.text.trim();
224
+ if (!text.startsWith('{')) {
225
+ // An opaque variables bag does not declare an ignore-missing option.
226
+ // Explicit flag expressions and with-context options remain uncertain.
227
+ if (isVariablesArgument) return undefined;
228
+ // Core normalizes primitive/array variables to an empty variables object.
229
+ if (staticTwigBoolean(argument) !== null) {
230
+ return undefined;
231
+ }
232
+ if (text.startsWith('[')) {
233
+ const start = argument.offset + argument.text.indexOf('[');
234
+ const array = readTwigList(source, start + 1, ']');
235
+ if (
236
+ array &&
237
+ !source.slice(array.end, argument.offset + argument.text.length).trim()
238
+ ) {
239
+ return undefined;
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ const start = argument.offset + argument.text.indexOf('{');
246
+ const object = readTwigList(source, start + 1, '}');
247
+ if (
248
+ !object ||
249
+ source.slice(object.end, argument.offset + argument.text.length).trim()
250
+ )
251
+ return isVariablesArgument ? undefined : null;
252
+ // Twig.js keeps the first value for duplicate object keys. A preceding
253
+ // computed key may already define this option, so it remains unknown.
254
+ for (const property of object.values) {
255
+ if (!property.text.trim()) continue;
256
+ const key = readTwigList(source, property.offset, ':');
257
+ if (!key || key.values.length !== 1) return null;
258
+ const keyText = key.values[0].text.trim();
259
+ const propertyName = /^(?:[A-Za-z_]\w*|-?\d+(?:\.\d+)?)$/.test(keyText)
260
+ ? keyText
261
+ : staticTwigString(keyText);
262
+ if (propertyName === null) return null;
263
+ if (propertyName === name) {
264
+ return {
265
+ text: source.slice(key.end, property.offset + property.text.length),
266
+ offset: key.end,
267
+ };
268
+ }
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * Read supported Core optional-missing arguments, including include options.
275
+ *
276
+ * @param {string} type - include or source.
277
+ * @param {object[]} args - Complete arguments with source offsets.
278
+ * @param {string} source - Comment-masked Twig source.
279
+ * @returns {boolean|null} Optional, required, or unknown.
280
+ */
281
+ function readIgnoreMissing(type, args, source) {
282
+ // Twig.js does not bind native named parameters: colon pairs become positional
283
+ // tokens and equals syntax does not compile. Do not infer flags from those
284
+ // accidental positions; keep unsupported calls explicitly unknown.
285
+ if (args.some(({ text }) => /^\s*[A-Za-z_]\w*\s*[:=]/.test(text)))
286
+ return null;
287
+ if (type === 'source') return staticTwigBoolean(args[1]);
288
+
289
+ let ignoreMissing = staticTwigBoolean(args[3]);
290
+ const variableFlag = readTwigObjectOption(
291
+ args[1],
292
+ 'ignore_missing',
293
+ source,
294
+ true,
295
+ );
296
+ if (variableFlag !== undefined)
297
+ ignoreMissing = staticTwigBoolean(variableFlag);
298
+
299
+ // This precedence mirrors Core's normalizeIncludeOptions: variables can
300
+ // replace withContext before a third-argument options object is inspected.
301
+ const variableContext = readTwigObjectOption(
302
+ args[1],
303
+ 'with_context',
304
+ source,
305
+ true,
306
+ );
307
+ const withContext = variableContext === undefined ? args[2] : variableContext;
308
+ const contextFlag = readTwigObjectOption(
309
+ withContext,
310
+ 'ignore_missing',
311
+ source,
312
+ );
313
+ if (contextFlag !== undefined) ignoreMissing = staticTwigBoolean(contextFlag);
314
+ return ignoreMissing;
315
+ }
316
+
317
+ /**
318
+ * Scan actual Twig calls while keeping their argument boundaries and locations.
319
+ *
320
+ * @param {string} source - Twig source.
321
+ * @returns {object} Masked source and complete calls.
322
+ */
323
+ function scanTwigReferenceCalls(source) {
324
+ const calls = [];
325
+ const maskedSource = maskTwigSource(source);
326
+ const callSource = maskTwigSource(source, true);
327
+ const callPattern = /\b(include|source)\s*\(/g;
328
+
329
+ let callMatch;
330
+ while ((callMatch = callPattern.exec(callSource))) {
331
+ if (callSource.slice(0, callMatch.index).trimEnd().endsWith('.')) continue;
332
+ const argsStart = callMatch.index + callMatch[0].length;
333
+ const call = readTwigList(maskedSource, argsStart, ')');
334
+ if (!call) continue;
335
+ const args = [...call.values];
336
+ if (!args.at(-1)?.text.trim()) args.pop();
337
+ calls.push({
338
+ type: callMatch[1],
339
+ args,
340
+ line: lineNumberAt(source, callMatch.index),
341
+ });
342
+ }
343
+ return { calls, maskedSource };
344
+ }
345
+
346
+ /**
347
+ * Preserve the flat static-reference interface exported by the audit entrypoint.
348
+ *
349
+ * This compatibility view intentionally retains individual array literals and
350
+ * optional references. The audit check uses the richer call view below.
351
+ *
352
+ * @param {string} source - Twig source.
353
+ * @returns {{type: string, value: string, line: number}[]} Static references.
354
+ */
355
+ export function findTwigIncludeSourceReferences(source) {
356
+ const { calls, maskedSource } = scanTwigReferenceCalls(source);
357
+ return calls.flatMap(({ type, args }) =>
358
+ readTwigCandidates(args[0], maskedSource).candidates.map((candidate) => ({
359
+ type,
360
+ ...candidate,
361
+ })),
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Extract call-level reference semantics without compiling or rendering Twig.
367
+ *
368
+ * Candidate lines locate literals; the call line locates a grouped finding.
369
+ * Source only accepts a scalar name, unlike include's ordered fallback list.
370
+ * Dynamic candidates or optionality remain explicit internal unknown states.
371
+ *
372
+ * @param {string} source - Twig source.
373
+ * @returns {object[]} Calls, candidates, optionality, and source locations.
374
+ */
375
+ export function findTwigReferenceCalls(source) {
376
+ const { calls, maskedSource } = scanTwigReferenceCalls(source);
377
+ return calls.map(({ type, args, line }) => ({
378
+ type,
379
+ line,
380
+ ...readTwigCandidates(args[0], maskedSource, type === 'include'),
381
+ ignoreMissing: readIgnoreMissing(type, args, maskedSource),
382
+ }));
84
383
  }
85
384
 
86
385
  /**
@@ -93,7 +392,7 @@ export function findTwigNamespaceReferences(source) {
93
392
  const references = [];
94
393
  const pattern = /@([A-Za-z][\w-]*)\/[A-Za-z0-9_./-]+/g;
95
394
 
96
- for (const match of source.matchAll(pattern)) {
395
+ for (const match of maskTwigSource(source).matchAll(pattern)) {
97
396
  references.push({
98
397
  namespace: match[1],
99
398
  value: match[0],
@@ -143,45 +442,23 @@ function candidateKeysToFiles(keys, env) {
143
442
  * @returns {string} Absolute filesystem path, or an empty string.
144
443
  */
145
444
  export function resolveAuditAssetRoot(projectDir, assetRoot) {
146
- if (typeof assetRoot !== 'string' || !assetRoot.trim()) return '';
147
-
148
- const normalizedProjectDir = resolve(projectDir || process.cwd());
149
- const normalizedRoot = assetRoot.trim();
150
-
151
- if (isAbsolute(normalizedRoot)) {
152
- const absoluteRoot = resolve(normalizedRoot);
153
-
154
- return safeExists(absoluteRoot)
155
- ? absoluteRoot
156
- : resolve(normalizedProjectDir, `.${normalizedRoot}`);
157
- }
158
-
159
- return resolve(normalizedProjectDir, normalizedRoot);
445
+ return toAbsoluteAssetRoot(projectDir, assetRoot);
160
446
  }
161
447
 
162
448
  /**
163
449
  * Return filesystem roots that Storybook can use for @assets source() calls.
164
450
  *
451
+ * Existence filtering stays off here because callers do their own directory
452
+ * check, and a configured-but-missing root is worth reporting rather than
453
+ * silently dropping.
454
+ *
165
455
  * @param {object} env - Normalized environment.
166
456
  * @param {object} [options={}] - Asset root options.
167
457
  * @param {boolean} [options.includeGenerated=false] - Include generated roots.
168
458
  * @returns {string[]} Absolute asset roots.
169
459
  */
170
460
  export function auditAssetRoots(env = {}, { includeGenerated = false } = {}) {
171
- const projectDir = env.projectDir || process.cwd();
172
- const configuredRoots = Array.isArray(env?.projectStructure?.assetRoots)
173
- ? env.projectStructure.assetRoots
174
- : [];
175
- const fallbackRoots = ['assets', 'src/assets'];
176
- const generatedRoots = includeGenerated ? ['dist/assets'] : [];
177
-
178
- return Array.from(
179
- new Set(
180
- [...fallbackRoots, ...configuredRoots, ...generatedRoots]
181
- .map((root) => resolveAuditAssetRoot(projectDir, root))
182
- .filter(Boolean),
183
- ),
184
- );
461
+ return resolveAssetRoots(env, { includeGenerated, existingOnly: false });
185
462
  }
186
463
 
187
464
  /**
@@ -209,19 +486,34 @@ function resolvesAssetReference(reference, env) {
209
486
  * @param {string} reference - Twig reference.
210
487
  * @param {string} filePath - Referencing file path.
211
488
  * @param {object} env - Normalized environment.
489
+ * @param {Map<string, string[]>} [componentGroupRootsCache] - Directory cache shared across one audit pass.
212
490
  * @returns {boolean} TRUE when a candidate exists.
213
491
  */
214
- export function resolvesTwigReference(reference, filePath, env) {
492
+ export function resolvesTwigReference(
493
+ reference,
494
+ filePath,
495
+ env,
496
+ componentGroupRootsCache = new Map(),
497
+ ) {
215
498
  if (!reference || /^https?:\/\//i.test(reference)) return true;
216
499
 
217
500
  if (reference.startsWith('@assets/')) {
218
501
  return resolvesAssetReference(reference, env);
219
502
  }
220
503
 
221
- const candidates =
222
- reference.startsWith('./') || reference.startsWith('../')
223
- ? relativeTwigCandidates(filePath, reference)
224
- : candidateKeysToFiles(candidateKeysForReference(reference, env), env);
504
+ const isRelative = reference.startsWith('./') || reference.startsWith('../');
505
+ const candidates = isRelative
506
+ ? relativeTwigCandidates(filePath, reference)
507
+ : candidateKeysToFiles(candidateKeysForReference(reference, env), env);
508
+
509
+ if (candidates.some(safeExists)) return true;
510
+ if (isRelative || !(env.singleDirectoryComponents || env.SDC)) return false;
225
511
 
226
- return candidates.some(safeExists);
512
+ return Boolean(
513
+ resolveComponentReference(
514
+ reference,
515
+ env.projectStructure?.namespaceRoots || env.namespaceRoots || {},
516
+ componentGroupRootsCache,
517
+ ),
518
+ );
227
519
  }
@@ -58,16 +58,51 @@ export function formatAuditReport(result) {
58
58
 
59
59
  if (!result.findings.length) {
60
60
  lines.push('No audit findings found.');
61
- return lines.join('\n');
62
61
  }
63
62
 
64
63
  for (const finding of result.findings) {
65
64
  lines.push('', ...formatFinding(finding, result.projectDir));
66
65
  }
67
66
 
67
+ lines.push(...formatFixSection(result.fixes, result.projectDir));
68
+
68
69
  return lines.join('\n');
69
70
  }
70
71
 
72
+ /**
73
+ * Format the autofix section appended by `--fix`.
74
+ *
75
+ * @param {{dryRun: boolean, applied: object[], skipped: object[]}} [fixes] - Fix result.
76
+ * @param {string} projectDir - Absolute scanned root.
77
+ * @returns {string[]} Report lines.
78
+ */
79
+ function formatFixSection(fixes, projectDir) {
80
+ if (!fixes) return [];
81
+
82
+ const lines = ['', 'Fixes'];
83
+ const verb = fixes.dryRun ? 'Would apply' : 'Applied';
84
+
85
+ if (!fixes.applied.length) {
86
+ lines.push(`${verb} 0 fix(es).`);
87
+ } else {
88
+ lines.push(`${verb} ${fixes.applied.length} fix(es):`);
89
+ for (const { finding, from, to } of fixes.applied) {
90
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
91
+ lines.push(` ${where} ${from} -> ${to}`);
92
+ }
93
+ }
94
+
95
+ if (fixes.skipped.length) {
96
+ lines.push(`Skipped ${fixes.skipped.length} fixable finding(s):`);
97
+ for (const { finding, reason } of fixes.skipped) {
98
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
99
+ lines.push(` ${where} ${reason}`);
100
+ }
101
+ }
102
+
103
+ return lines;
104
+ }
105
+
71
106
  /**
72
107
  * Count findings by severity for machine-readable reports.
73
108
  *
@@ -219,7 +254,7 @@ export function createAuditJsonReport(result, options = {}) {
219
254
  normalizeAuditFinding(finding, result.projectDir, options.defaultSeverity),
220
255
  );
221
256
 
222
- return {
257
+ const document = {
223
258
  schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
224
259
  tool: createToolIdentity(),
225
260
  root: '.',
@@ -227,19 +262,56 @@ export function createAuditJsonReport(result, options = {}) {
227
262
  files: normalizeFileCounts(result.files),
228
263
  findings,
229
264
  };
265
+
266
+ // Present only when --fix ran, so the document shape is unchanged for every
267
+ // existing consumer.
268
+ if (result.fixes) {
269
+ document.fixes = normalizeFixes(result.fixes, result.projectDir);
270
+ }
271
+
272
+ return document;
273
+ }
274
+
275
+ /**
276
+ * Normalize the autofix result for the machine-readable report.
277
+ *
278
+ * @param {{dryRun: boolean, applied: object[], skipped: object[]}} fixes - Fix result.
279
+ * @param {string} projectDir - Absolute scanned root.
280
+ * @returns {object} JSON fix block.
281
+ */
282
+ function normalizeFixes(fixes, projectDir) {
283
+ const locate = (finding) => ({
284
+ path: displayPath(projectDir, finding.filePath) || '.',
285
+ ...(Number.isInteger(finding.line) && finding.line > 0
286
+ ? { line: finding.line }
287
+ : {}),
288
+ });
289
+
290
+ return {
291
+ dryRun: Boolean(fixes.dryRun),
292
+ applied: fixes.applied.map(({ finding, from, to }) => ({
293
+ ...locate(finding),
294
+ from,
295
+ to,
296
+ })),
297
+ skipped: fixes.skipped.map(({ finding, reason }) => ({
298
+ ...locate(finding),
299
+ reason: normalizeReportText(reason, projectDir),
300
+ })),
301
+ };
230
302
  }
231
303
 
232
304
  /**
233
305
  * Create a structured machine-readable CLI or audit failure.
234
306
  *
235
307
  * @param {*} error - Failure value.
236
- * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
308
+ * @param {{code?: string, projectDir?: string, fixes?: object}} [options={}] - Error options.
237
309
  * @returns {object} JSON error document.
238
310
  */
239
311
  export function createAuditJsonErrorReport(error, options = {}) {
240
312
  const message = error?.message || error;
241
313
 
242
- return {
314
+ const document = {
243
315
  schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
244
316
  tool: createToolIdentity(),
245
317
  error: {
@@ -247,6 +319,12 @@ export function createAuditJsonErrorReport(error, options = {}) {
247
319
  message: normalizeReportText(message, options.projectDir || ''),
248
320
  },
249
321
  };
322
+
323
+ if (options.fixes) {
324
+ document.fixes = normalizeFixes(options.fixes, options.projectDir || '');
325
+ }
326
+
327
+ return document;
250
328
  }
251
329
 
252
330
  /**
@@ -265,7 +343,7 @@ export function formatAuditJsonReport(result, options = {}) {
265
343
  * Format a CLI or audit failure as machine-readable JSON.
266
344
  *
267
345
  * @param {*} error - Failure value.
268
- * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
346
+ * @param {{code?: string, projectDir?: string, fixes?: object}} [options={}] - Error options.
269
347
  * @returns {string} JSON error document.
270
348
  */
271
349
  export function formatAuditJsonErrorReport(error, options = {}) {