@ttsc/lint 0.26.2 → 0.28.0

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