@emulsify/core 4.3.1 → 4.4.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 (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +96 -25
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -0,0 +1,836 @@
1
+ /**
2
+ * @file Autofix application for audit findings.
3
+ *
4
+ * A finding becomes fixable by carrying a `fix` payload: the source range of the
5
+ * URL specifier in the authored source, the exact text expected there, and the
6
+ * replacement. Checks decide what is safe to rewrite; this module only applies
7
+ * what they hand over.
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import {
13
+ clearImmediate as cancelImmediate,
14
+ setImmediate as scheduleImmediate,
15
+ } from 'node:timers';
16
+ import {
17
+ basename,
18
+ dirname,
19
+ isAbsolute,
20
+ join,
21
+ relative,
22
+ resolve,
23
+ sep,
24
+ } from 'node:path';
25
+ import { escape, globSync } from 'glob';
26
+
27
+ import { resolveProjectConfig } from '../../config/vite/project-config.js';
28
+ import {
29
+ DEFAULT_IGNORES,
30
+ normalizeAuditRoots,
31
+ resetFileReadCache,
32
+ } from './lib/files.js';
33
+
34
+ const maximumTemporaryNameBytes = 255;
35
+ const temporaryFileGlob = '**/.*.*.*.tmp';
36
+ const temporaryFilePattern =
37
+ /^\..+\.(\d+)\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/i;
38
+ const ignoredCleanupErrors = new Set(['ENOENT', 'ENAMETOOLONG']);
39
+ const activeTemporaryFiles = new Map();
40
+ let temporaryCleanupHandlersInstalled = false;
41
+ let temporaryCleanupTurn;
42
+
43
+ /**
44
+ * Determine whether a canonical path is inside the canonical scanned root.
45
+ *
46
+ * @param {string} filePath - Canonical candidate path.
47
+ * @param {string} root - Canonical scanned root.
48
+ * @returns {boolean} TRUE when the candidate is inside or equal to the root.
49
+ */
50
+ function isContained(filePath, root) {
51
+ const rel = relative(root, filePath);
52
+ return (
53
+ !rel || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`))
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Determine whether a path is excluded by the audit's scan rules.
59
+ *
60
+ * Ignore patterns are evaluated relative to the project, just as consumers
61
+ * understand directories such as node_modules/ and dist/. An absolute path is
62
+ * never matched: a project may itself live beneath a pnpm node_modules tree.
63
+ *
64
+ * @param {string} filePath - Candidate path.
65
+ * @param {string} root - Root against which ignore patterns are evaluated.
66
+ * @returns {boolean} TRUE when the candidate is ignored.
67
+ */
68
+ function isIgnored(filePath, root) {
69
+ if (!isContained(filePath, root)) return false;
70
+
71
+ const rel = relative(root, filePath);
72
+ if (!rel) return false;
73
+
74
+ // Probe the literal existing path through the same glob implementation and
75
+ // options used to collect audit files. This preserves its platform-aware
76
+ // case behavior and keeps the scan and write deny-lists identical.
77
+ const literalPath = escape(rel.split(sep).join('/'), {
78
+ magicalBraces: true,
79
+ });
80
+ return (
81
+ globSync(literalPath, {
82
+ cwd: root,
83
+ nodir: true,
84
+ ignore: DEFAULT_IGNORES,
85
+ }).length === 0
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Resolve the lexical and canonical boundaries that authorize source writes.
91
+ *
92
+ * @param {string} projectDir - Project root supplied to the audit.
93
+ * @param {string[]|undefined} sourceRoots - Normalized source roots.
94
+ * @returns {object} Canonical fix scope.
95
+ */
96
+ function createFixScope(projectDir, sourceRoots) {
97
+ const projectPath = resolve(projectDir);
98
+ const realProject = fs.realpathSync(projectPath);
99
+ let requestedRoots = Array.isArray(sourceRoots) ? sourceRoots : [];
100
+
101
+ if (sourceRoots === undefined) {
102
+ try {
103
+ const env = resolveProjectConfig(projectPath, process.env);
104
+ requestedRoots = normalizeAuditRoots(
105
+ projectPath,
106
+ env.projectStructure?.sourceRoots || [],
107
+ );
108
+ } catch {
109
+ // Direct API callers may omit sourceRoots for compatibility. Derive the
110
+ // same project scope as the audit when possible, and fail closed when
111
+ // invalid configuration prevents that derivation.
112
+ requestedRoots = [];
113
+ }
114
+ }
115
+ const lexicalRoots = [];
116
+ const realRoots = [];
117
+
118
+ for (const root of requestedRoots) {
119
+ if (!root) continue;
120
+
121
+ const lexicalRoot = resolve(projectPath, root);
122
+ if (!isContained(lexicalRoot, projectPath)) continue;
123
+
124
+ let realRoot;
125
+ try {
126
+ realRoot = fs.realpathSync(lexicalRoot);
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (!isContained(realRoot, realProject)) continue;
131
+
132
+ if (!lexicalRoots.includes(lexicalRoot)) lexicalRoots.push(lexicalRoot);
133
+ if (!realRoots.includes(realRoot)) realRoots.push(realRoot);
134
+ }
135
+
136
+ return {
137
+ projectPath,
138
+ realProject,
139
+ lexicalRoots,
140
+ realRoots,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Resolve one candidate against a prepared source-write scope.
146
+ *
147
+ * @param {string} filePath - Authored source path carried by a finding.
148
+ * @param {object} scope - Canonical fix scope.
149
+ * @returns {{writable: boolean, realTarget?: string, reason?: string}} Status.
150
+ */
151
+ function auditFixTargetStatus(filePath, scope) {
152
+ const sourcePath = resolve(filePath);
153
+
154
+ if (!scope.lexicalRoots.some((root) => isContained(sourcePath, root))) {
155
+ return {
156
+ writable: false,
157
+ reason: `source path is outside scanned roots: ${sourcePath}`,
158
+ };
159
+ }
160
+
161
+ const realTarget = fs.realpathSync(sourcePath);
162
+ if (!scope.realRoots.some((root) => isContained(realTarget, root))) {
163
+ return {
164
+ writable: false,
165
+ realTarget,
166
+ reason: `real target is outside scanned root: ${realTarget}`,
167
+ };
168
+ }
169
+
170
+ if (
171
+ isIgnored(sourcePath, scope.projectPath) ||
172
+ isIgnored(realTarget, scope.realProject)
173
+ ) {
174
+ return {
175
+ writable: false,
176
+ realTarget,
177
+ reason: `real target is excluded by audit ignore rules: ${realTarget}`,
178
+ };
179
+ }
180
+
181
+ const targetDirectory = dirname(realTarget);
182
+ try {
183
+ fs.accessSync(targetDirectory, fs.constants.W_OK | fs.constants.X_OK);
184
+ } catch {
185
+ return {
186
+ writable: false,
187
+ realTarget,
188
+ reason: `real target directory is not writable: ${targetDirectory}`,
189
+ };
190
+ }
191
+
192
+ return { writable: true, realTarget };
193
+ }
194
+
195
+ /**
196
+ * Determine whether the target metadata captured before a rewrite is stable.
197
+ *
198
+ * @param {import('node:fs').Stats} current - Current target metadata.
199
+ * @param {import('node:fs').Stats} expected - Previously captured metadata.
200
+ * @returns {boolean} TRUE when no inode or portable metadata changed.
201
+ */
202
+ function sameTargetMetadata(current, expected) {
203
+ return [
204
+ 'dev',
205
+ 'ino',
206
+ 'mode',
207
+ 'uid',
208
+ 'gid',
209
+ 'size',
210
+ 'mtimeMs',
211
+ 'ctimeMs',
212
+ ].every((key) => current[key] === expected[key]);
213
+ }
214
+
215
+ /**
216
+ * Create a reusable predicate for automatic source rewrites.
217
+ *
218
+ * This is shared with checks that advertise --fix, so their advice cannot
219
+ * promise a write that applyAuditFixes() will refuse. Scope resolution is lazy
220
+ * and cached; per-file realpath, ignore, and access checks remain current.
221
+ *
222
+ * @param {{projectDir?: string, sourceRoots?: string[]}} [options={}] - Scope.
223
+ * @returns {(filePath: string) => boolean} Source eligibility predicate.
224
+ */
225
+ export function createAuditFixTargetChecker({
226
+ projectDir = process.cwd(),
227
+ sourceRoots,
228
+ } = {}) {
229
+ let scope;
230
+ let scopeFailed = false;
231
+
232
+ return (filePath) => {
233
+ if (!scope && !scopeFailed) {
234
+ try {
235
+ scope = createFixScope(projectDir, sourceRoots);
236
+ } catch {
237
+ scopeFailed = true;
238
+ }
239
+ }
240
+ if (!scope) return false;
241
+
242
+ try {
243
+ return auditFixTargetStatus(filePath, scope).writable;
244
+ } catch {
245
+ return false;
246
+ }
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Decide whether the audit may safely offer one automatic source rewrite.
252
+ *
253
+ * @param {string} filePath - Authored source path.
254
+ * @param {{projectDir?: string, sourceRoots?: string[]}} [options={}] - Scope.
255
+ * @returns {boolean} TRUE when the source is inside the writable audit scope.
256
+ */
257
+ export function isAuditFixTargetWritable(filePath, options = {}) {
258
+ return createAuditFixTargetChecker(options)(filePath);
259
+ }
260
+
261
+ /**
262
+ * Reject a source path or file that changed after it was inspected.
263
+ *
264
+ * @param {string} filePath - Original path carried by the finding.
265
+ * @param {string} realTarget - Canonical target resolved before editing.
266
+ * @param {object} scope - Canonical source-write scope.
267
+ * @param {Buffer} expectedBytes - Bytes used to prepare the replacement.
268
+ * @param {import('node:fs').Stats} [expectedStat] - Captured target metadata.
269
+ * @returns {void}
270
+ */
271
+ function validateTarget(
272
+ filePath,
273
+ realTarget,
274
+ scope,
275
+ expectedBytes,
276
+ expectedStat,
277
+ ) {
278
+ const current = auditFixTargetStatus(filePath, scope);
279
+
280
+ if (!current.writable || current.realTarget !== realTarget) {
281
+ throw new Error('source path changed while applying audit fixes');
282
+ }
283
+ if (!fs.readFileSync(current.realTarget).equals(expectedBytes)) {
284
+ throw new Error('source changed while applying audit fixes');
285
+ }
286
+ if (
287
+ expectedStat &&
288
+ !sameTargetMetadata(fs.statSync(current.realTarget), expectedStat)
289
+ ) {
290
+ throw new Error('source metadata changed while applying audit fixes');
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Preserve target ownership on an atomic replacement inode.
296
+ *
297
+ * @param {number} descriptor - Open temporary-file descriptor.
298
+ * @param {{uid: number, gid: number}} targetStat - Original target metadata.
299
+ * @returns {void}
300
+ */
301
+ function preserveOwnership(descriptor, targetStat) {
302
+ if (process.platform === 'win32') return;
303
+
304
+ try {
305
+ fs.fchownSync(descriptor, targetStat.uid, targetStat.gid);
306
+ } catch (error) {
307
+ if (error?.code !== 'EPERM') throw error;
308
+
309
+ // An unprivileged owner cannot chown even to the ownership the newly
310
+ // created inode already has. Only suppress EPERM when nothing would change.
311
+ const temporaryStat = fs.fstatSync(descriptor);
312
+ if (
313
+ temporaryStat.uid !== targetStat.uid ||
314
+ temporaryStat.gid !== targetStat.gid
315
+ ) {
316
+ throw error;
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Attach a cleanup failure without obscuring the write failure that caused it.
323
+ *
324
+ * @param {Error} error - Primary failure.
325
+ * @param {*} cleanupError - Secondary cleanup failure.
326
+ * @param {string} detail - Human-readable cleanup action.
327
+ * @returns {void}
328
+ */
329
+ function attachCleanupError(error, cleanupError, detail) {
330
+ error.cleanupError ??= cleanupError;
331
+ error.message = `${error.message}; ${detail}: ${cleanupError?.message || cleanupError}`;
332
+ }
333
+
334
+ /**
335
+ * Truncate text without splitting a UTF-8 code point.
336
+ *
337
+ * @param {string} value - Filename portion to truncate.
338
+ * @param {number} byteLimit - Maximum UTF-8 byte length.
339
+ * @returns {string} Byte-bounded value.
340
+ */
341
+ function truncateUtf8(value, byteLimit) {
342
+ let result = '';
343
+ let bytes = 0;
344
+
345
+ for (const character of value) {
346
+ const characterBytes = Buffer.byteLength(character);
347
+ if (bytes + characterBytes > byteLimit) break;
348
+ result += character;
349
+ bytes += characterBytes;
350
+ }
351
+
352
+ return result;
353
+ }
354
+
355
+ /**
356
+ * Create an exclusive temp path whose filename fits the common NAME_MAX.
357
+ *
358
+ * @param {string} filePath - Canonical target path.
359
+ * @returns {string} Same-directory temporary path.
360
+ */
361
+ function createTemporaryPath(filePath) {
362
+ const suffix = `.${process.pid}.${randomUUID()}.tmp`;
363
+ const basenameBudget =
364
+ maximumTemporaryNameBytes - Buffer.byteLength(`.${suffix}`);
365
+ const boundedBasename = truncateUtf8(basename(filePath), basenameBudget);
366
+
367
+ return join(dirname(filePath), `.${boundedBasename}${suffix}`);
368
+ }
369
+
370
+ /**
371
+ * Remove every active audit-fix temporary file synchronously.
372
+ *
373
+ * @returns {void}
374
+ */
375
+ function cleanupActiveTemporaryFiles() {
376
+ for (const [temporaryPath, descriptor] of activeTemporaryFiles) {
377
+ if (descriptor !== undefined) {
378
+ activeTemporaryFiles.set(temporaryPath, undefined);
379
+ try {
380
+ fs.closeSync(descriptor);
381
+ } catch {
382
+ // Process-exit cleanup is best effort; still try to remove the path.
383
+ }
384
+ }
385
+
386
+ try {
387
+ fs.unlinkSync(temporaryPath);
388
+ activeTemporaryFiles.delete(temporaryPath);
389
+ } catch (error) {
390
+ if (ignoredCleanupErrors.has(error?.code)) {
391
+ activeTemporaryFiles.delete(temporaryPath);
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Remove this module's process handlers before re-raising a signal.
399
+ *
400
+ * @returns {void}
401
+ */
402
+ function removeTemporaryCleanupHandlers() {
403
+ if (!temporaryCleanupHandlersInstalled) return;
404
+ process.removeListener('exit', onTemporaryCleanupExit);
405
+ process.removeListener('SIGINT', onTemporaryCleanupSigint);
406
+ process.removeListener('SIGTERM', onTemporaryCleanupSigterm);
407
+ temporaryCleanupHandlersInstalled = false;
408
+ }
409
+
410
+ /**
411
+ * Cancel the event-loop turn that keeps queued signals deliverable.
412
+ *
413
+ * @returns {void}
414
+ */
415
+ function clearTemporaryCleanupTurn() {
416
+ if (temporaryCleanupTurn === undefined) return;
417
+ cancelImmediate(temporaryCleanupTurn);
418
+ temporaryCleanupTurn = undefined;
419
+ }
420
+
421
+ /**
422
+ * Clean active files and preserve the operating system's signal semantics.
423
+ *
424
+ * @param {'SIGINT'|'SIGTERM'} signal - Signal to re-raise.
425
+ * @returns {void}
426
+ */
427
+ function forwardTemporaryCleanupSignal(signal, ownHandler) {
428
+ const hasOtherSignalHandler = process
429
+ .listeners(signal)
430
+ .some((listener) => listener !== ownHandler);
431
+ clearTemporaryCleanupTurn();
432
+ cleanupActiveTemporaryFiles();
433
+ removeTemporaryCleanupHandlers();
434
+ if (!hasOtherSignalHandler) process.kill(process.pid, signal);
435
+ }
436
+
437
+ function onTemporaryCleanupExit() {
438
+ clearTemporaryCleanupTurn();
439
+ cleanupActiveTemporaryFiles();
440
+ removeTemporaryCleanupHandlers();
441
+ }
442
+
443
+ function onTemporaryCleanupSigint() {
444
+ forwardTemporaryCleanupSignal('SIGINT', onTemporaryCleanupSigint);
445
+ }
446
+
447
+ function onTemporaryCleanupSigterm() {
448
+ forwardTemporaryCleanupSignal('SIGTERM', onTemporaryCleanupSigterm);
449
+ }
450
+
451
+ /**
452
+ * Install one handler set through the next event-loop turn.
453
+ *
454
+ * Handlers must survive the synchronous applyAuditFixes() call. Node queues a
455
+ * signal received during synchronous I/O until JavaScript yields; removing the
456
+ * listener at function return would swallow that queued signal. A referenced
457
+ * immediate also prevents a short-lived CLI from exiting before delivery.
458
+ *
459
+ * @returns {void}
460
+ */
461
+ function installTemporaryCleanupHandlers() {
462
+ if (!temporaryCleanupHandlersInstalled) {
463
+ process.once('exit', onTemporaryCleanupExit);
464
+ process.once('SIGINT', onTemporaryCleanupSigint);
465
+ process.once('SIGTERM', onTemporaryCleanupSigterm);
466
+ temporaryCleanupHandlersInstalled = true;
467
+ }
468
+
469
+ if (temporaryCleanupTurn === undefined) {
470
+ temporaryCleanupTurn = scheduleImmediate(() => {
471
+ temporaryCleanupTurn = undefined;
472
+ cleanupActiveTemporaryFiles();
473
+ if (!activeTemporaryFiles.size) removeTemporaryCleanupHandlers();
474
+ });
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Determine whether a PID from a temp filename can still own that file.
480
+ *
481
+ * @param {number} pid - Process identifier.
482
+ * @returns {boolean} TRUE unless the operating system confirms it is gone.
483
+ */
484
+ function isProcessAlive(pid) {
485
+ if (pid === process.pid) return true;
486
+
487
+ try {
488
+ process.kill(pid, 0);
489
+ return true;
490
+ } catch (error) {
491
+ return error?.code !== 'ESRCH';
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Remove abandoned audit-fix temp files from canonical source roots.
497
+ *
498
+ * Only regular files with the exact generated UUID/PID shape are eligible.
499
+ * Live or indeterminate PIDs are retained so concurrent fix runs cannot delete
500
+ * one another's in-progress writes.
501
+ *
502
+ * @param {object} scope - Canonical source-write scope.
503
+ * @returns {void}
504
+ */
505
+ function sweepStaleTemporaryFiles(scope) {
506
+ for (const root of scope.realRoots) {
507
+ let candidates;
508
+ try {
509
+ candidates = globSync(temporaryFileGlob, {
510
+ cwd: root,
511
+ absolute: true,
512
+ dot: true,
513
+ follow: false,
514
+ nodir: true,
515
+ ignore: DEFAULT_IGNORES,
516
+ });
517
+ } catch {
518
+ continue;
519
+ }
520
+
521
+ for (const candidate of candidates) {
522
+ const match = basename(candidate).match(temporaryFilePattern);
523
+ if (!match || isIgnored(candidate, scope.realProject)) continue;
524
+
525
+ const ownerPid = Number(match[1]);
526
+ if (!Number.isSafeInteger(ownerPid) || isProcessAlive(ownerPid)) {
527
+ continue;
528
+ }
529
+
530
+ try {
531
+ const candidateStat = fs.lstatSync(candidate);
532
+ const realCandidate = fs.realpathSync(candidate);
533
+ const realCandidateStat = fs.lstatSync(realCandidate);
534
+
535
+ if (
536
+ !candidateStat.isFile() ||
537
+ !isContained(realCandidate, root) ||
538
+ isIgnored(realCandidate, scope.realProject) ||
539
+ candidateStat.dev !== realCandidateStat.dev ||
540
+ candidateStat.ino !== realCandidateStat.ino
541
+ ) {
542
+ continue;
543
+ }
544
+
545
+ fs.unlinkSync(realCandidate);
546
+ } catch {
547
+ // Cleanup is best effort. A later --fix run can retry a stale file.
548
+ }
549
+ }
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Replace a file atomically through an exclusive temp file beside it.
555
+ *
556
+ * @param {string} filePath - Canonical target path.
557
+ * @param {Buffer} contents - Complete replacement contents.
558
+ * @param {Function} validate - Last-moment source validation.
559
+ * @returns {void}
560
+ */
561
+ function atomicReplace(filePath, contents, validate) {
562
+ validate();
563
+ const temporaryPath = createTemporaryPath(filePath);
564
+ const targetStat = fs.statSync(filePath);
565
+ const mode = targetStat.mode & 0o7777;
566
+ let descriptor;
567
+ let temporaryCreated = false;
568
+
569
+ try {
570
+ installTemporaryCleanupHandlers();
571
+ descriptor = fs.openSync(temporaryPath, 'wx', mode);
572
+ temporaryCreated = true;
573
+ activeTemporaryFiles.set(temporaryPath, descriptor);
574
+ fs.writeFileSync(descriptor, contents);
575
+ preserveOwnership(descriptor, targetStat);
576
+ // chown may clear setuid/setgid bits, so mode restoration must come last.
577
+ fs.fchmodSync(descriptor, mode);
578
+ const descriptorToClose = descriptor;
579
+ descriptor = undefined;
580
+ activeTemporaryFiles.set(temporaryPath, undefined);
581
+ fs.closeSync(descriptorToClose);
582
+ validate(targetStat);
583
+ fs.renameSync(temporaryPath, filePath);
584
+ activeTemporaryFiles.delete(temporaryPath);
585
+ } catch (error) {
586
+ if (descriptor !== undefined) {
587
+ const descriptorToClose = descriptor;
588
+ descriptor = undefined;
589
+
590
+ if (activeTemporaryFiles.has(temporaryPath)) {
591
+ activeTemporaryFiles.set(temporaryPath, undefined);
592
+ try {
593
+ fs.closeSync(descriptorToClose);
594
+ } catch (cleanupError) {
595
+ attachCleanupError(
596
+ error,
597
+ cleanupError,
598
+ 'unable to close temporary file',
599
+ );
600
+ }
601
+ }
602
+ }
603
+
604
+ if (temporaryCreated) {
605
+ try {
606
+ fs.unlinkSync(temporaryPath);
607
+ activeTemporaryFiles.delete(temporaryPath);
608
+ } catch (cleanupError) {
609
+ // Preserve the failure that prevented the replacement. A missing temp
610
+ // file simply means a watcher removed it before cleanup. An overlong
611
+ // path was never materialized, so it is not a cleanup failure either.
612
+ if (ignoredCleanupErrors.has(cleanupError?.code)) {
613
+ activeTemporaryFiles.delete(temporaryPath);
614
+ } else {
615
+ attachCleanupError(
616
+ error,
617
+ cleanupError,
618
+ `unable to remove temporary file ${temporaryPath}`,
619
+ );
620
+ }
621
+ }
622
+ }
623
+ throw error;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Add one skip record for every fix targeting an unsafe source file.
629
+ *
630
+ * @param {object[]} skipped - Accumulated skipped records.
631
+ * @param {object[]} findings - Findings targeting the file.
632
+ * @param {string} reason - Human-readable reason.
633
+ * @returns {void}
634
+ */
635
+ function skipFile(skipped, findings, reason) {
636
+ for (const finding of findings) {
637
+ skipped.push({ finding, reason });
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Skip only findings not already rejected while preparing the same file.
643
+ *
644
+ * @param {object[]} skipped - Accumulated skipped records.
645
+ * @param {object[]} findings - Findings targeting the failed file.
646
+ * @param {string} reason - Human-readable reason.
647
+ * @param {Set<object>} recorded - Findings already rejected for this file.
648
+ * @returns {void}
649
+ */
650
+ function skipUnrecordedFile(skipped, findings, reason, recorded) {
651
+ for (const finding of findings) {
652
+ if (!recorded.has(finding)) skipped.push({ finding, reason });
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Wrap an I/O failure without discarding fixes committed before it.
658
+ *
659
+ * @param {*} error - Original failure.
660
+ * @param {string} filePath - File being processed when it failed.
661
+ * @param {object} fixes - Partial fix result.
662
+ * @returns {Error} Contextual failure.
663
+ */
664
+ function createFixError(error, filePath, fixes) {
665
+ const detail = error?.message || error;
666
+ const wrapped = new Error(`Unable to rewrite ${filePath}: ${detail}`, {
667
+ cause: error instanceof Error ? error : undefined,
668
+ });
669
+
670
+ wrapped.name = 'AuditFixError';
671
+ wrapped.code = error?.code;
672
+ wrapped.filePath = filePath;
673
+ wrapped.fixes = fixes;
674
+
675
+ return wrapped;
676
+ }
677
+
678
+ /**
679
+ * Group fixable findings by the file they edit.
680
+ *
681
+ * @param {object[]} findings - Audit findings.
682
+ * @returns {Map<string, object[]>} Findings keyed by absolute file path.
683
+ */
684
+ function groupFixesByFile(findings) {
685
+ const byFile = new Map();
686
+
687
+ for (const finding of findings) {
688
+ const filePath = finding?.fix?.filePath;
689
+ if (!filePath) continue;
690
+
691
+ const existing = byFile.get(filePath);
692
+ if (existing) existing.push(finding);
693
+ else byFile.set(filePath, [finding]);
694
+ }
695
+
696
+ return byFile;
697
+ }
698
+
699
+ /**
700
+ * Apply every fixable finding to its source file.
701
+ *
702
+ * Edits are applied descending by offset, so each one lands to the left of the
703
+ * previous and no offset bookkeeping is needed — two URLs on one line stay
704
+ * independent. Every edit verifies the text it is replacing first, so a stale
705
+ * offset skips one fix rather than corrupting the file.
706
+ *
707
+ * @param {object[]} [findings=[]] - Audit findings.
708
+ * @param {{dryRun?: boolean, projectDir?: string, sourceRoots?: string[]}} [options={}] - Fix options.
709
+ * @returns {{applied: object[], skipped: object[], dryRun: boolean}} Fix result.
710
+ */
711
+ export function applyAuditFixes(
712
+ findings = [],
713
+ { dryRun = false, projectDir = process.cwd(), sourceRoots } = {},
714
+ ) {
715
+ const applied = [];
716
+ const skipped = [];
717
+ const fixes = { applied, skipped, dryRun };
718
+ const fixesByFile = groupFixesByFile(findings);
719
+ if (!fixesByFile.size && dryRun) return fixes;
720
+
721
+ let wrote = false;
722
+ let scope;
723
+
724
+ try {
725
+ scope = createFixScope(projectDir, sourceRoots);
726
+ } catch (error) {
727
+ throw createFixError(error, resolve(projectDir), fixes);
728
+ }
729
+
730
+ try {
731
+ if (!dryRun) sweepStaleTemporaryFiles(scope);
732
+
733
+ for (const [filePath, fileFindings] of fixesByFile) {
734
+ let target;
735
+ let bytes;
736
+
737
+ try {
738
+ target = auditFixTargetStatus(filePath, scope);
739
+ if (target.writable) bytes = fs.readFileSync(target.realTarget);
740
+ } catch (error) {
741
+ const detail = error?.message || error?.code || error;
742
+ skipFile(skipped, fileFindings, `unable to rewrite file: ${detail}`);
743
+ continue;
744
+ }
745
+
746
+ if (!target.writable) {
747
+ skipFile(skipped, fileFindings, target.reason);
748
+ continue;
749
+ }
750
+ const { realTarget } = target;
751
+ const source = bytes.toString('utf8');
752
+ if (!Buffer.from(source, 'utf8').equals(bytes)) {
753
+ skipFile(
754
+ skipped,
755
+ fileFindings,
756
+ 'file is not valid UTF-8; left unchanged',
757
+ );
758
+ continue;
759
+ }
760
+
761
+ const ordered = [...fileFindings].sort(
762
+ (a, b) => b.fix.start - a.fix.start,
763
+ );
764
+ const pendingApplied = [];
765
+ const rejected = new Set();
766
+ let next = source;
767
+ let lastStart = Number.POSITIVE_INFINITY;
768
+
769
+ for (const finding of ordered) {
770
+ const { start, end, original, replacement } = finding.fix;
771
+
772
+ if (end > lastStart) {
773
+ skipped.push({ finding, reason: 'overlaps another fix' });
774
+ rejected.add(finding);
775
+ continue;
776
+ }
777
+ if (next.slice(start, end) !== original) {
778
+ skipped.push({ finding, reason: 'source no longer matches' });
779
+ rejected.add(finding);
780
+ continue;
781
+ }
782
+
783
+ next = next.slice(0, start) + replacement + next.slice(end);
784
+ lastStart = start;
785
+ pendingApplied.push({ finding, from: original, to: replacement });
786
+ }
787
+
788
+ if (!pendingApplied.length) continue;
789
+
790
+ if (dryRun) {
791
+ applied.push(...pendingApplied);
792
+ continue;
793
+ }
794
+
795
+ const validate = (expectedStat) =>
796
+ validateTarget(filePath, realTarget, scope, bytes, expectedStat);
797
+ const replacementBytes = Buffer.from(next, 'utf8');
798
+
799
+ try {
800
+ atomicReplace(realTarget, replacementBytes, validate);
801
+ } catch (error) {
802
+ const detail = error?.message || error?.code || error;
803
+ skipUnrecordedFile(
804
+ skipped,
805
+ fileFindings,
806
+ `unable to rewrite file: ${detail}`,
807
+ rejected,
808
+ );
809
+ continue;
810
+ }
811
+
812
+ wrote = true;
813
+ applied.push(...pendingApplied);
814
+ }
815
+ } finally {
816
+ cleanupActiveTemporaryFiles();
817
+ // The audit reads through a process-local cache. This must run even when a
818
+ // later file fails, or a follow-up scan will hide the writes that landed.
819
+ if (wrote) resetFileReadCache();
820
+ }
821
+
822
+ return fixes;
823
+ }
824
+
825
+ /**
826
+ * Remove findings an autofix has already resolved.
827
+ *
828
+ * @param {object[]} [findings=[]] - Audit findings.
829
+ * @param {object[]} [applied=[]] - Applied fix records.
830
+ * @returns {object[]} Remaining findings.
831
+ */
832
+ export function remainingFindings(findings = [], applied = []) {
833
+ const resolved = new Set(applied.map((entry) => entry.finding));
834
+
835
+ return findings.filter((finding) => !resolved.has(finding));
836
+ }