@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/lib/index.js CHANGED
@@ -48,20 +48,20 @@ function goSubpackageName(namespace) {
48
48
  return namespace.replace(/-/g, "_");
49
49
  }
50
50
  const LINT_CONFIG_FILENAMES = [
51
+ "lint.config.json",
52
+ "lint.config.js",
53
+ "lint.config.mjs",
54
+ "lint.config.cjs",
51
55
  "lint.config.ts",
52
56
  "lint.config.mts",
53
57
  "lint.config.cts",
54
- "lint.config.mjs",
55
- "lint.config.cjs",
56
- "lint.config.js",
57
- "lint.config.json",
58
+ "ttsc-lint.config.json",
59
+ "ttsc-lint.config.js",
60
+ "ttsc-lint.config.mjs",
61
+ "ttsc-lint.config.cjs",
58
62
  "ttsc-lint.config.ts",
59
63
  "ttsc-lint.config.mts",
60
64
  "ttsc-lint.config.cts",
61
- "ttsc-lint.config.mjs",
62
- "ttsc-lint.config.cjs",
63
- "ttsc-lint.config.js",
64
- "ttsc-lint.config.json",
65
65
  ];
66
66
  /**
67
67
  * Tsconfig plugin-entry keys owned by the ttsc host framework. They are
@@ -95,13 +95,14 @@ const FRAMEWORK_KEYS = new Set([
95
95
  */
96
96
  function createTtscPlugin(context) {
97
97
  rejectUnsupportedEntryKeys(context.plugin);
98
- const contributors = resolveConfigFileContributors(context);
98
+ const resolvedConfig = resolveConfigFileContributors(context);
99
99
  // Build the descriptor without a `contributors` key when none were
100
100
  // declared, so consumers (and the existing key-shape regression
101
101
  // tests) see the same surface as before this feature shipped.
102
102
  const descriptor = {
103
103
  capabilities: {
104
104
  diagnosticsTiming: true,
105
+ graphNodes: true,
105
106
  lsp: true,
106
107
  projectContextArgs: true,
107
108
  projectDiagnostics: true,
@@ -109,6 +110,9 @@ function createTtscPlugin(context) {
109
110
  residentCheck: true,
110
111
  threadingArgs: true,
111
112
  },
113
+ hostInputHashes: resolvedConfig.hostInputHashes,
114
+ hostInputRealpaths: resolvedConfig.hostInputRealpaths,
115
+ hostInputs: resolvedConfig.hostInputs,
112
116
  name: "@ttsc/lint",
113
117
  reportsTypeScriptDiagnostics: true,
114
118
  // `context.dirname` is this descriptor's own directory in every load mode —
@@ -117,8 +121,8 @@ function createTtscPlugin(context) {
117
121
  source: node_path_1.default.resolve(context.dirname, "..", "plugin"),
118
122
  stage: "check",
119
123
  };
120
- if (contributors.length > 0) {
121
- descriptor.contributors = contributors;
124
+ if (resolvedConfig.contributors.length > 0) {
125
+ descriptor.contributors = resolvedConfig.contributors;
122
126
  }
123
127
  return descriptor;
124
128
  }
@@ -163,12 +167,28 @@ function loadContributorPluginViaRequire(specifier, context, namespace, anchorFi
163
167
  */
164
168
  function resolveConfigFileContributors(context) {
165
169
  const configFile = readConfigFileOption(context);
166
- const configPath = configFile !== undefined
167
- ? node_path_1.default.resolve(pluginConfigBaseDir(context), configFile)
168
- : findLintConfigFile(context);
169
- if (!configPath || !node_fs_1.default.existsSync(configPath))
170
- return [];
171
- const entries = readConfigPluginEntries(configPath, context);
170
+ const explicitConfigPath = configFile === undefined
171
+ ? undefined
172
+ : node_path_1.default.resolve(pluginConfigBaseDir(context), configFile);
173
+ const discovery = explicitConfigPath === undefined
174
+ ? discoverLintConfigFile(context)
175
+ : {
176
+ configPath: explicitConfigPath,
177
+ hostInputHashes: hashHostInputPaths([explicitConfigPath]),
178
+ hostInputRealpaths: realpathHostInputPaths([explicitConfigPath]),
179
+ hostInputs: [explicitConfigPath],
180
+ };
181
+ const { configPath } = discovery;
182
+ if (!configPath || !node_fs_1.default.existsSync(configPath)) {
183
+ return {
184
+ contributors: [],
185
+ hostInputHashes: discovery.hostInputHashes,
186
+ hostInputRealpaths: discovery.hostInputRealpaths,
187
+ hostInputs: discovery.hostInputs,
188
+ };
189
+ }
190
+ const evaluation = readConfigPluginEntries(configPath, context);
191
+ const entries = evaluation.entries;
172
192
  assertContributorNamespacesDoNotCollide(entries, configPath);
173
193
  // Dedup exact repeated namespaces on the Go-subpackage form. Config-array
174
194
  // folding can surface the same namespace more than once; that existing
@@ -182,7 +202,157 @@ function resolveConfigFileContributors(context) {
182
202
  occupied.add(goName);
183
203
  out.push({ name: goName, source: entry.source });
184
204
  }
185
- return out;
205
+ const dependencyInputs = evaluation.dependencies
206
+ .filter((dependency) => dependency.scope === "watch" && dependency.kind !== "directory")
207
+ .map((dependency) => dependency.path);
208
+ const hostInputHashes = { ...discovery.hostInputHashes };
209
+ const hostInputRealpaths = { ...discovery.hostInputRealpaths };
210
+ const unstableRealpaths = new Set();
211
+ const unprovenInputs = new Set();
212
+ const missingOptionalDigest = (0, node_crypto_1.createHash)("sha256")
213
+ .update("missing\0")
214
+ .digest("hex");
215
+ const directoryCandidateDigest = (0, node_crypto_1.createHash)("sha256")
216
+ .update("ttsc:host-input:directory\0")
217
+ .digest("hex");
218
+ for (const dependency of evaluation.dependencies) {
219
+ if (dependency.scope !== "watch" || dependency.kind === "directory") {
220
+ continue;
221
+ }
222
+ const input = node_path_1.default.resolve(dependency.path);
223
+ const realpath = dependency.realpath;
224
+ if (!dependency.identityStable) {
225
+ delete hostInputRealpaths[input];
226
+ delete hostInputHashes[input];
227
+ unstableRealpaths.add(input);
228
+ unprovenInputs.add(input);
229
+ continue;
230
+ }
231
+ if (Object.prototype.hasOwnProperty.call(hostInputRealpaths, input) &&
232
+ hostInputRealpaths[input] !== realpath) {
233
+ delete hostInputRealpaths[input];
234
+ delete hostInputHashes[input];
235
+ unstableRealpaths.add(input);
236
+ }
237
+ else if (!unstableRealpaths.has(input)) {
238
+ hostInputRealpaths[input] = realpath;
239
+ }
240
+ let hash;
241
+ if (dependency.kind === "file" &&
242
+ /^[0-9a-f]{64}$/.test(dependency.digest)) {
243
+ hash = dependency.digest;
244
+ }
245
+ else if (dependency.digest === missingOptionalDigest) {
246
+ // The evaluator's optional-file digest includes a state prefix. The
247
+ // public host-input contract uses null for the observed missing state.
248
+ hash = null;
249
+ }
250
+ else if (dependency.digest === directoryCandidateDigest) {
251
+ // A path that is currently a directory is still an exact file candidate:
252
+ // replacing it with a file changes module/config selection.
253
+ hash = directoryCandidateDigest;
254
+ }
255
+ if (hash === undefined) {
256
+ delete hostInputHashes[input];
257
+ unprovenInputs.add(input);
258
+ }
259
+ else if (unprovenInputs.has(input)) {
260
+ // A later observation cannot revive proof another evaluation stage
261
+ // could not provide for the same combined descriptor result.
262
+ }
263
+ else if (Object.prototype.hasOwnProperty.call(hostInputHashes, input) &&
264
+ hostInputHashes[input] !== hash) {
265
+ delete hostInputHashes[input];
266
+ unprovenInputs.add(input);
267
+ }
268
+ else {
269
+ hostInputHashes[input] = hash;
270
+ }
271
+ }
272
+ return {
273
+ contributors: out,
274
+ hostInputHashes,
275
+ hostInputRealpaths,
276
+ hostInputs: [...discovery.hostInputs, ...dependencyInputs],
277
+ };
278
+ }
279
+ /** Snapshot config candidates before any discovery/evaluation side effect. */
280
+ function hashHostInputPaths(inputs) {
281
+ return Object.fromEntries(inputs.map((input) => {
282
+ const file = node_path_1.default.resolve(input);
283
+ try {
284
+ if (node_fs_1.default.statSync(file).isDirectory()) {
285
+ return [
286
+ file,
287
+ (0, node_crypto_1.createHash)("sha256")
288
+ .update("ttsc:host-input:directory\0")
289
+ .digest("hex"),
290
+ ];
291
+ }
292
+ return [
293
+ file,
294
+ (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(file)).digest("hex"),
295
+ ];
296
+ }
297
+ catch {
298
+ return [file, null];
299
+ }
300
+ }));
301
+ }
302
+ function hostInputRealpath(file) {
303
+ try {
304
+ return node_fs_1.default.realpathSync.native(file);
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
310
+ function realpathHostInputPaths(inputs) {
311
+ return Object.fromEntries(inputs.map((input) => {
312
+ const file = node_path_1.default.resolve(input);
313
+ return [file, hostInputRealpath(file)];
314
+ }));
315
+ }
316
+ /** Mirror native discovery and fingerprint every candidate before selecting. */
317
+ function discoverLintConfigFile(context) {
318
+ const hostInputHashes = {};
319
+ const hostInputRealpaths = {};
320
+ const hostInputs = [];
321
+ const recordCandidates = (candidates) => {
322
+ for (const candidate of candidates) {
323
+ const absolute = node_path_1.default.resolve(candidate);
324
+ if (Object.prototype.hasOwnProperty.call(hostInputHashes, absolute)) {
325
+ continue;
326
+ }
327
+ hostInputs.push(absolute);
328
+ Object.assign(hostInputHashes, hashHostInputPaths([absolute]));
329
+ Object.assign(hostInputRealpaths, realpathHostInputPaths([absolute]));
330
+ }
331
+ };
332
+ for (const origin of discoveryConfigBaseDirs(context)) {
333
+ for (let directory = origin;; directory = node_path_1.default.dirname(directory)) {
334
+ const candidates = LINT_CONFIG_FILENAMES.map((name) => node_path_1.default.join(directory, name));
335
+ recordCandidates(candidates);
336
+ const matches = lintConfigMatchesIn(directory);
337
+ if (matches.length === 1) {
338
+ return {
339
+ configPath: matches[0],
340
+ hostInputHashes,
341
+ hostInputRealpaths,
342
+ hostInputs,
343
+ };
344
+ }
345
+ if (matches.length > 1) {
346
+ throw new Error(`@ttsc/lint: multiple lint config files found in ${directory} (${matches
347
+ .map((file) => node_path_1.default.basename(file))
348
+ .join(", ")}); set "configFile" explicitly`);
349
+ }
350
+ const parent = node_path_1.default.dirname(directory);
351
+ if (parent === directory)
352
+ break;
353
+ }
354
+ }
355
+ return { hostInputHashes, hostInputRealpaths, hostInputs };
186
356
  }
187
357
  function assertContributorNamespacesDoNotCollide(entries, configPath) {
188
358
  const namespacesByGoName = new Map();
@@ -237,23 +407,6 @@ function readConfigFileOption(context) {
237
407
  }
238
408
  return value;
239
409
  }
240
- function findLintConfigFile(context) {
241
- // Mirror the Go side (driver.PluginConfigBaseDir): the caller-declared
242
- // pluginConfigDir is the single walk origin when present — it names the
243
- // real project when the resolved tsconfig is a generated wrapper in a temp
244
- // dir (@ttsc/unplugin's alias overlay), and it keeps the wrapper's temp
245
- // ancestry out of the walk so a stray config planted there is never
246
- // honored. Otherwise walk upward from the tsconfig directory first, then
247
- // fall back to the working directory: that covers callers that point at an
248
- // out-of-tree tsconfig without declaring an anchor.
249
- for (const origin of discoveryConfigBaseDirs(context)) {
250
- const discovered = findLintConfigFileFrom(origin);
251
- if (discovered !== undefined) {
252
- return discovered;
253
- }
254
- }
255
- return undefined;
256
- }
257
410
  function discoveryConfigBaseDirs(context) {
258
411
  if (context.pluginConfigDir) {
259
412
  return [node_path_1.default.resolve(context.cwd ?? ".", context.pluginConfigDir)];
@@ -262,45 +415,25 @@ function discoveryConfigBaseDirs(context) {
262
415
  const cwd = node_path_1.default.resolve(context.cwd ?? context.projectRoot);
263
416
  return tsconfigDir === cwd ? [tsconfigDir] : [tsconfigDir, cwd];
264
417
  }
265
- function findLintConfigFileFrom(origin) {
266
- // Mirror the Go-side discovery loop: walk from `origin` upward, returning
267
- // the first directory that has exactly one of the candidate filenames.
268
- // Multiple files in the same directory is treated as ambiguous and skipped
269
- // (the Go side raises a hard error on the duplicate; here we leave it to
270
- // the binary's own discovery to surface the issue once with one canonical
271
- // message).
272
- const candidateSet = new Set(LINT_CONFIG_FILENAMES);
273
- let dir = origin;
274
- while (true) {
275
- // One `readdirSync` per directory level beats 14 `existsSync`+
276
- // `statSync` pairs (= 28 stat syscalls) per level; intersect the
277
- // listing with the candidate set instead.
278
- let entries;
418
+ /** Return the non-directory candidates native discovery recognizes. */
419
+ function lintConfigMatchesIn(directory) {
420
+ const matches = [];
421
+ for (const name of LINT_CONFIG_FILENAMES) {
422
+ const candidate = node_path_1.default.join(directory, name);
279
423
  try {
280
- entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
424
+ // Go's os.Stat follows symlinks and junctions. Follow them here too so a
425
+ // directory or dangling link cannot stop descriptor discovery before
426
+ // the native host reaches a valid ancestor config. Probing the canonical
427
+ // candidate spelling also preserves Go's behavior on case-insensitive
428
+ // filesystems when the directory entry uses different casing.
429
+ if (!node_fs_1.default.statSync(candidate).isDirectory())
430
+ matches.push(candidate);
281
431
  }
282
432
  catch {
283
- entries = [];
284
- }
285
- const matches = [];
286
- for (const entry of entries) {
287
- if (!candidateSet.has(entry.name))
288
- continue;
289
- if (!entry.isFile() && !entry.isSymbolicLink())
290
- continue;
291
- matches.push(node_path_1.default.join(dir, entry.name));
292
- }
293
- if (matches.length === 1) {
294
- return matches[0];
433
+ // Missing, dangling, and unreadable candidates are not native matches.
295
434
  }
296
- if (matches.length > 1) {
297
- return undefined; // ambiguous — defer to the Go side's error
298
- }
299
- const parent = node_path_1.default.dirname(dir);
300
- if (parent === dir)
301
- return undefined;
302
- dir = parent;
303
435
  }
436
+ return matches;
304
437
  }
305
438
  /**
306
439
  * Base directory for resolving a relative `configFile` from the tsconfig plugin
@@ -332,8 +465,9 @@ function readConfigPluginEntries(configPath, context) {
332
465
  // is a real subprocess, and a host that only wanted to know whether there
333
466
  // were contributors would otherwise depend on a launcher being resolvable and
334
467
  // on a compiler accepting one more invocation.
335
- if (jsonConfigDeclaresNoContributor(configPath))
336
- return [];
468
+ if (jsonConfigDeclaresNoContributor(configPath)) {
469
+ return { dependencies: [], entries: [] };
470
+ }
337
471
  // Every other config uses the same isolated evaluator. Executable config can
338
472
  // name contributor packages whose top-level code writes to stdout, so loading
339
473
  // it in this host process would corrupt CLI JSON or preface the first LSP
@@ -417,9 +551,11 @@ const CONFIG_KEYS = new Set<string>([
417
551
  ]);
418
552
  const dependencies = new Map<string, {
419
553
  digest: string;
554
+ identityStable: boolean;
420
555
  kind: "directory" | "file" | "optional-file";
421
556
  path: string;
422
557
  owners: Set<string>;
558
+ realpath: string | null;
423
559
  }>();
424
560
  const graphNodes = new Map<string, string>();
425
561
  const graphEdges: Array<{
@@ -450,6 +586,23 @@ const configUrlSpellings = [
450
586
  pathToFileURL(realConfigLocation()).href,
451
587
  ]),
452
588
  ];
589
+ const moduleProbeExtensions = [
590
+ ".ts",
591
+ ".tsx",
592
+ ".mts",
593
+ ".cts",
594
+ ".js",
595
+ ".mjs",
596
+ ".cjs",
597
+ ".json",
598
+ ".node",
599
+ ] as const;
600
+ const jsToTsProbeExtensions = new Map<string, readonly string[]>([
601
+ [".js", [".ts", ".tsx"]],
602
+ [".jsx", [".tsx"]],
603
+ [".mjs", [".mts"]],
604
+ [".cjs", [".cts"]],
605
+ ]);
453
606
  for (const spelling of configUrlSpellings) {
454
607
  graphNodes.set(spelling, configLocation);
455
608
  }
@@ -471,6 +624,11 @@ declare const process: {
471
624
 
472
625
  const hooks = registerHooks({
473
626
  resolve(specifier, context, nextResolve) {
627
+ const requestedParent =
628
+ context.parentURL && new URL(context.parentURL).href;
629
+ if (requestedParent !== undefined && graphNodes.has(requestedParent)) {
630
+ recordLocalResolutionCandidates(specifier, requestedParent);
631
+ }
474
632
  const resolved = nextResolve(specifier, context);
475
633
  if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
476
634
  return resolved;
@@ -603,14 +761,32 @@ function recordDependency(
603
761
  const previous = dependencies.get(key);
604
762
  const mergedOwners = previous?.owners ?? new Set<string>();
605
763
  for (const owner of owners) mergedOwners.add(owner);
764
+ const realpath = dependencyRealpath(location);
765
+ const identityStable =
766
+ previous?.identityStable !== false &&
767
+ (previous === undefined || previous.realpath === realpath);
606
768
  dependencies.set(key, {
607
- digest: previous !== undefined && previous.digest !== digest ? "" : digest,
769
+ digest:
770
+ !identityStable ||
771
+ (previous !== undefined && previous.digest !== digest)
772
+ ? ""
773
+ : digest,
774
+ identityStable,
608
775
  kind,
609
776
  owners: mergedOwners,
610
777
  path: location,
778
+ realpath,
611
779
  });
612
780
  }
613
781
 
782
+ function dependencyRealpath(location: string): string | null {
783
+ try {
784
+ return realPath(location);
785
+ } catch {
786
+ return null;
787
+ }
788
+ }
789
+
614
790
  function isLocalModuleSpecifier(specifier: string): boolean {
615
791
  return specifier.startsWith(".") ||
616
792
  specifier.startsWith("/") ||
@@ -728,13 +904,19 @@ function directoryDigestRecord(
728
904
 
729
905
  function optionalFileDigest(location: string): string {
730
906
  try {
731
- if (fs.statSync(location).isFile()) {
907
+ const entry = fs.statSync(location);
908
+ if (entry.isFile()) {
732
909
  return createHash("sha256")
733
910
  .update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)]))
734
911
  .digest("hex");
735
912
  }
913
+ if (entry.isDirectory()) {
914
+ return createHash("sha256")
915
+ .update("ttsc:host-input:directory\\0")
916
+ .digest("hex");
917
+ }
736
918
  } catch {
737
- // Missing, unreadable, and non-file candidates share the absent state.
919
+ // Missing and unreadable candidates share the absent state.
738
920
  }
739
921
  return createHash("sha256").update("missing\\0").digest("hex");
740
922
  }
@@ -760,6 +942,80 @@ function recordOptionalFileDependency(
760
942
  return false;
761
943
  }
762
944
 
945
+ function moduleResolutionCandidates(base: string): string[] {
946
+ const extension = path.extname(base).toLowerCase();
947
+ const substitutions = jsToTsProbeExtensions.get(extension) ?? [];
948
+ const stem = base.slice(0, base.length - extension.length);
949
+ return [
950
+ base,
951
+ ...substitutions.map((candidate) => stem + candidate),
952
+ ...moduleProbeExtensions.map((candidate) => base + candidate),
953
+ path.join(base, "package.json"),
954
+ ...moduleProbeExtensions.map((candidate) =>
955
+ path.join(base, "index" + candidate),
956
+ ),
957
+ ];
958
+ }
959
+
960
+ /** Record exact local probes before the runtime resolver chooses one. */
961
+ function recordLocalResolutionCandidates(
962
+ specifier: string,
963
+ parentUrl: string,
964
+ ): void {
965
+ if (!isLocalModuleSpecifier(specifier)) return;
966
+ let bases: string[];
967
+ try {
968
+ if (specifier.startsWith("file:")) {
969
+ bases = [fileURLToPath(specifier)];
970
+ } else {
971
+ const directory = path.dirname(fileURLToPath(parentUrl));
972
+ const raw = path.resolve(directory, specifier);
973
+ const suffixStart = specifier.search(/[?#]/);
974
+ const pathname =
975
+ suffixStart === -1 ? specifier : specifier.slice(0, suffixStart);
976
+ bases = pathname === ""
977
+ ? [raw]
978
+ : [...new Set([raw, path.resolve(directory, pathname)])];
979
+ }
980
+ } catch {
981
+ return;
982
+ }
983
+ const owners = [parentUrl];
984
+ for (const base of bases) {
985
+ try {
986
+ if (fs.statSync(base).isFile()) {
987
+ recordOptionalFileDependency(base, owners);
988
+ continue;
989
+ }
990
+ } catch {
991
+ // A missing exact spelling falls through to source/extension/directory
992
+ // probes, all of which can redirect a later evaluation.
993
+ }
994
+ for (const candidate of moduleResolutionCandidates(base)) {
995
+ recordOptionalFileDependency(candidate, owners);
996
+ }
997
+ }
998
+ }
999
+
1000
+ /** CommonJS LOAD_AS_FILE / LOAD_AS_DIRECTORY candidates for one legacy path. */
1001
+ function recordLegacyPackagePathCandidates(
1002
+ candidate: string,
1003
+ owners: readonly string[],
1004
+ ): void {
1005
+ for (const file of [
1006
+ candidate,
1007
+ candidate + ".js",
1008
+ candidate + ".json",
1009
+ candidate + ".node",
1010
+ path.join(candidate, "package.json"),
1011
+ path.join(candidate, "index.js"),
1012
+ path.join(candidate, "index.json"),
1013
+ path.join(candidate, "index.node"),
1014
+ ]) {
1015
+ recordOptionalFileDependency(file, owners);
1016
+ }
1017
+ }
1018
+
763
1019
  function recordPackageManifests(
764
1020
  location: string,
765
1021
  owners: readonly string[],
@@ -805,26 +1061,26 @@ function recordNodeModulesSearchDirectories(
805
1061
  // The directory digest of node_modules records a missing scope.
806
1062
  }
807
1063
  }
808
- if (packageName !== undefined) {
809
- const selected = recordPackageCandidateTopology(
810
- modules,
811
- packageName,
812
- specifier,
813
- childLocation,
814
- owners,
815
- conditions,
816
- );
817
- if (
818
- selected ||
819
- resolvedPackageContains(modules, packageName, childLocation)
820
- ) {
821
- return;
822
- }
823
- }
824
1064
  }
825
1065
  } catch {
826
1066
  // Missing search levels do not participate in the current resolution.
827
1067
  }
1068
+ if (packageName !== undefined) {
1069
+ const selected = recordPackageCandidateTopology(
1070
+ modules,
1071
+ packageName,
1072
+ specifier,
1073
+ childLocation,
1074
+ owners,
1075
+ conditions,
1076
+ );
1077
+ if (
1078
+ selected ||
1079
+ resolvedPackageContains(modules, packageName, childLocation)
1080
+ ) {
1081
+ return;
1082
+ }
1083
+ }
828
1084
  if (
829
1085
  packageName === undefined &&
830
1086
  samePhysicalPath(current, resolutionRoot)
@@ -846,14 +1102,32 @@ function recordPackageCandidateTopology(
846
1102
  conditions: readonly string[],
847
1103
  ): boolean {
848
1104
  const packageRoot = path.join(modules, packageName);
1105
+ const subpath = specifier
1106
+ .slice(packageName.length)
1107
+ .replace(/^[/\\\\]+/, "");
849
1108
  try {
850
- if (!fs.statSync(packageRoot).isDirectory()) return false;
1109
+ if (!fs.statSync(packageRoot).isDirectory()) {
1110
+ recordOptionalFileDependency(
1111
+ path.join(packageRoot, "package.json"),
1112
+ owners,
1113
+ );
1114
+ recordLegacyPackagePathCandidates(
1115
+ subpath === "" ? packageRoot : path.join(packageRoot, subpath),
1116
+ owners,
1117
+ );
1118
+ return false;
1119
+ }
851
1120
  } catch {
1121
+ recordOptionalFileDependency(
1122
+ path.join(packageRoot, "package.json"),
1123
+ owners,
1124
+ );
1125
+ recordLegacyPackagePathCandidates(
1126
+ subpath === "" ? packageRoot : path.join(packageRoot, subpath),
1127
+ owners,
1128
+ );
852
1129
  return false;
853
1130
  }
854
- const subpath = specifier
855
- .slice(packageName.length)
856
- .replace(/^[/\\\\]+/, "");
857
1131
  const rootTopology = recordPackageRootTopology(
858
1132
  packageRoot,
859
1133
  owners,
@@ -888,6 +1162,7 @@ function recordPackageRootTopology(
888
1162
  const legacySelected = (): boolean =>
889
1163
  useMain &&
890
1164
  packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
1165
+ if (useMain) recordLegacyPackagePathCandidates(normalizedRoot, owners);
891
1166
  if (!recordOptionalFileDependency(manifest, owners)) {
892
1167
  const selected = legacySelected();
893
1168
  if (!selected) {
@@ -934,6 +1209,7 @@ function recordPackageRootTopology(
934
1209
  // it literally and permits absolute paths and paths outside the package.
935
1210
  const main = path.resolve(normalizedRoot, metadata.main);
936
1211
  recordPackagePathCandidate(main, owners);
1212
+ recordLegacyPackagePathCandidates(main, owners);
937
1213
  selected =
938
1214
  packagePathCandidateMatchesChild(main, childLocation, true) ||
939
1215
  selected;
@@ -1181,6 +1457,7 @@ function recordPackageSubpathTopology(
1181
1457
  const candidate = boundedPackageTarget(packageRoot, subpath);
1182
1458
  if (candidate === undefined) return false;
1183
1459
  recordPackagePathCandidate(candidate, owners);
1460
+ recordLegacyPackagePathCandidates(candidate, owners);
1184
1461
  let selected = packagePathCandidateMatchesChild(
1185
1462
  candidate,
1186
1463
  childLocation,
@@ -1200,6 +1477,7 @@ function recordPackageSubpathTopology(
1200
1477
  if (typeof metadata.main === "string") {
1201
1478
  const main = path.resolve(candidate, metadata.main);
1202
1479
  recordPackagePathCandidate(main, owners);
1480
+ recordLegacyPackagePathCandidates(main, owners);
1203
1481
  selected =
1204
1482
  packagePathCandidateMatchesChild(main, childLocation, true) ||
1205
1483
  selected;
@@ -1370,10 +1648,25 @@ function realPath(location: string): string {
1370
1648
 
1371
1649
  function finalizeDependencies(): Array<{
1372
1650
  digest: string;
1651
+ identityStable: boolean;
1373
1652
  kind: "directory" | "file" | "optional-file";
1374
1653
  path: string;
1654
+ realpath: string | null;
1375
1655
  scope: "cache" | "watch";
1376
1656
  }> {
1657
+ // The evaluator may have observed a dependency, run arbitrary config code,
1658
+ // and then serialize after that path changed again. Re-read every recorded
1659
+ // dependency under its original ownership set so an A -> B -> A transition
1660
+ // is marked identity-unstable instead of pairing transient output with the
1661
+ // restored fingerprint.
1662
+ for (const dependency of [...dependencies.values()]) {
1663
+ recordDependency(
1664
+ dependency.kind,
1665
+ dependency.path,
1666
+ currentDependencyDigest(dependency.kind, dependency.path),
1667
+ [...dependency.owners],
1668
+ );
1669
+ }
1377
1670
  const watched = graphWatchReachability();
1378
1671
  return [...dependencies.values()].map(({ owners, ...dependency }) => ({
1379
1672
  ...dependency,
@@ -1383,6 +1676,21 @@ function finalizeDependencies(): Array<{
1383
1676
  }));
1384
1677
  }
1385
1678
 
1679
+ function currentDependencyDigest(
1680
+ kind: "directory" | "file" | "optional-file",
1681
+ location: string,
1682
+ ): string {
1683
+ try {
1684
+ if (kind === "directory") return directoryDigest(location);
1685
+ if (kind === "optional-file") return optionalFileDigest(location);
1686
+ return createHash("sha256")
1687
+ .update(fs.readFileSync(location))
1688
+ .digest("hex");
1689
+ } catch {
1690
+ return "";
1691
+ }
1692
+ }
1693
+
1386
1694
  function graphWatchReachability(): Set<string> {
1387
1695
  const adjacency = new Map<string, typeof graphEdges>();
1388
1696
  for (const edge of graphEdges) {
@@ -1547,7 +1855,7 @@ function readTtsxConfigPlugins(configPath, context) {
1547
1855
  if (cached &&
1548
1856
  cached.entries.every(isValidConfigPluginEntry) &&
1549
1857
  configDependenciesAreCurrent(cached.dependencies)) {
1550
- return cached.entries;
1858
+ return cached;
1551
1859
  }
1552
1860
  }
1553
1861
  // A config can be saved while it is being evaluated. Retry a bounded number
@@ -1560,10 +1868,10 @@ function readTtsxConfigPlugins(configPath, context) {
1560
1868
  if (configDependenciesAreCurrent(evaluation.dependencies)) {
1561
1869
  if (cacheKey)
1562
1870
  writeConfigPluginCache(cacheKey, evaluation);
1563
- return evaluation.entries;
1871
+ return evaluation;
1564
1872
  }
1565
1873
  }
1566
- return evaluation.entries;
1874
+ return evaluation;
1567
1875
  }
1568
1876
  /**
1569
1877
  * Reports whether a cached plugin entry is still usable: a well-formed
@@ -1590,7 +1898,7 @@ function isValidConfigPluginEntry(entry) {
1590
1898
  }
1591
1899
  }
1592
1900
  function evaluateTtsxConfigPlugins(configPath, context) {
1593
- const tempDir = realpathIfPossible(node_fs_1.default.mkdtempSync(node_path_1.default.join(loaderTempBase(configPath), "ttsc-lint-cfg-")));
1901
+ const tempDir = createCanonicalTempDirectory("ttsc-lint-cfg-", loaderTempBase(configPath));
1594
1902
  try {
1595
1903
  linkNearestNodeModules(tempDir, node_path_1.default.dirname(configPath));
1596
1904
  const loaderPath = node_path_1.default.join(tempDir, "loader.mts");
@@ -1735,11 +2043,27 @@ function evaluateTtsxConfigPlugins(configPath, context) {
1735
2043
  // ────────────────────────────────────────────────────────────────────────────
1736
2044
  // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
1737
2045
  // ────────────────────────────────────────────────────────────────────────────
2046
+ /** Create evaluator storage beneath a frozen physical parent. */
2047
+ function createCanonicalTempDirectory(prefix, parent) {
2048
+ const physicalParent = node_fs_1.default.realpathSync.native(parent);
2049
+ if (!node_fs_1.default.lstatSync(physicalParent).isDirectory()) {
2050
+ throw new Error(`@ttsc/lint: temporary directory parent is not a directory: ${physicalParent}`);
2051
+ }
2052
+ const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(physicalParent, prefix));
2053
+ if (!node_fs_1.default.lstatSync(directory).isDirectory()) {
2054
+ throw new Error(`@ttsc/lint: temporary directory postflight is not a directory: ${directory}`);
2055
+ }
2056
+ const physicalDirectory = node_fs_1.default.realpathSync.native(directory);
2057
+ if (node_path_1.default.dirname(physicalDirectory) !== physicalParent) {
2058
+ throw new Error(`@ttsc/lint: temporary directory escaped its physical parent: ${physicalDirectory}`);
2059
+ }
2060
+ return physicalDirectory;
2061
+ }
1738
2062
  /**
1739
2063
  * Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
1740
2064
  * `configCacheVersion`; bump both when the cached shape changes.
1741
2065
  */
1742
- const CONFIG_CACHE_VERSION = "v5";
2066
+ const CONFIG_CACHE_VERSION = "v7";
1743
2067
  /**
1744
2068
  * Directory shared by this factory and the Go sidecar for cached lint configs.
1745
2069
  * The two write different files (the `kind` segment of the cache key keeps
@@ -1848,6 +2172,12 @@ function normalizeConfigDependencyFingerprints(value) {
1848
2172
  typeof candidate !== "object" ||
1849
2173
  typeof candidate.path !== "string" ||
1850
2174
  typeof candidate.digest !== "string" ||
2175
+ typeof candidate.identityStable !==
2176
+ "boolean" ||
2177
+ (candidate.realpath !== null &&
2178
+ (typeof candidate.realpath !==
2179
+ "string" ||
2180
+ !node_path_1.default.isAbsolute(candidate.realpath))) ||
1851
2181
  !["directory", "file", "optional-file"].includes(candidate.kind) ||
1852
2182
  !["cache", "watch"].includes(candidate.scope)) {
1853
2183
  return undefined;
@@ -1855,22 +2185,36 @@ function normalizeConfigDependencyFingerprints(value) {
1855
2185
  const candidatePath = candidate.path;
1856
2186
  const digest = candidate.digest;
1857
2187
  const kind = candidate.kind;
2188
+ const identityStable = candidate
2189
+ .identityStable;
2190
+ const realpath = candidate.realpath;
1858
2191
  const scope = candidate.scope;
1859
- if (!node_path_1.default.isAbsolute(candidatePath) || !/^[0-9a-f]{64}$/.test(digest)) {
2192
+ if (!node_path_1.default.isAbsolute(candidatePath) ||
2193
+ (digest !== "" && !/^[0-9a-f]{64}$/.test(digest))) {
1860
2194
  return undefined;
1861
2195
  }
1862
2196
  const location = node_path_1.default.resolve(candidatePath);
1863
- const previous = dependencies.get(location);
2197
+ // The same lexical path can be both a traversed directory and an exact
2198
+ // file candidate. Those observations have different freshness contracts:
2199
+ // one fingerprints the listing, while the other fingerprints its entry
2200
+ // type. Preserve both, but still reject contradictory duplicates of one
2201
+ // kind.
2202
+ const key = kind + "\0" + location;
2203
+ const previous = dependencies.get(key);
1864
2204
  if (previous !== undefined &&
1865
2205
  (previous.digest !== digest ||
2206
+ previous.identityStable !== identityStable ||
1866
2207
  previous.kind !== kind ||
2208
+ previous.realpath !== realpath ||
1867
2209
  previous.scope !== scope)) {
1868
2210
  return undefined;
1869
2211
  }
1870
- dependencies.set(location, {
2212
+ dependencies.set(key, {
1871
2213
  digest,
2214
+ identityStable,
1872
2215
  kind,
1873
2216
  path: location,
2217
+ realpath,
1874
2218
  scope,
1875
2219
  });
1876
2220
  }
@@ -1880,6 +2224,10 @@ function configDependenciesAreCurrent(dependencies) {
1880
2224
  if (dependencies.length === 0)
1881
2225
  return false;
1882
2226
  return dependencies.every((dependency) => {
2227
+ if (!dependency.identityStable ||
2228
+ dependency.realpath !== hostInputRealpath(dependency.path)) {
2229
+ return false;
2230
+ }
1883
2231
  if (!/^[0-9a-f]{64}$/.test(dependency.digest))
1884
2232
  return false;
1885
2233
  try {
@@ -1950,14 +2298,20 @@ function configDirectoryDigestRecord(name, entry, target) {
1950
2298
  }
1951
2299
  function configOptionalFileDigest(location) {
1952
2300
  try {
1953
- if (node_fs_1.default.statSync(location).isFile()) {
2301
+ const entry = node_fs_1.default.statSync(location);
2302
+ if (entry.isFile()) {
1954
2303
  return (0, node_crypto_1.createHash)("sha256")
1955
2304
  .update(node_buffer_1.Buffer.concat([node_buffer_1.Buffer.from("file\0"), node_fs_1.default.readFileSync(location)]))
1956
2305
  .digest("hex");
1957
2306
  }
2307
+ if (entry.isDirectory()) {
2308
+ return (0, node_crypto_1.createHash)("sha256")
2309
+ .update("ttsc:host-input:directory\0")
2310
+ .digest("hex");
2311
+ }
1958
2312
  }
1959
2313
  catch {
1960
- // Missing, unreadable, and non-file candidates share the absent state.
2314
+ // Missing and unreadable candidates share the absent state.
1961
2315
  }
1962
2316
  return (0, node_crypto_1.createHash)("sha256").update("missing\0").digest("hex");
1963
2317
  }
@@ -2177,14 +2531,6 @@ function loaderTempBase(configPath) {
2177
2531
  }
2178
2532
  return node_path_1.default.dirname(configPath);
2179
2533
  }
2180
- function realpathIfPossible(location) {
2181
- try {
2182
- return node_fs_1.default.realpathSync(location);
2183
- }
2184
- catch {
2185
- return location;
2186
- }
2187
- }
2188
2534
  function nodeConfigLoaderEnv(configPath) {
2189
2535
  const env = { ...process.env };
2190
2536
  const parts = [];