@ttsc/unplugin 0.28.2 → 0.28.4
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 +71 -7
- 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 +131 -18
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +130 -19
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.cts +116 -29
- package/lib/core/transform.d.mts +116 -29
- package/lib/core/transform.d.ts +116 -29
- package/lib/core/transform.js +1502 -222
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +1503 -223
- 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 +136 -18
- package/src/core/transform.ts +2070 -254
- package/src/core/tsconfigPaths.ts +254 -8
- package/src/next.ts +262 -10
- package/src/turbopack.ts +13 -9
package/lib/core/transform.mjs
CHANGED
|
@@ -5,8 +5,117 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { TtscCompiler } from 'ttsc';
|
|
7
7
|
import { createFilesystemPathIdentityContext } from 'ttsc/path-identity';
|
|
8
|
-
import { absolutizePathsTarget, readEffectiveTsconfigPaths } from './tsconfigPaths.mjs';
|
|
8
|
+
import { mergeMembershipPolicyOverlay, readProjectMembershipPolicy, PERMISSIVE_PROJECT_MEMBERSHIP_POLICY, absolutizePathsTarget, readEffectiveTsconfigPaths } from './tsconfigPaths.mjs';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A verdict about one generation that later deliveries replay instead of
|
|
12
|
+
* repeating the whole compile behind it.
|
|
13
|
+
*
|
|
14
|
+
* The two kinds are replayed on different evidence, and each carries its own: a
|
|
15
|
+
* pass verdict knows the pass it belongs to, and an unstable generation knows
|
|
16
|
+
* the recorded environment it was proven against.
|
|
17
|
+
*/
|
|
18
|
+
class TtscTerminalGenerationError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The compile succeeded and produced no output for one requested module,
|
|
22
|
+
* because the program does not contain it.
|
|
23
|
+
*
|
|
24
|
+
* Not a terminal generation error, and deliberately not a build failure. It is
|
|
25
|
+
* a fact about one file, and the answer to it is to leave that file to the host
|
|
26
|
+
* (samchon/ttsc#1308). It is a distinct type rather than a message match so the
|
|
27
|
+
* decision travels as a type: `@ttsc/metro` used to recognise this case by
|
|
28
|
+
* searching the message text for "did not return output", which is how one
|
|
29
|
+
* product came to hold two different answers to one condition.
|
|
30
|
+
*/
|
|
31
|
+
class TtscMissingProgramOutputError extends Error {
|
|
32
|
+
/** The module the bundler asked for. */
|
|
33
|
+
file;
|
|
34
|
+
/** The project config whose program does not contain it. */
|
|
35
|
+
tsconfig;
|
|
36
|
+
constructor(file, tsconfig) {
|
|
37
|
+
super(`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.`);
|
|
38
|
+
this.name = "TtscMissingProgramOutputError";
|
|
39
|
+
this.file = file;
|
|
40
|
+
this.tsconfig = tsconfig;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A bounded proof failure that stays authoritative until its inputs change.
|
|
45
|
+
*
|
|
46
|
+
* This is the adapter failing to _obtain_ a coherent snapshot — a race it lost
|
|
47
|
+
* — so a later attempt may well succeed with the same inputs. It is retried
|
|
48
|
+
* when its recorded environment moves, and a new delivery epoch grants it the
|
|
49
|
+
* one fresh attempt the per-pass cache clear used to give it
|
|
50
|
+
* (samchon/ttsc#1300).
|
|
51
|
+
*/
|
|
52
|
+
class TtscUnstableGenerationError extends TtscTerminalGenerationError {
|
|
53
|
+
validation;
|
|
54
|
+
constructor(message, validation) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "TtscUnstableGenerationError";
|
|
57
|
+
this.validation = validation;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A compile this pass already attempted, whose envelope failed outright.
|
|
62
|
+
*
|
|
63
|
+
* The envelope cannot say whether the host reported diagnostics about the
|
|
64
|
+
* project or failed to run at all: an ordinary type error arrives as an
|
|
65
|
+
* `"exception"` carrying the compiler's own diagnostic text, exactly as a
|
|
66
|
+
* crashed host would. Sniffing that message to tell the two apart would be a
|
|
67
|
+
* guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
|
|
68
|
+
* the answer is already settled, so every later module replays it instead of
|
|
69
|
+
* repeating a whole-project transform to reach the same verdict, which is what
|
|
70
|
+
* made a single broken save cost one compile per delivered module
|
|
71
|
+
* (samchon/ttsc#1303).
|
|
72
|
+
*
|
|
73
|
+
* The scope is exactly the pass. A host whose `buildStart` repeats drops the
|
|
74
|
+
* verdict at its next rebuild, so a transient host failure costs that one
|
|
75
|
+
* rebuild. A host with no pass boundary never retains one at all and keeps
|
|
76
|
+
* retrying on its very next delivery. Between them sits a host that opens
|
|
77
|
+
* exactly one pass for its whole process — Bun's runtime plugin, and a Vite dev
|
|
78
|
+
* server configured with `server.watch: null` — where the verdict lasts the
|
|
79
|
+
* session. That follows from what those hosts already publish about themselves,
|
|
80
|
+
* that their session is one immutable load session and the remedy for changed
|
|
81
|
+
* inputs is to restart, and it is the deliberate trade: without it, one type
|
|
82
|
+
* error costs such a session a whole-project compile per delivered module,
|
|
83
|
+
* which is the workload samchon/ttsc#970 is about.
|
|
84
|
+
*
|
|
85
|
+
* It carries the original error's message, stack and `cause` rather than
|
|
86
|
+
* replacing them, so what a bundler reports is what it reported before the
|
|
87
|
+
* verdict existed.
|
|
88
|
+
*/
|
|
89
|
+
class TtscPassVerdictError extends TtscTerminalGenerationError {
|
|
90
|
+
/** The delivery pass this verdict belongs to, and its whole scope. */
|
|
91
|
+
epoch;
|
|
92
|
+
constructor(original, epoch) {
|
|
93
|
+
super(original instanceof Error
|
|
94
|
+
? original.message
|
|
95
|
+
: formatUnknownError(original), { cause: original });
|
|
96
|
+
if (original instanceof Error) {
|
|
97
|
+
this.name = original.name;
|
|
98
|
+
if (original.stack !== undefined)
|
|
99
|
+
this.stack = original.stack;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this.name = "TtscPassVerdictError";
|
|
103
|
+
}
|
|
104
|
+
this.epoch = epoch;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Proof witnesses retained beside a compiler result without extending its API. */
|
|
108
|
+
const TRANSFORM_GENERATION_FAILURES = new WeakMap();
|
|
109
|
+
/** Retry baselines retained only for attempts that could not be published. */
|
|
110
|
+
const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap();
|
|
111
|
+
/** Cache promises whose unchanged terminal verdict may be replayed. */
|
|
112
|
+
const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap();
|
|
113
|
+
/** Maximum witnesses printed and retained for each failed transform attempt. */
|
|
114
|
+
const MAX_GENERATION_PROOF_FAILURES = 8;
|
|
115
|
+
/** Maximum exact mutation paths kept after a tracker already proved a change. */
|
|
116
|
+
const MAX_GENERATION_MUTATION_PATHS = 8;
|
|
117
|
+
/** One retry absorbs a transient watch write without admitting an infinite loop. */
|
|
118
|
+
const TRANSFORM_GENERATION_ATTEMPTS = 2;
|
|
10
119
|
const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
|
|
11
120
|
exists: fs.existsSync,
|
|
12
121
|
lstat: (location) => fs.lstatSync(location, { bigint: true }),
|
|
@@ -19,10 +128,27 @@ const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
|
|
|
19
128
|
const TRANSFORM_CACHE_FILESYSTEM = new WeakMap();
|
|
20
129
|
const TRANSFORM_RESULT_FILESYSTEM = new WeakMap();
|
|
21
130
|
/**
|
|
22
|
-
*
|
|
23
|
-
* {@link beginTtscTransformBuild}
|
|
131
|
+
* The current delivery epoch of each cache whose owner has declared a real
|
|
132
|
+
* per-pass lifecycle by calling {@link beginTtscTransformBuild}.
|
|
133
|
+
*
|
|
134
|
+
* A _delivery epoch_ is one bundler pass: the window inside which each module
|
|
135
|
+
* is requested at most once, so its first delivery may be settled against the
|
|
136
|
+
* state the pass started from. It is deliberately not the same fact as whether
|
|
137
|
+
* the generation is still valid, which the recorded snapshot answers.
|
|
138
|
+
* Conflating the two is what made every host with a repeating `buildStart` —
|
|
139
|
+
* webpack and Rspack watch, Rollup and Rolldown watch, `vite build --watch`,
|
|
140
|
+
* esbuild rebuild — discard a perfectly good whole-project compile on every
|
|
141
|
+
* edit (samchon/ttsc#1300).
|
|
142
|
+
*
|
|
143
|
+
* Absent from the map means persistent validation: a host with no pass boundary
|
|
144
|
+
* at all (a watching Vite dev server, Metro, the Turbopack loader), where every
|
|
145
|
+
* delivery proves the generation for itself.
|
|
24
146
|
*/
|
|
25
|
-
const
|
|
147
|
+
const TRANSFORM_CACHE_EPOCHS = new WeakMap();
|
|
148
|
+
/** The pass a delivery belongs to, or `undefined` under persistent validation. */
|
|
149
|
+
function transformCacheEpoch(cache) {
|
|
150
|
+
return cache === undefined ? undefined : TRANSFORM_CACHE_EPOCHS.get(cache);
|
|
151
|
+
}
|
|
26
152
|
function createHostPathIdentityContext(filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
27
153
|
return createFilesystemPathIdentityContext({
|
|
28
154
|
caseSensitive: filesystem.caseSensitive,
|
|
@@ -62,27 +188,37 @@ function resultFilesystem(result) {
|
|
|
62
188
|
return (TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS);
|
|
63
189
|
}
|
|
64
190
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
191
|
+
* Open a new delivery pass, enabling constant-time first delivery for every
|
|
192
|
+
* module this pass asks for.
|
|
193
|
+
*
|
|
194
|
+
* This deliberately retains the cached generation. The pass boundary is a
|
|
195
|
+
* statement about _deliveries_ — each module is requested at most once inside
|
|
196
|
+
* it — not about whether the compiled program is still correct, which the
|
|
197
|
+
* generation's own recorded snapshot answers and which
|
|
198
|
+
* {@link matchesCachedSource} proves once at the pass's first delivery. Clearing
|
|
199
|
+
* here instead made a host whose `buildStart` repeats recompile the whole
|
|
200
|
+
* project on every rebuild even when no compiler input had changed
|
|
201
|
+
* (samchon/ttsc#1300). Use {@link resetTtscTransformCache} to actually discard a
|
|
202
|
+
* generation and its watchers.
|
|
67
203
|
*
|
|
68
|
-
* Hosts without a guaranteed
|
|
69
|
-
*
|
|
204
|
+
* Hosts without a guaranteed pass boundary use persistent validation unless
|
|
205
|
+
* they have another immutable lifecycle. Bun runtime setup, for example,
|
|
70
206
|
* defines one process-scoped module-loading session.
|
|
71
207
|
*/
|
|
72
208
|
function beginTtscTransformBuild(cache) {
|
|
73
|
-
|
|
74
|
-
BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
|
|
209
|
+
TRANSFORM_CACHE_EPOCHS.set(cache, (TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1);
|
|
75
210
|
}
|
|
76
211
|
/**
|
|
77
|
-
*
|
|
212
|
+
* Discard every generation, dispose its watchers, and return the cache to
|
|
213
|
+
* persistent validation mode.
|
|
78
214
|
*
|
|
79
|
-
* This is
|
|
80
|
-
*
|
|
81
|
-
*
|
|
215
|
+
* This is the unconditional lifecycle boundary, and it is distinct from
|
|
216
|
+
* {@link beginTtscTransformBuild}: a pass ending is not a reason to throw a
|
|
217
|
+
* proven compile away, while a session ending is.
|
|
82
218
|
*/
|
|
83
219
|
function resetTtscTransformCache(cache) {
|
|
84
220
|
clearTtscTransformCache(cache);
|
|
85
|
-
|
|
221
|
+
TRANSFORM_CACHE_EPOCHS.delete(cache);
|
|
86
222
|
}
|
|
87
223
|
/** Dispose generation-owned filesystem resources before clearing a cache. */
|
|
88
224
|
function clearTtscTransformCache(cache) {
|
|
@@ -138,10 +274,33 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
138
274
|
tsconfig,
|
|
139
275
|
});
|
|
140
276
|
for (;;) {
|
|
277
|
+
// Read once per iteration, before the cache is consulted, so a delivery
|
|
278
|
+
// belongs to the pass that was current when it started examining the
|
|
279
|
+
// generation. A pass opened while this one awaits an in-flight compile is
|
|
280
|
+
// picked up by the next iteration, which is the one that runs when the
|
|
281
|
+
// entry it awaited turns out to have been superseded.
|
|
282
|
+
const epoch = transformCacheEpoch(cache);
|
|
141
283
|
let transformed = cache?.get(key);
|
|
142
284
|
if (transformed !== undefined) {
|
|
143
|
-
|
|
144
|
-
|
|
285
|
+
const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
|
|
286
|
+
if (terminal !== undefined) {
|
|
287
|
+
// A terminal verdict is an answer about one observed environment, not an
|
|
288
|
+
// invitation for every later module to repeat the whole compile.
|
|
289
|
+
if (replaysTerminalGeneration(terminal, epoch, {
|
|
290
|
+
currentFile: file,
|
|
291
|
+
currentSource: source,
|
|
292
|
+
filesystem,
|
|
293
|
+
})) {
|
|
294
|
+
throw terminal;
|
|
295
|
+
}
|
|
296
|
+
evictGeneration(cache, key, transformed);
|
|
297
|
+
if (cache?.get(key) !== undefined) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
transformed = undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (transformed !== undefined) {
|
|
145
304
|
const cached = await awaitOrEvict(cache, key, transformed);
|
|
146
305
|
TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
|
|
147
306
|
// While this caller awaited the old Promise, another caller may have
|
|
@@ -149,8 +308,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
149
308
|
if (cache?.get(key) !== transformed) {
|
|
150
309
|
continue;
|
|
151
310
|
}
|
|
152
|
-
|
|
153
|
-
if (!buildScoped) {
|
|
311
|
+
if (epoch === undefined) {
|
|
154
312
|
await settleProjectMutationEvents(cached);
|
|
155
313
|
if (cache?.get(key) !== transformed) {
|
|
156
314
|
continue;
|
|
@@ -165,15 +323,33 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
165
323
|
projectRoot: cached.projectRoot,
|
|
166
324
|
result: cached.result,
|
|
167
325
|
}) &&
|
|
168
|
-
matchesCachedSource(cached, file, source,
|
|
169
|
-
reportSuccessDiagnostics(cached
|
|
326
|
+
matchesCachedSource(cached, file, source, epoch)) {
|
|
327
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
170
328
|
// A resolved `"exception"` / `"failure"` envelope makes this throw;
|
|
171
|
-
// that is a failed generation too, so
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
329
|
+
// that is a failed generation too, so it is retained for this pass or
|
|
330
|
+
// evicted outside one before being surfaced.
|
|
331
|
+
let code;
|
|
332
|
+
try {
|
|
333
|
+
code = selectOrEvict(cache, key, transformed, epoch, {
|
|
334
|
+
file,
|
|
335
|
+
projectRoot: cached.projectRoot,
|
|
336
|
+
result: cached.result,
|
|
337
|
+
tsconfig: cached.tsconfig,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
342
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
// The compile is fine and simply has nothing for this module, so the
|
|
346
|
+
// module goes back to the host untransformed rather than failing the
|
|
347
|
+
// build (samchon/ttsc#1308). It still counts as delivered in this
|
|
348
|
+
// pass, and there is nothing to watch for a file with no output.
|
|
349
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
350
|
+
markCachedSourceServed(cached, file);
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
177
353
|
notifyWatchInputs(hooks, cached, file);
|
|
178
354
|
markCachedSourceServed(cached, file);
|
|
179
355
|
return createTransformResult(source, code);
|
|
@@ -193,6 +369,10 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
193
369
|
compilerOptions: options.compilerOptions,
|
|
194
370
|
currentFile: file,
|
|
195
371
|
currentSource: source,
|
|
372
|
+
// Stamp the pass this compile was started for, not the one it happens
|
|
373
|
+
// to finish in: a boundary crossed mid-compile leaves the generation
|
|
374
|
+
// belonging to the earlier pass, so the next pass re-proves it.
|
|
375
|
+
deliveryEpoch: epoch,
|
|
196
376
|
filesystem,
|
|
197
377
|
plugins: options.plugins,
|
|
198
378
|
trackProjectMembership: cache !== undefined,
|
|
@@ -206,12 +386,25 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
206
386
|
continue;
|
|
207
387
|
}
|
|
208
388
|
const { projectRoot, result } = cached;
|
|
209
|
-
reportSuccessDiagnostics(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
389
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
390
|
+
let code;
|
|
391
|
+
try {
|
|
392
|
+
code = selectOrEvict(cache, key, generation, epoch, {
|
|
393
|
+
file,
|
|
394
|
+
projectRoot,
|
|
395
|
+
result,
|
|
396
|
+
tsconfig: cached.tsconfig,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
401
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
405
|
+
markCachedSourceServed(cached, file);
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
215
408
|
notifyWatchInputs(hooks, cached, file);
|
|
216
409
|
markCachedSourceServed(cached, file);
|
|
217
410
|
if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
|
|
@@ -221,38 +414,133 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
221
414
|
}
|
|
222
415
|
}
|
|
223
416
|
/**
|
|
224
|
-
* Await a cached generation,
|
|
417
|
+
* Await a cached generation, retaining only terminal proof failures.
|
|
225
418
|
*
|
|
226
419
|
* The cache stores the in-flight transform Promise before it settles so
|
|
227
|
-
* concurrent callers share one compilation.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
420
|
+
* concurrent callers share one compilation. Ordinary compiler and host
|
|
421
|
+
* rejections are evicted so a transient failure cannot become permanent. A
|
|
422
|
+
* bounded stabilization failure is different: it already spent its retry and
|
|
423
|
+
* repeating it for every later module recreates the issue this gate prevents.
|
|
424
|
+
* It stays authoritative until its retained input baseline changes or the cache
|
|
425
|
+
* owner starts a new lifecycle.
|
|
231
426
|
*/
|
|
232
427
|
async function awaitOrEvict(cache, key, generation) {
|
|
233
428
|
try {
|
|
234
429
|
return await generation;
|
|
235
430
|
}
|
|
236
431
|
catch (error) {
|
|
237
|
-
|
|
432
|
+
if (error instanceof TtscUnstableGenerationError &&
|
|
433
|
+
cache?.get(key) === generation) {
|
|
434
|
+
TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
evictGeneration(cache, key, generation);
|
|
438
|
+
}
|
|
238
439
|
throw error;
|
|
239
440
|
}
|
|
240
441
|
}
|
|
241
442
|
/**
|
|
242
|
-
* Extract the transformed source,
|
|
243
|
-
*
|
|
244
|
-
* {@link selectTransformedSource}
|
|
245
|
-
*
|
|
443
|
+
* Extract the transformed source, and decide what a throwing generation is.
|
|
444
|
+
*
|
|
445
|
+
* {@link selectTransformedSource} throws for two different reasons, and only one
|
|
446
|
+
* of them is about the generation. A host `"exception"` or a compiler
|
|
447
|
+
* `"failure"` means the compile produced nothing for anyone: inside a pass that
|
|
448
|
+
* verdict is retained and replayed, because evicting it made every remaining
|
|
449
|
+
* module repeat the whole-project transform only to reach the identical answer
|
|
450
|
+
* (samchon/ttsc#1303), and outside a pass it keeps being evicted so a
|
|
451
|
+
* long-lived worker retries on its very next delivery exactly as before.
|
|
452
|
+
*
|
|
453
|
+
* A `"success"` envelope that has no output for the module asking is the other
|
|
454
|
+
* reason, and it is a fact about that one file: an ordinary condition for a
|
|
455
|
+
* module the bundle reaches and the tsconfig program does not contain. It is
|
|
456
|
+
* neither retained nor evicted. The error reaches the caller, and the
|
|
457
|
+
* generation, which compiled perfectly well for every other module, stays.
|
|
246
458
|
*/
|
|
247
|
-
function selectOrEvict(cache, key, generation, props) {
|
|
459
|
+
function selectOrEvict(cache, key, generation, epoch, props) {
|
|
248
460
|
try {
|
|
249
461
|
return selectTransformedSource(props);
|
|
250
462
|
}
|
|
251
463
|
catch (error) {
|
|
252
|
-
|
|
464
|
+
const verdict = retainPassVerdict(cache, key, generation, epoch, props.result, error);
|
|
465
|
+
if (verdict !== undefined) {
|
|
466
|
+
throw verdict;
|
|
467
|
+
}
|
|
468
|
+
// A generation that compiled fine and simply has no output for the module
|
|
469
|
+
// asking is not a failed generation. Discarding it made every later module
|
|
470
|
+
// recompile the whole project to reach the same answer, which is the cost
|
|
471
|
+
// samchon/ttsc#1303 is about, for a bundle that merely reaches a file the
|
|
472
|
+
// tsconfig program does not contain.
|
|
473
|
+
if (props.result.type !== "success") {
|
|
474
|
+
evictGeneration(cache, key, generation);
|
|
475
|
+
}
|
|
253
476
|
throw error;
|
|
254
477
|
}
|
|
255
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Retain the verdict of a compile this pass already attempted, or return
|
|
481
|
+
* `undefined` when nothing may be retained.
|
|
482
|
+
*
|
|
483
|
+
* Only inside a delivery pass. A pass is the window in which every delivery is
|
|
484
|
+
* settled against the state the pass started from, so an attempt it already
|
|
485
|
+
* made is part of that state and the remaining modules replay it rather than
|
|
486
|
+
* each repeating a whole-project transform to reach the same answer. Outside a
|
|
487
|
+
* pass there is no such window, and a long-lived worker must keep retrying on
|
|
488
|
+
* its very next delivery so a transient host failure never becomes permanent.
|
|
489
|
+
*/
|
|
490
|
+
function retainPassVerdict(cache, key, generation, epoch, result, error) {
|
|
491
|
+
// Only an envelope that failed outright is a statement about the generation.
|
|
492
|
+
// `selectTransformedSource` also throws for a file the compile simply has no
|
|
493
|
+
// output for, which is an ordinary condition for a module the bundle reaches
|
|
494
|
+
// but the tsconfig program does not contain, and which says nothing about the
|
|
495
|
+
// other modules. Retaining that would fail the whole pass, naming a file none
|
|
496
|
+
// of them asked about.
|
|
497
|
+
if (result.type === "success" ||
|
|
498
|
+
epoch === undefined ||
|
|
499
|
+
cache?.get(key) !== generation) {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
const existing = TERMINAL_TRANSFORM_GENERATIONS.get(generation);
|
|
503
|
+
if (existing !== undefined) {
|
|
504
|
+
return existing;
|
|
505
|
+
}
|
|
506
|
+
const verdict = new TtscPassVerdictError(error, epoch);
|
|
507
|
+
TERMINAL_TRANSFORM_GENERATIONS.set(generation, verdict);
|
|
508
|
+
return verdict;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Whether a terminal verdict still answers for this delivery.
|
|
512
|
+
*
|
|
513
|
+
* Inside the pass that produced or confirmed it, it is replayed without
|
|
514
|
+
* re-probing anything: the pass settles every delivery against the state it
|
|
515
|
+
* started from, so re-walking the project once per module would spend exactly
|
|
516
|
+
* the cost this gate exists to remove.
|
|
517
|
+
*
|
|
518
|
+
* Across passes the two kinds part company. A pass verdict is dropped, because
|
|
519
|
+
* a new pass is the first boundary at which the host itself claims something
|
|
520
|
+
* may have changed, and the compile it stood for was never proven against a
|
|
521
|
+
* recorded environment. An unstable generation was, so it keeps its own rule:
|
|
522
|
+
* one fresh attempt per pass, and otherwise replayed until that recorded
|
|
523
|
+
* environment provably moves.
|
|
524
|
+
*/
|
|
525
|
+
function replaysTerminalGeneration(terminal, epoch, props) {
|
|
526
|
+
if (terminal instanceof TtscPassVerdictError) {
|
|
527
|
+
// A pass verdict has no recorded environment to re-confirm against, so the
|
|
528
|
+
// pass that produced it is its whole scope.
|
|
529
|
+
return epoch !== undefined && terminal.epoch === epoch;
|
|
530
|
+
}
|
|
531
|
+
if (!(terminal instanceof TtscUnstableGenerationError)) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
// An unstable generation does have one, and confirming it per delivery is the
|
|
535
|
+
// behaviour its own contract describes, so the pass does not cache that
|
|
536
|
+
// answer. A new pass still grants the fresh attempt the per-pass cache clear
|
|
537
|
+
// used to give it.
|
|
538
|
+
if (epoch !== undefined &&
|
|
539
|
+
terminal.validation.cached.deliveryEpoch !== epoch) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return !failedGenerationEnvironmentChanged(terminal.validation, props);
|
|
543
|
+
}
|
|
256
544
|
/**
|
|
257
545
|
* Delete a failed generation from the cache only when it is still the entry
|
|
258
546
|
* stored under `key`. The identity check prevents an older failed generation's
|
|
@@ -316,6 +604,7 @@ function envelopeGraphIndexes(state, props) {
|
|
|
316
604
|
members: new Set(),
|
|
317
605
|
speculative: new Set(),
|
|
318
606
|
inputProofs: new Map(),
|
|
607
|
+
inputProofFailures: new Map(),
|
|
319
608
|
inputProofConflicts: new Set(),
|
|
320
609
|
};
|
|
321
610
|
const graph = props.result.type === "exception" ? undefined : props.result.graph;
|
|
@@ -333,7 +622,11 @@ function envelopeGraphIndexes(state, props) {
|
|
|
333
622
|
.filter((target) => typeof target === "string" && target.length !== 0)
|
|
334
623
|
.map((target) => {
|
|
335
624
|
const absoluteTarget = path.resolve(props.projectRoot, target);
|
|
336
|
-
|
|
625
|
+
const targetIdentity = derivationIdentity(state, absoluteTarget);
|
|
626
|
+
built.members.add(targetIdentity);
|
|
627
|
+
if (!built.spellings.has(targetIdentity)) {
|
|
628
|
+
built.spellings.set(targetIdentity, absoluteTarget);
|
|
629
|
+
}
|
|
337
630
|
return absoluteTarget;
|
|
338
631
|
}));
|
|
339
632
|
built.edges.set(identity, entries);
|
|
@@ -341,7 +634,10 @@ function envelopeGraphIndexes(state, props) {
|
|
|
341
634
|
built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
|
|
342
635
|
built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
|
|
343
636
|
for (const input of [...built.globals, ...built.configs]) {
|
|
344
|
-
|
|
637
|
+
const identity = derivationIdentity(state, input);
|
|
638
|
+
built.members.add(identity);
|
|
639
|
+
if (!built.spellings.has(identity))
|
|
640
|
+
built.spellings.set(identity, input);
|
|
345
641
|
}
|
|
346
642
|
const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
|
|
347
643
|
// Every candidate source is an importing file the compiler read, so fold
|
|
@@ -349,7 +645,12 @@ function envelopeGraphIndexes(state, props) {
|
|
|
349
645
|
// candidate could be classified speculative before a later entry proves
|
|
350
646
|
// the same path is a realized source.
|
|
351
647
|
for (const [source] of candidateEntries) {
|
|
352
|
-
|
|
648
|
+
const absoluteSource = path.resolve(props.projectRoot, source);
|
|
649
|
+
const identity = derivationIdentity(state, absoluteSource);
|
|
650
|
+
built.members.add(identity);
|
|
651
|
+
if (!built.spellings.has(identity)) {
|
|
652
|
+
built.spellings.set(identity, absoluteSource);
|
|
653
|
+
}
|
|
353
654
|
}
|
|
354
655
|
const realized = new Set(built.members);
|
|
355
656
|
for (const [source, candidates] of candidateEntries) {
|
|
@@ -367,6 +668,17 @@ function envelopeGraphIndexes(state, props) {
|
|
|
367
668
|
if (!realized.has(identity))
|
|
368
669
|
built.speculative.add(identity);
|
|
369
670
|
built.members.add(identity);
|
|
671
|
+
if (!built.spellings.has(identity)) {
|
|
672
|
+
built.spellings.set(identity, path.resolve(props.projectRoot, candidate));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const transformSources = new Set();
|
|
677
|
+
if (props.result.type === "success") {
|
|
678
|
+
for (const output of Object.keys(props.result.typescript)) {
|
|
679
|
+
if (!isDeclarationFile(output)) {
|
|
680
|
+
transformSources.add(derivationIdentity(state, path.resolve(props.projectRoot, output)));
|
|
681
|
+
}
|
|
370
682
|
}
|
|
371
683
|
}
|
|
372
684
|
for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
|
|
@@ -386,8 +698,9 @@ function envelopeGraphIndexes(state, props) {
|
|
|
386
698
|
}
|
|
387
699
|
const absolute = path.resolve(props.projectRoot, input);
|
|
388
700
|
const identity = derivationIdentity(state, absolute);
|
|
389
|
-
if (!built.members.has(identity))
|
|
701
|
+
if (!built.members.has(identity) && !transformSources.has(identity)) {
|
|
390
702
|
continue;
|
|
703
|
+
}
|
|
391
704
|
const proof = {
|
|
392
705
|
hash,
|
|
393
706
|
path: absolute,
|
|
@@ -404,6 +717,23 @@ function envelopeGraphIndexes(state, props) {
|
|
|
404
717
|
built.inputProofs.set(identity, proof);
|
|
405
718
|
}
|
|
406
719
|
}
|
|
720
|
+
for (const [input, reason] of Object.entries(graph.inputProofFailures ?? {})) {
|
|
721
|
+
if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const absolute = path.resolve(props.projectRoot, input);
|
|
725
|
+
const identity = derivationIdentity(state, absolute);
|
|
726
|
+
if (!built.members.has(identity) && !transformSources.has(identity)) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (built.inputProofs.has(identity)) {
|
|
730
|
+
built.inputProofs.delete(identity);
|
|
731
|
+
built.inputProofConflicts.add(identity);
|
|
732
|
+
}
|
|
733
|
+
if (!built.inputProofFailures.has(identity)) {
|
|
734
|
+
built.inputProofFailures.set(identity, reason);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
407
737
|
}
|
|
408
738
|
state.graph = built;
|
|
409
739
|
return built;
|
|
@@ -450,9 +780,51 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
|
|
|
450
780
|
* Envelope keys mirror the `typescript` keys (project-relative); values may be
|
|
451
781
|
* project-relative or absolute. Every path is absolutized against the project
|
|
452
782
|
* root and deduplicated; the file itself is dropped (the bundler already
|
|
453
|
-
* watches the module it transforms), and so is the disposed
|
|
454
|
-
* (see
|
|
783
|
+
* watches the module it transforms), and so is every path in the disposed
|
|
784
|
+
* transform scratch tree (see
|
|
785
|
+
* {@link TtscCachedProjectTransform.scratchDirectory}).
|
|
786
|
+
*/
|
|
787
|
+
/**
|
|
788
|
+
* Register the failed generation's own project inputs so the host can observe
|
|
789
|
+
* the fix.
|
|
790
|
+
*
|
|
791
|
+
* A successful delivery registers the derived watch inputs, which is how a
|
|
792
|
+
* type-only file that no bundler graph contains still invalidates the modules
|
|
793
|
+
* depending on it. A failed one used to register nothing: `selectWatchInputs`
|
|
794
|
+
* returns an empty list for an `"exception"` envelope, and the throw happens
|
|
795
|
+
* before `notifyWatchInputs` is reached at all. When the failing compile is the
|
|
796
|
+
* first of a watching session, that leaves no channel through which the fix can
|
|
797
|
+
* arrive: the user repairs a file the bundler does not track, nothing is
|
|
798
|
+
* invalidated, and the error stays on screen (samchon/ttsc#1312).
|
|
799
|
+
*
|
|
800
|
+
* The generation records the project walk even when the compile failed, so the
|
|
801
|
+
* files a fix would touch are exactly what it already holds. The cost is paid
|
|
802
|
+
* only on a failure, and only until the next compile succeeds and narrows the
|
|
803
|
+
* set back to the derived inputs.
|
|
455
804
|
*/
|
|
805
|
+
function notifyFailedGenerationInputs(hooks, cached) {
|
|
806
|
+
const addWatchFile = hooks?.addWatchFile;
|
|
807
|
+
if (addWatchFile === undefined) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
for (const key of Object.keys(cached.inputHashes)) {
|
|
811
|
+
const input = path.resolve(cached.projectRoot, key);
|
|
812
|
+
if (isTransformScratchInput(input, cached.scratchDirectory)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
// No evidence argument, deliberately. `missing: false` would be a claim
|
|
816
|
+
// this path cannot back: a failed generation is replayed for the rest of
|
|
817
|
+
// its pass without re-proving its inputs, so the walk that recorded them
|
|
818
|
+
// may be older than the delivery, and one of them having been deleted is a
|
|
819
|
+
// live reason for that compile to have failed. Letting the adapter probe
|
|
820
|
+
// also routes an absent input to the missing-input poll, which is the only
|
|
821
|
+
// channel through which restoring it can invalidate anything: a bundler
|
|
822
|
+
// watch on a path that does not exist registers nothing, and no module
|
|
823
|
+
// graph carries a type-only input. It costs one `existsSync` per input,
|
|
824
|
+
// and only where the adapter reads evidence at all, which is Vite serve.
|
|
825
|
+
addWatchFile(input);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
456
828
|
function notifyWatchInputs(hooks, cached, file) {
|
|
457
829
|
const addWatchFile = hooks?.addWatchFile;
|
|
458
830
|
if (addWatchFile === undefined) {
|
|
@@ -464,6 +836,7 @@ function notifyWatchInputs(hooks, cached, file) {
|
|
|
464
836
|
file,
|
|
465
837
|
projectRoot: cached.projectRoot,
|
|
466
838
|
result: cached.result,
|
|
839
|
+
scratchDirectory: cached.scratchDirectory,
|
|
467
840
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
468
841
|
})) {
|
|
469
842
|
// Hand the adapter the identity this generation already resolved and the
|
|
@@ -533,6 +906,7 @@ function deriveWatchInputs(state, props, fileIdentity) {
|
|
|
533
906
|
const spelling = path.resolve(input);
|
|
534
907
|
if (spelling === currentSpelling ||
|
|
535
908
|
spelling === temporarySpelling ||
|
|
909
|
+
isTransformScratchInput(spelling, props.scratchDirectory) ||
|
|
536
910
|
lexicalSeen.has(spelling)) {
|
|
537
911
|
return;
|
|
538
912
|
}
|
|
@@ -541,6 +915,8 @@ function deriveWatchInputs(state, props, fileIdentity) {
|
|
|
541
915
|
output.push(input);
|
|
542
916
|
};
|
|
543
917
|
const appendPhysical = (input) => {
|
|
918
|
+
if (isTransformScratchInput(input, props.scratchDirectory))
|
|
919
|
+
return;
|
|
544
920
|
const identity = derivationIdentity(state, input);
|
|
545
921
|
if (excluded.has(identity) || physicalSeen.has(identity))
|
|
546
922
|
return;
|
|
@@ -784,11 +1160,22 @@ function stripQuery(id) {
|
|
|
784
1160
|
return query === -1 ? id : id.slice(0, query);
|
|
785
1161
|
}
|
|
786
1162
|
/**
|
|
787
|
-
* Returns `true` for
|
|
788
|
-
* `.d.cts`
|
|
1163
|
+
* Returns `true` for every declaration-file spelling TypeScript-Go accepts.
|
|
1164
|
+
* Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
|
|
1165
|
+
* treats an arbitrary-extension source such as `styles.d.css.ts` as a
|
|
1166
|
+
* declaration file too.
|
|
789
1167
|
*/
|
|
790
1168
|
function isDeclarationFile(id) {
|
|
791
|
-
|
|
1169
|
+
// Module ids can cross process/platform boundaries (for example, a Windows
|
|
1170
|
+
// id inspected by a POSIX host). TypeScript-Go normalizes both separators
|
|
1171
|
+
// before taking the basename, so a `.d.` directory component must not turn
|
|
1172
|
+
// an ordinary source into a declaration file.
|
|
1173
|
+
const normalized = id.replaceAll("\\", "/");
|
|
1174
|
+
const base = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
1175
|
+
return (base.endsWith(".d.ts") ||
|
|
1176
|
+
base.endsWith(".d.mts") ||
|
|
1177
|
+
base.endsWith(".d.cts") ||
|
|
1178
|
+
(base.endsWith(".ts") && base.includes(".d.")));
|
|
792
1179
|
}
|
|
793
1180
|
/**
|
|
794
1181
|
* Returns `true` when the caller has explicitly opted out of all plugins. An
|
|
@@ -816,25 +1203,44 @@ function createTransformResult(source, code) {
|
|
|
816
1203
|
* state.
|
|
817
1204
|
*
|
|
818
1205
|
* Always compares the current module's in-memory source with the generation
|
|
819
|
-
* snapshot. A cache
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
1206
|
+
* snapshot. A cache with a delivery epoch can use that comparison alone for a
|
|
1207
|
+
* stable generation's first delivery of each module in the current pass, once
|
|
1208
|
+
* the pass's own first delivery has proven the whole generation still matches
|
|
1209
|
+
* the filesystem. An incomplete generation may not take this shortcut:
|
|
1210
|
+
* otherwise a sibling output captured during a filesystem race could still be
|
|
1211
|
+
* served once. Later graph-bearing requests validate the file's derived input
|
|
1212
|
+
* set and project membership; graph-free envelopes conservatively re-hash the
|
|
1213
|
+
* complete project and out-of-walk snapshots. Any mismatch forces a complete
|
|
826
1214
|
* re-transform.
|
|
827
1215
|
*/
|
|
828
|
-
function matchesCachedSource(cached, file, source,
|
|
1216
|
+
function matchesCachedSource(cached, file, source, epoch) {
|
|
829
1217
|
const identities = envelopeDerivation(cached).identityContext;
|
|
830
1218
|
const currentKey = toProjectKey(cached.projectRoot, file, identities);
|
|
831
|
-
|
|
1219
|
+
const identity = pathIdentityKey(file, identities);
|
|
1220
|
+
const expected = cached.sourceHashes?.[identity] ??
|
|
1221
|
+
cached.inputHashes[currentKey] ??
|
|
1222
|
+
cached.externalInputHashes?.[identity];
|
|
1223
|
+
if (expected !== hashText(source)) {
|
|
832
1224
|
return false;
|
|
833
1225
|
}
|
|
834
|
-
if (
|
|
835
|
-
cached.
|
|
836
|
-
|
|
837
|
-
|
|
1226
|
+
if (epoch !== undefined && cached.projectSnapshotComplete === true) {
|
|
1227
|
+
if (cached.deliveryEpoch !== epoch) {
|
|
1228
|
+
// The pass's first delivery. The generation was settled against an
|
|
1229
|
+
// earlier pass, so prove the whole of it once — every input the envelope
|
|
1230
|
+
// declares, the directory membership, the universal host inputs, and the
|
|
1231
|
+
// out-of-walk snapshot — before any of this pass's deliveries may be
|
|
1232
|
+
// settled against it. That proof is what a per-pass recompile used to buy
|
|
1233
|
+
// (samchon/ttsc#1300), at a walk instead of a compile.
|
|
1234
|
+
if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
cached.deliveryEpoch = epoch;
|
|
1238
|
+
cached.servedFiles?.clear();
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
if (!cached.servedFiles?.has(identity)) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
838
1244
|
}
|
|
839
1245
|
if (cached.result.type !== "exception" &&
|
|
840
1246
|
cached.result.graph !== undefined &&
|
|
@@ -884,6 +1290,7 @@ function matchesNarrowPersistentInputs(cached, file) {
|
|
|
884
1290
|
file,
|
|
885
1291
|
projectRoot: cached.projectRoot,
|
|
886
1292
|
result: cached.result,
|
|
1293
|
+
scratchDirectory: cached.scratchDirectory,
|
|
887
1294
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
888
1295
|
});
|
|
889
1296
|
for (const input of inputs) {
|
|
@@ -1102,6 +1509,7 @@ function isMissingPathError(error) {
|
|
|
1102
1509
|
function captureUniversalHostInputValidation(cached, currentFile) {
|
|
1103
1510
|
const filesystem = resultFilesystem(cached.result);
|
|
1104
1511
|
const state = envelopeDerivation(cached);
|
|
1512
|
+
const failures = createGenerationProofFailures();
|
|
1105
1513
|
const validation = {
|
|
1106
1514
|
entries: new Map(),
|
|
1107
1515
|
covered: new Set(),
|
|
@@ -1111,6 +1519,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1111
1519
|
filesystem,
|
|
1112
1520
|
projectRoot: cached.projectRoot,
|
|
1113
1521
|
result: cached.result,
|
|
1522
|
+
scratchDirectory: cached.scratchDirectory,
|
|
1114
1523
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1115
1524
|
})) {
|
|
1116
1525
|
const generationHashes = cached.result.type === "exception"
|
|
@@ -1126,8 +1535,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1126
1535
|
let readable = false;
|
|
1127
1536
|
if (expected === undefined) {
|
|
1128
1537
|
const current = path.resolve(currentFile);
|
|
1129
|
-
if (path.resolve(input) !== current)
|
|
1130
|
-
|
|
1538
|
+
if (path.resolve(input) !== current) {
|
|
1539
|
+
recordGenerationProofFailure(failures, {
|
|
1540
|
+
domain: "host",
|
|
1541
|
+
kind: "content-proof-missing",
|
|
1542
|
+
path: input,
|
|
1543
|
+
});
|
|
1544
|
+
return { failures };
|
|
1545
|
+
}
|
|
1131
1546
|
// The current module may be supplied from an unsaved editor buffer. Its
|
|
1132
1547
|
// generation snapshot is overlaid below from `currentSource`, so a disk
|
|
1133
1548
|
// fingerprint would be both unavailable and the wrong authority. The
|
|
@@ -1137,7 +1552,12 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1137
1552
|
else {
|
|
1138
1553
|
const current = hostInputStateHash(input, filesystem);
|
|
1139
1554
|
if (expected !== current) {
|
|
1140
|
-
|
|
1555
|
+
recordGenerationProofFailure(failures, {
|
|
1556
|
+
domain: "host",
|
|
1557
|
+
kind: "content-changed",
|
|
1558
|
+
path: input,
|
|
1559
|
+
});
|
|
1560
|
+
return { failures };
|
|
1141
1561
|
}
|
|
1142
1562
|
// A path both sides agree they could not read carries no bytes for a
|
|
1143
1563
|
// signature to stand for. It still belongs in the manifest, so the
|
|
@@ -1148,16 +1568,35 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1148
1568
|
if (generationRealpaths !== undefined) {
|
|
1149
1569
|
if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
|
|
1150
1570
|
!sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
|
|
1151
|
-
|
|
1571
|
+
recordGenerationProofFailure(failures, {
|
|
1572
|
+
domain: "host",
|
|
1573
|
+
kind: Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput)
|
|
1574
|
+
? "realpath-changed"
|
|
1575
|
+
: "realpath-proof-missing",
|
|
1576
|
+
path: input,
|
|
1577
|
+
});
|
|
1578
|
+
return { failures };
|
|
1152
1579
|
}
|
|
1153
1580
|
}
|
|
1154
1581
|
validation.covered.add(path.resolve(input));
|
|
1155
1582
|
const before = inputMetadataEvidence(input, filesystem);
|
|
1156
|
-
if (!matchesRecordedInput(cached, input))
|
|
1157
|
-
|
|
1583
|
+
if (!matchesRecordedInput(cached, input)) {
|
|
1584
|
+
recordGenerationProofFailure(failures, {
|
|
1585
|
+
domain: "host",
|
|
1586
|
+
kind: "snapshot-mismatch",
|
|
1587
|
+
path: input,
|
|
1588
|
+
});
|
|
1589
|
+
return { failures };
|
|
1590
|
+
}
|
|
1158
1591
|
const after = inputMetadataSignature(input, filesystem);
|
|
1159
|
-
if (before?.signature !== after)
|
|
1160
|
-
|
|
1592
|
+
if (before?.signature !== after) {
|
|
1593
|
+
recordGenerationProofFailure(failures, {
|
|
1594
|
+
domain: "host",
|
|
1595
|
+
kind: "changed-during-validation",
|
|
1596
|
+
path: input,
|
|
1597
|
+
});
|
|
1598
|
+
return { failures };
|
|
1599
|
+
}
|
|
1161
1600
|
if (before !== undefined) {
|
|
1162
1601
|
// Do not key this manifest by physical identity. A symlink/junction
|
|
1163
1602
|
// spelling and its selected target deliberately share that identity,
|
|
@@ -1177,8 +1616,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1177
1616
|
const probe = missingPathProbe(input, filesystem);
|
|
1178
1617
|
if (probe.blocker !== undefined) {
|
|
1179
1618
|
const signature = inputMetadataSignature(probe.blocker, filesystem);
|
|
1180
|
-
if (signature === undefined)
|
|
1181
|
-
|
|
1619
|
+
if (signature === undefined) {
|
|
1620
|
+
recordGenerationProofFailure(failures, {
|
|
1621
|
+
domain: "host",
|
|
1622
|
+
kind: "blocker-metadata-unavailable",
|
|
1623
|
+
path: probe.blocker,
|
|
1624
|
+
});
|
|
1625
|
+
return { failures };
|
|
1626
|
+
}
|
|
1182
1627
|
// A blocker proves a kind and an identity, not content: it is the
|
|
1183
1628
|
// non-directory ancestor that makes everything below it unreachable, and
|
|
1184
1629
|
// it cannot stop being that without its metadata moving. So it keeps a
|
|
@@ -1205,7 +1650,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
|
|
|
1205
1650
|
names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
|
|
1206
1651
|
}
|
|
1207
1652
|
cached.hostInputValidation = validation;
|
|
1208
|
-
return validation;
|
|
1653
|
+
return { failures, validation };
|
|
1209
1654
|
}
|
|
1210
1655
|
/**
|
|
1211
1656
|
* The recorded state of an input the generation read nothing from: absent, or
|
|
@@ -1503,14 +1948,21 @@ function matchesCompleteInputSnapshot(cached, currentKey, source) {
|
|
|
1503
1948
|
const declaredInputs = declaredProjectInputKeys(state, cached);
|
|
1504
1949
|
const current = collectProjectInputSnapshot(cached.projectRoot, state.identityContext, resultFilesystem(cached.result), cached.inputSignatures === undefined
|
|
1505
1950
|
? undefined
|
|
1506
|
-
: { hashes: cached.inputHashes, signatures: cached.inputSignatures }
|
|
1951
|
+
: { hashes: cached.inputHashes, signatures: cached.inputSignatures }, {
|
|
1952
|
+
// Judge membership by the rule the compile ran under, and read only the
|
|
1953
|
+
// inputs this comparison actually consults.
|
|
1954
|
+
declaredKeys: declaredInputs,
|
|
1955
|
+
policy: cached.membershipPolicy,
|
|
1956
|
+
});
|
|
1507
1957
|
if (!walkSnapshotComplete(current, declaredInputs)) {
|
|
1508
1958
|
return false;
|
|
1509
1959
|
}
|
|
1510
1960
|
if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
|
|
1511
1961
|
return false;
|
|
1512
1962
|
}
|
|
1513
|
-
|
|
1963
|
+
if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
|
|
1964
|
+
current.hashes[currentKey] = hashText(source);
|
|
1965
|
+
}
|
|
1514
1966
|
if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
|
|
1515
1967
|
return false;
|
|
1516
1968
|
}
|
|
@@ -1578,18 +2030,30 @@ function matchesExternalInputRealpaths(cached) {
|
|
|
1578
2030
|
}
|
|
1579
2031
|
/**
|
|
1580
2032
|
* Capture external-input hashes without attaching post-compile state to an
|
|
1581
|
-
* earlier graph. Graph members
|
|
1582
|
-
* it now; plugin-declared dependency-only
|
|
1583
|
-
* post-compile snapshot because their own protocol
|
|
1584
|
-
* fingerprints.
|
|
2033
|
+
* earlier graph. Graph members and out-of-walk transformed sources must carry
|
|
2034
|
+
* compiler-time proof and still match it now; plugin-declared dependency-only
|
|
2035
|
+
* paths retain the historical post-compile snapshot because their own protocol
|
|
2036
|
+
* does not claim generation fingerprints.
|
|
1585
2037
|
*/
|
|
1586
2038
|
function captureExternalInputSnapshot(cached, paths) {
|
|
1587
2039
|
const state = envelopeDerivation(cached);
|
|
1588
2040
|
const filesystem = resultFilesystem(cached.result);
|
|
1589
2041
|
const graph = envelopeGraphIndexes(state, cached);
|
|
2042
|
+
// A non-declaration transform output is a compiler-realized source even when
|
|
2043
|
+
// a malformed or legacy graph omitted its node. Its output was computed from
|
|
2044
|
+
// compiler-time bytes, so a post-compile host read cannot prove coherence.
|
|
2045
|
+
const transformSources = new Set();
|
|
2046
|
+
if (cached.result.type === "success") {
|
|
2047
|
+
for (const output of Object.keys(cached.result.typescript)) {
|
|
2048
|
+
if (!isDeclarationFile(output)) {
|
|
2049
|
+
transformSources.add(derivationIdentity(state, path.resolve(cached.projectRoot, output)));
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
1590
2053
|
const hashes = {};
|
|
1591
2054
|
const realpaths = {};
|
|
1592
2055
|
const signatures = {};
|
|
2056
|
+
const failures = createGenerationProofFailures();
|
|
1593
2057
|
let complete = true;
|
|
1594
2058
|
// Sandwich every read between two metadata signatures. Only a signature that
|
|
1595
2059
|
// survived its own read, and whose stamp's tick the filesystem's clock has
|
|
@@ -1607,21 +2071,47 @@ function captureExternalInputSnapshot(cached, paths) {
|
|
|
1607
2071
|
// through to the recorded-state branch below, the same evidence a
|
|
1608
2072
|
// plugin-declared dependency path carries. Its absence still invalidates
|
|
1609
2073
|
// the generation when it appears, because `missing` is recorded state.
|
|
1610
|
-
const
|
|
2074
|
+
const realizedTransformSource = transformSources.has(identity);
|
|
2075
|
+
const speculativeOnly = !realizedTransformSource &&
|
|
2076
|
+
graph.speculative.has(identity) &&
|
|
1611
2077
|
!graph.inputProofs.has(identity) &&
|
|
1612
2078
|
!graph.inputProofConflicts.has(identity);
|
|
1613
|
-
if (graph.members.has(identity) &&
|
|
2079
|
+
if ((realizedTransformSource || graph.members.has(identity)) &&
|
|
2080
|
+
!speculativeOnly) {
|
|
1614
2081
|
const proof = graph.inputProofs.get(identity);
|
|
1615
2082
|
if (proof === undefined || graph.inputProofConflicts.has(identity)) {
|
|
1616
2083
|
complete = false;
|
|
2084
|
+
recordGenerationProofFailure(failures, {
|
|
2085
|
+
domain: "external",
|
|
2086
|
+
kind: graph.inputProofConflicts.has(identity)
|
|
2087
|
+
? "graph-proof-conflict"
|
|
2088
|
+
: "graph-proof-missing",
|
|
2089
|
+
detail: graph.inputProofFailures.get(identity),
|
|
2090
|
+
path: input,
|
|
2091
|
+
});
|
|
1617
2092
|
continue;
|
|
1618
2093
|
}
|
|
1619
2094
|
const before = inputMetadataEvidence(input, filesystem);
|
|
1620
2095
|
const currentHash = graphInputStateHash(input, filesystem);
|
|
2096
|
+
const currentRealpath = hostInputRealpath(input, filesystem);
|
|
1621
2097
|
const after = inputMetadataSignature(input, filesystem);
|
|
1622
|
-
|
|
1623
|
-
|
|
2098
|
+
const realpathMatches = sameHostInputRealpath(proof.realpath, currentRealpath, state.identityContext);
|
|
2099
|
+
if (currentHash !== proof.hash || !realpathMatches) {
|
|
1624
2100
|
complete = false;
|
|
2101
|
+
if (currentHash !== proof.hash) {
|
|
2102
|
+
recordGenerationProofFailure(failures, {
|
|
2103
|
+
domain: "external",
|
|
2104
|
+
kind: "graph-content-changed",
|
|
2105
|
+
path: input,
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
if (!realpathMatches) {
|
|
2109
|
+
recordGenerationProofFailure(failures, {
|
|
2110
|
+
domain: "external",
|
|
2111
|
+
kind: "graph-realpath-changed",
|
|
2112
|
+
path: input,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
1625
2115
|
}
|
|
1626
2116
|
else if (currentHash !== null) {
|
|
1627
2117
|
// The recorded hash is the compiler's own proof, so a signature may
|
|
@@ -1641,27 +2131,30 @@ function captureExternalInputSnapshot(cached, paths) {
|
|
|
1641
2131
|
if (hash !== null)
|
|
1642
2132
|
record(input, before, after);
|
|
1643
2133
|
}
|
|
1644
|
-
return { complete, hashes, realpaths, signatures };
|
|
2134
|
+
return { complete, failures, hashes, realpaths, signatures };
|
|
1645
2135
|
}
|
|
1646
|
-
/**
|
|
1647
|
-
function
|
|
2136
|
+
/** Explain every graph member that no longer matches the compiler's state. */
|
|
2137
|
+
function compilerGraphInputProofFailures(cached) {
|
|
2138
|
+
const failures = createGenerationProofFailures();
|
|
1648
2139
|
if (cached.result.type === "exception" ||
|
|
1649
2140
|
cached.result.graph === undefined ||
|
|
1650
2141
|
(cached.result.graph.inputHashes === undefined &&
|
|
1651
|
-
cached.result.graph.inputRealpaths === undefined
|
|
2142
|
+
cached.result.graph.inputRealpaths === undefined &&
|
|
2143
|
+
cached.result.graph.inputProofFailures === undefined)) {
|
|
1652
2144
|
// Legacy sidecars remain compatible for ordinary in-project graphs. Their
|
|
1653
2145
|
// out-of-walk members are still rejected by captureExternalInputSnapshot,
|
|
1654
2146
|
// where a post-compile snapshot cannot prove the compiler's generation.
|
|
1655
|
-
return
|
|
2147
|
+
return failures;
|
|
1656
2148
|
}
|
|
1657
2149
|
const state = envelopeDerivation(cached);
|
|
1658
2150
|
const filesystem = resultFilesystem(cached.result);
|
|
1659
2151
|
const graph = envelopeGraphIndexes(state, cached);
|
|
1660
|
-
if (graph.inputProofConflicts.size !== 0) {
|
|
1661
|
-
return false;
|
|
1662
|
-
}
|
|
1663
2152
|
for (const identity of graph.members) {
|
|
1664
2153
|
const proof = graph.inputProofs.get(identity);
|
|
2154
|
+
const spelling = proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
|
|
2155
|
+
if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
1665
2158
|
// A speculative candidate has no compile-time read to prove. Requiring one
|
|
1666
2159
|
// would void every generation of every project whose resolution passes over
|
|
1667
2160
|
// a higher-priority spelling, which is every project with a dependency
|
|
@@ -1670,13 +2163,41 @@ function matchesCompilerGraphInputProofs(cached) {
|
|
|
1670
2163
|
if (proof === undefined && graph.speculative.has(identity)) {
|
|
1671
2164
|
continue;
|
|
1672
2165
|
}
|
|
1673
|
-
if (
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
2166
|
+
if (graph.inputProofConflicts.has(identity)) {
|
|
2167
|
+
recordGenerationProofFailure(failures, {
|
|
2168
|
+
domain: "graph",
|
|
2169
|
+
kind: "proof-conflict",
|
|
2170
|
+
detail: graph.inputProofFailures.get(identity),
|
|
2171
|
+
path: spelling,
|
|
2172
|
+
});
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
if (proof === undefined) {
|
|
2176
|
+
recordGenerationProofFailure(failures, {
|
|
2177
|
+
domain: "graph",
|
|
2178
|
+
kind: "proof-missing",
|
|
2179
|
+
detail: graph.inputProofFailures.get(identity),
|
|
2180
|
+
path: spelling,
|
|
2181
|
+
});
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
const currentHash = graphInputStateHash(proof.path, filesystem);
|
|
2185
|
+
if (currentHash !== proof.hash) {
|
|
2186
|
+
recordGenerationProofFailure(failures, {
|
|
2187
|
+
domain: "graph",
|
|
2188
|
+
kind: "content-changed",
|
|
2189
|
+
path: proof.path,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
if (!sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
|
|
2193
|
+
recordGenerationProofFailure(failures, {
|
|
2194
|
+
domain: "graph",
|
|
2195
|
+
kind: "realpath-changed",
|
|
2196
|
+
path: proof.path,
|
|
2197
|
+
});
|
|
1677
2198
|
}
|
|
1678
2199
|
}
|
|
1679
|
-
return
|
|
2200
|
+
return failures;
|
|
1680
2201
|
}
|
|
1681
2202
|
/** Compare one derived input with the snapshot that owned it at generation. */
|
|
1682
2203
|
function matchesRecordedInput(cached, input) {
|
|
@@ -1723,23 +2244,36 @@ function markCachedSourceServed(cached, file) {
|
|
|
1723
2244
|
* slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
|
|
1724
2245
|
* can fold the identical input universe into their own cache fingerprints.
|
|
1725
2246
|
*/
|
|
1726
|
-
function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1727
|
-
return collectProjectInputSnapshot(projectRoot, identities, filesystem
|
|
1728
|
-
|
|
2247
|
+
function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy) {
|
|
2248
|
+
return collectProjectInputSnapshot(projectRoot, identities, filesystem, undefined, {
|
|
2249
|
+
policy,
|
|
2250
|
+
}).hashes;
|
|
1729
2251
|
}
|
|
1730
2252
|
/** Hash project files and snapshot the directory topology in one walk. */
|
|
1731
|
-
function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven) {
|
|
2253
|
+
function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven, options) {
|
|
1732
2254
|
const hashes = {};
|
|
1733
2255
|
const fileSignatures = {};
|
|
1734
2256
|
const provenSignatures = {};
|
|
1735
2257
|
const unstableFiles = new Set();
|
|
1736
2258
|
let attributed = true;
|
|
1737
|
-
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
2259
|
+
const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
|
|
2260
|
+
const walkFailures = [...walked.failures];
|
|
1738
2261
|
let complete = walked.complete;
|
|
1739
2262
|
for (const file of walked.files) {
|
|
1740
2263
|
try {
|
|
1741
|
-
const before = inputMetadataEvidence(file, filesystem);
|
|
1742
2264
|
const key = toProjectKey(projectRoot, file, identities);
|
|
2265
|
+
// A caller validating a generation compares hashes over that
|
|
2266
|
+
// generation's declared inputs alone (`sameHashes` takes the declared key
|
|
2267
|
+
// set), so reading anything else is work whose result is never consulted.
|
|
2268
|
+
// Skipping it is what keeps a directory full of emitted files from
|
|
2269
|
+
// costing a read per file on the pass that first sees them
|
|
2270
|
+
// (samchon/ttsc#1307). Capture passes supply no restriction and still
|
|
2271
|
+
// record the whole walk.
|
|
2272
|
+
if (options?.declaredKeys !== undefined &&
|
|
2273
|
+
!options.declaredKeys.has(key)) {
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
1743
2277
|
// A file whose signature still equals the one captured around the read
|
|
1744
2278
|
// that produced the recorded hash carries that content, so the whole
|
|
1745
2279
|
// project does not have to be re-read to prove one delivery. A signature
|
|
@@ -1762,6 +2296,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
|
|
|
1762
2296
|
before.signature !== after) {
|
|
1763
2297
|
complete = false;
|
|
1764
2298
|
unstableFiles.add(key);
|
|
2299
|
+
walkFailures.push({ kind: "file-changed-during-read", path: file });
|
|
1765
2300
|
}
|
|
1766
2301
|
else {
|
|
1767
2302
|
fileSignatures[key] = after;
|
|
@@ -1778,6 +2313,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
|
|
|
1778
2313
|
// File watchers may observe a transform while another process is moving
|
|
1779
2314
|
// or deleting files. The missing key invalidates older cache entries.
|
|
1780
2315
|
complete = false;
|
|
2316
|
+
walkFailures.push({ kind: "file-read-failed", path: file });
|
|
1781
2317
|
try {
|
|
1782
2318
|
unstableFiles.add(toProjectKey(projectRoot, file, identities));
|
|
1783
2319
|
}
|
|
@@ -1796,26 +2332,36 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
|
|
|
1796
2332
|
projectDirectories: walked.directories,
|
|
1797
2333
|
provenSignatures,
|
|
1798
2334
|
unstableFiles,
|
|
2335
|
+
walkFailures,
|
|
1799
2336
|
};
|
|
1800
2337
|
}
|
|
1801
2338
|
/**
|
|
1802
|
-
* Enumerate every regular file under `root`, skipping
|
|
1803
|
-
*
|
|
2339
|
+
* Enumerate every regular file under `root`, skipping the directories no
|
|
2340
|
+
* configuration can name ({@link isIgnoredProjectDirectory}) and the ones the
|
|
2341
|
+
* resolved configuration excludes ({@link isExcludedProjectDirectory}).
|
|
1804
2342
|
*
|
|
1805
2343
|
* Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
|
|
1806
2344
|
* unbounded call-stack depth on deep project trees. The result is sorted so
|
|
1807
2345
|
* that hash comparisons are deterministic across OS-level directory orderings.
|
|
1808
2346
|
*/
|
|
1809
|
-
function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
2347
|
+
function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
|
|
1810
2348
|
let complete = true;
|
|
1811
|
-
const
|
|
2349
|
+
const failures = [];
|
|
1812
2350
|
const files = [];
|
|
2351
|
+
// Collected in one pass, then digested in a second. A directory's digest has
|
|
2352
|
+
// to know whether each child directory can hold program inputs, and the walk
|
|
2353
|
+
// learns that only after descending, so the two cannot be one pass.
|
|
2354
|
+
const visited = [];
|
|
1813
2355
|
const stack = [root];
|
|
1814
2356
|
while (stack.length !== 0) {
|
|
1815
2357
|
const current = stack.pop();
|
|
1816
2358
|
const before = projectDirectorySignature(current, filesystem);
|
|
1817
2359
|
if (before === undefined) {
|
|
1818
2360
|
complete = false;
|
|
2361
|
+
failures.push({
|
|
2362
|
+
kind: "directory-metadata-unavailable",
|
|
2363
|
+
path: current,
|
|
2364
|
+
});
|
|
1819
2365
|
continue;
|
|
1820
2366
|
}
|
|
1821
2367
|
let entries;
|
|
@@ -1824,39 +2370,112 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
|
1824
2370
|
}
|
|
1825
2371
|
catch {
|
|
1826
2372
|
complete = false;
|
|
2373
|
+
failures.push({ kind: "directory-read-failed", path: current });
|
|
1827
2374
|
continue;
|
|
1828
2375
|
}
|
|
1829
2376
|
const after = projectDirectorySignature(current, filesystem);
|
|
1830
2377
|
if (after === undefined || before !== after) {
|
|
1831
2378
|
complete = false;
|
|
2379
|
+
failures.push({
|
|
2380
|
+
kind: after === undefined
|
|
2381
|
+
? "directory-metadata-unavailable"
|
|
2382
|
+
: "directory-changed-during-walk",
|
|
2383
|
+
path: current,
|
|
2384
|
+
});
|
|
1832
2385
|
}
|
|
1833
|
-
|
|
2386
|
+
const visit = {
|
|
2387
|
+
childDirectories: [],
|
|
2388
|
+
entries: [],
|
|
2389
|
+
ownInput: false,
|
|
1834
2390
|
path: current,
|
|
1835
2391
|
// If membership moved during enumeration, force the next delivery to
|
|
1836
2392
|
// replace this generation instead of blessing a torn directory/file
|
|
1837
2393
|
// snapshot as stable.
|
|
1838
|
-
|
|
1839
|
-
?
|
|
2394
|
+
stable: after !== undefined && before === after
|
|
2395
|
+
? undefined
|
|
1840
2396
|
: `unstable:${before}:${after ?? "missing"}`,
|
|
1841
|
-
}
|
|
2397
|
+
};
|
|
1842
2398
|
for (const entry of entries) {
|
|
1843
2399
|
if (isIgnoredProjectDirectory(entry.name)) {
|
|
1844
2400
|
continue;
|
|
1845
2401
|
}
|
|
1846
2402
|
const file = path.join(current, entry.name);
|
|
2403
|
+
if (entry.isDirectory() && isExcludedProjectDirectory(file, policy)) {
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
const possible = isPossibleProgramEntry(entry, policy);
|
|
2407
|
+
visit.entries.push({
|
|
2408
|
+
kind: [
|
|
2409
|
+
entry.isDirectory(),
|
|
2410
|
+
entry.isFile(),
|
|
2411
|
+
entry.isSymbolicLink(),
|
|
2412
|
+
].join(":"),
|
|
2413
|
+
name: entry.name,
|
|
2414
|
+
possible,
|
|
2415
|
+
});
|
|
1847
2416
|
if (entry.isDirectory()) {
|
|
2417
|
+
visit.childDirectories.push(file);
|
|
1848
2418
|
stack.push(file);
|
|
1849
2419
|
}
|
|
1850
|
-
else if (entry.isFile()) {
|
|
2420
|
+
else if (entry.isFile() && possible) {
|
|
2421
|
+
// Only a file that could enter the program is hashed. A file that
|
|
2422
|
+
// could not is either irrelevant to every generation, or it is one the
|
|
2423
|
+
// compiler actually read, in which case the graph reports it and
|
|
2424
|
+
// `isProjectWalkPath` now agrees it is out of the walk, so it is
|
|
2425
|
+
// recorded and proven by the out-of-walk snapshot instead. Hashing an
|
|
2426
|
+
// emitted tree here bought nothing and cost a read per file, including
|
|
2427
|
+
// in `@ttsc/metro`, whose fingerprint re-keys every transformed file
|
|
2428
|
+
// (samchon/ttsc#1307).
|
|
1851
2429
|
files.push(file);
|
|
2430
|
+
visit.ownInput = true;
|
|
1852
2431
|
}
|
|
1853
2432
|
}
|
|
2433
|
+
visited.push(visit);
|
|
2434
|
+
}
|
|
2435
|
+
// A directory matters to program membership only if its subtree can hold a
|
|
2436
|
+
// program input. Propagate that up from the directories that hold one, so a
|
|
2437
|
+
// bundler creating `out/` and filling it with JavaScript a project admitting
|
|
2438
|
+
// none can never compile is not a membership change at any level: not in the
|
|
2439
|
+
// directory itself, and not in the parent that now lists it
|
|
2440
|
+
// (samchon/ttsc#1307).
|
|
2441
|
+
const byPath = new Map(visited.map((visit) => [visit.path, visit]));
|
|
2442
|
+
const relevant = new Set();
|
|
2443
|
+
for (const visit of visited) {
|
|
2444
|
+
if (!visit.ownInput) {
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
let current = visit.path;
|
|
2448
|
+
while (current !== undefined && !relevant.has(current)) {
|
|
2449
|
+
relevant.add(current);
|
|
2450
|
+
const parent = path.dirname(current);
|
|
2451
|
+
current = parent === current || !byPath.has(parent) ? undefined : parent;
|
|
2452
|
+
}
|
|
1854
2453
|
}
|
|
2454
|
+
const directories = visited.map((visit) => {
|
|
2455
|
+
const membership = visit.entries
|
|
2456
|
+
.filter((entry) => entry.possible &&
|
|
2457
|
+
(!visit.childDirectories.includes(path.join(visit.path, entry.name)) ||
|
|
2458
|
+
relevant.has(path.join(visit.path, entry.name))))
|
|
2459
|
+
.map((entry) => `${entry.name}:${entry.kind}`);
|
|
2460
|
+
return {
|
|
2461
|
+
path: visit.path,
|
|
2462
|
+
relevant: relevant.has(visit.path),
|
|
2463
|
+
signature: visit.stable ??
|
|
2464
|
+
hashText(membership.sort().join(String.fromCharCode(0))),
|
|
2465
|
+
};
|
|
2466
|
+
});
|
|
1855
2467
|
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
1856
2468
|
files.sort();
|
|
1857
|
-
return { complete, directories, files };
|
|
2469
|
+
return { complete, directories, failures, files };
|
|
1858
2470
|
}
|
|
1859
|
-
/**
|
|
2471
|
+
/**
|
|
2472
|
+
* Return a directory's metadata stamp, used to detect that its membership moved
|
|
2473
|
+
* _while_ the walk was enumerating it, and to feed the observed-clock floor.
|
|
2474
|
+
*
|
|
2475
|
+
* This is the right instrument for that job and the wrong one for comparing two
|
|
2476
|
+
* generations: it moves for ignored entries too. {@link walkProjectInputs}
|
|
2477
|
+
* records the filtered membership digest for the comparison instead.
|
|
2478
|
+
*/
|
|
1860
2479
|
function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1861
2480
|
try {
|
|
1862
2481
|
const stats = filesystem.statBigInt(directory);
|
|
@@ -1881,9 +2500,24 @@ function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OP
|
|
|
1881
2500
|
}
|
|
1882
2501
|
/** Compare two deterministic project-directory membership snapshots. */
|
|
1883
2502
|
function sameProjectDirectories(left, right) {
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2503
|
+
// Compare only the directories that can hold program inputs, on either side.
|
|
2504
|
+
// A directory irrelevant on both is not part of the program's membership at
|
|
2505
|
+
// all, so its appearance, disappearance or churn says nothing: that is a
|
|
2506
|
+
// bundler's output tree. One that gained or lost relevance is present in the
|
|
2507
|
+
// comparison from the side where it counts, and so is caught.
|
|
2508
|
+
const select = (snapshots) => new Map(snapshots
|
|
2509
|
+
.filter((directory) => directory.relevant)
|
|
2510
|
+
.map((directory) => [directory.path, directory]));
|
|
2511
|
+
const leftRelevant = select(left);
|
|
2512
|
+
const rightRelevant = select(right);
|
|
2513
|
+
const paths = new Set([...leftRelevant.keys(), ...rightRelevant.keys()]);
|
|
2514
|
+
for (const location of paths) {
|
|
2515
|
+
if (leftRelevant.get(location)?.signature !==
|
|
2516
|
+
rightRelevant.get(location)?.signature) {
|
|
2517
|
+
return false;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return true;
|
|
1887
2521
|
}
|
|
1888
2522
|
/**
|
|
1889
2523
|
* Open one directory's change notification through the cache-owned watch seam,
|
|
@@ -1900,14 +2534,16 @@ function openDirectoryWatch(filesystem, directory, listener, onError) {
|
|
|
1900
2534
|
return { close: () => watcher.close() };
|
|
1901
2535
|
}
|
|
1902
2536
|
/** Watch every walked directory for membership changes after generation. */
|
|
1903
|
-
async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
2537
|
+
async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
|
|
1904
2538
|
const tracker = {
|
|
2539
|
+
changes: new Set(),
|
|
2540
|
+
changesOmitted: false,
|
|
1905
2541
|
close: () => undefined,
|
|
1906
2542
|
failed: false,
|
|
1907
2543
|
membershipChanged: false,
|
|
1908
2544
|
};
|
|
1909
2545
|
if (process.platform === "win32" && filesystem.watch === undefined) {
|
|
1910
|
-
await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
|
|
2546
|
+
await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem, (location, filename) => reportsProgramMembership(path.join(location, filename), filename, policy, filesystem));
|
|
1911
2547
|
return tracker;
|
|
1912
2548
|
}
|
|
1913
2549
|
const watchers = [];
|
|
@@ -1918,9 +2554,17 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
|
|
|
1918
2554
|
};
|
|
1919
2555
|
for (const directory of directories) {
|
|
1920
2556
|
try {
|
|
1921
|
-
watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
|
|
1922
|
-
if (eventType
|
|
1923
|
-
|
|
2557
|
+
watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType, filename) => {
|
|
2558
|
+
if (eventType !== "rename") {
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
if (filename !== null &&
|
|
2562
|
+
!reportsProgramMembership(path.join(directory.path, filename), filename, policy, filesystem)) {
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
recordProjectMutation(tracker, filename === null
|
|
2566
|
+
? directory.path
|
|
2567
|
+
: path.join(directory.path, filename));
|
|
1924
2568
|
}, () => {
|
|
1925
2569
|
tracker.failed = true;
|
|
1926
2570
|
}));
|
|
@@ -1956,6 +2600,8 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
|
|
|
1956
2600
|
names: [...location.names],
|
|
1957
2601
|
}));
|
|
1958
2602
|
const tracker = {
|
|
2603
|
+
changes: new Set(),
|
|
2604
|
+
changesOmitted: false,
|
|
1959
2605
|
close: () => undefined,
|
|
1960
2606
|
// Coverage is the caller's claim, and it is required rather than derived
|
|
1961
2607
|
// from the input list: an input is watched by its exact name here, but only
|
|
@@ -1989,7 +2635,9 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
|
|
|
1989
2635
|
? null
|
|
1990
2636
|
: normalizeHostInputName(filename, caseSensitive);
|
|
1991
2637
|
if (reported === null || names.has(reported)) {
|
|
1992
|
-
tracker
|
|
2638
|
+
recordProjectMutation(tracker, filename === null
|
|
2639
|
+
? location.directory
|
|
2640
|
+
: path.join(location.directory, filename));
|
|
1993
2641
|
}
|
|
1994
2642
|
}, () => {
|
|
1995
2643
|
tracker.failed = true;
|
|
@@ -2001,6 +2649,92 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
|
|
|
2001
2649
|
}
|
|
2002
2650
|
return tracker;
|
|
2003
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Whether a path lies inside a directory the configuration excludes.
|
|
2654
|
+
*
|
|
2655
|
+
* Lexical, exactly like the walk and like {@link isProjectWalkPath}, and for the
|
|
2656
|
+
* reason that predicate states: walk membership is lexical, so resolving a path
|
|
2657
|
+
* to physical identity first would collapse two spellings the walk keeps apart
|
|
2658
|
+
* and claim it covered a subtree it never followed. A junction whose target the
|
|
2659
|
+
* walk hashes under its own name is exactly that, and canonicalizing here would
|
|
2660
|
+
* suppress every event in it.
|
|
2661
|
+
*
|
|
2662
|
+
* `strictly` excludes an exact match, for the case where the excluded entry
|
|
2663
|
+
* names a file rather than a directory: `exclude` accepts one, the walk applies
|
|
2664
|
+
* exclusion to directories alone, so that file is still hashed and its events
|
|
2665
|
+
* must keep counting.
|
|
2666
|
+
*/
|
|
2667
|
+
function insideExcludedProjectDirectory(location, policy, strictly) {
|
|
2668
|
+
if (policy.excludedDirectories.length === 0) {
|
|
2669
|
+
return false;
|
|
2670
|
+
}
|
|
2671
|
+
const resolved = path.resolve(location);
|
|
2672
|
+
return policy.excludedDirectories.some((excluded) => {
|
|
2673
|
+
const target = path.resolve(excluded);
|
|
2674
|
+
if (strictly && target === resolved) {
|
|
2675
|
+
return false;
|
|
2676
|
+
}
|
|
2677
|
+
return pathIsWithin(resolved, target);
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Whether one directory event can be a change to the program's membership.
|
|
2682
|
+
*
|
|
2683
|
+
* The live tracker has to answer the same question the membership digest does,
|
|
2684
|
+
* or the two disagree about the same project: a bundler writing content-hashed
|
|
2685
|
+
* output fires a rename per rebuild, and treating that as membership kept the
|
|
2686
|
+
* cost samchon/ttsc#1307 removes on every host that has no build boundary,
|
|
2687
|
+
* which is every host the narrow path exists for.
|
|
2688
|
+
*
|
|
2689
|
+
* A name that could be a program input counts, unless it sits under a directory
|
|
2690
|
+
* the walk never descends into. A name that could not still counts when the
|
|
2691
|
+
* path is now a directory, because the walk's watches were opened for the
|
|
2692
|
+
* directories that existed when the generation was captured, so a directory
|
|
2693
|
+
* created since is not watched and the sources that may appear in it would
|
|
2694
|
+
* otherwise be invisible. A directory the configuration excludes is the
|
|
2695
|
+
* exception: the walk cannot see inside it, so the tracker must not either, or
|
|
2696
|
+
* emptying and recreating an `outDir` costs a compile per build. An event whose
|
|
2697
|
+
* name the host did not report is unattributable and always counts.
|
|
2698
|
+
*/
|
|
2699
|
+
function reportsProgramMembership(location, filename, policy, filesystem) {
|
|
2700
|
+
if (isPossibleProgramFileName(filename, policy)) {
|
|
2701
|
+
// A name the program could admit. It still says nothing if it lies inside a
|
|
2702
|
+
// directory the walk never descends into, because the digest cannot see
|
|
2703
|
+
// there either and the tracker must not be the one side that reacts.
|
|
2704
|
+
return !insideExcludedProjectDirectory(location, policy, true);
|
|
2705
|
+
}
|
|
2706
|
+
let directory;
|
|
2707
|
+
try {
|
|
2708
|
+
directory = filesystem.lstat(location).isDirectory();
|
|
2709
|
+
}
|
|
2710
|
+
catch {
|
|
2711
|
+
// Gone again, or unreadable. Its name could not have been a program input,
|
|
2712
|
+
// and a directory removed under this one reports its own contents leaving
|
|
2713
|
+
// through the watch that was opened on it.
|
|
2714
|
+
return false;
|
|
2715
|
+
}
|
|
2716
|
+
if (!directory) {
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
// A directory counts, because it can hold sources and the tracker is not
|
|
2720
|
+
// watching it yet, unless the configuration says the program does not contain
|
|
2721
|
+
// it. Emptying and recreating an `outDir`, which is what `emptyOutDir` and
|
|
2722
|
+
// `output.clean` do on every build, would otherwise void the generation once
|
|
2723
|
+
// per build on every host that has no build boundary.
|
|
2724
|
+
return !insideExcludedProjectDirectory(location, policy, false);
|
|
2725
|
+
}
|
|
2726
|
+
/** Record enough exact mutation evidence without retaining an event stream. */
|
|
2727
|
+
function recordProjectMutation(tracker, changed) {
|
|
2728
|
+
tracker.membershipChanged = true;
|
|
2729
|
+
if (tracker.changes.has(changed))
|
|
2730
|
+
return;
|
|
2731
|
+
if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
|
|
2732
|
+
tracker.changes.add(changed);
|
|
2733
|
+
}
|
|
2734
|
+
else {
|
|
2735
|
+
tracker.changesOmitted = true;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2004
2738
|
let windowsProjectMutationBroker;
|
|
2005
2739
|
/**
|
|
2006
2740
|
* Register directory watches in an isolated Windows process.
|
|
@@ -2009,8 +2743,21 @@ let windowsProjectMutationBroker;
|
|
|
2009
2743
|
* temporary tree is deleted. Isolation turns that unrecoverable process abort
|
|
2010
2744
|
* into an ordinary broker exit and a conservative cache miss in the host.
|
|
2011
2745
|
*/
|
|
2012
|
-
async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem
|
|
2746
|
+
async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem,
|
|
2747
|
+
/**
|
|
2748
|
+
* Optional filter for the project-directory tracker, whose events have to be
|
|
2749
|
+
* narrowed to program membership exactly as the in-process watcher's are. The
|
|
2750
|
+
* name-watching trackers pass none, since they already watch exact names.
|
|
2751
|
+
*/
|
|
2752
|
+
membership) {
|
|
2013
2753
|
const broker = getWindowsProjectMutationBroker();
|
|
2754
|
+
// The child watches canonical directories, and reports its events under that
|
|
2755
|
+
// spelling. Everything else in the adapter speaks the walk's own spelling,
|
|
2756
|
+
// which on Windows can be an 8.3 short form of the same directory, so keep
|
|
2757
|
+
// the way back: a filter that compared the child's spelling against the
|
|
2758
|
+
// configuration's would be comparing two names for one directory that share
|
|
2759
|
+
// no common prefix (samchon/ttsc#1307).
|
|
2760
|
+
const spellings = new Map();
|
|
2014
2761
|
const normalized = locations.map((location) => {
|
|
2015
2762
|
let directory;
|
|
2016
2763
|
try {
|
|
@@ -2019,6 +2766,7 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
|
|
|
2019
2766
|
catch {
|
|
2020
2767
|
directory = path.resolve(location.directory);
|
|
2021
2768
|
}
|
|
2769
|
+
spellings.set(directory, location.directory);
|
|
2022
2770
|
return {
|
|
2023
2771
|
directory,
|
|
2024
2772
|
...(location.names === undefined ? {} : { names: location.names }),
|
|
@@ -2032,7 +2780,12 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
|
|
|
2032
2780
|
const ready = new Promise((resolve) => {
|
|
2033
2781
|
resolveReady = resolve;
|
|
2034
2782
|
});
|
|
2035
|
-
broker.trackers.set(id, {
|
|
2783
|
+
broker.trackers.set(id, {
|
|
2784
|
+
membership,
|
|
2785
|
+
ready: resolveReady,
|
|
2786
|
+
spellings,
|
|
2787
|
+
tracker,
|
|
2788
|
+
});
|
|
2036
2789
|
tracker.drain = () => drainWindowsProjectMutationBroker(broker);
|
|
2037
2790
|
tracker.close = () => {
|
|
2038
2791
|
const active = broker.trackers.get(id);
|
|
@@ -2126,7 +2879,22 @@ function getWindowsProjectMutationBroker() {
|
|
|
2126
2879
|
if (record.ready === true)
|
|
2127
2880
|
registration.ready();
|
|
2128
2881
|
if (record.ready !== true && record.failed !== true) {
|
|
2129
|
-
|
|
2882
|
+
if (typeof record.directory === "string") {
|
|
2883
|
+
// The walk's spelling for this directory, which is what every
|
|
2884
|
+
// comparison and every recorded witness downstream expects.
|
|
2885
|
+
const reported = registration.spellings.get(record.directory) ?? record.directory;
|
|
2886
|
+
if (typeof record.filename === "string" &&
|
|
2887
|
+
registration.membership !== undefined &&
|
|
2888
|
+
!registration.membership(reported, record.filename)) {
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
recordProjectMutation(registration.tracker, typeof record.filename === "string"
|
|
2892
|
+
? path.join(reported, record.filename)
|
|
2893
|
+
: reported);
|
|
2894
|
+
}
|
|
2895
|
+
else {
|
|
2896
|
+
registration.tracker.membershipChanged = true;
|
|
2897
|
+
}
|
|
2130
2898
|
}
|
|
2131
2899
|
});
|
|
2132
2900
|
windowsProjectMutationBroker = broker;
|
|
@@ -2210,7 +2978,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
|
2210
2978
|
" const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
|
|
2211
2979
|
" const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
|
|
2212
2980
|
" const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
|
|
2213
|
-
' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
|
|
2981
|
+
' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
|
|
2214
2982
|
" });",
|
|
2215
2983
|
' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
|
|
2216
2984
|
" watchers.push(watcher);",
|
|
@@ -2301,7 +3069,7 @@ async function settleProjectMutationEvents(cached) {
|
|
|
2301
3069
|
* Missing paths and files reached through symlinks or Windows junctions are
|
|
2302
3070
|
* out-of-walk inputs that only the reference graph can prove relevant.
|
|
2303
3071
|
*/
|
|
2304
|
-
function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
3072
|
+
function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
|
|
2305
3073
|
// Walk membership is lexical. Resolving `file` to physical identity first
|
|
2306
3074
|
// would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
|
|
2307
3075
|
// symlink segment from the lstat loop below, and falsely claim the project
|
|
@@ -2315,7 +3083,20 @@ function isProjectWalkPath(root, file, _identities = createHostPathIdentityConte
|
|
|
2315
3083
|
return false;
|
|
2316
3084
|
}
|
|
2317
3085
|
const segments = relative.split(path.sep);
|
|
2318
|
-
|
|
3086
|
+
// The last segment is the file itself, which the walk names rather than
|
|
3087
|
+
// descends into, so only the directory components decide walk membership.
|
|
3088
|
+
if (segments.slice(0, -1).some(isIgnoredProjectDirectory)) {
|
|
3089
|
+
return false;
|
|
3090
|
+
}
|
|
3091
|
+
if (isExcludedProjectDirectory(path.dirname(path.resolve(file)), policy)) {
|
|
3092
|
+
return false;
|
|
3093
|
+
}
|
|
3094
|
+
// The walk hashes only files that could enter the program, so a path it does
|
|
3095
|
+
// not hash is out of the walk by definition. Answering otherwise would leave
|
|
3096
|
+
// a graph input the compiler really read in neither snapshot: absent from
|
|
3097
|
+
// `inputHashes` because the walk skipped it, and absent from the out-of-walk
|
|
3098
|
+
// snapshot because this predicate claimed the walk covered it.
|
|
3099
|
+
if (!isPossibleProgramFileName(path.basename(file), policy)) {
|
|
2319
3100
|
return false;
|
|
2320
3101
|
}
|
|
2321
3102
|
let current = resolvedRoot;
|
|
@@ -2417,12 +3198,13 @@ function matchesCachedExternalInputs(cached) {
|
|
|
2417
3198
|
}
|
|
2418
3199
|
/**
|
|
2419
3200
|
* Derive the absolute out-of-walk input set of a whole project transform: the
|
|
2420
|
-
* union of every reference-graph member (edge keys and
|
|
2421
|
-
* config chain) and
|
|
2422
|
-
* project walk already hashes and the disposed
|
|
2423
|
-
* inputs {@link matchesCachedSource}'s walk cannot see.
|
|
2424
|
-
* that are still missing remain in this set even under
|
|
2425
|
-
* first walk cannot hash a file that has not been created
|
|
3201
|
+
* union of every transformed source key, reference-graph member (edge keys and
|
|
3202
|
+
* targets, globals, the config chain), and plugin-reported dependency, minus
|
|
3203
|
+
* everything the project walk already hashes and the disposed transform scratch
|
|
3204
|
+
* tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
|
|
3205
|
+
* Resolution candidates that are still missing remain in this set even under
|
|
3206
|
+
* the project root: the first walk cannot hash a file that has not been created
|
|
3207
|
+
* yet.
|
|
2426
3208
|
*
|
|
2427
3209
|
* A `dependenciesComplete` declaration deliberately does not narrow the stored
|
|
2428
3210
|
* set: other files in the same whole-project result can still own the omitted
|
|
@@ -2439,6 +3221,10 @@ function selectExternalInputPaths(props) {
|
|
|
2439
3221
|
const identities = createHostPathIdentityContext(filesystem);
|
|
2440
3222
|
const resolutionCandidates = new Set();
|
|
2441
3223
|
const graph = props.result.graph;
|
|
3224
|
+
// Every transform output key names the source file whose transformed text it
|
|
3225
|
+
// carries. Keep an out-of-walk source in the external snapshot instead of
|
|
3226
|
+
// injecting it into the project-walk key universe (samchon/ttsc#252).
|
|
3227
|
+
members.push(...Object.keys(props.result.typescript));
|
|
2442
3228
|
if (graph !== undefined) {
|
|
2443
3229
|
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
2444
3230
|
members.push(source);
|
|
@@ -2495,9 +3281,10 @@ function selectExternalInputPaths(props) {
|
|
|
2495
3281
|
const identity = pathIdentityKey(absolute, identities);
|
|
2496
3282
|
const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
|
|
2497
3283
|
if (identity === excluded ||
|
|
3284
|
+
isTransformScratchInput(absolute, props.scratchDirectory) ||
|
|
2498
3285
|
seen.has(spelling) ||
|
|
2499
3286
|
(!missingCandidate &&
|
|
2500
|
-
isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
|
|
3287
|
+
isProjectWalkPath(props.projectRoot, absolute, identities, filesystem, props.membershipPolicy))) {
|
|
2501
3288
|
continue;
|
|
2502
3289
|
}
|
|
2503
3290
|
// Preserve distinct lexical aliases even when they currently select the
|
|
@@ -2558,6 +3345,7 @@ function selectNotifiableAbsentInputs(props) {
|
|
|
2558
3345
|
const absolute = path.resolve(props.projectRoot, candidate);
|
|
2559
3346
|
const spelling = path.resolve(absolute);
|
|
2560
3347
|
if (seen.has(spelling) ||
|
|
3348
|
+
isTransformScratchInput(absolute, props.scratchDirectory) ||
|
|
2561
3349
|
(excluded !== undefined &&
|
|
2562
3350
|
pathIdentityKey(absolute, identities) === excluded) ||
|
|
2563
3351
|
props.filesystem.exists(absolute)) {
|
|
@@ -2659,21 +3447,44 @@ function insideProject(directory, projectRoot) {
|
|
|
2659
3447
|
*/
|
|
2660
3448
|
const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
|
|
2661
3449
|
function isIgnoredProjectDirectory(name) {
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3450
|
+
// The residue of what used to be a fifteen-name list, kept to the three
|
|
3451
|
+
// directories no tsconfig can name and no program can contain: the VCS
|
|
3452
|
+
// store, the package manager's tree (TypeScript's own default `exclude`
|
|
3453
|
+
// carries it too), and ttsc's own plugin cache. Everything else the old list
|
|
3454
|
+
// guessed at, and guessing was wrong in both directions: a bundler writing
|
|
3455
|
+
// to an unnamed directory changed project membership with its own output,
|
|
3456
|
+
// while a real source directory named `build` or `temp` was dropped from the
|
|
3457
|
+
// walk and its new files were never seen (samchon/ttsc#1307). Those are now
|
|
3458
|
+
// decided by `ITtscProjectMembershipPolicy`, which reads the configuration
|
|
3459
|
+
// that actually knows.
|
|
3460
|
+
return name === ".git" || name === ".ttsc" || name === "node_modules";
|
|
3461
|
+
}
|
|
3462
|
+
/**
|
|
3463
|
+
* Whether the resolved configuration keeps this directory out of the program.
|
|
3464
|
+
*
|
|
3465
|
+
* Compared by physical containment rather than by name, so `outDir: "./dist"`
|
|
3466
|
+
* excludes that one directory instead of every directory called `dist` at every
|
|
3467
|
+
* depth, which is the distinction the name list could not draw.
|
|
3468
|
+
*/
|
|
3469
|
+
function isExcludedProjectDirectory(directory, policy) {
|
|
3470
|
+
return insideExcludedProjectDirectory(directory, policy, false);
|
|
3471
|
+
}
|
|
3472
|
+
/**
|
|
3473
|
+
* Whether this entry could enter the program, and so whether its appearance or
|
|
3474
|
+
* removal is a membership change.
|
|
3475
|
+
*
|
|
3476
|
+
* A directory always could, since it can hold sources. A file could only if it
|
|
3477
|
+
* carries an extension the resolved configuration admits, which is what makes a
|
|
3478
|
+
* bundle emitted beside the sources invisible to a project that compiles no
|
|
3479
|
+
* JavaScript.
|
|
3480
|
+
*/
|
|
3481
|
+
function isPossibleProgramEntry(entry, policy) {
|
|
3482
|
+
return entry.isFile() ? isPossibleProgramFileName(entry.name, policy) : true;
|
|
3483
|
+
}
|
|
3484
|
+
/** The same question for a bare file name, for callers holding no `Dirent`. */
|
|
3485
|
+
function isPossibleProgramFileName(name, policy) {
|
|
3486
|
+
const lowered = name.toLowerCase();
|
|
3487
|
+
return policy.inputExtensions.some((extension) => lowered.endsWith(extension));
|
|
2677
3488
|
}
|
|
2678
3489
|
/**
|
|
2679
3490
|
* Compare two project-walk snapshots.
|
|
@@ -2730,6 +3541,103 @@ function walkSnapshotComplete(snapshot, declared) {
|
|
|
2730
3541
|
}
|
|
2731
3542
|
return true;
|
|
2732
3543
|
}
|
|
3544
|
+
/** Preserve exact project-walk and mutation witnesses for one failed attempt. */
|
|
3545
|
+
function recordProjectSnapshotFailures(failures, props) {
|
|
3546
|
+
const recordWalk = (snapshot) => {
|
|
3547
|
+
for (const failure of snapshot.walkFailures) {
|
|
3548
|
+
if (failure.kind.startsWith("file-") && props.declared !== undefined) {
|
|
3549
|
+
try {
|
|
3550
|
+
const key = toProjectKey(props.projectRoot, failure.path, props.identities);
|
|
3551
|
+
if (!props.declared.has(key))
|
|
3552
|
+
continue;
|
|
3553
|
+
}
|
|
3554
|
+
catch {
|
|
3555
|
+
// An unidentifiable failed input taints the complete project walk.
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
recordGenerationProofFailure(failures, {
|
|
3559
|
+
domain: "project",
|
|
3560
|
+
kind: failure.kind,
|
|
3561
|
+
path: failure.path,
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
};
|
|
3565
|
+
recordWalk(props.before);
|
|
3566
|
+
recordWalk(props.snapshot);
|
|
3567
|
+
const keys = props.declared ??
|
|
3568
|
+
new Set([
|
|
3569
|
+
...Object.keys(props.before.hashes),
|
|
3570
|
+
...Object.keys(props.snapshot.hashes),
|
|
3571
|
+
]);
|
|
3572
|
+
for (const key of keys) {
|
|
3573
|
+
if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
|
|
3574
|
+
recordGenerationProofFailure(failures, {
|
|
3575
|
+
domain: "project",
|
|
3576
|
+
kind: "input-content-changed",
|
|
3577
|
+
path: path.resolve(props.projectRoot, key),
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
if (props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]) {
|
|
3581
|
+
recordGenerationProofFailure(failures, {
|
|
3582
|
+
domain: "project",
|
|
3583
|
+
kind: "input-metadata-changed",
|
|
3584
|
+
path: path.resolve(props.projectRoot, key),
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
const leftDirectories = new Map(props.before.projectDirectories.map((entry) => [
|
|
3589
|
+
entry.path,
|
|
3590
|
+
entry.signature,
|
|
3591
|
+
]));
|
|
3592
|
+
const rightDirectories = new Map(props.snapshot.projectDirectories.map((entry) => [
|
|
3593
|
+
entry.path,
|
|
3594
|
+
entry.signature,
|
|
3595
|
+
]));
|
|
3596
|
+
for (const directory of new Set([
|
|
3597
|
+
...leftDirectories.keys(),
|
|
3598
|
+
...rightDirectories.keys(),
|
|
3599
|
+
])) {
|
|
3600
|
+
if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
|
|
3601
|
+
recordGenerationProofFailure(failures, {
|
|
3602
|
+
domain: "project",
|
|
3603
|
+
kind: "directory-membership-changed",
|
|
3604
|
+
path: directory,
|
|
3605
|
+
});
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
const recordTracker = (tracker, kind) => {
|
|
3609
|
+
if (tracker?.membershipChanged !== true)
|
|
3610
|
+
return;
|
|
3611
|
+
if (tracker.changes.size === 0) {
|
|
3612
|
+
recordGenerationProofFailure(failures, {
|
|
3613
|
+
domain: "project",
|
|
3614
|
+
kind,
|
|
3615
|
+
path: props.projectRoot,
|
|
3616
|
+
});
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
for (const changed of tracker.changes) {
|
|
3620
|
+
recordGenerationProofFailure(failures, {
|
|
3621
|
+
domain: "project",
|
|
3622
|
+
kind,
|
|
3623
|
+
path: changed,
|
|
3624
|
+
});
|
|
3625
|
+
}
|
|
3626
|
+
if (tracker.changesOmitted) {
|
|
3627
|
+
failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
|
|
3628
|
+
}
|
|
3629
|
+
};
|
|
3630
|
+
recordTracker(props.tracker, "project-membership-event");
|
|
3631
|
+
recordTracker(props.hostInputTracker, "host-input-event");
|
|
3632
|
+
recordTracker(props.candidateTracker, "candidate-event");
|
|
3633
|
+
if (failures.entries.length === 0) {
|
|
3634
|
+
recordGenerationProofFailure(failures, {
|
|
3635
|
+
domain: "project",
|
|
3636
|
+
kind: "snapshot-incomplete",
|
|
3637
|
+
path: props.projectRoot,
|
|
3638
|
+
});
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
2733
3641
|
/** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
|
|
2734
3642
|
function declaredProjectInputKeys(state, cached) {
|
|
2735
3643
|
if (state.declaredInputKeysBuilt !== true) {
|
|
@@ -2737,6 +3645,7 @@ function declaredProjectInputKeys(state, cached) {
|
|
|
2737
3645
|
identities: state.identityContext,
|
|
2738
3646
|
projectRoot: cached.projectRoot,
|
|
2739
3647
|
result: cached.result,
|
|
3648
|
+
scratchDirectory: cached.scratchDirectory,
|
|
2740
3649
|
});
|
|
2741
3650
|
state.declaredInputKeysBuilt = true;
|
|
2742
3651
|
}
|
|
@@ -2757,7 +3666,10 @@ function selectDeclaredProjectInputKeys(props) {
|
|
|
2757
3666
|
const add = (entry) => {
|
|
2758
3667
|
if (typeof entry !== "string" || entry.length === 0)
|
|
2759
3668
|
return;
|
|
2760
|
-
|
|
3669
|
+
const absolute = path.resolve(props.projectRoot, entry);
|
|
3670
|
+
if (isTransformScratchInput(absolute, props.scratchDirectory))
|
|
3671
|
+
return;
|
|
3672
|
+
keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
|
|
2761
3673
|
};
|
|
2762
3674
|
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
2763
3675
|
add(source);
|
|
@@ -2789,46 +3701,231 @@ function selectDeclaredProjectInputKeys(props) {
|
|
|
2789
3701
|
}
|
|
2790
3702
|
return keys;
|
|
2791
3703
|
}
|
|
3704
|
+
/** Create an empty bounded witness collection for one transform attempt. */
|
|
3705
|
+
function createGenerationProofFailures() {
|
|
3706
|
+
return { entries: [], omitted: 0, seen: new Set() };
|
|
3707
|
+
}
|
|
3708
|
+
/** Retain one unique proof witness without allowing diagnostics to grow freely. */
|
|
3709
|
+
function recordGenerationProofFailure(failures, failure) {
|
|
3710
|
+
const key = JSON.stringify([
|
|
3711
|
+
failure.domain,
|
|
3712
|
+
failure.kind,
|
|
3713
|
+
failure.path,
|
|
3714
|
+
failure.detail,
|
|
3715
|
+
]);
|
|
3716
|
+
if (failures.seen.has(key))
|
|
3717
|
+
return;
|
|
3718
|
+
if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
|
|
3719
|
+
// `seen` follows the same bound as `entries`: retaining every discarded
|
|
3720
|
+
// identity would make a bounded diagnostic an unbounded memory sink.
|
|
3721
|
+
failures.seen.add(key);
|
|
3722
|
+
failures.entries.push(failure);
|
|
3723
|
+
}
|
|
3724
|
+
else {
|
|
3725
|
+
failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
/** Fold one bounded witness collection into another. */
|
|
3729
|
+
function mergeGenerationProofFailures(target, source) {
|
|
3730
|
+
for (const failure of source.entries) {
|
|
3731
|
+
recordGenerationProofFailure(target, failure);
|
|
3732
|
+
}
|
|
3733
|
+
target.omitted = Math.min(Number.MAX_SAFE_INTEGER, target.omitted + source.omitted);
|
|
3734
|
+
}
|
|
3735
|
+
/** Hash the declared-input-relevant failure shape without retaining it. */
|
|
3736
|
+
function projectWalkFailureFingerprint(snapshot, declared, projectRoot, identities) {
|
|
3737
|
+
const relevantUnstableFiles = declared === undefined
|
|
3738
|
+
? [...snapshot.unstableFiles]
|
|
3739
|
+
: [...snapshot.unstableFiles].filter((key) => declared.has(key));
|
|
3740
|
+
const relevantFailures = snapshot.walkFailures.filter((failure) => {
|
|
3741
|
+
if (!failure.kind.startsWith("file-"))
|
|
3742
|
+
return true;
|
|
3743
|
+
if (declared === undefined)
|
|
3744
|
+
return true;
|
|
3745
|
+
try {
|
|
3746
|
+
return declared.has(toProjectKey(projectRoot, failure.path, identities));
|
|
3747
|
+
}
|
|
3748
|
+
catch {
|
|
3749
|
+
return true;
|
|
3750
|
+
}
|
|
3751
|
+
});
|
|
3752
|
+
return hashText(JSON.stringify({
|
|
3753
|
+
complete: walkSnapshotComplete(snapshot, declared),
|
|
3754
|
+
directoryComplete: snapshot.directoryComplete,
|
|
3755
|
+
failures: relevantFailures
|
|
3756
|
+
.map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
|
|
3757
|
+
.sort(),
|
|
3758
|
+
unstableFiles: relevantUnstableFiles.sort(),
|
|
3759
|
+
}));
|
|
3760
|
+
}
|
|
3761
|
+
/** Compact state of one exact out-of-walk input in a failed generation. */
|
|
3762
|
+
function failedGenerationInputState(input, filesystem) {
|
|
3763
|
+
let directory = "not-directory";
|
|
3764
|
+
try {
|
|
3765
|
+
if (filesystem.stat(input).isDirectory()) {
|
|
3766
|
+
directory = hashText(filesystem
|
|
3767
|
+
.readdir(input)
|
|
3768
|
+
.map((entry) => [
|
|
3769
|
+
entry.name,
|
|
3770
|
+
entry.isDirectory(),
|
|
3771
|
+
entry.isFile(),
|
|
3772
|
+
entry.isSymbolicLink(),
|
|
3773
|
+
].join(":"))
|
|
3774
|
+
.sort()
|
|
3775
|
+
.join("\0"));
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
catch {
|
|
3779
|
+
directory = "unavailable";
|
|
3780
|
+
}
|
|
3781
|
+
return hashText(JSON.stringify([
|
|
3782
|
+
inputMetadataSignature(input, filesystem) ?? "missing",
|
|
3783
|
+
hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
|
|
3784
|
+
hostInputRealpath(input, filesystem),
|
|
3785
|
+
directory,
|
|
3786
|
+
]));
|
|
3787
|
+
}
|
|
3788
|
+
/** Snapshot every input outside the project walk that could change a retry. */
|
|
3789
|
+
function captureFailedGenerationInputStates(cached, failures) {
|
|
3790
|
+
const filesystem = resultFilesystem(cached.result);
|
|
3791
|
+
const inputs = new Set((cached.externalInputPaths ?? []).map((input) => path.resolve(input)));
|
|
3792
|
+
for (const input of selectPersistentHostInputs({
|
|
3793
|
+
filesystem,
|
|
3794
|
+
projectRoot: cached.projectRoot,
|
|
3795
|
+
result: cached.result,
|
|
3796
|
+
scratchDirectory: cached.scratchDirectory,
|
|
3797
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
3798
|
+
})) {
|
|
3799
|
+
inputs.add(path.resolve(input));
|
|
3800
|
+
}
|
|
3801
|
+
for (const failure of failures.entries) {
|
|
3802
|
+
if (failure.path !== undefined)
|
|
3803
|
+
inputs.add(path.resolve(failure.path));
|
|
3804
|
+
}
|
|
3805
|
+
return new Map([...inputs]
|
|
3806
|
+
.sort()
|
|
3807
|
+
.map((input) => [input, failedGenerationInputState(input, filesystem)]));
|
|
3808
|
+
}
|
|
3809
|
+
/** Capture source baselines for project and out-of-walk transform outputs. */
|
|
3810
|
+
function captureTransformSourceHashes(cached, currentFile, currentSourceHash) {
|
|
3811
|
+
const filesystem = resultFilesystem(cached.result);
|
|
3812
|
+
const identities = envelopeDerivation(cached).identityContext;
|
|
3813
|
+
const hashes = {};
|
|
3814
|
+
if (cached.result.type === "success") {
|
|
3815
|
+
for (const output of Object.keys(cached.result.typescript)) {
|
|
3816
|
+
const file = path.resolve(cached.projectRoot, output);
|
|
3817
|
+
const hash = hostInputStateHash(file, filesystem);
|
|
3818
|
+
if (hash !== null)
|
|
3819
|
+
hashes[pathIdentityKey(file, identities)] = hash;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
|
|
3823
|
+
return hashes;
|
|
3824
|
+
}
|
|
2792
3825
|
/**
|
|
2793
|
-
*
|
|
2794
|
-
* the condition once instead of once per module.
|
|
2795
|
-
*/
|
|
2796
|
-
const REPORTED_UNREUSABLE_GENERATIONS = new Set();
|
|
2797
|
-
/**
|
|
2798
|
-
* Report, once per project root, that a generation cannot be reused.
|
|
3826
|
+
* Whether a terminal proof failure's observed environment actually changed.
|
|
2799
3827
|
*
|
|
2800
|
-
*
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
2803
|
-
* that never finished, and each investigation had to rediscover the cause from
|
|
2804
|
-
* outside. A named reason turns the next occurrence into a bug report instead
|
|
2805
|
-
* of an archaeology session.
|
|
3828
|
+
* This is deliberately a confirmation test: inability to re-probe retains the
|
|
3829
|
+
* old verdict instead of turning every module request into another compile.
|
|
3830
|
+
* Cache lifecycle reset remains the unconditional recovery boundary.
|
|
2806
3831
|
*/
|
|
2807
|
-
function
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
3832
|
+
function failedGenerationEnvironmentChanged(validation, props) {
|
|
3833
|
+
try {
|
|
3834
|
+
const identities = envelopeDerivation(validation.cached).identityContext;
|
|
3835
|
+
const currentSourceHash = hashText(props.currentSource);
|
|
3836
|
+
const expectedSourceHash = validation.cached.sourceHashes?.[pathIdentityKey(props.currentFile, identities)];
|
|
3837
|
+
if (expectedSourceHash !== undefined &&
|
|
3838
|
+
expectedSourceHash !== currentSourceHash) {
|
|
3839
|
+
return true;
|
|
3840
|
+
}
|
|
3841
|
+
const current = collectProjectInputSnapshot(validation.cached.projectRoot, identities, props.filesystem, undefined, { policy: validation.cached.membershipPolicy });
|
|
3842
|
+
if (validation.projectWalkComplete !==
|
|
3843
|
+
walkSnapshotComplete(current, validation.declaredInputs) ||
|
|
3844
|
+
validation.projectWalkFailures !==
|
|
3845
|
+
projectWalkFailureFingerprint(current, validation.declaredInputs, validation.cached.projectRoot, identities) ||
|
|
3846
|
+
!sameHashes(validation.projectInputHashes, current.hashes, validation.declaredInputs) ||
|
|
3847
|
+
!sameProjectDirectories(validation.cached.projectDirectories ?? [], current.projectDirectories)) {
|
|
3848
|
+
return true;
|
|
3849
|
+
}
|
|
3850
|
+
for (const [input, recorded] of validation.inputStates) {
|
|
3851
|
+
if (failedGenerationInputState(input, props.filesystem) !== recorded) {
|
|
3852
|
+
return true;
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
return false;
|
|
2819
3856
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
3857
|
+
catch {
|
|
3858
|
+
return false;
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
/** Render one input without leaking source content or control characters. */
|
|
3862
|
+
function formatGenerationFailurePath(projectRoot, input) {
|
|
3863
|
+
const absolute = path.resolve(input);
|
|
3864
|
+
const relative = path.relative(projectRoot, absolute);
|
|
3865
|
+
const display = relative === ""
|
|
3866
|
+
? "."
|
|
3867
|
+
: relative !== ".." &&
|
|
3868
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
3869
|
+
!path.isAbsolute(relative)
|
|
3870
|
+
? relative
|
|
3871
|
+
: absolute;
|
|
3872
|
+
return JSON.stringify(display.split(path.sep).join("/"));
|
|
3873
|
+
}
|
|
3874
|
+
/** Build the terminal error shared by every waiter of an unstable generation. */
|
|
3875
|
+
function createUnstableGenerationError(projectRoot, attempts, validation) {
|
|
3876
|
+
const lines = [
|
|
3877
|
+
`ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
|
|
3878
|
+
` project: ${projectRoot}`,
|
|
3879
|
+
];
|
|
3880
|
+
attempts.forEach((failures, index) => {
|
|
3881
|
+
lines.push(` attempt ${index + 1}:`);
|
|
3882
|
+
if (failures.entries.length === 0) {
|
|
3883
|
+
lines.push(" - project/generation-proof-incomplete");
|
|
3884
|
+
}
|
|
3885
|
+
for (const failure of failures.entries) {
|
|
3886
|
+
const input = failure.path === undefined
|
|
3887
|
+
? ""
|
|
3888
|
+
: `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
|
|
3889
|
+
const detail = failure.detail === undefined
|
|
3890
|
+
? ""
|
|
3891
|
+
: ` (producer: ${JSON.stringify(failure.detail)})`;
|
|
3892
|
+
lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
|
|
3893
|
+
}
|
|
3894
|
+
if (failures.omitted !== 0) {
|
|
3895
|
+
lines.push(` - ... ${failures.omitted} additional witness(es) omitted`);
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
lines.push(" Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.");
|
|
3899
|
+
return new TtscUnstableGenerationError(lines.join("\n"), validation);
|
|
2827
3900
|
}
|
|
2828
3901
|
function hashText(input) {
|
|
2829
3902
|
return crypto.createHash("sha256").update(input).digest("hex");
|
|
2830
3903
|
}
|
|
2831
3904
|
async function transformProject(props) {
|
|
3905
|
+
const attempts = [];
|
|
3906
|
+
for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
|
|
3907
|
+
const cached = await captureTransformGeneration(props);
|
|
3908
|
+
if (!props.trackProjectMembership ||
|
|
3909
|
+
cached.result.type !== "success" ||
|
|
3910
|
+
cached.projectSnapshotComplete === true) {
|
|
3911
|
+
return cached;
|
|
3912
|
+
}
|
|
3913
|
+
attempts.push(TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
|
|
3914
|
+
createGenerationProofFailures());
|
|
3915
|
+
if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
|
|
3916
|
+
const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(cached.result);
|
|
3917
|
+
if (validation === undefined) {
|
|
3918
|
+
disposeCachedTransform(cached);
|
|
3919
|
+
throw new Error("ttsc: failed transform generation has no retry validation baseline");
|
|
3920
|
+
}
|
|
3921
|
+
throw createUnstableGenerationError(path.dirname(props.tsconfig), attempts, validation);
|
|
3922
|
+
}
|
|
3923
|
+
disposeCachedTransform(cached);
|
|
3924
|
+
}
|
|
3925
|
+
throw new Error("ttsc: transform generation retry loop did not terminate");
|
|
3926
|
+
}
|
|
3927
|
+
/** Capture one whole-project transform attempt and all of its reuse proofs. */
|
|
3928
|
+
async function captureTransformGeneration(props) {
|
|
2832
3929
|
const projectRoot = path.dirname(props.tsconfig);
|
|
2833
3930
|
const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
|
|
2834
3931
|
let tracker;
|
|
@@ -2841,9 +3938,14 @@ async function transformProject(props) {
|
|
|
2841
3938
|
const configured = createTransformTsconfig(props, scratchDirectory);
|
|
2842
3939
|
const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
|
|
2843
3940
|
const identities = createHostPathIdentityContext(props.filesystem);
|
|
2844
|
-
|
|
3941
|
+
// Read from the project's own tsconfig rather than the generated one: a
|
|
3942
|
+
// relative `outDir` is anchored at the config that declares it, and the
|
|
3943
|
+
// generated config lives in a system temp directory. The caller's
|
|
3944
|
+
// compiler-options overlay still wins, since it wins for the compile too.
|
|
3945
|
+
const membershipPolicy = mergeMembershipPolicyOverlay(readProjectMembershipPolicy(props.tsconfig), props.compilerOptions, projectRoot);
|
|
3946
|
+
const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem, undefined, { policy: membershipPolicy });
|
|
2845
3947
|
tracker = props.trackProjectMembership
|
|
2846
|
-
? await createProjectMutationTracker(before.projectDirectories, props.filesystem)
|
|
3948
|
+
? await createProjectMutationTracker(before.projectDirectories, props.filesystem, membershipPolicy)
|
|
2847
3949
|
: undefined;
|
|
2848
3950
|
const result = withTransformScratchEnvironment(scratchDirectory, () => new TtscCompiler({
|
|
2849
3951
|
cwd: projectRoot,
|
|
@@ -2868,6 +3970,7 @@ async function transformProject(props) {
|
|
|
2868
3970
|
filesystem: props.filesystem,
|
|
2869
3971
|
projectRoot,
|
|
2870
3972
|
result,
|
|
3973
|
+
scratchDirectory,
|
|
2871
3974
|
temporaryTsconfig,
|
|
2872
3975
|
});
|
|
2873
3976
|
// The generation's absent resolution candidates, which get a watcher of
|
|
@@ -2883,6 +3986,7 @@ async function transformProject(props) {
|
|
|
2883
3986
|
filesystem: props.filesystem,
|
|
2884
3987
|
projectRoot,
|
|
2885
3988
|
result,
|
|
3989
|
+
scratchDirectory,
|
|
2886
3990
|
temporaryTsconfig,
|
|
2887
3991
|
})
|
|
2888
3992
|
: { candidates: [], watched: [] };
|
|
@@ -2906,11 +4010,13 @@ async function transformProject(props) {
|
|
|
2906
4010
|
: undefined;
|
|
2907
4011
|
const externalInputPaths = selectExternalInputPaths({
|
|
2908
4012
|
filesystem: props.filesystem,
|
|
4013
|
+
membershipPolicy,
|
|
2909
4014
|
projectRoot,
|
|
2910
4015
|
result,
|
|
4016
|
+
scratchDirectory,
|
|
2911
4017
|
temporaryTsconfig,
|
|
2912
4018
|
});
|
|
2913
|
-
const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
|
|
4019
|
+
const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem, undefined, { policy: membershipPolicy });
|
|
2914
4020
|
// Whether the recorded snapshot describes one coherent state of the
|
|
2915
4021
|
// project. A membership event during the compile taints it exactly like an
|
|
2916
4022
|
// unstable walk pair; whether notifications can be *opened* is a separate
|
|
@@ -2920,6 +4026,7 @@ async function transformProject(props) {
|
|
|
2920
4026
|
identities,
|
|
2921
4027
|
projectRoot,
|
|
2922
4028
|
result,
|
|
4029
|
+
scratchDirectory,
|
|
2923
4030
|
});
|
|
2924
4031
|
const walkStable = walkSnapshotComplete(before, declaredInputs) &&
|
|
2925
4032
|
walkSnapshotComplete(inputSnapshot, declaredInputs) &&
|
|
@@ -2935,29 +4042,44 @@ async function transformProject(props) {
|
|
|
2935
4042
|
// Overlay the in-memory source only after proving the two on-disk snapshots
|
|
2936
4043
|
// stable; an unsaved editor buffer must not look like a compile-time race.
|
|
2937
4044
|
const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
4045
|
+
const currentSourceHash = hashText(props.currentSource);
|
|
4046
|
+
const projectInputHashes = { ...inputSnapshot.hashes };
|
|
4047
|
+
if (Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)) {
|
|
4048
|
+
inputSnapshot.hashes[currentFileKey] = currentSourceHash;
|
|
4049
|
+
// That overlay makes this one key the only recorded hash a disk signature
|
|
4050
|
+
// cannot stand for: the bytes it names came from the bundler, not the file.
|
|
4051
|
+
delete inputSnapshot.provenSignatures[currentFileKey];
|
|
4052
|
+
}
|
|
2942
4053
|
const cached = {
|
|
4054
|
+
// The pass this compile was started for. Its snapshot describes the
|
|
4055
|
+
// project as of this compile, so it is settled for this pass and any
|
|
4056
|
+
// later pass must re-prove it.
|
|
4057
|
+
...(props.deliveryEpoch === undefined
|
|
4058
|
+
? {}
|
|
4059
|
+
: { deliveryEpoch: props.deliveryEpoch }),
|
|
2943
4060
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
2944
4061
|
// cache validation can re-check them; computed before dispose so the
|
|
2945
|
-
//
|
|
4062
|
+
// scratch-tree exclusion is the only reason its disposed artifacts never
|
|
4063
|
+
// key the persistent generation.
|
|
2946
4064
|
externalInputHashes: {},
|
|
2947
4065
|
externalInputRealpaths: {},
|
|
2948
4066
|
externalInputPaths,
|
|
2949
4067
|
inputHashes: inputSnapshot.hashes,
|
|
2950
4068
|
inputSignatures: inputSnapshot.provenSignatures,
|
|
4069
|
+
membershipPolicy,
|
|
2951
4070
|
projectDirectories: inputSnapshot.projectDirectories,
|
|
4071
|
+
tsconfig: props.tsconfig,
|
|
2952
4072
|
projectSnapshotComplete: false,
|
|
2953
4073
|
projectRoot,
|
|
2954
4074
|
result,
|
|
4075
|
+
scratchDirectory,
|
|
2955
4076
|
servedFiles: new Set(),
|
|
2956
4077
|
// Remember the generated temp-dir tsconfig (disposed below) so watch
|
|
2957
4078
|
// derivation can drop it from the envelope's config chain; a registered
|
|
2958
4079
|
// but deleted file would invalidate every persistent-cache snapshot.
|
|
2959
4080
|
...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
|
|
2960
4081
|
};
|
|
4082
|
+
cached.sourceHashes = captureTransformSourceHashes(cached, props.currentFile, currentSourceHash);
|
|
2961
4083
|
const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
|
|
2962
4084
|
cached.externalInputHashes = externalInputSnapshot.hashes;
|
|
2963
4085
|
cached.externalInputRealpaths = externalInputSnapshot.realpaths;
|
|
@@ -2966,21 +4088,39 @@ async function transformProject(props) {
|
|
|
2966
4088
|
// cannot be reused can say which evidence it lacked. The extra work runs
|
|
2967
4089
|
// only on the failing path, where the alternative is recompiling the whole
|
|
2968
4090
|
// project for every remaining module.
|
|
2969
|
-
const
|
|
2970
|
-
|
|
2971
|
-
|
|
4091
|
+
const failures = createGenerationProofFailures();
|
|
4092
|
+
if (!walkStable) {
|
|
4093
|
+
recordProjectSnapshotFailures(failures, {
|
|
4094
|
+
before,
|
|
4095
|
+
candidateTracker,
|
|
4096
|
+
declared: declaredInputs,
|
|
4097
|
+
hostInputTracker,
|
|
4098
|
+
identities,
|
|
4099
|
+
projectRoot,
|
|
4100
|
+
snapshot: inputSnapshot,
|
|
4101
|
+
tracker,
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
const graphFailures = compilerGraphInputProofFailures(cached);
|
|
4105
|
+
mergeGenerationProofFailures(failures, graphFailures);
|
|
4106
|
+
mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
|
|
4107
|
+
const universalInputCapture = captureUniversalHostInputValidation(cached, props.currentFile);
|
|
4108
|
+
mergeGenerationProofFailures(failures, universalInputCapture.failures);
|
|
4109
|
+
const graphProofs = graphFailures.entries.length === 0 && graphFailures.omitted === 0;
|
|
4110
|
+
const universalInputs = universalInputCapture.validation !== undefined;
|
|
2972
4111
|
const stableProjectSnapshot = walkStable &&
|
|
2973
4112
|
graphProofs &&
|
|
2974
4113
|
externalInputSnapshot.complete &&
|
|
2975
4114
|
universalInputs;
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
4115
|
+
if (!stableProjectSnapshot) {
|
|
4116
|
+
TRANSFORM_GENERATION_FAILURES.set(result, failures);
|
|
4117
|
+
TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
|
|
4118
|
+
cached,
|
|
4119
|
+
declaredInputs,
|
|
4120
|
+
inputStates: captureFailedGenerationInputStates(cached, failures),
|
|
4121
|
+
projectInputHashes,
|
|
4122
|
+
projectWalkComplete: walkSnapshotComplete(inputSnapshot, declaredInputs),
|
|
4123
|
+
projectWalkFailures: projectWalkFailureFingerprint(inputSnapshot, declaredInputs, projectRoot, identities),
|
|
2984
4124
|
});
|
|
2985
4125
|
}
|
|
2986
4126
|
cached.projectSnapshotComplete = stableProjectSnapshot;
|
|
@@ -3031,16 +4171,23 @@ async function transformProject(props) {
|
|
|
3031
4171
|
}
|
|
3032
4172
|
}
|
|
3033
4173
|
}
|
|
3034
|
-
/** Exclude
|
|
4174
|
+
/** Exclude disposed transform scratch from live host-input tracking. */
|
|
3035
4175
|
function selectPersistentHostInputs(props) {
|
|
3036
4176
|
if (props.result.type === "exception")
|
|
3037
4177
|
return [];
|
|
3038
4178
|
const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
3039
|
-
if (props.
|
|
4179
|
+
if (props.scratchDirectory === undefined &&
|
|
4180
|
+
props.temporaryTsconfig === undefined)
|
|
3040
4181
|
return inputs;
|
|
3041
4182
|
const identities = createHostPathIdentityContext(props.filesystem);
|
|
3042
|
-
const temporary =
|
|
3043
|
-
|
|
4183
|
+
const temporary = props.temporaryTsconfig === undefined
|
|
4184
|
+
? undefined
|
|
4185
|
+
: pathIdentityKey(props.temporaryTsconfig, identities);
|
|
4186
|
+
return inputs.filter((input) => {
|
|
4187
|
+
if (isTransformScratchInput(input, props.scratchDirectory))
|
|
4188
|
+
return false;
|
|
4189
|
+
return pathIdentityKey(input, identities) !== temporary;
|
|
4190
|
+
});
|
|
3044
4191
|
}
|
|
3045
4192
|
function createTransformTsconfig(props, scratchDirectory) {
|
|
3046
4193
|
const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
|
|
@@ -3131,6 +4278,11 @@ function pathIsWithin(child, parent) {
|
|
|
3131
4278
|
!relative.startsWith(`..${path.sep}`) &&
|
|
3132
4279
|
!path.isAbsolute(relative)));
|
|
3133
4280
|
}
|
|
4281
|
+
/** Whether an input is owned by the disposable transform scratch tree. */
|
|
4282
|
+
function isTransformScratchInput(input, scratchDirectory) {
|
|
4283
|
+
return (scratchDirectory !== undefined &&
|
|
4284
|
+
pathIsWithin(path.resolve(input), path.resolve(scratchDirectory)));
|
|
4285
|
+
}
|
|
3134
4286
|
/** Route all compiler/plugin scratch to one owned directory outside project. */
|
|
3135
4287
|
function transformScratchEnvironment(directory) {
|
|
3136
4288
|
return {
|
|
@@ -3276,10 +4428,37 @@ function readPaths(value) {
|
|
|
3276
4428
|
function createAliasPaths(aliases) {
|
|
3277
4429
|
const paths = {};
|
|
3278
4430
|
for (const alias of normalizeAliases(aliases)) {
|
|
3279
|
-
if (typeof alias.find !== "string"
|
|
4431
|
+
if (typeof alias.find !== "string") {
|
|
4432
|
+
// Vite's array form accepts a `RegExp` find, and `{ find: /^~/ }` is a
|
|
4433
|
+
// common way to spell a prefix alias. A tsconfig `paths` map has no
|
|
4434
|
+
// regular-expression form, so there is nothing to translate it into
|
|
4435
|
+
// (samchon/ttsc#1315). Reducing the simple prefix cases to a string is
|
|
4436
|
+
// possible in principle and deliberately not done: telling `/^~/` from
|
|
4437
|
+
// `/^~(?=\/)/` or `/^@app/` — which matches `@apple` too — means
|
|
4438
|
+
// implementing enough of a regular-expression engine that a wrong
|
|
4439
|
+
// reduction becomes likely, and a mistranslated alias resolves imports to
|
|
4440
|
+
// the wrong file silently, which is worse than not forwarding it.
|
|
4441
|
+
//
|
|
4442
|
+
// Not reported, unlike the wildcard below, and that asymmetry is the
|
|
4443
|
+
// whole point: Vite merges two `RegExp` aliases of its own into every
|
|
4444
|
+
// resolved config, `/^\/?@vite\/env/` and `/^\/?@vite\/client/`. Measured
|
|
4445
|
+
// on a bare project with no user aliases at all, `resolve.alias` has
|
|
4446
|
+
// exactly those two entries under both `serve` and `build`, so a report
|
|
4447
|
+
// on this form would fire twice for every Vite user in every build, name
|
|
4448
|
+
// aliases they never wrote, and say nothing about their configuration.
|
|
4449
|
+
// A diagnostic that cannot distinguish the user's input from the host's
|
|
4450
|
+
// is noise, and noise is what teaches people to stop reading the channel
|
|
4451
|
+
// the out-of-program report depends on. The documentation carries this
|
|
4452
|
+
// form instead, in both README and guide.
|
|
4453
|
+
continue;
|
|
4454
|
+
}
|
|
4455
|
+
if (alias.find.length === 0) {
|
|
3280
4456
|
continue;
|
|
3281
4457
|
}
|
|
3282
4458
|
if (alias.find.includes("*")) {
|
|
4459
|
+
// A `paths` key reads `*` as its own wildcard, so forwarding a `find`
|
|
4460
|
+
// that already contains one cannot preserve the caller's meaning.
|
|
4461
|
+
reportUntranslatableAlias(JSON.stringify(alias.find), 'a "paths" key already reads "*" as its own wildcard');
|
|
3283
4462
|
continue;
|
|
3284
4463
|
}
|
|
3285
4464
|
const key = alias.find.replace(/\/+$/, "");
|
|
@@ -3294,9 +4473,50 @@ function createAliasPaths(aliases) {
|
|
|
3294
4473
|
}
|
|
3295
4474
|
return paths;
|
|
3296
4475
|
}
|
|
4476
|
+
/**
|
|
4477
|
+
* Alias descriptions already reported in this process.
|
|
4478
|
+
*
|
|
4479
|
+
* The message is about configuration rather than about a module:
|
|
4480
|
+
* `resolve.alias` is resolved once and then consulted on every delivery, so
|
|
4481
|
+
* reporting per delivery would repeat one statement about the config for every
|
|
4482
|
+
* file in the bundle. Keyed by the description, so a Vite dev server that
|
|
4483
|
+
* reloads its config reports again only when the alias itself changed.
|
|
4484
|
+
*/
|
|
4485
|
+
const REPORTED_UNTRANSLATABLE_ALIASES = new Set();
|
|
4486
|
+
/**
|
|
4487
|
+
* Tell the user once that an alias they declared is not reaching the compile.
|
|
4488
|
+
*
|
|
4489
|
+
* A dropped alias is not silent in its consequence — the compile resolves
|
|
4490
|
+
* through the tsconfig's own `paths`, and a module that resolves for the
|
|
4491
|
+
* bundler but not for the compiler surfaces as the out-of-program report
|
|
4492
|
+
* (samchon/ttsc#1308) — but that report names the module, not the alias, so the
|
|
4493
|
+
* user cannot learn from it that a configuration they wrote was ignored.
|
|
4494
|
+
*
|
|
4495
|
+
* Only the wildcard form reaches here. Every entry it names was written by the
|
|
4496
|
+
* user, because nothing injects one; the `RegExp` form is left to the
|
|
4497
|
+
* documentation precisely because Vite does inject those, and
|
|
4498
|
+
* {@link createAliasPaths} carries that measurement.
|
|
4499
|
+
*/
|
|
4500
|
+
function reportUntranslatableAlias(description, reason) {
|
|
4501
|
+
if (REPORTED_UNTRANSLATABLE_ALIASES.has(description)) {
|
|
4502
|
+
return;
|
|
4503
|
+
}
|
|
4504
|
+
REPORTED_UNTRANSLATABLE_ALIASES.add(description);
|
|
4505
|
+
process.stderr.write(`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`);
|
|
4506
|
+
}
|
|
4507
|
+
/**
|
|
4508
|
+
* Collect the host's declared aliases without deciding which of them can be
|
|
4509
|
+
* expressed as `paths`.
|
|
4510
|
+
*
|
|
4511
|
+
* That decision belongs to {@link createAliasPaths} alone. It used to be split:
|
|
4512
|
+
* this function's type guard required a string `find` and dropped Vite's
|
|
4513
|
+
* `RegExp` form before `createAliasPaths` ever saw it, which left
|
|
4514
|
+
* `createAliasPaths`'s own non-string branch unreachable and put the drop
|
|
4515
|
+
* somewhere nothing could report it (samchon/ttsc#1315).
|
|
4516
|
+
*/
|
|
3297
4517
|
function normalizeAliases(aliases) {
|
|
3298
4518
|
if (Array.isArray(aliases)) {
|
|
3299
|
-
return aliases.filter(
|
|
4519
|
+
return aliases.filter(isDeclaredAlias);
|
|
3300
4520
|
}
|
|
3301
4521
|
if (typeof aliases === "object" && aliases !== null) {
|
|
3302
4522
|
return Object.entries(aliases)
|
|
@@ -3341,12 +4561,11 @@ function isRelativeSpecifier(value) {
|
|
|
3341
4561
|
value.startsWith(".\\") ||
|
|
3342
4562
|
value.startsWith("..\\"));
|
|
3343
4563
|
}
|
|
3344
|
-
function
|
|
4564
|
+
function isDeclaredAlias(value) {
|
|
3345
4565
|
return (typeof value === "object" &&
|
|
3346
4566
|
value !== null &&
|
|
3347
4567
|
"find" in value &&
|
|
3348
4568
|
"replacement" in value &&
|
|
3349
|
-
typeof value.find === "string" &&
|
|
3350
4569
|
typeof value.replacement === "string");
|
|
3351
4570
|
}
|
|
3352
4571
|
/**
|
|
@@ -3379,19 +4598,57 @@ function selectTransformedSource(props) {
|
|
|
3379
4598
|
if (source !== undefined) {
|
|
3380
4599
|
return source;
|
|
3381
4600
|
}
|
|
3382
|
-
throw new
|
|
4601
|
+
throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* Tell the user once that a module was left untransformed, and why.
|
|
4605
|
+
*
|
|
4606
|
+
* The condition is ordinary and the build continues, but it must never be
|
|
4607
|
+
* silent: a file the program does not contain keeps whatever plugin syntax it
|
|
4608
|
+
* carries, so a typia `assert<T>()` in it becomes a runtime failure rather than
|
|
4609
|
+
* a build failure. One line per file per generation per pass, on the channel
|
|
4610
|
+
* the generation's other non-fatal diagnostics already use, so a bundle that
|
|
4611
|
+
* reaches many such files does not repeat itself per delivery.
|
|
4612
|
+
*/
|
|
4613
|
+
function reportMissingProgramOutput(cached, error, epoch) {
|
|
4614
|
+
const reported = (cached.missingOutputReported ??= new Set());
|
|
4615
|
+
if (cached.missingOutputEpoch !== epoch) {
|
|
4616
|
+
cached.missingOutputEpoch = epoch;
|
|
4617
|
+
reported.clear();
|
|
4618
|
+
}
|
|
4619
|
+
if (reported.has(error.file)) {
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
reported.add(error.file);
|
|
4623
|
+
process.stderr.write(`${error.message}
|
|
4624
|
+
`);
|
|
3383
4625
|
}
|
|
3384
4626
|
/**
|
|
3385
|
-
* Forward non-fatal plugin diagnostics to stderr.
|
|
4627
|
+
* Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
|
|
3386
4628
|
*
|
|
3387
4629
|
* A `success` result may still carry warnings or informational messages from
|
|
3388
|
-
* plugins
|
|
3389
|
-
*
|
|
4630
|
+
* plugins — `@ttsc/lint` reports every rule below error severity this way.
|
|
4631
|
+
* These are surfaced via stderr rather than throwing so the build continues.
|
|
4632
|
+
* Failures and exceptions are handled by the caller.
|
|
4633
|
+
*
|
|
4634
|
+
* They describe one compile of one program, so writing them per delivery
|
|
4635
|
+
* printed the same warning once per module and scaled the noise with exactly
|
|
4636
|
+
* the reuse the cache exists to provide (samchon/ttsc#1304). A pass that reuses
|
|
4637
|
+
* a retained generation still surfaces them once, because a build's warnings
|
|
4638
|
+
* are part of what that build reports; a host with no pass boundary surfaces
|
|
4639
|
+
* them once per generation, which is the same rule with one pass.
|
|
3390
4640
|
*/
|
|
3391
|
-
function reportSuccessDiagnostics(
|
|
4641
|
+
function reportSuccessDiagnostics(cached, epoch) {
|
|
4642
|
+
const result = cached.result;
|
|
3392
4643
|
if (result.type !== "success" || result.diagnostics === undefined) {
|
|
3393
4644
|
return;
|
|
3394
4645
|
}
|
|
4646
|
+
if (cached.diagnosticsReported === true &&
|
|
4647
|
+
cached.diagnosticsEpoch === epoch) {
|
|
4648
|
+
return;
|
|
4649
|
+
}
|
|
4650
|
+
cached.diagnosticsReported = true;
|
|
4651
|
+
cached.diagnosticsEpoch = epoch;
|
|
3395
4652
|
const text = formatDiagnostics(result.diagnostics);
|
|
3396
4653
|
if (text.length !== 0) {
|
|
3397
4654
|
process.stderr.write(`${text}\n`);
|
|
@@ -3415,7 +4672,7 @@ function formatDiagnostics(diagnostics) {
|
|
|
3415
4672
|
diag.line === undefined
|
|
3416
4673
|
? undefined
|
|
3417
4674
|
: `${diag.line}:${diag.character ?? 1}`,
|
|
3418
|
-
diag.messageText,
|
|
4675
|
+
stripTerminalEscapes(diag.messageText),
|
|
3419
4676
|
]
|
|
3420
4677
|
.filter((part) => part !== undefined && part !== "")
|
|
3421
4678
|
.join(": "))
|
|
@@ -3423,15 +4680,38 @@ function formatDiagnostics(diagnostics) {
|
|
|
3423
4680
|
}
|
|
3424
4681
|
function formatUnknownError(error) {
|
|
3425
4682
|
if (error instanceof Error) {
|
|
3426
|
-
return error.message;
|
|
4683
|
+
return stripTerminalEscapes(error.message);
|
|
3427
4684
|
}
|
|
3428
4685
|
if (typeof error === "object" &&
|
|
3429
4686
|
error !== null &&
|
|
3430
4687
|
"message" in error &&
|
|
3431
4688
|
typeof error.message === "string") {
|
|
3432
|
-
return error.message;
|
|
4689
|
+
return stripTerminalEscapes(error.message);
|
|
3433
4690
|
}
|
|
3434
|
-
return String(error);
|
|
4691
|
+
return stripTerminalEscapes(String(error));
|
|
4692
|
+
}
|
|
4693
|
+
/**
|
|
4694
|
+
* Remove terminal colour and cursor sequences from text the adapter surfaces.
|
|
4695
|
+
*
|
|
4696
|
+
* An ordinary type error reaches the adapter as an `"exception"` envelope whose
|
|
4697
|
+
* `error` is the host's own rendered output, colour and all, and the envelope
|
|
4698
|
+
* carries no structured diagnostics to format instead. What the adapter hands
|
|
4699
|
+
* back is not going to a terminal: it becomes the `Error` a bundler reports, so
|
|
4700
|
+
* it lands in a Vite overlay, a webpack error report or a CI annotation, where
|
|
4701
|
+
* the escapes render as literal noise around the file and line the reader needs
|
|
4702
|
+
* (samchon/ttsc#1312).
|
|
4703
|
+
*
|
|
4704
|
+
* The colour originates in the host's rendering rather than in anything this
|
|
4705
|
+
* adapter configures, so this is the adapter-side repair, applied to every
|
|
4706
|
+
* message it surfaces rather than to one call site.
|
|
4707
|
+
*/
|
|
4708
|
+
function stripTerminalEscapes(text) {
|
|
4709
|
+
// Built from a char code so no control byte lives in this source file, and
|
|
4710
|
+
// written with `[[]` (a class holding one literal bracket) so the pattern
|
|
4711
|
+
// needs no backslash escapes to survive the string it is assembled from.
|
|
4712
|
+
const escape = String.fromCharCode(27);
|
|
4713
|
+
const controlSequence = new RegExp(escape + "[[][0-9;?]*[ -/]*[@-~]", "g");
|
|
4714
|
+
return text.replace(controlSequence, "");
|
|
3435
4715
|
}
|
|
3436
4716
|
/**
|
|
3437
4717
|
* Locate the tsconfig that should govern the transform for `file`.
|