@ttsc/lint 0.21.0 → 0.23.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.
@@ -7,6 +7,7 @@ import (
7
7
  "encoding/hex"
8
8
  "encoding/json"
9
9
  "fmt"
10
+ "io"
10
11
  "net/url"
11
12
  "os"
12
13
  "os/exec"
@@ -219,6 +220,22 @@ func (r boundProjectRuleResolver) RuleOptionsVariants(name string) []json.RawMes
219
220
  return resolvedRuleOptionsVariants(r.RuleResolver, name)
220
221
  }
221
222
 
223
+ func (r boundProjectRuleResolver) ConfigPaths() []string {
224
+ resolver, ok := r.RuleResolver.(interface{ ConfigPaths() []string })
225
+ if !ok {
226
+ return nil
227
+ }
228
+ return resolver.ConfigPaths()
229
+ }
230
+
231
+ func (r boundProjectRuleResolver) ConfigDirectories() []string {
232
+ resolver, ok := r.RuleResolver.(interface{ ConfigDirectories() []string })
233
+ if !ok {
234
+ return nil
235
+ }
236
+ return resolver.ConfigDirectories()
237
+ }
238
+
222
239
  // ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
223
240
  // so every file receives the full map unchanged.
224
241
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
@@ -354,7 +371,30 @@ func (r InlineRuleResolver) ResolveProjectRules(names []string) (map[string]Proj
354
371
  // ConfigEntry per file, the extends-target entries declared before the
355
372
  // extending file's own entry so local rules win on collision.
356
373
  type ConfigStore struct {
357
- entries []ConfigEntry
374
+ directories []string
375
+ entries []ConfigEntry
376
+ paths []string
377
+ resolutionRoot string
378
+ }
379
+
380
+ // ConfigPaths returns the config and extends files that produced this store.
381
+ // The paths are retained as exact dependencies even when no rule declares
382
+ // additional project inputs.
383
+ func (s *ConfigStore) ConfigPaths() []string {
384
+ if s == nil {
385
+ return nil
386
+ }
387
+ return append([]string(nil), s.paths...)
388
+ }
389
+
390
+ // ConfigDirectories returns resolution-topology directories whose immediate
391
+ // entries can change which executable-config module Node selects. Consumers
392
+ // watch these as cold configuration inputs rather than ordinary rule data.
393
+ func (s *ConfigStore) ConfigDirectories() []string {
394
+ if s == nil {
395
+ return nil
396
+ }
397
+ return append([]string(nil), s.directories...)
358
398
  }
359
399
 
360
400
  // RuleOptions implements the file-agnostic RuleResolver compatibility method.
@@ -700,10 +740,21 @@ func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
700
740
  // guard so a config that `extends` itself (directly or transitively) is
701
741
  // rejected.
702
742
  func collectConfigStore(raw any, configDir, rootPath string) (*ConfigStore, error) {
703
- store := &ConfigStore{}
743
+ return collectConfigStoreWithin(raw, configDir, rootPath, configDir)
744
+ }
745
+
746
+ func collectConfigStoreWithin(
747
+ raw any,
748
+ configDir string,
749
+ rootPath string,
750
+ resolutionRoot string,
751
+ ) (*ConfigStore, error) {
752
+ store := &ConfigStore{resolutionRoot: filepath.Clean(resolutionRoot)}
704
753
  var chain []string
705
754
  if rootPath != "" {
706
- chain = []string{filepath.Clean(rootPath)}
755
+ rootPath = filepath.Clean(rootPath)
756
+ chain = []string{rootPath}
757
+ store.paths = append(store.paths, rootPath)
707
758
  }
708
759
  if err := collectConfigObject(store, raw, configDir, "config", chain); err != nil {
709
760
  return nil, err
@@ -787,11 +838,19 @@ func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chai
787
838
  if err != nil {
788
839
  return err
789
840
  }
790
- extendedRaw, err := loadConfigFile(location)
841
+ if !containsPath(store.paths, location) {
842
+ store.paths = append(store.paths, location)
843
+ }
844
+ evaluated, err := loadConfigFileEvaluationWithin(
845
+ location,
846
+ store.resolutionRoot,
847
+ )
791
848
  if err != nil {
792
849
  return err
793
850
  }
794
- if err := collectConfigObject(store, extendedRaw, filepath.Dir(location), path+".extends", extendedChain); err != nil {
851
+ appendConfigPaths(store, evaluated.dependencies)
852
+ appendConfigDirectories(store, evaluated.dependencyDirectories)
853
+ if err := collectConfigObject(store, evaluated.value, filepath.Dir(location), path+".extends", extendedChain); err != nil {
795
854
  return err
796
855
  }
797
856
  }
@@ -1004,6 +1063,7 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
1004
1063
  if inline == nil {
1005
1064
  inline = map[string]any{}
1006
1065
  }
1066
+ resolutionRoot := tsconfigBaseDir(cwd, tsconfigPath)
1007
1067
 
1008
1068
  if configFileValue, ok := inline["configFile"]; ok {
1009
1069
  configFile, ok := configFileValue.(string)
@@ -1014,7 +1074,7 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
1014
1074
  return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must not be empty")
1015
1075
  }
1016
1076
  location := resolveConfigFilePath(configFile, cwd, tsconfigPath)
1017
- return loadConfigResolver(location)
1077
+ return loadConfigResolver(location, resolutionRoot)
1018
1078
  }
1019
1079
 
1020
1080
  discovered, err := findLintConfigFile(cwd, tsconfigPath)
@@ -1028,23 +1088,53 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
1028
1088
  strings.Join(discoveryConfigBaseDirs(cwd, tsconfigPath), ", then from "),
1029
1089
  )
1030
1090
  }
1031
- return loadConfigResolver(discovered)
1091
+ return loadConfigResolver(discovered, resolutionRoot)
1032
1092
  }
1033
1093
 
1034
1094
  // loadConfigResolver loads and parses the lint config file at `location` into
1035
1095
  // a *ConfigStore and returns it as a RuleResolver.
1036
- func loadConfigResolver(location string) (RuleResolver, error) {
1037
- raw, err := loadConfigFile(location)
1096
+ func loadConfigResolver(
1097
+ location string,
1098
+ resolutionRoot string,
1099
+ ) (RuleResolver, error) {
1100
+ evaluated, err := loadConfigFileEvaluationWithin(location, resolutionRoot)
1038
1101
  if err != nil {
1039
1102
  return nil, err
1040
1103
  }
1041
- store, err := collectConfigStore(raw, filepath.Dir(location), location)
1104
+ store, err := collectConfigStoreWithin(
1105
+ evaluated.value,
1106
+ filepath.Dir(location),
1107
+ location,
1108
+ resolutionRoot,
1109
+ )
1042
1110
  if err != nil {
1043
1111
  return nil, err
1044
1112
  }
1113
+ appendConfigPaths(store, evaluated.dependencies)
1114
+ appendConfigDirectories(store, evaluated.dependencyDirectories)
1045
1115
  return store, nil
1046
1116
  }
1047
1117
 
1118
+ func appendConfigPaths(store *ConfigStore, paths []string) {
1119
+ for _, location := range paths {
1120
+ location = filepath.Clean(location)
1121
+ if !containsPath(store.paths, location) {
1122
+ store.paths = append(store.paths, location)
1123
+ }
1124
+ }
1125
+ sort.Strings(store.paths)
1126
+ }
1127
+
1128
+ func appendConfigDirectories(store *ConfigStore, directories []string) {
1129
+ for _, location := range directories {
1130
+ location = filepath.Clean(location)
1131
+ if !containsPath(store.directories, location) {
1132
+ store.directories = append(store.directories, location)
1133
+ }
1134
+ }
1135
+ sort.Strings(store.directories)
1136
+ }
1137
+
1048
1138
  func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
1049
1139
  for _, origin := range discoveryConfigBaseDirs(cwd, tsconfigPath) {
1050
1140
  discovered, err := findLintConfigFileFrom(origin)
@@ -1209,23 +1299,84 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
1209
1299
  // monorepo build — which spawns one `ttsc` process per package — evaluates a
1210
1300
  // shared lint config once instead of once per package.
1211
1301
  func loadConfigFile(location string) (any, error) {
1302
+ evaluated, err := loadConfigFileEvaluation(location)
1303
+ return evaluated.value, err
1304
+ }
1305
+
1306
+ type configDependencyFingerprint struct {
1307
+ Path string `json:"path"`
1308
+ Digest string `json:"digest"`
1309
+ Kind string `json:"kind"`
1310
+ Scope string `json:"scope"`
1311
+ }
1312
+
1313
+ const (
1314
+ configDependencyCache = "cache"
1315
+ configDependencyWatch = "watch"
1316
+ configDependencyFile = "file"
1317
+ configDependencyDir = "directory"
1318
+ configDependencyOptionalFile = "optional-file"
1319
+ )
1320
+
1321
+ type evaluatedConfigFile struct {
1322
+ value any
1323
+ dependencies []string
1324
+ dependencyDirectories []string
1325
+ dependencyDigests []configDependencyFingerprint
1326
+ dependenciesTracked bool
1327
+ }
1328
+
1329
+ type cachedConfigEvaluation struct {
1330
+ Value any `json:"value"`
1331
+ Dependencies []configDependencyFingerprint `json:"dependencies"`
1332
+ DependenciesTracked bool `json:"dependenciesTracked"`
1333
+ }
1334
+
1335
+ func loadConfigFileEvaluation(location string) (evaluatedConfigFile, error) {
1336
+ return loadConfigFileEvaluationWithin(location, filepath.Dir(location))
1337
+ }
1338
+
1339
+ func loadConfigFileEvaluationWithin(
1340
+ location string,
1341
+ resolutionRoot string,
1342
+ ) (evaluatedConfigFile, error) {
1343
+ if strings.TrimSpace(resolutionRoot) == "" {
1344
+ resolutionRoot = filepath.Dir(location)
1345
+ }
1346
+ if absolute, err := filepath.Abs(resolutionRoot); err == nil {
1347
+ resolutionRoot = absolute
1348
+ }
1349
+ resolutionRoot = filepath.Clean(resolutionRoot)
1212
1350
  ext := strings.ToLower(filepath.Ext(location))
1213
1351
  switch ext {
1214
1352
  case ".json":
1215
- return loadJSONConfigFile(location)
1353
+ value, err := loadJSONConfigFile(location)
1354
+ return evaluatedConfigFile{value: value}, err
1216
1355
  case ".js", ".cjs", ".mjs":
1217
- return loadCachedConfigFile(location, loadScriptConfigFile)
1356
+ return loadCachedConfigEvaluationForRoot(
1357
+ location,
1358
+ resolutionRoot,
1359
+ func(location string) (evaluatedConfigFile, error) {
1360
+ return loadScriptConfigEvaluationWithin(location, resolutionRoot)
1361
+ },
1362
+ )
1218
1363
  case ".ts", ".cts", ".mts":
1219
- return loadCachedConfigFile(location, loadTypeScriptConfigFile)
1364
+ return loadCachedConfigEvaluationForRoot(
1365
+ location,
1366
+ resolutionRoot,
1367
+ func(location string) (evaluatedConfigFile, error) {
1368
+ return loadTypeScriptConfigEvaluationWithin(location, resolutionRoot)
1369
+ },
1370
+ )
1220
1371
  default:
1221
- return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
1372
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
1222
1373
  }
1223
1374
  }
1224
1375
 
1225
1376
  // configCacheVersion namespaces the on-disk config cache. Bump it whenever
1226
1377
  // the shape of a cached config object changes so that entries written by an
1227
1378
  // older @ttsc/lint binary are treated as a miss rather than silently reused.
1228
- const configCacheVersion = "v2"
1379
+ const configCacheVersion = "v5"
1229
1380
 
1230
1381
  // configEvalCache memoizes evaluated .ts/.js lint config objects for the
1231
1382
  // lifetime of one process; the on-disk cache (configCacheDir) extends the
@@ -1233,7 +1384,7 @@ const configCacheVersion = "v2"
1233
1384
  // spawns. Guarded by configEvalCacheMu.
1234
1385
  var (
1235
1386
  configEvalCacheMu sync.Mutex
1236
- configEvalCache = map[string]any{}
1387
+ configEvalCache = map[string]cachedConfigEvaluation{}
1237
1388
  )
1238
1389
 
1239
1390
  // configCacheDir is the directory shared by this Go sidecar and the JS
@@ -1245,8 +1396,9 @@ func configCacheDir() string {
1245
1396
  }
1246
1397
 
1247
1398
  // configCacheDisabled reports whether the env opt-out is set — an escape
1248
- // hatch for callers that must force a fresh evaluation (e.g. a config whose
1249
- // behavior depends on imported non-config files that the key cannot see).
1399
+ // hatch for configs whose behavior depends on state outside their local module
1400
+ // graph, such as environment variables, network responses, or arbitrary file
1401
+ // reads.
1250
1402
  func configCacheDisabled() bool {
1251
1403
  return os.Getenv("TTSC_LINT_DISABLE_CONFIG_CACHE") != ""
1252
1404
  }
@@ -1269,67 +1421,254 @@ func configCacheKey(kind, absPath string, content []byte) string {
1269
1421
  return hex.EncodeToString(h.Sum(nil))
1270
1422
  }
1271
1423
 
1272
- // loadCachedConfigFile wraps a subprocess-backed config loader (`eval`)
1273
- // with the two-tier (in-process + on-disk) config cache. The cache key
1274
- // covers the config file's path and bytes only — a config's own `import`s
1275
- // of non-config files are NOT tracked, since a lint config is expected to
1276
- // be self-contained; set TTSC_LINT_DISABLE_CONFIG_CACHE when it is not.
1277
- // Errors are never cached: a failed evaluation re-runs next time.
1424
+ // loadCachedConfigFile preserves the historical value-only test seam around
1425
+ // the two-tier (in-process + on-disk) cache. Production executable-config
1426
+ // loaders use loadCachedConfigEvaluation so their complete local module graph
1427
+ // participates in validation. Errors are never cached: a failed evaluation
1428
+ // re-runs next time.
1278
1429
  func loadCachedConfigFile(location string, eval func(string) (any, error)) (any, error) {
1430
+ evaluated, err := loadCachedConfigEvaluationWithPolicy(
1431
+ location,
1432
+ func(location string) (evaluatedConfigFile, error) {
1433
+ value, err := eval(location)
1434
+ return evaluatedConfigFile{value: value}, err
1435
+ },
1436
+ false,
1437
+ "",
1438
+ )
1439
+ return evaluated.value, err
1440
+ }
1441
+
1442
+ func loadCachedConfigEvaluation(
1443
+ location string,
1444
+ eval func(string) (evaluatedConfigFile, error),
1445
+ ) (evaluatedConfigFile, error) {
1446
+ return loadCachedConfigEvaluationWithPolicy(location, eval, true, "")
1447
+ }
1448
+
1449
+ func loadCachedConfigEvaluationForRoot(
1450
+ location string,
1451
+ resolutionRoot string,
1452
+ eval func(string) (evaluatedConfigFile, error),
1453
+ ) (evaluatedConfigFile, error) {
1454
+ return loadCachedConfigEvaluationWithPolicy(
1455
+ location,
1456
+ eval,
1457
+ true,
1458
+ filepath.Clean(resolutionRoot),
1459
+ )
1460
+ }
1461
+
1462
+ func loadCachedConfigEvaluationWithPolicy(
1463
+ location string,
1464
+ eval func(string) (evaluatedConfigFile, error),
1465
+ dependenciesRequired bool,
1466
+ cacheNamespace string,
1467
+ ) (evaluatedConfigFile, error) {
1279
1468
  if configCacheDisabled() {
1280
1469
  return eval(location)
1281
1470
  }
1282
1471
  content, err := os.ReadFile(location)
1283
1472
  if err != nil {
1284
- return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
1473
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
1285
1474
  }
1286
1475
  abs := location
1287
1476
  if resolved, absErr := filepath.Abs(location); absErr == nil {
1288
1477
  abs = resolved
1289
1478
  }
1290
- key := configCacheKey("config", abs, content)
1479
+ kind := "config-value"
1480
+ if dependenciesRequired {
1481
+ kind = "config-graph"
1482
+ }
1483
+ if cacheNamespace != "" {
1484
+ kind += "\x00" + cacheNamespace
1485
+ }
1486
+ key := configCacheKey(kind, abs, content)
1291
1487
 
1292
1488
  configEvalCacheMu.Lock()
1293
1489
  cached, ok := configEvalCache[key]
1294
1490
  configEvalCacheMu.Unlock()
1295
- if ok {
1296
- return cached, nil
1297
- }
1298
- if value, hit := readConfigDiskCache(key); hit {
1491
+ if ok &&
1492
+ cached.DependenciesTracked == dependenciesRequired &&
1493
+ cachedConfigEvaluationIsCurrent(cached) {
1494
+ return evaluatedConfigFileFromCache(cached), nil
1495
+ }
1496
+ if disk, hit := readConfigDiskCache(key); hit &&
1497
+ disk.DependenciesTracked == dependenciesRequired &&
1498
+ cachedConfigEvaluationIsCurrent(disk) {
1299
1499
  configEvalCacheMu.Lock()
1300
- configEvalCache[key] = value
1500
+ configEvalCache[key] = disk
1301
1501
  configEvalCacheMu.Unlock()
1302
- return value, nil
1502
+ return evaluatedConfigFileFromCache(disk), nil
1303
1503
  }
1304
1504
 
1305
- value, err := eval(location)
1306
- if err != nil {
1307
- return nil, err
1505
+ var evaluated evaluatedConfigFile
1506
+ for attempt := 0; attempt < 3; attempt++ {
1507
+ evaluated, err = eval(location)
1508
+ if err != nil {
1509
+ return evaluatedConfigFile{}, err
1510
+ }
1511
+ if evaluated.dependenciesTracked != dependenciesRequired {
1512
+ return evaluatedConfigFile{}, fmt.Errorf(
1513
+ "@ttsc/lint: config evaluator for %s returned dependenciesTracked=%t, want %t",
1514
+ location,
1515
+ evaluated.dependenciesTracked,
1516
+ dependenciesRequired,
1517
+ )
1518
+ }
1519
+ if (!evaluated.dependenciesTracked ||
1520
+ len(evaluated.dependencyDigests) != 0) &&
1521
+ configDependencyDigestsAreCurrent(evaluated.dependencyDigests) {
1522
+ cached = cachedConfigEvaluation{
1523
+ Value: evaluated.value,
1524
+ Dependencies: append([]configDependencyFingerprint(nil), evaluated.dependencyDigests...),
1525
+ DependenciesTracked: evaluated.dependenciesTracked,
1526
+ }
1527
+ configEvalCacheMu.Lock()
1528
+ configEvalCache[key] = cached
1529
+ configEvalCacheMu.Unlock()
1530
+ writeConfigDiskCache(key, cached)
1531
+ return evaluated, nil
1532
+ }
1308
1533
  }
1309
- configEvalCacheMu.Lock()
1310
- configEvalCache[key] = value
1311
- configEvalCacheMu.Unlock()
1312
- writeConfigDiskCache(key, value)
1313
- return value, nil
1534
+ return evaluated, nil
1314
1535
  }
1315
1536
 
1316
1537
  // readConfigDiskCache returns the cached config object for `key`, or
1317
1538
  // (nil, false) on any miss — a missing file, an unreadable file, or
1318
1539
  // content that no longer parses as a config object. Every failure is a
1319
1540
  // soft miss: the caller re-evaluates rather than surfacing a cache fault.
1320
- func readConfigDiskCache(key string) (any, bool) {
1541
+ func evaluatedConfigFileFromCache(cached cachedConfigEvaluation) evaluatedConfigFile {
1542
+ dependencies := make([]string, 0, len(cached.Dependencies))
1543
+ directories := make([]string, 0, len(cached.Dependencies))
1544
+ for _, dependency := range cached.Dependencies {
1545
+ if dependency.Scope == configDependencyWatch {
1546
+ if dependency.Kind == configDependencyDir {
1547
+ directories = append(directories, dependency.Path)
1548
+ } else {
1549
+ dependencies = append(dependencies, dependency.Path)
1550
+ }
1551
+ }
1552
+ }
1553
+ return evaluatedConfigFile{
1554
+ value: cached.Value,
1555
+ dependencies: dependencies,
1556
+ dependencyDirectories: directories,
1557
+ dependencyDigests: append([]configDependencyFingerprint(nil), cached.Dependencies...),
1558
+ dependenciesTracked: cached.DependenciesTracked,
1559
+ }
1560
+ }
1561
+
1562
+ func configDependencyDigestsAreCurrent(
1563
+ dependencies []configDependencyFingerprint,
1564
+ ) bool {
1565
+ for _, dependency := range dependencies {
1566
+ digest, err := configDependencyDigest(dependency)
1567
+ if err != nil {
1568
+ return false
1569
+ }
1570
+ if digest != dependency.Digest {
1571
+ return false
1572
+ }
1573
+ }
1574
+ return true
1575
+ }
1576
+
1577
+ func configDependencyDigest(
1578
+ dependency configDependencyFingerprint,
1579
+ ) (string, error) {
1580
+ if dependency.Kind == configDependencyDir {
1581
+ entries, err := os.ReadDir(dependency.Path)
1582
+ if err != nil {
1583
+ return "", err
1584
+ }
1585
+ h := sha256.New()
1586
+ for index, entry := range entries {
1587
+ kind := "other"
1588
+ info, err := entry.Info()
1589
+ if err != nil {
1590
+ return "", err
1591
+ }
1592
+ switch {
1593
+ case info.Mode()&os.ModeSymlink != 0:
1594
+ kind = "symlink"
1595
+ case info.IsDir():
1596
+ kind = "directory"
1597
+ case info.Mode().IsRegular():
1598
+ kind = "file"
1599
+ }
1600
+ target := ""
1601
+ if kind == "symlink" {
1602
+ target, err = os.Readlink(filepath.Join(dependency.Path, entry.Name()))
1603
+ if err != nil {
1604
+ target = "<unreadable>"
1605
+ }
1606
+ }
1607
+ h.Write([]byte(entry.Name()))
1608
+ h.Write([]byte{0})
1609
+ h.Write([]byte(kind))
1610
+ h.Write([]byte{0})
1611
+ h.Write([]byte(target))
1612
+ if index+1 != len(entries) {
1613
+ h.Write([]byte{0})
1614
+ }
1615
+ }
1616
+ return hex.EncodeToString(h.Sum(nil)), nil
1617
+ }
1618
+ if dependency.Kind == configDependencyOptionalFile {
1619
+ info, err := os.Stat(dependency.Path)
1620
+ if err != nil || !info.Mode().IsRegular() {
1621
+ digest := sha256.Sum256([]byte("missing\x00"))
1622
+ return hex.EncodeToString(digest[:]), nil
1623
+ }
1624
+ body, err := os.ReadFile(dependency.Path)
1625
+ if err != nil {
1626
+ digest := sha256.Sum256([]byte("missing\x00"))
1627
+ return hex.EncodeToString(digest[:]), nil
1628
+ }
1629
+ h := sha256.New()
1630
+ h.Write([]byte("file\x00"))
1631
+ h.Write(body)
1632
+ return hex.EncodeToString(h.Sum(nil)), nil
1633
+ }
1634
+ body, err := os.ReadFile(dependency.Path)
1635
+ if err != nil {
1636
+ return "", err
1637
+ }
1638
+ digest := sha256.Sum256(body)
1639
+ return hex.EncodeToString(digest[:]), nil
1640
+ }
1641
+
1642
+ func cachedConfigEvaluationIsCurrent(cached cachedConfigEvaluation) bool {
1643
+ if !cached.DependenciesTracked {
1644
+ return len(cached.Dependencies) == 0
1645
+ }
1646
+ normalized, ok := normalizeConfigDependencyFingerprints(cached.Dependencies)
1647
+ return ok && configDependencyDigestsAreCurrent(normalized)
1648
+ }
1649
+
1650
+ func readConfigDiskCache(key string) (cachedConfigEvaluation, bool) {
1321
1651
  body, err := os.ReadFile(filepath.Join(configCacheDir(), key+".json"))
1322
1652
  if err != nil {
1323
- return nil, false
1653
+ return cachedConfigEvaluation{}, false
1324
1654
  }
1325
- var value any
1326
- if err := json.Unmarshal(body, &value); err != nil {
1327
- return nil, false
1655
+ var cached cachedConfigEvaluation
1656
+ if err := json.Unmarshal(body, &cached); err != nil {
1657
+ return cachedConfigEvaluation{}, false
1328
1658
  }
1329
- if !isConfigObject(value) {
1330
- return nil, false
1659
+ if !isConfigObject(cached.Value) {
1660
+ return cachedConfigEvaluation{}, false
1661
+ }
1662
+ if cached.DependenciesTracked {
1663
+ normalized, ok := normalizeConfigDependencyFingerprints(cached.Dependencies)
1664
+ if !ok {
1665
+ return cachedConfigEvaluation{}, false
1666
+ }
1667
+ cached.Dependencies = normalized
1668
+ } else if len(cached.Dependencies) != 0 {
1669
+ return cachedConfigEvaluation{}, false
1331
1670
  }
1332
- return value, true
1671
+ return cached, true
1333
1672
  }
1334
1673
 
1335
1674
  // writeConfigDiskCache stores `value` under `key`. It is best-effort: a
@@ -1337,8 +1676,8 @@ func readConfigDiskCache(key string) (any, bool) {
1337
1676
  // (the next run re-evaluates) rather than failing the lint run. The write
1338
1677
  // goes through a temp file + rename so a concurrent reader in a sibling
1339
1678
  // `ttsc` process never observes a half-written entry.
1340
- func writeConfigDiskCache(key string, value any) {
1341
- body, err := json.Marshal(value)
1679
+ func writeConfigDiskCache(key string, cached cachedConfigEvaluation) {
1680
+ body, err := json.Marshal(cached)
1342
1681
  if err != nil {
1343
1682
  return
1344
1683
  }
@@ -1408,56 +1747,294 @@ func serializableConfigKeysLiteral() string {
1408
1747
 
1409
1748
  // runConfigLoaderCommand runs a prepared config-loader subprocess (`cmd`),
1410
1749
  // then turns its result into a parsed config object. It owns the shared tail
1411
- // of both subprocess-backed loaders: capturing stdout, distinguishing a
1412
- // timeout from a process error (extracting the subprocess stderr when present),
1413
- // JSON-parsing the output, and rejecting a non-object result. `ctx` is the
1750
+ // of both subprocess-backed loaders: discarding user stdout, distinguishing a
1751
+ // timeout from a process error, reading the private result file, JSON-parsing
1752
+ // its envelope, and rejecting a non-object result. `ctx` is the
1414
1753
  // timeout context the caller bound `cmd` to; `location` is the config file path
1415
1754
  // for error messages; `label` is the human-readable subject (e.g. "config
1416
1755
  // file" or "TypeScript config file") spliced into the load/parse error
1417
1756
  // prefixes so each loader keeps its own wording.
1418
- func runConfigLoaderCommand(ctx context.Context, cmd *exec.Cmd, location, label string) (any, error) {
1419
- output, err := cmd.Output()
1757
+ func runConfigLoaderCommand(
1758
+ ctx context.Context,
1759
+ cmd *exec.Cmd,
1760
+ location string,
1761
+ label string,
1762
+ outputPath string,
1763
+ ) (evaluatedConfigFile, error) {
1764
+ var stderr bytes.Buffer
1765
+ cmd.Stdout = io.Discard
1766
+ cmd.Stderr = &stderr
1767
+ err := cmd.Run()
1768
+ // A loader diagnostic is only useful when the load succeeds, because a
1769
+ // failure already carries the same text in its message. Forward it so an
1770
+ // assertion about what the loader recorded can name what it resolved.
1771
+ if err == nil && os.Getenv("TTSC_LINT_DEBUG_CONFIG_GRAPH") != "" {
1772
+ if text := strings.TrimSpace(stderr.String()); text != "" {
1773
+ fmt.Fprintln(os.Stderr, text)
1774
+ }
1775
+ }
1420
1776
  if err != nil {
1421
1777
  if ctx.Err() == context.DeadlineExceeded {
1422
- return nil, fmt.Errorf("@ttsc/lint: load %s %s: timed out after %s", label, location, configLoaderTimeout)
1778
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: timed out after %s", label, location, configLoaderTimeout)
1423
1779
  }
1424
- stderr := ""
1425
- if exit, ok := err.(*exec.ExitError); ok {
1426
- stderr = strings.TrimSpace(string(exit.Stderr))
1780
+ stderrText := strings.TrimSpace(stderr.String())
1781
+ if stderrText != "" {
1782
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, stderrText)
1427
1783
  }
1428
- if stderr != "" {
1429
- return nil, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, stderr)
1784
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %w", label, location, err)
1785
+ }
1786
+ output, err := os.ReadFile(outputPath)
1787
+ if err != nil {
1788
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: read %s %s result: %w", label, location, err)
1789
+ }
1790
+ var envelope struct {
1791
+ Dependencies []configDependencyFingerprint `json:"dependencies"`
1792
+ Value any `json:"value"`
1793
+ }
1794
+ if err := json.Unmarshal(output, &envelope); err != nil {
1795
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: parse %s %s output: %w", label, location, err)
1796
+ }
1797
+ if !isConfigObject(envelope.Value) {
1798
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
1799
+ }
1800
+ normalized, ok := normalizeConfigDependencyFingerprints(envelope.Dependencies)
1801
+ if !ok {
1802
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: %s %s returned malformed dependency fingerprints", label, location)
1803
+ }
1804
+ dependencies := make([]string, 0, len(normalized))
1805
+ directories := make([]string, 0, len(normalized))
1806
+ for _, dependency := range normalized {
1807
+ if dependency.Scope == configDependencyWatch {
1808
+ if dependency.Kind == configDependencyDir {
1809
+ directories = append(directories, dependency.Path)
1810
+ } else {
1811
+ dependencies = append(dependencies, dependency.Path)
1812
+ }
1430
1813
  }
1431
- return nil, fmt.Errorf("@ttsc/lint: load %s %s: %w", label, location, err)
1432
1814
  }
1433
- var out any
1434
- if err := json.Unmarshal(output, &out); err != nil {
1435
- return nil, fmt.Errorf("@ttsc/lint: parse %s %s output: %w", label, location, err)
1815
+ return evaluatedConfigFile{
1816
+ value: envelope.Value,
1817
+ dependencies: dependencies,
1818
+ dependencyDirectories: directories,
1819
+ dependencyDigests: normalized,
1820
+ dependenciesTracked: true,
1821
+ }, nil
1822
+ }
1823
+
1824
+ func normalizeConfigDependencyFingerprints(
1825
+ input []configDependencyFingerprint,
1826
+ ) ([]configDependencyFingerprint, bool) {
1827
+ if len(input) == 0 {
1828
+ return nil, false
1436
1829
  }
1437
- if !isConfigObject(out) {
1438
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
1830
+ seen := make(map[string]configDependencyFingerprint, len(input))
1831
+ normalized := make([]configDependencyFingerprint, 0, len(input))
1832
+ for _, dependency := range input {
1833
+ if strings.TrimSpace(dependency.Path) == "" ||
1834
+ !filepath.IsAbs(dependency.Path) ||
1835
+ len(dependency.Digest) != sha256.Size*2 ||
1836
+ strings.ToLower(dependency.Digest) != dependency.Digest ||
1837
+ (dependency.Kind != configDependencyFile &&
1838
+ dependency.Kind != configDependencyDir &&
1839
+ dependency.Kind != configDependencyOptionalFile) ||
1840
+ (dependency.Scope != configDependencyCache &&
1841
+ dependency.Scope != configDependencyWatch) {
1842
+ return nil, false
1843
+ }
1844
+ if _, err := hex.DecodeString(dependency.Digest); err != nil {
1845
+ return nil, false
1846
+ }
1847
+ absolute := filepath.Clean(dependency.Path)
1848
+ key := dependency.Kind + "\x00" + absolute
1849
+ if previous, exists := seen[key]; exists {
1850
+ if previous.Digest != dependency.Digest ||
1851
+ previous.Kind != dependency.Kind ||
1852
+ previous.Scope != dependency.Scope {
1853
+ return nil, false
1854
+ }
1855
+ continue
1856
+ }
1857
+ fingerprint := configDependencyFingerprint{
1858
+ Path: absolute,
1859
+ Digest: dependency.Digest,
1860
+ Kind: dependency.Kind,
1861
+ Scope: dependency.Scope,
1862
+ }
1863
+ seen[key] = fingerprint
1864
+ normalized = append(normalized, fingerprint)
1439
1865
  }
1440
- return out, nil
1866
+ sort.Slice(normalized, func(left, right int) bool {
1867
+ return normalized[left].Path < normalized[right].Path
1868
+ })
1869
+ return normalized, true
1441
1870
  }
1442
1871
 
1443
1872
  // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1444
1873
  // Node subprocess that dynamic-imports the file, resolves the exported config
1445
- // through the same 8-hop default/config normalization used by the TS loader, and
1446
- // serializes the result as JSON to stdout. The subprocess has a
1874
+ // through the same 8-hop default/config normalization used by the TS loader,
1875
+ // and serializes the result into a private result file. The subprocess has a
1447
1876
  // configLoaderTimeout deadline to prevent user code from hanging indefinitely.
1448
1877
  func loadScriptConfigFile(location string) (any, error) {
1449
- script := fmt.Sprintf(`
1450
- const { pathToFileURL } = require("node:url");
1878
+ evaluated, err := loadScriptConfigEvaluation(location)
1879
+ return evaluated.value, err
1880
+ }
1881
+
1882
+ func loadScriptConfigEvaluation(location string) (evaluatedConfigFile, error) {
1883
+ return loadScriptConfigEvaluationWithin(location, filepath.Dir(location))
1884
+ }
1885
+
1886
+ func loadScriptConfigEvaluationWithin(
1887
+ location string,
1888
+ resolutionRoot string,
1889
+ ) (evaluatedConfigFile, error) {
1890
+ tempDir, err := os.MkdirTemp("", "ttsc-lint-script-config-")
1891
+ if err != nil {
1892
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: create script config result directory: %w", err)
1893
+ }
1894
+ defer os.RemoveAll(tempDir)
1895
+ outputPath := filepath.Join(tempDir, "result.json")
1896
+ script := scriptConfigLoaderSource()
1897
+ node := os.Getenv("TTSC_NODE_BINARY")
1898
+ if node == "" {
1899
+ node = "node"
1900
+ }
1901
+ ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1902
+ defer cancel()
1903
+ cmd := exec.CommandContext(
1904
+ ctx,
1905
+ node,
1906
+ "-e",
1907
+ script,
1908
+ location,
1909
+ outputPath,
1910
+ resolutionRoot,
1911
+ )
1912
+ return runConfigLoaderCommand(ctx, cmd, location, "config file", outputPath)
1913
+ }
1914
+
1915
+ // scriptConfigLoaderSource returns the CommonJS source of the loader script
1916
+ // Node executes to evaluate a .js/.cjs/.mjs lint config file. It is a named
1917
+ // function for the same reason as typeScriptConfigLoaderSource: the source is
1918
+ // a fmt.Sprintf format string, so every literal percent sign inside it must be
1919
+ // doubled, and only a callable generator lets a regression prove the emitted
1920
+ // script carries no formatting artifact.
1921
+ func scriptConfigLoaderSource() string {
1922
+ return fmt.Sprintf(`
1923
+ const { Buffer } = require("node:buffer");
1924
+ const fs = require("node:fs");
1925
+ const { createHash } = require("node:crypto");
1926
+ const { registerHooks } = require("node:module");
1927
+ const path = require("node:path");
1928
+ const { fileURLToPath, pathToFileURL } = require("node:url");
1451
1929
 
1452
1930
  const CONFIG_KEYS = new Set([%s]);
1931
+ const configUrl = pathToFileURL(process.argv[1]).href;
1932
+ const outputPath = process.argv[2];
1933
+ const resolutionRoot = path.resolve(process.argv[3]);
1934
+ const dependencies = new Map();
1935
+ const graphNodes = new Map();
1936
+ const graphEdges = [];
1937
+ const configLocation = fileURLToPath(configUrl);
1938
+ // Every spelling of this config the module system might key an edge under.
1939
+ //
1940
+ // Which one it uses is not knowable from here, and guessing has failed in both
1941
+ // directions. A path handed in by another producer can be escaped by a rule
1942
+ // Node does not share. Node respells a resolved file module through its real
1943
+ // path unless "--preserve-symlinks" is set, so a config reached through a
1944
+ // symlinked directory is keyed by its target. And a Windows 8.3 short name is
1945
+ // not a symlink: fs.realpathSync expands it, the module resolver does not, so
1946
+ // asking the volume there produces a spelling no edge carries.
1947
+ //
1948
+ // A seed that names a URL no edge was keyed under sits on a node with no
1949
+ // outgoing edges, the walk ends immediately, and every dependency recorded
1950
+ // after the first import is demoted from watch to cache. That failure is
1951
+ // silent: the build still succeeds and simply stops reacting. Seeding every
1952
+ // spelling costs one extra queue entry and cannot be wrong.
1953
+ const configUrlSpellings = [
1954
+ ...new Set([
1955
+ configUrl,
1956
+ pathToFileURL(configLocation).href,
1957
+ pathToFileURL(realConfigLocation()).href,
1958
+ ]),
1959
+ ];
1960
+ for (const spelling of configUrlSpellings) {
1961
+ graphNodes.set(spelling, configLocation);
1962
+ }
1963
+ recordDependency(
1964
+ "file",
1965
+ configLocation,
1966
+ createHash("sha256").update(fs.readFileSync(configLocation)).digest("hex"),
1967
+ configUrlSpellings,
1968
+ );
1969
+ recordPackageManifests(configLocation, configUrlSpellings);
1970
+ const hooks = registerHooks({
1971
+ resolve(specifier, context, nextResolve) {
1972
+ const resolved = nextResolve(specifier, context);
1973
+ if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
1974
+ return resolved;
1975
+ }
1976
+ const url = new URL(resolved.url).href;
1977
+ const parent = context.parentURL && new URL(context.parentURL).href;
1978
+ const location = fileURLToPath(url);
1979
+ // The entry is recognized by what was asked for, not only by what came
1980
+ // back. A module URL is assigned by whoever loaded it: a compiling loader
1981
+ // can serve the config from its emitted output, and a platform can hand
1982
+ // back a different spelling of the same file. Either way the URL bears no
1983
+ // resemblance to the one this process was given, so the config's own
1984
+ // imports would be rejected here — their parent is a URL no node was
1985
+ // recorded under — and the graph would collapse to the records made before
1986
+ // the first import. The request itself is unambiguous, so it decides.
1987
+ const entry =
1988
+ specifier === configUrl ||
1989
+ url === configUrl ||
1990
+ samePhysicalPath(location, configLocation);
1991
+ if (!entry && (parent === undefined || !graphNodes.has(parent))) {
1992
+ return resolved;
1993
+ }
1994
+ graphNodes.set(url, location);
1995
+ if (parent !== undefined) {
1996
+ graphEdges.push({
1997
+ child: url,
1998
+ packageBoundary:
1999
+ pathHasNodeModules(location) && !isLocalModuleSpecifier(specifier),
2000
+ parent,
2001
+ });
2002
+ recordResolutionTopology(
2003
+ specifier,
2004
+ parent,
2005
+ url,
2006
+ location,
2007
+ context.conditions,
2008
+ );
2009
+ }
2010
+ try {
2011
+ recordDependency(
2012
+ "file",
2013
+ location,
2014
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
2015
+ [url],
2016
+ );
2017
+ } catch {
2018
+ recordDependency("file", location, "", [url]);
2019
+ }
2020
+ return resolved;
2021
+ },
2022
+ });
1453
2023
 
1454
2024
  (async () => {
1455
- const mod = await import(pathToFileURL(process.argv[1]).href);
1456
- const value = await resolveConfig(mod, true);
1457
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1458
- throw new Error("config file must export an ITtscLintConfig object");
2025
+ try {
2026
+ const mod = await import(configUrl);
2027
+ const value = await resolveConfig(mod, true);
2028
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
2029
+ throw new Error("config file must export an ITtscLintConfig object");
2030
+ }
2031
+ fs.writeFileSync(outputPath, JSON.stringify({
2032
+ dependencies: finalizeDependencies(),
2033
+ value: toSerializableConfig(value),
2034
+ }), "utf8");
2035
+ } finally {
2036
+ hooks.deregister();
1459
2037
  }
1460
- process.stdout.write(JSON.stringify(toSerializableConfig(value)));
1461
2038
  })().catch((error) => {
1462
2039
  process.stderr.write(error && error.stack ? error.stack : String(error));
1463
2040
  process.exit(1);
@@ -1501,6 +2078,768 @@ function isModuleNamespace(value) {
1501
2078
  return Object.prototype.toString.call(value) === "[object Module]";
1502
2079
  }
1503
2080
 
2081
+ function isObject(value) {
2082
+ return value !== null && typeof value === "object";
2083
+ }
2084
+
2085
+ function recordDependency(kind, location, digest, owners) {
2086
+ const key = kind + "\0" + location;
2087
+ const previous = dependencies.get(key);
2088
+ const mergedOwners = previous ? previous.owners : new Set();
2089
+ for (const owner of owners) mergedOwners.add(owner);
2090
+ dependencies.set(key, {
2091
+ digest: previous && previous.digest !== digest ? "" : digest,
2092
+ kind,
2093
+ owners: mergedOwners,
2094
+ path: location,
2095
+ });
2096
+ }
2097
+
2098
+ function isLocalModuleSpecifier(specifier) {
2099
+ return specifier.startsWith(".") ||
2100
+ specifier.startsWith("/") ||
2101
+ specifier.startsWith("file:") ||
2102
+ /^[A-Za-z]:[\\/]/.test(specifier);
2103
+ }
2104
+
2105
+ function pathHasNodeModules(location) {
2106
+ return location.replaceAll("\\", "/").split("/").includes("node_modules");
2107
+ }
2108
+
2109
+ function recordResolutionTopology(
2110
+ specifier,
2111
+ parentUrl,
2112
+ childUrl,
2113
+ childLocation,
2114
+ conditions,
2115
+ ) {
2116
+ const owners = [parentUrl, childUrl];
2117
+ const parentLocation = graphNodes.get(parentUrl);
2118
+ if (parentLocation !== undefined && isLocalModuleSpecifier(specifier)) {
2119
+ recordDirectoryDependency(path.dirname(parentLocation), owners);
2120
+ }
2121
+ recordDirectoryDependency(path.dirname(childLocation), owners);
2122
+ recordPackageManifests(childLocation, owners);
2123
+ if (parentLocation !== undefined && !isLocalModuleSpecifier(specifier)) {
2124
+ recordNodeModulesSearchDirectories(
2125
+ parentLocation,
2126
+ specifier,
2127
+ childLocation,
2128
+ owners,
2129
+ conditions,
2130
+ );
2131
+ }
2132
+ }
2133
+
2134
+ function recordDirectoryDependency(location, owners) {
2135
+ try {
2136
+ recordDependency("directory", location, directoryDigest(location), owners);
2137
+ } catch {
2138
+ recordDependency("directory", location, "", owners);
2139
+ }
2140
+ }
2141
+
2142
+ function directoryDigest(location) {
2143
+ const entries = [];
2144
+ if (process.platform === "win32") {
2145
+ for (const entry of fs.readdirSync(location, { withFileTypes: true })) {
2146
+ let target = Buffer.alloc(0);
2147
+ if (entry.isSymbolicLink()) {
2148
+ try {
2149
+ target = Buffer.from(
2150
+ fs.readlinkSync(path.join(location, entry.name)),
2151
+ "utf8",
2152
+ );
2153
+ } catch {
2154
+ target = Buffer.from("<unreadable>");
2155
+ }
2156
+ }
2157
+ entries.push(directoryDigestRecord(Buffer.from(entry.name), entry, target));
2158
+ }
2159
+ } else {
2160
+ for (const entry of fs.readdirSync(
2161
+ location,
2162
+ { encoding: "buffer", withFileTypes: true },
2163
+ )) {
2164
+ let target = Buffer.alloc(0);
2165
+ if (entry.isSymbolicLink()) {
2166
+ try {
2167
+ target = fs.readlinkSync(
2168
+ Buffer.concat([
2169
+ Buffer.from(location),
2170
+ Buffer.from(path.sep),
2171
+ entry.name,
2172
+ ]),
2173
+ { encoding: "buffer" },
2174
+ );
2175
+ } catch {
2176
+ target = Buffer.from("<unreadable>");
2177
+ }
2178
+ }
2179
+ entries.push(directoryDigestRecord(entry.name, entry, target));
2180
+ }
2181
+ }
2182
+ entries.sort(Buffer.compare);
2183
+ const serialized = Buffer.concat(
2184
+ entries.flatMap((entry, index) =>
2185
+ index === 0 ? [entry] : [Buffer.from([0]), entry],
2186
+ ),
2187
+ );
2188
+ return createHash("sha256").update(serialized).digest("hex");
2189
+ }
2190
+
2191
+ function directoryDigestRecord(name, entry, target) {
2192
+ const kind = entry.isDirectory()
2193
+ ? "directory"
2194
+ : entry.isFile()
2195
+ ? "file"
2196
+ : entry.isSymbolicLink()
2197
+ ? "symlink"
2198
+ : "other";
2199
+ return Buffer.concat([name, Buffer.from("\0" + kind + "\0"), target]);
2200
+ }
2201
+
2202
+ function optionalFileDigest(location) {
2203
+ try {
2204
+ if (fs.statSync(location).isFile()) {
2205
+ return createHash("sha256")
2206
+ .update(Buffer.concat([Buffer.from("file\0"), fs.readFileSync(location)]))
2207
+ .digest("hex");
2208
+ }
2209
+ } catch {
2210
+ }
2211
+ return createHash("sha256").update("missing\0").digest("hex");
2212
+ }
2213
+
2214
+ function recordOptionalFileDependency(location, owners) {
2215
+ try {
2216
+ if (fs.statSync(location).isFile()) {
2217
+ recordDependency(
2218
+ "file",
2219
+ location,
2220
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
2221
+ owners,
2222
+ );
2223
+ return true;
2224
+ }
2225
+ } catch {
2226
+ }
2227
+ recordDependency("optional-file", location, optionalFileDigest(location), owners);
2228
+ return false;
2229
+ }
2230
+
2231
+ function recordPackageManifests(location, owners) {
2232
+ let current = path.dirname(location);
2233
+ while (true) {
2234
+ const manifest = path.join(current, "package.json");
2235
+ if (recordOptionalFileDependency(manifest, owners)) return;
2236
+ const parent = path.dirname(current);
2237
+ if (parent === current || path.basename(current) === "node_modules") return;
2238
+ current = parent;
2239
+ }
2240
+ }
2241
+
2242
+ function recordNodeModulesSearchDirectories(
2243
+ parentLocation,
2244
+ specifier,
2245
+ childLocation,
2246
+ owners,
2247
+ conditions,
2248
+ ) {
2249
+ const packageName = modulePackageName(specifier);
2250
+ const scope =
2251
+ specifier.startsWith("@") && specifier.includes("/")
2252
+ ? specifier.slice(0, specifier.indexOf("/"))
2253
+ : undefined;
2254
+ let current = path.dirname(parentLocation);
2255
+ while (true) {
2256
+ recordDirectoryDependency(current, owners);
2257
+ const modules = path.join(current, "node_modules");
2258
+ try {
2259
+ if (fs.statSync(modules).isDirectory()) {
2260
+ recordDirectoryDependency(modules, owners);
2261
+ if (scope !== undefined) {
2262
+ const scoped = path.join(modules, scope);
2263
+ try {
2264
+ if (fs.statSync(scoped).isDirectory()) {
2265
+ recordDirectoryDependency(scoped, owners);
2266
+ }
2267
+ } catch {
2268
+ }
2269
+ }
2270
+ if (packageName !== undefined) {
2271
+ const selected = recordPackageCandidateTopology(
2272
+ modules,
2273
+ packageName,
2274
+ specifier,
2275
+ childLocation,
2276
+ owners,
2277
+ conditions,
2278
+ );
2279
+ if (
2280
+ selected ||
2281
+ resolvedPackageContains(modules, packageName, childLocation)
2282
+ ) {
2283
+ return;
2284
+ }
2285
+ }
2286
+ }
2287
+ } catch {
2288
+ }
2289
+ if (
2290
+ packageName === undefined &&
2291
+ samePhysicalPath(current, resolutionRoot)
2292
+ ) {
2293
+ return;
2294
+ }
2295
+ const parent = path.dirname(current);
2296
+ if (parent === current) return;
2297
+ current = parent;
2298
+ }
2299
+ }
2300
+
2301
+ function recordPackageCandidateTopology(
2302
+ modules,
2303
+ packageName,
2304
+ specifier,
2305
+ childLocation,
2306
+ owners,
2307
+ conditions,
2308
+ ) {
2309
+ const packageRoot = path.join(modules, packageName);
2310
+ try {
2311
+ if (!fs.statSync(packageRoot).isDirectory()) return false;
2312
+ } catch {
2313
+ return false;
2314
+ }
2315
+ const subpath = specifier
2316
+ .slice(packageName.length)
2317
+ .replace(/^[/\\]+/, "");
2318
+ const rootTopology = recordPackageRootTopology(
2319
+ packageRoot,
2320
+ owners,
2321
+ subpath === "",
2322
+ subpath === "" ? "." : "./" + subpath.replaceAll("\\", "/"),
2323
+ childLocation,
2324
+ conditions,
2325
+ );
2326
+ if (subpath !== "" && !rootTopology.hasExports) {
2327
+ return (
2328
+ recordPackageSubpathTopology(
2329
+ packageRoot,
2330
+ subpath,
2331
+ childLocation,
2332
+ owners,
2333
+ ) || rootTopology.selected
2334
+ );
2335
+ }
2336
+ return rootTopology.selected;
2337
+ }
2338
+
2339
+ function recordPackageRootTopology(
2340
+ packageRoot,
2341
+ owners,
2342
+ useMain,
2343
+ packageSubpath,
2344
+ childLocation,
2345
+ conditions,
2346
+ ) {
2347
+ const normalizedRoot = path.resolve(packageRoot);
2348
+ const manifest = path.join(normalizedRoot, "package.json");
2349
+ const legacySelected = () =>
2350
+ useMain &&
2351
+ packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
2352
+ if (!recordOptionalFileDependency(manifest, owners)) {
2353
+ const selected = legacySelected();
2354
+ if (!selected) {
2355
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
2356
+ }
2357
+ return { hasExports: false, selected };
2358
+ }
2359
+ try {
2360
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
2361
+ if (value !== null && typeof value === "object") {
2362
+ const hasExports =
2363
+ value.exports !== undefined && value.exports !== null;
2364
+ if (hasExports) {
2365
+ const target = selectPackageExportsTarget(
2366
+ value.exports,
2367
+ packageSubpath,
2368
+ new Set(conditions),
2369
+ );
2370
+ const candidate =
2371
+ typeof target === "string"
2372
+ ? packageExportsTarget(normalizedRoot, target)
2373
+ : undefined;
2374
+ const selected =
2375
+ candidate !== undefined &&
2376
+ packagePathCandidateMatchesChild(
2377
+ candidate,
2378
+ childLocation,
2379
+ false,
2380
+ );
2381
+ if (selected) {
2382
+ recordPackagePathCandidate(candidate, owners);
2383
+ } else if (candidate !== undefined) {
2384
+ // A nearer package the search skipped starts winning the moment its
2385
+ // own active target appears, and neither the parent node_modules
2386
+ // listing nor the manifest changes when only that file is created.
2387
+ recordOptionalFileDependency(candidate, owners);
2388
+ }
2389
+ return { hasExports: true, selected };
2390
+ }
2391
+ let selected = legacySelected();
2392
+ if (useMain && typeof value.main === "string") {
2393
+ const main = path.resolve(normalizedRoot, value.main);
2394
+ recordPackagePathCandidate(main, owners);
2395
+ selected =
2396
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
2397
+ selected;
2398
+ }
2399
+ if (!selected) {
2400
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
2401
+ }
2402
+ return { hasExports: false, selected };
2403
+ }
2404
+ } catch {
2405
+ }
2406
+ const rootSelected = legacySelected();
2407
+ if (!rootSelected) {
2408
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
2409
+ }
2410
+ return { hasExports: false, selected: rootSelected };
2411
+ }
2412
+
2413
+ // recordPackageIndexCandidates pins the LOAD_INDEX fallbacks of a package root
2414
+ // this resolution walked past without selecting. An empty package directory, or
2415
+ // one whose manifest declares no usable entry, becomes resolvable as soon as one
2416
+ // of these files exists, and that creation changes neither the parent directory
2417
+ // listing nor the manifest digest already recorded for the candidate.
2418
+ function recordPackageIndexCandidates(packageRoot, useMain, owners) {
2419
+ if (!useMain) return;
2420
+ for (const name of ["index.js", "index.json", "index.node"]) {
2421
+ recordOptionalFileDependency(path.join(packageRoot, name), owners);
2422
+ }
2423
+ }
2424
+
2425
+ function selectPackageExportsTarget(
2426
+ exportsValue,
2427
+ packageSubpath,
2428
+ conditions,
2429
+ ) {
2430
+ let mappings = exportsValue;
2431
+ if (
2432
+ typeof mappings === "string" ||
2433
+ Array.isArray(mappings) ||
2434
+ (isObject(mappings) &&
2435
+ Object.keys(mappings).every((key) => !key.startsWith(".")))
2436
+ ) {
2437
+ if (packageSubpath !== ".") return undefined;
2438
+ return selectPackageTarget(mappings, "", false, conditions);
2439
+ }
2440
+ if (!isObject(mappings)) return undefined;
2441
+ if (
2442
+ Object.prototype.hasOwnProperty.call(mappings, packageSubpath) &&
2443
+ !packageSubpath.includes("*") &&
2444
+ !packageSubpath.endsWith("/")
2445
+ ) {
2446
+ return selectPackageTarget(
2447
+ mappings[packageSubpath],
2448
+ "",
2449
+ false,
2450
+ conditions,
2451
+ );
2452
+ }
2453
+ let bestMatch = "";
2454
+ let bestSubpath = "";
2455
+ for (const key of Object.keys(mappings)) {
2456
+ const wildcard = key.indexOf("*");
2457
+ if (
2458
+ wildcard === -1 ||
2459
+ key.lastIndexOf("*") !== wildcard ||
2460
+ !packageSubpath.startsWith(key.slice(0, wildcard))
2461
+ ) {
2462
+ continue;
2463
+ }
2464
+ const trailer = key.slice(wildcard + 1);
2465
+ if (
2466
+ packageSubpath.length < key.length ||
2467
+ !packageSubpath.endsWith(trailer) ||
2468
+ packagePatternKeyCompare(bestMatch, key) !== 1
2469
+ ) {
2470
+ continue;
2471
+ }
2472
+ bestMatch = key;
2473
+ bestSubpath = packageSubpath.slice(
2474
+ wildcard,
2475
+ packageSubpath.length - trailer.length,
2476
+ );
2477
+ }
2478
+ return bestMatch === ""
2479
+ ? undefined
2480
+ : selectPackageTarget(
2481
+ mappings[bestMatch],
2482
+ bestSubpath,
2483
+ true,
2484
+ conditions,
2485
+ );
2486
+ }
2487
+
2488
+ function selectPackageTarget(target, subpath, pattern, conditions) {
2489
+ if (typeof target === "string") {
2490
+ const selected = pattern ? target.replaceAll("*", subpath) : target;
2491
+ return validPackageExportsTarget(selected) ? selected : undefined;
2492
+ }
2493
+ if (Array.isArray(target)) {
2494
+ for (const item of target) {
2495
+ const selected = selectPackageTarget(
2496
+ item,
2497
+ subpath,
2498
+ pattern,
2499
+ conditions,
2500
+ );
2501
+ if (selected !== undefined && selected !== null) return selected;
2502
+ }
2503
+ return null;
2504
+ }
2505
+ if (isObject(target)) {
2506
+ for (const [condition, value] of Object.entries(target)) {
2507
+ if (condition !== "default" && !conditions.has(condition)) continue;
2508
+ const selected = selectPackageTarget(
2509
+ value,
2510
+ subpath,
2511
+ pattern,
2512
+ conditions,
2513
+ );
2514
+ if (selected !== undefined) return selected;
2515
+ }
2516
+ return undefined;
2517
+ }
2518
+ return target === null ? null : undefined;
2519
+ }
2520
+
2521
+ function packagePatternKeyCompare(left, right) {
2522
+ const leftWildcard = left.indexOf("*");
2523
+ const rightWildcard = right.indexOf("*");
2524
+ const leftBase =
2525
+ leftWildcard === -1 ? left.length : leftWildcard + 1;
2526
+ const rightBase =
2527
+ rightWildcard === -1 ? right.length : rightWildcard + 1;
2528
+ if (leftBase > rightBase) return -1;
2529
+ if (rightBase > leftBase) return 1;
2530
+ if (leftWildcard === -1) return 1;
2531
+ if (rightWildcard === -1) return -1;
2532
+ if (left.length > right.length) return -1;
2533
+ if (right.length > left.length) return 1;
2534
+ return 0;
2535
+ }
2536
+
2537
+ function packageExportsTarget(packageRoot, target) {
2538
+ if (!validPackageExportsTarget(target)) return undefined;
2539
+ try {
2540
+ // Node resolves an exports target as a URL against the package manifest,
2541
+ // so percent escapes, query strings, and fragments all take part in the
2542
+ // path it finally loads. Joining the raw target by hand diverges from that
2543
+ // whenever the target is anything but a plain relative path, and a target
2544
+ // Node resolves while this model rejects loses the selected file's
2545
+ // fingerprint, leaving a retargeted symlink cached as fresh.
2546
+ const packageUrl = pathToFileURL(path.join(packageRoot, "package.json"));
2547
+ const resolved = new URL(target, packageUrl);
2548
+ const packagePath = new URL(".", packageUrl).pathname;
2549
+ if (!resolved.pathname.startsWith(packagePath)) return undefined;
2550
+ return fileURLToPath(resolved);
2551
+ } catch {
2552
+ return undefined;
2553
+ }
2554
+ }
2555
+
2556
+ function validPackageExportsTarget(target) {
2557
+ if (!target.startsWith("./") || /%%2f|%%5c/i.test(target)) return false;
2558
+ const components = target
2559
+ .slice(2)
2560
+ .replaceAll("\\", "/")
2561
+ .split("/");
2562
+ if (
2563
+ components.some(
2564
+ (component) => {
2565
+ try {
2566
+ const decoded = decodeURIComponent(component);
2567
+ return (
2568
+ decoded === "." ||
2569
+ decoded === ".." ||
2570
+ decoded.includes("/") ||
2571
+ decoded.includes("\\") ||
2572
+ decoded.toLowerCase() === "node_modules"
2573
+ );
2574
+ } catch {
2575
+ return true;
2576
+ }
2577
+ },
2578
+ )
2579
+ ) {
2580
+ return false;
2581
+ }
2582
+ return true;
2583
+ }
2584
+
2585
+ function packagePathCandidateMatchesChild(
2586
+ candidate,
2587
+ childLocation,
2588
+ legacy,
2589
+ ) {
2590
+ let child;
2591
+ try {
2592
+ child = fs.realpathSync.native(childLocation);
2593
+ } catch {
2594
+ child = path.resolve(childLocation);
2595
+ }
2596
+ const candidates = legacy
2597
+ ? [
2598
+ candidate,
2599
+ candidate + ".js",
2600
+ candidate + ".json",
2601
+ candidate + ".node",
2602
+ path.join(candidate, "index.js"),
2603
+ path.join(candidate, "index.json"),
2604
+ path.join(candidate, "index.node"),
2605
+ ]
2606
+ : [candidate];
2607
+ return candidates.some((location) => {
2608
+ try {
2609
+ return sameResolutionPath(fs.realpathSync.native(location), child);
2610
+ } catch {
2611
+ return false;
2612
+ }
2613
+ });
2614
+ }
2615
+
2616
+ function recordPackageSubpathTopology(
2617
+ packageRoot,
2618
+ subpath,
2619
+ childLocation,
2620
+ owners,
2621
+ ) {
2622
+ const candidate = boundedPackageTarget(packageRoot, subpath);
2623
+ if (candidate === undefined) return false;
2624
+ recordPackagePathCandidate(candidate, owners);
2625
+ let selected = packagePathCandidateMatchesChild(
2626
+ candidate,
2627
+ childLocation,
2628
+ true,
2629
+ );
2630
+ try {
2631
+ if (!fs.statSync(candidate).isDirectory()) return selected;
2632
+ } catch {
2633
+ return selected;
2634
+ }
2635
+ const manifest = path.join(candidate, "package.json");
2636
+ if (!recordOptionalFileDependency(manifest, owners)) return selected;
2637
+ try {
2638
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
2639
+ if (value !== null && typeof value === "object") {
2640
+ if (typeof value.main === "string") {
2641
+ const main = path.resolve(candidate, value.main);
2642
+ recordPackagePathCandidate(main, owners);
2643
+ selected =
2644
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
2645
+ selected;
2646
+ }
2647
+ }
2648
+ } catch {
2649
+ }
2650
+ return selected;
2651
+ }
2652
+
2653
+ function boundedPackageTarget(
2654
+ packageRoot,
2655
+ target,
2656
+ ) {
2657
+ const candidate = path.resolve(packageRoot, target);
2658
+ const relative = path.relative(packageRoot, candidate);
2659
+ if (
2660
+ relative === ".." ||
2661
+ relative.startsWith(".." + path.sep) ||
2662
+ path.isAbsolute(relative)
2663
+ ) {
2664
+ return undefined;
2665
+ }
2666
+ return candidate;
2667
+ }
2668
+
2669
+ function recordPackagePathCandidate(
2670
+ candidate,
2671
+ owners,
2672
+ visited = new Set(),
2673
+ depth = 0,
2674
+ ) {
2675
+ const normalized = path.resolve(candidate);
2676
+ // The depth bound owns termination. A platform-wide case fold would merge
2677
+ // paths that differ only by case, which a per-directory case-sensitive
2678
+ // Windows tree keeps distinct, and would truncate a valid symlink chain.
2679
+ if (depth >= 64 || visited.has(normalized)) return;
2680
+ visited.add(normalized);
2681
+ const parsed = path.parse(normalized);
2682
+ const components = normalized
2683
+ .slice(parsed.root.length)
2684
+ .split(path.sep)
2685
+ .filter(Boolean);
2686
+ let current = parsed.root;
2687
+ for (let index = 0; index < components.length; index++) {
2688
+ const component = components[index];
2689
+ const next = path.join(current, component);
2690
+ let entry;
2691
+ try {
2692
+ entry = fs.lstatSync(next);
2693
+ } catch {
2694
+ recordDirectoryDependency(current, owners);
2695
+ return;
2696
+ }
2697
+ if (entry.isSymbolicLink()) {
2698
+ recordDirectoryDependency(current, owners);
2699
+ try {
2700
+ const target = fs.readlinkSync(next);
2701
+ const remainder = components.slice(index + 1);
2702
+ recordPackagePathCandidate(
2703
+ path.join(path.resolve(current, target), ...remainder),
2704
+ owners,
2705
+ visited,
2706
+ depth + 1,
2707
+ );
2708
+ } catch {
2709
+ }
2710
+ }
2711
+ let isDirectory = entry.isDirectory();
2712
+ if (entry.isSymbolicLink()) {
2713
+ try {
2714
+ isDirectory = fs.statSync(next).isDirectory();
2715
+ } catch {
2716
+ return;
2717
+ }
2718
+ }
2719
+ if (index === components.length - 1) {
2720
+ recordDirectoryDependency(isDirectory ? next : current, owners);
2721
+ return;
2722
+ }
2723
+ if (!isDirectory) {
2724
+ recordDirectoryDependency(current, owners);
2725
+ return;
2726
+ }
2727
+ current = next;
2728
+ }
2729
+ recordDirectoryDependency(current, owners);
2730
+ }
2731
+
2732
+ function modulePackageName(specifier) {
2733
+ if (specifier.startsWith("@")) {
2734
+ const components = specifier.split("/");
2735
+ return components.length >= 2
2736
+ ? components[0] + "/" + components[1]
2737
+ : undefined;
2738
+ }
2739
+ const [name] = specifier.split("/");
2740
+ return name && !name.startsWith("#") ? name : undefined;
2741
+ }
2742
+
2743
+ function resolvedPackageContains(modules, packageName, childLocation) {
2744
+ try {
2745
+ const packageRoot = fs.realpathSync(path.join(modules, packageName));
2746
+ const relative = path.relative(
2747
+ packageRoot,
2748
+ fs.realpathSync(childLocation),
2749
+ );
2750
+ return (
2751
+ relative === "" ||
2752
+ (relative !== ".." &&
2753
+ !relative.startsWith(".." + path.sep) &&
2754
+ !path.isAbsolute(relative))
2755
+ );
2756
+ } catch {
2757
+ return false;
2758
+ }
2759
+ }
2760
+
2761
+ function sameResolutionPath(left, right) {
2762
+ return path.relative(left, right) === "";
2763
+ }
2764
+
2765
+ function samePhysicalPath(left, right) {
2766
+ try {
2767
+ return sameResolutionPath(realPath(left), realPath(right));
2768
+ } catch {
2769
+ // Fall back to the spellings themselves, folding case the way the platform
2770
+ // does. On the entry gate a false negative is catastrophic — the config
2771
+ // stops being recognized and its whole graph collapses — while a false
2772
+ // positive only over-includes, so the degradation has to lean toward "same
2773
+ // file". A drive-letter or component case difference is the ordinary
2774
+ // Windows situation; a per-directory case-sensitive tree is the rare one.
2775
+ return sameResolutionPath(left, right);
2776
+ }
2777
+ }
2778
+
2779
+ /**
2780
+ * The config's real path, or its declared one when the volume will not say.
2781
+ *
2782
+ * A config can disappear between the host reading it and this loader starting,
2783
+ * and a throw here would replace a precise report from the import below with a
2784
+ * crash in bookkeeping. Seeding lexically instead only risks the demotion this
2785
+ * value exists to prevent, on a file that is already gone.
2786
+ */
2787
+ function realConfigLocation() {
2788
+ try {
2789
+ return realPath(configLocation);
2790
+ } catch {
2791
+ return configLocation;
2792
+ }
2793
+ }
2794
+
2795
+ function realPath(location) {
2796
+ return fs.realpathSync.native
2797
+ ? fs.realpathSync.native(location)
2798
+ : fs.realpathSync(location);
2799
+ }
2800
+
2801
+ function finalizeDependencies() {
2802
+ const watched = graphWatchReachability();
2803
+ return [...dependencies.values()].map(({ owners, ...dependency }) => ({
2804
+ ...dependency,
2805
+ scope: [...owners].some((owner) => watched.has(owner))
2806
+ ? "watch"
2807
+ : "cache",
2808
+ }));
2809
+ }
2810
+
2811
+ function graphWatchReachability() {
2812
+ const adjacency = new Map();
2813
+ for (const edge of graphEdges) {
2814
+ const outgoing = adjacency.get(edge.parent) || [];
2815
+ outgoing.push(edge);
2816
+ adjacency.set(edge.parent, outgoing);
2817
+ }
2818
+ const queue = configUrlSpellings.map((url) => ({
2819
+ url,
2820
+ watched: true,
2821
+ }));
2822
+ const visited = new Set();
2823
+ const watched = new Set();
2824
+ while (queue.length !== 0) {
2825
+ const state = queue.shift();
2826
+ const key = state.url + "\0" + (state.watched ? "1" : "0");
2827
+ if (visited.has(key)) continue;
2828
+ visited.add(key);
2829
+ if (state.watched) watched.add(state.url);
2830
+ for (const edge of adjacency.get(state.url) || []) {
2831
+ const childLocation = graphNodes.get(edge.child);
2832
+ const childWatched = edge.packageBoundary
2833
+ ? false
2834
+ : childLocation !== undefined && !pathHasNodeModules(childLocation)
2835
+ ? true
2836
+ : state.watched;
2837
+ queue.push({ url: edge.child, watched: childWatched });
2838
+ }
2839
+ }
2840
+ return watched;
2841
+ }
2842
+
1504
2843
  function hasConfigKey(value) {
1505
2844
  for (const key of CONFIG_KEYS) {
1506
2845
  if (Object.prototype.hasOwnProperty.call(value, key)) {
@@ -1534,44 +2873,70 @@ function toSerializableConfig(value) {
1534
2873
  return out;
1535
2874
  }
1536
2875
  `, serializableConfigKeysLiteral())
1537
- node := os.Getenv("TTSC_NODE_BINARY")
1538
- if node == "" {
1539
- node = "node"
1540
- }
1541
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1542
- defer cancel()
1543
- cmd := exec.CommandContext(ctx, node, "-e", script, location)
1544
- return runConfigLoaderCommand(ctx, cmd, location, "config file")
1545
2876
  }
1546
2877
 
1547
2878
  // loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
1548
2879
  // an ephemeral loader script and tsconfig into a temp directory, symlinking the
1549
2880
  // nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
1550
2881
  // The loader script imports the config file, resolves it through the same
1551
- // normalization chain used by loadScriptConfigFile, and writes JSON to stdout.
2882
+ // normalization chain used by loadScriptConfigFile, and writes a private JSON
2883
+ // result file so user stdout cannot corrupt the protocol.
1552
2884
  func loadTypeScriptConfigFile(location string) (any, error) {
2885
+ evaluated, err := loadTypeScriptConfigEvaluation(location)
2886
+ return evaluated.value, err
2887
+ }
2888
+
2889
+ func loadTypeScriptConfigEvaluation(location string) (evaluatedConfigFile, error) {
2890
+ return loadTypeScriptConfigEvaluationWithin(location, filepath.Dir(location))
2891
+ }
2892
+
2893
+ func loadTypeScriptConfigEvaluationWithin(
2894
+ location string,
2895
+ resolutionRoot string,
2896
+ ) (evaluatedConfigFile, error) {
1553
2897
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-lint-config-")
1554
2898
  if err != nil {
1555
- return nil, fmt.Errorf("@ttsc/lint: create config loader tempdir: %w", err)
2899
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: create config loader tempdir: %w", err)
1556
2900
  }
1557
2901
  tempDir = realpathIfPossible(tempDir)
1558
2902
  defer os.RemoveAll(tempDir)
1559
2903
 
1560
2904
  if err := linkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
1561
- return nil, err
2905
+ return evaluatedConfigFile{}, err
1562
2906
  }
1563
2907
 
1564
2908
  loader := filepath.Join(tempDir, "loader.mts")
2909
+ outputPath := filepath.Join(tempDir, "result.json")
1565
2910
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
1566
2911
  importLiteral, err := json.Marshal(fileURL(location))
1567
2912
  if err != nil {
1568
- return nil, fmt.Errorf("@ttsc/lint: encode config import %s: %w", location, err)
2913
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: encode config import %s: %w", location, err)
2914
+ }
2915
+ outputLiteral, err := json.Marshal(outputPath)
2916
+ if err != nil {
2917
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: encode config result path %s: %w", outputPath, err)
2918
+ }
2919
+ resolutionRootLiteral, err := json.Marshal(filepath.Clean(resolutionRoot))
2920
+ if err != nil {
2921
+ return evaluatedConfigFile{}, fmt.Errorf(
2922
+ "@ttsc/lint: encode config resolution root %s: %w",
2923
+ resolutionRoot,
2924
+ err,
2925
+ )
1569
2926
  }
1570
- if err := os.WriteFile(loader, []byte(typeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
1571
- return nil, fmt.Errorf("@ttsc/lint: write config loader: %w", err)
2927
+ if err := os.WriteFile(
2928
+ loader,
2929
+ []byte(typeScriptConfigLoaderSource(
2930
+ string(importLiteral),
2931
+ string(outputLiteral),
2932
+ string(resolutionRootLiteral),
2933
+ )),
2934
+ 0o644,
2935
+ ); err != nil {
2936
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: write config loader: %w", err)
1572
2937
  }
1573
2938
  if err := os.WriteFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
1574
- return nil, fmt.Errorf("@ttsc/lint: write config loader tsconfig: %w", err)
2939
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: write config loader tsconfig: %w", err)
1575
2940
  }
1576
2941
 
1577
2942
  args := []string{
@@ -1597,7 +2962,7 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1597
2962
  defer cancel()
1598
2963
  cmd := ttsxCommandContext(ctx, args...)
1599
2964
  cmd.Env = nodeConfigLoaderEnv(location)
1600
- return runConfigLoaderCommand(ctx, cmd, location, "TypeScript config file")
2965
+ return runConfigLoaderCommand(ctx, cmd, location, "TypeScript config file", outputPath)
1601
2966
  }
1602
2967
 
1603
2968
  // isConfigObject reports whether `value` is a top-level config object. A lint
@@ -1656,26 +3021,151 @@ func uncFileURL(pathname string) string {
1656
3021
  // `importLiteral` is a JSON-encoded file URL (produced by json.Marshal). It is
1657
3022
  // assigned to a variable before `import(configUrl)` so tsgo does not try to
1658
3023
  // statically resolve the file URL during the loader build.
1659
- func typeScriptConfigLoaderSource(importLiteral string) string {
1660
- return fmt.Sprintf(`const configUrl = %s;
3024
+ func typeScriptConfigLoaderSource(
3025
+ importLiteral string,
3026
+ outputLiteral string,
3027
+ resolutionRootLiteral string,
3028
+ ) string {
3029
+ return fmt.Sprintf(`// @ts-ignore -- internal loader must not require user-installed Node typings.
3030
+ import * as fs from "node:fs";
3031
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
3032
+ import { Buffer } from "node:buffer";
3033
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
3034
+ import { createHash } from "node:crypto";
3035
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
3036
+ import { registerHooks } from "node:module";
3037
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
3038
+ import * as path from "node:path";
3039
+ // @ts-ignore -- internal loader must not require user-installed Node typings.
3040
+ import { fileURLToPath, pathToFileURL } from "node:url";
3041
+
3042
+ const configUrl = %s;
3043
+ const outputPath = %s;
3044
+ const resolutionRoot = path.resolve(%s);
1661
3045
  const CONFIG_KEYS = new Set<string>([%s]);
1662
- const importedConfig = await import(configUrl);
3046
+ const dependencies = new Map<string, {
3047
+ digest: string;
3048
+ kind: "directory" | "file" | "optional-file";
3049
+ path: string;
3050
+ owners: Set<string>;
3051
+ }>();
3052
+ const graphNodes = new Map<string, string>();
3053
+ const graphEdges: Array<{
3054
+ child: string;
3055
+ packageBoundary: boolean;
3056
+ parent: string;
3057
+ }> = [];
3058
+ const configLocation = fileURLToPath(configUrl);
3059
+ // Every spelling of this config the module system might key an edge under.
3060
+ //
3061
+ // Which one it uses is not knowable from here, and guessing has failed in both
3062
+ // directions. A path handed in by another producer can be escaped by a rule
3063
+ // Node does not share. Node respells a resolved file module through its real
3064
+ // path unless "--preserve-symlinks" is set, so a config reached through a
3065
+ // symlinked directory is keyed by its target. And a Windows 8.3 short name is
3066
+ // not a symlink: fs.realpathSync expands it, the module resolver does not, so
3067
+ // asking the volume there produces a spelling no edge carries.
3068
+ //
3069
+ // A seed that names a URL no edge was keyed under sits on a node with no
3070
+ // outgoing edges, the walk ends immediately, and every dependency recorded
3071
+ // after the first import is demoted from watch to cache. That failure is
3072
+ // silent: the build still succeeds and simply stops reacting. Seeding every
3073
+ // spelling costs one extra queue entry and cannot be wrong.
3074
+ const configUrlSpellings = [
3075
+ ...new Set([
3076
+ configUrl,
3077
+ pathToFileURL(configLocation).href,
3078
+ pathToFileURL(realConfigLocation()).href,
3079
+ ]),
3080
+ ];
3081
+ for (const spelling of configUrlSpellings) {
3082
+ graphNodes.set(spelling, configLocation);
3083
+ }
3084
+ recordDependency(
3085
+ "file",
3086
+ configLocation,
3087
+ createHash("sha256").update(fs.readFileSync(configLocation)).digest("hex"),
3088
+ configUrlSpellings,
3089
+ );
3090
+ recordPackageManifests(configLocation, configUrlSpellings);
1663
3091
 
1664
3092
  declare const process: {
3093
+ env: Record<string, string | undefined>;
3094
+ platform: string;
1665
3095
  stdout: { write(value: string): void };
1666
3096
  stderr: { write(value: string): void };
1667
3097
  exit(code?: number): never;
1668
3098
  };
1669
3099
 
3100
+ const hooks = registerHooks({
3101
+ resolve(specifier, context, nextResolve) {
3102
+ const resolved = nextResolve(specifier, context);
3103
+ if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
3104
+ return resolved;
3105
+ }
3106
+ const url = new URL(resolved.url).href;
3107
+ const parent = context.parentURL && new URL(context.parentURL).href;
3108
+ const location = fileURLToPath(url);
3109
+ // The entry is recognized by what was asked for, not only by what came
3110
+ // back. A module URL is assigned by whoever loaded it: a compiling loader
3111
+ // can serve the config from its emitted output, and a platform can hand
3112
+ // back a different spelling of the same file. Either way the URL bears no
3113
+ // resemblance to the one this process was given, so the config's own
3114
+ // imports would be rejected here — their parent is a URL no node was
3115
+ // recorded under — and the graph would collapse to the records made before
3116
+ // the first import. The request itself is unambiguous, so it decides.
3117
+ const entry =
3118
+ specifier === configUrl ||
3119
+ url === new URL(configUrl).href ||
3120
+ samePhysicalPath(location, configLocation);
3121
+ if (!entry && (parent === undefined || !graphNodes.has(parent))) {
3122
+ return resolved;
3123
+ }
3124
+ graphNodes.set(url, location);
3125
+ if (parent !== undefined) {
3126
+ graphEdges.push({
3127
+ child: url,
3128
+ packageBoundary:
3129
+ pathHasNodeModules(location) && !isLocalModuleSpecifier(specifier),
3130
+ parent,
3131
+ });
3132
+ recordResolutionTopology(
3133
+ specifier,
3134
+ parent,
3135
+ url,
3136
+ location,
3137
+ context.conditions,
3138
+ );
3139
+ }
3140
+ try {
3141
+ recordDependency(
3142
+ "file",
3143
+ location,
3144
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
3145
+ [url],
3146
+ );
3147
+ } catch {
3148
+ recordDependency("file", location, "", [url]);
3149
+ }
3150
+ return resolved;
3151
+ },
3152
+ });
3153
+
1670
3154
  try {
3155
+ const importedConfig = await import(configUrl);
1671
3156
  const value = await resolveConfig(importedConfig, true);
1672
3157
  if (!isObject(value) || Array.isArray(value)) {
1673
3158
  throw new Error("config file must export an ITtscLintConfig object");
1674
3159
  }
1675
- process.stdout.write(JSON.stringify(toSerializableConfig(value)));
3160
+ fs.writeFileSync(outputPath, JSON.stringify({
3161
+ dependencies: finalizeDependencies(),
3162
+ value: toSerializableConfig(value),
3163
+ }), "utf8");
1676
3164
  } catch (error) {
1677
3165
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
1678
3166
  process.exit(1);
3167
+ } finally {
3168
+ hooks.deregister();
1679
3169
  }
1680
3170
 
1681
3171
  async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise<unknown> {
@@ -1716,6 +3206,823 @@ function isObject(value: unknown): value is Record<string, unknown> {
1716
3206
  return value !== null && typeof value === "object";
1717
3207
  }
1718
3208
 
3209
+ function recordDependency(
3210
+ kind: "directory" | "file" | "optional-file",
3211
+ location: string,
3212
+ digest: string,
3213
+ owners: readonly string[],
3214
+ ): void {
3215
+ const key = kind + "\0" + location;
3216
+ const previous = dependencies.get(key);
3217
+ const mergedOwners = previous?.owners ?? new Set<string>();
3218
+ for (const owner of owners) mergedOwners.add(owner);
3219
+ dependencies.set(key, {
3220
+ digest: previous !== undefined && previous.digest !== digest ? "" : digest,
3221
+ kind,
3222
+ owners: mergedOwners,
3223
+ path: location,
3224
+ });
3225
+ }
3226
+
3227
+ function isLocalModuleSpecifier(specifier: string): boolean {
3228
+ return specifier.startsWith(".") ||
3229
+ specifier.startsWith("/") ||
3230
+ specifier.startsWith("file:") ||
3231
+ /^[A-Za-z]:[\\/]/.test(specifier);
3232
+ }
3233
+
3234
+ function pathHasNodeModules(location: string): boolean {
3235
+ return location.replaceAll("\\", "/").split("/").includes("node_modules");
3236
+ }
3237
+
3238
+ function recordResolutionTopology(
3239
+ specifier: string,
3240
+ parentUrl: string,
3241
+ childUrl: string,
3242
+ childLocation: string,
3243
+ conditions: readonly string[],
3244
+ ): void {
3245
+ const owners = [parentUrl, childUrl];
3246
+ const parentLocation = graphNodes.get(parentUrl);
3247
+ if (parentLocation !== undefined && isLocalModuleSpecifier(specifier)) {
3248
+ recordDirectoryDependency(path.dirname(parentLocation), owners);
3249
+ }
3250
+ recordDirectoryDependency(path.dirname(childLocation), owners);
3251
+ recordPackageManifests(childLocation, owners);
3252
+ if (parentLocation !== undefined && !isLocalModuleSpecifier(specifier)) {
3253
+ recordNodeModulesSearchDirectories(
3254
+ parentLocation,
3255
+ specifier,
3256
+ childLocation,
3257
+ owners,
3258
+ conditions,
3259
+ );
3260
+ }
3261
+ }
3262
+
3263
+ function recordDirectoryDependency(
3264
+ location: string,
3265
+ owners: readonly string[],
3266
+ ): void {
3267
+ try {
3268
+ recordDependency("directory", location, directoryDigest(location), owners);
3269
+ } catch {
3270
+ recordDependency("directory", location, "", owners);
3271
+ }
3272
+ }
3273
+
3274
+ function directoryDigest(location: string): string {
3275
+ const entries: Buffer[] = [];
3276
+ if (process.platform === "win32") {
3277
+ for (const entry of fs.readdirSync(location, { withFileTypes: true })) {
3278
+ let target = Buffer.alloc(0);
3279
+ if (entry.isSymbolicLink()) {
3280
+ try {
3281
+ target = Buffer.from(
3282
+ fs.readlinkSync(path.join(location, entry.name)),
3283
+ "utf8",
3284
+ );
3285
+ } catch {
3286
+ target = Buffer.from("<unreadable>");
3287
+ }
3288
+ }
3289
+ entries.push(directoryDigestRecord(Buffer.from(entry.name), entry, target));
3290
+ }
3291
+ } else {
3292
+ for (const entry of fs.readdirSync(location, {
3293
+ encoding: "buffer",
3294
+ withFileTypes: true,
3295
+ })) {
3296
+ let target = Buffer.alloc(0);
3297
+ if (entry.isSymbolicLink()) {
3298
+ try {
3299
+ target = fs.readlinkSync(
3300
+ Buffer.concat([
3301
+ Buffer.from(location),
3302
+ Buffer.from(path.sep),
3303
+ entry.name,
3304
+ ]),
3305
+ { encoding: "buffer" },
3306
+ );
3307
+ } catch {
3308
+ target = Buffer.from("<unreadable>");
3309
+ }
3310
+ }
3311
+ entries.push(directoryDigestRecord(entry.name, entry, target));
3312
+ }
3313
+ }
3314
+ entries.sort(Buffer.compare);
3315
+ const serialized = Buffer.concat(
3316
+ entries.flatMap((entry, index) =>
3317
+ index === 0 ? [entry] : [Buffer.from([0]), entry],
3318
+ ),
3319
+ );
3320
+ return createHash("sha256").update(serialized).digest("hex");
3321
+ }
3322
+
3323
+ function directoryDigestRecord(
3324
+ name: Buffer,
3325
+ entry: {
3326
+ isDirectory(): boolean;
3327
+ isFile(): boolean;
3328
+ isSymbolicLink(): boolean;
3329
+ },
3330
+ target: Buffer,
3331
+ ): Buffer {
3332
+ const kind = entry.isDirectory()
3333
+ ? "directory"
3334
+ : entry.isFile()
3335
+ ? "file"
3336
+ : entry.isSymbolicLink()
3337
+ ? "symlink"
3338
+ : "other";
3339
+ return Buffer.concat([name, Buffer.from("\0" + kind + "\0"), target]);
3340
+ }
3341
+
3342
+ function optionalFileDigest(location: string): string {
3343
+ try {
3344
+ if (fs.statSync(location).isFile()) {
3345
+ return createHash("sha256")
3346
+ .update(Buffer.concat([Buffer.from("file\0"), fs.readFileSync(location)]))
3347
+ .digest("hex");
3348
+ }
3349
+ } catch {
3350
+ }
3351
+ return createHash("sha256").update("missing\0").digest("hex");
3352
+ }
3353
+
3354
+ function recordOptionalFileDependency(
3355
+ location: string,
3356
+ owners: readonly string[],
3357
+ ): boolean {
3358
+ try {
3359
+ if (fs.statSync(location).isFile()) {
3360
+ recordDependency(
3361
+ "file",
3362
+ location,
3363
+ createHash("sha256").update(fs.readFileSync(location)).digest("hex"),
3364
+ owners,
3365
+ );
3366
+ return true;
3367
+ }
3368
+ } catch {
3369
+ }
3370
+ recordDependency("optional-file", location, optionalFileDigest(location), owners);
3371
+ return false;
3372
+ }
3373
+
3374
+ function recordPackageManifests(
3375
+ location: string,
3376
+ owners: readonly string[],
3377
+ ): void {
3378
+ let current = path.dirname(location);
3379
+ while (true) {
3380
+ const manifest = path.join(current, "package.json");
3381
+ if (recordOptionalFileDependency(manifest, owners)) return;
3382
+ const parent = path.dirname(current);
3383
+ if (parent === current || path.basename(current) === "node_modules") return;
3384
+ current = parent;
3385
+ }
3386
+ }
3387
+
3388
+ function recordNodeModulesSearchDirectories(
3389
+ parentLocation: string,
3390
+ specifier: string,
3391
+ childLocation: string,
3392
+ owners: readonly string[],
3393
+ conditions: readonly string[],
3394
+ ): void {
3395
+ const packageName = modulePackageName(specifier);
3396
+ const scope =
3397
+ specifier.startsWith("@") && specifier.includes("/")
3398
+ ? specifier.slice(0, specifier.indexOf("/"))
3399
+ : undefined;
3400
+ let current = path.dirname(parentLocation);
3401
+ while (true) {
3402
+ recordDirectoryDependency(current, owners);
3403
+ const modules = path.join(current, "node_modules");
3404
+ try {
3405
+ if (fs.statSync(modules).isDirectory()) {
3406
+ recordDirectoryDependency(modules, owners);
3407
+ if (scope !== undefined) {
3408
+ const scoped = path.join(modules, scope);
3409
+ try {
3410
+ if (fs.statSync(scoped).isDirectory()) {
3411
+ recordDirectoryDependency(scoped, owners);
3412
+ }
3413
+ } catch {
3414
+ }
3415
+ }
3416
+ if (packageName !== undefined) {
3417
+ const selected = recordPackageCandidateTopology(
3418
+ modules,
3419
+ packageName,
3420
+ specifier,
3421
+ childLocation,
3422
+ owners,
3423
+ conditions,
3424
+ );
3425
+ if (
3426
+ selected ||
3427
+ resolvedPackageContains(modules, packageName, childLocation)
3428
+ ) {
3429
+ return;
3430
+ }
3431
+ }
3432
+ }
3433
+ } catch {
3434
+ }
3435
+ if (
3436
+ packageName === undefined &&
3437
+ samePhysicalPath(current, resolutionRoot)
3438
+ ) {
3439
+ return;
3440
+ }
3441
+ const parent = path.dirname(current);
3442
+ if (parent === current) return;
3443
+ current = parent;
3444
+ }
3445
+ }
3446
+
3447
+ function recordPackageCandidateTopology(
3448
+ modules: string,
3449
+ packageName: string,
3450
+ specifier: string,
3451
+ childLocation: string,
3452
+ owners: readonly string[],
3453
+ conditions: readonly string[],
3454
+ ): boolean {
3455
+ const packageRoot = path.join(modules, packageName);
3456
+ try {
3457
+ if (!fs.statSync(packageRoot).isDirectory()) return false;
3458
+ } catch {
3459
+ return false;
3460
+ }
3461
+ const subpath = specifier
3462
+ .slice(packageName.length)
3463
+ .replace(/^[/\\]+/, "");
3464
+ const rootTopology = recordPackageRootTopology(
3465
+ packageRoot,
3466
+ owners,
3467
+ subpath === "",
3468
+ subpath === "" ? "." : "./" + subpath.replaceAll("\\", "/"),
3469
+ childLocation,
3470
+ conditions,
3471
+ );
3472
+ if (subpath !== "" && !rootTopology.hasExports) {
3473
+ return (
3474
+ recordPackageSubpathTopology(
3475
+ packageRoot,
3476
+ subpath,
3477
+ childLocation,
3478
+ owners,
3479
+ ) || rootTopology.selected
3480
+ );
3481
+ }
3482
+ return rootTopology.selected;
3483
+ }
3484
+
3485
+ function recordPackageRootTopology(
3486
+ packageRoot: string,
3487
+ owners: readonly string[],
3488
+ useMain: boolean,
3489
+ packageSubpath: string,
3490
+ childLocation: string,
3491
+ conditions: readonly string[],
3492
+ ): { hasExports: boolean; selected: boolean } {
3493
+ const normalizedRoot = path.resolve(packageRoot);
3494
+ const manifest = path.join(normalizedRoot, "package.json");
3495
+ const legacySelected = (): boolean =>
3496
+ useMain &&
3497
+ packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
3498
+ if (!recordOptionalFileDependency(manifest, owners)) {
3499
+ const selected = legacySelected();
3500
+ if (!selected) {
3501
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
3502
+ }
3503
+ return { hasExports: false, selected };
3504
+ }
3505
+ try {
3506
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
3507
+ if (value !== null && typeof value === "object") {
3508
+ const metadata = value as Record<string, unknown>;
3509
+ const hasExports =
3510
+ metadata.exports !== undefined && metadata.exports !== null;
3511
+ if (hasExports) {
3512
+ const target = selectPackageExportsTarget(
3513
+ metadata.exports,
3514
+ packageSubpath,
3515
+ new Set(conditions),
3516
+ );
3517
+ const candidate =
3518
+ typeof target === "string"
3519
+ ? packageExportsTarget(normalizedRoot, target)
3520
+ : undefined;
3521
+ const selected =
3522
+ candidate !== undefined &&
3523
+ packagePathCandidateMatchesChild(
3524
+ candidate,
3525
+ childLocation,
3526
+ false,
3527
+ );
3528
+ if (selected) {
3529
+ recordPackagePathCandidate(candidate, owners);
3530
+ } else if (candidate !== undefined) {
3531
+ // A nearer package the search skipped starts winning the moment its
3532
+ // own active target appears, and neither the parent node_modules
3533
+ // listing nor the manifest changes when only that file is created.
3534
+ recordOptionalFileDependency(candidate, owners);
3535
+ }
3536
+ return { hasExports: true, selected };
3537
+ }
3538
+ let selected = legacySelected();
3539
+ if (useMain && typeof metadata.main === "string") {
3540
+ const main = path.resolve(normalizedRoot, metadata.main);
3541
+ recordPackagePathCandidate(main, owners);
3542
+ selected =
3543
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
3544
+ selected;
3545
+ }
3546
+ if (!selected) {
3547
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
3548
+ }
3549
+ return { hasExports: false, selected };
3550
+ }
3551
+ } catch {
3552
+ }
3553
+ const rootSelected = legacySelected();
3554
+ if (!rootSelected) {
3555
+ recordPackageIndexCandidates(normalizedRoot, useMain, owners);
3556
+ }
3557
+ return { hasExports: false, selected: rootSelected };
3558
+ }
3559
+
3560
+ // recordPackageIndexCandidates pins the LOAD_INDEX fallbacks of a package root
3561
+ // this resolution walked past without selecting. An empty package directory, or
3562
+ // one whose manifest declares no usable entry, becomes resolvable as soon as one
3563
+ // of these files exists, and that creation changes neither the parent directory
3564
+ // listing nor the manifest digest already recorded for the candidate.
3565
+ function recordPackageIndexCandidates(
3566
+ packageRoot: string,
3567
+ useMain: boolean,
3568
+ owners: readonly string[],
3569
+ ): void {
3570
+ if (!useMain) return;
3571
+ for (const name of ["index.js", "index.json", "index.node"]) {
3572
+ recordOptionalFileDependency(path.join(packageRoot, name), owners);
3573
+ }
3574
+ }
3575
+
3576
+ function selectPackageExportsTarget(
3577
+ exportsValue: unknown,
3578
+ packageSubpath: string,
3579
+ conditions: ReadonlySet<string>,
3580
+ ): string | null | undefined {
3581
+ let mappings: unknown = exportsValue;
3582
+ if (
3583
+ typeof mappings === "string" ||
3584
+ Array.isArray(mappings) ||
3585
+ (isObject(mappings) &&
3586
+ Object.keys(mappings).every((key) => !key.startsWith(".")))
3587
+ ) {
3588
+ if (packageSubpath !== ".") return undefined;
3589
+ return selectPackageTarget(mappings, "", false, conditions);
3590
+ }
3591
+ if (!isObject(mappings)) return undefined;
3592
+ if (
3593
+ Object.prototype.hasOwnProperty.call(mappings, packageSubpath) &&
3594
+ !packageSubpath.includes("*") &&
3595
+ !packageSubpath.endsWith("/")
3596
+ ) {
3597
+ return selectPackageTarget(
3598
+ mappings[packageSubpath],
3599
+ "",
3600
+ false,
3601
+ conditions,
3602
+ );
3603
+ }
3604
+ let bestMatch = "";
3605
+ let bestSubpath = "";
3606
+ for (const key of Object.keys(mappings)) {
3607
+ const wildcard = key.indexOf("*");
3608
+ if (
3609
+ wildcard === -1 ||
3610
+ key.lastIndexOf("*") !== wildcard ||
3611
+ !packageSubpath.startsWith(key.slice(0, wildcard))
3612
+ ) {
3613
+ continue;
3614
+ }
3615
+ const trailer = key.slice(wildcard + 1);
3616
+ if (
3617
+ packageSubpath.length < key.length ||
3618
+ !packageSubpath.endsWith(trailer) ||
3619
+ packagePatternKeyCompare(bestMatch, key) !== 1
3620
+ ) {
3621
+ continue;
3622
+ }
3623
+ bestMatch = key;
3624
+ bestSubpath = packageSubpath.slice(
3625
+ wildcard,
3626
+ packageSubpath.length - trailer.length,
3627
+ );
3628
+ }
3629
+ return bestMatch === ""
3630
+ ? undefined
3631
+ : selectPackageTarget(
3632
+ mappings[bestMatch],
3633
+ bestSubpath,
3634
+ true,
3635
+ conditions,
3636
+ );
3637
+ }
3638
+
3639
+ function selectPackageTarget(
3640
+ target: unknown,
3641
+ subpath: string,
3642
+ pattern: boolean,
3643
+ conditions: ReadonlySet<string>,
3644
+ ): string | null | undefined {
3645
+ if (typeof target === "string") {
3646
+ const selected = pattern ? target.replaceAll("*", subpath) : target;
3647
+ return validPackageExportsTarget(selected) ? selected : undefined;
3648
+ }
3649
+ if (Array.isArray(target)) {
3650
+ for (const item of target) {
3651
+ const selected = selectPackageTarget(
3652
+ item,
3653
+ subpath,
3654
+ pattern,
3655
+ conditions,
3656
+ );
3657
+ if (selected !== undefined && selected !== null) return selected;
3658
+ }
3659
+ return null;
3660
+ }
3661
+ if (isObject(target)) {
3662
+ for (const [condition, value] of Object.entries(target)) {
3663
+ if (condition !== "default" && !conditions.has(condition)) continue;
3664
+ const selected = selectPackageTarget(
3665
+ value,
3666
+ subpath,
3667
+ pattern,
3668
+ conditions,
3669
+ );
3670
+ if (selected !== undefined) return selected;
3671
+ }
3672
+ return undefined;
3673
+ }
3674
+ return target === null ? null : undefined;
3675
+ }
3676
+
3677
+ function packagePatternKeyCompare(left: string, right: string): number {
3678
+ const leftWildcard = left.indexOf("*");
3679
+ const rightWildcard = right.indexOf("*");
3680
+ const leftBase =
3681
+ leftWildcard === -1 ? left.length : leftWildcard + 1;
3682
+ const rightBase =
3683
+ rightWildcard === -1 ? right.length : rightWildcard + 1;
3684
+ if (leftBase > rightBase) return -1;
3685
+ if (rightBase > leftBase) return 1;
3686
+ if (leftWildcard === -1) return 1;
3687
+ if (rightWildcard === -1) return -1;
3688
+ if (left.length > right.length) return -1;
3689
+ if (right.length > left.length) return 1;
3690
+ return 0;
3691
+ }
3692
+
3693
+ function packageExportsTarget(
3694
+ packageRoot: string,
3695
+ target: string,
3696
+ ): string | undefined {
3697
+ if (!validPackageExportsTarget(target)) return undefined;
3698
+ try {
3699
+ // Node resolves an exports target as a URL against the package manifest,
3700
+ // so percent escapes, query strings, and fragments all take part in the
3701
+ // path it finally loads. Joining the raw target by hand diverges from that
3702
+ // whenever the target is anything but a plain relative path, and a target
3703
+ // Node resolves while this model rejects loses the selected file's
3704
+ // fingerprint, leaving a retargeted symlink cached as fresh.
3705
+ const packageUrl = pathToFileURL(path.join(packageRoot, "package.json"));
3706
+ const resolved = new URL(target, packageUrl);
3707
+ const packagePath = new URL(".", packageUrl).pathname;
3708
+ if (!resolved.pathname.startsWith(packagePath)) return undefined;
3709
+ return fileURLToPath(resolved);
3710
+ } catch {
3711
+ return undefined;
3712
+ }
3713
+ }
3714
+
3715
+ function validPackageExportsTarget(target: string): boolean {
3716
+ if (!target.startsWith("./") || /%%2f|%%5c/i.test(target)) return false;
3717
+ const components = target
3718
+ .slice(2)
3719
+ .replaceAll("\\", "/")
3720
+ .split("/");
3721
+ if (
3722
+ components.some(
3723
+ (component) => {
3724
+ try {
3725
+ const decoded = decodeURIComponent(component);
3726
+ return (
3727
+ decoded === "." ||
3728
+ decoded === ".." ||
3729
+ decoded.includes("/") ||
3730
+ decoded.includes("\\") ||
3731
+ decoded.toLowerCase() === "node_modules"
3732
+ );
3733
+ } catch {
3734
+ return true;
3735
+ }
3736
+ },
3737
+ )
3738
+ ) {
3739
+ return false;
3740
+ }
3741
+ return true;
3742
+ }
3743
+
3744
+ function packagePathCandidateMatchesChild(
3745
+ candidate: string,
3746
+ childLocation: string,
3747
+ legacy: boolean,
3748
+ ): boolean {
3749
+ let child: string;
3750
+ try {
3751
+ child = fs.realpathSync.native(childLocation);
3752
+ } catch {
3753
+ child = path.resolve(childLocation);
3754
+ }
3755
+ const candidates = legacy
3756
+ ? [
3757
+ candidate,
3758
+ candidate + ".js",
3759
+ candidate + ".json",
3760
+ candidate + ".node",
3761
+ path.join(candidate, "index.js"),
3762
+ path.join(candidate, "index.json"),
3763
+ path.join(candidate, "index.node"),
3764
+ ]
3765
+ : [candidate];
3766
+ return candidates.some((location) => {
3767
+ try {
3768
+ return sameResolutionPath(fs.realpathSync.native(location), child);
3769
+ } catch {
3770
+ return false;
3771
+ }
3772
+ });
3773
+ }
3774
+
3775
+ function recordPackageSubpathTopology(
3776
+ packageRoot: string,
3777
+ subpath: string,
3778
+ childLocation: string,
3779
+ owners: readonly string[],
3780
+ ): boolean {
3781
+ const candidate = boundedPackageTarget(packageRoot, subpath);
3782
+ if (candidate === undefined) return false;
3783
+ recordPackagePathCandidate(candidate, owners);
3784
+ let selected = packagePathCandidateMatchesChild(
3785
+ candidate,
3786
+ childLocation,
3787
+ true,
3788
+ );
3789
+ try {
3790
+ if (!fs.statSync(candidate).isDirectory()) return selected;
3791
+ } catch {
3792
+ return selected;
3793
+ }
3794
+ const manifest = path.join(candidate, "package.json");
3795
+ if (!recordOptionalFileDependency(manifest, owners)) return selected;
3796
+ try {
3797
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
3798
+ if (value !== null && typeof value === "object") {
3799
+ const metadata = value as Record<string, unknown>;
3800
+ if (typeof metadata.main === "string") {
3801
+ const main = path.resolve(candidate, metadata.main);
3802
+ recordPackagePathCandidate(main, owners);
3803
+ selected =
3804
+ packagePathCandidateMatchesChild(main, childLocation, true) ||
3805
+ selected;
3806
+ }
3807
+ }
3808
+ } catch {
3809
+ }
3810
+ return selected;
3811
+ }
3812
+
3813
+ function boundedPackageTarget(
3814
+ packageRoot: string,
3815
+ target: string,
3816
+ ): string | undefined {
3817
+ const candidate = path.resolve(packageRoot, target);
3818
+ const relative = path.relative(packageRoot, candidate);
3819
+ if (
3820
+ relative === ".." ||
3821
+ relative.startsWith(".." + path.sep) ||
3822
+ path.isAbsolute(relative)
3823
+ ) {
3824
+ return undefined;
3825
+ }
3826
+ return candidate;
3827
+ }
3828
+
3829
+ function recordPackagePathCandidate(
3830
+ candidate: string,
3831
+ owners: readonly string[],
3832
+ visited: Set<string> = new Set(),
3833
+ depth = 0,
3834
+ ): void {
3835
+ const normalized = path.resolve(candidate);
3836
+ // The depth bound owns termination. A platform-wide case fold would merge
3837
+ // paths that differ only by case, which a per-directory case-sensitive
3838
+ // Windows tree keeps distinct, and would truncate a valid symlink chain.
3839
+ if (depth >= 64 || visited.has(normalized)) return;
3840
+ visited.add(normalized);
3841
+ const parsed = path.parse(normalized);
3842
+ const components = normalized
3843
+ .slice(parsed.root.length)
3844
+ .split(path.sep)
3845
+ .filter(Boolean);
3846
+ let current = parsed.root;
3847
+ for (let index = 0; index < components.length; index++) {
3848
+ const component = components[index];
3849
+ const next = path.join(current, component);
3850
+ let entry: ReturnType<typeof fs.lstatSync>;
3851
+ try {
3852
+ entry = fs.lstatSync(next);
3853
+ } catch {
3854
+ recordDirectoryDependency(current, owners);
3855
+ return;
3856
+ }
3857
+ if (entry.isSymbolicLink()) {
3858
+ recordDirectoryDependency(current, owners);
3859
+ try {
3860
+ const target = fs.readlinkSync(next);
3861
+ const remainder = components.slice(index + 1);
3862
+ recordPackagePathCandidate(
3863
+ path.join(path.resolve(current, target), ...remainder),
3864
+ owners,
3865
+ visited,
3866
+ depth + 1,
3867
+ );
3868
+ } catch {
3869
+ }
3870
+ }
3871
+ let isDirectory = entry.isDirectory();
3872
+ if (entry.isSymbolicLink()) {
3873
+ try {
3874
+ isDirectory = fs.statSync(next).isDirectory();
3875
+ } catch {
3876
+ return;
3877
+ }
3878
+ }
3879
+ if (index === components.length - 1) {
3880
+ recordDirectoryDependency(isDirectory ? next : current, owners);
3881
+ return;
3882
+ }
3883
+ if (!isDirectory) {
3884
+ recordDirectoryDependency(current, owners);
3885
+ return;
3886
+ }
3887
+ current = next;
3888
+ }
3889
+ recordDirectoryDependency(current, owners);
3890
+ }
3891
+
3892
+ function modulePackageName(specifier: string): string | undefined {
3893
+ if (specifier.startsWith("@")) {
3894
+ const components = specifier.split("/");
3895
+ return components.length >= 2
3896
+ ? components[0] + "/" + components[1]
3897
+ : undefined;
3898
+ }
3899
+ const [name] = specifier.split("/");
3900
+ return name && !name.startsWith("#") ? name : undefined;
3901
+ }
3902
+
3903
+ function resolvedPackageContains(
3904
+ modules: string,
3905
+ packageName: string,
3906
+ childLocation: string,
3907
+ ): boolean {
3908
+ try {
3909
+ const packageRoot = fs.realpathSync(path.join(modules, packageName));
3910
+ const relative = path.relative(
3911
+ packageRoot,
3912
+ fs.realpathSync(childLocation),
3913
+ );
3914
+ return (
3915
+ relative === "" ||
3916
+ (relative !== ".." &&
3917
+ !relative.startsWith(".." + path.sep) &&
3918
+ !path.isAbsolute(relative))
3919
+ );
3920
+ } catch {
3921
+ return false;
3922
+ }
3923
+ }
3924
+
3925
+ function sameResolutionPath(left: string, right: string): boolean {
3926
+ return path.relative(left, right) === "";
3927
+ }
3928
+
3929
+ function samePhysicalPath(left: string, right: string): boolean {
3930
+ try {
3931
+ return sameResolutionPath(realPath(left), realPath(right));
3932
+ } catch {
3933
+ // Fall back to the spellings themselves, folding case the way the platform
3934
+ // does. On the entry gate a false negative is catastrophic — the config
3935
+ // stops being recognized and its whole graph collapses — while a false
3936
+ // positive only over-includes, so the degradation has to lean toward "same
3937
+ // file". A drive-letter or component case difference is the ordinary
3938
+ // Windows situation; a per-directory case-sensitive tree is the rare one.
3939
+ return sameResolutionPath(left, right);
3940
+ }
3941
+ }
3942
+
3943
+ /**
3944
+ * The config's real path, or its declared one when the volume will not say.
3945
+ *
3946
+ * A config can disappear between the host reading it and this loader starting,
3947
+ * and a throw here would replace a precise report from the import below with a
3948
+ * crash in bookkeeping. Seeding lexically instead only risks the demotion this
3949
+ * value exists to prevent, on a file that is already gone.
3950
+ */
3951
+ function realConfigLocation(): string {
3952
+ try {
3953
+ return realPath(configLocation);
3954
+ } catch {
3955
+ return configLocation;
3956
+ }
3957
+ }
3958
+
3959
+ function realPath(location: string): string {
3960
+ return fs.realpathSync.native
3961
+ ? fs.realpathSync.native(location)
3962
+ : fs.realpathSync(location);
3963
+ }
3964
+
3965
+ function finalizeDependencies(): Array<{
3966
+ digest: string;
3967
+ kind: "directory" | "file" | "optional-file";
3968
+ path: string;
3969
+ scope: "cache" | "watch";
3970
+ }> {
3971
+ const watched = graphWatchReachability();
3972
+ // Opt-in diagnostics for a graph that comes back empty. The only channel this
3973
+ // loader may use is stderr, because the result travels through a private file
3974
+ // that user output must not corrupt; it stays silent unless a caller asks.
3975
+ if (process.env.TTSC_LINT_DEBUG_CONFIG_GRAPH) {
3976
+ process.stderr.write(
3977
+ "@ttsc/lint: config graph " +
3978
+ JSON.stringify({
3979
+ configUrl,
3980
+ seeds: configUrlSpellings,
3981
+ nodes: [...graphNodes.keys()],
3982
+ edges: graphEdges.map((edge) => edge.parent + " -> " + edge.child),
3983
+ watched: [...watched],
3984
+ }) +
3985
+ "\n",
3986
+ );
3987
+ }
3988
+ return [...dependencies.values()].map(({ owners, ...dependency }) => ({
3989
+ ...dependency,
3990
+ scope: [...owners].some((owner) => watched.has(owner))
3991
+ ? "watch"
3992
+ : "cache",
3993
+ }));
3994
+ }
3995
+
3996
+ function graphWatchReachability(): Set<string> {
3997
+ const adjacency = new Map<string, typeof graphEdges>();
3998
+ for (const edge of graphEdges) {
3999
+ const outgoing = adjacency.get(edge.parent) ?? [];
4000
+ outgoing.push(edge);
4001
+ adjacency.set(edge.parent, outgoing);
4002
+ }
4003
+ const queue: Array<{ url: string; watched: boolean }> =
4004
+ configUrlSpellings.map((url) => ({ url, watched: true }));
4005
+ const visited = new Set<string>();
4006
+ const watched = new Set<string>();
4007
+ while (queue.length !== 0) {
4008
+ const state = queue.shift()!;
4009
+ const key = state.url + "\0" + (state.watched ? "1" : "0");
4010
+ if (visited.has(key)) continue;
4011
+ visited.add(key);
4012
+ if (state.watched) watched.add(state.url);
4013
+ for (const edge of adjacency.get(state.url) ?? []) {
4014
+ const childLocation = graphNodes.get(edge.child);
4015
+ const childWatched = edge.packageBoundary
4016
+ ? false
4017
+ : childLocation !== undefined && !pathHasNodeModules(childLocation)
4018
+ ? true
4019
+ : state.watched;
4020
+ queue.push({ url: edge.child, watched: childWatched });
4021
+ }
4022
+ }
4023
+ return watched;
4024
+ }
4025
+
1719
4026
  function hasOwn(value: Record<string, unknown>, key: string): boolean {
1720
4027
  return Object.prototype.hasOwnProperty.call(value, key);
1721
4028
  }
@@ -1759,7 +4066,12 @@ function toSerializableConfig(value: Record<string, unknown>): Record<string, un
1759
4066
  }
1760
4067
  return out;
1761
4068
  }
1762
- `, importLiteral, serializableConfigKeysLiteral())
4069
+ `,
4070
+ importLiteral,
4071
+ outputLiteral,
4072
+ resolutionRootLiteral,
4073
+ serializableConfigKeysLiteral(),
4074
+ )
1763
4075
  }
1764
4076
 
1765
4077
  // typeScriptConfigLoaderTsconfig generates the JSON content of the ephemeral