@emulsify/core 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @file Select statically readable CSF story exports without executing modules.
3
+ */
4
+
5
+ import {
6
+ identifierName,
7
+ moduleBody,
8
+ resolveModuleValue,
9
+ readStaticProperty,
10
+ unwrapExpression,
11
+ } from './story-ast.js';
12
+
13
+ const RESERVED_EXPORTS = new Set([
14
+ 'default',
15
+ '__esModule',
16
+ '__namedExportsOrder',
17
+ ]);
18
+
19
+ /**
20
+ * Recognize literal falsy values used by CSF filter and render defaults.
21
+ *
22
+ * @param {object} node - Babel expression.
23
+ * @param {Map<string, object>} declarations - Module declarations.
24
+ * @returns {boolean} TRUE only for a statically known falsy value.
25
+ */
26
+ export function isStaticFalsy(node, declarations) {
27
+ const value = resolveModuleValue(node, declarations);
28
+ if (value?.type === 'NullLiteral') return true;
29
+ if (
30
+ ['BooleanLiteral', 'NumericLiteral', 'StringLiteral'].includes(value?.type)
31
+ ) {
32
+ return !value.value;
33
+ }
34
+ return (
35
+ value?.type === 'Identifier' &&
36
+ value.name === 'undefined' &&
37
+ !declarations.has('undefined')
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Decode supported CSF filters; dynamic expressions retain an unknown state.
43
+ *
44
+ * @param {object} property - Static property lookup.
45
+ * @param {Map<string, object>} declarations - Module declarations.
46
+ * @returns {object} Absent, known array/regex, or unknown filter.
47
+ */
48
+ function readFilter(property, declarations) {
49
+ if (property.state !== 'known') return property;
50
+ const value = resolveModuleValue(property.node, declarations);
51
+ if (isStaticFalsy(value, declarations)) return { state: 'absent' };
52
+
53
+ if (value?.type === 'ArrayExpression') {
54
+ const elements = value.elements.map(unwrapExpression);
55
+ if (elements.every((element) => element?.type === 'StringLiteral')) {
56
+ return {
57
+ state: 'known',
58
+ strings: elements.map((element) => element.value),
59
+ };
60
+ }
61
+ }
62
+ if (value?.type === 'RegExpLiteral') {
63
+ try {
64
+ return {
65
+ state: 'known',
66
+ regex: new RegExp(value.pattern, value.flags),
67
+ };
68
+ } catch {
69
+ // A newer RegExp syntax may parse but be unsupported by this Node version.
70
+ }
71
+ }
72
+ return { state: 'unknown' };
73
+ }
74
+
75
+ /**
76
+ * Match the Storybook CSF array/regex contract, with no shared RegExp cursor.
77
+ *
78
+ * @param {string} name - Exported name, rather than a local binding or story name.
79
+ * @param {object} filter - Decoded static filter.
80
+ * @returns {boolean|null} Match, mismatch, or unknown.
81
+ */
82
+ function matchesFilter(name, filter) {
83
+ if (filter.state === 'unknown') return null;
84
+ if (filter.strings) return filter.strings.includes(name);
85
+ // Storybook's isExportStory uses String.match(). A fresh expression also keeps
86
+ // sticky filters from changing the result when another export was checked.
87
+ return Boolean(
88
+ name.match(new RegExp(filter.regex.source, filter.regex.flags)),
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Collect named exports and default metadata before selecting any render paths.
94
+ *
95
+ * @param {object} ast - Babel File or Program.
96
+ * @returns {object} Metadata, candidates, and unresolved re-export state.
97
+ */
98
+ function collectExports(ast) {
99
+ let metadata;
100
+ let hasUnknownExports = false;
101
+ const candidates = [];
102
+ for (const statement of moduleBody(ast)) {
103
+ if (statement.exportKind === 'type') continue;
104
+ if (statement.type === 'ExportDefaultDeclaration') {
105
+ metadata = statement.declaration;
106
+ continue;
107
+ }
108
+ if (statement.type === 'ExportAllDeclaration') {
109
+ hasUnknownExports = true;
110
+ continue;
111
+ }
112
+ if (statement.type !== 'ExportNamedDeclaration') continue;
113
+ const declaration = statement.declaration;
114
+ if (declaration?.declare) continue;
115
+ if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
116
+ candidates.push({
117
+ name: declaration.id.name,
118
+ node: declaration,
119
+ lineNode: declaration,
120
+ });
121
+ } else if (declaration?.type === 'VariableDeclaration') {
122
+ for (const declarator of declaration.declarations) {
123
+ if (declarator.id?.type !== 'Identifier') {
124
+ hasUnknownExports = true;
125
+ continue;
126
+ }
127
+ candidates.push({
128
+ name: declarator.id.name,
129
+ node: declarator.init,
130
+ lineNode: declarator,
131
+ });
132
+ }
133
+ }
134
+ for (const specifier of statement.specifiers) {
135
+ if (specifier.exportKind === 'type') continue;
136
+ const name = identifierName(specifier.exported);
137
+ if (name === 'default') {
138
+ metadata = statement.source ? statement : specifier.local;
139
+ } else if (name) {
140
+ candidates.push({
141
+ name,
142
+ node: statement.source ? null : specifier.local,
143
+ lineNode: specifier,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return { metadata, candidates, hasUnknownExports };
149
+ }
150
+
151
+ /**
152
+ * Apply includeStories and excludeStories before resolving effective renders.
153
+ *
154
+ * Unknown filters keep possible stories in the analysis, but a known exclusion
155
+ * (or a known inclusion mismatch) can still rule a candidate out. No module is
156
+ * imported and no call, getter, or arbitrary expression is evaluated.
157
+ *
158
+ * @param {object} ast - Babel File or Program.
159
+ * @param {Map<string, object>} declarations - Module declarations.
160
+ * @returns {object} Selected candidates, default render, and selection certainty.
161
+ */
162
+ export function selectStoryExports(ast, declarations) {
163
+ const { metadata, candidates, hasUnknownExports } = collectExports(ast);
164
+ const include = readFilter(
165
+ readStaticProperty(metadata, 'includeStories', declarations),
166
+ declarations,
167
+ );
168
+ const exclude = readFilter(
169
+ readStaticProperty(metadata, 'excludeStories', declarations),
170
+ declarations,
171
+ );
172
+ const stories = [];
173
+ let hasUnknownSelection = hasUnknownExports;
174
+ for (const candidate of candidates) {
175
+ if (RESERVED_EXPORTS.has(candidate.name)) continue;
176
+ const included =
177
+ include.state === 'absent' || matchesFilter(candidate.name, include);
178
+ const excluded =
179
+ exclude.state !== 'absent' && matchesFilter(candidate.name, exclude);
180
+ // Storybook requires inclusion and no exclusion; an exclusion always wins.
181
+ if (included === false || excluded === true) continue;
182
+ if (included === null || excluded === null) hasUnknownSelection = true;
183
+ stories.push(candidate);
184
+ }
185
+ return {
186
+ stories,
187
+ defaultRender: readStaticProperty(metadata, 'render', declarations),
188
+ hasUnknownSelection,
189
+ };
190
+ }
@@ -8,6 +8,7 @@ import {
8
8
  toAbsoluteAssetRoot,
9
9
  } from '../../../config/vite/utils/asset-roots.js';
10
10
  import { safeExists } from '../../../config/vite/utils/fs-safe.js';
11
+ import { resolveComponentReference } from '../../../config/vite/utils/twig-component-resolver.js';
11
12
  import { candidateKeysForReference } from '../../../src/storybook/twig/reference-paths.js';
12
13
  import { lineNumberAt } from '../../lib/text.js';
13
14
  import { isSameOrInside } from './files.js';
@@ -15,76 +16,370 @@ import { isSameOrInside } from './files.js';
15
16
  const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
16
17
 
17
18
  /**
18
- * 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.
19
23
  *
20
24
  * @param {string} source - Twig source.
21
- * @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.
22
27
  */
23
- export function findTwigIncludeSourceReferences(source) {
24
- const references = [];
25
- const callPattern = /\b(include|source)\s*\(([\s\S]*?)\)/g;
26
-
27
- for (const callMatch of source.matchAll(callPattern)) {
28
- const type = callMatch[1];
29
- const args = firstArgumentText(callMatch[2]);
30
- const argsOffset = (callMatch.index || 0) + callMatch[0].indexOf(args);
31
- const stringPattern = /['"]([^'"]+)['"]/g;
32
-
33
- for (const stringMatch of args.matchAll(stringPattern)) {
34
- references.push({
35
- type,
36
- value: stringMatch[1],
37
- line: lineNumberAt(source, argsOffset + (stringMatch.index || 0)),
38
- });
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] = ' ';
39
69
  }
40
70
  }
41
71
 
42
- return references;
72
+ return characters.join('');
43
73
  }
44
74
 
45
75
  /**
46
- * Extract the first function argument, including array syntax.
76
+ * Read comma-separated call arguments or array elements and their offsets.
47
77
  *
48
- * Twig include()/source() only use the first argument as the template/source
49
- * reference. Later object values may also be strings, but they are context
50
- * 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.
51
80
  *
52
- * @param {string} args - Function argument source.
53
- * @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.
54
85
  */
55
- function firstArgumentText(args) {
86
+ function readTwigList(source, start, closing) {
87
+ const values = [];
88
+ const closings = { '(': ')', '[': ']', '{': '}' };
89
+ const stack = [];
56
90
  let quote = '';
57
- let depth = 0;
58
-
59
- for (let index = 0; index < args.length; index += 1) {
60
- const char = args[index];
61
- const prev = args[index - 1];
91
+ let offset = start;
62
92
 
93
+ for (let index = start; index < source.length; index += 1) {
94
+ const char = source[index];
63
95
  if (quote) {
64
- if (char === quote && prev !== '\\') {
65
- quote = '';
66
- }
96
+ if (char === '\\') index += 1;
97
+ else if (char === quote) quote = '';
67
98
  continue;
68
99
  }
69
-
70
100
  if (char === '"' || char.charCodeAt(0) === 39) {
71
101
  quote = char;
72
- 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;
73
110
  }
74
- if (char === '[' || char === '{' || char === '(') {
75
- depth += 1;
76
- 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 };
77
162
  }
78
- if (char === ']' || char === '}' || char === ')') {
79
- 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()) {
80
170
  continue;
81
171
  }
82
- if (char === ',' && depth === 0) {
83
- 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
+ });
84
183
  }
85
184
  }
185
+ return result;
186
+ }
86
187
 
87
- 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
+ }));
88
383
  }
89
384
 
90
385
  /**
@@ -97,7 +392,7 @@ export function findTwigNamespaceReferences(source) {
97
392
  const references = [];
98
393
  const pattern = /@([A-Za-z][\w-]*)\/[A-Za-z0-9_./-]+/g;
99
394
 
100
- for (const match of source.matchAll(pattern)) {
395
+ for (const match of maskTwigSource(source).matchAll(pattern)) {
101
396
  references.push({
102
397
  namespace: match[1],
103
398
  value: match[0],
@@ -191,19 +486,34 @@ function resolvesAssetReference(reference, env) {
191
486
  * @param {string} reference - Twig reference.
192
487
  * @param {string} filePath - Referencing file path.
193
488
  * @param {object} env - Normalized environment.
489
+ * @param {Map<string, string[]>} [componentGroupRootsCache] - Directory cache shared across one audit pass.
194
490
  * @returns {boolean} TRUE when a candidate exists.
195
491
  */
196
- export function resolvesTwigReference(reference, filePath, env) {
492
+ export function resolvesTwigReference(
493
+ reference,
494
+ filePath,
495
+ env,
496
+ componentGroupRootsCache = new Map(),
497
+ ) {
197
498
  if (!reference || /^https?:\/\//i.test(reference)) return true;
198
499
 
199
500
  if (reference.startsWith('@assets/')) {
200
501
  return resolvesAssetReference(reference, env);
201
502
  }
202
503
 
203
- const candidates =
204
- reference.startsWith('./') || reference.startsWith('../')
205
- ? relativeTwigCandidates(filePath, reference)
206
- : 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);
207
508
 
208
- return candidates.some(safeExists);
509
+ if (candidates.some(safeExists)) return true;
510
+ if (isRelative || !(env.singleDirectoryComponents || env.SDC)) return false;
511
+
512
+ return Boolean(
513
+ resolveComponentReference(
514
+ reference,
515
+ env.projectStructure?.namespaceRoots || env.namespaceRoots || {},
516
+ componentGroupRootsCache,
517
+ ),
518
+ );
209
519
  }