@ttsc/lint 0.26.2 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +5 -1
- package/lib/index.js +458 -112
- package/lib/index.js.map +1 -1
- package/linthost/compile.go +147 -3
- package/linthost/config.go +230 -25
- package/linthost/dispatch.go +3 -1
- package/linthost/graph_nodes.go +205 -0
- package/linthost/hints.go +1 -1
- package/linthost/project_inputs.go +15 -4
- package/linthost/serve.go +19 -0
- package/package.json +2 -2
- package/rule/graph.go +150 -0
- package/src/index.ts +511 -115
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ type TtscPluginContributor = {
|
|
|
26
26
|
type TtscPluginDescriptor = {
|
|
27
27
|
capabilities?: {
|
|
28
28
|
diagnosticsTiming?: boolean;
|
|
29
|
+
graphNodes?: boolean;
|
|
29
30
|
lsp?: boolean;
|
|
30
31
|
projectContextArgs?: boolean;
|
|
31
32
|
projectDiagnostics?: boolean;
|
|
@@ -34,6 +35,9 @@ type TtscPluginDescriptor = {
|
|
|
34
35
|
threadingArgs?: boolean;
|
|
35
36
|
};
|
|
36
37
|
contributors?: TtscPluginContributor[];
|
|
38
|
+
hostInputHashes?: Record<string, string | null>;
|
|
39
|
+
hostInputRealpaths?: Record<string, string | null>;
|
|
40
|
+
hostInputs?: string[];
|
|
37
41
|
name: string;
|
|
38
42
|
reportsTypeScriptDiagnostics?: boolean;
|
|
39
43
|
source: string;
|
|
@@ -82,20 +86,20 @@ function goSubpackageName(namespace: string): string {
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
const LINT_CONFIG_FILENAMES = [
|
|
89
|
+
"lint.config.json",
|
|
90
|
+
"lint.config.js",
|
|
91
|
+
"lint.config.mjs",
|
|
92
|
+
"lint.config.cjs",
|
|
85
93
|
"lint.config.ts",
|
|
86
94
|
"lint.config.mts",
|
|
87
95
|
"lint.config.cts",
|
|
88
|
-
"lint.config.
|
|
89
|
-
"lint.config.
|
|
90
|
-
"lint.config.
|
|
91
|
-
"lint.config.
|
|
96
|
+
"ttsc-lint.config.json",
|
|
97
|
+
"ttsc-lint.config.js",
|
|
98
|
+
"ttsc-lint.config.mjs",
|
|
99
|
+
"ttsc-lint.config.cjs",
|
|
92
100
|
"ttsc-lint.config.ts",
|
|
93
101
|
"ttsc-lint.config.mts",
|
|
94
102
|
"ttsc-lint.config.cts",
|
|
95
|
-
"ttsc-lint.config.mjs",
|
|
96
|
-
"ttsc-lint.config.cjs",
|
|
97
|
-
"ttsc-lint.config.js",
|
|
98
|
-
"ttsc-lint.config.json",
|
|
99
103
|
];
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -133,13 +137,14 @@ export default function createTtscPlugin(
|
|
|
133
137
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
134
138
|
): TtscPluginDescriptor {
|
|
135
139
|
rejectUnsupportedEntryKeys(context.plugin);
|
|
136
|
-
const
|
|
140
|
+
const resolvedConfig = resolveConfigFileContributors(context);
|
|
137
141
|
// Build the descriptor without a `contributors` key when none were
|
|
138
142
|
// declared, so consumers (and the existing key-shape regression
|
|
139
143
|
// tests) see the same surface as before this feature shipped.
|
|
140
144
|
const descriptor: TtscPluginDescriptor = {
|
|
141
145
|
capabilities: {
|
|
142
146
|
diagnosticsTiming: true,
|
|
147
|
+
graphNodes: true,
|
|
143
148
|
lsp: true,
|
|
144
149
|
projectContextArgs: true,
|
|
145
150
|
projectDiagnostics: true,
|
|
@@ -147,6 +152,9 @@ export default function createTtscPlugin(
|
|
|
147
152
|
residentCheck: true,
|
|
148
153
|
threadingArgs: true,
|
|
149
154
|
},
|
|
155
|
+
hostInputHashes: resolvedConfig.hostInputHashes,
|
|
156
|
+
hostInputRealpaths: resolvedConfig.hostInputRealpaths,
|
|
157
|
+
hostInputs: resolvedConfig.hostInputs,
|
|
150
158
|
name: "@ttsc/lint",
|
|
151
159
|
reportsTypeScriptDiagnostics: true,
|
|
152
160
|
// `context.dirname` is this descriptor's own directory in every load mode —
|
|
@@ -155,8 +163,8 @@ export default function createTtscPlugin(
|
|
|
155
163
|
source: path.resolve(context.dirname, "..", "plugin"),
|
|
156
164
|
stage: "check",
|
|
157
165
|
};
|
|
158
|
-
if (contributors.length > 0) {
|
|
159
|
-
descriptor.contributors = contributors;
|
|
166
|
+
if (resolvedConfig.contributors.length > 0) {
|
|
167
|
+
descriptor.contributors = resolvedConfig.contributors;
|
|
160
168
|
}
|
|
161
169
|
return descriptor;
|
|
162
170
|
}
|
|
@@ -210,8 +218,10 @@ type ConfigPluginEntry = { namespace: string; source: string };
|
|
|
210
218
|
|
|
211
219
|
type ConfigDependencyFingerprint = {
|
|
212
220
|
digest: string;
|
|
221
|
+
identityStable: boolean;
|
|
213
222
|
kind: "directory" | "file" | "optional-file";
|
|
214
223
|
path: string;
|
|
224
|
+
realpath: string | null;
|
|
215
225
|
scope: "cache" | "watch";
|
|
216
226
|
};
|
|
217
227
|
|
|
@@ -237,15 +247,38 @@ type ConfigPluginEvaluation = {
|
|
|
237
247
|
*/
|
|
238
248
|
function resolveConfigFileContributors(
|
|
239
249
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
240
|
-
):
|
|
250
|
+
): {
|
|
251
|
+
contributors: TtscPluginContributor[];
|
|
252
|
+
hostInputHashes: Record<string, string | null>;
|
|
253
|
+
hostInputRealpaths: Record<string, string | null>;
|
|
254
|
+
hostInputs: string[];
|
|
255
|
+
} {
|
|
241
256
|
const configFile = readConfigFileOption(context);
|
|
242
|
-
const
|
|
243
|
-
configFile
|
|
244
|
-
?
|
|
245
|
-
:
|
|
246
|
-
|
|
257
|
+
const explicitConfigPath =
|
|
258
|
+
configFile === undefined
|
|
259
|
+
? undefined
|
|
260
|
+
: path.resolve(pluginConfigBaseDir(context), configFile);
|
|
261
|
+
const discovery =
|
|
262
|
+
explicitConfigPath === undefined
|
|
263
|
+
? discoverLintConfigFile(context)
|
|
264
|
+
: {
|
|
265
|
+
configPath: explicitConfigPath,
|
|
266
|
+
hostInputHashes: hashHostInputPaths([explicitConfigPath]),
|
|
267
|
+
hostInputRealpaths: realpathHostInputPaths([explicitConfigPath]),
|
|
268
|
+
hostInputs: [explicitConfigPath],
|
|
269
|
+
};
|
|
270
|
+
const { configPath } = discovery;
|
|
271
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
272
|
+
return {
|
|
273
|
+
contributors: [],
|
|
274
|
+
hostInputHashes: discovery.hostInputHashes,
|
|
275
|
+
hostInputRealpaths: discovery.hostInputRealpaths,
|
|
276
|
+
hostInputs: discovery.hostInputs,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
247
279
|
|
|
248
|
-
const
|
|
280
|
+
const evaluation = readConfigPluginEntries(configPath, context);
|
|
281
|
+
const entries = evaluation.entries;
|
|
249
282
|
assertContributorNamespacesDoNotCollide(entries, configPath);
|
|
250
283
|
// Dedup exact repeated namespaces on the Go-subpackage form. Config-array
|
|
251
284
|
// folding can surface the same namespace more than once; that existing
|
|
@@ -258,7 +291,180 @@ function resolveConfigFileContributors(
|
|
|
258
291
|
occupied.add(goName);
|
|
259
292
|
out.push({ name: goName, source: entry.source });
|
|
260
293
|
}
|
|
261
|
-
|
|
294
|
+
const dependencyInputs = evaluation.dependencies
|
|
295
|
+
.filter(
|
|
296
|
+
(dependency) =>
|
|
297
|
+
dependency.scope === "watch" && dependency.kind !== "directory",
|
|
298
|
+
)
|
|
299
|
+
.map((dependency) => dependency.path);
|
|
300
|
+
const hostInputHashes = { ...discovery.hostInputHashes };
|
|
301
|
+
const hostInputRealpaths = { ...discovery.hostInputRealpaths };
|
|
302
|
+
const unstableRealpaths = new Set<string>();
|
|
303
|
+
const unprovenInputs = new Set<string>();
|
|
304
|
+
const missingOptionalDigest = createHash("sha256")
|
|
305
|
+
.update("missing\0")
|
|
306
|
+
.digest("hex");
|
|
307
|
+
const directoryCandidateDigest = createHash("sha256")
|
|
308
|
+
.update("ttsc:host-input:directory\0")
|
|
309
|
+
.digest("hex");
|
|
310
|
+
for (const dependency of evaluation.dependencies) {
|
|
311
|
+
if (dependency.scope !== "watch" || dependency.kind === "directory") {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const input = path.resolve(dependency.path);
|
|
315
|
+
const realpath = dependency.realpath;
|
|
316
|
+
if (!dependency.identityStable) {
|
|
317
|
+
delete hostInputRealpaths[input];
|
|
318
|
+
delete hostInputHashes[input];
|
|
319
|
+
unstableRealpaths.add(input);
|
|
320
|
+
unprovenInputs.add(input);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
Object.prototype.hasOwnProperty.call(hostInputRealpaths, input) &&
|
|
325
|
+
hostInputRealpaths[input] !== realpath
|
|
326
|
+
) {
|
|
327
|
+
delete hostInputRealpaths[input];
|
|
328
|
+
delete hostInputHashes[input];
|
|
329
|
+
unstableRealpaths.add(input);
|
|
330
|
+
} else if (!unstableRealpaths.has(input)) {
|
|
331
|
+
hostInputRealpaths[input] = realpath;
|
|
332
|
+
}
|
|
333
|
+
let hash: string | null | undefined;
|
|
334
|
+
if (
|
|
335
|
+
dependency.kind === "file" &&
|
|
336
|
+
/^[0-9a-f]{64}$/.test(dependency.digest)
|
|
337
|
+
) {
|
|
338
|
+
hash = dependency.digest;
|
|
339
|
+
} else if (dependency.digest === missingOptionalDigest) {
|
|
340
|
+
// The evaluator's optional-file digest includes a state prefix. The
|
|
341
|
+
// public host-input contract uses null for the observed missing state.
|
|
342
|
+
hash = null;
|
|
343
|
+
} else if (dependency.digest === directoryCandidateDigest) {
|
|
344
|
+
// A path that is currently a directory is still an exact file candidate:
|
|
345
|
+
// replacing it with a file changes module/config selection.
|
|
346
|
+
hash = directoryCandidateDigest;
|
|
347
|
+
}
|
|
348
|
+
if (hash === undefined) {
|
|
349
|
+
delete hostInputHashes[input];
|
|
350
|
+
unprovenInputs.add(input);
|
|
351
|
+
} else if (unprovenInputs.has(input)) {
|
|
352
|
+
// A later observation cannot revive proof another evaluation stage
|
|
353
|
+
// could not provide for the same combined descriptor result.
|
|
354
|
+
} else if (
|
|
355
|
+
Object.prototype.hasOwnProperty.call(hostInputHashes, input) &&
|
|
356
|
+
hostInputHashes[input] !== hash
|
|
357
|
+
) {
|
|
358
|
+
delete hostInputHashes[input];
|
|
359
|
+
unprovenInputs.add(input);
|
|
360
|
+
} else {
|
|
361
|
+
hostInputHashes[input] = hash;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
contributors: out,
|
|
366
|
+
hostInputHashes,
|
|
367
|
+
hostInputRealpaths,
|
|
368
|
+
hostInputs: [...discovery.hostInputs, ...dependencyInputs],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Snapshot config candidates before any discovery/evaluation side effect. */
|
|
373
|
+
function hashHostInputPaths(
|
|
374
|
+
inputs: readonly string[],
|
|
375
|
+
): Record<string, string | null> {
|
|
376
|
+
return Object.fromEntries(
|
|
377
|
+
inputs.map((input) => {
|
|
378
|
+
const file = path.resolve(input);
|
|
379
|
+
try {
|
|
380
|
+
if (fs.statSync(file).isDirectory()) {
|
|
381
|
+
return [
|
|
382
|
+
file,
|
|
383
|
+
createHash("sha256")
|
|
384
|
+
.update("ttsc:host-input:directory\0")
|
|
385
|
+
.digest("hex"),
|
|
386
|
+
] as const;
|
|
387
|
+
}
|
|
388
|
+
return [
|
|
389
|
+
file,
|
|
390
|
+
createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
|
|
391
|
+
] as const;
|
|
392
|
+
} catch {
|
|
393
|
+
return [file, null] as const;
|
|
394
|
+
}
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function hostInputRealpath(file: string): string | null {
|
|
400
|
+
try {
|
|
401
|
+
return fs.realpathSync.native(file);
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function realpathHostInputPaths(
|
|
408
|
+
inputs: readonly string[],
|
|
409
|
+
): Record<string, string | null> {
|
|
410
|
+
return Object.fromEntries(
|
|
411
|
+
inputs.map((input) => {
|
|
412
|
+
const file = path.resolve(input);
|
|
413
|
+
return [file, hostInputRealpath(file)] as const;
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Mirror native discovery and fingerprint every candidate before selecting. */
|
|
419
|
+
function discoverLintConfigFile(
|
|
420
|
+
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
421
|
+
): {
|
|
422
|
+
configPath?: string;
|
|
423
|
+
hostInputHashes: Record<string, string | null>;
|
|
424
|
+
hostInputRealpaths: Record<string, string | null>;
|
|
425
|
+
hostInputs: string[];
|
|
426
|
+
} {
|
|
427
|
+
const hostInputHashes: Record<string, string | null> = {};
|
|
428
|
+
const hostInputRealpaths: Record<string, string | null> = {};
|
|
429
|
+
const hostInputs: string[] = [];
|
|
430
|
+
const recordCandidates = (candidates: readonly string[]): void => {
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
const absolute = path.resolve(candidate);
|
|
433
|
+
if (Object.prototype.hasOwnProperty.call(hostInputHashes, absolute)) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
hostInputs.push(absolute);
|
|
437
|
+
Object.assign(hostInputHashes, hashHostInputPaths([absolute]));
|
|
438
|
+
Object.assign(hostInputRealpaths, realpathHostInputPaths([absolute]));
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
for (const origin of discoveryConfigBaseDirs(context)) {
|
|
442
|
+
for (let directory = origin; ; directory = path.dirname(directory)) {
|
|
443
|
+
const candidates = LINT_CONFIG_FILENAMES.map((name) =>
|
|
444
|
+
path.join(directory, name),
|
|
445
|
+
);
|
|
446
|
+
recordCandidates(candidates);
|
|
447
|
+
const matches = lintConfigMatchesIn(directory);
|
|
448
|
+
if (matches.length === 1) {
|
|
449
|
+
return {
|
|
450
|
+
configPath: matches[0],
|
|
451
|
+
hostInputHashes,
|
|
452
|
+
hostInputRealpaths,
|
|
453
|
+
hostInputs,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (matches.length > 1) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`@ttsc/lint: multiple lint config files found in ${directory} (${matches
|
|
459
|
+
.map((file) => path.basename(file))
|
|
460
|
+
.join(", ")}); set "configFile" explicitly`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const parent = path.dirname(directory);
|
|
464
|
+
if (parent === directory) break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { hostInputHashes, hostInputRealpaths, hostInputs };
|
|
262
468
|
}
|
|
263
469
|
|
|
264
470
|
function assertContributorNamespacesDoNotCollide(
|
|
@@ -328,26 +534,6 @@ function readConfigFileOption(
|
|
|
328
534
|
return value;
|
|
329
535
|
}
|
|
330
536
|
|
|
331
|
-
function findLintConfigFile(
|
|
332
|
-
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
333
|
-
): string | undefined {
|
|
334
|
-
// Mirror the Go side (driver.PluginConfigBaseDir): the caller-declared
|
|
335
|
-
// pluginConfigDir is the single walk origin when present — it names the
|
|
336
|
-
// real project when the resolved tsconfig is a generated wrapper in a temp
|
|
337
|
-
// dir (@ttsc/unplugin's alias overlay), and it keeps the wrapper's temp
|
|
338
|
-
// ancestry out of the walk so a stray config planted there is never
|
|
339
|
-
// honored. Otherwise walk upward from the tsconfig directory first, then
|
|
340
|
-
// fall back to the working directory: that covers callers that point at an
|
|
341
|
-
// out-of-tree tsconfig without declaring an anchor.
|
|
342
|
-
for (const origin of discoveryConfigBaseDirs(context)) {
|
|
343
|
-
const discovered = findLintConfigFileFrom(origin);
|
|
344
|
-
if (discovered !== undefined) {
|
|
345
|
-
return discovered;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
return undefined;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
537
|
function discoveryConfigBaseDirs(
|
|
352
538
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
353
539
|
): string[] {
|
|
@@ -359,41 +545,23 @@ function discoveryConfigBaseDirs(
|
|
|
359
545
|
return tsconfigDir === cwd ? [tsconfigDir] : [tsconfigDir, cwd];
|
|
360
546
|
}
|
|
361
547
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// the binary's own discovery to surface the issue once with one canonical
|
|
368
|
-
// message).
|
|
369
|
-
const candidateSet = new Set<string>(LINT_CONFIG_FILENAMES);
|
|
370
|
-
let dir = origin;
|
|
371
|
-
while (true) {
|
|
372
|
-
// One `readdirSync` per directory level beats 14 `existsSync`+
|
|
373
|
-
// `statSync` pairs (= 28 stat syscalls) per level; intersect the
|
|
374
|
-
// listing with the candidate set instead.
|
|
375
|
-
let entries: fs.Dirent[];
|
|
548
|
+
/** Return the non-directory candidates native discovery recognizes. */
|
|
549
|
+
function lintConfigMatchesIn(directory: string): string[] {
|
|
550
|
+
const matches: string[] = [];
|
|
551
|
+
for (const name of LINT_CONFIG_FILENAMES) {
|
|
552
|
+
const candidate = path.join(directory, name);
|
|
376
553
|
try {
|
|
377
|
-
|
|
554
|
+
// Go's os.Stat follows symlinks and junctions. Follow them here too so a
|
|
555
|
+
// directory or dangling link cannot stop descriptor discovery before
|
|
556
|
+
// the native host reaches a valid ancestor config. Probing the canonical
|
|
557
|
+
// candidate spelling also preserves Go's behavior on case-insensitive
|
|
558
|
+
// filesystems when the directory entry uses different casing.
|
|
559
|
+
if (!fs.statSync(candidate).isDirectory()) matches.push(candidate);
|
|
378
560
|
} catch {
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
const matches: string[] = [];
|
|
382
|
-
for (const entry of entries) {
|
|
383
|
-
if (!candidateSet.has(entry.name)) continue;
|
|
384
|
-
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
385
|
-
matches.push(path.join(dir, entry.name));
|
|
561
|
+
// Missing, dangling, and unreadable candidates are not native matches.
|
|
386
562
|
}
|
|
387
|
-
if (matches.length === 1) {
|
|
388
|
-
return matches[0];
|
|
389
|
-
}
|
|
390
|
-
if (matches.length > 1) {
|
|
391
|
-
return undefined; // ambiguous — defer to the Go side's error
|
|
392
|
-
}
|
|
393
|
-
const parent = path.dirname(dir);
|
|
394
|
-
if (parent === dir) return undefined;
|
|
395
|
-
dir = parent;
|
|
396
563
|
}
|
|
564
|
+
return matches;
|
|
397
565
|
}
|
|
398
566
|
|
|
399
567
|
/**
|
|
@@ -429,7 +597,7 @@ function tsconfigBaseDir(
|
|
|
429
597
|
function readConfigPluginEntries(
|
|
430
598
|
configPath: string,
|
|
431
599
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
432
|
-
):
|
|
600
|
+
): ConfigPluginEvaluation {
|
|
433
601
|
// A JSON config that can bring no contributor with it — no `plugins` map and
|
|
434
602
|
// no `extends` chain to follow — has nothing to extract, and reading it runs
|
|
435
603
|
// no user code, so the isolated evaluator is not needed to keep its strings
|
|
@@ -437,7 +605,9 @@ function readConfigPluginEntries(
|
|
|
437
605
|
// is a real subprocess, and a host that only wanted to know whether there
|
|
438
606
|
// were contributors would otherwise depend on a launcher being resolvable and
|
|
439
607
|
// on a compiler accepting one more invocation.
|
|
440
|
-
if (jsonConfigDeclaresNoContributor(configPath))
|
|
608
|
+
if (jsonConfigDeclaresNoContributor(configPath)) {
|
|
609
|
+
return { dependencies: [], entries: [] };
|
|
610
|
+
}
|
|
441
611
|
// Every other config uses the same isolated evaluator. Executable config can
|
|
442
612
|
// name contributor packages whose top-level code writes to stdout, so loading
|
|
443
613
|
// it in this host process would corrupt CLI JSON or preface the first LSP
|
|
@@ -520,9 +690,11 @@ const CONFIG_KEYS = new Set<string>([
|
|
|
520
690
|
]);
|
|
521
691
|
const dependencies = new Map<string, {
|
|
522
692
|
digest: string;
|
|
693
|
+
identityStable: boolean;
|
|
523
694
|
kind: "directory" | "file" | "optional-file";
|
|
524
695
|
path: string;
|
|
525
696
|
owners: Set<string>;
|
|
697
|
+
realpath: string | null;
|
|
526
698
|
}>();
|
|
527
699
|
const graphNodes = new Map<string, string>();
|
|
528
700
|
const graphEdges: Array<{
|
|
@@ -553,6 +725,23 @@ const configUrlSpellings = [
|
|
|
553
725
|
pathToFileURL(realConfigLocation()).href,
|
|
554
726
|
]),
|
|
555
727
|
];
|
|
728
|
+
const moduleProbeExtensions = [
|
|
729
|
+
".ts",
|
|
730
|
+
".tsx",
|
|
731
|
+
".mts",
|
|
732
|
+
".cts",
|
|
733
|
+
".js",
|
|
734
|
+
".mjs",
|
|
735
|
+
".cjs",
|
|
736
|
+
".json",
|
|
737
|
+
".node",
|
|
738
|
+
] as const;
|
|
739
|
+
const jsToTsProbeExtensions = new Map<string, readonly string[]>([
|
|
740
|
+
[".js", [".ts", ".tsx"]],
|
|
741
|
+
[".jsx", [".tsx"]],
|
|
742
|
+
[".mjs", [".mts"]],
|
|
743
|
+
[".cjs", [".cts"]],
|
|
744
|
+
]);
|
|
556
745
|
for (const spelling of configUrlSpellings) {
|
|
557
746
|
graphNodes.set(spelling, configLocation);
|
|
558
747
|
}
|
|
@@ -574,6 +763,11 @@ declare const process: {
|
|
|
574
763
|
|
|
575
764
|
const hooks = registerHooks({
|
|
576
765
|
resolve(specifier, context, nextResolve) {
|
|
766
|
+
const requestedParent =
|
|
767
|
+
context.parentURL && new URL(context.parentURL).href;
|
|
768
|
+
if (requestedParent !== undefined && graphNodes.has(requestedParent)) {
|
|
769
|
+
recordLocalResolutionCandidates(specifier, requestedParent);
|
|
770
|
+
}
|
|
577
771
|
const resolved = nextResolve(specifier, context);
|
|
578
772
|
if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
|
|
579
773
|
return resolved;
|
|
@@ -706,14 +900,32 @@ function recordDependency(
|
|
|
706
900
|
const previous = dependencies.get(key);
|
|
707
901
|
const mergedOwners = previous?.owners ?? new Set<string>();
|
|
708
902
|
for (const owner of owners) mergedOwners.add(owner);
|
|
903
|
+
const realpath = dependencyRealpath(location);
|
|
904
|
+
const identityStable =
|
|
905
|
+
previous?.identityStable !== false &&
|
|
906
|
+
(previous === undefined || previous.realpath === realpath);
|
|
709
907
|
dependencies.set(key, {
|
|
710
|
-
digest:
|
|
908
|
+
digest:
|
|
909
|
+
!identityStable ||
|
|
910
|
+
(previous !== undefined && previous.digest !== digest)
|
|
911
|
+
? ""
|
|
912
|
+
: digest,
|
|
913
|
+
identityStable,
|
|
711
914
|
kind,
|
|
712
915
|
owners: mergedOwners,
|
|
713
916
|
path: location,
|
|
917
|
+
realpath,
|
|
714
918
|
});
|
|
715
919
|
}
|
|
716
920
|
|
|
921
|
+
function dependencyRealpath(location: string): string | null {
|
|
922
|
+
try {
|
|
923
|
+
return realPath(location);
|
|
924
|
+
} catch {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
717
929
|
function isLocalModuleSpecifier(specifier: string): boolean {
|
|
718
930
|
return specifier.startsWith(".") ||
|
|
719
931
|
specifier.startsWith("/") ||
|
|
@@ -831,13 +1043,19 @@ function directoryDigestRecord(
|
|
|
831
1043
|
|
|
832
1044
|
function optionalFileDigest(location: string): string {
|
|
833
1045
|
try {
|
|
834
|
-
|
|
1046
|
+
const entry = fs.statSync(location);
|
|
1047
|
+
if (entry.isFile()) {
|
|
835
1048
|
return createHash("sha256")
|
|
836
1049
|
.update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)]))
|
|
837
1050
|
.digest("hex");
|
|
838
1051
|
}
|
|
1052
|
+
if (entry.isDirectory()) {
|
|
1053
|
+
return createHash("sha256")
|
|
1054
|
+
.update("ttsc:host-input:directory\\0")
|
|
1055
|
+
.digest("hex");
|
|
1056
|
+
}
|
|
839
1057
|
} catch {
|
|
840
|
-
// Missing
|
|
1058
|
+
// Missing and unreadable candidates share the absent state.
|
|
841
1059
|
}
|
|
842
1060
|
return createHash("sha256").update("missing\\0").digest("hex");
|
|
843
1061
|
}
|
|
@@ -863,6 +1081,80 @@ function recordOptionalFileDependency(
|
|
|
863
1081
|
return false;
|
|
864
1082
|
}
|
|
865
1083
|
|
|
1084
|
+
function moduleResolutionCandidates(base: string): string[] {
|
|
1085
|
+
const extension = path.extname(base).toLowerCase();
|
|
1086
|
+
const substitutions = jsToTsProbeExtensions.get(extension) ?? [];
|
|
1087
|
+
const stem = base.slice(0, base.length - extension.length);
|
|
1088
|
+
return [
|
|
1089
|
+
base,
|
|
1090
|
+
...substitutions.map((candidate) => stem + candidate),
|
|
1091
|
+
...moduleProbeExtensions.map((candidate) => base + candidate),
|
|
1092
|
+
path.join(base, "package.json"),
|
|
1093
|
+
...moduleProbeExtensions.map((candidate) =>
|
|
1094
|
+
path.join(base, "index" + candidate),
|
|
1095
|
+
),
|
|
1096
|
+
];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Record exact local probes before the runtime resolver chooses one. */
|
|
1100
|
+
function recordLocalResolutionCandidates(
|
|
1101
|
+
specifier: string,
|
|
1102
|
+
parentUrl: string,
|
|
1103
|
+
): void {
|
|
1104
|
+
if (!isLocalModuleSpecifier(specifier)) return;
|
|
1105
|
+
let bases: string[];
|
|
1106
|
+
try {
|
|
1107
|
+
if (specifier.startsWith("file:")) {
|
|
1108
|
+
bases = [fileURLToPath(specifier)];
|
|
1109
|
+
} else {
|
|
1110
|
+
const directory = path.dirname(fileURLToPath(parentUrl));
|
|
1111
|
+
const raw = path.resolve(directory, specifier);
|
|
1112
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
1113
|
+
const pathname =
|
|
1114
|
+
suffixStart === -1 ? specifier : specifier.slice(0, suffixStart);
|
|
1115
|
+
bases = pathname === ""
|
|
1116
|
+
? [raw]
|
|
1117
|
+
: [...new Set([raw, path.resolve(directory, pathname)])];
|
|
1118
|
+
}
|
|
1119
|
+
} catch {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const owners = [parentUrl];
|
|
1123
|
+
for (const base of bases) {
|
|
1124
|
+
try {
|
|
1125
|
+
if (fs.statSync(base).isFile()) {
|
|
1126
|
+
recordOptionalFileDependency(base, owners);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
} catch {
|
|
1130
|
+
// A missing exact spelling falls through to source/extension/directory
|
|
1131
|
+
// probes, all of which can redirect a later evaluation.
|
|
1132
|
+
}
|
|
1133
|
+
for (const candidate of moduleResolutionCandidates(base)) {
|
|
1134
|
+
recordOptionalFileDependency(candidate, owners);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** CommonJS LOAD_AS_FILE / LOAD_AS_DIRECTORY candidates for one legacy path. */
|
|
1140
|
+
function recordLegacyPackagePathCandidates(
|
|
1141
|
+
candidate: string,
|
|
1142
|
+
owners: readonly string[],
|
|
1143
|
+
): void {
|
|
1144
|
+
for (const file of [
|
|
1145
|
+
candidate,
|
|
1146
|
+
candidate + ".js",
|
|
1147
|
+
candidate + ".json",
|
|
1148
|
+
candidate + ".node",
|
|
1149
|
+
path.join(candidate, "package.json"),
|
|
1150
|
+
path.join(candidate, "index.js"),
|
|
1151
|
+
path.join(candidate, "index.json"),
|
|
1152
|
+
path.join(candidate, "index.node"),
|
|
1153
|
+
]) {
|
|
1154
|
+
recordOptionalFileDependency(file, owners);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
866
1158
|
function recordPackageManifests(
|
|
867
1159
|
location: string,
|
|
868
1160
|
owners: readonly string[],
|
|
@@ -908,26 +1200,26 @@ function recordNodeModulesSearchDirectories(
|
|
|
908
1200
|
// The directory digest of node_modules records a missing scope.
|
|
909
1201
|
}
|
|
910
1202
|
}
|
|
911
|
-
if (packageName !== undefined) {
|
|
912
|
-
const selected = recordPackageCandidateTopology(
|
|
913
|
-
modules,
|
|
914
|
-
packageName,
|
|
915
|
-
specifier,
|
|
916
|
-
childLocation,
|
|
917
|
-
owners,
|
|
918
|
-
conditions,
|
|
919
|
-
);
|
|
920
|
-
if (
|
|
921
|
-
selected ||
|
|
922
|
-
resolvedPackageContains(modules, packageName, childLocation)
|
|
923
|
-
) {
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
1203
|
}
|
|
928
1204
|
} catch {
|
|
929
1205
|
// Missing search levels do not participate in the current resolution.
|
|
930
1206
|
}
|
|
1207
|
+
if (packageName !== undefined) {
|
|
1208
|
+
const selected = recordPackageCandidateTopology(
|
|
1209
|
+
modules,
|
|
1210
|
+
packageName,
|
|
1211
|
+
specifier,
|
|
1212
|
+
childLocation,
|
|
1213
|
+
owners,
|
|
1214
|
+
conditions,
|
|
1215
|
+
);
|
|
1216
|
+
if (
|
|
1217
|
+
selected ||
|
|
1218
|
+
resolvedPackageContains(modules, packageName, childLocation)
|
|
1219
|
+
) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
931
1223
|
if (
|
|
932
1224
|
packageName === undefined &&
|
|
933
1225
|
samePhysicalPath(current, resolutionRoot)
|
|
@@ -949,14 +1241,32 @@ function recordPackageCandidateTopology(
|
|
|
949
1241
|
conditions: readonly string[],
|
|
950
1242
|
): boolean {
|
|
951
1243
|
const packageRoot = path.join(modules, packageName);
|
|
1244
|
+
const subpath = specifier
|
|
1245
|
+
.slice(packageName.length)
|
|
1246
|
+
.replace(/^[/\\\\]+/, "");
|
|
952
1247
|
try {
|
|
953
|
-
if (!fs.statSync(packageRoot).isDirectory())
|
|
1248
|
+
if (!fs.statSync(packageRoot).isDirectory()) {
|
|
1249
|
+
recordOptionalFileDependency(
|
|
1250
|
+
path.join(packageRoot, "package.json"),
|
|
1251
|
+
owners,
|
|
1252
|
+
);
|
|
1253
|
+
recordLegacyPackagePathCandidates(
|
|
1254
|
+
subpath === "" ? packageRoot : path.join(packageRoot, subpath),
|
|
1255
|
+
owners,
|
|
1256
|
+
);
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
954
1259
|
} catch {
|
|
1260
|
+
recordOptionalFileDependency(
|
|
1261
|
+
path.join(packageRoot, "package.json"),
|
|
1262
|
+
owners,
|
|
1263
|
+
);
|
|
1264
|
+
recordLegacyPackagePathCandidates(
|
|
1265
|
+
subpath === "" ? packageRoot : path.join(packageRoot, subpath),
|
|
1266
|
+
owners,
|
|
1267
|
+
);
|
|
955
1268
|
return false;
|
|
956
1269
|
}
|
|
957
|
-
const subpath = specifier
|
|
958
|
-
.slice(packageName.length)
|
|
959
|
-
.replace(/^[/\\\\]+/, "");
|
|
960
1270
|
const rootTopology = recordPackageRootTopology(
|
|
961
1271
|
packageRoot,
|
|
962
1272
|
owners,
|
|
@@ -991,6 +1301,7 @@ function recordPackageRootTopology(
|
|
|
991
1301
|
const legacySelected = (): boolean =>
|
|
992
1302
|
useMain &&
|
|
993
1303
|
packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
|
|
1304
|
+
if (useMain) recordLegacyPackagePathCandidates(normalizedRoot, owners);
|
|
994
1305
|
if (!recordOptionalFileDependency(manifest, owners)) {
|
|
995
1306
|
const selected = legacySelected();
|
|
996
1307
|
if (!selected) {
|
|
@@ -1037,6 +1348,7 @@ function recordPackageRootTopology(
|
|
|
1037
1348
|
// it literally and permits absolute paths and paths outside the package.
|
|
1038
1349
|
const main = path.resolve(normalizedRoot, metadata.main);
|
|
1039
1350
|
recordPackagePathCandidate(main, owners);
|
|
1351
|
+
recordLegacyPackagePathCandidates(main, owners);
|
|
1040
1352
|
selected =
|
|
1041
1353
|
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
1042
1354
|
selected;
|
|
@@ -1284,6 +1596,7 @@ function recordPackageSubpathTopology(
|
|
|
1284
1596
|
const candidate = boundedPackageTarget(packageRoot, subpath);
|
|
1285
1597
|
if (candidate === undefined) return false;
|
|
1286
1598
|
recordPackagePathCandidate(candidate, owners);
|
|
1599
|
+
recordLegacyPackagePathCandidates(candidate, owners);
|
|
1287
1600
|
let selected = packagePathCandidateMatchesChild(
|
|
1288
1601
|
candidate,
|
|
1289
1602
|
childLocation,
|
|
@@ -1303,6 +1616,7 @@ function recordPackageSubpathTopology(
|
|
|
1303
1616
|
if (typeof metadata.main === "string") {
|
|
1304
1617
|
const main = path.resolve(candidate, metadata.main);
|
|
1305
1618
|
recordPackagePathCandidate(main, owners);
|
|
1619
|
+
recordLegacyPackagePathCandidates(main, owners);
|
|
1306
1620
|
selected =
|
|
1307
1621
|
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
1308
1622
|
selected;
|
|
@@ -1473,10 +1787,25 @@ function realPath(location: string): string {
|
|
|
1473
1787
|
|
|
1474
1788
|
function finalizeDependencies(): Array<{
|
|
1475
1789
|
digest: string;
|
|
1790
|
+
identityStable: boolean;
|
|
1476
1791
|
kind: "directory" | "file" | "optional-file";
|
|
1477
1792
|
path: string;
|
|
1793
|
+
realpath: string | null;
|
|
1478
1794
|
scope: "cache" | "watch";
|
|
1479
1795
|
}> {
|
|
1796
|
+
// The evaluator may have observed a dependency, run arbitrary config code,
|
|
1797
|
+
// and then serialize after that path changed again. Re-read every recorded
|
|
1798
|
+
// dependency under its original ownership set so an A -> B -> A transition
|
|
1799
|
+
// is marked identity-unstable instead of pairing transient output with the
|
|
1800
|
+
// restored fingerprint.
|
|
1801
|
+
for (const dependency of [...dependencies.values()]) {
|
|
1802
|
+
recordDependency(
|
|
1803
|
+
dependency.kind,
|
|
1804
|
+
dependency.path,
|
|
1805
|
+
currentDependencyDigest(dependency.kind, dependency.path),
|
|
1806
|
+
[...dependency.owners],
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1480
1809
|
const watched = graphWatchReachability();
|
|
1481
1810
|
return [...dependencies.values()].map(({ owners, ...dependency }) => ({
|
|
1482
1811
|
...dependency,
|
|
@@ -1486,6 +1815,21 @@ function finalizeDependencies(): Array<{
|
|
|
1486
1815
|
}));
|
|
1487
1816
|
}
|
|
1488
1817
|
|
|
1818
|
+
function currentDependencyDigest(
|
|
1819
|
+
kind: "directory" | "file" | "optional-file",
|
|
1820
|
+
location: string,
|
|
1821
|
+
): string {
|
|
1822
|
+
try {
|
|
1823
|
+
if (kind === "directory") return directoryDigest(location);
|
|
1824
|
+
if (kind === "optional-file") return optionalFileDigest(location);
|
|
1825
|
+
return createHash("sha256")
|
|
1826
|
+
.update(fs.readFileSync(location))
|
|
1827
|
+
.digest("hex");
|
|
1828
|
+
} catch {
|
|
1829
|
+
return "";
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1489
1833
|
function graphWatchReachability(): Set<string> {
|
|
1490
1834
|
const adjacency = new Map<string, typeof graphEdges>();
|
|
1491
1835
|
for (const edge of graphEdges) {
|
|
@@ -1642,7 +1986,7 @@ function extractPluginSource(value: unknown): string | undefined {
|
|
|
1642
1986
|
function readTtsxConfigPlugins(
|
|
1643
1987
|
configPath: string,
|
|
1644
1988
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
1645
|
-
):
|
|
1989
|
+
): ConfigPluginEvaluation {
|
|
1646
1990
|
const resolutionRoot = path.resolve(pluginConfigBaseDir(context));
|
|
1647
1991
|
const cacheKey = configCacheKey(`plugins\0${resolutionRoot}`, configPath);
|
|
1648
1992
|
if (cacheKey) {
|
|
@@ -1656,7 +2000,7 @@ function readTtsxConfigPlugins(
|
|
|
1656
2000
|
cached.entries.every(isValidConfigPluginEntry) &&
|
|
1657
2001
|
configDependenciesAreCurrent(cached.dependencies)
|
|
1658
2002
|
) {
|
|
1659
|
-
return cached
|
|
2003
|
+
return cached;
|
|
1660
2004
|
}
|
|
1661
2005
|
}
|
|
1662
2006
|
// A config can be saved while it is being evaluated. Retry a bounded number
|
|
@@ -1668,10 +2012,10 @@ function readTtsxConfigPlugins(
|
|
|
1668
2012
|
evaluation = evaluateTtsxConfigPlugins(configPath, context);
|
|
1669
2013
|
if (configDependenciesAreCurrent(evaluation.dependencies)) {
|
|
1670
2014
|
if (cacheKey) writeConfigPluginCache(cacheKey, evaluation);
|
|
1671
|
-
return evaluation
|
|
2015
|
+
return evaluation;
|
|
1672
2016
|
}
|
|
1673
2017
|
}
|
|
1674
|
-
return evaluation
|
|
2018
|
+
return evaluation!;
|
|
1675
2019
|
}
|
|
1676
2020
|
|
|
1677
2021
|
/**
|
|
@@ -1704,8 +2048,9 @@ function evaluateTtsxConfigPlugins(
|
|
|
1704
2048
|
configPath: string,
|
|
1705
2049
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
1706
2050
|
): ConfigPluginEvaluation {
|
|
1707
|
-
const tempDir =
|
|
1708
|
-
|
|
2051
|
+
const tempDir = createCanonicalTempDirectory(
|
|
2052
|
+
"ttsc-lint-cfg-",
|
|
2053
|
+
loaderTempBase(configPath),
|
|
1709
2054
|
);
|
|
1710
2055
|
try {
|
|
1711
2056
|
linkNearestNodeModules(tempDir, path.dirname(configPath));
|
|
@@ -1896,11 +2241,34 @@ function evaluateTtsxConfigPlugins(
|
|
|
1896
2241
|
// Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
|
|
1897
2242
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1898
2243
|
|
|
2244
|
+
/** Create evaluator storage beneath a frozen physical parent. */
|
|
2245
|
+
function createCanonicalTempDirectory(prefix: string, parent: string): string {
|
|
2246
|
+
const physicalParent = fs.realpathSync.native(parent);
|
|
2247
|
+
if (!fs.lstatSync(physicalParent).isDirectory()) {
|
|
2248
|
+
throw new Error(
|
|
2249
|
+
`@ttsc/lint: temporary directory parent is not a directory: ${physicalParent}`,
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
const directory = fs.mkdtempSync(path.join(physicalParent, prefix));
|
|
2253
|
+
if (!fs.lstatSync(directory).isDirectory()) {
|
|
2254
|
+
throw new Error(
|
|
2255
|
+
`@ttsc/lint: temporary directory postflight is not a directory: ${directory}`,
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
const physicalDirectory = fs.realpathSync.native(directory);
|
|
2259
|
+
if (path.dirname(physicalDirectory) !== physicalParent) {
|
|
2260
|
+
throw new Error(
|
|
2261
|
+
`@ttsc/lint: temporary directory escaped its physical parent: ${physicalDirectory}`,
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
return physicalDirectory;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
1899
2267
|
/**
|
|
1900
2268
|
* Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
|
|
1901
2269
|
* `configCacheVersion`; bump both when the cached shape changes.
|
|
1902
2270
|
*/
|
|
1903
|
-
const CONFIG_CACHE_VERSION = "
|
|
2271
|
+
const CONFIG_CACHE_VERSION = "v7";
|
|
1904
2272
|
|
|
1905
2273
|
/**
|
|
1906
2274
|
* Directory shared by this factory and the Go sidecar for cached lint configs.
|
|
@@ -2024,6 +2392,14 @@ function normalizeConfigDependencyFingerprints(
|
|
|
2024
2392
|
typeof candidate !== "object" ||
|
|
2025
2393
|
typeof (candidate as ConfigDependencyFingerprint).path !== "string" ||
|
|
2026
2394
|
typeof (candidate as ConfigDependencyFingerprint).digest !== "string" ||
|
|
2395
|
+
typeof (candidate as ConfigDependencyFingerprint).identityStable !==
|
|
2396
|
+
"boolean" ||
|
|
2397
|
+
((candidate as ConfigDependencyFingerprint).realpath !== null &&
|
|
2398
|
+
(typeof (candidate as ConfigDependencyFingerprint).realpath !==
|
|
2399
|
+
"string" ||
|
|
2400
|
+
!path.isAbsolute(
|
|
2401
|
+
(candidate as ConfigDependencyFingerprint).realpath as string,
|
|
2402
|
+
))) ||
|
|
2027
2403
|
!["directory", "file", "optional-file"].includes(
|
|
2028
2404
|
(candidate as ConfigDependencyFingerprint).kind,
|
|
2029
2405
|
) ||
|
|
@@ -2036,24 +2412,40 @@ function normalizeConfigDependencyFingerprints(
|
|
|
2036
2412
|
const candidatePath = (candidate as ConfigDependencyFingerprint).path;
|
|
2037
2413
|
const digest = (candidate as ConfigDependencyFingerprint).digest;
|
|
2038
2414
|
const kind = (candidate as ConfigDependencyFingerprint).kind;
|
|
2415
|
+
const identityStable = (candidate as ConfigDependencyFingerprint)
|
|
2416
|
+
.identityStable;
|
|
2417
|
+
const realpath = (candidate as ConfigDependencyFingerprint).realpath;
|
|
2039
2418
|
const scope = (candidate as ConfigDependencyFingerprint).scope;
|
|
2040
|
-
if (
|
|
2419
|
+
if (
|
|
2420
|
+
!path.isAbsolute(candidatePath) ||
|
|
2421
|
+
(digest !== "" && !/^[0-9a-f]{64}$/.test(digest))
|
|
2422
|
+
) {
|
|
2041
2423
|
return undefined;
|
|
2042
2424
|
}
|
|
2043
2425
|
const location = path.resolve(candidatePath);
|
|
2044
|
-
|
|
2426
|
+
// The same lexical path can be both a traversed directory and an exact
|
|
2427
|
+
// file candidate. Those observations have different freshness contracts:
|
|
2428
|
+
// one fingerprints the listing, while the other fingerprints its entry
|
|
2429
|
+
// type. Preserve both, but still reject contradictory duplicates of one
|
|
2430
|
+
// kind.
|
|
2431
|
+
const key = kind + "\0" + location;
|
|
2432
|
+
const previous = dependencies.get(key);
|
|
2045
2433
|
if (
|
|
2046
2434
|
previous !== undefined &&
|
|
2047
2435
|
(previous.digest !== digest ||
|
|
2436
|
+
previous.identityStable !== identityStable ||
|
|
2048
2437
|
previous.kind !== kind ||
|
|
2438
|
+
previous.realpath !== realpath ||
|
|
2049
2439
|
previous.scope !== scope)
|
|
2050
2440
|
) {
|
|
2051
2441
|
return undefined;
|
|
2052
2442
|
}
|
|
2053
|
-
dependencies.set(
|
|
2443
|
+
dependencies.set(key, {
|
|
2054
2444
|
digest,
|
|
2445
|
+
identityStable,
|
|
2055
2446
|
kind,
|
|
2056
2447
|
path: location,
|
|
2448
|
+
realpath,
|
|
2057
2449
|
scope,
|
|
2058
2450
|
});
|
|
2059
2451
|
}
|
|
@@ -2067,6 +2459,12 @@ function configDependenciesAreCurrent(
|
|
|
2067
2459
|
): boolean {
|
|
2068
2460
|
if (dependencies.length === 0) return false;
|
|
2069
2461
|
return dependencies.every((dependency) => {
|
|
2462
|
+
if (
|
|
2463
|
+
!dependency.identityStable ||
|
|
2464
|
+
dependency.realpath !== hostInputRealpath(dependency.path)
|
|
2465
|
+
) {
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2070
2468
|
if (!/^[0-9a-f]{64}$/.test(dependency.digest)) return false;
|
|
2071
2469
|
try {
|
|
2072
2470
|
const digest =
|
|
@@ -2156,15 +2554,21 @@ function configDirectoryDigestRecord(
|
|
|
2156
2554
|
|
|
2157
2555
|
function configOptionalFileDigest(location: string): string {
|
|
2158
2556
|
try {
|
|
2159
|
-
|
|
2557
|
+
const entry = fs.statSync(location);
|
|
2558
|
+
if (entry.isFile()) {
|
|
2160
2559
|
return createHash("sha256")
|
|
2161
2560
|
.update(
|
|
2162
2561
|
Buffer.concat([Buffer.from("file\0"), fs.readFileSync(location)]),
|
|
2163
2562
|
)
|
|
2164
2563
|
.digest("hex");
|
|
2165
2564
|
}
|
|
2565
|
+
if (entry.isDirectory()) {
|
|
2566
|
+
return createHash("sha256")
|
|
2567
|
+
.update("ttsc:host-input:directory\0")
|
|
2568
|
+
.digest("hex");
|
|
2569
|
+
}
|
|
2166
2570
|
} catch {
|
|
2167
|
-
// Missing
|
|
2571
|
+
// Missing and unreadable candidates share the absent state.
|
|
2168
2572
|
}
|
|
2169
2573
|
return createHash("sha256").update("missing\0").digest("hex");
|
|
2170
2574
|
}
|
|
@@ -2425,14 +2829,6 @@ function loaderTempBase(configPath: string): string {
|
|
|
2425
2829
|
return path.dirname(configPath);
|
|
2426
2830
|
}
|
|
2427
2831
|
|
|
2428
|
-
function realpathIfPossible(location: string): string {
|
|
2429
|
-
try {
|
|
2430
|
-
return fs.realpathSync(location);
|
|
2431
|
-
} catch {
|
|
2432
|
-
return location;
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2436
2832
|
function nodeConfigLoaderEnv(configPath: string): NodeJS.ProcessEnv {
|
|
2437
2833
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
2438
2834
|
const parts: string[] = [];
|