@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.
@@ -1294,10 +1294,12 @@ func loadConfigFile(location string) (any, error) {
1294
1294
  }
1295
1295
 
1296
1296
  type configDependencyFingerprint struct {
1297
- Path string `json:"path"`
1298
- Digest string `json:"digest"`
1299
- Kind string `json:"kind"`
1300
- Scope string `json:"scope"`
1297
+ Path string `json:"path"`
1298
+ Digest string `json:"digest"`
1299
+ IdentityStable bool `json:"identityStable"`
1300
+ Kind string `json:"kind"`
1301
+ Realpath *string `json:"realpath"`
1302
+ Scope string `json:"scope"`
1301
1303
  }
1302
1304
 
1303
1305
  const (
@@ -1367,10 +1369,10 @@ func loadConfigFileEvaluationWithin(
1367
1369
  // configCacheVersion namespaces the on-disk config cache. Bump it whenever
1368
1370
  // the shape of a cached config object changes so that entries written by an
1369
1371
  // older @ttsc/lint binary are treated as a miss rather than silently reused.
1370
- // v6 adds the `entry` dependency kind. A v5 cache records a resolution trace's
1371
- // ancestors as directory digests, so reusing it would keep republishing the
1372
- // filesystem root as a watch input for as long as the entry survives.
1373
- const configCacheVersion = "v6"
1372
+ // v8 also rejects content-restoring A-B-A replacement during evaluation. A v7
1373
+ // cache can otherwise pair output from the transient state with the restored
1374
+ // state's equal digest and physical path.
1375
+ const configCacheVersion = "v8"
1374
1376
 
1375
1377
  // configEvalCache memoizes evaluated .ts/.js lint config objects for the
1376
1378
  // lifetime of one process; the on-disk cache (configCacheDir) extends the
@@ -1557,6 +1559,10 @@ func configDependencyDigestsAreCurrent(
1557
1559
  dependencies []configDependencyFingerprint,
1558
1560
  ) bool {
1559
1561
  for _, dependency := range dependencies {
1562
+ if !dependency.IdentityStable ||
1563
+ !sameConfigDependencyRealpath(dependency.Realpath, configDependencyRealpath(dependency.Path)) {
1564
+ return false
1565
+ }
1560
1566
  digest, err := configDependencyDigest(dependency)
1561
1567
  if err != nil {
1562
1568
  return false
@@ -1568,6 +1574,19 @@ func configDependencyDigestsAreCurrent(
1568
1574
  return true
1569
1575
  }
1570
1576
 
1577
+ func configDependencyRealpath(location string) *string {
1578
+ absolute, err := filepath.Abs(location)
1579
+ if err != nil {
1580
+ return nil
1581
+ }
1582
+ resolved := realProjectPath(absolute)
1583
+ if _, statErr := os.Stat(resolved); statErr != nil {
1584
+ return nil
1585
+ }
1586
+ resolved = filepath.Clean(resolved)
1587
+ return &resolved
1588
+ }
1589
+
1571
1590
  func configDependencyDigest(
1572
1591
  dependency configDependencyFingerprint,
1573
1592
  ) (string, error) {
@@ -1848,7 +1867,8 @@ func normalizeConfigDependencyFingerprints(
1848
1867
  for _, dependency := range input {
1849
1868
  if strings.TrimSpace(dependency.Path) == "" ||
1850
1869
  !filepath.IsAbs(dependency.Path) ||
1851
- len(dependency.Digest) != sha256.Size*2 ||
1870
+ (dependency.Realpath != nil && !filepath.IsAbs(*dependency.Realpath)) ||
1871
+ (dependency.Digest != "" && len(dependency.Digest) != sha256.Size*2) ||
1852
1872
  strings.ToLower(dependency.Digest) != dependency.Digest ||
1853
1873
  (dependency.Kind != configDependencyFile &&
1854
1874
  dependency.Kind != configDependencyDir &&
@@ -1858,24 +1878,30 @@ func normalizeConfigDependencyFingerprints(
1858
1878
  dependency.Scope != configDependencyWatch) {
1859
1879
  return nil, false
1860
1880
  }
1861
- if _, err := hex.DecodeString(dependency.Digest); err != nil {
1862
- return nil, false
1881
+ if dependency.Digest != "" {
1882
+ if _, err := hex.DecodeString(dependency.Digest); err != nil {
1883
+ return nil, false
1884
+ }
1863
1885
  }
1864
1886
  absolute := filepath.Clean(dependency.Path)
1865
1887
  key := dependency.Kind + "\x00" + absolute
1866
1888
  if previous, exists := seen[key]; exists {
1867
1889
  if previous.Digest != dependency.Digest ||
1890
+ previous.IdentityStable != dependency.IdentityStable ||
1868
1891
  previous.Kind != dependency.Kind ||
1892
+ !sameConfigDependencyRealpath(previous.Realpath, dependency.Realpath) ||
1869
1893
  previous.Scope != dependency.Scope {
1870
1894
  return nil, false
1871
1895
  }
1872
1896
  continue
1873
1897
  }
1874
1898
  fingerprint := configDependencyFingerprint{
1875
- Path: absolute,
1876
- Digest: dependency.Digest,
1877
- Kind: dependency.Kind,
1878
- Scope: dependency.Scope,
1899
+ Path: absolute,
1900
+ Digest: dependency.Digest,
1901
+ IdentityStable: dependency.IdentityStable,
1902
+ Kind: dependency.Kind,
1903
+ Realpath: cloneConfigDependencyRealpath(dependency.Realpath),
1904
+ Scope: dependency.Scope,
1879
1905
  }
1880
1906
  seen[key] = fingerprint
1881
1907
  normalized = append(normalized, fingerprint)
@@ -1886,6 +1912,21 @@ func normalizeConfigDependencyFingerprints(
1886
1912
  return normalized, true
1887
1913
  }
1888
1914
 
1915
+ func cloneConfigDependencyRealpath(value *string) *string {
1916
+ if value == nil {
1917
+ return nil
1918
+ }
1919
+ cloned := filepath.Clean(*value)
1920
+ return &cloned
1921
+ }
1922
+
1923
+ func sameConfigDependencyRealpath(left, right *string) bool {
1924
+ if left == nil || right == nil {
1925
+ return left == nil && right == nil
1926
+ }
1927
+ return filepath.Clean(*left) == filepath.Clean(*right)
1928
+ }
1929
+
1889
1930
  // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1890
1931
  // Node subprocess that dynamic-imports the file, resolves the exported config
1891
1932
  // through the same 8-hop default/config normalization used by the TS loader,
@@ -1919,12 +1960,18 @@ func loadScriptConfigEvaluationWithin(
1919
1960
  cmd := exec.CommandContext(
1920
1961
  ctx,
1921
1962
  node,
1922
- "-e",
1923
- script,
1963
+ "--input-type=commonjs",
1964
+ "-",
1924
1965
  location,
1925
1966
  outputPath,
1926
1967
  resolutionRoot,
1927
1968
  )
1969
+ // Windows limits the whole process command line to roughly 32 KiB. The
1970
+ // dependency-tracking loader is intentionally larger than that, so keep only
1971
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
1972
+ // the loader runs. This preserves the historical process.argv layout seen by
1973
+ // both the loader and the imported user config without using string eval.
1974
+ cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
1928
1975
  return runConfigLoaderCommand(cmd, location, "config file", outputPath)
1929
1976
  }
1930
1977
 
@@ -2104,19 +2151,78 @@ function isObject(value) {
2104
2151
  return value !== null && typeof value === "object";
2105
2152
  }
2106
2153
 
2154
+ function missingPathError(error) {
2155
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
2156
+ }
2157
+
2158
+ function dependencyMetadataSignature(location) {
2159
+ const requested = path.resolve(location);
2160
+ let current = requested;
2161
+ for (;;) {
2162
+ try {
2163
+ const link = fs.lstatSync(current, { bigint: true });
2164
+ let target = link;
2165
+ if (link.isSymbolicLink()) {
2166
+ try { target = fs.statSync(current, { bigint: true }); }
2167
+ catch { return undefined; }
2168
+ }
2169
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
2170
+ } catch (error) {
2171
+ if (!missingPathError(error)) return undefined;
2172
+ const parent = path.dirname(current);
2173
+ if (parent === current) return undefined;
2174
+ current = parent;
2175
+ }
2176
+ }
2177
+ }
2178
+
2179
+ function currentDependencyDigest(kind, location) {
2180
+ try {
2181
+ if (kind === "directory") return directoryDigest(location);
2182
+ if (kind === "entry") return entryDigest(location);
2183
+ if (kind === "optional-file") return optionalFileDigest(location);
2184
+ return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
2185
+ } catch {
2186
+ return "";
2187
+ }
2188
+ }
2189
+
2107
2190
  function recordDependency(kind, location, digest, owners) {
2108
2191
  const key = kind + "\0" + location;
2109
2192
  const previous = dependencies.get(key);
2110
2193
  const mergedOwners = previous ? previous.owners : new Set();
2111
2194
  for (const owner of owners) mergedOwners.add(owner);
2195
+ const beforeSignature = dependencyMetadataSignature(location);
2196
+ const observedDigest = currentDependencyDigest(kind, location);
2197
+ const realpath = dependencyRealpath(location);
2198
+ const afterSignature = dependencyMetadataSignature(location);
2199
+ const identityStable =
2200
+ (!previous || previous.identityStable) &&
2201
+ beforeSignature !== undefined &&
2202
+ afterSignature !== undefined &&
2203
+ beforeSignature === afterSignature &&
2204
+ digest === observedDigest &&
2205
+ (!previous || previous.realpath === realpath) &&
2206
+ (!previous || previous.signature === afterSignature);
2112
2207
  dependencies.set(key, {
2113
- digest: previous && previous.digest !== digest ? "" : digest,
2208
+ digest: !identityStable || (previous && previous.digest !== digest) ? "" : digest,
2209
+ identityStable,
2114
2210
  kind,
2115
2211
  owners: mergedOwners,
2116
2212
  path: location,
2213
+ realpath,
2214
+ signature: afterSignature,
2117
2215
  });
2118
2216
  }
2119
2217
 
2218
+ function dependencyRealpath(location) {
2219
+ try {
2220
+ return realPath(location);
2221
+ } catch {
2222
+ return null;
2223
+ }
2224
+ }
2225
+
2120
2226
  function isLocalModuleSpecifier(specifier) {
2121
2227
  return specifier.startsWith(".") ||
2122
2228
  specifier.startsWith("/") ||
@@ -2874,10 +2980,22 @@ function realPath(location) {
2874
2980
  }
2875
2981
 
2876
2982
  function finalizeDependencies() {
2983
+ for (const dependency of [...dependencies.values()]) {
2984
+ recordDependency(
2985
+ dependency.kind,
2986
+ dependency.path,
2987
+ currentDependencyDigest(dependency.kind, dependency.path),
2988
+ [...dependency.owners],
2989
+ );
2990
+ }
2877
2991
  const watched = graphWatchReachability();
2878
- return [...dependencies.values()].map(({ owners, ...dependency }) => ({
2879
- ...dependency,
2880
- scope: [...owners].some((owner) => watched.has(owner))
2992
+ return [...dependencies.values()].map((dependency) => ({
2993
+ digest: dependency.digest,
2994
+ identityStable: dependency.identityStable,
2995
+ kind: dependency.kind,
2996
+ path: dependency.path,
2997
+ realpath: dependency.realpath,
2998
+ scope: [...dependency.owners].some((owner) => watched.has(owner))
2881
2999
  ? "watch"
2882
3000
  : "cache",
2883
3001
  }));
@@ -3028,14 +3146,15 @@ func loadTypeScriptConfigEvaluationWithin(
3028
3146
  // ttsx build hermetic.
3029
3147
  "--no-plugins",
3030
3148
  }
3031
- if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
3149
+ anchors := configToolAnchors(location, resolutionRoot)
3150
+ if tsgo := resolveConfigTsgo(anchors); tsgo != "" {
3032
3151
  args = append(args, "--binary", tsgo)
3033
3152
  }
3034
3153
  args = append(args, loader)
3035
3154
 
3036
3155
  ctx, cancel := context.WithCancel(context.Background())
3037
3156
  defer cancel()
3038
- cmd := ttsxCommandContext(ctx, args...)
3157
+ cmd := ttsxCommandContext(ctx, anchors, args...)
3039
3158
  cmd.Env = nodeConfigLoaderEnv(location)
3040
3159
  return runConfigLoaderCommand(cmd, location, "TypeScript config file", outputPath)
3041
3160
  }
@@ -3120,9 +3239,12 @@ const resolutionRoot = path.resolve(%s);
3120
3239
  const CONFIG_KEYS = new Set<string>([%s]);
3121
3240
  const dependencies = new Map<string, {
3122
3241
  digest: string;
3242
+ identityStable: boolean;
3123
3243
  kind: "directory" | "entry" | "file" | "optional-file";
3124
3244
  path: string;
3125
3245
  owners: Set<string>;
3246
+ realpath: string | null;
3247
+ signature: string | undefined;
3126
3248
  }>();
3127
3249
  const graphNodes = new Map<string, string>();
3128
3250
  const graphEdges: Array<{
@@ -3295,6 +3417,48 @@ function isObject(value: unknown): value is Record<string, unknown> {
3295
3417
  return value !== null && typeof value === "object";
3296
3418
  }
3297
3419
 
3420
+ function missingPathError(error: unknown): boolean {
3421
+ const code = (error as { code?: unknown } | undefined)?.code;
3422
+ return code === "ENOENT" || code === "ENOTDIR";
3423
+ }
3424
+
3425
+ function dependencyMetadataSignature(
3426
+ location: string,
3427
+ ): string | undefined {
3428
+ const requested = path.resolve(location);
3429
+ let current = requested;
3430
+ for (;;) {
3431
+ try {
3432
+ const link = fs.lstatSync(current, { bigint: true });
3433
+ let target = link;
3434
+ if (link.isSymbolicLink()) {
3435
+ try { target = fs.statSync(current, { bigint: true }); }
3436
+ catch { return undefined; }
3437
+ }
3438
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
3439
+ } catch (error) {
3440
+ if (!missingPathError(error)) return undefined;
3441
+ const parent = path.dirname(current);
3442
+ if (parent === current) return undefined;
3443
+ current = parent;
3444
+ }
3445
+ }
3446
+ }
3447
+
3448
+ function currentDependencyDigest(
3449
+ kind: "directory" | "entry" | "file" | "optional-file",
3450
+ location: string,
3451
+ ): string {
3452
+ try {
3453
+ if (kind === "directory") return directoryDigest(location);
3454
+ if (kind === "entry") return entryDigest(location);
3455
+ if (kind === "optional-file") return optionalFileDigest(location);
3456
+ return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
3457
+ } catch {
3458
+ return "";
3459
+ }
3460
+ }
3461
+
3298
3462
  function recordDependency(
3299
3463
  kind: "directory" | "entry" | "file" | "optional-file",
3300
3464
  location: string,
@@ -3305,14 +3469,41 @@ function recordDependency(
3305
3469
  const previous = dependencies.get(key);
3306
3470
  const mergedOwners = previous?.owners ?? new Set<string>();
3307
3471
  for (const owner of owners) mergedOwners.add(owner);
3472
+ const beforeSignature = dependencyMetadataSignature(location);
3473
+ const observedDigest = currentDependencyDigest(kind, location);
3474
+ const realpath = dependencyRealpath(location);
3475
+ const afterSignature = dependencyMetadataSignature(location);
3476
+ const identityStable =
3477
+ previous?.identityStable !== false &&
3478
+ beforeSignature !== undefined &&
3479
+ afterSignature !== undefined &&
3480
+ beforeSignature === afterSignature &&
3481
+ digest === observedDigest &&
3482
+ (previous === undefined || previous.realpath === realpath) &&
3483
+ (previous === undefined || previous.signature === afterSignature);
3308
3484
  dependencies.set(key, {
3309
- digest: previous !== undefined && previous.digest !== digest ? "" : digest,
3485
+ digest:
3486
+ !identityStable ||
3487
+ (previous !== undefined && previous.digest !== digest)
3488
+ ? ""
3489
+ : digest,
3490
+ identityStable,
3310
3491
  kind,
3311
3492
  owners: mergedOwners,
3312
3493
  path: location,
3494
+ realpath,
3495
+ signature: afterSignature,
3313
3496
  });
3314
3497
  }
3315
3498
 
3499
+ function dependencyRealpath(location: string): string | null {
3500
+ try {
3501
+ return realPath(location);
3502
+ } catch {
3503
+ return null;
3504
+ }
3505
+ }
3506
+
3316
3507
  function isLocalModuleSpecifier(specifier: string): boolean {
3317
3508
  return specifier.startsWith(".") ||
3318
3509
  specifier.startsWith("/") ||
@@ -4114,10 +4305,20 @@ function realPath(location: string): string {
4114
4305
 
4115
4306
  function finalizeDependencies(): Array<{
4116
4307
  digest: string;
4308
+ identityStable: boolean;
4117
4309
  kind: "directory" | "entry" | "file" | "optional-file";
4118
4310
  path: string;
4311
+ realpath: string | null;
4119
4312
  scope: "cache" | "watch";
4120
4313
  }> {
4314
+ for (const dependency of [...dependencies.values()]) {
4315
+ recordDependency(
4316
+ dependency.kind,
4317
+ dependency.path,
4318
+ currentDependencyDigest(dependency.kind, dependency.path),
4319
+ [...dependency.owners],
4320
+ );
4321
+ }
4121
4322
  const watched = graphWatchReachability();
4122
4323
  // Opt-in diagnostics for a graph that comes back empty. The only channel this
4123
4324
  // loader may use is stderr, because the result travels through a private file
@@ -4135,9 +4336,13 @@ function finalizeDependencies(): Array<{
4135
4336
  "\n",
4136
4337
  );
4137
4338
  }
4138
- return [...dependencies.values()].map(({ owners, ...dependency }) => ({
4139
- ...dependency,
4140
- scope: [...owners].some((owner) => watched.has(owner))
4339
+ return [...dependencies.values()].map((dependency) => ({
4340
+ digest: dependency.digest,
4341
+ identityStable: dependency.identityStable,
4342
+ kind: dependency.kind,
4343
+ path: dependency.path,
4344
+ realpath: dependency.realpath,
4345
+ scope: [...dependency.owners].some((owner) => watched.has(owner))
4141
4346
  ? "watch"
4142
4347
  : "cache",
4143
4348
  }));
@@ -4250,6 +4455,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4250
4455
  // resolving either way.
4251
4456
  "module": configModuleOption(location),
4252
4457
  "moduleResolution": "bundler",
4458
+ "jsx": "preserve",
4253
4459
  "noImplicitAny": false,
4254
4460
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
4255
4461
  "rewriteRelativeImportExtensions": true,
@@ -4276,6 +4482,23 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4276
4482
  return string(body)
4277
4483
  }
4278
4484
 
4485
+ // ttsc:config-loader-shared begin
4486
+ //
4487
+ // One policy in three Go copies: everything between these markers is
4488
+ // duplicated verbatim in packages/lint/linthost/config.go,
4489
+ // packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
4490
+ // decided against extracting it — the only home the three modules could share
4491
+ // is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
4492
+ // deliberately requires no in-tree ttsc module — and replaced the checklist
4493
+ // with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
4494
+ // between these markers across all three copies on every pull request, so
4495
+ // editing one and not the others fails by name. That file's header carries the
4496
+ // full decision and the rules for changing this block.
4497
+ //
4498
+ // The code between the markers must stay identical. Comments may differ, the
4499
+ // `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
4500
+ // a `strip` prefix. Anything package-specific belongs outside the markers.
4501
+
4279
4502
  // configModuleOption returns the loader tsconfig's "module" for a config file:
4280
4503
  // the module kind Node itself would give that file.
4281
4504
  //
@@ -4406,21 +4629,235 @@ func resolveDirLink(dir string) string {
4406
4629
  return dir
4407
4630
  }
4408
4631
 
4632
+ // Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
4633
+ // spawns and the native compiler it hands that launcher — are resolved from the
4634
+ // project being linted, with an explicit environment variable winning and a
4635
+ // last resort that invents no path.
4636
+ //
4637
+ // The three Go copies are held identical by the gate named at the top of this
4638
+ // block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
4639
+ // packages/lint/src/index.ts — is a fourth copy in another language that no Go
4640
+ // gate can reach; the two evaluators must keep one policy, because it was a
4641
+ // divergence between them that made a TypeScript lint config unevaluable
4642
+ // outside a `ttsx`-launched host.
4643
+ //
4644
+ // The environment alone is the wrong place to ask. `ttsx` exports
4645
+ // TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
4646
+ // launched under `ttsx` inherited both and a host launched any other way
4647
+ // inherited neither. The shipped `ttscserver` binary invoked with its
4648
+ // documented `--tsgo <path>` flag keeps that path in a local and exports
4649
+ // nothing, and an embedder of the driver package exports nothing either. For
4650
+ // those the evaluator spawned a bare `ttsx` that only a global install puts on
4651
+ // PATH, and, past that, a compiler-less child that aborted with
4652
+ // `ttsc: typescript is required` before a line of the config was read.
4653
+ //
4654
+ // configToolAnchors lists the file paths those resolutions walk upward from,
4655
+ // in order: the config file being evaluated, then the resolution root's
4656
+ // manifest. The config comes first because it is the file whose own
4657
+ // installation decides which toolchain the config's imports were written
4658
+ // against; the resolution root answers for a config that lives outside the
4659
+ // project tree (an `extends` target, or a `configFile` pointed at a shared
4660
+ // package).
4661
+ //
4662
+ // The JS evaluator carries a third anchor, the loaded descriptor's own
4663
+ // directory. It has no counterpart here: this host is a compiled binary rather
4664
+ // than a module some `node_modules` copy of `@ttsc/lint` was loaded from, so
4665
+ // there is no third installation to ask.
4666
+ func configToolAnchors(configPath, resolutionRoot string) []string {
4667
+ anchors := make([]string, 0, 2)
4668
+ if strings.TrimSpace(configPath) != "" {
4669
+ anchors = append(anchors, configPath)
4670
+ }
4671
+ if strings.TrimSpace(resolutionRoot) != "" {
4672
+ anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
4673
+ }
4674
+ return anchors
4675
+ }
4676
+
4677
+ // resolveConfigTsgo returns the native TypeScript compiler the evaluator hands
4678
+ // its ttsx child through `--binary`, or "" to leave the child resolving for
4679
+ // itself.
4680
+ //
4681
+ // The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
4682
+ // `typescript` the way an ordinary invocation does: linkNearestNodeModules is
4683
+ // the only thing that puts the project's modules within its reach, and it links
4684
+ // nothing when the config's ancestry carries no node_modules. An explicit
4685
+ // TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
4686
+ // pinning it. "" is the unchanged last resort: a project that cannot answer
4687
+ // here could not answer inside the child either, and the child's own diagnostic
4688
+ // is the one that names the missing package.
4689
+ func resolveConfigTsgo(anchors []string) string {
4690
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
4691
+ return explicit
4692
+ }
4693
+ for _, anchor := range anchors {
4694
+ if binary := tsgoBinaryFrom(anchor); binary != "" {
4695
+ return binary
4696
+ }
4697
+ }
4698
+ return ""
4699
+ }
4700
+
4701
+ // tsgoBinaryFrom returns the platform compiler executable of the `typescript`
4702
+ // install `anchor` can see, or "" when this anchor reaches neither the package
4703
+ // nor its platform dependency.
4704
+ //
4705
+ // Mirrors resolveTsgo.ts so the Go host and the JS launcher name one file: the
4706
+ // `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
4707
+ // resolved from that manifest, then `lib/tsc` inside it.
4708
+ //
4709
+ // The install is chased to its real directory before the second hop, because
4710
+ // Node resolves a module's own dependencies from its real location. pnpm keeps
4711
+ // the real `typescript` directory in its content-addressed store with the
4712
+ // platform package beside it and leaves a link in the project's node_modules,
4713
+ // so a walk that started at the link would climb straight past the platform
4714
+ // package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
4715
+ // is chased by hand first, the same order loaderTempBase uses.
4716
+ func tsgoBinaryFrom(anchor string) string {
4717
+ manifest := nodePackageManifestFrom(anchor, "typescript")
4718
+ if manifest == "" {
4719
+ return ""
4720
+ }
4721
+ packageDir := realpathIfPossible(resolveDirLink(filepath.Dir(manifest)))
4722
+ platform, arch := nodePlatformPair()
4723
+ platformManifest := nodePackageManifestFrom(
4724
+ filepath.Join(packageDir, "package.json"),
4725
+ "@typescript/typescript-"+platform+"-"+arch,
4726
+ )
4727
+ if platformManifest == "" {
4728
+ return ""
4729
+ }
4730
+ name := "tsc"
4731
+ if runtime.GOOS == "windows" {
4732
+ name = "tsc.exe"
4733
+ }
4734
+ binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
4735
+ if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
4736
+ return ""
4737
+ }
4738
+ return binary
4739
+ }
4740
+
4741
+ // resolveTtsxLauncher returns the launcher ttsxCommandContext spawns.
4742
+ //
4743
+ // An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
4744
+ // `ttsc` installation one of the anchors can see, because a bare command name
4745
+ // only works when a bin link happens to be on PATH — which it is for a global
4746
+ // install and is not for the ordinary project-local one. The bare `"ttsx"` name
4747
+ // remains the unchanged last resort for an installation no anchor reaches.
4748
+ func resolveTtsxLauncher(anchors []string) string {
4749
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
4750
+ return explicit
4751
+ }
4752
+ for _, anchor := range anchors {
4753
+ if launcher := ttsxLauncherFrom(anchor); launcher != "" {
4754
+ return launcher
4755
+ }
4756
+ }
4757
+ return "ttsx"
4758
+ }
4759
+
4760
+ // ttsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
4761
+ // `anchor` can see, or "" when this anchor reaches no such install. Only the
4762
+ // manifest is an exported subpath, so the launcher is derived from where the
4763
+ // manifest resolved rather than requested as a subpath of its own.
4764
+ func ttsxLauncherFrom(anchor string) string {
4765
+ manifest := nodePackageManifestFrom(anchor, "ttsc")
4766
+ if manifest == "" {
4767
+ return ""
4768
+ }
4769
+ launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
4770
+ if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
4771
+ return ""
4772
+ }
4773
+ return launcher
4774
+ }
4775
+
4776
+ // nodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
4777
+ // require.resolve does from the FILE `anchor`: walk upward from the anchor's
4778
+ // directory and return the first `<dir>/node_modules/<pkg>/package.json` that
4779
+ // exists. The anchor is treated as a file path, so its own directory is the
4780
+ // first candidate's parent, and it need not exist — Node derives the search
4781
+ // paths from the string alone.
4782
+ //
4783
+ // A directory already named `node_modules` contributes no candidate of its own,
4784
+ // matching Module._nodeModulePaths, so nothing ever resolves through
4785
+ // `node_modules/node_modules`.
4786
+ //
4787
+ // A relative anchor is resolved against the process directory before the walk,
4788
+ // again matching Node. Walking a relative path instead would terminate at "."
4789
+ // after one step and silently answer nothing for a config named relatively.
4790
+ func nodePackageManifestFrom(anchor, pkg string) string {
4791
+ if strings.TrimSpace(anchor) == "" || pkg == "" {
4792
+ return ""
4793
+ }
4794
+ if absolute, err := filepath.Abs(anchor); err == nil {
4795
+ anchor = absolute
4796
+ }
4797
+ dir := filepath.Dir(filepath.Clean(anchor))
4798
+ for {
4799
+ if filepath.Base(dir) != "node_modules" {
4800
+ candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
4801
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
4802
+ return candidate
4803
+ }
4804
+ }
4805
+ parent := filepath.Dir(dir)
4806
+ if parent == dir {
4807
+ return ""
4808
+ }
4809
+ dir = parent
4810
+ }
4811
+ }
4812
+
4813
+ // nodePlatformPair is nodePlatformPairFor applied to this build's own target.
4814
+ func nodePlatformPair() (string, string) {
4815
+ return nodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
4816
+ }
4817
+
4818
+ // nodePlatformPairFor maps a Go build target onto the `process.platform` and
4819
+ // `process.arch` pair npm spells a platform package with, so the package name
4820
+ // this host resolves is the same one the JS launcher resolves.
4821
+ //
4822
+ // Only the members whose two vocabularies disagree are mapped. Every other
4823
+ // value is identical on both sides and passes through, which keeps a target
4824
+ // neither side publishes yet resolvable rather than silently wrong, and keeps
4825
+ // this from becoming a list that has to grow with every new port.
4826
+ func nodePlatformPairFor(goos, goarch string) (string, string) {
4827
+ platform := goos
4828
+ switch platform {
4829
+ case "windows":
4830
+ platform = "win32"
4831
+ case "solaris":
4832
+ platform = "sunos"
4833
+ }
4834
+ arch := goarch
4835
+ switch arch {
4836
+ case "amd64":
4837
+ arch = "x64"
4838
+ case "386":
4839
+ arch = "ia32"
4840
+ case "ppc64le":
4841
+ arch = "ppc64"
4842
+ }
4843
+ return platform, arch
4844
+ }
4845
+
4409
4846
  // ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
4410
4847
  // ttsxCommandContext when the caller owns a cancellable context.
4411
- func ttsxCommand(args ...string) *exec.Cmd {
4412
- return ttsxCommandContext(context.Background(), args...)
4848
+ func ttsxCommand(anchors []string, args ...string) *exec.Cmd {
4849
+ return ttsxCommandContext(context.Background(), anchors, args...)
4413
4850
  }
4414
4851
 
4415
4852
  // ttsxCommandContext is the cancellable variant, used by the config loaders so
4416
4853
  // their subprocess is torn down with the call that started it. It carries no
4417
4854
  // deadline: evaluating a user config is the user's own code running, and how
4418
4855
  // long that is allowed to take is not this binary's decision.
4419
- func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
4420
- ttsx := os.Getenv("TTSC_TTSX_BINARY")
4421
- if ttsx == "" {
4422
- ttsx = "ttsx"
4423
- }
4856
+ //
4857
+ // `anchors` are the file paths the launcher is resolved from; see
4858
+ // resolveTtsxLauncher.
4859
+ func ttsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
4860
+ ttsx := resolveTtsxLauncher(anchors)
4424
4861
  if shouldRunTtsxThroughNode(ttsx) {
4425
4862
  node := os.Getenv("TTSC_NODE_BINARY")
4426
4863
  if node == "" {
@@ -4531,6 +4968,8 @@ func setEnv(env []string, key, value string) []string {
4531
4968
  return append(env, prefix+value)
4532
4969
  }
4533
4970
 
4971
+ // ttsc:config-loader-shared end
4972
+
4534
4973
  // parseExternalRuleEntry delegates to parseRuleEntry. It is kept under this
4535
4974
  // name because test files in the same package call it directly.
4536
4975
  func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {