@ttsc/lint 0.12.3 → 0.13.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.
- package/lib/defaultFormat.d.ts +10 -11
- package/lib/defaultFormat.js +10 -11
- package/lib/defaultFormat.js.map +1 -1
- package/lib/index.js +233 -135
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintConfig.d.ts +2 -2
- package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
- package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
- package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
- package/lib/structures/TtscLintRule.d.ts +10 -1
- package/lib/structures/TtscLintRuleMap.d.ts +2 -2
- package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
- package/linthost/ast_helpers.go +80 -11
- package/linthost/compile.go +116 -112
- package/linthost/config.go +647 -687
- package/linthost/config_format.go +272 -247
- package/linthost/contrib_adapter.go +7 -0
- package/linthost/directives.go +116 -0
- package/linthost/dispatch.go +33 -33
- package/linthost/engine.go +156 -43
- package/linthost/fix.go +47 -27
- package/linthost/flags_gen.go +31 -0
- package/linthost/format.go +119 -3
- package/linthost/host.go +115 -5
- package/linthost/print_dispatch.go +128 -28
- package/linthost/print_doc.go +23 -0
- package/linthost/print_engine.go +168 -4
- package/linthost/print_nodes_array.go +17 -7
- package/linthost/print_nodes_call.go +138 -20
- package/linthost/print_nodes_function.go +353 -0
- package/linthost/print_nodes_imports.go +46 -29
- package/linthost/print_nodes_list.go +86 -5
- package/linthost/print_nodes_object.go +56 -11
- package/linthost/rules_arrays.go +5 -2
- package/linthost/rules_debugger.go +3 -2
- package/linthost/rules_dupes.go +7 -4
- package/linthost/rules_empty.go +3 -2
- package/linthost/rules_escape.go +35 -3
- package/linthost/rules_eval.go +3 -0
- package/linthost/rules_finally.go +11 -0
- package/linthost/rules_format_jsdoc.go +7 -0
- package/linthost/rules_format_print_width.go +270 -15
- package/linthost/rules_format_quotes.go +3 -0
- package/linthost/rules_format_sort_imports.go +4 -0
- package/linthost/rules_gap.go +75 -3
- package/linthost/rules_imports.go +5 -7
- package/linthost/rules_logic.go +75 -5
- package/linthost/rules_loops.go +4 -0
- package/linthost/rules_misc.go +4 -2
- package/linthost/rules_params.go +7 -11
- package/linthost/rules_problems.go +89 -22
- package/linthost/rules_promise.go +15 -0
- package/linthost/rules_protos.go +3 -2
- package/linthost/rules_self.go +6 -0
- package/linthost/rules_strings.go +10 -0
- package/linthost/rules_suggestions.go +174 -9
- package/linthost/rules_throw.go +2 -0
- package/linthost/rules_ts.go +13 -0
- package/linthost/rules_ts_extra.go +25 -8
- package/linthost/rules_var.go +15 -2
- package/package.json +3 -3
- package/plugin/main.go +4 -4
- package/rule/astutil/astutil.go +12 -0
- package/rule/rule.go +123 -123
- package/src/defaultFormat.ts +10 -11
- package/src/index.ts +257 -168
- package/src/structures/ITtscLintConfig.ts +2 -2
- package/src/structures/ITtscLintFormatConfig.ts +53 -55
- package/src/structures/ITtscLintPluginConfig.ts +10 -83
- package/src/structures/ITtscLintPluginMeta.ts +9 -1
- package/src/structures/TtscLintRule.ts +10 -1
- package/src/structures/TtscLintRuleMap.ts +17 -18
- package/src/structures/TtscLintRuleOptions.ts +15 -0
- package/linthost/eslint_runtime.go +0 -320
package/linthost/config.go
CHANGED
|
@@ -3,6 +3,8 @@ package linthost
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
5
|
"context"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
6
8
|
"encoding/json"
|
|
7
9
|
"fmt"
|
|
8
10
|
"os"
|
|
@@ -85,22 +87,34 @@ type RuleConfig map[string]Severity
|
|
|
85
87
|
|
|
86
88
|
// RuleOptionsMap captures the rule-specific options blob, keyed by rule
|
|
87
89
|
// name. Severity-only rules never appear here. The values are the raw
|
|
88
|
-
// JSON the user wrote in the second tuple slot of
|
|
89
|
-
//
|
|
90
|
-
//
|
|
90
|
+
// JSON the user wrote in the second tuple slot of a `["error", { ... }]`
|
|
91
|
+
// rule setting; each rule decodes the blob into its own option struct on
|
|
92
|
+
// demand.
|
|
91
93
|
type RuleOptionsMap map[string]json.RawMessage
|
|
92
94
|
|
|
93
95
|
// ResolvedRuleConfig is the rule map that applies to one source file.
|
|
94
|
-
// `Ignored` means an
|
|
95
|
-
//
|
|
96
|
+
// `Ignored` means an `ignores`-only config entry matched the file and the
|
|
97
|
+
// engine should skip linting it entirely.
|
|
96
98
|
type ResolvedRuleConfig struct {
|
|
97
99
|
Rules RuleConfig
|
|
98
100
|
Ignored bool
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
// RuleResolver is the engine-facing view of a resolved lint configuration.
|
|
104
|
+
// Implementations include RuleConfig (severity-only, no options),
|
|
105
|
+
// InlineRuleResolver (a severity map plus per-rule options), and *ConfigStore
|
|
106
|
+
// (a parsed lint config file, with per-file glob resolution and a unified
|
|
107
|
+
// options map).
|
|
101
108
|
type RuleResolver interface {
|
|
109
|
+
// ResolveRules returns the effective severity map for the given source file.
|
|
110
|
+
// Implementations that support `files`/`ignores` patterns apply them here;
|
|
111
|
+
// flat RuleConfig always returns all rules unchanged.
|
|
102
112
|
ResolveRules(fileName string) ResolvedRuleConfig
|
|
113
|
+
// ActiveRuleNames returns the sorted names of every rule that is not SeverityOff
|
|
114
|
+
// in at least one config entry. Used to build the engine's dispatch table.
|
|
103
115
|
ActiveRuleNames() []string
|
|
116
|
+
// EnabledRuleConfig returns the project-wide severity map for rules that are
|
|
117
|
+
// not SeverityOff. Where multiple entries disagree, SeverityError wins.
|
|
104
118
|
EnabledRuleConfig() RuleConfig
|
|
105
119
|
// RuleOptions returns the raw JSON options for `name`, or nil when the
|
|
106
120
|
// rule was configured with a severity alone. Returns nil for unknown
|
|
@@ -108,14 +122,20 @@ type RuleResolver interface {
|
|
|
108
122
|
RuleOptions(name string) json.RawMessage
|
|
109
123
|
}
|
|
110
124
|
|
|
125
|
+
// ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
|
|
126
|
+
// so every file receives the full map unchanged.
|
|
111
127
|
func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
|
|
112
128
|
return ResolvedRuleConfig{Rules: c}
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
// ActiveRuleNames implements RuleResolver. Returns rule names whose severity
|
|
132
|
+
// is not SeverityOff, sorted for deterministic engine dispatch-table construction.
|
|
115
133
|
func (c RuleConfig) ActiveRuleNames() []string {
|
|
116
134
|
return sortedRuleNames(c, func(sev Severity) bool { return sev != SeverityOff })
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
// EnabledRuleConfig implements RuleResolver. Returns a copy containing only the
|
|
138
|
+
// non-off entries; used to populate engine state and diagnostic reporting.
|
|
119
139
|
func (c RuleConfig) EnabledRuleConfig() RuleConfig {
|
|
120
140
|
out := RuleConfig{}
|
|
121
141
|
for name, sev := range c {
|
|
@@ -127,30 +147,37 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
|
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
// RuleOptions on a bare RuleConfig is always nil — this form is the
|
|
130
|
-
// severity-only path used by Go unit tests and
|
|
131
|
-
//
|
|
150
|
+
// severity-only path used by Go unit tests and rule constructors that
|
|
151
|
+
// predate option support.
|
|
132
152
|
func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
|
|
133
153
|
|
|
134
|
-
// InlineRuleResolver pairs a severity map with an options map
|
|
135
|
-
//
|
|
136
|
-
//
|
|
154
|
+
// InlineRuleResolver pairs a severity map with an options map. The fields
|
|
155
|
+
// are public so tests can construct one without going through
|
|
156
|
+
// ParseRulesWithOptions.
|
|
137
157
|
type InlineRuleResolver struct {
|
|
138
158
|
Rules RuleConfig
|
|
139
159
|
Options RuleOptionsMap
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
// ResolveRules implements RuleResolver. Inline rules have no glob scoping;
|
|
163
|
+
// the full map applies to every file.
|
|
142
164
|
func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
|
|
143
165
|
return ResolvedRuleConfig{Rules: r.Rules}
|
|
144
166
|
}
|
|
145
167
|
|
|
168
|
+
// ActiveRuleNames implements RuleResolver by delegating to the inner RuleConfig.
|
|
146
169
|
func (r InlineRuleResolver) ActiveRuleNames() []string {
|
|
147
170
|
return r.Rules.ActiveRuleNames()
|
|
148
171
|
}
|
|
149
172
|
|
|
173
|
+
// EnabledRuleConfig implements RuleResolver by delegating to the inner RuleConfig.
|
|
150
174
|
func (r InlineRuleResolver) EnabledRuleConfig() RuleConfig {
|
|
151
175
|
return r.Rules.EnabledRuleConfig()
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
// RuleOptions implements RuleResolver. Returns the raw JSON options blob for
|
|
179
|
+
// `name`, or nil when the rule was configured without options or the name is
|
|
180
|
+
// unknown.
|
|
154
181
|
func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
|
|
155
182
|
if r.Options == nil {
|
|
156
183
|
return nil
|
|
@@ -158,11 +185,18 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
|
|
|
158
185
|
return r.Options[name]
|
|
159
186
|
}
|
|
160
187
|
|
|
188
|
+
// ConfigStore holds the parsed representation of a lint config file. It
|
|
189
|
+
// implements RuleResolver with per-file glob scoping: ResolveRules walks the
|
|
190
|
+
// entries in declaration order and the last matching entry wins. Options are
|
|
191
|
+
// intentionally NOT per-file — one project-wide options map is kept so rule
|
|
192
|
+
// behavior is uniform across the codebase even when severity varies by glob.
|
|
193
|
+
//
|
|
194
|
+
// A config file is a single `ITtscLintConfig` object. Its `extends` field
|
|
195
|
+
// names another config file to fold in first; the extends chain produces one
|
|
196
|
+
// ConfigEntry per file, the extends-target entries declared before the
|
|
197
|
+
// extending file's own entry so local rules win on collision.
|
|
161
198
|
type ConfigStore struct {
|
|
162
|
-
entries
|
|
163
|
-
externalConfigPath string
|
|
164
|
-
eslintRuntime bool
|
|
165
|
-
eslintRuntimeRequired bool
|
|
199
|
+
entries []ConfigEntry
|
|
166
200
|
// options is a flat rule-name → JSON map. Options are not scoped by
|
|
167
201
|
// `files` / `ignores`: a rule's behavior is a single project-wide
|
|
168
202
|
// configuration even when its severity is per-file. The simplification
|
|
@@ -179,6 +213,11 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
|
|
|
179
213
|
return s.options[name]
|
|
180
214
|
}
|
|
181
215
|
|
|
216
|
+
// ConfigEntry is the parsed form of one config file in the extends chain.
|
|
217
|
+
// BaseDir anchors glob resolution; Files and Ignores are the pattern lists.
|
|
218
|
+
// IgnoreOnly marks entries that carry only `ignores` (no `files`, no `rules`)
|
|
219
|
+
// — these are evaluated first in ResolveRules and short-circuit the walk when
|
|
220
|
+
// matched.
|
|
182
221
|
type ConfigEntry struct {
|
|
183
222
|
BaseDir string
|
|
184
223
|
Files []string
|
|
@@ -187,6 +226,10 @@ type ConfigEntry struct {
|
|
|
187
226
|
IgnoreOnly bool
|
|
188
227
|
}
|
|
189
228
|
|
|
229
|
+
// ResolveRules implements RuleResolver. Ignore-only entries are checked first;
|
|
230
|
+
// if one matches, the file is marked Ignored and linting is skipped entirely.
|
|
231
|
+
// Otherwise the entries are walked in declaration order and the last matching
|
|
232
|
+
// entry wins (later entries shadow earlier ones for the same rule name).
|
|
190
233
|
func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
|
|
191
234
|
if s == nil {
|
|
192
235
|
return ResolvedRuleConfig{Rules: RuleConfig{}}
|
|
@@ -208,6 +251,10 @@ func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
|
|
|
208
251
|
return ResolvedRuleConfig{Rules: out}
|
|
209
252
|
}
|
|
210
253
|
|
|
254
|
+
// ActiveRuleNames implements RuleResolver. Returns the sorted union of all rule
|
|
255
|
+
// names that are not SeverityOff across every non-ignore-only config entry,
|
|
256
|
+
// regardless of which files they apply to. The engine uses this to build the
|
|
257
|
+
// per-rule dispatch table before file iteration begins.
|
|
211
258
|
func (s *ConfigStore) ActiveRuleNames() []string {
|
|
212
259
|
if s == nil {
|
|
213
260
|
return nil
|
|
@@ -226,6 +273,9 @@ func (s *ConfigStore) ActiveRuleNames() []string {
|
|
|
226
273
|
return sortedRuleNames(active, func(Severity) bool { return true })
|
|
227
274
|
}
|
|
228
275
|
|
|
276
|
+
// EnabledRuleConfig implements RuleResolver. Returns the project-wide severity
|
|
277
|
+
// map for non-off rules. Where multiple entries configure the same rule,
|
|
278
|
+
// SeverityError is sticky — it cannot be downgraded by a later warning entry.
|
|
229
279
|
func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
|
|
230
280
|
out := RuleConfig{}
|
|
231
281
|
if s == nil {
|
|
@@ -247,6 +297,9 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
|
|
|
247
297
|
return out
|
|
248
298
|
}
|
|
249
299
|
|
|
300
|
+
// Flatten returns the unconstrained union of all non-ignore-only entries,
|
|
301
|
+
// including SeverityOff rules. Used by LoadRuleConfig (callers that expect a
|
|
302
|
+
// plain RuleConfig). Later entries shadow earlier ones for the same rule name.
|
|
250
303
|
func (s *ConfigStore) Flatten() RuleConfig {
|
|
251
304
|
out := RuleConfig{}
|
|
252
305
|
if s == nil {
|
|
@@ -263,31 +316,6 @@ func (s *ConfigStore) Flatten() RuleConfig {
|
|
|
263
316
|
return out
|
|
264
317
|
}
|
|
265
318
|
|
|
266
|
-
func (s *ConfigStore) ExternalConfigPath() string {
|
|
267
|
-
if s == nil {
|
|
268
|
-
return ""
|
|
269
|
-
}
|
|
270
|
-
return s.externalConfigPath
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
func (s *ConfigStore) WantsESLintRuntime() bool {
|
|
274
|
-
if s == nil {
|
|
275
|
-
return false
|
|
276
|
-
}
|
|
277
|
-
if s.eslintRuntime {
|
|
278
|
-
return true
|
|
279
|
-
}
|
|
280
|
-
base := filepath.Base(s.externalConfigPath)
|
|
281
|
-
return strings.HasPrefix(base, "eslint.config.")
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
func (s *ConfigStore) RequiresESLintRuntime() bool {
|
|
285
|
-
if s == nil {
|
|
286
|
-
return false
|
|
287
|
-
}
|
|
288
|
-
return s.eslintRuntimeRequired
|
|
289
|
-
}
|
|
290
|
-
|
|
291
319
|
func (e ConfigEntry) matchesFile(fileName string) bool {
|
|
292
320
|
if len(e.Files) > 0 && !matchAnyPattern(e.BaseDir, e.Files, fileName) {
|
|
293
321
|
return false
|
|
@@ -302,8 +330,7 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
|
|
|
302
330
|
return len(e.Ignores) > 0 && matchAnyPattern(e.BaseDir, e.Ignores, fileName)
|
|
303
331
|
}
|
|
304
332
|
|
|
305
|
-
// ParseRules normalizes
|
|
306
|
-
// entry.
|
|
333
|
+
// ParseRules normalizes a rule severity map.
|
|
307
334
|
//
|
|
308
335
|
// Severity values:
|
|
309
336
|
// - `"off"` → SeverityOff
|
|
@@ -327,7 +354,7 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
|
|
|
327
354
|
}
|
|
328
355
|
dict, ok := raw.(map[string]any)
|
|
329
356
|
if !ok {
|
|
330
|
-
return nil, nil, fmt.Errorf("@ttsc/lint: \"
|
|
357
|
+
return nil, nil, fmt.Errorf("@ttsc/lint: \"rules\" must be an object, got %T", raw)
|
|
331
358
|
}
|
|
332
359
|
cfg := make(RuleConfig, len(dict))
|
|
333
360
|
opts := make(RuleOptionsMap)
|
|
@@ -344,11 +371,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
|
|
|
344
371
|
return cfg, opts, nil
|
|
345
372
|
}
|
|
346
373
|
|
|
347
|
-
// parseRuleEntry splits
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// into its own struct later.
|
|
374
|
+
// parseRuleEntry splits a rule entry into its severity and (optional) options
|
|
375
|
+
// payload. Bare severity literals produce a nil options blob; `[severity]`
|
|
376
|
+
// (no options) does the same; `[severity, options]` re-serializes the options
|
|
377
|
+
// to JSON so each rule can decode it into its own struct later.
|
|
352
378
|
func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
353
379
|
if tuple, ok := value.([]any); ok {
|
|
354
380
|
if len(tuple) == 0 {
|
|
@@ -368,10 +394,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
|
368
394
|
return sev, nil, nil
|
|
369
395
|
}
|
|
370
396
|
if _, ok := tuple[1].(map[string]any); !ok {
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// and silently encoding a non-object slot would land in
|
|
397
|
+
// A positional string option (e.g. `["error", "single"]`) is
|
|
398
|
+
// rejected: every option struct in TtscLintRuleOptions is an
|
|
399
|
+
// object, and silently encoding a non-object slot would land in
|
|
375
400
|
// DecodeOptions as a decode error that every rule discards. Fail
|
|
376
401
|
// loudly so users discover the proper `["error", { … }]` form.
|
|
377
402
|
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
|
|
@@ -386,6 +411,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
|
386
411
|
return sev, nil, err
|
|
387
412
|
}
|
|
388
413
|
|
|
414
|
+
// parseExternalConfigRules is a convenience wrapper used by unit tests that
|
|
415
|
+
// only need a flat RuleConfig from an already-deserialized config object. Glob
|
|
416
|
+
// scoping and options are discarded.
|
|
389
417
|
func parseExternalConfigRules(raw any) (RuleConfig, error) {
|
|
390
418
|
store, err := parseExternalConfigStore(raw, "")
|
|
391
419
|
if err != nil {
|
|
@@ -394,164 +422,175 @@ func parseExternalConfigRules(raw any) (RuleConfig, error) {
|
|
|
394
422
|
return store.Flatten(), nil
|
|
395
423
|
}
|
|
396
424
|
|
|
425
|
+
// parseExternalConfigStore parses a single `ITtscLintConfig` object into a
|
|
426
|
+
// *ConfigStore. `configDir` anchors glob resolution and `extends` lookups; it
|
|
427
|
+
// is empty for in-memory inputs that do not load from a real file.
|
|
397
428
|
func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
|
|
398
|
-
return
|
|
429
|
+
return collectConfigStore(raw, configDir, "")
|
|
399
430
|
}
|
|
400
431
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
432
|
+
// collectConfigStore parses a single `ITtscLintConfig` object into a fresh
|
|
433
|
+
// *ConfigStore. `rootPath` is the absolute path of the file `raw` was loaded
|
|
434
|
+
// from, or "" for in-memory inputs; when set it seeds the `extends` cycle
|
|
435
|
+
// guard so a config that `extends` itself (directly or transitively) is
|
|
436
|
+
// rejected.
|
|
437
|
+
func collectConfigStore(raw any, configDir, rootPath string) (*ConfigStore, error) {
|
|
406
438
|
store := &ConfigStore{}
|
|
407
|
-
|
|
439
|
+
var chain []string
|
|
440
|
+
if rootPath != "" {
|
|
441
|
+
chain = []string{filepath.Clean(rootPath)}
|
|
442
|
+
}
|
|
443
|
+
if err := collectConfigObject(store, raw, configDir, "config", chain); err != nil {
|
|
408
444
|
return nil, err
|
|
409
445
|
}
|
|
410
446
|
return store, nil
|
|
411
447
|
}
|
|
412
448
|
|
|
413
|
-
|
|
449
|
+
// extendsDepthLimit caps how many `extends` hops collectConfigObject will
|
|
450
|
+
// follow. The cycle check in appendExtendsLink already rejects every loop;
|
|
451
|
+
// this is a backstop so a config chain that escapes that check (e.g. a future
|
|
452
|
+
// change that resolves the same file under two different cleaned paths) still
|
|
453
|
+
// fails fast instead of spawning an unbounded run of `ttsx`/`node`
|
|
454
|
+
// config-loader subprocesses — one per hop.
|
|
455
|
+
const extendsDepthLimit = 32
|
|
456
|
+
|
|
457
|
+
// appendExtendsLink validates that following the `extends` target at `next`
|
|
458
|
+
// neither closes a cycle nor exceeds extendsDepthLimit, then returns `chain`
|
|
459
|
+
// extended by `next`. `chain` holds the resolved absolute paths of every
|
|
460
|
+
// config file already on the current `extends` lineage, root first. The guard
|
|
461
|
+
// runs before loadConfigFile so a cyclic chain fails fast instead of
|
|
462
|
+
// re-reading files (and re-spawning subprocesses) without bound.
|
|
463
|
+
func appendExtendsLink(chain []string, next string) ([]string, error) {
|
|
464
|
+
for i, prior := range chain {
|
|
465
|
+
if prior == next {
|
|
466
|
+
// chain[i:] capped at its own length so append allocates a fresh
|
|
467
|
+
// backing array rather than mutating the caller's `chain`.
|
|
468
|
+
cycle := append(chain[i:len(chain):len(chain)], next)
|
|
469
|
+
return nil, fmt.Errorf(
|
|
470
|
+
"@ttsc/lint: extends cycle detected: %s",
|
|
471
|
+
strings.Join(cycle, " -> "),
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if len(chain) >= extendsDepthLimit {
|
|
476
|
+
return nil, fmt.Errorf(
|
|
477
|
+
"@ttsc/lint: extends chain exceeds the depth limit of %d: %s -> ...",
|
|
478
|
+
extendsDepthLimit,
|
|
479
|
+
strings.Join(chain, " -> "),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
extended := make([]string, len(chain)+1)
|
|
483
|
+
copy(extended, chain)
|
|
484
|
+
extended[len(chain)] = next
|
|
485
|
+
return extended, nil
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// collectConfigObject parses one `ITtscLintConfig` object into `store`,
|
|
489
|
+
// appending one ConfigEntry for the object's own rules (and, recursively, the
|
|
490
|
+
// entries of any `extends`-named config file). The extends-target's entries
|
|
491
|
+
// are appended first so the extending file's local rules win on collision.
|
|
492
|
+
//
|
|
493
|
+
// `chain` carries the resolved absolute paths of the config files already on
|
|
494
|
+
// the current `extends` lineage (root first); appendExtendsLink consults it to
|
|
495
|
+
// reject cyclic or pathologically deep chains before another file is read.
|
|
496
|
+
func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chain []string) error {
|
|
414
497
|
if raw == nil {
|
|
415
498
|
return nil
|
|
416
499
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
500
|
+
obj, ok := raw.(map[string]any)
|
|
501
|
+
if !ok {
|
|
502
|
+
return fmt.Errorf("@ttsc/lint: %s must be an ITtscLintConfig object, got %T", path, raw)
|
|
503
|
+
}
|
|
504
|
+
if err := rejectUnknownConfigKeys(obj, path); err != nil {
|
|
505
|
+
return err
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if extended, hasExtends := obj["extends"]; hasExtends && extended != nil {
|
|
509
|
+
extendsStr, ok := extended.(string)
|
|
510
|
+
if !ok {
|
|
511
|
+
return fmt.Errorf("@ttsc/lint: %s.extends must be a string path to another config file, got %T", path, extended)
|
|
423
512
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
localBaseDir := baseDir
|
|
432
|
-
if rawBasePath, ok := typed["basePath"]; ok {
|
|
433
|
-
basePath, ok := rawBasePath.(string)
|
|
434
|
-
if !ok {
|
|
435
|
-
return fmt.Errorf("@ttsc/lint: %s.basePath must be a string, got %T", path, rawBasePath)
|
|
436
|
-
}
|
|
437
|
-
if filepath.IsAbs(basePath) {
|
|
438
|
-
localBaseDir = basePath
|
|
439
|
-
} else {
|
|
440
|
-
localBaseDir = filepath.Join(baseDir, basePath)
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if hasESLintRuntimeFields(typed) {
|
|
444
|
-
store.eslintRuntime = true
|
|
445
|
-
store.eslintRuntimeRequired = true
|
|
446
|
-
}
|
|
447
|
-
if extended, ok := typed["extends"]; ok {
|
|
448
|
-
if err := collectExternalExtends(store, extended, localBaseDir, path+".extends", allowRuntimeOnly); err != nil {
|
|
449
|
-
return err
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
files, err := parsePatternList(typed["files"], path+".files")
|
|
453
|
-
if err != nil {
|
|
454
|
-
return err
|
|
455
|
-
}
|
|
456
|
-
ignores, err := parsePatternList(typed["ignores"], path+".ignores")
|
|
457
|
-
if err != nil {
|
|
458
|
-
return err
|
|
459
|
-
}
|
|
460
|
-
rulesValue, hasRules := typed["rules"]
|
|
461
|
-
formatValue, hasFormat := typed["format"]
|
|
462
|
-
if hasRules || hasFormat {
|
|
463
|
-
// Expand the format block (if any) into a rules-shaped map,
|
|
464
|
-
// then overlay any explicit `rules` entries. `rules`-wins
|
|
465
|
-
// semantics match the inline path; the conflict-resolution
|
|
466
|
-
// table is identical regardless of which surface a user
|
|
467
|
-
// chose.
|
|
468
|
-
var formatRulesRaw map[string]any
|
|
469
|
-
if hasFormat {
|
|
470
|
-
fmtMap, ok := formatValue.(map[string]any)
|
|
471
|
-
if !ok {
|
|
472
|
-
return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
|
|
473
|
-
}
|
|
474
|
-
expanded, err := expandFormatBlock(fmtMap)
|
|
475
|
-
if err != nil {
|
|
476
|
-
return err
|
|
477
|
-
}
|
|
478
|
-
formatRulesRaw = expanded
|
|
479
|
-
}
|
|
480
|
-
var rulesMap map[string]any
|
|
481
|
-
if hasRules {
|
|
482
|
-
// `parseExternalRuleMapInto` accepts the raw map directly.
|
|
483
|
-
// Coerce here to feed the same merge pipeline as the
|
|
484
|
-
// inline path.
|
|
485
|
-
typedMap, ok := rulesValue.(map[string]any)
|
|
486
|
-
if !ok {
|
|
487
|
-
return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
|
|
488
|
-
}
|
|
489
|
-
rulesMap = typedMap
|
|
490
|
-
}
|
|
491
|
-
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
492
|
-
if len(merged) == 0 {
|
|
493
|
-
return nil
|
|
494
|
-
}
|
|
495
|
-
parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
|
|
496
|
-
if err != nil {
|
|
497
|
-
return err
|
|
498
|
-
}
|
|
499
|
-
store.entries = append(store.entries, ConfigEntry{
|
|
500
|
-
BaseDir: localBaseDir,
|
|
501
|
-
Files: files,
|
|
502
|
-
Ignores: ignores,
|
|
503
|
-
Rules: parsed,
|
|
504
|
-
})
|
|
505
|
-
return nil
|
|
506
|
-
}
|
|
507
|
-
if len(files) == 0 && len(ignores) > 0 {
|
|
508
|
-
store.entries = append(store.entries, ConfigEntry{
|
|
509
|
-
BaseDir: localBaseDir,
|
|
510
|
-
Ignores: ignores,
|
|
511
|
-
IgnoreOnly: true,
|
|
512
|
-
})
|
|
513
|
-
}
|
|
514
|
-
return nil
|
|
513
|
+
if strings.TrimSpace(extendsStr) == "" {
|
|
514
|
+
return fmt.Errorf("@ttsc/lint: %s.extends must not be empty", path)
|
|
515
|
+
}
|
|
516
|
+
location := extendsStr
|
|
517
|
+
if !filepath.IsAbs(location) {
|
|
518
|
+
location = filepath.Join(baseDir, location)
|
|
515
519
|
}
|
|
516
|
-
|
|
520
|
+
location = filepath.Clean(location)
|
|
521
|
+
extendedChain, err := appendExtendsLink(chain, location)
|
|
517
522
|
if err != nil {
|
|
518
523
|
return err
|
|
519
524
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
525
|
+
extendedRaw, err := loadConfigFile(location)
|
|
526
|
+
if err != nil {
|
|
527
|
+
return err
|
|
528
|
+
}
|
|
529
|
+
if err := collectConfigObject(store, extendedRaw, filepath.Dir(location), path+".extends", extendedChain); err != nil {
|
|
530
|
+
return err
|
|
531
|
+
}
|
|
527
532
|
}
|
|
528
|
-
}
|
|
529
533
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
534
|
+
files, err := parsePatternList(obj["files"], path+".files")
|
|
535
|
+
if err != nil {
|
|
536
|
+
return err
|
|
537
|
+
}
|
|
538
|
+
ignores, err := parsePatternList(obj["ignores"], path+".ignores")
|
|
539
|
+
if err != nil {
|
|
540
|
+
return err
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
rulesValue, hasRules := obj["rules"]
|
|
544
|
+
formatValue, hasFormat := obj["format"]
|
|
545
|
+
if hasRules || hasFormat {
|
|
546
|
+
// Expand the format block (if any) into a rules-shaped map, then
|
|
547
|
+
// overlay any explicit `rules` entries. `rules`-wins semantics: a
|
|
548
|
+
// `rules` entry that names a `format/*` rule fully replaces the
|
|
549
|
+
// entry expanded from the `format` block.
|
|
550
|
+
var formatRulesRaw map[string]any
|
|
551
|
+
if hasFormat {
|
|
552
|
+
formatMap, ok := formatValue.(map[string]any)
|
|
553
|
+
if !ok {
|
|
554
|
+
return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
|
|
555
|
+
}
|
|
556
|
+
expanded, err := expandFormatBlock(formatMap)
|
|
557
|
+
if err != nil {
|
|
558
|
+
return err
|
|
559
|
+
}
|
|
560
|
+
formatRulesRaw = expanded
|
|
537
561
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
store.eslintRuntime = true
|
|
544
|
-
store.eslintRuntimeRequired = true
|
|
545
|
-
continue
|
|
562
|
+
var rulesMap map[string]any
|
|
563
|
+
if hasRules {
|
|
564
|
+
typedMap, ok := rulesValue.(map[string]any)
|
|
565
|
+
if !ok {
|
|
566
|
+
return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
|
|
546
567
|
}
|
|
547
|
-
|
|
568
|
+
rulesMap = typedMap
|
|
569
|
+
}
|
|
570
|
+
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
571
|
+
if len(merged) > 0 {
|
|
572
|
+
parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
|
|
573
|
+
if err != nil {
|
|
548
574
|
return err
|
|
549
575
|
}
|
|
576
|
+
store.entries = append(store.entries, ConfigEntry{
|
|
577
|
+
BaseDir: baseDir,
|
|
578
|
+
Files: files,
|
|
579
|
+
Ignores: ignores,
|
|
580
|
+
Rules: parsed,
|
|
581
|
+
})
|
|
550
582
|
}
|
|
551
583
|
return nil
|
|
552
|
-
default:
|
|
553
|
-
return collectExternalConfigEntries(store, raw, baseDir, path, allowRuntimeOnly)
|
|
554
584
|
}
|
|
585
|
+
|
|
586
|
+
if len(files) == 0 && len(ignores) > 0 {
|
|
587
|
+
store.entries = append(store.entries, ConfigEntry{
|
|
588
|
+
BaseDir: baseDir,
|
|
589
|
+
Ignores: ignores,
|
|
590
|
+
IgnoreOnly: true,
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
return nil
|
|
555
594
|
}
|
|
556
595
|
|
|
557
596
|
// parseExternalRuleMapInto parses the rules map and folds any
|
|
@@ -570,7 +609,7 @@ func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleCon
|
|
|
570
609
|
|
|
571
610
|
// collectExternalRuleMapWithOptions also records the rule's options blob
|
|
572
611
|
// when the entry is a `[severity, options]` tuple. `opts` may be nil
|
|
573
|
-
// when the caller does not need option capture
|
|
612
|
+
// when the caller does not need option capture.
|
|
574
613
|
func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
|
|
575
614
|
dict, ok := raw.(map[string]any)
|
|
576
615
|
if !ok {
|
|
@@ -590,110 +629,37 @@ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw
|
|
|
590
629
|
return nil
|
|
591
630
|
}
|
|
592
631
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
"
|
|
599
|
-
"ignores",
|
|
600
|
-
"
|
|
601
|
-
"
|
|
602
|
-
"
|
|
603
|
-
"
|
|
604
|
-
"processor",
|
|
605
|
-
"rules",
|
|
606
|
-
"settings",
|
|
607
|
-
"__ttscLintEslintRuntime",
|
|
608
|
-
} {
|
|
609
|
-
if _, ok := value[key]; ok {
|
|
610
|
-
return true
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
return false
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
func hasESLintRuntimeFields(value map[string]any) bool {
|
|
617
|
-
for _, key := range []string{
|
|
618
|
-
"languageOptions",
|
|
619
|
-
"linterOptions",
|
|
620
|
-
"processor",
|
|
621
|
-
"settings",
|
|
622
|
-
} {
|
|
623
|
-
if _, ok := value[key]; ok {
|
|
624
|
-
return true
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
if plugins, ok := value["plugins"]; ok {
|
|
628
|
-
if !isNativePluginMap(plugins) {
|
|
629
|
-
return true
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
return false
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// isNativePluginMap reports whether every entry in a flat-config
|
|
636
|
-
// `plugins` map points at a ttsc-lint native contributor object
|
|
637
|
-
// (carrying a non-empty string `source` field). Native contributors are
|
|
638
|
-
// compiled into the lint binary at build time and require no JS ESLint
|
|
639
|
-
// runtime; only mixed or pure-ESLint plugin maps require the runtime
|
|
640
|
-
// fallback.
|
|
641
|
-
func isNativePluginMap(value any) bool {
|
|
642
|
-
dict, ok := value.(map[string]any)
|
|
643
|
-
if !ok {
|
|
644
|
-
return false
|
|
632
|
+
// rejectUnknownConfigKeys surfaces typos in top-level config-file keys at the
|
|
633
|
+
// boundary rather than silently ignoring them. The key set mirrors
|
|
634
|
+
// `ITtscLintConfig` exactly.
|
|
635
|
+
func rejectUnknownConfigKeys(value map[string]any, path string) error {
|
|
636
|
+
allowed := map[string]struct{}{
|
|
637
|
+
"files": {},
|
|
638
|
+
"ignores": {},
|
|
639
|
+
"extends": {},
|
|
640
|
+
"plugins": {},
|
|
641
|
+
"rules": {},
|
|
642
|
+
"format": {},
|
|
645
643
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
for _, entry := range dict {
|
|
650
|
-
if !isNativePluginValue(entry) {
|
|
651
|
-
return false
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
return true
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
func isNativePluginValue(entry any) bool {
|
|
658
|
-
if entry == nil {
|
|
659
|
-
return false
|
|
660
|
-
}
|
|
661
|
-
switch typed := entry.(type) {
|
|
662
|
-
case string:
|
|
663
|
-
// A non-empty string is a native npm specifier (matching the JS
|
|
664
|
-
// factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
|
|
665
|
-
// configs and the JSON-only `readJsonConfigPlugins` path). The JS
|
|
666
|
-
// factory resolves the specifier at load time and bakes the
|
|
667
|
-
// contributor into the binary, so the Go sidecar should not flip
|
|
668
|
-
// `eslintRuntimeRequired` for a file that already declared a
|
|
669
|
-
// native specifier.
|
|
670
|
-
return typed != ""
|
|
671
|
-
case map[string]any:
|
|
672
|
-
// Walk ESM-from-CJS `.default` indirection so a contributor authored
|
|
673
|
-
// as `export default plugin` registers as native here, matching the
|
|
674
|
-
// JS factory's `extractPluginSource` behavior.
|
|
675
|
-
current := typed
|
|
676
|
-
for i := 0; i < 4; i++ {
|
|
677
|
-
if source, ok := current["source"].(string); ok && source != "" {
|
|
678
|
-
return true
|
|
679
|
-
}
|
|
680
|
-
next, ok := current["default"].(map[string]any)
|
|
681
|
-
if !ok {
|
|
682
|
-
return false
|
|
683
|
-
}
|
|
684
|
-
current = next
|
|
644
|
+
for key := range value {
|
|
645
|
+
if _, ok := allowed[key]; !ok {
|
|
646
|
+
return fmt.Errorf("@ttsc/lint: %s has unknown key %q; a lint config file must be an ITtscLintConfig object (files, ignores, extends, plugins, rules, format)", path, key)
|
|
685
647
|
}
|
|
686
|
-
return false
|
|
687
|
-
default:
|
|
688
|
-
return false
|
|
689
648
|
}
|
|
649
|
+
return nil
|
|
690
650
|
}
|
|
691
651
|
|
|
652
|
+
// normalizeExternalRuleName strips the standard typescript-eslint namespace
|
|
653
|
+
// prefixes so that rules like "@typescript-eslint/no-explicit-any" and the
|
|
654
|
+
// bare "no-explicit-any" key both resolve to the same engine-internal name.
|
|
692
655
|
func normalizeExternalRuleName(name string) string {
|
|
693
656
|
name = strings.TrimPrefix(name, "@typescript-eslint/")
|
|
694
657
|
return strings.TrimPrefix(name, "typescript-eslint/")
|
|
695
658
|
}
|
|
696
659
|
|
|
660
|
+
// parsePatternList coerces a raw config value to a string slice for use as a
|
|
661
|
+
// `files` or `ignores` pattern list. Accepts a bare string (single-pattern
|
|
662
|
+
// shorthand) or a string array. Empty patterns are rejected eagerly.
|
|
697
663
|
func parsePatternList(raw any, path string) ([]string, error) {
|
|
698
664
|
if raw == nil {
|
|
699
665
|
return nil, nil
|
|
@@ -722,10 +688,9 @@ func parsePatternList(raw any, path string) ([]string, error) {
|
|
|
722
688
|
}
|
|
723
689
|
}
|
|
724
690
|
|
|
725
|
-
// LoadRuleConfig resolves the lint config for one plugin entry
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
// paths are resolved from the tsconfig directory.
|
|
691
|
+
// LoadRuleConfig resolves the lint config for one plugin entry and flattens it
|
|
692
|
+
// to a plain RuleConfig (no glob scoping). Used by callers and tests that only
|
|
693
|
+
// need a project-wide severity map.
|
|
729
694
|
func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
|
|
730
695
|
resolver, err := LoadConfigResolver(entry, cwd, tsconfigPath)
|
|
731
696
|
if err != nil {
|
|
@@ -741,22 +706,17 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
|
|
|
741
706
|
}
|
|
742
707
|
}
|
|
743
708
|
|
|
744
|
-
// LoadConfigResolver resolves one plugin entry into the engine-facing
|
|
745
|
-
//
|
|
709
|
+
// LoadConfigResolver resolves one plugin entry into the engine-facing config
|
|
710
|
+
// model.
|
|
746
711
|
//
|
|
747
|
-
//
|
|
712
|
+
// The tsconfig plugin entry carries exactly one optional lint-specific key:
|
|
713
|
+
// `configFile`, a path (relative to the tsconfig directory, or absolute) to
|
|
714
|
+
// the lint config file. When `configFile` is set, that file is loaded; when it
|
|
715
|
+
// is absent, a `lint.config.*` / `ttsc-lint.config.*` file is discovered by
|
|
716
|
+
// walking upward from the tsconfig directory.
|
|
748
717
|
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// - `config` (legacy) — accepts the same string-or-map values but
|
|
752
|
-
// emits a one-time stderr deprecation notice. Removed in a future
|
|
753
|
-
// minor.
|
|
754
|
-
//
|
|
755
|
-
// `rules` and `extends` are mutually exclusive on a single plugin
|
|
756
|
-
// entry; mixing legacy `config` with either new field is rejected.
|
|
757
|
-
// `configFile` and `configPath` remain reserved keywords surfaced with
|
|
758
|
-
// a hint pointing at `extends`, in case a user mistakenly reaches for
|
|
759
|
-
// either spelling.
|
|
718
|
+
// All rules, format options, and contributor plugins live in the config file
|
|
719
|
+
// itself — the tsconfig entry has no inline rule/format/plugin surface.
|
|
760
720
|
func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
|
|
761
721
|
if entry == nil {
|
|
762
722
|
return RuleConfig{}, nil
|
|
@@ -765,102 +725,17 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
|
|
|
765
725
|
if inline == nil {
|
|
766
726
|
inline = map[string]any{}
|
|
767
727
|
}
|
|
768
|
-
for _, key := range []string{"configFile", "configPath"} {
|
|
769
|
-
if _, ok := inline[key]; ok {
|
|
770
|
-
return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
rulesValue, hasRules := inline["rules"]
|
|
775
|
-
extendsValue, hasExtends := inline["extends"]
|
|
776
|
-
legacyValue, hasLegacy := inline["config"]
|
|
777
|
-
formatValue, hasFormat := inline["format"]
|
|
778
|
-
|
|
779
|
-
if hasLegacy && (hasRules || hasExtends || hasFormat) {
|
|
780
|
-
sibling := ""
|
|
781
|
-
switch {
|
|
782
|
-
case hasFormat && !hasRules && !hasExtends:
|
|
783
|
-
sibling = "format"
|
|
784
|
-
case hasExtends && !hasRules && !hasFormat:
|
|
785
|
-
sibling = "extends"
|
|
786
|
-
case hasRules && !hasExtends && !hasFormat:
|
|
787
|
-
sibling = "rules"
|
|
788
|
-
default:
|
|
789
|
-
sibling = "rules/extends/format"
|
|
790
|
-
}
|
|
791
|
-
return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
|
|
792
|
-
}
|
|
793
|
-
if hasRules && hasExtends {
|
|
794
|
-
return nil, fmt.Errorf("@ttsc/lint: \"rules\" and \"extends\" cannot be combined on a single plugin entry; put base rules in the \"extends\" file and inline overrides in lint.config.ts itself")
|
|
795
|
-
}
|
|
796
|
-
if hasFormat && hasExtends {
|
|
797
|
-
return nil, fmt.Errorf("@ttsc/lint: \"format\" and \"extends\" cannot be combined on a single plugin entry; put format options inside the extends-target lint.config.ts instead")
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Expand the format block (if any) into a rules-shaped map.
|
|
801
|
-
var formatRulesRaw map[string]any
|
|
802
|
-
if hasFormat {
|
|
803
|
-
formatMap, ok := formatValue.(map[string]any)
|
|
804
|
-
if !ok {
|
|
805
|
-
return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
|
|
806
|
-
}
|
|
807
|
-
expanded, err := expandFormatBlock(formatMap)
|
|
808
|
-
if err != nil {
|
|
809
|
-
return nil, err
|
|
810
|
-
}
|
|
811
|
-
formatRulesRaw = expanded
|
|
812
|
-
}
|
|
813
728
|
|
|
814
|
-
if
|
|
815
|
-
|
|
816
|
-
// entry wins on key collisions (more explicit surface).
|
|
817
|
-
var rulesMap map[string]any
|
|
818
|
-
if hasRules {
|
|
819
|
-
typed, ok := rulesValue.(map[string]any)
|
|
820
|
-
if !ok {
|
|
821
|
-
return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
|
|
822
|
-
}
|
|
823
|
-
rulesMap = typed
|
|
824
|
-
}
|
|
825
|
-
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
826
|
-
if len(merged) == 0 {
|
|
827
|
-
return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
|
|
828
|
-
}
|
|
829
|
-
cfg, opts, err := ParseRulesWithOptions(merged)
|
|
830
|
-
if err != nil {
|
|
831
|
-
return nil, err
|
|
832
|
-
}
|
|
833
|
-
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
834
|
-
}
|
|
835
|
-
if hasExtends {
|
|
836
|
-
extendsStr, ok := extendsValue.(string)
|
|
729
|
+
if configFileValue, ok := inline["configFile"]; ok {
|
|
730
|
+
configFile, ok := configFileValue.(string)
|
|
837
731
|
if !ok {
|
|
838
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
732
|
+
return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must be a string path, got %T", configFileValue)
|
|
839
733
|
}
|
|
840
|
-
if strings.TrimSpace(
|
|
841
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
842
|
-
}
|
|
843
|
-
location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
|
|
844
|
-
return loadExternalConfigResolver(location)
|
|
845
|
-
}
|
|
846
|
-
if hasLegacy {
|
|
847
|
-
emitLegacyConfigDeprecation()
|
|
848
|
-
switch typed := legacyValue.(type) {
|
|
849
|
-
case string:
|
|
850
|
-
if strings.TrimSpace(typed) == "" {
|
|
851
|
-
return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
|
|
852
|
-
}
|
|
853
|
-
location := resolveConfigFilePath(typed, cwd, tsconfigPath)
|
|
854
|
-
return loadExternalConfigResolver(location)
|
|
855
|
-
case map[string]any:
|
|
856
|
-
cfg, opts, err := ParseRulesWithOptions(typed)
|
|
857
|
-
if err != nil {
|
|
858
|
-
return nil, err
|
|
859
|
-
}
|
|
860
|
-
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
861
|
-
default:
|
|
862
|
-
return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must be a string path or object, got %T", legacyValue)
|
|
734
|
+
if strings.TrimSpace(configFile) == "" {
|
|
735
|
+
return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must not be empty")
|
|
863
736
|
}
|
|
737
|
+
location := resolveConfigFilePath(configFile, cwd, tsconfigPath)
|
|
738
|
+
return loadConfigResolver(location)
|
|
864
739
|
}
|
|
865
740
|
|
|
866
741
|
discovered, err := findLintConfigFile(cwd, tsconfigPath)
|
|
@@ -868,29 +743,22 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
|
|
|
868
743
|
return nil, err
|
|
869
744
|
}
|
|
870
745
|
if discovered == "" {
|
|
871
|
-
return nil, fmt.Errorf("@ttsc/lint:
|
|
746
|
+
return nil, fmt.Errorf("@ttsc/lint: no lint.config.* or ttsc-lint.config.* file found (searched upward from %s); create one or set \"configFile\" on the tsconfig plugin entry", cwd)
|
|
872
747
|
}
|
|
873
|
-
return
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
var legacyConfigDeprecationOnce sync.Once
|
|
877
|
-
|
|
878
|
-
func emitLegacyConfigDeprecation() {
|
|
879
|
-
legacyConfigDeprecationOnce.Do(func() {
|
|
880
|
-
fmt.Fprintln(os.Stderr, "@ttsc/lint: tsconfig plugin entry \"config\" is deprecated; use \"rules\" for inline severity maps or \"extends\" for a config file path.")
|
|
881
|
-
})
|
|
748
|
+
return loadConfigResolver(discovered)
|
|
882
749
|
}
|
|
883
750
|
|
|
884
|
-
|
|
751
|
+
// loadConfigResolver loads and parses the lint config file at `location` into
|
|
752
|
+
// a *ConfigStore and returns it as a RuleResolver.
|
|
753
|
+
func loadConfigResolver(location string) (RuleResolver, error) {
|
|
885
754
|
raw, err := loadConfigFile(location)
|
|
886
755
|
if err != nil {
|
|
887
756
|
return nil, err
|
|
888
757
|
}
|
|
889
|
-
store, err :=
|
|
758
|
+
store, err := collectConfigStore(raw, filepath.Dir(location), location)
|
|
890
759
|
if err != nil {
|
|
891
760
|
return nil, err
|
|
892
761
|
}
|
|
893
|
-
store.externalConfigPath = location
|
|
894
762
|
return store, nil
|
|
895
763
|
}
|
|
896
764
|
|
|
@@ -913,12 +781,6 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
913
781
|
"ttsc-lint.config.ts",
|
|
914
782
|
"ttsc-lint.config.mts",
|
|
915
783
|
"ttsc-lint.config.cts",
|
|
916
|
-
"eslint.config.js",
|
|
917
|
-
"eslint.config.mjs",
|
|
918
|
-
"eslint.config.cjs",
|
|
919
|
-
"eslint.config.ts",
|
|
920
|
-
"eslint.config.mts",
|
|
921
|
-
"eslint.config.cts",
|
|
922
784
|
} {
|
|
923
785
|
candidate := filepath.Join(dir, name)
|
|
924
786
|
if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
|
|
@@ -930,7 +792,7 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
930
792
|
for _, m := range matches {
|
|
931
793
|
names = append(names, filepath.Base(m))
|
|
932
794
|
}
|
|
933
|
-
return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"
|
|
795
|
+
return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"configFile\" explicitly", dir, strings.Join(names, ", "))
|
|
934
796
|
}
|
|
935
797
|
if len(matches) == 1 {
|
|
936
798
|
return matches[0], nil
|
|
@@ -943,6 +805,9 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
943
805
|
}
|
|
944
806
|
}
|
|
945
807
|
|
|
808
|
+
// resolveConfigFilePath resolves a user-supplied config path to an absolute
|
|
809
|
+
// path. Absolute paths are returned unchanged; relative paths are joined to the
|
|
810
|
+
// tsconfig directory (or cwd when no tsconfig is set).
|
|
946
811
|
func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
|
|
947
812
|
if filepath.IsAbs(configPath) {
|
|
948
813
|
return configPath
|
|
@@ -950,6 +815,10 @@ func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
|
|
|
950
815
|
return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
|
|
951
816
|
}
|
|
952
817
|
|
|
818
|
+
// discoveryConfigBaseDir returns the directory from which auto-discovery walks
|
|
819
|
+
// upward when no explicit config path is provided. Prefer the tsconfig
|
|
820
|
+
// directory over cwd so that nested package configs are found relative to the
|
|
821
|
+
// tsconfig that triggered the lint run.
|
|
953
822
|
func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
|
|
954
823
|
if tsconfigPath != "" {
|
|
955
824
|
resolvedTsconfig := tsconfigPath
|
|
@@ -961,6 +830,9 @@ func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
|
|
|
961
830
|
return cwd
|
|
962
831
|
}
|
|
963
832
|
|
|
833
|
+
// tsconfigBaseDir returns the directory that contains the tsconfig file, or
|
|
834
|
+
// cwd when tsconfigPath is empty. Used as the base for relative config paths
|
|
835
|
+
// supplied in the tsconfig plugin entry.
|
|
964
836
|
func tsconfigBaseDir(cwd, tsconfigPath string) string {
|
|
965
837
|
if tsconfigPath == "" {
|
|
966
838
|
return cwd
|
|
@@ -972,20 +844,171 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
|
|
|
972
844
|
return filepath.Dir(resolvedTsconfig)
|
|
973
845
|
}
|
|
974
846
|
|
|
847
|
+
// loadConfigFile loads and deserializes a lint config file at `location`.
|
|
848
|
+
// The file format is determined by extension: .json is parsed natively;
|
|
849
|
+
// .js/.cjs/.mjs run through a Node subprocess; .ts/.cts/.mts run through ttsx.
|
|
850
|
+
// The two subprocess-backed forms go through loadCachedConfigFile so that a
|
|
851
|
+
// monorepo build — which spawns one `ttsc` process per package — evaluates a
|
|
852
|
+
// shared lint config once instead of once per package.
|
|
975
853
|
func loadConfigFile(location string) (any, error) {
|
|
976
854
|
ext := strings.ToLower(filepath.Ext(location))
|
|
977
855
|
switch ext {
|
|
978
856
|
case ".json":
|
|
979
857
|
return loadJSONConfigFile(location)
|
|
980
858
|
case ".js", ".cjs", ".mjs":
|
|
981
|
-
return
|
|
859
|
+
return loadCachedConfigFile(location, loadScriptConfigFile)
|
|
982
860
|
case ".ts", ".cts", ".mts":
|
|
983
|
-
return
|
|
861
|
+
return loadCachedConfigFile(location, loadTypeScriptConfigFile)
|
|
984
862
|
default:
|
|
985
863
|
return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
|
|
986
864
|
}
|
|
987
865
|
}
|
|
988
866
|
|
|
867
|
+
// configCacheVersion namespaces the on-disk config cache. Bump it whenever
|
|
868
|
+
// the shape of a cached config object changes so that entries written by an
|
|
869
|
+
// older @ttsc/lint binary are treated as a miss rather than silently reused.
|
|
870
|
+
const configCacheVersion = "v1"
|
|
871
|
+
|
|
872
|
+
// configEvalCache memoizes evaluated .ts/.js lint config objects for the
|
|
873
|
+
// lifetime of one process; the on-disk cache (configCacheDir) extends the
|
|
874
|
+
// same memoization across the separate `ttsc` processes a monorepo build
|
|
875
|
+
// spawns. Guarded by configEvalCacheMu.
|
|
876
|
+
var (
|
|
877
|
+
configEvalCacheMu sync.Mutex
|
|
878
|
+
configEvalCache = map[string]any{}
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
// configCacheDir is the directory shared by this Go sidecar and the JS
|
|
882
|
+
// plugin factory (packages/lint/src/index.ts) for cached lint configs.
|
|
883
|
+
// Evaluating a .ts/.js config means spawning a ttsx/node subprocess; the
|
|
884
|
+
// cache keeps every `ttsc` invocation after the first from re-paying it.
|
|
885
|
+
func configCacheDir() string {
|
|
886
|
+
return filepath.Join(os.TempDir(), "ttsc-lint-config-cache")
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// configCacheDisabled reports whether the env opt-out is set — an escape
|
|
890
|
+
// hatch for callers that must force a fresh evaluation (e.g. a config whose
|
|
891
|
+
// behavior depends on imported non-config files that the key cannot see).
|
|
892
|
+
func configCacheDisabled() bool {
|
|
893
|
+
return os.Getenv("TTSC_LINT_DISABLE_CONFIG_CACHE") != ""
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// configCacheKey derives the cache key for a config file from a version
|
|
897
|
+
// tag, a namespace `kind`, the file's absolute path, and its exact
|
|
898
|
+
// contents. Content-addressing means an edited config invalidates cleanly
|
|
899
|
+
// with no clock-resolution race; the absolute path keeps two projects with
|
|
900
|
+
// byte-identical configs distinct; `kind` separates this sidecar's
|
|
901
|
+
// evaluated-config namespace from the JS factory's plugin-entry namespace.
|
|
902
|
+
func configCacheKey(kind, absPath string, content []byte) string {
|
|
903
|
+
h := sha256.New()
|
|
904
|
+
h.Write([]byte(configCacheVersion))
|
|
905
|
+
h.Write([]byte{0})
|
|
906
|
+
h.Write([]byte(kind))
|
|
907
|
+
h.Write([]byte{0})
|
|
908
|
+
h.Write([]byte(absPath))
|
|
909
|
+
h.Write([]byte{0})
|
|
910
|
+
h.Write(content)
|
|
911
|
+
return hex.EncodeToString(h.Sum(nil))
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// loadCachedConfigFile wraps a subprocess-backed config loader (`eval`)
|
|
915
|
+
// with the two-tier (in-process + on-disk) config cache. The cache key
|
|
916
|
+
// covers the config file's path and bytes only — a config's own `import`s
|
|
917
|
+
// of non-config files are NOT tracked, since a lint config is expected to
|
|
918
|
+
// be self-contained; set TTSC_LINT_DISABLE_CONFIG_CACHE when it is not.
|
|
919
|
+
// Errors are never cached: a failed evaluation re-runs next time.
|
|
920
|
+
func loadCachedConfigFile(location string, eval func(string) (any, error)) (any, error) {
|
|
921
|
+
if configCacheDisabled() {
|
|
922
|
+
return eval(location)
|
|
923
|
+
}
|
|
924
|
+
content, err := os.ReadFile(location)
|
|
925
|
+
if err != nil {
|
|
926
|
+
return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
|
|
927
|
+
}
|
|
928
|
+
abs := location
|
|
929
|
+
if resolved, absErr := filepath.Abs(location); absErr == nil {
|
|
930
|
+
abs = resolved
|
|
931
|
+
}
|
|
932
|
+
key := configCacheKey("config", abs, content)
|
|
933
|
+
|
|
934
|
+
configEvalCacheMu.Lock()
|
|
935
|
+
cached, ok := configEvalCache[key]
|
|
936
|
+
configEvalCacheMu.Unlock()
|
|
937
|
+
if ok {
|
|
938
|
+
return cached, nil
|
|
939
|
+
}
|
|
940
|
+
if value, hit := readConfigDiskCache(key); hit {
|
|
941
|
+
configEvalCacheMu.Lock()
|
|
942
|
+
configEvalCache[key] = value
|
|
943
|
+
configEvalCacheMu.Unlock()
|
|
944
|
+
return value, nil
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
value, err := eval(location)
|
|
948
|
+
if err != nil {
|
|
949
|
+
return nil, err
|
|
950
|
+
}
|
|
951
|
+
configEvalCacheMu.Lock()
|
|
952
|
+
configEvalCache[key] = value
|
|
953
|
+
configEvalCacheMu.Unlock()
|
|
954
|
+
writeConfigDiskCache(key, value)
|
|
955
|
+
return value, nil
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// readConfigDiskCache returns the cached config object for `key`, or
|
|
959
|
+
// (nil, false) on any miss — a missing file, an unreadable file, or
|
|
960
|
+
// content that no longer parses as a config object. Every failure is a
|
|
961
|
+
// soft miss: the caller re-evaluates rather than surfacing a cache fault.
|
|
962
|
+
func readConfigDiskCache(key string) (any, bool) {
|
|
963
|
+
body, err := os.ReadFile(filepath.Join(configCacheDir(), key+".json"))
|
|
964
|
+
if err != nil {
|
|
965
|
+
return nil, false
|
|
966
|
+
}
|
|
967
|
+
var value any
|
|
968
|
+
if err := json.Unmarshal(body, &value); err != nil {
|
|
969
|
+
return nil, false
|
|
970
|
+
}
|
|
971
|
+
if !isConfigObject(value) {
|
|
972
|
+
return nil, false
|
|
973
|
+
}
|
|
974
|
+
return value, true
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// writeConfigDiskCache stores `value` under `key`. It is best-effort: a
|
|
978
|
+
// failure to create the directory or write the file leaves the cache cold
|
|
979
|
+
// (the next run re-evaluates) rather than failing the lint run. The write
|
|
980
|
+
// goes through a temp file + rename so a concurrent reader in a sibling
|
|
981
|
+
// `ttsc` process never observes a half-written entry.
|
|
982
|
+
func writeConfigDiskCache(key string, value any) {
|
|
983
|
+
body, err := json.Marshal(value)
|
|
984
|
+
if err != nil {
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
dir := configCacheDir()
|
|
988
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
tmp, err := os.CreateTemp(dir, key+".*.tmp")
|
|
992
|
+
if err != nil {
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
tmpName := tmp.Name()
|
|
996
|
+
if _, err := tmp.Write(body); err != nil {
|
|
997
|
+
tmp.Close()
|
|
998
|
+
os.Remove(tmpName)
|
|
999
|
+
return
|
|
1000
|
+
}
|
|
1001
|
+
if err := tmp.Close(); err != nil {
|
|
1002
|
+
os.Remove(tmpName)
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
if err := os.Rename(tmpName, filepath.Join(dir, key+".json")); err != nil {
|
|
1006
|
+
os.Remove(tmpName)
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// loadJSONConfigFile reads and JSON-parses a lint config file. A leading UTF-8
|
|
1011
|
+
// BOM is stripped before parsing so files saved by Windows editors are accepted.
|
|
989
1012
|
func loadJSONConfigFile(location string) (any, error) {
|
|
990
1013
|
body, err := os.ReadFile(location)
|
|
991
1014
|
if err != nil {
|
|
@@ -1000,12 +1023,17 @@ func loadJSONConfigFile(location string) (any, error) {
|
|
|
1000
1023
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
1001
1024
|
return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
|
|
1002
1025
|
}
|
|
1003
|
-
if !
|
|
1004
|
-
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object
|
|
1026
|
+
if !isConfigObject(out) {
|
|
1027
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
|
|
1005
1028
|
}
|
|
1006
1029
|
return out, nil
|
|
1007
1030
|
}
|
|
1008
1031
|
|
|
1032
|
+
// loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
|
|
1033
|
+
// Node subprocess that dynamic-imports the file, resolves the exported config
|
|
1034
|
+
// through the same 8-hop default/config unwrap used by the TS loader, and
|
|
1035
|
+
// serializes the result as JSON to stdout. The subprocess has a
|
|
1036
|
+
// configLoaderTimeout deadline to prevent user code from hanging indefinitely.
|
|
1009
1037
|
func loadScriptConfigFile(location string) (any, error) {
|
|
1010
1038
|
const script = `
|
|
1011
1039
|
const { pathToFileURL } = require("node:url");
|
|
@@ -1032,8 +1060,8 @@ const { pathToFileURL } = require("node:url");
|
|
|
1032
1060
|
break;
|
|
1033
1061
|
}
|
|
1034
1062
|
const value = typeof current === "function" ? await current() : current;
|
|
1035
|
-
if (value === null || typeof value !== "object") {
|
|
1036
|
-
throw new Error("config file must export an object
|
|
1063
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1064
|
+
throw new Error("config file must export an ITtscLintConfig object");
|
|
1037
1065
|
}
|
|
1038
1066
|
process.stdout.write(JSON.stringify(toSerializableConfig(value)));
|
|
1039
1067
|
})().catch((error) => {
|
|
@@ -1041,114 +1069,18 @@ const { pathToFileURL } = require("node:url");
|
|
|
1041
1069
|
process.exit(1);
|
|
1042
1070
|
});
|
|
1043
1071
|
|
|
1072
|
+
// toSerializableConfig copies every ITtscLintConfig key onto a plain object so
|
|
1073
|
+
// it survives the JSON round trip to the Go sidecar. Every key is copied
|
|
1074
|
+
// verbatim — files, ignores, extends, plugins, rules, AND format — so a config
|
|
1075
|
+
// whose only key is ` + "`" + `format` + "`" + ` is not silently dropped.
|
|
1044
1076
|
function toSerializableConfig(value) {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
return value;
|
|
1050
|
-
}
|
|
1051
|
-
if (isESLintConfigObject(value)) {
|
|
1052
|
-
const out = {};
|
|
1053
|
-
if (hasESLintRuntimeFields(value)) {
|
|
1054
|
-
out.__ttscLintEslintRuntime = true;
|
|
1055
|
-
}
|
|
1056
|
-
if (Object.prototype.hasOwnProperty.call(value, "basePath")) {
|
|
1057
|
-
out.basePath = value.basePath;
|
|
1058
|
-
}
|
|
1059
|
-
if (Object.prototype.hasOwnProperty.call(value, "extends")) {
|
|
1060
|
-
out.extends = toSerializableConfig(value.extends);
|
|
1061
|
-
}
|
|
1062
|
-
if (Object.prototype.hasOwnProperty.call(value, "files")) {
|
|
1063
|
-
out.files = toSerializablePatterns(value.files, "files");
|
|
1064
|
-
}
|
|
1065
|
-
if (Object.prototype.hasOwnProperty.call(value, "ignores")) {
|
|
1066
|
-
out.ignores = toSerializablePatterns(value.ignores, "ignores");
|
|
1067
|
-
}
|
|
1068
|
-
if (Object.prototype.hasOwnProperty.call(value, "rules")) {
|
|
1069
|
-
out.rules = toSerializableRules(value.rules);
|
|
1070
|
-
}
|
|
1071
|
-
return out;
|
|
1072
|
-
}
|
|
1073
|
-
return { rules: toSerializableRules(value) };
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
function toSerializableRules(value) {
|
|
1077
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1078
|
-
throw new Error("rules must be an object");
|
|
1079
|
-
}
|
|
1080
|
-
return Object.fromEntries(Object.entries(value));
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function toSerializablePatterns(value, key) {
|
|
1084
|
-
if (typeof value === "string") {
|
|
1085
|
-
return value;
|
|
1086
|
-
}
|
|
1087
|
-
if (Array.isArray(value)) {
|
|
1088
|
-
return value.map((item, index) => {
|
|
1089
|
-
if (typeof item !== "string") {
|
|
1090
|
-
throw new Error(key + "[" + index + "] must be a string");
|
|
1091
|
-
}
|
|
1092
|
-
return item;
|
|
1093
|
-
});
|
|
1094
|
-
}
|
|
1095
|
-
throw new Error(key + " must be a string or string array");
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function isESLintConfigObject(value) {
|
|
1099
|
-
return [
|
|
1100
|
-
"basePath",
|
|
1101
|
-
"extends",
|
|
1102
|
-
"files",
|
|
1103
|
-
"ignores",
|
|
1104
|
-
"languageOptions",
|
|
1105
|
-
"linterOptions",
|
|
1106
|
-
"name",
|
|
1107
|
-
"plugins",
|
|
1108
|
-
"processor",
|
|
1109
|
-
"rules",
|
|
1110
|
-
"settings",
|
|
1111
|
-
].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
function hasESLintRuntimeFields(value) {
|
|
1115
|
-
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1116
|
-
if (Object.prototype.hasOwnProperty.call(value, key)) return true;
|
|
1117
|
-
}
|
|
1118
|
-
if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
|
|
1119
|
-
if (!isNativePluginMap(value.plugins)) return true;
|
|
1120
|
-
}
|
|
1121
|
-
return false;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function isNativePluginMap(value) {
|
|
1125
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1126
|
-
return false;
|
|
1127
|
-
}
|
|
1128
|
-
const entries = Object.values(value);
|
|
1129
|
-
if (entries.length === 0) return true;
|
|
1130
|
-
for (const entry of entries) {
|
|
1131
|
-
if (!isNativePluginValue(entry)) return false;
|
|
1132
|
-
}
|
|
1133
|
-
return true;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
function isNativePluginValue(entry) {
|
|
1137
|
-
// A non-empty string is a native specifier — JS factory resolves it
|
|
1138
|
-
// at load time, so the loader must not flip the ESLint-runtime flag.
|
|
1139
|
-
if (typeof entry === "string") return entry.length > 0;
|
|
1140
|
-
if (entry === null || typeof entry !== "object") return false;
|
|
1141
|
-
let current = entry;
|
|
1142
|
-
for (let i = 0; i < 4; i++) {
|
|
1143
|
-
if (typeof current.source === "string" && current.source.length > 0) {
|
|
1144
|
-
return true;
|
|
1145
|
-
}
|
|
1146
|
-
if (current.default === null || typeof current.default !== "object") {
|
|
1147
|
-
return false;
|
|
1077
|
+
const out = {};
|
|
1078
|
+
for (const key of ["files", "ignores", "extends", "plugins", "rules", "format"]) {
|
|
1079
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
1080
|
+
out[key] = value[key];
|
|
1148
1081
|
}
|
|
1149
|
-
current = current.default;
|
|
1150
1082
|
}
|
|
1151
|
-
return
|
|
1083
|
+
return out;
|
|
1152
1084
|
}
|
|
1153
1085
|
`
|
|
1154
1086
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
@@ -1176,12 +1108,17 @@ function isNativePluginValue(entry) {
|
|
|
1176
1108
|
if err := json.Unmarshal(output, &out); err != nil {
|
|
1177
1109
|
return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
|
|
1178
1110
|
}
|
|
1179
|
-
if !
|
|
1180
|
-
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object
|
|
1111
|
+
if !isConfigObject(out) {
|
|
1112
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
|
|
1181
1113
|
}
|
|
1182
1114
|
return out, nil
|
|
1183
1115
|
}
|
|
1184
1116
|
|
|
1117
|
+
// loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
|
|
1118
|
+
// an ephemeral loader script and tsconfig into a temp directory, symlinking the
|
|
1119
|
+
// nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
|
|
1120
|
+
// The loader script imports the config file, resolves it through the same
|
|
1121
|
+
// unwrap chain used by loadScriptConfigFile, and writes JSON to stdout.
|
|
1185
1122
|
func loadTypeScriptConfigFile(location string) (any, error) {
|
|
1186
1123
|
tempDir, err := os.MkdirTemp("", "ttsc-lint-config-")
|
|
1187
1124
|
if err != nil {
|
|
@@ -1214,6 +1151,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
|
|
|
1214
1151
|
"--project", tsconfig,
|
|
1215
1152
|
"--cwd", tempDir,
|
|
1216
1153
|
"--cache-dir", filepath.Join(tempDir, "cache"),
|
|
1154
|
+
// The loader only needs to type-check and execute the user's
|
|
1155
|
+
// `*.config.ts`; it must NOT load the host project's transform /
|
|
1156
|
+
// check plugins. Discovering them (`@nestia/core`, `typia`, …)
|
|
1157
|
+
// would run their project checks against this ephemeral loader
|
|
1158
|
+
// tsconfig — which is deliberately lenient (`strict: false`) — so a
|
|
1159
|
+
// plugin like `@nestia/core` that demands strict mode would fail
|
|
1160
|
+
// the build and abort config evaluation. `--no-plugins` makes the
|
|
1161
|
+
// ttsx build hermetic.
|
|
1162
|
+
"--no-plugins",
|
|
1217
1163
|
}
|
|
1218
1164
|
if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
|
|
1219
1165
|
args = append(args, "--binary", tsgo)
|
|
@@ -1242,21 +1188,25 @@ func loadTypeScriptConfigFile(location string) (any, error) {
|
|
|
1242
1188
|
if err := json.Unmarshal(output, &out); err != nil {
|
|
1243
1189
|
return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
|
|
1244
1190
|
}
|
|
1245
|
-
if !
|
|
1246
|
-
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object
|
|
1191
|
+
if !isConfigObject(out) {
|
|
1192
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
|
|
1247
1193
|
}
|
|
1248
1194
|
return out, nil
|
|
1249
1195
|
}
|
|
1250
1196
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1197
|
+
// isConfigObject reports whether `value` is a top-level config object. A lint
|
|
1198
|
+
// config file always exports a single `ITtscLintConfig` object; arrays and
|
|
1199
|
+
// scalars are rejected so users get a clear error instead of an opaque parse
|
|
1200
|
+
// failure downstream.
|
|
1201
|
+
func isConfigObject(value any) bool {
|
|
1202
|
+
_, ok := value.(map[string]any)
|
|
1203
|
+
return ok
|
|
1258
1204
|
}
|
|
1259
1205
|
|
|
1206
|
+
// relativeImportSpecifier computes the ESM import specifier for `location`
|
|
1207
|
+
// relative to `fromDir`. The result always starts with "./" or "../" so it is
|
|
1208
|
+
// treated as a relative path by the ESM loader rather than as a bare package
|
|
1209
|
+
// name.
|
|
1260
1210
|
func relativeImportSpecifier(fromDir, location string) (string, error) {
|
|
1261
1211
|
relative, err := filepath.Rel(fromDir, location)
|
|
1262
1212
|
if err != nil {
|
|
@@ -1269,6 +1219,11 @@ func relativeImportSpecifier(fromDir, location string) (string, error) {
|
|
|
1269
1219
|
return "./" + relative, nil
|
|
1270
1220
|
}
|
|
1271
1221
|
|
|
1222
|
+
// typeScriptConfigLoaderSource returns the TypeScript source of the ephemeral
|
|
1223
|
+
// loader script that ttsx executes to evaluate a TypeScript lint config file.
|
|
1224
|
+
// `importLiteral` is a JSON-encoded relative import path (e.g. `"./lint.config.ts"`)
|
|
1225
|
+
// that is spliced directly into the `import * as` statement, so it must
|
|
1226
|
+
// already be a valid JSON string (produced by json.Marshal).
|
|
1272
1227
|
func typeScriptConfigLoaderSource(importLiteral string) string {
|
|
1273
1228
|
return fmt.Sprintf(`import * as importedConfig from %s;
|
|
1274
1229
|
|
|
@@ -1280,8 +1235,8 @@ declare const process: {
|
|
|
1280
1235
|
|
|
1281
1236
|
try {
|
|
1282
1237
|
const value = await resolveConfig(importedConfig, true);
|
|
1283
|
-
if (!isObject(value)) {
|
|
1284
|
-
throw new Error("config file must export an object
|
|
1238
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
1239
|
+
throw new Error("config file must export an ITtscLintConfig object");
|
|
1285
1240
|
}
|
|
1286
1241
|
process.stdout.write(JSON.stringify(toSerializableConfig(value)));
|
|
1287
1242
|
} catch (error) {
|
|
@@ -1318,130 +1273,26 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
|
1318
1273
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
1319
1274
|
}
|
|
1320
1275
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
if (hasESLintRuntimeFields(value)) {
|
|
1331
|
-
out.__ttscLintEslintRuntime = true;
|
|
1276
|
+
// toSerializableConfig copies every ITtscLintConfig key onto a plain object so
|
|
1277
|
+
// it survives the JSON round trip to the Go sidecar. Every key is copied
|
|
1278
|
+
// verbatim — files, ignores, extends, plugins, rules, AND format — so a config
|
|
1279
|
+
// whose only key is "format" is not silently dropped.
|
|
1280
|
+
function toSerializableConfig(value: Record<string, unknown>): Record<string, unknown> {
|
|
1281
|
+
const out: Record<string, unknown> = {};
|
|
1282
|
+
for (const key of ["files", "ignores", "extends", "plugins", "rules", "format"]) {
|
|
1283
|
+
if (hasOwn(value, key)) {
|
|
1284
|
+
out[key] = value[key];
|
|
1332
1285
|
}
|
|
1333
|
-
if (hasOwn(value, "basePath")) {
|
|
1334
|
-
out.basePath = value.basePath;
|
|
1335
|
-
}
|
|
1336
|
-
if (hasOwn(value, "extends")) {
|
|
1337
|
-
out.extends = toSerializableConfig(value.extends);
|
|
1338
|
-
}
|
|
1339
|
-
if (hasOwn(value, "files")) {
|
|
1340
|
-
out.files = toSerializablePatterns(value.files, "files");
|
|
1341
|
-
}
|
|
1342
|
-
if (hasOwn(value, "ignores")) {
|
|
1343
|
-
out.ignores = toSerializablePatterns(value.ignores, "ignores");
|
|
1344
|
-
}
|
|
1345
|
-
if (hasOwn(value, "rules")) {
|
|
1346
|
-
out.rules = toSerializableRules(value.rules);
|
|
1347
|
-
}
|
|
1348
|
-
return out;
|
|
1349
|
-
}
|
|
1350
|
-
return { rules: toSerializableRules(value) };
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
function toSerializableRules(value: unknown): Record<string, unknown> {
|
|
1354
|
-
if (!isObject(value) || Array.isArray(value)) {
|
|
1355
|
-
throw new Error("rules must be an object");
|
|
1356
|
-
}
|
|
1357
|
-
return Object.fromEntries(Object.entries(value));
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function toSerializablePatterns(value: unknown, key: string): string | string[] {
|
|
1361
|
-
if (typeof value === "string") {
|
|
1362
|
-
return value;
|
|
1363
|
-
}
|
|
1364
|
-
if (Array.isArray(value)) {
|
|
1365
|
-
return value.map((item, index) => {
|
|
1366
|
-
if (typeof item !== "string") {
|
|
1367
|
-
throw new Error(key + "[" + index + "] must be a string");
|
|
1368
|
-
}
|
|
1369
|
-
return item;
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
throw new Error(key + " must be a string or string array");
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
function isESLintConfigObject(value: Record<string, unknown>): boolean {
|
|
1376
|
-
return [
|
|
1377
|
-
"basePath",
|
|
1378
|
-
"extends",
|
|
1379
|
-
"files",
|
|
1380
|
-
"ignores",
|
|
1381
|
-
"languageOptions",
|
|
1382
|
-
"linterOptions",
|
|
1383
|
-
"name",
|
|
1384
|
-
"plugins",
|
|
1385
|
-
"processor",
|
|
1386
|
-
"rules",
|
|
1387
|
-
"settings",
|
|
1388
|
-
].some((key) => hasOwn(value, key));
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
|
|
1392
|
-
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1393
|
-
if (hasOwn(value, key)) return true;
|
|
1394
1286
|
}
|
|
1395
|
-
|
|
1396
|
-
const plugins = value.plugins;
|
|
1397
|
-
if (!isNativePluginMap(plugins)) return true;
|
|
1398
|
-
}
|
|
1399
|
-
return false;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// isNativePluginMap reports whether every entry of a plugins map points
|
|
1403
|
-
// at a ttsc-lint native contributor (an object with a string "source"
|
|
1404
|
-
// field). Native plugins are compiled into the lint binary at build
|
|
1405
|
-
// time, so their presence does NOT require the JavaScript ESLint
|
|
1406
|
-
// runtime; only mixed or pure-ESLint plugin maps do.
|
|
1407
|
-
function isNativePluginMap(value: unknown): boolean {
|
|
1408
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1409
|
-
return false;
|
|
1410
|
-
}
|
|
1411
|
-
const entries = Object.values(value as Record<string, unknown>);
|
|
1412
|
-
if (entries.length === 0) return true;
|
|
1413
|
-
for (const entry of entries) {
|
|
1414
|
-
if (!isNativePluginValue(entry)) return false;
|
|
1415
|
-
}
|
|
1416
|
-
return true;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
function isNativePluginValue(entry: unknown): boolean {
|
|
1420
|
-
// A non-empty string is a native specifier — see the matching Go-side
|
|
1421
|
-
// and JS-loader implementations.
|
|
1422
|
-
if (typeof entry === "string") return entry.length > 0;
|
|
1423
|
-
if (entry === null || typeof entry !== "object") return false;
|
|
1424
|
-
// ESM-from-CJS interop wraps CJS modules' "exports.default" so a
|
|
1425
|
-
// contributor authored as "export default plugin" lands under a
|
|
1426
|
-
// ".default" indirection. Walk a few hops so both "export default"
|
|
1427
|
-
// and plain "module.exports = plugin" contributors register as
|
|
1428
|
-
// native here.
|
|
1429
|
-
let current = entry as Record<string, unknown>;
|
|
1430
|
-
for (let i = 0; i < 4; i++) {
|
|
1431
|
-
if (typeof current.source === "string" && (current.source as string).length > 0) {
|
|
1432
|
-
return true;
|
|
1433
|
-
}
|
|
1434
|
-
const next = current.default;
|
|
1435
|
-
if (next === null || typeof next !== "object" || Array.isArray(next)) {
|
|
1436
|
-
return false;
|
|
1437
|
-
}
|
|
1438
|
-
current = next as Record<string, unknown>;
|
|
1439
|
-
}
|
|
1440
|
-
return false;
|
|
1287
|
+
return out;
|
|
1441
1288
|
}
|
|
1442
1289
|
`, importLiteral)
|
|
1443
1290
|
}
|
|
1444
1291
|
|
|
1292
|
+
// typeScriptConfigLoaderTsconfig generates the JSON content of the ephemeral
|
|
1293
|
+
// tsconfig that compiles the loader script. Settings mirror the JS-factory
|
|
1294
|
+
// loader's lenient baseline so identical user configs evaluate the same way
|
|
1295
|
+
// from both the JS and Go sides.
|
|
1445
1296
|
func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
1446
1297
|
// Mirror the JS-factory loader's lenient settings (see the matching
|
|
1447
1298
|
// tsconfig synthesis in `packages/lint/src/index.ts::readTtsxConfigPlugins`).
|
|
@@ -1478,6 +1329,8 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
1478
1329
|
return string(body)
|
|
1479
1330
|
}
|
|
1480
1331
|
|
|
1332
|
+
// ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
|
|
1333
|
+
// ttsxCommandContext when a deadline is needed (e.g. config file loading).
|
|
1481
1334
|
func ttsxCommand(args ...string) *exec.Cmd {
|
|
1482
1335
|
return ttsxCommandContext(context.Background(), args...)
|
|
1483
1336
|
}
|
|
@@ -1501,6 +1354,9 @@ func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
|
|
|
1501
1354
|
return exec.CommandContext(ctx, ttsx, args...)
|
|
1502
1355
|
}
|
|
1503
1356
|
|
|
1357
|
+
// shouldRunTtsxThroughNode reports whether the resolved ttsx binary is a
|
|
1358
|
+
// script (JS or TS extension) rather than a compiled native executable.
|
|
1359
|
+
// Scripts must be executed via `node <binary> <args>` instead of directly.
|
|
1504
1360
|
func shouldRunTtsxThroughNode(binary string) bool {
|
|
1505
1361
|
switch strings.ToLower(filepath.Ext(binary)) {
|
|
1506
1362
|
case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
|
|
@@ -1510,6 +1366,10 @@ func shouldRunTtsxThroughNode(binary string) bool {
|
|
|
1510
1366
|
}
|
|
1511
1367
|
}
|
|
1512
1368
|
|
|
1369
|
+
// nodeConfigLoaderEnv builds the environment for a Node.js config-loader
|
|
1370
|
+
// subprocess. It prepends the nearest node_modules directory to NODE_PATH so
|
|
1371
|
+
// that imports in .js/.cjs/.mjs config files resolve correctly even when the
|
|
1372
|
+
// subprocess's cwd differs from the config file's location.
|
|
1513
1373
|
func nodeConfigLoaderEnv(location string) []string {
|
|
1514
1374
|
env := os.Environ()
|
|
1515
1375
|
parts := make([]string, 0, 2)
|
|
@@ -1525,6 +1385,11 @@ func nodeConfigLoaderEnv(location string) []string {
|
|
|
1525
1385
|
return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
|
|
1526
1386
|
}
|
|
1527
1387
|
|
|
1388
|
+
// linkNearestNodeModules creates a node_modules symlink (or Windows junction)
|
|
1389
|
+
// inside `tempDir` that points at the nearest node_modules directory found
|
|
1390
|
+
// upward from `sourceDir`. This lets the TypeScript config loader resolve
|
|
1391
|
+
// imports from the user's project without copying the entire module tree.
|
|
1392
|
+
// If no node_modules directory exists, the function is a no-op.
|
|
1528
1393
|
func linkNearestNodeModules(tempDir, sourceDir string) error {
|
|
1529
1394
|
nodeModules := findNearestNodeModules(sourceDir)
|
|
1530
1395
|
if nodeModules == "" {
|
|
@@ -1550,6 +1415,10 @@ func linkNearestNodeModules(tempDir, sourceDir string) error {
|
|
|
1550
1415
|
return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
|
|
1551
1416
|
}
|
|
1552
1417
|
|
|
1418
|
+
// createWindowsJunction creates a directory junction at `link` pointing at
|
|
1419
|
+
// `target` using `cmd /c mklink /J`. Junctions do not require elevated
|
|
1420
|
+
// privileges (unlike symlinks on Windows), making them the right fallback when
|
|
1421
|
+
// os.Symlink fails.
|
|
1553
1422
|
func createWindowsJunction(link, target string) error {
|
|
1554
1423
|
// `cmd /c mklink /J link target` is the standard recipe and works
|
|
1555
1424
|
// without elevated privileges. Both arguments must be absolute paths
|
|
@@ -1561,6 +1430,9 @@ func createWindowsJunction(link, target string) error {
|
|
|
1561
1430
|
return nil
|
|
1562
1431
|
}
|
|
1563
1432
|
|
|
1433
|
+
// findNearestNodeModules walks upward from `start` and returns the first
|
|
1434
|
+
// node_modules directory found, or the empty string if the filesystem root is
|
|
1435
|
+
// reached without a match.
|
|
1564
1436
|
func findNearestNodeModules(start string) string {
|
|
1565
1437
|
dir := filepath.Clean(start)
|
|
1566
1438
|
for {
|
|
@@ -1576,6 +1448,9 @@ func findNearestNodeModules(start string) string {
|
|
|
1576
1448
|
}
|
|
1577
1449
|
}
|
|
1578
1450
|
|
|
1451
|
+
// setEnv updates an existing key=value entry in `env` (in-place) or appends
|
|
1452
|
+
// a new one. It is intentionally a pure-slice helper — no os.Setenv side
|
|
1453
|
+
// effects — so callers can pass it directly to exec.Cmd.Env.
|
|
1579
1454
|
func setEnv(env []string, key, value string) []string {
|
|
1580
1455
|
prefix := key + "="
|
|
1581
1456
|
for i, entry := range env {
|
|
@@ -1587,44 +1462,16 @@ func setEnv(env []string, key, value string) []string {
|
|
|
1587
1462
|
return append(env, prefix+value)
|
|
1588
1463
|
}
|
|
1589
1464
|
|
|
1590
|
-
// parseExternalRuleEntry
|
|
1591
|
-
//
|
|
1592
|
-
// literal, `[severity]`, or `[severity, options]`. Tuples longer than
|
|
1593
|
-
// two elements are rejected because no built-in rule's option struct
|
|
1594
|
-
// models positional ESLint args, so silently encoding the tail as a
|
|
1595
|
-
// JSON array would land in `DecodeOptions` and fall back to defaults
|
|
1596
|
-
// — a silent fallback the standard parser deliberately avoids.
|
|
1465
|
+
// parseExternalRuleEntry delegates to parseRuleEntry. It is kept under this
|
|
1466
|
+
// name because test files in the same package call it directly.
|
|
1597
1467
|
func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
|
|
1598
|
-
|
|
1599
|
-
if len(tuple) == 0 {
|
|
1600
|
-
return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
|
|
1601
|
-
}
|
|
1602
|
-
sev, err := parseSeverity(tuple[0])
|
|
1603
|
-
if err != nil {
|
|
1604
|
-
return SeverityOff, nil, err
|
|
1605
|
-
}
|
|
1606
|
-
if len(tuple) == 1 {
|
|
1607
|
-
return sev, nil, nil
|
|
1608
|
-
}
|
|
1609
|
-
if len(tuple) > 2 {
|
|
1610
|
-
return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
|
|
1611
|
-
}
|
|
1612
|
-
if tuple[1] == nil {
|
|
1613
|
-
return sev, nil, nil
|
|
1614
|
-
}
|
|
1615
|
-
if _, ok := tuple[1].(map[string]any); !ok {
|
|
1616
|
-
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
|
|
1617
|
-
}
|
|
1618
|
-
encoded, err := json.Marshal(tuple[1])
|
|
1619
|
-
if err != nil {
|
|
1620
|
-
return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
|
|
1621
|
-
}
|
|
1622
|
-
return sev, encoded, nil
|
|
1623
|
-
}
|
|
1624
|
-
sev, err := parseSeverity(v)
|
|
1625
|
-
return sev, nil, err
|
|
1468
|
+
return parseRuleEntry(v)
|
|
1626
1469
|
}
|
|
1627
1470
|
|
|
1471
|
+
// parseSeverity converts a raw config value to a Severity. Accepts the string
|
|
1472
|
+
// literals "off", "warn"/"warning", "error" and the numeric equivalents 0, 1,
|
|
1473
|
+
// 2 (the ESLint convention). Any other value is a hard error — there is no
|
|
1474
|
+
// silent fallback so typos are surfaced immediately.
|
|
1628
1475
|
func parseSeverity(v any) (Severity, error) {
|
|
1629
1476
|
switch x := v.(type) {
|
|
1630
1477
|
case string:
|
|
@@ -1651,6 +1498,9 @@ func parseSeverity(v any) (Severity, error) {
|
|
|
1651
1498
|
return SeverityOff, fmt.Errorf("severity must be one of: off | warn | warning | error | 0 | 1 | 2, got %T", v)
|
|
1652
1499
|
}
|
|
1653
1500
|
|
|
1501
|
+
// sortedRuleNames returns the sorted slice of rule names from `config` for
|
|
1502
|
+
// which `include` returns true. Sorting ensures deterministic dispatch-table
|
|
1503
|
+
// ordering so test output and diagnostic ordering are stable across runs.
|
|
1654
1504
|
func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
|
|
1655
1505
|
names := make([]string, 0, len(config))
|
|
1656
1506
|
for name, sev := range config {
|
|
@@ -1662,6 +1512,12 @@ func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
|
|
|
1662
1512
|
return names
|
|
1663
1513
|
}
|
|
1664
1514
|
|
|
1515
|
+
// matchAnyPattern reports whether `fileName` matches at least one of the
|
|
1516
|
+
// provided glob patterns. If baseDir is non-empty, both paths are made
|
|
1517
|
+
// absolute before computing a relative path so that glob patterns rooted at
|
|
1518
|
+
// the config file's directory match correctly regardless of the process cwd.
|
|
1519
|
+
// Files outside the base directory never match (the relative path would start
|
|
1520
|
+
// with "..").
|
|
1665
1521
|
func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
|
|
1666
1522
|
rel := filepath.ToSlash(fileName)
|
|
1667
1523
|
if baseDir != "" {
|
|
@@ -1689,6 +1545,10 @@ func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
|
|
|
1689
1545
|
return false
|
|
1690
1546
|
}
|
|
1691
1547
|
|
|
1548
|
+
// normalizeGlobPattern normalizes a user-supplied glob pattern to forward
|
|
1549
|
+
// slashes and strips a leading "./". Patterns that contain no slash are treated
|
|
1550
|
+
// as basename-only globs by prepending "**/" so that `*.ts` matches any
|
|
1551
|
+
// TypeScript file regardless of directory depth, matching ESLint's behavior.
|
|
1692
1552
|
func normalizeGlobPattern(pattern string) string {
|
|
1693
1553
|
pattern = filepath.ToSlash(pattern)
|
|
1694
1554
|
pattern = strings.TrimPrefix(pattern, "./")
|
|
@@ -1698,20 +1558,120 @@ func normalizeGlobPattern(pattern string) string {
|
|
|
1698
1558
|
return pattern
|
|
1699
1559
|
}
|
|
1700
1560
|
|
|
1561
|
+
// matchGlob tests whether `name` matches `pattern` using the ESLint-compatible
|
|
1562
|
+
// glob semantics implemented by matchGlobParts. Both strings are trimmed of
|
|
1563
|
+
// leading/trailing slashes before splitting on "/" so that empty segments do
|
|
1564
|
+
// not appear in the part slices. Brace alternatives (`{a,b,c}`) are expanded
|
|
1565
|
+
// before matching so patterns like `src/foo/{a.ts,b.ts}` reach every branch —
|
|
1566
|
+
// Go's `filepath.Match` does not honor brace expansion natively.
|
|
1701
1567
|
func matchGlob(pattern, name string) bool {
|
|
1702
1568
|
pattern = strings.Trim(pattern, "/")
|
|
1703
1569
|
name = strings.Trim(name, "/")
|
|
1704
1570
|
if pattern == "" {
|
|
1705
1571
|
return name == ""
|
|
1706
1572
|
}
|
|
1707
|
-
patternParts := strings.Split(pattern, "/")
|
|
1708
1573
|
nameParts := []string{}
|
|
1709
1574
|
if name != "" {
|
|
1710
1575
|
nameParts = strings.Split(name, "/")
|
|
1711
1576
|
}
|
|
1712
|
-
|
|
1577
|
+
for _, expanded := range expandBraces(pattern) {
|
|
1578
|
+
if matchGlobParts(strings.Split(expanded, "/"), nameParts) {
|
|
1579
|
+
return true
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
return false
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// expandBraces expands shell-style brace alternatives (`{a,b,c}`) in `pattern`
|
|
1586
|
+
// into the equivalent flat list of patterns. The expansion is recursive: a
|
|
1587
|
+
// pattern with multiple brace groups produces the Cartesian product across all
|
|
1588
|
+
// groups. Patterns with no braces, or with an unmatched opening `{`, are
|
|
1589
|
+
// returned unchanged so that callers can treat the result as an authoritative
|
|
1590
|
+
// list of every concrete alternative the user wrote.
|
|
1591
|
+
//
|
|
1592
|
+
// Only top-level braces are recognized; nested braces inside another brace
|
|
1593
|
+
// group's alternative are honored by the recursion in alternative expansion,
|
|
1594
|
+
// but escaped braces (`\{`, `\}`) are not currently supported because lint
|
|
1595
|
+
// config patterns have no reason to embed literal braces. If a user ever needs
|
|
1596
|
+
// one, the simplest workaround is to author the glob without the brace group.
|
|
1597
|
+
func expandBraces(pattern string) []string {
|
|
1598
|
+
open := strings.IndexByte(pattern, '{')
|
|
1599
|
+
if open < 0 {
|
|
1600
|
+
return []string{pattern}
|
|
1601
|
+
}
|
|
1602
|
+
// Find the matching close brace, accounting for nested groups so the
|
|
1603
|
+
// outermost group is split first. A pattern with no matching close brace
|
|
1604
|
+
// is treated as a literal — return it unchanged. `closeIdx` shadows no
|
|
1605
|
+
// builtin (unlike the natural `close` name), which keeps `go vet` quiet.
|
|
1606
|
+
depth := 0
|
|
1607
|
+
closeIdx := -1
|
|
1608
|
+
for i := open; i < len(pattern); i++ {
|
|
1609
|
+
switch pattern[i] {
|
|
1610
|
+
case '{':
|
|
1611
|
+
depth++
|
|
1612
|
+
case '}':
|
|
1613
|
+
depth--
|
|
1614
|
+
if depth == 0 {
|
|
1615
|
+
closeIdx = i
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
if closeIdx >= 0 {
|
|
1619
|
+
break
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if closeIdx < 0 {
|
|
1623
|
+
return []string{pattern}
|
|
1624
|
+
}
|
|
1625
|
+
prefix := pattern[:open]
|
|
1626
|
+
suffix := pattern[closeIdx+1:]
|
|
1627
|
+
// Split the brace body on top-level commas so nested groups remain
|
|
1628
|
+
// intact for the recursive expansion below.
|
|
1629
|
+
body := pattern[open+1 : closeIdx]
|
|
1630
|
+
alternatives := splitBraceAlternatives(body)
|
|
1631
|
+
// Expand each alternative against the suffix; the suffix may itself
|
|
1632
|
+
// contain further brace groups, which the recursive call handles.
|
|
1633
|
+
suffixExpansions := expandBraces(suffix)
|
|
1634
|
+
out := make([]string, 0, len(alternatives)*len(suffixExpansions))
|
|
1635
|
+
for _, alt := range alternatives {
|
|
1636
|
+
for _, altExpanded := range expandBraces(alt) {
|
|
1637
|
+
for _, suf := range suffixExpansions {
|
|
1638
|
+
out = append(out, prefix+altExpanded+suf)
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return out
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// splitBraceAlternatives splits the body of a brace group on top-level commas.
|
|
1646
|
+
// Commas inside a nested `{...}` are not separators — the matching close brace
|
|
1647
|
+
// is tracked so `a,{b,c},d` splits into three alternatives, not four.
|
|
1648
|
+
func splitBraceAlternatives(body string) []string {
|
|
1649
|
+
out := []string{}
|
|
1650
|
+
depth := 0
|
|
1651
|
+
start := 0
|
|
1652
|
+
for i := 0; i < len(body); i++ {
|
|
1653
|
+
switch body[i] {
|
|
1654
|
+
case '{':
|
|
1655
|
+
depth++
|
|
1656
|
+
case '}':
|
|
1657
|
+
if depth > 0 {
|
|
1658
|
+
depth--
|
|
1659
|
+
}
|
|
1660
|
+
case ',':
|
|
1661
|
+
if depth == 0 {
|
|
1662
|
+
out = append(out, body[start:i])
|
|
1663
|
+
start = i + 1
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
out = append(out, body[start:])
|
|
1668
|
+
return out
|
|
1713
1669
|
}
|
|
1714
1670
|
|
|
1671
|
+
// matchGlobParts recursively matches path segments against pattern segments.
|
|
1672
|
+
// A "**" segment matches zero or more path segments (greedy: tries zero first,
|
|
1673
|
+
// then each successive prefix) so that `**/*.ts` matches both `a.ts` and
|
|
1674
|
+
// `dir/a.ts`.
|
|
1715
1675
|
func matchGlobParts(patternParts, nameParts []string) bool {
|
|
1716
1676
|
if len(patternParts) == 0 {
|
|
1717
1677
|
return len(nameParts) == 0
|