@ttsc/unplugin 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/core/index.d.ts +1 -1
- package/lib/core/index.js +49 -8
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +49 -8
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.ts +144 -1
- package/lib/core/transform.js +1188 -159
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +1188 -159
- package/lib/core/transform.mjs.map +1 -1
- package/lib/core/viteServe.d.ts +9 -2
- package/lib/core/viteServe.js +2 -2
- package/lib/core/viteServe.js.map +1 -1
- package/lib/core/viteServe.mjs +2 -2
- package/lib/core/viteServe.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +54 -10
- package/src/core/transform.ts +1579 -198
- package/src/core/viteServe.ts +11 -4
package/src/core/transform.ts
CHANGED
|
@@ -56,6 +56,27 @@ interface TtscProjectDirectorySnapshot {
|
|
|
56
56
|
/** Generation-scoped directory watchers used to detect membership changes. */
|
|
57
57
|
interface TtscProjectMutationTracker {
|
|
58
58
|
close: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Absolute spellings whose creation, change or removal this tracker would
|
|
61
|
+
* report, when it watches exact names rather than whole directories.
|
|
62
|
+
*
|
|
63
|
+
* A validation that finds an input here needs no filesystem call of its own:
|
|
64
|
+
* the tracker is the evidence, and every path that leaves this set falls back
|
|
65
|
+
* to being proven by hand. Empty for a tracker that watches directories as a
|
|
66
|
+
* whole, which cannot answer for one name.
|
|
67
|
+
*/
|
|
68
|
+
covered?: ReadonlySet<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Wait until every event this tracker's watcher has already dispatched has
|
|
71
|
+
* been applied to it.
|
|
72
|
+
*
|
|
73
|
+
* An in-process watcher drains on the next macrotask turn, because its
|
|
74
|
+
* callbacks are already queued on this loop. A watcher living in the Windows
|
|
75
|
+
* broker drains by round-trip instead: the child replies after its own turn,
|
|
76
|
+
* and IPC preserves order, so the reply cannot overtake an event the child
|
|
77
|
+
* had already sent (samchon/ttsc#1272).
|
|
78
|
+
*/
|
|
79
|
+
drain?: () => Promise<void>;
|
|
59
80
|
failed: boolean;
|
|
60
81
|
membershipChanged: boolean;
|
|
61
82
|
settle?: Promise<void>;
|
|
@@ -98,15 +119,68 @@ export interface TtscCachedProjectTransform {
|
|
|
98
119
|
* compiler reported rather than a normalized replacement spelling.
|
|
99
120
|
*/
|
|
100
121
|
externalInputPaths?: string[];
|
|
122
|
+
/**
|
|
123
|
+
* Metadata signature of each out-of-walk input, captured around the read that
|
|
124
|
+
* proved its {@link externalInputHashes} entry and recorded only once the
|
|
125
|
+
* observed filesystem's clock provably left the stamp's tick
|
|
126
|
+
* ({@link stampSeparable}). An input whose signature still holds carries the
|
|
127
|
+
* recorded content, so revalidation may skip the read.
|
|
128
|
+
*
|
|
129
|
+
* Keyed by lexical spelling rather than by physical identity, for the reason
|
|
130
|
+
* {@link TtscHostInputValidation} states: a symlink or junction spelling and
|
|
131
|
+
* its selected target deliberately share one identity but have different
|
|
132
|
+
* metadata, so an identity key would let the two overwrite each other's
|
|
133
|
+
* signature and force both to be re-read on every delivery.
|
|
134
|
+
*/
|
|
135
|
+
externalInputSignatures?: Record<string, string>;
|
|
101
136
|
/**
|
|
102
137
|
* SHA-256 hash of each project-relative input path at the time of the
|
|
103
138
|
* transform.
|
|
104
139
|
*/
|
|
105
140
|
inputHashes: Record<string, string>;
|
|
141
|
+
/**
|
|
142
|
+
* Metadata signature of each {@link inputHashes} entry whose hash was proven
|
|
143
|
+
* against an unracing read of the file on disk, in a tick the observed
|
|
144
|
+
* filesystem's clock had provably left ({@link stampSeparable}).
|
|
145
|
+
*
|
|
146
|
+
* The generation's own current file is absent at capture: its recorded hash
|
|
147
|
+
* comes from the bundler's in-memory source, so the walk that produced it
|
|
148
|
+
* compared nothing. A later delivery of a sibling does compare that file's
|
|
149
|
+
* disk bytes against the recorded hash, and may record a signature then.
|
|
150
|
+
*/
|
|
151
|
+
inputSignatures?: Record<string, string>;
|
|
106
152
|
/** Metadata snapshot of every directory in the stable generation walk. */
|
|
107
153
|
projectDirectories?: TtscProjectDirectorySnapshot[];
|
|
108
154
|
/** Live notification state for universal host-input changes. */
|
|
109
155
|
hostInputMutationTracker?: TtscProjectMutationTracker;
|
|
156
|
+
/**
|
|
157
|
+
* Live notification state for the generation's absent resolution candidates
|
|
158
|
+
* and the directories that carry them.
|
|
159
|
+
*
|
|
160
|
+
* Separate from the universal-input tracker because it listens for a
|
|
161
|
+
* different thing. Every event that can make an absent candidate present is a
|
|
162
|
+
* rename — the file appearing, a component of the path being created,
|
|
163
|
+
* replaced, or retargeted — so a change event on one of these names is never
|
|
164
|
+
* evidence this tracker exists to collect. What it is, on a backend that
|
|
165
|
+
* reports a write below a directory as a change to that directory's own entry
|
|
166
|
+
* (Windows does), is a dev server's steady traffic: listening for every event
|
|
167
|
+
* would replace the generation each time a bundler wrote inside
|
|
168
|
+
* `node_modules`. The filter therefore drops noise without dropping proof.
|
|
169
|
+
* The one appearance it cannot see is a Windows junction retargeted in place
|
|
170
|
+
* through `FSCTL_SET_REPARSE_POINT`, which no mainstream tool does; every
|
|
171
|
+
* package manager replaces the entry instead, which is a rename.
|
|
172
|
+
*/
|
|
173
|
+
candidateMutationTracker?: TtscProjectMutationTracker;
|
|
174
|
+
/**
|
|
175
|
+
* Universal descriptor/config inputs proven once at generation time, then by
|
|
176
|
+
* metadata.
|
|
177
|
+
*
|
|
178
|
+
* Recorded state of the generation, like the input hashes and the directory
|
|
179
|
+
* snapshot beside it, rather than state derived from the envelope: an entry
|
|
180
|
+
* carries the manifest that proved it, so nothing can present one
|
|
181
|
+
* generation's recorded inputs under another envelope's proof.
|
|
182
|
+
*/
|
|
183
|
+
hostInputValidation?: TtscHostInputValidation;
|
|
110
184
|
/** Live notification state for file/directory creation, deletion, and rename. */
|
|
111
185
|
projectMutationTracker?: TtscProjectMutationTracker;
|
|
112
186
|
/**
|
|
@@ -167,6 +241,25 @@ export interface TtscTransformFilesystemOperations {
|
|
|
167
241
|
statBigInt(location: string): fs.BigIntStats;
|
|
168
242
|
/** Override path parsing when the observed filesystem is not the host. */
|
|
169
243
|
platform?: NodeJS.Platform;
|
|
244
|
+
/**
|
|
245
|
+
* Open one directory's change notification, or throw when the observed
|
|
246
|
+
* filesystem cannot provide one.
|
|
247
|
+
*
|
|
248
|
+
* Left undefined, generations watch the host filesystem: `fs.watch` on POSIX
|
|
249
|
+
* and an isolated broker process on Windows. An embedder observing another
|
|
250
|
+
* filesystem supplies its own; a generation whose watch cannot be opened
|
|
251
|
+
* keeps validating from recorded state instead of losing its cache.
|
|
252
|
+
*
|
|
253
|
+
* Supplying one replaces the Windows broker as well, so an embedder that
|
|
254
|
+
* wraps Node's own `fs.watch` there gives up the isolation that contains the
|
|
255
|
+
* native abort Node's Windows fs-event backend can raise when a watched
|
|
256
|
+
* temporary tree is deleted.
|
|
257
|
+
*/
|
|
258
|
+
watch?(
|
|
259
|
+
directory: string,
|
|
260
|
+
listener: (eventType: string, filename: string | null) => void,
|
|
261
|
+
onError: () => void,
|
|
262
|
+
): { close: () => void };
|
|
170
263
|
}
|
|
171
264
|
|
|
172
265
|
const DEFAULT_FILESYSTEM_OPERATIONS: TtscTransformFilesystemOperations =
|
|
@@ -234,6 +327,7 @@ export function createTtscTransformCache(
|
|
|
234
327
|
statBigInt:
|
|
235
328
|
operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
|
|
236
329
|
platform: operations.platform,
|
|
330
|
+
watch: operations.watch,
|
|
237
331
|
});
|
|
238
332
|
return cache;
|
|
239
333
|
}
|
|
@@ -289,6 +383,24 @@ function clearTtscTransformCache(cache: TtscTransformCache): void {
|
|
|
289
383
|
}
|
|
290
384
|
}
|
|
291
385
|
|
|
386
|
+
/**
|
|
387
|
+
* What the generation already knows about one derived watch input, handed to
|
|
388
|
+
* the adapter so it does not rederive it per input per delivery.
|
|
389
|
+
*
|
|
390
|
+
* Both facts are generation state: the identity is the memoized
|
|
391
|
+
* {@link pathIdentityKey} of the input, and `missing` is the existence the
|
|
392
|
+
* generation recorded and every cache hit revalidates. An adapter that computes
|
|
393
|
+
* them itself pays a `realpath`, a case-sensitivity directory listing, and an
|
|
394
|
+
* `existsSync` for every input of every delivered module, which is O(modules x
|
|
395
|
+
* inputs) for one build (samchon/ttsc#1246).
|
|
396
|
+
*/
|
|
397
|
+
export interface TtscWatchInputEvidence {
|
|
398
|
+
/** Memoized filesystem identity of the input. */
|
|
399
|
+
identity: string;
|
|
400
|
+
/** Whether the generation recorded this input as absent. */
|
|
401
|
+
missing: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
292
404
|
/**
|
|
293
405
|
* Hooks the bundler adapter passes into {@link transformTtsc} so transform
|
|
294
406
|
* side-channels (plugin-reported dependencies and host resolution candidates)
|
|
@@ -308,7 +420,7 @@ export interface TtscTransformHooks {
|
|
|
308
420
|
* persistent-cache invalidation. See {@link selectWatchInputs} for the exact
|
|
309
421
|
* derivation.
|
|
310
422
|
*/
|
|
311
|
-
addWatchFile?: (file: string) => void;
|
|
423
|
+
addWatchFile?: (file: string, evidence?: TtscWatchInputEvidence) => void;
|
|
312
424
|
/**
|
|
313
425
|
* Invoked when the plugin declared the transformed file volatile (the
|
|
314
426
|
* envelope's `volatile` list): its output depends on non-file inputs that no
|
|
@@ -412,12 +524,7 @@ export async function transformTtsc(
|
|
|
412
524
|
projectRoot: cached.projectRoot,
|
|
413
525
|
result: cached.result,
|
|
414
526
|
});
|
|
415
|
-
notifyWatchInputs(hooks,
|
|
416
|
-
file,
|
|
417
|
-
projectRoot: cached.projectRoot,
|
|
418
|
-
result: cached.result,
|
|
419
|
-
temporaryTsconfig: cached.temporaryTsconfig,
|
|
420
|
-
});
|
|
527
|
+
notifyWatchInputs(hooks, cached, file);
|
|
421
528
|
markCachedSourceServed(cached, file);
|
|
422
529
|
return createTransformResult(source, code);
|
|
423
530
|
}
|
|
@@ -449,14 +556,14 @@ export async function transformTtsc(
|
|
|
449
556
|
if (cache !== undefined && cache.get(key) !== generation) {
|
|
450
557
|
continue;
|
|
451
558
|
}
|
|
452
|
-
const { projectRoot, result
|
|
559
|
+
const { projectRoot, result } = cached;
|
|
453
560
|
reportSuccessDiagnostics(result);
|
|
454
561
|
const code = selectOrEvict(cache, key, generation, {
|
|
455
562
|
file,
|
|
456
563
|
projectRoot,
|
|
457
564
|
result,
|
|
458
565
|
});
|
|
459
|
-
notifyWatchInputs(hooks,
|
|
566
|
+
notifyWatchInputs(hooks, cached, file);
|
|
460
567
|
markCachedSourceServed(cached, file);
|
|
461
568
|
if (
|
|
462
569
|
isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })
|
|
@@ -535,9 +642,11 @@ function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
|
|
|
535
642
|
const trackers = [
|
|
536
643
|
cached.projectMutationTracker,
|
|
537
644
|
cached.hostInputMutationTracker,
|
|
645
|
+
cached.candidateMutationTracker,
|
|
538
646
|
];
|
|
539
647
|
cached.projectMutationTracker = undefined;
|
|
540
648
|
cached.hostInputMutationTracker = undefined;
|
|
649
|
+
cached.candidateMutationTracker = undefined;
|
|
541
650
|
for (const tracker of trackers) tracker?.close();
|
|
542
651
|
}
|
|
543
652
|
|
|
@@ -586,8 +695,6 @@ interface TtscEnvelopeDerivation {
|
|
|
586
695
|
* files, `undefined` until the first completeness predicate.
|
|
587
696
|
*/
|
|
588
697
|
dependenciesComplete?: Set<string>;
|
|
589
|
-
/** Universal inputs validated once at generation time and then by metadata. */
|
|
590
|
-
hostInputValidation?: TtscHostInputValidation;
|
|
591
698
|
/**
|
|
592
699
|
* Lazily built identity -> output source index of the `typescript` map (first
|
|
593
700
|
* match wins, mirroring the historical scan). `undefined` until the first
|
|
@@ -601,6 +708,14 @@ interface TtscEnvelopeDerivation {
|
|
|
601
708
|
dependencyIndex?: Map<string, unknown>;
|
|
602
709
|
/** Per-file memo of the final derived watch-input list. */
|
|
603
710
|
readonly watchInputs: Map<string, string[]>;
|
|
711
|
+
/**
|
|
712
|
+
* Lazily built project-walk keys of the envelope's declared inputs, and
|
|
713
|
+
* whether that build already ran. A graph-free envelope declares no input
|
|
714
|
+
* set, so `undefined` after a completed build means "compare the whole walk";
|
|
715
|
+
* see {@link sameHashes}.
|
|
716
|
+
*/
|
|
717
|
+
declaredInputKeys?: Set<string>;
|
|
718
|
+
declaredInputKeysBuilt?: boolean;
|
|
604
719
|
}
|
|
605
720
|
|
|
606
721
|
interface TtscHostInputValidation {
|
|
@@ -609,13 +724,32 @@ interface TtscHostInputValidation {
|
|
|
609
724
|
string,
|
|
610
725
|
{
|
|
611
726
|
path: string;
|
|
727
|
+
/**
|
|
728
|
+
* Whether the recorded state of this input came from reading its bytes.
|
|
729
|
+
* An input that existed but could not be read records a missing state, so
|
|
730
|
+
* no signature may stand in for it: its metadata holds still while the
|
|
731
|
+
* bytes behind it appear.
|
|
732
|
+
*/
|
|
733
|
+
readable: boolean;
|
|
612
734
|
realpath: string | null;
|
|
613
|
-
|
|
735
|
+
/**
|
|
736
|
+
* The signature that may stand in for this entry's content comparison, or
|
|
737
|
+
* `undefined` when none may. A blocker keeps one regardless: it proves a
|
|
738
|
+
* kind and an identity rather than content.
|
|
739
|
+
*/
|
|
740
|
+
signature: string | undefined;
|
|
614
741
|
strict?: true;
|
|
615
742
|
}
|
|
616
743
|
>;
|
|
617
|
-
/**
|
|
618
|
-
|
|
744
|
+
/**
|
|
745
|
+
* Lexical spellings the manifest accounts for, omitted from the per-module
|
|
746
|
+
* dependency loop below.
|
|
747
|
+
*
|
|
748
|
+
* Spellings, not identities: a symlink and its target share one identity but
|
|
749
|
+
* are two inputs, and skipping the alias because the manifest proved the
|
|
750
|
+
* target would leave the alias's own retarget unvalidated.
|
|
751
|
+
*/
|
|
752
|
+
readonly covered: Set<string>;
|
|
619
753
|
/**
|
|
620
754
|
* Missing paths grouped by the nearest directory whose listing proves them
|
|
621
755
|
* absent.
|
|
@@ -636,6 +770,22 @@ interface TtscEnvelopeGraphIndexes {
|
|
|
636
770
|
readonly configs: string[];
|
|
637
771
|
/** Every realized/candidate graph path, keyed by filesystem identity. */
|
|
638
772
|
readonly members: Set<string>;
|
|
773
|
+
/**
|
|
774
|
+
* Members the envelope reported only under `graph.candidates`, keyed by
|
|
775
|
+
* filesystem identity.
|
|
776
|
+
*
|
|
777
|
+
* A superseding candidate is by construction a path the compiler did not
|
|
778
|
+
* select, and usually one it never read at all: resolution stopped at the
|
|
779
|
+
* target that won, and the host enumerates the higher-priority spellings so
|
|
780
|
+
* that one appearing later can invalidate the generation. Such a path has no
|
|
781
|
+
* compile-time read to prove, so it carries the evidence a plugin-declared
|
|
782
|
+
* dependency path carries (the state recorded when the envelope was produced)
|
|
783
|
+
* instead of a compiler proof it can never have.
|
|
784
|
+
*
|
|
785
|
+
* A candidate that is also a realized input (an edge endpoint, a global, or a
|
|
786
|
+
* config) is absent from this set and keeps the realized standard.
|
|
787
|
+
*/
|
|
788
|
+
readonly speculative: Set<string>;
|
|
639
789
|
/** Compiler-time proof for graph members, keyed by filesystem identity. */
|
|
640
790
|
readonly inputProofs: Map<
|
|
641
791
|
string,
|
|
@@ -697,6 +847,7 @@ function envelopeGraphIndexes(
|
|
|
697
847
|
globals: [],
|
|
698
848
|
configs: [],
|
|
699
849
|
members: new Set(),
|
|
850
|
+
speculative: new Set(),
|
|
700
851
|
inputProofs: new Map(),
|
|
701
852
|
inputProofConflicts: new Set(),
|
|
702
853
|
};
|
|
@@ -731,24 +882,38 @@ function envelopeGraphIndexes(
|
|
|
731
882
|
for (const input of [...built.globals, ...built.configs]) {
|
|
732
883
|
built.members.add(derivationIdentity(state, input));
|
|
733
884
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
885
|
+
const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
|
|
886
|
+
(entry) => Array.isArray(entry[1]),
|
|
887
|
+
);
|
|
888
|
+
// Every candidate source is an importing file the compiler read, so fold
|
|
889
|
+
// the sources in before classifying any candidate. Otherwise one entry's
|
|
890
|
+
// candidate could be classified speculative before a later entry proves
|
|
891
|
+
// the same path is a realized source.
|
|
892
|
+
for (const [source] of candidateEntries) {
|
|
893
|
+
built.members.add(
|
|
894
|
+
derivationIdentity(state, path.resolve(props.projectRoot, source)),
|
|
741
895
|
);
|
|
742
|
-
|
|
896
|
+
}
|
|
897
|
+
const realized = new Set(built.members);
|
|
898
|
+
for (const [source, candidates] of candidateEntries) {
|
|
743
899
|
built.candidates.push({
|
|
744
|
-
source:
|
|
900
|
+
source: derivationIdentity(
|
|
901
|
+
state,
|
|
902
|
+
path.resolve(props.projectRoot, source),
|
|
903
|
+
),
|
|
745
904
|
files: selectListedFiles(props.projectRoot, candidates),
|
|
746
905
|
});
|
|
747
906
|
for (const candidate of candidates) {
|
|
748
907
|
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
749
|
-
|
|
750
|
-
|
|
908
|
+
const identity = derivationIdentity(
|
|
909
|
+
state,
|
|
910
|
+
path.resolve(props.projectRoot, candidate),
|
|
751
911
|
);
|
|
912
|
+
// Edges, globals, configs, and every candidate source are folded in
|
|
913
|
+
// above, so a path absent from that set is one the envelope reported
|
|
914
|
+
// only as a candidate.
|
|
915
|
+
if (!realized.has(identity)) built.speculative.add(identity);
|
|
916
|
+
built.members.add(identity);
|
|
752
917
|
}
|
|
753
918
|
}
|
|
754
919
|
for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
|
|
@@ -858,19 +1023,31 @@ function collectDeclaredIdentities(
|
|
|
858
1023
|
*/
|
|
859
1024
|
function notifyWatchInputs(
|
|
860
1025
|
hooks: TtscTransformHooks | undefined,
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
projectRoot: string;
|
|
864
|
-
result: ITtscCompilerTransformation;
|
|
865
|
-
temporaryTsconfig?: string;
|
|
866
|
-
},
|
|
1026
|
+
cached: TtscCachedProjectTransform,
|
|
1027
|
+
file: string,
|
|
867
1028
|
): void {
|
|
868
1029
|
const addWatchFile = hooks?.addWatchFile;
|
|
869
1030
|
if (addWatchFile === undefined) {
|
|
870
1031
|
return;
|
|
871
1032
|
}
|
|
872
|
-
|
|
873
|
-
|
|
1033
|
+
const state = envelopeDerivation(cached);
|
|
1034
|
+
const external = cached.externalInputHashes ?? {};
|
|
1035
|
+
for (const input of selectWatchInputs({
|
|
1036
|
+
file,
|
|
1037
|
+
projectRoot: cached.projectRoot,
|
|
1038
|
+
result: cached.result,
|
|
1039
|
+
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1040
|
+
})) {
|
|
1041
|
+
// Hand the adapter the identity this generation already resolved and the
|
|
1042
|
+
// existence state it already recorded. Both are memoized per generation,
|
|
1043
|
+
// while an adapter deriving them itself pays a `realpath`, a directory
|
|
1044
|
+
// listing, and an `existsSync` per input on every delivery of every module
|
|
1045
|
+
// (samchon/ttsc#1246).
|
|
1046
|
+
const identity = derivationIdentity(state, input);
|
|
1047
|
+
addWatchFile(input, {
|
|
1048
|
+
identity,
|
|
1049
|
+
missing: external[identity] === MISSING_INPUT_STATE,
|
|
1050
|
+
});
|
|
874
1051
|
}
|
|
875
1052
|
}
|
|
876
1053
|
|
|
@@ -1360,7 +1537,13 @@ function matchesCachedSource(
|
|
|
1360
1537
|
cached.projectMutationTracker !== undefined &&
|
|
1361
1538
|
cached.hostInputMutationTracker !== undefined
|
|
1362
1539
|
) {
|
|
1363
|
-
|
|
1540
|
+
const narrow = matchesNarrowPersistentInputs(cached, file);
|
|
1541
|
+
if (narrow !== undefined) {
|
|
1542
|
+
return narrow;
|
|
1543
|
+
}
|
|
1544
|
+
// Notifications stopped proving membership after this generation was
|
|
1545
|
+
// produced. Losing the proof is not evidence of a change, so fall through
|
|
1546
|
+
// to the snapshot the entry still carries.
|
|
1364
1547
|
}
|
|
1365
1548
|
return matchesCompleteInputSnapshot(cached, currentKey, source);
|
|
1366
1549
|
}
|
|
@@ -1370,28 +1553,30 @@ function matchesCachedSource(
|
|
|
1370
1553
|
* affect that file. Project membership is validated once per event-loop turn,
|
|
1371
1554
|
* so sibling module deliveries share one directory-metadata pass instead of
|
|
1372
1555
|
* multiplying it by module count.
|
|
1556
|
+
*
|
|
1557
|
+
* Returns `undefined` when this narrow proof is unavailable — live
|
|
1558
|
+
* notifications can no longer prove membership, or the generation carries no
|
|
1559
|
+
* universal-input manifest. That is the absence of a proof, not evidence of a
|
|
1560
|
+
* change, so the caller falls back to complete-snapshot validation instead of
|
|
1561
|
+
* discarding the generation. A reported membership event, a changed universal
|
|
1562
|
+
* input, or a changed derived input is evidence, and returns `false`.
|
|
1373
1563
|
*/
|
|
1374
1564
|
function matchesNarrowPersistentInputs(
|
|
1375
1565
|
cached: TtscCachedProjectTransform,
|
|
1376
1566
|
file: string,
|
|
1377
|
-
): boolean {
|
|
1378
|
-
if (
|
|
1567
|
+
): boolean | undefined {
|
|
1568
|
+
if (reportsMembershipChange(cached)) {
|
|
1379
1569
|
return false;
|
|
1380
1570
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
hostTracker === undefined ||
|
|
1384
|
-
hostTracker.failed ||
|
|
1385
|
-
hostTracker.membershipChanged
|
|
1386
|
-
) {
|
|
1387
|
-
return false;
|
|
1571
|
+
if (!notificationsProveMembership(cached)) {
|
|
1572
|
+
return undefined;
|
|
1388
1573
|
}
|
|
1389
1574
|
const state = envelopeDerivation(cached);
|
|
1390
|
-
const hostValidation =
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
) {
|
|
1575
|
+
const hostValidation = cached.hostInputValidation;
|
|
1576
|
+
if (hostValidation === undefined) {
|
|
1577
|
+
return undefined;
|
|
1578
|
+
}
|
|
1579
|
+
if (!matchesUniversalHostInputs(cached, hostValidation)) {
|
|
1395
1580
|
return false;
|
|
1396
1581
|
}
|
|
1397
1582
|
const inputs = selectWatchInputs({
|
|
@@ -1401,16 +1586,146 @@ function matchesNarrowPersistentInputs(
|
|
|
1401
1586
|
temporaryTsconfig: cached.temporaryTsconfig,
|
|
1402
1587
|
});
|
|
1403
1588
|
for (const input of inputs) {
|
|
1404
|
-
|
|
1589
|
+
// Skip by spelling, not identity: the manifest proved this exact path, and
|
|
1590
|
+
// an alias of the same physical file is a different input whose own
|
|
1591
|
+
// retarget nothing else would see.
|
|
1592
|
+
if (hostValidation.covered.has(path.resolve(input))) {
|
|
1405
1593
|
continue;
|
|
1406
1594
|
}
|
|
1407
|
-
if (!
|
|
1595
|
+
if (!matchesProvenInput(cached, state, input)) {
|
|
1408
1596
|
return false;
|
|
1409
1597
|
}
|
|
1410
1598
|
}
|
|
1411
1599
|
return true;
|
|
1412
1600
|
}
|
|
1413
1601
|
|
|
1602
|
+
/**
|
|
1603
|
+
* Validate one derived input against the generation, skipping the content read
|
|
1604
|
+
* while the recorded metadata signature still holds.
|
|
1605
|
+
*
|
|
1606
|
+
* Sibling deliveries of one generation share most of their derived inputs, and
|
|
1607
|
+
* `graph.globals` is shared by every one of them, so re-reading and re-hashing
|
|
1608
|
+
* the whole derived set per delivery multiplies one generation's proven bytes
|
|
1609
|
+
* by the module count. The derived set is proven the same way the universal
|
|
1610
|
+
* descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
|
|
1611
|
+
* rules: an unchanged signature stands in for the content comparison, and any
|
|
1612
|
+
* signature change falls back to the full comparison. A signature is recorded
|
|
1613
|
+
* only around a read nothing raced, only for a recorded state that came from
|
|
1614
|
+
* reading the input rather than from failing to, and only while the observed
|
|
1615
|
+
* filesystem's own clock has provably left the stamp's tick
|
|
1616
|
+
* ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
|
|
1617
|
+
* hide behind an unchanged signature.
|
|
1618
|
+
*
|
|
1619
|
+
* The signature carries the physical identity of both the lexical path and its
|
|
1620
|
+
* link target ({@link inputMetadataSignature}), so retargeting a symlink or
|
|
1621
|
+
* junction moves it and the skipped realpath comparison cannot be evaded.
|
|
1622
|
+
*/
|
|
1623
|
+
function matchesProvenInput(
|
|
1624
|
+
cached: TtscCachedProjectTransform,
|
|
1625
|
+
state: TtscEnvelopeDerivation,
|
|
1626
|
+
input: string,
|
|
1627
|
+
): boolean {
|
|
1628
|
+
const slot = inputSignatureSlot(cached, state, input);
|
|
1629
|
+
if (slot === undefined) {
|
|
1630
|
+
return matchesRecordedInput(cached, input);
|
|
1631
|
+
}
|
|
1632
|
+
if (slot.recorded === MISSING_INPUT_STATE && notifiesAbsence(cached, input)) {
|
|
1633
|
+
// The generation's watcher holds this exact name, and the caller already
|
|
1634
|
+
// established that neither tracker failed and neither reported a change.
|
|
1635
|
+
// The path is therefore still absent, proven by the same channel that
|
|
1636
|
+
// proves project membership, and probing it again would only repeat what
|
|
1637
|
+
// the notification already answered.
|
|
1638
|
+
return true;
|
|
1639
|
+
}
|
|
1640
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1641
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1642
|
+
if (before !== undefined && slot.signatures[slot.key] === before.signature) {
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
if (!matchesRecordedInput(cached, input)) {
|
|
1646
|
+
return false;
|
|
1647
|
+
}
|
|
1648
|
+
// A recorded `missing` state is the one comparison that succeeds without
|
|
1649
|
+
// reading anything: an unreadable path still reports `missing`, so its
|
|
1650
|
+
// metadata can hold still while the bytes behind it appear. Only content a
|
|
1651
|
+
// read produced may be stood for.
|
|
1652
|
+
const after =
|
|
1653
|
+
slot.recorded === MISSING_INPUT_STATE
|
|
1654
|
+
? undefined
|
|
1655
|
+
: inputMetadataSignature(input, filesystem);
|
|
1656
|
+
if (after !== undefined && before?.signature === after && before.separable) {
|
|
1657
|
+
slot.signatures[slot.key] = after;
|
|
1658
|
+
} else {
|
|
1659
|
+
delete slot.signatures[slot.key];
|
|
1660
|
+
}
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Report whether the generation's live watcher would announce a creation at
|
|
1666
|
+
* this absent input's exact spelling.
|
|
1667
|
+
*
|
|
1668
|
+
* Losing the watcher is not evidence of anything, so a failed tracker sends the
|
|
1669
|
+
* input back to being probed by hand, exactly as a failed tracker already sends
|
|
1670
|
+
* the whole generation back to complete-snapshot validation.
|
|
1671
|
+
*/
|
|
1672
|
+
function notifiesAbsence(
|
|
1673
|
+
cached: TtscCachedProjectTransform,
|
|
1674
|
+
input: string,
|
|
1675
|
+
): boolean {
|
|
1676
|
+
const tracker = cached.candidateMutationTracker;
|
|
1677
|
+
return (
|
|
1678
|
+
tracker !== undefined &&
|
|
1679
|
+
!tracker.failed &&
|
|
1680
|
+
tracker.covered?.has(path.resolve(input)) === true
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Locate the signature manifest that owns one recorded input, mirroring
|
|
1686
|
+
* {@link matchesRecordedInput}'s own preference for the out-of-walk spelling's
|
|
1687
|
+
* snapshot over the walked project's.
|
|
1688
|
+
*
|
|
1689
|
+
* The manifest is returned whether or not it currently holds a signature for
|
|
1690
|
+
* the input, so a content comparison that succeeds can record one. Without
|
|
1691
|
+
* that, an input whose capture-time metadata was too recent to prove anything
|
|
1692
|
+
* would keep its content read for the whole life of the generation, since
|
|
1693
|
+
* nothing else ever revisits it. Returns `undefined` only for an input the
|
|
1694
|
+
* generation recorded no hash for, which no signature could stand for.
|
|
1695
|
+
*/
|
|
1696
|
+
function inputSignatureSlot(
|
|
1697
|
+
cached: TtscCachedProjectTransform,
|
|
1698
|
+
state: TtscEnvelopeDerivation,
|
|
1699
|
+
input: string,
|
|
1700
|
+
):
|
|
1701
|
+
| { key: string; recorded: string; signatures: Record<string, string> }
|
|
1702
|
+
| undefined {
|
|
1703
|
+
const identity = derivationIdentity(state, input);
|
|
1704
|
+
const external = cached.externalInputHashes ?? {};
|
|
1705
|
+
if (Object.prototype.hasOwnProperty.call(external, identity)) {
|
|
1706
|
+
// The recorded hash is identity-keyed because aliases of one physical file
|
|
1707
|
+
// share its content; the signature is spelling-keyed because they do not
|
|
1708
|
+
// share its metadata.
|
|
1709
|
+
return {
|
|
1710
|
+
key: path.resolve(input),
|
|
1711
|
+
recorded: external[identity]!,
|
|
1712
|
+
signatures: (cached.externalInputSignatures ??= {}),
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
const projectKey = toProjectKey(
|
|
1716
|
+
cached.projectRoot,
|
|
1717
|
+
input,
|
|
1718
|
+
state.identityContext,
|
|
1719
|
+
);
|
|
1720
|
+
return Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
|
|
1721
|
+
? {
|
|
1722
|
+
key: projectKey,
|
|
1723
|
+
recorded: cached.inputHashes[projectKey]!,
|
|
1724
|
+
signatures: (cached.inputSignatures ??= {}),
|
|
1725
|
+
}
|
|
1726
|
+
: undefined;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1414
1729
|
/**
|
|
1415
1730
|
* Validate universal descriptor/config inputs without re-reading them for every
|
|
1416
1731
|
* module. Existing paths use the same nanosecond metadata manifest that guards
|
|
@@ -1420,20 +1735,70 @@ function matchesNarrowPersistentInputs(
|
|
|
1420
1735
|
function matchesUniversalHostInputs(
|
|
1421
1736
|
cached: TtscCachedProjectTransform,
|
|
1422
1737
|
validation: TtscHostInputValidation,
|
|
1738
|
+
): boolean {
|
|
1739
|
+
return (
|
|
1740
|
+
matchesUniversalHostInputEntries(cached, validation) &&
|
|
1741
|
+
matchesUniversalHostInputProbes(cached, validation)
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Validate the universal inputs that exist, by metadata first and content only
|
|
1747
|
+
* when that moved.
|
|
1748
|
+
*
|
|
1749
|
+
* Every rejection here is evidence of a change — a vanished path, a moved
|
|
1750
|
+
* physical target, a strict blocker's metadata, differing content — so this
|
|
1751
|
+
* half is safe for a validation path that must never discard a generation for
|
|
1752
|
+
* want of a proof.
|
|
1753
|
+
*/
|
|
1754
|
+
function matchesUniversalHostInputEntries(
|
|
1755
|
+
cached: TtscCachedProjectTransform,
|
|
1756
|
+
validation: TtscHostInputValidation,
|
|
1423
1757
|
): boolean {
|
|
1424
1758
|
const filesystem = resultFilesystem(cached.result);
|
|
1425
1759
|
for (const entry of validation.entries.values()) {
|
|
1426
|
-
const
|
|
1427
|
-
if (
|
|
1760
|
+
const evidence = inputMetadataEvidence(entry.path, filesystem);
|
|
1761
|
+
if (
|
|
1762
|
+
entry.signature !== undefined &&
|
|
1763
|
+
evidence?.signature === entry.signature
|
|
1764
|
+
)
|
|
1765
|
+
continue;
|
|
1428
1766
|
if (entry.strict === true) return false;
|
|
1429
1767
|
if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
|
|
1430
1768
|
return false;
|
|
1431
1769
|
if (!matchesRecordedInput(cached, entry.path)) {
|
|
1432
1770
|
return false;
|
|
1433
1771
|
}
|
|
1434
|
-
if (
|
|
1435
|
-
entry
|
|
1772
|
+
if (evidence === undefined) return false;
|
|
1773
|
+
// Re-earn the proof under the rules the capture applies: an entry whose
|
|
1774
|
+
// recorded state came from reading nothing keeps its content comparison, a
|
|
1775
|
+
// write racing the read that just proved it records nothing, and a stamp
|
|
1776
|
+
// the filesystem's clock has not provably left records nothing either.
|
|
1777
|
+
const after = inputMetadataSignature(entry.path, filesystem);
|
|
1778
|
+
entry.signature =
|
|
1779
|
+
entry.readable && evidence.separable && after === evidence.signature
|
|
1780
|
+
? evidence.signature
|
|
1781
|
+
: undefined;
|
|
1436
1782
|
}
|
|
1783
|
+
return true;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Prove the universal inputs that were absent are still absent, through one
|
|
1788
|
+
* exact listing of the nearest directory that can settle it.
|
|
1789
|
+
*
|
|
1790
|
+
* Unlike the entries half, this one rejects on an inability to prove: a
|
|
1791
|
+
* directory that exists but cannot be listed certifies nothing about the
|
|
1792
|
+
* candidates inside it. That is the right answer for the narrow path, which has
|
|
1793
|
+
* no stronger proof to fall back to, but not for the whole-snapshot path, where
|
|
1794
|
+
* the recorded `missing` markers are re-compared directly and losing a proof
|
|
1795
|
+
* must not cost the cache.
|
|
1796
|
+
*/
|
|
1797
|
+
function matchesUniversalHostInputProbes(
|
|
1798
|
+
cached: TtscCachedProjectTransform,
|
|
1799
|
+
validation: TtscHostInputValidation,
|
|
1800
|
+
): boolean {
|
|
1801
|
+
const filesystem = resultFilesystem(cached.result);
|
|
1437
1802
|
for (const [directory, names] of validation.missing) {
|
|
1438
1803
|
let entries: fs.Dirent[];
|
|
1439
1804
|
try {
|
|
@@ -1479,7 +1844,7 @@ function captureUniversalHostInputValidation(
|
|
|
1479
1844
|
const state = envelopeDerivation(cached);
|
|
1480
1845
|
const validation: TtscHostInputValidation = {
|
|
1481
1846
|
entries: new Map(),
|
|
1482
|
-
|
|
1847
|
+
covered: new Set(),
|
|
1483
1848
|
missing: new Map(),
|
|
1484
1849
|
};
|
|
1485
1850
|
for (const input of selectPersistentHostInputs({
|
|
@@ -1500,14 +1865,24 @@ function captureUniversalHostInputValidation(
|
|
|
1500
1865
|
// Every persistent universal input must carry an evaluation-time
|
|
1501
1866
|
// fingerprint. If a plugin/native host cannot provide one, keep the fresh
|
|
1502
1867
|
// result but decline narrow long-lived reuse.
|
|
1868
|
+
let readable = false;
|
|
1503
1869
|
if (expected === undefined) {
|
|
1504
1870
|
const current = path.resolve(currentFile);
|
|
1505
1871
|
if (path.resolve(input) !== current) return undefined;
|
|
1506
1872
|
// The current module may be supplied from an unsaved editor buffer. Its
|
|
1507
1873
|
// generation snapshot is overlaid below from `currentSource`, so a disk
|
|
1508
|
-
// fingerprint would be both unavailable and the wrong authority.
|
|
1509
|
-
|
|
1510
|
-
|
|
1874
|
+
// fingerprint would be both unavailable and the wrong authority. The
|
|
1875
|
+
// recorded state is the bundler's, so a signature of the disk cannot
|
|
1876
|
+
// stand for it however readable that disk is.
|
|
1877
|
+
} else {
|
|
1878
|
+
const current = hostInputStateHash(input, filesystem);
|
|
1879
|
+
if (expected !== current) {
|
|
1880
|
+
return undefined;
|
|
1881
|
+
}
|
|
1882
|
+
// A path both sides agree they could not read carries no bytes for a
|
|
1883
|
+
// signature to stand for. It still belongs in the manifest, so the
|
|
1884
|
+
// content comparison keeps running for it on every delivery.
|
|
1885
|
+
readable = current !== null;
|
|
1511
1886
|
}
|
|
1512
1887
|
const absoluteInput = path.resolve(input);
|
|
1513
1888
|
if (generationRealpaths !== undefined) {
|
|
@@ -1525,37 +1900,49 @@ function captureUniversalHostInputValidation(
|
|
|
1525
1900
|
return undefined;
|
|
1526
1901
|
}
|
|
1527
1902
|
}
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
const before = inputMetadataSignature(input, filesystem);
|
|
1903
|
+
validation.covered.add(path.resolve(input));
|
|
1904
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1531
1905
|
if (!matchesRecordedInput(cached, input)) return undefined;
|
|
1532
1906
|
const after = inputMetadataSignature(input, filesystem);
|
|
1533
|
-
if (before !== after) return undefined;
|
|
1534
|
-
if (
|
|
1907
|
+
if (before?.signature !== after) return undefined;
|
|
1908
|
+
if (before !== undefined) {
|
|
1535
1909
|
// Do not key this manifest by physical identity. A symlink/junction
|
|
1536
1910
|
// spelling and its selected target deliberately share that identity,
|
|
1537
1911
|
// but both lexical paths must survive so retargeting the alias is visible.
|
|
1538
1912
|
validation.entries.set(path.resolve(input), {
|
|
1539
1913
|
path: input,
|
|
1914
|
+
readable,
|
|
1540
1915
|
realpath: hostInputRealpath(input, filesystem),
|
|
1541
|
-
signature
|
|
1916
|
+
// The signature stands in for content only when the read produced the
|
|
1917
|
+
// recorded bytes and the filesystem's clock has provably left the
|
|
1918
|
+
// stamp's tick; otherwise the content comparison keeps running until
|
|
1919
|
+
// the re-earn path can prove both.
|
|
1920
|
+
signature: readable && before.separable ? before.signature : undefined,
|
|
1542
1921
|
});
|
|
1543
1922
|
continue;
|
|
1544
1923
|
}
|
|
1545
1924
|
const probe = missingPathProbe(input, filesystem);
|
|
1546
1925
|
if (probe.blocker !== undefined) {
|
|
1547
|
-
const blockerIdentity = derivationIdentity(state, probe.blocker);
|
|
1548
1926
|
const signature = inputMetadataSignature(probe.blocker, filesystem);
|
|
1549
1927
|
if (signature === undefined) return undefined;
|
|
1550
|
-
|
|
1928
|
+
// A blocker proves a kind and an identity, not content: it is the
|
|
1929
|
+
// non-directory ancestor that makes everything below it unreachable, and
|
|
1930
|
+
// it cannot stop being that without its metadata moving. So it keeps a
|
|
1931
|
+
// usable signature whether or not anything read it, and exempt from the
|
|
1932
|
+
// clock-separability rule content signatures need — a same-tick rewrite
|
|
1933
|
+
// of its bytes leaves it exactly as blocking as before.
|
|
1934
|
+
validation.covered.add(path.resolve(probe.blocker));
|
|
1551
1935
|
validation.entries.set(path.resolve(probe.blocker), {
|
|
1552
1936
|
path: probe.blocker,
|
|
1937
|
+
readable: true,
|
|
1553
1938
|
realpath: hostInputRealpath(probe.blocker, filesystem),
|
|
1554
1939
|
signature,
|
|
1555
1940
|
strict: true,
|
|
1556
1941
|
});
|
|
1557
1942
|
continue;
|
|
1558
1943
|
}
|
|
1944
|
+
// The probe below proves this exact spelling absent, so the per-module loop
|
|
1945
|
+
// need not re-derive it either.
|
|
1559
1946
|
let names = validation.missing.get(probe.directory);
|
|
1560
1947
|
if (names === undefined) {
|
|
1561
1948
|
names = new Set<string>();
|
|
@@ -1568,52 +1955,224 @@ function captureUniversalHostInputValidation(
|
|
|
1568
1955
|
),
|
|
1569
1956
|
);
|
|
1570
1957
|
}
|
|
1571
|
-
|
|
1958
|
+
cached.hostInputValidation = validation;
|
|
1572
1959
|
return validation;
|
|
1573
1960
|
}
|
|
1574
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* The recorded state of an input the generation read nothing from: absent, or
|
|
1964
|
+
* present but unreadable. It is deliberately not a hash, so no signature may
|
|
1965
|
+
* stand in for it: the metadata of an unreadable path holds still while the
|
|
1966
|
+
* bytes behind it appear.
|
|
1967
|
+
*
|
|
1968
|
+
* A directory is not this state. It records the hash of a marker instead, which
|
|
1969
|
+
* a signature may stand for, because the mode both halves of the signature
|
|
1970
|
+
* carry cannot change without the path ceasing to be that directory.
|
|
1971
|
+
*/
|
|
1972
|
+
const MISSING_INPUT_STATE = "missing";
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* The highest stamp each observed filesystem clock has provably minted, keyed
|
|
1976
|
+
* by the operations object that observes it and, inside, by reporting device.
|
|
1977
|
+
*
|
|
1978
|
+
* A filesystem stamps a write once per clock tick, so two same-length writes
|
|
1979
|
+
* inside one tick are indistinguishable by metadata alone. A signature may
|
|
1980
|
+
* therefore stand for content only while a later write is guaranteed to move
|
|
1981
|
+
* it, and that guarantee needs a reference instant the observed filesystem
|
|
1982
|
+
* itself produced: once some stamp on the same device is strictly newer than an
|
|
1983
|
+
* input's modification stamp, that input's tick is provably over, so any later
|
|
1984
|
+
* write must mint a newer stamp and move the signature. That is git's
|
|
1985
|
+
* racily-clean index rule, adapted to a read-only contract: where git compares
|
|
1986
|
+
* entries against the index file's own timestamp, this floor accumulates every
|
|
1987
|
+
* stamp the cache-owned operations report, seeded per generation by
|
|
1988
|
+
* {@link mintFilesystemClockReference}.
|
|
1989
|
+
*
|
|
1990
|
+
* The process clock never participates: both sides of every comparison are
|
|
1991
|
+
* stamps the same filesystem clock minted, at the same granularity, so a
|
|
1992
|
+
* filesystem clock running behind (or ahead of) the host process changes
|
|
1993
|
+
* nothing.
|
|
1994
|
+
*
|
|
1995
|
+
* Accumulating observed stamps is deliberately weaker than git's own reference,
|
|
1996
|
+
* which is a single stamp git minted itself. A stamp this floor accepts may
|
|
1997
|
+
* instead have been _set_ rather than minted, and a set stamp is dangerous only
|
|
1998
|
+
* when it lands in the future: the floor is a maximum, so a restored past stamp
|
|
1999
|
+
* never raises it. One future-dated file — a stamp-preserving extraction or
|
|
2000
|
+
* copy from a machine whose clock ran ahead — pushes its device's floor past
|
|
2001
|
+
* the present and reopens the same-tick window for every other input on that
|
|
2002
|
+
* device until the clock catches up. A clock that jumps backwards strands the
|
|
2003
|
+
* floor above the present the same way, a different hazard from the constant
|
|
2004
|
+
* offset the paragraph above is about: an offset moves both operands together
|
|
2005
|
+
* and changes nothing, a jump moves only the present.
|
|
2006
|
+
*
|
|
2007
|
+
* The minted probe is not enough on its own to replace observed stamps: it
|
|
2008
|
+
* lands on the scratch volume, which is frequently not the inputs' volume (a
|
|
2009
|
+
* project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
|
|
2010
|
+
* decline every _content_ signature, so every input carrying bytes would be
|
|
2011
|
+
* re-read on every delivery. A strict blocker keeps its signature either way,
|
|
2012
|
+
* because it proves a kind rather than content. Observed stamps keep the common
|
|
2013
|
+
* case working; the probe covers the case they cannot, a tree whose files were
|
|
2014
|
+
* all written inside one tick.
|
|
2015
|
+
*/
|
|
2016
|
+
const FILESYSTEM_CLOCK_FLOORS = new WeakMap<
|
|
2017
|
+
TtscTransformFilesystemOperations,
|
|
2018
|
+
Map<bigint, bigint>
|
|
2019
|
+
>();
|
|
2020
|
+
|
|
2021
|
+
/** Return one observed filesystem's per-device clock floor, creating it. */
|
|
2022
|
+
function filesystemClockFloors(
|
|
2023
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2024
|
+
): Map<bigint, bigint> {
|
|
2025
|
+
let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
|
|
2026
|
+
if (floors === undefined) {
|
|
2027
|
+
floors = new Map();
|
|
2028
|
+
FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
|
|
2029
|
+
}
|
|
2030
|
+
return floors;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** Raise a device's clock floor with the stamps one observation reported. */
|
|
2034
|
+
function observeFilesystemClock(
|
|
2035
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2036
|
+
stats: fs.BigIntStats,
|
|
2037
|
+
): void {
|
|
2038
|
+
const floors = filesystemClockFloors(filesystem);
|
|
2039
|
+
const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
|
|
2040
|
+
const current = floors.get(stats.dev);
|
|
2041
|
+
if (current === undefined || stamp > current) {
|
|
2042
|
+
floors.set(stats.dev, stamp);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* Report whether a later write to the observed path is guaranteed to move its
|
|
2048
|
+
* modification stamp: the device's clock floor holds a stamp strictly newer, so
|
|
2049
|
+
* the tick that minted the stamp is provably over. The floor was observed
|
|
2050
|
+
* before the caller's content read began, which is the ordering the guarantee
|
|
2051
|
+
* needs — a stamp minted before the read proves every post-read write lands in
|
|
2052
|
+
* a newer tick.
|
|
2053
|
+
*/
|
|
2054
|
+
function stampSeparable(
|
|
2055
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2056
|
+
stats: fs.BigIntStats,
|
|
2057
|
+
): boolean {
|
|
2058
|
+
const floor = filesystemClockFloors(filesystem).get(stats.dev);
|
|
2059
|
+
return floor !== undefined && stats.mtimeNs < floor;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Mint a reference instant for this generation and feed it into the observed
|
|
2064
|
+
* filesystem's clock floor.
|
|
2065
|
+
*
|
|
2066
|
+
* The scratch directory is a write the adapter already owns, deliberately
|
|
2067
|
+
* outside the project root, so stamping a probe file there produces a
|
|
2068
|
+
* freshly-minted "now" without touching the user's project — the analogue of
|
|
2069
|
+
* git writing its index. The probe is observed through the cache-owned
|
|
2070
|
+
* operations and keyed by the device those operations report, so it only ever
|
|
2071
|
+
* separates stamps on the filesystem that actually minted it; when the scratch
|
|
2072
|
+
* volume differs from the inputs' volume, or the observed filesystem cannot see
|
|
2073
|
+
* the probe at all, nothing is proven and signature recording simply stays
|
|
2074
|
+
* declined until passively observed stamps separate an input on their own.
|
|
2075
|
+
*
|
|
2076
|
+
* Relocating the scratch directory onto the inputs' volume would make the probe
|
|
2077
|
+
* universal, but it would also move every compiler and plugin temporary write
|
|
2078
|
+
* into the project's parent (frequently a monorepo root or a home directory)
|
|
2079
|
+
* for those layouts. That is a product decision about where ttsc writes, not a
|
|
2080
|
+
* property of this rule, so the cross-volume case degrades to more reads here
|
|
2081
|
+
* rather than being bought with it.
|
|
2082
|
+
*/
|
|
2083
|
+
function mintFilesystemClockReference(
|
|
2084
|
+
scratchDirectory: string,
|
|
2085
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2086
|
+
): void {
|
|
2087
|
+
try {
|
|
2088
|
+
const probe = path.join(scratchDirectory, "clock-reference");
|
|
2089
|
+
fs.writeFileSync(probe, "");
|
|
2090
|
+
observeFilesystemClock(filesystem, filesystem.lstat(probe));
|
|
2091
|
+
} catch {
|
|
2092
|
+
// The absence of a reference declines signature recording; it never
|
|
2093
|
+
// invalidates a generation.
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* One metadata observation: the signature plus whether the observed filesystem
|
|
2099
|
+
* has provably moved past every write-mintable stamp inside it.
|
|
2100
|
+
*/
|
|
2101
|
+
interface TtscInputMetadataEvidence {
|
|
2102
|
+
/** The joined metadata signature of the lexical path and its link target. */
|
|
2103
|
+
signature: string;
|
|
2104
|
+
/**
|
|
2105
|
+
* Whether a later write is guaranteed to move this signature. Only a
|
|
2106
|
+
* signature captured with this evidence may be recorded to stand in for a
|
|
2107
|
+
* content comparison; without it, a same-length rewrite inside the stamp's
|
|
2108
|
+
* own clock tick would leave the signature unchanged.
|
|
2109
|
+
*/
|
|
2110
|
+
separable: boolean;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1575
2113
|
/** Metadata identity whose stability lets a generation reuse a content hash. */
|
|
1576
2114
|
function inputMetadataSignature(
|
|
1577
2115
|
file: string,
|
|
1578
2116
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
1579
2117
|
): string | undefined {
|
|
2118
|
+
return inputMetadataEvidence(file, filesystem)?.signature;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** Observe one input's metadata signature and its clock separability. */
|
|
2122
|
+
function inputMetadataEvidence(
|
|
2123
|
+
file: string,
|
|
2124
|
+
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2125
|
+
): TtscInputMetadataEvidence | undefined {
|
|
1580
2126
|
try {
|
|
1581
2127
|
const link = filesystem.lstat(file);
|
|
2128
|
+
observeFilesystemClock(filesystem, link);
|
|
1582
2129
|
let target = link;
|
|
1583
2130
|
if (link.isSymbolicLink()) {
|
|
1584
2131
|
try {
|
|
1585
2132
|
target = filesystem.statBigInt(file);
|
|
2133
|
+
observeFilesystemClock(filesystem, target);
|
|
1586
2134
|
} catch {
|
|
1587
2135
|
// Keep a broken link in the existing-input manifest. Its own metadata
|
|
1588
2136
|
// stays stable while the target is missing, and the first successful
|
|
1589
2137
|
// stat after the target appears changes this signature. Treating it as
|
|
1590
2138
|
// a plain missing path would watch/list only the link's parent, which
|
|
1591
|
-
// cannot observe a target created in another directory.
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2139
|
+
// cannot observe a target created in another directory. It carries no
|
|
2140
|
+
// readable bytes, so it never needs to be separable.
|
|
2141
|
+
return {
|
|
2142
|
+
signature: [
|
|
2143
|
+
link.dev,
|
|
2144
|
+
link.ino,
|
|
2145
|
+
link.mode,
|
|
2146
|
+
link.size,
|
|
2147
|
+
link.mtimeNs,
|
|
2148
|
+
link.ctimeNs,
|
|
2149
|
+
"missing-target",
|
|
2150
|
+
].join(":"),
|
|
2151
|
+
separable: false,
|
|
2152
|
+
};
|
|
1601
2153
|
}
|
|
1602
2154
|
}
|
|
1603
|
-
return
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2155
|
+
return {
|
|
2156
|
+
signature: [
|
|
2157
|
+
link.dev,
|
|
2158
|
+
link.ino,
|
|
2159
|
+
link.mode,
|
|
2160
|
+
link.size,
|
|
2161
|
+
link.mtimeNs,
|
|
2162
|
+
link.ctimeNs,
|
|
2163
|
+
target.dev,
|
|
2164
|
+
target.ino,
|
|
2165
|
+
target.mode,
|
|
2166
|
+
target.size,
|
|
2167
|
+
target.mtimeNs,
|
|
2168
|
+
target.ctimeNs,
|
|
2169
|
+
].join(":"),
|
|
2170
|
+
// Both halves must be separable: a write remints the target's stamp, a
|
|
2171
|
+
// link retarget the link's own, and either one hiding inside its recorded
|
|
2172
|
+
// tick would evade the skipped content and realpath comparisons.
|
|
2173
|
+
separable:
|
|
2174
|
+
stampSeparable(filesystem, link) && stampSeparable(filesystem, target),
|
|
2175
|
+
};
|
|
1617
2176
|
} catch {
|
|
1618
2177
|
return undefined;
|
|
1619
2178
|
}
|
|
@@ -1732,25 +2291,64 @@ function missingPathProbe(
|
|
|
1732
2291
|
}
|
|
1733
2292
|
}
|
|
1734
2293
|
|
|
1735
|
-
/**
|
|
2294
|
+
/**
|
|
2295
|
+
* Prove one generation from its own recorded snapshot, with no help from live
|
|
2296
|
+
* notifications.
|
|
2297
|
+
*
|
|
2298
|
+
* This is the fallback for a graph-free envelope and for a generation whose
|
|
2299
|
+
* watchers could not be opened or have since failed: losing the notification
|
|
2300
|
+
* proof must cost the narrow path, not the cache. The walk re-proves membership
|
|
2301
|
+
* directly — the recorded directory signatures plus the recorded file-key
|
|
2302
|
+
* universe — so a created, deleted, or renamed input still invalidates without
|
|
2303
|
+
* any watcher.
|
|
2304
|
+
*/
|
|
1736
2305
|
function matchesCompleteInputSnapshot(
|
|
1737
2306
|
cached: TtscCachedProjectTransform,
|
|
1738
2307
|
currentKey: string,
|
|
1739
2308
|
source: string,
|
|
1740
2309
|
): boolean {
|
|
1741
|
-
if (
|
|
2310
|
+
if (
|
|
2311
|
+
cached.projectSnapshotComplete !== true ||
|
|
2312
|
+
cached.projectDirectories === undefined
|
|
2313
|
+
) {
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
// Universal descriptor/config inputs carry a physical-identity proof that no
|
|
2317
|
+
// content comparison can replace: retargeting a symlinked input to a
|
|
2318
|
+
// byte-identical file selects a different file, and its own transitive
|
|
2319
|
+
// requires with it. Only the graph half of the out-of-walk snapshot records
|
|
2320
|
+
// realpaths, so without this the fallback would quietly hold a lower standard
|
|
2321
|
+
// than the narrow path it stands in for.
|
|
2322
|
+
const state = envelopeDerivation(cached);
|
|
2323
|
+
const hostValidation = cached.hostInputValidation;
|
|
2324
|
+
if (
|
|
2325
|
+
hostValidation === undefined ||
|
|
2326
|
+
!matchesUniversalHostInputEntries(cached, hostValidation)
|
|
2327
|
+
) {
|
|
1742
2328
|
return false;
|
|
1743
2329
|
}
|
|
2330
|
+
const declaredInputs = declaredProjectInputKeys(state, cached);
|
|
1744
2331
|
const current = collectProjectInputSnapshot(
|
|
1745
2332
|
cached.projectRoot,
|
|
1746
|
-
|
|
2333
|
+
state.identityContext,
|
|
1747
2334
|
resultFilesystem(cached.result),
|
|
2335
|
+
cached.inputSignatures === undefined
|
|
2336
|
+
? undefined
|
|
2337
|
+
: { hashes: cached.inputHashes, signatures: cached.inputSignatures },
|
|
1748
2338
|
);
|
|
1749
|
-
if (!current
|
|
2339
|
+
if (!walkSnapshotComplete(current, declaredInputs)) {
|
|
2340
|
+
return false;
|
|
2341
|
+
}
|
|
2342
|
+
if (
|
|
2343
|
+
!sameProjectDirectories(
|
|
2344
|
+
cached.projectDirectories,
|
|
2345
|
+
current.projectDirectories,
|
|
2346
|
+
)
|
|
2347
|
+
) {
|
|
1750
2348
|
return false;
|
|
1751
2349
|
}
|
|
1752
2350
|
current.hashes[currentKey] = hashText(source);
|
|
1753
|
-
if (!sameHashes(cached.inputHashes, current.hashes)) {
|
|
2351
|
+
if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
|
|
1754
2352
|
return false;
|
|
1755
2353
|
}
|
|
1756
2354
|
// Re-hash the out-of-walk inputs the compiler reported for this generation
|
|
@@ -1761,11 +2359,49 @@ function matchesCompleteInputSnapshot(
|
|
|
1761
2359
|
// edge requires editing an in-walk source, and a new global or config file
|
|
1762
2360
|
// requires a tsconfig or package manifest change, both of which the project
|
|
1763
2361
|
// walk above already detects.
|
|
1764
|
-
const
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2362
|
+
const externalCurrent = matchesCachedExternalInputs(cached);
|
|
2363
|
+
if (!externalCurrent.matches || !matchesExternalInputRealpaths(cached)) {
|
|
2364
|
+
return false;
|
|
2365
|
+
}
|
|
2366
|
+
adoptProvenSignatures(cached, {
|
|
2367
|
+
currentKey,
|
|
2368
|
+
external: externalCurrent.signatures,
|
|
2369
|
+
project: current.provenSignatures,
|
|
2370
|
+
});
|
|
2371
|
+
return true;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Adopt the signatures captured while this walk proved every recorded input
|
|
2376
|
+
* still carries its recorded content.
|
|
2377
|
+
*
|
|
2378
|
+
* Without this, a metadata-only change — a touch, or a rewrite of identical
|
|
2379
|
+
* bytes — costs a re-read on every later delivery for the rest of the
|
|
2380
|
+
* generation's life, because the recorded signature can never match again. The
|
|
2381
|
+
* narrow path self-heals through {@link matchesProvenInput}; this is the same
|
|
2382
|
+
* refresh for the path that proves the whole snapshot at once.
|
|
2383
|
+
*
|
|
2384
|
+
* The delivered file is the single exclusion: its recorded hash is the source
|
|
2385
|
+
* the bundler supplied, so the disk bytes this walk read for it were compared
|
|
2386
|
+
* against nothing.
|
|
2387
|
+
*/
|
|
2388
|
+
function adoptProvenSignatures(
|
|
2389
|
+
cached: TtscCachedProjectTransform,
|
|
2390
|
+
proven: {
|
|
2391
|
+
currentKey: string;
|
|
2392
|
+
external: Record<string, string>;
|
|
2393
|
+
project: Record<string, string>;
|
|
2394
|
+
},
|
|
2395
|
+
): void {
|
|
2396
|
+
const projectSignatures = (cached.inputSignatures ??= {});
|
|
2397
|
+
for (const [key, signature] of Object.entries(proven.project)) {
|
|
2398
|
+
if (key === proven.currentKey) continue;
|
|
2399
|
+
projectSignatures[key] = signature;
|
|
2400
|
+
}
|
|
2401
|
+
const externalSignatures = (cached.externalInputSignatures ??= {});
|
|
2402
|
+
for (const [spelling, signature] of Object.entries(proven.external)) {
|
|
2403
|
+
externalSignatures[spelling] = signature;
|
|
2404
|
+
}
|
|
1769
2405
|
}
|
|
1770
2406
|
|
|
1771
2407
|
/** Re-check graph-owned physical identities in complete-snapshot fallback. */
|
|
@@ -1806,22 +2442,48 @@ function captureExternalInputSnapshot(
|
|
|
1806
2442
|
complete: boolean;
|
|
1807
2443
|
hashes: Record<string, string>;
|
|
1808
2444
|
realpaths: Record<string, string | null>;
|
|
2445
|
+
signatures: Record<string, string>;
|
|
1809
2446
|
} {
|
|
1810
2447
|
const state = envelopeDerivation(cached);
|
|
1811
2448
|
const filesystem = resultFilesystem(cached.result);
|
|
1812
2449
|
const graph = envelopeGraphIndexes(state, cached);
|
|
1813
2450
|
const hashes: Record<string, string> = {};
|
|
1814
2451
|
const realpaths: Record<string, string | null> = {};
|
|
2452
|
+
const signatures: Record<string, string> = {};
|
|
1815
2453
|
let complete = true;
|
|
2454
|
+
// Sandwich every read between two metadata signatures. Only a signature that
|
|
2455
|
+
// survived its own read, and whose stamp's tick the filesystem's clock has
|
|
2456
|
+
// provably left ({@link stampSeparable}), may stand in for the content
|
|
2457
|
+
// comparison; a write racing the capture, or a stamp a same-tick rewrite
|
|
2458
|
+
// could still reproduce, leaves the input without one, so revalidation keeps
|
|
2459
|
+
// re-reading it.
|
|
2460
|
+
const record = (
|
|
2461
|
+
input: string,
|
|
2462
|
+
before: TtscInputMetadataEvidence | undefined,
|
|
2463
|
+
after: string | undefined,
|
|
2464
|
+
): void => {
|
|
2465
|
+
if (after !== undefined && before?.signature === after && before.separable)
|
|
2466
|
+
signatures[path.resolve(input)] = after;
|
|
2467
|
+
};
|
|
1816
2468
|
for (const input of paths) {
|
|
1817
2469
|
const identity = derivationIdentity(state, input);
|
|
1818
|
-
|
|
2470
|
+
// A member the envelope reported only as a resolution candidate falls
|
|
2471
|
+
// through to the recorded-state branch below, the same evidence a
|
|
2472
|
+
// plugin-declared dependency path carries. Its absence still invalidates
|
|
2473
|
+
// the generation when it appears, because `missing` is recorded state.
|
|
2474
|
+
const speculativeOnly =
|
|
2475
|
+
graph.speculative.has(identity) &&
|
|
2476
|
+
!graph.inputProofs.has(identity) &&
|
|
2477
|
+
!graph.inputProofConflicts.has(identity);
|
|
2478
|
+
if (graph.members.has(identity) && !speculativeOnly) {
|
|
1819
2479
|
const proof = graph.inputProofs.get(identity);
|
|
1820
2480
|
if (proof === undefined || graph.inputProofConflicts.has(identity)) {
|
|
1821
2481
|
complete = false;
|
|
1822
2482
|
continue;
|
|
1823
2483
|
}
|
|
2484
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
1824
2485
|
const currentHash = graphInputStateHash(input, filesystem);
|
|
2486
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
1825
2487
|
if (
|
|
1826
2488
|
currentHash !== proof.hash ||
|
|
1827
2489
|
!sameHostInputRealpath(
|
|
@@ -1831,14 +2493,24 @@ function captureExternalInputSnapshot(
|
|
|
1831
2493
|
)
|
|
1832
2494
|
) {
|
|
1833
2495
|
complete = false;
|
|
2496
|
+
} else if (currentHash !== null) {
|
|
2497
|
+
// The recorded hash is the compiler's own proof, so a signature may
|
|
2498
|
+
// only stand for it once the current bytes were shown to match it.
|
|
2499
|
+
// A path with no readable content has no bytes to stand for: it can
|
|
2500
|
+
// hold stable metadata while becoming readable, so it keeps the read.
|
|
2501
|
+
record(input, before, after);
|
|
1834
2502
|
}
|
|
1835
|
-
hashes[identity] = proof.hash ??
|
|
2503
|
+
hashes[identity] = proof.hash ?? MISSING_INPUT_STATE;
|
|
1836
2504
|
realpaths[identity] = proof.realpath;
|
|
1837
2505
|
continue;
|
|
1838
2506
|
}
|
|
1839
|
-
|
|
2507
|
+
const before = inputMetadataEvidence(input, filesystem);
|
|
2508
|
+
const hash = hostInputStateHash(input, filesystem);
|
|
2509
|
+
const after = inputMetadataSignature(input, filesystem);
|
|
2510
|
+
hashes[identity] = hash ?? MISSING_INPUT_STATE;
|
|
2511
|
+
if (hash !== null) record(input, before, after);
|
|
1840
2512
|
}
|
|
1841
|
-
return { complete, hashes, realpaths };
|
|
2513
|
+
return { complete, hashes, realpaths, signatures };
|
|
1842
2514
|
}
|
|
1843
2515
|
|
|
1844
2516
|
/** Verify every graph member still has the state read by the compiler. */
|
|
@@ -1859,14 +2531,19 @@ function matchesCompilerGraphInputProofs(
|
|
|
1859
2531
|
const state = envelopeDerivation(cached);
|
|
1860
2532
|
const filesystem = resultFilesystem(cached.result);
|
|
1861
2533
|
const graph = envelopeGraphIndexes(state, cached);
|
|
1862
|
-
if (
|
|
1863
|
-
graph.inputProofConflicts.size !== 0 ||
|
|
1864
|
-
graph.inputProofs.size !== graph.members.size
|
|
1865
|
-
) {
|
|
2534
|
+
if (graph.inputProofConflicts.size !== 0) {
|
|
1866
2535
|
return false;
|
|
1867
2536
|
}
|
|
1868
2537
|
for (const identity of graph.members) {
|
|
1869
2538
|
const proof = graph.inputProofs.get(identity);
|
|
2539
|
+
// A speculative candidate has no compile-time read to prove. Requiring one
|
|
2540
|
+
// would void every generation of every project whose resolution passes over
|
|
2541
|
+
// a higher-priority spelling, which is every project with a dependency
|
|
2542
|
+
// typed by a declaration file (samchon/ttsc#1245). It is validated instead
|
|
2543
|
+
// against the state {@link captureExternalInputSnapshot} recorded for it.
|
|
2544
|
+
if (proof === undefined && graph.speculative.has(identity)) {
|
|
2545
|
+
continue;
|
|
2546
|
+
}
|
|
1870
2547
|
if (
|
|
1871
2548
|
proof === undefined ||
|
|
1872
2549
|
graphInputStateHash(proof.path, filesystem) !== proof.hash ||
|
|
@@ -1928,9 +2605,9 @@ function matchesRecordedInput(
|
|
|
1928
2605
|
const current = graphInput
|
|
1929
2606
|
? graphInputStateHash(input, filesystem)
|
|
1930
2607
|
: hostInputStateHash(input, filesystem);
|
|
1931
|
-
return recorded === (current ??
|
|
2608
|
+
return recorded === (current ?? MISSING_INPUT_STATE);
|
|
1932
2609
|
} catch {
|
|
1933
|
-
return recorded ===
|
|
2610
|
+
return recorded === MISSING_INPUT_STATE;
|
|
1934
2611
|
}
|
|
1935
2612
|
}
|
|
1936
2613
|
|
|
@@ -1964,39 +2641,87 @@ function collectProjectInputSnapshot(
|
|
|
1964
2641
|
projectRoot: string,
|
|
1965
2642
|
identities: FilesystemPathIdentityContext,
|
|
1966
2643
|
filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
|
|
2644
|
+
proven?: {
|
|
2645
|
+
hashes: Record<string, string>;
|
|
2646
|
+
signatures: Record<string, string>;
|
|
2647
|
+
},
|
|
1967
2648
|
): {
|
|
1968
2649
|
complete: boolean;
|
|
2650
|
+
directoryComplete: boolean;
|
|
1969
2651
|
fileSignatures: Record<string, string>;
|
|
1970
2652
|
hashes: Record<string, string>;
|
|
1971
2653
|
projectDirectories: TtscProjectDirectorySnapshot[];
|
|
2654
|
+
provenSignatures: Record<string, string>;
|
|
2655
|
+
unstableFiles: Set<string>;
|
|
1972
2656
|
} {
|
|
1973
2657
|
const hashes: Record<string, string> = {};
|
|
1974
2658
|
const fileSignatures: Record<string, string> = {};
|
|
2659
|
+
const provenSignatures: Record<string, string> = {};
|
|
2660
|
+
const unstableFiles = new Set<string>();
|
|
2661
|
+
let attributed = true;
|
|
1975
2662
|
const walked = walkProjectInputs(projectRoot, filesystem);
|
|
1976
2663
|
let complete = walked.complete;
|
|
1977
2664
|
for (const file of walked.files) {
|
|
1978
2665
|
try {
|
|
1979
|
-
const before =
|
|
2666
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
2667
|
+
const key = toProjectKey(projectRoot, file, identities);
|
|
2668
|
+
// A file whose signature still equals the one captured around the read
|
|
2669
|
+
// that produced the recorded hash carries that content, so the whole
|
|
2670
|
+
// project does not have to be re-read to prove one delivery. A signature
|
|
2671
|
+
// that was already proven stays proven: its stamp has not moved since the
|
|
2672
|
+
// clock provably left its tick.
|
|
2673
|
+
if (
|
|
2674
|
+
before !== undefined &&
|
|
2675
|
+
proven !== undefined &&
|
|
2676
|
+
proven.signatures[key] === before.signature &&
|
|
2677
|
+
Object.prototype.hasOwnProperty.call(proven.hashes, key)
|
|
2678
|
+
) {
|
|
2679
|
+
hashes[key] = proven.hashes[key]!;
|
|
2680
|
+
fileSignatures[key] = before.signature;
|
|
2681
|
+
provenSignatures[key] = before.signature;
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
1980
2684
|
const contents = filesystem.readFile(file);
|
|
1981
2685
|
const after = inputMetadataSignature(file, filesystem);
|
|
1982
|
-
const key = toProjectKey(projectRoot, file, identities);
|
|
1983
2686
|
hashes[key] = hashText(contents);
|
|
1984
|
-
if (
|
|
2687
|
+
if (
|
|
2688
|
+
before === undefined ||
|
|
2689
|
+
after === undefined ||
|
|
2690
|
+
before.signature !== after
|
|
2691
|
+
) {
|
|
1985
2692
|
complete = false;
|
|
2693
|
+
unstableFiles.add(key);
|
|
1986
2694
|
} else {
|
|
1987
2695
|
fileSignatures[key] = after;
|
|
2696
|
+
// Only a signature whose stamp's tick the filesystem's clock provably
|
|
2697
|
+
// left before this read may later stand in for the content comparison
|
|
2698
|
+
// ({@link stampSeparable}); the raw signature above still participates
|
|
2699
|
+
// in the generation-time stability comparison.
|
|
2700
|
+
if (before.separable) {
|
|
2701
|
+
provenSignatures[key] = after;
|
|
2702
|
+
}
|
|
1988
2703
|
}
|
|
1989
2704
|
} catch {
|
|
1990
2705
|
// File watchers may observe a transform while another process is moving
|
|
1991
2706
|
// or deleting files. The missing key invalidates older cache entries.
|
|
1992
2707
|
complete = false;
|
|
2708
|
+
try {
|
|
2709
|
+
unstableFiles.add(toProjectKey(projectRoot, file, identities));
|
|
2710
|
+
} catch {
|
|
2711
|
+
// Without a key the failure cannot be attributed, so it keeps the
|
|
2712
|
+
// whole snapshot incomplete rather than being scoped away.
|
|
2713
|
+
attributed = false;
|
|
2714
|
+
}
|
|
1993
2715
|
}
|
|
1994
2716
|
}
|
|
1995
2717
|
return {
|
|
1996
2718
|
complete,
|
|
2719
|
+
directoryComplete: walked.complete && attributed,
|
|
1997
2720
|
fileSignatures,
|
|
1998
2721
|
hashes,
|
|
1999
2722
|
projectDirectories: walked.directories,
|
|
2723
|
+
provenSignatures,
|
|
2724
|
+
unstableFiles,
|
|
2000
2725
|
};
|
|
2001
2726
|
}
|
|
2002
2727
|
|
|
@@ -2072,6 +2797,9 @@ function projectDirectorySignature(
|
|
|
2072
2797
|
): string | undefined {
|
|
2073
2798
|
try {
|
|
2074
2799
|
const stats = filesystem.statBigInt(directory);
|
|
2800
|
+
// Directory stamps are minted by the same clock as file stamps, so every
|
|
2801
|
+
// walk observation also raises the clock floor that separates them.
|
|
2802
|
+
observeFilesystemClock(filesystem, stats);
|
|
2075
2803
|
if (!stats.isDirectory()) {
|
|
2076
2804
|
return undefined;
|
|
2077
2805
|
}
|
|
@@ -2103,6 +2831,31 @@ function sameProjectDirectories(
|
|
|
2103
2831
|
);
|
|
2104
2832
|
}
|
|
2105
2833
|
|
|
2834
|
+
/**
|
|
2835
|
+
* Open one directory's change notification through the cache-owned watch seam,
|
|
2836
|
+
* falling back to the host's own `fs.watch`. Throws exactly where the
|
|
2837
|
+
* underlying watch does, so callers classify a registration failure
|
|
2838
|
+
* themselves.
|
|
2839
|
+
*/
|
|
2840
|
+
function openDirectoryWatch(
|
|
2841
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2842
|
+
directory: string,
|
|
2843
|
+
listener: (eventType: string, filename: string | null) => void,
|
|
2844
|
+
onError: () => void,
|
|
2845
|
+
): { close: () => void } {
|
|
2846
|
+
if (filesystem.watch !== undefined) {
|
|
2847
|
+
return filesystem.watch(directory, listener, onError);
|
|
2848
|
+
}
|
|
2849
|
+
const watcher = fs.watch(
|
|
2850
|
+
directory,
|
|
2851
|
+
{ persistent: false },
|
|
2852
|
+
(eventType, filename) =>
|
|
2853
|
+
listener(eventType, filename === null ? null : String(filename)),
|
|
2854
|
+
);
|
|
2855
|
+
watcher.on("error", onError);
|
|
2856
|
+
return { close: () => watcher.close() };
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2106
2859
|
/** Watch every walked directory for membership changes after generation. */
|
|
2107
2860
|
async function createProjectMutationTracker(
|
|
2108
2861
|
directories: readonly TtscProjectDirectorySnapshot[],
|
|
@@ -2113,7 +2866,7 @@ async function createProjectMutationTracker(
|
|
|
2113
2866
|
failed: false,
|
|
2114
2867
|
membershipChanged: false,
|
|
2115
2868
|
};
|
|
2116
|
-
if (process.platform === "win32") {
|
|
2869
|
+
if (process.platform === "win32" && filesystem.watch === undefined) {
|
|
2117
2870
|
await registerWindowsProjectMutationTracker(
|
|
2118
2871
|
tracker,
|
|
2119
2872
|
directories.map((directory) => ({ directory: directory.path })),
|
|
@@ -2122,24 +2875,25 @@ async function createProjectMutationTracker(
|
|
|
2122
2875
|
);
|
|
2123
2876
|
return tracker;
|
|
2124
2877
|
}
|
|
2125
|
-
const watchers:
|
|
2878
|
+
const watchers: { close: () => void }[] = [];
|
|
2126
2879
|
tracker.close = () => {
|
|
2127
2880
|
for (const watcher of watchers) watcher.close();
|
|
2128
2881
|
watchers.length = 0;
|
|
2129
2882
|
};
|
|
2130
2883
|
for (const directory of directories) {
|
|
2131
2884
|
try {
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2885
|
+
watchers.push(
|
|
2886
|
+
openDirectoryWatch(
|
|
2887
|
+
filesystem,
|
|
2888
|
+
directory.path,
|
|
2889
|
+
(eventType) => {
|
|
2890
|
+
if (eventType === "rename") tracker.membershipChanged = true;
|
|
2891
|
+
},
|
|
2892
|
+
() => {
|
|
2893
|
+
tracker.failed = true;
|
|
2894
|
+
},
|
|
2895
|
+
),
|
|
2138
2896
|
);
|
|
2139
|
-
watcher.on("error", () => {
|
|
2140
|
-
tracker.failed = true;
|
|
2141
|
-
});
|
|
2142
|
-
watchers.push(watcher);
|
|
2143
2897
|
} catch {
|
|
2144
2898
|
tracker.failed = true;
|
|
2145
2899
|
}
|
|
@@ -2150,7 +2904,9 @@ async function createProjectMutationTracker(
|
|
|
2150
2904
|
/** Watch exact universal inputs, or their nearest existing parent if missing. */
|
|
2151
2905
|
async function createHostInputMutationTracker(
|
|
2152
2906
|
inputs: readonly string[],
|
|
2153
|
-
filesystem: TtscTransformFilesystemOperations
|
|
2907
|
+
filesystem: TtscTransformFilesystemOperations,
|
|
2908
|
+
covered: ReadonlySet<string>,
|
|
2909
|
+
events: "all" | "rename" = "all",
|
|
2154
2910
|
): Promise<TtscProjectMutationTracker> {
|
|
2155
2911
|
const identities = createHostPathIdentityContext(filesystem);
|
|
2156
2912
|
const namesByDirectory = new Map<
|
|
@@ -2184,19 +2940,26 @@ async function createHostInputMutationTracker(
|
|
|
2184
2940
|
}));
|
|
2185
2941
|
const tracker: TtscProjectMutationTracker = {
|
|
2186
2942
|
close: () => undefined,
|
|
2943
|
+
// Coverage is the caller's claim, and it is required rather than derived
|
|
2944
|
+
// from the input list: an input is watched by its exact name here, but only
|
|
2945
|
+
// the caller knows whether the path leading to it is watched as well, which
|
|
2946
|
+
// is what a later validation needs before it trusts the watcher instead of
|
|
2947
|
+
// probing the path again. Deriving it here would hand that claim to every
|
|
2948
|
+
// future caller by default (samchon/ttsc#1261).
|
|
2949
|
+
covered,
|
|
2187
2950
|
failed: false,
|
|
2188
2951
|
membershipChanged: false,
|
|
2189
2952
|
};
|
|
2190
|
-
if (process.platform === "win32") {
|
|
2953
|
+
if (process.platform === "win32" && filesystem.watch === undefined) {
|
|
2191
2954
|
await registerWindowsProjectMutationTracker(
|
|
2192
2955
|
tracker,
|
|
2193
2956
|
locations,
|
|
2194
|
-
|
|
2957
|
+
events === "all",
|
|
2195
2958
|
filesystem,
|
|
2196
2959
|
);
|
|
2197
2960
|
return tracker;
|
|
2198
2961
|
}
|
|
2199
|
-
const watchers:
|
|
2962
|
+
const watchers: { close: () => void }[] = [];
|
|
2200
2963
|
tracker.close = () => {
|
|
2201
2964
|
for (const watcher of watchers) watcher.close();
|
|
2202
2965
|
watchers.length = 0;
|
|
@@ -2205,23 +2968,27 @@ async function createHostInputMutationTracker(
|
|
|
2205
2968
|
try {
|
|
2206
2969
|
const names = new Set(location.names);
|
|
2207
2970
|
const caseSensitive = identities.caseSensitive(location.directory);
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2971
|
+
watchers.push(
|
|
2972
|
+
openDirectoryWatch(
|
|
2973
|
+
filesystem,
|
|
2974
|
+
location.directory,
|
|
2975
|
+
(eventType, filename) => {
|
|
2976
|
+
if (events === "rename" && eventType !== "rename") {
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
const reported =
|
|
2980
|
+
filename === null
|
|
2981
|
+
? null
|
|
2982
|
+
: normalizeHostInputName(filename, caseSensitive);
|
|
2983
|
+
if (reported === null || names.has(reported)) {
|
|
2984
|
+
tracker.membershipChanged = true;
|
|
2985
|
+
}
|
|
2986
|
+
},
|
|
2987
|
+
() => {
|
|
2988
|
+
tracker.failed = true;
|
|
2989
|
+
},
|
|
2990
|
+
),
|
|
2220
2991
|
);
|
|
2221
|
-
watcher.on("error", () => {
|
|
2222
|
-
tracker.failed = true;
|
|
2223
|
-
});
|
|
2224
|
-
watchers.push(watcher);
|
|
2225
2992
|
} catch {
|
|
2226
2993
|
tracker.failed = true;
|
|
2227
2994
|
}
|
|
@@ -2231,7 +2998,12 @@ async function createHostInputMutationTracker(
|
|
|
2231
2998
|
|
|
2232
2999
|
interface WindowsProjectMutationBroker {
|
|
2233
3000
|
child: ChildProcess;
|
|
3001
|
+
/** Round-trips awaiting the child's reply, by request id. */
|
|
3002
|
+
drains: Map<number, () => void>;
|
|
3003
|
+
/** The acknowledgement currently in flight, shared by every waiter. */
|
|
3004
|
+
draining?: Promise<void>;
|
|
2234
3005
|
nextId: number;
|
|
3006
|
+
pendingDrains: number;
|
|
2235
3007
|
pendingRegistrations: number;
|
|
2236
3008
|
trackers: Map<
|
|
2237
3009
|
number,
|
|
@@ -2284,6 +3056,7 @@ async function registerWindowsProjectMutationTracker(
|
|
|
2284
3056
|
resolveReady = resolve;
|
|
2285
3057
|
});
|
|
2286
3058
|
broker.trackers.set(id, { ready: resolveReady, tracker });
|
|
3059
|
+
tracker.drain = () => drainWindowsProjectMutationBroker(broker);
|
|
2287
3060
|
tracker.close = () => {
|
|
2288
3061
|
const active = broker.trackers.get(id);
|
|
2289
3062
|
if (active === undefined) return;
|
|
@@ -2308,7 +3081,11 @@ async function registerWindowsProjectMutationTracker(
|
|
|
2308
3081
|
await ready;
|
|
2309
3082
|
} finally {
|
|
2310
3083
|
broker.pendingRegistrations -= 1;
|
|
2311
|
-
|
|
3084
|
+
// `ref`/`unref` is a flag rather than a counter, so this must not clear a
|
|
3085
|
+
// reference an in-flight acknowledgement is holding: a delivery waiting on
|
|
3086
|
+
// a reply over an unreferenced channel lets the loop empty and the process
|
|
3087
|
+
// exit mid-build.
|
|
3088
|
+
if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
|
|
2312
3089
|
broker.child.unref();
|
|
2313
3090
|
broker.child.channel?.unref();
|
|
2314
3091
|
}
|
|
@@ -2325,7 +3102,9 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
2325
3102
|
});
|
|
2326
3103
|
const broker: WindowsProjectMutationBroker = {
|
|
2327
3104
|
child,
|
|
3105
|
+
drains: new Map(),
|
|
2328
3106
|
nextId: 1,
|
|
3107
|
+
pendingDrains: 0,
|
|
2329
3108
|
pendingRegistrations: 0,
|
|
2330
3109
|
trackers: new Map(),
|
|
2331
3110
|
};
|
|
@@ -2335,6 +3114,11 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
2335
3114
|
registration.ready();
|
|
2336
3115
|
}
|
|
2337
3116
|
broker.trackers.clear();
|
|
3117
|
+
// A broker that died answers no round-trip. Release every waiter instead of
|
|
3118
|
+
// stalling the deliveries behind them; their trackers are failed now, so
|
|
3119
|
+
// validation falls back to proving the generation from its own state.
|
|
3120
|
+
for (const release of broker.drains.values()) release();
|
|
3121
|
+
broker.drains.clear();
|
|
2338
3122
|
if (windowsProjectMutationBroker === broker) {
|
|
2339
3123
|
windowsProjectMutationBroker = undefined;
|
|
2340
3124
|
}
|
|
@@ -2344,11 +3128,20 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
2344
3128
|
child.on("message", (message: unknown) => {
|
|
2345
3129
|
if (message === null || typeof message !== "object") return;
|
|
2346
3130
|
const record = message as {
|
|
3131
|
+
drained?: boolean;
|
|
2347
3132
|
failed?: boolean;
|
|
2348
3133
|
id?: number;
|
|
2349
3134
|
ready?: boolean;
|
|
2350
3135
|
};
|
|
2351
3136
|
if (typeof record.id !== "number") return;
|
|
3137
|
+
if (record.drained === true) {
|
|
3138
|
+
// Every event the child had already sent arrived before this reply, since
|
|
3139
|
+
// one IPC channel delivers in order.
|
|
3140
|
+
const release = broker.drains.get(record.id);
|
|
3141
|
+
broker.drains.delete(record.id);
|
|
3142
|
+
release?.();
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
2352
3145
|
const registration = broker.trackers.get(record.id);
|
|
2353
3146
|
if (registration === undefined) return;
|
|
2354
3147
|
if (record.failed === true) registration.tracker.failed = true;
|
|
@@ -2361,10 +3154,78 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
|
|
|
2361
3154
|
return broker;
|
|
2362
3155
|
}
|
|
2363
3156
|
|
|
3157
|
+
/**
|
|
3158
|
+
* Ask the Windows broker to acknowledge, and resolve when it does.
|
|
3159
|
+
*
|
|
3160
|
+
* The child answers after a turn of its own loop, so a watch callback it had
|
|
3161
|
+
* already queued has run, and the ordered IPC channel puts every message it
|
|
3162
|
+
* sent before the reply ahead of the reply. That is the same proof an
|
|
3163
|
+
* in-process watcher gets from a macrotask turn, rather than the fixed wait
|
|
3164
|
+
* this replaces, which guessed at the crossing (samchon/ttsc#1272).
|
|
3165
|
+
*
|
|
3166
|
+
* A broker that never answers must not hold a delivery: the wait falls back to
|
|
3167
|
+
* the previous fixed grace, after which validation proceeds against whatever
|
|
3168
|
+
* the tracker knows, exactly as it did before.
|
|
3169
|
+
*/
|
|
3170
|
+
function drainWindowsProjectMutationBroker(
|
|
3171
|
+
broker: WindowsProjectMutationBroker,
|
|
3172
|
+
): Promise<void> {
|
|
3173
|
+
// Every tracker of a generation lives in one broker, so one acknowledgement
|
|
3174
|
+
// answers for all of them. Sharing the in-flight round-trip keeps a settle to
|
|
3175
|
+
// a single crossing.
|
|
3176
|
+
broker.draining ??= startWindowsProjectMutationDrain(broker).finally(() => {
|
|
3177
|
+
broker.draining = undefined;
|
|
3178
|
+
});
|
|
3179
|
+
return broker.draining;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
function startWindowsProjectMutationDrain(
|
|
3183
|
+
broker: WindowsProjectMutationBroker,
|
|
3184
|
+
): Promise<void> {
|
|
3185
|
+
return new Promise<void>((resolve) => {
|
|
3186
|
+
const id = broker.nextId++;
|
|
3187
|
+
let settled = false;
|
|
3188
|
+
const release = (): void => {
|
|
3189
|
+
if (settled) return;
|
|
3190
|
+
settled = true;
|
|
3191
|
+
clearTimeout(timer);
|
|
3192
|
+
broker.drains.delete(id);
|
|
3193
|
+
broker.pendingDrains -= 1;
|
|
3194
|
+
if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
|
|
3195
|
+
broker.child.unref();
|
|
3196
|
+
broker.child.channel?.unref();
|
|
3197
|
+
}
|
|
3198
|
+
resolve();
|
|
3199
|
+
};
|
|
3200
|
+
// Hold the channel open while the acknowledgement is outstanding. The
|
|
3201
|
+
// broker is unreferenced between requests so it never keeps a host alive,
|
|
3202
|
+
// and a reply is the only thing this promise can be resolved by: without
|
|
3203
|
+
// the reference the loop can empty while a delivery waits here, and the
|
|
3204
|
+
// process exits mid-build with nothing to report.
|
|
3205
|
+
broker.pendingDrains += 1;
|
|
3206
|
+
broker.child.ref();
|
|
3207
|
+
broker.child.channel?.ref();
|
|
3208
|
+
const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
|
|
3209
|
+
broker.drains.set(id, release);
|
|
3210
|
+
if (broker.child.send?.({ id, op: "drain" }) !== true) {
|
|
3211
|
+
release();
|
|
3212
|
+
}
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/** The wait a broker that stopped answering degrades to. */
|
|
3217
|
+
const WINDOWS_MUTATION_DRAIN_FALLBACK_MS = 10;
|
|
3218
|
+
|
|
2364
3219
|
const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
2365
3220
|
'const fs = require("node:fs");',
|
|
2366
3221
|
"const groups = new Map();",
|
|
2367
3222
|
'process.on("message", (message) => {',
|
|
3223
|
+
' if (message.op === "drain") {',
|
|
3224
|
+
// Two turns, not one: the first lets the loop poll for watch completions the
|
|
3225
|
+
// kernel had already queued, the second answers after their callbacks ran.
|
|
3226
|
+
" setImmediate(() => setImmediate(() => process.send?.({ drained: true, id: message.id })));",
|
|
3227
|
+
" return;",
|
|
3228
|
+
" }",
|
|
2368
3229
|
' if (message.op === "remove") {',
|
|
2369
3230
|
" close(message.id);",
|
|
2370
3231
|
" return;",
|
|
@@ -2398,20 +3259,63 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
|
|
|
2398
3259
|
"}",
|
|
2399
3260
|
].join("\n");
|
|
2400
3261
|
|
|
2401
|
-
/**
|
|
2402
|
-
|
|
2403
|
-
|
|
3262
|
+
/**
|
|
3263
|
+
* Report whether either live notification observed a membership event. This is
|
|
3264
|
+
* positive evidence that the generation is stale, so it outranks the question
|
|
3265
|
+
* of whether the notifications still work.
|
|
3266
|
+
*/
|
|
3267
|
+
function reportsMembershipChange(cached: TtscCachedProjectTransform): boolean {
|
|
2404
3268
|
return (
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
3269
|
+
cached.projectMutationTracker?.membershipChanged === true ||
|
|
3270
|
+
cached.hostInputMutationTracker?.membershipChanged === true ||
|
|
3271
|
+
cached.candidateMutationTracker?.membershipChanged === true
|
|
2408
3272
|
);
|
|
2409
3273
|
}
|
|
2410
3274
|
|
|
2411
3275
|
/**
|
|
2412
|
-
*
|
|
2413
|
-
*
|
|
2414
|
-
* the
|
|
3276
|
+
* Report whether the live notifications can still prove membership. A watcher
|
|
3277
|
+
* that failed to register, or that errored after the generation was produced,
|
|
3278
|
+
* proves nothing either way — it never proves the generation stale.
|
|
3279
|
+
*/
|
|
3280
|
+
function notificationsProveMembership(
|
|
3281
|
+
cached: TtscCachedProjectTransform,
|
|
3282
|
+
): boolean {
|
|
3283
|
+
for (const tracker of [
|
|
3284
|
+
cached.projectMutationTracker,
|
|
3285
|
+
cached.hostInputMutationTracker,
|
|
3286
|
+
]) {
|
|
3287
|
+
if (tracker === undefined || tracker.failed) {
|
|
3288
|
+
return false;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
// The candidate tracker is optional: a generation with no absent candidate
|
|
3292
|
+
// opens none, and one that declined to watch them left the per-delivery probe
|
|
3293
|
+
// in place. Only a tracker that exists and has failed withdraws the proof.
|
|
3294
|
+
return cached.candidateMutationTracker?.failed !== true;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
/**
|
|
3298
|
+
* Yield to the loop the tracker's own watcher callbacks are queued on.
|
|
3299
|
+
*
|
|
3300
|
+
* Two turns for the same reason the broker takes two: the first gives the loop
|
|
3301
|
+
* a poll phase for completions the kernel had already queued, the second runs
|
|
3302
|
+
* after the callbacks they produced.
|
|
3303
|
+
*/
|
|
3304
|
+
function drainOnNextTurn(): Promise<void> {
|
|
3305
|
+
return new Promise<void>((resolve) =>
|
|
3306
|
+
setImmediate(() => setImmediate(resolve)),
|
|
3307
|
+
);
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
/**
|
|
3311
|
+
* Settle every notification the trackers' watchers have already dispatched,
|
|
3312
|
+
* before persistent validation reads their verdict.
|
|
3313
|
+
*
|
|
3314
|
+
* A synchronous edit returns before its watch event is applied, so without this
|
|
3315
|
+
* a delivery could validate against a tracker that has not been told yet. Each
|
|
3316
|
+
* tracker drains through its own channel, which is a macrotask turn for a
|
|
3317
|
+
* watcher on this loop and an ordered round-trip for one inside the Windows
|
|
3318
|
+
* broker. Concurrent sibling deliveries share the barrier one of them started.
|
|
2415
3319
|
*/
|
|
2416
3320
|
async function settleProjectMutationEvents(
|
|
2417
3321
|
cached: TtscCachedProjectTransform,
|
|
@@ -2419,18 +3323,14 @@ async function settleProjectMutationEvents(
|
|
|
2419
3323
|
const trackers = [
|
|
2420
3324
|
cached.projectMutationTracker,
|
|
2421
3325
|
cached.hostInputMutationTracker,
|
|
3326
|
+
cached.candidateMutationTracker,
|
|
2422
3327
|
].filter(
|
|
2423
3328
|
(tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
|
|
2424
3329
|
);
|
|
2425
3330
|
await Promise.all(
|
|
2426
3331
|
trackers.map(async (tracker) => {
|
|
2427
|
-
tracker.settle ??=
|
|
2428
|
-
|
|
2429
|
-
tracker.settle = undefined;
|
|
2430
|
-
resolve();
|
|
2431
|
-
};
|
|
2432
|
-
if (process.platform === "win32") setTimeout(settled, 10);
|
|
2433
|
-
else setImmediate(settled);
|
|
3332
|
+
tracker.settle ??= (tracker.drain ?? drainOnNextTurn)().finally(() => {
|
|
3333
|
+
tracker.settle = undefined;
|
|
2434
3334
|
});
|
|
2435
3335
|
await tracker.settle;
|
|
2436
3336
|
}),
|
|
@@ -2511,29 +3411,74 @@ export function collectExternalInputHashes(
|
|
|
2511
3411
|
if (identity in hashes) {
|
|
2512
3412
|
continue;
|
|
2513
3413
|
}
|
|
2514
|
-
hashes[identity] =
|
|
3414
|
+
hashes[identity] =
|
|
3415
|
+
hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE;
|
|
2515
3416
|
}
|
|
2516
3417
|
return hashes;
|
|
2517
3418
|
}
|
|
2518
3419
|
|
|
2519
|
-
/**
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
3420
|
+
/**
|
|
3421
|
+
* Re-check a cached mixed graph/dependency input set with its owning codec,
|
|
3422
|
+
* reusing the recorded hash of any input whose metadata signature still holds
|
|
3423
|
+
* and reporting the signatures this pass captured.
|
|
3424
|
+
*
|
|
3425
|
+
* The caller adopts those signatures only once every input is proven unchanged,
|
|
3426
|
+
* so a signature never outlives the content comparison that justified it.
|
|
3427
|
+
*/
|
|
3428
|
+
function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
|
|
3429
|
+
matches: boolean;
|
|
3430
|
+
signatures: Record<string, string>;
|
|
3431
|
+
} {
|
|
3432
|
+
const signatures: Record<string, string> = {};
|
|
3433
|
+
let matches = true;
|
|
2524
3434
|
const state = envelopeDerivation(cached);
|
|
2525
3435
|
const graphRealpaths = cached.externalInputRealpaths ?? {};
|
|
2526
3436
|
const filesystem = resultFilesystem(cached.result);
|
|
3437
|
+
const recordedHashes = cached.externalInputHashes ?? {};
|
|
3438
|
+
const recordedSignatures = cached.externalInputSignatures ?? {};
|
|
3439
|
+
// Compare each spelling against the recorded state under its own name. Two
|
|
3440
|
+
// spellings share one identity exactly when they selected one physical file
|
|
3441
|
+
// at generation time, which is the state a retarget ends, so neither may
|
|
3442
|
+
// answer for the other: skipping the second would leave a retargeted alias
|
|
3443
|
+
// unvalidated, and comparing them only through a shared key would let
|
|
3444
|
+
// whichever came first decide.
|
|
2527
3445
|
for (const file of cached.externalInputPaths ??
|
|
2528
3446
|
Object.keys(cached.externalInputHashes ?? {})) {
|
|
2529
3447
|
const identity = derivationIdentity(state, file);
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
3448
|
+
const spelling = path.resolve(file);
|
|
3449
|
+
// Reuse the recorded hash of an out-of-walk input whose signature still
|
|
3450
|
+
// equals the one captured around the read that proved it. The signature is
|
|
3451
|
+
// keyed by this exact spelling, so an alias of the same physical file
|
|
3452
|
+
// cannot answer for it.
|
|
3453
|
+
const before = inputMetadataEvidence(file, filesystem);
|
|
3454
|
+
if (
|
|
3455
|
+
before !== undefined &&
|
|
3456
|
+
Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
|
|
3457
|
+
Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
|
|
3458
|
+
before.signature === recordedSignatures[spelling]
|
|
3459
|
+
) {
|
|
3460
|
+
continue;
|
|
3461
|
+
}
|
|
3462
|
+
const hash = Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
|
|
3463
|
+
? graphInputStateHash(file, filesystem)
|
|
3464
|
+
: hostInputStateHash(file, filesystem);
|
|
3465
|
+
const after = inputMetadataSignature(file, filesystem);
|
|
3466
|
+
if (
|
|
3467
|
+
!Object.prototype.hasOwnProperty.call(recordedHashes, identity) ||
|
|
3468
|
+
recordedHashes[identity] !== (hash ?? MISSING_INPUT_STATE)
|
|
3469
|
+
) {
|
|
3470
|
+
matches = false;
|
|
3471
|
+
}
|
|
3472
|
+
if (
|
|
3473
|
+
hash !== null &&
|
|
3474
|
+
after !== undefined &&
|
|
3475
|
+
before?.signature === after &&
|
|
3476
|
+
before.separable
|
|
3477
|
+
) {
|
|
3478
|
+
signatures[spelling] = after;
|
|
3479
|
+
}
|
|
2535
3480
|
}
|
|
2536
|
-
return
|
|
3481
|
+
return { matches, signatures };
|
|
2537
3482
|
}
|
|
2538
3483
|
|
|
2539
3484
|
/**
|
|
@@ -2641,6 +3586,175 @@ function selectExternalInputPaths(props: {
|
|
|
2641
3586
|
return output;
|
|
2642
3587
|
}
|
|
2643
3588
|
|
|
3589
|
+
/**
|
|
3590
|
+
* The generation's resolution candidates that do not exist, so its host-input
|
|
3591
|
+
* watcher can be told to announce their creation.
|
|
3592
|
+
*
|
|
3593
|
+
* A missing candidate is the one input class no proof can be memoized for: its
|
|
3594
|
+
* metadata cannot be read, so the signature shortcut that stands in for every
|
|
3595
|
+
* other input's comparison never applies, and every delivery that reaches it
|
|
3596
|
+
* probes the filesystem again. Watching the name instead turns that repeated
|
|
3597
|
+
* probe into one notification for the whole generation, using the same channel
|
|
3598
|
+
* and the same failure rules the universal inputs already run under
|
|
3599
|
+
* (samchon/ttsc#1261).
|
|
3600
|
+
*
|
|
3601
|
+
* Only absent candidates qualify. One that exists is validated by content and
|
|
3602
|
+
* physical identity like any other input, and adding it here would replace the
|
|
3603
|
+
* generation for a change that cannot affect a resolution the compiler already
|
|
3604
|
+
* declined to take.
|
|
3605
|
+
*/
|
|
3606
|
+
function selectNotifiableAbsentInputs(props: {
|
|
3607
|
+
filesystem: TtscTransformFilesystemOperations;
|
|
3608
|
+
projectRoot: string;
|
|
3609
|
+
result: ITtscCompilerTransformation;
|
|
3610
|
+
temporaryTsconfig?: string;
|
|
3611
|
+
}): { candidates: string[]; watched: string[] } {
|
|
3612
|
+
const empty = { candidates: [], watched: [] };
|
|
3613
|
+
if (props.result.type === "exception") {
|
|
3614
|
+
return empty;
|
|
3615
|
+
}
|
|
3616
|
+
const graph = props.result.graph;
|
|
3617
|
+
if (graph === undefined) {
|
|
3618
|
+
return empty;
|
|
3619
|
+
}
|
|
3620
|
+
const identities = createHostPathIdentityContext(props.filesystem);
|
|
3621
|
+
const excluded =
|
|
3622
|
+
props.temporaryTsconfig === undefined
|
|
3623
|
+
? undefined
|
|
3624
|
+
: pathIdentityKey(props.temporaryTsconfig, identities);
|
|
3625
|
+
const resolvedProjectRoot = path.resolve(props.projectRoot);
|
|
3626
|
+
const output: string[] = [];
|
|
3627
|
+
const watched: string[] = [];
|
|
3628
|
+
const directories = new Set<string>();
|
|
3629
|
+
// Two namespaces, deliberately not one set: candidates are the paths a
|
|
3630
|
+
// delivery may stop probing, while the chain holds the directories that carry
|
|
3631
|
+
// them. Sharing a set would let one silently answer for the other.
|
|
3632
|
+
const seen = new Set<string>();
|
|
3633
|
+
const chain = new Set<string>();
|
|
3634
|
+
for (const candidates of Object.values(graph.candidates ?? {})) {
|
|
3635
|
+
if (!Array.isArray(candidates)) {
|
|
3636
|
+
continue;
|
|
3637
|
+
}
|
|
3638
|
+
for (const candidate of candidates) {
|
|
3639
|
+
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
3640
|
+
continue;
|
|
3641
|
+
}
|
|
3642
|
+
const absolute = path.resolve(props.projectRoot, candidate);
|
|
3643
|
+
const spelling = path.resolve(absolute);
|
|
3644
|
+
if (
|
|
3645
|
+
seen.has(spelling) ||
|
|
3646
|
+
(excluded !== undefined &&
|
|
3647
|
+
pathIdentityKey(absolute, identities) === excluded) ||
|
|
3648
|
+
props.filesystem.exists(absolute)
|
|
3649
|
+
) {
|
|
3650
|
+
continue;
|
|
3651
|
+
}
|
|
3652
|
+
seen.add(spelling);
|
|
3653
|
+
// Collect the components of the lexical path, by the name each carries in
|
|
3654
|
+
// its own parent. The watcher a missing path opens follows the spelling
|
|
3655
|
+
// to a physical directory, so retargeting a link along the way moves the
|
|
3656
|
+
// answer without touching what is watched: in a pnpm layout
|
|
3657
|
+
// `node_modules/<pkg>` is exactly such a link, and reinstalling it makes
|
|
3658
|
+
// a candidate appear behind a watch still looking at the old store
|
|
3659
|
+
// directory. Watching `<pkg>` inside `node_modules` is what reports that.
|
|
3660
|
+
//
|
|
3661
|
+
// The collection stops at the project root, and a spelling that leaves
|
|
3662
|
+
// the project subtree before reaching it is not claimed at all. Above
|
|
3663
|
+
// that line the components are the machine's own layout rather than the
|
|
3664
|
+
// project's, and watching those entries costs a generation whenever an
|
|
3665
|
+
// unrelated process touches anything inside them; a candidate whose path
|
|
3666
|
+
// runs outside the subtree therefore keeps the probe it always had rather
|
|
3667
|
+
// than a proof this cannot complete.
|
|
3668
|
+
const components: string[] = [];
|
|
3669
|
+
let reachedProject = false;
|
|
3670
|
+
for (
|
|
3671
|
+
let child = path.dirname(spelling), parent = path.dirname(child);
|
|
3672
|
+
parent !== child;
|
|
3673
|
+
child = parent, parent = path.dirname(child)
|
|
3674
|
+
) {
|
|
3675
|
+
if (insideProject(child, resolvedProjectRoot)) {
|
|
3676
|
+
components.push(child);
|
|
3677
|
+
continue;
|
|
3678
|
+
}
|
|
3679
|
+
// Compared through `path.relative` rather than by string, so a
|
|
3680
|
+
// spelling that differs from the root only in case still counts as
|
|
3681
|
+
// having arrived where the platform says it has.
|
|
3682
|
+
reachedProject = path.relative(child, resolvedProjectRoot).length === 0;
|
|
3683
|
+
break;
|
|
3684
|
+
}
|
|
3685
|
+
if (!reachedProject) {
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
output.push(absolute);
|
|
3689
|
+
watched.push(absolute);
|
|
3690
|
+
for (const component of components) {
|
|
3691
|
+
if (chain.has(component)) break;
|
|
3692
|
+
chain.add(component);
|
|
3693
|
+
watched.push(component);
|
|
3694
|
+
directories.add(path.dirname(component));
|
|
3695
|
+
}
|
|
3696
|
+
directories.add(path.dirname(spelling));
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
if (directories.size > NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT) {
|
|
3700
|
+
// Past this many distinct directories the watch registration is the more
|
|
3701
|
+
// expensive half: a host that runs out of watch descriptors fails the
|
|
3702
|
+
// tracker, and a failed tracker sends every delivery to complete-snapshot
|
|
3703
|
+
// validation, which re-hashes the whole project. Declining to watch leaves
|
|
3704
|
+
// the per-delivery probe in place, which is what this replaces and is far
|
|
3705
|
+
// cheaper than that.
|
|
3706
|
+
return empty;
|
|
3707
|
+
}
|
|
3708
|
+
output.sort();
|
|
3709
|
+
watched.sort();
|
|
3710
|
+
return { candidates: output, watched };
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/**
|
|
3714
|
+
* Report whether a directory lies strictly below the project root.
|
|
3715
|
+
*
|
|
3716
|
+
* The boundary of what a generation may watch on a candidate's behalf: what the
|
|
3717
|
+
* project contains is its own layout, while the project root and everything
|
|
3718
|
+
* above it belongs to the machine, which nobody retargets and which changes for
|
|
3719
|
+
* reasons no generation should hear about.
|
|
3720
|
+
*/
|
|
3721
|
+
function insideProject(directory: string, projectRoot: string): boolean {
|
|
3722
|
+
const relative = path.relative(
|
|
3723
|
+
path.resolve(projectRoot),
|
|
3724
|
+
path.resolve(directory),
|
|
3725
|
+
);
|
|
3726
|
+
// An empty result is the platform saying the two name the same directory,
|
|
3727
|
+
// which it answers for spellings that differ only in case where the path
|
|
3728
|
+
// module folds case. The root itself is not below itself, so the walk stops
|
|
3729
|
+
// there rather than one level past it.
|
|
3730
|
+
if (relative.length === 0) {
|
|
3731
|
+
return false;
|
|
3732
|
+
}
|
|
3733
|
+
// `..` alone and `../` climb out, and an absolute answer means another drive
|
|
3734
|
+
// or share entirely; a directory literally named `..x` does neither, which a
|
|
3735
|
+
// plain prefix test would misread. The project walk's own containment check
|
|
3736
|
+
// spells it the same way.
|
|
3737
|
+
return (
|
|
3738
|
+
relative !== ".." &&
|
|
3739
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
3740
|
+
!path.isAbsolute(relative)
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
/**
|
|
3745
|
+
* Distinct directories the absent-candidate watch may open before it declines.
|
|
3746
|
+
*
|
|
3747
|
+
* Sized well below the inotify per-user default so a project's own walk keeps
|
|
3748
|
+
* its share, and far above the distinct `node_modules` package directories a
|
|
3749
|
+
* real dependency graph produces.
|
|
3750
|
+
*
|
|
3751
|
+
* Counted lexically, over the parents of every watched name. A missing subtree
|
|
3752
|
+
* collapses onto the one watch its nearest existing ancestor carries, so the
|
|
3753
|
+
* count is an upper bound on the watches actually opened rather than their
|
|
3754
|
+
* number; the bound stays sound and is merely not tight.
|
|
3755
|
+
*/
|
|
3756
|
+
const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
|
|
3757
|
+
|
|
2644
3758
|
function isIgnoredProjectDirectory(name: string): boolean {
|
|
2645
3759
|
return (
|
|
2646
3760
|
name === ".git" ||
|
|
@@ -2661,10 +3775,32 @@ function isIgnoredProjectDirectory(name: string): boolean {
|
|
|
2661
3775
|
);
|
|
2662
3776
|
}
|
|
2663
3777
|
|
|
3778
|
+
/**
|
|
3779
|
+
* Compare two project-walk snapshots.
|
|
3780
|
+
*
|
|
3781
|
+
* `keys` narrows the comparison to the generation's declared inputs. The walk
|
|
3782
|
+
* hashes every file under the project root, but only a file the compile
|
|
3783
|
+
* actually consumed can change an output, and a project root is a working
|
|
3784
|
+
* directory: a framework's generated types, a log, a coverage report, or a test
|
|
3785
|
+
* artifact appears and changes there while a compile runs. Comparing those
|
|
3786
|
+
* would declare the generation incoherent and cost a whole-project recompile
|
|
3787
|
+
* for every remaining module (samchon/ttsc#1246). Files entering or leaving the
|
|
3788
|
+
* project remain covered by the directory-membership snapshot, which is the one
|
|
3789
|
+
* thing a content comparison cannot see. An envelope that declares no input set
|
|
3790
|
+
* (a graph-free legacy host) passes `undefined` and keeps the whole-walk
|
|
3791
|
+
* comparison.
|
|
3792
|
+
*/
|
|
2664
3793
|
function sameHashes(
|
|
2665
3794
|
left: Record<string, string>,
|
|
2666
3795
|
right: Record<string, string>,
|
|
3796
|
+
keys?: ReadonlySet<string>,
|
|
2667
3797
|
): boolean {
|
|
3798
|
+
if (keys !== undefined) {
|
|
3799
|
+
for (const key of keys) {
|
|
3800
|
+
if (left[key] !== right[key]) return false;
|
|
3801
|
+
}
|
|
3802
|
+
return true;
|
|
3803
|
+
}
|
|
2668
3804
|
const leftKeys = Object.keys(left);
|
|
2669
3805
|
const rightKeys = Object.keys(right);
|
|
2670
3806
|
if (leftKeys.length !== rightKeys.length) {
|
|
@@ -2673,6 +3809,150 @@ function sameHashes(
|
|
|
2673
3809
|
return leftKeys.every((key) => right[key] === left[key]);
|
|
2674
3810
|
}
|
|
2675
3811
|
|
|
3812
|
+
/**
|
|
3813
|
+
* Whether a project-walk snapshot is coherent for the inputs that matter.
|
|
3814
|
+
*
|
|
3815
|
+
* The walk reads every file under the project root, so a file nothing compiled
|
|
3816
|
+
* (a log being appended, a coverage report being written, a generated artifact
|
|
3817
|
+
* being replaced) can fail its own read sandwich while every input holds still.
|
|
3818
|
+
* That is not evidence about the generation, and treating it as such costs a
|
|
3819
|
+
* whole-project recompile per delivered module. A walk that could not enumerate
|
|
3820
|
+
* a directory, or a file-level failure this snapshot could not attribute to a
|
|
3821
|
+
* key, still taints everything: neither can be shown to leave the inputs
|
|
3822
|
+
* alone.
|
|
3823
|
+
*/
|
|
3824
|
+
function walkSnapshotComplete(
|
|
3825
|
+
snapshot: {
|
|
3826
|
+
complete: boolean;
|
|
3827
|
+
directoryComplete: boolean;
|
|
3828
|
+
unstableFiles: Set<string>;
|
|
3829
|
+
},
|
|
3830
|
+
declared: ReadonlySet<string> | undefined,
|
|
3831
|
+
): boolean {
|
|
3832
|
+
if (declared === undefined) {
|
|
3833
|
+
return snapshot.complete;
|
|
3834
|
+
}
|
|
3835
|
+
if (!snapshot.directoryComplete) {
|
|
3836
|
+
return false;
|
|
3837
|
+
}
|
|
3838
|
+
for (const key of snapshot.unstableFiles) {
|
|
3839
|
+
if (declared.has(key)) return false;
|
|
3840
|
+
}
|
|
3841
|
+
return true;
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
/** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
|
|
3845
|
+
function declaredProjectInputKeys(
|
|
3846
|
+
state: TtscEnvelopeDerivation,
|
|
3847
|
+
cached: TtscCachedProjectTransform,
|
|
3848
|
+
): Set<string> | undefined {
|
|
3849
|
+
if (state.declaredInputKeysBuilt !== true) {
|
|
3850
|
+
state.declaredInputKeys = selectDeclaredProjectInputKeys({
|
|
3851
|
+
identities: state.identityContext,
|
|
3852
|
+
projectRoot: cached.projectRoot,
|
|
3853
|
+
result: cached.result,
|
|
3854
|
+
});
|
|
3855
|
+
state.declaredInputKeysBuilt = true;
|
|
3856
|
+
}
|
|
3857
|
+
return state.declaredInputKeys;
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
/**
|
|
3861
|
+
* Project-walk keys of every input the envelope declares: the reference graph's
|
|
3862
|
+
* edge endpoints, globals, config chain, and resolution candidates, plus the
|
|
3863
|
+
* universal host inputs. Returns `undefined` for an envelope with no graph,
|
|
3864
|
+
* which declares no input set and therefore keeps whole-walk comparison.
|
|
3865
|
+
*/
|
|
3866
|
+
function selectDeclaredProjectInputKeys(props: {
|
|
3867
|
+
identities: FilesystemPathIdentityContext;
|
|
3868
|
+
projectRoot: string;
|
|
3869
|
+
result: ITtscCompilerTransformation;
|
|
3870
|
+
}): Set<string> | undefined {
|
|
3871
|
+
if (props.result.type === "exception" || props.result.graph === undefined) {
|
|
3872
|
+
return undefined;
|
|
3873
|
+
}
|
|
3874
|
+
const graph = props.result.graph;
|
|
3875
|
+
const keys = new Set<string>();
|
|
3876
|
+
const add = (entry: unknown): void => {
|
|
3877
|
+
if (typeof entry !== "string" || entry.length === 0) return;
|
|
3878
|
+
keys.add(
|
|
3879
|
+
toProjectKey(
|
|
3880
|
+
props.projectRoot,
|
|
3881
|
+
path.resolve(props.projectRoot, entry),
|
|
3882
|
+
props.identities,
|
|
3883
|
+
),
|
|
3884
|
+
);
|
|
3885
|
+
};
|
|
3886
|
+
for (const [source, targets] of Object.entries(graph.edges ?? {})) {
|
|
3887
|
+
add(source);
|
|
3888
|
+
if (Array.isArray(targets)) for (const target of targets) add(target);
|
|
3889
|
+
}
|
|
3890
|
+
if (Array.isArray(graph.globals))
|
|
3891
|
+
for (const input of graph.globals) add(input);
|
|
3892
|
+
if (Array.isArray(graph.configs))
|
|
3893
|
+
for (const input of graph.configs) add(input);
|
|
3894
|
+
for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
|
|
3895
|
+
add(source);
|
|
3896
|
+
if (Array.isArray(candidates)) for (const entry of candidates) add(entry);
|
|
3897
|
+
}
|
|
3898
|
+
if (Array.isArray(props.result.hostInputs))
|
|
3899
|
+
for (const input of props.result.hostInputs) add(input);
|
|
3900
|
+
// Plugin-reported dependencies are inputs the graph never sees: a utility
|
|
3901
|
+
// plugin's own config file is consulted by the plugin, not by the compiler.
|
|
3902
|
+
for (const reported of Object.values(props.result.dependencies ?? {})) {
|
|
3903
|
+
if (Array.isArray(reported)) for (const input of reported) add(input);
|
|
3904
|
+
}
|
|
3905
|
+
return keys;
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
/**
|
|
3909
|
+
* Project roots already told they cannot reuse a compile, so a build reports
|
|
3910
|
+
* the condition once instead of once per module.
|
|
3911
|
+
*/
|
|
3912
|
+
const REPORTED_UNREUSABLE_GENERATIONS = new Set<string>();
|
|
3913
|
+
|
|
3914
|
+
/**
|
|
3915
|
+
* Report, once per project root, that a generation cannot be reused.
|
|
3916
|
+
*
|
|
3917
|
+
* Every module of the build then recompiles the whole project, so the condition
|
|
3918
|
+
* is the difference between one compile and one compile per module. It stayed
|
|
3919
|
+
* invisible for the whole life of samchon/ttsc#970: consumers saw only a build
|
|
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.
|
|
3923
|
+
*/
|
|
3924
|
+
function reportUnreusableGeneration(
|
|
3925
|
+
cached: TtscCachedProjectTransform,
|
|
3926
|
+
evidence: {
|
|
3927
|
+
externalInputs: boolean;
|
|
3928
|
+
graphProofs: boolean;
|
|
3929
|
+
universalInputs: boolean;
|
|
3930
|
+
walkStable: boolean;
|
|
3931
|
+
},
|
|
3932
|
+
): void {
|
|
3933
|
+
const missing = [
|
|
3934
|
+
...(evidence.walkStable ? [] : ["a stable project snapshot"]),
|
|
3935
|
+
...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
|
|
3936
|
+
...(evidence.externalInputs
|
|
3937
|
+
? []
|
|
3938
|
+
: ["a complete out-of-walk input snapshot"]),
|
|
3939
|
+
...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
|
|
3940
|
+
];
|
|
3941
|
+
const key = `${cached.projectRoot}\0${missing.join(",")}`;
|
|
3942
|
+
if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3945
|
+
REPORTED_UNREUSABLE_GENERATIONS.add(key);
|
|
3946
|
+
process.stderr.write(
|
|
3947
|
+
`ttsc: the transform cache cannot reuse this project's compile, so every ` +
|
|
3948
|
+
`module recompiles the whole project.\n` +
|
|
3949
|
+
` project: ${cached.projectRoot}\n` +
|
|
3950
|
+
` missing: ${missing.join("; ")}\n` +
|
|
3951
|
+
` Please report this at https://github.com/samchon/ttsc/issues with ` +
|
|
3952
|
+
`this message.\n`,
|
|
3953
|
+
);
|
|
3954
|
+
}
|
|
3955
|
+
|
|
2676
3956
|
function hashText(input: string | Buffer): string {
|
|
2677
3957
|
return crypto.createHash("sha256").update(input).digest("hex");
|
|
2678
3958
|
}
|
|
@@ -2695,6 +3975,9 @@ async function transformProject(props: {
|
|
|
2695
3975
|
let tracker: TtscProjectMutationTracker | undefined;
|
|
2696
3976
|
let retainTracker = false;
|
|
2697
3977
|
let hostInputTracker: TtscProjectMutationTracker | undefined;
|
|
3978
|
+
let candidateTracker: TtscProjectMutationTracker | undefined;
|
|
3979
|
+
let retainHostInputTracker = false;
|
|
3980
|
+
let retainCandidateTracker = false;
|
|
2698
3981
|
try {
|
|
2699
3982
|
const configured = createTransformTsconfig(props, scratchDirectory);
|
|
2700
3983
|
const temporaryTsconfig =
|
|
@@ -2728,18 +4011,58 @@ async function transformProject(props: {
|
|
|
2728
4011
|
}).transform(),
|
|
2729
4012
|
);
|
|
2730
4013
|
TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
|
|
4014
|
+
// Mint the generation's clock reference after the compile and before any
|
|
4015
|
+
// signature-recording read below, so every input written before the
|
|
4016
|
+
// compile sits in a provably finished tick when its signature is captured.
|
|
4017
|
+
mintFilesystemClockReference(scratchDirectory, props.filesystem);
|
|
2731
4018
|
const persistentHostInputs = selectPersistentHostInputs({
|
|
2732
4019
|
filesystem: props.filesystem,
|
|
2733
4020
|
projectRoot,
|
|
2734
4021
|
result,
|
|
2735
4022
|
temporaryTsconfig,
|
|
2736
4023
|
});
|
|
4024
|
+
// The generation's absent resolution candidates, which get a watcher of
|
|
4025
|
+
// their own below; watching one is what lets a delivery stop probing it
|
|
4026
|
+
// (samchon/ttsc#1261). The validation manifest stays built from the
|
|
4027
|
+
// universal inputs alone, so nothing else about a candidate changes.
|
|
4028
|
+
//
|
|
4029
|
+
// Derived only where a tracker could carry it: a build-scoped adapter opens
|
|
4030
|
+
// no watcher, so probing every candidate's existence here would be work
|
|
4031
|
+
// whose answer nothing can read.
|
|
4032
|
+
const notifiableAbsence = props.trackProjectMembership
|
|
4033
|
+
? selectNotifiableAbsentInputs({
|
|
4034
|
+
filesystem: props.filesystem,
|
|
4035
|
+
projectRoot,
|
|
4036
|
+
result,
|
|
4037
|
+
temporaryTsconfig,
|
|
4038
|
+
})
|
|
4039
|
+
: { candidates: [], watched: [] };
|
|
2737
4040
|
hostInputTracker = props.trackProjectMembership
|
|
2738
4041
|
? await createHostInputMutationTracker(
|
|
2739
4042
|
persistentHostInputs,
|
|
2740
4043
|
props.filesystem,
|
|
4044
|
+
// A universal input never reaches the per-input loop that consults a
|
|
4045
|
+
// coverage claim: an absent one is proven by its directory listing
|
|
4046
|
+
// instead, which re-resolves the spelling every delivery.
|
|
4047
|
+
new Set(),
|
|
2741
4048
|
)
|
|
2742
4049
|
: undefined;
|
|
4050
|
+
// The candidates and the directories carrying them get their own tracker,
|
|
4051
|
+
// listening for renames alone. Every event that can make one of these
|
|
4052
|
+
// paths appear is a rename — the file itself, or a component of the path
|
|
4053
|
+
// being created, replaced, or retargeted — so nothing is given up, while a
|
|
4054
|
+
// backend that reports a write below a directory as a change to that
|
|
4055
|
+
// directory's entry (Windows does) would otherwise replace the generation
|
|
4056
|
+
// every time a bundler wrote inside `node_modules`.
|
|
4057
|
+
candidateTracker =
|
|
4058
|
+
notifiableAbsence.watched.length !== 0
|
|
4059
|
+
? await createHostInputMutationTracker(
|
|
4060
|
+
notifiableAbsence.watched,
|
|
4061
|
+
props.filesystem,
|
|
4062
|
+
new Set(notifiableAbsence.candidates),
|
|
4063
|
+
"rename",
|
|
4064
|
+
)
|
|
4065
|
+
: undefined;
|
|
2743
4066
|
const externalInputPaths = selectExternalInputPaths({
|
|
2744
4067
|
filesystem: props.filesystem,
|
|
2745
4068
|
projectRoot,
|
|
@@ -2751,24 +4074,47 @@ async function transformProject(props: {
|
|
|
2751
4074
|
identities,
|
|
2752
4075
|
props.filesystem,
|
|
2753
4076
|
);
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
4077
|
+
// Whether the recorded snapshot describes one coherent state of the
|
|
4078
|
+
// project. A membership event during the compile taints it exactly like an
|
|
4079
|
+
// unstable walk pair; whether notifications can be *opened* is a separate
|
|
4080
|
+
// fact, tracked below, because a generation with no watcher is still
|
|
4081
|
+
// provable from its own recorded state.
|
|
4082
|
+
const declaredInputs = selectDeclaredProjectInputKeys({
|
|
4083
|
+
identities,
|
|
4084
|
+
projectRoot,
|
|
4085
|
+
result,
|
|
4086
|
+
});
|
|
4087
|
+
const walkStable =
|
|
4088
|
+
walkSnapshotComplete(before, declaredInputs) &&
|
|
4089
|
+
walkSnapshotComplete(inputSnapshot, declaredInputs) &&
|
|
4090
|
+
sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
|
|
4091
|
+
sameHashes(
|
|
4092
|
+
before.fileSignatures,
|
|
4093
|
+
inputSnapshot.fileSignatures,
|
|
4094
|
+
declaredInputs,
|
|
4095
|
+
) &&
|
|
2759
4096
|
sameProjectDirectories(
|
|
2760
4097
|
before.projectDirectories,
|
|
2761
4098
|
inputSnapshot.projectDirectories,
|
|
2762
4099
|
) &&
|
|
2763
|
-
tracker?.failed !== true &&
|
|
2764
4100
|
tracker?.membershipChanged !== true &&
|
|
4101
|
+
hostInputTracker?.membershipChanged !== true &&
|
|
4102
|
+
candidateTracker?.membershipChanged !== true;
|
|
4103
|
+
const notificationsAvailable =
|
|
4104
|
+
tracker?.failed !== true &&
|
|
2765
4105
|
hostInputTracker?.failed !== true &&
|
|
2766
|
-
|
|
4106
|
+
candidateTracker?.failed !== true;
|
|
2767
4107
|
// Overlay the in-memory source only after proving the two on-disk snapshots
|
|
2768
4108
|
// stable; an unsaved editor buffer must not look like a compile-time race.
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
4109
|
+
const currentFileKey = toProjectKey(
|
|
4110
|
+
projectRoot,
|
|
4111
|
+
props.currentFile,
|
|
4112
|
+
identities,
|
|
4113
|
+
);
|
|
4114
|
+
inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
|
|
4115
|
+
// That overlay makes this one key the only recorded hash a disk signature
|
|
4116
|
+
// cannot stand for: the bytes it names came from the bundler, not the file.
|
|
4117
|
+
delete inputSnapshot.provenSignatures[currentFileKey];
|
|
2772
4118
|
const cached: TtscCachedProjectTransform = {
|
|
2773
4119
|
// Capture the out-of-walk input hashes while the generation is fresh so
|
|
2774
4120
|
// cache validation can re-check them; computed before dispose so the
|
|
@@ -2777,6 +4123,7 @@ async function transformProject(props: {
|
|
|
2777
4123
|
externalInputRealpaths: {},
|
|
2778
4124
|
externalInputPaths,
|
|
2779
4125
|
inputHashes: inputSnapshot.hashes,
|
|
4126
|
+
inputSignatures: inputSnapshot.provenSignatures,
|
|
2780
4127
|
projectDirectories: inputSnapshot.projectDirectories,
|
|
2781
4128
|
projectSnapshotComplete: false,
|
|
2782
4129
|
projectRoot,
|
|
@@ -2793,23 +4140,51 @@ async function transformProject(props: {
|
|
|
2793
4140
|
);
|
|
2794
4141
|
cached.externalInputHashes = externalInputSnapshot.hashes;
|
|
2795
4142
|
cached.externalInputRealpaths = externalInputSnapshot.realpaths;
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
4143
|
+
cached.externalInputSignatures = externalInputSnapshot.signatures;
|
|
4144
|
+
// Evaluate every half, rather than short-circuiting, so a generation that
|
|
4145
|
+
// cannot be reused can say which evidence it lacked. The extra work runs
|
|
4146
|
+
// only on the failing path, where the alternative is recompiling the whole
|
|
4147
|
+
// project for every remaining module.
|
|
4148
|
+
const graphProofs = matchesCompilerGraphInputProofs(cached);
|
|
4149
|
+
const universalInputs =
|
|
2800
4150
|
captureUniversalHostInputValidation(cached, props.currentFile) !==
|
|
2801
|
-
|
|
4151
|
+
undefined;
|
|
4152
|
+
const stableProjectSnapshot =
|
|
4153
|
+
walkStable &&
|
|
4154
|
+
graphProofs &&
|
|
4155
|
+
externalInputSnapshot.complete &&
|
|
4156
|
+
universalInputs;
|
|
4157
|
+
// Only a caching host loses anything here: without a cache every delivery
|
|
4158
|
+
// compiles by design, so an unprovable generation costs it nothing.
|
|
4159
|
+
if (!stableProjectSnapshot && props.trackProjectMembership) {
|
|
4160
|
+
reportUnreusableGeneration(cached, {
|
|
4161
|
+
externalInputs: externalInputSnapshot.complete,
|
|
4162
|
+
graphProofs,
|
|
4163
|
+
universalInputs,
|
|
4164
|
+
walkStable,
|
|
4165
|
+
});
|
|
4166
|
+
}
|
|
2802
4167
|
cached.projectSnapshotComplete = stableProjectSnapshot;
|
|
2803
|
-
|
|
4168
|
+
// Attach notifications only while they can actually prove membership. A
|
|
4169
|
+
// generation that could not open its watchers keeps its recorded snapshot
|
|
4170
|
+
// and validates through it, rather than losing the cache entirely.
|
|
4171
|
+
const notifying = stableProjectSnapshot && notificationsAvailable;
|
|
4172
|
+
if (notifying && tracker !== undefined) {
|
|
2804
4173
|
cached.projectMutationTracker = tracker;
|
|
2805
4174
|
}
|
|
2806
|
-
if (
|
|
4175
|
+
if (notifying && hostInputTracker !== undefined) {
|
|
2807
4176
|
cached.hostInputMutationTracker = hostInputTracker;
|
|
2808
4177
|
}
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
4178
|
+
if (notifying && candidateTracker !== undefined) {
|
|
4179
|
+
cached.candidateMutationTracker = candidateTracker;
|
|
4180
|
+
}
|
|
4181
|
+
// Every tracker the generation published is retained, and every tracker it
|
|
4182
|
+
// did not is closed below. Naming only two of the three would close a
|
|
4183
|
+
// published candidate tracker the moment either of the others was absent,
|
|
4184
|
+
// and that is the one tracker whose silence is read as evidence.
|
|
4185
|
+
retainTracker = notifying && tracker !== undefined;
|
|
4186
|
+
retainHostInputTracker = notifying && hostInputTracker !== undefined;
|
|
4187
|
+
retainCandidateTracker = notifying && candidateTracker !== undefined;
|
|
2813
4188
|
return cached;
|
|
2814
4189
|
} finally {
|
|
2815
4190
|
try {
|
|
@@ -2818,11 +4193,17 @@ async function transformProject(props: {
|
|
|
2818
4193
|
}
|
|
2819
4194
|
} finally {
|
|
2820
4195
|
try {
|
|
2821
|
-
if (!
|
|
4196
|
+
if (!retainHostInputTracker && hostInputTracker !== undefined) {
|
|
2822
4197
|
hostInputTracker.close();
|
|
2823
4198
|
}
|
|
2824
4199
|
} finally {
|
|
2825
|
-
|
|
4200
|
+
try {
|
|
4201
|
+
if (!retainCandidateTracker && candidateTracker !== undefined) {
|
|
4202
|
+
candidateTracker.close();
|
|
4203
|
+
}
|
|
4204
|
+
} finally {
|
|
4205
|
+
fs.rmSync(scratchDirectory, { force: true, recursive: true });
|
|
4206
|
+
}
|
|
2826
4207
|
}
|
|
2827
4208
|
}
|
|
2828
4209
|
}
|