@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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +14 -6
- package/config/a11y-wcag22.js +11 -0
- package/config/vite/entries.js +7 -2
- package/config/vite/environment.js +4 -0
- package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
- package/config/vite/plugins/assets/copy-src-assets.js +82 -12
- package/config/vite/plugins/assets/copy-twig-files.js +85 -16
- package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
- package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
- package/config/vite/plugins/assets/development-source-maps.js +273 -0
- package/config/vite/plugins/assets/mirror-components.js +98 -82
- package/config/vite/plugins/assets/output-freshness.js +235 -0
- package/config/vite/plugins/assets/source-file-index.js +13 -13
- package/config/vite/plugins/assets/stable-watch-output.js +165 -0
- package/config/vite/plugins/assets/storybook-output.js +27 -0
- package/config/vite/plugins/index.js +95 -9
- package/config/vite/plugins/reporter/asset-resolver.js +34 -6
- package/config/vite/plugins/reporter/build-errors.js +7 -3
- package/config/vite/plugins/reporter/diagnostics.js +140 -10
- package/config/vite/plugins/reporter/index.js +380 -75
- package/config/vite/plugins/reporter/render.js +297 -44
- package/config/vite/plugins/reporter/sass-logger.js +30 -0
- package/config/vite/plugins/reporter/source-roots.js +101 -21
- package/config/vite/plugins/reporter/strict-mode.js +99 -0
- package/config/vite/plugins/reporter/vite-logger.js +220 -8
- package/config/vite/plugins/reporter/watch-mode.js +6 -2
- package/config/vite/plugins/twig/twig-module.js +35 -258
- package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
- package/config/vite/project-config.js +121 -21
- package/config/vite/project-structure.js +6 -0
- package/config/vite/utils/asset-roots.js +205 -0
- package/config/vite/utils/css-urls.js +350 -0
- package/config/vite/utils/fs-safe.js +38 -1
- package/config/vite/utils/source-directory-skips.js +13 -0
- package/config/vite/utils/source-maps.js +88 -0
- package/config/vite/utils/twig-component-resolver.js +316 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +54 -40
- package/scripts/a11y.js +88 -9
- package/scripts/audit/checks/css-asset-references.js +256 -24
- package/scripts/audit/checks/twig-references.js +16 -5
- package/scripts/audit/fix.js +836 -0
- package/scripts/audit/index.js +10 -2
- package/scripts/audit/lib/css.js +41 -35
- package/scripts/audit/lib/story-ast.js +392 -0
- package/scripts/audit/lib/story-render-paths.js +600 -0
- package/scripts/audit/lib/story-selection.js +190 -0
- package/scripts/audit/lib/twig.js +372 -80
- package/scripts/audit/report.js +83 -5
- package/scripts/audit-twig-stories.js +73 -3
- package/scripts/audit.js +87 -2
- 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,
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
72
|
+
return characters.join('');
|
|
39
73
|
}
|
|
40
74
|
|
|
41
75
|
/**
|
|
42
|
-
*
|
|
76
|
+
* Read comma-separated call arguments or array elements and their offsets.
|
|
43
77
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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}
|
|
49
|
-
* @
|
|
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
|
|
86
|
+
function readTwigList(source, start, closing) {
|
|
87
|
+
const values = [];
|
|
88
|
+
const closings = { '(': ')', '[': ']', '{': '}' };
|
|
89
|
+
const stack = [];
|
|
52
90
|
let quote = '';
|
|
53
|
-
let
|
|
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 ===
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
512
|
+
return Boolean(
|
|
513
|
+
resolveComponentReference(
|
|
514
|
+
reference,
|
|
515
|
+
env.projectStructure?.namespaceRoots || env.namespaceRoots || {},
|
|
516
|
+
componentGroupRootsCache,
|
|
517
|
+
),
|
|
518
|
+
);
|
|
227
519
|
}
|
package/scripts/audit/report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {}) {
|