@ttsc/lint 0.28.0 → 0.28.2

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.
@@ -404,8 +404,8 @@ type residentRuleCache struct {
404
404
  mu sync.Mutex
405
405
  key string
406
406
  resolver RuleResolver
407
- configs map[string][sha256.Size]byte
408
- // loads counts the evaluations this memo did not avoid.
407
+ configs *residentRuleConfigSnapshot
408
+ // loads counts the resolver loads this memo did not avoid.
409
409
  //
410
410
  // It exists because a reuse is otherwise unobservable: a RuleResolver holds
411
411
  // maps, so it is not a comparable type and two of them cannot be asked
@@ -414,14 +414,29 @@ type residentRuleCache struct {
414
414
  loads int
415
415
  }
416
416
 
417
+ // residentRuleConfigState is the resolver-owned description of every input a
418
+ // resident answer depends on. Files are native JSON configs; dependencies are
419
+ // the executable loader's full fingerprints, including cache-only package
420
+ // files that deliberately stay out of ConfigPaths and external watch lists.
421
+ type residentRuleConfigState struct {
422
+ dependencies []configDependencyFingerprint
423
+ files []string
424
+ }
425
+
426
+ type residentRuleConfigSnapshot struct {
427
+ dependencies []configDependencyFingerprint
428
+ files map[string][sha256.Size]byte
429
+ }
430
+
417
431
  // acquireRules returns the loaded rule configuration, reusing the daemon's memo
418
- // while the files it was loaded from are unchanged.
432
+ // while the complete state it was loaded from is unchanged.
419
433
  //
420
- // Validated against those files rather than trusted for the daemon's life. The
421
- // configuration is a file on disk that an author edits, and a rule set the user
422
- // has just changed is exactly what a resident consumer must not keep answering
423
- // from. The set is small the config and whatever it extends — so re-hashing
424
- // it costs nothing beside the evaluation it avoids.
434
+ // Native JSON configs are validated against their files. Executable configs
435
+ // retain the loader's complete dependency fingerprints, including missing
436
+ // resolution candidates and cache-only package files. The latter must remain
437
+ // outside public project watch lists without becoming invisible to the resident
438
+ // memo: an imported package edit can change the resolved rules just as surely
439
+ // as an edit to lint.config.ts itself.
425
440
  func acquireRules(pluginsJSON, cwd, tsconfigPath string) (RuleResolver, error) {
426
441
  cache := residentRules
427
442
  if cache == nil {
@@ -457,16 +472,20 @@ func acquireRules(pluginsJSON, cwd, tsconfigPath string) (RuleResolver, error) {
457
472
  return resolver, nil
458
473
  }
459
474
 
460
- // hashRuleConfigs records the content of every file the resolver was built
461
- // from. A resolver naming none records none, and a memo with no recorded file
462
- // is never reused it could not prove anything.
475
+ // hashRuleConfigs records the complete state the resolver was built from. JSON
476
+ // configs contribute file digests. Executable configs contribute the loader's
477
+ // full dependency fingerprints, including missing resolution candidates,
478
+ // directories, and cache-only package files that are not public watch inputs.
479
+ // A resolver naming no state records nothing, and a memo with no proof is never
480
+ // reused.
463
481
  //
464
- // A file written after the load began is recorded as nothing at all. Which
465
- // bytes the resolver was built from is unknowable once an edit lands inside the
466
- // evaluation window, and recording the ones readable now is the one wrong
467
- // answer that never corrects itself: the memo would agree with a file the
482
+ // A native JSON file written after the load began is recorded as nothing at
483
+ // all. Which bytes the resolver was built from is unknowable once an edit lands
484
+ // inside the evaluation window, and recording the ones readable now is the one
485
+ // wrong answer that never corrects itself: the memo would agree with a file the
468
486
  // resolver does not match, and every later request would pass the reuse test
469
- // until some further edit happened to disagree. Declining costs one reload.
487
+ // until some further edit happened to disagree. Executable configs carry the
488
+ // loader's own pre/post dependency proof instead. Declining costs one reload.
470
489
  //
471
490
  // After, and not "not before". A filesystem timestamp and the instant the load
472
491
  // began are read from the same clock, whose tick is coarse on Windows, so the
@@ -480,13 +499,25 @@ func acquireRules(pluginsJSON, cwd, tsconfigPath string) (RuleResolver, error) {
480
499
  // Read first and stated second, in that order. A write landing between the two
481
500
  // moves the modification time forward and is caught; stating it first would
482
501
  // leave the window the check exists to close.
483
- func hashRuleConfigs(resolver RuleResolver, started time.Time) map[string][sha256.Size]byte {
484
- source, ok := resolver.(interface{ ConfigPaths() []string })
502
+ func hashRuleConfigs(resolver RuleResolver, started time.Time) *residentRuleConfigSnapshot {
503
+ if configCacheDisabled() {
504
+ return nil
505
+ }
506
+ source, ok := resolver.(interface {
507
+ residentRuleConfigState() residentRuleConfigState
508
+ })
485
509
  if !ok {
486
510
  return nil
487
511
  }
488
- configs := map[string][sha256.Size]byte{}
489
- for _, location := range source.ConfigPaths() {
512
+ state := source.residentRuleConfigState()
513
+ if len(state.files) == 0 && len(state.dependencies) == 0 {
514
+ return nil
515
+ }
516
+ if !configDependencyDigestsAreCurrent(state.dependencies) {
517
+ return nil
518
+ }
519
+ configs := make(map[string][sha256.Size]byte, len(state.files))
520
+ for _, location := range state.files {
490
521
  contents, err := os.ReadFile(location)
491
522
  if err != nil {
492
523
  return nil
@@ -497,17 +528,20 @@ func hashRuleConfigs(resolver RuleResolver, started time.Time) map[string][sha25
497
528
  }
498
529
  configs[location] = sha256.Sum256(contents)
499
530
  }
500
- if len(configs) == 0 {
501
- return nil
531
+ return &residentRuleConfigSnapshot{
532
+ dependencies: state.dependencies,
533
+ files: configs,
502
534
  }
503
- return configs
504
535
  }
505
536
 
506
- func ruleConfigsUnchanged(configs map[string][sha256.Size]byte) bool {
507
- if len(configs) == 0 {
537
+ func ruleConfigsUnchanged(configs *residentRuleConfigSnapshot) bool {
538
+ if configCacheDisabled() ||
539
+ configs == nil ||
540
+ (len(configs.files) == 0 && len(configs.dependencies) == 0) ||
541
+ !configDependencyDigestsAreCurrent(configs.dependencies) {
508
542
  return false
509
543
  }
510
- for location, recorded := range configs {
544
+ for location, recorded := range configs.files {
511
545
  contents, err := os.ReadFile(location)
512
546
  if err != nil || sha256.Sum256(contents) != recorded {
513
547
  return false
@@ -226,6 +226,16 @@ func (r boundProjectRuleResolver) ConfigDirectories() []string {
226
226
  return resolver.ConfigDirectories()
227
227
  }
228
228
 
229
+ func (r boundProjectRuleResolver) residentRuleConfigState() residentRuleConfigState {
230
+ resolver, ok := r.RuleResolver.(interface {
231
+ residentRuleConfigState() residentRuleConfigState
232
+ })
233
+ if !ok {
234
+ return residentRuleConfigState{}
235
+ }
236
+ return resolver.residentRuleConfigState()
237
+ }
238
+
229
239
  // ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
230
240
  // so every file receives the full map unchanged.
231
241
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
@@ -361,10 +371,12 @@ func (r InlineRuleResolver) ResolveProjectRules(names []string) (map[string]Proj
361
371
  // ConfigEntry per file, the extends-target entries declared before the
362
372
  // extending file's own entry so local rules win on collision.
363
373
  type ConfigStore struct {
364
- directories []string
365
- entries []ConfigEntry
366
- paths []string
367
- resolutionRoot string
374
+ cacheDependencies []configDependencyFingerprint
375
+ cacheFiles []string
376
+ directories []string
377
+ entries []ConfigEntry
378
+ paths []string
379
+ resolutionRoot string
368
380
  }
369
381
 
370
382
  // ConfigPaths returns the config and extends files that produced this store.
@@ -387,6 +399,26 @@ func (s *ConfigStore) ConfigDirectories() []string {
387
399
  return append([]string(nil), s.directories...)
388
400
  }
389
401
 
402
+ // residentRuleConfigState returns the private cache proof for the configuration
403
+ // that produced this store. Executable configs retain their complete dependency
404
+ // fingerprints, including package implementation files that must invalidate a
405
+ // resident answer without becoming public project-watch inputs. JSON configs
406
+ // retain only their own files because they have no executable module graph.
407
+ func (s *ConfigStore) residentRuleConfigState() residentRuleConfigState {
408
+ if s == nil {
409
+ return residentRuleConfigState{}
410
+ }
411
+ dependencies := make([]configDependencyFingerprint, len(s.cacheDependencies))
412
+ for index, dependency := range s.cacheDependencies {
413
+ dependencies[index] = dependency
414
+ dependencies[index].Realpath = cloneConfigDependencyRealpath(dependency.Realpath)
415
+ }
416
+ return residentRuleConfigState{
417
+ dependencies: dependencies,
418
+ files: append([]string(nil), s.cacheFiles...),
419
+ }
420
+ }
421
+
390
422
  // RuleOptions implements the file-agnostic RuleResolver compatibility method.
391
423
  // Engine execution does not use this representative value: ResolveRules
392
424
  // carries the matching file's options. Callers that only understand the older
@@ -840,6 +872,7 @@ func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chai
840
872
  }
841
873
  appendConfigPaths(store, evaluated.dependencies)
842
874
  appendConfigDirectories(store, evaluated.dependencyDirectories)
875
+ appendResidentConfigEvaluation(store, location, evaluated)
843
876
  if err := collectConfigObject(store, evaluated.value, filepath.Dir(location), path+".extends", extendedChain); err != nil {
844
877
  return err
845
878
  }
@@ -1102,9 +1135,30 @@ func loadConfigResolver(
1102
1135
  }
1103
1136
  appendConfigPaths(store, evaluated.dependencies)
1104
1137
  appendConfigDirectories(store, evaluated.dependencyDirectories)
1138
+ appendResidentConfigEvaluation(store, location, evaluated)
1105
1139
  return store, nil
1106
1140
  }
1107
1141
 
1142
+ func appendResidentConfigEvaluation(
1143
+ store *ConfigStore,
1144
+ location string,
1145
+ evaluated evaluatedConfigFile,
1146
+ ) {
1147
+ if evaluated.dependenciesTracked {
1148
+ for _, dependency := range evaluated.dependencyDigests {
1149
+ cloned := dependency
1150
+ cloned.Realpath = cloneConfigDependencyRealpath(dependency.Realpath)
1151
+ store.cacheDependencies = append(store.cacheDependencies, cloned)
1152
+ }
1153
+ return
1154
+ }
1155
+ location = filepath.Clean(location)
1156
+ if !containsPath(store.cacheFiles, location) {
1157
+ store.cacheFiles = append(store.cacheFiles, location)
1158
+ sort.Strings(store.cacheFiles)
1159
+ }
1160
+ }
1161
+
1108
1162
  func appendConfigPaths(store *ConfigStore, paths []string) {
1109
1163
  for _, location := range paths {
1110
1164
  location = filepath.Clean(location)
@@ -1602,17 +1656,28 @@ func configDependencyDigest(
1602
1656
  if err != nil {
1603
1657
  return "", err
1604
1658
  }
1605
- switch {
1606
- case info.Mode()&os.ModeSymlink != 0:
1659
+ target := ""
1660
+ entryPath := filepath.Join(dependency.Path, entry.Name())
1661
+ if linked, linkErr := os.Readlink(entryPath); linkErr == nil {
1662
+ // Node reports Windows junctions as symbolic links even though Go's
1663
+ // FileMode does not carry ModeSymlink for the same reparse point. The
1664
+ // loader writes Node's classification, so validation must ask the link
1665
+ // itself before falling back to FileMode or an unchanged junction will
1666
+ // make every cache lookup miss.
1607
1667
  kind = "symlink"
1608
- case info.IsDir():
1609
- kind = "directory"
1610
- case info.Mode().IsRegular():
1611
- kind = "file"
1668
+ target = linked
1669
+ } else {
1670
+ switch {
1671
+ case info.Mode()&os.ModeSymlink != 0:
1672
+ kind = "symlink"
1673
+ case info.IsDir():
1674
+ kind = "directory"
1675
+ case info.Mode().IsRegular():
1676
+ kind = "file"
1677
+ }
1612
1678
  }
1613
- target := ""
1614
- if kind == "symlink" {
1615
- target, err = os.Readlink(filepath.Join(dependency.Path, entry.Name()))
1679
+ if kind == "symlink" && target == "" {
1680
+ target, err = os.Readlink(entryPath)
1616
1681
  if err != nil {
1617
1682
  target = "<unreadable>"
1618
1683
  }
@@ -1633,6 +1698,14 @@ func configDependencyDigest(
1633
1698
  // script writes the fingerprint and this function is what later decides the
1634
1699
  // cached evaluation is still current.
1635
1700
  if dependency.Kind == configDependencyEntry {
1701
+ if target, err := os.Readlink(dependency.Path); err == nil {
1702
+ // Keep the entry form in the same Windows-junction vocabulary as the
1703
+ // directory form above and the Node loader that produced the digest.
1704
+ h := sha256.New()
1705
+ h.Write([]byte("symlink\x00"))
1706
+ h.Write([]byte(target))
1707
+ return hex.EncodeToString(h.Sum(nil)), nil
1708
+ }
1636
1709
  info, err := os.Lstat(dependency.Path)
1637
1710
  if err != nil {
1638
1711
  digest := sha256.Sum256([]byte("missing\x00"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "Reference ttsc plugin: ESLint-style lint rules over the TypeScript-Go Program used by the type-check pass.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "@types/node": "^25.3.0",
38
38
  "rimraf": "^6.1.2",
39
39
  "typescript": "^7.0.2",
40
- "ttsc": "0.28.0"
40
+ "ttsc": "0.28.2"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",