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