@ttsc/unplugin 0.28.2 → 0.28.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -7
- package/lib/api.js +3 -0
- package/lib/api.js.map +1 -1
- package/lib/api.mjs +1 -0
- package/lib/api.mjs.map +1 -1
- package/lib/core/index.d.cts +17 -6
- package/lib/core/index.d.mts +17 -6
- package/lib/core/index.d.ts +17 -6
- package/lib/core/index.js +131 -18
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +130 -19
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.cts +116 -29
- package/lib/core/transform.d.mts +116 -29
- package/lib/core/transform.d.ts +116 -29
- package/lib/core/transform.js +1502 -222
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +1503 -223
- package/lib/core/transform.mjs.map +1 -1
- package/lib/core/tsconfigPaths.d.cts +61 -0
- package/lib/core/tsconfigPaths.d.mts +61 -0
- package/lib/core/tsconfigPaths.d.ts +61 -0
- package/lib/core/tsconfigPaths.js +190 -7
- package/lib/core/tsconfigPaths.js.map +1 -1
- package/lib/core/tsconfigPaths.mjs +188 -8
- package/lib/core/tsconfigPaths.mjs.map +1 -1
- package/lib/next.d.cts +27 -10
- package/lib/next.d.mts +27 -10
- package/lib/next.d.ts +27 -10
- package/lib/next.js +229 -8
- package/lib/next.js.map +1 -1
- package/lib/next.mjs +229 -8
- package/lib/next.mjs.map +1 -1
- package/lib/turbopack.d.cts +5 -4
- package/lib/turbopack.d.mts +5 -4
- package/lib/turbopack.d.ts +5 -4
- package/lib/turbopack.js +13 -7
- package/lib/turbopack.js.map +1 -1
- package/lib/turbopack.mjs +14 -8
- package/lib/turbopack.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +136 -18
- package/src/core/transform.ts +2070 -254
- package/src/core/tsconfigPaths.ts +254 -8
- package/src/next.ts +262 -10
- package/src/turbopack.ts +13 -9
package/src/core/transform.ts
CHANGED
|
@@ -17,8 +17,12 @@ import type { TransformResult } from "unplugin";
|
|
|
17
17
|
|
|
18
18
|
import type { ResolvedTtscUnpluginOptions } from "./options";
|
|
19
19
|
import {
|
|
20
|
+
type ITtscProjectMembershipPolicy,
|
|
21
|
+
PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
20
22
|
absolutizePathsTarget,
|
|
23
|
+
mergeMembershipPolicyOverlay,
|
|
21
24
|
readEffectiveTsconfigPaths,
|
|
25
|
+
readProjectMembershipPolicy,
|
|
22
26
|
} from "./tsconfigPaths";
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -34,27 +38,72 @@ export type TtscTransformResult = Exclude<
|
|
|
34
38
|
>;
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* One alias entry as the host declared it, before anything decides whether a
|
|
42
|
+
* tsconfig `paths` map can express it.
|
|
43
|
+
*
|
|
44
|
+
* Both of Vite's spellings reach here, the `{ "@": "/src" }` object and the `{
|
|
45
|
+
* find, replacement }` array, and only Vite's: `aliases` is populated in
|
|
46
|
+
* `vite.configResolved` alone, and every other adapter passes `undefined`. (The
|
|
47
|
+
* previous wording credited the object form to webpack and Rspack, which never
|
|
48
|
+
* supply one.)
|
|
49
|
+
*
|
|
50
|
+
* `find` is `unknown` rather than `string` because Vite's array form accepts a
|
|
51
|
+
* `RegExp`, and narrowing it here is what used to drop that form before the one
|
|
52
|
+
* place that could report the drop ever saw it (samchon/ttsc#1315).
|
|
40
53
|
*/
|
|
41
|
-
|
|
42
|
-
/** The alias key
|
|
43
|
-
find:
|
|
54
|
+
interface TtscDeclaredAlias {
|
|
55
|
+
/** The alias key, as declared: a module specifier prefix, or a `RegExp`. */
|
|
56
|
+
find: unknown;
|
|
44
57
|
/** Absolute or cwd-relative path that the alias points to. */
|
|
45
58
|
replacement: string;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
/** One directory's
|
|
61
|
+
/** One directory's project-membership identity at generation time. */
|
|
49
62
|
interface TtscProjectDirectorySnapshot {
|
|
50
63
|
/** Absolute directory spelling used by the project walk. */
|
|
51
64
|
path: string;
|
|
52
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Whether this directory's subtree can hold a program input.
|
|
67
|
+
*
|
|
68
|
+
* A directory that cannot is still walked and still watched, so a source
|
|
69
|
+
* appearing in it later is noticed, but it takes no part in the membership
|
|
70
|
+
* comparison. That is what lets a bundler create its output directory and
|
|
71
|
+
* fill it without voiding a generation no compiler input touched, for any
|
|
72
|
+
* output directory rather than for fifteen names (samchon/ttsc#1307).
|
|
73
|
+
*/
|
|
74
|
+
relevant: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Digest of the entries the walk itself considers: every immediate child the
|
|
77
|
+
* ignore list does not drop, with its kind.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately not the directory's own metadata. A directory's stamp moves
|
|
80
|
+
* whenever _any_ entry is added or removed, including the ones the walk
|
|
81
|
+
* exists to ignore, so a bundler emitting into `dist/` — or merely creating
|
|
82
|
+
* that directory for the first time — moved the project root's stamp and
|
|
83
|
+
* voided a generation that no compiler input had touched. The ignore list
|
|
84
|
+
* only protects the generation if the membership proof honours it too.
|
|
85
|
+
*/
|
|
53
86
|
signature: string;
|
|
54
87
|
}
|
|
55
88
|
|
|
89
|
+
/** One project-walk observation that could not prove a coherent snapshot. */
|
|
90
|
+
interface TtscProjectWalkFailure {
|
|
91
|
+
kind:
|
|
92
|
+
| "directory-changed-during-walk"
|
|
93
|
+
| "directory-metadata-unavailable"
|
|
94
|
+
| "directory-read-failed"
|
|
95
|
+
| "file-changed-during-read"
|
|
96
|
+
| "file-read-failed";
|
|
97
|
+
/** Absolute lexical spelling observed by the walk. */
|
|
98
|
+
path: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
56
101
|
/** Generation-scoped directory watchers used to detect membership changes. */
|
|
57
102
|
interface TtscProjectMutationTracker {
|
|
103
|
+
/** Absolute paths named by generation-time mutation events. */
|
|
104
|
+
changes: Set<string>;
|
|
105
|
+
/** Whether additional event paths were discarded after the witness bound. */
|
|
106
|
+
changesOmitted: boolean;
|
|
58
107
|
close: () => void;
|
|
59
108
|
/**
|
|
60
109
|
* Absolute spellings whose creation, change or removal this tracker would
|
|
@@ -82,6 +131,174 @@ interface TtscProjectMutationTracker {
|
|
|
82
131
|
settle?: Promise<void>;
|
|
83
132
|
}
|
|
84
133
|
|
|
134
|
+
/** One reason a whole-project transform cannot become a reusable generation. */
|
|
135
|
+
interface TtscGenerationProofFailure {
|
|
136
|
+
domain: "external" | "graph" | "host" | "project";
|
|
137
|
+
/** Machine-readable failure class printed verbatim in terminal diagnostics. */
|
|
138
|
+
kind: string;
|
|
139
|
+
/** Optional producer detail, such as the native compiler observation failure. */
|
|
140
|
+
detail?: string;
|
|
141
|
+
/** Absolute lexical spelling of the input or directory that failed proof. */
|
|
142
|
+
path?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Bounded proof witnesses for one transform attempt. */
|
|
146
|
+
interface TtscGenerationProofFailures {
|
|
147
|
+
entries: TtscGenerationProofFailure[];
|
|
148
|
+
omitted: number;
|
|
149
|
+
seen: Set<string>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Filesystem state that may authorize replacing one terminal failed generation. */
|
|
153
|
+
interface TtscFailedGenerationValidation {
|
|
154
|
+
/** Last attempted generation, retained only as a comparison baseline. */
|
|
155
|
+
cached: TtscCachedProjectTransform;
|
|
156
|
+
/** Input keys whose content can affect the generation, or the whole walk. */
|
|
157
|
+
declaredInputs: ReadonlySet<string> | undefined;
|
|
158
|
+
/** Fingerprints of every out-of-walk and exact host input. */
|
|
159
|
+
inputStates: ReadonlyMap<string, string>;
|
|
160
|
+
/** Unmodified on-disk project hashes before the in-memory source overlay. */
|
|
161
|
+
projectInputHashes: Readonly<Record<string, string>>;
|
|
162
|
+
/** Coherence and exact failure state of the final project walk. */
|
|
163
|
+
projectWalkComplete: boolean;
|
|
164
|
+
projectWalkFailures: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A verdict about one generation that later deliveries replay instead of
|
|
169
|
+
* repeating the whole compile behind it.
|
|
170
|
+
*
|
|
171
|
+
* The two kinds are replayed on different evidence, and each carries its own: a
|
|
172
|
+
* pass verdict knows the pass it belongs to, and an unstable generation knows
|
|
173
|
+
* the recorded environment it was proven against.
|
|
174
|
+
*/
|
|
175
|
+
abstract class TtscTerminalGenerationError extends Error {}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The compile succeeded and produced no output for one requested module,
|
|
179
|
+
* because the program does not contain it.
|
|
180
|
+
*
|
|
181
|
+
* Not a terminal generation error, and deliberately not a build failure. It is
|
|
182
|
+
* a fact about one file, and the answer to it is to leave that file to the host
|
|
183
|
+
* (samchon/ttsc#1308). It is a distinct type rather than a message match so the
|
|
184
|
+
* decision travels as a type: `@ttsc/metro` used to recognise this case by
|
|
185
|
+
* searching the message text for "did not return output", which is how one
|
|
186
|
+
* product came to hold two different answers to one condition.
|
|
187
|
+
*/
|
|
188
|
+
class TtscMissingProgramOutputError extends Error {
|
|
189
|
+
/** The module the bundler asked for. */
|
|
190
|
+
public readonly file: string;
|
|
191
|
+
/** The project config whose program does not contain it. */
|
|
192
|
+
public readonly tsconfig: string;
|
|
193
|
+
public constructor(file: string, tsconfig: string) {
|
|
194
|
+
super(
|
|
195
|
+
`ttsc: ${file} is not part of the program described by ${tsconfig}, so it was left untransformed. Add it to that project's "include" if ttsc plugins should apply to it.`,
|
|
196
|
+
);
|
|
197
|
+
this.name = "TtscMissingProgramOutputError";
|
|
198
|
+
this.file = file;
|
|
199
|
+
this.tsconfig = tsconfig;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A bounded proof failure that stays authoritative until its inputs change.
|
|
205
|
+
*
|
|
206
|
+
* This is the adapter failing to _obtain_ a coherent snapshot — a race it lost
|
|
207
|
+
* — so a later attempt may well succeed with the same inputs. It is retried
|
|
208
|
+
* when its recorded environment moves, and a new delivery epoch grants it the
|
|
209
|
+
* one fresh attempt the per-pass cache clear used to give it
|
|
210
|
+
* (samchon/ttsc#1300).
|
|
211
|
+
*/
|
|
212
|
+
class TtscUnstableGenerationError extends TtscTerminalGenerationError {
|
|
213
|
+
public readonly validation: TtscFailedGenerationValidation;
|
|
214
|
+
|
|
215
|
+
public constructor(
|
|
216
|
+
message: string,
|
|
217
|
+
validation: TtscFailedGenerationValidation,
|
|
218
|
+
) {
|
|
219
|
+
super(message);
|
|
220
|
+
this.name = "TtscUnstableGenerationError";
|
|
221
|
+
this.validation = validation;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A compile this pass already attempted, whose envelope failed outright.
|
|
227
|
+
*
|
|
228
|
+
* The envelope cannot say whether the host reported diagnostics about the
|
|
229
|
+
* project or failed to run at all: an ordinary type error arrives as an
|
|
230
|
+
* `"exception"` carrying the compiler's own diagnostic text, exactly as a
|
|
231
|
+
* crashed host would. Sniffing that message to tell the two apart would be a
|
|
232
|
+
* guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
|
|
233
|
+
* the answer is already settled, so every later module replays it instead of
|
|
234
|
+
* repeating a whole-project transform to reach the same verdict, which is what
|
|
235
|
+
* made a single broken save cost one compile per delivered module
|
|
236
|
+
* (samchon/ttsc#1303).
|
|
237
|
+
*
|
|
238
|
+
* The scope is exactly the pass. A host whose `buildStart` repeats drops the
|
|
239
|
+
* verdict at its next rebuild, so a transient host failure costs that one
|
|
240
|
+
* rebuild. A host with no pass boundary never retains one at all and keeps
|
|
241
|
+
* retrying on its very next delivery. Between them sits a host that opens
|
|
242
|
+
* exactly one pass for its whole process — Bun's runtime plugin, and a Vite dev
|
|
243
|
+
* server configured with `server.watch: null` — where the verdict lasts the
|
|
244
|
+
* session. That follows from what those hosts already publish about themselves,
|
|
245
|
+
* that their session is one immutable load session and the remedy for changed
|
|
246
|
+
* inputs is to restart, and it is the deliberate trade: without it, one type
|
|
247
|
+
* error costs such a session a whole-project compile per delivered module,
|
|
248
|
+
* which is the workload samchon/ttsc#970 is about.
|
|
249
|
+
*
|
|
250
|
+
* It carries the original error's message, stack and `cause` rather than
|
|
251
|
+
* replacing them, so what a bundler reports is what it reported before the
|
|
252
|
+
* verdict existed.
|
|
253
|
+
*/
|
|
254
|
+
class TtscPassVerdictError extends TtscTerminalGenerationError {
|
|
255
|
+
/** The delivery pass this verdict belongs to, and its whole scope. */
|
|
256
|
+
public readonly epoch: number;
|
|
257
|
+
|
|
258
|
+
public constructor(original: unknown, epoch: number) {
|
|
259
|
+
super(
|
|
260
|
+
original instanceof Error
|
|
261
|
+
? original.message
|
|
262
|
+
: formatUnknownError(original),
|
|
263
|
+
{ cause: original },
|
|
264
|
+
);
|
|
265
|
+
if (original instanceof Error) {
|
|
266
|
+
this.name = original.name;
|
|
267
|
+
if (original.stack !== undefined) this.stack = original.stack;
|
|
268
|
+
} else {
|
|
269
|
+
this.name = "TtscPassVerdictError";
|
|
270
|
+
}
|
|
271
|
+
this.epoch = epoch;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Proof witnesses retained beside a compiler result without extending its API. */
|
|
276
|
+
const TRANSFORM_GENERATION_FAILURES = new WeakMap<
|
|
277
|
+
ITtscCompilerTransformation,
|
|
278
|
+
TtscGenerationProofFailures
|
|
279
|
+
>();
|
|
280
|
+
|
|
281
|
+
/** Retry baselines retained only for attempts that could not be published. */
|
|
282
|
+
const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
|
|
283
|
+
ITtscCompilerTransformation,
|
|
284
|
+
TtscFailedGenerationValidation
|
|
285
|
+
>();
|
|
286
|
+
|
|
287
|
+
/** Cache promises whose unchanged terminal verdict may be replayed. */
|
|
288
|
+
const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
|
|
289
|
+
Promise<TtscCachedProjectTransform>,
|
|
290
|
+
TtscTerminalGenerationError
|
|
291
|
+
>();
|
|
292
|
+
|
|
293
|
+
/** Maximum witnesses printed and retained for each failed transform attempt. */
|
|
294
|
+
const MAX_GENERATION_PROOF_FAILURES = 8;
|
|
295
|
+
|
|
296
|
+
/** Maximum exact mutation paths kept after a tracker already proved a change. */
|
|
297
|
+
const MAX_GENERATION_MUTATION_PATHS = 8;
|
|
298
|
+
|
|
299
|
+
/** One retry absorbs a transient watch write without admitting an infinite loop. */
|
|
300
|
+
const TRANSFORM_GENERATION_ATTEMPTS = 2;
|
|
301
|
+
|
|
85
302
|
/**
|
|
86
303
|
* A single entry in the project transform cache.
|
|
87
304
|
*
|
|
@@ -138,6 +355,28 @@ export interface TtscCachedProjectTransform {
|
|
|
138
355
|
* transform.
|
|
139
356
|
*/
|
|
140
357
|
inputHashes: Record<string, string>;
|
|
358
|
+
/**
|
|
359
|
+
* What the resolved configuration admitted into this generation's program.
|
|
360
|
+
*
|
|
361
|
+
* Recorded per generation rather than read per validation because it is a
|
|
362
|
+
* property of the configuration the compile ran under, so a later delivery
|
|
363
|
+
* must judge membership by the same rule the compile did. A tsconfig edit
|
|
364
|
+
* that changes the rule also changes a declared input, which replaces the
|
|
365
|
+
* generation and its policy together.
|
|
366
|
+
*/
|
|
367
|
+
membershipPolicy: ITtscProjectMembershipPolicy;
|
|
368
|
+
/**
|
|
369
|
+
* Files already reported as absent from the program, and the pass that
|
|
370
|
+
* reporting belongs to, so the notice is one per file per pass rather than
|
|
371
|
+
* one per delivery.
|
|
372
|
+
*/
|
|
373
|
+
missingOutputReported?: Set<string>;
|
|
374
|
+
missingOutputEpoch?: number;
|
|
375
|
+
/**
|
|
376
|
+
* The project config this generation compiled, so a module the program does
|
|
377
|
+
* not contain can be told which program that was.
|
|
378
|
+
*/
|
|
379
|
+
tsconfig: string;
|
|
141
380
|
/**
|
|
142
381
|
* Metadata signature of each {@link inputHashes} entry whose hash was proven
|
|
143
382
|
* against an unracing read of the file on disk, in a tick the observed
|
|
@@ -149,6 +388,13 @@ export interface TtscCachedProjectTransform {
|
|
|
149
388
|
* disk bytes against the recorded hash, and may record a signature then.
|
|
150
389
|
*/
|
|
151
390
|
inputSignatures?: Record<string, string>;
|
|
391
|
+
/**
|
|
392
|
+
* Raw source hash of every readable key in the transform output, keyed by
|
|
393
|
+
* filesystem identity. Unlike {@link inputHashes}, this includes source
|
|
394
|
+
* outputs outside the project walk without adding arbitrary output keys to
|
|
395
|
+
* the complete project snapshot.
|
|
396
|
+
*/
|
|
397
|
+
sourceHashes?: Record<string, string>;
|
|
152
398
|
/** Metadata snapshot of every directory in the stable generation walk. */
|
|
153
399
|
projectDirectories?: TtscProjectDirectorySnapshot[];
|
|
154
400
|
/** Live notification state for universal host-input changes. */
|
|
@@ -193,19 +439,50 @@ export interface TtscCachedProjectTransform {
|
|
|
193
439
|
projectRoot: string;
|
|
194
440
|
/** Raw compiler output returned by {@link TtscCompiler.transform}. */
|
|
195
441
|
result: ITtscCompilerTransformation;
|
|
442
|
+
/**
|
|
443
|
+
* The delivery epoch this generation is currently settled against, or
|
|
444
|
+
* `undefined` for a generation no epoch has proven.
|
|
445
|
+
*
|
|
446
|
+
* Set when the generation is compiled, and again whenever a later epoch's
|
|
447
|
+
* first delivery proves the whole generation still matches the filesystem.
|
|
448
|
+
* While it equals the cache's current epoch, each module's first delivery is
|
|
449
|
+
* settled by the supplied source alone, exactly as it was when every pass
|
|
450
|
+
* compiled its own generation (samchon/ttsc#1300).
|
|
451
|
+
*/
|
|
452
|
+
deliveryEpoch?: number;
|
|
453
|
+
/**
|
|
454
|
+
* Whether this generation's non-error diagnostics have been surfaced at all,
|
|
455
|
+
* and the epoch they were last surfaced in.
|
|
456
|
+
*
|
|
457
|
+
* The diagnostics describe one compile of one program, so they belong to the
|
|
458
|
+
* generation rather than to a delivery; a pass that reuses a retained
|
|
459
|
+
* generation still surfaces them once, because a build's warnings are part of
|
|
460
|
+
* what that build reports (samchon/ttsc#1304). The two fields are separate so
|
|
461
|
+
* a persistent host, whose epoch is `undefined`, still reports the first
|
|
462
|
+
* time.
|
|
463
|
+
*/
|
|
464
|
+
diagnosticsReported?: boolean;
|
|
465
|
+
diagnosticsEpoch?: number;
|
|
196
466
|
/**
|
|
197
467
|
* Files already delivered from this generation, keyed by filesystem identity.
|
|
198
|
-
*
|
|
199
|
-
* module's first delivery inside the current
|
|
468
|
+
* A cache with a delivery epoch uses this to skip persistent validation only
|
|
469
|
+
* for a module's first delivery inside the current pass; the set is cleared
|
|
470
|
+
* whenever a new epoch's gate re-proves the generation.
|
|
200
471
|
*/
|
|
201
472
|
servedFiles?: Set<string>;
|
|
473
|
+
/**
|
|
474
|
+
* Absolute path of the adapter-owned scratch directory used for this
|
|
475
|
+
* generation. It is disposed after compilation, so none of its compiler,
|
|
476
|
+
* resolver, or plugin artifacts can be a persistent cache or watch input.
|
|
477
|
+
*/
|
|
478
|
+
scratchDirectory?: string;
|
|
202
479
|
/**
|
|
203
480
|
* Absolute path of the generated temp-dir tsconfig this compile ran against,
|
|
204
481
|
* when an alias/compiler-options overlay required one. The compiler reports
|
|
205
482
|
* it in the envelope's `graph.configs` chain, but it is disposed right after
|
|
206
483
|
* the compile, so registering it as a watch input would invalidate every
|
|
207
|
-
* bundler cache snapshot on the next build; watch derivation must skip
|
|
208
|
-
*
|
|
484
|
+
* bundler cache snapshot on the next build; watch derivation must skip this
|
|
485
|
+
* path. {@link scratchDirectory} owns the wider disposable-input bound.
|
|
209
486
|
*/
|
|
210
487
|
temporaryTsconfig?: string;
|
|
211
488
|
}
|
|
@@ -284,10 +561,30 @@ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
|
|
|
284
561
|
>();
|
|
285
562
|
|
|
286
563
|
/**
|
|
287
|
-
*
|
|
288
|
-
* {@link beginTtscTransformBuild}
|
|
564
|
+
* The current delivery epoch of each cache whose owner has declared a real
|
|
565
|
+
* per-pass lifecycle by calling {@link beginTtscTransformBuild}.
|
|
566
|
+
*
|
|
567
|
+
* A _delivery epoch_ is one bundler pass: the window inside which each module
|
|
568
|
+
* is requested at most once, so its first delivery may be settled against the
|
|
569
|
+
* state the pass started from. It is deliberately not the same fact as whether
|
|
570
|
+
* the generation is still valid, which the recorded snapshot answers.
|
|
571
|
+
* Conflating the two is what made every host with a repeating `buildStart` —
|
|
572
|
+
* webpack and Rspack watch, Rollup and Rolldown watch, `vite build --watch`,
|
|
573
|
+
* esbuild rebuild — discard a perfectly good whole-project compile on every
|
|
574
|
+
* edit (samchon/ttsc#1300).
|
|
575
|
+
*
|
|
576
|
+
* Absent from the map means persistent validation: a host with no pass boundary
|
|
577
|
+
* at all (a watching Vite dev server, Metro, the Turbopack loader), where every
|
|
578
|
+
* delivery proves the generation for itself.
|
|
289
579
|
*/
|
|
290
|
-
const
|
|
580
|
+
const TRANSFORM_CACHE_EPOCHS = new WeakMap<TtscTransformCache, number>();
|
|
581
|
+
|
|
582
|
+
/** The pass a delivery belongs to, or `undefined` under persistent validation. */
|
|
583
|
+
function transformCacheEpoch(
|
|
584
|
+
cache: TtscTransformCache | undefined,
|
|
585
|
+
): number | undefined {
|
|
586
|
+
return cache === undefined ? undefined : TRANSFORM_CACHE_EPOCHS.get(cache);
|
|
587
|
+
}
|
|
291
588
|
|
|
292
589
|
function createHostPathIdentityContext(
|
|
293
590
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
@@ -350,28 +647,41 @@ function resultFilesystem(
|
|
|
350
647
|
}
|
|
351
648
|
|
|
352
649
|
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
650
|
+
* Open a new delivery pass, enabling constant-time first delivery for every
|
|
651
|
+
* module this pass asks for.
|
|
652
|
+
*
|
|
653
|
+
* This deliberately retains the cached generation. The pass boundary is a
|
|
654
|
+
* statement about _deliveries_ — each module is requested at most once inside
|
|
655
|
+
* it — not about whether the compiled program is still correct, which the
|
|
656
|
+
* generation's own recorded snapshot answers and which
|
|
657
|
+
* {@link matchesCachedSource} proves once at the pass's first delivery. Clearing
|
|
658
|
+
* here instead made a host whose `buildStart` repeats recompile the whole
|
|
659
|
+
* project on every rebuild even when no compiler input had changed
|
|
660
|
+
* (samchon/ttsc#1300). Use {@link resetTtscTransformCache} to actually discard a
|
|
661
|
+
* generation and its watchers.
|
|
355
662
|
*
|
|
356
|
-
* Hosts without a guaranteed
|
|
357
|
-
*
|
|
663
|
+
* Hosts without a guaranteed pass boundary use persistent validation unless
|
|
664
|
+
* they have another immutable lifecycle. Bun runtime setup, for example,
|
|
358
665
|
* defines one process-scoped module-loading session.
|
|
359
666
|
*/
|
|
360
667
|
export function beginTtscTransformBuild(cache: TtscTransformCache): void {
|
|
361
|
-
|
|
362
|
-
|
|
668
|
+
TRANSFORM_CACHE_EPOCHS.set(
|
|
669
|
+
cache,
|
|
670
|
+
(TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1,
|
|
671
|
+
);
|
|
363
672
|
}
|
|
364
673
|
|
|
365
674
|
/**
|
|
366
|
-
*
|
|
675
|
+
* Discard every generation, dispose its watchers, and return the cache to
|
|
676
|
+
* persistent validation mode.
|
|
367
677
|
*
|
|
368
|
-
* This is
|
|
369
|
-
*
|
|
370
|
-
*
|
|
678
|
+
* This is the unconditional lifecycle boundary, and it is distinct from
|
|
679
|
+
* {@link beginTtscTransformBuild}: a pass ending is not a reason to throw a
|
|
680
|
+
* proven compile away, while a session ending is.
|
|
371
681
|
*/
|
|
372
682
|
export function resetTtscTransformCache(cache: TtscTransformCache): void {
|
|
373
683
|
clearTtscTransformCache(cache);
|
|
374
|
-
|
|
684
|
+
TRANSFORM_CACHE_EPOCHS.delete(cache);
|
|
375
685
|
}
|
|
376
686
|
|
|
377
687
|
/** Dispose generation-owned filesystem resources before clearing a cache. */
|
|
@@ -486,10 +796,35 @@ export async function transformTtsc(
|
|
|
486
796
|
});
|
|
487
797
|
|
|
488
798
|
for (;;) {
|
|
799
|
+
// Read once per iteration, before the cache is consulted, so a delivery
|
|
800
|
+
// belongs to the pass that was current when it started examining the
|
|
801
|
+
// generation. A pass opened while this one awaits an in-flight compile is
|
|
802
|
+
// picked up by the next iteration, which is the one that runs when the
|
|
803
|
+
// entry it awaited turns out to have been superseded.
|
|
804
|
+
const epoch = transformCacheEpoch(cache);
|
|
489
805
|
let transformed = cache?.get(key);
|
|
490
806
|
if (transformed !== undefined) {
|
|
491
|
-
|
|
492
|
-
|
|
807
|
+
const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
|
|
808
|
+
if (terminal !== undefined) {
|
|
809
|
+
// A terminal verdict is an answer about one observed environment, not an
|
|
810
|
+
// invitation for every later module to repeat the whole compile.
|
|
811
|
+
if (
|
|
812
|
+
replaysTerminalGeneration(terminal, epoch, {
|
|
813
|
+
currentFile: file,
|
|
814
|
+
currentSource: source,
|
|
815
|
+
filesystem,
|
|
816
|
+
})
|
|
817
|
+
) {
|
|
818
|
+
throw terminal;
|
|
819
|
+
}
|
|
820
|
+
evictGeneration(cache, key, transformed);
|
|
821
|
+
if (cache?.get(key) !== undefined) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
transformed = undefined;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (transformed !== undefined) {
|
|
493
828
|
const cached = await awaitOrEvict(cache, key, transformed);
|
|
494
829
|
TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
|
|
495
830
|
// While this caller awaited the old Promise, another caller may have
|
|
@@ -497,9 +832,7 @@ export async function transformTtsc(
|
|
|
497
832
|
if (cache?.get(key) !== transformed) {
|
|
498
833
|
continue;
|
|
499
834
|
}
|
|
500
|
-
|
|
501
|
-
cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
|
|
502
|
-
if (!buildScoped) {
|
|
835
|
+
if (epoch === undefined) {
|
|
503
836
|
await settleProjectMutationEvents(cached);
|
|
504
837
|
if (cache?.get(key) !== transformed) {
|
|
505
838
|
continue;
|
|
@@ -514,16 +847,33 @@ export async function transformTtsc(
|
|
|
514
847
|
projectRoot: cached.projectRoot,
|
|
515
848
|
result: cached.result,
|
|
516
849
|
}) &&
|
|
517
|
-
matchesCachedSource(cached, file, source,
|
|
850
|
+
matchesCachedSource(cached, file, source, epoch)
|
|
518
851
|
) {
|
|
519
|
-
reportSuccessDiagnostics(cached
|
|
852
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
520
853
|
// A resolved `"exception"` / `"failure"` envelope makes this throw;
|
|
521
|
-
// that is a failed generation too, so
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
854
|
+
// that is a failed generation too, so it is retained for this pass or
|
|
855
|
+
// evicted outside one before being surfaced.
|
|
856
|
+
let code: string;
|
|
857
|
+
try {
|
|
858
|
+
code = selectOrEvict(cache, key, transformed, epoch, {
|
|
859
|
+
file,
|
|
860
|
+
projectRoot: cached.projectRoot,
|
|
861
|
+
result: cached.result,
|
|
862
|
+
tsconfig: cached.tsconfig,
|
|
863
|
+
});
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
866
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
// The compile is fine and simply has nothing for this module, so the
|
|
870
|
+
// module goes back to the host untransformed rather than failing the
|
|
871
|
+
// build (samchon/ttsc#1308). It still counts as delivered in this
|
|
872
|
+
// pass, and there is nothing to watch for a file with no output.
|
|
873
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
874
|
+
markCachedSourceServed(cached, file);
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
527
877
|
notifyWatchInputs(hooks, cached, file);
|
|
528
878
|
markCachedSourceServed(cached, file);
|
|
529
879
|
return createTransformResult(source, code);
|
|
@@ -544,6 +894,10 @@ export async function transformTtsc(
|
|
|
544
894
|
compilerOptions: options.compilerOptions,
|
|
545
895
|
currentFile: file,
|
|
546
896
|
currentSource: source,
|
|
897
|
+
// Stamp the pass this compile was started for, not the one it happens
|
|
898
|
+
// to finish in: a boundary crossed mid-compile leaves the generation
|
|
899
|
+
// belonging to the earlier pass, so the next pass re-proves it.
|
|
900
|
+
deliveryEpoch: epoch,
|
|
547
901
|
filesystem,
|
|
548
902
|
plugins: options.plugins,
|
|
549
903
|
trackProjectMembership: cache !== undefined,
|
|
@@ -557,12 +911,24 @@ export async function transformTtsc(
|
|
|
557
911
|
continue;
|
|
558
912
|
}
|
|
559
913
|
const { projectRoot, result } = cached;
|
|
560
|
-
reportSuccessDiagnostics(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
914
|
+
reportSuccessDiagnostics(cached, epoch);
|
|
915
|
+
let code: string;
|
|
916
|
+
try {
|
|
917
|
+
code = selectOrEvict(cache, key, generation, epoch, {
|
|
918
|
+
file,
|
|
919
|
+
projectRoot,
|
|
920
|
+
result,
|
|
921
|
+
tsconfig: cached.tsconfig,
|
|
922
|
+
});
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (!(error instanceof TtscMissingProgramOutputError)) {
|
|
925
|
+
notifyFailedGenerationInputs(hooks, cached);
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
reportMissingProgramOutput(cached, error, epoch);
|
|
929
|
+
markCachedSourceServed(cached, file);
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
566
932
|
notifyWatchInputs(hooks, cached, file);
|
|
567
933
|
markCachedSourceServed(cached, file);
|
|
568
934
|
if (
|
|
@@ -575,13 +941,15 @@ export async function transformTtsc(
|
|
|
575
941
|
}
|
|
576
942
|
|
|
577
943
|
/**
|
|
578
|
-
* Await a cached generation,
|
|
944
|
+
* Await a cached generation, retaining only terminal proof failures.
|
|
579
945
|
*
|
|
580
946
|
* The cache stores the in-flight transform Promise before it settles so
|
|
581
|
-
* concurrent callers share one compilation.
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
947
|
+
* concurrent callers share one compilation. Ordinary compiler and host
|
|
948
|
+
* rejections are evicted so a transient failure cannot become permanent. A
|
|
949
|
+
* bounded stabilization failure is different: it already spent its retry and
|
|
950
|
+
* repeating it for every later module recreates the issue this gate prevents.
|
|
951
|
+
* It stays authoritative until its retained input baseline changes or the cache
|
|
952
|
+
* owner starts a new lifecycle.
|
|
585
953
|
*/
|
|
586
954
|
async function awaitOrEvict(
|
|
587
955
|
cache: TtscTransformCache | undefined,
|
|
@@ -591,35 +959,159 @@ async function awaitOrEvict(
|
|
|
591
959
|
try {
|
|
592
960
|
return await generation;
|
|
593
961
|
} catch (error) {
|
|
594
|
-
|
|
962
|
+
if (
|
|
963
|
+
error instanceof TtscUnstableGenerationError &&
|
|
964
|
+
cache?.get(key) === generation
|
|
965
|
+
) {
|
|
966
|
+
TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
|
|
967
|
+
} else {
|
|
968
|
+
evictGeneration(cache, key, generation);
|
|
969
|
+
}
|
|
595
970
|
throw error;
|
|
596
971
|
}
|
|
597
972
|
}
|
|
598
973
|
|
|
599
974
|
/**
|
|
600
|
-
* Extract the transformed source,
|
|
601
|
-
*
|
|
602
|
-
* {@link selectTransformedSource}
|
|
603
|
-
*
|
|
975
|
+
* Extract the transformed source, and decide what a throwing generation is.
|
|
976
|
+
*
|
|
977
|
+
* {@link selectTransformedSource} throws for two different reasons, and only one
|
|
978
|
+
* of them is about the generation. A host `"exception"` or a compiler
|
|
979
|
+
* `"failure"` means the compile produced nothing for anyone: inside a pass that
|
|
980
|
+
* verdict is retained and replayed, because evicting it made every remaining
|
|
981
|
+
* module repeat the whole-project transform only to reach the identical answer
|
|
982
|
+
* (samchon/ttsc#1303), and outside a pass it keeps being evicted so a
|
|
983
|
+
* long-lived worker retries on its very next delivery exactly as before.
|
|
984
|
+
*
|
|
985
|
+
* A `"success"` envelope that has no output for the module asking is the other
|
|
986
|
+
* reason, and it is a fact about that one file: an ordinary condition for a
|
|
987
|
+
* module the bundle reaches and the tsconfig program does not contain. It is
|
|
988
|
+
* neither retained nor evicted. The error reaches the caller, and the
|
|
989
|
+
* generation, which compiled perfectly well for every other module, stays.
|
|
604
990
|
*/
|
|
605
991
|
function selectOrEvict(
|
|
606
992
|
cache: TtscTransformCache | undefined,
|
|
607
993
|
key: string,
|
|
608
994
|
generation: Promise<TtscCachedProjectTransform>,
|
|
995
|
+
epoch: number | undefined,
|
|
609
996
|
props: {
|
|
610
997
|
file: string;
|
|
611
998
|
projectRoot: string;
|
|
612
999
|
result: ITtscCompilerTransformation;
|
|
1000
|
+
tsconfig: string;
|
|
613
1001
|
},
|
|
614
1002
|
): string {
|
|
615
1003
|
try {
|
|
616
1004
|
return selectTransformedSource(props);
|
|
617
1005
|
} catch (error) {
|
|
618
|
-
|
|
1006
|
+
const verdict = retainPassVerdict(
|
|
1007
|
+
cache,
|
|
1008
|
+
key,
|
|
1009
|
+
generation,
|
|
1010
|
+
epoch,
|
|
1011
|
+
props.result,
|
|
1012
|
+
error,
|
|
1013
|
+
);
|
|
1014
|
+
if (verdict !== undefined) {
|
|
1015
|
+
throw verdict;
|
|
1016
|
+
}
|
|
1017
|
+
// A generation that compiled fine and simply has no output for the module
|
|
1018
|
+
// asking is not a failed generation. Discarding it made every later module
|
|
1019
|
+
// recompile the whole project to reach the same answer, which is the cost
|
|
1020
|
+
// samchon/ttsc#1303 is about, for a bundle that merely reaches a file the
|
|
1021
|
+
// tsconfig program does not contain.
|
|
1022
|
+
if (props.result.type !== "success") {
|
|
1023
|
+
evictGeneration(cache, key, generation);
|
|
1024
|
+
}
|
|
619
1025
|
throw error;
|
|
620
1026
|
}
|
|
621
1027
|
}
|
|
622
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* Retain the verdict of a compile this pass already attempted, or return
|
|
1031
|
+
* `undefined` when nothing may be retained.
|
|
1032
|
+
*
|
|
1033
|
+
* Only inside a delivery pass. A pass is the window in which every delivery is
|
|
1034
|
+
* settled against the state the pass started from, so an attempt it already
|
|
1035
|
+
* made is part of that state and the remaining modules replay it rather than
|
|
1036
|
+
* each repeating a whole-project transform to reach the same answer. Outside a
|
|
1037
|
+
* pass there is no such window, and a long-lived worker must keep retrying on
|
|
1038
|
+
* its very next delivery so a transient host failure never becomes permanent.
|
|
1039
|
+
*/
|
|
1040
|
+
function retainPassVerdict(
|
|
1041
|
+
cache: TtscTransformCache | undefined,
|
|
1042
|
+
key: string,
|
|
1043
|
+
generation: Promise<TtscCachedProjectTransform>,
|
|
1044
|
+
epoch: number | undefined,
|
|
1045
|
+
result: ITtscCompilerTransformation,
|
|
1046
|
+
error: unknown,
|
|
1047
|
+
): TtscTerminalGenerationError | undefined {
|
|
1048
|
+
// Only an envelope that failed outright is a statement about the generation.
|
|
1049
|
+
// `selectTransformedSource` also throws for a file the compile simply has no
|
|
1050
|
+
// output for, which is an ordinary condition for a module the bundle reaches
|
|
1051
|
+
// but the tsconfig program does not contain, and which says nothing about the
|
|
1052
|
+
// other modules. Retaining that would fail the whole pass, naming a file none
|
|
1053
|
+
// of them asked about.
|
|
1054
|
+
if (
|
|
1055
|
+
result.type === "success" ||
|
|
1056
|
+
epoch === undefined ||
|
|
1057
|
+
cache?.get(key) !== generation
|
|
1058
|
+
) {
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
const existing = TERMINAL_TRANSFORM_GENERATIONS.get(generation);
|
|
1062
|
+
if (existing !== undefined) {
|
|
1063
|
+
return existing;
|
|
1064
|
+
}
|
|
1065
|
+
const verdict = new TtscPassVerdictError(error, epoch);
|
|
1066
|
+
TERMINAL_TRANSFORM_GENERATIONS.set(generation, verdict);
|
|
1067
|
+
return verdict;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Whether a terminal verdict still answers for this delivery.
|
|
1072
|
+
*
|
|
1073
|
+
* Inside the pass that produced or confirmed it, it is replayed without
|
|
1074
|
+
* re-probing anything: the pass settles every delivery against the state it
|
|
1075
|
+
* started from, so re-walking the project once per module would spend exactly
|
|
1076
|
+
* the cost this gate exists to remove.
|
|
1077
|
+
*
|
|
1078
|
+
* Across passes the two kinds part company. A pass verdict is dropped, because
|
|
1079
|
+
* a new pass is the first boundary at which the host itself claims something
|
|
1080
|
+
* may have changed, and the compile it stood for was never proven against a
|
|
1081
|
+
* recorded environment. An unstable generation was, so it keeps its own rule:
|
|
1082
|
+
* one fresh attempt per pass, and otherwise replayed until that recorded
|
|
1083
|
+
* environment provably moves.
|
|
1084
|
+
*/
|
|
1085
|
+
function replaysTerminalGeneration(
|
|
1086
|
+
terminal: TtscTerminalGenerationError,
|
|
1087
|
+
epoch: number | undefined,
|
|
1088
|
+
props: {
|
|
1089
|
+
currentFile: string;
|
|
1090
|
+
currentSource: string;
|
|
1091
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
1092
|
+
},
|
|
1093
|
+
): boolean {
|
|
1094
|
+
if (terminal instanceof TtscPassVerdictError) {
|
|
1095
|
+
// A pass verdict has no recorded environment to re-confirm against, so the
|
|
1096
|
+
// pass that produced it is its whole scope.
|
|
1097
|
+
return epoch !== undefined && terminal.epoch === epoch;
|
|
1098
|
+
}
|
|
1099
|
+
if (!(terminal instanceof TtscUnstableGenerationError)) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
// An unstable generation does have one, and confirming it per delivery is the
|
|
1103
|
+
// behaviour its own contract describes, so the pass does not cache that
|
|
1104
|
+
// answer. A new pass still grants the fresh attempt the per-pass cache clear
|
|
1105
|
+
// used to give it.
|
|
1106
|
+
if (
|
|
1107
|
+
epoch !== undefined &&
|
|
1108
|
+
terminal.validation.cached.deliveryEpoch !== epoch
|
|
1109
|
+
) {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
return !failedGenerationEnvironmentChanged(terminal.validation, props);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
623
1115
|
/**
|
|
624
1116
|
* Delete a failed generation from the cache only when it is still the entry
|
|
625
1117
|
* stored under `key`. The identity check prevents an older failed generation's
|
|
@@ -791,6 +1283,8 @@ interface TtscEnvelopeGraphIndexes {
|
|
|
791
1283
|
string,
|
|
792
1284
|
{ hash: string | null; path: string; realpath: string | null }
|
|
793
1285
|
>;
|
|
1286
|
+
/** Native compiler-observation failure for an unproven member identity. */
|
|
1287
|
+
readonly inputProofFailures: Map<string, string>;
|
|
794
1288
|
/** Aliased graph proof keys that reported contradictory generation states. */
|
|
795
1289
|
readonly inputProofConflicts: Set<string>;
|
|
796
1290
|
}
|
|
@@ -849,6 +1343,7 @@ function envelopeGraphIndexes(
|
|
|
849
1343
|
members: new Set(),
|
|
850
1344
|
speculative: new Set(),
|
|
851
1345
|
inputProofs: new Map(),
|
|
1346
|
+
inputProofFailures: new Map(),
|
|
852
1347
|
inputProofConflicts: new Set(),
|
|
853
1348
|
};
|
|
854
1349
|
const graph =
|
|
@@ -871,7 +1366,11 @@ function envelopeGraphIndexes(
|
|
|
871
1366
|
)
|
|
872
1367
|
.map((target) => {
|
|
873
1368
|
const absoluteTarget = path.resolve(props.projectRoot, target);
|
|
874
|
-
|
|
1369
|
+
const targetIdentity = derivationIdentity(state, absoluteTarget);
|
|
1370
|
+
built.members.add(targetIdentity);
|
|
1371
|
+
if (!built.spellings.has(targetIdentity)) {
|
|
1372
|
+
built.spellings.set(targetIdentity, absoluteTarget);
|
|
1373
|
+
}
|
|
875
1374
|
return absoluteTarget;
|
|
876
1375
|
}),
|
|
877
1376
|
);
|
|
@@ -880,7 +1379,9 @@ function envelopeGraphIndexes(
|
|
|
880
1379
|
built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
|
|
881
1380
|
built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
|
|
882
1381
|
for (const input of [...built.globals, ...built.configs]) {
|
|
883
|
-
|
|
1382
|
+
const identity = derivationIdentity(state, input);
|
|
1383
|
+
built.members.add(identity);
|
|
1384
|
+
if (!built.spellings.has(identity)) built.spellings.set(identity, input);
|
|
884
1385
|
}
|
|
885
1386
|
const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
|
|
886
1387
|
(entry) => Array.isArray(entry[1]),
|
|
@@ -890,9 +1391,12 @@ function envelopeGraphIndexes(
|
|
|
890
1391
|
// candidate could be classified speculative before a later entry proves
|
|
891
1392
|
// the same path is a realized source.
|
|
892
1393
|
for (const [source] of candidateEntries) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
);
|
|
1394
|
+
const absoluteSource = path.resolve(props.projectRoot, source);
|
|
1395
|
+
const identity = derivationIdentity(state, absoluteSource);
|
|
1396
|
+
built.members.add(identity);
|
|
1397
|
+
if (!built.spellings.has(identity)) {
|
|
1398
|
+
built.spellings.set(identity, absoluteSource);
|
|
1399
|
+
}
|
|
896
1400
|
}
|
|
897
1401
|
const realized = new Set(built.members);
|
|
898
1402
|
for (const [source, candidates] of candidateEntries) {
|
|
@@ -914,6 +1418,22 @@ function envelopeGraphIndexes(
|
|
|
914
1418
|
// only as a candidate.
|
|
915
1419
|
if (!realized.has(identity)) built.speculative.add(identity);
|
|
916
1420
|
built.members.add(identity);
|
|
1421
|
+
if (!built.spellings.has(identity)) {
|
|
1422
|
+
built.spellings.set(
|
|
1423
|
+
identity,
|
|
1424
|
+
path.resolve(props.projectRoot, candidate),
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const transformSources = new Set<string>();
|
|
1430
|
+
if (props.result.type === "success") {
|
|
1431
|
+
for (const output of Object.keys(props.result.typescript)) {
|
|
1432
|
+
if (!isDeclarationFile(output)) {
|
|
1433
|
+
transformSources.add(
|
|
1434
|
+
derivationIdentity(state, path.resolve(props.projectRoot, output)),
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
917
1437
|
}
|
|
918
1438
|
}
|
|
919
1439
|
for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
|
|
@@ -939,7 +1459,9 @@ function envelopeGraphIndexes(
|
|
|
939
1459
|
}
|
|
940
1460
|
const absolute = path.resolve(props.projectRoot, input);
|
|
941
1461
|
const identity = derivationIdentity(state, absolute);
|
|
942
|
-
if (!built.members.has(identity))
|
|
1462
|
+
if (!built.members.has(identity) && !transformSources.has(identity)) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
943
1465
|
const proof = {
|
|
944
1466
|
hash,
|
|
945
1467
|
path: absolute,
|
|
@@ -962,6 +1484,25 @@ function envelopeGraphIndexes(
|
|
|
962
1484
|
built.inputProofs.set(identity, proof);
|
|
963
1485
|
}
|
|
964
1486
|
}
|
|
1487
|
+
for (const [input, reason] of Object.entries(
|
|
1488
|
+
graph.inputProofFailures ?? {},
|
|
1489
|
+
)) {
|
|
1490
|
+
if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
const absolute = path.resolve(props.projectRoot, input);
|
|
1494
|
+
const identity = derivationIdentity(state, absolute);
|
|
1495
|
+
if (!built.members.has(identity) && !transformSources.has(identity)) {
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (built.inputProofs.has(identity)) {
|
|
1499
|
+
built.inputProofs.delete(identity);
|
|
1500
|
+
built.inputProofConflicts.add(identity);
|
|
1501
|
+
}
|
|
1502
|
+
if (!built.inputProofFailures.has(identity)) {
|
|
1503
|
+
built.inputProofFailures.set(identity, reason);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
965
1506
|
}
|
|
966
1507
|
state.graph = built;
|
|
967
1508
|
return built;
|
|
@@ -1018,9 +1559,55 @@ function collectDeclaredIdentities(
|
|
|
1018
1559
|
* Envelope keys mirror the `typescript` keys (project-relative); values may be
|
|
1019
1560
|
* project-relative or absolute. Every path is absolutized against the project
|
|
1020
1561
|
* root and deduplicated; the file itself is dropped (the bundler already
|
|
1021
|
-
* watches the module it transforms), and so is the disposed
|
|
1022
|
-
* (see
|
|
1562
|
+
* watches the module it transforms), and so is every path in the disposed
|
|
1563
|
+
* transform scratch tree (see
|
|
1564
|
+
* {@link TtscCachedProjectTransform.scratchDirectory}).
|
|
1565
|
+
*/
|
|
1566
|
+
/**
|
|
1567
|
+
* Register the failed generation's own project inputs so the host can observe
|
|
1568
|
+
* the fix.
|
|
1569
|
+
*
|
|
1570
|
+
* A successful delivery registers the derived watch inputs, which is how a
|
|
1571
|
+
* type-only file that no bundler graph contains still invalidates the modules
|
|
1572
|
+
* depending on it. A failed one used to register nothing: `selectWatchInputs`
|
|
1573
|
+
* returns an empty list for an `"exception"` envelope, and the throw happens
|
|
1574
|
+
* before `notifyWatchInputs` is reached at all. When the failing compile is the
|
|
1575
|
+
* first of a watching session, that leaves no channel through which the fix can
|
|
1576
|
+
* arrive: the user repairs a file the bundler does not track, nothing is
|
|
1577
|
+
* invalidated, and the error stays on screen (samchon/ttsc#1312).
|
|
1578
|
+
*
|
|
1579
|
+
* The generation records the project walk even when the compile failed, so the
|
|
1580
|
+
* files a fix would touch are exactly what it already holds. The cost is paid
|
|
1581
|
+
* only on a failure, and only until the next compile succeeds and narrows the
|
|
1582
|
+
* set back to the derived inputs.
|
|
1023
1583
|
*/
|
|
1584
|
+
function notifyFailedGenerationInputs(
|
|
1585
|
+
hooks: TtscTransformHooks | undefined,
|
|
1586
|
+
cached: TtscCachedProjectTransform,
|
|
1587
|
+
): void {
|
|
1588
|
+
const addWatchFile = hooks?.addWatchFile;
|
|
1589
|
+
if (addWatchFile === undefined) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
for (const key of Object.keys(cached.inputHashes)) {
|
|
1593
|
+
const input = path.resolve(cached.projectRoot, key);
|
|
1594
|
+
if (isTransformScratchInput(input, cached.scratchDirectory)) {
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
// No evidence argument, deliberately. `missing: false` would be a claim
|
|
1598
|
+
// this path cannot back: a failed generation is replayed for the rest of
|
|
1599
|
+
// its pass without re-proving its inputs, so the walk that recorded them
|
|
1600
|
+
// may be older than the delivery, and one of them having been deleted is a
|
|
1601
|
+
// live reason for that compile to have failed. Letting the adapter probe
|
|
1602
|
+
// also routes an absent input to the missing-input poll, which is the only
|
|
1603
|
+
// channel through which restoring it can invalidate anything: a bundler
|
|
1604
|
+
// watch on a path that does not exist registers nothing, and no module
|
|
1605
|
+
// graph carries a type-only input. It costs one `existsSync` per input,
|
|
1606
|
+
// and only where the adapter reads evidence at all, which is Vite serve.
|
|
1607
|
+
addWatchFile(input);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1024
1611
|
function notifyWatchInputs(
|
|
1025
1612
|
hooks: TtscTransformHooks | undefined,
|
|
1026
1613
|
cached: TtscCachedProjectTransform,
|
|
@@ -1036,6 +1623,7 @@ function notifyWatchInputs(
|
|
|
1036
1623
|
file,
|
|
1037
1624
|
projectRoot: cached.projectRoot,
|
|
1038
1625
|
result: cached.result,
|
|
1626
|
+
scratchDirectory: cached.scratchDirectory,
|
|
1039
1627
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1040
1628
|
})) {
|
|
1041
1629
|
// Hand the adapter the identity this generation already resolved and the
|
|
@@ -1078,6 +1666,7 @@ function selectWatchInputs(props: {
|
|
|
1078
1666
|
file: string;
|
|
1079
1667
|
projectRoot: string;
|
|
1080
1668
|
result: ITtscCompilerTransformation;
|
|
1669
|
+
scratchDirectory?: string;
|
|
1081
1670
|
temporaryTsconfig?: string;
|
|
1082
1671
|
}): string[] {
|
|
1083
1672
|
if (props.result.type === "exception") {
|
|
@@ -1101,6 +1690,7 @@ function deriveWatchInputs(
|
|
|
1101
1690
|
file: string;
|
|
1102
1691
|
projectRoot: string;
|
|
1103
1692
|
result: ITtscCompilerTransformation;
|
|
1693
|
+
scratchDirectory?: string;
|
|
1104
1694
|
temporaryTsconfig?: string;
|
|
1105
1695
|
},
|
|
1106
1696
|
fileIdentity: string,
|
|
@@ -1123,6 +1713,7 @@ function deriveWatchInputs(
|
|
|
1123
1713
|
if (
|
|
1124
1714
|
spelling === currentSpelling ||
|
|
1125
1715
|
spelling === temporarySpelling ||
|
|
1716
|
+
isTransformScratchInput(spelling, props.scratchDirectory) ||
|
|
1126
1717
|
lexicalSeen.has(spelling)
|
|
1127
1718
|
) {
|
|
1128
1719
|
return;
|
|
@@ -1132,6 +1723,7 @@ function deriveWatchInputs(
|
|
|
1132
1723
|
output.push(input);
|
|
1133
1724
|
};
|
|
1134
1725
|
const appendPhysical = (input: string): void => {
|
|
1726
|
+
if (isTransformScratchInput(input, props.scratchDirectory)) return;
|
|
1135
1727
|
const identity = derivationIdentity(state, input);
|
|
1136
1728
|
if (excluded.has(identity) || physicalSeen.has(identity)) return;
|
|
1137
1729
|
physicalSeen.add(identity);
|
|
@@ -1462,11 +2054,24 @@ export function stripQuery(id: string): string {
|
|
|
1462
2054
|
}
|
|
1463
2055
|
|
|
1464
2056
|
/**
|
|
1465
|
-
* Returns `true` for
|
|
1466
|
-
* `.d.cts`
|
|
2057
|
+
* Returns `true` for every declaration-file spelling TypeScript-Go accepts.
|
|
2058
|
+
* Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
|
|
2059
|
+
* treats an arbitrary-extension source such as `styles.d.css.ts` as a
|
|
2060
|
+
* declaration file too.
|
|
1467
2061
|
*/
|
|
1468
2062
|
export function isDeclarationFile(id: string): boolean {
|
|
1469
|
-
|
|
2063
|
+
// Module ids can cross process/platform boundaries (for example, a Windows
|
|
2064
|
+
// id inspected by a POSIX host). TypeScript-Go normalizes both separators
|
|
2065
|
+
// before taking the basename, so a `.d.` directory component must not turn
|
|
2066
|
+
// an ordinary source into a declaration file.
|
|
2067
|
+
const normalized = id.replaceAll("\\", "/");
|
|
2068
|
+
const base = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
2069
|
+
return (
|
|
2070
|
+
base.endsWith(".d.ts") ||
|
|
2071
|
+
base.endsWith(".d.mts") ||
|
|
2072
|
+
base.endsWith(".d.cts") ||
|
|
2073
|
+
(base.endsWith(".ts") && base.includes(".d."))
|
|
2074
|
+
);
|
|
1470
2075
|
}
|
|
1471
2076
|
|
|
1472
2077
|
/**
|
|
@@ -1502,32 +2107,50 @@ export function createTransformResult(
|
|
|
1502
2107
|
* state.
|
|
1503
2108
|
*
|
|
1504
2109
|
* Always compares the current module's in-memory source with the generation
|
|
1505
|
-
* snapshot. A cache
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
2110
|
+
* snapshot. A cache with a delivery epoch can use that comparison alone for a
|
|
2111
|
+
* stable generation's first delivery of each module in the current pass, once
|
|
2112
|
+
* the pass's own first delivery has proven the whole generation still matches
|
|
2113
|
+
* the filesystem. An incomplete generation may not take this shortcut:
|
|
2114
|
+
* otherwise a sibling output captured during a filesystem race could still be
|
|
2115
|
+
* served once. Later graph-bearing requests validate the file's derived input
|
|
2116
|
+
* set and project membership; graph-free envelopes conservatively re-hash the
|
|
2117
|
+
* complete project and out-of-walk snapshots. Any mismatch forces a complete
|
|
1512
2118
|
* re-transform.
|
|
1513
2119
|
*/
|
|
1514
2120
|
function matchesCachedSource(
|
|
1515
2121
|
cached: TtscCachedProjectTransform,
|
|
1516
2122
|
file: string,
|
|
1517
2123
|
source: string,
|
|
1518
|
-
|
|
2124
|
+
epoch: number | undefined,
|
|
1519
2125
|
): boolean {
|
|
1520
2126
|
const identities = envelopeDerivation(cached).identityContext;
|
|
1521
2127
|
const currentKey = toProjectKey(cached.projectRoot, file, identities);
|
|
1522
|
-
|
|
2128
|
+
const identity = pathIdentityKey(file, identities);
|
|
2129
|
+
const expected =
|
|
2130
|
+
cached.sourceHashes?.[identity] ??
|
|
2131
|
+
cached.inputHashes[currentKey] ??
|
|
2132
|
+
cached.externalInputHashes?.[identity];
|
|
2133
|
+
if (expected !== hashText(source)) {
|
|
1523
2134
|
return false;
|
|
1524
2135
|
}
|
|
1525
|
-
if (
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
2136
|
+
if (epoch !== undefined && cached.projectSnapshotComplete === true) {
|
|
2137
|
+
if (cached.deliveryEpoch !== epoch) {
|
|
2138
|
+
// The pass's first delivery. The generation was settled against an
|
|
2139
|
+
// earlier pass, so prove the whole of it once — every input the envelope
|
|
2140
|
+
// declares, the directory membership, the universal host inputs, and the
|
|
2141
|
+
// out-of-walk snapshot — before any of this pass's deliveries may be
|
|
2142
|
+
// settled against it. That proof is what a per-pass recompile used to buy
|
|
2143
|
+
// (samchon/ttsc#1300), at a walk instead of a compile.
|
|
2144
|
+
if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
|
|
2145
|
+
return false;
|
|
2146
|
+
}
|
|
2147
|
+
cached.deliveryEpoch = epoch;
|
|
2148
|
+
cached.servedFiles?.clear();
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
if (!cached.servedFiles?.has(identity)) {
|
|
2152
|
+
return true;
|
|
2153
|
+
}
|
|
1531
2154
|
}
|
|
1532
2155
|
if (
|
|
1533
2156
|
cached.result.type !== "exception" &&
|
|
@@ -1583,6 +2206,7 @@ function matchesNarrowPersistentInputs(
|
|
|
1583
2206
|
file,
|
|
1584
2207
|
projectRoot: cached.projectRoot,
|
|
1585
2208
|
result: cached.result,
|
|
2209
|
+
scratchDirectory: cached.scratchDirectory,
|
|
1586
2210
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1587
2211
|
});
|
|
1588
2212
|
for (const input of inputs) {
|
|
@@ -1839,9 +2463,13 @@ function isMissingPathError(error: unknown): boolean {
|
|
|
1839
2463
|
function captureUniversalHostInputValidation(
|
|
1840
2464
|
cached: TtscCachedProjectTransform,
|
|
1841
2465
|
currentFile: string,
|
|
1842
|
-
):
|
|
2466
|
+
): {
|
|
2467
|
+
failures: TtscGenerationProofFailures;
|
|
2468
|
+
validation?: TtscHostInputValidation;
|
|
2469
|
+
} {
|
|
1843
2470
|
const filesystem = resultFilesystem(cached.result);
|
|
1844
2471
|
const state = envelopeDerivation(cached);
|
|
2472
|
+
const failures = createGenerationProofFailures();
|
|
1845
2473
|
const validation: TtscHostInputValidation = {
|
|
1846
2474
|
entries: new Map(),
|
|
1847
2475
|
covered: new Set(),
|
|
@@ -1851,6 +2479,7 @@ function captureUniversalHostInputValidation(
|
|
|
1851
2479
|
filesystem,
|
|
1852
2480
|
projectRoot: cached.projectRoot,
|
|
1853
2481
|
result: cached.result,
|
|
2482
|
+
scratchDirectory: cached.scratchDirectory,
|
|
1854
2483
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1855
2484
|
})) {
|
|
1856
2485
|
const generationHashes =
|
|
@@ -1868,7 +2497,14 @@ function captureUniversalHostInputValidation(
|
|
|
1868
2497
|
let readable = false;
|
|
1869
2498
|
if (expected === undefined) {
|
|
1870
2499
|
const current = path.resolve(currentFile);
|
|
1871
|
-
if (path.resolve(input) !== current)
|
|
2500
|
+
if (path.resolve(input) !== current) {
|
|
2501
|
+
recordGenerationProofFailure(failures, {
|
|
2502
|
+
domain: "host",
|
|
2503
|
+
kind: "content-proof-missing",
|
|
2504
|
+
path: input,
|
|
2505
|
+
});
|
|
2506
|
+
return { failures };
|
|
2507
|
+
}
|
|
1872
2508
|
// The current module may be supplied from an unsaved editor buffer. Its
|
|
1873
2509
|
// generation snapshot is overlaid below from `currentSource`, so a disk
|
|
1874
2510
|
// fingerprint would be both unavailable and the wrong authority. The
|
|
@@ -1877,7 +2513,12 @@ function captureUniversalHostInputValidation(
|
|
|
1877
2513
|
} else {
|
|
1878
2514
|
const current = hostInputStateHash(input, filesystem);
|
|
1879
2515
|
if (expected !== current) {
|
|
1880
|
-
|
|
2516
|
+
recordGenerationProofFailure(failures, {
|
|
2517
|
+
domain: "host",
|
|
2518
|
+
kind: "content-changed",
|
|
2519
|
+
path: input,
|
|
2520
|
+
});
|
|
2521
|
+
return { failures };
|
|
1881
2522
|
}
|
|
1882
2523
|
// A path both sides agree they could not read carries no bytes for a
|
|
1883
2524
|
// signature to stand for. It still belongs in the manifest, so the
|
|
@@ -1897,14 +2538,38 @@ function captureUniversalHostInputValidation(
|
|
|
1897
2538
|
state.identityContext,
|
|
1898
2539
|
)
|
|
1899
2540
|
) {
|
|
1900
|
-
|
|
2541
|
+
recordGenerationProofFailure(failures, {
|
|
2542
|
+
domain: "host",
|
|
2543
|
+
kind: Object.prototype.hasOwnProperty.call(
|
|
2544
|
+
generationRealpaths,
|
|
2545
|
+
absoluteInput,
|
|
2546
|
+
)
|
|
2547
|
+
? "realpath-changed"
|
|
2548
|
+
: "realpath-proof-missing",
|
|
2549
|
+
path: input,
|
|
2550
|
+
});
|
|
2551
|
+
return { failures };
|
|
1901
2552
|
}
|
|
1902
2553
|
}
|
|
1903
2554
|
validation.covered.add(path.resolve(input));
|
|
1904
2555
|
const before = inputMetadataEvidence(input, filesystem);
|
|
1905
|
-
if (!matchesRecordedInput(cached, input))
|
|
2556
|
+
if (!matchesRecordedInput(cached, input)) {
|
|
2557
|
+
recordGenerationProofFailure(failures, {
|
|
2558
|
+
domain: "host",
|
|
2559
|
+
kind: "snapshot-mismatch",
|
|
2560
|
+
path: input,
|
|
2561
|
+
});
|
|
2562
|
+
return { failures };
|
|
2563
|
+
}
|
|
1906
2564
|
const after = inputMetadataSignature(input, filesystem);
|
|
1907
|
-
if (before?.signature !== after)
|
|
2565
|
+
if (before?.signature !== after) {
|
|
2566
|
+
recordGenerationProofFailure(failures, {
|
|
2567
|
+
domain: "host",
|
|
2568
|
+
kind: "changed-during-validation",
|
|
2569
|
+
path: input,
|
|
2570
|
+
});
|
|
2571
|
+
return { failures };
|
|
2572
|
+
}
|
|
1908
2573
|
if (before !== undefined) {
|
|
1909
2574
|
// Do not key this manifest by physical identity. A symlink/junction
|
|
1910
2575
|
// spelling and its selected target deliberately share that identity,
|
|
@@ -1924,7 +2589,14 @@ function captureUniversalHostInputValidation(
|
|
|
1924
2589
|
const probe = missingPathProbe(input, filesystem);
|
|
1925
2590
|
if (probe.blocker !== undefined) {
|
|
1926
2591
|
const signature = inputMetadataSignature(probe.blocker, filesystem);
|
|
1927
|
-
if (signature === undefined)
|
|
2592
|
+
if (signature === undefined) {
|
|
2593
|
+
recordGenerationProofFailure(failures, {
|
|
2594
|
+
domain: "host",
|
|
2595
|
+
kind: "blocker-metadata-unavailable",
|
|
2596
|
+
path: probe.blocker,
|
|
2597
|
+
});
|
|
2598
|
+
return { failures };
|
|
2599
|
+
}
|
|
1928
2600
|
// A blocker proves a kind and an identity, not content: it is the
|
|
1929
2601
|
// non-directory ancestor that makes everything below it unreachable, and
|
|
1930
2602
|
// it cannot stop being that without its metadata moving. So it keeps a
|
|
@@ -1956,7 +2628,7 @@ function captureUniversalHostInputValidation(
|
|
|
1956
2628
|
);
|
|
1957
2629
|
}
|
|
1958
2630
|
cached.hostInputValidation = validation;
|
|
1959
|
-
return validation;
|
|
2631
|
+
return { failures, validation };
|
|
1960
2632
|
}
|
|
1961
2633
|
|
|
1962
2634
|
/**
|
|
@@ -2335,6 +3007,12 @@ function matchesCompleteInputSnapshot(
|
|
|
2335
3007
|
cached.inputSignatures === undefined
|
|
2336
3008
|
? undefined
|
|
2337
3009
|
: { hashes: cached.inputHashes, signatures: cached.inputSignatures },
|
|
3010
|
+
{
|
|
3011
|
+
// Judge membership by the rule the compile ran under, and read only the
|
|
3012
|
+
// inputs this comparison actually consults.
|
|
3013
|
+
declaredKeys: declaredInputs,
|
|
3014
|
+
policy: cached.membershipPolicy,
|
|
3015
|
+
},
|
|
2338
3016
|
);
|
|
2339
3017
|
if (!walkSnapshotComplete(current, declaredInputs)) {
|
|
2340
3018
|
return false;
|
|
@@ -2347,7 +3025,9 @@ function matchesCompleteInputSnapshot(
|
|
|
2347
3025
|
) {
|
|
2348
3026
|
return false;
|
|
2349
3027
|
}
|
|
2350
|
-
|
|
3028
|
+
if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
|
|
3029
|
+
current.hashes[currentKey] = hashText(source);
|
|
3030
|
+
}
|
|
2351
3031
|
if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
|
|
2352
3032
|
return false;
|
|
2353
3033
|
}
|
|
@@ -2430,16 +3110,17 @@ function matchesExternalInputRealpaths(
|
|
|
2430
3110
|
|
|
2431
3111
|
/**
|
|
2432
3112
|
* Capture external-input hashes without attaching post-compile state to an
|
|
2433
|
-
* earlier graph. Graph members
|
|
2434
|
-
* it now; plugin-declared dependency-only
|
|
2435
|
-
* post-compile snapshot because their own protocol
|
|
2436
|
-
* fingerprints.
|
|
3113
|
+
* earlier graph. Graph members and out-of-walk transformed sources must carry
|
|
3114
|
+
* compiler-time proof and still match it now; plugin-declared dependency-only
|
|
3115
|
+
* paths retain the historical post-compile snapshot because their own protocol
|
|
3116
|
+
* does not claim generation fingerprints.
|
|
2437
3117
|
*/
|
|
2438
3118
|
function captureExternalInputSnapshot(
|
|
2439
3119
|
cached: TtscCachedProjectTransform,
|
|
2440
3120
|
paths: readonly string[],
|
|
2441
3121
|
): {
|
|
2442
3122
|
complete: boolean;
|
|
3123
|
+
failures: TtscGenerationProofFailures;
|
|
2443
3124
|
hashes: Record<string, string>;
|
|
2444
3125
|
realpaths: Record<string, string | null>;
|
|
2445
3126
|
signatures: Record<string, string>;
|
|
@@ -2447,9 +3128,23 @@ function captureExternalInputSnapshot(
|
|
|
2447
3128
|
const state = envelopeDerivation(cached);
|
|
2448
3129
|
const filesystem = resultFilesystem(cached.result);
|
|
2449
3130
|
const graph = envelopeGraphIndexes(state, cached);
|
|
3131
|
+
// A non-declaration transform output is a compiler-realized source even when
|
|
3132
|
+
// a malformed or legacy graph omitted its node. Its output was computed from
|
|
3133
|
+
// compiler-time bytes, so a post-compile host read cannot prove coherence.
|
|
3134
|
+
const transformSources = new Set<string>();
|
|
3135
|
+
if (cached.result.type === "success") {
|
|
3136
|
+
for (const output of Object.keys(cached.result.typescript)) {
|
|
3137
|
+
if (!isDeclarationFile(output)) {
|
|
3138
|
+
transformSources.add(
|
|
3139
|
+
derivationIdentity(state, path.resolve(cached.projectRoot, output)),
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
2450
3144
|
const hashes: Record<string, string> = {};
|
|
2451
3145
|
const realpaths: Record<string, string | null> = {};
|
|
2452
3146
|
const signatures: Record<string, string> = {};
|
|
3147
|
+
const failures = createGenerationProofFailures();
|
|
2453
3148
|
let complete = true;
|
|
2454
3149
|
// Sandwich every read between two metadata signatures. Only a signature that
|
|
2455
3150
|
// survived its own read, and whose stamp's tick the filesystem's clock has
|
|
@@ -2471,28 +3166,54 @@ function captureExternalInputSnapshot(
|
|
|
2471
3166
|
// through to the recorded-state branch below, the same evidence a
|
|
2472
3167
|
// plugin-declared dependency path carries. Its absence still invalidates
|
|
2473
3168
|
// the generation when it appears, because `missing` is recorded state.
|
|
3169
|
+
const realizedTransformSource = transformSources.has(identity);
|
|
2474
3170
|
const speculativeOnly =
|
|
3171
|
+
!realizedTransformSource &&
|
|
2475
3172
|
graph.speculative.has(identity) &&
|
|
2476
3173
|
!graph.inputProofs.has(identity) &&
|
|
2477
3174
|
!graph.inputProofConflicts.has(identity);
|
|
2478
|
-
if (
|
|
3175
|
+
if (
|
|
3176
|
+
(realizedTransformSource || graph.members.has(identity)) &&
|
|
3177
|
+
!speculativeOnly
|
|
3178
|
+
) {
|
|
2479
3179
|
const proof = graph.inputProofs.get(identity);
|
|
2480
3180
|
if (proof === undefined || graph.inputProofConflicts.has(identity)) {
|
|
2481
3181
|
complete = false;
|
|
3182
|
+
recordGenerationProofFailure(failures, {
|
|
3183
|
+
domain: "external",
|
|
3184
|
+
kind: graph.inputProofConflicts.has(identity)
|
|
3185
|
+
? "graph-proof-conflict"
|
|
3186
|
+
: "graph-proof-missing",
|
|
3187
|
+
detail: graph.inputProofFailures.get(identity),
|
|
3188
|
+
path: input,
|
|
3189
|
+
});
|
|
2482
3190
|
continue;
|
|
2483
3191
|
}
|
|
2484
3192
|
const before = inputMetadataEvidence(input, filesystem);
|
|
2485
3193
|
const currentHash = graphInputStateHash(input, filesystem);
|
|
3194
|
+
const currentRealpath = hostInputRealpath(input, filesystem);
|
|
2486
3195
|
const after = inputMetadataSignature(input, filesystem);
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
)
|
|
2494
|
-
) {
|
|
3196
|
+
const realpathMatches = sameHostInputRealpath(
|
|
3197
|
+
proof.realpath,
|
|
3198
|
+
currentRealpath,
|
|
3199
|
+
state.identityContext,
|
|
3200
|
+
);
|
|
3201
|
+
if (currentHash !== proof.hash || !realpathMatches) {
|
|
2495
3202
|
complete = false;
|
|
3203
|
+
if (currentHash !== proof.hash) {
|
|
3204
|
+
recordGenerationProofFailure(failures, {
|
|
3205
|
+
domain: "external",
|
|
3206
|
+
kind: "graph-content-changed",
|
|
3207
|
+
path: input,
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
if (!realpathMatches) {
|
|
3211
|
+
recordGenerationProofFailure(failures, {
|
|
3212
|
+
domain: "external",
|
|
3213
|
+
kind: "graph-realpath-changed",
|
|
3214
|
+
path: input,
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
2496
3217
|
} else if (currentHash !== null) {
|
|
2497
3218
|
// The recorded hash is the compiler's own proof, so a signature may
|
|
2498
3219
|
// only stand for it once the current bytes were shown to match it.
|
|
@@ -2510,32 +3231,36 @@ function captureExternalInputSnapshot(
|
|
|
2510
3231
|
hashes[identity] = hash ?? MISSING_INPUT_STATE;
|
|
2511
3232
|
if (hash !== null) record(input, before, after);
|
|
2512
3233
|
}
|
|
2513
|
-
return { complete, hashes, realpaths, signatures };
|
|
3234
|
+
return { complete, failures, hashes, realpaths, signatures };
|
|
2514
3235
|
}
|
|
2515
3236
|
|
|
2516
|
-
/**
|
|
2517
|
-
function
|
|
3237
|
+
/** Explain every graph member that no longer matches the compiler's state. */
|
|
3238
|
+
function compilerGraphInputProofFailures(
|
|
2518
3239
|
cached: TtscCachedProjectTransform,
|
|
2519
|
-
):
|
|
3240
|
+
): TtscGenerationProofFailures {
|
|
3241
|
+
const failures = createGenerationProofFailures();
|
|
2520
3242
|
if (
|
|
2521
3243
|
cached.result.type === "exception" ||
|
|
2522
3244
|
cached.result.graph === undefined ||
|
|
2523
3245
|
(cached.result.graph.inputHashes === undefined &&
|
|
2524
|
-
cached.result.graph.inputRealpaths === undefined
|
|
3246
|
+
cached.result.graph.inputRealpaths === undefined &&
|
|
3247
|
+
cached.result.graph.inputProofFailures === undefined)
|
|
2525
3248
|
) {
|
|
2526
3249
|
// Legacy sidecars remain compatible for ordinary in-project graphs. Their
|
|
2527
3250
|
// out-of-walk members are still rejected by captureExternalInputSnapshot,
|
|
2528
3251
|
// where a post-compile snapshot cannot prove the compiler's generation.
|
|
2529
|
-
return
|
|
3252
|
+
return failures;
|
|
2530
3253
|
}
|
|
2531
3254
|
const state = envelopeDerivation(cached);
|
|
2532
3255
|
const filesystem = resultFilesystem(cached.result);
|
|
2533
3256
|
const graph = envelopeGraphIndexes(state, cached);
|
|
2534
|
-
if (graph.inputProofConflicts.size !== 0) {
|
|
2535
|
-
return false;
|
|
2536
|
-
}
|
|
2537
3257
|
for (const identity of graph.members) {
|
|
2538
3258
|
const proof = graph.inputProofs.get(identity);
|
|
3259
|
+
const spelling =
|
|
3260
|
+
proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
|
|
3261
|
+
if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
|
|
3262
|
+
continue;
|
|
3263
|
+
}
|
|
2539
3264
|
// A speculative candidate has no compile-time read to prove. Requiring one
|
|
2540
3265
|
// would void every generation of every project whose resolution passes over
|
|
2541
3266
|
// a higher-priority spelling, which is every project with a dependency
|
|
@@ -2544,19 +3269,47 @@ function matchesCompilerGraphInputProofs(
|
|
|
2544
3269
|
if (proof === undefined && graph.speculative.has(identity)) {
|
|
2545
3270
|
continue;
|
|
2546
3271
|
}
|
|
3272
|
+
if (graph.inputProofConflicts.has(identity)) {
|
|
3273
|
+
recordGenerationProofFailure(failures, {
|
|
3274
|
+
domain: "graph",
|
|
3275
|
+
kind: "proof-conflict",
|
|
3276
|
+
detail: graph.inputProofFailures.get(identity),
|
|
3277
|
+
path: spelling,
|
|
3278
|
+
});
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
3281
|
+
if (proof === undefined) {
|
|
3282
|
+
recordGenerationProofFailure(failures, {
|
|
3283
|
+
domain: "graph",
|
|
3284
|
+
kind: "proof-missing",
|
|
3285
|
+
detail: graph.inputProofFailures.get(identity),
|
|
3286
|
+
path: spelling,
|
|
3287
|
+
});
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
const currentHash = graphInputStateHash(proof.path, filesystem);
|
|
3291
|
+
if (currentHash !== proof.hash) {
|
|
3292
|
+
recordGenerationProofFailure(failures, {
|
|
3293
|
+
domain: "graph",
|
|
3294
|
+
kind: "content-changed",
|
|
3295
|
+
path: proof.path,
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
2547
3298
|
if (
|
|
2548
|
-
proof === undefined ||
|
|
2549
|
-
graphInputStateHash(proof.path, filesystem) !== proof.hash ||
|
|
2550
3299
|
!sameHostInputRealpath(
|
|
2551
3300
|
proof.realpath,
|
|
2552
3301
|
hostInputRealpath(proof.path, filesystem),
|
|
2553
3302
|
state.identityContext,
|
|
2554
3303
|
)
|
|
2555
3304
|
) {
|
|
2556
|
-
|
|
3305
|
+
recordGenerationProofFailure(failures, {
|
|
3306
|
+
domain: "graph",
|
|
3307
|
+
kind: "realpath-changed",
|
|
3308
|
+
path: proof.path,
|
|
3309
|
+
});
|
|
2557
3310
|
}
|
|
2558
3311
|
}
|
|
2559
|
-
return
|
|
3312
|
+
return failures;
|
|
2560
3313
|
}
|
|
2561
3314
|
|
|
2562
3315
|
/** Compare one derived input with the snapshot that owned it at generation. */
|
|
@@ -2631,9 +3384,17 @@ export function collectProjectInputHashes(
|
|
|
2631
3384
|
projectRoot: string,
|
|
2632
3385
|
identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
2633
3386
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3387
|
+
policy?: ITtscProjectMembershipPolicy,
|
|
2634
3388
|
): Record<string, string> {
|
|
2635
|
-
return collectProjectInputSnapshot(
|
|
2636
|
-
|
|
3389
|
+
return collectProjectInputSnapshot(
|
|
3390
|
+
projectRoot,
|
|
3391
|
+
identities,
|
|
3392
|
+
filesystem,
|
|
3393
|
+
undefined,
|
|
3394
|
+
{
|
|
3395
|
+
policy,
|
|
3396
|
+
},
|
|
3397
|
+
).hashes;
|
|
2637
3398
|
}
|
|
2638
3399
|
|
|
2639
3400
|
/** Hash project files and snapshot the directory topology in one walk. */
|
|
@@ -2645,6 +3406,16 @@ function collectProjectInputSnapshot(
|
|
|
2645
3406
|
hashes: Record<string, string>;
|
|
2646
3407
|
signatures: Record<string, string>;
|
|
2647
3408
|
},
|
|
3409
|
+
options?: {
|
|
3410
|
+
/**
|
|
3411
|
+
* Restrict hashing to these project keys. Supplied by a validating caller,
|
|
3412
|
+
* which compares over exactly this set, and omitted by a capturing one,
|
|
3413
|
+
* which has no generation to compare against yet.
|
|
3414
|
+
*/
|
|
3415
|
+
declaredKeys?: ReadonlySet<string>;
|
|
3416
|
+
/** What the resolved configuration admits into the program. */
|
|
3417
|
+
policy?: ITtscProjectMembershipPolicy;
|
|
3418
|
+
},
|
|
2648
3419
|
): {
|
|
2649
3420
|
complete: boolean;
|
|
2650
3421
|
directoryComplete: boolean;
|
|
@@ -2653,19 +3424,34 @@ function collectProjectInputSnapshot(
|
|
|
2653
3424
|
projectDirectories: TtscProjectDirectorySnapshot[];
|
|
2654
3425
|
provenSignatures: Record<string, string>;
|
|
2655
3426
|
unstableFiles: Set<string>;
|
|
3427
|
+
walkFailures: TtscProjectWalkFailure[];
|
|
2656
3428
|
} {
|
|
2657
3429
|
const hashes: Record<string, string> = {};
|
|
2658
3430
|
const fileSignatures: Record<string, string> = {};
|
|
2659
3431
|
const provenSignatures: Record<string, string> = {};
|
|
2660
3432
|
const unstableFiles = new Set<string>();
|
|
2661
3433
|
let attributed = true;
|
|
2662
|
-
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
3434
|
+
const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
|
|
3435
|
+
const walkFailures = [...walked.failures];
|
|
2663
3436
|
let complete = walked.complete;
|
|
2664
3437
|
for (const file of walked.files) {
|
|
2665
3438
|
try {
|
|
2666
|
-
const before = inputMetadataEvidence(file, filesystem);
|
|
2667
3439
|
const key = toProjectKey(projectRoot, file, identities);
|
|
2668
|
-
// A
|
|
3440
|
+
// A caller validating a generation compares hashes over that
|
|
3441
|
+
// generation's declared inputs alone (`sameHashes` takes the declared key
|
|
3442
|
+
// set), so reading anything else is work whose result is never consulted.
|
|
3443
|
+
// Skipping it is what keeps a directory full of emitted files from
|
|
3444
|
+
// costing a read per file on the pass that first sees them
|
|
3445
|
+
// (samchon/ttsc#1307). Capture passes supply no restriction and still
|
|
3446
|
+
// record the whole walk.
|
|
3447
|
+
if (
|
|
3448
|
+
options?.declaredKeys !== undefined &&
|
|
3449
|
+
!options.declaredKeys.has(key)
|
|
3450
|
+
) {
|
|
3451
|
+
continue;
|
|
3452
|
+
}
|
|
3453
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
3454
|
+
// A file whose signature still equals the one captured around the read
|
|
2669
3455
|
// that produced the recorded hash carries that content, so the whole
|
|
2670
3456
|
// project does not have to be re-read to prove one delivery. A signature
|
|
2671
3457
|
// that was already proven stays proven: its stamp has not moved since the
|
|
@@ -2691,6 +3477,7 @@ function collectProjectInputSnapshot(
|
|
|
2691
3477
|
) {
|
|
2692
3478
|
complete = false;
|
|
2693
3479
|
unstableFiles.add(key);
|
|
3480
|
+
walkFailures.push({ kind: "file-changed-during-read", path: file });
|
|
2694
3481
|
} else {
|
|
2695
3482
|
fileSignatures[key] = after;
|
|
2696
3483
|
// Only a signature whose stamp's tick the filesystem's clock provably
|
|
@@ -2705,6 +3492,7 @@ function collectProjectInputSnapshot(
|
|
|
2705
3492
|
// File watchers may observe a transform while another process is moving
|
|
2706
3493
|
// or deleting files. The missing key invalidates older cache entries.
|
|
2707
3494
|
complete = false;
|
|
3495
|
+
walkFailures.push({ kind: "file-read-failed", path: file });
|
|
2708
3496
|
try {
|
|
2709
3497
|
unstableFiles.add(toProjectKey(projectRoot, file, identities));
|
|
2710
3498
|
} catch {
|
|
@@ -2722,12 +3510,14 @@ function collectProjectInputSnapshot(
|
|
|
2722
3510
|
projectDirectories: walked.directories,
|
|
2723
3511
|
provenSignatures,
|
|
2724
3512
|
unstableFiles,
|
|
3513
|
+
walkFailures,
|
|
2725
3514
|
};
|
|
2726
3515
|
}
|
|
2727
3516
|
|
|
2728
3517
|
/**
|
|
2729
|
-
* Enumerate every regular file under `root`, skipping
|
|
2730
|
-
*
|
|
3518
|
+
* Enumerate every regular file under `root`, skipping the directories no
|
|
3519
|
+
* configuration can name ({@link isIgnoredProjectDirectory}) and the ones the
|
|
3520
|
+
* resolved configuration excludes ({@link isExcludedProjectDirectory}).
|
|
2731
3521
|
*
|
|
2732
3522
|
* Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
|
|
2733
3523
|
* unbounded call-stack depth on deep project trees. The result is sorted so
|
|
@@ -2736,20 +3526,36 @@ function collectProjectInputSnapshot(
|
|
|
2736
3526
|
function walkProjectInputs(
|
|
2737
3527
|
root: string,
|
|
2738
3528
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3529
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
2739
3530
|
): {
|
|
2740
3531
|
complete: boolean;
|
|
2741
3532
|
directories: TtscProjectDirectorySnapshot[];
|
|
3533
|
+
failures: TtscProjectWalkFailure[];
|
|
2742
3534
|
files: string[];
|
|
2743
3535
|
} {
|
|
2744
3536
|
let complete = true;
|
|
2745
|
-
const
|
|
3537
|
+
const failures: TtscProjectWalkFailure[] = [];
|
|
2746
3538
|
const files: string[] = [];
|
|
3539
|
+
// Collected in one pass, then digested in a second. A directory's digest has
|
|
3540
|
+
// to know whether each child directory can hold program inputs, and the walk
|
|
3541
|
+
// learns that only after descending, so the two cannot be one pass.
|
|
3542
|
+
const visited: {
|
|
3543
|
+
childDirectories: string[];
|
|
3544
|
+
entries: { name: string; kind: string; possible: boolean }[];
|
|
3545
|
+
ownInput: boolean;
|
|
3546
|
+
path: string;
|
|
3547
|
+
stable: string | undefined;
|
|
3548
|
+
}[] = [];
|
|
2747
3549
|
const stack = [root];
|
|
2748
3550
|
while (stack.length !== 0) {
|
|
2749
3551
|
const current = stack.pop()!;
|
|
2750
3552
|
const before = projectDirectorySignature(current, filesystem);
|
|
2751
3553
|
if (before === undefined) {
|
|
2752
3554
|
complete = false;
|
|
3555
|
+
failures.push({
|
|
3556
|
+
kind: "directory-metadata-unavailable",
|
|
3557
|
+
path: current,
|
|
3558
|
+
});
|
|
2753
3559
|
continue;
|
|
2754
3560
|
}
|
|
2755
3561
|
let entries: fs.Dirent[];
|
|
@@ -2757,40 +3563,122 @@ function walkProjectInputs(
|
|
|
2757
3563
|
entries = filesystem.readdir(current);
|
|
2758
3564
|
} catch {
|
|
2759
3565
|
complete = false;
|
|
3566
|
+
failures.push({ kind: "directory-read-failed", path: current });
|
|
2760
3567
|
continue;
|
|
2761
3568
|
}
|
|
2762
3569
|
const after = projectDirectorySignature(current, filesystem);
|
|
2763
3570
|
if (after === undefined || before !== after) {
|
|
2764
3571
|
complete = false;
|
|
3572
|
+
failures.push({
|
|
3573
|
+
kind:
|
|
3574
|
+
after === undefined
|
|
3575
|
+
? "directory-metadata-unavailable"
|
|
3576
|
+
: "directory-changed-during-walk",
|
|
3577
|
+
path: current,
|
|
3578
|
+
});
|
|
2765
3579
|
}
|
|
2766
|
-
|
|
3580
|
+
const visit = {
|
|
3581
|
+
childDirectories: [] as string[],
|
|
3582
|
+
entries: [] as { name: string; kind: string; possible: boolean }[],
|
|
3583
|
+
ownInput: false,
|
|
2767
3584
|
path: current,
|
|
2768
3585
|
// If membership moved during enumeration, force the next delivery to
|
|
2769
3586
|
// replace this generation instead of blessing a torn directory/file
|
|
2770
3587
|
// snapshot as stable.
|
|
2771
|
-
|
|
3588
|
+
stable:
|
|
2772
3589
|
after !== undefined && before === after
|
|
2773
|
-
?
|
|
3590
|
+
? undefined
|
|
2774
3591
|
: `unstable:${before}:${after ?? "missing"}`,
|
|
2775
|
-
}
|
|
3592
|
+
};
|
|
2776
3593
|
for (const entry of entries) {
|
|
2777
3594
|
if (isIgnoredProjectDirectory(entry.name)) {
|
|
2778
3595
|
continue;
|
|
2779
3596
|
}
|
|
2780
3597
|
const file = path.join(current, entry.name);
|
|
3598
|
+
if (entry.isDirectory() && isExcludedProjectDirectory(file, policy)) {
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
const possible = isPossibleProgramEntry(entry, policy);
|
|
3602
|
+
visit.entries.push({
|
|
3603
|
+
kind: [
|
|
3604
|
+
entry.isDirectory(),
|
|
3605
|
+
entry.isFile(),
|
|
3606
|
+
entry.isSymbolicLink(),
|
|
3607
|
+
].join(":"),
|
|
3608
|
+
name: entry.name,
|
|
3609
|
+
possible,
|
|
3610
|
+
});
|
|
2781
3611
|
if (entry.isDirectory()) {
|
|
3612
|
+
visit.childDirectories.push(file);
|
|
2782
3613
|
stack.push(file);
|
|
2783
|
-
} else if (entry.isFile()) {
|
|
3614
|
+
} else if (entry.isFile() && possible) {
|
|
3615
|
+
// Only a file that could enter the program is hashed. A file that
|
|
3616
|
+
// could not is either irrelevant to every generation, or it is one the
|
|
3617
|
+
// compiler actually read, in which case the graph reports it and
|
|
3618
|
+
// `isProjectWalkPath` now agrees it is out of the walk, so it is
|
|
3619
|
+
// recorded and proven by the out-of-walk snapshot instead. Hashing an
|
|
3620
|
+
// emitted tree here bought nothing and cost a read per file, including
|
|
3621
|
+
// in `@ttsc/metro`, whose fingerprint re-keys every transformed file
|
|
3622
|
+
// (samchon/ttsc#1307).
|
|
2784
3623
|
files.push(file);
|
|
3624
|
+
visit.ownInput = true;
|
|
2785
3625
|
}
|
|
2786
3626
|
}
|
|
3627
|
+
visited.push(visit);
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
// A directory matters to program membership only if its subtree can hold a
|
|
3631
|
+
// program input. Propagate that up from the directories that hold one, so a
|
|
3632
|
+
// bundler creating `out/` and filling it with JavaScript a project admitting
|
|
3633
|
+
// none can never compile is not a membership change at any level: not in the
|
|
3634
|
+
// directory itself, and not in the parent that now lists it
|
|
3635
|
+
// (samchon/ttsc#1307).
|
|
3636
|
+
const byPath = new Map(visited.map((visit) => [visit.path, visit]));
|
|
3637
|
+
const relevant = new Set<string>();
|
|
3638
|
+
for (const visit of visited) {
|
|
3639
|
+
if (!visit.ownInput) {
|
|
3640
|
+
continue;
|
|
3641
|
+
}
|
|
3642
|
+
let current: string | undefined = visit.path;
|
|
3643
|
+
while (current !== undefined && !relevant.has(current)) {
|
|
3644
|
+
relevant.add(current);
|
|
3645
|
+
const parent = path.dirname(current);
|
|
3646
|
+
current = parent === current || !byPath.has(parent) ? undefined : parent;
|
|
3647
|
+
}
|
|
2787
3648
|
}
|
|
3649
|
+
|
|
3650
|
+
const directories: TtscProjectDirectorySnapshot[] = visited.map((visit) => {
|
|
3651
|
+
const membership = visit.entries
|
|
3652
|
+
.filter(
|
|
3653
|
+
(entry) =>
|
|
3654
|
+
entry.possible &&
|
|
3655
|
+
(!visit.childDirectories.includes(
|
|
3656
|
+
path.join(visit.path, entry.name),
|
|
3657
|
+
) ||
|
|
3658
|
+
relevant.has(path.join(visit.path, entry.name))),
|
|
3659
|
+
)
|
|
3660
|
+
.map((entry) => `${entry.name}:${entry.kind}`);
|
|
3661
|
+
return {
|
|
3662
|
+
path: visit.path,
|
|
3663
|
+
relevant: relevant.has(visit.path),
|
|
3664
|
+
signature:
|
|
3665
|
+
visit.stable ??
|
|
3666
|
+
hashText(membership.sort().join(String.fromCharCode(0))),
|
|
3667
|
+
};
|
|
3668
|
+
});
|
|
2788
3669
|
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
2789
3670
|
files.sort();
|
|
2790
|
-
return { complete, directories, files };
|
|
3671
|
+
return { complete, directories, failures, files };
|
|
2791
3672
|
}
|
|
2792
3673
|
|
|
2793
|
-
/**
|
|
3674
|
+
/**
|
|
3675
|
+
* Return a directory's metadata stamp, used to detect that its membership moved
|
|
3676
|
+
* _while_ the walk was enumerating it, and to feed the observed-clock floor.
|
|
3677
|
+
*
|
|
3678
|
+
* This is the right instrument for that job and the wrong one for comparing two
|
|
3679
|
+
* generations: it moves for ignored entries too. {@link walkProjectInputs}
|
|
3680
|
+
* records the filtered membership digest for the comparison instead.
|
|
3681
|
+
*/
|
|
2794
3682
|
function projectDirectorySignature(
|
|
2795
3683
|
directory: string,
|
|
2796
3684
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
@@ -2821,14 +3709,31 @@ function sameProjectDirectories(
|
|
|
2821
3709
|
left: readonly TtscProjectDirectorySnapshot[],
|
|
2822
3710
|
right: readonly TtscProjectDirectorySnapshot[],
|
|
2823
3711
|
): boolean {
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
)
|
|
3712
|
+
// Compare only the directories that can hold program inputs, on either side.
|
|
3713
|
+
// A directory irrelevant on both is not part of the program's membership at
|
|
3714
|
+
// all, so its appearance, disappearance or churn says nothing: that is a
|
|
3715
|
+
// bundler's output tree. One that gained or lost relevance is present in the
|
|
3716
|
+
// comparison from the side where it counts, and so is caught.
|
|
3717
|
+
const select = (
|
|
3718
|
+
snapshots: readonly TtscProjectDirectorySnapshot[],
|
|
3719
|
+
): Map<string, TtscProjectDirectorySnapshot> =>
|
|
3720
|
+
new Map(
|
|
3721
|
+
snapshots
|
|
3722
|
+
.filter((directory) => directory.relevant)
|
|
3723
|
+
.map((directory) => [directory.path, directory]),
|
|
3724
|
+
);
|
|
3725
|
+
const leftRelevant = select(left);
|
|
3726
|
+
const rightRelevant = select(right);
|
|
3727
|
+
const paths = new Set([...leftRelevant.keys(), ...rightRelevant.keys()]);
|
|
3728
|
+
for (const location of paths) {
|
|
3729
|
+
if (
|
|
3730
|
+
leftRelevant.get(location)?.signature !==
|
|
3731
|
+
rightRelevant.get(location)?.signature
|
|
3732
|
+
) {
|
|
3733
|
+
return false;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
return true;
|
|
2832
3737
|
}
|
|
2833
3738
|
|
|
2834
3739
|
/**
|
|
@@ -2860,8 +3765,11 @@ function openDirectoryWatch(
|
|
|
2860
3765
|
async function createProjectMutationTracker(
|
|
2861
3766
|
directories: readonly TtscProjectDirectorySnapshot[],
|
|
2862
3767
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
3768
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
2863
3769
|
): Promise<TtscProjectMutationTracker> {
|
|
2864
3770
|
const tracker: TtscProjectMutationTracker = {
|
|
3771
|
+
changes: new Set(),
|
|
3772
|
+
changesOmitted: false,
|
|
2865
3773
|
close: () => undefined,
|
|
2866
3774
|
failed: false,
|
|
2867
3775
|
membershipChanged: false,
|
|
@@ -2872,6 +3780,13 @@ async function createProjectMutationTracker(
|
|
|
2872
3780
|
directories.map((directory) => ({ directory: directory.path })),
|
|
2873
3781
|
false,
|
|
2874
3782
|
filesystem,
|
|
3783
|
+
(location, filename) =>
|
|
3784
|
+
reportsProgramMembership(
|
|
3785
|
+
path.join(location, filename),
|
|
3786
|
+
filename,
|
|
3787
|
+
policy,
|
|
3788
|
+
filesystem,
|
|
3789
|
+
),
|
|
2875
3790
|
);
|
|
2876
3791
|
return tracker;
|
|
2877
3792
|
}
|
|
@@ -2886,8 +3801,27 @@ async function createProjectMutationTracker(
|
|
|
2886
3801
|
openDirectoryWatch(
|
|
2887
3802
|
filesystem,
|
|
2888
3803
|
directory.path,
|
|
2889
|
-
(eventType) => {
|
|
2890
|
-
if (eventType
|
|
3804
|
+
(eventType, filename) => {
|
|
3805
|
+
if (eventType !== "rename") {
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (
|
|
3809
|
+
filename !== null &&
|
|
3810
|
+
!reportsProgramMembership(
|
|
3811
|
+
path.join(directory.path, filename),
|
|
3812
|
+
filename,
|
|
3813
|
+
policy,
|
|
3814
|
+
filesystem,
|
|
3815
|
+
)
|
|
3816
|
+
) {
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
recordProjectMutation(
|
|
3820
|
+
tracker,
|
|
3821
|
+
filename === null
|
|
3822
|
+
? directory.path
|
|
3823
|
+
: path.join(directory.path, filename),
|
|
3824
|
+
);
|
|
2891
3825
|
},
|
|
2892
3826
|
() => {
|
|
2893
3827
|
tracker.failed = true;
|
|
@@ -2939,6 +3873,8 @@ async function createHostInputMutationTracker(
|
|
|
2939
3873
|
names: [...location.names],
|
|
2940
3874
|
}));
|
|
2941
3875
|
const tracker: TtscProjectMutationTracker = {
|
|
3876
|
+
changes: new Set(),
|
|
3877
|
+
changesOmitted: false,
|
|
2942
3878
|
close: () => undefined,
|
|
2943
3879
|
// Coverage is the caller's claim, and it is required rather than derived
|
|
2944
3880
|
// from the input list: an input is watched by its exact name here, but only
|
|
@@ -2981,7 +3917,12 @@ async function createHostInputMutationTracker(
|
|
|
2981
3917
|
? null
|
|
2982
3918
|
: normalizeHostInputName(filename, caseSensitive);
|
|
2983
3919
|
if (reported === null || names.has(reported)) {
|
|
2984
|
-
|
|
3920
|
+
recordProjectMutation(
|
|
3921
|
+
tracker,
|
|
3922
|
+
filename === null
|
|
3923
|
+
? location.directory
|
|
3924
|
+
: path.join(location.directory, filename),
|
|
3925
|
+
);
|
|
2985
3926
|
}
|
|
2986
3927
|
},
|
|
2987
3928
|
() => {
|
|
@@ -2996,6 +3937,104 @@ async function createHostInputMutationTracker(
|
|
|
2996
3937
|
return tracker;
|
|
2997
3938
|
}
|
|
2998
3939
|
|
|
3940
|
+
/**
|
|
3941
|
+
* Whether a path lies inside a directory the configuration excludes.
|
|
3942
|
+
*
|
|
3943
|
+
* Lexical, exactly like the walk and like {@link isProjectWalkPath}, and for the
|
|
3944
|
+
* reason that predicate states: walk membership is lexical, so resolving a path
|
|
3945
|
+
* to physical identity first would collapse two spellings the walk keeps apart
|
|
3946
|
+
* and claim it covered a subtree it never followed. A junction whose target the
|
|
3947
|
+
* walk hashes under its own name is exactly that, and canonicalizing here would
|
|
3948
|
+
* suppress every event in it.
|
|
3949
|
+
*
|
|
3950
|
+
* `strictly` excludes an exact match, for the case where the excluded entry
|
|
3951
|
+
* names a file rather than a directory: `exclude` accepts one, the walk applies
|
|
3952
|
+
* exclusion to directories alone, so that file is still hashed and its events
|
|
3953
|
+
* must keep counting.
|
|
3954
|
+
*/
|
|
3955
|
+
function insideExcludedProjectDirectory(
|
|
3956
|
+
location: string,
|
|
3957
|
+
policy: ITtscProjectMembershipPolicy,
|
|
3958
|
+
strictly: boolean,
|
|
3959
|
+
): boolean {
|
|
3960
|
+
if (policy.excludedDirectories.length === 0) {
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
const resolved = path.resolve(location);
|
|
3964
|
+
return policy.excludedDirectories.some((excluded) => {
|
|
3965
|
+
const target = path.resolve(excluded);
|
|
3966
|
+
if (strictly && target === resolved) {
|
|
3967
|
+
return false;
|
|
3968
|
+
}
|
|
3969
|
+
return pathIsWithin(resolved, target);
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/**
|
|
3974
|
+
* Whether one directory event can be a change to the program's membership.
|
|
3975
|
+
*
|
|
3976
|
+
* The live tracker has to answer the same question the membership digest does,
|
|
3977
|
+
* or the two disagree about the same project: a bundler writing content-hashed
|
|
3978
|
+
* output fires a rename per rebuild, and treating that as membership kept the
|
|
3979
|
+
* cost samchon/ttsc#1307 removes on every host that has no build boundary,
|
|
3980
|
+
* which is every host the narrow path exists for.
|
|
3981
|
+
*
|
|
3982
|
+
* A name that could be a program input counts, unless it sits under a directory
|
|
3983
|
+
* the walk never descends into. A name that could not still counts when the
|
|
3984
|
+
* path is now a directory, because the walk's watches were opened for the
|
|
3985
|
+
* directories that existed when the generation was captured, so a directory
|
|
3986
|
+
* created since is not watched and the sources that may appear in it would
|
|
3987
|
+
* otherwise be invisible. A directory the configuration excludes is the
|
|
3988
|
+
* exception: the walk cannot see inside it, so the tracker must not either, or
|
|
3989
|
+
* emptying and recreating an `outDir` costs a compile per build. An event whose
|
|
3990
|
+
* name the host did not report is unattributable and always counts.
|
|
3991
|
+
*/
|
|
3992
|
+
function reportsProgramMembership(
|
|
3993
|
+
location: string,
|
|
3994
|
+
filename: string,
|
|
3995
|
+
policy: ITtscProjectMembershipPolicy,
|
|
3996
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
3997
|
+
): boolean {
|
|
3998
|
+
if (isPossibleProgramFileName(filename, policy)) {
|
|
3999
|
+
// A name the program could admit. It still says nothing if it lies inside a
|
|
4000
|
+
// directory the walk never descends into, because the digest cannot see
|
|
4001
|
+
// there either and the tracker must not be the one side that reacts.
|
|
4002
|
+
return !insideExcludedProjectDirectory(location, policy, true);
|
|
4003
|
+
}
|
|
4004
|
+
let directory: boolean;
|
|
4005
|
+
try {
|
|
4006
|
+
directory = filesystem.lstat(location).isDirectory();
|
|
4007
|
+
} catch {
|
|
4008
|
+
// Gone again, or unreadable. Its name could not have been a program input,
|
|
4009
|
+
// and a directory removed under this one reports its own contents leaving
|
|
4010
|
+
// through the watch that was opened on it.
|
|
4011
|
+
return false;
|
|
4012
|
+
}
|
|
4013
|
+
if (!directory) {
|
|
4014
|
+
return false;
|
|
4015
|
+
}
|
|
4016
|
+
// A directory counts, because it can hold sources and the tracker is not
|
|
4017
|
+
// watching it yet, unless the configuration says the program does not contain
|
|
4018
|
+
// it. Emptying and recreating an `outDir`, which is what `emptyOutDir` and
|
|
4019
|
+
// `output.clean` do on every build, would otherwise void the generation once
|
|
4020
|
+
// per build on every host that has no build boundary.
|
|
4021
|
+
return !insideExcludedProjectDirectory(location, policy, false);
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
/** Record enough exact mutation evidence without retaining an event stream. */
|
|
4025
|
+
function recordProjectMutation(
|
|
4026
|
+
tracker: TtscProjectMutationTracker,
|
|
4027
|
+
changed: string,
|
|
4028
|
+
): void {
|
|
4029
|
+
tracker.membershipChanged = true;
|
|
4030
|
+
if (tracker.changes.has(changed)) return;
|
|
4031
|
+
if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
|
|
4032
|
+
tracker.changes.add(changed);
|
|
4033
|
+
} else {
|
|
4034
|
+
tracker.changesOmitted = true;
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
|
|
2999
4038
|
interface WindowsProjectMutationBroker {
|
|
3000
4039
|
child: ChildProcess;
|
|
3001
4040
|
/** Round-trips awaiting the child's reply, by request id. */
|
|
@@ -3008,7 +4047,24 @@ interface WindowsProjectMutationBroker {
|
|
|
3008
4047
|
trackers: Map<
|
|
3009
4048
|
number,
|
|
3010
4049
|
{
|
|
4050
|
+
/**
|
|
4051
|
+
* Whether one named event can be a program membership change. Present
|
|
4052
|
+
* only for the project-directory tracker, which watches whole directories
|
|
4053
|
+
* and so has to narrow what it hears; the trackers that watch exact names
|
|
4054
|
+
* have already narrowed theirs by construction.
|
|
4055
|
+
*/
|
|
4056
|
+
membership?: (location: string, filename: string) => boolean;
|
|
3011
4057
|
ready: () => void;
|
|
4058
|
+
/**
|
|
4059
|
+
* The walk's own spelling for each canonical directory the child watches,
|
|
4060
|
+
* so a reported event can be translated back before anything compares it
|
|
4061
|
+
* with a path the walk or the configuration produced.
|
|
4062
|
+
*
|
|
4063
|
+
* Required, not optional. A registration that forgot it would fall back
|
|
4064
|
+
* to the child's canonical spelling and silently reintroduce the mismatch
|
|
4065
|
+
* this map exists to remove, with no type error and no failing test.
|
|
4066
|
+
*/
|
|
4067
|
+
spellings: ReadonlyMap<string, string>;
|
|
3012
4068
|
tracker: TtscProjectMutationTracker;
|
|
3013
4069
|
}
|
|
3014
4070
|
>;
|
|
@@ -3033,8 +4089,21 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3033
4089
|
locations: readonly WindowsMutationLocation[],
|
|
3034
4090
|
allEvents: boolean,
|
|
3035
4091
|
filesystem: TtscTransformFilesystemOperations,
|
|
4092
|
+
/**
|
|
4093
|
+
* Optional filter for the project-directory tracker, whose events have to be
|
|
4094
|
+
* narrowed to program membership exactly as the in-process watcher's are. The
|
|
4095
|
+
* name-watching trackers pass none, since they already watch exact names.
|
|
4096
|
+
*/
|
|
4097
|
+
membership?: (location: string, filename: string) => boolean,
|
|
3036
4098
|
): Promise<void> {
|
|
3037
4099
|
const broker = getWindowsProjectMutationBroker();
|
|
4100
|
+
// The child watches canonical directories, and reports its events under that
|
|
4101
|
+
// spelling. Everything else in the adapter speaks the walk's own spelling,
|
|
4102
|
+
// which on Windows can be an 8.3 short form of the same directory, so keep
|
|
4103
|
+
// the way back: a filter that compared the child's spelling against the
|
|
4104
|
+
// configuration's would be comparing two names for one directory that share
|
|
4105
|
+
// no common prefix (samchon/ttsc#1307).
|
|
4106
|
+
const spellings = new Map<string, string>();
|
|
3038
4107
|
const normalized = locations.map((location) => {
|
|
3039
4108
|
let directory: string;
|
|
3040
4109
|
try {
|
|
@@ -3042,6 +4111,7 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3042
4111
|
} catch {
|
|
3043
4112
|
directory = path.resolve(location.directory);
|
|
3044
4113
|
}
|
|
4114
|
+
spellings.set(directory, location.directory);
|
|
3045
4115
|
return {
|
|
3046
4116
|
directory,
|
|
3047
4117
|
...(location.names === undefined ? {} : { names: location.names }),
|
|
@@ -3055,7 +4125,12 @@ async function registerWindowsProjectMutationTracker(
|
|
|
3055
4125
|
const ready = new Promise<void>((resolve) => {
|
|
3056
4126
|
resolveReady = resolve;
|
|
3057
4127
|
});
|
|
3058
|
-
broker.trackers.set(id, {
|
|
4128
|
+
broker.trackers.set(id, {
|
|
4129
|
+
membership,
|
|
4130
|
+
ready: resolveReady,
|
|
4131
|
+
spellings,
|
|
4132
|
+
tracker,
|
|
4133
|
+
});
|
|
3059
4134
|
tracker.drain = () => drainWindowsProjectMutationBroker(broker);
|
|
3060
4135
|
tracker.close = () => {
|
|
3061
4136
|
const active = broker.trackers.get(id);
|
|
@@ -3128,8 +4203,10 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
3128
4203
|
child.on("message", (message: unknown) => {
|
|
3129
4204
|
if (message === null || typeof message !== "object") return;
|
|
3130
4205
|
const record = message as {
|
|
4206
|
+
directory?: string;
|
|
3131
4207
|
drained?: boolean;
|
|
3132
4208
|
failed?: boolean;
|
|
4209
|
+
filename?: string | null;
|
|
3133
4210
|
id?: number;
|
|
3134
4211
|
ready?: boolean;
|
|
3135
4212
|
};
|
|
@@ -3147,7 +4224,27 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
3147
4224
|
if (record.failed === true) registration.tracker.failed = true;
|
|
3148
4225
|
if (record.ready === true) registration.ready();
|
|
3149
4226
|
if (record.ready !== true && record.failed !== true) {
|
|
3150
|
-
|
|
4227
|
+
if (typeof record.directory === "string") {
|
|
4228
|
+
// The walk's spelling for this directory, which is what every
|
|
4229
|
+
// comparison and every recorded witness downstream expects.
|
|
4230
|
+
const reported =
|
|
4231
|
+
registration.spellings.get(record.directory) ?? record.directory;
|
|
4232
|
+
if (
|
|
4233
|
+
typeof record.filename === "string" &&
|
|
4234
|
+
registration.membership !== undefined &&
|
|
4235
|
+
!registration.membership(reported, record.filename)
|
|
4236
|
+
) {
|
|
4237
|
+
return;
|
|
4238
|
+
}
|
|
4239
|
+
recordProjectMutation(
|
|
4240
|
+
registration.tracker,
|
|
4241
|
+
typeof record.filename === "string"
|
|
4242
|
+
? path.join(reported, record.filename)
|
|
4243
|
+
: reported,
|
|
4244
|
+
);
|
|
4245
|
+
} else {
|
|
4246
|
+
registration.tracker.membershipChanged = true;
|
|
4247
|
+
}
|
|
3151
4248
|
}
|
|
3152
4249
|
});
|
|
3153
4250
|
windowsProjectMutationBroker = broker;
|
|
@@ -3238,7 +4335,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
|
3238
4335
|
" const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
|
|
3239
4336
|
" const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
|
|
3240
4337
|
" const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
|
|
3241
|
-
' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
|
|
4338
|
+
' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
|
|
3242
4339
|
" });",
|
|
3243
4340
|
' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
|
|
3244
4341
|
" watchers.push(watcher);",
|
|
@@ -3351,6 +4448,7 @@ export function isProjectWalkPath(
|
|
|
3351
4448
|
file: string,
|
|
3352
4449
|
_identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
|
|
3353
4450
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
4451
|
+
policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
|
|
3354
4452
|
): boolean {
|
|
3355
4453
|
// Walk membership is lexical. Resolving `file` to physical identity first
|
|
3356
4454
|
// would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
|
|
@@ -3367,7 +4465,20 @@ export function isProjectWalkPath(
|
|
|
3367
4465
|
return false;
|
|
3368
4466
|
}
|
|
3369
4467
|
const segments = relative.split(path.sep);
|
|
3370
|
-
|
|
4468
|
+
// The last segment is the file itself, which the walk names rather than
|
|
4469
|
+
// descends into, so only the directory components decide walk membership.
|
|
4470
|
+
if (segments.slice(0, -1).some(isIgnoredProjectDirectory)) {
|
|
4471
|
+
return false;
|
|
4472
|
+
}
|
|
4473
|
+
if (isExcludedProjectDirectory(path.dirname(path.resolve(file)), policy)) {
|
|
4474
|
+
return false;
|
|
4475
|
+
}
|
|
4476
|
+
// The walk hashes only files that could enter the program, so a path it does
|
|
4477
|
+
// not hash is out of the walk by definition. Answering otherwise would leave
|
|
4478
|
+
// a graph input the compiler really read in neither snapshot: absent from
|
|
4479
|
+
// `inputHashes` because the walk skipped it, and absent from the out-of-walk
|
|
4480
|
+
// snapshot because this predicate claimed the walk covered it.
|
|
4481
|
+
if (!isPossibleProgramFileName(path.basename(file), policy)) {
|
|
3371
4482
|
return false;
|
|
3372
4483
|
}
|
|
3373
4484
|
let current = resolvedRoot;
|
|
@@ -3483,12 +4594,13 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
|
|
|
3483
4594
|
|
|
3484
4595
|
/**
|
|
3485
4596
|
* Derive the absolute out-of-walk input set of a whole project transform: the
|
|
3486
|
-
* union of every reference-graph member (edge keys and
|
|
3487
|
-
* config chain) and
|
|
3488
|
-
* project walk already hashes and the disposed
|
|
3489
|
-
* inputs {@link matchesCachedSource}'s walk cannot see.
|
|
3490
|
-
* that are still missing remain in this set even under
|
|
3491
|
-
* first walk cannot hash a file that has not been created
|
|
4597
|
+
* union of every transformed source key, reference-graph member (edge keys and
|
|
4598
|
+
* targets, globals, the config chain), and plugin-reported dependency, minus
|
|
4599
|
+
* everything the project walk already hashes and the disposed transform scratch
|
|
4600
|
+
* tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
|
|
4601
|
+
* Resolution candidates that are still missing remain in this set even under
|
|
4602
|
+
* the project root: the first walk cannot hash a file that has not been created
|
|
4603
|
+
* yet.
|
|
3492
4604
|
*
|
|
3493
4605
|
* A `dependenciesComplete` declaration deliberately does not narrow the stored
|
|
3494
4606
|
* set: other files in the same whole-project result can still own the omitted
|
|
@@ -3498,8 +4610,10 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
|
|
|
3498
4610
|
*/
|
|
3499
4611
|
function selectExternalInputPaths(props: {
|
|
3500
4612
|
filesystem?: TtscTransformFilesystemOperations;
|
|
4613
|
+
membershipPolicy: ITtscProjectMembershipPolicy;
|
|
3501
4614
|
projectRoot: string;
|
|
3502
4615
|
result: ITtscCompilerTransformation;
|
|
4616
|
+
scratchDirectory?: string;
|
|
3503
4617
|
temporaryTsconfig?: string;
|
|
3504
4618
|
}): string[] {
|
|
3505
4619
|
if (props.result.type === "exception") {
|
|
@@ -3510,6 +4624,10 @@ function selectExternalInputPaths(props: {
|
|
|
3510
4624
|
const identities = createHostPathIdentityContext(filesystem);
|
|
3511
4625
|
const resolutionCandidates = new Set<string>();
|
|
3512
4626
|
const graph = props.result.graph;
|
|
4627
|
+
// Every transform output key names the source file whose transformed text it
|
|
4628
|
+
// carries. Keep an out-of-walk source in the external snapshot instead of
|
|
4629
|
+
// injecting it into the project-walk key universe (samchon/ttsc#252).
|
|
4630
|
+
members.push(...Object.keys(props.result.typescript));
|
|
3513
4631
|
if (graph !== undefined) {
|
|
3514
4632
|
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
3515
4633
|
members.push(source);
|
|
@@ -3571,9 +4689,16 @@ function selectExternalInputPaths(props: {
|
|
|
3571
4689
|
resolutionCandidates.has(identity) && !filesystem.exists(absolute);
|
|
3572
4690
|
if (
|
|
3573
4691
|
identity === excluded ||
|
|
4692
|
+
isTransformScratchInput(absolute, props.scratchDirectory) ||
|
|
3574
4693
|
seen.has(spelling) ||
|
|
3575
4694
|
(!missingCandidate &&
|
|
3576
|
-
isProjectWalkPath(
|
|
4695
|
+
isProjectWalkPath(
|
|
4696
|
+
props.projectRoot,
|
|
4697
|
+
absolute,
|
|
4698
|
+
identities,
|
|
4699
|
+
filesystem,
|
|
4700
|
+
props.membershipPolicy,
|
|
4701
|
+
))
|
|
3577
4702
|
) {
|
|
3578
4703
|
continue;
|
|
3579
4704
|
}
|
|
@@ -3607,6 +4732,7 @@ function selectNotifiableAbsentInputs(props: {
|
|
|
3607
4732
|
filesystem: TtscTransformFilesystemOperations;
|
|
3608
4733
|
projectRoot: string;
|
|
3609
4734
|
result: ITtscCompilerTransformation;
|
|
4735
|
+
scratchDirectory?: string;
|
|
3610
4736
|
temporaryTsconfig?: string;
|
|
3611
4737
|
}): { candidates: string[]; watched: string[] } {
|
|
3612
4738
|
const empty = { candidates: [], watched: [] };
|
|
@@ -3643,6 +4769,7 @@ function selectNotifiableAbsentInputs(props: {
|
|
|
3643
4769
|
const spelling = path.resolve(absolute);
|
|
3644
4770
|
if (
|
|
3645
4771
|
seen.has(spelling) ||
|
|
4772
|
+
isTransformScratchInput(absolute, props.scratchDirectory) ||
|
|
3646
4773
|
(excluded !== undefined &&
|
|
3647
4774
|
pathIdentityKey(absolute, identities) === excluded) ||
|
|
3648
4775
|
props.filesystem.exists(absolute)
|
|
@@ -3756,22 +4883,57 @@ function insideProject(directory: string, projectRoot: string): boolean {
|
|
|
3756
4883
|
const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
|
|
3757
4884
|
|
|
3758
4885
|
function isIgnoredProjectDirectory(name: string): boolean {
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
4886
|
+
// The residue of what used to be a fifteen-name list, kept to the three
|
|
4887
|
+
// directories no tsconfig can name and no program can contain: the VCS
|
|
4888
|
+
// store, the package manager's tree (TypeScript's own default `exclude`
|
|
4889
|
+
// carries it too), and ttsc's own plugin cache. Everything else the old list
|
|
4890
|
+
// guessed at, and guessing was wrong in both directions: a bundler writing
|
|
4891
|
+
// to an unnamed directory changed project membership with its own output,
|
|
4892
|
+
// while a real source directory named `build` or `temp` was dropped from the
|
|
4893
|
+
// walk and its new files were never seen (samchon/ttsc#1307). Those are now
|
|
4894
|
+
// decided by `ITtscProjectMembershipPolicy`, which reads the configuration
|
|
4895
|
+
// that actually knows.
|
|
4896
|
+
return name === ".git" || name === ".ttsc" || name === "node_modules";
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
/**
|
|
4900
|
+
* Whether the resolved configuration keeps this directory out of the program.
|
|
4901
|
+
*
|
|
4902
|
+
* Compared by physical containment rather than by name, so `outDir: "./dist"`
|
|
4903
|
+
* excludes that one directory instead of every directory called `dist` at every
|
|
4904
|
+
* depth, which is the distinction the name list could not draw.
|
|
4905
|
+
*/
|
|
4906
|
+
function isExcludedProjectDirectory(
|
|
4907
|
+
directory: string,
|
|
4908
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4909
|
+
): boolean {
|
|
4910
|
+
return insideExcludedProjectDirectory(directory, policy, false);
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
/**
|
|
4914
|
+
* Whether this entry could enter the program, and so whether its appearance or
|
|
4915
|
+
* removal is a membership change.
|
|
4916
|
+
*
|
|
4917
|
+
* A directory always could, since it can hold sources. A file could only if it
|
|
4918
|
+
* carries an extension the resolved configuration admits, which is what makes a
|
|
4919
|
+
* bundle emitted beside the sources invisible to a project that compiles no
|
|
4920
|
+
* JavaScript.
|
|
4921
|
+
*/
|
|
4922
|
+
function isPossibleProgramEntry(
|
|
4923
|
+
entry: fs.Dirent,
|
|
4924
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4925
|
+
): boolean {
|
|
4926
|
+
return entry.isFile() ? isPossibleProgramFileName(entry.name, policy) : true;
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
/** The same question for a bare file name, for callers holding no `Dirent`. */
|
|
4930
|
+
function isPossibleProgramFileName(
|
|
4931
|
+
name: string,
|
|
4932
|
+
policy: ITtscProjectMembershipPolicy,
|
|
4933
|
+
): boolean {
|
|
4934
|
+
const lowered = name.toLowerCase();
|
|
4935
|
+
return policy.inputExtensions.some((extension) =>
|
|
4936
|
+
lowered.endsWith(extension),
|
|
3775
4937
|
);
|
|
3776
4938
|
}
|
|
3777
4939
|
|
|
@@ -3825,7 +4987,7 @@ function walkSnapshotComplete(
|
|
|
3825
4987
|
snapshot: {
|
|
3826
4988
|
complete: boolean;
|
|
3827
4989
|
directoryComplete: boolean;
|
|
3828
|
-
unstableFiles:
|
|
4990
|
+
unstableFiles: ReadonlySet<string>;
|
|
3829
4991
|
},
|
|
3830
4992
|
declared: ReadonlySet<string> | undefined,
|
|
3831
4993
|
): boolean {
|
|
@@ -3841,6 +5003,136 @@ function walkSnapshotComplete(
|
|
|
3841
5003
|
return true;
|
|
3842
5004
|
}
|
|
3843
5005
|
|
|
5006
|
+
/** Preserve exact project-walk and mutation witnesses for one failed attempt. */
|
|
5007
|
+
function recordProjectSnapshotFailures(
|
|
5008
|
+
failures: TtscGenerationProofFailures,
|
|
5009
|
+
props: {
|
|
5010
|
+
before: ReturnType<typeof collectProjectInputSnapshot>;
|
|
5011
|
+
candidateTracker?: TtscProjectMutationTracker;
|
|
5012
|
+
declared: ReadonlySet<string> | undefined;
|
|
5013
|
+
hostInputTracker?: TtscProjectMutationTracker;
|
|
5014
|
+
identities: FilesystemPathIdentityContext;
|
|
5015
|
+
projectRoot: string;
|
|
5016
|
+
snapshot: ReturnType<typeof collectProjectInputSnapshot>;
|
|
5017
|
+
tracker?: TtscProjectMutationTracker;
|
|
5018
|
+
},
|
|
5019
|
+
): void {
|
|
5020
|
+
const recordWalk = (
|
|
5021
|
+
snapshot: ReturnType<typeof collectProjectInputSnapshot>,
|
|
5022
|
+
): void => {
|
|
5023
|
+
for (const failure of snapshot.walkFailures) {
|
|
5024
|
+
if (failure.kind.startsWith("file-") && props.declared !== undefined) {
|
|
5025
|
+
try {
|
|
5026
|
+
const key = toProjectKey(
|
|
5027
|
+
props.projectRoot,
|
|
5028
|
+
failure.path,
|
|
5029
|
+
props.identities,
|
|
5030
|
+
);
|
|
5031
|
+
if (!props.declared.has(key)) continue;
|
|
5032
|
+
} catch {
|
|
5033
|
+
// An unidentifiable failed input taints the complete project walk.
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
recordGenerationProofFailure(failures, {
|
|
5037
|
+
domain: "project",
|
|
5038
|
+
kind: failure.kind,
|
|
5039
|
+
path: failure.path,
|
|
5040
|
+
});
|
|
5041
|
+
}
|
|
5042
|
+
};
|
|
5043
|
+
recordWalk(props.before);
|
|
5044
|
+
recordWalk(props.snapshot);
|
|
5045
|
+
|
|
5046
|
+
const keys =
|
|
5047
|
+
props.declared ??
|
|
5048
|
+
new Set([
|
|
5049
|
+
...Object.keys(props.before.hashes),
|
|
5050
|
+
...Object.keys(props.snapshot.hashes),
|
|
5051
|
+
]);
|
|
5052
|
+
for (const key of keys) {
|
|
5053
|
+
if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
|
|
5054
|
+
recordGenerationProofFailure(failures, {
|
|
5055
|
+
domain: "project",
|
|
5056
|
+
kind: "input-content-changed",
|
|
5057
|
+
path: path.resolve(props.projectRoot, key),
|
|
5058
|
+
});
|
|
5059
|
+
}
|
|
5060
|
+
if (
|
|
5061
|
+
props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]
|
|
5062
|
+
) {
|
|
5063
|
+
recordGenerationProofFailure(failures, {
|
|
5064
|
+
domain: "project",
|
|
5065
|
+
kind: "input-metadata-changed",
|
|
5066
|
+
path: path.resolve(props.projectRoot, key),
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
|
|
5071
|
+
const leftDirectories = new Map(
|
|
5072
|
+
props.before.projectDirectories.map((entry) => [
|
|
5073
|
+
entry.path,
|
|
5074
|
+
entry.signature,
|
|
5075
|
+
]),
|
|
5076
|
+
);
|
|
5077
|
+
const rightDirectories = new Map(
|
|
5078
|
+
props.snapshot.projectDirectories.map((entry) => [
|
|
5079
|
+
entry.path,
|
|
5080
|
+
entry.signature,
|
|
5081
|
+
]),
|
|
5082
|
+
);
|
|
5083
|
+
for (const directory of new Set([
|
|
5084
|
+
...leftDirectories.keys(),
|
|
5085
|
+
...rightDirectories.keys(),
|
|
5086
|
+
])) {
|
|
5087
|
+
if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
|
|
5088
|
+
recordGenerationProofFailure(failures, {
|
|
5089
|
+
domain: "project",
|
|
5090
|
+
kind: "directory-membership-changed",
|
|
5091
|
+
path: directory,
|
|
5092
|
+
});
|
|
5093
|
+
}
|
|
5094
|
+
}
|
|
5095
|
+
|
|
5096
|
+
const recordTracker = (
|
|
5097
|
+
tracker: TtscProjectMutationTracker | undefined,
|
|
5098
|
+
kind: string,
|
|
5099
|
+
): void => {
|
|
5100
|
+
if (tracker?.membershipChanged !== true) return;
|
|
5101
|
+
if (tracker.changes.size === 0) {
|
|
5102
|
+
recordGenerationProofFailure(failures, {
|
|
5103
|
+
domain: "project",
|
|
5104
|
+
kind,
|
|
5105
|
+
path: props.projectRoot,
|
|
5106
|
+
});
|
|
5107
|
+
return;
|
|
5108
|
+
}
|
|
5109
|
+
for (const changed of tracker.changes) {
|
|
5110
|
+
recordGenerationProofFailure(failures, {
|
|
5111
|
+
domain: "project",
|
|
5112
|
+
kind,
|
|
5113
|
+
path: changed,
|
|
5114
|
+
});
|
|
5115
|
+
}
|
|
5116
|
+
if (tracker.changesOmitted) {
|
|
5117
|
+
failures.omitted = Math.min(
|
|
5118
|
+
Number.MAX_SAFE_INTEGER,
|
|
5119
|
+
failures.omitted + 1,
|
|
5120
|
+
);
|
|
5121
|
+
}
|
|
5122
|
+
};
|
|
5123
|
+
recordTracker(props.tracker, "project-membership-event");
|
|
5124
|
+
recordTracker(props.hostInputTracker, "host-input-event");
|
|
5125
|
+
recordTracker(props.candidateTracker, "candidate-event");
|
|
5126
|
+
|
|
5127
|
+
if (failures.entries.length === 0) {
|
|
5128
|
+
recordGenerationProofFailure(failures, {
|
|
5129
|
+
domain: "project",
|
|
5130
|
+
kind: "snapshot-incomplete",
|
|
5131
|
+
path: props.projectRoot,
|
|
5132
|
+
});
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
|
|
3844
5136
|
/** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
|
|
3845
5137
|
function declaredProjectInputKeys(
|
|
3846
5138
|
state: TtscEnvelopeDerivation,
|
|
@@ -3851,6 +5143,7 @@ function declaredProjectInputKeys(
|
|
|
3851
5143
|
identities: state.identityContext,
|
|
3852
5144
|
projectRoot: cached.projectRoot,
|
|
3853
5145
|
result: cached.result,
|
|
5146
|
+
scratchDirectory: cached.scratchDirectory,
|
|
3854
5147
|
});
|
|
3855
5148
|
state.declaredInputKeysBuilt = true;
|
|
3856
5149
|
}
|
|
@@ -3867,6 +5160,7 @@ function selectDeclaredProjectInputKeys(props: {
|
|
|
3867
5160
|
identities: FilesystemPathIdentityContext;
|
|
3868
5161
|
projectRoot: string;
|
|
3869
5162
|
result: ITtscCompilerTransformation;
|
|
5163
|
+
scratchDirectory?: string;
|
|
3870
5164
|
}): Set<string> | undefined {
|
|
3871
5165
|
if (props.result.type === "exception" || props.result.graph === undefined) {
|
|
3872
5166
|
return undefined;
|
|
@@ -3875,13 +5169,9 @@ function selectDeclaredProjectInputKeys(props: {
|
|
|
3875
5169
|
const keys = new Set<string>();
|
|
3876
5170
|
const add = (entry: unknown): void => {
|
|
3877
5171
|
if (typeof entry !== "string" || entry.length === 0) return;
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
path.resolve(props.projectRoot, entry),
|
|
3882
|
-
props.identities,
|
|
3883
|
-
),
|
|
3884
|
-
);
|
|
5172
|
+
const absolute = path.resolve(props.projectRoot, entry);
|
|
5173
|
+
if (isTransformScratchInput(absolute, props.scratchDirectory)) return;
|
|
5174
|
+
keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
|
|
3885
5175
|
};
|
|
3886
5176
|
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
3887
5177
|
add(source);
|
|
@@ -3905,52 +5195,290 @@ function selectDeclaredProjectInputKeys(props: {
|
|
|
3905
5195
|
return keys;
|
|
3906
5196
|
}
|
|
3907
5197
|
|
|
3908
|
-
/**
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
5198
|
+
/** Create an empty bounded witness collection for one transform attempt. */
|
|
5199
|
+
function createGenerationProofFailures(): TtscGenerationProofFailures {
|
|
5200
|
+
return { entries: [], omitted: 0, seen: new Set() };
|
|
5201
|
+
}
|
|
5202
|
+
|
|
5203
|
+
/** Retain one unique proof witness without allowing diagnostics to grow freely. */
|
|
5204
|
+
function recordGenerationProofFailure(
|
|
5205
|
+
failures: TtscGenerationProofFailures,
|
|
5206
|
+
failure: TtscGenerationProofFailure,
|
|
5207
|
+
): void {
|
|
5208
|
+
const key = JSON.stringify([
|
|
5209
|
+
failure.domain,
|
|
5210
|
+
failure.kind,
|
|
5211
|
+
failure.path,
|
|
5212
|
+
failure.detail,
|
|
5213
|
+
]);
|
|
5214
|
+
if (failures.seen.has(key)) return;
|
|
5215
|
+
if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
|
|
5216
|
+
// `seen` follows the same bound as `entries`: retaining every discarded
|
|
5217
|
+
// identity would make a bounded diagnostic an unbounded memory sink.
|
|
5218
|
+
failures.seen.add(key);
|
|
5219
|
+
failures.entries.push(failure);
|
|
5220
|
+
} else {
|
|
5221
|
+
failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
|
|
5225
|
+
/** Fold one bounded witness collection into another. */
|
|
5226
|
+
function mergeGenerationProofFailures(
|
|
5227
|
+
target: TtscGenerationProofFailures,
|
|
5228
|
+
source: TtscGenerationProofFailures,
|
|
5229
|
+
): void {
|
|
5230
|
+
for (const failure of source.entries) {
|
|
5231
|
+
recordGenerationProofFailure(target, failure);
|
|
5232
|
+
}
|
|
5233
|
+
target.omitted = Math.min(
|
|
5234
|
+
Number.MAX_SAFE_INTEGER,
|
|
5235
|
+
target.omitted + source.omitted,
|
|
5236
|
+
);
|
|
5237
|
+
}
|
|
5238
|
+
|
|
5239
|
+
/** Hash the declared-input-relevant failure shape without retaining it. */
|
|
5240
|
+
function projectWalkFailureFingerprint(
|
|
5241
|
+
snapshot: {
|
|
5242
|
+
complete: boolean;
|
|
5243
|
+
directoryComplete: boolean;
|
|
5244
|
+
unstableFiles: ReadonlySet<string>;
|
|
5245
|
+
walkFailures: readonly TtscProjectWalkFailure[];
|
|
5246
|
+
},
|
|
5247
|
+
declared: ReadonlySet<string> | undefined,
|
|
5248
|
+
projectRoot: string,
|
|
5249
|
+
identities: FilesystemPathIdentityContext,
|
|
5250
|
+
): string {
|
|
5251
|
+
const relevantUnstableFiles =
|
|
5252
|
+
declared === undefined
|
|
5253
|
+
? [...snapshot.unstableFiles]
|
|
5254
|
+
: [...snapshot.unstableFiles].filter((key) => declared.has(key));
|
|
5255
|
+
const relevantFailures = snapshot.walkFailures.filter((failure) => {
|
|
5256
|
+
if (!failure.kind.startsWith("file-")) return true;
|
|
5257
|
+
if (declared === undefined) return true;
|
|
5258
|
+
try {
|
|
5259
|
+
return declared.has(toProjectKey(projectRoot, failure.path, identities));
|
|
5260
|
+
} catch {
|
|
5261
|
+
return true;
|
|
5262
|
+
}
|
|
5263
|
+
});
|
|
5264
|
+
return hashText(
|
|
5265
|
+
JSON.stringify({
|
|
5266
|
+
complete: walkSnapshotComplete(snapshot, declared),
|
|
5267
|
+
directoryComplete: snapshot.directoryComplete,
|
|
5268
|
+
failures: relevantFailures
|
|
5269
|
+
.map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
|
|
5270
|
+
.sort(),
|
|
5271
|
+
unstableFiles: relevantUnstableFiles.sort(),
|
|
5272
|
+
}),
|
|
5273
|
+
);
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5276
|
+
/** Compact state of one exact out-of-walk input in a failed generation. */
|
|
5277
|
+
function failedGenerationInputState(
|
|
5278
|
+
input: string,
|
|
5279
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
5280
|
+
): string {
|
|
5281
|
+
let directory = "not-directory";
|
|
5282
|
+
try {
|
|
5283
|
+
if (filesystem.stat(input).isDirectory()) {
|
|
5284
|
+
directory = hashText(
|
|
5285
|
+
filesystem
|
|
5286
|
+
.readdir(input)
|
|
5287
|
+
.map((entry) =>
|
|
5288
|
+
[
|
|
5289
|
+
entry.name,
|
|
5290
|
+
entry.isDirectory(),
|
|
5291
|
+
entry.isFile(),
|
|
5292
|
+
entry.isSymbolicLink(),
|
|
5293
|
+
].join(":"),
|
|
5294
|
+
)
|
|
5295
|
+
.sort()
|
|
5296
|
+
.join("\0"),
|
|
5297
|
+
);
|
|
5298
|
+
}
|
|
5299
|
+
} catch {
|
|
5300
|
+
directory = "unavailable";
|
|
5301
|
+
}
|
|
5302
|
+
return hashText(
|
|
5303
|
+
JSON.stringify([
|
|
5304
|
+
inputMetadataSignature(input, filesystem) ?? "missing",
|
|
5305
|
+
hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
|
|
5306
|
+
hostInputRealpath(input, filesystem),
|
|
5307
|
+
directory,
|
|
5308
|
+
]),
|
|
5309
|
+
);
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5312
|
+
/** Snapshot every input outside the project walk that could change a retry. */
|
|
5313
|
+
function captureFailedGenerationInputStates(
|
|
5314
|
+
cached: TtscCachedProjectTransform,
|
|
5315
|
+
failures: TtscGenerationProofFailures,
|
|
5316
|
+
): ReadonlyMap<string, string> {
|
|
5317
|
+
const filesystem = resultFilesystem(cached.result);
|
|
5318
|
+
const inputs = new Set(
|
|
5319
|
+
(cached.externalInputPaths ?? []).map((input) => path.resolve(input)),
|
|
5320
|
+
);
|
|
5321
|
+
for (const input of selectPersistentHostInputs({
|
|
5322
|
+
filesystem,
|
|
5323
|
+
projectRoot: cached.projectRoot,
|
|
5324
|
+
result: cached.result,
|
|
5325
|
+
scratchDirectory: cached.scratchDirectory,
|
|
5326
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
5327
|
+
})) {
|
|
5328
|
+
inputs.add(path.resolve(input));
|
|
5329
|
+
}
|
|
5330
|
+
for (const failure of failures.entries) {
|
|
5331
|
+
if (failure.path !== undefined) inputs.add(path.resolve(failure.path));
|
|
5332
|
+
}
|
|
5333
|
+
return new Map(
|
|
5334
|
+
[...inputs]
|
|
5335
|
+
.sort()
|
|
5336
|
+
.map((input) => [input, failedGenerationInputState(input, filesystem)]),
|
|
5337
|
+
);
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
/** Capture source baselines for project and out-of-walk transform outputs. */
|
|
5341
|
+
function captureTransformSourceHashes(
|
|
5342
|
+
cached: TtscCachedProjectTransform,
|
|
5343
|
+
currentFile: string,
|
|
5344
|
+
currentSourceHash: string,
|
|
5345
|
+
): Record<string, string> {
|
|
5346
|
+
const filesystem = resultFilesystem(cached.result);
|
|
5347
|
+
const identities = envelopeDerivation(cached).identityContext;
|
|
5348
|
+
const hashes: Record<string, string> = {};
|
|
5349
|
+
if (cached.result.type === "success") {
|
|
5350
|
+
for (const output of Object.keys(cached.result.typescript)) {
|
|
5351
|
+
const file = path.resolve(cached.projectRoot, output);
|
|
5352
|
+
const hash = hostInputStateHash(file, filesystem);
|
|
5353
|
+
if (hash !== null) hashes[pathIdentityKey(file, identities)] = hash;
|
|
5354
|
+
}
|
|
5355
|
+
}
|
|
5356
|
+
hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
|
|
5357
|
+
return hashes;
|
|
5358
|
+
}
|
|
3913
5359
|
|
|
3914
5360
|
/**
|
|
3915
|
-
*
|
|
5361
|
+
* Whether a terminal proof failure's observed environment actually changed.
|
|
3916
5362
|
*
|
|
3917
|
-
*
|
|
3918
|
-
*
|
|
3919
|
-
*
|
|
3920
|
-
* that never finished, and each investigation had to rediscover the cause from
|
|
3921
|
-
* outside. A named reason turns the next occurrence into a bug report instead
|
|
3922
|
-
* of an archaeology session.
|
|
5363
|
+
* This is deliberately a confirmation test: inability to re-probe retains the
|
|
5364
|
+
* old verdict instead of turning every module request into another compile.
|
|
5365
|
+
* Cache lifecycle reset remains the unconditional recovery boundary.
|
|
3923
5366
|
*/
|
|
3924
|
-
function
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
walkStable: boolean;
|
|
5367
|
+
function failedGenerationEnvironmentChanged(
|
|
5368
|
+
validation: TtscFailedGenerationValidation,
|
|
5369
|
+
props: {
|
|
5370
|
+
currentFile: string;
|
|
5371
|
+
currentSource: string;
|
|
5372
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
3931
5373
|
},
|
|
3932
|
-
):
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
5374
|
+
): boolean {
|
|
5375
|
+
try {
|
|
5376
|
+
const identities = envelopeDerivation(validation.cached).identityContext;
|
|
5377
|
+
const currentSourceHash = hashText(props.currentSource);
|
|
5378
|
+
const expectedSourceHash =
|
|
5379
|
+
validation.cached.sourceHashes?.[
|
|
5380
|
+
pathIdentityKey(props.currentFile, identities)
|
|
5381
|
+
];
|
|
5382
|
+
if (
|
|
5383
|
+
expectedSourceHash !== undefined &&
|
|
5384
|
+
expectedSourceHash !== currentSourceHash
|
|
5385
|
+
) {
|
|
5386
|
+
return true;
|
|
5387
|
+
}
|
|
5388
|
+
const current = collectProjectInputSnapshot(
|
|
5389
|
+
validation.cached.projectRoot,
|
|
5390
|
+
identities,
|
|
5391
|
+
props.filesystem,
|
|
5392
|
+
undefined,
|
|
5393
|
+
{ policy: validation.cached.membershipPolicy },
|
|
5394
|
+
);
|
|
5395
|
+
if (
|
|
5396
|
+
validation.projectWalkComplete !==
|
|
5397
|
+
walkSnapshotComplete(current, validation.declaredInputs) ||
|
|
5398
|
+
validation.projectWalkFailures !==
|
|
5399
|
+
projectWalkFailureFingerprint(
|
|
5400
|
+
current,
|
|
5401
|
+
validation.declaredInputs,
|
|
5402
|
+
validation.cached.projectRoot,
|
|
5403
|
+
identities,
|
|
5404
|
+
) ||
|
|
5405
|
+
!sameHashes(
|
|
5406
|
+
validation.projectInputHashes,
|
|
5407
|
+
current.hashes,
|
|
5408
|
+
validation.declaredInputs,
|
|
5409
|
+
) ||
|
|
5410
|
+
!sameProjectDirectories(
|
|
5411
|
+
validation.cached.projectDirectories ?? [],
|
|
5412
|
+
current.projectDirectories,
|
|
5413
|
+
)
|
|
5414
|
+
) {
|
|
5415
|
+
return true;
|
|
5416
|
+
}
|
|
5417
|
+
for (const [input, recorded] of validation.inputStates) {
|
|
5418
|
+
if (failedGenerationInputState(input, props.filesystem) !== recorded) {
|
|
5419
|
+
return true;
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
return false;
|
|
5423
|
+
} catch {
|
|
5424
|
+
return false;
|
|
3944
5425
|
}
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
5426
|
+
}
|
|
5427
|
+
|
|
5428
|
+
/** Render one input without leaking source content or control characters. */
|
|
5429
|
+
function formatGenerationFailurePath(
|
|
5430
|
+
projectRoot: string,
|
|
5431
|
+
input: string,
|
|
5432
|
+
): string {
|
|
5433
|
+
const absolute = path.resolve(input);
|
|
5434
|
+
const relative = path.relative(projectRoot, absolute);
|
|
5435
|
+
const display =
|
|
5436
|
+
relative === ""
|
|
5437
|
+
? "."
|
|
5438
|
+
: relative !== ".." &&
|
|
5439
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
5440
|
+
!path.isAbsolute(relative)
|
|
5441
|
+
? relative
|
|
5442
|
+
: absolute;
|
|
5443
|
+
return JSON.stringify(display.split(path.sep).join("/"));
|
|
5444
|
+
}
|
|
5445
|
+
|
|
5446
|
+
/** Build the terminal error shared by every waiter of an unstable generation. */
|
|
5447
|
+
function createUnstableGenerationError(
|
|
5448
|
+
projectRoot: string,
|
|
5449
|
+
attempts: readonly TtscGenerationProofFailures[],
|
|
5450
|
+
validation: TtscFailedGenerationValidation,
|
|
5451
|
+
): TtscUnstableGenerationError {
|
|
5452
|
+
const lines = [
|
|
5453
|
+
`ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
|
|
5454
|
+
` project: ${projectRoot}`,
|
|
5455
|
+
];
|
|
5456
|
+
attempts.forEach((failures, index) => {
|
|
5457
|
+
lines.push(` attempt ${index + 1}:`);
|
|
5458
|
+
if (failures.entries.length === 0) {
|
|
5459
|
+
lines.push(" - project/generation-proof-incomplete");
|
|
5460
|
+
}
|
|
5461
|
+
for (const failure of failures.entries) {
|
|
5462
|
+
const input =
|
|
5463
|
+
failure.path === undefined
|
|
5464
|
+
? ""
|
|
5465
|
+
: `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
|
|
5466
|
+
const detail =
|
|
5467
|
+
failure.detail === undefined
|
|
5468
|
+
? ""
|
|
5469
|
+
: ` (producer: ${JSON.stringify(failure.detail)})`;
|
|
5470
|
+
lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
|
|
5471
|
+
}
|
|
5472
|
+
if (failures.omitted !== 0) {
|
|
5473
|
+
lines.push(
|
|
5474
|
+
` - ... ${failures.omitted} additional witness(es) omitted`,
|
|
5475
|
+
);
|
|
5476
|
+
}
|
|
5477
|
+
});
|
|
5478
|
+
lines.push(
|
|
5479
|
+
" Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.",
|
|
3953
5480
|
);
|
|
5481
|
+
return new TtscUnstableGenerationError(lines.join("\n"), validation);
|
|
3954
5482
|
}
|
|
3955
5483
|
|
|
3956
5484
|
function hashText(input: string | Buffer): string {
|
|
@@ -3962,6 +5490,58 @@ async function transformProject(props: {
|
|
|
3962
5490
|
compilerOptions: Record<string, unknown>;
|
|
3963
5491
|
currentFile: string;
|
|
3964
5492
|
currentSource: string;
|
|
5493
|
+
/**
|
|
5494
|
+
* Delivery pass this compile was started for; see
|
|
5495
|
+
* {@link TtscCachedProjectTransform.deliveryEpoch}.
|
|
5496
|
+
*/
|
|
5497
|
+
deliveryEpoch?: number;
|
|
5498
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
5499
|
+
plugins?: ResolvedTtscUnpluginOptions["plugins"];
|
|
5500
|
+
trackProjectMembership: boolean;
|
|
5501
|
+
tsconfig: string;
|
|
5502
|
+
}): Promise<TtscCachedProjectTransform> {
|
|
5503
|
+
const attempts: TtscGenerationProofFailures[] = [];
|
|
5504
|
+
for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
|
|
5505
|
+
const cached = await captureTransformGeneration(props);
|
|
5506
|
+
if (
|
|
5507
|
+
!props.trackProjectMembership ||
|
|
5508
|
+
cached.result.type !== "success" ||
|
|
5509
|
+
cached.projectSnapshotComplete === true
|
|
5510
|
+
) {
|
|
5511
|
+
return cached;
|
|
5512
|
+
}
|
|
5513
|
+
attempts.push(
|
|
5514
|
+
TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
|
|
5515
|
+
createGenerationProofFailures(),
|
|
5516
|
+
);
|
|
5517
|
+
if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
|
|
5518
|
+
const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(
|
|
5519
|
+
cached.result,
|
|
5520
|
+
);
|
|
5521
|
+
if (validation === undefined) {
|
|
5522
|
+
disposeCachedTransform(cached);
|
|
5523
|
+
throw new Error(
|
|
5524
|
+
"ttsc: failed transform generation has no retry validation baseline",
|
|
5525
|
+
);
|
|
5526
|
+
}
|
|
5527
|
+
throw createUnstableGenerationError(
|
|
5528
|
+
path.dirname(props.tsconfig),
|
|
5529
|
+
attempts,
|
|
5530
|
+
validation,
|
|
5531
|
+
);
|
|
5532
|
+
}
|
|
5533
|
+
disposeCachedTransform(cached);
|
|
5534
|
+
}
|
|
5535
|
+
throw new Error("ttsc: transform generation retry loop did not terminate");
|
|
5536
|
+
}
|
|
5537
|
+
|
|
5538
|
+
/** Capture one whole-project transform attempt and all of its reuse proofs. */
|
|
5539
|
+
async function captureTransformGeneration(props: {
|
|
5540
|
+
aliasPaths: Record<string, string[]>;
|
|
5541
|
+
compilerOptions: Record<string, unknown>;
|
|
5542
|
+
currentFile: string;
|
|
5543
|
+
currentSource: string;
|
|
5544
|
+
deliveryEpoch?: number;
|
|
3965
5545
|
filesystem: TtscTransformFilesystemOperations;
|
|
3966
5546
|
plugins?: ResolvedTtscUnpluginOptions["plugins"];
|
|
3967
5547
|
trackProjectMembership: boolean;
|
|
@@ -3983,15 +5563,27 @@ async function transformProject(props: {
|
|
|
3983
5563
|
const temporaryTsconfig =
|
|
3984
5564
|
configured.path === props.tsconfig ? undefined : configured.path;
|
|
3985
5565
|
const identities = createHostPathIdentityContext(props.filesystem);
|
|
5566
|
+
// Read from the project's own tsconfig rather than the generated one: a
|
|
5567
|
+
// relative `outDir` is anchored at the config that declares it, and the
|
|
5568
|
+
// generated config lives in a system temp directory. The caller's
|
|
5569
|
+
// compiler-options overlay still wins, since it wins for the compile too.
|
|
5570
|
+
const membershipPolicy = mergeMembershipPolicyOverlay(
|
|
5571
|
+
readProjectMembershipPolicy(props.tsconfig),
|
|
5572
|
+
props.compilerOptions,
|
|
5573
|
+
projectRoot,
|
|
5574
|
+
);
|
|
3986
5575
|
const before = collectProjectInputSnapshot(
|
|
3987
5576
|
projectRoot,
|
|
3988
5577
|
identities,
|
|
3989
5578
|
props.filesystem,
|
|
5579
|
+
undefined,
|
|
5580
|
+
{ policy: membershipPolicy },
|
|
3990
5581
|
);
|
|
3991
5582
|
tracker = props.trackProjectMembership
|
|
3992
5583
|
? await createProjectMutationTracker(
|
|
3993
5584
|
before.projectDirectories,
|
|
3994
5585
|
props.filesystem,
|
|
5586
|
+
membershipPolicy,
|
|
3995
5587
|
)
|
|
3996
5588
|
: undefined;
|
|
3997
5589
|
const result = withTransformScratchEnvironment(scratchDirectory, () =>
|
|
@@ -4019,6 +5611,7 @@ async function transformProject(props: {
|
|
|
4019
5611
|
filesystem: props.filesystem,
|
|
4020
5612
|
projectRoot,
|
|
4021
5613
|
result,
|
|
5614
|
+
scratchDirectory,
|
|
4022
5615
|
temporaryTsconfig,
|
|
4023
5616
|
});
|
|
4024
5617
|
// The generation's absent resolution candidates, which get a watcher of
|
|
@@ -4034,6 +5627,7 @@ async function transformProject(props: {
|
|
|
4034
5627
|
filesystem: props.filesystem,
|
|
4035
5628
|
projectRoot,
|
|
4036
5629
|
result,
|
|
5630
|
+
scratchDirectory,
|
|
4037
5631
|
temporaryTsconfig,
|
|
4038
5632
|
})
|
|
4039
5633
|
: { candidates: [], watched: [] };
|
|
@@ -4065,14 +5659,18 @@ async function transformProject(props: {
|
|
|
4065
5659
|
: undefined;
|
|
4066
5660
|
const externalInputPaths = selectExternalInputPaths({
|
|
4067
5661
|
filesystem: props.filesystem,
|
|
5662
|
+
membershipPolicy,
|
|
4068
5663
|
projectRoot,
|
|
4069
5664
|
result,
|
|
5665
|
+
scratchDirectory,
|
|
4070
5666
|
temporaryTsconfig,
|
|
4071
5667
|
});
|
|
4072
5668
|
const inputSnapshot = collectProjectInputSnapshot(
|
|
4073
5669
|
projectRoot,
|
|
4074
5670
|
identities,
|
|
4075
5671
|
props.filesystem,
|
|
5672
|
+
undefined,
|
|
5673
|
+
{ policy: membershipPolicy },
|
|
4076
5674
|
);
|
|
4077
5675
|
// Whether the recorded snapshot describes one coherent state of the
|
|
4078
5676
|
// project. A membership event during the compile taints it exactly like an
|
|
@@ -4083,6 +5681,7 @@ async function transformProject(props: {
|
|
|
4083
5681
|
identities,
|
|
4084
5682
|
projectRoot,
|
|
4085
5683
|
result,
|
|
5684
|
+
scratchDirectory,
|
|
4086
5685
|
});
|
|
4087
5686
|
const walkStable =
|
|
4088
5687
|
walkSnapshotComplete(before, declaredInputs) &&
|
|
@@ -4111,29 +5710,50 @@ async function transformProject(props: {
|
|
|
4111
5710
|
props.currentFile,
|
|
4112
5711
|
identities,
|
|
4113
5712
|
);
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
5713
|
+
const currentSourceHash = hashText(props.currentSource);
|
|
5714
|
+
const projectInputHashes = { ...inputSnapshot.hashes };
|
|
5715
|
+
if (
|
|
5716
|
+
Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)
|
|
5717
|
+
) {
|
|
5718
|
+
inputSnapshot.hashes[currentFileKey] = currentSourceHash;
|
|
5719
|
+
// That overlay makes this one key the only recorded hash a disk signature
|
|
5720
|
+
// cannot stand for: the bytes it names came from the bundler, not the file.
|
|
5721
|
+
delete inputSnapshot.provenSignatures[currentFileKey];
|
|
5722
|
+
}
|
|
4118
5723
|
const cached: TtscCachedProjectTransform = {
|
|
5724
|
+
// The pass this compile was started for. Its snapshot describes the
|
|
5725
|
+
// project as of this compile, so it is settled for this pass and any
|
|
5726
|
+
// later pass must re-prove it.
|
|
5727
|
+
...(props.deliveryEpoch === undefined
|
|
5728
|
+
? {}
|
|
5729
|
+
: { deliveryEpoch: props.deliveryEpoch }),
|
|
4119
5730
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
4120
5731
|
// cache validation can re-check them; computed before dispose so the
|
|
4121
|
-
//
|
|
5732
|
+
// scratch-tree exclusion is the only reason its disposed artifacts never
|
|
5733
|
+
// key the persistent generation.
|
|
4122
5734
|
externalInputHashes: {},
|
|
4123
5735
|
externalInputRealpaths: {},
|
|
4124
5736
|
externalInputPaths,
|
|
4125
5737
|
inputHashes: inputSnapshot.hashes,
|
|
4126
5738
|
inputSignatures: inputSnapshot.provenSignatures,
|
|
5739
|
+
membershipPolicy,
|
|
4127
5740
|
projectDirectories: inputSnapshot.projectDirectories,
|
|
5741
|
+
tsconfig: props.tsconfig,
|
|
4128
5742
|
projectSnapshotComplete: false,
|
|
4129
5743
|
projectRoot,
|
|
4130
5744
|
result,
|
|
5745
|
+
scratchDirectory,
|
|
4131
5746
|
servedFiles: new Set(),
|
|
4132
5747
|
// Remember the generated temp-dir tsconfig (disposed below) so watch
|
|
4133
5748
|
// derivation can drop it from the envelope's config chain; a registered
|
|
4134
5749
|
// but deleted file would invalidate every persistent-cache snapshot.
|
|
4135
5750
|
...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
|
|
4136
5751
|
};
|
|
5752
|
+
cached.sourceHashes = captureTransformSourceHashes(
|
|
5753
|
+
cached,
|
|
5754
|
+
props.currentFile,
|
|
5755
|
+
currentSourceHash,
|
|
5756
|
+
);
|
|
4137
5757
|
const externalInputSnapshot = captureExternalInputSnapshot(
|
|
4138
5758
|
cached,
|
|
4139
5759
|
externalInputPaths,
|
|
@@ -4145,23 +5765,52 @@ async function transformProject(props: {
|
|
|
4145
5765
|
// cannot be reused can say which evidence it lacked. The extra work runs
|
|
4146
5766
|
// only on the failing path, where the alternative is recompiling the whole
|
|
4147
5767
|
// project for every remaining module.
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
5768
|
+
const failures = createGenerationProofFailures();
|
|
5769
|
+
if (!walkStable) {
|
|
5770
|
+
recordProjectSnapshotFailures(failures, {
|
|
5771
|
+
before,
|
|
5772
|
+
candidateTracker,
|
|
5773
|
+
declared: declaredInputs,
|
|
5774
|
+
hostInputTracker,
|
|
5775
|
+
identities,
|
|
5776
|
+
projectRoot,
|
|
5777
|
+
snapshot: inputSnapshot,
|
|
5778
|
+
tracker,
|
|
5779
|
+
});
|
|
5780
|
+
}
|
|
5781
|
+
const graphFailures = compilerGraphInputProofFailures(cached);
|
|
5782
|
+
mergeGenerationProofFailures(failures, graphFailures);
|
|
5783
|
+
mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
|
|
5784
|
+
const universalInputCapture = captureUniversalHostInputValidation(
|
|
5785
|
+
cached,
|
|
5786
|
+
props.currentFile,
|
|
5787
|
+
);
|
|
5788
|
+
mergeGenerationProofFailures(failures, universalInputCapture.failures);
|
|
5789
|
+
const graphProofs =
|
|
5790
|
+
graphFailures.entries.length === 0 && graphFailures.omitted === 0;
|
|
5791
|
+
const universalInputs = universalInputCapture.validation !== undefined;
|
|
4152
5792
|
const stableProjectSnapshot =
|
|
4153
5793
|
walkStable &&
|
|
4154
5794
|
graphProofs &&
|
|
4155
5795
|
externalInputSnapshot.complete &&
|
|
4156
5796
|
universalInputs;
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
5797
|
+
if (!stableProjectSnapshot) {
|
|
5798
|
+
TRANSFORM_GENERATION_FAILURES.set(result, failures);
|
|
5799
|
+
TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
|
|
5800
|
+
cached,
|
|
5801
|
+
declaredInputs,
|
|
5802
|
+
inputStates: captureFailedGenerationInputStates(cached, failures),
|
|
5803
|
+
projectInputHashes,
|
|
5804
|
+
projectWalkComplete: walkSnapshotComplete(
|
|
5805
|
+
inputSnapshot,
|
|
5806
|
+
declaredInputs,
|
|
5807
|
+
),
|
|
5808
|
+
projectWalkFailures: projectWalkFailureFingerprint(
|
|
5809
|
+
inputSnapshot,
|
|
5810
|
+
declaredInputs,
|
|
5811
|
+
projectRoot,
|
|
5812
|
+
identities,
|
|
5813
|
+
),
|
|
4165
5814
|
});
|
|
4166
5815
|
}
|
|
4167
5816
|
cached.projectSnapshotComplete = stableProjectSnapshot;
|
|
@@ -4209,21 +5858,30 @@ async function transformProject(props: {
|
|
|
4209
5858
|
}
|
|
4210
5859
|
}
|
|
4211
5860
|
|
|
4212
|
-
/** Exclude
|
|
5861
|
+
/** Exclude disposed transform scratch from live host-input tracking. */
|
|
4213
5862
|
function selectPersistentHostInputs(props: {
|
|
4214
5863
|
filesystem: TtscTransformFilesystemOperations;
|
|
4215
5864
|
projectRoot: string;
|
|
4216
5865
|
result: ITtscCompilerTransformation;
|
|
5866
|
+
scratchDirectory?: string;
|
|
4217
5867
|
temporaryTsconfig?: string;
|
|
4218
5868
|
}): string[] {
|
|
4219
5869
|
if (props.result.type === "exception") return [];
|
|
4220
5870
|
const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
|
|
4221
|
-
if (
|
|
5871
|
+
if (
|
|
5872
|
+
props.scratchDirectory === undefined &&
|
|
5873
|
+
props.temporaryTsconfig === undefined
|
|
5874
|
+
)
|
|
5875
|
+
return inputs;
|
|
4222
5876
|
const identities = createHostPathIdentityContext(props.filesystem);
|
|
4223
|
-
const temporary =
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
5877
|
+
const temporary =
|
|
5878
|
+
props.temporaryTsconfig === undefined
|
|
5879
|
+
? undefined
|
|
5880
|
+
: pathIdentityKey(props.temporaryTsconfig, identities);
|
|
5881
|
+
return inputs.filter((input) => {
|
|
5882
|
+
if (isTransformScratchInput(input, props.scratchDirectory)) return false;
|
|
5883
|
+
return pathIdentityKey(input, identities) !== temporary;
|
|
5884
|
+
});
|
|
4227
5885
|
}
|
|
4228
5886
|
|
|
4229
5887
|
function createTransformTsconfig(
|
|
@@ -4344,6 +6002,17 @@ function pathIsWithin(child: string, parent: string): boolean {
|
|
|
4344
6002
|
);
|
|
4345
6003
|
}
|
|
4346
6004
|
|
|
6005
|
+
/** Whether an input is owned by the disposable transform scratch tree. */
|
|
6006
|
+
function isTransformScratchInput(
|
|
6007
|
+
input: string,
|
|
6008
|
+
scratchDirectory: string | undefined,
|
|
6009
|
+
): boolean {
|
|
6010
|
+
return (
|
|
6011
|
+
scratchDirectory !== undefined &&
|
|
6012
|
+
pathIsWithin(path.resolve(input), path.resolve(scratchDirectory))
|
|
6013
|
+
);
|
|
6014
|
+
}
|
|
6015
|
+
|
|
4347
6016
|
/** Route all compiler/plugin scratch to one owned directory outside project. */
|
|
4348
6017
|
function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
|
|
4349
6018
|
return {
|
|
@@ -4513,10 +6182,40 @@ function readPaths(value: unknown): Record<string, string[]> {
|
|
|
4513
6182
|
function createAliasPaths(aliases: unknown): Record<string, string[]> {
|
|
4514
6183
|
const paths: Record<string, string[]> = {};
|
|
4515
6184
|
for (const alias of normalizeAliases(aliases)) {
|
|
4516
|
-
if (typeof alias.find !== "string"
|
|
6185
|
+
if (typeof alias.find !== "string") {
|
|
6186
|
+
// Vite's array form accepts a `RegExp` find, and `{ find: /^~/ }` is a
|
|
6187
|
+
// common way to spell a prefix alias. A tsconfig `paths` map has no
|
|
6188
|
+
// regular-expression form, so there is nothing to translate it into
|
|
6189
|
+
// (samchon/ttsc#1315). Reducing the simple prefix cases to a string is
|
|
6190
|
+
// possible in principle and deliberately not done: telling `/^~/` from
|
|
6191
|
+
// `/^~(?=\/)/` or `/^@app/` — which matches `@apple` too — means
|
|
6192
|
+
// implementing enough of a regular-expression engine that a wrong
|
|
6193
|
+
// reduction becomes likely, and a mistranslated alias resolves imports to
|
|
6194
|
+
// the wrong file silently, which is worse than not forwarding it.
|
|
6195
|
+
//
|
|
6196
|
+
// Not reported, unlike the wildcard below, and that asymmetry is the
|
|
6197
|
+
// whole point: Vite merges two `RegExp` aliases of its own into every
|
|
6198
|
+
// resolved config, `/^\/?@vite\/env/` and `/^\/?@vite\/client/`. Measured
|
|
6199
|
+
// on a bare project with no user aliases at all, `resolve.alias` has
|
|
6200
|
+
// exactly those two entries under both `serve` and `build`, so a report
|
|
6201
|
+
// on this form would fire twice for every Vite user in every build, name
|
|
6202
|
+
// aliases they never wrote, and say nothing about their configuration.
|
|
6203
|
+
// A diagnostic that cannot distinguish the user's input from the host's
|
|
6204
|
+
// is noise, and noise is what teaches people to stop reading the channel
|
|
6205
|
+
// the out-of-program report depends on. The documentation carries this
|
|
6206
|
+
// form instead, in both README and guide.
|
|
6207
|
+
continue;
|
|
6208
|
+
}
|
|
6209
|
+
if (alias.find.length === 0) {
|
|
4517
6210
|
continue;
|
|
4518
6211
|
}
|
|
4519
6212
|
if (alias.find.includes("*")) {
|
|
6213
|
+
// A `paths` key reads `*` as its own wildcard, so forwarding a `find`
|
|
6214
|
+
// that already contains one cannot preserve the caller's meaning.
|
|
6215
|
+
reportUntranslatableAlias(
|
|
6216
|
+
JSON.stringify(alias.find),
|
|
6217
|
+
'a "paths" key already reads "*" as its own wildcard',
|
|
6218
|
+
);
|
|
4520
6219
|
continue;
|
|
4521
6220
|
}
|
|
4522
6221
|
const key = alias.find.replace(/\/+$/, "");
|
|
@@ -4534,9 +6233,54 @@ function createAliasPaths(aliases: unknown): Record<string, string[]> {
|
|
|
4534
6233
|
return paths;
|
|
4535
6234
|
}
|
|
4536
6235
|
|
|
4537
|
-
|
|
6236
|
+
/**
|
|
6237
|
+
* Alias descriptions already reported in this process.
|
|
6238
|
+
*
|
|
6239
|
+
* The message is about configuration rather than about a module:
|
|
6240
|
+
* `resolve.alias` is resolved once and then consulted on every delivery, so
|
|
6241
|
+
* reporting per delivery would repeat one statement about the config for every
|
|
6242
|
+
* file in the bundle. Keyed by the description, so a Vite dev server that
|
|
6243
|
+
* reloads its config reports again only when the alias itself changed.
|
|
6244
|
+
*/
|
|
6245
|
+
const REPORTED_UNTRANSLATABLE_ALIASES = new Set<string>();
|
|
6246
|
+
|
|
6247
|
+
/**
|
|
6248
|
+
* Tell the user once that an alias they declared is not reaching the compile.
|
|
6249
|
+
*
|
|
6250
|
+
* A dropped alias is not silent in its consequence — the compile resolves
|
|
6251
|
+
* through the tsconfig's own `paths`, and a module that resolves for the
|
|
6252
|
+
* bundler but not for the compiler surfaces as the out-of-program report
|
|
6253
|
+
* (samchon/ttsc#1308) — but that report names the module, not the alias, so the
|
|
6254
|
+
* user cannot learn from it that a configuration they wrote was ignored.
|
|
6255
|
+
*
|
|
6256
|
+
* Only the wildcard form reaches here. Every entry it names was written by the
|
|
6257
|
+
* user, because nothing injects one; the `RegExp` form is left to the
|
|
6258
|
+
* documentation precisely because Vite does inject those, and
|
|
6259
|
+
* {@link createAliasPaths} carries that measurement.
|
|
6260
|
+
*/
|
|
6261
|
+
function reportUntranslatableAlias(description: string, reason: string): void {
|
|
6262
|
+
if (REPORTED_UNTRANSLATABLE_ALIASES.has(description)) {
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
REPORTED_UNTRANSLATABLE_ALIASES.add(description);
|
|
6266
|
+
process.stderr.write(
|
|
6267
|
+
`ttsc: the Vite alias ${description} was not forwarded to the compile, because ${reason}. Declare it in your tsconfig's "paths" if ttsc must resolve through it.\n`,
|
|
6268
|
+
);
|
|
6269
|
+
}
|
|
6270
|
+
|
|
6271
|
+
/**
|
|
6272
|
+
* Collect the host's declared aliases without deciding which of them can be
|
|
6273
|
+
* expressed as `paths`.
|
|
6274
|
+
*
|
|
6275
|
+
* That decision belongs to {@link createAliasPaths} alone. It used to be split:
|
|
6276
|
+
* this function's type guard required a string `find` and dropped Vite's
|
|
6277
|
+
* `RegExp` form before `createAliasPaths` ever saw it, which left
|
|
6278
|
+
* `createAliasPaths`'s own non-string branch unreachable and put the drop
|
|
6279
|
+
* somewhere nothing could report it (samchon/ttsc#1315).
|
|
6280
|
+
*/
|
|
6281
|
+
function normalizeAliases(aliases: unknown): TtscDeclaredAlias[] {
|
|
4538
6282
|
if (Array.isArray(aliases)) {
|
|
4539
|
-
return aliases.filter(
|
|
6283
|
+
return aliases.filter(isDeclaredAlias);
|
|
4540
6284
|
}
|
|
4541
6285
|
if (typeof aliases === "object" && aliases !== null) {
|
|
4542
6286
|
return Object.entries(aliases)
|
|
@@ -4594,13 +6338,12 @@ function isRelativeSpecifier(value: string): boolean {
|
|
|
4594
6338
|
);
|
|
4595
6339
|
}
|
|
4596
6340
|
|
|
4597
|
-
function
|
|
6341
|
+
function isDeclaredAlias(value: unknown): value is TtscDeclaredAlias {
|
|
4598
6342
|
return (
|
|
4599
6343
|
typeof value === "object" &&
|
|
4600
6344
|
value !== null &&
|
|
4601
6345
|
"find" in value &&
|
|
4602
6346
|
"replacement" in value &&
|
|
4603
|
-
typeof value.find === "string" &&
|
|
4604
6347
|
typeof value.replacement === "string"
|
|
4605
6348
|
);
|
|
4606
6349
|
}
|
|
@@ -4618,6 +6361,7 @@ function selectTransformedSource(props: {
|
|
|
4618
6361
|
file: string;
|
|
4619
6362
|
projectRoot: string;
|
|
4620
6363
|
result: ITtscCompilerTransformation;
|
|
6364
|
+
tsconfig: string;
|
|
4621
6365
|
}): string {
|
|
4622
6366
|
if (props.result.type === "exception") {
|
|
4623
6367
|
throw new Error(formatUnknownError(props.result.error));
|
|
@@ -4648,20 +6392,68 @@ function selectTransformedSource(props: {
|
|
|
4648
6392
|
if (source !== undefined) {
|
|
4649
6393
|
return source;
|
|
4650
6394
|
}
|
|
4651
|
-
throw new
|
|
6395
|
+
throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
|
|
6396
|
+
}
|
|
6397
|
+
|
|
6398
|
+
/**
|
|
6399
|
+
* Tell the user once that a module was left untransformed, and why.
|
|
6400
|
+
*
|
|
6401
|
+
* The condition is ordinary and the build continues, but it must never be
|
|
6402
|
+
* silent: a file the program does not contain keeps whatever plugin syntax it
|
|
6403
|
+
* carries, so a typia `assert<T>()` in it becomes a runtime failure rather than
|
|
6404
|
+
* a build failure. One line per file per generation per pass, on the channel
|
|
6405
|
+
* the generation's other non-fatal diagnostics already use, so a bundle that
|
|
6406
|
+
* reaches many such files does not repeat itself per delivery.
|
|
6407
|
+
*/
|
|
6408
|
+
function reportMissingProgramOutput(
|
|
6409
|
+
cached: TtscCachedProjectTransform,
|
|
6410
|
+
error: TtscMissingProgramOutputError,
|
|
6411
|
+
epoch: number | undefined,
|
|
6412
|
+
): void {
|
|
6413
|
+
const reported = (cached.missingOutputReported ??= new Set<string>());
|
|
6414
|
+
if (cached.missingOutputEpoch !== epoch) {
|
|
6415
|
+
cached.missingOutputEpoch = epoch;
|
|
6416
|
+
reported.clear();
|
|
6417
|
+
}
|
|
6418
|
+
if (reported.has(error.file)) {
|
|
6419
|
+
return;
|
|
6420
|
+
}
|
|
6421
|
+
reported.add(error.file);
|
|
6422
|
+
process.stderr.write(`${error.message}
|
|
6423
|
+
`);
|
|
4652
6424
|
}
|
|
4653
6425
|
|
|
4654
6426
|
/**
|
|
4655
|
-
* Forward non-fatal plugin diagnostics to stderr.
|
|
6427
|
+
* Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
|
|
4656
6428
|
*
|
|
4657
6429
|
* A `success` result may still carry warnings or informational messages from
|
|
4658
|
-
* plugins
|
|
4659
|
-
*
|
|
6430
|
+
* plugins — `@ttsc/lint` reports every rule below error severity this way.
|
|
6431
|
+
* These are surfaced via stderr rather than throwing so the build continues.
|
|
6432
|
+
* Failures and exceptions are handled by the caller.
|
|
6433
|
+
*
|
|
6434
|
+
* They describe one compile of one program, so writing them per delivery
|
|
6435
|
+
* printed the same warning once per module and scaled the noise with exactly
|
|
6436
|
+
* the reuse the cache exists to provide (samchon/ttsc#1304). A pass that reuses
|
|
6437
|
+
* a retained generation still surfaces them once, because a build's warnings
|
|
6438
|
+
* are part of what that build reports; a host with no pass boundary surfaces
|
|
6439
|
+
* them once per generation, which is the same rule with one pass.
|
|
4660
6440
|
*/
|
|
4661
|
-
function reportSuccessDiagnostics(
|
|
6441
|
+
function reportSuccessDiagnostics(
|
|
6442
|
+
cached: TtscCachedProjectTransform,
|
|
6443
|
+
epoch: number | undefined,
|
|
6444
|
+
): void {
|
|
6445
|
+
const result = cached.result;
|
|
4662
6446
|
if (result.type !== "success" || result.diagnostics === undefined) {
|
|
4663
6447
|
return;
|
|
4664
6448
|
}
|
|
6449
|
+
if (
|
|
6450
|
+
cached.diagnosticsReported === true &&
|
|
6451
|
+
cached.diagnosticsEpoch === epoch
|
|
6452
|
+
) {
|
|
6453
|
+
return;
|
|
6454
|
+
}
|
|
6455
|
+
cached.diagnosticsReported = true;
|
|
6456
|
+
cached.diagnosticsEpoch = epoch;
|
|
4665
6457
|
const text = formatDiagnostics(result.diagnostics);
|
|
4666
6458
|
if (text.length !== 0) {
|
|
4667
6459
|
process.stderr.write(`${text}\n`);
|
|
@@ -4687,7 +6479,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
|
|
|
4687
6479
|
diag.line === undefined
|
|
4688
6480
|
? undefined
|
|
4689
6481
|
: `${diag.line}:${diag.character ?? 1}`,
|
|
4690
|
-
diag.messageText,
|
|
6482
|
+
stripTerminalEscapes(diag.messageText),
|
|
4691
6483
|
]
|
|
4692
6484
|
.filter((part) => part !== undefined && part !== "")
|
|
4693
6485
|
.join(": "),
|
|
@@ -4697,7 +6489,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
|
|
|
4697
6489
|
|
|
4698
6490
|
function formatUnknownError(error: unknown): string {
|
|
4699
6491
|
if (error instanceof Error) {
|
|
4700
|
-
return error.message;
|
|
6492
|
+
return stripTerminalEscapes(error.message);
|
|
4701
6493
|
}
|
|
4702
6494
|
if (
|
|
4703
6495
|
typeof error === "object" &&
|
|
@@ -4705,9 +6497,33 @@ function formatUnknownError(error: unknown): string {
|
|
|
4705
6497
|
"message" in error &&
|
|
4706
6498
|
typeof error.message === "string"
|
|
4707
6499
|
) {
|
|
4708
|
-
return error.message;
|
|
6500
|
+
return stripTerminalEscapes(error.message);
|
|
4709
6501
|
}
|
|
4710
|
-
return String(error);
|
|
6502
|
+
return stripTerminalEscapes(String(error));
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
/**
|
|
6506
|
+
* Remove terminal colour and cursor sequences from text the adapter surfaces.
|
|
6507
|
+
*
|
|
6508
|
+
* An ordinary type error reaches the adapter as an `"exception"` envelope whose
|
|
6509
|
+
* `error` is the host's own rendered output, colour and all, and the envelope
|
|
6510
|
+
* carries no structured diagnostics to format instead. What the adapter hands
|
|
6511
|
+
* back is not going to a terminal: it becomes the `Error` a bundler reports, so
|
|
6512
|
+
* it lands in a Vite overlay, a webpack error report or a CI annotation, where
|
|
6513
|
+
* the escapes render as literal noise around the file and line the reader needs
|
|
6514
|
+
* (samchon/ttsc#1312).
|
|
6515
|
+
*
|
|
6516
|
+
* The colour originates in the host's rendering rather than in anything this
|
|
6517
|
+
* adapter configures, so this is the adapter-side repair, applied to every
|
|
6518
|
+
* message it surfaces rather than to one call site.
|
|
6519
|
+
*/
|
|
6520
|
+
function stripTerminalEscapes(text: string): string {
|
|
6521
|
+
// Built from a char code so no control byte lives in this source file, and
|
|
6522
|
+
// written with `[[]` (a class holding one literal bracket) so the pattern
|
|
6523
|
+
// needs no backslash escapes to survive the string it is assembled from.
|
|
6524
|
+
const escape = String.fromCharCode(27);
|
|
6525
|
+
const controlSequence = new RegExp(escape + "[[][0-9;?]*[ -/]*[@-~]", "g");
|
|
6526
|
+
return text.replace(controlSequence, "");
|
|
4711
6527
|
}
|
|
4712
6528
|
|
|
4713
6529
|
/**
|