@ttsc/unplugin 0.26.2 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/bun.mjs +1 -1
- package/lib/core/index.d.ts +1 -1
- package/lib/core/index.js +49 -8
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +50 -9
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.ts +220 -19
- package/lib/core/transform.js +2387 -225
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +2387 -226
- package/lib/core/transform.mjs.map +1 -1
- package/lib/core/viteServe.d.ts +9 -2
- package/lib/core/viteServe.js +2 -2
- package/lib/core/viteServe.js.map +1 -1
- package/lib/core/viteServe.mjs +2 -2
- package/lib/core/viteServe.mjs.map +1 -1
- package/lib/turbopack.mjs +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +58 -11
- package/src/core/transform.ts +3141 -243
- package/src/core/viteServe.ts +11 -4
package/lib/core/transform.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var node_child_process = require('node:child_process');
|
|
3
4
|
var crypto = require('node:crypto');
|
|
4
5
|
var fs = require('node:fs');
|
|
5
6
|
var os = require('node:os');
|
|
@@ -8,19 +9,59 @@ var ttsc = require('ttsc');
|
|
|
8
9
|
var pathIdentity = require('ttsc/path-identity');
|
|
9
10
|
var tsconfigPaths = require('./tsconfigPaths.js');
|
|
10
11
|
|
|
12
|
+
const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
|
|
13
|
+
exists: fs.existsSync,
|
|
14
|
+
lstat: (location) => fs.lstatSync(location, { bigint: true }),
|
|
15
|
+
readFile: (location) => fs.readFileSync(location),
|
|
16
|
+
readdir: (location) => fs.readdirSync(location, { withFileTypes: true }),
|
|
17
|
+
realpath: fs.realpathSync.native,
|
|
18
|
+
stat: fs.statSync,
|
|
19
|
+
statBigInt: (location) => fs.statSync(location, { bigint: true }),
|
|
20
|
+
});
|
|
21
|
+
const TRANSFORM_CACHE_FILESYSTEM = new WeakMap();
|
|
22
|
+
const TRANSFORM_RESULT_FILESYSTEM = new WeakMap();
|
|
11
23
|
/**
|
|
12
24
|
* Caches whose owner has declared a real per-build lifecycle by calling
|
|
13
25
|
* {@link beginTtscTransformBuild} before transforms begin.
|
|
14
26
|
*/
|
|
15
27
|
const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet();
|
|
16
|
-
function createHostPathIdentityContext() {
|
|
28
|
+
function createHostPathIdentityContext(filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
17
29
|
return pathIdentity.createFilesystemPathIdentityContext({
|
|
30
|
+
caseSensitive: filesystem.caseSensitive,
|
|
31
|
+
lstat: filesystem.lstat,
|
|
32
|
+
platform: filesystem.platform,
|
|
33
|
+
readdir: (directory) => filesystem.readdir(directory).map((entry) => entry.name),
|
|
34
|
+
realpath: filesystem.realpath,
|
|
18
35
|
throwOnRealpathError: false,
|
|
19
36
|
});
|
|
20
37
|
}
|
|
21
|
-
/**
|
|
22
|
-
function
|
|
23
|
-
return
|
|
38
|
+
/** Normalize one directory entry under the owning filesystem's case policy. */
|
|
39
|
+
function normalizeHostInputName(name, caseSensitive) {
|
|
40
|
+
return caseSensitive ? name : name.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
/** Create an empty persistent transform cache with isolated filesystem reads. */
|
|
43
|
+
function createTtscTransformCache(operations = {}) {
|
|
44
|
+
const cache = new Map();
|
|
45
|
+
TRANSFORM_CACHE_FILESYSTEM.set(cache, {
|
|
46
|
+
caseSensitive: operations.caseSensitive,
|
|
47
|
+
exists: operations.exists ?? DEFAULT_FILESYSTEM_OPERATIONS.exists,
|
|
48
|
+
lstat: operations.lstat ?? DEFAULT_FILESYSTEM_OPERATIONS.lstat,
|
|
49
|
+
readFile: operations.readFile ?? DEFAULT_FILESYSTEM_OPERATIONS.readFile,
|
|
50
|
+
readdir: operations.readdir ?? DEFAULT_FILESYSTEM_OPERATIONS.readdir,
|
|
51
|
+
realpath: operations.realpath ?? DEFAULT_FILESYSTEM_OPERATIONS.realpath,
|
|
52
|
+
stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
|
|
53
|
+
statBigInt: operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
|
|
54
|
+
platform: operations.platform,
|
|
55
|
+
watch: operations.watch,
|
|
56
|
+
});
|
|
57
|
+
return cache;
|
|
58
|
+
}
|
|
59
|
+
function transformFilesystem(cache) {
|
|
60
|
+
return ((cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
|
|
61
|
+
DEFAULT_FILESYSTEM_OPERATIONS);
|
|
62
|
+
}
|
|
63
|
+
function resultFilesystem(result) {
|
|
64
|
+
return (TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS);
|
|
24
65
|
}
|
|
25
66
|
/**
|
|
26
67
|
* Start a host build, clearing its prior generation and enabling constant-time
|
|
@@ -31,7 +72,7 @@ function createTtscTransformCache() {
|
|
|
31
72
|
* defines one process-scoped module-loading session.
|
|
32
73
|
*/
|
|
33
74
|
function beginTtscTransformBuild(cache) {
|
|
34
|
-
cache
|
|
75
|
+
clearTtscTransformCache(cache);
|
|
35
76
|
BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
|
|
36
77
|
}
|
|
37
78
|
/**
|
|
@@ -42,9 +83,17 @@ function beginTtscTransformBuild(cache) {
|
|
|
42
83
|
* many edits, so that callback cannot authorize build-scoped shortcuts.
|
|
43
84
|
*/
|
|
44
85
|
function resetTtscTransformCache(cache) {
|
|
45
|
-
cache
|
|
86
|
+
clearTtscTransformCache(cache);
|
|
46
87
|
BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
|
|
47
88
|
}
|
|
89
|
+
/** Dispose generation-owned filesystem resources before clearing a cache. */
|
|
90
|
+
function clearTtscTransformCache(cache) {
|
|
91
|
+
const generations = [...cache.values()];
|
|
92
|
+
cache.clear();
|
|
93
|
+
for (const generation of generations) {
|
|
94
|
+
void generation.then(disposeCachedTransform, () => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
48
97
|
/**
|
|
49
98
|
* Apply the ttsc plugin transform to a single source file.
|
|
50
99
|
*
|
|
@@ -70,6 +119,7 @@ function resetTtscTransformCache(cache) {
|
|
|
70
119
|
* per build, not per compilation.
|
|
71
120
|
*/
|
|
72
121
|
async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
122
|
+
const filesystem = transformFilesystem(cache);
|
|
73
123
|
const clean = stripQuery(id);
|
|
74
124
|
if (clean.includes("\0")) {
|
|
75
125
|
return undefined;
|
|
@@ -81,7 +131,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
81
131
|
if (pluginsAreDisabled(options.plugins)) {
|
|
82
132
|
return undefined;
|
|
83
133
|
}
|
|
84
|
-
const tsconfig = resolveTsconfig(file, options.project);
|
|
134
|
+
const tsconfig = resolveTsconfig(file, options.project, filesystem);
|
|
85
135
|
const aliasPaths = createAliasPaths(aliases);
|
|
86
136
|
const key = createTransformCacheKey({
|
|
87
137
|
aliasPaths,
|
|
@@ -95,11 +145,19 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
95
145
|
// A rejected in-flight generation must not stay cached: evict it (only if
|
|
96
146
|
// it is still the current entry) so a later call re-runs the transform.
|
|
97
147
|
const cached = await awaitOrEvict(cache, key, transformed);
|
|
148
|
+
TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
|
|
98
149
|
// While this caller awaited the old Promise, another caller may have
|
|
99
150
|
// invalidated it and installed a newer authoritative generation.
|
|
100
151
|
if (cache?.get(key) !== transformed) {
|
|
101
152
|
continue;
|
|
102
153
|
}
|
|
154
|
+
const buildScoped = cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
|
|
155
|
+
if (!buildScoped) {
|
|
156
|
+
await settleProjectMutationEvents(cached);
|
|
157
|
+
if (cache?.get(key) !== transformed) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
103
161
|
if (
|
|
104
162
|
// A file the plugin declared volatile must never be served from the
|
|
105
163
|
// cache: its output depends on non-file inputs, so the input-hash
|
|
@@ -109,7 +167,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
109
167
|
projectRoot: cached.projectRoot,
|
|
110
168
|
result: cached.result,
|
|
111
169
|
}) &&
|
|
112
|
-
matchesCachedSource(cached, file, source,
|
|
170
|
+
matchesCachedSource(cached, file, source, buildScoped)) {
|
|
113
171
|
reportSuccessDiagnostics(cached.result);
|
|
114
172
|
// A resolved `"exception"` / `"failure"` envelope makes this throw;
|
|
115
173
|
// that is a failed generation too, so evict before surfacing it.
|
|
@@ -118,12 +176,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
118
176
|
projectRoot: cached.projectRoot,
|
|
119
177
|
result: cached.result,
|
|
120
178
|
});
|
|
121
|
-
notifyWatchInputs(hooks,
|
|
122
|
-
file,
|
|
123
|
-
projectRoot: cached.projectRoot,
|
|
124
|
-
result: cached.result,
|
|
125
|
-
temporaryTsconfig: cached.temporaryTsconfig,
|
|
126
|
-
});
|
|
179
|
+
notifyWatchInputs(hooks, cached, file);
|
|
127
180
|
markCachedSourceServed(cached, file);
|
|
128
181
|
return createTransformResult(source, code);
|
|
129
182
|
}
|
|
@@ -142,7 +195,9 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
142
195
|
compilerOptions: options.compilerOptions,
|
|
143
196
|
currentFile: file,
|
|
144
197
|
currentSource: source,
|
|
198
|
+
filesystem,
|
|
145
199
|
plugins: options.plugins,
|
|
200
|
+
trackProjectMembership: cache !== undefined,
|
|
146
201
|
tsconfig,
|
|
147
202
|
});
|
|
148
203
|
cache?.set(key, transformed);
|
|
@@ -152,14 +207,14 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
|
|
|
152
207
|
if (cache !== undefined && cache.get(key) !== generation) {
|
|
153
208
|
continue;
|
|
154
209
|
}
|
|
155
|
-
const { projectRoot, result
|
|
210
|
+
const { projectRoot, result } = cached;
|
|
156
211
|
reportSuccessDiagnostics(result);
|
|
157
212
|
const code = selectOrEvict(cache, key, generation, {
|
|
158
213
|
file,
|
|
159
214
|
projectRoot,
|
|
160
215
|
result,
|
|
161
216
|
});
|
|
162
|
-
notifyWatchInputs(hooks,
|
|
217
|
+
notifyWatchInputs(hooks, cached, file);
|
|
163
218
|
markCachedSourceServed(cached, file);
|
|
164
219
|
if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
|
|
165
220
|
hooks?.markVolatile?.();
|
|
@@ -209,8 +264,22 @@ function selectOrEvict(cache, key, generation, props) {
|
|
|
209
264
|
function evictGeneration(cache, key, generation) {
|
|
210
265
|
if (cache?.get(key) === generation) {
|
|
211
266
|
cache.delete(key);
|
|
267
|
+
void generation.then(disposeCachedTransform, () => undefined);
|
|
212
268
|
}
|
|
213
269
|
}
|
|
270
|
+
/** Close one generation's directory watchers exactly once. */
|
|
271
|
+
function disposeCachedTransform(cached) {
|
|
272
|
+
const trackers = [
|
|
273
|
+
cached.projectMutationTracker,
|
|
274
|
+
cached.hostInputMutationTracker,
|
|
275
|
+
cached.candidateMutationTracker,
|
|
276
|
+
];
|
|
277
|
+
cached.projectMutationTracker = undefined;
|
|
278
|
+
cached.hostInputMutationTracker = undefined;
|
|
279
|
+
cached.candidateMutationTracker = undefined;
|
|
280
|
+
for (const tracker of trackers)
|
|
281
|
+
tracker?.close();
|
|
282
|
+
}
|
|
214
283
|
/**
|
|
215
284
|
* Derivation states keyed by the compiler result object. One result object is
|
|
216
285
|
* produced by one compile against one project root, so the root captured at
|
|
@@ -224,7 +293,7 @@ function envelopeDerivation(props) {
|
|
|
224
293
|
return existing;
|
|
225
294
|
}
|
|
226
295
|
const created = {
|
|
227
|
-
identityContext: createHostPathIdentityContext(),
|
|
296
|
+
identityContext: createHostPathIdentityContext(resultFilesystem(props.result)),
|
|
228
297
|
identities: new Map(),
|
|
229
298
|
watchInputs: new Map(),
|
|
230
299
|
};
|
|
@@ -246,6 +315,10 @@ function envelopeGraphIndexes(state, props) {
|
|
|
246
315
|
candidates: [],
|
|
247
316
|
globals: [],
|
|
248
317
|
configs: [],
|
|
318
|
+
members: new Set(),
|
|
319
|
+
speculative: new Set(),
|
|
320
|
+
inputProofs: new Map(),
|
|
321
|
+
inputProofConflicts: new Set(),
|
|
249
322
|
};
|
|
250
323
|
const graph = props.result.type === "exception" ? undefined : props.result.graph;
|
|
251
324
|
if (graph !== undefined) {
|
|
@@ -255,23 +328,83 @@ function envelopeGraphIndexes(state, props) {
|
|
|
255
328
|
}
|
|
256
329
|
const absolute = path.resolve(props.projectRoot, source);
|
|
257
330
|
const identity = derivationIdentity(state, absolute);
|
|
331
|
+
built.members.add(identity);
|
|
258
332
|
built.spellings.set(identity, absolute);
|
|
259
333
|
const entries = built.edges.get(identity) ?? [];
|
|
260
334
|
entries.push(...targets
|
|
261
335
|
.filter((target) => typeof target === "string" && target.length !== 0)
|
|
262
|
-
.map((target) =>
|
|
336
|
+
.map((target) => {
|
|
337
|
+
const absoluteTarget = path.resolve(props.projectRoot, target);
|
|
338
|
+
built.members.add(derivationIdentity(state, absoluteTarget));
|
|
339
|
+
return absoluteTarget;
|
|
340
|
+
}));
|
|
263
341
|
built.edges.set(identity, entries);
|
|
264
342
|
}
|
|
265
343
|
built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
|
|
266
344
|
built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
|
|
267
|
-
for (const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
345
|
+
for (const input of [...built.globals, ...built.configs]) {
|
|
346
|
+
built.members.add(derivationIdentity(state, input));
|
|
347
|
+
}
|
|
348
|
+
const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
|
|
349
|
+
// Every candidate source is an importing file the compiler read, so fold
|
|
350
|
+
// the sources in before classifying any candidate. Otherwise one entry's
|
|
351
|
+
// candidate could be classified speculative before a later entry proves
|
|
352
|
+
// the same path is a realized source.
|
|
353
|
+
for (const [source] of candidateEntries) {
|
|
354
|
+
built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, source)));
|
|
355
|
+
}
|
|
356
|
+
const realized = new Set(built.members);
|
|
357
|
+
for (const [source, candidates] of candidateEntries) {
|
|
271
358
|
built.candidates.push({
|
|
272
359
|
source: derivationIdentity(state, path.resolve(props.projectRoot, source)),
|
|
273
360
|
files: selectListedFiles(props.projectRoot, candidates),
|
|
274
361
|
});
|
|
362
|
+
for (const candidate of candidates) {
|
|
363
|
+
if (typeof candidate !== "string" || candidate.length === 0)
|
|
364
|
+
continue;
|
|
365
|
+
const identity = derivationIdentity(state, path.resolve(props.projectRoot, candidate));
|
|
366
|
+
// Edges, globals, configs, and every candidate source are folded in
|
|
367
|
+
// above, so a path absent from that set is one the envelope reported
|
|
368
|
+
// only as a candidate.
|
|
369
|
+
if (!realized.has(identity))
|
|
370
|
+
built.speculative.add(identity);
|
|
371
|
+
built.members.add(identity);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
|
|
375
|
+
if (hash !== null &&
|
|
376
|
+
(typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (graph.inputRealpaths === undefined ||
|
|
380
|
+
!Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const reportedRealpath = graph.inputRealpaths[input];
|
|
384
|
+
if (reportedRealpath !== null &&
|
|
385
|
+
(typeof reportedRealpath !== "string" ||
|
|
386
|
+
!path.isAbsolute(reportedRealpath))) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const absolute = path.resolve(props.projectRoot, input);
|
|
390
|
+
const identity = derivationIdentity(state, absolute);
|
|
391
|
+
if (!built.members.has(identity))
|
|
392
|
+
continue;
|
|
393
|
+
const proof = {
|
|
394
|
+
hash,
|
|
395
|
+
path: absolute,
|
|
396
|
+
realpath: reportedRealpath === null ? null : path.resolve(reportedRealpath),
|
|
397
|
+
};
|
|
398
|
+
const previous = built.inputProofs.get(identity);
|
|
399
|
+
if (previous !== undefined &&
|
|
400
|
+
(previous.hash !== proof.hash ||
|
|
401
|
+
!sameHostInputRealpath(previous.realpath, proof.realpath, state.identityContext))) {
|
|
402
|
+
built.inputProofs.delete(identity);
|
|
403
|
+
built.inputProofConflicts.add(identity);
|
|
404
|
+
}
|
|
405
|
+
else if (!built.inputProofConflicts.has(identity)) {
|
|
406
|
+
built.inputProofs.set(identity, proof);
|
|
407
|
+
}
|
|
275
408
|
}
|
|
276
409
|
}
|
|
277
410
|
state.graph = built;
|
|
@@ -322,13 +455,29 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
|
|
|
322
455
|
* watches the module it transforms), and so is the disposed temp-dir tsconfig
|
|
323
456
|
* (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
|
|
324
457
|
*/
|
|
325
|
-
function notifyWatchInputs(hooks,
|
|
458
|
+
function notifyWatchInputs(hooks, cached, file) {
|
|
326
459
|
const addWatchFile = hooks?.addWatchFile;
|
|
327
460
|
if (addWatchFile === undefined) {
|
|
328
461
|
return;
|
|
329
462
|
}
|
|
330
|
-
|
|
331
|
-
|
|
463
|
+
const state = envelopeDerivation(cached);
|
|
464
|
+
const external = cached.externalInputHashes ?? {};
|
|
465
|
+
for (const input of selectWatchInputs({
|
|
466
|
+
file,
|
|
467
|
+
projectRoot: cached.projectRoot,
|
|
468
|
+
result: cached.result,
|
|
469
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
470
|
+
})) {
|
|
471
|
+
// Hand the adapter the identity this generation already resolved and the
|
|
472
|
+
// existence state it already recorded. Both are memoized per generation,
|
|
473
|
+
// while an adapter deriving them itself pays a `realpath`, a directory
|
|
474
|
+
// listing, and an `existsSync` per input on every delivery of every module
|
|
475
|
+
// (samchon/ttsc#1246).
|
|
476
|
+
const identity = derivationIdentity(state, input);
|
|
477
|
+
addWatchFile(input, {
|
|
478
|
+
identity,
|
|
479
|
+
missing: external[identity] === MISSING_INPUT_STATE,
|
|
480
|
+
});
|
|
332
481
|
}
|
|
333
482
|
}
|
|
334
483
|
/**
|
|
@@ -372,29 +521,59 @@ function selectWatchInputs(props) {
|
|
|
372
521
|
function deriveWatchInputs(state, props, fileIdentity) {
|
|
373
522
|
const graph = envelopeGraphIndexes(state, props);
|
|
374
523
|
const output = [];
|
|
375
|
-
const
|
|
524
|
+
const physicalSeen = new Set();
|
|
525
|
+
const lexicalSeen = new Set();
|
|
376
526
|
const excluded = new Set([fileIdentity]);
|
|
377
527
|
if (props.temporaryTsconfig !== undefined) {
|
|
378
528
|
excluded.add(derivationIdentity(state, props.temporaryTsconfig));
|
|
379
529
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (excluded.has(identity) || seen.has(identity)) {
|
|
391
|
-
continue;
|
|
530
|
+
const currentSpelling = path.resolve(props.file);
|
|
531
|
+
const temporarySpelling = props.temporaryTsconfig === undefined
|
|
532
|
+
? undefined
|
|
533
|
+
: path.resolve(props.temporaryTsconfig);
|
|
534
|
+
const appendLexical = (input) => {
|
|
535
|
+
const spelling = path.resolve(input);
|
|
536
|
+
if (spelling === currentSpelling ||
|
|
537
|
+
spelling === temporarySpelling ||
|
|
538
|
+
lexicalSeen.has(spelling)) {
|
|
539
|
+
return;
|
|
392
540
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
541
|
+
lexicalSeen.add(spelling);
|
|
542
|
+
physicalSeen.add(derivationIdentity(state, input));
|
|
543
|
+
output.push(input);
|
|
544
|
+
};
|
|
545
|
+
const appendPhysical = (input) => {
|
|
546
|
+
const identity = derivationIdentity(state, input);
|
|
547
|
+
if (excluded.has(identity) || physicalSeen.has(identity))
|
|
548
|
+
return;
|
|
549
|
+
physicalSeen.add(identity);
|
|
550
|
+
lexicalSeen.add(path.resolve(input));
|
|
551
|
+
output.push(input);
|
|
552
|
+
};
|
|
553
|
+
for (const input of selectFileDependencies(props))
|
|
554
|
+
appendLexical(input);
|
|
555
|
+
for (const input of selectGraphInputs(graph, state, {
|
|
556
|
+
...props,
|
|
557
|
+
complete: declaresCompleteDependencies(state, props) &&
|
|
558
|
+
!isVolatileFile(state, props),
|
|
559
|
+
}))
|
|
560
|
+
appendPhysical(input);
|
|
561
|
+
// Resolution candidates, plugin dependencies, and universal host inputs
|
|
562
|
+
// preserve lexical aliases. Physical deduplication would collapse
|
|
563
|
+
// `alias/selection.cjs` into the selected target path, so a bundler would
|
|
564
|
+
// watch only the target and miss a symlink/junction retarget.
|
|
565
|
+
for (const input of selectResolutionCandidateInputs(graph, state, props))
|
|
566
|
+
appendLexical(input);
|
|
567
|
+
for (const input of selectHostInputs(props))
|
|
568
|
+
appendLexical(input);
|
|
396
569
|
return output;
|
|
397
570
|
}
|
|
571
|
+
/** Return exact host-wide descriptor/config inputs for every output file. */
|
|
572
|
+
function selectHostInputs(props) {
|
|
573
|
+
return props.result.type === "exception"
|
|
574
|
+
? []
|
|
575
|
+
: selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
576
|
+
}
|
|
398
577
|
/**
|
|
399
578
|
* Return the module-resolution paths that can supersede a currently resolved
|
|
400
579
|
* module reachable from `file`. They remain host-owned even when a plugin
|
|
@@ -640,14 +819,13 @@ function createTransformResult(source, code) {
|
|
|
640
819
|
*
|
|
641
820
|
* Always compares the current module's in-memory source with the generation
|
|
642
821
|
* snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
|
|
643
|
-
* that comparison alone for
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
* agree on the key universe.
|
|
822
|
+
* that comparison alone for a stable generation's first module delivery in the
|
|
823
|
+
* current build. An incomplete generation may not take this shortcut: otherwise
|
|
824
|
+
* a sibling output captured during a filesystem race could still be served
|
|
825
|
+
* once. Later graph-bearing requests validate the file's derived input set and
|
|
826
|
+
* project membership; graph-free envelopes conservatively re-hash the complete
|
|
827
|
+
* project and out-of-walk snapshots. Any mismatch forces a complete
|
|
828
|
+
* re-transform.
|
|
651
829
|
*/
|
|
652
830
|
function matchesCachedSource(cached, file, source, buildScoped) {
|
|
653
831
|
const identities = envelopeDerivation(cached).identityContext;
|
|
@@ -656,201 +834,1611 @@ function matchesCachedSource(cached, file, source, buildScoped) {
|
|
|
656
834
|
return false;
|
|
657
835
|
}
|
|
658
836
|
if (buildScoped &&
|
|
837
|
+
cached.projectSnapshotComplete === true &&
|
|
659
838
|
!cached.servedFiles?.has(pathIdentityKey(file, identities))) {
|
|
660
839
|
return true;
|
|
661
840
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
841
|
+
if (cached.result.type !== "exception" &&
|
|
842
|
+
cached.result.graph !== undefined &&
|
|
843
|
+
cached.projectSnapshotComplete === true &&
|
|
844
|
+
cached.projectDirectories !== undefined &&
|
|
845
|
+
cached.projectMutationTracker !== undefined &&
|
|
846
|
+
cached.hostInputMutationTracker !== undefined) {
|
|
847
|
+
const narrow = matchesNarrowPersistentInputs(cached, file);
|
|
848
|
+
if (narrow !== undefined) {
|
|
849
|
+
return narrow;
|
|
850
|
+
}
|
|
851
|
+
// Notifications stopped proving membership after this generation was
|
|
852
|
+
// produced. Losing the proof is not evidence of a change, so fall through
|
|
853
|
+
// to the snapshot the entry still carries.
|
|
666
854
|
}
|
|
667
|
-
|
|
668
|
-
// over exactly the recorded key universe, so an edit to a `node_modules`
|
|
669
|
-
// declaration or a monorepo sibling source invalidates the entry even in a
|
|
670
|
-
// host that never clears the cache between builds. A new out-of-walk input
|
|
671
|
-
// cannot appear without some recorded input changing first: a new reference
|
|
672
|
-
// edge requires editing an in-walk source, and a new global or config file
|
|
673
|
-
// requires a tsconfig or package manifest change, both of which the project
|
|
674
|
-
// walk above already detects.
|
|
675
|
-
const externalHashes = cached.externalInputHashes ?? {};
|
|
676
|
-
return sameHashes(externalHashes, collectExternalInputHashes(cached.externalInputPaths ?? Object.keys(externalHashes)));
|
|
855
|
+
return matchesCompleteInputSnapshot(cached, currentKey, source);
|
|
677
856
|
}
|
|
678
|
-
/**
|
|
679
|
-
|
|
680
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Validate one graph-bearing cached output against only the inputs that can
|
|
859
|
+
* affect that file. Project membership is validated once per event-loop turn,
|
|
860
|
+
* so sibling module deliveries share one directory-metadata pass instead of
|
|
861
|
+
* multiplying it by module count.
|
|
862
|
+
*
|
|
863
|
+
* Returns `undefined` when this narrow proof is unavailable — live
|
|
864
|
+
* notifications can no longer prove membership, or the generation carries no
|
|
865
|
+
* universal-input manifest. That is the absence of a proof, not evidence of a
|
|
866
|
+
* change, so the caller falls back to complete-snapshot validation instead of
|
|
867
|
+
* discarding the generation. A reported membership event, a changed universal
|
|
868
|
+
* input, or a changed derived input is evidence, and returns `false`.
|
|
869
|
+
*/
|
|
870
|
+
function matchesNarrowPersistentInputs(cached, file) {
|
|
871
|
+
if (reportsMembershipChange(cached)) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
if (!notificationsProveMembership(cached)) {
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
const state = envelopeDerivation(cached);
|
|
878
|
+
const hostValidation = cached.hostInputValidation;
|
|
879
|
+
if (hostValidation === undefined) {
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
if (!matchesUniversalHostInputs(cached, hostValidation)) {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
const inputs = selectWatchInputs({
|
|
886
|
+
file,
|
|
887
|
+
projectRoot: cached.projectRoot,
|
|
888
|
+
result: cached.result,
|
|
889
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
890
|
+
});
|
|
891
|
+
for (const input of inputs) {
|
|
892
|
+
// Skip by spelling, not identity: the manifest proved this exact path, and
|
|
893
|
+
// an alias of the same physical file is a different input whose own
|
|
894
|
+
// retarget nothing else would see.
|
|
895
|
+
if (hostValidation.covered.has(path.resolve(input))) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!matchesProvenInput(cached, state, input)) {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return true;
|
|
681
903
|
}
|
|
682
904
|
/**
|
|
683
|
-
*
|
|
905
|
+
* Validate one derived input against the generation, skipping the content read
|
|
906
|
+
* while the recorded metadata signature still holds.
|
|
684
907
|
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
908
|
+
* Sibling deliveries of one generation share most of their derived inputs, and
|
|
909
|
+
* `graph.globals` is shared by every one of them, so re-reading and re-hashing
|
|
910
|
+
* the whole derived set per delivery multiplies one generation's proven bytes
|
|
911
|
+
* by the module count. The derived set is proven the same way the universal
|
|
912
|
+
* descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
|
|
913
|
+
* rules: an unchanged signature stands in for the content comparison, and any
|
|
914
|
+
* signature change falls back to the full comparison. A signature is recorded
|
|
915
|
+
* only around a read nothing raced, only for a recorded state that came from
|
|
916
|
+
* reading the input rather than from failing to, and only while the observed
|
|
917
|
+
* filesystem's own clock has provably left the stamp's tick
|
|
918
|
+
* ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
|
|
919
|
+
* hide behind an unchanged signature.
|
|
689
920
|
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
921
|
+
* The signature carries the physical identity of both the lexical path and its
|
|
922
|
+
* link target ({@link inputMetadataSignature}), so retargeting a symlink or
|
|
923
|
+
* junction moves it and the skipped realpath comparison cannot be evaded.
|
|
924
|
+
*/
|
|
925
|
+
function matchesProvenInput(cached, state, input) {
|
|
926
|
+
const slot = inputSignatureSlot(cached, state, input);
|
|
927
|
+
if (slot === undefined) {
|
|
928
|
+
return matchesRecordedInput(cached, input);
|
|
929
|
+
}
|
|
930
|
+
if (slot.recorded === MISSING_INPUT_STATE && notifiesAbsence(cached, input)) {
|
|
931
|
+
// The generation's watcher holds this exact name, and the caller already
|
|
932
|
+
// established that neither tracker failed and neither reported a change.
|
|
933
|
+
// The path is therefore still absent, proven by the same channel that
|
|
934
|
+
// proves project membership, and probing it again would only repeat what
|
|
935
|
+
// the notification already answered.
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
const filesystem = resultFilesystem(cached.result);
|
|
939
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
940
|
+
if (before !== undefined && slot.signatures[slot.key] === before.signature) {
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
if (!matchesRecordedInput(cached, input)) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
// A recorded `missing` state is the one comparison that succeeds without
|
|
947
|
+
// reading anything: an unreadable path still reports `missing`, so its
|
|
948
|
+
// metadata can hold still while the bytes behind it appear. Only content a
|
|
949
|
+
// read produced may be stood for.
|
|
950
|
+
const after = slot.recorded === MISSING_INPUT_STATE
|
|
951
|
+
? undefined
|
|
952
|
+
: inputMetadataSignature(input, filesystem);
|
|
953
|
+
if (after !== undefined && before?.signature === after && before.separable) {
|
|
954
|
+
slot.signatures[slot.key] = after;
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
delete slot.signatures[slot.key];
|
|
958
|
+
}
|
|
959
|
+
return true;
|
|
702
960
|
}
|
|
703
961
|
/**
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
962
|
+
* Report whether the generation's live watcher would announce a creation at
|
|
963
|
+
* this absent input's exact spelling.
|
|
964
|
+
*
|
|
965
|
+
* Losing the watcher is not evidence of anything, so a failed tracker sends the
|
|
966
|
+
* input back to being probed by hand, exactly as a failed tracker already sends
|
|
967
|
+
* the whole generation back to complete-snapshot validation.
|
|
708
968
|
*/
|
|
709
|
-
function
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
969
|
+
function notifiesAbsence(cached, input) {
|
|
970
|
+
const tracker = cached.candidateMutationTracker;
|
|
971
|
+
return (tracker !== undefined &&
|
|
972
|
+
!tracker.failed &&
|
|
973
|
+
tracker.covered?.has(path.resolve(input)) === true);
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Locate the signature manifest that owns one recorded input, mirroring
|
|
977
|
+
* {@link matchesRecordedInput}'s own preference for the out-of-walk spelling's
|
|
978
|
+
* snapshot over the walked project's.
|
|
979
|
+
*
|
|
980
|
+
* The manifest is returned whether or not it currently holds a signature for
|
|
981
|
+
* the input, so a content comparison that succeeds can record one. Without
|
|
982
|
+
* that, an input whose capture-time metadata was too recent to prove anything
|
|
983
|
+
* would keep its content read for the whole life of the generation, since
|
|
984
|
+
* nothing else ever revisits it. Returns `undefined` only for an input the
|
|
985
|
+
* generation recorded no hash for, which no signature could stand for.
|
|
986
|
+
*/
|
|
987
|
+
function inputSignatureSlot(cached, state, input) {
|
|
988
|
+
const identity = derivationIdentity(state, input);
|
|
989
|
+
const external = cached.externalInputHashes ?? {};
|
|
990
|
+
if (Object.prototype.hasOwnProperty.call(external, identity)) {
|
|
991
|
+
// The recorded hash is identity-keyed because aliases of one physical file
|
|
992
|
+
// share its content; the signature is spelling-keyed because they do not
|
|
993
|
+
// share its metadata.
|
|
994
|
+
return {
|
|
995
|
+
key: path.resolve(input),
|
|
996
|
+
recorded: external[identity],
|
|
997
|
+
signatures: (cached.externalInputSignatures ??= {}),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
|
|
1001
|
+
return Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
|
|
1002
|
+
? {
|
|
1003
|
+
key: projectKey,
|
|
1004
|
+
recorded: cached.inputHashes[projectKey],
|
|
1005
|
+
signatures: (cached.inputSignatures ??= {}),
|
|
714
1006
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1007
|
+
: undefined;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Validate universal descriptor/config inputs without re-reading them for every
|
|
1011
|
+
* module. Existing paths use the same nanosecond metadata manifest that guards
|
|
1012
|
+
* GOROOT identity memoization; missing probes are grouped by the nearest
|
|
1013
|
+
* existing directory and checked through one exact membership listing.
|
|
1014
|
+
*/
|
|
1015
|
+
function matchesUniversalHostInputs(cached, validation) {
|
|
1016
|
+
return (matchesUniversalHostInputEntries(cached, validation) &&
|
|
1017
|
+
matchesUniversalHostInputProbes(cached, validation));
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Validate the universal inputs that exist, by metadata first and content only
|
|
1021
|
+
* when that moved.
|
|
1022
|
+
*
|
|
1023
|
+
* Every rejection here is evidence of a change — a vanished path, a moved
|
|
1024
|
+
* physical target, a strict blocker's metadata, differing content — so this
|
|
1025
|
+
* half is safe for a validation path that must never discard a generation for
|
|
1026
|
+
* want of a proof.
|
|
1027
|
+
*/
|
|
1028
|
+
function matchesUniversalHostInputEntries(cached, validation) {
|
|
1029
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1030
|
+
for (const entry of validation.entries.values()) {
|
|
1031
|
+
const evidence = inputMetadataEvidence(entry.path, filesystem);
|
|
1032
|
+
if (entry.signature !== undefined &&
|
|
1033
|
+
evidence?.signature === entry.signature)
|
|
1034
|
+
continue;
|
|
1035
|
+
if (entry.strict === true)
|
|
1036
|
+
return false;
|
|
1037
|
+
if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
|
|
1038
|
+
return false;
|
|
1039
|
+
if (!matchesRecordedInput(cached, entry.path)) {
|
|
1040
|
+
return false;
|
|
718
1041
|
}
|
|
1042
|
+
if (evidence === undefined)
|
|
1043
|
+
return false;
|
|
1044
|
+
// Re-earn the proof under the rules the capture applies: an entry whose
|
|
1045
|
+
// recorded state came from reading nothing keeps its content comparison, a
|
|
1046
|
+
// write racing the read that just proved it records nothing, and a stamp
|
|
1047
|
+
// the filesystem's clock has not provably left records nothing either.
|
|
1048
|
+
const after = inputMetadataSignature(entry.path, filesystem);
|
|
1049
|
+
entry.signature =
|
|
1050
|
+
entry.readable && evidence.separable && after === evidence.signature
|
|
1051
|
+
? evidence.signature
|
|
1052
|
+
: undefined;
|
|
719
1053
|
}
|
|
720
|
-
return
|
|
1054
|
+
return true;
|
|
721
1055
|
}
|
|
722
1056
|
/**
|
|
723
|
-
*
|
|
724
|
-
*
|
|
1057
|
+
* Prove the universal inputs that were absent are still absent, through one
|
|
1058
|
+
* exact listing of the nearest directory that can settle it.
|
|
725
1059
|
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
1060
|
+
* Unlike the entries half, this one rejects on an inability to prove: a
|
|
1061
|
+
* directory that exists but cannot be listed certifies nothing about the
|
|
1062
|
+
* candidates inside it. That is the right answer for the narrow path, which has
|
|
1063
|
+
* no stronger proof to fall back to, but not for the whole-snapshot path, where
|
|
1064
|
+
* the recorded `missing` markers are re-compared directly and losing a proof
|
|
1065
|
+
* must not cost the cache.
|
|
729
1066
|
*/
|
|
730
|
-
function
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
while (stack.length !== 0) {
|
|
734
|
-
const current = stack.pop();
|
|
1067
|
+
function matchesUniversalHostInputProbes(cached, validation) {
|
|
1068
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1069
|
+
for (const [directory, names] of validation.missing) {
|
|
735
1070
|
let entries;
|
|
736
1071
|
try {
|
|
737
|
-
entries =
|
|
738
|
-
}
|
|
739
|
-
catch {
|
|
740
|
-
continue;
|
|
1072
|
+
entries = filesystem.readdir(directory);
|
|
741
1073
|
}
|
|
742
|
-
|
|
743
|
-
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
// Only a provably absent/non-directory ancestor keeps every descendant
|
|
1076
|
+
// unreachable. Permission and transient I/O failures cannot prove that
|
|
1077
|
+
// a candidate is still missing, while replacing the proving directory
|
|
1078
|
+
// with an exact file can itself redirect module resolution.
|
|
1079
|
+
try {
|
|
1080
|
+
if (!filesystem.stat(directory).isDirectory())
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
catch (statError) {
|
|
1084
|
+
if (!isMissingPathError(statError))
|
|
1085
|
+
return false;
|
|
744
1086
|
continue;
|
|
745
1087
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
const identities = envelopeDerivation(cached).identityContext;
|
|
1091
|
+
const caseSensitive = identities.caseSensitive(directory);
|
|
1092
|
+
if (entries.some((entry) => names.has(normalizeHostInputName(entry.name, caseSensitive)))) {
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
/** True only for errors that prove a path cannot currently be traversed. */
|
|
1099
|
+
function isMissingPathError(error) {
|
|
1100
|
+
const code = error?.code;
|
|
1101
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
1102
|
+
}
|
|
1103
|
+
/** Capture the universal-input manifest while the generation is still fresh. */
|
|
1104
|
+
function captureUniversalHostInputValidation(cached, currentFile) {
|
|
1105
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1106
|
+
const state = envelopeDerivation(cached);
|
|
1107
|
+
const validation = {
|
|
1108
|
+
entries: new Map(),
|
|
1109
|
+
covered: new Set(),
|
|
1110
|
+
missing: new Map(),
|
|
1111
|
+
};
|
|
1112
|
+
for (const input of selectPersistentHostInputs({
|
|
1113
|
+
filesystem,
|
|
1114
|
+
projectRoot: cached.projectRoot,
|
|
1115
|
+
result: cached.result,
|
|
1116
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1117
|
+
})) {
|
|
1118
|
+
const generationHashes = cached.result.type === "exception"
|
|
1119
|
+
? undefined
|
|
1120
|
+
: cached.result.hostInputHashes;
|
|
1121
|
+
const generationRealpaths = cached.result.type === "exception"
|
|
1122
|
+
? undefined
|
|
1123
|
+
: cached.result.hostInputRealpaths;
|
|
1124
|
+
const expected = generationHashes?.[path.resolve(input)];
|
|
1125
|
+
// Every persistent universal input must carry an evaluation-time
|
|
1126
|
+
// fingerprint. If a plugin/native host cannot provide one, keep the fresh
|
|
1127
|
+
// result but decline narrow long-lived reuse.
|
|
1128
|
+
let readable = false;
|
|
1129
|
+
if (expected === undefined) {
|
|
1130
|
+
const current = path.resolve(currentFile);
|
|
1131
|
+
if (path.resolve(input) !== current)
|
|
1132
|
+
return undefined;
|
|
1133
|
+
// The current module may be supplied from an unsaved editor buffer. Its
|
|
1134
|
+
// generation snapshot is overlaid below from `currentSource`, so a disk
|
|
1135
|
+
// fingerprint would be both unavailable and the wrong authority. The
|
|
1136
|
+
// recorded state is the bundler's, so a signature of the disk cannot
|
|
1137
|
+
// stand for it however readable that disk is.
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
const current = hostInputStateHash(input, filesystem);
|
|
1141
|
+
if (expected !== current) {
|
|
1142
|
+
return undefined;
|
|
749
1143
|
}
|
|
750
|
-
|
|
751
|
-
|
|
1144
|
+
// A path both sides agree they could not read carries no bytes for a
|
|
1145
|
+
// signature to stand for. It still belongs in the manifest, so the
|
|
1146
|
+
// content comparison keeps running for it on every delivery.
|
|
1147
|
+
readable = current !== null;
|
|
1148
|
+
}
|
|
1149
|
+
const absoluteInput = path.resolve(input);
|
|
1150
|
+
if (generationRealpaths !== undefined) {
|
|
1151
|
+
if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
|
|
1152
|
+
!sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
|
|
1153
|
+
return undefined;
|
|
752
1154
|
}
|
|
753
1155
|
}
|
|
1156
|
+
validation.covered.add(path.resolve(input));
|
|
1157
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1158
|
+
if (!matchesRecordedInput(cached, input))
|
|
1159
|
+
return undefined;
|
|
1160
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
1161
|
+
if (before?.signature !== after)
|
|
1162
|
+
return undefined;
|
|
1163
|
+
if (before !== undefined) {
|
|
1164
|
+
// Do not key this manifest by physical identity. A symlink/junction
|
|
1165
|
+
// spelling and its selected target deliberately share that identity,
|
|
1166
|
+
// but both lexical paths must survive so retargeting the alias is visible.
|
|
1167
|
+
validation.entries.set(path.resolve(input), {
|
|
1168
|
+
path: input,
|
|
1169
|
+
readable,
|
|
1170
|
+
realpath: hostInputRealpath(input, filesystem),
|
|
1171
|
+
// The signature stands in for content only when the read produced the
|
|
1172
|
+
// recorded bytes and the filesystem's clock has provably left the
|
|
1173
|
+
// stamp's tick; otherwise the content comparison keeps running until
|
|
1174
|
+
// the re-earn path can prove both.
|
|
1175
|
+
signature: readable && before.separable ? before.signature : undefined,
|
|
1176
|
+
});
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const probe = missingPathProbe(input, filesystem);
|
|
1180
|
+
if (probe.blocker !== undefined) {
|
|
1181
|
+
const signature = inputMetadataSignature(probe.blocker, filesystem);
|
|
1182
|
+
if (signature === undefined)
|
|
1183
|
+
return undefined;
|
|
1184
|
+
// A blocker proves a kind and an identity, not content: it is the
|
|
1185
|
+
// non-directory ancestor that makes everything below it unreachable, and
|
|
1186
|
+
// it cannot stop being that without its metadata moving. So it keeps a
|
|
1187
|
+
// usable signature whether or not anything read it, and exempt from the
|
|
1188
|
+
// clock-separability rule content signatures need — a same-tick rewrite
|
|
1189
|
+
// of its bytes leaves it exactly as blocking as before.
|
|
1190
|
+
validation.covered.add(path.resolve(probe.blocker));
|
|
1191
|
+
validation.entries.set(path.resolve(probe.blocker), {
|
|
1192
|
+
path: probe.blocker,
|
|
1193
|
+
readable: true,
|
|
1194
|
+
realpath: hostInputRealpath(probe.blocker, filesystem),
|
|
1195
|
+
signature,
|
|
1196
|
+
strict: true,
|
|
1197
|
+
});
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
// The probe below proves this exact spelling absent, so the per-module loop
|
|
1201
|
+
// need not re-derive it either.
|
|
1202
|
+
let names = validation.missing.get(probe.directory);
|
|
1203
|
+
if (names === undefined) {
|
|
1204
|
+
names = new Set();
|
|
1205
|
+
validation.missing.set(probe.directory, names);
|
|
1206
|
+
}
|
|
1207
|
+
names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
|
|
754
1208
|
}
|
|
755
|
-
|
|
756
|
-
return
|
|
1209
|
+
cached.hostInputValidation = validation;
|
|
1210
|
+
return validation;
|
|
757
1211
|
}
|
|
758
1212
|
/**
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
1213
|
+
* The recorded state of an input the generation read nothing from: absent, or
|
|
1214
|
+
* present but unreadable. It is deliberately not a hash, so no signature may
|
|
1215
|
+
* stand in for it: the metadata of an unreadable path holds still while the
|
|
1216
|
+
* bytes behind it appear.
|
|
1217
|
+
*
|
|
1218
|
+
* A directory is not this state. It records the hash of a marker instead, which
|
|
1219
|
+
* a signature may stand for, because the mode both halves of the signature
|
|
1220
|
+
* carry cannot change without the path ceasing to be that directory.
|
|
766
1221
|
*/
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1222
|
+
const MISSING_INPUT_STATE = "missing";
|
|
1223
|
+
/**
|
|
1224
|
+
* The highest stamp each observed filesystem clock has provably minted, keyed
|
|
1225
|
+
* by the operations object that observes it and, inside, by reporting device.
|
|
1226
|
+
*
|
|
1227
|
+
* A filesystem stamps a write once per clock tick, so two same-length writes
|
|
1228
|
+
* inside one tick are indistinguishable by metadata alone. A signature may
|
|
1229
|
+
* therefore stand for content only while a later write is guaranteed to move
|
|
1230
|
+
* it, and that guarantee needs a reference instant the observed filesystem
|
|
1231
|
+
* itself produced: once some stamp on the same device is strictly newer than an
|
|
1232
|
+
* input's modification stamp, that input's tick is provably over, so any later
|
|
1233
|
+
* write must mint a newer stamp and move the signature. That is git's
|
|
1234
|
+
* racily-clean index rule, adapted to a read-only contract: where git compares
|
|
1235
|
+
* entries against the index file's own timestamp, this floor accumulates every
|
|
1236
|
+
* stamp the cache-owned operations report, seeded per generation by
|
|
1237
|
+
* {@link mintFilesystemClockReference}.
|
|
1238
|
+
*
|
|
1239
|
+
* The process clock never participates: both sides of every comparison are
|
|
1240
|
+
* stamps the same filesystem clock minted, at the same granularity, so a
|
|
1241
|
+
* filesystem clock running behind (or ahead of) the host process changes
|
|
1242
|
+
* nothing.
|
|
1243
|
+
*
|
|
1244
|
+
* Accumulating observed stamps is deliberately weaker than git's own reference,
|
|
1245
|
+
* which is a single stamp git minted itself. A stamp this floor accepts may
|
|
1246
|
+
* instead have been _set_ rather than minted, and a set stamp is dangerous only
|
|
1247
|
+
* when it lands in the future: the floor is a maximum, so a restored past stamp
|
|
1248
|
+
* never raises it. One future-dated file — a stamp-preserving extraction or
|
|
1249
|
+
* copy from a machine whose clock ran ahead — pushes its device's floor past
|
|
1250
|
+
* the present and reopens the same-tick window for every other input on that
|
|
1251
|
+
* device until the clock catches up. A clock that jumps backwards strands the
|
|
1252
|
+
* floor above the present the same way, a different hazard from the constant
|
|
1253
|
+
* offset the paragraph above is about: an offset moves both operands together
|
|
1254
|
+
* and changes nothing, a jump moves only the present.
|
|
1255
|
+
*
|
|
1256
|
+
* The minted probe is not enough on its own to replace observed stamps: it
|
|
1257
|
+
* lands on the scratch volume, which is frequently not the inputs' volume (a
|
|
1258
|
+
* project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
|
|
1259
|
+
* decline every _content_ signature, so every input carrying bytes would be
|
|
1260
|
+
* re-read on every delivery. A strict blocker keeps its signature either way,
|
|
1261
|
+
* because it proves a kind rather than content. Observed stamps keep the common
|
|
1262
|
+
* case working; the probe covers the case they cannot, a tree whose files were
|
|
1263
|
+
* all written inside one tick.
|
|
1264
|
+
*/
|
|
1265
|
+
const FILESYSTEM_CLOCK_FLOORS = new WeakMap();
|
|
1266
|
+
/** Return one observed filesystem's per-device clock floor, creating it. */
|
|
1267
|
+
function filesystemClockFloors(filesystem) {
|
|
1268
|
+
let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
|
|
1269
|
+
if (floors === undefined) {
|
|
1270
|
+
floors = new Map();
|
|
1271
|
+
FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
|
|
1272
|
+
}
|
|
1273
|
+
return floors;
|
|
1274
|
+
}
|
|
1275
|
+
/** Raise a device's clock floor with the stamps one observation reported. */
|
|
1276
|
+
function observeFilesystemClock(filesystem, stats) {
|
|
1277
|
+
const floors = filesystemClockFloors(filesystem);
|
|
1278
|
+
const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
|
|
1279
|
+
const current = floors.get(stats.dev);
|
|
1280
|
+
if (current === undefined || stamp > current) {
|
|
1281
|
+
floors.set(stats.dev, stamp);
|
|
770
1282
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Report whether a later write to the observed path is guaranteed to move its
|
|
1286
|
+
* modification stamp: the device's clock floor holds a stamp strictly newer, so
|
|
1287
|
+
* the tick that minted the stamp is provably over. The floor was observed
|
|
1288
|
+
* before the caller's content read began, which is the ordering the guarantee
|
|
1289
|
+
* needs — a stamp minted before the read proves every post-read write lands in
|
|
1290
|
+
* a newer tick.
|
|
1291
|
+
*/
|
|
1292
|
+
function stampSeparable(filesystem, stats) {
|
|
1293
|
+
const floor = filesystemClockFloors(filesystem).get(stats.dev);
|
|
1294
|
+
return floor !== undefined && stats.mtimeNs < floor;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Mint a reference instant for this generation and feed it into the observed
|
|
1298
|
+
* filesystem's clock floor.
|
|
1299
|
+
*
|
|
1300
|
+
* The scratch directory is a write the adapter already owns, deliberately
|
|
1301
|
+
* outside the project root, so stamping a probe file there produces a
|
|
1302
|
+
* freshly-minted "now" without touching the user's project — the analogue of
|
|
1303
|
+
* git writing its index. The probe is observed through the cache-owned
|
|
1304
|
+
* operations and keyed by the device those operations report, so it only ever
|
|
1305
|
+
* separates stamps on the filesystem that actually minted it; when the scratch
|
|
1306
|
+
* volume differs from the inputs' volume, or the observed filesystem cannot see
|
|
1307
|
+
* the probe at all, nothing is proven and signature recording simply stays
|
|
1308
|
+
* declined until passively observed stamps separate an input on their own.
|
|
1309
|
+
*
|
|
1310
|
+
* Relocating the scratch directory onto the inputs' volume would make the probe
|
|
1311
|
+
* universal, but it would also move every compiler and plugin temporary write
|
|
1312
|
+
* into the project's parent (frequently a monorepo root or a home directory)
|
|
1313
|
+
* for those layouts. That is a product decision about where ttsc writes, not a
|
|
1314
|
+
* property of this rule, so the cross-volume case degrades to more reads here
|
|
1315
|
+
* rather than being bought with it.
|
|
1316
|
+
*/
|
|
1317
|
+
function mintFilesystemClockReference(scratchDirectory, filesystem) {
|
|
1318
|
+
try {
|
|
1319
|
+
const probe = path.join(scratchDirectory, "clock-reference");
|
|
1320
|
+
fs.writeFileSync(probe, "");
|
|
1321
|
+
observeFilesystemClock(filesystem, filesystem.lstat(probe));
|
|
779
1322
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1323
|
+
catch {
|
|
1324
|
+
// The absence of a reference declines signature recording; it never
|
|
1325
|
+
// invalidates a generation.
|
|
783
1326
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1327
|
+
}
|
|
1328
|
+
/** Metadata identity whose stability lets a generation reuse a content hash. */
|
|
1329
|
+
function inputMetadataSignature(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1330
|
+
return inputMetadataEvidence(file, filesystem)?.signature;
|
|
1331
|
+
}
|
|
1332
|
+
/** Observe one input's metadata signature and its clock separability. */
|
|
1333
|
+
function inputMetadataEvidence(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1334
|
+
try {
|
|
1335
|
+
const link = filesystem.lstat(file);
|
|
1336
|
+
observeFilesystemClock(filesystem, link);
|
|
1337
|
+
let target = link;
|
|
1338
|
+
if (link.isSymbolicLink()) {
|
|
1339
|
+
try {
|
|
1340
|
+
target = filesystem.statBigInt(file);
|
|
1341
|
+
observeFilesystemClock(filesystem, target);
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
// Keep a broken link in the existing-input manifest. Its own metadata
|
|
1345
|
+
// stays stable while the target is missing, and the first successful
|
|
1346
|
+
// stat after the target appears changes this signature. Treating it as
|
|
1347
|
+
// a plain missing path would watch/list only the link's parent, which
|
|
1348
|
+
// cannot observe a target created in another directory. It carries no
|
|
1349
|
+
// readable bytes, so it never needs to be separable.
|
|
1350
|
+
return {
|
|
1351
|
+
signature: [
|
|
1352
|
+
link.dev,
|
|
1353
|
+
link.ino,
|
|
1354
|
+
link.mode,
|
|
1355
|
+
link.size,
|
|
1356
|
+
link.mtimeNs,
|
|
1357
|
+
link.ctimeNs,
|
|
1358
|
+
"missing-target",
|
|
1359
|
+
].join(":"),
|
|
1360
|
+
separable: false,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return {
|
|
1365
|
+
signature: [
|
|
1366
|
+
link.dev,
|
|
1367
|
+
link.ino,
|
|
1368
|
+
link.mode,
|
|
1369
|
+
link.size,
|
|
1370
|
+
link.mtimeNs,
|
|
1371
|
+
link.ctimeNs,
|
|
1372
|
+
target.dev,
|
|
1373
|
+
target.ino,
|
|
1374
|
+
target.mode,
|
|
1375
|
+
target.size,
|
|
1376
|
+
target.mtimeNs,
|
|
1377
|
+
target.ctimeNs,
|
|
1378
|
+
].join(":"),
|
|
1379
|
+
// Both halves must be separable: a write remints the target's stamp, a
|
|
1380
|
+
// link retarget the link's own, and either one hiding inside its recorded
|
|
1381
|
+
// tick would evade the skipped content and realpath comparisons.
|
|
1382
|
+
separable: stampSeparable(filesystem, link) && stampSeparable(filesystem, target),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
return undefined;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
/** Content/kind fingerprint matching the compiler host-input contract. */
|
|
1390
|
+
function hostInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1391
|
+
try {
|
|
1392
|
+
return hashText(filesystem.readFile(file));
|
|
1393
|
+
}
|
|
1394
|
+
catch {
|
|
788
1395
|
try {
|
|
789
|
-
|
|
1396
|
+
return filesystem.stat(file).isDirectory()
|
|
1397
|
+
? hashText("ttsc:host-input:directory\0")
|
|
1398
|
+
: null;
|
|
790
1399
|
}
|
|
791
1400
|
catch {
|
|
792
|
-
return
|
|
793
|
-
}
|
|
794
|
-
if (stats.isSymbolicLink()) {
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
const leaf = index === segments.length - 1;
|
|
798
|
-
if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) {
|
|
799
|
-
return false;
|
|
1401
|
+
return null;
|
|
800
1402
|
}
|
|
801
1403
|
}
|
|
802
|
-
return true;
|
|
803
1404
|
}
|
|
804
|
-
/**
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
for (const file of paths) {
|
|
817
|
-
const identity = pathIdentityKey(file, identities);
|
|
818
|
-
if (identity in hashes) {
|
|
819
|
-
continue;
|
|
1405
|
+
/** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
|
|
1406
|
+
function graphInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1407
|
+
try {
|
|
1408
|
+
const bytes = filesystem.readFile(file);
|
|
1409
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
1410
|
+
const even = bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2);
|
|
1411
|
+
return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
|
|
1412
|
+
}
|
|
1413
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
1414
|
+
const even = Buffer.from(bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2));
|
|
1415
|
+
even.swap16();
|
|
1416
|
+
return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
|
|
820
1417
|
}
|
|
1418
|
+
const content = bytes.length >= 3 &&
|
|
1419
|
+
bytes[0] === 0xef &&
|
|
1420
|
+
bytes[1] === 0xbb &&
|
|
1421
|
+
bytes[2] === 0xbf
|
|
1422
|
+
? bytes.subarray(3)
|
|
1423
|
+
: bytes;
|
|
1424
|
+
return hashText(content);
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
821
1427
|
try {
|
|
822
|
-
|
|
1428
|
+
return filesystem.stat(file).isDirectory()
|
|
1429
|
+
? hashText("ttsc:host-input:directory\0")
|
|
1430
|
+
: null;
|
|
823
1431
|
}
|
|
824
1432
|
catch {
|
|
825
|
-
|
|
1433
|
+
return null;
|
|
826
1434
|
}
|
|
827
1435
|
}
|
|
828
|
-
|
|
1436
|
+
}
|
|
1437
|
+
/** Physical target selected by a lexical host-input path. */
|
|
1438
|
+
function hostInputRealpath(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1439
|
+
try {
|
|
1440
|
+
return filesystem.realpath(file);
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/** Compare two reported realpaths by filesystem identity, not Windows spelling. */
|
|
1447
|
+
function sameHostInputRealpath(left, right, identities) {
|
|
1448
|
+
if (left === undefined || (left === null) !== (right === null))
|
|
1449
|
+
return false;
|
|
1450
|
+
if (left === null || right === null)
|
|
1451
|
+
return true;
|
|
1452
|
+
return (pathIdentityKey(left, identities) === pathIdentityKey(right, identities));
|
|
1453
|
+
}
|
|
1454
|
+
/** Find one directory listing that proves an absent path is still absent. */
|
|
1455
|
+
function missingPathProbe(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1456
|
+
let child = path.resolve(file);
|
|
1457
|
+
for (;;) {
|
|
1458
|
+
const directory = path.dirname(child);
|
|
1459
|
+
try {
|
|
1460
|
+
const stats = filesystem.stat(directory);
|
|
1461
|
+
if (stats.isDirectory()) {
|
|
1462
|
+
return { directory, name: path.basename(child) };
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
blocker: directory,
|
|
1466
|
+
directory: path.dirname(directory),
|
|
1467
|
+
name: path.basename(directory),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
catch { }
|
|
1471
|
+
if (directory === child) {
|
|
1472
|
+
return { directory, name: path.basename(child) };
|
|
1473
|
+
}
|
|
1474
|
+
child = directory;
|
|
1475
|
+
}
|
|
829
1476
|
}
|
|
830
1477
|
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
* config chain) and every plugin-reported dependency, minus everything the
|
|
834
|
-
* project walk already hashes and the disposed temp-dir tsconfig. These are the
|
|
835
|
-
* inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
|
|
836
|
-
* that are still missing remain in this set even under the project root: the
|
|
837
|
-
* first walk cannot hash a file that has not been created yet.
|
|
1478
|
+
* Prove one generation from its own recorded snapshot, with no help from live
|
|
1479
|
+
* notifications.
|
|
838
1480
|
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* lands at the bundler boundary through {@link selectWatchInputs}, which is what
|
|
846
|
-
* feeds persistent caches and watch graphs.
|
|
1481
|
+
* This is the fallback for a graph-free envelope and for a generation whose
|
|
1482
|
+
* watchers could not be opened or have since failed: losing the notification
|
|
1483
|
+
* proof must cost the narrow path, not the cache. The walk re-proves membership
|
|
1484
|
+
* directly — the recorded directory signatures plus the recorded file-key
|
|
1485
|
+
* universe — so a created, deleted, or renamed input still invalidates without
|
|
1486
|
+
* any watcher.
|
|
847
1487
|
*/
|
|
848
|
-
function
|
|
849
|
-
if (
|
|
850
|
-
|
|
1488
|
+
function matchesCompleteInputSnapshot(cached, currentKey, source) {
|
|
1489
|
+
if (cached.projectSnapshotComplete !== true ||
|
|
1490
|
+
cached.projectDirectories === undefined) {
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
// Universal descriptor/config inputs carry a physical-identity proof that no
|
|
1494
|
+
// content comparison can replace: retargeting a symlinked input to a
|
|
1495
|
+
// byte-identical file selects a different file, and its own transitive
|
|
1496
|
+
// requires with it. Only the graph half of the out-of-walk snapshot records
|
|
1497
|
+
// realpaths, so without this the fallback would quietly hold a lower standard
|
|
1498
|
+
// than the narrow path it stands in for.
|
|
1499
|
+
const state = envelopeDerivation(cached);
|
|
1500
|
+
const hostValidation = cached.hostInputValidation;
|
|
1501
|
+
if (hostValidation === undefined ||
|
|
1502
|
+
!matchesUniversalHostInputEntries(cached, hostValidation)) {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
const declaredInputs = declaredProjectInputKeys(state, cached);
|
|
1506
|
+
const current = collectProjectInputSnapshot(cached.projectRoot, state.identityContext, resultFilesystem(cached.result), cached.inputSignatures === undefined
|
|
1507
|
+
? undefined
|
|
1508
|
+
: { hashes: cached.inputHashes, signatures: cached.inputSignatures });
|
|
1509
|
+
if (!walkSnapshotComplete(current, declaredInputs)) {
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
current.hashes[currentKey] = hashText(source);
|
|
1516
|
+
if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
// Re-hash the out-of-walk inputs the compiler reported for this generation
|
|
1520
|
+
// over exactly the recorded key universe, so an edit to a `node_modules`
|
|
1521
|
+
// declaration or a monorepo sibling source invalidates the entry even in a
|
|
1522
|
+
// host that never clears the cache between builds. A new out-of-walk input
|
|
1523
|
+
// cannot appear without some recorded input changing first: a new reference
|
|
1524
|
+
// edge requires editing an in-walk source, and a new global or config file
|
|
1525
|
+
// requires a tsconfig or package manifest change, both of which the project
|
|
1526
|
+
// walk above already detects.
|
|
1527
|
+
const externalCurrent = matchesCachedExternalInputs(cached);
|
|
1528
|
+
if (!externalCurrent.matches || !matchesExternalInputRealpaths(cached)) {
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
adoptProvenSignatures(cached, {
|
|
1532
|
+
currentKey,
|
|
1533
|
+
external: externalCurrent.signatures,
|
|
1534
|
+
project: current.provenSignatures,
|
|
1535
|
+
});
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Adopt the signatures captured while this walk proved every recorded input
|
|
1540
|
+
* still carries its recorded content.
|
|
1541
|
+
*
|
|
1542
|
+
* Without this, a metadata-only change — a touch, or a rewrite of identical
|
|
1543
|
+
* bytes — costs a re-read on every later delivery for the rest of the
|
|
1544
|
+
* generation's life, because the recorded signature can never match again. The
|
|
1545
|
+
* narrow path self-heals through {@link matchesProvenInput}; this is the same
|
|
1546
|
+
* refresh for the path that proves the whole snapshot at once.
|
|
1547
|
+
*
|
|
1548
|
+
* The delivered file is the single exclusion: its recorded hash is the source
|
|
1549
|
+
* the bundler supplied, so the disk bytes this walk read for it were compared
|
|
1550
|
+
* against nothing.
|
|
1551
|
+
*/
|
|
1552
|
+
function adoptProvenSignatures(cached, proven) {
|
|
1553
|
+
const projectSignatures = (cached.inputSignatures ??= {});
|
|
1554
|
+
for (const [key, signature] of Object.entries(proven.project)) {
|
|
1555
|
+
if (key === proven.currentKey)
|
|
1556
|
+
continue;
|
|
1557
|
+
projectSignatures[key] = signature;
|
|
1558
|
+
}
|
|
1559
|
+
const externalSignatures = (cached.externalInputSignatures ??= {});
|
|
1560
|
+
for (const [spelling, signature] of Object.entries(proven.external)) {
|
|
1561
|
+
externalSignatures[spelling] = signature;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/** Re-check graph-owned physical identities in complete-snapshot fallback. */
|
|
1565
|
+
function matchesExternalInputRealpaths(cached) {
|
|
1566
|
+
const expected = cached.externalInputRealpaths;
|
|
1567
|
+
if (expected === undefined || Object.keys(expected).length === 0)
|
|
1568
|
+
return true;
|
|
1569
|
+
const state = envelopeDerivation(cached);
|
|
1570
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1571
|
+
for (const input of cached.externalInputPaths ?? []) {
|
|
1572
|
+
const identity = derivationIdentity(state, input);
|
|
1573
|
+
if (!Object.prototype.hasOwnProperty.call(expected, identity))
|
|
1574
|
+
continue;
|
|
1575
|
+
if (!sameHostInputRealpath(expected[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Capture external-input hashes without attaching post-compile state to an
|
|
1583
|
+
* earlier graph. Graph members must carry compiler-time proof and still match
|
|
1584
|
+
* it now; plugin-declared dependency-only paths retain the historical
|
|
1585
|
+
* post-compile snapshot because their own protocol does not claim generation
|
|
1586
|
+
* fingerprints.
|
|
1587
|
+
*/
|
|
1588
|
+
function captureExternalInputSnapshot(cached, paths) {
|
|
1589
|
+
const state = envelopeDerivation(cached);
|
|
1590
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1591
|
+
const graph = envelopeGraphIndexes(state, cached);
|
|
1592
|
+
const hashes = {};
|
|
1593
|
+
const realpaths = {};
|
|
1594
|
+
const signatures = {};
|
|
1595
|
+
let complete = true;
|
|
1596
|
+
// Sandwich every read between two metadata signatures. Only a signature that
|
|
1597
|
+
// survived its own read, and whose stamp's tick the filesystem's clock has
|
|
1598
|
+
// provably left ({@link stampSeparable}), may stand in for the content
|
|
1599
|
+
// comparison; a write racing the capture, or a stamp a same-tick rewrite
|
|
1600
|
+
// could still reproduce, leaves the input without one, so revalidation keeps
|
|
1601
|
+
// re-reading it.
|
|
1602
|
+
const record = (input, before, after) => {
|
|
1603
|
+
if (after !== undefined && before?.signature === after && before.separable)
|
|
1604
|
+
signatures[path.resolve(input)] = after;
|
|
1605
|
+
};
|
|
1606
|
+
for (const input of paths) {
|
|
1607
|
+
const identity = derivationIdentity(state, input);
|
|
1608
|
+
// A member the envelope reported only as a resolution candidate falls
|
|
1609
|
+
// through to the recorded-state branch below, the same evidence a
|
|
1610
|
+
// plugin-declared dependency path carries. Its absence still invalidates
|
|
1611
|
+
// the generation when it appears, because `missing` is recorded state.
|
|
1612
|
+
const speculativeOnly = graph.speculative.has(identity) &&
|
|
1613
|
+
!graph.inputProofs.has(identity) &&
|
|
1614
|
+
!graph.inputProofConflicts.has(identity);
|
|
1615
|
+
if (graph.members.has(identity) && !speculativeOnly) {
|
|
1616
|
+
const proof = graph.inputProofs.get(identity);
|
|
1617
|
+
if (proof === undefined || graph.inputProofConflicts.has(identity)) {
|
|
1618
|
+
complete = false;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1622
|
+
const currentHash = graphInputStateHash(input, filesystem);
|
|
1623
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
1624
|
+
if (currentHash !== proof.hash ||
|
|
1625
|
+
!sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
|
|
1626
|
+
complete = false;
|
|
1627
|
+
}
|
|
1628
|
+
else if (currentHash !== null) {
|
|
1629
|
+
// The recorded hash is the compiler's own proof, so a signature may
|
|
1630
|
+
// only stand for it once the current bytes were shown to match it.
|
|
1631
|
+
// A path with no readable content has no bytes to stand for: it can
|
|
1632
|
+
// hold stable metadata while becoming readable, so it keeps the read.
|
|
1633
|
+
record(input, before, after);
|
|
1634
|
+
}
|
|
1635
|
+
hashes[identity] = proof.hash ?? MISSING_INPUT_STATE;
|
|
1636
|
+
realpaths[identity] = proof.realpath;
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1640
|
+
const hash = hostInputStateHash(input, filesystem);
|
|
1641
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
1642
|
+
hashes[identity] = hash ?? MISSING_INPUT_STATE;
|
|
1643
|
+
if (hash !== null)
|
|
1644
|
+
record(input, before, after);
|
|
1645
|
+
}
|
|
1646
|
+
return { complete, hashes, realpaths, signatures };
|
|
1647
|
+
}
|
|
1648
|
+
/** Verify every graph member still has the state read by the compiler. */
|
|
1649
|
+
function matchesCompilerGraphInputProofs(cached) {
|
|
1650
|
+
if (cached.result.type === "exception" ||
|
|
1651
|
+
cached.result.graph === undefined ||
|
|
1652
|
+
(cached.result.graph.inputHashes === undefined &&
|
|
1653
|
+
cached.result.graph.inputRealpaths === undefined)) {
|
|
1654
|
+
// Legacy sidecars remain compatible for ordinary in-project graphs. Their
|
|
1655
|
+
// out-of-walk members are still rejected by captureExternalInputSnapshot,
|
|
1656
|
+
// where a post-compile snapshot cannot prove the compiler's generation.
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
const state = envelopeDerivation(cached);
|
|
1660
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1661
|
+
const graph = envelopeGraphIndexes(state, cached);
|
|
1662
|
+
if (graph.inputProofConflicts.size !== 0) {
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1665
|
+
for (const identity of graph.members) {
|
|
1666
|
+
const proof = graph.inputProofs.get(identity);
|
|
1667
|
+
// A speculative candidate has no compile-time read to prove. Requiring one
|
|
1668
|
+
// would void every generation of every project whose resolution passes over
|
|
1669
|
+
// a higher-priority spelling, which is every project with a dependency
|
|
1670
|
+
// typed by a declaration file (samchon/ttsc#1245). It is validated instead
|
|
1671
|
+
// against the state {@link captureExternalInputSnapshot} recorded for it.
|
|
1672
|
+
if (proof === undefined && graph.speculative.has(identity)) {
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (proof === undefined ||
|
|
1676
|
+
graphInputStateHash(proof.path, filesystem) !== proof.hash ||
|
|
1677
|
+
!sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
/** Compare one derived input with the snapshot that owned it at generation. */
|
|
1684
|
+
function matchesRecordedInput(cached, input) {
|
|
1685
|
+
const state = envelopeDerivation(cached);
|
|
1686
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1687
|
+
const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
|
|
1688
|
+
const projectHash = Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
|
|
1689
|
+
? cached.inputHashes[projectKey]
|
|
1690
|
+
: undefined;
|
|
1691
|
+
const identity = derivationIdentity(state, input);
|
|
1692
|
+
const externalHash = (cached.externalInputHashes ?? {})[identity];
|
|
1693
|
+
const externalRealpaths = cached.externalInputRealpaths;
|
|
1694
|
+
const graphInput = externalRealpaths !== undefined &&
|
|
1695
|
+
Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
|
|
1696
|
+
if (externalRealpaths !== undefined &&
|
|
1697
|
+
Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
|
|
1698
|
+
!sameHostInputRealpath(externalRealpaths[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
// Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
|
|
1702
|
+
// alias can point back into the walked project, where the physical target's
|
|
1703
|
+
// project hash is a different authority (and graph text uses BOM decoding).
|
|
1704
|
+
const recorded = externalHash ?? projectHash;
|
|
1705
|
+
if (recorded === undefined) {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const current = graphInput
|
|
1710
|
+
? graphInputStateHash(input, filesystem)
|
|
1711
|
+
: hostInputStateHash(input, filesystem);
|
|
1712
|
+
return recorded === (current ?? MISSING_INPUT_STATE);
|
|
1713
|
+
}
|
|
1714
|
+
catch {
|
|
1715
|
+
return recorded === MISSING_INPUT_STATE;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/** Record a successfully selected module as delivered by this generation. */
|
|
1719
|
+
function markCachedSourceServed(cached, file) {
|
|
1720
|
+
(cached.servedFiles ??= new Set()).add(pathIdentityKey(file, envelopeDerivation(cached).identityContext));
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Hash every input file under `projectRoot` (the same walk universe
|
|
1724
|
+
* {@link matchesCachedSource} validates against), keyed by project-relative
|
|
1725
|
+
* slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
|
|
1726
|
+
* can fold the identical input universe into their own cache fingerprints.
|
|
1727
|
+
*/
|
|
1728
|
+
function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1729
|
+
return collectProjectInputSnapshot(projectRoot, identities, filesystem)
|
|
1730
|
+
.hashes;
|
|
1731
|
+
}
|
|
1732
|
+
/** Hash project files and snapshot the directory topology in one walk. */
|
|
1733
|
+
function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven) {
|
|
1734
|
+
const hashes = {};
|
|
1735
|
+
const fileSignatures = {};
|
|
1736
|
+
const provenSignatures = {};
|
|
1737
|
+
const unstableFiles = new Set();
|
|
1738
|
+
let attributed = true;
|
|
1739
|
+
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
1740
|
+
let complete = walked.complete;
|
|
1741
|
+
for (const file of walked.files) {
|
|
1742
|
+
try {
|
|
1743
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
1744
|
+
const key = toProjectKey(projectRoot, file, identities);
|
|
1745
|
+
// A file whose signature still equals the one captured around the read
|
|
1746
|
+
// that produced the recorded hash carries that content, so the whole
|
|
1747
|
+
// project does not have to be re-read to prove one delivery. A signature
|
|
1748
|
+
// that was already proven stays proven: its stamp has not moved since the
|
|
1749
|
+
// clock provably left its tick.
|
|
1750
|
+
if (before !== undefined &&
|
|
1751
|
+
proven !== undefined &&
|
|
1752
|
+
proven.signatures[key] === before.signature &&
|
|
1753
|
+
Object.prototype.hasOwnProperty.call(proven.hashes, key)) {
|
|
1754
|
+
hashes[key] = proven.hashes[key];
|
|
1755
|
+
fileSignatures[key] = before.signature;
|
|
1756
|
+
provenSignatures[key] = before.signature;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
const contents = filesystem.readFile(file);
|
|
1760
|
+
const after = inputMetadataSignature(file, filesystem);
|
|
1761
|
+
hashes[key] = hashText(contents);
|
|
1762
|
+
if (before === undefined ||
|
|
1763
|
+
after === undefined ||
|
|
1764
|
+
before.signature !== after) {
|
|
1765
|
+
complete = false;
|
|
1766
|
+
unstableFiles.add(key);
|
|
1767
|
+
}
|
|
1768
|
+
else {
|
|
1769
|
+
fileSignatures[key] = after;
|
|
1770
|
+
// Only a signature whose stamp's tick the filesystem's clock provably
|
|
1771
|
+
// left before this read may later stand in for the content comparison
|
|
1772
|
+
// ({@link stampSeparable}); the raw signature above still participates
|
|
1773
|
+
// in the generation-time stability comparison.
|
|
1774
|
+
if (before.separable) {
|
|
1775
|
+
provenSignatures[key] = after;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
catch {
|
|
1780
|
+
// File watchers may observe a transform while another process is moving
|
|
1781
|
+
// or deleting files. The missing key invalidates older cache entries.
|
|
1782
|
+
complete = false;
|
|
1783
|
+
try {
|
|
1784
|
+
unstableFiles.add(toProjectKey(projectRoot, file, identities));
|
|
1785
|
+
}
|
|
1786
|
+
catch {
|
|
1787
|
+
// Without a key the failure cannot be attributed, so it keeps the
|
|
1788
|
+
// whole snapshot incomplete rather than being scoped away.
|
|
1789
|
+
attributed = false;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return {
|
|
1794
|
+
complete,
|
|
1795
|
+
directoryComplete: walked.complete && attributed,
|
|
1796
|
+
fileSignatures,
|
|
1797
|
+
hashes,
|
|
1798
|
+
projectDirectories: walked.directories,
|
|
1799
|
+
provenSignatures,
|
|
1800
|
+
unstableFiles,
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Enumerate every regular file under `root`, skipping well-known output and
|
|
1805
|
+
* tooling directories (see {@link isIgnoredProjectDirectory}).
|
|
1806
|
+
*
|
|
1807
|
+
* Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
|
|
1808
|
+
* unbounded call-stack depth on deep project trees. The result is sorted so
|
|
1809
|
+
* that hash comparisons are deterministic across OS-level directory orderings.
|
|
1810
|
+
*/
|
|
1811
|
+
function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1812
|
+
let complete = true;
|
|
1813
|
+
const directories = [];
|
|
1814
|
+
const files = [];
|
|
1815
|
+
const stack = [root];
|
|
1816
|
+
while (stack.length !== 0) {
|
|
1817
|
+
const current = stack.pop();
|
|
1818
|
+
const before = projectDirectorySignature(current, filesystem);
|
|
1819
|
+
if (before === undefined) {
|
|
1820
|
+
complete = false;
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
let entries;
|
|
1824
|
+
try {
|
|
1825
|
+
entries = filesystem.readdir(current);
|
|
1826
|
+
}
|
|
1827
|
+
catch {
|
|
1828
|
+
complete = false;
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
const after = projectDirectorySignature(current, filesystem);
|
|
1832
|
+
if (after === undefined || before !== after) {
|
|
1833
|
+
complete = false;
|
|
1834
|
+
}
|
|
1835
|
+
directories.push({
|
|
1836
|
+
path: current,
|
|
1837
|
+
// If membership moved during enumeration, force the next delivery to
|
|
1838
|
+
// replace this generation instead of blessing a torn directory/file
|
|
1839
|
+
// snapshot as stable.
|
|
1840
|
+
signature: after !== undefined && before === after
|
|
1841
|
+
? after
|
|
1842
|
+
: `unstable:${before}:${after ?? "missing"}`,
|
|
1843
|
+
});
|
|
1844
|
+
for (const entry of entries) {
|
|
1845
|
+
if (isIgnoredProjectDirectory(entry.name)) {
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const file = path.join(current, entry.name);
|
|
1849
|
+
if (entry.isDirectory()) {
|
|
1850
|
+
stack.push(file);
|
|
1851
|
+
}
|
|
1852
|
+
else if (entry.isFile()) {
|
|
1853
|
+
files.push(file);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
1858
|
+
files.sort();
|
|
1859
|
+
return { complete, directories, files };
|
|
1860
|
+
}
|
|
1861
|
+
/** Return a cheap identity for one directory's immediate membership. */
|
|
1862
|
+
function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1863
|
+
try {
|
|
1864
|
+
const stats = filesystem.statBigInt(directory);
|
|
1865
|
+
// Directory stamps are minted by the same clock as file stamps, so every
|
|
1866
|
+
// walk observation also raises the clock floor that separates them.
|
|
1867
|
+
observeFilesystemClock(filesystem, stats);
|
|
1868
|
+
if (!stats.isDirectory()) {
|
|
1869
|
+
return undefined;
|
|
1870
|
+
}
|
|
1871
|
+
return [
|
|
1872
|
+
stats.dev,
|
|
1873
|
+
stats.ino,
|
|
1874
|
+
stats.mode,
|
|
1875
|
+
stats.size,
|
|
1876
|
+
stats.mtimeNs,
|
|
1877
|
+
stats.ctimeNs,
|
|
1878
|
+
].join(":");
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/** Compare two deterministic project-directory membership snapshots. */
|
|
1885
|
+
function sameProjectDirectories(left, right) {
|
|
1886
|
+
return (left.length === right.length &&
|
|
1887
|
+
left.every((directory, index) => directory.path === right[index]?.path &&
|
|
1888
|
+
directory.signature === right[index]?.signature));
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Open one directory's change notification through the cache-owned watch seam,
|
|
1892
|
+
* falling back to the host's own `fs.watch`. Throws exactly where the
|
|
1893
|
+
* underlying watch does, so callers classify a registration failure
|
|
1894
|
+
* themselves.
|
|
1895
|
+
*/
|
|
1896
|
+
function openDirectoryWatch(filesystem, directory, listener, onError) {
|
|
1897
|
+
if (filesystem.watch !== undefined) {
|
|
1898
|
+
return filesystem.watch(directory, listener, onError);
|
|
1899
|
+
}
|
|
1900
|
+
const watcher = fs.watch(directory, { persistent: false }, (eventType, filename) => listener(eventType, filename === null ? null : String(filename)));
|
|
1901
|
+
watcher.on("error", onError);
|
|
1902
|
+
return { close: () => watcher.close() };
|
|
1903
|
+
}
|
|
1904
|
+
/** Watch every walked directory for membership changes after generation. */
|
|
1905
|
+
async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1906
|
+
const tracker = {
|
|
1907
|
+
close: () => undefined,
|
|
1908
|
+
failed: false,
|
|
1909
|
+
membershipChanged: false,
|
|
1910
|
+
};
|
|
1911
|
+
if (process.platform === "win32" && filesystem.watch === undefined) {
|
|
1912
|
+
await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
|
|
1913
|
+
return tracker;
|
|
1914
|
+
}
|
|
1915
|
+
const watchers = [];
|
|
1916
|
+
tracker.close = () => {
|
|
1917
|
+
for (const watcher of watchers)
|
|
1918
|
+
watcher.close();
|
|
1919
|
+
watchers.length = 0;
|
|
1920
|
+
};
|
|
1921
|
+
for (const directory of directories) {
|
|
1922
|
+
try {
|
|
1923
|
+
watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
|
|
1924
|
+
if (eventType === "rename")
|
|
1925
|
+
tracker.membershipChanged = true;
|
|
1926
|
+
}, () => {
|
|
1927
|
+
tracker.failed = true;
|
|
1928
|
+
}));
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
tracker.failed = true;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
return tracker;
|
|
1935
|
+
}
|
|
1936
|
+
/** Watch exact universal inputs, or their nearest existing parent if missing. */
|
|
1937
|
+
async function createHostInputMutationTracker(inputs, filesystem, covered, events = "all") {
|
|
1938
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
1939
|
+
const namesByDirectory = new Map();
|
|
1940
|
+
for (const input of inputs) {
|
|
1941
|
+
const absolute = path.resolve(input);
|
|
1942
|
+
const probe = filesystem.exists(absolute)
|
|
1943
|
+
? { directory: path.dirname(absolute), name: path.basename(absolute) }
|
|
1944
|
+
: missingPathProbe(absolute, filesystem);
|
|
1945
|
+
const directoryIdentity = identities.resolve(probe.directory);
|
|
1946
|
+
let location = namesByDirectory.get(directoryIdentity.key);
|
|
1947
|
+
if (location === undefined) {
|
|
1948
|
+
location = {
|
|
1949
|
+
directory: directoryIdentity.path,
|
|
1950
|
+
names: new Set(),
|
|
1951
|
+
};
|
|
1952
|
+
namesByDirectory.set(directoryIdentity.key, location);
|
|
1953
|
+
}
|
|
1954
|
+
location.names.add(normalizeHostInputName(probe.name, identities.caseSensitive(directoryIdentity.path)));
|
|
1955
|
+
}
|
|
1956
|
+
const locations = [...namesByDirectory.values()].map((location) => ({
|
|
1957
|
+
directory: location.directory,
|
|
1958
|
+
names: [...location.names],
|
|
1959
|
+
}));
|
|
1960
|
+
const tracker = {
|
|
1961
|
+
close: () => undefined,
|
|
1962
|
+
// Coverage is the caller's claim, and it is required rather than derived
|
|
1963
|
+
// from the input list: an input is watched by its exact name here, but only
|
|
1964
|
+
// the caller knows whether the path leading to it is watched as well, which
|
|
1965
|
+
// is what a later validation needs before it trusts the watcher instead of
|
|
1966
|
+
// probing the path again. Deriving it here would hand that claim to every
|
|
1967
|
+
// future caller by default (samchon/ttsc#1261).
|
|
1968
|
+
covered,
|
|
1969
|
+
failed: false,
|
|
1970
|
+
membershipChanged: false,
|
|
1971
|
+
};
|
|
1972
|
+
if (process.platform === "win32" && filesystem.watch === undefined) {
|
|
1973
|
+
await registerWindowsProjectMutationTracker(tracker, locations, events === "all", filesystem);
|
|
1974
|
+
return tracker;
|
|
1975
|
+
}
|
|
1976
|
+
const watchers = [];
|
|
1977
|
+
tracker.close = () => {
|
|
1978
|
+
for (const watcher of watchers)
|
|
1979
|
+
watcher.close();
|
|
1980
|
+
watchers.length = 0;
|
|
1981
|
+
};
|
|
1982
|
+
for (const location of locations) {
|
|
1983
|
+
try {
|
|
1984
|
+
const names = new Set(location.names);
|
|
1985
|
+
const caseSensitive = identities.caseSensitive(location.directory);
|
|
1986
|
+
watchers.push(openDirectoryWatch(filesystem, location.directory, (eventType, filename) => {
|
|
1987
|
+
if (events === "rename" && eventType !== "rename") {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const reported = filename === null
|
|
1991
|
+
? null
|
|
1992
|
+
: normalizeHostInputName(filename, caseSensitive);
|
|
1993
|
+
if (reported === null || names.has(reported)) {
|
|
1994
|
+
tracker.membershipChanged = true;
|
|
1995
|
+
}
|
|
1996
|
+
}, () => {
|
|
1997
|
+
tracker.failed = true;
|
|
1998
|
+
}));
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
tracker.failed = true;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return tracker;
|
|
2005
|
+
}
|
|
2006
|
+
let windowsProjectMutationBroker;
|
|
2007
|
+
/**
|
|
2008
|
+
* Register directory watches in an isolated Windows process.
|
|
2009
|
+
*
|
|
2010
|
+
* Node's Windows fs-event backend can assert in native code when a watched
|
|
2011
|
+
* temporary tree is deleted. Isolation turns that unrecoverable process abort
|
|
2012
|
+
* into an ordinary broker exit and a conservative cache miss in the host.
|
|
2013
|
+
*/
|
|
2014
|
+
async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem) {
|
|
2015
|
+
const broker = getWindowsProjectMutationBroker();
|
|
2016
|
+
const normalized = locations.map((location) => {
|
|
2017
|
+
let directory;
|
|
2018
|
+
try {
|
|
2019
|
+
directory = filesystem.realpath(location.directory);
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
directory = path.resolve(location.directory);
|
|
2023
|
+
}
|
|
2024
|
+
return {
|
|
2025
|
+
directory,
|
|
2026
|
+
...(location.names === undefined ? {} : { names: location.names }),
|
|
2027
|
+
};
|
|
2028
|
+
});
|
|
2029
|
+
broker.pendingRegistrations += 1;
|
|
2030
|
+
broker.child.ref();
|
|
2031
|
+
broker.child.channel?.ref();
|
|
2032
|
+
const id = broker.nextId++;
|
|
2033
|
+
let resolveReady;
|
|
2034
|
+
const ready = new Promise((resolve) => {
|
|
2035
|
+
resolveReady = resolve;
|
|
2036
|
+
});
|
|
2037
|
+
broker.trackers.set(id, { ready: resolveReady, tracker });
|
|
2038
|
+
tracker.drain = () => drainWindowsProjectMutationBroker(broker);
|
|
2039
|
+
tracker.close = () => {
|
|
2040
|
+
const active = broker.trackers.get(id);
|
|
2041
|
+
if (active === undefined)
|
|
2042
|
+
return;
|
|
2043
|
+
broker.trackers.delete(id);
|
|
2044
|
+
active.ready();
|
|
2045
|
+
broker.child.send?.({ id, op: "remove" });
|
|
2046
|
+
if (broker.trackers.size === 0) {
|
|
2047
|
+
broker.child.disconnect?.();
|
|
2048
|
+
broker.child.kill();
|
|
2049
|
+
if (windowsProjectMutationBroker === broker) {
|
|
2050
|
+
windowsProjectMutationBroker = undefined;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
broker.child.send?.({
|
|
2055
|
+
allEvents,
|
|
2056
|
+
locations: normalized,
|
|
2057
|
+
id,
|
|
2058
|
+
op: "add",
|
|
2059
|
+
});
|
|
2060
|
+
try {
|
|
2061
|
+
await ready;
|
|
2062
|
+
}
|
|
2063
|
+
finally {
|
|
2064
|
+
broker.pendingRegistrations -= 1;
|
|
2065
|
+
// `ref`/`unref` is a flag rather than a counter, so this must not clear a
|
|
2066
|
+
// reference an in-flight acknowledgement is holding: a delivery waiting on
|
|
2067
|
+
// a reply over an unreferenced channel lets the loop empty and the process
|
|
2068
|
+
// exit mid-build.
|
|
2069
|
+
if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
|
|
2070
|
+
broker.child.unref();
|
|
2071
|
+
broker.child.channel?.unref();
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
function getWindowsProjectMutationBroker() {
|
|
2076
|
+
if (windowsProjectMutationBroker !== undefined) {
|
|
2077
|
+
return windowsProjectMutationBroker;
|
|
2078
|
+
}
|
|
2079
|
+
const child = node_child_process.spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
|
|
2080
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
2081
|
+
windowsHide: true,
|
|
2082
|
+
});
|
|
2083
|
+
const broker = {
|
|
2084
|
+
child,
|
|
2085
|
+
drains: new Map(),
|
|
2086
|
+
nextId: 1,
|
|
2087
|
+
pendingDrains: 0,
|
|
2088
|
+
pendingRegistrations: 0,
|
|
2089
|
+
trackers: new Map(),
|
|
2090
|
+
};
|
|
2091
|
+
const fail = () => {
|
|
2092
|
+
for (const registration of broker.trackers.values()) {
|
|
2093
|
+
registration.tracker.failed = true;
|
|
2094
|
+
registration.ready();
|
|
2095
|
+
}
|
|
2096
|
+
broker.trackers.clear();
|
|
2097
|
+
// A broker that died answers no round-trip. Release every waiter instead of
|
|
2098
|
+
// stalling the deliveries behind them; their trackers are failed now, so
|
|
2099
|
+
// validation falls back to proving the generation from its own state.
|
|
2100
|
+
for (const release of broker.drains.values())
|
|
2101
|
+
release();
|
|
2102
|
+
broker.drains.clear();
|
|
2103
|
+
if (windowsProjectMutationBroker === broker) {
|
|
2104
|
+
windowsProjectMutationBroker = undefined;
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
child.on("error", fail);
|
|
2108
|
+
child.on("exit", fail);
|
|
2109
|
+
child.on("message", (message) => {
|
|
2110
|
+
if (message === null || typeof message !== "object")
|
|
2111
|
+
return;
|
|
2112
|
+
const record = message;
|
|
2113
|
+
if (typeof record.id !== "number")
|
|
2114
|
+
return;
|
|
2115
|
+
if (record.drained === true) {
|
|
2116
|
+
// Every event the child had already sent arrived before this reply, since
|
|
2117
|
+
// one IPC channel delivers in order.
|
|
2118
|
+
const release = broker.drains.get(record.id);
|
|
2119
|
+
broker.drains.delete(record.id);
|
|
2120
|
+
release?.();
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
const registration = broker.trackers.get(record.id);
|
|
2124
|
+
if (registration === undefined)
|
|
2125
|
+
return;
|
|
2126
|
+
if (record.failed === true)
|
|
2127
|
+
registration.tracker.failed = true;
|
|
2128
|
+
if (record.ready === true)
|
|
2129
|
+
registration.ready();
|
|
2130
|
+
if (record.ready !== true && record.failed !== true) {
|
|
2131
|
+
registration.tracker.membershipChanged = true;
|
|
2132
|
+
}
|
|
2133
|
+
});
|
|
2134
|
+
windowsProjectMutationBroker = broker;
|
|
2135
|
+
return broker;
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Ask the Windows broker to acknowledge, and resolve when it does.
|
|
2139
|
+
*
|
|
2140
|
+
* The child answers after a turn of its own loop, so a watch callback it had
|
|
2141
|
+
* already queued has run, and the ordered IPC channel puts every message it
|
|
2142
|
+
* sent before the reply ahead of the reply. That is the same proof an
|
|
2143
|
+
* in-process watcher gets from a macrotask turn, rather than the fixed wait
|
|
2144
|
+
* this replaces, which guessed at the crossing (samchon/ttsc#1272).
|
|
2145
|
+
*
|
|
2146
|
+
* A broker that never answers must not hold a delivery: the wait falls back to
|
|
2147
|
+
* the previous fixed grace, after which validation proceeds against whatever
|
|
2148
|
+
* the tracker knows, exactly as it did before.
|
|
2149
|
+
*/
|
|
2150
|
+
function drainWindowsProjectMutationBroker(broker) {
|
|
2151
|
+
// Every tracker of a generation lives in one broker, so one acknowledgement
|
|
2152
|
+
// answers for all of them. Sharing the in-flight round-trip keeps a settle to
|
|
2153
|
+
// a single crossing.
|
|
2154
|
+
broker.draining ??= startWindowsProjectMutationDrain(broker).finally(() => {
|
|
2155
|
+
broker.draining = undefined;
|
|
2156
|
+
});
|
|
2157
|
+
return broker.draining;
|
|
2158
|
+
}
|
|
2159
|
+
function startWindowsProjectMutationDrain(broker) {
|
|
2160
|
+
return new Promise((resolve) => {
|
|
2161
|
+
const id = broker.nextId++;
|
|
2162
|
+
let settled = false;
|
|
2163
|
+
const release = () => {
|
|
2164
|
+
if (settled)
|
|
2165
|
+
return;
|
|
2166
|
+
settled = true;
|
|
2167
|
+
clearTimeout(timer);
|
|
2168
|
+
broker.drains.delete(id);
|
|
2169
|
+
broker.pendingDrains -= 1;
|
|
2170
|
+
if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
|
|
2171
|
+
broker.child.unref();
|
|
2172
|
+
broker.child.channel?.unref();
|
|
2173
|
+
}
|
|
2174
|
+
resolve();
|
|
2175
|
+
};
|
|
2176
|
+
// Hold the channel open while the acknowledgement is outstanding. The
|
|
2177
|
+
// broker is unreferenced between requests so it never keeps a host alive,
|
|
2178
|
+
// and a reply is the only thing this promise can be resolved by: without
|
|
2179
|
+
// the reference the loop can empty while a delivery waits here, and the
|
|
2180
|
+
// process exits mid-build with nothing to report.
|
|
2181
|
+
broker.pendingDrains += 1;
|
|
2182
|
+
broker.child.ref();
|
|
2183
|
+
broker.child.channel?.ref();
|
|
2184
|
+
const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
|
|
2185
|
+
broker.drains.set(id, release);
|
|
2186
|
+
if (broker.child.send?.({ id, op: "drain" }) !== true) {
|
|
2187
|
+
release();
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
/** The wait a broker that stopped answering degrades to. */
|
|
2192
|
+
const WINDOWS_MUTATION_DRAIN_FALLBACK_MS = 10;
|
|
2193
|
+
const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
2194
|
+
'const fs = require("node:fs");',
|
|
2195
|
+
"const groups = new Map();",
|
|
2196
|
+
'process.on("message", (message) => {',
|
|
2197
|
+
' if (message.op === "drain") {',
|
|
2198
|
+
// Two turns, not one: the first lets the loop poll for watch completions the
|
|
2199
|
+
// kernel had already queued, the second answers after their callbacks ran.
|
|
2200
|
+
" setImmediate(() => setImmediate(() => process.send?.({ drained: true, id: message.id })));",
|
|
2201
|
+
" return;",
|
|
2202
|
+
" }",
|
|
2203
|
+
' if (message.op === "remove") {',
|
|
2204
|
+
" close(message.id);",
|
|
2205
|
+
" return;",
|
|
2206
|
+
" }",
|
|
2207
|
+
' if (message.op !== "add") return;',
|
|
2208
|
+
" const watchers = [];",
|
|
2209
|
+
" let failed = false;",
|
|
2210
|
+
" for (const location of message.locations) {",
|
|
2211
|
+
" try {",
|
|
2212
|
+
" const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
|
|
2213
|
+
" const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
|
|
2214
|
+
" const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
|
|
2215
|
+
' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
|
|
2216
|
+
" });",
|
|
2217
|
+
' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
|
|
2218
|
+
" watchers.push(watcher);",
|
|
2219
|
+
" } catch {",
|
|
2220
|
+
" failed = true;",
|
|
2221
|
+
" }",
|
|
2222
|
+
" }",
|
|
2223
|
+
" groups.set(message.id, watchers);",
|
|
2224
|
+
" process.send?.({ failed, id: message.id, ready: true });",
|
|
2225
|
+
"});",
|
|
2226
|
+
'process.on("disconnect", () => {',
|
|
2227
|
+
" for (const id of groups.keys()) close(id);",
|
|
2228
|
+
" process.exit(0);",
|
|
2229
|
+
"});",
|
|
2230
|
+
"function close(id) {",
|
|
2231
|
+
" for (const watcher of groups.get(id) ?? []) watcher.close();",
|
|
2232
|
+
" groups.delete(id);",
|
|
2233
|
+
"}",
|
|
2234
|
+
].join("\n");
|
|
2235
|
+
/**
|
|
2236
|
+
* Report whether either live notification observed a membership event. This is
|
|
2237
|
+
* positive evidence that the generation is stale, so it outranks the question
|
|
2238
|
+
* of whether the notifications still work.
|
|
2239
|
+
*/
|
|
2240
|
+
function reportsMembershipChange(cached) {
|
|
2241
|
+
return (cached.projectMutationTracker?.membershipChanged === true ||
|
|
2242
|
+
cached.hostInputMutationTracker?.membershipChanged === true ||
|
|
2243
|
+
cached.candidateMutationTracker?.membershipChanged === true);
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Report whether the live notifications can still prove membership. A watcher
|
|
2247
|
+
* that failed to register, or that errored after the generation was produced,
|
|
2248
|
+
* proves nothing either way — it never proves the generation stale.
|
|
2249
|
+
*/
|
|
2250
|
+
function notificationsProveMembership(cached) {
|
|
2251
|
+
for (const tracker of [
|
|
2252
|
+
cached.projectMutationTracker,
|
|
2253
|
+
cached.hostInputMutationTracker,
|
|
2254
|
+
]) {
|
|
2255
|
+
if (tracker === undefined || tracker.failed) {
|
|
2256
|
+
return false;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
// The candidate tracker is optional: a generation with no absent candidate
|
|
2260
|
+
// opens none, and one that declined to watch them left the per-delivery probe
|
|
2261
|
+
// in place. Only a tracker that exists and has failed withdraws the proof.
|
|
2262
|
+
return cached.candidateMutationTracker?.failed !== true;
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Yield to the loop the tracker's own watcher callbacks are queued on.
|
|
2266
|
+
*
|
|
2267
|
+
* Two turns for the same reason the broker takes two: the first gives the loop
|
|
2268
|
+
* a poll phase for completions the kernel had already queued, the second runs
|
|
2269
|
+
* after the callbacks they produced.
|
|
2270
|
+
*/
|
|
2271
|
+
function drainOnNextTurn() {
|
|
2272
|
+
return new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Settle every notification the trackers' watchers have already dispatched,
|
|
2276
|
+
* before persistent validation reads their verdict.
|
|
2277
|
+
*
|
|
2278
|
+
* A synchronous edit returns before its watch event is applied, so without this
|
|
2279
|
+
* a delivery could validate against a tracker that has not been told yet. Each
|
|
2280
|
+
* tracker drains through its own channel, which is a macrotask turn for a
|
|
2281
|
+
* watcher on this loop and an ordered round-trip for one inside the Windows
|
|
2282
|
+
* broker. Concurrent sibling deliveries share the barrier one of them started.
|
|
2283
|
+
*/
|
|
2284
|
+
async function settleProjectMutationEvents(cached) {
|
|
2285
|
+
const trackers = [
|
|
2286
|
+
cached.projectMutationTracker,
|
|
2287
|
+
cached.hostInputMutationTracker,
|
|
2288
|
+
cached.candidateMutationTracker,
|
|
2289
|
+
].filter((tracker) => tracker !== undefined);
|
|
2290
|
+
await Promise.all(trackers.map(async (tracker) => {
|
|
2291
|
+
tracker.settle ??= (tracker.drain ?? drainOnNextTurn)().finally(() => {
|
|
2292
|
+
tracker.settle = undefined;
|
|
2293
|
+
});
|
|
2294
|
+
await tracker.settle;
|
|
2295
|
+
}));
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Report whether an absolute `file` belongs to the project walk universe of
|
|
2299
|
+
* `root`: it lies under `root`, every component exists without traversing a
|
|
2300
|
+
* symbolic link, the leaf is a regular file, and no segment of the relative
|
|
2301
|
+
* path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
|
|
2302
|
+
* "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
|
|
2303
|
+
* Missing paths and files reached through symlinks or Windows junctions are
|
|
2304
|
+
* out-of-walk inputs that only the reference graph can prove relevant.
|
|
2305
|
+
*/
|
|
2306
|
+
function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
2307
|
+
// Walk membership is lexical. Resolving `file` to physical identity first
|
|
2308
|
+
// would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
|
|
2309
|
+
// symlink segment from the lstat loop below, and falsely claim the project
|
|
2310
|
+
// walk hashed a path it deliberately never followed.
|
|
2311
|
+
const resolvedRoot = path.resolve(root);
|
|
2312
|
+
const relative = path.relative(resolvedRoot, path.resolve(file));
|
|
2313
|
+
if (relative.length === 0 ||
|
|
2314
|
+
relative === ".." ||
|
|
2315
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
2316
|
+
path.isAbsolute(relative)) {
|
|
2317
|
+
return false;
|
|
2318
|
+
}
|
|
2319
|
+
const segments = relative.split(path.sep);
|
|
2320
|
+
if (segments.some(isIgnoredProjectDirectory)) {
|
|
2321
|
+
return false;
|
|
2322
|
+
}
|
|
2323
|
+
let current = resolvedRoot;
|
|
2324
|
+
for (let index = 0; index < segments.length; ++index) {
|
|
2325
|
+
current = path.join(current, segments[index]);
|
|
2326
|
+
let stats;
|
|
2327
|
+
try {
|
|
2328
|
+
stats = filesystem.lstat(current);
|
|
2329
|
+
}
|
|
2330
|
+
catch {
|
|
2331
|
+
return false;
|
|
2332
|
+
}
|
|
2333
|
+
if (stats.isSymbolicLink()) {
|
|
2334
|
+
return false;
|
|
2335
|
+
}
|
|
2336
|
+
const leaf = index === segments.length - 1;
|
|
2337
|
+
if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) {
|
|
2338
|
+
return false;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
return true;
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Hash a list of absolute out-of-walk input paths: content SHA-256 for a
|
|
2345
|
+
* readable file, a stable directory-kind digest for a directory candidate, and
|
|
2346
|
+
* a stable `missing` marker otherwise. Keys use filesystem identity so
|
|
2347
|
+
* case-only spellings share one snapshot entry, while reads retain the original
|
|
2348
|
+
* path supplied by the compiler. The marker is state, not an error — a recorded
|
|
2349
|
+
* input disappearing (or reappearing) must change the comparison exactly like a
|
|
2350
|
+
* content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
|
|
2351
|
+
* with identical semantics at cache-key time.
|
|
2352
|
+
*/
|
|
2353
|
+
function collectExternalInputHashes(paths, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
2354
|
+
const hashes = {};
|
|
2355
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
2356
|
+
for (const file of paths) {
|
|
2357
|
+
const identity = pathIdentityKey(file, identities);
|
|
2358
|
+
if (identity in hashes) {
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
hashes[identity] =
|
|
2362
|
+
hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE;
|
|
2363
|
+
}
|
|
2364
|
+
return hashes;
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Re-check a cached mixed graph/dependency input set with its owning codec,
|
|
2368
|
+
* reusing the recorded hash of any input whose metadata signature still holds
|
|
2369
|
+
* and reporting the signatures this pass captured.
|
|
2370
|
+
*
|
|
2371
|
+
* The caller adopts those signatures only once every input is proven unchanged,
|
|
2372
|
+
* so a signature never outlives the content comparison that justified it.
|
|
2373
|
+
*/
|
|
2374
|
+
function matchesCachedExternalInputs(cached) {
|
|
2375
|
+
const signatures = {};
|
|
2376
|
+
let matches = true;
|
|
2377
|
+
const state = envelopeDerivation(cached);
|
|
2378
|
+
const graphRealpaths = cached.externalInputRealpaths ?? {};
|
|
2379
|
+
const filesystem = resultFilesystem(cached.result);
|
|
2380
|
+
const recordedHashes = cached.externalInputHashes ?? {};
|
|
2381
|
+
const recordedSignatures = cached.externalInputSignatures ?? {};
|
|
2382
|
+
// Compare each spelling against the recorded state under its own name. Two
|
|
2383
|
+
// spellings share one identity exactly when they selected one physical file
|
|
2384
|
+
// at generation time, which is the state a retarget ends, so neither may
|
|
2385
|
+
// answer for the other: skipping the second would leave a retargeted alias
|
|
2386
|
+
// unvalidated, and comparing them only through a shared key would let
|
|
2387
|
+
// whichever came first decide.
|
|
2388
|
+
for (const file of cached.externalInputPaths ??
|
|
2389
|
+
Object.keys(cached.externalInputHashes ?? {})) {
|
|
2390
|
+
const identity = derivationIdentity(state, file);
|
|
2391
|
+
const spelling = path.resolve(file);
|
|
2392
|
+
// Reuse the recorded hash of an out-of-walk input whose signature still
|
|
2393
|
+
// equals the one captured around the read that proved it. The signature is
|
|
2394
|
+
// keyed by this exact spelling, so an alias of the same physical file
|
|
2395
|
+
// cannot answer for it.
|
|
2396
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
2397
|
+
if (before !== undefined &&
|
|
2398
|
+
Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
|
|
2399
|
+
Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
|
|
2400
|
+
before.signature === recordedSignatures[spelling]) {
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
const hash = Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
|
|
2404
|
+
? graphInputStateHash(file, filesystem)
|
|
2405
|
+
: hostInputStateHash(file, filesystem);
|
|
2406
|
+
const after = inputMetadataSignature(file, filesystem);
|
|
2407
|
+
if (!Object.prototype.hasOwnProperty.call(recordedHashes, identity) ||
|
|
2408
|
+
recordedHashes[identity] !== (hash ?? MISSING_INPUT_STATE)) {
|
|
2409
|
+
matches = false;
|
|
2410
|
+
}
|
|
2411
|
+
if (hash !== null &&
|
|
2412
|
+
after !== undefined &&
|
|
2413
|
+
before?.signature === after &&
|
|
2414
|
+
before.separable) {
|
|
2415
|
+
signatures[spelling] = after;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return { matches, signatures };
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Derive the absolute out-of-walk input set of a whole project transform: the
|
|
2422
|
+
* union of every reference-graph member (edge keys and targets, globals, the
|
|
2423
|
+
* config chain) and every plugin-reported dependency, minus everything the
|
|
2424
|
+
* project walk already hashes and the disposed temp-dir tsconfig. These are the
|
|
2425
|
+
* inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
|
|
2426
|
+
* that are still missing remain in this set even under the project root: the
|
|
2427
|
+
* first walk cannot hash a file that has not been created yet.
|
|
2428
|
+
*
|
|
2429
|
+
* A `dependenciesComplete` declaration deliberately does not narrow the stored
|
|
2430
|
+
* set: other files in the same whole-project result can still own the omitted
|
|
2431
|
+
* members. Persistent validation selects the requested file's subset through
|
|
2432
|
+
* {@link selectWatchInputs}, while graph-free envelopes use this union as their
|
|
2433
|
+
* conservative fallback.
|
|
2434
|
+
*/
|
|
2435
|
+
function selectExternalInputPaths(props) {
|
|
2436
|
+
if (props.result.type === "exception") {
|
|
2437
|
+
return [];
|
|
851
2438
|
}
|
|
852
2439
|
const members = [];
|
|
853
|
-
const
|
|
2440
|
+
const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
|
|
2441
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
854
2442
|
const resolutionCandidates = new Set();
|
|
855
2443
|
const graph = props.result.graph;
|
|
856
2444
|
if (graph !== undefined) {
|
|
@@ -884,6 +2472,17 @@ function selectExternalInputPaths(props) {
|
|
|
884
2472
|
members.push(...entries);
|
|
885
2473
|
}
|
|
886
2474
|
}
|
|
2475
|
+
if (Array.isArray(props.result.hostInputs)) {
|
|
2476
|
+
for (const input of props.result.hostInputs) {
|
|
2477
|
+
members.push(input);
|
|
2478
|
+
if (typeof input === "string" && input.length !== 0) {
|
|
2479
|
+
// Plugin discovery inputs deliberately include absent config and
|
|
2480
|
+
// resolution probes. A project walk cannot snapshot a path that does
|
|
2481
|
+
// not exist yet, even when its spelling lies below projectRoot.
|
|
2482
|
+
resolutionCandidates.add(pathIdentityKey(path.resolve(props.projectRoot, input), identities));
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
887
2486
|
const excluded = props.temporaryTsconfig === undefined
|
|
888
2487
|
? undefined
|
|
889
2488
|
: pathIdentityKey(props.temporaryTsconfig, identities);
|
|
@@ -894,20 +2493,173 @@ function selectExternalInputPaths(props) {
|
|
|
894
2493
|
continue;
|
|
895
2494
|
}
|
|
896
2495
|
const absolute = path.resolve(props.projectRoot, member);
|
|
2496
|
+
const spelling = path.resolve(absolute);
|
|
897
2497
|
const identity = pathIdentityKey(absolute, identities);
|
|
898
|
-
const missingCandidate = resolutionCandidates.has(identity) && !
|
|
2498
|
+
const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
|
|
899
2499
|
if (identity === excluded ||
|
|
900
|
-
seen.has(
|
|
2500
|
+
seen.has(spelling) ||
|
|
901
2501
|
(!missingCandidate &&
|
|
902
|
-
isProjectWalkPath(props.projectRoot, absolute, identities))) {
|
|
2502
|
+
isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
|
|
903
2503
|
continue;
|
|
904
2504
|
}
|
|
905
|
-
|
|
2505
|
+
// Preserve distinct lexical aliases even when they currently select the
|
|
2506
|
+
// same physical file. A later retarget must validate the alias itself.
|
|
2507
|
+
seen.add(spelling);
|
|
906
2508
|
output.push(absolute);
|
|
907
2509
|
}
|
|
908
2510
|
output.sort();
|
|
909
2511
|
return output;
|
|
910
2512
|
}
|
|
2513
|
+
/**
|
|
2514
|
+
* The generation's resolution candidates that do not exist, so its host-input
|
|
2515
|
+
* watcher can be told to announce their creation.
|
|
2516
|
+
*
|
|
2517
|
+
* A missing candidate is the one input class no proof can be memoized for: its
|
|
2518
|
+
* metadata cannot be read, so the signature shortcut that stands in for every
|
|
2519
|
+
* other input's comparison never applies, and every delivery that reaches it
|
|
2520
|
+
* probes the filesystem again. Watching the name instead turns that repeated
|
|
2521
|
+
* probe into one notification for the whole generation, using the same channel
|
|
2522
|
+
* and the same failure rules the universal inputs already run under
|
|
2523
|
+
* (samchon/ttsc#1261).
|
|
2524
|
+
*
|
|
2525
|
+
* Only absent candidates qualify. One that exists is validated by content and
|
|
2526
|
+
* physical identity like any other input, and adding it here would replace the
|
|
2527
|
+
* generation for a change that cannot affect a resolution the compiler already
|
|
2528
|
+
* declined to take.
|
|
2529
|
+
*/
|
|
2530
|
+
function selectNotifiableAbsentInputs(props) {
|
|
2531
|
+
const empty = { candidates: [], watched: [] };
|
|
2532
|
+
if (props.result.type === "exception") {
|
|
2533
|
+
return empty;
|
|
2534
|
+
}
|
|
2535
|
+
const graph = props.result.graph;
|
|
2536
|
+
if (graph === undefined) {
|
|
2537
|
+
return empty;
|
|
2538
|
+
}
|
|
2539
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
2540
|
+
const excluded = props.temporaryTsconfig === undefined
|
|
2541
|
+
? undefined
|
|
2542
|
+
: pathIdentityKey(props.temporaryTsconfig, identities);
|
|
2543
|
+
const resolvedProjectRoot = path.resolve(props.projectRoot);
|
|
2544
|
+
const output = [];
|
|
2545
|
+
const watched = [];
|
|
2546
|
+
const directories = new Set();
|
|
2547
|
+
// Two namespaces, deliberately not one set: candidates are the paths a
|
|
2548
|
+
// delivery may stop probing, while the chain holds the directories that carry
|
|
2549
|
+
// them. Sharing a set would let one silently answer for the other.
|
|
2550
|
+
const seen = new Set();
|
|
2551
|
+
const chain = new Set();
|
|
2552
|
+
for (const candidates of Object.values(graph.candidates ?? {})) {
|
|
2553
|
+
if (!Array.isArray(candidates)) {
|
|
2554
|
+
continue;
|
|
2555
|
+
}
|
|
2556
|
+
for (const candidate of candidates) {
|
|
2557
|
+
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
const absolute = path.resolve(props.projectRoot, candidate);
|
|
2561
|
+
const spelling = path.resolve(absolute);
|
|
2562
|
+
if (seen.has(spelling) ||
|
|
2563
|
+
(excluded !== undefined &&
|
|
2564
|
+
pathIdentityKey(absolute, identities) === excluded) ||
|
|
2565
|
+
props.filesystem.exists(absolute)) {
|
|
2566
|
+
continue;
|
|
2567
|
+
}
|
|
2568
|
+
seen.add(spelling);
|
|
2569
|
+
// Collect the components of the lexical path, by the name each carries in
|
|
2570
|
+
// its own parent. The watcher a missing path opens follows the spelling
|
|
2571
|
+
// to a physical directory, so retargeting a link along the way moves the
|
|
2572
|
+
// answer without touching what is watched: in a pnpm layout
|
|
2573
|
+
// `node_modules/<pkg>` is exactly such a link, and reinstalling it makes
|
|
2574
|
+
// a candidate appear behind a watch still looking at the old store
|
|
2575
|
+
// directory. Watching `<pkg>` inside `node_modules` is what reports that.
|
|
2576
|
+
//
|
|
2577
|
+
// The collection stops at the project root, and a spelling that leaves
|
|
2578
|
+
// the project subtree before reaching it is not claimed at all. Above
|
|
2579
|
+
// that line the components are the machine's own layout rather than the
|
|
2580
|
+
// project's, and watching those entries costs a generation whenever an
|
|
2581
|
+
// unrelated process touches anything inside them; a candidate whose path
|
|
2582
|
+
// runs outside the subtree therefore keeps the probe it always had rather
|
|
2583
|
+
// than a proof this cannot complete.
|
|
2584
|
+
const components = [];
|
|
2585
|
+
let reachedProject = false;
|
|
2586
|
+
for (let child = path.dirname(spelling), parent = path.dirname(child); parent !== child; child = parent, parent = path.dirname(child)) {
|
|
2587
|
+
if (insideProject(child, resolvedProjectRoot)) {
|
|
2588
|
+
components.push(child);
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
// Compared through `path.relative` rather than by string, so a
|
|
2592
|
+
// spelling that differs from the root only in case still counts as
|
|
2593
|
+
// having arrived where the platform says it has.
|
|
2594
|
+
reachedProject = path.relative(child, resolvedProjectRoot).length === 0;
|
|
2595
|
+
break;
|
|
2596
|
+
}
|
|
2597
|
+
if (!reachedProject) {
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
output.push(absolute);
|
|
2601
|
+
watched.push(absolute);
|
|
2602
|
+
for (const component of components) {
|
|
2603
|
+
if (chain.has(component))
|
|
2604
|
+
break;
|
|
2605
|
+
chain.add(component);
|
|
2606
|
+
watched.push(component);
|
|
2607
|
+
directories.add(path.dirname(component));
|
|
2608
|
+
}
|
|
2609
|
+
directories.add(path.dirname(spelling));
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
if (directories.size > NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT) {
|
|
2613
|
+
// Past this many distinct directories the watch registration is the more
|
|
2614
|
+
// expensive half: a host that runs out of watch descriptors fails the
|
|
2615
|
+
// tracker, and a failed tracker sends every delivery to complete-snapshot
|
|
2616
|
+
// validation, which re-hashes the whole project. Declining to watch leaves
|
|
2617
|
+
// the per-delivery probe in place, which is what this replaces and is far
|
|
2618
|
+
// cheaper than that.
|
|
2619
|
+
return empty;
|
|
2620
|
+
}
|
|
2621
|
+
output.sort();
|
|
2622
|
+
watched.sort();
|
|
2623
|
+
return { candidates: output, watched };
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Report whether a directory lies strictly below the project root.
|
|
2627
|
+
*
|
|
2628
|
+
* The boundary of what a generation may watch on a candidate's behalf: what the
|
|
2629
|
+
* project contains is its own layout, while the project root and everything
|
|
2630
|
+
* above it belongs to the machine, which nobody retargets and which changes for
|
|
2631
|
+
* reasons no generation should hear about.
|
|
2632
|
+
*/
|
|
2633
|
+
function insideProject(directory, projectRoot) {
|
|
2634
|
+
const relative = path.relative(path.resolve(projectRoot), path.resolve(directory));
|
|
2635
|
+
// An empty result is the platform saying the two name the same directory,
|
|
2636
|
+
// which it answers for spellings that differ only in case where the path
|
|
2637
|
+
// module folds case. The root itself is not below itself, so the walk stops
|
|
2638
|
+
// there rather than one level past it.
|
|
2639
|
+
if (relative.length === 0) {
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
// `..` alone and `../` climb out, and an absolute answer means another drive
|
|
2643
|
+
// or share entirely; a directory literally named `..x` does neither, which a
|
|
2644
|
+
// plain prefix test would misread. The project walk's own containment check
|
|
2645
|
+
// spells it the same way.
|
|
2646
|
+
return (relative !== ".." &&
|
|
2647
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
2648
|
+
!path.isAbsolute(relative));
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Distinct directories the absent-candidate watch may open before it declines.
|
|
2652
|
+
*
|
|
2653
|
+
* Sized well below the inotify per-user default so a project's own walk keeps
|
|
2654
|
+
* its share, and far above the distinct `node_modules` package directories a
|
|
2655
|
+
* real dependency graph produces.
|
|
2656
|
+
*
|
|
2657
|
+
* Counted lexically, over the parents of every watched name. A missing subtree
|
|
2658
|
+
* collapses onto the one watch its nearest existing ancestor carries, so the
|
|
2659
|
+
* count is an upper bound on the watches actually opened rather than their
|
|
2660
|
+
* number; the bound stays sound and is merely not tight.
|
|
2661
|
+
*/
|
|
2662
|
+
const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
|
|
911
2663
|
function isIgnoredProjectDirectory(name) {
|
|
912
2664
|
return (name === ".git" ||
|
|
913
2665
|
name === ".ttsc" ||
|
|
@@ -925,7 +2677,29 @@ function isIgnoredProjectDirectory(name) {
|
|
|
925
2677
|
name === "temp" ||
|
|
926
2678
|
name === "tmp");
|
|
927
2679
|
}
|
|
928
|
-
|
|
2680
|
+
/**
|
|
2681
|
+
* Compare two project-walk snapshots.
|
|
2682
|
+
*
|
|
2683
|
+
* `keys` narrows the comparison to the generation's declared inputs. The walk
|
|
2684
|
+
* hashes every file under the project root, but only a file the compile
|
|
2685
|
+
* actually consumed can change an output, and a project root is a working
|
|
2686
|
+
* directory: a framework's generated types, a log, a coverage report, or a test
|
|
2687
|
+
* artifact appears and changes there while a compile runs. Comparing those
|
|
2688
|
+
* would declare the generation incoherent and cost a whole-project recompile
|
|
2689
|
+
* for every remaining module (samchon/ttsc#1246). Files entering or leaving the
|
|
2690
|
+
* project remain covered by the directory-membership snapshot, which is the one
|
|
2691
|
+
* thing a content comparison cannot see. An envelope that declares no input set
|
|
2692
|
+
* (a graph-free legacy host) passes `undefined` and keeps the whole-walk
|
|
2693
|
+
* comparison.
|
|
2694
|
+
*/
|
|
2695
|
+
function sameHashes(left, right, keys) {
|
|
2696
|
+
if (keys !== undefined) {
|
|
2697
|
+
for (const key of keys) {
|
|
2698
|
+
if (left[key] !== right[key])
|
|
2699
|
+
return false;
|
|
2700
|
+
}
|
|
2701
|
+
return true;
|
|
2702
|
+
}
|
|
929
2703
|
const leftKeys = Object.keys(left);
|
|
930
2704
|
const rightKeys = Object.keys(right);
|
|
931
2705
|
if (leftKeys.length !== rightKeys.length) {
|
|
@@ -933,16 +2707,149 @@ function sameHashes(left, right) {
|
|
|
933
2707
|
}
|
|
934
2708
|
return leftKeys.every((key) => right[key] === left[key]);
|
|
935
2709
|
}
|
|
2710
|
+
/**
|
|
2711
|
+
* Whether a project-walk snapshot is coherent for the inputs that matter.
|
|
2712
|
+
*
|
|
2713
|
+
* The walk reads every file under the project root, so a file nothing compiled
|
|
2714
|
+
* (a log being appended, a coverage report being written, a generated artifact
|
|
2715
|
+
* being replaced) can fail its own read sandwich while every input holds still.
|
|
2716
|
+
* That is not evidence about the generation, and treating it as such costs a
|
|
2717
|
+
* whole-project recompile per delivered module. A walk that could not enumerate
|
|
2718
|
+
* a directory, or a file-level failure this snapshot could not attribute to a
|
|
2719
|
+
* key, still taints everything: neither can be shown to leave the inputs
|
|
2720
|
+
* alone.
|
|
2721
|
+
*/
|
|
2722
|
+
function walkSnapshotComplete(snapshot, declared) {
|
|
2723
|
+
if (declared === undefined) {
|
|
2724
|
+
return snapshot.complete;
|
|
2725
|
+
}
|
|
2726
|
+
if (!snapshot.directoryComplete) {
|
|
2727
|
+
return false;
|
|
2728
|
+
}
|
|
2729
|
+
for (const key of snapshot.unstableFiles) {
|
|
2730
|
+
if (declared.has(key))
|
|
2731
|
+
return false;
|
|
2732
|
+
}
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
/** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
|
|
2736
|
+
function declaredProjectInputKeys(state, cached) {
|
|
2737
|
+
if (state.declaredInputKeysBuilt !== true) {
|
|
2738
|
+
state.declaredInputKeys = selectDeclaredProjectInputKeys({
|
|
2739
|
+
identities: state.identityContext,
|
|
2740
|
+
projectRoot: cached.projectRoot,
|
|
2741
|
+
result: cached.result,
|
|
2742
|
+
});
|
|
2743
|
+
state.declaredInputKeysBuilt = true;
|
|
2744
|
+
}
|
|
2745
|
+
return state.declaredInputKeys;
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Project-walk keys of every input the envelope declares: the reference graph's
|
|
2749
|
+
* edge endpoints, globals, config chain, and resolution candidates, plus the
|
|
2750
|
+
* universal host inputs. Returns `undefined` for an envelope with no graph,
|
|
2751
|
+
* which declares no input set and therefore keeps whole-walk comparison.
|
|
2752
|
+
*/
|
|
2753
|
+
function selectDeclaredProjectInputKeys(props) {
|
|
2754
|
+
if (props.result.type === "exception" || props.result.graph === undefined) {
|
|
2755
|
+
return undefined;
|
|
2756
|
+
}
|
|
2757
|
+
const graph = props.result.graph;
|
|
2758
|
+
const keys = new Set();
|
|
2759
|
+
const add = (entry) => {
|
|
2760
|
+
if (typeof entry !== "string" || entry.length === 0)
|
|
2761
|
+
return;
|
|
2762
|
+
keys.add(toProjectKey(props.projectRoot, path.resolve(props.projectRoot, entry), props.identities));
|
|
2763
|
+
};
|
|
2764
|
+
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
2765
|
+
add(source);
|
|
2766
|
+
if (Array.isArray(targets))
|
|
2767
|
+
for (const target of targets)
|
|
2768
|
+
add(target);
|
|
2769
|
+
}
|
|
2770
|
+
if (Array.isArray(graph.globals))
|
|
2771
|
+
for (const input of graph.globals)
|
|
2772
|
+
add(input);
|
|
2773
|
+
if (Array.isArray(graph.configs))
|
|
2774
|
+
for (const input of graph.configs)
|
|
2775
|
+
add(input);
|
|
2776
|
+
for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
|
|
2777
|
+
add(source);
|
|
2778
|
+
if (Array.isArray(candidates))
|
|
2779
|
+
for (const entry of candidates)
|
|
2780
|
+
add(entry);
|
|
2781
|
+
}
|
|
2782
|
+
if (Array.isArray(props.result.hostInputs))
|
|
2783
|
+
for (const input of props.result.hostInputs)
|
|
2784
|
+
add(input);
|
|
2785
|
+
// Plugin-reported dependencies are inputs the graph never sees: a utility
|
|
2786
|
+
// plugin's own config file is consulted by the plugin, not by the compiler.
|
|
2787
|
+
for (const reported of Object.values(props.result.dependencies ?? {})) {
|
|
2788
|
+
if (Array.isArray(reported))
|
|
2789
|
+
for (const input of reported)
|
|
2790
|
+
add(input);
|
|
2791
|
+
}
|
|
2792
|
+
return keys;
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Project roots already told they cannot reuse a compile, so a build reports
|
|
2796
|
+
* the condition once instead of once per module.
|
|
2797
|
+
*/
|
|
2798
|
+
const REPORTED_UNREUSABLE_GENERATIONS = new Set();
|
|
2799
|
+
/**
|
|
2800
|
+
* Report, once per project root, that a generation cannot be reused.
|
|
2801
|
+
*
|
|
2802
|
+
* Every module of the build then recompiles the whole project, so the condition
|
|
2803
|
+
* is the difference between one compile and one compile per module. It stayed
|
|
2804
|
+
* invisible for the whole life of samchon/ttsc#970: consumers saw only a build
|
|
2805
|
+
* that never finished, and each investigation had to rediscover the cause from
|
|
2806
|
+
* outside. A named reason turns the next occurrence into a bug report instead
|
|
2807
|
+
* of an archaeology session.
|
|
2808
|
+
*/
|
|
2809
|
+
function reportUnreusableGeneration(cached, evidence) {
|
|
2810
|
+
const missing = [
|
|
2811
|
+
...(evidence.walkStable ? [] : ["a stable project snapshot"]),
|
|
2812
|
+
...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
|
|
2813
|
+
...(evidence.externalInputs
|
|
2814
|
+
? []
|
|
2815
|
+
: ["a complete out-of-walk input snapshot"]),
|
|
2816
|
+
...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
|
|
2817
|
+
];
|
|
2818
|
+
const key = `${cached.projectRoot}\0${missing.join(",")}`;
|
|
2819
|
+
if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
REPORTED_UNREUSABLE_GENERATIONS.add(key);
|
|
2823
|
+
process.stderr.write(`ttsc: the transform cache cannot reuse this project's compile, so every ` +
|
|
2824
|
+
`module recompiles the whole project.\n` +
|
|
2825
|
+
` project: ${cached.projectRoot}\n` +
|
|
2826
|
+
` missing: ${missing.join("; ")}\n` +
|
|
2827
|
+
` Please report this at https://github.com/samchon/ttsc/issues with ` +
|
|
2828
|
+
`this message.\n`);
|
|
2829
|
+
}
|
|
936
2830
|
function hashText(input) {
|
|
937
2831
|
return crypto.createHash("sha256").update(input).digest("hex");
|
|
938
2832
|
}
|
|
939
2833
|
async function transformProject(props) {
|
|
940
|
-
const configured = createTransformTsconfig(props);
|
|
941
2834
|
const projectRoot = path.dirname(props.tsconfig);
|
|
2835
|
+
const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
|
|
2836
|
+
let tracker;
|
|
2837
|
+
let retainTracker = false;
|
|
2838
|
+
let hostInputTracker;
|
|
2839
|
+
let candidateTracker;
|
|
2840
|
+
let retainHostInputTracker = false;
|
|
2841
|
+
let retainCandidateTracker = false;
|
|
942
2842
|
try {
|
|
943
|
-
const
|
|
2843
|
+
const configured = createTransformTsconfig(props, scratchDirectory);
|
|
2844
|
+
const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
|
|
2845
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
2846
|
+
const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
|
|
2847
|
+
tracker = props.trackProjectMembership
|
|
2848
|
+
? await createProjectMutationTracker(before.projectDirectories, props.filesystem)
|
|
2849
|
+
: undefined;
|
|
2850
|
+
const result = withTransformScratchEnvironment(scratchDirectory, () => new ttsc.TtscCompiler({
|
|
944
2851
|
cwd: projectRoot,
|
|
945
|
-
// The generated tsconfig (if any) lives
|
|
2852
|
+
// The generated tsconfig (if any) lives outside the project directory,
|
|
946
2853
|
// so declare the real project as the plugin config anchor: utility
|
|
947
2854
|
// plugin config discovery (banner.config.*, strip.config.*,
|
|
948
2855
|
// lint.config.*) and relative configFile resolution walk the project,
|
|
@@ -952,24 +2859,99 @@ async function transformProject(props) {
|
|
|
952
2859
|
plugins: props.plugins,
|
|
953
2860
|
projectRoot,
|
|
954
2861
|
tsconfig: configured.path,
|
|
955
|
-
|
|
956
|
-
|
|
2862
|
+
env: transformScratchEnvironment(scratchDirectory),
|
|
2863
|
+
}).transform());
|
|
2864
|
+
TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
|
|
2865
|
+
// Mint the generation's clock reference after the compile and before any
|
|
2866
|
+
// signature-recording read below, so every input written before the
|
|
2867
|
+
// compile sits in a provably finished tick when its signature is captured.
|
|
2868
|
+
mintFilesystemClockReference(scratchDirectory, props.filesystem);
|
|
2869
|
+
const persistentHostInputs = selectPersistentHostInputs({
|
|
2870
|
+
filesystem: props.filesystem,
|
|
2871
|
+
projectRoot,
|
|
2872
|
+
result,
|
|
2873
|
+
temporaryTsconfig,
|
|
2874
|
+
});
|
|
2875
|
+
// The generation's absent resolution candidates, which get a watcher of
|
|
2876
|
+
// their own below; watching one is what lets a delivery stop probing it
|
|
2877
|
+
// (samchon/ttsc#1261). The validation manifest stays built from the
|
|
2878
|
+
// universal inputs alone, so nothing else about a candidate changes.
|
|
2879
|
+
//
|
|
2880
|
+
// Derived only where a tracker could carry it: a build-scoped adapter opens
|
|
2881
|
+
// no watcher, so probing every candidate's existence here would be work
|
|
2882
|
+
// whose answer nothing can read.
|
|
2883
|
+
const notifiableAbsence = props.trackProjectMembership
|
|
2884
|
+
? selectNotifiableAbsentInputs({
|
|
2885
|
+
filesystem: props.filesystem,
|
|
2886
|
+
projectRoot,
|
|
2887
|
+
result,
|
|
2888
|
+
temporaryTsconfig,
|
|
2889
|
+
})
|
|
2890
|
+
: { candidates: [], watched: [] };
|
|
2891
|
+
hostInputTracker = props.trackProjectMembership
|
|
2892
|
+
? await createHostInputMutationTracker(persistentHostInputs, props.filesystem,
|
|
2893
|
+
// A universal input never reaches the per-input loop that consults a
|
|
2894
|
+
// coverage claim: an absent one is proven by its directory listing
|
|
2895
|
+
// instead, which re-resolves the spelling every delivery.
|
|
2896
|
+
new Set())
|
|
2897
|
+
: undefined;
|
|
2898
|
+
// The candidates and the directories carrying them get their own tracker,
|
|
2899
|
+
// listening for renames alone. Every event that can make one of these
|
|
2900
|
+
// paths appear is a rename — the file itself, or a component of the path
|
|
2901
|
+
// being created, replaced, or retargeted — so nothing is given up, while a
|
|
2902
|
+
// backend that reports a write below a directory as a change to that
|
|
2903
|
+
// directory's entry (Windows does) would otherwise replace the generation
|
|
2904
|
+
// every time a bundler wrote inside `node_modules`.
|
|
2905
|
+
candidateTracker =
|
|
2906
|
+
notifiableAbsence.watched.length !== 0
|
|
2907
|
+
? await createHostInputMutationTracker(notifiableAbsence.watched, props.filesystem, new Set(notifiableAbsence.candidates), "rename")
|
|
2908
|
+
: undefined;
|
|
957
2909
|
const externalInputPaths = selectExternalInputPaths({
|
|
2910
|
+
filesystem: props.filesystem,
|
|
958
2911
|
projectRoot,
|
|
959
2912
|
result,
|
|
960
2913
|
temporaryTsconfig,
|
|
961
2914
|
});
|
|
962
|
-
|
|
2915
|
+
const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
|
|
2916
|
+
// Whether the recorded snapshot describes one coherent state of the
|
|
2917
|
+
// project. A membership event during the compile taints it exactly like an
|
|
2918
|
+
// unstable walk pair; whether notifications can be *opened* is a separate
|
|
2919
|
+
// fact, tracked below, because a generation with no watcher is still
|
|
2920
|
+
// provable from its own recorded state.
|
|
2921
|
+
const declaredInputs = selectDeclaredProjectInputKeys({
|
|
2922
|
+
identities,
|
|
2923
|
+
projectRoot,
|
|
2924
|
+
result,
|
|
2925
|
+
});
|
|
2926
|
+
const walkStable = walkSnapshotComplete(before, declaredInputs) &&
|
|
2927
|
+
walkSnapshotComplete(inputSnapshot, declaredInputs) &&
|
|
2928
|
+
sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
|
|
2929
|
+
sameHashes(before.fileSignatures, inputSnapshot.fileSignatures, declaredInputs) &&
|
|
2930
|
+
sameProjectDirectories(before.projectDirectories, inputSnapshot.projectDirectories) &&
|
|
2931
|
+
tracker?.membershipChanged !== true &&
|
|
2932
|
+
hostInputTracker?.membershipChanged !== true &&
|
|
2933
|
+
candidateTracker?.membershipChanged !== true;
|
|
2934
|
+
const notificationsAvailable = tracker?.failed !== true &&
|
|
2935
|
+
hostInputTracker?.failed !== true &&
|
|
2936
|
+
candidateTracker?.failed !== true;
|
|
2937
|
+
// Overlay the in-memory source only after proving the two on-disk snapshots
|
|
2938
|
+
// stable; an unsaved editor buffer must not look like a compile-time race.
|
|
2939
|
+
const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
|
|
2940
|
+
inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
|
|
2941
|
+
// That overlay makes this one key the only recorded hash a disk signature
|
|
2942
|
+
// cannot stand for: the bytes it names came from the bundler, not the file.
|
|
2943
|
+
delete inputSnapshot.provenSignatures[currentFileKey];
|
|
2944
|
+
const cached = {
|
|
963
2945
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
964
2946
|
// cache validation can re-check them; computed before dispose so the
|
|
965
2947
|
// exclusion of the temp-dir tsconfig is the only reason it never keys.
|
|
966
|
-
externalInputHashes:
|
|
2948
|
+
externalInputHashes: {},
|
|
2949
|
+
externalInputRealpaths: {},
|
|
967
2950
|
externalInputPaths,
|
|
968
|
-
inputHashes:
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}),
|
|
2951
|
+
inputHashes: inputSnapshot.hashes,
|
|
2952
|
+
inputSignatures: inputSnapshot.provenSignatures,
|
|
2953
|
+
projectDirectories: inputSnapshot.projectDirectories,
|
|
2954
|
+
projectSnapshotComplete: false,
|
|
973
2955
|
projectRoot,
|
|
974
2956
|
result,
|
|
975
2957
|
servedFiles: new Set(),
|
|
@@ -978,40 +2960,219 @@ async function transformProject(props) {
|
|
|
978
2960
|
// but deleted file would invalidate every persistent-cache snapshot.
|
|
979
2961
|
...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
|
|
980
2962
|
};
|
|
2963
|
+
const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
|
|
2964
|
+
cached.externalInputHashes = externalInputSnapshot.hashes;
|
|
2965
|
+
cached.externalInputRealpaths = externalInputSnapshot.realpaths;
|
|
2966
|
+
cached.externalInputSignatures = externalInputSnapshot.signatures;
|
|
2967
|
+
// Evaluate every half, rather than short-circuiting, so a generation that
|
|
2968
|
+
// cannot be reused can say which evidence it lacked. The extra work runs
|
|
2969
|
+
// only on the failing path, where the alternative is recompiling the whole
|
|
2970
|
+
// project for every remaining module.
|
|
2971
|
+
const graphProofs = matchesCompilerGraphInputProofs(cached);
|
|
2972
|
+
const universalInputs = captureUniversalHostInputValidation(cached, props.currentFile) !==
|
|
2973
|
+
undefined;
|
|
2974
|
+
const stableProjectSnapshot = walkStable &&
|
|
2975
|
+
graphProofs &&
|
|
2976
|
+
externalInputSnapshot.complete &&
|
|
2977
|
+
universalInputs;
|
|
2978
|
+
// Only a caching host loses anything here: without a cache every delivery
|
|
2979
|
+
// compiles by design, so an unprovable generation costs it nothing.
|
|
2980
|
+
if (!stableProjectSnapshot && props.trackProjectMembership) {
|
|
2981
|
+
reportUnreusableGeneration(cached, {
|
|
2982
|
+
externalInputs: externalInputSnapshot.complete,
|
|
2983
|
+
graphProofs,
|
|
2984
|
+
universalInputs,
|
|
2985
|
+
walkStable,
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
cached.projectSnapshotComplete = stableProjectSnapshot;
|
|
2989
|
+
// Attach notifications only while they can actually prove membership. A
|
|
2990
|
+
// generation that could not open its watchers keeps its recorded snapshot
|
|
2991
|
+
// and validates through it, rather than losing the cache entirely.
|
|
2992
|
+
const notifying = stableProjectSnapshot && notificationsAvailable;
|
|
2993
|
+
if (notifying && tracker !== undefined) {
|
|
2994
|
+
cached.projectMutationTracker = tracker;
|
|
2995
|
+
}
|
|
2996
|
+
if (notifying && hostInputTracker !== undefined) {
|
|
2997
|
+
cached.hostInputMutationTracker = hostInputTracker;
|
|
2998
|
+
}
|
|
2999
|
+
if (notifying && candidateTracker !== undefined) {
|
|
3000
|
+
cached.candidateMutationTracker = candidateTracker;
|
|
3001
|
+
}
|
|
3002
|
+
// Every tracker the generation published is retained, and every tracker it
|
|
3003
|
+
// did not is closed below. Naming only two of the three would close a
|
|
3004
|
+
// published candidate tracker the moment either of the others was absent,
|
|
3005
|
+
// and that is the one tracker whose silence is read as evidence.
|
|
3006
|
+
retainTracker = notifying && tracker !== undefined;
|
|
3007
|
+
retainHostInputTracker = notifying && hostInputTracker !== undefined;
|
|
3008
|
+
retainCandidateTracker = notifying && candidateTracker !== undefined;
|
|
3009
|
+
return cached;
|
|
981
3010
|
}
|
|
982
3011
|
finally {
|
|
983
|
-
|
|
3012
|
+
try {
|
|
3013
|
+
if (!retainTracker && tracker !== undefined) {
|
|
3014
|
+
tracker.close();
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
finally {
|
|
3018
|
+
try {
|
|
3019
|
+
if (!retainHostInputTracker && hostInputTracker !== undefined) {
|
|
3020
|
+
hostInputTracker.close();
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
finally {
|
|
3024
|
+
try {
|
|
3025
|
+
if (!retainCandidateTracker && candidateTracker !== undefined) {
|
|
3026
|
+
candidateTracker.close();
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
finally {
|
|
3030
|
+
fs.rmSync(scratchDirectory, { force: true, recursive: true });
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
984
3034
|
}
|
|
985
3035
|
}
|
|
986
|
-
|
|
3036
|
+
/** Exclude the disposed overlay tsconfig from live host-input tracking. */
|
|
3037
|
+
function selectPersistentHostInputs(props) {
|
|
3038
|
+
if (props.result.type === "exception")
|
|
3039
|
+
return [];
|
|
3040
|
+
const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
3041
|
+
if (props.temporaryTsconfig === undefined)
|
|
3042
|
+
return inputs;
|
|
3043
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
3044
|
+
const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
|
|
3045
|
+
return inputs.filter((input) => pathIdentityKey(input, identities) !== temporary);
|
|
3046
|
+
}
|
|
3047
|
+
function createTransformTsconfig(props, scratchDirectory) {
|
|
987
3048
|
const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
|
|
988
3049
|
...props.compilerOptions,
|
|
989
3050
|
...createAliasCompilerOptions(props),
|
|
990
3051
|
}, path.dirname(props.tsconfig));
|
|
991
3052
|
if (Object.keys(compilerOptions).length === 0) {
|
|
992
|
-
return {
|
|
993
|
-
path: props.tsconfig,
|
|
994
|
-
dispose: () => undefined,
|
|
995
|
-
};
|
|
3053
|
+
return { path: props.tsconfig };
|
|
996
3054
|
}
|
|
997
|
-
const
|
|
998
|
-
const file = path.join(directory, "tsconfig.json");
|
|
3055
|
+
const file = path.join(scratchDirectory, "tsconfig.json");
|
|
999
3056
|
fs.writeFileSync(file, JSON.stringify({
|
|
1000
3057
|
extends: normalizePath(props.tsconfig),
|
|
1001
3058
|
compilerOptions,
|
|
1002
3059
|
}, null, 2), "utf8");
|
|
3060
|
+
return { path: file };
|
|
3061
|
+
}
|
|
3062
|
+
/** Create compiler scratch storage outside the project snapshot and watchers. */
|
|
3063
|
+
function createTransformScratchDirectory(projectRoot, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
3064
|
+
const root = path.resolve(projectRoot);
|
|
3065
|
+
const canonicalRoot = filesystem.realpath(root);
|
|
3066
|
+
const platformTemp = process.platform === "win32" && process.env.LOCALAPPDATA
|
|
3067
|
+
? path.join(process.env.LOCALAPPDATA, "Temp")
|
|
3068
|
+
: "/tmp";
|
|
3069
|
+
const candidates = [
|
|
3070
|
+
os.tmpdir(),
|
|
3071
|
+
platformTemp,
|
|
3072
|
+
path.dirname(root),
|
|
3073
|
+
os.homedir(),
|
|
3074
|
+
];
|
|
3075
|
+
const canonicalCandidates = new Set();
|
|
3076
|
+
let failure;
|
|
3077
|
+
for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
|
|
3078
|
+
if (pathIsWithin(candidate, root))
|
|
3079
|
+
continue;
|
|
3080
|
+
let canonicalCandidate;
|
|
3081
|
+
try {
|
|
3082
|
+
canonicalCandidate = filesystem.realpath(candidate);
|
|
3083
|
+
}
|
|
3084
|
+
catch (error) {
|
|
3085
|
+
failure = error;
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
if (pathIsWithin(canonicalCandidate, canonicalRoot) ||
|
|
3089
|
+
canonicalCandidates.has(canonicalCandidate)) {
|
|
3090
|
+
continue;
|
|
3091
|
+
}
|
|
3092
|
+
canonicalCandidates.add(canonicalCandidate);
|
|
3093
|
+
let directory;
|
|
3094
|
+
try {
|
|
3095
|
+
directory = fs.mkdtempSync(path.join(canonicalCandidate, "ttsc-unplugin-"));
|
|
3096
|
+
}
|
|
3097
|
+
catch (error) {
|
|
3098
|
+
failure = error;
|
|
3099
|
+
continue;
|
|
3100
|
+
}
|
|
3101
|
+
let canonicalDirectory;
|
|
3102
|
+
try {
|
|
3103
|
+
canonicalDirectory = filesystem.realpath(directory);
|
|
3104
|
+
}
|
|
3105
|
+
catch (error) {
|
|
3106
|
+
try {
|
|
3107
|
+
fs.rmdirSync(directory);
|
|
3108
|
+
}
|
|
3109
|
+
catch (cleanupError) {
|
|
3110
|
+
throw cleanupError;
|
|
3111
|
+
}
|
|
3112
|
+
failure = error;
|
|
3113
|
+
continue;
|
|
3114
|
+
}
|
|
3115
|
+
// Use the postflight canonical spelling from this point onward. Returning
|
|
3116
|
+
// the candidate-relative spelling would let another process retarget its
|
|
3117
|
+
// parent symlink/junction after validation, redirecting compiler writes or
|
|
3118
|
+
// the final recursive removal into the project.
|
|
3119
|
+
if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
|
|
3120
|
+
return canonicalDirectory;
|
|
3121
|
+
}
|
|
3122
|
+
// Refuse the result and synchronously remove only our empty random child
|
|
3123
|
+
// through the identity that the postflight check just classified.
|
|
3124
|
+
fs.rmdirSync(canonicalDirectory);
|
|
3125
|
+
}
|
|
3126
|
+
throw (failure ??
|
|
3127
|
+
new Error("ttsc: no temporary directory exists outside the project"));
|
|
3128
|
+
}
|
|
3129
|
+
function pathIsWithin(child, parent) {
|
|
3130
|
+
const relative = path.relative(parent, child);
|
|
3131
|
+
return (relative === "" ||
|
|
3132
|
+
(relative !== ".." &&
|
|
3133
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
3134
|
+
!path.isAbsolute(relative)));
|
|
3135
|
+
}
|
|
3136
|
+
/** Route all compiler/plugin scratch to one owned directory outside project. */
|
|
3137
|
+
function transformScratchEnvironment(directory) {
|
|
1003
3138
|
return {
|
|
1004
|
-
|
|
1005
|
-
|
|
3139
|
+
...process.env,
|
|
3140
|
+
TEMP: directory,
|
|
3141
|
+
TMP: directory,
|
|
3142
|
+
TMPDIR: directory,
|
|
1006
3143
|
};
|
|
1007
3144
|
}
|
|
3145
|
+
/** Scope parent-process temp consumers to the same owned scratch directory. */
|
|
3146
|
+
function withTransformScratchEnvironment(scratchDirectory, callback) {
|
|
3147
|
+
const environment = transformScratchEnvironment(scratchDirectory);
|
|
3148
|
+
const previous = {
|
|
3149
|
+
TEMP: process.env.TEMP,
|
|
3150
|
+
TMP: process.env.TMP,
|
|
3151
|
+
TMPDIR: process.env.TMPDIR,
|
|
3152
|
+
};
|
|
3153
|
+
process.env.TEMP = environment.TEMP;
|
|
3154
|
+
process.env.TMP = environment.TMP;
|
|
3155
|
+
process.env.TMPDIR = environment.TMPDIR;
|
|
3156
|
+
try {
|
|
3157
|
+
return callback();
|
|
3158
|
+
}
|
|
3159
|
+
finally {
|
|
3160
|
+
for (const [name, value] of Object.entries(previous)) {
|
|
3161
|
+
if (value === undefined)
|
|
3162
|
+
delete process.env[name];
|
|
3163
|
+
else
|
|
3164
|
+
process.env[name] = value;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
1008
3168
|
/**
|
|
1009
3169
|
* Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
|
|
1010
3170
|
*
|
|
1011
|
-
* The generated tsconfig lives in a
|
|
1012
|
-
* (e.g. `"outDir": "../dist"`) that was meaningful relative
|
|
1013
|
-
* tsconfig must be converted to an absolute path before writing
|
|
1014
|
-
* file. Otherwise TypeScript-Go resolves it against the temp
|
|
3171
|
+
* The generated tsconfig lives in a temporary directory outside the project, so
|
|
3172
|
+
* any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
|
|
3173
|
+
* to the original tsconfig must be converted to an absolute path before writing
|
|
3174
|
+
* the generated file. Otherwise TypeScript-Go resolves it against the temp
|
|
3175
|
+
* dir.
|
|
1015
3176
|
*
|
|
1016
3177
|
* `paths` targets are absolutized for the same reason, with the extra twist
|
|
1017
3178
|
* that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
|
|
@@ -1284,7 +3445,7 @@ function formatUnknownError(error) {
|
|
|
1284
3445
|
* compiler will error if that file does not exist, which is the correct
|
|
1285
3446
|
* behavior for a mis-configured project.
|
|
1286
3447
|
*/
|
|
1287
|
-
function resolveTsconfig(file, tsconfig) {
|
|
3448
|
+
function resolveTsconfig(file, tsconfig, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
|
|
1288
3449
|
if (tsconfig !== undefined) {
|
|
1289
3450
|
return path.isAbsolute(tsconfig)
|
|
1290
3451
|
? tsconfig
|
|
@@ -1293,7 +3454,7 @@ function resolveTsconfig(file, tsconfig) {
|
|
|
1293
3454
|
let current = path.dirname(file);
|
|
1294
3455
|
while (true) {
|
|
1295
3456
|
const candidate = path.join(current, "tsconfig.json");
|
|
1296
|
-
if (
|
|
3457
|
+
if (filesystem.exists(candidate)) {
|
|
1297
3458
|
return candidate;
|
|
1298
3459
|
}
|
|
1299
3460
|
const parent = path.dirname(current);
|
|
@@ -1332,6 +3493,7 @@ exports.createTransformResult = createTransformResult;
|
|
|
1332
3493
|
exports.createTtscTransformCache = createTtscTransformCache;
|
|
1333
3494
|
exports.isDeclarationFile = isDeclarationFile;
|
|
1334
3495
|
exports.isProjectWalkPath = isProjectWalkPath;
|
|
3496
|
+
exports.normalizeHostInputName = normalizeHostInputName;
|
|
1335
3497
|
exports.pathIdentityKey = pathIdentityKey;
|
|
1336
3498
|
exports.resetTtscTransformCache = resetTtscTransformCache;
|
|
1337
3499
|
exports.stripQuery = stripQuery;
|