@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/lib/index.js CHANGED
@@ -17,7 +17,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.TTSX_EXTRACTOR_SCRIPT = void 0;
20
21
  exports.default = createTtscPlugin;
22
+ const node_buffer_1 = require("node:buffer");
21
23
  const node_child_process_1 = require("node:child_process");
22
24
  const node_crypto_1 = require("node:crypto");
23
25
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -25,6 +27,7 @@ const node_module_1 = require("node:module");
25
27
  const node_os_1 = __importDefault(require("node:os"));
26
28
  const node_path_1 = __importDefault(require("node:path"));
27
29
  const node_url_1 = require("node:url");
30
+ const configEvaluatorFailure_1 = require("./internal/configEvaluatorFailure");
28
31
  __exportStar(require("./defaultFormat"), exports);
29
32
  __exportStar(require("./structures/index"), exports);
30
33
  // Namespace becomes the rule-name prefix (`<ns>/<rule>`). Mirrors ESLint
@@ -101,6 +104,9 @@ function createTtscPlugin(context) {
101
104
  diagnosticsTiming: true,
102
105
  lsp: true,
103
106
  projectContextArgs: true,
107
+ projectDiagnostics: true,
108
+ projectInputs: true,
109
+ residentCheck: true,
104
110
  threadingArgs: true,
105
111
  },
106
112
  name: "@ttsc/lint",
@@ -319,56 +325,56 @@ function tsconfigBaseDir(context) {
319
325
  return node_path_1.default.resolve(cwd);
320
326
  }
321
327
  function readConfigPluginEntries(configPath, context) {
322
- const ext = node_path_1.default.extname(configPath).toLowerCase();
323
- if (ext === ".json") {
324
- return readJsonConfigPlugins(configPath, context);
325
- }
326
- if (ext === ".js" || ext === ".cjs") {
327
- return readCjsConfigPlugins(configPath);
328
- }
329
- // .ts, .cts, .mts, .mjs all need ttsx-side evaluation. .mjs sneaks in
330
- // here because Node can't `require()` an ESM file synchronously.
328
+ // A JSON config that can bring no contributor with it — no `plugins` map and
329
+ // no `extends` chain to follow — has nothing to extract, and reading it runs
330
+ // no user code, so the isolated evaluator is not needed to keep its strings
331
+ // away from stdout. Skipping it there matters beyond the cost: the evaluator
332
+ // is a real subprocess, and a host that only wanted to know whether there
333
+ // were contributors would otherwise depend on a launcher being resolvable and
334
+ // on a compiler accepting one more invocation.
335
+ if (jsonConfigDeclaresNoContributor(configPath))
336
+ return [];
337
+ // Every other config uses the same isolated evaluator. Executable config can
338
+ // name contributor packages whose top-level code writes to stdout, so loading
339
+ // it in this host process would corrupt CLI JSON or preface the first LSP
340
+ // frame.
331
341
  return readTtsxConfigPlugins(configPath, context);
332
342
  }
333
- function readJsonConfigPlugins(configPath, context) {
334
- let parsed;
343
+ function jsonConfigDeclaresNoContributor(configPath) {
344
+ if (node_path_1.default.extname(configPath).toLowerCase() !== ".json")
345
+ return false;
335
346
  try {
336
- // Strip a leading UTF-8 BOM so files saved by Windows editors
337
- // (Notepad++, some VS Code setups) round-trip through `JSON.parse`
338
- // without an opaque "Unexpected token" failure.
339
- const text = node_fs_1.default.readFileSync(configPath, "utf8").replace(/^\uFEFF/, "");
340
- parsed = JSON.parse(text);
347
+ const value = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
348
+ return !configMayDeclareContributor(value);
341
349
  }
342
- catch (error) {
343
- throw new Error(`@ttsc/lint: failed to parse lint config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
350
+ catch {
351
+ // A malformed config is the evaluator's diagnostic to report, not this
352
+ // shortcut's, so it falls through to the path that reports it.
353
+ return false;
344
354
  }
345
- // In JSON, plugin values can only be strings (npm specifiers) — there
346
- // is no way to attach an in-memory plugin object inside a JSON file.
347
- return collectPluginObjectsFromConfig(parsed)
348
- .flatMap((map) => Object.entries(map))
349
- .map(([namespace, value]) => {
350
- if (!NAMESPACE_PATTERN.test(namespace)) {
351
- throw new Error(`@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`);
352
- }
353
- if (typeof value !== "string" || value.length === 0) {
354
- throw new Error(`@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(namespace)} must point at a package specifier string`);
355
- }
356
- const plugin = loadContributorPluginViaRequire(value, context, namespace, configPath);
357
- return { namespace, source: plugin.source };
358
- });
359
355
  }
360
- function readCjsConfigPlugins(configPath) {
361
- let mod;
362
- try {
363
- const requireFromConfig = (0, node_module_1.createRequire)(configPath);
364
- mod = requireFromConfig(configPath);
356
+ /**
357
+ * Whether any object in a config could still bring a contributor with it.
358
+ *
359
+ * A `plugins` map names one directly. An `extends` chain names one indirectly:
360
+ * the base it points at may be executable and may declare contributors of its
361
+ * own, and following that chain is the evaluator's job. Treating a config that
362
+ * extends anything as undecidable here is what keeps this shortcut from
363
+ * silently dropping a contributor the base would have supplied.
364
+ */
365
+ function configMayDeclareContributor(value) {
366
+ if (Array.isArray(value)) {
367
+ return value.some((entry) => configMayDeclareContributor(entry));
365
368
  }
366
- catch (error) {
367
- throw new Error(`@ttsc/lint: failed to load lint config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
369
+ if (value === null || typeof value !== "object")
370
+ return false;
371
+ const record = value;
372
+ for (const key of ["plugins", "extends"]) {
373
+ const declared = record[key];
374
+ if (declared !== undefined && declared !== null)
375
+ return true;
368
376
  }
369
- return collectPluginObjectsFromConfig(unwrapDefault(mod))
370
- .flatMap((map) => Object.entries(map))
371
- .map(([namespace, value]) => normalizePluginValue(namespace, value, configPath));
377
+ return false;
372
378
  }
373
379
  // TypeScript source written to a temp file and executed via ttsx. The
374
380
  // %CONFIG_IMPORT% placeholder is replaced with a JSON-quoted file URL
@@ -377,7 +383,30 @@ function readCjsConfigPlugins(configPath) {
377
383
  // as a JSON array for the parent process to parse — avoiding the need to
378
384
  // serialise arbitrary in-memory plugin objects across the process boundary.
379
385
  // The URL lives in a variable so tsgo does not statically resolve it.
380
- const TTSX_EXTRACTOR_SCRIPT = `const configUrl = %CONFIG_IMPORT%;
386
+ /**
387
+ * The descriptor extractor's emitted source.
388
+ *
389
+ * Exported so a regression can inspect the same bytes the loader executes. The
390
+ * template consumes its own escapes, so reading this file's text instead would
391
+ * check characters no consumer ever sees.
392
+ */
393
+ exports.TTSX_EXTRACTOR_SCRIPT = `// @ts-ignore -- internal loader must not require user-installed Node typings.
394
+ import * as fs from "node:fs";
395
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
396
+ import { Buffer } from "node:buffer";
397
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
398
+ import { createHash } from "node:crypto";
399
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
400
+ import { createRequire, registerHooks } from "node:module";
401
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
402
+ import * as path from "node:path";
403
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
404
+ import { fileURLToPath, pathToFileURL } from "node:url";
405
+
406
+ const configUrl = %CONFIG_IMPORT%;
407
+ const outputPath = %CONFIG_OUTPUT%;
408
+ const resolutionRoot = path.resolve(%CONFIG_ROOT%);
409
+ const requireFromConfig = createRequire(configUrl);
381
410
  const CONFIG_KEYS = new Set<string>([
382
411
  "files",
383
412
  "ignores",
@@ -386,36 +415,969 @@ const CONFIG_KEYS = new Set<string>([
386
415
  "rules",
387
416
  "format",
388
417
  ]);
389
- const importedConfig = await import(configUrl);
418
+ const dependencies = new Map<string, {
419
+ digest: string;
420
+ kind: "directory" | "file" | "optional-file";
421
+ path: string;
422
+ owners: Set<string>;
423
+ }>();
424
+ const graphNodes = new Map<string, string>();
425
+ const graphEdges: Array<{
426
+ child: string;
427
+ packageBoundary: boolean;
428
+ parent: string;
429
+ }> = [];
430
+ const configLocation = fileURLToPath(configUrl);
431
+ // Every spelling of this config the module system might key an edge under.
432
+ //
433
+ // Which one it uses is not knowable from here, and guessing has failed in both
434
+ // directions. A path handed in by another producer can be escaped by a rule
435
+ // Node does not share. Node respells a resolved file module through its real
436
+ // path unless "--preserve-symlinks" is set, so a config reached through a
437
+ // symlinked directory is keyed by its target. And a Windows 8.3 short name is
438
+ // not a symlink: fs.realpathSync expands it, the module resolver does not, so
439
+ // asking the volume there produces a spelling no edge carries.
440
+ //
441
+ // A seed that names a URL no edge was keyed under sits on a node with no
442
+ // outgoing edges, the walk ends immediately, and every dependency recorded
443
+ // after the first import is demoted from watch to cache. That failure is
444
+ // silent: the build still succeeds and simply stops reacting. Seeding every
445
+ // spelling costs one extra queue entry and cannot be wrong.
446
+ const configUrlSpellings = [
447
+ ...new Set([
448
+ configUrl,
449
+ pathToFileURL(configLocation).href,
450
+ pathToFileURL(realConfigLocation()).href,
451
+ ]),
452
+ ];
453
+ for (const spelling of configUrlSpellings) {
454
+ graphNodes.set(spelling, configLocation);
455
+ }
456
+ recordDependency(
457
+ "file",
458
+ configLocation,
459
+ createHash("sha256").update(fs.readFileSync(configLocation)).digest("hex"),
460
+ configUrlSpellings,
461
+ );
462
+ recordPackageManifests(configLocation, configUrlSpellings);
390
463
 
391
464
  declare const process: {
392
465
  cwd(): string;
466
+ platform: string;
393
467
  stdout: { write(value: string): void };
394
468
  stderr: { write(value: string): void };
395
469
  exit(code?: number): never;
396
470
  };
397
471
 
472
+ const hooks = registerHooks({
473
+ resolve(specifier, context, nextResolve) {
474
+ const resolved = nextResolve(specifier, context);
475
+ if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
476
+ return resolved;
477
+ }
478
+ const url = new URL(resolved.url).href;
479
+ const parent = context.parentURL && new URL(context.parentURL).href;
480
+ const location = fileURLToPath(url);
481
+ // The entry is recognized by what was asked for, not only by what came
482
+ // back. A module URL is assigned by whoever loaded it: a compiling loader
483
+ // can serve the config from its emitted output, and a platform can hand
484
+ // back a different spelling of the same file. Either way the URL bears no
485
+ // resemblance to the one this process was given, so the config's own
486
+ // imports would be rejected here — their parent is a URL no node was
487
+ // recorded under — and the graph would collapse to the records made before
488
+ // the first import. The request itself is unambiguous, so it decides.
489
+ const entry =
490
+ specifier === configUrl ||
491
+ url === new URL(configUrl).href ||
492
+ samePhysicalPath(location, configLocation);
493
+ if (!entry && (parent === undefined || !graphNodes.has(parent))) {
494
+ return resolved;
495
+ }
496
+ graphNodes.set(url, location);
497
+ if (parent !== undefined) {
498
+ graphEdges.push({
499
+ child: url,
500
+ packageBoundary:
501
+ pathHasNodeModules(location) && !isLocalModuleSpecifier(specifier),
502
+ parent,
503
+ });
504
+ recordResolutionTopology(
505
+ specifier,
506
+ parent,
507
+ url,
508
+ location,
509
+ context.conditions,
510
+ );
511
+ }
512
+ try {
513
+ recordDependency(
514
+ "file",
515
+ location,
516
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
517
+ [url],
518
+ );
519
+ } catch {
520
+ // The evaluator remains authoritative for the load error. An unreadable
521
+ // dependency simply makes this result non-cacheable in the parent.
522
+ recordDependency("file", location, "", [url]);
523
+ }
524
+ return resolved;
525
+ },
526
+ });
527
+
398
528
  try {
529
+ const importedConfig = configLocation.toLowerCase().endsWith(".json")
530
+ ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
531
+ : await import(configUrl);
399
532
  const current = await resolveConfig(importedConfig, true);
400
533
  const pluginMaps = collectPluginObjects(current);
401
534
  const entries: Array<{ namespace: string; source: string }> = [];
402
535
  for (const map of pluginMaps) {
403
536
  for (const [namespace, value] of Object.entries(map)) {
404
537
  const source = extractPluginSource(value);
405
- if (source === undefined) continue;
538
+ if (source === undefined || source.length === 0) {
539
+ throw new Error(
540
+ \`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
541
+ );
542
+ }
406
543
  entries.push({ namespace, source });
407
544
  }
408
545
  }
409
- process.stdout.write(JSON.stringify({ entries }));
546
+ fs.writeFileSync(outputPath, JSON.stringify({
547
+ dependencies: finalizeDependencies(),
548
+ entries,
549
+ }), "utf8");
410
550
  } catch (error) {
411
551
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
412
552
  process.exit(1);
553
+ } finally {
554
+ hooks.deregister();
413
555
  }
414
556
 
415
557
  function isObject(value: unknown): value is Record<string, unknown> {
416
558
  return value !== null && typeof value === "object";
417
559
  }
418
560
 
561
+ function recordDependency(
562
+ kind: "directory" | "file" | "optional-file",
563
+ location: string,
564
+ digest: string,
565
+ owners: readonly string[],
566
+ ): void {
567
+ const key = kind + "\\0" + location;
568
+ const previous = dependencies.get(key);
569
+ const mergedOwners = previous?.owners ?? new Set<string>();
570
+ for (const owner of owners) mergedOwners.add(owner);
571
+ dependencies.set(key, {
572
+ digest: previous !== undefined && previous.digest !== digest ? "" : digest,
573
+ kind,
574
+ owners: mergedOwners,
575
+ path: location,
576
+ });
577
+ }
578
+
579
+ function isLocalModuleSpecifier(specifier: string): boolean {
580
+ return specifier.startsWith(".") ||
581
+ specifier.startsWith("/") ||
582
+ specifier.startsWith("file:") ||
583
+ /^[A-Za-z]:[\\\\/]/.test(specifier);
584
+ }
585
+
586
+ function pathHasNodeModules(location: string): boolean {
587
+ return location.replaceAll("\\\\", "/").split("/").includes("node_modules");
588
+ }
589
+
590
+ function recordResolutionTopology(
591
+ specifier: string,
592
+ parentUrl: string,
593
+ childUrl: string,
594
+ childLocation: string,
595
+ conditions: readonly string[],
596
+ ): void {
597
+ const owners = [parentUrl, childUrl];
598
+ const parentLocation = graphNodes.get(parentUrl);
599
+ if (parentLocation !== undefined && isLocalModuleSpecifier(specifier)) {
600
+ recordDirectoryDependency(path.dirname(parentLocation), owners);
601
+ }
602
+ recordDirectoryDependency(path.dirname(childLocation), owners);
603
+ recordPackageManifests(childLocation, owners);
604
+ if (parentLocation !== undefined && !isLocalModuleSpecifier(specifier)) {
605
+ recordNodeModulesSearchDirectories(
606
+ parentLocation,
607
+ specifier,
608
+ childLocation,
609
+ owners,
610
+ conditions,
611
+ );
612
+ }
613
+ }
614
+
615
+ function recordDirectoryDependency(
616
+ location: string,
617
+ owners: readonly string[],
618
+ ): void {
619
+ try {
620
+ recordDependency("directory", location, directoryDigest(location), owners);
621
+ } catch {
622
+ recordDependency("directory", location, "", owners);
623
+ }
624
+ }
625
+
626
+ function directoryDigest(location: string): string {
627
+ const entries: Buffer[] = [];
628
+ if (process.platform === "win32") {
629
+ for (const entry of fs.readdirSync(location, { withFileTypes: true })) {
630
+ let target = Buffer.alloc(0);
631
+ if (entry.isSymbolicLink()) {
632
+ try {
633
+ target = Buffer.from(
634
+ fs.readlinkSync(path.join(location, entry.name)),
635
+ "utf8",
636
+ );
637
+ } catch {
638
+ target = Buffer.from("<unreadable>");
639
+ }
640
+ }
641
+ entries.push(directoryDigestRecord(Buffer.from(entry.name), entry, target));
642
+ }
643
+ } else {
644
+ for (const entry of fs.readdirSync(location, {
645
+ encoding: "buffer",
646
+ withFileTypes: true,
647
+ })) {
648
+ let target = Buffer.alloc(0);
649
+ if (entry.isSymbolicLink()) {
650
+ try {
651
+ target = fs.readlinkSync(
652
+ Buffer.concat([
653
+ Buffer.from(location),
654
+ Buffer.from(path.sep),
655
+ entry.name,
656
+ ]),
657
+ { encoding: "buffer" },
658
+ );
659
+ } catch {
660
+ target = Buffer.from("<unreadable>");
661
+ }
662
+ }
663
+ entries.push(directoryDigestRecord(entry.name, entry, target));
664
+ }
665
+ }
666
+ entries.sort(Buffer.compare);
667
+ const serialized = Buffer.concat(
668
+ entries.flatMap((entry, index) =>
669
+ index === 0 ? [entry] : [Buffer.from([0]), entry],
670
+ ),
671
+ );
672
+ return createHash("sha256").update(serialized).digest("hex");
673
+ }
674
+
675
+ function directoryDigestRecord(
676
+ name: Buffer,
677
+ entry: {
678
+ isDirectory(): boolean;
679
+ isFile(): boolean;
680
+ isSymbolicLink(): boolean;
681
+ },
682
+ target: Buffer,
683
+ ): Buffer {
684
+ const kind = entry.isDirectory()
685
+ ? "directory"
686
+ : entry.isFile()
687
+ ? "file"
688
+ : entry.isSymbolicLink()
689
+ ? "symlink"
690
+ : "other";
691
+ return Buffer.concat([name, Buffer.from("\\0" + kind + "\\0"), target]);
692
+ }
693
+
694
+ function optionalFileDigest(location: string): string {
695
+ try {
696
+ if (fs.statSync(location).isFile()) {
697
+ return createHash("sha256")
698
+ .update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)]))
699
+ .digest("hex");
700
+ }
701
+ } catch {
702
+ // Missing, unreadable, and non-file candidates share the absent state.
703
+ }
704
+ return createHash("sha256").update("missing\\0").digest("hex");
705
+ }
706
+
707
+ function recordOptionalFileDependency(
708
+ location: string,
709
+ owners: readonly string[],
710
+ ): boolean {
711
+ try {
712
+ if (fs.statSync(location).isFile()) {
713
+ recordDependency(
714
+ "file",
715
+ location,
716
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
717
+ owners,
718
+ );
719
+ return true;
720
+ }
721
+ } catch {
722
+ // The exact missing path remains a dependency of the resolution result.
723
+ }
724
+ recordDependency("optional-file", location, optionalFileDigest(location), owners);
725
+ return false;
726
+ }
727
+
728
+ function recordPackageManifests(
729
+ location: string,
730
+ owners: readonly string[],
731
+ ): void {
732
+ let current = path.dirname(location);
733
+ while (true) {
734
+ const manifest = path.join(current, "package.json");
735
+ if (recordOptionalFileDependency(manifest, owners)) return;
736
+ const parent = path.dirname(current);
737
+ if (parent === current || path.basename(current) === "node_modules") return;
738
+ current = parent;
739
+ }
740
+ }
741
+
742
+ function recordNodeModulesSearchDirectories(
743
+ parentLocation: string,
744
+ specifier: string,
745
+ childLocation: string,
746
+ owners: readonly string[],
747
+ conditions: readonly string[],
748
+ ): void {
749
+ const packageName = modulePackageName(specifier);
750
+ const scope =
751
+ specifier.startsWith("@") && specifier.includes("/")
752
+ ? specifier.slice(0, specifier.indexOf("/"))
753
+ : undefined;
754
+ let current = path.dirname(parentLocation);
755
+ while (true) {
756
+ // A newly created nearer node_modules directory can shadow the package
757
+ // selected by this evaluation, so missing search levels are dependencies.
758
+ recordDirectoryDependency(current, owners);
759
+ const modules = path.join(current, "node_modules");
760
+ try {
761
+ if (fs.statSync(modules).isDirectory()) {
762
+ recordDirectoryDependency(modules, owners);
763
+ if (scope !== undefined) {
764
+ const scoped = path.join(modules, scope);
765
+ try {
766
+ if (fs.statSync(scoped).isDirectory()) {
767
+ recordDirectoryDependency(scoped, owners);
768
+ }
769
+ } catch {
770
+ // The directory digest of node_modules records a missing scope.
771
+ }
772
+ }
773
+ if (packageName !== undefined) {
774
+ const selected = recordPackageCandidateTopology(
775
+ modules,
776
+ packageName,
777
+ specifier,
778
+ childLocation,
779
+ owners,
780
+ conditions,
781
+ );
782
+ if (
783
+ selected ||
784
+ resolvedPackageContains(modules, packageName, childLocation)
785
+ ) {
786
+ return;
787
+ }
788
+ }
789
+ }
790
+ } catch {
791
+ // Missing search levels do not participate in the current resolution.
792
+ }
793
+ if (
794
+ packageName === undefined &&
795
+ samePhysicalPath(current, resolutionRoot)
796
+ ) {
797
+ return;
798
+ }
799
+ const parent = path.dirname(current);
800
+ if (parent === current) return;
801
+ current = parent;
802
+ }
803
+ }
804
+
805
+ function recordPackageCandidateTopology(
806
+ modules: string,
807
+ packageName: string,
808
+ specifier: string,
809
+ childLocation: string,
810
+ owners: readonly string[],
811
+ conditions: readonly string[],
812
+ ): boolean {
813
+ const packageRoot = path.join(modules, packageName);
814
+ try {
815
+ if (!fs.statSync(packageRoot).isDirectory()) return false;
816
+ } catch {
817
+ return false;
818
+ }
819
+ const subpath = specifier
820
+ .slice(packageName.length)
821
+ .replace(/^[/\\\\]+/, "");
822
+ const rootTopology = recordPackageRootTopology(
823
+ packageRoot,
824
+ owners,
825
+ subpath === "",
826
+ subpath === "" ? "." : "./" + subpath.replaceAll("\\\\", "/"),
827
+ childLocation,
828
+ conditions,
829
+ );
830
+ if (subpath !== "" && !rootTopology.hasExports) {
831
+ return (
832
+ recordPackageSubpathTopology(
833
+ packageRoot,
834
+ subpath,
835
+ childLocation,
836
+ owners,
837
+ ) || rootTopology.selected
838
+ );
839
+ }
840
+ return rootTopology.selected;
841
+ }
842
+
843
+ function recordPackageRootTopology(
844
+ packageRoot: string,
845
+ owners: readonly string[],
846
+ useMain: boolean,
847
+ packageSubpath: string,
848
+ childLocation: string,
849
+ conditions: readonly string[],
850
+ ): { hasExports: boolean; selected: boolean } {
851
+ const normalizedRoot = path.resolve(packageRoot);
852
+ const manifest = path.join(normalizedRoot, "package.json");
853
+ const legacySelected = (): boolean =>
854
+ useMain &&
855
+ packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
856
+ if (!recordOptionalFileDependency(manifest, owners)) {
857
+ const selected = legacySelected();
858
+ if (!selected) {
859
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
860
+ }
861
+ return { hasExports: false, selected };
862
+ }
863
+ try {
864
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
865
+ if (value !== null && typeof value === "object") {
866
+ const metadata = value as Record<string, unknown>;
867
+ const hasExports =
868
+ metadata.exports !== undefined && metadata.exports !== null;
869
+ if (hasExports) {
870
+ const target = selectPackageExportsTarget(
871
+ metadata.exports,
872
+ packageSubpath,
873
+ new Set(conditions),
874
+ );
875
+ const candidate =
876
+ typeof target === "string"
877
+ ? packageExportsTarget(normalizedRoot, target)
878
+ : undefined;
879
+ const selected =
880
+ candidate !== undefined &&
881
+ packagePathCandidateMatchesChild(
882
+ candidate,
883
+ childLocation,
884
+ false,
885
+ );
886
+ if (selected) {
887
+ recordPackagePathCandidate(candidate, owners);
888
+ } else if (candidate !== undefined) {
889
+ // A nearer package the search skipped starts winning the moment its
890
+ // own active target appears, and neither the parent node_modules
891
+ // listing nor the manifest changes when only that file is created.
892
+ recordOptionalFileDependency(candidate, owners);
893
+ }
894
+ return { hasExports: true, selected };
895
+ }
896
+ let selected = legacySelected();
897
+ if (useMain && typeof metadata.main === "string") {
898
+ // CommonJS main is a legacy path, not an exports target. Node resolves
899
+ // it literally and permits absolute paths and paths outside the package.
900
+ const main = path.resolve(normalizedRoot, metadata.main);
901
+ recordPackagePathCandidate(main, owners);
902
+ selected =
903
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
904
+ selected;
905
+ }
906
+ if (!selected) {
907
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
908
+ }
909
+ return {
910
+ hasExports: false,
911
+ selected,
912
+ };
913
+ }
914
+ } catch {
915
+ // Node owns malformed-manifest diagnostics; the manifest digest is enough
916
+ // to invalidate this evaluation when its contents change.
917
+ }
918
+ const selected = legacySelected();
919
+ if (!selected) {
920
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
921
+ }
922
+ return { hasExports: false, selected };
923
+ }
924
+
925
+ // recordPackageIndexCandidates pins the LOAD_INDEX fallbacks of a package root
926
+ // this resolution walked past without selecting. An empty package directory, or
927
+ // one whose manifest declares no usable entry, becomes resolvable as soon as one
928
+ // of these files exists, and that creation changes neither the parent directory
929
+ // listing nor the manifest digest already recorded for the candidate.
930
+ function recordPackageIndexCandidates(
931
+ packageRoot: string,
932
+ useMain: boolean,
933
+ owners: readonly string[],
934
+ ): void {
935
+ if (!useMain) return;
936
+ for (const name of ["index.js", "index.json", "index.node"]) {
937
+ recordOptionalFileDependency(path.join(packageRoot, name), owners);
938
+ }
939
+ }
940
+
941
+ function selectPackageExportsTarget(
942
+ exportsValue: unknown,
943
+ packageSubpath: string,
944
+ conditions: ReadonlySet<string>,
945
+ ): string | null | undefined {
946
+ let mappings: unknown = exportsValue;
947
+ if (
948
+ typeof mappings === "string" ||
949
+ Array.isArray(mappings) ||
950
+ (isObject(mappings) &&
951
+ Object.keys(mappings).every((key) => !key.startsWith(".")))
952
+ ) {
953
+ if (packageSubpath !== ".") return undefined;
954
+ return selectPackageTarget(mappings, "", false, conditions);
955
+ }
956
+ if (!isObject(mappings)) return undefined;
957
+ if (
958
+ Object.prototype.hasOwnProperty.call(mappings, packageSubpath) &&
959
+ !packageSubpath.includes("*") &&
960
+ !packageSubpath.endsWith("/")
961
+ ) {
962
+ return selectPackageTarget(
963
+ mappings[packageSubpath],
964
+ "",
965
+ false,
966
+ conditions,
967
+ );
968
+ }
969
+ let bestMatch = "";
970
+ let bestSubpath = "";
971
+ for (const key of Object.keys(mappings)) {
972
+ const wildcard = key.indexOf("*");
973
+ if (
974
+ wildcard === -1 ||
975
+ key.lastIndexOf("*") !== wildcard ||
976
+ !packageSubpath.startsWith(key.slice(0, wildcard))
977
+ ) {
978
+ continue;
979
+ }
980
+ const trailer = key.slice(wildcard + 1);
981
+ if (
982
+ packageSubpath.length < key.length ||
983
+ !packageSubpath.endsWith(trailer) ||
984
+ packagePatternKeyCompare(bestMatch, key) !== 1
985
+ ) {
986
+ continue;
987
+ }
988
+ bestMatch = key;
989
+ bestSubpath = packageSubpath.slice(
990
+ wildcard,
991
+ packageSubpath.length - trailer.length,
992
+ );
993
+ }
994
+ return bestMatch === ""
995
+ ? undefined
996
+ : selectPackageTarget(
997
+ mappings[bestMatch],
998
+ bestSubpath,
999
+ true,
1000
+ conditions,
1001
+ );
1002
+ }
1003
+
1004
+ function selectPackageTarget(
1005
+ target: unknown,
1006
+ subpath: string,
1007
+ pattern: boolean,
1008
+ conditions: ReadonlySet<string>,
1009
+ ): string | null | undefined {
1010
+ if (typeof target === "string") {
1011
+ const selected = pattern ? target.replaceAll("*", subpath) : target;
1012
+ return validPackageExportsTarget(selected) ? selected : undefined;
1013
+ }
1014
+ if (Array.isArray(target)) {
1015
+ for (const item of target) {
1016
+ const selected = selectPackageTarget(
1017
+ item,
1018
+ subpath,
1019
+ pattern,
1020
+ conditions,
1021
+ );
1022
+ if (selected !== undefined && selected !== null) return selected;
1023
+ }
1024
+ return null;
1025
+ }
1026
+ if (isObject(target)) {
1027
+ for (const [condition, value] of Object.entries(target)) {
1028
+ if (condition !== "default" && !conditions.has(condition)) continue;
1029
+ const selected = selectPackageTarget(
1030
+ value,
1031
+ subpath,
1032
+ pattern,
1033
+ conditions,
1034
+ );
1035
+ if (selected !== undefined) return selected;
1036
+ }
1037
+ return undefined;
1038
+ }
1039
+ return target === null ? null : undefined;
1040
+ }
1041
+
1042
+ function packagePatternKeyCompare(left: string, right: string): number {
1043
+ const leftWildcard = left.indexOf("*");
1044
+ const rightWildcard = right.indexOf("*");
1045
+ const leftBase =
1046
+ leftWildcard === -1 ? left.length : leftWildcard + 1;
1047
+ const rightBase =
1048
+ rightWildcard === -1 ? right.length : rightWildcard + 1;
1049
+ if (leftBase > rightBase) return -1;
1050
+ if (rightBase > leftBase) return 1;
1051
+ if (leftWildcard === -1) return 1;
1052
+ if (rightWildcard === -1) return -1;
1053
+ if (left.length > right.length) return -1;
1054
+ if (right.length > left.length) return 1;
1055
+ return 0;
1056
+ }
1057
+
1058
+ function packageExportsTarget(
1059
+ packageRoot: string,
1060
+ target: string,
1061
+ ): string | undefined {
1062
+ if (!validPackageExportsTarget(target)) return undefined;
1063
+ try {
1064
+ // Node resolves an exports target as a URL against the package manifest,
1065
+ // so percent escapes, query strings, and fragments all take part in the
1066
+ // path it finally loads. Joining the raw target by hand diverges from that
1067
+ // whenever the target is anything but a plain relative path, and a target
1068
+ // Node resolves while this model rejects loses the selected file's
1069
+ // fingerprint, leaving a retargeted symlink cached as fresh.
1070
+ const packageUrl = pathToFileURL(path.join(packageRoot, "package.json"));
1071
+ const resolved = new URL(target, packageUrl);
1072
+ const packagePath = new URL(".", packageUrl).pathname;
1073
+ if (!resolved.pathname.startsWith(packagePath)) return undefined;
1074
+ return fileURLToPath(resolved);
1075
+ } catch {
1076
+ return undefined;
1077
+ }
1078
+ }
1079
+
1080
+ function validPackageExportsTarget(target: string): boolean {
1081
+ if (!target.startsWith("./") || /%2f|%5c/i.test(target)) return false;
1082
+ const components = target
1083
+ .slice(2)
1084
+ .replaceAll("\\\\", "/")
1085
+ .split("/");
1086
+ if (
1087
+ components.some(
1088
+ (component) => {
1089
+ try {
1090
+ const decoded = decodeURIComponent(component);
1091
+ return (
1092
+ decoded === "." ||
1093
+ decoded === ".." ||
1094
+ decoded.includes("/") ||
1095
+ decoded.includes("\\\\") ||
1096
+ decoded.toLowerCase() === "node_modules"
1097
+ );
1098
+ } catch {
1099
+ return true;
1100
+ }
1101
+ },
1102
+ )
1103
+ ) {
1104
+ return false;
1105
+ }
1106
+ return true;
1107
+ }
1108
+
1109
+ function packagePathCandidateMatchesChild(
1110
+ candidate: string,
1111
+ childLocation: string,
1112
+ legacy: boolean,
1113
+ ): boolean {
1114
+ let child: string;
1115
+ try {
1116
+ child = fs.realpathSync.native(childLocation);
1117
+ } catch {
1118
+ child = path.resolve(childLocation);
1119
+ }
1120
+ const candidates = legacy
1121
+ ? [
1122
+ candidate,
1123
+ candidate + ".js",
1124
+ candidate + ".json",
1125
+ candidate + ".node",
1126
+ path.join(candidate, "index.js"),
1127
+ path.join(candidate, "index.json"),
1128
+ path.join(candidate, "index.node"),
1129
+ ]
1130
+ : [candidate];
1131
+ return candidates.some((location) => {
1132
+ try {
1133
+ return sameResolutionPath(fs.realpathSync.native(location), child);
1134
+ } catch {
1135
+ return false;
1136
+ }
1137
+ });
1138
+ }
1139
+
1140
+ function recordPackageSubpathTopology(
1141
+ packageRoot: string,
1142
+ subpath: string,
1143
+ childLocation: string,
1144
+ owners: readonly string[],
1145
+ ): boolean {
1146
+ const candidate = boundedPackageTarget(packageRoot, subpath);
1147
+ if (candidate === undefined) return false;
1148
+ recordPackagePathCandidate(candidate, owners);
1149
+ let selected = packagePathCandidateMatchesChild(
1150
+ candidate,
1151
+ childLocation,
1152
+ true,
1153
+ );
1154
+ try {
1155
+ if (!fs.statSync(candidate).isDirectory()) return selected;
1156
+ } catch {
1157
+ return selected;
1158
+ }
1159
+ const manifest = path.join(candidate, "package.json");
1160
+ if (!recordOptionalFileDependency(manifest, owners)) return selected;
1161
+ try {
1162
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
1163
+ if (value !== null && typeof value === "object") {
1164
+ const metadata = value as Record<string, unknown>;
1165
+ if (typeof metadata.main === "string") {
1166
+ const main = path.resolve(candidate, metadata.main);
1167
+ recordPackagePathCandidate(main, owners);
1168
+ selected =
1169
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
1170
+ selected;
1171
+ }
1172
+ }
1173
+ } catch {
1174
+ // Node owns malformed nested-package diagnostics.
1175
+ }
1176
+ return selected;
1177
+ }
1178
+
1179
+ function boundedPackageTarget(
1180
+ packageRoot: string,
1181
+ target: string,
1182
+ ): string | undefined {
1183
+ const candidate = path.resolve(packageRoot, target);
1184
+ const relative = path.relative(packageRoot, candidate);
1185
+ if (
1186
+ relative === ".." ||
1187
+ relative.startsWith(".." + path.sep) ||
1188
+ path.isAbsolute(relative)
1189
+ ) {
1190
+ return undefined;
1191
+ }
1192
+ return candidate;
1193
+ }
1194
+
1195
+ function recordPackagePathCandidate(
1196
+ candidate: string,
1197
+ owners: readonly string[],
1198
+ visited: Set<string> = new Set(),
1199
+ depth = 0,
1200
+ ): void {
1201
+ const normalized = path.resolve(candidate);
1202
+ // The depth bound owns termination. A platform-wide case fold would merge
1203
+ // paths that differ only by case, which a per-directory case-sensitive
1204
+ // Windows tree keeps distinct, and would truncate a valid symlink chain.
1205
+ if (depth >= 64 || visited.has(normalized)) return;
1206
+ visited.add(normalized);
1207
+ const parsed = path.parse(normalized);
1208
+ const components = normalized
1209
+ .slice(parsed.root.length)
1210
+ .split(path.sep)
1211
+ .filter(Boolean);
1212
+ let current = parsed.root;
1213
+ for (let index = 0; index < components.length; index++) {
1214
+ const component = components[index];
1215
+ const next = path.join(current, component);
1216
+ let entry: ReturnType<typeof fs.lstatSync>;
1217
+ try {
1218
+ entry = fs.lstatSync(next);
1219
+ } catch {
1220
+ recordDirectoryDependency(current, owners);
1221
+ return;
1222
+ }
1223
+ if (entry.isSymbolicLink()) {
1224
+ // The containing directory digest carries the raw link target.
1225
+ recordDirectoryDependency(current, owners);
1226
+ try {
1227
+ const target = fs.readlinkSync(next);
1228
+ const remainder = components.slice(index + 1);
1229
+ recordPackagePathCandidate(
1230
+ path.join(
1231
+ path.resolve(current, target),
1232
+ ...remainder,
1233
+ ),
1234
+ owners,
1235
+ visited,
1236
+ depth + 1,
1237
+ );
1238
+ } catch {
1239
+ // The lexical link record already carries the unreadable state.
1240
+ }
1241
+ }
1242
+ let isDirectory = entry.isDirectory();
1243
+ if (entry.isSymbolicLink()) {
1244
+ try {
1245
+ isDirectory = fs.statSync(next).isDirectory();
1246
+ } catch {
1247
+ return;
1248
+ }
1249
+ }
1250
+ if (index === components.length - 1) {
1251
+ recordDirectoryDependency(isDirectory ? next : current, owners);
1252
+ return;
1253
+ }
1254
+ if (!isDirectory) {
1255
+ recordDirectoryDependency(current, owners);
1256
+ return;
1257
+ }
1258
+ current = next;
1259
+ }
1260
+ recordDirectoryDependency(current, owners);
1261
+ }
1262
+
1263
+ function modulePackageName(specifier: string): string | undefined {
1264
+ if (specifier.startsWith("@")) {
1265
+ const components = specifier.split("/");
1266
+ return components.length >= 2
1267
+ ? components[0] + "/" + components[1]
1268
+ : undefined;
1269
+ }
1270
+ const [name] = specifier.split("/");
1271
+ return name && !name.startsWith("#") ? name : undefined;
1272
+ }
1273
+
1274
+ function resolvedPackageContains(
1275
+ modules: string,
1276
+ packageName: string,
1277
+ childLocation: string,
1278
+ ): boolean {
1279
+ try {
1280
+ const packageRoot = fs.realpathSync(path.join(modules, packageName));
1281
+ const relative = path.relative(
1282
+ packageRoot,
1283
+ fs.realpathSync(childLocation),
1284
+ );
1285
+ return (
1286
+ relative === "" ||
1287
+ (relative !== ".." &&
1288
+ !relative.startsWith(".." + path.sep) &&
1289
+ !path.isAbsolute(relative))
1290
+ );
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ }
1295
+
1296
+ function sameResolutionPath(left: string, right: string): boolean {
1297
+ return path.relative(left, right) === "";
1298
+ }
1299
+
1300
+ function samePhysicalPath(left: string, right: string): boolean {
1301
+ try {
1302
+ return sameResolutionPath(realPath(left), realPath(right));
1303
+ } catch {
1304
+ // Fall back to the spellings themselves, folding case the way the platform
1305
+ // does. On the entry gate a false negative is catastrophic — the config
1306
+ // stops being recognized and its whole graph collapses — while a false
1307
+ // positive only over-includes, so the degradation has to lean toward "same
1308
+ // file". A drive-letter or component case difference is the ordinary
1309
+ // Windows situation; a per-directory case-sensitive tree is the rare one.
1310
+ return sameResolutionPath(left, right);
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * The config's real path, or its declared one when the volume will not say.
1316
+ *
1317
+ * A config can disappear between the host reading it and this loader starting,
1318
+ * and a throw here would replace a precise report from the import below with a
1319
+ * crash in bookkeeping. Seeding lexically instead only risks the demotion this
1320
+ * value exists to prevent, on a file that is already gone.
1321
+ */
1322
+ function realConfigLocation(): string {
1323
+ try {
1324
+ return realPath(configLocation);
1325
+ } catch {
1326
+ return configLocation;
1327
+ }
1328
+ }
1329
+
1330
+ function realPath(location: string): string {
1331
+ return fs.realpathSync.native
1332
+ ? fs.realpathSync.native(location)
1333
+ : fs.realpathSync(location);
1334
+ }
1335
+
1336
+ function finalizeDependencies(): Array<{
1337
+ digest: string;
1338
+ kind: "directory" | "file" | "optional-file";
1339
+ path: string;
1340
+ scope: "cache" | "watch";
1341
+ }> {
1342
+ const watched = graphWatchReachability();
1343
+ return [...dependencies.values()].map(({ owners, ...dependency }) => ({
1344
+ ...dependency,
1345
+ scope: [...owners].some((owner) => watched.has(owner))
1346
+ ? "watch"
1347
+ : "cache",
1348
+ }));
1349
+ }
1350
+
1351
+ function graphWatchReachability(): Set<string> {
1352
+ const adjacency = new Map<string, typeof graphEdges>();
1353
+ for (const edge of graphEdges) {
1354
+ const outgoing = adjacency.get(edge.parent) ?? [];
1355
+ outgoing.push(edge);
1356
+ adjacency.set(edge.parent, outgoing);
1357
+ }
1358
+ const queue: Array<{ url: string; watched: boolean }> =
1359
+ configUrlSpellings.map((url) => ({ url, watched: true }));
1360
+ const visited = new Set<string>();
1361
+ const watched = new Set<string>();
1362
+ while (queue.length !== 0) {
1363
+ const state = queue.shift()!;
1364
+ const key = state.url + "\\0" + (state.watched ? "1" : "0");
1365
+ if (visited.has(key)) continue;
1366
+ visited.add(key);
1367
+ if (state.watched) watched.add(state.url);
1368
+ for (const edge of adjacency.get(state.url) ?? []) {
1369
+ const childLocation = graphNodes.get(edge.child);
1370
+ const childWatched = edge.packageBoundary
1371
+ ? false
1372
+ : childLocation !== undefined && !pathHasNodeModules(childLocation)
1373
+ ? true
1374
+ : state.watched;
1375
+ queue.push({ url: edge.child, watched: childWatched });
1376
+ }
1377
+ }
1378
+ return watched;
1379
+ }
1380
+
419
1381
  function hasOwn(value: Record<string, unknown>, key: string): boolean {
420
1382
  return Object.prototype.hasOwnProperty.call(value, key);
421
1383
  }
@@ -503,7 +1465,9 @@ function collectPluginObjects(value: unknown): Array<Record<string, unknown>> {
503
1465
  }
504
1466
 
505
1467
  function extractPluginSource(value: unknown): string | undefined {
506
- if (typeof value === "string") return value;
1468
+ if (typeof value === "string") {
1469
+ value = requireFromConfig(value);
1470
+ }
507
1471
  if (!isObject(value)) return undefined;
508
1472
  // ESM-from-CJS interop wraps CJS modules' \`exports.default\` so the
509
1473
  // plugin object can land under a \`.default\` indirection. Walk a few
@@ -525,30 +1489,46 @@ function extractPluginSource(value: unknown): string | undefined {
525
1489
  }
526
1490
  `;
527
1491
  /**
528
- * Resolves the contributor plugin entries declared in a .ts/.mjs lint config,
1492
+ * Resolves contributor plugin entries declared in any executable lint config,
529
1493
  * memoized through the shared on-disk config cache.
530
1494
  *
531
1495
  * Evaluating such a config spawns a full `ttsx` subprocess. A monorepo build
532
1496
  * runs one `ttsc` process per package, and each would otherwise re-spawn `ttsx`
533
1497
  * for the same shared config; the cache collapses that to a single evaluation.
534
- * The cache is keyed by the config file's path and exact contents (see
535
- * `configCacheKey`), so an edit re-evaluates cleanly.
1498
+ * The cache key covers the entry's path and exact contents; the payload also
1499
+ * fingerprints every local module reached from that entry. An entry or helper
1500
+ * edit therefore re-evaluates cleanly without treating installed packages as
1501
+ * project watch inputs.
536
1502
  */
537
1503
  function readTtsxConfigPlugins(configPath, context) {
538
- const cacheKey = configCacheKey("plugins", configPath);
1504
+ const resolutionRoot = node_path_1.default.resolve(pluginConfigBaseDir(context));
1505
+ const cacheKey = configCacheKey(`plugins\0${resolutionRoot}`, configPath);
539
1506
  if (cacheKey) {
540
1507
  const cached = readConfigPluginCache(cacheKey);
541
1508
  // Re-validate cached entries before trusting them: a contributor's
542
1509
  // resolved `source` directory may have moved since the entry was
543
1510
  // written. A stale entry falls through to a fresh evaluation rather
544
1511
  // than being forwarded to ttsc's plugin builder as a dead path.
545
- if (cached && cached.every(isValidConfigPluginEntry))
546
- return cached;
1512
+ if (cached &&
1513
+ cached.entries.every(isValidConfigPluginEntry) &&
1514
+ configDependenciesAreCurrent(cached.dependencies)) {
1515
+ return cached.entries;
1516
+ }
1517
+ }
1518
+ // A config can be saved while it is being evaluated. Retry a bounded number
1519
+ // of times until every dependency still has the bytes the module hook saw.
1520
+ // A continuously changing config remains usable but is deliberately not
1521
+ // cached; watch will schedule another cycle.
1522
+ let evaluation;
1523
+ for (let attempt = 0; attempt < 3; attempt++) {
1524
+ evaluation = evaluateTtsxConfigPlugins(configPath, context);
1525
+ if (configDependenciesAreCurrent(evaluation.dependencies)) {
1526
+ if (cacheKey)
1527
+ writeConfigPluginCache(cacheKey, evaluation);
1528
+ return evaluation.entries;
1529
+ }
547
1530
  }
548
- const entries = evaluateTtsxConfigPlugins(configPath, context);
549
- if (cacheKey)
550
- writeConfigPluginCache(cacheKey, entries);
551
- return entries;
1531
+ return evaluation.entries;
552
1532
  }
553
1533
  /**
554
1534
  * Reports whether a cached plugin entry is still usable: a well-formed
@@ -574,13 +1554,16 @@ function isValidConfigPluginEntry(entry) {
574
1554
  return false;
575
1555
  }
576
1556
  }
577
- function evaluateTtsxConfigPlugins(configPath, _context) {
1557
+ function evaluateTtsxConfigPlugins(configPath, context) {
578
1558
  const tempDir = realpathIfPossible(node_fs_1.default.mkdtempSync(node_path_1.default.join(loaderTempBase(configPath), "ttsc-lint-cfg-")));
579
1559
  try {
580
1560
  linkNearestNodeModules(tempDir, node_path_1.default.dirname(configPath));
581
1561
  const loaderPath = node_path_1.default.join(tempDir, "loader.mts");
1562
+ const outputPath = node_path_1.default.join(tempDir, "result.json");
582
1563
  const tsconfigPath = node_path_1.default.join(tempDir, "tsconfig.json");
583
- const loaderSource = TTSX_EXTRACTOR_SCRIPT.replace("%CONFIG_IMPORT%", JSON.stringify((0, node_url_1.pathToFileURL)(configPath).href));
1564
+ const loaderSource = exports.TTSX_EXTRACTOR_SCRIPT.replace("%CONFIG_IMPORT%", JSON.stringify((0, node_url_1.pathToFileURL)(configPath).href))
1565
+ .replace("%CONFIG_OUTPUT%", JSON.stringify(outputPath))
1566
+ .replace("%CONFIG_ROOT%", JSON.stringify(node_path_1.default.resolve(pluginConfigBaseDir(context))));
584
1567
  node_fs_1.default.writeFileSync(loaderPath, loaderSource, "utf8");
585
1568
  node_fs_1.default.writeFileSync(tsconfigPath, JSON.stringify({
586
1569
  compilerOptions: {
@@ -604,10 +1587,16 @@ function evaluateTtsxConfigPlugins(configPath, _context) {
604
1587
  },
605
1588
  files: [
606
1589
  loaderPath.replace(/\\/g, "/"),
607
- configPath.replace(/\\/g, "/"),
1590
+ ...(node_path_1.default.extname(configPath).toLowerCase() === ".json"
1591
+ ? []
1592
+ : [configPath.replace(/\\/g, "/")]),
608
1593
  ],
609
1594
  }, null, 2), "utf8");
610
- const ttsxBinary = process.env.TTSC_TTSX_BINARY ?? "ttsx";
1595
+ // The config comes first, so a project that pins its own ttsc gets it. This
1596
+ // descriptor's own location comes second, because a fixture or a workspace
1597
+ // that installs only the lint package cannot resolve ttsc from the config
1598
+ // at all, and the descriptor always sits beside the host that loaded it.
1599
+ const ttsxBinary = resolveTtsxLauncher([configPath, context.dirname]);
611
1600
  // `--no-plugins` keeps this build hermetic: the loader only needs to
612
1601
  // type-check and run the user's lint config to extract its plugin
613
1602
  // entries. Loading the host project's transform/check plugins
@@ -625,41 +1614,48 @@ function evaluateTtsxConfigPlugins(configPath, _context) {
625
1614
  if (process.env.TTSC_TSGO_BINARY) {
626
1615
  args.unshift("--binary", process.env.TTSC_TSGO_BINARY);
627
1616
  }
628
- const env = nodeConfigLoaderEnv(configPath);
1617
+ const env = {
1618
+ ...nodeConfigLoaderEnv(configPath),
1619
+ ...(0, configEvaluatorFailure_1.configEvaluatorBoundaryEnvironment)(),
1620
+ };
629
1621
  const command = ttsxThroughNodeIfNeeded(ttsxBinary);
630
1622
  const result = (0, node_child_process_1.spawnSync)(command.binary, [...command.prefix, ...args], {
631
1623
  cwd: tempDir,
632
1624
  env,
633
1625
  encoding: "utf8",
634
- maxBuffer: 1024 * 1024 * 16,
635
- // 60s cap so a runaway top-level await / infinite loop in the
636
- // user's lint config can't hang the entire ttsc invocation.
637
- timeout: 60_000,
1626
+ ...configEvaluatorFailure_1.CONFIG_EVALUATOR_PROCESS_OPTIONS,
1627
+ stdio: [
1628
+ "ignore",
1629
+ "pipe",
1630
+ "pipe",
1631
+ ...Array.from({ length: configEvaluatorFailure_1.CONFIG_EVALUATOR_STATUS_FD - 2 }, () => "pipe"),
1632
+ ],
638
1633
  windowsHide: true,
639
1634
  });
640
- if (result.error) {
641
- throw new Error(`@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`);
642
- }
643
- if (result.signal) {
644
- throw new Error(`@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal} ` +
645
- `(likely the 60s timeout). Simplify the config or move heavy work out of top-level.`);
646
- }
647
- if (result.status !== 0) {
648
- throw new Error(`@ttsc/lint: lint config ${configPath} evaluation failed:\n${result.stderr || result.stdout}`);
649
- }
1635
+ forwardConfigEvaluatorStreams(result.stdout, result.stderr);
1636
+ const processFailure = (0, configEvaluatorFailure_1.configEvaluatorProcessFailure)(result, configPath);
1637
+ if (processFailure)
1638
+ throw processFailure;
650
1639
  let payload;
651
1640
  try {
652
- payload = JSON.parse(result.stdout);
1641
+ payload = JSON.parse(node_fs_1.default.readFileSync(outputPath, "utf8"));
653
1642
  }
654
1643
  catch (error) {
655
1644
  throw new Error(`@ttsc/lint: lint config ${configPath} evaluator returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
656
1645
  }
657
- const entries = payload.entries ?? [];
658
- return entries.map((entry) => {
1646
+ if (!Array.isArray(payload.entries)) {
1647
+ throw new Error(`@ttsc/lint: lint config ${configPath} evaluator omitted its plugin-entry array`);
1648
+ }
1649
+ const entries = payload.entries.map((entry) => {
659
1650
  // The ttsx extractor already resolved each plugin object's
660
1651
  // `source` to an absolute directory path. Validate the shape but
661
1652
  // skip the specifier-resolution branch — re-routing a directory
662
1653
  // through `createRequire().resolve` would fail.
1654
+ if (entry === null ||
1655
+ typeof entry !== "object" ||
1656
+ typeof entry.namespace !== "string") {
1657
+ throw new Error(`@ttsc/lint: lint config ${configPath} evaluator returned a malformed plugin entry`);
1658
+ }
663
1659
  if (!NAMESPACE_PATTERN.test(entry.namespace)) {
664
1660
  throw new Error(`@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(entry.namespace)} must match /^[a-z][a-z0-9_-]*$/`);
665
1661
  }
@@ -675,11 +1671,24 @@ function evaluateTtsxConfigPlugins(configPath, _context) {
675
1671
  }
676
1672
  return { namespace: entry.namespace, source: entry.source };
677
1673
  });
1674
+ const dependencies = normalizeConfigDependencyFingerprints(payload.dependencies);
1675
+ if (dependencies === undefined) {
1676
+ throw new Error(`@ttsc/lint: lint config ${configPath} evaluator returned malformed dependency fingerprints`);
1677
+ }
1678
+ return { dependencies, entries };
678
1679
  }
679
1680
  finally {
680
1681
  node_fs_1.default.rmSync(tempDir, { recursive: true, force: true });
681
1682
  }
682
1683
  }
1684
+ function forwardConfigEvaluatorStreams(stdout, stderr) {
1685
+ // Both child streams are human output. Parent stdout is reserved for compiler
1686
+ // JSON or LSP frames, so even a user console.log is redirected.
1687
+ if (stdout)
1688
+ process.stderr.write(stdout);
1689
+ if (stderr)
1690
+ process.stderr.write(stderr);
1691
+ }
683
1692
  // ────────────────────────────────────────────────────────────────────────────
684
1693
  // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
685
1694
  // ────────────────────────────────────────────────────────────────────────────
@@ -687,7 +1696,7 @@ function evaluateTtsxConfigPlugins(configPath, _context) {
687
1696
  * Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
688
1697
  * `configCacheVersion`; bump both when the cached shape changes.
689
1698
  */
690
- const CONFIG_CACHE_VERSION = "v2";
1699
+ const CONFIG_CACHE_VERSION = "v5";
691
1700
  /**
692
1701
  * Directory shared by this factory and the Go sidecar for cached lint configs.
693
1702
  * The two write different files (the `kind` segment of the cache key keeps
@@ -741,7 +1750,19 @@ function readConfigPluginCache(cacheKey) {
741
1750
  }
742
1751
  try {
743
1752
  const parsed = JSON.parse(body);
744
- return Array.isArray(parsed) ? parsed : undefined;
1753
+ if (parsed === null ||
1754
+ typeof parsed !== "object" ||
1755
+ !Array.isArray(parsed.entries) ||
1756
+ !Array.isArray(parsed.dependencies)) {
1757
+ return undefined;
1758
+ }
1759
+ const dependencies = normalizeConfigDependencyFingerprints(parsed.dependencies);
1760
+ if (dependencies === undefined)
1761
+ return undefined;
1762
+ return {
1763
+ dependencies,
1764
+ entries: parsed.entries,
1765
+ };
745
1766
  }
746
1767
  catch {
747
1768
  return undefined;
@@ -753,18 +1774,150 @@ function readConfigPluginCache(cacheKey) {
753
1774
  * temp-file + rename keeps a concurrent reader in a sibling `ttsc` process from
754
1775
  * observing a half-written file.
755
1776
  */
756
- function writeConfigPluginCache(cacheKey, entries) {
1777
+ function writeConfigPluginCache(cacheKey, evaluation) {
757
1778
  try {
758
1779
  const dir = configCacheDir();
759
1780
  node_fs_1.default.mkdirSync(dir, { recursive: true });
760
- const tmp = node_path_1.default.join(dir, `${cacheKey}.${process.pid}.tmp`);
761
- node_fs_1.default.writeFileSync(tmp, JSON.stringify(entries), "utf8");
762
- node_fs_1.default.renameSync(tmp, node_path_1.default.join(dir, `${cacheKey}.json`));
1781
+ const tmp = node_path_1.default.join(dir, `${cacheKey}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
1782
+ try {
1783
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(evaluation), "utf8");
1784
+ node_fs_1.default.renameSync(tmp, node_path_1.default.join(dir, `${cacheKey}.json`));
1785
+ }
1786
+ finally {
1787
+ try {
1788
+ node_fs_1.default.unlinkSync(tmp);
1789
+ }
1790
+ catch {
1791
+ // A successful rename already consumed the temporary path.
1792
+ }
1793
+ }
763
1794
  }
764
1795
  catch {
765
1796
  // Cold cache on failure — the next invocation re-evaluates.
766
1797
  }
767
1798
  }
1799
+ function normalizeConfigDependencyFingerprints(value) {
1800
+ if (!Array.isArray(value) || value.length === 0)
1801
+ return undefined;
1802
+ const dependencies = new Map();
1803
+ for (const candidate of value) {
1804
+ if (candidate === null ||
1805
+ typeof candidate !== "object" ||
1806
+ typeof candidate.path !== "string" ||
1807
+ typeof candidate.digest !== "string" ||
1808
+ !["directory", "file", "optional-file"].includes(candidate.kind) ||
1809
+ !["cache", "watch"].includes(candidate.scope)) {
1810
+ return undefined;
1811
+ }
1812
+ const candidatePath = candidate.path;
1813
+ const digest = candidate.digest;
1814
+ const kind = candidate.kind;
1815
+ const scope = candidate.scope;
1816
+ if (!node_path_1.default.isAbsolute(candidatePath) || !/^[0-9a-f]{64}$/.test(digest)) {
1817
+ return undefined;
1818
+ }
1819
+ const location = node_path_1.default.resolve(candidatePath);
1820
+ const previous = dependencies.get(location);
1821
+ if (previous !== undefined &&
1822
+ (previous.digest !== digest ||
1823
+ previous.kind !== kind ||
1824
+ previous.scope !== scope)) {
1825
+ return undefined;
1826
+ }
1827
+ dependencies.set(location, {
1828
+ digest,
1829
+ kind,
1830
+ path: location,
1831
+ scope,
1832
+ });
1833
+ }
1834
+ return [...dependencies.values()].sort((left, right) => left.path.localeCompare(right.path));
1835
+ }
1836
+ function configDependenciesAreCurrent(dependencies) {
1837
+ if (dependencies.length === 0)
1838
+ return false;
1839
+ return dependencies.every((dependency) => {
1840
+ if (!/^[0-9a-f]{64}$/.test(dependency.digest))
1841
+ return false;
1842
+ try {
1843
+ const digest = dependency.kind === "directory"
1844
+ ? configDirectoryDigest(dependency.path)
1845
+ : dependency.kind === "optional-file"
1846
+ ? configOptionalFileDigest(dependency.path)
1847
+ : (0, node_crypto_1.createHash)("sha256")
1848
+ .update(node_fs_1.default.readFileSync(dependency.path))
1849
+ .digest("hex");
1850
+ return digest === dependency.digest;
1851
+ }
1852
+ catch {
1853
+ return false;
1854
+ }
1855
+ });
1856
+ }
1857
+ function configDirectoryDigest(location) {
1858
+ const entries = [];
1859
+ if (process.platform === "win32") {
1860
+ for (const entry of node_fs_1.default.readdirSync(location, { withFileTypes: true })) {
1861
+ let target = node_buffer_1.Buffer.alloc(0);
1862
+ if (entry.isSymbolicLink()) {
1863
+ try {
1864
+ target = node_buffer_1.Buffer.from(node_fs_1.default.readlinkSync(node_path_1.default.join(location, entry.name)), "utf8");
1865
+ }
1866
+ catch {
1867
+ target = node_buffer_1.Buffer.from("<unreadable>");
1868
+ }
1869
+ }
1870
+ entries.push(configDirectoryDigestRecord(node_buffer_1.Buffer.from(entry.name), entry, target));
1871
+ }
1872
+ }
1873
+ else {
1874
+ for (const entry of node_fs_1.default.readdirSync(location, {
1875
+ encoding: "buffer",
1876
+ withFileTypes: true,
1877
+ })) {
1878
+ let target = node_buffer_1.Buffer.alloc(0);
1879
+ if (entry.isSymbolicLink()) {
1880
+ try {
1881
+ target = node_fs_1.default.readlinkSync(node_buffer_1.Buffer.concat([
1882
+ node_buffer_1.Buffer.from(location),
1883
+ node_buffer_1.Buffer.from(node_path_1.default.sep),
1884
+ entry.name,
1885
+ ]), { encoding: "buffer" });
1886
+ }
1887
+ catch {
1888
+ target = node_buffer_1.Buffer.from("<unreadable>");
1889
+ }
1890
+ }
1891
+ entries.push(configDirectoryDigestRecord(entry.name, entry, target));
1892
+ }
1893
+ }
1894
+ entries.sort(node_buffer_1.Buffer.compare);
1895
+ const serialized = node_buffer_1.Buffer.concat(entries.flatMap((entry, index) => index === 0 ? [entry] : [node_buffer_1.Buffer.from([0]), entry]));
1896
+ return (0, node_crypto_1.createHash)("sha256").update(serialized).digest("hex");
1897
+ }
1898
+ function configDirectoryDigestRecord(name, entry, target) {
1899
+ const kind = entry.isDirectory()
1900
+ ? "directory"
1901
+ : entry.isFile()
1902
+ ? "file"
1903
+ : entry.isSymbolicLink()
1904
+ ? "symlink"
1905
+ : "other";
1906
+ return node_buffer_1.Buffer.concat([name, node_buffer_1.Buffer.from("\0" + kind + "\0"), target]);
1907
+ }
1908
+ function configOptionalFileDigest(location) {
1909
+ try {
1910
+ if (node_fs_1.default.statSync(location).isFile()) {
1911
+ return (0, node_crypto_1.createHash)("sha256")
1912
+ .update(node_buffer_1.Buffer.concat([node_buffer_1.Buffer.from("file\0"), node_fs_1.default.readFileSync(location)]))
1913
+ .digest("hex");
1914
+ }
1915
+ }
1916
+ catch {
1917
+ // Missing, unreadable, and non-file candidates share the absent state.
1918
+ }
1919
+ return (0, node_crypto_1.createHash)("sha256").update("missing\0").digest("hex");
1920
+ }
768
1921
  // ────────────────────────────────────────────────────────────────────────────
769
1922
  // Shared helpers
770
1923
  // ────────────────────────────────────────────────────────────────────────────
@@ -945,6 +2098,43 @@ function nodeConfigLoaderEnv(configPath) {
945
2098
  }
946
2099
  return env;
947
2100
  }
2101
+ /**
2102
+ * Where the isolated config evaluator lives.
2103
+ *
2104
+ * An explicit override wins. Otherwise the launcher is resolved out of a ttsc
2105
+ * installation one of the anchors can see, because a bare command name only
2106
+ * works when a bin link happens to be on PATH — which it is for a spawned CLI
2107
+ * and is not for a host that loaded this descriptor in process. The bare name
2108
+ * remains the last resort for an installation none of them reach.
2109
+ */
2110
+ function resolveTtsxLauncher(anchors) {
2111
+ const explicit = process.env.TTSC_TTSX_BINARY?.trim();
2112
+ if (explicit)
2113
+ return explicit;
2114
+ for (const anchor of anchors) {
2115
+ const launcher = ttsxLauncherFrom(anchor);
2116
+ if (launcher !== undefined)
2117
+ return launcher;
2118
+ }
2119
+ return "ttsx";
2120
+ }
2121
+ function ttsxLauncherFrom(anchor) {
2122
+ try {
2123
+ // Only the manifest is exported, so the launcher is derived from where the
2124
+ // manifest resolved rather than requested as a subpath. Resolution is
2125
+ // anchored on the config being evaluated, which is how every other
2126
+ // resolution in this descriptor is anchored and picks the ttsc the project
2127
+ // actually installed.
2128
+ const manifest = (0, node_module_1.createRequire)(anchor).resolve("ttsc/package.json");
2129
+ const launcher = node_path_1.default.join(node_path_1.default.dirname(manifest), "lib", "launcher", "ttsx.js");
2130
+ if (node_fs_1.default.existsSync(launcher))
2131
+ return launcher;
2132
+ }
2133
+ catch {
2134
+ // This anchor cannot see ttsc; the caller tries the next one.
2135
+ }
2136
+ return undefined;
2137
+ }
948
2138
  function ttsxThroughNodeIfNeeded(binary) {
949
2139
  const ext = node_path_1.default.extname(binary).toLowerCase();
950
2140
  if ([".js", ".cjs", ".mjs", ".ts", ".cts", ".mts"].includes(ext)) {