@ttsc/lint 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1182,6 +1182,36 @@ func init() { rule.RegisterProject(noCycles{}) }
1182
1182
 
1183
1183
  Each project rule runs once per loaded Program, before file rules. `ctx.Identity` includes the invocation cwd, logical and physical config paths and roots, an optional explicit project root, the plugin-config origin, and a lifecycle id. `Report` marks the rule failed and emits one project finding; `Fail` marks it failed without a finding. Later file rules can call `ctx.ProjectResult(name)` and distinguish `absent`, `off`, `not_evaluated`, `passed`, and `failed`.
1184
1184
 
1185
+ Use `ctx.SetState(value)` when a later file rule needs the exact project binding selected during that check. The host returns the same value without interpreting or serializing it:
1186
+
1187
+ ```go
1188
+ type projectBinding struct { /* contributor-owned fields */ }
1189
+
1190
+ func (projectGuard) Check(ctx *rule.ProjectContext) {
1191
+ ctx.SetState(loadProjectBinding(ctx.Identity))
1192
+ }
1193
+
1194
+ func (guardedFileRule) Check(ctx *rule.Context, node *ast.Node) {
1195
+ result := ctx.ProjectResult("demo/project-guard")
1196
+ if result.Status != rule.ProjectRulePassed {
1197
+ return
1198
+ }
1199
+ binding, ok := result.State.(*projectBinding)
1200
+ if !ok {
1201
+ return
1202
+ }
1203
+ if err := binding.Revalidate(); err != nil {
1204
+ result.Report(err.Error())
1205
+ return
1206
+ }
1207
+ useGuardedResource(binding, node)
1208
+ }
1209
+ ```
1210
+
1211
+ `ProjectResult` is a snapshot of the current status and findings, while its `Report` and `Fail` methods remain live until file dispatch finishes. Call `ctx.ProjectResult(name)` again to observe a failure reported by an earlier helper. Equal messages are deduplicated, distinct messages are sorted, and finalized project findings stay ahead of file findings. `absent`, `off`, and `not_evaluated` results expose no state and their mutation methods do nothing.
1212
+
1213
+ State belongs to one loaded Program cycle. The host does not carry it into a watch or LSP rebuild: the new project check attaches that cycle's value and receives a new reporter. A reporter retained past file dispatch is inert. The host synchronizes status changes and finding deduplication across concurrent files. The contributor must create any required fresh state and synchronize mutable data inside its own value.
1214
+
1185
1215
  Project rules use the normal `rules` map and `extends` order, but only global config entries may configure them. Any entry that contains `files`, including `files: []` or an `off` value, is rejected. Global `ignores` remain source-file filters rather than project-rule selectors. A later bare severity preserves the last explicit tuple options while replacing severity.
1186
1216
 
1187
1217
  CLI, API, watch, and LSP runs carry the same project identity into the native host. Structured API findings use `file: null`. LSP publishes project findings once at the logical config URI with a zero range and no document version; project findings never provide fixes or code actions.
@@ -694,7 +694,7 @@ func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker)
694
694
  currentDirectory, _ = os.Getwd()
695
695
  }
696
696
  fileFindings := e.runFiles(files, checker, cycle.results, currentDirectory)
697
- return append(cycle.findings, fileFindings...)
697
+ return append(cycle.finalize(), fileFindings...)
698
698
  }
699
699
 
700
700
  func (e *Engine) runFiles(
package/linthost/host.go CHANGED
@@ -263,11 +263,10 @@ func (p *program) runLintCycle(engine *Engine) []*Finding {
263
263
  }
264
264
  files := p.userSourceFiles()
265
265
  if p.projectCycle == nil {
266
- cycle := engine.evaluateProject(p.identity, files, p.checker)
267
- p.projectCycle = &cycle
266
+ p.projectCycle = engine.evaluateProject(p.identity, files, p.checker)
268
267
  }
269
- projectFindings := append([]*Finding(nil), p.projectCycle.findings...)
270
- return append(projectFindings, engine.runFiles(files, p.checker, p.projectCycle.results, p.cwd)...)
268
+ fileFindings := engine.runFiles(files, p.checker, p.projectCycle.results, p.cwd)
269
+ return append(p.projectCycle.finalize(), fileFindings...)
271
270
  }
272
271
 
273
272
  // close drops the standalone lint checker. Safe to call on a nil receiver and
@@ -3,6 +3,7 @@ package linthost
3
3
  import (
4
4
  "fmt"
5
5
  "sort"
6
+ "sync"
6
7
 
7
8
  shimast "github.com/microsoft/typescript-go/shim/ast"
8
9
  shimchecker "github.com/microsoft/typescript-go/shim/checker"
@@ -10,7 +11,13 @@ import (
10
11
  )
11
12
 
12
13
  type projectCycleResults struct {
13
- byName map[string]publicrule.ProjectRuleResult
14
+ byName map[string]projectCycleResult
15
+ }
16
+
17
+ type projectCycleResult struct {
18
+ status publicrule.ProjectRuleStatus
19
+ severity Severity
20
+ reporter *projectReporter
14
21
  }
15
22
 
16
23
  func (r *projectCycleResults) ProjectResult(name string) publicrule.ProjectRuleResult {
@@ -21,22 +28,33 @@ func (r *projectCycleResults) ProjectResult(name string) publicrule.ProjectRuleR
21
28
  if !ok {
22
29
  return publicrule.ProjectRuleResult{Status: publicrule.ProjectRuleAbsent}
23
30
  }
24
- result.Findings = append([]publicrule.ProjectFinding(nil), result.Findings...)
25
- return result
31
+ if result.reporter == nil {
32
+ return publicrule.ProjectRuleResult{Status: result.status}
33
+ }
34
+ return result.reporter.snapshot()
26
35
  }
27
36
 
28
37
  type projectCycle struct {
29
- findings []*Finding
30
- results *projectCycleResults
38
+ finalizeOnce sync.Once
39
+ findings []*Finding
40
+ results *projectCycleResults
31
41
  }
32
42
 
33
43
  type projectReporter struct {
44
+ mu sync.Mutex
45
+ active bool
34
46
  failed bool
35
47
  messages map[string]struct{}
48
+ state any
36
49
  }
37
50
 
38
51
  func (r *projectReporter) Fail() {
39
- if r != nil {
52
+ if r == nil {
53
+ return
54
+ }
55
+ r.mu.Lock()
56
+ defer r.mu.Unlock()
57
+ if r.active {
40
58
  r.failed = true
41
59
  }
42
60
  }
@@ -45,6 +63,11 @@ func (r *projectReporter) Report(message string) {
45
63
  if r == nil {
46
64
  return
47
65
  }
66
+ r.mu.Lock()
67
+ defer r.mu.Unlock()
68
+ if !r.active {
69
+ return
70
+ }
48
71
  r.failed = true
49
72
  if r.messages == nil {
50
73
  r.messages = map[string]struct{}{}
@@ -52,13 +75,85 @@ func (r *projectReporter) Report(message string) {
52
75
  r.messages[message] = struct{}{}
53
76
  }
54
77
 
78
+ func (r *projectReporter) SetState(state any) {
79
+ if r == nil {
80
+ return
81
+ }
82
+ r.mu.Lock()
83
+ defer r.mu.Unlock()
84
+ if r.active {
85
+ r.state = state
86
+ }
87
+ }
88
+
89
+ func (r *projectReporter) snapshot() publicrule.ProjectRuleResult {
90
+ return r.snapshotLocked(false)
91
+ }
92
+
93
+ func (r *projectReporter) snapshotAndClose() publicrule.ProjectRuleResult {
94
+ return r.snapshotLocked(true)
95
+ }
96
+
97
+ func (r *projectReporter) snapshotLocked(close bool) publicrule.ProjectRuleResult {
98
+ if r == nil {
99
+ return publicrule.ProjectRuleResult{Status: publicrule.ProjectRuleAbsent}
100
+ }
101
+ r.mu.Lock()
102
+ defer r.mu.Unlock()
103
+ if close {
104
+ r.active = false
105
+ }
106
+ messages := make([]string, 0, len(r.messages))
107
+ for message := range r.messages {
108
+ messages = append(messages, message)
109
+ }
110
+ sort.Strings(messages)
111
+ status := publicrule.ProjectRulePassed
112
+ if r.failed {
113
+ status = publicrule.ProjectRuleFailed
114
+ }
115
+ findings := make([]publicrule.ProjectFinding, 0, len(messages))
116
+ for _, message := range messages {
117
+ findings = append(findings, publicrule.ProjectFinding{Message: message})
118
+ }
119
+ return publicrule.NewProjectRuleResult(status, r.state, findings, r)
120
+ }
121
+
122
+ func (c *projectCycle) finalize() []*Finding {
123
+ if c == nil || c.results == nil {
124
+ return nil
125
+ }
126
+ c.finalizeOnce.Do(func() {
127
+ names := make([]string, 0, len(c.results.byName))
128
+ for name := range c.results.byName {
129
+ names = append(names, name)
130
+ }
131
+ sort.Strings(names)
132
+ for _, name := range names {
133
+ entry := c.results.byName[name]
134
+ if entry.reporter == nil {
135
+ continue
136
+ }
137
+ result := entry.reporter.snapshotAndClose()
138
+ for _, finding := range result.Findings {
139
+ c.findings = append(c.findings, &Finding{
140
+ Rule: name,
141
+ Severity: entry.severity,
142
+ Message: finding.Message,
143
+ })
144
+ }
145
+ }
146
+ })
147
+ return append([]*Finding(nil), c.findings...)
148
+ }
149
+
55
150
  func (e *Engine) evaluateProject(
56
151
  identity publicrule.ProjectIdentity,
57
152
  files []*shimast.SourceFile,
58
153
  checker *shimchecker.Checker,
59
- ) projectCycle {
60
- results := &projectCycleResults{byName: map[string]publicrule.ProjectRuleResult{}}
61
- cycle := projectCycle{results: results}
154
+ ) *projectCycle {
155
+ results := &projectCycleResults{byName: map[string]projectCycleResult{}}
156
+ cycle := &projectCycle{results: results}
62
157
  names := allProjectRuleNames()
63
158
  if len(names) == 0 {
64
159
  return cycle
@@ -68,11 +163,11 @@ func (e *Engine) evaluateProject(
68
163
  for _, name := range names {
69
164
  setting := e.projectSettings[name]
70
165
  if !setting.Declared {
71
- results.byName[name] = publicrule.ProjectRuleResult{Status: publicrule.ProjectRuleNotEvaluated}
166
+ results.byName[name] = projectCycleResult{status: publicrule.ProjectRuleNotEvaluated}
72
167
  continue
73
168
  }
74
169
  if setting.Severity == SeverityOff {
75
- results.byName[name] = publicrule.ProjectRuleResult{Status: publicrule.ProjectRuleOff}
170
+ results.byName[name] = projectCycleResult{status: publicrule.ProjectRuleOff}
76
171
  continue
77
172
  }
78
173
  adapter, exists := registeredProjectRules[name]
@@ -83,7 +178,7 @@ func (e *Engine) evaluateProject(
83
178
  sources = e.projectSources(files)
84
179
  sourcesResolved = true
85
180
  }
86
- reporter := &projectReporter{}
181
+ reporter := &projectReporter{active: true}
87
182
  context := publicrule.NewProjectContext(
88
183
  identity,
89
184
  sources,
@@ -93,25 +188,10 @@ func (e *Engine) evaluateProject(
93
188
  reporter,
94
189
  )
95
190
  runProjectRuleCheck(adapter, context, reporter)
96
- messages := make([]string, 0, len(reporter.messages))
97
- for message := range reporter.messages {
98
- messages = append(messages, message)
99
- }
100
- sort.Strings(messages)
101
- status := publicrule.ProjectRulePassed
102
- if reporter.failed {
103
- status = publicrule.ProjectRuleFailed
104
- }
105
- result := publicrule.ProjectRuleResult{Status: status}
106
- for _, message := range messages {
107
- result.Findings = append(result.Findings, publicrule.ProjectFinding{Message: message})
108
- cycle.findings = append(cycle.findings, &Finding{
109
- Rule: name,
110
- Severity: setting.Severity,
111
- Message: message,
112
- })
191
+ results.byName[name] = projectCycleResult{
192
+ severity: setting.Severity,
193
+ reporter: reporter,
113
194
  }
114
- results.byName[name] = result
115
195
  }
116
196
  return cycle
117
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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.19.0"
40
+ "ttsc": "0.19.1"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
package/rule/project.go CHANGED
@@ -39,16 +39,59 @@ type ProjectFinding struct {
39
39
  Message string
40
40
  }
41
41
 
42
- // ProjectRuleResult is the finalized, read-only view of one named project
43
- // rule. Findings is returned as a defensive copy by host result readers.
42
+ // ProjectRuleResult is one snapshot of a named project rule in the current
43
+ // Program cycle. State is the contributor-owned value attached during the
44
+ // project check; the host neither interprets nor synchronizes its contents.
45
+ // Findings is returned as a defensive copy by host result readers.
46
+ //
47
+ // Evaluated results retain a cycle-scoped failure channel through file-rule
48
+ // dispatch. Call Report or Fail immediately before a guarded operation, then
49
+ // call Context.ProjectResult again when the updated status is needed. Absent,
50
+ // off, and not-evaluated results have no state or live failure channel.
44
51
  type ProjectRuleResult struct {
45
52
  Status ProjectRuleStatus
53
+ State any
46
54
  Findings []ProjectFinding
55
+
56
+ reporter ProjectReporter
47
57
  }
48
58
 
49
- // ProjectResultReader supplies finalized project state to later file-rule
50
- // contexts. Hosts return ProjectRuleAbsent for names with no registered
51
- // project rule.
59
+ // NewProjectRuleResult constructs one host-owned project-result snapshot.
60
+ // Contributor code normally receives this value from Context.ProjectResult
61
+ // and does not construct it.
62
+ func NewProjectRuleResult(
63
+ status ProjectRuleStatus,
64
+ state any,
65
+ findings []ProjectFinding,
66
+ reporter ProjectReporter,
67
+ ) ProjectRuleResult {
68
+ return ProjectRuleResult{
69
+ Status: status,
70
+ State: state,
71
+ Findings: append([]ProjectFinding(nil), findings...),
72
+ reporter: reporter,
73
+ }
74
+ }
75
+
76
+ // Fail marks this evaluated project result failed without adding a finding.
77
+ // It is a no-op after file dispatch or for a result that was not evaluated.
78
+ func (r ProjectRuleResult) Fail() {
79
+ if r.reporter != nil {
80
+ r.reporter.Fail()
81
+ }
82
+ }
83
+
84
+ // Report records one project finding and marks this evaluated result failed.
85
+ // Equal messages are deduplicated by the host. It is a no-op after file
86
+ // dispatch or for a result that was not evaluated.
87
+ func (r ProjectRuleResult) Report(message string) {
88
+ if r.reporter != nil {
89
+ r.reporter.Report(message)
90
+ }
91
+ }
92
+
93
+ // ProjectResultReader supplies live project state to later file-rule contexts.
94
+ // Hosts return ProjectRuleAbsent for names with no registered project rule.
52
95
  type ProjectResultReader interface {
53
96
  ProjectResult(name string) ProjectRuleResult
54
97
  }
@@ -77,7 +120,12 @@ type ProjectContext struct {
77
120
  Severity Severity
78
121
  Options json.RawMessage
79
122
 
80
- reporter ProjectReporter
123
+ reporter ProjectReporter
124
+ stateSetter projectStateSetter
125
+ }
126
+
127
+ type projectStateSetter interface {
128
+ SetState(state any)
81
129
  }
82
130
 
83
131
  // NewProjectContext constructs the context a host passes to ProjectRule.Check.
@@ -91,13 +139,15 @@ func NewProjectContext(
91
139
  reporter ProjectReporter,
92
140
  ) *ProjectContext {
93
141
  copiedSources := append([]*shimast.SourceFile(nil), sources...)
142
+ stateSetter, _ := reporter.(projectStateSetter)
94
143
  return &ProjectContext{
95
- Identity: identity,
96
- Sources: copiedSources,
97
- Checker: checker,
98
- Severity: severity,
99
- Options: append(json.RawMessage(nil), options...),
100
- reporter: reporter,
144
+ Identity: identity,
145
+ Sources: copiedSources,
146
+ Checker: checker,
147
+ Severity: severity,
148
+ Options: append(json.RawMessage(nil), options...),
149
+ reporter: reporter,
150
+ stateSetter: stateSetter,
101
151
  }
102
152
  }
103
153
 
@@ -110,6 +160,17 @@ func (c *ProjectContext) DecodeOptions(out interface{}) error {
110
160
  return json.Unmarshal(c.Options, out)
111
161
  }
112
162
 
163
+ // SetState attaches one contributor-owned value to this rule's evaluated
164
+ // result. The exact value is returned to file rules in the same Program cycle;
165
+ // contributors own any synchronization needed inside it. The host does not
166
+ // serialize the value or retain it for a later watch or LSP rebuild.
167
+ func (c *ProjectContext) SetState(state any) {
168
+ if c == nil || c.stateSetter == nil || c.Severity == SeverityOff {
169
+ return
170
+ }
171
+ c.stateSetter.SetState(state)
172
+ }
173
+
113
174
  // Fail marks the current project rule failed without adding a diagnostic.
114
175
  func (c *ProjectContext) Fail() {
115
176
  if c == nil || c.reporter == nil || c.Severity == SeverityOff {
package/rule/rule.go CHANGED
@@ -240,8 +240,8 @@ func NewContext(
240
240
  return NewContextWithProjectResults(file, checker, severity, options, reporter, nil)
241
241
  }
242
242
 
243
- // NewContextWithProjectResults constructs a file-rule Context with the
244
- // finalized project state for the same loaded Program cycle.
243
+ // NewContextWithProjectResults constructs a file-rule Context with the live
244
+ // project results for the same loaded Program cycle.
245
245
  func NewContextWithProjectResults(
246
246
  file *shimast.SourceFile,
247
247
  checker *shimchecker.Checker,
@@ -260,7 +260,7 @@ func NewContextWithProjectResults(
260
260
  }
261
261
  }
262
262
 
263
- // ProjectResult returns the finalized state for a named project rule in this
263
+ // ProjectResult returns a current snapshot for a named project rule in this
264
264
  // file's Program cycle. Missing registrations return ProjectRuleAbsent.
265
265
  func (c *Context) ProjectResult(name string) ProjectRuleResult {
266
266
  if c == nil || c.results == nil {