@ttsc/unplugin 0.28.3 → 0.28.5
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/README.md +70 -8
- package/lib/api.js +3 -0
- package/lib/api.js.map +1 -1
- package/lib/api.mjs +1 -0
- package/lib/api.mjs.map +1 -1
- package/lib/core/index.d.cts +17 -6
- package/lib/core/index.d.mts +17 -6
- package/lib/core/index.d.ts +17 -6
- package/lib/core/index.js +117 -21
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +115 -21
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.cts +93 -25
- package/lib/core/transform.d.mts +93 -25
- package/lib/core/transform.d.ts +93 -25
- package/lib/core/transform.js +803 -121
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +804 -122
- package/lib/core/transform.mjs.map +1 -1
- package/lib/core/tsconfigPaths.d.cts +61 -0
- package/lib/core/tsconfigPaths.d.mts +61 -0
- package/lib/core/tsconfigPaths.d.ts +61 -0
- package/lib/core/tsconfigPaths.js +190 -7
- package/lib/core/tsconfigPaths.js.map +1 -1
- package/lib/core/tsconfigPaths.mjs +188 -8
- package/lib/core/tsconfigPaths.mjs.map +1 -1
- package/lib/next.d.cts +27 -10
- package/lib/next.d.mts +27 -10
- package/lib/next.d.ts +27 -10
- package/lib/next.js +229 -8
- package/lib/next.js.map +1 -1
- package/lib/next.mjs +229 -8
- package/lib/next.mjs.map +1 -1
- package/lib/turbopack.d.cts +5 -4
- package/lib/turbopack.d.mts +5 -4
- package/lib/turbopack.d.ts +5 -4
- package/lib/turbopack.js +13 -7
- package/lib/turbopack.js.map +1 -1
- package/lib/turbopack.mjs +14 -8
- package/lib/turbopack.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +122 -21
- package/src/core/transform.ts +1073 -132
- package/src/core/tsconfigPaths.ts +254 -8
- package/src/next.ts +262 -10
- package/src/turbopack.ts +13 -9
package/src/core/transform.ts
CHANGED
|
@@ -17,8 +17,12 @@ import type { TransformResult } from "unplugin";
|
|
|
17
17
|
|
|
18
18
|
import type { ResolvedTtscUnpluginOptions } from "./options";
|
|
19
19
|
import {
|
|
20
|
+
type ITtscProjectMembershipPolicy,
|
|
21
|
+
PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
20
22
|
absolutizePathsTarget,
|
|
23
|
+
mergeMembershipPolicyOverlay,
|
|
21
24
|
readEffectiveTsconfigPaths,
|
|
25
|
+
readProjectMembershipPolicy,
|
|
22
26
|
} from "./tsconfigPaths";
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -34,22 +38,51 @@ export type TtscTransformResult = Exclude<
|
|
|
34
38
|
>;
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* One alias entry as the host declared it, before anything decides whether a
|
|
42
|
+
* tsconfig `paths` map can express it.
|
|
43
|
+
*
|
|
44
|
+
* Both of Vite's spellings reach here, the `{ "@": "/src" }` object and the `{
|
|
45
|
+
* find, replacement }` array, and only Vite's: `aliases` is populated in
|
|
46
|
+
* `vite.configResolved` alone, and every other adapter passes `undefined`. (The
|
|
47
|
+
* previous wording credited the object form to webpack and Rspack, which never
|
|
48
|
+
* supply one.)
|
|
49
|
+
*
|
|
50
|
+
* `find` is `unknown` rather than `string` because Vite's array form accepts a
|
|
51
|
+
* `RegExp`, and narrowing it here is what used to drop that form before the one
|
|
52
|
+
* place that could report the drop ever saw it (samchon/ttsc#1315).
|
|
40
53
|
*/
|
|
41
|
-
|
|
42
|
-
/** The alias key
|
|
43
|
-
find:
|
|
54
|
+
interface TtscDeclaredAlias {
|
|
55
|
+
/** The alias key, as declared: a module specifier prefix, or a `RegExp`. */
|
|
56
|
+
find: unknown;
|
|
44
57
|
/** Absolute or cwd-relative path that the alias points to. */
|
|
45
58
|
replacement: string;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
/** One directory's
|
|
61
|
+
/** One directory's project-membership identity at generation time. */
|
|
49
62
|
interface TtscProjectDirectorySnapshot {
|
|
50
63
|
/** Absolute directory spelling used by the project walk. */
|
|
51
64
|
path: string;
|
|
52
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Whether this directory's subtree can hold a program input.
|
|
67
|
+
*
|
|
68
|
+
* A directory that cannot is still walked and still watched, so a source
|
|
69
|
+
* appearing in it later is noticed, but it takes no part in the membership
|
|
70
|
+
* comparison. That is what lets a bundler create its output directory and
|
|
71
|
+
* fill it without voiding a generation no compiler input touched, for any
|
|
72
|
+
* output directory rather than for fifteen names (samchon/ttsc#1307).
|
|
73
|
+
*/
|
|
74
|
+
relevant: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Digest of the entries the walk itself considers: every immediate child the
|
|
77
|
+
* ignore list does not drop, with its kind.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately not the directory's own metadata. A directory's stamp moves
|
|
80
|
+
* whenever _any_ entry is added or removed, including the ones the walk
|
|
81
|
+
* exists to ignore, so a bundler emitting into `dist/` — or merely creating
|
|
82
|
+
* that directory for the first time — moved the project root's stamp and
|
|
83
|
+
* voided a generation that no compiler input had touched. The ignore list
|
|
84
|
+
* only protects the generation if the membership proof honours it too.
|
|
85
|
+
*/
|
|
53
86
|
signature: string;
|
|
54
87
|
}
|
|
55
88
|
|
|
@@ -131,8 +164,52 @@ interface TtscFailedGenerationValidation {
|
|
|
131
164
|
projectWalkFailures: string;
|
|
132
165
|
}
|
|
133
166
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
167
|
+
/**
|
|
168
|
+
* A verdict about one generation that later deliveries replay instead of
|
|
169
|
+
* repeating the whole compile behind it.
|
|
170
|
+
*
|
|
171
|
+
* The two kinds are replayed on different evidence, and each carries its own: a
|
|
172
|
+
* pass verdict knows the pass it belongs to, and an unstable generation knows
|
|
173
|
+
* the recorded environment it was proven against.
|
|
174
|
+
*/
|
|
175
|
+
abstract class TtscTerminalGenerationError extends Error {}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The compile succeeded and produced no output for one requested module,
|
|
179
|
+
* because the program does not contain it.
|
|
180
|
+
*
|
|
181
|
+
* Not a terminal generation error, and deliberately not a build failure. It is
|
|
182
|
+
* a fact about one file, and the answer to it is to leave that file to the host
|
|
183
|
+
* (samchon/ttsc#1308). It is a distinct type rather than a message match so the
|
|
184
|
+
* decision travels as a type: `@ttsc/metro` used to recognise this case by
|
|
185
|
+
* searching the message text for "did not return output", which is how one
|
|
186
|
+
* product came to hold two different answers to one condition.
|
|
187
|
+
*/
|
|
188
|
+
class TtscMissingProgramOutputError extends Error {
|
|
189
|
+
/** The module the bundler asked for. */
|
|
190
|
+
public readonly file: string;
|
|
191
|
+
/** The project config whose program does not contain it. */
|
|
192
|
+
public readonly tsconfig: string;
|
|
193
|
+
public constructor(file: string, tsconfig: string) {
|
|
194
|
+
super(
|
|
195
|
+
`ttsc: ${file} is not part of the program described by ${tsconfig}, so it was left untransformed. Add it to that project's "include" if ttsc plugins should apply to it.`,
|
|
196
|
+
);
|
|
197
|
+
this.name = "TtscMissingProgramOutputError";
|
|
198
|
+
this.file = file;
|
|
199
|
+
this.tsconfig = tsconfig;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A bounded proof failure that stays authoritative until its inputs change.
|
|
205
|
+
*
|
|
206
|
+
* This is the adapter failing to _obtain_ a coherent snapshot — a race it lost
|
|
207
|
+
* — so a later attempt may well succeed with the same inputs. It is retried
|
|
208
|
+
* when its recorded environment moves, and a new delivery epoch grants it the
|
|
209
|
+
* one fresh attempt the per-pass cache clear used to give it
|
|
210
|
+
* (samchon/ttsc#1300).
|
|
211
|
+
*/
|
|
212
|
+
class TtscUnstableGenerationError extends TtscTerminalGenerationError {
|
|
136
213
|
public readonly validation: TtscFailedGenerationValidation;
|
|
137
214
|
|
|
138
215
|
public constructor(
|
|
@@ -145,6 +222,56 @@ class TtscUnstableGenerationError extends Error {
|
|
|
145
222
|
}
|
|
146
223
|
}
|
|
147
224
|
|
|
225
|
+
/**
|
|
226
|
+
* A compile this pass already attempted, whose envelope failed outright.
|
|
227
|
+
*
|
|
228
|
+
* The envelope cannot say whether the host reported diagnostics about the
|
|
229
|
+
* project or failed to run at all: an ordinary type error arrives as an
|
|
230
|
+
* `"exception"` carrying the compiler's own diagnostic text, exactly as a
|
|
231
|
+
* crashed host would. Sniffing that message to tell the two apart would be a
|
|
232
|
+
* guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
|
|
233
|
+
* the answer is already settled, so every later module replays it instead of
|
|
234
|
+
* repeating a whole-project transform to reach the same verdict, which is what
|
|
235
|
+
* made a single broken save cost one compile per delivered module
|
|
236
|
+
* (samchon/ttsc#1303).
|
|
237
|
+
*
|
|
238
|
+
* The scope is exactly the pass. A host whose `buildStart` repeats drops the
|
|
239
|
+
* verdict at its next rebuild, so a transient host failure costs that one
|
|
240
|
+
* rebuild. A host with no pass boundary never retains one at all and keeps
|
|
241
|
+
* retrying on its very next delivery. Between them sits a host that opens
|
|
242
|
+
* exactly one pass for its whole process — Bun's runtime plugin, and a Vite dev
|
|
243
|
+
* server configured with `server.watch: null` — where the verdict lasts the
|
|
244
|
+
* session. That follows from what those hosts already publish about themselves,
|
|
245
|
+
* that their session is one immutable load session and the remedy for changed
|
|
246
|
+
* inputs is to restart, and it is the deliberate trade: without it, one type
|
|
247
|
+
* error costs such a session a whole-project compile per delivered module,
|
|
248
|
+
* which is the workload samchon/ttsc#970 is about.
|
|
249
|
+
*
|
|
250
|
+
* It carries the original error's message, stack and `cause` rather than
|
|
251
|
+
* replacing them, so what a bundler reports is what it reported before the
|
|
252
|
+
* verdict existed.
|
|
253
|
+
*/
|
|
254
|
+
class TtscPassVerdictError extends TtscTerminalGenerationError {
|
|
255
|
+
/** The delivery pass this verdict belongs to, and its whole scope. */
|
|
256
|
+
public readonly epoch: number;
|
|
257
|
+
|
|
258
|
+
public constructor(original: unknown, epoch: number) {
|
|
259
|
+
super(
|
|
260
|
+
original instanceof Error
|
|
261
|
+
? original.message
|
|
262
|
+
: formatUnknownError(original),
|
|
263
|
+
{ cause: original },
|
|
264
|
+
);
|
|
265
|
+
if (original instanceof Error) {
|
|
266
|
+
this.name = original.name;
|
|
267
|
+
if (original.stack !== undefined) this.stack = original.stack;
|
|
268
|
+
} else {
|
|
269
|
+
this.name = "TtscPassVerdictError";
|
|
270
|
+
}
|
|
271
|
+
this.epoch = epoch;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
148
275
|
/** Proof witnesses retained beside a compiler result without extending its API. */
|
|
149
276
|
const TRANSFORM_GENERATION_FAILURES = new WeakMap<
|
|
150
277
|
ITtscCompilerTransformation,
|
|
@@ -157,10 +284,10 @@ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
|
|
|
157
284
|
TtscFailedGenerationValidation
|
|
158
285
|
>();
|
|
159
286
|
|
|
160
|
-
/**
|
|
287
|
+
/** Cache promises whose unchanged terminal verdict may be replayed. */
|
|
161
288
|
const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
|
|
162
289
|
Promise<TtscCachedProjectTransform>,
|
|
163
|
-
|
|
290
|
+
TtscTerminalGenerationError
|
|
164
291
|
>();
|
|
165
292
|
|
|
166
293
|
/** Maximum witnesses printed and retained for each failed transform attempt. */
|
|
@@ -228,6 +355,28 @@ export interface TtscCachedProjectTransform {
|
|
|
228
355
|
* transform.
|
|
229
356
|
*/
|
|
230
357
|
inputHashes: Record<string, string>;
|
|
358
|
+
/**
|
|
359
|
+
* What the resolved configuration admitted into this generation's program.
|
|
360
|
+
*
|
|
361
|
+
* Recorded per generation rather than read per validation because it is a
|
|
362
|
+
* property of the configuration the compile ran under, so a later delivery
|
|
363
|
+
* must judge membership by the same rule the compile did. A tsconfig edit
|
|
364
|
+
* that changes the rule also changes a declared input, which replaces the
|
|
365
|
+
* generation and its policy together.
|
|
366
|
+
*/
|
|
367
|
+
membershipPolicy: ITtscProjectMembershipPolicy;
|
|
368
|
+
/**
|
|
369
|
+
* Files already reported as absent from the program, and the pass that
|
|
370
|
+
* reporting belongs to, so the notice is one per file per pass rather than
|
|
371
|
+
* one per delivery.
|
|
372
|
+
*/
|
|
373
|
+
missingOutputReported?: Set<string>;
|
|
374
|
+
missingOutputEpoch?: number;
|
|
375
|
+
/**
|
|
376
|
+
* The project config this generation compiled, so a module the program does
|
|
377
|
+
* not contain can be told which program that was.
|
|
378
|
+
*/
|
|
379
|
+
tsconfig: string;
|
|
231
380
|
/**
|
|
232
381
|
* Metadata signature of each {@link inputHashes} entry whose hash was proven
|
|
233
382
|
* against an unracing read of the file on disk, in a tick the observed
|
|
@@ -290,10 +439,35 @@ export interface TtscCachedProjectTransform {
|
|
|
290
439
|
projectRoot: string;
|
|
291
440
|
/** Raw compiler output returned by {@link TtscCompiler.transform}. */
|
|
292
441
|
result: ITtscCompilerTransformation;
|
|
442
|
+
/**
|
|
443
|
+
* The delivery epoch this generation is currently settled against, or
|
|
444
|
+
* `undefined` for a generation no epoch has proven.
|
|
445
|
+
*
|
|
446
|
+
* Set when the generation is compiled, and again whenever a later epoch's
|
|
447
|
+
* first delivery proves the whole generation still matches the filesystem.
|
|
448
|
+
* While it equals the cache's current epoch, each module's first delivery is
|
|
449
|
+
* settled by the supplied source alone, exactly as it was when every pass
|
|
450
|
+
* compiled its own generation (samchon/ttsc#1300).
|
|
451
|
+
*/
|
|
452
|
+
deliveryEpoch?: number;
|
|
453
|
+
/**
|
|
454
|
+
* Whether this generation's non-error diagnostics have been surfaced at all,
|
|
455
|
+
* and the epoch they were last surfaced in.
|
|
456
|
+
*
|
|
457
|
+
* The diagnostics describe one compile of one program, so they belong to the
|
|
458
|
+
* generation rather than to a delivery; a pass that reuses a retained
|
|
459
|
+
* generation still surfaces them once, because a build's warnings are part of
|
|
460
|
+
* what that build reports (samchon/ttsc#1304). The two fields are separate so
|
|
461
|
+
* a persistent host, whose epoch is `undefined`, still reports the first
|
|
462
|
+
* time.
|
|
463
|
+
*/
|
|
464
|
+
diagnosticsReported?: boolean;
|
|
465
|
+
diagnosticsEpoch?: number;
|
|
293
466
|
/**
|
|
294
467
|
* Files already delivered from this generation, keyed by filesystem identity.
|
|
295
|
-
*
|
|
296
|
-
* module's first delivery inside the current
|
|
468
|
+
* A cache with a delivery epoch uses this to skip persistent validation only
|
|
469
|
+
* for a module's first delivery inside the current pass; the set is cleared
|
|
470
|
+
* whenever a new epoch's gate re-proves the generation.
|
|
297
471
|
*/
|
|
298
472
|
servedFiles?: Set<string>;
|
|
299
473
|
/**
|
|
@@ -387,10 +561,30 @@ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
|
|
|
387
561
|
>();
|
|
388
562
|
|
|
389
563
|
/**
|
|
390
|
-
*
|
|
391
|
-
* {@link beginTtscTransformBuild}
|
|
564
|
+
* The current delivery epoch of each cache whose owner has declared a real
|
|
565
|
+
* per-pass lifecycle by calling {@link beginTtscTransformBuild}.
|
|
566
|
+
*
|
|
567
|
+
* A _delivery epoch_ is one bundler pass: the window inside which each module
|
|
568
|
+
* is requested at most once, so its first delivery may be settled against the
|
|
569
|
+
* state the pass started from. It is deliberately not the same fact as whether
|
|
570
|
+
* the generation is still valid, which the recorded snapshot answers.
|
|
571
|
+
* Conflating the two is what made every host with a repeating `buildStart` —
|
|
572
|
+
* webpack and Rspack watch, Rollup and Rolldown watch, `vite build --watch`,
|
|
573
|
+
* esbuild rebuild — discard a perfectly good whole-project compile on every
|
|
574
|
+
* edit (samchon/ttsc#1300).
|
|
575
|
+
*
|
|
576
|
+
* Absent from the map means persistent validation: a host with no pass boundary
|
|
577
|
+
* at all (a watching Vite dev server, Metro, the Turbopack loader), where every
|
|
578
|
+
* delivery proves the generation for itself.
|
|
392
579
|
*/
|
|
393
|
-
const
|
|
580
|
+
const TRANSFORM_CACHE_EPOCHS = new WeakMap<TtscTransformCache, number>();
|
|
581
|
+
|
|
582
|
+
/** The pass a delivery belongs to, or `undefined` under persistent validation. */
|
|
583
|
+
function transformCacheEpoch(
|
|
584
|
+
cache: TtscTransformCache | undefined,
|
|
585
|
+
): number | undefined {
|
|
586
|
+
return cache === undefined ? undefined : TRANSFORM_CACHE_EPOCHS.get(cache);
|
|
587
|
+
}
|
|
394
588
|
|
|
395
589
|
function createHostPathIdentityContext(
|
|
396
590
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
@@ -453,28 +647,41 @@ function resultFilesystem(
|
|
|
453
647
|
}
|
|
454
648
|
|
|
455
649
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
650
|
+
* Open a new delivery pass, enabling constant-time first delivery for every
|
|
651
|
+
* module this pass asks for.
|
|
652
|
+
*
|
|
653
|
+
* This deliberately retains the cached generation. The pass boundary is a
|
|
654
|
+
* statement about _deliveries_ — each module is requested at most once inside
|
|
655
|
+
* it — not about whether the compiled program is still correct, which the
|
|
656
|
+
* generation's own recorded snapshot answers and which
|
|
657
|
+
* {@link matchesCachedSource} proves once at the pass's first delivery. Clearing
|
|
658
|
+
* here instead made a host whose `buildStart` repeats recompile the whole
|
|
659
|
+
* project on every rebuild even when no compiler input had changed
|
|
660
|
+
* (samchon/ttsc#1300). Use {@link resetTtscTransformCache} to actually discard a
|
|
661
|
+
* generation and its watchers.
|
|
458
662
|
*
|
|
459
|
-
* Hosts without a guaranteed
|
|
460
|
-
*
|
|
663
|
+
* Hosts without a guaranteed pass boundary use persistent validation unless
|
|
664
|
+
* they have another immutable lifecycle. Bun runtime setup, for example,
|
|
461
665
|
* defines one process-scoped module-loading session.
|
|
462
666
|
*/
|
|
463
667
|
export function beginTtscTransformBuild(cache: TtscTransformCache): void {
|
|
464
|
-
|
|
465
|
-
|
|
668
|
+
TRANSFORM_CACHE_EPOCHS.set(
|
|
669
|
+
cache,
|
|
670
|
+
(TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1,
|
|
671
|
+
);
|
|
466
672
|
}
|
|
467
673
|
|
|
468
674
|
/**
|
|
469
|
-
*
|
|
675
|
+
* Discard every generation, dispose its watchers, and return the cache to
|
|
676
|
+
* persistent validation mode.
|
|
470
677
|
*
|
|
471
|
-
* This is
|
|
472
|
-
*
|
|
473
|
-
*
|
|
678
|
+
* This is the unconditional lifecycle boundary, and it is distinct from
|
|
679
|
+
* {@link beginTtscTransformBuild}: a pass ending is not a reason to throw a
|
|
680
|
+
* proven compile away, while a session ending is.
|
|
474
681
|
*/
|
|
475
682
|
export function resetTtscTransformCache(cache: TtscTransformCache): void {
|
|
476
683
|
clearTtscTransformCache(cache);
|
|
477
|
-
|
|
684
|
+
TRANSFORM_CACHE_EPOCHS.delete(cache);
|
|
478
685
|
}
|
|
479
686
|
|
|
480
687
|
/** Dispose generation-owned filesystem resources before clearing a cache. */
|
|
@@ -589,16 +796,20 @@ export async function transformTtsc(
|
|
|
589
796
|
});
|
|
590
797
|
|
|
591
798
|
for (;;) {
|
|
799
|
+
// Read once per iteration, before the cache is consulted, so a delivery
|
|
800
|
+
// belongs to the pass that was current when it started examining the
|
|
801
|
+
// generation. A pass opened while this one awaits an in-flight compile is
|
|
802
|
+
// picked up by the next iteration, which is the one that runs when the
|
|
803
|
+
// entry it awaited turns out to have been superseded.
|
|
804
|
+
const epoch = transformCacheEpoch(cache);
|
|
592
805
|
let transformed = cache?.get(key);
|
|
593
806
|
if (transformed !== undefined) {
|
|
594
807
|
const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
|
|
595
808
|
if (terminal !== undefined) {
|
|
596
|
-
// A
|
|
597
|
-
// invitation for every later module to repeat the whole compile.
|
|
598
|
-
// replaying it until a source/input probe or an explicit cache reset
|
|
599
|
-
// establishes that a new generation could differ.
|
|
809
|
+
// A terminal verdict is an answer about one observed environment, not an
|
|
810
|
+
// invitation for every later module to repeat the whole compile.
|
|
600
811
|
if (
|
|
601
|
-
|
|
812
|
+
replaysTerminalGeneration(terminal, epoch, {
|
|
602
813
|
currentFile: file,
|
|
603
814
|
currentSource: source,
|
|
604
815
|
filesystem,
|
|
@@ -621,9 +832,7 @@ export async function transformTtsc(
|
|
|
621
832
|
if (cache?.get(key) !== transformed) {
|
|
622
833
|
continue;
|
|
623
834
|
}
|
|
624
|
-
|
|
625
|
-
cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
|
|
626
|
-
if (!buildScoped) {
|
|
835
|
+
if (epoch === undefined) {
|
|
627
836
|
await settleProjectMutationEvents(cached);
|
|
628
837
|
if (cache?.get(key) !== transformed) {
|
|
629
838
|
continue;
|
|
@@ -638,16 +847,33 @@ export async function transformTtsc(
|
|
|
638
847
|
projectRoot: cached.projectRoot,
|
|
639
848
|
result: cached.result,
|
|
640
849
|
}) &&
|
|
641
|
-
matchesCachedSource(cached, file, source,
|
|
850
|
+
matchesCachedSource(cached, file, source, epoch)
|
|
642
851
|
) {
|
|
643
|
-
reportSuccessDiagnostics(cached
|
|
852
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
644
853
|
// A resolved `"exception"` / `"failure"` envelope makes this throw;
|
|
645
|
-
// that is a failed generation too, so
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
854
|
+
// that is a failed generation too, so it is retained for this pass or
|
|
855
|
+
// evicted outside one before being surfaced.
|
|
856
|
+
let code: string;
|
|
857
|
+
try {
|
|
858
|
+
code = selectOrEvict(cache, key, transformed, epoch, {
|
|
859
|
+
file,
|
|
860
|
+
projectRoot: cached.projectRoot,
|
|
861
|
+
result: cached.result,
|
|
862
|
+
tsconfig: cached.tsconfig,
|
|
863
|
+
});
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
866
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
// The compile is fine and simply has nothing for this module, so the
|
|
870
|
+
// module goes back to the host untransformed rather than failing the
|
|
871
|
+
// build (samchon/ttsc#1308). It still counts as delivered in this
|
|
872
|
+
// pass, and there is nothing to watch for a file with no output.
|
|
873
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
874
|
+
markCachedSourceServed(cached, file);
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
651
877
|
notifyWatchInputs(hooks, cached, file);
|
|
652
878
|
markCachedSourceServed(cached, file);
|
|
653
879
|
return createTransformResult(source, code);
|
|
@@ -668,6 +894,10 @@ export async function transformTtsc(
|
|
|
668
894
|
compilerOptions: options.compilerOptions,
|
|
669
895
|
currentFile: file,
|
|
670
896
|
currentSource: source,
|
|
897
|
+
// Stamp the pass this compile was started for, not the one it happens
|
|
898
|
+
// to finish in: a boundary crossed mid-compile leaves the generation
|
|
899
|
+
// belonging to the earlier pass, so the next pass re-proves it.
|
|
900
|
+
deliveryEpoch: epoch,
|
|
671
901
|
filesystem,
|
|
672
902
|
plugins: options.plugins,
|
|
673
903
|
trackProjectMembership: cache !== undefined,
|
|
@@ -681,12 +911,24 @@ export async function transformTtsc(
|
|
|
681
911
|
continue;
|
|
682
912
|
}
|
|
683
913
|
const { projectRoot, result } = cached;
|
|
684
|
-
reportSuccessDiagnostics(
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
914
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
915
|
+
let code: string;
|
|
916
|
+
try {
|
|
917
|
+
code = selectOrEvict(cache, key, generation, epoch, {
|
|
918
|
+
file,
|
|
919
|
+
projectRoot,
|
|
920
|
+
result,
|
|
921
|
+
tsconfig: cached.tsconfig,
|
|
922
|
+
});
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
925
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
929
|
+
markCachedSourceServed(cached, file);
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
690
932
|
notifyWatchInputs(hooks, cached, file);
|
|
691
933
|
markCachedSourceServed(cached, file);
|
|
692
934
|
if (
|
|
@@ -730,29 +972,146 @@ async function awaitOrEvict(
|
|
|
730
972
|
}
|
|
731
973
|
|
|
732
974
|
/**
|
|
733
|
-
* Extract the transformed source,
|
|
734
|
-
*
|
|
735
|
-
* {@link selectTransformedSource}
|
|
736
|
-
*
|
|
975
|
+
* Extract the transformed source, and decide what a throwing generation is.
|
|
976
|
+
*
|
|
977
|
+
* {@link selectTransformedSource} throws for two different reasons, and only one
|
|
978
|
+
* of them is about the generation. A host `"exception"` or a compiler
|
|
979
|
+
* `"failure"` means the compile produced nothing for anyone: inside a pass that
|
|
980
|
+
* verdict is retained and replayed, because evicting it made every remaining
|
|
981
|
+
* module repeat the whole-project transform only to reach the identical answer
|
|
982
|
+
* (samchon/ttsc#1303), and outside a pass it keeps being evicted so a
|
|
983
|
+
* long-lived worker retries on its very next delivery exactly as before.
|
|
984
|
+
*
|
|
985
|
+
* A `"success"` envelope that has no output for the module asking is the other
|
|
986
|
+
* reason, and it is a fact about that one file: an ordinary condition for a
|
|
987
|
+
* module the bundle reaches and the tsconfig program does not contain. It is
|
|
988
|
+
* neither retained nor evicted. The error reaches the caller, and the
|
|
989
|
+
* generation, which compiled perfectly well for every other module, stays.
|
|
737
990
|
*/
|
|
738
991
|
function selectOrEvict(
|
|
739
992
|
cache: TtscTransformCache | undefined,
|
|
740
993
|
key: string,
|
|
741
994
|
generation: Promise<TtscCachedProjectTransform>,
|
|
995
|
+
epoch: number | undefined,
|
|
742
996
|
props: {
|
|
743
997
|
file: string;
|
|
744
998
|
projectRoot: string;
|
|
745
999
|
result: ITtscCompilerTransformation;
|
|
1000
|
+
tsconfig: string;
|
|
746
1001
|
},
|
|
747
1002
|
): string {
|
|
748
1003
|
try {
|
|
749
1004
|
return selectTransformedSource(props);
|
|
750
1005
|
} catch (error) {
|
|
751
|
-
|
|
1006
|
+
const verdict = retainPassVerdict(
|
|
1007
|
+
cache,
|
|
1008
|
+
key,
|
|
1009
|
+
generation,
|
|
1010
|
+
epoch,
|
|
1011
|
+
props.result,
|
|
1012
|
+
error,
|
|
1013
|
+
);
|
|
1014
|
+
if (verdict !== undefined) {
|
|
1015
|
+
throw verdict;
|
|
1016
|
+
}
|
|
1017
|
+
// A generation that compiled fine and simply has no output for the module
|
|
1018
|
+
// asking is not a failed generation. Discarding it made every later module
|
|
1019
|
+
// recompile the whole project to reach the same answer, which is the cost
|
|
1020
|
+
// samchon/ttsc#1303 is about, for a bundle that merely reaches a file the
|
|
1021
|
+
// tsconfig program does not contain.
|
|
1022
|
+
if (props.result.type !== "success") {
|
|
1023
|
+
evictGeneration(cache, key, generation);
|
|
1024
|
+
}
|
|
752
1025
|
throw error;
|
|
753
1026
|
}
|
|
754
1027
|
}
|
|
755
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* Retain the verdict of a compile this pass already attempted, or return
|
|
1031
|
+
* `undefined` when nothing may be retained.
|
|
1032
|
+
*
|
|
1033
|
+
* Only inside a delivery pass. A pass is the window in which every delivery is
|
|
1034
|
+
* settled against the state the pass started from, so an attempt it already
|
|
1035
|
+
* made is part of that state and the remaining modules replay it rather than
|
|
1036
|
+
* each repeating a whole-project transform to reach the same answer. Outside a
|
|
1037
|
+
* pass there is no such window, and a long-lived worker must keep retrying on
|
|
1038
|
+
* its very next delivery so a transient host failure never becomes permanent.
|
|
1039
|
+
*/
|
|
1040
|
+
function retainPassVerdict(
|
|
1041
|
+
cache: TtscTransformCache | undefined,
|
|
1042
|
+
key: string,
|
|
1043
|
+
generation: Promise<TtscCachedProjectTransform>,
|
|
1044
|
+
epoch: number | undefined,
|
|
1045
|
+
result: ITtscCompilerTransformation,
|
|
1046
|
+
error: unknown,
|
|
1047
|
+
): TtscTerminalGenerationError | undefined {
|
|
1048
|
+
// Only an envelope that failed outright is a statement about the generation.
|
|
1049
|
+
// `selectTransformedSource` also throws for a file the compile simply has no
|
|
1050
|
+
// output for, which is an ordinary condition for a module the bundle reaches
|
|
1051
|
+
// but the tsconfig program does not contain, and which says nothing about the
|
|
1052
|
+
// other modules. Retaining that would fail the whole pass, naming a file none
|
|
1053
|
+
// of them asked about.
|
|
1054
|
+
if (
|
|
1055
|
+
result.type === "success" ||
|
|
1056
|
+
epoch === undefined ||
|
|
1057
|
+
cache?.get(key) !== generation
|
|
1058
|
+
) {
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
const existing = TERMINAL_TRANSFORM_GENERATIONS.get(generation);
|
|
1062
|
+
if (existing !== undefined) {
|
|
1063
|
+
return existing;
|
|
1064
|
+
}
|
|
1065
|
+
const verdict = new TtscPassVerdictError(error, epoch);
|
|
1066
|
+
TERMINAL_TRANSFORM_GENERATIONS.set(generation, verdict);
|
|
1067
|
+
return verdict;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Whether a terminal verdict still answers for this delivery.
|
|
1072
|
+
*
|
|
1073
|
+
* Inside the pass that produced or confirmed it, it is replayed without
|
|
1074
|
+
* re-probing anything: the pass settles every delivery against the state it
|
|
1075
|
+
* started from, so re-walking the project once per module would spend exactly
|
|
1076
|
+
* the cost this gate exists to remove.
|
|
1077
|
+
*
|
|
1078
|
+
* Across passes the two kinds part company. A pass verdict is dropped, because
|
|
1079
|
+
* a new pass is the first boundary at which the host itself claims something
|
|
1080
|
+
* may have changed, and the compile it stood for was never proven against a
|
|
1081
|
+
* recorded environment. An unstable generation was, so it keeps its own rule:
|
|
1082
|
+
* one fresh attempt per pass, and otherwise replayed until that recorded
|
|
1083
|
+
* environment provably moves.
|
|
1084
|
+
*/
|
|
1085
|
+
function replaysTerminalGeneration(
|
|
1086
|
+
terminal: TtscTerminalGenerationError,
|
|
1087
|
+
epoch: number | undefined,
|
|
1088
|
+
props: {
|
|
1089
|
+
currentFile: string;
|
|
1090
|
+
currentSource: string;
|
|
1091
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
1092
|
+
},
|
|
1093
|
+
): boolean {
|
|
1094
|
+
if (terminal instanceof TtscPassVerdictError) {
|
|
1095
|
+
// A pass verdict has no recorded environment to re-confirm against, so the
|
|
1096
|
+
// pass that produced it is its whole scope.
|
|
1097
|
+
return epoch !== undefined && terminal.epoch === epoch;
|
|
1098
|
+
}
|
|
1099
|
+
if (!(terminal instanceof TtscUnstableGenerationError)) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
// An unstable generation does have one, and confirming it per delivery is the
|
|
1103
|
+
// behaviour its own contract describes, so the pass does not cache that
|
|
1104
|
+
// answer. A new pass still grants the fresh attempt the per-pass cache clear
|
|
1105
|
+
// used to give it.
|
|
1106
|
+
if (
|
|
1107
|
+
epoch !== undefined &&
|
|
1108
|
+
terminal.validation.cached.deliveryEpoch !== epoch
|
|
1109
|
+
) {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
return !failedGenerationEnvironmentChanged(terminal.validation, props);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
756
1115
|
/**
|
|
757
1116
|
* Delete a failed generation from the cache only when it is still the entry
|
|
758
1117
|
* stored under `key`. The identity check prevents an older failed generation's
|
|
@@ -1204,6 +1563,51 @@ function collectDeclaredIdentities(
|
|
|
1204
1563
|
* transform scratch tree (see
|
|
1205
1564
|
* {@link TtscCachedProjectTransform.scratchDirectory}).
|
|
1206
1565
|
*/
|
|
1566
|
+
/**
|
|
1567
|
+
* Register the failed generation's own project inputs so the host can observe
|
|
1568
|
+
* the fix.
|
|
1569
|
+
*
|
|
1570
|
+
* A successful delivery registers the derived watch inputs, which is how a
|
|
1571
|
+
* type-only file that no bundler graph contains still invalidates the modules
|
|
1572
|
+
* depending on it. A failed one used to register nothing: `selectWatchInputs`
|
|
1573
|
+
* returns an empty list for an `"exception"` envelope, and the throw happens
|
|
1574
|
+
* before `notifyWatchInputs` is reached at all. When the failing compile is the
|
|
1575
|
+
* first of a watching session, that leaves no channel through which the fix can
|
|
1576
|
+
* arrive: the user repairs a file the bundler does not track, nothing is
|
|
1577
|
+
* invalidated, and the error stays on screen (samchon/ttsc#1312).
|
|
1578
|
+
*
|
|
1579
|
+
* The generation records the project walk even when the compile failed, so the
|
|
1580
|
+
* files a fix would touch are exactly what it already holds. The cost is paid
|
|
1581
|
+
* only on a failure, and only until the next compile succeeds and narrows the
|
|
1582
|
+
* set back to the derived inputs.
|
|
1583
|
+
*/
|
|
1584
|
+
function notifyFailedGenerationInputs(
|
|
1585
|
+
hooks: TtscTransformHooks | undefined,
|
|
1586
|
+
cached: TtscCachedProjectTransform,
|
|
1587
|
+
): void {
|
|
1588
|
+
const addWatchFile = hooks?.addWatchFile;
|
|
1589
|
+
if (addWatchFile === undefined) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
for (const key of Object.keys(cached.inputHashes)) {
|
|
1593
|
+
const input = path.resolve(cached.projectRoot, key);
|
|
1594
|
+
if (isTransformScratchInput(input, cached.scratchDirectory)) {
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
// No evidence argument, deliberately. `missing: false` would be a claim
|
|
1598
|
+
// this path cannot back: a failed generation is replayed for the rest of
|
|
1599
|
+
// its pass without re-proving its inputs, so the walk that recorded them
|
|
1600
|
+
// may be older than the delivery, and one of them having been deleted is a
|
|
1601
|
+
// live reason for that compile to have failed. Letting the adapter probe
|
|
1602
|
+
// also routes an absent input to the missing-input poll, which is the only
|
|
1603
|
+
// channel through which restoring it can invalidate anything: a bundler
|
|
1604
|
+
// watch on a path that does not exist registers nothing, and no module
|
|
1605
|
+
// graph carries a type-only input. It costs one `existsSync` per input,
|
|
1606
|
+
// and only where the adapter reads evidence at all, which is Vite serve.
|
|
1607
|
+
addWatchFile(input);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1207
1611
|
function notifyWatchInputs(
|
|
1208
1612
|
hooks: TtscTransformHooks | undefined,
|
|
1209
1613
|
cached: TtscCachedProjectTransform,
|
|
@@ -1703,20 +2107,21 @@ export function createTransformResult(
|
|
|
1703
2107
|
* state.
|
|
1704
2108
|
*
|
|
1705
2109
|
* Always compares the current module's in-memory source with the generation
|
|
1706
|
-
* snapshot. A cache
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
2110
|
+
* snapshot. A cache with a delivery epoch can use that comparison alone for a
|
|
2111
|
+
* stable generation's first delivery of each module in the current pass, once
|
|
2112
|
+
* the pass's own first delivery has proven the whole generation still matches
|
|
2113
|
+
* the filesystem. An incomplete generation may not take this shortcut:
|
|
2114
|
+
* otherwise a sibling output captured during a filesystem race could still be
|
|
2115
|
+
* served once. Later graph-bearing requests validate the file's derived input
|
|
2116
|
+
* set and project membership; graph-free envelopes conservatively re-hash the
|
|
2117
|
+
* complete project and out-of-walk snapshots. Any mismatch forces a complete
|
|
1713
2118
|
* re-transform.
|
|
1714
2119
|
*/
|
|
1715
2120
|
function matchesCachedSource(
|
|
1716
2121
|
cached: TtscCachedProjectTransform,
|
|
1717
2122
|
file: string,
|
|
1718
2123
|
source: string,
|
|
1719
|
-
|
|
2124
|
+
epoch: number | undefined,
|
|
1720
2125
|
): boolean {
|
|
1721
2126
|
const identities = envelopeDerivation(cached).identityContext;
|
|
1722
2127
|
const currentKey = toProjectKey(cached.projectRoot, file, identities);
|
|
@@ -1728,12 +2133,24 @@ function matchesCachedSource(
|
|
|
1728
2133
|
if (expected !== hashText(source)) {
|
|
1729
2134
|
return false;
|
|
1730
2135
|
}
|
|
1731
|
-
if (
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2136
|
+
if (epoch !== undefined && cached.projectSnapshotComplete === true) {
|
|
2137
|
+
if (cached.deliveryEpoch !== epoch) {
|
|
2138
|
+
// The pass's first delivery. The generation was settled against an
|
|
2139
|
+
// earlier pass, so prove the whole of it once — every input the envelope
|
|
2140
|
+
// declares, the directory membership, the universal host inputs, and the
|
|
2141
|
+
// out-of-walk snapshot — before any of this pass's deliveries may be
|
|
2142
|
+
// settled against it. That proof is what a per-pass recompile used to buy
|
|
2143
|
+
// (samchon/ttsc#1300), at a walk instead of a compile.
|
|
2144
|
+
if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
|
|
2145
|
+
return false;
|
|
2146
|
+
}
|
|
2147
|
+
cached.deliveryEpoch = epoch;
|
|
2148
|
+
cached.servedFiles?.clear();
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
if (!cached.servedFiles?.has(identity)) {
|
|
2152
|
+
return true;
|
|
2153
|
+
}
|
|
1737
2154
|
}
|
|
1738
2155
|
if (
|
|
1739
2156
|
cached.result.type !== "exception" &&
|
|
@@ -2590,6 +3007,12 @@ function matchesCompleteInputSnapshot(
|
|
|
2590
3007
|
cached.inputSignatures === undefined
|
|
2591
3008
|
? undefined
|
|
2592
3009
|
: { hashes: cached.inputHashes, signatures: cached.inputSignatures },
|
|
3010
|
+
{
|
|
3011
|
+
// Judge membership by the rule the compile ran under, and read only the
|
|
3012
|
+
// inputs this comparison actually consults.
|
|
3013
|
+
declaredKeys: declaredInputs,
|
|
3014
|
+
policy: cached.membershipPolicy,
|
|
3015
|
+
},
|
|
2593
3016
|
);
|
|
2594
3017
|
if (!walkSnapshotComplete(current, declaredInputs)) {
|
|
2595
3018
|
return false;
|
|
@@ -2961,9 +3384,17 @@ export function collectProjectInputHashes(
|
|
|
2961
3384
|
projectRoot: string,
|
|
2962
3385
|
identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
2963
3386
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3387
|
+
policy?: ITtscProjectMembershipPolicy,
|
|
2964
3388
|
): Record<string, string> {
|
|
2965
|
-
return collectProjectInputSnapshot(
|
|
2966
|
-
|
|
3389
|
+
return collectProjectInputSnapshot(
|
|
3390
|
+
projectRoot,
|
|
3391
|
+
identities,
|
|
3392
|
+
filesystem,
|
|
3393
|
+
undefined,
|
|
3394
|
+
{
|
|
3395
|
+
policy,
|
|
3396
|
+
},
|
|
3397
|
+
).hashes;
|
|
2967
3398
|
}
|
|
2968
3399
|
|
|
2969
3400
|
/** Hash project files and snapshot the directory topology in one walk. */
|
|
@@ -2975,6 +3406,16 @@ function collectProjectInputSnapshot(
|
|
|
2975
3406
|
hashes: Record<string, string>;
|
|
2976
3407
|
signatures: Record<string, string>;
|
|
2977
3408
|
},
|
|
3409
|
+
options?: {
|
|
3410
|
+
/**
|
|
3411
|
+
* Restrict hashing to these project keys. Supplied by a validating caller,
|
|
3412
|
+
* which compares over exactly this set, and omitted by a capturing one,
|
|
3413
|
+
* which has no generation to compare against yet.
|
|
3414
|
+
*/
|
|
3415
|
+
declaredKeys?: ReadonlySet<string>;
|
|
3416
|
+
/** What the resolved configuration admits into the program. */
|
|
3417
|
+
policy?: ITtscProjectMembershipPolicy;
|
|
3418
|
+
},
|
|
2978
3419
|
): {
|
|
2979
3420
|
complete: boolean;
|
|
2980
3421
|
directoryComplete: boolean;
|
|
@@ -2990,13 +3431,26 @@ function collectProjectInputSnapshot(
|
|
|
2990
3431
|
const provenSignatures: Record<string, string> = {};
|
|
2991
3432
|
const unstableFiles = new Set<string>();
|
|
2992
3433
|
let attributed = true;
|
|
2993
|
-
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
3434
|
+
const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
|
|
2994
3435
|
const walkFailures = [...walked.failures];
|
|
2995
3436
|
let complete = walked.complete;
|
|
2996
3437
|
for (const file of walked.files) {
|
|
2997
3438
|
try {
|
|
2998
|
-
const before = inputMetadataEvidence(file, filesystem);
|
|
2999
3439
|
const key = toProjectKey(projectRoot, file, identities);
|
|
3440
|
+
// A caller validating a generation compares hashes over that
|
|
3441
|
+
// generation's declared inputs alone (`sameHashes` takes the declared key
|
|
3442
|
+
// set), so reading anything else is work whose result is never consulted.
|
|
3443
|
+
// Skipping it is what keeps a directory full of emitted files from
|
|
3444
|
+
// costing a read per file on the pass that first sees them
|
|
3445
|
+
// (samchon/ttsc#1307). Capture passes supply no restriction and still
|
|
3446
|
+
// record the whole walk.
|
|
3447
|
+
if (
|
|
3448
|
+
options?.declaredKeys !== undefined &&
|
|
3449
|
+
!options.declaredKeys.has(key)
|
|
3450
|
+
) {
|
|
3451
|
+
continue;
|
|
3452
|
+
}
|
|
3453
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
3000
3454
|
// A file whose signature still equals the one captured around the read
|
|
3001
3455
|
// that produced the recorded hash carries that content, so the whole
|
|
3002
3456
|
// project does not have to be re-read to prove one delivery. A signature
|
|
@@ -3061,8 +3515,9 @@ function collectProjectInputSnapshot(
|
|
|
3061
3515
|
}
|
|
3062
3516
|
|
|
3063
3517
|
/**
|
|
3064
|
-
* Enumerate every regular file under `root`, skipping
|
|
3065
|
-
*
|
|
3518
|
+
* Enumerate every regular file under `root`, skipping the directories no
|
|
3519
|
+
* configuration can name ({@link isIgnoredProjectDirectory}) and the ones the
|
|
3520
|
+
* resolved configuration excludes ({@link isExcludedProjectDirectory}).
|
|
3066
3521
|
*
|
|
3067
3522
|
* Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
|
|
3068
3523
|
* unbounded call-stack depth on deep project trees. The result is sorted so
|
|
@@ -3071,6 +3526,7 @@ function collectProjectInputSnapshot(
|
|
|
3071
3526
|
function walkProjectInputs(
|
|
3072
3527
|
root: string,
|
|
3073
3528
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3529
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
3074
3530
|
): {
|
|
3075
3531
|
complete: boolean;
|
|
3076
3532
|
directories: TtscProjectDirectorySnapshot[];
|
|
@@ -3078,9 +3534,18 @@ function walkProjectInputs(
|
|
|
3078
3534
|
files: string[];
|
|
3079
3535
|
} {
|
|
3080
3536
|
let complete = true;
|
|
3081
|
-
const directories: TtscProjectDirectorySnapshot[] = [];
|
|
3082
3537
|
const failures: TtscProjectWalkFailure[] = [];
|
|
3083
3538
|
const files: string[] = [];
|
|
3539
|
+
// Collected in one pass, then digested in a second. A directory's digest has
|
|
3540
|
+
// to know whether each child directory can hold program inputs, and the walk
|
|
3541
|
+
// learns that only after descending, so the two cannot be one pass.
|
|
3542
|
+
const visited: {
|
|
3543
|
+
childDirectories: string[];
|
|
3544
|
+
entries: { name: string; kind: string; possible: boolean }[];
|
|
3545
|
+
ownInput: boolean;
|
|
3546
|
+
path: string;
|
|
3547
|
+
stable: string | undefined;
|
|
3548
|
+
}[] = [];
|
|
3084
3549
|
const stack = [root];
|
|
3085
3550
|
while (stack.length !== 0) {
|
|
3086
3551
|
const current = stack.pop()!;
|
|
@@ -3112,34 +3577,108 @@ function walkProjectInputs(
|
|
|
3112
3577
|
path: current,
|
|
3113
3578
|
});
|
|
3114
3579
|
}
|
|
3115
|
-
|
|
3580
|
+
const visit = {
|
|
3581
|
+
childDirectories: [] as string[],
|
|
3582
|
+
entries: [] as { name: string; kind: string; possible: boolean }[],
|
|
3583
|
+
ownInput: false,
|
|
3116
3584
|
path: current,
|
|
3117
3585
|
// If membership moved during enumeration, force the next delivery to
|
|
3118
3586
|
// replace this generation instead of blessing a torn directory/file
|
|
3119
3587
|
// snapshot as stable.
|
|
3120
|
-
|
|
3588
|
+
stable:
|
|
3121
3589
|
after !== undefined && before === after
|
|
3122
|
-
?
|
|
3590
|
+
? undefined
|
|
3123
3591
|
: `unstable:${before}:${after ?? "missing"}`,
|
|
3124
|
-
}
|
|
3592
|
+
};
|
|
3125
3593
|
for (const entry of entries) {
|
|
3126
3594
|
if (isIgnoredProjectDirectory(entry.name)) {
|
|
3127
3595
|
continue;
|
|
3128
3596
|
}
|
|
3129
3597
|
const file = path.join(current, entry.name);
|
|
3598
|
+
if (entry.isDirectory() && isExcludedProjectDirectory(file, policy)) {
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
const possible = isPossibleProgramEntry(entry, policy);
|
|
3602
|
+
visit.entries.push({
|
|
3603
|
+
kind: [
|
|
3604
|
+
entry.isDirectory(),
|
|
3605
|
+
entry.isFile(),
|
|
3606
|
+
entry.isSymbolicLink(),
|
|
3607
|
+
].join(":"),
|
|
3608
|
+
name: entry.name,
|
|
3609
|
+
possible,
|
|
3610
|
+
});
|
|
3130
3611
|
if (entry.isDirectory()) {
|
|
3612
|
+
visit.childDirectories.push(file);
|
|
3131
3613
|
stack.push(file);
|
|
3132
|
-
} else if (entry.isFile()) {
|
|
3614
|
+
} else if (entry.isFile() && possible) {
|
|
3615
|
+
// Only a file that could enter the program is hashed. A file that
|
|
3616
|
+
// could not is either irrelevant to every generation, or it is one the
|
|
3617
|
+
// compiler actually read, in which case the graph reports it and
|
|
3618
|
+
// `isProjectWalkPath` now agrees it is out of the walk, so it is
|
|
3619
|
+
// recorded and proven by the out-of-walk snapshot instead. Hashing an
|
|
3620
|
+
// emitted tree here bought nothing and cost a read per file, including
|
|
3621
|
+
// in `@ttsc/metro`, whose fingerprint re-keys every transformed file
|
|
3622
|
+
// (samchon/ttsc#1307).
|
|
3133
3623
|
files.push(file);
|
|
3624
|
+
visit.ownInput = true;
|
|
3134
3625
|
}
|
|
3135
3626
|
}
|
|
3627
|
+
visited.push(visit);
|
|
3136
3628
|
}
|
|
3629
|
+
|
|
3630
|
+
// A directory matters to program membership only if its subtree can hold a
|
|
3631
|
+
// program input. Propagate that up from the directories that hold one, so a
|
|
3632
|
+
// bundler creating `out/` and filling it with JavaScript a project admitting
|
|
3633
|
+
// none can never compile is not a membership change at any level: not in the
|
|
3634
|
+
// directory itself, and not in the parent that now lists it
|
|
3635
|
+
// (samchon/ttsc#1307).
|
|
3636
|
+
const byPath = new Map(visited.map((visit) => [visit.path, visit]));
|
|
3637
|
+
const relevant = new Set<string>();
|
|
3638
|
+
for (const visit of visited) {
|
|
3639
|
+
if (!visit.ownInput) {
|
|
3640
|
+
continue;
|
|
3641
|
+
}
|
|
3642
|
+
let current: string | undefined = visit.path;
|
|
3643
|
+
while (current !== undefined && !relevant.has(current)) {
|
|
3644
|
+
relevant.add(current);
|
|
3645
|
+
const parent = path.dirname(current);
|
|
3646
|
+
current = parent === current || !byPath.has(parent) ? undefined : parent;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
const directories: TtscProjectDirectorySnapshot[] = visited.map((visit) => {
|
|
3651
|
+
const membership = visit.entries
|
|
3652
|
+
.filter(
|
|
3653
|
+
(entry) =>
|
|
3654
|
+
entry.possible &&
|
|
3655
|
+
(!visit.childDirectories.includes(
|
|
3656
|
+
path.join(visit.path, entry.name),
|
|
3657
|
+
) ||
|
|
3658
|
+
relevant.has(path.join(visit.path, entry.name))),
|
|
3659
|
+
)
|
|
3660
|
+
.map((entry) => `${entry.name}:${entry.kind}`);
|
|
3661
|
+
return {
|
|
3662
|
+
path: visit.path,
|
|
3663
|
+
relevant: relevant.has(visit.path),
|
|
3664
|
+
signature:
|
|
3665
|
+
visit.stable ??
|
|
3666
|
+
hashText(membership.sort().join(String.fromCharCode(0))),
|
|
3667
|
+
};
|
|
3668
|
+
});
|
|
3137
3669
|
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
3138
3670
|
files.sort();
|
|
3139
3671
|
return { complete, directories, failures, files };
|
|
3140
3672
|
}
|
|
3141
3673
|
|
|
3142
|
-
/**
|
|
3674
|
+
/**
|
|
3675
|
+
* Return a directory's metadata stamp, used to detect that its membership moved
|
|
3676
|
+
* _while_ the walk was enumerating it, and to feed the observed-clock floor.
|
|
3677
|
+
*
|
|
3678
|
+
* This is the right instrument for that job and the wrong one for comparing two
|
|
3679
|
+
* generations: it moves for ignored entries too. {@link walkProjectInputs}
|
|
3680
|
+
* records the filtered membership digest for the comparison instead.
|
|
3681
|
+
*/
|
|
3143
3682
|
function projectDirectorySignature(
|
|
3144
3683
|
directory: string,
|
|
3145
3684
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
@@ -3170,14 +3709,31 @@ function sameProjectDirectories(
|
|
|
3170
3709
|
left: readonly TtscProjectDirectorySnapshot[],
|
|
3171
3710
|
right: readonly TtscProjectDirectorySnapshot[],
|
|
3172
3711
|
): boolean {
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
)
|
|
3712
|
+
// Compare only the directories that can hold program inputs, on either side.
|
|
3713
|
+
// A directory irrelevant on both is not part of the program's membership at
|
|
3714
|
+
// all, so its appearance, disappearance or churn says nothing: that is a
|
|
3715
|
+
// bundler's output tree. One that gained or lost relevance is present in the
|
|
3716
|
+
// comparison from the side where it counts, and so is caught.
|
|
3717
|
+
const select = (
|
|
3718
|
+
snapshots: readonly TtscProjectDirectorySnapshot[],
|
|
3719
|
+
): Map<string, TtscProjectDirectorySnapshot> =>
|
|
3720
|
+
new Map(
|
|
3721
|
+
snapshots
|
|
3722
|
+
.filter((directory) => directory.relevant)
|
|
3723
|
+
.map((directory) => [directory.path, directory]),
|
|
3724
|
+
);
|
|
3725
|
+
const leftRelevant = select(left);
|
|
3726
|
+
const rightRelevant = select(right);
|
|
3727
|
+
const paths = new Set([...leftRelevant.keys(), ...rightRelevant.keys()]);
|
|
3728
|
+
for (const location of paths) {
|
|
3729
|
+
if (
|
|
3730
|
+
leftRelevant.get(location)?.signature !==
|
|
3731
|
+
rightRelevant.get(location)?.signature
|
|
3732
|
+
) {
|
|
3733
|
+
return false;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
return true;
|
|
3181
3737
|
}
|
|
3182
3738
|
|
|
3183
3739
|
/**
|
|
@@ -3209,6 +3765,7 @@ function openDirectoryWatch(
|
|
|
3209
3765
|
async function createProjectMutationTracker(
|
|
3210
3766
|
directories: readonly TtscProjectDirectorySnapshot[],
|
|
3211
3767
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3768
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
3212
3769
|
): Promise<TtscProjectMutationTracker> {
|
|
3213
3770
|
const tracker: TtscProjectMutationTracker = {
|
|
3214
3771
|
changes: new Set(),
|
|
@@ -3223,6 +3780,13 @@ async function createProjectMutationTracker(
|
|
|
3223
3780
|
directories.map((directory) => ({ directory: directory.path })),
|
|
3224
3781
|
false,
|
|
3225
3782
|
filesystem,
|
|
3783
|
+
(location, filename) =>
|
|
3784
|
+
reportsProgramMembership(
|
|
3785
|
+
path.join(location, filename),
|
|
3786
|
+
filename,
|
|
3787
|
+
policy,
|
|
3788
|
+
filesystem,
|
|
3789
|
+
),
|
|
3226
3790
|
);
|
|
3227
3791
|
return tracker;
|
|
3228
3792
|
}
|
|
@@ -3238,14 +3802,26 @@ async function createProjectMutationTracker(
|
|
|
3238
3802
|
filesystem,
|
|
3239
3803
|
directory.path,
|
|
3240
3804
|
(eventType, filename) => {
|
|
3241
|
-
if (eventType
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3805
|
+
if (eventType !== "rename") {
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (
|
|
3809
|
+
filename !== null &&
|
|
3810
|
+
!reportsProgramMembership(
|
|
3811
|
+
path.join(directory.path, filename),
|
|
3812
|
+
filename,
|
|
3813
|
+
policy,
|
|
3814
|
+
filesystem,
|
|
3815
|
+
)
|
|
3816
|
+
) {
|
|
3817
|
+
return;
|
|
3248
3818
|
}
|
|
3819
|
+
recordProjectMutation(
|
|
3820
|
+
tracker,
|
|
3821
|
+
filename === null
|
|
3822
|
+
? directory.path
|
|
3823
|
+
: path.join(directory.path, filename),
|
|
3824
|
+
);
|
|
3249
3825
|
},
|
|
3250
3826
|
() => {
|
|
3251
3827
|
tracker.failed = true;
|
|
@@ -3361,6 +3937,90 @@ async function createHostInputMutationTracker(
|
|
|
3361
3937
|
return tracker;
|
|
3362
3938
|
}
|
|
3363
3939
|
|
|
3940
|
+
/**
|
|
3941
|
+
* Whether a path lies inside a directory the configuration excludes.
|
|
3942
|
+
*
|
|
3943
|
+
* Lexical, exactly like the walk and like {@link isProjectWalkPath}, and for the
|
|
3944
|
+
* reason that predicate states: walk membership is lexical, so resolving a path
|
|
3945
|
+
* to physical identity first would collapse two spellings the walk keeps apart
|
|
3946
|
+
* and claim it covered a subtree it never followed. A junction whose target the
|
|
3947
|
+
* walk hashes under its own name is exactly that, and canonicalizing here would
|
|
3948
|
+
* suppress every event in it.
|
|
3949
|
+
*
|
|
3950
|
+
* `strictly` excludes an exact match, for the case where the excluded entry
|
|
3951
|
+
* names a file rather than a directory: `exclude` accepts one, the walk applies
|
|
3952
|
+
* exclusion to directories alone, so that file is still hashed and its events
|
|
3953
|
+
* must keep counting.
|
|
3954
|
+
*/
|
|
3955
|
+
function insideExcludedProjectDirectory(
|
|
3956
|
+
location: string,
|
|
3957
|
+
policy: ITtscProjectMembershipPolicy,
|
|
3958
|
+
strictly: boolean,
|
|
3959
|
+
): boolean {
|
|
3960
|
+
if (policy.excludedDirectories.length === 0) {
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
const resolved = path.resolve(location);
|
|
3964
|
+
return policy.excludedDirectories.some((excluded) => {
|
|
3965
|
+
const target = path.resolve(excluded);
|
|
3966
|
+
if (strictly && target === resolved) {
|
|
3967
|
+
return false;
|
|
3968
|
+
}
|
|
3969
|
+
return pathIsWithin(resolved, target);
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/**
|
|
3974
|
+
* Whether one directory event can be a change to the program's membership.
|
|
3975
|
+
*
|
|
3976
|
+
* The live tracker has to answer the same question the membership digest does,
|
|
3977
|
+
* or the two disagree about the same project: a bundler writing content-hashed
|
|
3978
|
+
* output fires a rename per rebuild, and treating that as membership kept the
|
|
3979
|
+
* cost samchon/ttsc#1307 removes on every host that has no build boundary,
|
|
3980
|
+
* which is every host the narrow path exists for.
|
|
3981
|
+
*
|
|
3982
|
+
* A name that could be a program input counts, unless it sits under a directory
|
|
3983
|
+
* the walk never descends into. A name that could not still counts when the
|
|
3984
|
+
* path is now a directory, because the walk's watches were opened for the
|
|
3985
|
+
* directories that existed when the generation was captured, so a directory
|
|
3986
|
+
* created since is not watched and the sources that may appear in it would
|
|
3987
|
+
* otherwise be invisible. A directory the configuration excludes is the
|
|
3988
|
+
* exception: the walk cannot see inside it, so the tracker must not either, or
|
|
3989
|
+
* emptying and recreating an `outDir` costs a compile per build. An event whose
|
|
3990
|
+
* name the host did not report is unattributable and always counts.
|
|
3991
|
+
*/
|
|
3992
|
+
function reportsProgramMembership(
|
|
3993
|
+
location: string,
|
|
3994
|
+
filename: string,
|
|
3995
|
+
policy: ITtscProjectMembershipPolicy,
|
|
3996
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
3997
|
+
): boolean {
|
|
3998
|
+
if (isPossibleProgramFileName(filename, policy)) {
|
|
3999
|
+
// A name the program could admit. It still says nothing if it lies inside a
|
|
4000
|
+
// directory the walk never descends into, because the digest cannot see
|
|
4001
|
+
// there either and the tracker must not be the one side that reacts.
|
|
4002
|
+
return !insideExcludedProjectDirectory(location, policy, true);
|
|
4003
|
+
}
|
|
4004
|
+
let directory: boolean;
|
|
4005
|
+
try {
|
|
4006
|
+
directory = filesystem.lstat(location).isDirectory();
|
|
4007
|
+
} catch {
|
|
4008
|
+
// Gone again, or unreadable. Its name could not have been a program input,
|
|
4009
|
+
// and a directory removed under this one reports its own contents leaving
|
|
4010
|
+
// through the watch that was opened on it.
|
|
4011
|
+
return false;
|
|
4012
|
+
}
|
|
4013
|
+
if (!directory) {
|
|
4014
|
+
return false;
|
|
4015
|
+
}
|
|
4016
|
+
// A directory counts, because it can hold sources and the tracker is not
|
|
4017
|
+
// watching it yet, unless the configuration says the program does not contain
|
|
4018
|
+
// it. Emptying and recreating an `outDir`, which is what `emptyOutDir` and
|
|
4019
|
+
// `output.clean` do on every build, would otherwise void the generation once
|
|
4020
|
+
// per build on every host that has no build boundary.
|
|
4021
|
+
return !insideExcludedProjectDirectory(location, policy, false);
|
|
4022
|
+
}
|
|
4023
|
+
|
|
3364
4024
|
/** Record enough exact mutation evidence without retaining an event stream. */
|
|
3365
4025
|
function recordProjectMutation(
|
|
3366
4026
|
tracker: TtscProjectMutationTracker,
|
|
@@ -3387,7 +4047,24 @@ interface WindowsProjectMutationBroker {
|
|
|
3387
4047
|
trackers: Map<
|
|
3388
4048
|
number,
|
|
3389
4049
|
{
|
|
4050
|
+
/**
|
|
4051
|
+
* Whether one named event can be a program membership change. Present
|
|
4052
|
+
* only for the project-directory tracker, which watches whole directories
|
|
4053
|
+
* and so has to narrow what it hears; the trackers that watch exact names
|
|
4054
|
+
* have already narrowed theirs by construction.
|
|
4055
|
+
*/
|
|
4056
|
+
membership?: (location: string, filename: string) => boolean;
|
|
3390
4057
|
ready: () => void;
|
|
4058
|
+
/**
|
|
4059
|
+
* The walk's own spelling for each canonical directory the child watches,
|
|
4060
|
+
* so a reported event can be translated back before anything compares it
|
|
4061
|
+
* with a path the walk or the configuration produced.
|
|
4062
|
+
*
|
|
4063
|
+
* Required, not optional. A registration that forgot it would fall back
|
|
4064
|
+
* to the child's canonical spelling and silently reintroduce the mismatch
|
|
4065
|
+
* this map exists to remove, with no type error and no failing test.
|
|
4066
|
+
*/
|
|
4067
|
+
spellings: ReadonlyMap<string, string>;
|
|
3391
4068
|
tracker: TtscProjectMutationTracker;
|
|
3392
4069
|
}
|
|
3393
4070
|
>;
|
|
@@ -3412,8 +4089,21 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3412
4089
|
locations: readonly WindowsMutationLocation[],
|
|
3413
4090
|
allEvents: boolean,
|
|
3414
4091
|
filesystem: TtscTransformFilesystemOperations,
|
|
4092
|
+
/**
|
|
4093
|
+
* Optional filter for the project-directory tracker, whose events have to be
|
|
4094
|
+
* narrowed to program membership exactly as the in-process watcher's are. The
|
|
4095
|
+
* name-watching trackers pass none, since they already watch exact names.
|
|
4096
|
+
*/
|
|
4097
|
+
membership?: (location: string, filename: string) => boolean,
|
|
3415
4098
|
): Promise<void> {
|
|
3416
4099
|
const broker = getWindowsProjectMutationBroker();
|
|
4100
|
+
// The child watches canonical directories, and reports its events under that
|
|
4101
|
+
// spelling. Everything else in the adapter speaks the walk's own spelling,
|
|
4102
|
+
// which on Windows can be an 8.3 short form of the same directory, so keep
|
|
4103
|
+
// the way back: a filter that compared the child's spelling against the
|
|
4104
|
+
// configuration's would be comparing two names for one directory that share
|
|
4105
|
+
// no common prefix (samchon/ttsc#1307).
|
|
4106
|
+
const spellings = new Map<string, string>();
|
|
3417
4107
|
const normalized = locations.map((location) => {
|
|
3418
4108
|
let directory: string;
|
|
3419
4109
|
try {
|
|
@@ -3421,6 +4111,7 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3421
4111
|
} catch {
|
|
3422
4112
|
directory = path.resolve(location.directory);
|
|
3423
4113
|
}
|
|
4114
|
+
spellings.set(directory, location.directory);
|
|
3424
4115
|
return {
|
|
3425
4116
|
directory,
|
|
3426
4117
|
...(location.names === undefined ? {} : { names: location.names }),
|
|
@@ -3434,7 +4125,12 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3434
4125
|
const ready = new Promise<void>((resolve) => {
|
|
3435
4126
|
resolveReady = resolve;
|
|
3436
4127
|
});
|
|
3437
|
-
broker.trackers.set(id, {
|
|
4128
|
+
broker.trackers.set(id, {
|
|
4129
|
+
membership,
|
|
4130
|
+
ready: resolveReady,
|
|
4131
|
+
spellings,
|
|
4132
|
+
tracker,
|
|
4133
|
+
});
|
|
3438
4134
|
tracker.drain = () => drainWindowsProjectMutationBroker(broker);
|
|
3439
4135
|
tracker.close = () => {
|
|
3440
4136
|
const active = broker.trackers.get(id);
|
|
@@ -3529,11 +4225,22 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
3529
4225
|
if (record.ready === true) registration.ready();
|
|
3530
4226
|
if (record.ready !== true && record.failed !== true) {
|
|
3531
4227
|
if (typeof record.directory === "string") {
|
|
4228
|
+
// The walk's spelling for this directory, which is what every
|
|
4229
|
+
// comparison and every recorded witness downstream expects.
|
|
4230
|
+
const reported =
|
|
4231
|
+
registration.spellings.get(record.directory) ?? record.directory;
|
|
4232
|
+
if (
|
|
4233
|
+
typeof record.filename === "string" &&
|
|
4234
|
+
registration.membership !== undefined &&
|
|
4235
|
+
!registration.membership(reported, record.filename)
|
|
4236
|
+
) {
|
|
4237
|
+
return;
|
|
4238
|
+
}
|
|
3532
4239
|
recordProjectMutation(
|
|
3533
4240
|
registration.tracker,
|
|
3534
4241
|
typeof record.filename === "string"
|
|
3535
|
-
? path.join(
|
|
3536
|
-
:
|
|
4242
|
+
? path.join(reported, record.filename)
|
|
4243
|
+
: reported,
|
|
3537
4244
|
);
|
|
3538
4245
|
} else {
|
|
3539
4246
|
registration.tracker.membershipChanged = true;
|
|
@@ -3741,6 +4448,7 @@ export function isProjectWalkPath(
|
|
|
3741
4448
|
file: string,
|
|
3742
4449
|
_identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
3743
4450
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
4451
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
3744
4452
|
): boolean {
|
|
3745
4453
|
// Walk membership is lexical. Resolving `file` to physical identity first
|
|
3746
4454
|
// would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
|
|
@@ -3757,7 +4465,20 @@ export function isProjectWalkPath(
|
|
|
3757
4465
|
return false;
|
|
3758
4466
|
}
|
|
3759
4467
|
const segments = relative.split(path.sep);
|
|
3760
|
-
|
|
4468
|
+
// The last segment is the file itself, which the walk names rather than
|
|
4469
|
+
// descends into, so only the directory components decide walk membership.
|
|
4470
|
+
if (segments.slice(0, -1).some(isIgnoredProjectDirectory)) {
|
|
4471
|
+
return false;
|
|
4472
|
+
}
|
|
4473
|
+
if (isExcludedProjectDirectory(path.dirname(path.resolve(file)), policy)) {
|
|
4474
|
+
return false;
|
|
4475
|
+
}
|
|
4476
|
+
// The walk hashes only files that could enter the program, so a path it does
|
|
4477
|
+
// not hash is out of the walk by definition. Answering otherwise would leave
|
|
4478
|
+
// a graph input the compiler really read in neither snapshot: absent from
|
|
4479
|
+
// `inputHashes` because the walk skipped it, and absent from the out-of-walk
|
|
4480
|
+
// snapshot because this predicate claimed the walk covered it.
|
|
4481
|
+
if (!isPossibleProgramFileName(path.basename(file), policy)) {
|
|
3761
4482
|
return false;
|
|
3762
4483
|
}
|
|
3763
4484
|
let current = resolvedRoot;
|
|
@@ -3889,6 +4610,7 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
|
|
|
3889
4610
|
*/
|
|
3890
4611
|
function selectExternalInputPaths(props: {
|
|
3891
4612
|
filesystem?: TtscTransformFilesystemOperations;
|
|
4613
|
+
membershipPolicy: ITtscProjectMembershipPolicy;
|
|
3892
4614
|
projectRoot: string;
|
|
3893
4615
|
result: ITtscCompilerTransformation;
|
|
3894
4616
|
scratchDirectory?: string;
|
|
@@ -3970,7 +4692,13 @@ function selectExternalInputPaths(props: {
|
|
|
3970
4692
|
isTransformScratchInput(absolute, props.scratchDirectory) ||
|
|
3971
4693
|
seen.has(spelling) ||
|
|
3972
4694
|
(!missingCandidate &&
|
|
3973
|
-
isProjectWalkPath(
|
|
4695
|
+
isProjectWalkPath(
|
|
4696
|
+
props.projectRoot,
|
|
4697
|
+
absolute,
|
|
4698
|
+
identities,
|
|
4699
|
+
filesystem,
|
|
4700
|
+
props.membershipPolicy,
|
|
4701
|
+
))
|
|
3974
4702
|
) {
|
|
3975
4703
|
continue;
|
|
3976
4704
|
}
|
|
@@ -4155,22 +4883,57 @@ function insideProject(directory: string, projectRoot: string): boolean {
|
|
|
4155
4883
|
const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
|
|
4156
4884
|
|
|
4157
4885
|
function isIgnoredProjectDirectory(name: string): boolean {
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4886
|
+
// The residue of what used to be a fifteen-name list, kept to the three
|
|
4887
|
+
// directories no tsconfig can name and no program can contain: the VCS
|
|
4888
|
+
// store, the package manager's tree (TypeScript's own default `exclude`
|
|
4889
|
+
// carries it too), and ttsc's own plugin cache. Everything else the old list
|
|
4890
|
+
// guessed at, and guessing was wrong in both directions: a bundler writing
|
|
4891
|
+
// to an unnamed directory changed project membership with its own output,
|
|
4892
|
+
// while a real source directory named `build` or `temp` was dropped from the
|
|
4893
|
+
// walk and its new files were never seen (samchon/ttsc#1307). Those are now
|
|
4894
|
+
// decided by `ITtscProjectMembershipPolicy`, which reads the configuration
|
|
4895
|
+
// that actually knows.
|
|
4896
|
+
return name === ".git" || name === ".ttsc" || name === "node_modules";
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
/**
|
|
4900
|
+
* Whether the resolved configuration keeps this directory out of the program.
|
|
4901
|
+
*
|
|
4902
|
+
* Compared by physical containment rather than by name, so `outDir: "./dist"`
|
|
4903
|
+
* excludes that one directory instead of every directory called `dist` at every
|
|
4904
|
+
* depth, which is the distinction the name list could not draw.
|
|
4905
|
+
*/
|
|
4906
|
+
function isExcludedProjectDirectory(
|
|
4907
|
+
directory: string,
|
|
4908
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4909
|
+
): boolean {
|
|
4910
|
+
return insideExcludedProjectDirectory(directory, policy, false);
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
/**
|
|
4914
|
+
* Whether this entry could enter the program, and so whether its appearance or
|
|
4915
|
+
* removal is a membership change.
|
|
4916
|
+
*
|
|
4917
|
+
* A directory always could, since it can hold sources. A file could only if it
|
|
4918
|
+
* carries an extension the resolved configuration admits, which is what makes a
|
|
4919
|
+
* bundle emitted beside the sources invisible to a project that compiles no
|
|
4920
|
+
* JavaScript.
|
|
4921
|
+
*/
|
|
4922
|
+
function isPossibleProgramEntry(
|
|
4923
|
+
entry: fs.Dirent,
|
|
4924
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4925
|
+
): boolean {
|
|
4926
|
+
return entry.isFile() ? isPossibleProgramFileName(entry.name, policy) : true;
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
/** The same question for a bare file name, for callers holding no `Dirent`. */
|
|
4930
|
+
function isPossibleProgramFileName(
|
|
4931
|
+
name: string,
|
|
4932
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4933
|
+
): boolean {
|
|
4934
|
+
const lowered = name.toLowerCase();
|
|
4935
|
+
return policy.inputExtensions.some((extension) =>
|
|
4936
|
+
lowered.endsWith(extension),
|
|
4174
4937
|
);
|
|
4175
4938
|
}
|
|
4176
4939
|
|
|
@@ -4626,6 +5389,8 @@ function failedGenerationEnvironmentChanged(
|
|
|
4626
5389
|
validation.cached.projectRoot,
|
|
4627
5390
|
identities,
|
|
4628
5391
|
props.filesystem,
|
|
5392
|
+
undefined,
|
|
5393
|
+
{ policy: validation.cached.membershipPolicy },
|
|
4629
5394
|
);
|
|
4630
5395
|
if (
|
|
4631
5396
|
validation.projectWalkComplete !==
|
|
@@ -4725,6 +5490,11 @@ async function transformProject(props: {
|
|
|
4725
5490
|
compilerOptions: Record<string, unknown>;
|
|
4726
5491
|
currentFile: string;
|
|
4727
5492
|
currentSource: string;
|
|
5493
|
+
/**
|
|
5494
|
+
* Delivery pass this compile was started for; see
|
|
5495
|
+
* {@link TtscCachedProjectTransform.deliveryEpoch}.
|
|
5496
|
+
*/
|
|
5497
|
+
deliveryEpoch?: number;
|
|
4728
5498
|
filesystem: TtscTransformFilesystemOperations;
|
|
4729
5499
|
plugins?: ResolvedTtscUnpluginOptions["plugins"];
|
|
4730
5500
|
trackProjectMembership: boolean;
|
|
@@ -4771,6 +5541,7 @@ async function captureTransformGeneration(props: {
|
|
|
4771
5541
|
compilerOptions: Record<string, unknown>;
|
|
4772
5542
|
currentFile: string;
|
|
4773
5543
|
currentSource: string;
|
|
5544
|
+
deliveryEpoch?: number;
|
|
4774
5545
|
filesystem: TtscTransformFilesystemOperations;
|
|
4775
5546
|
plugins?: ResolvedTtscUnpluginOptions["plugins"];
|
|
4776
5547
|
trackProjectMembership: boolean;
|
|
@@ -4792,15 +5563,27 @@ async function captureTransformGeneration(props: {
|
|
|
4792
5563
|
const temporaryTsconfig =
|
|
4793
5564
|
configured.path === props.tsconfig ? undefined : configured.path;
|
|
4794
5565
|
const identities = createHostPathIdentityContext(props.filesystem);
|
|
5566
|
+
// Read from the project's own tsconfig rather than the generated one: a
|
|
5567
|
+
// relative `outDir` is anchored at the config that declares it, and the
|
|
5568
|
+
// generated config lives in a system temp directory. The caller's
|
|
5569
|
+
// compiler-options overlay still wins, since it wins for the compile too.
|
|
5570
|
+
const membershipPolicy = mergeMembershipPolicyOverlay(
|
|
5571
|
+
readProjectMembershipPolicy(props.tsconfig),
|
|
5572
|
+
props.compilerOptions,
|
|
5573
|
+
projectRoot,
|
|
5574
|
+
);
|
|
4795
5575
|
const before = collectProjectInputSnapshot(
|
|
4796
5576
|
projectRoot,
|
|
4797
5577
|
identities,
|
|
4798
5578
|
props.filesystem,
|
|
5579
|
+
undefined,
|
|
5580
|
+
{ policy: membershipPolicy },
|
|
4799
5581
|
);
|
|
4800
5582
|
tracker = props.trackProjectMembership
|
|
4801
5583
|
? await createProjectMutationTracker(
|
|
4802
5584
|
before.projectDirectories,
|
|
4803
5585
|
props.filesystem,
|
|
5586
|
+
membershipPolicy,
|
|
4804
5587
|
)
|
|
4805
5588
|
: undefined;
|
|
4806
5589
|
const result = withTransformScratchEnvironment(scratchDirectory, () =>
|
|
@@ -4876,6 +5659,7 @@ async function captureTransformGeneration(props: {
|
|
|
4876
5659
|
: undefined;
|
|
4877
5660
|
const externalInputPaths = selectExternalInputPaths({
|
|
4878
5661
|
filesystem: props.filesystem,
|
|
5662
|
+
membershipPolicy,
|
|
4879
5663
|
projectRoot,
|
|
4880
5664
|
result,
|
|
4881
5665
|
scratchDirectory,
|
|
@@ -4885,6 +5669,8 @@ async function captureTransformGeneration(props: {
|
|
|
4885
5669
|
projectRoot,
|
|
4886
5670
|
identities,
|
|
4887
5671
|
props.filesystem,
|
|
5672
|
+
undefined,
|
|
5673
|
+
{ policy: membershipPolicy },
|
|
4888
5674
|
);
|
|
4889
5675
|
// Whether the recorded snapshot describes one coherent state of the
|
|
4890
5676
|
// project. A membership event during the compile taints it exactly like an
|
|
@@ -4935,6 +5721,12 @@ async function captureTransformGeneration(props: {
|
|
|
4935
5721
|
delete inputSnapshot.provenSignatures[currentFileKey];
|
|
4936
5722
|
}
|
|
4937
5723
|
const cached: TtscCachedProjectTransform = {
|
|
5724
|
+
// The pass this compile was started for. Its snapshot describes the
|
|
5725
|
+
// project as of this compile, so it is settled for this pass and any
|
|
5726
|
+
// later pass must re-prove it.
|
|
5727
|
+
...(props.deliveryEpoch === undefined
|
|
5728
|
+
? {}
|
|
5729
|
+
: { deliveryEpoch: props.deliveryEpoch }),
|
|
4938
5730
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
4939
5731
|
// cache validation can re-check them; computed before dispose so the
|
|
4940
5732
|
// scratch-tree exclusion is the only reason its disposed artifacts never
|
|
@@ -4944,7 +5736,9 @@ async function captureTransformGeneration(props: {
|
|
|
4944
5736
|
externalInputPaths,
|
|
4945
5737
|
inputHashes: inputSnapshot.hashes,
|
|
4946
5738
|
inputSignatures: inputSnapshot.provenSignatures,
|
|
5739
|
+
membershipPolicy,
|
|
4947
5740
|
projectDirectories: inputSnapshot.projectDirectories,
|
|
5741
|
+
tsconfig: props.tsconfig,
|
|
4948
5742
|
projectSnapshotComplete: false,
|
|
4949
5743
|
projectRoot,
|
|
4950
5744
|
result,
|
|
@@ -5388,10 +6182,40 @@ function readPaths(value: unknown): Record<string, string[]> {
|
|
|
5388
6182
|
function createAliasPaths(aliases: unknown): Record<string, string[]> {
|
|
5389
6183
|
const paths: Record<string, string[]> = {};
|
|
5390
6184
|
for (const alias of normalizeAliases(aliases)) {
|
|
5391
|
-
if (typeof alias.find !== "string"
|
|
6185
|
+
if (typeof alias.find !== "string") {
|
|
6186
|
+
// Vite's array form accepts a `RegExp` find, and `{ find: /^~/ }` is a
|
|
6187
|
+
// common way to spell a prefix alias. A tsconfig `paths` map has no
|
|
6188
|
+
// regular-expression form, so there is nothing to translate it into
|
|
6189
|
+
// (samchon/ttsc#1315). Reducing the simple prefix cases to a string is
|
|
6190
|
+
// possible in principle and deliberately not done: telling `/^~/` from
|
|
6191
|
+
// `/^~(?=\/)/` or `/^@app/` — which matches `@apple` too — means
|
|
6192
|
+
// implementing enough of a regular-expression engine that a wrong
|
|
6193
|
+
// reduction becomes likely, and a mistranslated alias resolves imports to
|
|
6194
|
+
// the wrong file silently, which is worse than not forwarding it.
|
|
6195
|
+
//
|
|
6196
|
+
// Not reported, unlike the wildcard below, and that asymmetry is the
|
|
6197
|
+
// whole point: Vite merges two `RegExp` aliases of its own into every
|
|
6198
|
+
// resolved config, `/^\/?@vite\/env/` and `/^\/?@vite\/client/`. Measured
|
|
6199
|
+
// on a bare project with no user aliases at all, `resolve.alias` has
|
|
6200
|
+
// exactly those two entries under both `serve` and `build`, so a report
|
|
6201
|
+
// on this form would fire twice for every Vite user in every build, name
|
|
6202
|
+
// aliases they never wrote, and say nothing about their configuration.
|
|
6203
|
+
// A diagnostic that cannot distinguish the user's input from the host's
|
|
6204
|
+
// is noise, and noise is what teaches people to stop reading the channel
|
|
6205
|
+
// the out-of-program report depends on. The documentation carries this
|
|
6206
|
+
// form instead, in both README and guide.
|
|
6207
|
+
continue;
|
|
6208
|
+
}
|
|
6209
|
+
if (alias.find.length === 0) {
|
|
5392
6210
|
continue;
|
|
5393
6211
|
}
|
|
5394
6212
|
if (alias.find.includes("*")) {
|
|
6213
|
+
// A `paths` key reads `*` as its own wildcard, so forwarding a `find`
|
|
6214
|
+
// that already contains one cannot preserve the caller's meaning.
|
|
6215
|
+
reportUntranslatableAlias(
|
|
6216
|
+
JSON.stringify(alias.find),
|
|
6217
|
+
'a "paths" key already reads "*" as its own wildcard',
|
|
6218
|
+
);
|
|
5395
6219
|
continue;
|
|
5396
6220
|
}
|
|
5397
6221
|
const key = alias.find.replace(/\/+$/, "");
|
|
@@ -5409,9 +6233,54 @@ function createAliasPaths(aliases: unknown): Record<string, string[]> {
|
|
|
5409
6233
|
return paths;
|
|
5410
6234
|
}
|
|
5411
6235
|
|
|
5412
|
-
|
|
6236
|
+
/**
|
|
6237
|
+
* Alias descriptions already reported in this process.
|
|
6238
|
+
*
|
|
6239
|
+
* The message is about configuration rather than about a module:
|
|
6240
|
+
* `resolve.alias` is resolved once and then consulted on every delivery, so
|
|
6241
|
+
* reporting per delivery would repeat one statement about the config for every
|
|
6242
|
+
* file in the bundle. Keyed by the description, so a Vite dev server that
|
|
6243
|
+
* reloads its config reports again only when the alias itself changed.
|
|
6244
|
+
*/
|
|
6245
|
+
const REPORTED_UNTRANSLATABLE_ALIASES = new Set<string>();
|
|
6246
|
+
|
|
6247
|
+
/**
|
|
6248
|
+
* Tell the user once that an alias they declared is not reaching the compile.
|
|
6249
|
+
*
|
|
6250
|
+
* A dropped alias is not silent in its consequence — the compile resolves
|
|
6251
|
+
* through the tsconfig's own `paths`, and a module that resolves for the
|
|
6252
|
+
* bundler but not for the compiler surfaces as the out-of-program report
|
|
6253
|
+
* (samchon/ttsc#1308) — but that report names the module, not the alias, so the
|
|
6254
|
+
* user cannot learn from it that a configuration they wrote was ignored.
|
|
6255
|
+
*
|
|
6256
|
+
* Only the wildcard form reaches here. Every entry it names was written by the
|
|
6257
|
+
* user, because nothing injects one; the `RegExp` form is left to the
|
|
6258
|
+
* documentation precisely because Vite does inject those, and
|
|
6259
|
+
* {@link createAliasPaths} carries that measurement.
|
|
6260
|
+
*/
|
|
6261
|
+
function reportUntranslatableAlias(description: string, reason: string): void {
|
|
6262
|
+
if (REPORTED_UNTRANSLATABLE_ALIASES.has(description)) {
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
REPORTED_UNTRANSLATABLE_ALIASES.add(description);
|
|
6266
|
+
process.stderr.write(
|
|
6267
|
+
`ttsc: the Vite alias ${description} was not forwarded to the compile, because ${reason}. Declare it in your tsconfig's "paths" if ttsc must resolve through it.\n`,
|
|
6268
|
+
);
|
|
6269
|
+
}
|
|
6270
|
+
|
|
6271
|
+
/**
|
|
6272
|
+
* Collect the host's declared aliases without deciding which of them can be
|
|
6273
|
+
* expressed as `paths`.
|
|
6274
|
+
*
|
|
6275
|
+
* That decision belongs to {@link createAliasPaths} alone. It used to be split:
|
|
6276
|
+
* this function's type guard required a string `find` and dropped Vite's
|
|
6277
|
+
* `RegExp` form before `createAliasPaths` ever saw it, which left
|
|
6278
|
+
* `createAliasPaths`'s own non-string branch unreachable and put the drop
|
|
6279
|
+
* somewhere nothing could report it (samchon/ttsc#1315).
|
|
6280
|
+
*/
|
|
6281
|
+
function normalizeAliases(aliases: unknown): TtscDeclaredAlias[] {
|
|
5413
6282
|
if (Array.isArray(aliases)) {
|
|
5414
|
-
return aliases.filter(
|
|
6283
|
+
return aliases.filter(isDeclaredAlias);
|
|
5415
6284
|
}
|
|
5416
6285
|
if (typeof aliases === "object" && aliases !== null) {
|
|
5417
6286
|
return Object.entries(aliases)
|
|
@@ -5469,13 +6338,12 @@ function isRelativeSpecifier(value: string): boolean {
|
|
|
5469
6338
|
);
|
|
5470
6339
|
}
|
|
5471
6340
|
|
|
5472
|
-
function
|
|
6341
|
+
function isDeclaredAlias(value: unknown): value is TtscDeclaredAlias {
|
|
5473
6342
|
return (
|
|
5474
6343
|
typeof value === "object" &&
|
|
5475
6344
|
value !== null &&
|
|
5476
6345
|
"find" in value &&
|
|
5477
6346
|
"replacement" in value &&
|
|
5478
|
-
typeof value.find === "string" &&
|
|
5479
6347
|
typeof value.replacement === "string"
|
|
5480
6348
|
);
|
|
5481
6349
|
}
|
|
@@ -5493,6 +6361,7 @@ function selectTransformedSource(props: {
|
|
|
5493
6361
|
file: string;
|
|
5494
6362
|
projectRoot: string;
|
|
5495
6363
|
result: ITtscCompilerTransformation;
|
|
6364
|
+
tsconfig: string;
|
|
5496
6365
|
}): string {
|
|
5497
6366
|
if (props.result.type === "exception") {
|
|
5498
6367
|
throw new Error(formatUnknownError(props.result.error));
|
|
@@ -5523,20 +6392,68 @@ function selectTransformedSource(props: {
|
|
|
5523
6392
|
if (source !== undefined) {
|
|
5524
6393
|
return source;
|
|
5525
6394
|
}
|
|
5526
|
-
throw new
|
|
6395
|
+
throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
|
|
5527
6396
|
}
|
|
5528
6397
|
|
|
5529
6398
|
/**
|
|
5530
|
-
*
|
|
6399
|
+
* Tell the user once that a module was left untransformed, and why.
|
|
6400
|
+
*
|
|
6401
|
+
* The condition is ordinary and the build continues, but it must never be
|
|
6402
|
+
* silent: a file the program does not contain keeps whatever plugin syntax it
|
|
6403
|
+
* carries, so a typia `assert<T>()` in it becomes a runtime failure rather than
|
|
6404
|
+
* a build failure. One line per file per generation per pass, on the channel
|
|
6405
|
+
* the generation's other non-fatal diagnostics already use, so a bundle that
|
|
6406
|
+
* reaches many such files does not repeat itself per delivery.
|
|
6407
|
+
*/
|
|
6408
|
+
function reportMissingProgramOutput(
|
|
6409
|
+
cached: TtscCachedProjectTransform,
|
|
6410
|
+
error: TtscMissingProgramOutputError,
|
|
6411
|
+
epoch: number | undefined,
|
|
6412
|
+
): void {
|
|
6413
|
+
const reported = (cached.missingOutputReported ??= new Set<string>());
|
|
6414
|
+
if (cached.missingOutputEpoch !== epoch) {
|
|
6415
|
+
cached.missingOutputEpoch = epoch;
|
|
6416
|
+
reported.clear();
|
|
6417
|
+
}
|
|
6418
|
+
if (reported.has(error.file)) {
|
|
6419
|
+
return;
|
|
6420
|
+
}
|
|
6421
|
+
reported.add(error.file);
|
|
6422
|
+
process.stderr.write(`${error.message}
|
|
6423
|
+
`);
|
|
6424
|
+
}
|
|
6425
|
+
|
|
6426
|
+
/**
|
|
6427
|
+
* Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
|
|
5531
6428
|
*
|
|
5532
6429
|
* A `success` result may still carry warnings or informational messages from
|
|
5533
|
-
* plugins
|
|
5534
|
-
*
|
|
6430
|
+
* plugins — `@ttsc/lint` reports every rule below error severity this way.
|
|
6431
|
+
* These are surfaced via stderr rather than throwing so the build continues.
|
|
6432
|
+
* Failures and exceptions are handled by the caller.
|
|
6433
|
+
*
|
|
6434
|
+
* They describe one compile of one program, so writing them per delivery
|
|
6435
|
+
* printed the same warning once per module and scaled the noise with exactly
|
|
6436
|
+
* the reuse the cache exists to provide (samchon/ttsc#1304). A pass that reuses
|
|
6437
|
+
* a retained generation still surfaces them once, because a build's warnings
|
|
6438
|
+
* are part of what that build reports; a host with no pass boundary surfaces
|
|
6439
|
+
* them once per generation, which is the same rule with one pass.
|
|
5535
6440
|
*/
|
|
5536
|
-
function reportSuccessDiagnostics(
|
|
6441
|
+
function reportSuccessDiagnostics(
|
|
6442
|
+
cached: TtscCachedProjectTransform,
|
|
6443
|
+
epoch: number | undefined,
|
|
6444
|
+
): void {
|
|
6445
|
+
const result = cached.result;
|
|
5537
6446
|
if (result.type !== "success" || result.diagnostics === undefined) {
|
|
5538
6447
|
return;
|
|
5539
6448
|
}
|
|
6449
|
+
if (
|
|
6450
|
+
cached.diagnosticsReported === true &&
|
|
6451
|
+
cached.diagnosticsEpoch === epoch
|
|
6452
|
+
) {
|
|
6453
|
+
return;
|
|
6454
|
+
}
|
|
6455
|
+
cached.diagnosticsReported = true;
|
|
6456
|
+
cached.diagnosticsEpoch = epoch;
|
|
5540
6457
|
const text = formatDiagnostics(result.diagnostics);
|
|
5541
6458
|
if (text.length !== 0) {
|
|
5542
6459
|
process.stderr.write(`${text}\n`);
|
|
@@ -5562,7 +6479,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
|
|
|
5562
6479
|
diag.line === undefined
|
|
5563
6480
|
? undefined
|
|
5564
6481
|
: `${diag.line}:${diag.character ?? 1}`,
|
|
5565
|
-
diag.messageText,
|
|
6482
|
+
stripTerminalEscapes(diag.messageText),
|
|
5566
6483
|
]
|
|
5567
6484
|
.filter((part) => part !== undefined && part !== "")
|
|
5568
6485
|
.join(": "),
|
|
@@ -5572,7 +6489,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
|
|
|
5572
6489
|
|
|
5573
6490
|
function formatUnknownError(error: unknown): string {
|
|
5574
6491
|
if (error instanceof Error) {
|
|
5575
|
-
return error.message;
|
|
6492
|
+
return stripTerminalEscapes(error.message);
|
|
5576
6493
|
}
|
|
5577
6494
|
if (
|
|
5578
6495
|
typeof error === "object" &&
|
|
@@ -5580,9 +6497,33 @@ function formatUnknownError(error: unknown): string {
|
|
|
5580
6497
|
"message" in error &&
|
|
5581
6498
|
typeof error.message === "string"
|
|
5582
6499
|
) {
|
|
5583
|
-
return error.message;
|
|
6500
|
+
return stripTerminalEscapes(error.message);
|
|
5584
6501
|
}
|
|
5585
|
-
return String(error);
|
|
6502
|
+
return stripTerminalEscapes(String(error));
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
/**
|
|
6506
|
+
* Remove terminal colour and cursor sequences from text the adapter surfaces.
|
|
6507
|
+
*
|
|
6508
|
+
* An ordinary type error reaches the adapter as an `"exception"` envelope whose
|
|
6509
|
+
* `error` is the host's own rendered output, colour and all, and the envelope
|
|
6510
|
+
* carries no structured diagnostics to format instead. What the adapter hands
|
|
6511
|
+
* back is not going to a terminal: it becomes the `Error` a bundler reports, so
|
|
6512
|
+
* it lands in a Vite overlay, a webpack error report or a CI annotation, where
|
|
6513
|
+
* the escapes render as literal noise around the file and line the reader needs
|
|
6514
|
+
* (samchon/ttsc#1312).
|
|
6515
|
+
*
|
|
6516
|
+
* The colour originates in the host's rendering rather than in anything this
|
|
6517
|
+
* adapter configures, so this is the adapter-side repair, applied to every
|
|
6518
|
+
* message it surfaces rather than to one call site.
|
|
6519
|
+
*/
|
|
6520
|
+
function stripTerminalEscapes(text: string): string {
|
|
6521
|
+
// Built from a char code so no control byte lives in this source file, and
|
|
6522
|
+
// written with `[[]` (a class holding one literal bracket) so the pattern
|
|
6523
|
+
// needs no backslash escapes to survive the string it is assembled from.
|
|
6524
|
+
const escape = String.fromCharCode(27);
|
|
6525
|
+
const controlSequence = new RegExp(escape + "[[][0-9;?]*[ -/]*[@-~]", "g");
|
|
6526
|
+
return text.replace(controlSequence, "");
|
|
5586
6527
|
}
|
|
5587
6528
|
|
|
5588
6529
|
/**
|