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