@ttsc/lint 0.21.0 → 0.22.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 +27 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +1283 -71
- package/lib/index.js.map +1 -1
- package/linthost/check_serve.go +305 -0
- package/linthost/config.go +2410 -98
- package/linthost/dispatch.go +7 -1
- package/linthost/host.go +6 -4
- package/linthost/lsp.go +55 -0
- package/linthost/project_inputs.go +241 -0
- package/linthost/serve.go +24 -3
- package/package.json +2 -2
- package/rule/project.go +58 -0
- package/src/index.ts +1363 -95
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
5
6
|
import os from "node:os";
|
|
@@ -23,6 +24,9 @@ type TtscPluginDescriptor = {
|
|
|
23
24
|
diagnosticsTiming?: boolean;
|
|
24
25
|
lsp?: boolean;
|
|
25
26
|
projectContextArgs?: boolean;
|
|
27
|
+
projectDiagnostics?: boolean;
|
|
28
|
+
projectInputs?: boolean;
|
|
29
|
+
residentCheck?: boolean;
|
|
26
30
|
threadingArgs?: boolean;
|
|
27
31
|
};
|
|
28
32
|
contributors?: TtscPluginContributor[];
|
|
@@ -134,6 +138,9 @@ export default function createTtscPlugin(
|
|
|
134
138
|
diagnosticsTiming: true,
|
|
135
139
|
lsp: true,
|
|
136
140
|
projectContextArgs: true,
|
|
141
|
+
projectDiagnostics: true,
|
|
142
|
+
projectInputs: true,
|
|
143
|
+
residentCheck: true,
|
|
137
144
|
threadingArgs: true,
|
|
138
145
|
},
|
|
139
146
|
name: "@ttsc/lint",
|
|
@@ -197,6 +204,18 @@ function loadContributorPluginViaRequire(
|
|
|
197
204
|
/** Plugin entries observed in a lint config file, normalized per file. */
|
|
198
205
|
type ConfigPluginEntry = { namespace: string; source: string };
|
|
199
206
|
|
|
207
|
+
type ConfigDependencyFingerprint = {
|
|
208
|
+
digest: string;
|
|
209
|
+
kind: "directory" | "file" | "optional-file";
|
|
210
|
+
path: string;
|
|
211
|
+
scope: "cache" | "watch";
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
type ConfigPluginEvaluation = {
|
|
215
|
+
dependencies: ConfigDependencyFingerprint[];
|
|
216
|
+
entries: ConfigPluginEntry[];
|
|
217
|
+
};
|
|
218
|
+
|
|
200
219
|
/**
|
|
201
220
|
* Resolves the contributor lint plugins declared in the project's lint config
|
|
202
221
|
* file.
|
|
@@ -407,78 +426,53 @@ function readConfigPluginEntries(
|
|
|
407
426
|
configPath: string,
|
|
408
427
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
409
428
|
): ConfigPluginEntry[] {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
429
|
+
// A JSON config that can bring no contributor with it — no `plugins` map and
|
|
430
|
+
// no `extends` chain to follow — has nothing to extract, and reading it runs
|
|
431
|
+
// no user code, so the isolated evaluator is not needed to keep its strings
|
|
432
|
+
// away from stdout. Skipping it there matters beyond the cost: the evaluator
|
|
433
|
+
// is a real subprocess, and a host that only wanted to know whether there
|
|
434
|
+
// were contributors would otherwise depend on a launcher being resolvable and
|
|
435
|
+
// on a compiler accepting one more invocation.
|
|
436
|
+
if (jsonConfigDeclaresNoContributor(configPath)) return [];
|
|
437
|
+
// Every other config uses the same isolated evaluator. Executable config can
|
|
438
|
+
// name contributor packages whose top-level code writes to stdout, so loading
|
|
439
|
+
// it in this host process would corrupt CLI JSON or preface the first LSP
|
|
440
|
+
// frame.
|
|
419
441
|
return readTtsxConfigPlugins(configPath, context);
|
|
420
442
|
}
|
|
421
443
|
|
|
422
|
-
function
|
|
423
|
-
configPath
|
|
424
|
-
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
425
|
-
): ConfigPluginEntry[] {
|
|
426
|
-
let parsed: unknown;
|
|
444
|
+
function jsonConfigDeclaresNoContributor(configPath: string): boolean {
|
|
445
|
+
if (path.extname(configPath).toLowerCase() !== ".json") return false;
|
|
427
446
|
try {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
throw new Error(
|
|
435
|
-
`@ttsc/lint: failed to parse lint config ${configPath}: ${
|
|
436
|
-
error instanceof Error ? error.message : String(error)
|
|
437
|
-
}`,
|
|
438
|
-
);
|
|
447
|
+
const value: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
448
|
+
return !configMayDeclareContributor(value);
|
|
449
|
+
} catch {
|
|
450
|
+
// A malformed config is the evaluator's diagnostic to report, not this
|
|
451
|
+
// shortcut's, so it falls through to the path that reports it.
|
|
452
|
+
return false;
|
|
439
453
|
}
|
|
440
|
-
// In JSON, plugin values can only be strings (npm specifiers) — there
|
|
441
|
-
// is no way to attach an in-memory plugin object inside a JSON file.
|
|
442
|
-
return collectPluginObjectsFromConfig(parsed)
|
|
443
|
-
.flatMap((map) => Object.entries(map))
|
|
444
|
-
.map(([namespace, value]): ConfigPluginEntry => {
|
|
445
|
-
if (!NAMESPACE_PATTERN.test(namespace)) {
|
|
446
|
-
throw new Error(
|
|
447
|
-
`@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
451
|
-
throw new Error(
|
|
452
|
-
`@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(namespace)} must point at a package specifier string`,
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
const plugin = loadContributorPluginViaRequire(
|
|
456
|
-
value,
|
|
457
|
-
context,
|
|
458
|
-
namespace,
|
|
459
|
-
configPath,
|
|
460
|
-
);
|
|
461
|
-
return { namespace, source: plugin.source };
|
|
462
|
-
});
|
|
463
454
|
}
|
|
464
455
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
456
|
+
/**
|
|
457
|
+
* Whether any object in a config could still bring a contributor with it.
|
|
458
|
+
*
|
|
459
|
+
* A `plugins` map names one directly. An `extends` chain names one indirectly:
|
|
460
|
+
* the base it points at may be executable and may declare contributors of its
|
|
461
|
+
* own, and following that chain is the evaluator's job. Treating a config that
|
|
462
|
+
* extends anything as undecidable here is what keeps this shortcut from
|
|
463
|
+
* silently dropping a contributor the base would have supplied.
|
|
464
|
+
*/
|
|
465
|
+
function configMayDeclareContributor(value: unknown): boolean {
|
|
466
|
+
if (Array.isArray(value)) {
|
|
467
|
+
return value.some((entry) => configMayDeclareContributor(entry));
|
|
476
468
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
469
|
+
if (value === null || typeof value !== "object") return false;
|
|
470
|
+
const record = value as Record<string, unknown>;
|
|
471
|
+
for (const key of ["plugins", "extends"]) {
|
|
472
|
+
const declared = record[key];
|
|
473
|
+
if (declared !== undefined && declared !== null) return true;
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
482
476
|
}
|
|
483
477
|
|
|
484
478
|
// TypeScript source written to a temp file and executed via ttsx. The
|
|
@@ -488,7 +482,30 @@ function readCjsConfigPlugins(configPath: string): ConfigPluginEntry[] {
|
|
|
488
482
|
// as a JSON array for the parent process to parse — avoiding the need to
|
|
489
483
|
// serialise arbitrary in-memory plugin objects across the process boundary.
|
|
490
484
|
// The URL lives in a variable so tsgo does not statically resolve it.
|
|
491
|
-
|
|
485
|
+
/**
|
|
486
|
+
* The descriptor extractor's emitted source.
|
|
487
|
+
*
|
|
488
|
+
* Exported so a regression can inspect the same bytes the loader executes. The
|
|
489
|
+
* template consumes its own escapes, so reading this file's text instead would
|
|
490
|
+
* check characters no consumer ever sees.
|
|
491
|
+
*/
|
|
492
|
+
export const TTSX_EXTRACTOR_SCRIPT = `// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
493
|
+
import * as fs from "node:fs";
|
|
494
|
+
// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
495
|
+
import { Buffer } from "node:buffer";
|
|
496
|
+
// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
497
|
+
import { createHash } from "node:crypto";
|
|
498
|
+
// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
499
|
+
import { createRequire, registerHooks } from "node:module";
|
|
500
|
+
// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
501
|
+
import * as path from "node:path";
|
|
502
|
+
// @ts-ignore -- internal loader must not require user-installed Node typings.
|
|
503
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
504
|
+
|
|
505
|
+
const configUrl = %CONFIG_IMPORT%;
|
|
506
|
+
const outputPath = %CONFIG_OUTPUT%;
|
|
507
|
+
const resolutionRoot = path.resolve(%CONFIG_ROOT%);
|
|
508
|
+
const requireFromConfig = createRequire(configUrl);
|
|
492
509
|
const CONFIG_KEYS = new Set<string>([
|
|
493
510
|
"files",
|
|
494
511
|
"ignores",
|
|
@@ -497,36 +514,969 @@ const CONFIG_KEYS = new Set<string>([
|
|
|
497
514
|
"rules",
|
|
498
515
|
"format",
|
|
499
516
|
]);
|
|
500
|
-
const
|
|
517
|
+
const dependencies = new Map<string, {
|
|
518
|
+
digest: string;
|
|
519
|
+
kind: "directory" | "file" | "optional-file";
|
|
520
|
+
path: string;
|
|
521
|
+
owners: Set<string>;
|
|
522
|
+
}>();
|
|
523
|
+
const graphNodes = new Map<string, string>();
|
|
524
|
+
const graphEdges: Array<{
|
|
525
|
+
child: string;
|
|
526
|
+
packageBoundary: boolean;
|
|
527
|
+
parent: string;
|
|
528
|
+
}> = [];
|
|
529
|
+
const configLocation = fileURLToPath(configUrl);
|
|
530
|
+
// Every spelling of this config the module system might key an edge under.
|
|
531
|
+
//
|
|
532
|
+
// Which one it uses is not knowable from here, and guessing has failed in both
|
|
533
|
+
// directions. A path handed in by another producer can be escaped by a rule
|
|
534
|
+
// Node does not share. Node respells a resolved file module through its real
|
|
535
|
+
// path unless "--preserve-symlinks" is set, so a config reached through a
|
|
536
|
+
// symlinked directory is keyed by its target. And a Windows 8.3 short name is
|
|
537
|
+
// not a symlink: fs.realpathSync expands it, the module resolver does not, so
|
|
538
|
+
// asking the volume there produces a spelling no edge carries.
|
|
539
|
+
//
|
|
540
|
+
// A seed that names a URL no edge was keyed under sits on a node with no
|
|
541
|
+
// outgoing edges, the walk ends immediately, and every dependency recorded
|
|
542
|
+
// after the first import is demoted from watch to cache. That failure is
|
|
543
|
+
// silent: the build still succeeds and simply stops reacting. Seeding every
|
|
544
|
+
// spelling costs one extra queue entry and cannot be wrong.
|
|
545
|
+
const configUrlSpellings = [
|
|
546
|
+
...new Set([
|
|
547
|
+
configUrl,
|
|
548
|
+
pathToFileURL(configLocation).href,
|
|
549
|
+
pathToFileURL(realConfigLocation()).href,
|
|
550
|
+
]),
|
|
551
|
+
];
|
|
552
|
+
for (const spelling of configUrlSpellings) {
|
|
553
|
+
graphNodes.set(spelling, configLocation);
|
|
554
|
+
}
|
|
555
|
+
recordDependency(
|
|
556
|
+
"file",
|
|
557
|
+
configLocation,
|
|
558
|
+
createHash("sha256").update(fs.readFileSync(configLocation)).digest("hex"),
|
|
559
|
+
configUrlSpellings,
|
|
560
|
+
);
|
|
561
|
+
recordPackageManifests(configLocation, configUrlSpellings);
|
|
501
562
|
|
|
502
563
|
declare const process: {
|
|
503
564
|
cwd(): string;
|
|
565
|
+
platform: string;
|
|
504
566
|
stdout: { write(value: string): void };
|
|
505
567
|
stderr: { write(value: string): void };
|
|
506
568
|
exit(code?: number): never;
|
|
507
569
|
};
|
|
508
570
|
|
|
571
|
+
const hooks = registerHooks({
|
|
572
|
+
resolve(specifier, context, nextResolve) {
|
|
573
|
+
const resolved = nextResolve(specifier, context);
|
|
574
|
+
if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
|
|
575
|
+
return resolved;
|
|
576
|
+
}
|
|
577
|
+
const url = new URL(resolved.url).href;
|
|
578
|
+
const parent = context.parentURL && new URL(context.parentURL).href;
|
|
579
|
+
const location = fileURLToPath(url);
|
|
580
|
+
// The entry is recognized by what was asked for, not only by what came
|
|
581
|
+
// back. A module URL is assigned by whoever loaded it: a compiling loader
|
|
582
|
+
// can serve the config from its emitted output, and a platform can hand
|
|
583
|
+
// back a different spelling of the same file. Either way the URL bears no
|
|
584
|
+
// resemblance to the one this process was given, so the config's own
|
|
585
|
+
// imports would be rejected here — their parent is a URL no node was
|
|
586
|
+
// recorded under — and the graph would collapse to the records made before
|
|
587
|
+
// the first import. The request itself is unambiguous, so it decides.
|
|
588
|
+
const entry =
|
|
589
|
+
specifier === configUrl ||
|
|
590
|
+
url === new URL(configUrl).href ||
|
|
591
|
+
samePhysicalPath(location, configLocation);
|
|
592
|
+
if (!entry && (parent === undefined || !graphNodes.has(parent))) {
|
|
593
|
+
return resolved;
|
|
594
|
+
}
|
|
595
|
+
graphNodes.set(url, location);
|
|
596
|
+
if (parent !== undefined) {
|
|
597
|
+
graphEdges.push({
|
|
598
|
+
child: url,
|
|
599
|
+
packageBoundary:
|
|
600
|
+
pathHasNodeModules(location) && !isLocalModuleSpecifier(specifier),
|
|
601
|
+
parent,
|
|
602
|
+
});
|
|
603
|
+
recordResolutionTopology(
|
|
604
|
+
specifier,
|
|
605
|
+
parent,
|
|
606
|
+
url,
|
|
607
|
+
location,
|
|
608
|
+
context.conditions,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
recordDependency(
|
|
613
|
+
"file",
|
|
614
|
+
location,
|
|
615
|
+
createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
|
|
616
|
+
[url],
|
|
617
|
+
);
|
|
618
|
+
} catch {
|
|
619
|
+
// The evaluator remains authoritative for the load error. An unreadable
|
|
620
|
+
// dependency simply makes this result non-cacheable in the parent.
|
|
621
|
+
recordDependency("file", location, "", [url]);
|
|
622
|
+
}
|
|
623
|
+
return resolved;
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
509
627
|
try {
|
|
628
|
+
const importedConfig = configLocation.toLowerCase().endsWith(".json")
|
|
629
|
+
? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
|
|
630
|
+
: await import(configUrl);
|
|
510
631
|
const current = await resolveConfig(importedConfig, true);
|
|
511
632
|
const pluginMaps = collectPluginObjects(current);
|
|
512
633
|
const entries: Array<{ namespace: string; source: string }> = [];
|
|
513
634
|
for (const map of pluginMaps) {
|
|
514
635
|
for (const [namespace, value] of Object.entries(map)) {
|
|
515
636
|
const source = extractPluginSource(value);
|
|
516
|
-
if (source === undefined)
|
|
637
|
+
if (source === undefined || source.length === 0) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
\`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
517
642
|
entries.push({ namespace, source });
|
|
518
643
|
}
|
|
519
644
|
}
|
|
520
|
-
|
|
645
|
+
fs.writeFileSync(outputPath, JSON.stringify({
|
|
646
|
+
dependencies: finalizeDependencies(),
|
|
647
|
+
entries,
|
|
648
|
+
}), "utf8");
|
|
521
649
|
} catch (error) {
|
|
522
650
|
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
523
651
|
process.exit(1);
|
|
652
|
+
} finally {
|
|
653
|
+
hooks.deregister();
|
|
524
654
|
}
|
|
525
655
|
|
|
526
656
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
527
657
|
return value !== null && typeof value === "object";
|
|
528
658
|
}
|
|
529
659
|
|
|
660
|
+
function recordDependency(
|
|
661
|
+
kind: "directory" | "file" | "optional-file",
|
|
662
|
+
location: string,
|
|
663
|
+
digest: string,
|
|
664
|
+
owners: readonly string[],
|
|
665
|
+
): void {
|
|
666
|
+
const key = kind + "\\0" + location;
|
|
667
|
+
const previous = dependencies.get(key);
|
|
668
|
+
const mergedOwners = previous?.owners ?? new Set<string>();
|
|
669
|
+
for (const owner of owners) mergedOwners.add(owner);
|
|
670
|
+
dependencies.set(key, {
|
|
671
|
+
digest: previous !== undefined && previous.digest !== digest ? "" : digest,
|
|
672
|
+
kind,
|
|
673
|
+
owners: mergedOwners,
|
|
674
|
+
path: location,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function isLocalModuleSpecifier(specifier: string): boolean {
|
|
679
|
+
return specifier.startsWith(".") ||
|
|
680
|
+
specifier.startsWith("/") ||
|
|
681
|
+
specifier.startsWith("file:") ||
|
|
682
|
+
/^[A-Za-z]:[\\\\/]/.test(specifier);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function pathHasNodeModules(location: string): boolean {
|
|
686
|
+
return location.replaceAll("\\\\", "/").split("/").includes("node_modules");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function recordResolutionTopology(
|
|
690
|
+
specifier: string,
|
|
691
|
+
parentUrl: string,
|
|
692
|
+
childUrl: string,
|
|
693
|
+
childLocation: string,
|
|
694
|
+
conditions: readonly string[],
|
|
695
|
+
): void {
|
|
696
|
+
const owners = [parentUrl, childUrl];
|
|
697
|
+
const parentLocation = graphNodes.get(parentUrl);
|
|
698
|
+
if (parentLocation !== undefined && isLocalModuleSpecifier(specifier)) {
|
|
699
|
+
recordDirectoryDependency(path.dirname(parentLocation), owners);
|
|
700
|
+
}
|
|
701
|
+
recordDirectoryDependency(path.dirname(childLocation), owners);
|
|
702
|
+
recordPackageManifests(childLocation, owners);
|
|
703
|
+
if (parentLocation !== undefined && !isLocalModuleSpecifier(specifier)) {
|
|
704
|
+
recordNodeModulesSearchDirectories(
|
|
705
|
+
parentLocation,
|
|
706
|
+
specifier,
|
|
707
|
+
childLocation,
|
|
708
|
+
owners,
|
|
709
|
+
conditions,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function recordDirectoryDependency(
|
|
715
|
+
location: string,
|
|
716
|
+
owners: readonly string[],
|
|
717
|
+
): void {
|
|
718
|
+
try {
|
|
719
|
+
recordDependency("directory", location, directoryDigest(location), owners);
|
|
720
|
+
} catch {
|
|
721
|
+
recordDependency("directory", location, "", owners);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function directoryDigest(location: string): string {
|
|
726
|
+
const entries: Buffer[] = [];
|
|
727
|
+
if (process.platform === "win32") {
|
|
728
|
+
for (const entry of fs.readdirSync(location, { withFileTypes: true })) {
|
|
729
|
+
let target = Buffer.alloc(0);
|
|
730
|
+
if (entry.isSymbolicLink()) {
|
|
731
|
+
try {
|
|
732
|
+
target = Buffer.from(
|
|
733
|
+
fs.readlinkSync(path.join(location, entry.name)),
|
|
734
|
+
"utf8",
|
|
735
|
+
);
|
|
736
|
+
} catch {
|
|
737
|
+
target = Buffer.from("<unreadable>");
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
entries.push(directoryDigestRecord(Buffer.from(entry.name), entry, target));
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
for (const entry of fs.readdirSync(location, {
|
|
744
|
+
encoding: "buffer",
|
|
745
|
+
withFileTypes: true,
|
|
746
|
+
})) {
|
|
747
|
+
let target = Buffer.alloc(0);
|
|
748
|
+
if (entry.isSymbolicLink()) {
|
|
749
|
+
try {
|
|
750
|
+
target = fs.readlinkSync(
|
|
751
|
+
Buffer.concat([
|
|
752
|
+
Buffer.from(location),
|
|
753
|
+
Buffer.from(path.sep),
|
|
754
|
+
entry.name,
|
|
755
|
+
]),
|
|
756
|
+
{ encoding: "buffer" },
|
|
757
|
+
);
|
|
758
|
+
} catch {
|
|
759
|
+
target = Buffer.from("<unreadable>");
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
entries.push(directoryDigestRecord(entry.name, entry, target));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
entries.sort(Buffer.compare);
|
|
766
|
+
const serialized = Buffer.concat(
|
|
767
|
+
entries.flatMap((entry, index) =>
|
|
768
|
+
index === 0 ? [entry] : [Buffer.from([0]), entry],
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function directoryDigestRecord(
|
|
775
|
+
name: Buffer,
|
|
776
|
+
entry: {
|
|
777
|
+
isDirectory(): boolean;
|
|
778
|
+
isFile(): boolean;
|
|
779
|
+
isSymbolicLink(): boolean;
|
|
780
|
+
},
|
|
781
|
+
target: Buffer,
|
|
782
|
+
): Buffer {
|
|
783
|
+
const kind = entry.isDirectory()
|
|
784
|
+
? "directory"
|
|
785
|
+
: entry.isFile()
|
|
786
|
+
? "file"
|
|
787
|
+
: entry.isSymbolicLink()
|
|
788
|
+
? "symlink"
|
|
789
|
+
: "other";
|
|
790
|
+
return Buffer.concat([name, Buffer.from("\\0" + kind + "\\0"), target]);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function optionalFileDigest(location: string): string {
|
|
794
|
+
try {
|
|
795
|
+
if (fs.statSync(location).isFile()) {
|
|
796
|
+
return createHash("sha256")
|
|
797
|
+
.update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)]))
|
|
798
|
+
.digest("hex");
|
|
799
|
+
}
|
|
800
|
+
} catch {
|
|
801
|
+
// Missing, unreadable, and non-file candidates share the absent state.
|
|
802
|
+
}
|
|
803
|
+
return createHash("sha256").update("missing\\0").digest("hex");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function recordOptionalFileDependency(
|
|
807
|
+
location: string,
|
|
808
|
+
owners: readonly string[],
|
|
809
|
+
): boolean {
|
|
810
|
+
try {
|
|
811
|
+
if (fs.statSync(location).isFile()) {
|
|
812
|
+
recordDependency(
|
|
813
|
+
"file",
|
|
814
|
+
location,
|
|
815
|
+
createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
|
|
816
|
+
owners,
|
|
817
|
+
);
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
// The exact missing path remains a dependency of the resolution result.
|
|
822
|
+
}
|
|
823
|
+
recordDependency("optional-file", location, optionalFileDigest(location), owners);
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function recordPackageManifests(
|
|
828
|
+
location: string,
|
|
829
|
+
owners: readonly string[],
|
|
830
|
+
): void {
|
|
831
|
+
let current = path.dirname(location);
|
|
832
|
+
while (true) {
|
|
833
|
+
const manifest = path.join(current, "package.json");
|
|
834
|
+
if (recordOptionalFileDependency(manifest, owners)) return;
|
|
835
|
+
const parent = path.dirname(current);
|
|
836
|
+
if (parent === current || path.basename(current) === "node_modules") return;
|
|
837
|
+
current = parent;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function recordNodeModulesSearchDirectories(
|
|
842
|
+
parentLocation: string,
|
|
843
|
+
specifier: string,
|
|
844
|
+
childLocation: string,
|
|
845
|
+
owners: readonly string[],
|
|
846
|
+
conditions: readonly string[],
|
|
847
|
+
): void {
|
|
848
|
+
const packageName = modulePackageName(specifier);
|
|
849
|
+
const scope =
|
|
850
|
+
specifier.startsWith("@") && specifier.includes("/")
|
|
851
|
+
? specifier.slice(0, specifier.indexOf("/"))
|
|
852
|
+
: undefined;
|
|
853
|
+
let current = path.dirname(parentLocation);
|
|
854
|
+
while (true) {
|
|
855
|
+
// A newly created nearer node_modules directory can shadow the package
|
|
856
|
+
// selected by this evaluation, so missing search levels are dependencies.
|
|
857
|
+
recordDirectoryDependency(current, owners);
|
|
858
|
+
const modules = path.join(current, "node_modules");
|
|
859
|
+
try {
|
|
860
|
+
if (fs.statSync(modules).isDirectory()) {
|
|
861
|
+
recordDirectoryDependency(modules, owners);
|
|
862
|
+
if (scope !== undefined) {
|
|
863
|
+
const scoped = path.join(modules, scope);
|
|
864
|
+
try {
|
|
865
|
+
if (fs.statSync(scoped).isDirectory()) {
|
|
866
|
+
recordDirectoryDependency(scoped, owners);
|
|
867
|
+
}
|
|
868
|
+
} catch {
|
|
869
|
+
// The directory digest of node_modules records a missing scope.
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (packageName !== undefined) {
|
|
873
|
+
const selected = recordPackageCandidateTopology(
|
|
874
|
+
modules,
|
|
875
|
+
packageName,
|
|
876
|
+
specifier,
|
|
877
|
+
childLocation,
|
|
878
|
+
owners,
|
|
879
|
+
conditions,
|
|
880
|
+
);
|
|
881
|
+
if (
|
|
882
|
+
selected ||
|
|
883
|
+
resolvedPackageContains(modules, packageName, childLocation)
|
|
884
|
+
) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
// Missing search levels do not participate in the current resolution.
|
|
891
|
+
}
|
|
892
|
+
if (
|
|
893
|
+
packageName === undefined &&
|
|
894
|
+
samePhysicalPath(current, resolutionRoot)
|
|
895
|
+
) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const parent = path.dirname(current);
|
|
899
|
+
if (parent === current) return;
|
|
900
|
+
current = parent;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function recordPackageCandidateTopology(
|
|
905
|
+
modules: string,
|
|
906
|
+
packageName: string,
|
|
907
|
+
specifier: string,
|
|
908
|
+
childLocation: string,
|
|
909
|
+
owners: readonly string[],
|
|
910
|
+
conditions: readonly string[],
|
|
911
|
+
): boolean {
|
|
912
|
+
const packageRoot = path.join(modules, packageName);
|
|
913
|
+
try {
|
|
914
|
+
if (!fs.statSync(packageRoot).isDirectory()) return false;
|
|
915
|
+
} catch {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
const subpath = specifier
|
|
919
|
+
.slice(packageName.length)
|
|
920
|
+
.replace(/^[/\\\\]+/, "");
|
|
921
|
+
const rootTopology = recordPackageRootTopology(
|
|
922
|
+
packageRoot,
|
|
923
|
+
owners,
|
|
924
|
+
subpath === "",
|
|
925
|
+
subpath === "" ? "." : "./" + subpath.replaceAll("\\\\", "/"),
|
|
926
|
+
childLocation,
|
|
927
|
+
conditions,
|
|
928
|
+
);
|
|
929
|
+
if (subpath !== "" && !rootTopology.hasExports) {
|
|
930
|
+
return (
|
|
931
|
+
recordPackageSubpathTopology(
|
|
932
|
+
packageRoot,
|
|
933
|
+
subpath,
|
|
934
|
+
childLocation,
|
|
935
|
+
owners,
|
|
936
|
+
) || rootTopology.selected
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
return rootTopology.selected;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function recordPackageRootTopology(
|
|
943
|
+
packageRoot: string,
|
|
944
|
+
owners: readonly string[],
|
|
945
|
+
useMain: boolean,
|
|
946
|
+
packageSubpath: string,
|
|
947
|
+
childLocation: string,
|
|
948
|
+
conditions: readonly string[],
|
|
949
|
+
): { hasExports: boolean; selected: boolean } {
|
|
950
|
+
const normalizedRoot = path.resolve(packageRoot);
|
|
951
|
+
const manifest = path.join(normalizedRoot, "package.json");
|
|
952
|
+
const legacySelected = (): boolean =>
|
|
953
|
+
useMain &&
|
|
954
|
+
packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
|
|
955
|
+
if (!recordOptionalFileDependency(manifest, owners)) {
|
|
956
|
+
const selected = legacySelected();
|
|
957
|
+
if (!selected) {
|
|
958
|
+
recordPackageIndexCandidates(normalizedRoot, useMain, owners);
|
|
959
|
+
}
|
|
960
|
+
return { hasExports: false, selected };
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
964
|
+
if (value !== null && typeof value === "object") {
|
|
965
|
+
const metadata = value as Record<string, unknown>;
|
|
966
|
+
const hasExports =
|
|
967
|
+
metadata.exports !== undefined && metadata.exports !== null;
|
|
968
|
+
if (hasExports) {
|
|
969
|
+
const target = selectPackageExportsTarget(
|
|
970
|
+
metadata.exports,
|
|
971
|
+
packageSubpath,
|
|
972
|
+
new Set(conditions),
|
|
973
|
+
);
|
|
974
|
+
const candidate =
|
|
975
|
+
typeof target === "string"
|
|
976
|
+
? packageExportsTarget(normalizedRoot, target)
|
|
977
|
+
: undefined;
|
|
978
|
+
const selected =
|
|
979
|
+
candidate !== undefined &&
|
|
980
|
+
packagePathCandidateMatchesChild(
|
|
981
|
+
candidate,
|
|
982
|
+
childLocation,
|
|
983
|
+
false,
|
|
984
|
+
);
|
|
985
|
+
if (selected) {
|
|
986
|
+
recordPackagePathCandidate(candidate, owners);
|
|
987
|
+
} else if (candidate !== undefined) {
|
|
988
|
+
// A nearer package the search skipped starts winning the moment its
|
|
989
|
+
// own active target appears, and neither the parent node_modules
|
|
990
|
+
// listing nor the manifest changes when only that file is created.
|
|
991
|
+
recordOptionalFileDependency(candidate, owners);
|
|
992
|
+
}
|
|
993
|
+
return { hasExports: true, selected };
|
|
994
|
+
}
|
|
995
|
+
let selected = legacySelected();
|
|
996
|
+
if (useMain && typeof metadata.main === "string") {
|
|
997
|
+
// CommonJS main is a legacy path, not an exports target. Node resolves
|
|
998
|
+
// it literally and permits absolute paths and paths outside the package.
|
|
999
|
+
const main = path.resolve(normalizedRoot, metadata.main);
|
|
1000
|
+
recordPackagePathCandidate(main, owners);
|
|
1001
|
+
selected =
|
|
1002
|
+
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
1003
|
+
selected;
|
|
1004
|
+
}
|
|
1005
|
+
if (!selected) {
|
|
1006
|
+
recordPackageIndexCandidates(normalizedRoot, useMain, owners);
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
hasExports: false,
|
|
1010
|
+
selected,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
} catch {
|
|
1014
|
+
// Node owns malformed-manifest diagnostics; the manifest digest is enough
|
|
1015
|
+
// to invalidate this evaluation when its contents change.
|
|
1016
|
+
}
|
|
1017
|
+
const selected = legacySelected();
|
|
1018
|
+
if (!selected) {
|
|
1019
|
+
recordPackageIndexCandidates(normalizedRoot, useMain, owners);
|
|
1020
|
+
}
|
|
1021
|
+
return { hasExports: false, selected };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// recordPackageIndexCandidates pins the LOAD_INDEX fallbacks of a package root
|
|
1025
|
+
// this resolution walked past without selecting. An empty package directory, or
|
|
1026
|
+
// one whose manifest declares no usable entry, becomes resolvable as soon as one
|
|
1027
|
+
// of these files exists, and that creation changes neither the parent directory
|
|
1028
|
+
// listing nor the manifest digest already recorded for the candidate.
|
|
1029
|
+
function recordPackageIndexCandidates(
|
|
1030
|
+
packageRoot: string,
|
|
1031
|
+
useMain: boolean,
|
|
1032
|
+
owners: readonly string[],
|
|
1033
|
+
): void {
|
|
1034
|
+
if (!useMain) return;
|
|
1035
|
+
for (const name of ["index.js", "index.json", "index.node"]) {
|
|
1036
|
+
recordOptionalFileDependency(path.join(packageRoot, name), owners);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function selectPackageExportsTarget(
|
|
1041
|
+
exportsValue: unknown,
|
|
1042
|
+
packageSubpath: string,
|
|
1043
|
+
conditions: ReadonlySet<string>,
|
|
1044
|
+
): string | null | undefined {
|
|
1045
|
+
let mappings: unknown = exportsValue;
|
|
1046
|
+
if (
|
|
1047
|
+
typeof mappings === "string" ||
|
|
1048
|
+
Array.isArray(mappings) ||
|
|
1049
|
+
(isObject(mappings) &&
|
|
1050
|
+
Object.keys(mappings).every((key) => !key.startsWith(".")))
|
|
1051
|
+
) {
|
|
1052
|
+
if (packageSubpath !== ".") return undefined;
|
|
1053
|
+
return selectPackageTarget(mappings, "", false, conditions);
|
|
1054
|
+
}
|
|
1055
|
+
if (!isObject(mappings)) return undefined;
|
|
1056
|
+
if (
|
|
1057
|
+
Object.prototype.hasOwnProperty.call(mappings, packageSubpath) &&
|
|
1058
|
+
!packageSubpath.includes("*") &&
|
|
1059
|
+
!packageSubpath.endsWith("/")
|
|
1060
|
+
) {
|
|
1061
|
+
return selectPackageTarget(
|
|
1062
|
+
mappings[packageSubpath],
|
|
1063
|
+
"",
|
|
1064
|
+
false,
|
|
1065
|
+
conditions,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
let bestMatch = "";
|
|
1069
|
+
let bestSubpath = "";
|
|
1070
|
+
for (const key of Object.keys(mappings)) {
|
|
1071
|
+
const wildcard = key.indexOf("*");
|
|
1072
|
+
if (
|
|
1073
|
+
wildcard === -1 ||
|
|
1074
|
+
key.lastIndexOf("*") !== wildcard ||
|
|
1075
|
+
!packageSubpath.startsWith(key.slice(0, wildcard))
|
|
1076
|
+
) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const trailer = key.slice(wildcard + 1);
|
|
1080
|
+
if (
|
|
1081
|
+
packageSubpath.length < key.length ||
|
|
1082
|
+
!packageSubpath.endsWith(trailer) ||
|
|
1083
|
+
packagePatternKeyCompare(bestMatch, key) !== 1
|
|
1084
|
+
) {
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
bestMatch = key;
|
|
1088
|
+
bestSubpath = packageSubpath.slice(
|
|
1089
|
+
wildcard,
|
|
1090
|
+
packageSubpath.length - trailer.length,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
return bestMatch === ""
|
|
1094
|
+
? undefined
|
|
1095
|
+
: selectPackageTarget(
|
|
1096
|
+
mappings[bestMatch],
|
|
1097
|
+
bestSubpath,
|
|
1098
|
+
true,
|
|
1099
|
+
conditions,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function selectPackageTarget(
|
|
1104
|
+
target: unknown,
|
|
1105
|
+
subpath: string,
|
|
1106
|
+
pattern: boolean,
|
|
1107
|
+
conditions: ReadonlySet<string>,
|
|
1108
|
+
): string | null | undefined {
|
|
1109
|
+
if (typeof target === "string") {
|
|
1110
|
+
const selected = pattern ? target.replaceAll("*", subpath) : target;
|
|
1111
|
+
return validPackageExportsTarget(selected) ? selected : undefined;
|
|
1112
|
+
}
|
|
1113
|
+
if (Array.isArray(target)) {
|
|
1114
|
+
for (const item of target) {
|
|
1115
|
+
const selected = selectPackageTarget(
|
|
1116
|
+
item,
|
|
1117
|
+
subpath,
|
|
1118
|
+
pattern,
|
|
1119
|
+
conditions,
|
|
1120
|
+
);
|
|
1121
|
+
if (selected !== undefined && selected !== null) return selected;
|
|
1122
|
+
}
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
if (isObject(target)) {
|
|
1126
|
+
for (const [condition, value] of Object.entries(target)) {
|
|
1127
|
+
if (condition !== "default" && !conditions.has(condition)) continue;
|
|
1128
|
+
const selected = selectPackageTarget(
|
|
1129
|
+
value,
|
|
1130
|
+
subpath,
|
|
1131
|
+
pattern,
|
|
1132
|
+
conditions,
|
|
1133
|
+
);
|
|
1134
|
+
if (selected !== undefined) return selected;
|
|
1135
|
+
}
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
return target === null ? null : undefined;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function packagePatternKeyCompare(left: string, right: string): number {
|
|
1142
|
+
const leftWildcard = left.indexOf("*");
|
|
1143
|
+
const rightWildcard = right.indexOf("*");
|
|
1144
|
+
const leftBase =
|
|
1145
|
+
leftWildcard === -1 ? left.length : leftWildcard + 1;
|
|
1146
|
+
const rightBase =
|
|
1147
|
+
rightWildcard === -1 ? right.length : rightWildcard + 1;
|
|
1148
|
+
if (leftBase > rightBase) return -1;
|
|
1149
|
+
if (rightBase > leftBase) return 1;
|
|
1150
|
+
if (leftWildcard === -1) return 1;
|
|
1151
|
+
if (rightWildcard === -1) return -1;
|
|
1152
|
+
if (left.length > right.length) return -1;
|
|
1153
|
+
if (right.length > left.length) return 1;
|
|
1154
|
+
return 0;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function packageExportsTarget(
|
|
1158
|
+
packageRoot: string,
|
|
1159
|
+
target: string,
|
|
1160
|
+
): string | undefined {
|
|
1161
|
+
if (!validPackageExportsTarget(target)) return undefined;
|
|
1162
|
+
try {
|
|
1163
|
+
// Node resolves an exports target as a URL against the package manifest,
|
|
1164
|
+
// so percent escapes, query strings, and fragments all take part in the
|
|
1165
|
+
// path it finally loads. Joining the raw target by hand diverges from that
|
|
1166
|
+
// whenever the target is anything but a plain relative path, and a target
|
|
1167
|
+
// Node resolves while this model rejects loses the selected file's
|
|
1168
|
+
// fingerprint, leaving a retargeted symlink cached as fresh.
|
|
1169
|
+
const packageUrl = pathToFileURL(path.join(packageRoot, "package.json"));
|
|
1170
|
+
const resolved = new URL(target, packageUrl);
|
|
1171
|
+
const packagePath = new URL(".", packageUrl).pathname;
|
|
1172
|
+
if (!resolved.pathname.startsWith(packagePath)) return undefined;
|
|
1173
|
+
return fileURLToPath(resolved);
|
|
1174
|
+
} catch {
|
|
1175
|
+
return undefined;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function validPackageExportsTarget(target: string): boolean {
|
|
1180
|
+
if (!target.startsWith("./") || /%2f|%5c/i.test(target)) return false;
|
|
1181
|
+
const components = target
|
|
1182
|
+
.slice(2)
|
|
1183
|
+
.replaceAll("\\\\", "/")
|
|
1184
|
+
.split("/");
|
|
1185
|
+
if (
|
|
1186
|
+
components.some(
|
|
1187
|
+
(component) => {
|
|
1188
|
+
try {
|
|
1189
|
+
const decoded = decodeURIComponent(component);
|
|
1190
|
+
return (
|
|
1191
|
+
decoded === "." ||
|
|
1192
|
+
decoded === ".." ||
|
|
1193
|
+
decoded.includes("/") ||
|
|
1194
|
+
decoded.includes("\\\\") ||
|
|
1195
|
+
decoded.toLowerCase() === "node_modules"
|
|
1196
|
+
);
|
|
1197
|
+
} catch {
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
)
|
|
1202
|
+
) {
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
return true;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function packagePathCandidateMatchesChild(
|
|
1209
|
+
candidate: string,
|
|
1210
|
+
childLocation: string,
|
|
1211
|
+
legacy: boolean,
|
|
1212
|
+
): boolean {
|
|
1213
|
+
let child: string;
|
|
1214
|
+
try {
|
|
1215
|
+
child = fs.realpathSync.native(childLocation);
|
|
1216
|
+
} catch {
|
|
1217
|
+
child = path.resolve(childLocation);
|
|
1218
|
+
}
|
|
1219
|
+
const candidates = legacy
|
|
1220
|
+
? [
|
|
1221
|
+
candidate,
|
|
1222
|
+
candidate + ".js",
|
|
1223
|
+
candidate + ".json",
|
|
1224
|
+
candidate + ".node",
|
|
1225
|
+
path.join(candidate, "index.js"),
|
|
1226
|
+
path.join(candidate, "index.json"),
|
|
1227
|
+
path.join(candidate, "index.node"),
|
|
1228
|
+
]
|
|
1229
|
+
: [candidate];
|
|
1230
|
+
return candidates.some((location) => {
|
|
1231
|
+
try {
|
|
1232
|
+
return sameResolutionPath(fs.realpathSync.native(location), child);
|
|
1233
|
+
} catch {
|
|
1234
|
+
return false;
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function recordPackageSubpathTopology(
|
|
1240
|
+
packageRoot: string,
|
|
1241
|
+
subpath: string,
|
|
1242
|
+
childLocation: string,
|
|
1243
|
+
owners: readonly string[],
|
|
1244
|
+
): boolean {
|
|
1245
|
+
const candidate = boundedPackageTarget(packageRoot, subpath);
|
|
1246
|
+
if (candidate === undefined) return false;
|
|
1247
|
+
recordPackagePathCandidate(candidate, owners);
|
|
1248
|
+
let selected = packagePathCandidateMatchesChild(
|
|
1249
|
+
candidate,
|
|
1250
|
+
childLocation,
|
|
1251
|
+
true,
|
|
1252
|
+
);
|
|
1253
|
+
try {
|
|
1254
|
+
if (!fs.statSync(candidate).isDirectory()) return selected;
|
|
1255
|
+
} catch {
|
|
1256
|
+
return selected;
|
|
1257
|
+
}
|
|
1258
|
+
const manifest = path.join(candidate, "package.json");
|
|
1259
|
+
if (!recordOptionalFileDependency(manifest, owners)) return selected;
|
|
1260
|
+
try {
|
|
1261
|
+
const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
1262
|
+
if (value !== null && typeof value === "object") {
|
|
1263
|
+
const metadata = value as Record<string, unknown>;
|
|
1264
|
+
if (typeof metadata.main === "string") {
|
|
1265
|
+
const main = path.resolve(candidate, metadata.main);
|
|
1266
|
+
recordPackagePathCandidate(main, owners);
|
|
1267
|
+
selected =
|
|
1268
|
+
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
1269
|
+
selected;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
} catch {
|
|
1273
|
+
// Node owns malformed nested-package diagnostics.
|
|
1274
|
+
}
|
|
1275
|
+
return selected;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function boundedPackageTarget(
|
|
1279
|
+
packageRoot: string,
|
|
1280
|
+
target: string,
|
|
1281
|
+
): string | undefined {
|
|
1282
|
+
const candidate = path.resolve(packageRoot, target);
|
|
1283
|
+
const relative = path.relative(packageRoot, candidate);
|
|
1284
|
+
if (
|
|
1285
|
+
relative === ".." ||
|
|
1286
|
+
relative.startsWith(".." + path.sep) ||
|
|
1287
|
+
path.isAbsolute(relative)
|
|
1288
|
+
) {
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
return candidate;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function recordPackagePathCandidate(
|
|
1295
|
+
candidate: string,
|
|
1296
|
+
owners: readonly string[],
|
|
1297
|
+
visited: Set<string> = new Set(),
|
|
1298
|
+
depth = 0,
|
|
1299
|
+
): void {
|
|
1300
|
+
const normalized = path.resolve(candidate);
|
|
1301
|
+
// The depth bound owns termination. A platform-wide case fold would merge
|
|
1302
|
+
// paths that differ only by case, which a per-directory case-sensitive
|
|
1303
|
+
// Windows tree keeps distinct, and would truncate a valid symlink chain.
|
|
1304
|
+
if (depth >= 64 || visited.has(normalized)) return;
|
|
1305
|
+
visited.add(normalized);
|
|
1306
|
+
const parsed = path.parse(normalized);
|
|
1307
|
+
const components = normalized
|
|
1308
|
+
.slice(parsed.root.length)
|
|
1309
|
+
.split(path.sep)
|
|
1310
|
+
.filter(Boolean);
|
|
1311
|
+
let current = parsed.root;
|
|
1312
|
+
for (let index = 0; index < components.length; index++) {
|
|
1313
|
+
const component = components[index];
|
|
1314
|
+
const next = path.join(current, component);
|
|
1315
|
+
let entry: ReturnType<typeof fs.lstatSync>;
|
|
1316
|
+
try {
|
|
1317
|
+
entry = fs.lstatSync(next);
|
|
1318
|
+
} catch {
|
|
1319
|
+
recordDirectoryDependency(current, owners);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (entry.isSymbolicLink()) {
|
|
1323
|
+
// The containing directory digest carries the raw link target.
|
|
1324
|
+
recordDirectoryDependency(current, owners);
|
|
1325
|
+
try {
|
|
1326
|
+
const target = fs.readlinkSync(next);
|
|
1327
|
+
const remainder = components.slice(index + 1);
|
|
1328
|
+
recordPackagePathCandidate(
|
|
1329
|
+
path.join(
|
|
1330
|
+
path.resolve(current, target),
|
|
1331
|
+
...remainder,
|
|
1332
|
+
),
|
|
1333
|
+
owners,
|
|
1334
|
+
visited,
|
|
1335
|
+
depth + 1,
|
|
1336
|
+
);
|
|
1337
|
+
} catch {
|
|
1338
|
+
// The lexical link record already carries the unreadable state.
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
let isDirectory = entry.isDirectory();
|
|
1342
|
+
if (entry.isSymbolicLink()) {
|
|
1343
|
+
try {
|
|
1344
|
+
isDirectory = fs.statSync(next).isDirectory();
|
|
1345
|
+
} catch {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (index === components.length - 1) {
|
|
1350
|
+
recordDirectoryDependency(isDirectory ? next : current, owners);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (!isDirectory) {
|
|
1354
|
+
recordDirectoryDependency(current, owners);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
current = next;
|
|
1358
|
+
}
|
|
1359
|
+
recordDirectoryDependency(current, owners);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function modulePackageName(specifier: string): string | undefined {
|
|
1363
|
+
if (specifier.startsWith("@")) {
|
|
1364
|
+
const components = specifier.split("/");
|
|
1365
|
+
return components.length >= 2
|
|
1366
|
+
? components[0] + "/" + components[1]
|
|
1367
|
+
: undefined;
|
|
1368
|
+
}
|
|
1369
|
+
const [name] = specifier.split("/");
|
|
1370
|
+
return name && !name.startsWith("#") ? name : undefined;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function resolvedPackageContains(
|
|
1374
|
+
modules: string,
|
|
1375
|
+
packageName: string,
|
|
1376
|
+
childLocation: string,
|
|
1377
|
+
): boolean {
|
|
1378
|
+
try {
|
|
1379
|
+
const packageRoot = fs.realpathSync(path.join(modules, packageName));
|
|
1380
|
+
const relative = path.relative(
|
|
1381
|
+
packageRoot,
|
|
1382
|
+
fs.realpathSync(childLocation),
|
|
1383
|
+
);
|
|
1384
|
+
return (
|
|
1385
|
+
relative === "" ||
|
|
1386
|
+
(relative !== ".." &&
|
|
1387
|
+
!relative.startsWith(".." + path.sep) &&
|
|
1388
|
+
!path.isAbsolute(relative))
|
|
1389
|
+
);
|
|
1390
|
+
} catch {
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function sameResolutionPath(left: string, right: string): boolean {
|
|
1396
|
+
return path.relative(left, right) === "";
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function samePhysicalPath(left: string, right: string): boolean {
|
|
1400
|
+
try {
|
|
1401
|
+
return sameResolutionPath(realPath(left), realPath(right));
|
|
1402
|
+
} catch {
|
|
1403
|
+
// Fall back to the spellings themselves, folding case the way the platform
|
|
1404
|
+
// does. On the entry gate a false negative is catastrophic — the config
|
|
1405
|
+
// stops being recognized and its whole graph collapses — while a false
|
|
1406
|
+
// positive only over-includes, so the degradation has to lean toward "same
|
|
1407
|
+
// file". A drive-letter or component case difference is the ordinary
|
|
1408
|
+
// Windows situation; a per-directory case-sensitive tree is the rare one.
|
|
1409
|
+
return sameResolutionPath(left, right);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The config's real path, or its declared one when the volume will not say.
|
|
1415
|
+
*
|
|
1416
|
+
* A config can disappear between the host reading it and this loader starting,
|
|
1417
|
+
* and a throw here would replace a precise report from the import below with a
|
|
1418
|
+
* crash in bookkeeping. Seeding lexically instead only risks the demotion this
|
|
1419
|
+
* value exists to prevent, on a file that is already gone.
|
|
1420
|
+
*/
|
|
1421
|
+
function realConfigLocation(): string {
|
|
1422
|
+
try {
|
|
1423
|
+
return realPath(configLocation);
|
|
1424
|
+
} catch {
|
|
1425
|
+
return configLocation;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function realPath(location: string): string {
|
|
1430
|
+
return fs.realpathSync.native
|
|
1431
|
+
? fs.realpathSync.native(location)
|
|
1432
|
+
: fs.realpathSync(location);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function finalizeDependencies(): Array<{
|
|
1436
|
+
digest: string;
|
|
1437
|
+
kind: "directory" | "file" | "optional-file";
|
|
1438
|
+
path: string;
|
|
1439
|
+
scope: "cache" | "watch";
|
|
1440
|
+
}> {
|
|
1441
|
+
const watched = graphWatchReachability();
|
|
1442
|
+
return [...dependencies.values()].map(({ owners, ...dependency }) => ({
|
|
1443
|
+
...dependency,
|
|
1444
|
+
scope: [...owners].some((owner) => watched.has(owner))
|
|
1445
|
+
? "watch"
|
|
1446
|
+
: "cache",
|
|
1447
|
+
}));
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function graphWatchReachability(): Set<string> {
|
|
1451
|
+
const adjacency = new Map<string, typeof graphEdges>();
|
|
1452
|
+
for (const edge of graphEdges) {
|
|
1453
|
+
const outgoing = adjacency.get(edge.parent) ?? [];
|
|
1454
|
+
outgoing.push(edge);
|
|
1455
|
+
adjacency.set(edge.parent, outgoing);
|
|
1456
|
+
}
|
|
1457
|
+
const queue: Array<{ url: string; watched: boolean }> =
|
|
1458
|
+
configUrlSpellings.map((url) => ({ url, watched: true }));
|
|
1459
|
+
const visited = new Set<string>();
|
|
1460
|
+
const watched = new Set<string>();
|
|
1461
|
+
while (queue.length !== 0) {
|
|
1462
|
+
const state = queue.shift()!;
|
|
1463
|
+
const key = state.url + "\\0" + (state.watched ? "1" : "0");
|
|
1464
|
+
if (visited.has(key)) continue;
|
|
1465
|
+
visited.add(key);
|
|
1466
|
+
if (state.watched) watched.add(state.url);
|
|
1467
|
+
for (const edge of adjacency.get(state.url) ?? []) {
|
|
1468
|
+
const childLocation = graphNodes.get(edge.child);
|
|
1469
|
+
const childWatched = edge.packageBoundary
|
|
1470
|
+
? false
|
|
1471
|
+
: childLocation !== undefined && !pathHasNodeModules(childLocation)
|
|
1472
|
+
? true
|
|
1473
|
+
: state.watched;
|
|
1474
|
+
queue.push({ url: edge.child, watched: childWatched });
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return watched;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
530
1480
|
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
531
1481
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
532
1482
|
}
|
|
@@ -614,7 +1564,9 @@ function collectPluginObjects(value: unknown): Array<Record<string, unknown>> {
|
|
|
614
1564
|
}
|
|
615
1565
|
|
|
616
1566
|
function extractPluginSource(value: unknown): string | undefined {
|
|
617
|
-
if (typeof value === "string")
|
|
1567
|
+
if (typeof value === "string") {
|
|
1568
|
+
value = requireFromConfig(value);
|
|
1569
|
+
}
|
|
618
1570
|
if (!isObject(value)) return undefined;
|
|
619
1571
|
// ESM-from-CJS interop wraps CJS modules' \`exports.default\` so the
|
|
620
1572
|
// plugin object can land under a \`.default\` indirection. Walk a few
|
|
@@ -637,31 +1589,50 @@ function extractPluginSource(value: unknown): string | undefined {
|
|
|
637
1589
|
`;
|
|
638
1590
|
|
|
639
1591
|
/**
|
|
640
|
-
* Resolves
|
|
1592
|
+
* Resolves contributor plugin entries declared in any executable lint config,
|
|
641
1593
|
* memoized through the shared on-disk config cache.
|
|
642
1594
|
*
|
|
643
1595
|
* Evaluating such a config spawns a full `ttsx` subprocess. A monorepo build
|
|
644
1596
|
* runs one `ttsc` process per package, and each would otherwise re-spawn `ttsx`
|
|
645
1597
|
* for the same shared config; the cache collapses that to a single evaluation.
|
|
646
|
-
* The cache
|
|
647
|
-
*
|
|
1598
|
+
* The cache key covers the entry's path and exact contents; the payload also
|
|
1599
|
+
* fingerprints every local module reached from that entry. An entry or helper
|
|
1600
|
+
* edit therefore re-evaluates cleanly without treating installed packages as
|
|
1601
|
+
* project watch inputs.
|
|
648
1602
|
*/
|
|
649
1603
|
function readTtsxConfigPlugins(
|
|
650
1604
|
configPath: string,
|
|
651
1605
|
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
652
1606
|
): ConfigPluginEntry[] {
|
|
653
|
-
const
|
|
1607
|
+
const resolutionRoot = path.resolve(pluginConfigBaseDir(context));
|
|
1608
|
+
const cacheKey = configCacheKey(`plugins\0${resolutionRoot}`, configPath);
|
|
654
1609
|
if (cacheKey) {
|
|
655
1610
|
const cached = readConfigPluginCache(cacheKey);
|
|
656
1611
|
// Re-validate cached entries before trusting them: a contributor's
|
|
657
1612
|
// resolved `source` directory may have moved since the entry was
|
|
658
1613
|
// written. A stale entry falls through to a fresh evaluation rather
|
|
659
1614
|
// than being forwarded to ttsc's plugin builder as a dead path.
|
|
660
|
-
if (
|
|
1615
|
+
if (
|
|
1616
|
+
cached &&
|
|
1617
|
+
cached.entries.every(isValidConfigPluginEntry) &&
|
|
1618
|
+
configDependenciesAreCurrent(cached.dependencies)
|
|
1619
|
+
) {
|
|
1620
|
+
return cached.entries;
|
|
1621
|
+
}
|
|
661
1622
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1623
|
+
// A config can be saved while it is being evaluated. Retry a bounded number
|
|
1624
|
+
// of times until every dependency still has the bytes the module hook saw.
|
|
1625
|
+
// A continuously changing config remains usable but is deliberately not
|
|
1626
|
+
// cached; watch will schedule another cycle.
|
|
1627
|
+
let evaluation: ConfigPluginEvaluation;
|
|
1628
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1629
|
+
evaluation = evaluateTtsxConfigPlugins(configPath, context);
|
|
1630
|
+
if (configDependenciesAreCurrent(evaluation.dependencies)) {
|
|
1631
|
+
if (cacheKey) writeConfigPluginCache(cacheKey, evaluation);
|
|
1632
|
+
return evaluation.entries;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return evaluation!.entries;
|
|
665
1636
|
}
|
|
666
1637
|
|
|
667
1638
|
/**
|
|
@@ -692,19 +1663,25 @@ function isValidConfigPluginEntry(entry: unknown): entry is ConfigPluginEntry {
|
|
|
692
1663
|
|
|
693
1664
|
function evaluateTtsxConfigPlugins(
|
|
694
1665
|
configPath: string,
|
|
695
|
-
|
|
696
|
-
):
|
|
1666
|
+
context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
|
|
1667
|
+
): ConfigPluginEvaluation {
|
|
697
1668
|
const tempDir = realpathIfPossible(
|
|
698
1669
|
fs.mkdtempSync(path.join(loaderTempBase(configPath), "ttsc-lint-cfg-")),
|
|
699
1670
|
);
|
|
700
1671
|
try {
|
|
701
1672
|
linkNearestNodeModules(tempDir, path.dirname(configPath));
|
|
702
1673
|
const loaderPath = path.join(tempDir, "loader.mts");
|
|
1674
|
+
const outputPath = path.join(tempDir, "result.json");
|
|
703
1675
|
const tsconfigPath = path.join(tempDir, "tsconfig.json");
|
|
704
1676
|
const loaderSource = TTSX_EXTRACTOR_SCRIPT.replace(
|
|
705
1677
|
"%CONFIG_IMPORT%",
|
|
706
1678
|
JSON.stringify(pathToFileURL(configPath).href),
|
|
707
|
-
)
|
|
1679
|
+
)
|
|
1680
|
+
.replace("%CONFIG_OUTPUT%", JSON.stringify(outputPath))
|
|
1681
|
+
.replace(
|
|
1682
|
+
"%CONFIG_ROOT%",
|
|
1683
|
+
JSON.stringify(path.resolve(pluginConfigBaseDir(context))),
|
|
1684
|
+
);
|
|
708
1685
|
fs.writeFileSync(loaderPath, loaderSource, "utf8");
|
|
709
1686
|
fs.writeFileSync(
|
|
710
1687
|
tsconfigPath,
|
|
@@ -731,7 +1708,9 @@ function evaluateTtsxConfigPlugins(
|
|
|
731
1708
|
},
|
|
732
1709
|
files: [
|
|
733
1710
|
loaderPath.replace(/\\/g, "/"),
|
|
734
|
-
configPath.
|
|
1711
|
+
...(path.extname(configPath).toLowerCase() === ".json"
|
|
1712
|
+
? []
|
|
1713
|
+
: [configPath.replace(/\\/g, "/")]),
|
|
735
1714
|
],
|
|
736
1715
|
},
|
|
737
1716
|
null,
|
|
@@ -740,7 +1719,11 @@ function evaluateTtsxConfigPlugins(
|
|
|
740
1719
|
"utf8",
|
|
741
1720
|
);
|
|
742
1721
|
|
|
743
|
-
|
|
1722
|
+
// The config comes first, so a project that pins its own ttsc gets it. This
|
|
1723
|
+
// descriptor's own location comes second, because a fixture or a workspace
|
|
1724
|
+
// that installs only the lint package cannot resolve ttsc from the config
|
|
1725
|
+
// at all, and the descriptor always sits beside the host that loaded it.
|
|
1726
|
+
const ttsxBinary = resolveTtsxLauncher([configPath, context.dirname]);
|
|
744
1727
|
// `--no-plugins` keeps this build hermetic: the loader only needs to
|
|
745
1728
|
// type-check and run the user's lint config to extract its plugin
|
|
746
1729
|
// entries. Loading the host project's transform/check plugins
|
|
@@ -765,11 +1748,13 @@ function evaluateTtsxConfigPlugins(
|
|
|
765
1748
|
env,
|
|
766
1749
|
encoding: "utf8",
|
|
767
1750
|
maxBuffer: 1024 * 1024 * 16,
|
|
1751
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
768
1752
|
// 60s cap so a runaway top-level await / infinite loop in the
|
|
769
1753
|
// user's lint config can't hang the entire ttsc invocation.
|
|
770
1754
|
timeout: 60_000,
|
|
771
1755
|
windowsHide: true,
|
|
772
1756
|
});
|
|
1757
|
+
forwardConfigEvaluatorStreams(result.stdout, result.stderr);
|
|
773
1758
|
if (result.error) {
|
|
774
1759
|
throw new Error(
|
|
775
1760
|
`@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`,
|
|
@@ -782,13 +1767,24 @@ function evaluateTtsxConfigPlugins(
|
|
|
782
1767
|
);
|
|
783
1768
|
}
|
|
784
1769
|
if (result.status !== 0) {
|
|
1770
|
+
// The evaluator already said why on its own stderr. Reporting only the
|
|
1771
|
+
// exit code discards that and leaves the caller — a test, a CI log, a
|
|
1772
|
+
// user with a typo in one contributor — holding a number.
|
|
1773
|
+
const reason = configEvaluatorFailureReason(result.stderr);
|
|
785
1774
|
throw new Error(
|
|
786
|
-
`@ttsc/lint: lint config ${configPath} evaluation failed
|
|
1775
|
+
`@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` +
|
|
1776
|
+
(reason === "" ? "" : "\n" + reason),
|
|
787
1777
|
);
|
|
788
1778
|
}
|
|
789
|
-
let payload: {
|
|
1779
|
+
let payload: {
|
|
1780
|
+
dependencies?: ConfigDependencyFingerprint[];
|
|
1781
|
+
entries?: ConfigPluginEntry[];
|
|
1782
|
+
};
|
|
790
1783
|
try {
|
|
791
|
-
payload = JSON.parse(
|
|
1784
|
+
payload = JSON.parse(fs.readFileSync(outputPath, "utf8")) as {
|
|
1785
|
+
dependencies?: ConfigDependencyFingerprint[];
|
|
1786
|
+
entries?: ConfigPluginEntry[];
|
|
1787
|
+
};
|
|
792
1788
|
} catch (error) {
|
|
793
1789
|
throw new Error(
|
|
794
1790
|
`@ttsc/lint: lint config ${configPath} evaluator returned invalid JSON: ${
|
|
@@ -796,12 +1792,25 @@ function evaluateTtsxConfigPlugins(
|
|
|
796
1792
|
}`,
|
|
797
1793
|
);
|
|
798
1794
|
}
|
|
799
|
-
|
|
800
|
-
|
|
1795
|
+
if (!Array.isArray(payload.entries)) {
|
|
1796
|
+
throw new Error(
|
|
1797
|
+
`@ttsc/lint: lint config ${configPath} evaluator omitted its plugin-entry array`,
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
const entries = payload.entries.map((entry) => {
|
|
801
1801
|
// The ttsx extractor already resolved each plugin object's
|
|
802
1802
|
// `source` to an absolute directory path. Validate the shape but
|
|
803
1803
|
// skip the specifier-resolution branch — re-routing a directory
|
|
804
1804
|
// through `createRequire().resolve` would fail.
|
|
1805
|
+
if (
|
|
1806
|
+
entry === null ||
|
|
1807
|
+
typeof entry !== "object" ||
|
|
1808
|
+
typeof entry.namespace !== "string"
|
|
1809
|
+
) {
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`@ttsc/lint: lint config ${configPath} evaluator returned a malformed plugin entry`,
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
805
1814
|
if (!NAMESPACE_PATTERN.test(entry.namespace)) {
|
|
806
1815
|
throw new Error(
|
|
807
1816
|
`@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(entry.namespace)} must match /^[a-z][a-z0-9_-]*$/`,
|
|
@@ -827,11 +1836,48 @@ function evaluateTtsxConfigPlugins(
|
|
|
827
1836
|
}
|
|
828
1837
|
return { namespace: entry.namespace, source: entry.source };
|
|
829
1838
|
});
|
|
1839
|
+
const dependencies = normalizeConfigDependencyFingerprints(
|
|
1840
|
+
payload.dependencies,
|
|
1841
|
+
);
|
|
1842
|
+
if (dependencies === undefined) {
|
|
1843
|
+
throw new Error(
|
|
1844
|
+
`@ttsc/lint: lint config ${configPath} evaluator returned malformed dependency fingerprints`,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
return { dependencies, entries };
|
|
830
1848
|
} finally {
|
|
831
1849
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
832
1850
|
}
|
|
833
1851
|
}
|
|
834
1852
|
|
|
1853
|
+
/**
|
|
1854
|
+
* What the evaluator said before it gave up, trimmed to the part that explains
|
|
1855
|
+
* it.
|
|
1856
|
+
*
|
|
1857
|
+
* The build banner and progress lines are the same on success, so they say
|
|
1858
|
+
* nothing about the failure; the tail is where the thrown reason lands. Kept to
|
|
1859
|
+
* a few lines because this is appended to an exception message, not a log.
|
|
1860
|
+
*/
|
|
1861
|
+
function configEvaluatorFailureReason(stderr: string | undefined): string {
|
|
1862
|
+
const lines = (stderr ?? "")
|
|
1863
|
+
.split(/\r?\n/)
|
|
1864
|
+
.map((line) => line.trimEnd())
|
|
1865
|
+
.filter((line) => line.trim() !== "");
|
|
1866
|
+
return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n");
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const CONFIG_EVALUATOR_REASON_LINES = 5;
|
|
1870
|
+
|
|
1871
|
+
function forwardConfigEvaluatorStreams(
|
|
1872
|
+
stdout: string | null | undefined,
|
|
1873
|
+
stderr: string | null | undefined,
|
|
1874
|
+
): void {
|
|
1875
|
+
// Both child streams are human output. Parent stdout is reserved for compiler
|
|
1876
|
+
// JSON or LSP frames, so even a user console.log is redirected.
|
|
1877
|
+
if (stdout) process.stderr.write(stdout);
|
|
1878
|
+
if (stderr) process.stderr.write(stderr);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
835
1881
|
// ────────────────────────────────────────────────────────────────────────────
|
|
836
1882
|
// Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
|
|
837
1883
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -840,7 +1886,7 @@ function evaluateTtsxConfigPlugins(
|
|
|
840
1886
|
* Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
|
|
841
1887
|
* `configCacheVersion`; bump both when the cached shape changes.
|
|
842
1888
|
*/
|
|
843
|
-
const CONFIG_CACHE_VERSION = "
|
|
1889
|
+
const CONFIG_CACHE_VERSION = "v5";
|
|
844
1890
|
|
|
845
1891
|
/**
|
|
846
1892
|
* Directory shared by this factory and the Go sidecar for cached lint configs.
|
|
@@ -888,7 +1934,7 @@ function configCacheKey(kind: string, configPath: string): string {
|
|
|
888
1934
|
*/
|
|
889
1935
|
function readConfigPluginCache(
|
|
890
1936
|
cacheKey: string,
|
|
891
|
-
):
|
|
1937
|
+
): ConfigPluginEvaluation | undefined {
|
|
892
1938
|
let body: string;
|
|
893
1939
|
try {
|
|
894
1940
|
body = fs.readFileSync(
|
|
@@ -900,7 +1946,22 @@ function readConfigPluginCache(
|
|
|
900
1946
|
}
|
|
901
1947
|
try {
|
|
902
1948
|
const parsed: unknown = JSON.parse(body);
|
|
903
|
-
|
|
1949
|
+
if (
|
|
1950
|
+
parsed === null ||
|
|
1951
|
+
typeof parsed !== "object" ||
|
|
1952
|
+
!Array.isArray((parsed as ConfigPluginEvaluation).entries) ||
|
|
1953
|
+
!Array.isArray((parsed as ConfigPluginEvaluation).dependencies)
|
|
1954
|
+
) {
|
|
1955
|
+
return undefined;
|
|
1956
|
+
}
|
|
1957
|
+
const dependencies = normalizeConfigDependencyFingerprints(
|
|
1958
|
+
(parsed as ConfigPluginEvaluation).dependencies,
|
|
1959
|
+
);
|
|
1960
|
+
if (dependencies === undefined) return undefined;
|
|
1961
|
+
return {
|
|
1962
|
+
dependencies,
|
|
1963
|
+
entries: (parsed as ConfigPluginEvaluation).entries,
|
|
1964
|
+
};
|
|
904
1965
|
} catch {
|
|
905
1966
|
return undefined;
|
|
906
1967
|
}
|
|
@@ -914,19 +1975,186 @@ function readConfigPluginCache(
|
|
|
914
1975
|
*/
|
|
915
1976
|
function writeConfigPluginCache(
|
|
916
1977
|
cacheKey: string,
|
|
917
|
-
|
|
1978
|
+
evaluation: ConfigPluginEvaluation,
|
|
918
1979
|
): void {
|
|
919
1980
|
try {
|
|
920
1981
|
const dir = configCacheDir();
|
|
921
1982
|
fs.mkdirSync(dir, { recursive: true });
|
|
922
|
-
const tmp = path.join(
|
|
923
|
-
|
|
924
|
-
|
|
1983
|
+
const tmp = path.join(
|
|
1984
|
+
dir,
|
|
1985
|
+
`${cacheKey}.${process.pid}.${randomUUID()}.tmp`,
|
|
1986
|
+
);
|
|
1987
|
+
try {
|
|
1988
|
+
fs.writeFileSync(tmp, JSON.stringify(evaluation), "utf8");
|
|
1989
|
+
fs.renameSync(tmp, path.join(dir, `${cacheKey}.json`));
|
|
1990
|
+
} finally {
|
|
1991
|
+
try {
|
|
1992
|
+
fs.unlinkSync(tmp);
|
|
1993
|
+
} catch {
|
|
1994
|
+
// A successful rename already consumed the temporary path.
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
925
1997
|
} catch {
|
|
926
1998
|
// Cold cache on failure — the next invocation re-evaluates.
|
|
927
1999
|
}
|
|
928
2000
|
}
|
|
929
2001
|
|
|
2002
|
+
function normalizeConfigDependencyFingerprints(
|
|
2003
|
+
value: unknown,
|
|
2004
|
+
): ConfigDependencyFingerprint[] | undefined {
|
|
2005
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
2006
|
+
const dependencies = new Map<string, ConfigDependencyFingerprint>();
|
|
2007
|
+
for (const candidate of value) {
|
|
2008
|
+
if (
|
|
2009
|
+
candidate === null ||
|
|
2010
|
+
typeof candidate !== "object" ||
|
|
2011
|
+
typeof (candidate as ConfigDependencyFingerprint).path !== "string" ||
|
|
2012
|
+
typeof (candidate as ConfigDependencyFingerprint).digest !== "string" ||
|
|
2013
|
+
!["directory", "file", "optional-file"].includes(
|
|
2014
|
+
(candidate as ConfigDependencyFingerprint).kind,
|
|
2015
|
+
) ||
|
|
2016
|
+
!["cache", "watch"].includes(
|
|
2017
|
+
(candidate as ConfigDependencyFingerprint).scope,
|
|
2018
|
+
)
|
|
2019
|
+
) {
|
|
2020
|
+
return undefined;
|
|
2021
|
+
}
|
|
2022
|
+
const candidatePath = (candidate as ConfigDependencyFingerprint).path;
|
|
2023
|
+
const digest = (candidate as ConfigDependencyFingerprint).digest;
|
|
2024
|
+
const kind = (candidate as ConfigDependencyFingerprint).kind;
|
|
2025
|
+
const scope = (candidate as ConfigDependencyFingerprint).scope;
|
|
2026
|
+
if (!path.isAbsolute(candidatePath) || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
2027
|
+
return undefined;
|
|
2028
|
+
}
|
|
2029
|
+
const location = path.resolve(candidatePath);
|
|
2030
|
+
const previous = dependencies.get(location);
|
|
2031
|
+
if (
|
|
2032
|
+
previous !== undefined &&
|
|
2033
|
+
(previous.digest !== digest ||
|
|
2034
|
+
previous.kind !== kind ||
|
|
2035
|
+
previous.scope !== scope)
|
|
2036
|
+
) {
|
|
2037
|
+
return undefined;
|
|
2038
|
+
}
|
|
2039
|
+
dependencies.set(location, {
|
|
2040
|
+
digest,
|
|
2041
|
+
kind,
|
|
2042
|
+
path: location,
|
|
2043
|
+
scope,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
return [...dependencies.values()].sort((left, right) =>
|
|
2047
|
+
left.path.localeCompare(right.path),
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function configDependenciesAreCurrent(
|
|
2052
|
+
dependencies: readonly ConfigDependencyFingerprint[],
|
|
2053
|
+
): boolean {
|
|
2054
|
+
if (dependencies.length === 0) return false;
|
|
2055
|
+
return dependencies.every((dependency) => {
|
|
2056
|
+
if (!/^[0-9a-f]{64}$/.test(dependency.digest)) return false;
|
|
2057
|
+
try {
|
|
2058
|
+
const digest =
|
|
2059
|
+
dependency.kind === "directory"
|
|
2060
|
+
? configDirectoryDigest(dependency.path)
|
|
2061
|
+
: dependency.kind === "optional-file"
|
|
2062
|
+
? configOptionalFileDigest(dependency.path)
|
|
2063
|
+
: createHash("sha256")
|
|
2064
|
+
.update(fs.readFileSync(dependency.path))
|
|
2065
|
+
.digest("hex");
|
|
2066
|
+
return digest === dependency.digest;
|
|
2067
|
+
} catch {
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function configDirectoryDigest(location: string): string {
|
|
2074
|
+
const entries: Buffer[] = [];
|
|
2075
|
+
if (process.platform === "win32") {
|
|
2076
|
+
for (const entry of fs.readdirSync(location, { withFileTypes: true })) {
|
|
2077
|
+
let target = Buffer.alloc(0);
|
|
2078
|
+
if (entry.isSymbolicLink()) {
|
|
2079
|
+
try {
|
|
2080
|
+
target = Buffer.from(
|
|
2081
|
+
fs.readlinkSync(path.join(location, entry.name)),
|
|
2082
|
+
"utf8",
|
|
2083
|
+
);
|
|
2084
|
+
} catch {
|
|
2085
|
+
target = Buffer.from("<unreadable>");
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
entries.push(
|
|
2089
|
+
configDirectoryDigestRecord(Buffer.from(entry.name), entry, target),
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
} else {
|
|
2093
|
+
for (const entry of fs.readdirSync(location, {
|
|
2094
|
+
encoding: "buffer",
|
|
2095
|
+
withFileTypes: true,
|
|
2096
|
+
})) {
|
|
2097
|
+
let target = Buffer.alloc(0);
|
|
2098
|
+
if (entry.isSymbolicLink()) {
|
|
2099
|
+
try {
|
|
2100
|
+
target = fs.readlinkSync(
|
|
2101
|
+
Buffer.concat([
|
|
2102
|
+
Buffer.from(location),
|
|
2103
|
+
Buffer.from(path.sep),
|
|
2104
|
+
entry.name,
|
|
2105
|
+
]),
|
|
2106
|
+
{ encoding: "buffer" },
|
|
2107
|
+
);
|
|
2108
|
+
} catch {
|
|
2109
|
+
target = Buffer.from("<unreadable>");
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
entries.push(configDirectoryDigestRecord(entry.name, entry, target));
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
entries.sort(Buffer.compare);
|
|
2116
|
+
const serialized = Buffer.concat(
|
|
2117
|
+
entries.flatMap((entry, index) =>
|
|
2118
|
+
index === 0 ? [entry] : [Buffer.from([0]), entry],
|
|
2119
|
+
),
|
|
2120
|
+
);
|
|
2121
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function configDirectoryDigestRecord(
|
|
2125
|
+
name: Buffer,
|
|
2126
|
+
entry: {
|
|
2127
|
+
isDirectory(): boolean;
|
|
2128
|
+
isFile(): boolean;
|
|
2129
|
+
isSymbolicLink(): boolean;
|
|
2130
|
+
},
|
|
2131
|
+
target: Buffer,
|
|
2132
|
+
): Buffer {
|
|
2133
|
+
const kind = entry.isDirectory()
|
|
2134
|
+
? "directory"
|
|
2135
|
+
: entry.isFile()
|
|
2136
|
+
? "file"
|
|
2137
|
+
: entry.isSymbolicLink()
|
|
2138
|
+
? "symlink"
|
|
2139
|
+
: "other";
|
|
2140
|
+
return Buffer.concat([name, Buffer.from("\0" + kind + "\0"), target]);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function configOptionalFileDigest(location: string): string {
|
|
2144
|
+
try {
|
|
2145
|
+
if (fs.statSync(location).isFile()) {
|
|
2146
|
+
return createHash("sha256")
|
|
2147
|
+
.update(
|
|
2148
|
+
Buffer.concat([Buffer.from("file\0"), fs.readFileSync(location)]),
|
|
2149
|
+
)
|
|
2150
|
+
.digest("hex");
|
|
2151
|
+
}
|
|
2152
|
+
} catch {
|
|
2153
|
+
// Missing, unreadable, and non-file candidates share the absent state.
|
|
2154
|
+
}
|
|
2155
|
+
return createHash("sha256").update("missing\0").digest("hex");
|
|
2156
|
+
}
|
|
2157
|
+
|
|
930
2158
|
// ────────────────────────────────────────────────────────────────────────────
|
|
931
2159
|
// Shared helpers
|
|
932
2160
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -1145,6 +2373,46 @@ function nodeConfigLoaderEnv(configPath: string): NodeJS.ProcessEnv {
|
|
|
1145
2373
|
return env;
|
|
1146
2374
|
}
|
|
1147
2375
|
|
|
2376
|
+
/**
|
|
2377
|
+
* Where the isolated config evaluator lives.
|
|
2378
|
+
*
|
|
2379
|
+
* An explicit override wins. Otherwise the launcher is resolved out of a ttsc
|
|
2380
|
+
* installation one of the anchors can see, because a bare command name only
|
|
2381
|
+
* works when a bin link happens to be on PATH — which it is for a spawned CLI
|
|
2382
|
+
* and is not for a host that loaded this descriptor in process. The bare name
|
|
2383
|
+
* remains the last resort for an installation none of them reach.
|
|
2384
|
+
*/
|
|
2385
|
+
function resolveTtsxLauncher(anchors: readonly string[]): string {
|
|
2386
|
+
const explicit = process.env.TTSC_TTSX_BINARY?.trim();
|
|
2387
|
+
if (explicit) return explicit;
|
|
2388
|
+
for (const anchor of anchors) {
|
|
2389
|
+
const launcher = ttsxLauncherFrom(anchor);
|
|
2390
|
+
if (launcher !== undefined) return launcher;
|
|
2391
|
+
}
|
|
2392
|
+
return "ttsx";
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function ttsxLauncherFrom(anchor: string): string | undefined {
|
|
2396
|
+
try {
|
|
2397
|
+
// Only the manifest is exported, so the launcher is derived from where the
|
|
2398
|
+
// manifest resolved rather than requested as a subpath. Resolution is
|
|
2399
|
+
// anchored on the config being evaluated, which is how every other
|
|
2400
|
+
// resolution in this descriptor is anchored and picks the ttsc the project
|
|
2401
|
+
// actually installed.
|
|
2402
|
+
const manifest = createRequire(anchor).resolve("ttsc/package.json");
|
|
2403
|
+
const launcher = path.join(
|
|
2404
|
+
path.dirname(manifest),
|
|
2405
|
+
"lib",
|
|
2406
|
+
"launcher",
|
|
2407
|
+
"ttsx.js",
|
|
2408
|
+
);
|
|
2409
|
+
if (fs.existsSync(launcher)) return launcher;
|
|
2410
|
+
} catch {
|
|
2411
|
+
// This anchor cannot see ttsc; the caller tries the next one.
|
|
2412
|
+
}
|
|
2413
|
+
return undefined;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
1148
2416
|
function ttsxThroughNodeIfNeeded(binary: string): {
|
|
1149
2417
|
binary: string;
|
|
1150
2418
|
prefix: string[];
|