@ttsc/unplugin 0.26.1 → 0.27.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.map +1 -1
- package/lib/core/index.mjs +1 -1
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.ts +76 -18
- package/lib/core/transform.js +1269 -136
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +1269 -137
- package/lib/core/transform.mjs.map +1 -1
- package/lib/turbopack.mjs +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +4 -1
- package/src/core/transform.ts +1689 -172
package/src/core/transform.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
import { TtscCompiler } from "ttsc";
|
|
10
11
|
import {
|
|
11
12
|
type FilesystemPathIdentityContext,
|
|
13
|
+
type FilesystemPathIdentityOperations,
|
|
12
14
|
createFilesystemPathIdentityContext,
|
|
13
15
|
} from "ttsc/path-identity";
|
|
14
16
|
import type { TransformResult } from "unplugin";
|
|
@@ -43,15 +45,31 @@ export interface TtscTransformAlias {
|
|
|
43
45
|
replacement: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** One directory's cheap project-membership identity at generation time. */
|
|
49
|
+
interface TtscProjectDirectorySnapshot {
|
|
50
|
+
/** Absolute directory spelling used by the project walk. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** Metadata signature that changes when its immediate membership changes. */
|
|
53
|
+
signature: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Generation-scoped directory watchers used to detect membership changes. */
|
|
57
|
+
interface TtscProjectMutationTracker {
|
|
58
|
+
close: () => void;
|
|
59
|
+
failed: boolean;
|
|
60
|
+
membershipChanged: boolean;
|
|
61
|
+
settle?: Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/**
|
|
47
65
|
* A single entry in the project transform cache.
|
|
48
66
|
*
|
|
49
67
|
* Stores the full compiler result together with SHA-256 hashes of every project
|
|
50
68
|
* input file. In a cache with an explicit build lifecycle, the first delivery
|
|
51
69
|
* of each compiled module compares its supplied source with the generation
|
|
52
|
-
* snapshot in constant time
|
|
53
|
-
*
|
|
54
|
-
*
|
|
70
|
+
* snapshot in constant time. Later graph-bearing deliveries validate only the
|
|
71
|
+
* requested file's derived inputs plus exact host descriptor/config inputs;
|
|
72
|
+
* graph-free envelopes retain complete-snapshot validation.
|
|
55
73
|
*/
|
|
56
74
|
export interface TtscCachedProjectTransform {
|
|
57
75
|
/**
|
|
@@ -68,6 +86,12 @@ export interface TtscCachedProjectTransform {
|
|
|
68
86
|
* `buildStart` and never replay across edits.
|
|
69
87
|
*/
|
|
70
88
|
externalInputHashes?: Record<string, string>;
|
|
89
|
+
/**
|
|
90
|
+
* Compiler-time physical identities for graph-owned entries in
|
|
91
|
+
* {@link externalInputHashes}. Dependency-only paths have no generation
|
|
92
|
+
* realpath protocol and therefore omit this evidence.
|
|
93
|
+
*/
|
|
94
|
+
externalInputRealpaths?: Record<string, string | null>;
|
|
71
95
|
/**
|
|
72
96
|
* Original absolute spellings of {@link externalInputHashes} inputs. These
|
|
73
97
|
* stay separate from their identity keys so validation reads the paths the
|
|
@@ -79,13 +103,25 @@ export interface TtscCachedProjectTransform {
|
|
|
79
103
|
* transform.
|
|
80
104
|
*/
|
|
81
105
|
inputHashes: Record<string, string>;
|
|
106
|
+
/** Metadata snapshot of every directory in the stable generation walk. */
|
|
107
|
+
projectDirectories?: TtscProjectDirectorySnapshot[];
|
|
108
|
+
/** Live notification state for universal host-input changes. */
|
|
109
|
+
hostInputMutationTracker?: TtscProjectMutationTracker;
|
|
110
|
+
/** Live notification state for file/directory creation, deletion, and rename. */
|
|
111
|
+
projectMutationTracker?: TtscProjectMutationTracker;
|
|
112
|
+
/**
|
|
113
|
+
* Whether the generation-time project walk observed every directory and file
|
|
114
|
+
* it attempted to snapshot. An incomplete walk may never authorize narrow
|
|
115
|
+
* validation; a later complete walk must be allowed to replace it.
|
|
116
|
+
*/
|
|
117
|
+
projectSnapshotComplete?: boolean;
|
|
82
118
|
/** Absolute path to the directory that owns the tsconfig. */
|
|
83
119
|
projectRoot: string;
|
|
84
120
|
/** Raw compiler output returned by {@link TtscCompiler.transform}. */
|
|
85
121
|
result: ITtscCompilerTransformation;
|
|
86
122
|
/**
|
|
87
123
|
* Files already delivered from this generation, keyed by filesystem identity.
|
|
88
|
-
* Build-scoped caches use this to skip
|
|
124
|
+
* Build-scoped caches use this to skip persistent validation only for a
|
|
89
125
|
* module's first delivery inside the current build.
|
|
90
126
|
*/
|
|
91
127
|
servedFiles?: Set<string>;
|
|
@@ -111,21 +147,112 @@ export type TtscTransformCache = Map<
|
|
|
111
147
|
Promise<TtscCachedProjectTransform>
|
|
112
148
|
>;
|
|
113
149
|
|
|
150
|
+
/** Cache-owned synchronous filesystem reads used by transform validation. */
|
|
151
|
+
export interface TtscTransformFilesystemOperations {
|
|
152
|
+
/** Override the case policy when the observed filesystem is not the host. */
|
|
153
|
+
caseSensitive?: FilesystemPathIdentityOperations["caseSensitive"];
|
|
154
|
+
/** Test whether a validation or resolution candidate currently exists. */
|
|
155
|
+
exists(location: string): boolean;
|
|
156
|
+
/** Read link metadata without following a symbolic link. */
|
|
157
|
+
lstat(location: string): fs.BigIntStats;
|
|
158
|
+
/** Read bytes used by project, graph, and host-input fingerprints. */
|
|
159
|
+
readFile(location: string): Buffer;
|
|
160
|
+
/** Enumerate one project or missing-input proof directory. */
|
|
161
|
+
readdir(location: string): fs.Dirent[];
|
|
162
|
+
/** Resolve one lexical path to its current physical target. */
|
|
163
|
+
realpath(location: string): string;
|
|
164
|
+
/** Read ordinary metadata for file-kind and missing-path checks. */
|
|
165
|
+
stat(location: string): fs.Stats;
|
|
166
|
+
/** Read nanosecond metadata for stable file and directory signatures. */
|
|
167
|
+
statBigInt(location: string): fs.BigIntStats;
|
|
168
|
+
/** Override path parsing when the observed filesystem is not the host. */
|
|
169
|
+
platform?: NodeJS.Platform;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const DEFAULT_FILESYSTEM_OPERATIONS: TtscTransformFilesystemOperations =
|
|
173
|
+
Object.freeze({
|
|
174
|
+
exists: fs.existsSync,
|
|
175
|
+
lstat: (location: string) => fs.lstatSync(location, { bigint: true }),
|
|
176
|
+
readFile: (location: string) => fs.readFileSync(location),
|
|
177
|
+
readdir: (location: string) =>
|
|
178
|
+
fs.readdirSync(location, { withFileTypes: true }),
|
|
179
|
+
realpath: fs.realpathSync.native,
|
|
180
|
+
stat: fs.statSync,
|
|
181
|
+
statBigInt: (location: string) => fs.statSync(location, { bigint: true }),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const TRANSFORM_CACHE_FILESYSTEM = new WeakMap<
|
|
185
|
+
TtscTransformCache,
|
|
186
|
+
TtscTransformFilesystemOperations
|
|
187
|
+
>();
|
|
188
|
+
const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
|
|
189
|
+
ITtscCompilerTransformation,
|
|
190
|
+
TtscTransformFilesystemOperations
|
|
191
|
+
>();
|
|
192
|
+
|
|
114
193
|
/**
|
|
115
194
|
* Caches whose owner has declared a real per-build lifecycle by calling
|
|
116
195
|
* {@link beginTtscTransformBuild} before transforms begin.
|
|
117
196
|
*/
|
|
118
197
|
const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
|
|
119
198
|
|
|
120
|
-
function createHostPathIdentityContext(
|
|
199
|
+
function createHostPathIdentityContext(
|
|
200
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
201
|
+
): FilesystemPathIdentityContext {
|
|
121
202
|
return createFilesystemPathIdentityContext({
|
|
203
|
+
caseSensitive: filesystem.caseSensitive,
|
|
204
|
+
lstat: filesystem.lstat,
|
|
205
|
+
platform: filesystem.platform,
|
|
206
|
+
readdir: (directory) =>
|
|
207
|
+
filesystem.readdir(directory).map((entry) => entry.name),
|
|
208
|
+
realpath: filesystem.realpath,
|
|
122
209
|
throwOnRealpathError: false,
|
|
123
210
|
});
|
|
124
211
|
}
|
|
125
212
|
|
|
126
|
-
/**
|
|
127
|
-
export function
|
|
128
|
-
|
|
213
|
+
/** Normalize one directory entry under the owning filesystem's case policy. */
|
|
214
|
+
export function normalizeHostInputName(
|
|
215
|
+
name: string,
|
|
216
|
+
caseSensitive: boolean,
|
|
217
|
+
): string {
|
|
218
|
+
return caseSensitive ? name : name.toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Create an empty persistent transform cache with isolated filesystem reads. */
|
|
222
|
+
export function createTtscTransformCache(
|
|
223
|
+
operations: Partial<TtscTransformFilesystemOperations> = {},
|
|
224
|
+
): TtscTransformCache {
|
|
225
|
+
const cache: TtscTransformCache = new Map();
|
|
226
|
+
TRANSFORM_CACHE_FILESYSTEM.set(cache, {
|
|
227
|
+
caseSensitive: operations.caseSensitive,
|
|
228
|
+
exists: operations.exists ?? DEFAULT_FILESYSTEM_OPERATIONS.exists,
|
|
229
|
+
lstat: operations.lstat ?? DEFAULT_FILESYSTEM_OPERATIONS.lstat,
|
|
230
|
+
readFile: operations.readFile ?? DEFAULT_FILESYSTEM_OPERATIONS.readFile,
|
|
231
|
+
readdir: operations.readdir ?? DEFAULT_FILESYSTEM_OPERATIONS.readdir,
|
|
232
|
+
realpath: operations.realpath ?? DEFAULT_FILESYSTEM_OPERATIONS.realpath,
|
|
233
|
+
stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
|
|
234
|
+
statBigInt:
|
|
235
|
+
operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
|
|
236
|
+
platform: operations.platform,
|
|
237
|
+
});
|
|
238
|
+
return cache;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function transformFilesystem(
|
|
242
|
+
cache: TtscTransformCache | undefined,
|
|
243
|
+
): TtscTransformFilesystemOperations {
|
|
244
|
+
return (
|
|
245
|
+
(cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
|
|
246
|
+
DEFAULT_FILESYSTEM_OPERATIONS
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resultFilesystem(
|
|
251
|
+
result: ITtscCompilerTransformation,
|
|
252
|
+
): TtscTransformFilesystemOperations {
|
|
253
|
+
return (
|
|
254
|
+
TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS
|
|
255
|
+
);
|
|
129
256
|
}
|
|
130
257
|
|
|
131
258
|
/**
|
|
@@ -137,7 +264,7 @@ export function createTtscTransformCache(): TtscTransformCache {
|
|
|
137
264
|
* defines one process-scoped module-loading session.
|
|
138
265
|
*/
|
|
139
266
|
export function beginTtscTransformBuild(cache: TtscTransformCache): void {
|
|
140
|
-
cache
|
|
267
|
+
clearTtscTransformCache(cache);
|
|
141
268
|
BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
|
|
142
269
|
}
|
|
143
270
|
|
|
@@ -149,10 +276,19 @@ export function beginTtscTransformBuild(cache: TtscTransformCache): void {
|
|
|
149
276
|
* many edits, so that callback cannot authorize build-scoped shortcuts.
|
|
150
277
|
*/
|
|
151
278
|
export function resetTtscTransformCache(cache: TtscTransformCache): void {
|
|
152
|
-
cache
|
|
279
|
+
clearTtscTransformCache(cache);
|
|
153
280
|
BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
|
|
154
281
|
}
|
|
155
282
|
|
|
283
|
+
/** Dispose generation-owned filesystem resources before clearing a cache. */
|
|
284
|
+
function clearTtscTransformCache(cache: TtscTransformCache): void {
|
|
285
|
+
const generations = [...cache.values()];
|
|
286
|
+
cache.clear();
|
|
287
|
+
for (const generation of generations) {
|
|
288
|
+
void generation.then(disposeCachedTransform, () => undefined);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
156
292
|
/**
|
|
157
293
|
* Hooks the bundler adapter passes into {@link transformTtsc} so transform
|
|
158
294
|
* side-channels (plugin-reported dependencies and host resolution candidates)
|
|
@@ -215,6 +351,7 @@ export async function transformTtsc(
|
|
|
215
351
|
cache?: TtscTransformCache,
|
|
216
352
|
hooks?: TtscTransformHooks,
|
|
217
353
|
): Promise<TtscTransformResult | undefined> {
|
|
354
|
+
const filesystem = transformFilesystem(cache);
|
|
218
355
|
const clean = stripQuery(id);
|
|
219
356
|
if (clean.includes("\0")) {
|
|
220
357
|
return undefined;
|
|
@@ -227,7 +364,7 @@ export async function transformTtsc(
|
|
|
227
364
|
return undefined;
|
|
228
365
|
}
|
|
229
366
|
|
|
230
|
-
const tsconfig = resolveTsconfig(file, options.project);
|
|
367
|
+
const tsconfig = resolveTsconfig(file, options.project, filesystem);
|
|
231
368
|
const aliasPaths = createAliasPaths(aliases);
|
|
232
369
|
const key = createTransformCacheKey({
|
|
233
370
|
aliasPaths,
|
|
@@ -242,11 +379,20 @@ export async function transformTtsc(
|
|
|
242
379
|
// A rejected in-flight generation must not stay cached: evict it (only if
|
|
243
380
|
// it is still the current entry) so a later call re-runs the transform.
|
|
244
381
|
const cached = await awaitOrEvict(cache, key, transformed);
|
|
382
|
+
TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
|
|
245
383
|
// While this caller awaited the old Promise, another caller may have
|
|
246
384
|
// invalidated it and installed a newer authoritative generation.
|
|
247
385
|
if (cache?.get(key) !== transformed) {
|
|
248
386
|
continue;
|
|
249
387
|
}
|
|
388
|
+
const buildScoped =
|
|
389
|
+
cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
|
|
390
|
+
if (!buildScoped) {
|
|
391
|
+
await settleProjectMutationEvents(cached);
|
|
392
|
+
if (cache?.get(key) !== transformed) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
250
396
|
if (
|
|
251
397
|
// A file the plugin declared volatile must never be served from the
|
|
252
398
|
// cache: its output depends on non-file inputs, so the input-hash
|
|
@@ -256,12 +402,7 @@ export async function transformTtsc(
|
|
|
256
402
|
projectRoot: cached.projectRoot,
|
|
257
403
|
result: cached.result,
|
|
258
404
|
}) &&
|
|
259
|
-
matchesCachedSource(
|
|
260
|
-
cached,
|
|
261
|
-
file,
|
|
262
|
-
source,
|
|
263
|
-
cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache),
|
|
264
|
-
)
|
|
405
|
+
matchesCachedSource(cached, file, source, buildScoped)
|
|
265
406
|
) {
|
|
266
407
|
reportSuccessDiagnostics(cached.result);
|
|
267
408
|
// A resolved `"exception"` / `"failure"` envelope makes this throw;
|
|
@@ -296,7 +437,9 @@ export async function transformTtsc(
|
|
|
296
437
|
compilerOptions: options.compilerOptions,
|
|
297
438
|
currentFile: file,
|
|
298
439
|
currentSource: source,
|
|
440
|
+
filesystem,
|
|
299
441
|
plugins: options.plugins,
|
|
442
|
+
trackProjectMembership: cache !== undefined,
|
|
300
443
|
tsconfig,
|
|
301
444
|
});
|
|
302
445
|
cache?.set(key, transformed);
|
|
@@ -383,9 +526,21 @@ function evictGeneration(
|
|
|
383
526
|
): void {
|
|
384
527
|
if (cache?.get(key) === generation) {
|
|
385
528
|
cache.delete(key);
|
|
529
|
+
void generation.then(disposeCachedTransform, () => undefined);
|
|
386
530
|
}
|
|
387
531
|
}
|
|
388
532
|
|
|
533
|
+
/** Close one generation's directory watchers exactly once. */
|
|
534
|
+
function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
|
|
535
|
+
const trackers = [
|
|
536
|
+
cached.projectMutationTracker,
|
|
537
|
+
cached.hostInputMutationTracker,
|
|
538
|
+
];
|
|
539
|
+
cached.projectMutationTracker = undefined;
|
|
540
|
+
cached.hostInputMutationTracker = undefined;
|
|
541
|
+
for (const tracker of trackers) tracker?.close();
|
|
542
|
+
}
|
|
543
|
+
|
|
389
544
|
/**
|
|
390
545
|
* Per-envelope derivation state: every index the per-delivery paths need, built
|
|
391
546
|
* at most once and shared by all deliveries of one compiler result.
|
|
@@ -431,6 +586,8 @@ interface TtscEnvelopeDerivation {
|
|
|
431
586
|
* files, `undefined` until the first completeness predicate.
|
|
432
587
|
*/
|
|
433
588
|
dependenciesComplete?: Set<string>;
|
|
589
|
+
/** Universal inputs validated once at generation time and then by metadata. */
|
|
590
|
+
hostInputValidation?: TtscHostInputValidation;
|
|
434
591
|
/**
|
|
435
592
|
* Lazily built identity -> output source index of the `typescript` map (first
|
|
436
593
|
* match wins, mirroring the historical scan). `undefined` until the first
|
|
@@ -446,6 +603,26 @@ interface TtscEnvelopeDerivation {
|
|
|
446
603
|
readonly watchInputs: Map<string, string[]>;
|
|
447
604
|
}
|
|
448
605
|
|
|
606
|
+
interface TtscHostInputValidation {
|
|
607
|
+
/** Lexical input spellings that existed when the generation was captured. */
|
|
608
|
+
readonly entries: Map<
|
|
609
|
+
string,
|
|
610
|
+
{
|
|
611
|
+
path: string;
|
|
612
|
+
realpath: string | null;
|
|
613
|
+
signature: string;
|
|
614
|
+
strict?: true;
|
|
615
|
+
}
|
|
616
|
+
>;
|
|
617
|
+
/** Identities omitted from the per-module dependency loop below. */
|
|
618
|
+
readonly identities: Set<string>;
|
|
619
|
+
/**
|
|
620
|
+
* Missing paths grouped by the nearest directory whose listing proves them
|
|
621
|
+
* absent.
|
|
622
|
+
*/
|
|
623
|
+
readonly missing: Map<string, Set<string>>;
|
|
624
|
+
}
|
|
625
|
+
|
|
449
626
|
/** Reference-graph indexes shared by every watch-input derivation. */
|
|
450
627
|
interface TtscEnvelopeGraphIndexes {
|
|
451
628
|
/** Identity of each direct-edge source -> its resolved absolute targets. */
|
|
@@ -457,6 +634,15 @@ interface TtscEnvelopeGraphIndexes {
|
|
|
457
634
|
/** Resolved absolute `graph.globals` and `graph.configs` members. */
|
|
458
635
|
readonly globals: string[];
|
|
459
636
|
readonly configs: string[];
|
|
637
|
+
/** Every realized/candidate graph path, keyed by filesystem identity. */
|
|
638
|
+
readonly members: Set<string>;
|
|
639
|
+
/** Compiler-time proof for graph members, keyed by filesystem identity. */
|
|
640
|
+
readonly inputProofs: Map<
|
|
641
|
+
string,
|
|
642
|
+
{ hash: string | null; path: string; realpath: string | null }
|
|
643
|
+
>;
|
|
644
|
+
/** Aliased graph proof keys that reported contradictory generation states. */
|
|
645
|
+
readonly inputProofConflicts: Set<string>;
|
|
460
646
|
}
|
|
461
647
|
|
|
462
648
|
/**
|
|
@@ -479,7 +665,9 @@ function envelopeDerivation(props: {
|
|
|
479
665
|
return existing;
|
|
480
666
|
}
|
|
481
667
|
const created: TtscEnvelopeDerivation = {
|
|
482
|
-
identityContext: createHostPathIdentityContext(
|
|
668
|
+
identityContext: createHostPathIdentityContext(
|
|
669
|
+
resultFilesystem(props.result),
|
|
670
|
+
),
|
|
483
671
|
identities: new Map(),
|
|
484
672
|
watchInputs: new Map(),
|
|
485
673
|
};
|
|
@@ -508,6 +696,9 @@ function envelopeGraphIndexes(
|
|
|
508
696
|
candidates: [],
|
|
509
697
|
globals: [],
|
|
510
698
|
configs: [],
|
|
699
|
+
members: new Set(),
|
|
700
|
+
inputProofs: new Map(),
|
|
701
|
+
inputProofConflicts: new Set(),
|
|
511
702
|
};
|
|
512
703
|
const graph =
|
|
513
704
|
props.result.type === "exception" ? undefined : props.result.graph;
|
|
@@ -518,6 +709,7 @@ function envelopeGraphIndexes(
|
|
|
518
709
|
}
|
|
519
710
|
const absolute = path.resolve(props.projectRoot, source);
|
|
520
711
|
const identity = derivationIdentity(state, absolute);
|
|
712
|
+
built.members.add(identity);
|
|
521
713
|
built.spellings.set(identity, absolute);
|
|
522
714
|
const entries = built.edges.get(identity) ?? [];
|
|
523
715
|
entries.push(
|
|
@@ -526,23 +718,84 @@ function envelopeGraphIndexes(
|
|
|
526
718
|
(target): target is string =>
|
|
527
719
|
typeof target === "string" && target.length !== 0,
|
|
528
720
|
)
|
|
529
|
-
.map((target) =>
|
|
721
|
+
.map((target) => {
|
|
722
|
+
const absoluteTarget = path.resolve(props.projectRoot, target);
|
|
723
|
+
built.members.add(derivationIdentity(state, absoluteTarget));
|
|
724
|
+
return absoluteTarget;
|
|
725
|
+
}),
|
|
530
726
|
);
|
|
531
727
|
built.edges.set(identity, entries);
|
|
532
728
|
}
|
|
533
729
|
built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
|
|
534
730
|
built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
|
|
731
|
+
for (const input of [...built.globals, ...built.configs]) {
|
|
732
|
+
built.members.add(derivationIdentity(state, input));
|
|
733
|
+
}
|
|
535
734
|
for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
|
|
536
735
|
if (!Array.isArray(candidates)) {
|
|
537
736
|
continue;
|
|
538
737
|
}
|
|
738
|
+
const sourceIdentity = derivationIdentity(
|
|
739
|
+
state,
|
|
740
|
+
path.resolve(props.projectRoot, source),
|
|
741
|
+
);
|
|
742
|
+
built.members.add(sourceIdentity);
|
|
539
743
|
built.candidates.push({
|
|
540
|
-
source:
|
|
541
|
-
state,
|
|
542
|
-
path.resolve(props.projectRoot, source),
|
|
543
|
-
),
|
|
744
|
+
source: sourceIdentity,
|
|
544
745
|
files: selectListedFiles(props.projectRoot, candidates),
|
|
545
746
|
});
|
|
747
|
+
for (const candidate of candidates) {
|
|
748
|
+
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
749
|
+
built.members.add(
|
|
750
|
+
derivationIdentity(state, path.resolve(props.projectRoot, candidate)),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
|
|
755
|
+
if (
|
|
756
|
+
hash !== null &&
|
|
757
|
+
(typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
|
|
758
|
+
) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (
|
|
762
|
+
graph.inputRealpaths === undefined ||
|
|
763
|
+
!Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)
|
|
764
|
+
) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const reportedRealpath = graph.inputRealpaths[input];
|
|
768
|
+
if (
|
|
769
|
+
reportedRealpath !== null &&
|
|
770
|
+
(typeof reportedRealpath !== "string" ||
|
|
771
|
+
!path.isAbsolute(reportedRealpath))
|
|
772
|
+
) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const absolute = path.resolve(props.projectRoot, input);
|
|
776
|
+
const identity = derivationIdentity(state, absolute);
|
|
777
|
+
if (!built.members.has(identity)) continue;
|
|
778
|
+
const proof = {
|
|
779
|
+
hash,
|
|
780
|
+
path: absolute,
|
|
781
|
+
realpath:
|
|
782
|
+
reportedRealpath === null ? null : path.resolve(reportedRealpath),
|
|
783
|
+
};
|
|
784
|
+
const previous = built.inputProofs.get(identity);
|
|
785
|
+
if (
|
|
786
|
+
previous !== undefined &&
|
|
787
|
+
(previous.hash !== proof.hash ||
|
|
788
|
+
!sameHostInputRealpath(
|
|
789
|
+
previous.realpath,
|
|
790
|
+
proof.realpath,
|
|
791
|
+
state.identityContext,
|
|
792
|
+
))
|
|
793
|
+
) {
|
|
794
|
+
built.inputProofs.delete(identity);
|
|
795
|
+
built.inputProofConflicts.add(identity);
|
|
796
|
+
} else if (!built.inputProofConflicts.has(identity)) {
|
|
797
|
+
built.inputProofs.set(identity, proof);
|
|
798
|
+
}
|
|
546
799
|
}
|
|
547
800
|
}
|
|
548
801
|
state.graph = built;
|
|
@@ -677,31 +930,65 @@ function deriveWatchInputs(
|
|
|
677
930
|
): string[] {
|
|
678
931
|
const graph = envelopeGraphIndexes(state, props);
|
|
679
932
|
const output: string[] = [];
|
|
680
|
-
const
|
|
933
|
+
const physicalSeen = new Set<string>();
|
|
934
|
+
const lexicalSeen = new Set<string>();
|
|
681
935
|
const excluded = new Set([fileIdentity]);
|
|
682
936
|
if (props.temporaryTsconfig !== undefined) {
|
|
683
937
|
excluded.add(derivationIdentity(state, props.temporaryTsconfig));
|
|
684
938
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
939
|
+
const currentSpelling = path.resolve(props.file);
|
|
940
|
+
const temporarySpelling =
|
|
941
|
+
props.temporaryTsconfig === undefined
|
|
942
|
+
? undefined
|
|
943
|
+
: path.resolve(props.temporaryTsconfig);
|
|
944
|
+
const appendLexical = (input: string): void => {
|
|
945
|
+
const spelling = path.resolve(input);
|
|
946
|
+
if (
|
|
947
|
+
spelling === currentSpelling ||
|
|
948
|
+
spelling === temporarySpelling ||
|
|
949
|
+
lexicalSeen.has(spelling)
|
|
950
|
+
) {
|
|
951
|
+
return;
|
|
698
952
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
953
|
+
lexicalSeen.add(spelling);
|
|
954
|
+
physicalSeen.add(derivationIdentity(state, input));
|
|
955
|
+
output.push(input);
|
|
956
|
+
};
|
|
957
|
+
const appendPhysical = (input: string): void => {
|
|
958
|
+
const identity = derivationIdentity(state, input);
|
|
959
|
+
if (excluded.has(identity) || physicalSeen.has(identity)) return;
|
|
960
|
+
physicalSeen.add(identity);
|
|
961
|
+
lexicalSeen.add(path.resolve(input));
|
|
962
|
+
output.push(input);
|
|
963
|
+
};
|
|
964
|
+
for (const input of selectFileDependencies(props)) appendLexical(input);
|
|
965
|
+
for (const input of selectGraphInputs(graph, state, {
|
|
966
|
+
...props,
|
|
967
|
+
complete:
|
|
968
|
+
declaresCompleteDependencies(state, props) &&
|
|
969
|
+
!isVolatileFile(state, props),
|
|
970
|
+
}))
|
|
971
|
+
appendPhysical(input);
|
|
972
|
+
// Resolution candidates, plugin dependencies, and universal host inputs
|
|
973
|
+
// preserve lexical aliases. Physical deduplication would collapse
|
|
974
|
+
// `alias/selection.cjs` into the selected target path, so a bundler would
|
|
975
|
+
// watch only the target and miss a symlink/junction retarget.
|
|
976
|
+
for (const input of selectResolutionCandidateInputs(graph, state, props))
|
|
977
|
+
appendLexical(input);
|
|
978
|
+
for (const input of selectHostInputs(props)) appendLexical(input);
|
|
702
979
|
return output;
|
|
703
980
|
}
|
|
704
981
|
|
|
982
|
+
/** Return exact host-wide descriptor/config inputs for every output file. */
|
|
983
|
+
function selectHostInputs(props: {
|
|
984
|
+
projectRoot: string;
|
|
985
|
+
result: ITtscCompilerTransformation;
|
|
986
|
+
}): string[] {
|
|
987
|
+
return props.result.type === "exception"
|
|
988
|
+
? []
|
|
989
|
+
: selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
990
|
+
}
|
|
991
|
+
|
|
705
992
|
/**
|
|
706
993
|
* Return the module-resolution paths that can supersede a currently resolved
|
|
707
994
|
* module reachable from `file`. They remain host-owned even when a plugin
|
|
@@ -1039,14 +1326,13 @@ export function createTransformResult(
|
|
|
1039
1326
|
*
|
|
1040
1327
|
* Always compares the current module's in-memory source with the generation
|
|
1041
1328
|
* snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
|
|
1042
|
-
* that comparison alone for
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
* agree on the key universe.
|
|
1329
|
+
* that comparison alone for a stable generation's first module delivery in the
|
|
1330
|
+
* current build. An incomplete generation may not take this shortcut: otherwise
|
|
1331
|
+
* a sibling output captured during a filesystem race could still be served
|
|
1332
|
+
* once. Later graph-bearing requests validate the file's derived input set and
|
|
1333
|
+
* project membership; graph-free envelopes conservatively re-hash the complete
|
|
1334
|
+
* project and out-of-walk snapshots. Any mismatch forces a complete
|
|
1335
|
+
* re-transform.
|
|
1050
1336
|
*/
|
|
1051
1337
|
function matchesCachedSource(
|
|
1052
1338
|
cached: TtscCachedProjectTransform,
|
|
@@ -1061,16 +1347,410 @@ function matchesCachedSource(
|
|
|
1061
1347
|
}
|
|
1062
1348
|
if (
|
|
1063
1349
|
buildScoped &&
|
|
1350
|
+
cached.projectSnapshotComplete === true &&
|
|
1064
1351
|
!cached.servedFiles?.has(pathIdentityKey(file, identities))
|
|
1065
1352
|
) {
|
|
1066
1353
|
return true;
|
|
1067
1354
|
}
|
|
1068
|
-
|
|
1355
|
+
if (
|
|
1356
|
+
cached.result.type !== "exception" &&
|
|
1357
|
+
cached.result.graph !== undefined &&
|
|
1358
|
+
cached.projectSnapshotComplete === true &&
|
|
1359
|
+
cached.projectDirectories !== undefined &&
|
|
1360
|
+
cached.projectMutationTracker !== undefined &&
|
|
1361
|
+
cached.hostInputMutationTracker !== undefined
|
|
1362
|
+
) {
|
|
1363
|
+
return matchesNarrowPersistentInputs(cached, file);
|
|
1364
|
+
}
|
|
1365
|
+
return matchesCompleteInputSnapshot(cached, currentKey, source);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Validate one graph-bearing cached output against only the inputs that can
|
|
1370
|
+
* affect that file. Project membership is validated once per event-loop turn,
|
|
1371
|
+
* so sibling module deliveries share one directory-metadata pass instead of
|
|
1372
|
+
* multiplying it by module count.
|
|
1373
|
+
*/
|
|
1374
|
+
function matchesNarrowPersistentInputs(
|
|
1375
|
+
cached: TtscCachedProjectTransform,
|
|
1376
|
+
file: string,
|
|
1377
|
+
): boolean {
|
|
1378
|
+
if (!matchesProjectMembership(cached)) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
const hostTracker = cached.hostInputMutationTracker;
|
|
1382
|
+
if (
|
|
1383
|
+
hostTracker === undefined ||
|
|
1384
|
+
hostTracker.failed ||
|
|
1385
|
+
hostTracker.membershipChanged
|
|
1386
|
+
) {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
const state = envelopeDerivation(cached);
|
|
1390
|
+
const hostValidation = state.hostInputValidation;
|
|
1391
|
+
if (
|
|
1392
|
+
hostValidation === undefined ||
|
|
1393
|
+
!matchesUniversalHostInputs(cached, hostValidation)
|
|
1394
|
+
) {
|
|
1395
|
+
return false;
|
|
1396
|
+
}
|
|
1397
|
+
const inputs = selectWatchInputs({
|
|
1398
|
+
file,
|
|
1399
|
+
projectRoot: cached.projectRoot,
|
|
1400
|
+
result: cached.result,
|
|
1401
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1402
|
+
});
|
|
1403
|
+
for (const input of inputs) {
|
|
1404
|
+
if (hostValidation.identities.has(derivationIdentity(state, input))) {
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
if (!matchesRecordedInput(cached, input)) {
|
|
1408
|
+
return false;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Validate universal descriptor/config inputs without re-reading them for every
|
|
1416
|
+
* module. Existing paths use the same nanosecond metadata manifest that guards
|
|
1417
|
+
* GOROOT identity memoization; missing probes are grouped by the nearest
|
|
1418
|
+
* existing directory and checked through one exact membership listing.
|
|
1419
|
+
*/
|
|
1420
|
+
function matchesUniversalHostInputs(
|
|
1421
|
+
cached: TtscCachedProjectTransform,
|
|
1422
|
+
validation: TtscHostInputValidation,
|
|
1423
|
+
): boolean {
|
|
1424
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1425
|
+
for (const entry of validation.entries.values()) {
|
|
1426
|
+
const signature = inputMetadataSignature(entry.path, filesystem);
|
|
1427
|
+
if (signature === entry.signature) continue;
|
|
1428
|
+
if (entry.strict === true) return false;
|
|
1429
|
+
if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
|
|
1430
|
+
return false;
|
|
1431
|
+
if (!matchesRecordedInput(cached, entry.path)) {
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
if (signature === undefined) return false;
|
|
1435
|
+
entry.signature = signature;
|
|
1436
|
+
}
|
|
1437
|
+
for (const [directory, names] of validation.missing) {
|
|
1438
|
+
let entries: fs.Dirent[];
|
|
1439
|
+
try {
|
|
1440
|
+
entries = filesystem.readdir(directory);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
// Only a provably absent/non-directory ancestor keeps every descendant
|
|
1443
|
+
// unreachable. Permission and transient I/O failures cannot prove that
|
|
1444
|
+
// a candidate is still missing, while replacing the proving directory
|
|
1445
|
+
// with an exact file can itself redirect module resolution.
|
|
1446
|
+
try {
|
|
1447
|
+
if (!filesystem.stat(directory).isDirectory()) return false;
|
|
1448
|
+
} catch (statError) {
|
|
1449
|
+
if (!isMissingPathError(statError)) return false;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
const identities = envelopeDerivation(cached).identityContext;
|
|
1455
|
+
const caseSensitive = identities.caseSensitive(directory);
|
|
1456
|
+
if (
|
|
1457
|
+
entries.some((entry) =>
|
|
1458
|
+
names.has(normalizeHostInputName(entry.name, caseSensitive)),
|
|
1459
|
+
)
|
|
1460
|
+
) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return true;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** True only for errors that prove a path cannot currently be traversed. */
|
|
1468
|
+
function isMissingPathError(error: unknown): boolean {
|
|
1469
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
1470
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Capture the universal-input manifest while the generation is still fresh. */
|
|
1474
|
+
function captureUniversalHostInputValidation(
|
|
1475
|
+
cached: TtscCachedProjectTransform,
|
|
1476
|
+
currentFile: string,
|
|
1477
|
+
): TtscHostInputValidation | undefined {
|
|
1478
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1479
|
+
const state = envelopeDerivation(cached);
|
|
1480
|
+
const validation: TtscHostInputValidation = {
|
|
1481
|
+
entries: new Map(),
|
|
1482
|
+
identities: new Set(),
|
|
1483
|
+
missing: new Map(),
|
|
1484
|
+
};
|
|
1485
|
+
for (const input of selectPersistentHostInputs({
|
|
1486
|
+
filesystem,
|
|
1487
|
+
projectRoot: cached.projectRoot,
|
|
1488
|
+
result: cached.result,
|
|
1489
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1490
|
+
})) {
|
|
1491
|
+
const generationHashes =
|
|
1492
|
+
cached.result.type === "exception"
|
|
1493
|
+
? undefined
|
|
1494
|
+
: cached.result.hostInputHashes;
|
|
1495
|
+
const generationRealpaths =
|
|
1496
|
+
cached.result.type === "exception"
|
|
1497
|
+
? undefined
|
|
1498
|
+
: cached.result.hostInputRealpaths;
|
|
1499
|
+
const expected = generationHashes?.[path.resolve(input)];
|
|
1500
|
+
// Every persistent universal input must carry an evaluation-time
|
|
1501
|
+
// fingerprint. If a plugin/native host cannot provide one, keep the fresh
|
|
1502
|
+
// result but decline narrow long-lived reuse.
|
|
1503
|
+
if (expected === undefined) {
|
|
1504
|
+
const current = path.resolve(currentFile);
|
|
1505
|
+
if (path.resolve(input) !== current) return undefined;
|
|
1506
|
+
// The current module may be supplied from an unsaved editor buffer. Its
|
|
1507
|
+
// generation snapshot is overlaid below from `currentSource`, so a disk
|
|
1508
|
+
// fingerprint would be both unavailable and the wrong authority.
|
|
1509
|
+
} else if (expected !== hostInputStateHash(input, filesystem)) {
|
|
1510
|
+
return undefined;
|
|
1511
|
+
}
|
|
1512
|
+
const absoluteInput = path.resolve(input);
|
|
1513
|
+
if (generationRealpaths !== undefined) {
|
|
1514
|
+
if (
|
|
1515
|
+
!Object.prototype.hasOwnProperty.call(
|
|
1516
|
+
generationRealpaths,
|
|
1517
|
+
absoluteInput,
|
|
1518
|
+
) ||
|
|
1519
|
+
!sameHostInputRealpath(
|
|
1520
|
+
generationRealpaths[absoluteInput],
|
|
1521
|
+
hostInputRealpath(input, filesystem),
|
|
1522
|
+
state.identityContext,
|
|
1523
|
+
)
|
|
1524
|
+
) {
|
|
1525
|
+
return undefined;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const identity = derivationIdentity(state, input);
|
|
1529
|
+
validation.identities.add(identity);
|
|
1530
|
+
const before = inputMetadataSignature(input, filesystem);
|
|
1531
|
+
if (!matchesRecordedInput(cached, input)) return undefined;
|
|
1532
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
1533
|
+
if (before !== after) return undefined;
|
|
1534
|
+
if (after !== undefined) {
|
|
1535
|
+
// Do not key this manifest by physical identity. A symlink/junction
|
|
1536
|
+
// spelling and its selected target deliberately share that identity,
|
|
1537
|
+
// but both lexical paths must survive so retargeting the alias is visible.
|
|
1538
|
+
validation.entries.set(path.resolve(input), {
|
|
1539
|
+
path: input,
|
|
1540
|
+
realpath: hostInputRealpath(input, filesystem),
|
|
1541
|
+
signature: after,
|
|
1542
|
+
});
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const probe = missingPathProbe(input, filesystem);
|
|
1546
|
+
if (probe.blocker !== undefined) {
|
|
1547
|
+
const blockerIdentity = derivationIdentity(state, probe.blocker);
|
|
1548
|
+
const signature = inputMetadataSignature(probe.blocker, filesystem);
|
|
1549
|
+
if (signature === undefined) return undefined;
|
|
1550
|
+
validation.identities.add(blockerIdentity);
|
|
1551
|
+
validation.entries.set(path.resolve(probe.blocker), {
|
|
1552
|
+
path: probe.blocker,
|
|
1553
|
+
realpath: hostInputRealpath(probe.blocker, filesystem),
|
|
1554
|
+
signature,
|
|
1555
|
+
strict: true,
|
|
1556
|
+
});
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
let names = validation.missing.get(probe.directory);
|
|
1560
|
+
if (names === undefined) {
|
|
1561
|
+
names = new Set<string>();
|
|
1562
|
+
validation.missing.set(probe.directory, names);
|
|
1563
|
+
}
|
|
1564
|
+
names.add(
|
|
1565
|
+
normalizeHostInputName(
|
|
1566
|
+
probe.name,
|
|
1567
|
+
state.identityContext.caseSensitive(probe.directory),
|
|
1568
|
+
),
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
state.hostInputValidation = validation;
|
|
1572
|
+
return validation;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** Metadata identity whose stability lets a generation reuse a content hash. */
|
|
1576
|
+
function inputMetadataSignature(
|
|
1577
|
+
file: string,
|
|
1578
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1579
|
+
): string | undefined {
|
|
1580
|
+
try {
|
|
1581
|
+
const link = filesystem.lstat(file);
|
|
1582
|
+
let target = link;
|
|
1583
|
+
if (link.isSymbolicLink()) {
|
|
1584
|
+
try {
|
|
1585
|
+
target = filesystem.statBigInt(file);
|
|
1586
|
+
} catch {
|
|
1587
|
+
// Keep a broken link in the existing-input manifest. Its own metadata
|
|
1588
|
+
// stays stable while the target is missing, and the first successful
|
|
1589
|
+
// stat after the target appears changes this signature. Treating it as
|
|
1590
|
+
// a plain missing path would watch/list only the link's parent, which
|
|
1591
|
+
// cannot observe a target created in another directory.
|
|
1592
|
+
return [
|
|
1593
|
+
link.dev,
|
|
1594
|
+
link.ino,
|
|
1595
|
+
link.mode,
|
|
1596
|
+
link.size,
|
|
1597
|
+
link.mtimeNs,
|
|
1598
|
+
link.ctimeNs,
|
|
1599
|
+
"missing-target",
|
|
1600
|
+
].join(":");
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return [
|
|
1604
|
+
link.dev,
|
|
1605
|
+
link.ino,
|
|
1606
|
+
link.mode,
|
|
1607
|
+
link.size,
|
|
1608
|
+
link.mtimeNs,
|
|
1609
|
+
link.ctimeNs,
|
|
1610
|
+
target.dev,
|
|
1611
|
+
target.ino,
|
|
1612
|
+
target.mode,
|
|
1613
|
+
target.size,
|
|
1614
|
+
target.mtimeNs,
|
|
1615
|
+
target.ctimeNs,
|
|
1616
|
+
].join(":");
|
|
1617
|
+
} catch {
|
|
1618
|
+
return undefined;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** Content/kind fingerprint matching the compiler host-input contract. */
|
|
1623
|
+
function hostInputStateHash(
|
|
1624
|
+
file: string,
|
|
1625
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1626
|
+
): string | null {
|
|
1627
|
+
try {
|
|
1628
|
+
return hashText(filesystem.readFile(file));
|
|
1629
|
+
} catch {
|
|
1630
|
+
try {
|
|
1631
|
+
return filesystem.stat(file).isDirectory()
|
|
1632
|
+
? hashText("ttsc:host-input:directory\0")
|
|
1633
|
+
: null;
|
|
1634
|
+
} catch {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
|
|
1641
|
+
function graphInputStateHash(
|
|
1642
|
+
file: string,
|
|
1643
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1644
|
+
): string | null {
|
|
1645
|
+
try {
|
|
1646
|
+
const bytes = filesystem.readFile(file);
|
|
1647
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
1648
|
+
const even = bytes.subarray(
|
|
1649
|
+
2,
|
|
1650
|
+
2 + Math.floor((bytes.length - 2) / 2) * 2,
|
|
1651
|
+
);
|
|
1652
|
+
return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
|
|
1653
|
+
}
|
|
1654
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
1655
|
+
const even = Buffer.from(
|
|
1656
|
+
bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2),
|
|
1657
|
+
);
|
|
1658
|
+
even.swap16();
|
|
1659
|
+
return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
|
|
1660
|
+
}
|
|
1661
|
+
const content =
|
|
1662
|
+
bytes.length >= 3 &&
|
|
1663
|
+
bytes[0] === 0xef &&
|
|
1664
|
+
bytes[1] === 0xbb &&
|
|
1665
|
+
bytes[2] === 0xbf
|
|
1666
|
+
? bytes.subarray(3)
|
|
1667
|
+
: bytes;
|
|
1668
|
+
return hashText(content);
|
|
1669
|
+
} catch {
|
|
1670
|
+
try {
|
|
1671
|
+
return filesystem.stat(file).isDirectory()
|
|
1672
|
+
? hashText("ttsc:host-input:directory\0")
|
|
1673
|
+
: null;
|
|
1674
|
+
} catch {
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** Physical target selected by a lexical host-input path. */
|
|
1681
|
+
function hostInputRealpath(
|
|
1682
|
+
file: string,
|
|
1683
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1684
|
+
): string | null {
|
|
1685
|
+
try {
|
|
1686
|
+
return filesystem.realpath(file);
|
|
1687
|
+
} catch {
|
|
1688
|
+
return null;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/** Compare two reported realpaths by filesystem identity, not Windows spelling. */
|
|
1693
|
+
function sameHostInputRealpath(
|
|
1694
|
+
left: string | null | undefined,
|
|
1695
|
+
right: string | null,
|
|
1696
|
+
identities: FilesystemPathIdentityContext,
|
|
1697
|
+
): boolean {
|
|
1698
|
+
if (left === undefined || (left === null) !== (right === null)) return false;
|
|
1699
|
+
if (left === null || right === null) return true;
|
|
1700
|
+
return (
|
|
1701
|
+
pathIdentityKey(left, identities) === pathIdentityKey(right, identities)
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/** Find one directory listing that proves an absent path is still absent. */
|
|
1706
|
+
function missingPathProbe(
|
|
1707
|
+
file: string,
|
|
1708
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1709
|
+
): {
|
|
1710
|
+
blocker?: string;
|
|
1711
|
+
directory: string;
|
|
1712
|
+
name: string;
|
|
1713
|
+
} {
|
|
1714
|
+
let child = path.resolve(file);
|
|
1715
|
+
for (;;) {
|
|
1716
|
+
const directory = path.dirname(child);
|
|
1717
|
+
try {
|
|
1718
|
+
const stats = filesystem.stat(directory);
|
|
1719
|
+
if (stats.isDirectory()) {
|
|
1720
|
+
return { directory, name: path.basename(child) };
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
blocker: directory,
|
|
1724
|
+
directory: path.dirname(directory),
|
|
1725
|
+
name: path.basename(directory),
|
|
1726
|
+
};
|
|
1727
|
+
} catch {}
|
|
1728
|
+
if (directory === child) {
|
|
1729
|
+
return { directory, name: path.basename(child) };
|
|
1730
|
+
}
|
|
1731
|
+
child = directory;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** Fall back to the historical whole-envelope validation without a graph. */
|
|
1736
|
+
function matchesCompleteInputSnapshot(
|
|
1737
|
+
cached: TtscCachedProjectTransform,
|
|
1738
|
+
currentKey: string,
|
|
1739
|
+
source: string,
|
|
1740
|
+
): boolean {
|
|
1741
|
+
if (cached.projectSnapshotComplete !== true) {
|
|
1742
|
+
return false;
|
|
1743
|
+
}
|
|
1744
|
+
const current = collectProjectInputSnapshot(
|
|
1069
1745
|
cached.projectRoot,
|
|
1070
|
-
|
|
1746
|
+
envelopeDerivation(cached).identityContext,
|
|
1747
|
+
resultFilesystem(cached.result),
|
|
1071
1748
|
);
|
|
1072
|
-
|
|
1073
|
-
|
|
1749
|
+
if (!current.complete) {
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1752
|
+
current.hashes[currentKey] = hashText(source);
|
|
1753
|
+
if (!sameHashes(cached.inputHashes, current.hashes)) {
|
|
1074
1754
|
return false;
|
|
1075
1755
|
}
|
|
1076
1756
|
// Re-hash the out-of-walk inputs the compiler reported for this generation
|
|
@@ -1082,14 +1762,178 @@ function matchesCachedSource(
|
|
|
1082
1762
|
// requires a tsconfig or package manifest change, both of which the project
|
|
1083
1763
|
// walk above already detects.
|
|
1084
1764
|
const externalHashes = cached.externalInputHashes ?? {};
|
|
1085
|
-
return
|
|
1086
|
-
externalHashes,
|
|
1087
|
-
|
|
1088
|
-
cached.externalInputPaths ?? Object.keys(externalHashes),
|
|
1089
|
-
),
|
|
1765
|
+
return (
|
|
1766
|
+
sameHashes(externalHashes, collectCachedExternalInputHashes(cached)) &&
|
|
1767
|
+
matchesExternalInputRealpaths(cached)
|
|
1090
1768
|
);
|
|
1091
1769
|
}
|
|
1092
1770
|
|
|
1771
|
+
/** Re-check graph-owned physical identities in complete-snapshot fallback. */
|
|
1772
|
+
function matchesExternalInputRealpaths(
|
|
1773
|
+
cached: TtscCachedProjectTransform,
|
|
1774
|
+
): boolean {
|
|
1775
|
+
const expected = cached.externalInputRealpaths;
|
|
1776
|
+
if (expected === undefined || Object.keys(expected).length === 0) return true;
|
|
1777
|
+
const state = envelopeDerivation(cached);
|
|
1778
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1779
|
+
for (const input of cached.externalInputPaths ?? []) {
|
|
1780
|
+
const identity = derivationIdentity(state, input);
|
|
1781
|
+
if (!Object.prototype.hasOwnProperty.call(expected, identity)) continue;
|
|
1782
|
+
if (
|
|
1783
|
+
!sameHostInputRealpath(
|
|
1784
|
+
expected[identity],
|
|
1785
|
+
hostInputRealpath(input, filesystem),
|
|
1786
|
+
state.identityContext,
|
|
1787
|
+
)
|
|
1788
|
+
) {
|
|
1789
|
+
return false;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return true;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Capture external-input hashes without attaching post-compile state to an
|
|
1797
|
+
* earlier graph. Graph members must carry compiler-time proof and still match
|
|
1798
|
+
* it now; plugin-declared dependency-only paths retain the historical
|
|
1799
|
+
* post-compile snapshot because their own protocol does not claim generation
|
|
1800
|
+
* fingerprints.
|
|
1801
|
+
*/
|
|
1802
|
+
function captureExternalInputSnapshot(
|
|
1803
|
+
cached: TtscCachedProjectTransform,
|
|
1804
|
+
paths: readonly string[],
|
|
1805
|
+
): {
|
|
1806
|
+
complete: boolean;
|
|
1807
|
+
hashes: Record<string, string>;
|
|
1808
|
+
realpaths: Record<string, string | null>;
|
|
1809
|
+
} {
|
|
1810
|
+
const state = envelopeDerivation(cached);
|
|
1811
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1812
|
+
const graph = envelopeGraphIndexes(state, cached);
|
|
1813
|
+
const hashes: Record<string, string> = {};
|
|
1814
|
+
const realpaths: Record<string, string | null> = {};
|
|
1815
|
+
let complete = true;
|
|
1816
|
+
for (const input of paths) {
|
|
1817
|
+
const identity = derivationIdentity(state, input);
|
|
1818
|
+
if (graph.members.has(identity)) {
|
|
1819
|
+
const proof = graph.inputProofs.get(identity);
|
|
1820
|
+
if (proof === undefined || graph.inputProofConflicts.has(identity)) {
|
|
1821
|
+
complete = false;
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
const currentHash = graphInputStateHash(input, filesystem);
|
|
1825
|
+
if (
|
|
1826
|
+
currentHash !== proof.hash ||
|
|
1827
|
+
!sameHostInputRealpath(
|
|
1828
|
+
proof.realpath,
|
|
1829
|
+
hostInputRealpath(input, filesystem),
|
|
1830
|
+
state.identityContext,
|
|
1831
|
+
)
|
|
1832
|
+
) {
|
|
1833
|
+
complete = false;
|
|
1834
|
+
}
|
|
1835
|
+
hashes[identity] = proof.hash ?? "missing";
|
|
1836
|
+
realpaths[identity] = proof.realpath;
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
hashes[identity] = hostInputStateHash(input, filesystem) ?? "missing";
|
|
1840
|
+
}
|
|
1841
|
+
return { complete, hashes, realpaths };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Verify every graph member still has the state read by the compiler. */
|
|
1845
|
+
function matchesCompilerGraphInputProofs(
|
|
1846
|
+
cached: TtscCachedProjectTransform,
|
|
1847
|
+
): boolean {
|
|
1848
|
+
if (
|
|
1849
|
+
cached.result.type === "exception" ||
|
|
1850
|
+
cached.result.graph === undefined ||
|
|
1851
|
+
(cached.result.graph.inputHashes === undefined &&
|
|
1852
|
+
cached.result.graph.inputRealpaths === undefined)
|
|
1853
|
+
) {
|
|
1854
|
+
// Legacy sidecars remain compatible for ordinary in-project graphs. Their
|
|
1855
|
+
// out-of-walk members are still rejected by captureExternalInputSnapshot,
|
|
1856
|
+
// where a post-compile snapshot cannot prove the compiler's generation.
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
const state = envelopeDerivation(cached);
|
|
1860
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1861
|
+
const graph = envelopeGraphIndexes(state, cached);
|
|
1862
|
+
if (
|
|
1863
|
+
graph.inputProofConflicts.size !== 0 ||
|
|
1864
|
+
graph.inputProofs.size !== graph.members.size
|
|
1865
|
+
) {
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
for (const identity of graph.members) {
|
|
1869
|
+
const proof = graph.inputProofs.get(identity);
|
|
1870
|
+
if (
|
|
1871
|
+
proof === undefined ||
|
|
1872
|
+
graphInputStateHash(proof.path, filesystem) !== proof.hash ||
|
|
1873
|
+
!sameHostInputRealpath(
|
|
1874
|
+
proof.realpath,
|
|
1875
|
+
hostInputRealpath(proof.path, filesystem),
|
|
1876
|
+
state.identityContext,
|
|
1877
|
+
)
|
|
1878
|
+
) {
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return true;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/** Compare one derived input with the snapshot that owned it at generation. */
|
|
1886
|
+
function matchesRecordedInput(
|
|
1887
|
+
cached: TtscCachedProjectTransform,
|
|
1888
|
+
input: string,
|
|
1889
|
+
): boolean {
|
|
1890
|
+
const state = envelopeDerivation(cached);
|
|
1891
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1892
|
+
const projectKey = toProjectKey(
|
|
1893
|
+
cached.projectRoot,
|
|
1894
|
+
input,
|
|
1895
|
+
state.identityContext,
|
|
1896
|
+
);
|
|
1897
|
+
const projectHash = Object.prototype.hasOwnProperty.call(
|
|
1898
|
+
cached.inputHashes,
|
|
1899
|
+
projectKey,
|
|
1900
|
+
)
|
|
1901
|
+
? cached.inputHashes[projectKey]
|
|
1902
|
+
: undefined;
|
|
1903
|
+
const identity = derivationIdentity(state, input);
|
|
1904
|
+
const externalHash = (cached.externalInputHashes ?? {})[identity];
|
|
1905
|
+
const externalRealpaths = cached.externalInputRealpaths;
|
|
1906
|
+
const graphInput =
|
|
1907
|
+
externalRealpaths !== undefined &&
|
|
1908
|
+
Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
|
|
1909
|
+
if (
|
|
1910
|
+
externalRealpaths !== undefined &&
|
|
1911
|
+
Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
|
|
1912
|
+
!sameHostInputRealpath(
|
|
1913
|
+
externalRealpaths[identity],
|
|
1914
|
+
hostInputRealpath(input, filesystem),
|
|
1915
|
+
state.identityContext,
|
|
1916
|
+
)
|
|
1917
|
+
) {
|
|
1918
|
+
return false;
|
|
1919
|
+
}
|
|
1920
|
+
// Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
|
|
1921
|
+
// alias can point back into the walked project, where the physical target's
|
|
1922
|
+
// project hash is a different authority (and graph text uses BOM decoding).
|
|
1923
|
+
const recorded = externalHash ?? projectHash;
|
|
1924
|
+
if (recorded === undefined) {
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
try {
|
|
1928
|
+
const current = graphInput
|
|
1929
|
+
? graphInputStateHash(input, filesystem)
|
|
1930
|
+
: hostInputStateHash(input, filesystem);
|
|
1931
|
+
return recorded === (current ?? "missing");
|
|
1932
|
+
} catch {
|
|
1933
|
+
return recorded === "missing";
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1093
1937
|
/** Record a successfully selected module as delivered by this generation. */
|
|
1094
1938
|
function markCachedSourceServed(
|
|
1095
1939
|
cached: TtscCachedProjectTransform,
|
|
@@ -1100,32 +1944,6 @@ function markCachedSourceServed(
|
|
|
1100
1944
|
);
|
|
1101
1945
|
}
|
|
1102
1946
|
|
|
1103
|
-
/**
|
|
1104
|
-
* Build the input-hash snapshot stored alongside a fresh compiler result.
|
|
1105
|
-
*
|
|
1106
|
-
* Hashes every file under the project directory (the exact universe
|
|
1107
|
-
* {@link matchesCachedSource} re-hashes to validate), then overlays the
|
|
1108
|
-
* in-memory source for the module that triggered the compile so unsaved editor
|
|
1109
|
-
* content is captured correctly.
|
|
1110
|
-
*
|
|
1111
|
-
* Only the project's own files are hashed. Out-of-walk program inputs the
|
|
1112
|
-
* compiler also read (`node_modules` declarations, sibling-package sources) are
|
|
1113
|
-
* deliberately excluded: the validator never reproduces those keys, so keying
|
|
1114
|
-
* them here would make every snapshot comparison fail and the cache never hit.
|
|
1115
|
-
*/
|
|
1116
|
-
function collectInputHashes(props: {
|
|
1117
|
-
currentFile: string;
|
|
1118
|
-
currentSource: string;
|
|
1119
|
-
projectRoot: string;
|
|
1120
|
-
}): Record<string, string> {
|
|
1121
|
-
const identities = createHostPathIdentityContext();
|
|
1122
|
-
const hashes = collectProjectInputHashes(props.projectRoot, identities);
|
|
1123
|
-
// Overlay the in-memory source so unsaved edits invalidate the cache.
|
|
1124
|
-
hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
|
|
1125
|
-
hashText(props.currentSource);
|
|
1126
|
-
return hashes;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
1947
|
/**
|
|
1130
1948
|
* Hash every input file under `projectRoot` (the same walk universe
|
|
1131
1949
|
* {@link matchesCachedSource} validates against), keyed by project-relative
|
|
@@ -1135,19 +1953,51 @@ function collectInputHashes(props: {
|
|
|
1135
1953
|
export function collectProjectInputHashes(
|
|
1136
1954
|
projectRoot: string,
|
|
1137
1955
|
identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
1956
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1138
1957
|
): Record<string, string> {
|
|
1958
|
+
return collectProjectInputSnapshot(projectRoot, identities, filesystem)
|
|
1959
|
+
.hashes;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/** Hash project files and snapshot the directory topology in one walk. */
|
|
1963
|
+
function collectProjectInputSnapshot(
|
|
1964
|
+
projectRoot: string,
|
|
1965
|
+
identities: FilesystemPathIdentityContext,
|
|
1966
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1967
|
+
): {
|
|
1968
|
+
complete: boolean;
|
|
1969
|
+
fileSignatures: Record<string, string>;
|
|
1970
|
+
hashes: Record<string, string>;
|
|
1971
|
+
projectDirectories: TtscProjectDirectorySnapshot[];
|
|
1972
|
+
} {
|
|
1139
1973
|
const hashes: Record<string, string> = {};
|
|
1140
|
-
|
|
1974
|
+
const fileSignatures: Record<string, string> = {};
|
|
1975
|
+
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
1976
|
+
let complete = walked.complete;
|
|
1977
|
+
for (const file of walked.files) {
|
|
1141
1978
|
try {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
);
|
|
1979
|
+
const before = inputMetadataSignature(file, filesystem);
|
|
1980
|
+
const contents = filesystem.readFile(file);
|
|
1981
|
+
const after = inputMetadataSignature(file, filesystem);
|
|
1982
|
+
const key = toProjectKey(projectRoot, file, identities);
|
|
1983
|
+
hashes[key] = hashText(contents);
|
|
1984
|
+
if (before === undefined || after === undefined || before !== after) {
|
|
1985
|
+
complete = false;
|
|
1986
|
+
} else {
|
|
1987
|
+
fileSignatures[key] = after;
|
|
1988
|
+
}
|
|
1145
1989
|
} catch {
|
|
1146
1990
|
// File watchers may observe a transform while another process is moving
|
|
1147
1991
|
// or deleting files. The missing key invalidates older cache entries.
|
|
1992
|
+
complete = false;
|
|
1148
1993
|
}
|
|
1149
1994
|
}
|
|
1150
|
-
return
|
|
1995
|
+
return {
|
|
1996
|
+
complete,
|
|
1997
|
+
fileSignatures,
|
|
1998
|
+
hashes,
|
|
1999
|
+
projectDirectories: walked.directories,
|
|
2000
|
+
};
|
|
1151
2001
|
}
|
|
1152
2002
|
|
|
1153
2003
|
/**
|
|
@@ -1158,17 +2008,46 @@ export function collectProjectInputHashes(
|
|
|
1158
2008
|
* unbounded call-stack depth on deep project trees. The result is sorted so
|
|
1159
2009
|
* that hash comparisons are deterministic across OS-level directory orderings.
|
|
1160
2010
|
*/
|
|
1161
|
-
function
|
|
1162
|
-
|
|
2011
|
+
function walkProjectInputs(
|
|
2012
|
+
root: string,
|
|
2013
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2014
|
+
): {
|
|
2015
|
+
complete: boolean;
|
|
2016
|
+
directories: TtscProjectDirectorySnapshot[];
|
|
2017
|
+
files: string[];
|
|
2018
|
+
} {
|
|
2019
|
+
let complete = true;
|
|
2020
|
+
const directories: TtscProjectDirectorySnapshot[] = [];
|
|
2021
|
+
const files: string[] = [];
|
|
1163
2022
|
const stack = [root];
|
|
1164
2023
|
while (stack.length !== 0) {
|
|
1165
2024
|
const current = stack.pop()!;
|
|
2025
|
+
const before = projectDirectorySignature(current, filesystem);
|
|
2026
|
+
if (before === undefined) {
|
|
2027
|
+
complete = false;
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
1166
2030
|
let entries: fs.Dirent[];
|
|
1167
2031
|
try {
|
|
1168
|
-
entries =
|
|
2032
|
+
entries = filesystem.readdir(current);
|
|
1169
2033
|
} catch {
|
|
2034
|
+
complete = false;
|
|
1170
2035
|
continue;
|
|
1171
2036
|
}
|
|
2037
|
+
const after = projectDirectorySignature(current, filesystem);
|
|
2038
|
+
if (after === undefined || before !== after) {
|
|
2039
|
+
complete = false;
|
|
2040
|
+
}
|
|
2041
|
+
directories.push({
|
|
2042
|
+
path: current,
|
|
2043
|
+
// If membership moved during enumeration, force the next delivery to
|
|
2044
|
+
// replace this generation instead of blessing a torn directory/file
|
|
2045
|
+
// snapshot as stable.
|
|
2046
|
+
signature:
|
|
2047
|
+
after !== undefined && before === after
|
|
2048
|
+
? after
|
|
2049
|
+
: `unstable:${before}:${after ?? "missing"}`,
|
|
2050
|
+
});
|
|
1172
2051
|
for (const entry of entries) {
|
|
1173
2052
|
if (isIgnoredProjectDirectory(entry.name)) {
|
|
1174
2053
|
continue;
|
|
@@ -1177,34 +2056,408 @@ function listProjectInputFiles(root: string): string[] {
|
|
|
1177
2056
|
if (entry.isDirectory()) {
|
|
1178
2057
|
stack.push(file);
|
|
1179
2058
|
} else if (entry.isFile()) {
|
|
1180
|
-
|
|
2059
|
+
files.push(file);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
2064
|
+
files.sort();
|
|
2065
|
+
return { complete, directories, files };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** Return a cheap identity for one directory's immediate membership. */
|
|
2069
|
+
function projectDirectorySignature(
|
|
2070
|
+
directory: string,
|
|
2071
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2072
|
+
): string | undefined {
|
|
2073
|
+
try {
|
|
2074
|
+
const stats = filesystem.statBigInt(directory);
|
|
2075
|
+
if (!stats.isDirectory()) {
|
|
2076
|
+
return undefined;
|
|
2077
|
+
}
|
|
2078
|
+
return [
|
|
2079
|
+
stats.dev,
|
|
2080
|
+
stats.ino,
|
|
2081
|
+
stats.mode,
|
|
2082
|
+
stats.size,
|
|
2083
|
+
stats.mtimeNs,
|
|
2084
|
+
stats.ctimeNs,
|
|
2085
|
+
].join(":");
|
|
2086
|
+
} catch {
|
|
2087
|
+
return undefined;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/** Compare two deterministic project-directory membership snapshots. */
|
|
2092
|
+
function sameProjectDirectories(
|
|
2093
|
+
left: readonly TtscProjectDirectorySnapshot[],
|
|
2094
|
+
right: readonly TtscProjectDirectorySnapshot[],
|
|
2095
|
+
): boolean {
|
|
2096
|
+
return (
|
|
2097
|
+
left.length === right.length &&
|
|
2098
|
+
left.every(
|
|
2099
|
+
(directory, index) =>
|
|
2100
|
+
directory.path === right[index]?.path &&
|
|
2101
|
+
directory.signature === right[index]?.signature,
|
|
2102
|
+
)
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** Watch every walked directory for membership changes after generation. */
|
|
2107
|
+
async function createProjectMutationTracker(
|
|
2108
|
+
directories: readonly TtscProjectDirectorySnapshot[],
|
|
2109
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2110
|
+
): Promise<TtscProjectMutationTracker> {
|
|
2111
|
+
const tracker: TtscProjectMutationTracker = {
|
|
2112
|
+
close: () => undefined,
|
|
2113
|
+
failed: false,
|
|
2114
|
+
membershipChanged: false,
|
|
2115
|
+
};
|
|
2116
|
+
if (process.platform === "win32") {
|
|
2117
|
+
await registerWindowsProjectMutationTracker(
|
|
2118
|
+
tracker,
|
|
2119
|
+
directories.map((directory) => ({ directory: directory.path })),
|
|
2120
|
+
false,
|
|
2121
|
+
filesystem,
|
|
2122
|
+
);
|
|
2123
|
+
return tracker;
|
|
2124
|
+
}
|
|
2125
|
+
const watchers: fs.FSWatcher[] = [];
|
|
2126
|
+
tracker.close = () => {
|
|
2127
|
+
for (const watcher of watchers) watcher.close();
|
|
2128
|
+
watchers.length = 0;
|
|
2129
|
+
};
|
|
2130
|
+
for (const directory of directories) {
|
|
2131
|
+
try {
|
|
2132
|
+
const watcher = fs.watch(
|
|
2133
|
+
directory.path,
|
|
2134
|
+
{ persistent: false },
|
|
2135
|
+
(eventType) => {
|
|
2136
|
+
if (eventType === "rename") tracker.membershipChanged = true;
|
|
2137
|
+
},
|
|
2138
|
+
);
|
|
2139
|
+
watcher.on("error", () => {
|
|
2140
|
+
tracker.failed = true;
|
|
2141
|
+
});
|
|
2142
|
+
watchers.push(watcher);
|
|
2143
|
+
} catch {
|
|
2144
|
+
tracker.failed = true;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
return tracker;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** Watch exact universal inputs, or their nearest existing parent if missing. */
|
|
2151
|
+
async function createHostInputMutationTracker(
|
|
2152
|
+
inputs: readonly string[],
|
|
2153
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2154
|
+
): Promise<TtscProjectMutationTracker> {
|
|
2155
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
2156
|
+
const namesByDirectory = new Map<
|
|
2157
|
+
string,
|
|
2158
|
+
{ directory: string; names: Set<string> }
|
|
2159
|
+
>();
|
|
2160
|
+
for (const input of inputs) {
|
|
2161
|
+
const absolute = path.resolve(input);
|
|
2162
|
+
const probe = filesystem.exists(absolute)
|
|
2163
|
+
? { directory: path.dirname(absolute), name: path.basename(absolute) }
|
|
2164
|
+
: missingPathProbe(absolute, filesystem);
|
|
2165
|
+
const directoryIdentity = identities.resolve(probe.directory);
|
|
2166
|
+
let location = namesByDirectory.get(directoryIdentity.key);
|
|
2167
|
+
if (location === undefined) {
|
|
2168
|
+
location = {
|
|
2169
|
+
directory: directoryIdentity.path,
|
|
2170
|
+
names: new Set<string>(),
|
|
2171
|
+
};
|
|
2172
|
+
namesByDirectory.set(directoryIdentity.key, location);
|
|
2173
|
+
}
|
|
2174
|
+
location.names.add(
|
|
2175
|
+
normalizeHostInputName(
|
|
2176
|
+
probe.name,
|
|
2177
|
+
identities.caseSensitive(directoryIdentity.path),
|
|
2178
|
+
),
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
const locations = [...namesByDirectory.values()].map((location) => ({
|
|
2182
|
+
directory: location.directory,
|
|
2183
|
+
names: [...location.names],
|
|
2184
|
+
}));
|
|
2185
|
+
const tracker: TtscProjectMutationTracker = {
|
|
2186
|
+
close: () => undefined,
|
|
2187
|
+
failed: false,
|
|
2188
|
+
membershipChanged: false,
|
|
2189
|
+
};
|
|
2190
|
+
if (process.platform === "win32") {
|
|
2191
|
+
await registerWindowsProjectMutationTracker(
|
|
2192
|
+
tracker,
|
|
2193
|
+
locations,
|
|
2194
|
+
true,
|
|
2195
|
+
filesystem,
|
|
2196
|
+
);
|
|
2197
|
+
return tracker;
|
|
2198
|
+
}
|
|
2199
|
+
const watchers: fs.FSWatcher[] = [];
|
|
2200
|
+
tracker.close = () => {
|
|
2201
|
+
for (const watcher of watchers) watcher.close();
|
|
2202
|
+
watchers.length = 0;
|
|
2203
|
+
};
|
|
2204
|
+
for (const location of locations) {
|
|
2205
|
+
try {
|
|
2206
|
+
const names = new Set(location.names);
|
|
2207
|
+
const caseSensitive = identities.caseSensitive(location.directory);
|
|
2208
|
+
const watcher = fs.watch(
|
|
2209
|
+
location.directory,
|
|
2210
|
+
{ persistent: false },
|
|
2211
|
+
(_eventType, filename) => {
|
|
2212
|
+
const reported =
|
|
2213
|
+
filename === null
|
|
2214
|
+
? null
|
|
2215
|
+
: normalizeHostInputName(String(filename), caseSensitive);
|
|
2216
|
+
if (reported === null || names.has(reported)) {
|
|
2217
|
+
tracker.membershipChanged = true;
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
);
|
|
2221
|
+
watcher.on("error", () => {
|
|
2222
|
+
tracker.failed = true;
|
|
2223
|
+
});
|
|
2224
|
+
watchers.push(watcher);
|
|
2225
|
+
} catch {
|
|
2226
|
+
tracker.failed = true;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
return tracker;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
interface WindowsProjectMutationBroker {
|
|
2233
|
+
child: ChildProcess;
|
|
2234
|
+
nextId: number;
|
|
2235
|
+
pendingRegistrations: number;
|
|
2236
|
+
trackers: Map<
|
|
2237
|
+
number,
|
|
2238
|
+
{
|
|
2239
|
+
ready: () => void;
|
|
2240
|
+
tracker: TtscProjectMutationTracker;
|
|
2241
|
+
}
|
|
2242
|
+
>;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
let windowsProjectMutationBroker: WindowsProjectMutationBroker | undefined;
|
|
2246
|
+
|
|
2247
|
+
interface WindowsMutationLocation {
|
|
2248
|
+
directory: string;
|
|
2249
|
+
names?: string[];
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* Register directory watches in an isolated Windows process.
|
|
2254
|
+
*
|
|
2255
|
+
* Node's Windows fs-event backend can assert in native code when a watched
|
|
2256
|
+
* temporary tree is deleted. Isolation turns that unrecoverable process abort
|
|
2257
|
+
* into an ordinary broker exit and a conservative cache miss in the host.
|
|
2258
|
+
*/
|
|
2259
|
+
async function registerWindowsProjectMutationTracker(
|
|
2260
|
+
tracker: TtscProjectMutationTracker,
|
|
2261
|
+
locations: readonly WindowsMutationLocation[],
|
|
2262
|
+
allEvents: boolean,
|
|
2263
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2264
|
+
): Promise<void> {
|
|
2265
|
+
const broker = getWindowsProjectMutationBroker();
|
|
2266
|
+
const normalized = locations.map((location) => {
|
|
2267
|
+
let directory: string;
|
|
2268
|
+
try {
|
|
2269
|
+
directory = filesystem.realpath(location.directory);
|
|
2270
|
+
} catch {
|
|
2271
|
+
directory = path.resolve(location.directory);
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
directory,
|
|
2275
|
+
...(location.names === undefined ? {} : { names: location.names }),
|
|
2276
|
+
};
|
|
2277
|
+
});
|
|
2278
|
+
broker.pendingRegistrations += 1;
|
|
2279
|
+
broker.child.ref();
|
|
2280
|
+
broker.child.channel?.ref();
|
|
2281
|
+
const id = broker.nextId++;
|
|
2282
|
+
let resolveReady!: () => void;
|
|
2283
|
+
const ready = new Promise<void>((resolve) => {
|
|
2284
|
+
resolveReady = resolve;
|
|
2285
|
+
});
|
|
2286
|
+
broker.trackers.set(id, { ready: resolveReady, tracker });
|
|
2287
|
+
tracker.close = () => {
|
|
2288
|
+
const active = broker.trackers.get(id);
|
|
2289
|
+
if (active === undefined) return;
|
|
2290
|
+
broker.trackers.delete(id);
|
|
2291
|
+
active.ready();
|
|
2292
|
+
broker.child.send?.({ id, op: "remove" });
|
|
2293
|
+
if (broker.trackers.size === 0) {
|
|
2294
|
+
broker.child.disconnect?.();
|
|
2295
|
+
broker.child.kill();
|
|
2296
|
+
if (windowsProjectMutationBroker === broker) {
|
|
2297
|
+
windowsProjectMutationBroker = undefined;
|
|
1181
2298
|
}
|
|
1182
2299
|
}
|
|
2300
|
+
};
|
|
2301
|
+
broker.child.send?.({
|
|
2302
|
+
allEvents,
|
|
2303
|
+
locations: normalized,
|
|
2304
|
+
id,
|
|
2305
|
+
op: "add",
|
|
2306
|
+
});
|
|
2307
|
+
try {
|
|
2308
|
+
await ready;
|
|
2309
|
+
} finally {
|
|
2310
|
+
broker.pendingRegistrations -= 1;
|
|
2311
|
+
if (broker.pendingRegistrations === 0) {
|
|
2312
|
+
broker.child.unref();
|
|
2313
|
+
broker.child.channel?.unref();
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
2319
|
+
if (windowsProjectMutationBroker !== undefined) {
|
|
2320
|
+
return windowsProjectMutationBroker;
|
|
1183
2321
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
2322
|
+
const child = spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
|
|
2323
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
2324
|
+
windowsHide: true,
|
|
2325
|
+
});
|
|
2326
|
+
const broker: WindowsProjectMutationBroker = {
|
|
2327
|
+
child,
|
|
2328
|
+
nextId: 1,
|
|
2329
|
+
pendingRegistrations: 0,
|
|
2330
|
+
trackers: new Map(),
|
|
2331
|
+
};
|
|
2332
|
+
const fail = (): void => {
|
|
2333
|
+
for (const registration of broker.trackers.values()) {
|
|
2334
|
+
registration.tracker.failed = true;
|
|
2335
|
+
registration.ready();
|
|
2336
|
+
}
|
|
2337
|
+
broker.trackers.clear();
|
|
2338
|
+
if (windowsProjectMutationBroker === broker) {
|
|
2339
|
+
windowsProjectMutationBroker = undefined;
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
child.on("error", fail);
|
|
2343
|
+
child.on("exit", fail);
|
|
2344
|
+
child.on("message", (message: unknown) => {
|
|
2345
|
+
if (message === null || typeof message !== "object") return;
|
|
2346
|
+
const record = message as {
|
|
2347
|
+
failed?: boolean;
|
|
2348
|
+
id?: number;
|
|
2349
|
+
ready?: boolean;
|
|
2350
|
+
};
|
|
2351
|
+
if (typeof record.id !== "number") return;
|
|
2352
|
+
const registration = broker.trackers.get(record.id);
|
|
2353
|
+
if (registration === undefined) return;
|
|
2354
|
+
if (record.failed === true) registration.tracker.failed = true;
|
|
2355
|
+
if (record.ready === true) registration.ready();
|
|
2356
|
+
if (record.ready !== true && record.failed !== true) {
|
|
2357
|
+
registration.tracker.membershipChanged = true;
|
|
2358
|
+
}
|
|
2359
|
+
});
|
|
2360
|
+
windowsProjectMutationBroker = broker;
|
|
2361
|
+
return broker;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
2365
|
+
'const fs = require("node:fs");',
|
|
2366
|
+
"const groups = new Map();",
|
|
2367
|
+
'process.on("message", (message) => {',
|
|
2368
|
+
' if (message.op === "remove") {',
|
|
2369
|
+
" close(message.id);",
|
|
2370
|
+
" return;",
|
|
2371
|
+
" }",
|
|
2372
|
+
' if (message.op !== "add") return;',
|
|
2373
|
+
" const watchers = [];",
|
|
2374
|
+
" let failed = false;",
|
|
2375
|
+
" for (const location of message.locations) {",
|
|
2376
|
+
" try {",
|
|
2377
|
+
" const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
|
|
2378
|
+
" const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
|
|
2379
|
+
" const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
|
|
2380
|
+
' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
|
|
2381
|
+
" });",
|
|
2382
|
+
' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
|
|
2383
|
+
" watchers.push(watcher);",
|
|
2384
|
+
" } catch {",
|
|
2385
|
+
" failed = true;",
|
|
2386
|
+
" }",
|
|
2387
|
+
" }",
|
|
2388
|
+
" groups.set(message.id, watchers);",
|
|
2389
|
+
" process.send?.({ failed, id: message.id, ready: true });",
|
|
2390
|
+
"});",
|
|
2391
|
+
'process.on("disconnect", () => {',
|
|
2392
|
+
" for (const id of groups.keys()) close(id);",
|
|
2393
|
+
" process.exit(0);",
|
|
2394
|
+
"});",
|
|
2395
|
+
"function close(id) {",
|
|
2396
|
+
" for (const watcher of groups.get(id) ?? []) watcher.close();",
|
|
2397
|
+
" groups.delete(id);",
|
|
2398
|
+
"}",
|
|
2399
|
+
].join("\n");
|
|
2400
|
+
|
|
2401
|
+
/** Report whether live directory notifications preserve project membership. */
|
|
2402
|
+
function matchesProjectMembership(cached: TtscCachedProjectTransform): boolean {
|
|
2403
|
+
const tracker = cached.projectMutationTracker;
|
|
2404
|
+
return (
|
|
2405
|
+
tracker !== undefined &&
|
|
2406
|
+
tracker.failed === false &&
|
|
2407
|
+
tracker.membershipChanged === false
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Yield once before persistent validation so synchronous edits can reach the
|
|
2413
|
+
* directory watchers that guard membership. Concurrent sibling deliveries share
|
|
2414
|
+
* the same barrier.
|
|
2415
|
+
*/
|
|
2416
|
+
async function settleProjectMutationEvents(
|
|
2417
|
+
cached: TtscCachedProjectTransform,
|
|
2418
|
+
): Promise<void> {
|
|
2419
|
+
const trackers = [
|
|
2420
|
+
cached.projectMutationTracker,
|
|
2421
|
+
cached.hostInputMutationTracker,
|
|
2422
|
+
].filter(
|
|
2423
|
+
(tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
|
|
2424
|
+
);
|
|
2425
|
+
await Promise.all(
|
|
2426
|
+
trackers.map(async (tracker) => {
|
|
2427
|
+
tracker.settle ??= new Promise<void>((resolve) => {
|
|
2428
|
+
const settled = () => {
|
|
2429
|
+
tracker.settle = undefined;
|
|
2430
|
+
resolve();
|
|
2431
|
+
};
|
|
2432
|
+
if (process.platform === "win32") setTimeout(settled, 10);
|
|
2433
|
+
else setImmediate(settled);
|
|
2434
|
+
});
|
|
2435
|
+
await tracker.settle;
|
|
2436
|
+
}),
|
|
2437
|
+
);
|
|
1186
2438
|
}
|
|
1187
2439
|
|
|
1188
2440
|
/**
|
|
1189
2441
|
* Report whether an absolute `file` belongs to the project walk universe of
|
|
1190
2442
|
* `root`: it lies under `root`, every component exists without traversing a
|
|
1191
2443
|
* symbolic link, the leaf is a regular file, and no segment of the relative
|
|
1192
|
-
* path is ignored. The predicate mirrors {@link
|
|
1193
|
-
*
|
|
2444
|
+
* path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
|
|
2445
|
+
* "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
|
|
1194
2446
|
* Missing paths and files reached through symlinks or Windows junctions are
|
|
1195
2447
|
* out-of-walk inputs that only the reference graph can prove relevant.
|
|
1196
2448
|
*/
|
|
1197
2449
|
export function isProjectWalkPath(
|
|
1198
2450
|
root: string,
|
|
1199
2451
|
file: string,
|
|
1200
|
-
|
|
2452
|
+
_identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
2453
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1201
2454
|
): boolean {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
const
|
|
1207
|
-
const relative =
|
|
2455
|
+
// Walk membership is lexical. Resolving `file` to physical identity first
|
|
2456
|
+
// would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
|
|
2457
|
+
// symlink segment from the lstat loop below, and falsely claim the project
|
|
2458
|
+
// walk hashed a path it deliberately never followed.
|
|
2459
|
+
const resolvedRoot = path.resolve(root);
|
|
2460
|
+
const relative = path.relative(resolvedRoot, path.resolve(file));
|
|
1208
2461
|
if (
|
|
1209
2462
|
relative.length === 0 ||
|
|
1210
2463
|
relative === ".." ||
|
|
@@ -1217,12 +2470,12 @@ export function isProjectWalkPath(
|
|
|
1217
2470
|
if (segments.some(isIgnoredProjectDirectory)) {
|
|
1218
2471
|
return false;
|
|
1219
2472
|
}
|
|
1220
|
-
let current =
|
|
2473
|
+
let current = resolvedRoot;
|
|
1221
2474
|
for (let index = 0; index < segments.length; ++index) {
|
|
1222
2475
|
current = path.join(current, segments[index]!);
|
|
1223
|
-
let stats: fs.
|
|
2476
|
+
let stats: fs.BigIntStats;
|
|
1224
2477
|
try {
|
|
1225
|
-
stats =
|
|
2478
|
+
stats = filesystem.lstat(current);
|
|
1226
2479
|
} catch {
|
|
1227
2480
|
return false;
|
|
1228
2481
|
}
|
|
@@ -1239,28 +2492,46 @@ export function isProjectWalkPath(
|
|
|
1239
2492
|
|
|
1240
2493
|
/**
|
|
1241
2494
|
* Hash a list of absolute out-of-walk input paths: content SHA-256 for a
|
|
1242
|
-
* readable file, a stable
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
2495
|
+
* readable file, a stable directory-kind digest for a directory candidate, and
|
|
2496
|
+
* a stable `missing` marker otherwise. Keys use filesystem identity so
|
|
2497
|
+
* case-only spellings share one snapshot entry, while reads retain the original
|
|
2498
|
+
* path supplied by the compiler. The marker is state, not an error — a recorded
|
|
2499
|
+
* input disappearing (or reappearing) must change the comparison exactly like a
|
|
2500
|
+
* content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
|
|
2501
|
+
* with identical semantics at cache-key time.
|
|
1248
2502
|
*/
|
|
1249
2503
|
export function collectExternalInputHashes(
|
|
1250
2504
|
paths: readonly string[],
|
|
2505
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1251
2506
|
): Record<string, string> {
|
|
1252
2507
|
const hashes: Record<string, string> = {};
|
|
1253
|
-
const identities = createHostPathIdentityContext();
|
|
2508
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
1254
2509
|
for (const file of paths) {
|
|
1255
2510
|
const identity = pathIdentityKey(file, identities);
|
|
1256
2511
|
if (identity in hashes) {
|
|
1257
2512
|
continue;
|
|
1258
2513
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
2514
|
+
hashes[identity] = hostInputStateHash(file, filesystem) ?? "missing";
|
|
2515
|
+
}
|
|
2516
|
+
return hashes;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** Re-hash a cached mixed graph/dependency input set with its owning codec. */
|
|
2520
|
+
function collectCachedExternalInputHashes(
|
|
2521
|
+
cached: TtscCachedProjectTransform,
|
|
2522
|
+
): Record<string, string> {
|
|
2523
|
+
const hashes: Record<string, string> = {};
|
|
2524
|
+
const state = envelopeDerivation(cached);
|
|
2525
|
+
const graphRealpaths = cached.externalInputRealpaths ?? {};
|
|
2526
|
+
const filesystem = resultFilesystem(cached.result);
|
|
2527
|
+
for (const file of cached.externalInputPaths ??
|
|
2528
|
+
Object.keys(cached.externalInputHashes ?? {})) {
|
|
2529
|
+
const identity = derivationIdentity(state, file);
|
|
2530
|
+
if (identity in hashes) continue;
|
|
2531
|
+
hashes[identity] =
|
|
2532
|
+
(Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
|
|
2533
|
+
? graphInputStateHash(file, filesystem)
|
|
2534
|
+
: hostInputStateHash(file, filesystem)) ?? "missing";
|
|
1264
2535
|
}
|
|
1265
2536
|
return hashes;
|
|
1266
2537
|
}
|
|
@@ -1274,16 +2545,14 @@ export function collectExternalInputHashes(
|
|
|
1274
2545
|
* that are still missing remain in this set even under the project root: the
|
|
1275
2546
|
* first walk cannot hash a file that has not been created yet.
|
|
1276
2547
|
*
|
|
1277
|
-
* A `dependenciesComplete` declaration deliberately does not narrow
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
* is how a widened declaration is ever learned. The narrowing that matters
|
|
1283
|
-
* lands at the bundler boundary through {@link selectWatchInputs}, which is what
|
|
1284
|
-
* feeds persistent caches and watch graphs.
|
|
2548
|
+
* A `dependenciesComplete` declaration deliberately does not narrow the stored
|
|
2549
|
+
* set: other files in the same whole-project result can still own the omitted
|
|
2550
|
+
* members. Persistent validation selects the requested file's subset through
|
|
2551
|
+
* {@link selectWatchInputs}, while graph-free envelopes use this union as their
|
|
2552
|
+
* conservative fallback.
|
|
1285
2553
|
*/
|
|
1286
2554
|
function selectExternalInputPaths(props: {
|
|
2555
|
+
filesystem?: TtscTransformFilesystemOperations;
|
|
1287
2556
|
projectRoot: string;
|
|
1288
2557
|
result: ITtscCompilerTransformation;
|
|
1289
2558
|
temporaryTsconfig?: string;
|
|
@@ -1292,7 +2561,8 @@ function selectExternalInputPaths(props: {
|
|
|
1292
2561
|
return [];
|
|
1293
2562
|
}
|
|
1294
2563
|
const members: string[] = [];
|
|
1295
|
-
const
|
|
2564
|
+
const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
|
|
2565
|
+
const identities = createHostPathIdentityContext(filesystem);
|
|
1296
2566
|
const resolutionCandidates = new Set<string>();
|
|
1297
2567
|
const graph = props.result.graph;
|
|
1298
2568
|
if (graph !== undefined) {
|
|
@@ -1326,6 +2596,19 @@ function selectExternalInputPaths(props: {
|
|
|
1326
2596
|
members.push(...entries);
|
|
1327
2597
|
}
|
|
1328
2598
|
}
|
|
2599
|
+
if (Array.isArray(props.result.hostInputs)) {
|
|
2600
|
+
for (const input of props.result.hostInputs) {
|
|
2601
|
+
members.push(input);
|
|
2602
|
+
if (typeof input === "string" && input.length !== 0) {
|
|
2603
|
+
// Plugin discovery inputs deliberately include absent config and
|
|
2604
|
+
// resolution probes. A project walk cannot snapshot a path that does
|
|
2605
|
+
// not exist yet, even when its spelling lies below projectRoot.
|
|
2606
|
+
resolutionCandidates.add(
|
|
2607
|
+
pathIdentityKey(path.resolve(props.projectRoot, input), identities),
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
1329
2612
|
const excluded =
|
|
1330
2613
|
props.temporaryTsconfig === undefined
|
|
1331
2614
|
? undefined
|
|
@@ -1337,18 +2620,21 @@ function selectExternalInputPaths(props: {
|
|
|
1337
2620
|
continue;
|
|
1338
2621
|
}
|
|
1339
2622
|
const absolute = path.resolve(props.projectRoot, member);
|
|
2623
|
+
const spelling = path.resolve(absolute);
|
|
1340
2624
|
const identity = pathIdentityKey(absolute, identities);
|
|
1341
2625
|
const missingCandidate =
|
|
1342
|
-
resolutionCandidates.has(identity) && !
|
|
2626
|
+
resolutionCandidates.has(identity) && !filesystem.exists(absolute);
|
|
1343
2627
|
if (
|
|
1344
2628
|
identity === excluded ||
|
|
1345
|
-
seen.has(
|
|
2629
|
+
seen.has(spelling) ||
|
|
1346
2630
|
(!missingCandidate &&
|
|
1347
|
-
isProjectWalkPath(props.projectRoot, absolute, identities))
|
|
2631
|
+
isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
|
|
1348
2632
|
) {
|
|
1349
2633
|
continue;
|
|
1350
2634
|
}
|
|
1351
|
-
|
|
2635
|
+
// Preserve distinct lexical aliases even when they currently select the
|
|
2636
|
+
// same physical file. A later retarget must validate the alias itself.
|
|
2637
|
+
seen.add(spelling);
|
|
1352
2638
|
output.push(absolute);
|
|
1353
2639
|
}
|
|
1354
2640
|
output.sort();
|
|
@@ -1396,43 +2682,103 @@ async function transformProject(props: {
|
|
|
1396
2682
|
compilerOptions: Record<string, unknown>;
|
|
1397
2683
|
currentFile: string;
|
|
1398
2684
|
currentSource: string;
|
|
2685
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
1399
2686
|
plugins?: ResolvedTtscUnpluginOptions["plugins"];
|
|
2687
|
+
trackProjectMembership: boolean;
|
|
1400
2688
|
tsconfig: string;
|
|
1401
2689
|
}): Promise<TtscCachedProjectTransform> {
|
|
1402
|
-
const configured = createTransformTsconfig(props);
|
|
1403
2690
|
const projectRoot = path.dirname(props.tsconfig);
|
|
2691
|
+
const scratchDirectory = createTransformScratchDirectory(
|
|
2692
|
+
projectRoot,
|
|
2693
|
+
props.filesystem,
|
|
2694
|
+
);
|
|
2695
|
+
let tracker: TtscProjectMutationTracker | undefined;
|
|
2696
|
+
let retainTracker = false;
|
|
2697
|
+
let hostInputTracker: TtscProjectMutationTracker | undefined;
|
|
1404
2698
|
try {
|
|
1405
|
-
const
|
|
1406
|
-
cwd: projectRoot,
|
|
1407
|
-
// The generated tsconfig (if any) lives in the system temp directory,
|
|
1408
|
-
// so declare the real project as the plugin config anchor: utility
|
|
1409
|
-
// plugin config discovery (banner.config.*, strip.config.*,
|
|
1410
|
-
// lint.config.*) and relative configFile resolution walk the project,
|
|
1411
|
-
// never the temp tree. In the passthrough case this equals the
|
|
1412
|
-
// tsconfig's own directory, the default anchor.
|
|
1413
|
-
pluginConfigDir: projectRoot,
|
|
1414
|
-
plugins: props.plugins,
|
|
1415
|
-
projectRoot,
|
|
1416
|
-
tsconfig: configured.path,
|
|
1417
|
-
}).transform();
|
|
2699
|
+
const configured = createTransformTsconfig(props, scratchDirectory);
|
|
1418
2700
|
const temporaryTsconfig =
|
|
1419
2701
|
configured.path === props.tsconfig ? undefined : configured.path;
|
|
2702
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
2703
|
+
const before = collectProjectInputSnapshot(
|
|
2704
|
+
projectRoot,
|
|
2705
|
+
identities,
|
|
2706
|
+
props.filesystem,
|
|
2707
|
+
);
|
|
2708
|
+
tracker = props.trackProjectMembership
|
|
2709
|
+
? await createProjectMutationTracker(
|
|
2710
|
+
before.projectDirectories,
|
|
2711
|
+
props.filesystem,
|
|
2712
|
+
)
|
|
2713
|
+
: undefined;
|
|
2714
|
+
const result = withTransformScratchEnvironment(scratchDirectory, () =>
|
|
2715
|
+
new TtscCompiler({
|
|
2716
|
+
cwd: projectRoot,
|
|
2717
|
+
// The generated tsconfig (if any) lives outside the project directory,
|
|
2718
|
+
// so declare the real project as the plugin config anchor: utility
|
|
2719
|
+
// plugin config discovery (banner.config.*, strip.config.*,
|
|
2720
|
+
// lint.config.*) and relative configFile resolution walk the project,
|
|
2721
|
+
// never the temp tree. In the passthrough case this equals the
|
|
2722
|
+
// tsconfig's own directory, the default anchor.
|
|
2723
|
+
pluginConfigDir: projectRoot,
|
|
2724
|
+
plugins: props.plugins,
|
|
2725
|
+
projectRoot,
|
|
2726
|
+
tsconfig: configured.path,
|
|
2727
|
+
env: transformScratchEnvironment(scratchDirectory),
|
|
2728
|
+
}).transform(),
|
|
2729
|
+
);
|
|
2730
|
+
TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
|
|
2731
|
+
const persistentHostInputs = selectPersistentHostInputs({
|
|
2732
|
+
filesystem: props.filesystem,
|
|
2733
|
+
projectRoot,
|
|
2734
|
+
result,
|
|
2735
|
+
temporaryTsconfig,
|
|
2736
|
+
});
|
|
2737
|
+
hostInputTracker = props.trackProjectMembership
|
|
2738
|
+
? await createHostInputMutationTracker(
|
|
2739
|
+
persistentHostInputs,
|
|
2740
|
+
props.filesystem,
|
|
2741
|
+
)
|
|
2742
|
+
: undefined;
|
|
1420
2743
|
const externalInputPaths = selectExternalInputPaths({
|
|
2744
|
+
filesystem: props.filesystem,
|
|
1421
2745
|
projectRoot,
|
|
1422
2746
|
result,
|
|
1423
2747
|
temporaryTsconfig,
|
|
1424
2748
|
});
|
|
1425
|
-
|
|
2749
|
+
const inputSnapshot = collectProjectInputSnapshot(
|
|
2750
|
+
projectRoot,
|
|
2751
|
+
identities,
|
|
2752
|
+
props.filesystem,
|
|
2753
|
+
);
|
|
2754
|
+
let stableProjectSnapshot =
|
|
2755
|
+
before.complete &&
|
|
2756
|
+
inputSnapshot.complete &&
|
|
2757
|
+
sameHashes(before.hashes, inputSnapshot.hashes) &&
|
|
2758
|
+
sameHashes(before.fileSignatures, inputSnapshot.fileSignatures) &&
|
|
2759
|
+
sameProjectDirectories(
|
|
2760
|
+
before.projectDirectories,
|
|
2761
|
+
inputSnapshot.projectDirectories,
|
|
2762
|
+
) &&
|
|
2763
|
+
tracker?.failed !== true &&
|
|
2764
|
+
tracker?.membershipChanged !== true &&
|
|
2765
|
+
hostInputTracker?.failed !== true &&
|
|
2766
|
+
hostInputTracker?.membershipChanged !== true;
|
|
2767
|
+
// Overlay the in-memory source only after proving the two on-disk snapshots
|
|
2768
|
+
// stable; an unsaved editor buffer must not look like a compile-time race.
|
|
2769
|
+
inputSnapshot.hashes[
|
|
2770
|
+
toProjectKey(projectRoot, props.currentFile, identities)
|
|
2771
|
+
] = hashText(props.currentSource);
|
|
2772
|
+
const cached: TtscCachedProjectTransform = {
|
|
1426
2773
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
1427
2774
|
// cache validation can re-check them; computed before dispose so the
|
|
1428
2775
|
// exclusion of the temp-dir tsconfig is the only reason it never keys.
|
|
1429
|
-
externalInputHashes:
|
|
2776
|
+
externalInputHashes: {},
|
|
2777
|
+
externalInputRealpaths: {},
|
|
1430
2778
|
externalInputPaths,
|
|
1431
|
-
inputHashes:
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
projectRoot,
|
|
1435
|
-
}),
|
|
2779
|
+
inputHashes: inputSnapshot.hashes,
|
|
2780
|
+
projectDirectories: inputSnapshot.projectDirectories,
|
|
2781
|
+
projectSnapshotComplete: false,
|
|
1436
2782
|
projectRoot,
|
|
1437
2783
|
result,
|
|
1438
2784
|
servedFiles: new Set(),
|
|
@@ -1441,16 +2787,72 @@ async function transformProject(props: {
|
|
|
1441
2787
|
// but deleted file would invalidate every persistent-cache snapshot.
|
|
1442
2788
|
...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
|
|
1443
2789
|
};
|
|
2790
|
+
const externalInputSnapshot = captureExternalInputSnapshot(
|
|
2791
|
+
cached,
|
|
2792
|
+
externalInputPaths,
|
|
2793
|
+
);
|
|
2794
|
+
cached.externalInputHashes = externalInputSnapshot.hashes;
|
|
2795
|
+
cached.externalInputRealpaths = externalInputSnapshot.realpaths;
|
|
2796
|
+
stableProjectSnapshot =
|
|
2797
|
+
stableProjectSnapshot &&
|
|
2798
|
+
matchesCompilerGraphInputProofs(cached) &&
|
|
2799
|
+
externalInputSnapshot.complete &&
|
|
2800
|
+
captureUniversalHostInputValidation(cached, props.currentFile) !==
|
|
2801
|
+
undefined;
|
|
2802
|
+
cached.projectSnapshotComplete = stableProjectSnapshot;
|
|
2803
|
+
if (stableProjectSnapshot && tracker !== undefined) {
|
|
2804
|
+
cached.projectMutationTracker = tracker;
|
|
2805
|
+
}
|
|
2806
|
+
if (stableProjectSnapshot && hostInputTracker !== undefined) {
|
|
2807
|
+
cached.hostInputMutationTracker = hostInputTracker;
|
|
2808
|
+
}
|
|
2809
|
+
retainTracker =
|
|
2810
|
+
stableProjectSnapshot &&
|
|
2811
|
+
tracker !== undefined &&
|
|
2812
|
+
hostInputTracker !== undefined;
|
|
2813
|
+
return cached;
|
|
1444
2814
|
} finally {
|
|
1445
|
-
|
|
2815
|
+
try {
|
|
2816
|
+
if (!retainTracker && tracker !== undefined) {
|
|
2817
|
+
tracker.close();
|
|
2818
|
+
}
|
|
2819
|
+
} finally {
|
|
2820
|
+
try {
|
|
2821
|
+
if (!retainTracker && hostInputTracker !== undefined) {
|
|
2822
|
+
hostInputTracker.close();
|
|
2823
|
+
}
|
|
2824
|
+
} finally {
|
|
2825
|
+
fs.rmSync(scratchDirectory, { force: true, recursive: true });
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
1446
2828
|
}
|
|
1447
2829
|
}
|
|
1448
2830
|
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
2831
|
+
/** Exclude the disposed overlay tsconfig from live host-input tracking. */
|
|
2832
|
+
function selectPersistentHostInputs(props: {
|
|
2833
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
2834
|
+
projectRoot: string;
|
|
2835
|
+
result: ITtscCompilerTransformation;
|
|
2836
|
+
temporaryTsconfig?: string;
|
|
2837
|
+
}): string[] {
|
|
2838
|
+
if (props.result.type === "exception") return [];
|
|
2839
|
+
const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
2840
|
+
if (props.temporaryTsconfig === undefined) return inputs;
|
|
2841
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
2842
|
+
const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
|
|
2843
|
+
return inputs.filter(
|
|
2844
|
+
(input) => pathIdentityKey(input, identities) !== temporary,
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
function createTransformTsconfig(
|
|
2849
|
+
props: {
|
|
2850
|
+
aliasPaths: Record<string, string[]>;
|
|
2851
|
+
compilerOptions: Record<string, unknown>;
|
|
2852
|
+
tsconfig: string;
|
|
2853
|
+
},
|
|
2854
|
+
scratchDirectory: string,
|
|
2855
|
+
): { path: string } {
|
|
1454
2856
|
const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
|
|
1455
2857
|
{
|
|
1456
2858
|
...props.compilerOptions,
|
|
@@ -1459,14 +2861,10 @@ function createTransformTsconfig(props: {
|
|
|
1459
2861
|
path.dirname(props.tsconfig),
|
|
1460
2862
|
);
|
|
1461
2863
|
if (Object.keys(compilerOptions).length === 0) {
|
|
1462
|
-
return {
|
|
1463
|
-
path: props.tsconfig,
|
|
1464
|
-
dispose: () => undefined,
|
|
1465
|
-
};
|
|
2864
|
+
return { path: props.tsconfig };
|
|
1466
2865
|
}
|
|
1467
2866
|
|
|
1468
|
-
const
|
|
1469
|
-
const file = path.join(directory, "tsconfig.json");
|
|
2867
|
+
const file = path.join(scratchDirectory, "tsconfig.json");
|
|
1470
2868
|
fs.writeFileSync(
|
|
1471
2869
|
file,
|
|
1472
2870
|
JSON.stringify(
|
|
@@ -1479,19 +2877,134 @@ function createTransformTsconfig(props: {
|
|
|
1479
2877
|
),
|
|
1480
2878
|
"utf8",
|
|
1481
2879
|
);
|
|
2880
|
+
return { path: file };
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/** Create compiler scratch storage outside the project snapshot and watchers. */
|
|
2884
|
+
function createTransformScratchDirectory(
|
|
2885
|
+
projectRoot: string,
|
|
2886
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2887
|
+
): string {
|
|
2888
|
+
const root = path.resolve(projectRoot);
|
|
2889
|
+
const canonicalRoot = filesystem.realpath(root);
|
|
2890
|
+
const platformTemp =
|
|
2891
|
+
process.platform === "win32" && process.env.LOCALAPPDATA
|
|
2892
|
+
? path.join(process.env.LOCALAPPDATA, "Temp")
|
|
2893
|
+
: "/tmp";
|
|
2894
|
+
const candidates = [
|
|
2895
|
+
os.tmpdir(),
|
|
2896
|
+
platformTemp,
|
|
2897
|
+
path.dirname(root),
|
|
2898
|
+
os.homedir(),
|
|
2899
|
+
];
|
|
2900
|
+
const canonicalCandidates = new Set<string>();
|
|
2901
|
+
let failure: unknown;
|
|
2902
|
+
for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
|
|
2903
|
+
if (pathIsWithin(candidate, root)) continue;
|
|
2904
|
+
let canonicalCandidate: string;
|
|
2905
|
+
try {
|
|
2906
|
+
canonicalCandidate = filesystem.realpath(candidate);
|
|
2907
|
+
} catch (error) {
|
|
2908
|
+
failure = error;
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
if (
|
|
2912
|
+
pathIsWithin(canonicalCandidate, canonicalRoot) ||
|
|
2913
|
+
canonicalCandidates.has(canonicalCandidate)
|
|
2914
|
+
) {
|
|
2915
|
+
continue;
|
|
2916
|
+
}
|
|
2917
|
+
canonicalCandidates.add(canonicalCandidate);
|
|
2918
|
+
let directory: string;
|
|
2919
|
+
try {
|
|
2920
|
+
directory = fs.mkdtempSync(
|
|
2921
|
+
path.join(canonicalCandidate, "ttsc-unplugin-"),
|
|
2922
|
+
);
|
|
2923
|
+
} catch (error) {
|
|
2924
|
+
failure = error;
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
let canonicalDirectory: string;
|
|
2928
|
+
try {
|
|
2929
|
+
canonicalDirectory = filesystem.realpath(directory);
|
|
2930
|
+
} catch (error) {
|
|
2931
|
+
try {
|
|
2932
|
+
fs.rmdirSync(directory);
|
|
2933
|
+
} catch (cleanupError) {
|
|
2934
|
+
throw cleanupError;
|
|
2935
|
+
}
|
|
2936
|
+
failure = error;
|
|
2937
|
+
continue;
|
|
2938
|
+
}
|
|
2939
|
+
// Use the postflight canonical spelling from this point onward. Returning
|
|
2940
|
+
// the candidate-relative spelling would let another process retarget its
|
|
2941
|
+
// parent symlink/junction after validation, redirecting compiler writes or
|
|
2942
|
+
// the final recursive removal into the project.
|
|
2943
|
+
if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
|
|
2944
|
+
return canonicalDirectory;
|
|
2945
|
+
}
|
|
2946
|
+
// Refuse the result and synchronously remove only our empty random child
|
|
2947
|
+
// through the identity that the postflight check just classified.
|
|
2948
|
+
fs.rmdirSync(canonicalDirectory);
|
|
2949
|
+
}
|
|
2950
|
+
throw (
|
|
2951
|
+
failure ??
|
|
2952
|
+
new Error("ttsc: no temporary directory exists outside the project")
|
|
2953
|
+
);
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
function pathIsWithin(child: string, parent: string): boolean {
|
|
2957
|
+
const relative = path.relative(parent, child);
|
|
2958
|
+
return (
|
|
2959
|
+
relative === "" ||
|
|
2960
|
+
(relative !== ".." &&
|
|
2961
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
2962
|
+
!path.isAbsolute(relative))
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/** Route all compiler/plugin scratch to one owned directory outside project. */
|
|
2967
|
+
function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
|
|
1482
2968
|
return {
|
|
1483
|
-
|
|
1484
|
-
|
|
2969
|
+
...process.env,
|
|
2970
|
+
TEMP: directory,
|
|
2971
|
+
TMP: directory,
|
|
2972
|
+
TMPDIR: directory,
|
|
1485
2973
|
};
|
|
1486
2974
|
}
|
|
1487
2975
|
|
|
2976
|
+
/** Scope parent-process temp consumers to the same owned scratch directory. */
|
|
2977
|
+
function withTransformScratchEnvironment<T>(
|
|
2978
|
+
scratchDirectory: string,
|
|
2979
|
+
callback: () => T,
|
|
2980
|
+
): T {
|
|
2981
|
+
const environment = transformScratchEnvironment(scratchDirectory);
|
|
2982
|
+
const previous = {
|
|
2983
|
+
TEMP: process.env.TEMP,
|
|
2984
|
+
TMP: process.env.TMP,
|
|
2985
|
+
TMPDIR: process.env.TMPDIR,
|
|
2986
|
+
};
|
|
2987
|
+
process.env.TEMP = environment.TEMP;
|
|
2988
|
+
process.env.TMP = environment.TMP;
|
|
2989
|
+
process.env.TMPDIR = environment.TMPDIR;
|
|
2990
|
+
try {
|
|
2991
|
+
return callback();
|
|
2992
|
+
} finally {
|
|
2993
|
+
for (const [name, value] of Object.entries(previous)) {
|
|
2994
|
+
if (value === undefined) delete process.env[name];
|
|
2995
|
+
else process.env[name] = value;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
1488
3000
|
/**
|
|
1489
3001
|
* Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
|
|
1490
3002
|
*
|
|
1491
|
-
* The generated tsconfig lives in a
|
|
1492
|
-
* (e.g. `"outDir": "../dist"`) that was meaningful relative
|
|
1493
|
-
* tsconfig must be converted to an absolute path before writing
|
|
1494
|
-
* file. Otherwise TypeScript-Go resolves it against the temp
|
|
3003
|
+
* The generated tsconfig lives in a temporary directory outside the project, so
|
|
3004
|
+
* any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
|
|
3005
|
+
* to the original tsconfig must be converted to an absolute path before writing
|
|
3006
|
+
* the generated file. Otherwise TypeScript-Go resolves it against the temp
|
|
3007
|
+
* dir.
|
|
1495
3008
|
*
|
|
1496
3009
|
* `paths` targets are absolutized for the same reason, with the extra twist
|
|
1497
3010
|
* that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
|
|
@@ -1826,7 +3339,11 @@ function formatUnknownError(error: unknown): string {
|
|
|
1826
3339
|
* compiler will error if that file does not exist, which is the correct
|
|
1827
3340
|
* behavior for a mis-configured project.
|
|
1828
3341
|
*/
|
|
1829
|
-
function resolveTsconfig(
|
|
3342
|
+
function resolveTsconfig(
|
|
3343
|
+
file: string,
|
|
3344
|
+
tsconfig?: string,
|
|
3345
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3346
|
+
): string {
|
|
1830
3347
|
if (tsconfig !== undefined) {
|
|
1831
3348
|
return path.isAbsolute(tsconfig)
|
|
1832
3349
|
? tsconfig
|
|
@@ -1836,7 +3353,7 @@ function resolveTsconfig(file: string, tsconfig?: string): string {
|
|
|
1836
3353
|
let current = path.dirname(file);
|
|
1837
3354
|
while (true) {
|
|
1838
3355
|
const candidate = path.join(current, "tsconfig.json");
|
|
1839
|
-
if (
|
|
3356
|
+
if (filesystem.exists(candidate)) {
|
|
1840
3357
|
return candidate;
|
|
1841
3358
|
}
|
|
1842
3359
|
const parent = path.dirname(current);
|