@ttsc/lint 0.20.1 → 0.22.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.
@@ -76,7 +76,7 @@ func run(args []string) int {
76
76
  // Don't pay contributor-registration cost for the version banner.
77
77
  fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
78
78
  return 0
79
- case "check", "fix", "format", "build", "transform", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints", "lsp-serve":
79
+ case "check", "check-serve", "fix", "format", "build", "transform", "project-inputs", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-project-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints", "lsp-serve":
80
80
  default:
81
81
  fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
82
82
  return 2
@@ -87,6 +87,8 @@ func run(args []string) int {
87
87
  switch args[0] {
88
88
  case "check":
89
89
  return RunCheck(args[1:])
90
+ case "check-serve":
91
+ return RunCheckServe(os.Stdin, os.Stdout, args[1:])
90
92
  case "fix":
91
93
  return RunFix(args[1:])
92
94
  case "format":
@@ -95,12 +97,16 @@ func run(args []string) int {
95
97
  return RunBuild(args[1:])
96
98
  case "transform":
97
99
  return RunTransform(args[1:])
100
+ case "project-inputs":
101
+ return RunProjectInputs(args[1:])
98
102
  case "lsp-command-ids":
99
103
  return RunLSPCommandIDs(args[1:])
100
104
  case "lsp-code-action-kinds":
101
105
  return RunLSPCodeActionKinds(args[1:])
102
106
  case "lsp-diagnostics":
103
107
  return RunLSPDiagnostics(args[1:])
108
+ case "lsp-project-diagnostics":
109
+ return RunLSPProjectDiagnostics(args[1:])
104
110
  case "lsp-code-actions":
105
111
  return RunLSPCodeActions(args[1:])
106
112
  case "lsp-execute-command":
package/linthost/host.go CHANGED
@@ -322,10 +322,11 @@ func (p *program) sourceFileByPath(absPath string) *shimast.SourceFile {
322
322
  // confirmed absPath is a known source file; a config edit or a new/removed file
323
323
  // is handled by a full reload upstream, not here. tsgo returns a rebuilt Program
324
324
  // when the edit reshaped the import graph, and that rebuilt Program is still
325
- // correct, so the reused flag is not consulted.
326
- func (p *program) applyChange(absPath string) {
325
+ // correct, but callers use the reused flag to distinguish incremental updates
326
+ // from full Program reconstruction in product telemetry.
327
+ func (p *program) applyChange(absPath string) bool {
327
328
  if p == nil || p.tsProgram == nil {
328
- return
329
+ return false
329
330
  }
330
331
  name := absPath
331
332
  if file := p.sourceFileByPath(absPath); file != nil {
@@ -334,7 +335,7 @@ func (p *program) applyChange(absPath string) {
334
335
  fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
335
336
  host := shimcompiler.NewCompilerHost(p.cwd, fs, bundled.LibPath(), nil, nil)
336
337
  changed := shimtspath.ToPath(name, p.cwd, fs.UseCaseSensitiveFileNames())
337
- newProg, _ := p.tsProgram.UpdateProgram(changed, host, nil)
338
+ newProg, reused := p.tsProgram.UpdateProgram(changed, host, nil)
338
339
  if newProg != nil {
339
340
  p.tsProgram = newProg
340
341
  if p.checker != nil {
@@ -344,6 +345,7 @@ func (p *program) applyChange(absPath string) {
344
345
  // The prior cycle described the pre-edit Program; drop it so the next verb
345
346
  // re-evaluates its rules over the updated ASTs.
346
347
  p.projectCycle = nil
348
+ return reused
347
349
  }
348
350
 
349
351
  // userSourceFiles returns the tsconfig-selected source files the lint engine
package/linthost/lsp.go CHANGED
@@ -189,6 +189,61 @@ func RunLSPDiagnostics(args []string) int {
189
189
  return writeJSON(result)
190
190
  }
191
191
 
192
+ // RunLSPProjectDiagnostics prints the current project-rule publication without
193
+ // requiring an open TypeScript document.
194
+ func RunLSPProjectDiagnostics(args []string) int {
195
+ opts, ok := parseLSPCommandOptions("lsp-project-diagnostics", args)
196
+ if !ok {
197
+ return 2
198
+ }
199
+ result, code := computeLSPProjectDiagnostics(opts)
200
+ if code != 0 {
201
+ return code
202
+ }
203
+ return writeJSON(result)
204
+ }
205
+
206
+ // computeLSPProjectDiagnostics evaluates only project rules and returns an
207
+ // empty publication when they are disabled, allowing the proxy to clear the
208
+ // previous generation. A project that does not parse has no publication at
209
+ // all: acquireProgram returns no Program alongside its parse diagnostics, so
210
+ // the command prints null and the host keeps the producer's last good answer
211
+ // instead of clearing it with a broken evaluation.
212
+ func computeLSPProjectDiagnostics(opts *lspCommandOptions) (*lspProjectDiagnostics, int) {
213
+ rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
214
+ if err != nil {
215
+ fmt.Fprintln(os.Stderr, err)
216
+ return nil, 2
217
+ }
218
+ engine := NewEngineWithResolver(rules)
219
+ if err := engine.ConfigError(); err != nil {
220
+ fmt.Fprintln(os.Stderr, err)
221
+ return nil, 2
222
+ }
223
+ prog, _, closeProgram, err := acquireProgram(opts, engine.NeedsTypeChecker())
224
+ if closeProgram != nil {
225
+ defer closeProgram()
226
+ }
227
+ if err != nil {
228
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
229
+ return nil, 2
230
+ }
231
+ if prog == nil {
232
+ return nil, 0
233
+ }
234
+ publication := &lspProjectDiagnostics{
235
+ URI: fileURL(prog.identity.LogicalConfigPath),
236
+ Diagnostics: []lspDiagnostic{},
237
+ }
238
+ for _, finding := range prog.runProjectCycle(engine).finalize() {
239
+ publication.Diagnostics = append(
240
+ publication.Diagnostics,
241
+ findingToLSPDiagnostic(finding),
242
+ )
243
+ }
244
+ return publication, 0
245
+ }
246
+
192
247
  // computeLSPDiagnostics builds the diagnostics result for one file URI. Split
193
248
  // from RunLSPDiagnostics so the resident lsp-serve loop can produce the same
194
249
  // result against a warm Program without re-parsing per verb.
@@ -0,0 +1,241 @@
1
+ package linthost
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "net/url"
7
+ "os"
8
+ slashpath "path"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
+
13
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
14
+ )
15
+
16
+ // ProjectInputSnapshot is the normalized filesystem dependency publication
17
+ // shared by the CLI launcher and ttscserver.
18
+ type ProjectInputSnapshot struct {
19
+ Root string `json:"root"`
20
+ Files []string `json:"files"`
21
+ Globs []string `json:"globs"`
22
+ ReloadFiles []string `json:"reloadFiles,omitempty"`
23
+ ReloadDirectories []string `json:"reloadDirectories,omitempty"`
24
+ }
25
+
26
+ // RunProjectInputs prints the enabled ProjectRule dependency snapshot without
27
+ // loading a TypeScript Program.
28
+ func RunProjectInputs(args []string) int {
29
+ opts, ok := parseLSPCommandOptions("project-inputs", args)
30
+ if !ok {
31
+ return 2
32
+ }
33
+ resolver, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
34
+ if err != nil {
35
+ fmt.Fprintln(os.Stderr, err)
36
+ return 2
37
+ }
38
+ identity := normalizeProjectIdentity(
39
+ opts.projectIdentity,
40
+ opts.cwd,
41
+ opts.tsconfig,
42
+ )
43
+ snapshot, err := collectProjectInputs(resolver, identity)
44
+ if err != nil {
45
+ fmt.Fprintln(os.Stderr, err)
46
+ return 2
47
+ }
48
+ return writeJSON(snapshot)
49
+ }
50
+
51
+ func collectProjectInputs(
52
+ resolver RuleResolver,
53
+ identity publicrule.ProjectIdentity,
54
+ ) (ProjectInputSnapshot, error) {
55
+ engine := NewEngineWithResolver(resolver)
56
+ if err := engine.ConfigError(); err != nil {
57
+ return ProjectInputSnapshot{}, err
58
+ }
59
+ root := identity.PhysicalProjectRoot
60
+ if root == "" {
61
+ root = identity.LogicalProjectRoot
62
+ }
63
+ if root == "" {
64
+ root = identity.InvocationCwd
65
+ }
66
+ if root == "" {
67
+ return ProjectInputSnapshot{}, errors.New("@ttsc/lint: project inputs require a project root")
68
+ }
69
+ root = realProjectPath(root)
70
+ snapshot := ProjectInputSnapshot{Root: filepath.ToSlash(root)}
71
+ if source, ok := resolver.(interface{ ConfigPaths() []string }); ok {
72
+ for _, location := range source.ConfigPaths() {
73
+ normalized := filepath.ToSlash(realProjectPath(location))
74
+ // Keep configs in Files for older/LSP consumers that do not decode
75
+ // ReloadFiles yet, while CLI watch can classify the same path as an
76
+ // execution-selection transition.
77
+ snapshot.Files = append(snapshot.Files, normalized)
78
+ snapshot.ReloadFiles = append(snapshot.ReloadFiles, normalized)
79
+ }
80
+ }
81
+ if source, ok := resolver.(interface{ ConfigDirectories() []string }); ok {
82
+ for _, location := range source.ConfigDirectories() {
83
+ normalized := filepath.ToSlash(realProjectPath(location))
84
+ snapshot.ReloadDirectories = append(
85
+ snapshot.ReloadDirectories,
86
+ normalized,
87
+ )
88
+ }
89
+ }
90
+ var joined error
91
+ for _, name := range allProjectRuleNames() {
92
+ setting := engine.projectSettings[name]
93
+ if !setting.Declared || setting.Severity == SeverityOff {
94
+ continue
95
+ }
96
+ adapter := registeredProjectRules[name]
97
+ publisher, ok := adapter.inner.(publicrule.ProjectInputRule)
98
+ if !ok {
99
+ continue
100
+ }
101
+ inputs, err := callProjectInputs(
102
+ publisher,
103
+ publicrule.NewProjectInputContext(
104
+ identity,
105
+ publicrule.Severity(setting.Severity),
106
+ setting.Options,
107
+ ),
108
+ )
109
+ if err != nil {
110
+ joined = errors.Join(joined, fmt.Errorf("project rule %q inputs: %w", name, err))
111
+ continue
112
+ }
113
+ for _, input := range inputs {
114
+ normalized, err := normalizeProjectInput(root, input)
115
+ if err != nil {
116
+ joined = errors.Join(joined, fmt.Errorf("project rule %q input: %w", name, err))
117
+ continue
118
+ }
119
+ switch input.Kind {
120
+ case publicrule.ProjectInputFile:
121
+ snapshot.Files = append(snapshot.Files, normalized)
122
+ case publicrule.ProjectInputGlob:
123
+ snapshot.Globs = append(snapshot.Globs, normalized)
124
+ }
125
+ }
126
+ }
127
+ snapshot.Files = uniqueProjectInputPatterns(snapshot.Files)
128
+ snapshot.Globs = uniqueProjectInputPatterns(snapshot.Globs)
129
+ snapshot.ReloadFiles = uniqueProjectInputPatterns(snapshot.ReloadFiles)
130
+ snapshot.ReloadDirectories = uniqueProjectInputPatterns(
131
+ snapshot.ReloadDirectories,
132
+ )
133
+ if joined != nil {
134
+ return ProjectInputSnapshot{}, joined
135
+ }
136
+ return snapshot, nil
137
+ }
138
+
139
+ func callProjectInputs(
140
+ publisher publicrule.ProjectInputRule,
141
+ context *publicrule.ProjectInputContext,
142
+ ) (inputs []publicrule.ProjectInput, err error) {
143
+ defer func() {
144
+ if recovered := recover(); recovered != nil {
145
+ err = fmt.Errorf("panicked while declaring inputs: %v", recovered)
146
+ }
147
+ }()
148
+ return append([]publicrule.ProjectInput(nil), publisher.ProjectInputs(context)...), nil
149
+ }
150
+
151
+ func normalizeProjectInput(root string, input publicrule.ProjectInput) (string, error) {
152
+ if input.Kind != publicrule.ProjectInputFile && input.Kind != publicrule.ProjectInputGlob {
153
+ return "", fmt.Errorf("kind %q is not file or glob", input.Kind)
154
+ }
155
+ pattern := strings.TrimSpace(input.Pattern)
156
+ if pattern == "" {
157
+ return "", errors.New("pattern must not be empty")
158
+ }
159
+ if parsed, err := url.Parse(pattern); err == nil &&
160
+ (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
161
+ return "", fmt.Errorf("remote URL %q is not a filesystem dependency", pattern)
162
+ }
163
+ pattern = filepath.FromSlash(pattern)
164
+ if !filepath.IsAbs(pattern) {
165
+ pattern = filepath.Join(root, pattern)
166
+ }
167
+ if input.Kind == publicrule.ProjectInputFile {
168
+ return filepath.ToSlash(realProjectPath(pattern)), nil
169
+ }
170
+ return filepath.ToSlash(realProjectGlob(pattern)), nil
171
+ }
172
+
173
+ func realProjectGlob(pattern string) string {
174
+ clean := filepath.Clean(pattern)
175
+ volume := filepath.VolumeName(clean)
176
+ remainder := strings.TrimPrefix(clean, volume)
177
+ segments := strings.FieldsFunc(remainder, func(r rune) bool {
178
+ return r == '/' || r == '\\'
179
+ })
180
+ prefixCount := 0
181
+ for prefixCount < len(segments) && !strings.ContainsAny(segments[prefixCount], "*?") {
182
+ prefixCount++
183
+ }
184
+ prefix := volume + string(filepath.Separator)
185
+ if prefixCount > 0 {
186
+ prefix = filepath.Join(prefix, filepath.Join(segments[:prefixCount]...))
187
+ }
188
+ resolved := realProjectPath(prefix)
189
+ if prefixCount == len(segments) {
190
+ return resolved
191
+ }
192
+ return filepath.Join(resolved, filepath.Join(segments[prefixCount:]...))
193
+ }
194
+
195
+ func uniqueProjectInputPatterns(patterns []string) []string {
196
+ return uniqueProjectInputPatternsForFilesystem(
197
+ patterns,
198
+ isCaseInsensitiveFilesystem(),
199
+ )
200
+ }
201
+
202
+ func uniqueProjectInputPatternsForFilesystem(
203
+ patterns []string,
204
+ caseInsensitive bool,
205
+ ) []string {
206
+ seen := map[string]string{}
207
+ for _, pattern := range patterns {
208
+ normalized := normalizeProjectInputPattern(pattern, caseInsensitive)
209
+ key := normalized
210
+ if caseInsensitive {
211
+ key = strings.ToLower(key)
212
+ }
213
+ seen[key] = normalized
214
+ }
215
+ out := make([]string, 0, len(seen))
216
+ for _, pattern := range seen {
217
+ out = append(out, pattern)
218
+ }
219
+ sort.Strings(out)
220
+ return out
221
+ }
222
+
223
+ func normalizeProjectInputPattern(
224
+ pattern string,
225
+ caseInsensitive bool,
226
+ ) string {
227
+ if !caseInsensitive {
228
+ return filepath.ToSlash(filepath.Clean(filepath.FromSlash(pattern)))
229
+ }
230
+ slashed := strings.ReplaceAll(pattern, "\\", "/")
231
+ unc := strings.HasPrefix(slashed, "//")
232
+ normalized := slashpath.Clean(slashed)
233
+ if unc && !strings.HasPrefix(normalized, "//") {
234
+ normalized = "/" + normalized
235
+ }
236
+ return normalized
237
+ }
238
+
239
+ func isCaseInsensitiveFilesystem() bool {
240
+ return filepath.Separator == '\\'
241
+ }
package/linthost/serve.go CHANGED
@@ -126,7 +126,10 @@ func noopClose() {}
126
126
  // or drops an entry for a full reload when a changed path is not one of its
127
127
  // source files — a config edit, or a new or removed file, which tsgo's
128
128
  // per-file UpdateProgram cannot express and a fresh load handles correctly.
129
- func (c *residentProgramCache) applyChanges(paths []string) {
129
+ func (c *residentProgramCache) applyChanges(
130
+ paths []string,
131
+ external map[string]struct{},
132
+ ) {
130
133
  if len(paths) == 0 {
131
134
  return
132
135
  }
@@ -136,6 +139,9 @@ func (c *residentProgramCache) applyChanges(paths []string) {
136
139
  fullReload := false
137
140
  for _, path := range paths {
138
141
  if prog.sourceFileByPath(path) == nil {
142
+ if _, ok := external[canonicalProjectPath("", realProjectPath(path))]; ok {
143
+ continue
144
+ }
139
145
  fullReload = true
140
146
  break
141
147
  }
@@ -146,7 +152,9 @@ func (c *residentProgramCache) applyChanges(paths []string) {
146
152
  continue
147
153
  }
148
154
  for _, path := range paths {
149
- prog.applyChange(path)
155
+ if prog.sourceFileByPath(path) != nil {
156
+ prog.applyChange(path)
157
+ }
150
158
  }
151
159
  }
152
160
  }
@@ -193,6 +201,10 @@ type serveLSPRequest struct {
193
201
  // Program does not already hold as a source (a config edit, a new or removed
194
202
  // file) drops the entry for a full reload instead.
195
203
  Changed []string `json:"changed,omitempty"`
204
+ // External marks changed URIs that belong to declared ProjectRule inputs.
205
+ // Unknown external files invalidate fresh rule state but not the TypeScript
206
+ // Program; a path that is also a source file still updates incrementally.
207
+ External []string `json:"external,omitempty"`
196
208
  }
197
209
 
198
210
  // serveLSPResponse is the reply to one request: the verb's JSON result verbatim
@@ -268,12 +280,18 @@ func handleServeLSPLine(line string, base *lspCommandOptions, encoder *json.Enco
268
280
  }
269
281
  if len(req.Changed) > 0 {
270
282
  paths := make([]string, 0, len(req.Changed))
283
+ external := make(map[string]struct{}, len(req.External))
284
+ for _, uri := range req.External {
285
+ if path, err := filePathFromURI(uri); err == nil {
286
+ external[canonicalProjectPath("", realProjectPath(path))] = struct{}{}
287
+ }
288
+ }
271
289
  for _, uri := range req.Changed {
272
290
  if path, err := filePathFromURI(uri); err == nil {
273
291
  paths = append(paths, path)
274
292
  }
275
293
  }
276
- residentPrograms.applyChanges(paths)
294
+ residentPrograms.applyChanges(paths, external)
277
295
  }
278
296
  opts := *base
279
297
  opts.uri = req.URI
@@ -283,6 +301,9 @@ func handleServeLSPLine(line string, base *lspCommandOptions, encoder *json.Enco
283
301
  case "lsp-diagnostics":
284
302
  result, code := computeLSPDiagnostics(&opts)
285
303
  encodeServeResult(encoder, result, code)
304
+ case "lsp-project-diagnostics":
305
+ result, code := computeLSPProjectDiagnostics(&opts)
306
+ encodeServeResult(encoder, result, code)
286
307
  case "lsp-code-actions":
287
308
  result, code := computeLSPCodeActions(&opts)
288
309
  encodeServeResult(encoder, result, code)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
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.20.1"
40
+ "ttsc": "0.22.0"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
package/rule/project.go CHANGED
@@ -103,6 +103,64 @@ type ProjectRule interface {
103
103
  Check(ctx *ProjectContext)
104
104
  }
105
105
 
106
+ // ProjectInputKind distinguishes one exact local path from a glob population.
107
+ // Both kinds are resolved against ProjectIdentity.PhysicalProjectRoot by the
108
+ // host. Remote URLs are not project inputs.
109
+ type ProjectInputKind string
110
+
111
+ const (
112
+ ProjectInputFile ProjectInputKind = "file"
113
+ ProjectInputGlob ProjectInputKind = "glob"
114
+ )
115
+
116
+ // ProjectInput declares one local filesystem dependency of a ProjectRule.
117
+ // Pattern may be absolute or relative to the physical project root. Glob
118
+ // patterns support path-segment `*`, `?`, and `**`; exact files remain
119
+ // dependencies while missing.
120
+ type ProjectInput struct {
121
+ Kind ProjectInputKind `json:"kind"`
122
+ Pattern string `json:"pattern"`
123
+ }
124
+
125
+ // ProjectInputRule is the optional dependency-publication contract for a
126
+ // ProjectRule. The host calls ProjectInputs after resolving the rule's options
127
+ // and physical project identity, without loading a TypeScript Program.
128
+ type ProjectInputRule interface {
129
+ ProjectInputs(ctx *ProjectInputContext) []ProjectInput
130
+ }
131
+
132
+ // ProjectInputContext contains the immutable configuration available while a
133
+ // ProjectRule declares its local filesystem dependencies.
134
+ type ProjectInputContext struct {
135
+ Identity ProjectIdentity
136
+ Severity Severity
137
+ Options json.RawMessage
138
+ }
139
+
140
+ // NewProjectInputContext constructs the context passed to
141
+ // ProjectInputRule.ProjectInputs. Contributor code normally receives this value
142
+ // and does not construct it.
143
+ func NewProjectInputContext(
144
+ identity ProjectIdentity,
145
+ severity Severity,
146
+ options json.RawMessage,
147
+ ) *ProjectInputContext {
148
+ return &ProjectInputContext{
149
+ Identity: identity,
150
+ Severity: severity,
151
+ Options: append(json.RawMessage(nil), options...),
152
+ }
153
+ }
154
+
155
+ // DecodeOptions unmarshals the configured project-rule options into out. A
156
+ // missing options tuple leaves out unchanged and returns nil.
157
+ func (c *ProjectInputContext) DecodeOptions(out interface{}) error {
158
+ if c == nil || len(c.Options) == 0 {
159
+ return nil
160
+ }
161
+ return json.Unmarshal(c.Options, out)
162
+ }
163
+
106
164
  // ProjectReporter is the cycle-scoped failure channel available to project
107
165
  // helpers. Report records a deterministic project finding and also marks the
108
166
  // current rule failed; Fail marks failure without adding a finding.