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