@ttsc/lint 0.12.4 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +225 -135
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
- package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
- package/linthost/ast_helpers.go +68 -16
- package/linthost/compile.go +117 -119
- package/linthost/config.go +518 -707
- package/linthost/config_format.go +16 -4
- package/linthost/contrib_adapter.go +7 -0
- package/linthost/directives.go +44 -0
- package/linthost/engine.go +152 -44
- package/linthost/fix.go +24 -33
- package/linthost/flags_gen.go +33 -0
- package/linthost/format.go +96 -3
- package/linthost/host.go +144 -8
- package/linthost/print_dispatch.go +121 -23
- package/linthost/print_doc.go +19 -0
- package/linthost/print_engine.go +168 -4
- package/linthost/print_nodes_array.go +17 -7
- package/linthost/print_nodes_call.go +129 -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_escape.go +20 -3
- package/linthost/rules_format_print_width.go +267 -15
- package/linthost/rules_gap.go +55 -3
- package/linthost/rules_logic.go +64 -5
- package/linthost/rules_problems.go +65 -21
- package/linthost/rules_promise.go +3 -0
- package/linthost/rules_suggestions.go +160 -5
- package/linthost/rules_var.go +7 -1
- package/package.json +3 -3
- package/src/index.ts +243 -168
- package/src/structures/ITtscLintPluginConfig.ts +10 -83
- package/src/structures/TtscLintRuleOptions.ts +15 -0
- package/linthost/eslint_runtime.go +0 -351
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,23 +87,24 @@ 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
|
|
|
101
103
|
// RuleResolver is the engine-facing view of a resolved lint configuration.
|
|
102
|
-
// Implementations include RuleConfig (severity-only, no options),
|
|
103
|
-
// (
|
|
104
|
-
//
|
|
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).
|
|
105
108
|
type RuleResolver interface {
|
|
106
109
|
// ResolveRules returns the effective severity map for the given source file.
|
|
107
110
|
// Implementations that support `files`/`ignores` patterns apply them here;
|
|
@@ -144,13 +147,13 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
|
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
// RuleOptions on a bare RuleConfig is always nil — this form is the
|
|
147
|
-
// severity-only path used by Go unit tests and
|
|
148
|
-
//
|
|
150
|
+
// severity-only path used by Go unit tests and rule constructors that
|
|
151
|
+
// predate option support.
|
|
149
152
|
func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
|
|
150
153
|
|
|
151
|
-
// InlineRuleResolver pairs a severity map with an options map
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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.
|
|
154
157
|
type InlineRuleResolver struct {
|
|
155
158
|
Rules RuleConfig
|
|
156
159
|
Options RuleOptionsMap
|
|
@@ -182,16 +185,18 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
|
|
|
182
185
|
return r.Options[name]
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
// ConfigStore holds the parsed representation of
|
|
186
|
-
//
|
|
188
|
+
// ConfigStore holds the parsed representation of a lint config file. It
|
|
189
|
+
// implements RuleResolver with per-file glob scoping: ResolveRules walks the
|
|
187
190
|
// entries in declaration order and the last matching entry wins. Options are
|
|
188
191
|
// intentionally NOT per-file — one project-wide options map is kept so rule
|
|
189
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.
|
|
190
198
|
type ConfigStore struct {
|
|
191
|
-
entries
|
|
192
|
-
externalConfigPath string
|
|
193
|
-
eslintRuntime bool
|
|
194
|
-
eslintRuntimeRequired bool
|
|
199
|
+
entries []ConfigEntry
|
|
195
200
|
// options is a flat rule-name → JSON map. Options are not scoped by
|
|
196
201
|
// `files` / `ignores`: a rule's behavior is a single project-wide
|
|
197
202
|
// configuration even when its severity is per-file. The simplification
|
|
@@ -208,10 +213,11 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
|
|
|
208
213
|
return s.options[name]
|
|
209
214
|
}
|
|
210
215
|
|
|
211
|
-
// ConfigEntry is
|
|
212
|
-
// resolution; Files and Ignores are the
|
|
213
|
-
// marks entries that carry only `ignores` (no `files`, no `rules`)
|
|
214
|
-
// are evaluated first in ResolveRules and short-circuit the walk when
|
|
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.
|
|
215
221
|
type ConfigEntry struct {
|
|
216
222
|
BaseDir string
|
|
217
223
|
Files []string
|
|
@@ -292,9 +298,8 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
|
|
|
292
298
|
}
|
|
293
299
|
|
|
294
300
|
// Flatten returns the unconstrained union of all non-ignore-only entries,
|
|
295
|
-
// including SeverityOff rules. Used by LoadRuleConfig (
|
|
296
|
-
//
|
|
297
|
-
// shadow earlier ones for the same rule name.
|
|
301
|
+
// including SeverityOff rules. Used by LoadRuleConfig (callers that expect a
|
|
302
|
+
// plain RuleConfig). Later entries shadow earlier ones for the same rule name.
|
|
298
303
|
func (s *ConfigStore) Flatten() RuleConfig {
|
|
299
304
|
out := RuleConfig{}
|
|
300
305
|
if s == nil {
|
|
@@ -311,43 +316,6 @@ func (s *ConfigStore) Flatten() RuleConfig {
|
|
|
311
316
|
return out
|
|
312
317
|
}
|
|
313
318
|
|
|
314
|
-
// ExternalConfigPath returns the absolute path of the config file that was
|
|
315
|
-
// loaded into this store, or the empty string for stores built from inline
|
|
316
|
-
// tsconfig plugin entries.
|
|
317
|
-
func (s *ConfigStore) ExternalConfigPath() string {
|
|
318
|
-
if s == nil {
|
|
319
|
-
return ""
|
|
320
|
-
}
|
|
321
|
-
return s.externalConfigPath
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// WantsESLintRuntime reports whether the config uses any ESLint-native feature
|
|
325
|
-
// (languageOptions, linterOptions, processor, settings, or a non-native plugin
|
|
326
|
-
// map) OR whether the config file itself is an eslint.config.* file. The latter
|
|
327
|
-
// check covers pure-ttsc ESLint-named configs that happen not to use runtime
|
|
328
|
-
// fields.
|
|
329
|
-
func (s *ConfigStore) WantsESLintRuntime() bool {
|
|
330
|
-
if s == nil {
|
|
331
|
-
return false
|
|
332
|
-
}
|
|
333
|
-
if s.eslintRuntime {
|
|
334
|
-
return true
|
|
335
|
-
}
|
|
336
|
-
base := filepath.Base(s.externalConfigPath)
|
|
337
|
-
return strings.HasPrefix(base, "eslint.config.")
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// RequiresESLintRuntime reports whether the config contains fields that cannot
|
|
341
|
-
// be evaluated without the JS ESLint runtime (e.g. non-native plugins, runtime
|
|
342
|
-
// language options). Unlike WantsESLintRuntime it does NOT fire for the
|
|
343
|
-
// eslint.config.* naming convention alone.
|
|
344
|
-
func (s *ConfigStore) RequiresESLintRuntime() bool {
|
|
345
|
-
if s == nil {
|
|
346
|
-
return false
|
|
347
|
-
}
|
|
348
|
-
return s.eslintRuntimeRequired
|
|
349
|
-
}
|
|
350
|
-
|
|
351
319
|
func (e ConfigEntry) matchesFile(fileName string) bool {
|
|
352
320
|
if len(e.Files) > 0 && !matchAnyPattern(e.BaseDir, e.Files, fileName) {
|
|
353
321
|
return false
|
|
@@ -362,8 +330,7 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
|
|
|
362
330
|
return len(e.Ignores) > 0 && matchAnyPattern(e.BaseDir, e.Ignores, fileName)
|
|
363
331
|
}
|
|
364
332
|
|
|
365
|
-
// ParseRules normalizes
|
|
366
|
-
// entry.
|
|
333
|
+
// ParseRules normalizes a rule severity map.
|
|
367
334
|
//
|
|
368
335
|
// Severity values:
|
|
369
336
|
// - `"off"` → SeverityOff
|
|
@@ -387,7 +354,7 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
|
|
|
387
354
|
}
|
|
388
355
|
dict, ok := raw.(map[string]any)
|
|
389
356
|
if !ok {
|
|
390
|
-
return nil, nil, fmt.Errorf("@ttsc/lint: \"
|
|
357
|
+
return nil, nil, fmt.Errorf("@ttsc/lint: \"rules\" must be an object, got %T", raw)
|
|
391
358
|
}
|
|
392
359
|
cfg := make(RuleConfig, len(dict))
|
|
393
360
|
opts := make(RuleOptionsMap)
|
|
@@ -404,11 +371,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
|
|
|
404
371
|
return cfg, opts, nil
|
|
405
372
|
}
|
|
406
373
|
|
|
407
|
-
// parseRuleEntry splits
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// 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.
|
|
412
378
|
func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
413
379
|
if tuple, ok := value.([]any); ok {
|
|
414
380
|
if len(tuple) == 0 {
|
|
@@ -428,10 +394,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
|
428
394
|
return sev, nil, nil
|
|
429
395
|
}
|
|
430
396
|
if _, ok := tuple[1].(map[string]any); !ok {
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// 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
|
|
435
400
|
// DecodeOptions as a decode error that every rule discards. Fail
|
|
436
401
|
// loudly so users discover the proper `["error", { … }]` form.
|
|
437
402
|
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
|
|
@@ -446,9 +411,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
|
446
411
|
return sev, nil, err
|
|
447
412
|
}
|
|
448
413
|
|
|
449
|
-
// parseExternalConfigRules is a convenience wrapper used by
|
|
450
|
-
//
|
|
451
|
-
//
|
|
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.
|
|
452
417
|
func parseExternalConfigRules(raw any) (RuleConfig, error) {
|
|
453
418
|
store, err := parseExternalConfigStore(raw, "")
|
|
454
419
|
if err != nil {
|
|
@@ -457,174 +422,175 @@ func parseExternalConfigRules(raw any) (RuleConfig, error) {
|
|
|
457
422
|
return store.Flatten(), nil
|
|
458
423
|
}
|
|
459
424
|
|
|
460
|
-
// parseExternalConfigStore parses `
|
|
461
|
-
//
|
|
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.
|
|
462
428
|
func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
|
|
463
|
-
return
|
|
429
|
+
return collectConfigStore(raw, configDir, "")
|
|
464
430
|
}
|
|
465
431
|
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// parseExternalConfigStoreWithRuntimeMode is the shared implementation for the
|
|
475
|
-
// two public parse variants. allowRuntimeOnly controls whether a bare string
|
|
476
|
-
// extends value is accepted (sets eslintRuntimeRequired) or rejected with an
|
|
477
|
-
// error.
|
|
478
|
-
func parseExternalConfigStoreWithRuntimeMode(raw any, configDir string, allowRuntimeOnly bool) (*ConfigStore, error) {
|
|
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) {
|
|
479
438
|
store := &ConfigStore{}
|
|
480
|
-
|
|
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 {
|
|
481
444
|
return nil, err
|
|
482
445
|
}
|
|
483
446
|
return store, nil
|
|
484
447
|
}
|
|
485
448
|
|
|
486
|
-
|
|
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 {
|
|
487
497
|
if raw == nil {
|
|
488
498
|
return nil
|
|
489
499
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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)
|
|
496
512
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
localBaseDir := baseDir
|
|
505
|
-
if rawBasePath, ok := typed["basePath"]; ok {
|
|
506
|
-
basePath, ok := rawBasePath.(string)
|
|
507
|
-
if !ok {
|
|
508
|
-
return fmt.Errorf("@ttsc/lint: %s.basePath must be a string, got %T", path, rawBasePath)
|
|
509
|
-
}
|
|
510
|
-
if filepath.IsAbs(basePath) {
|
|
511
|
-
localBaseDir = basePath
|
|
512
|
-
} else {
|
|
513
|
-
localBaseDir = filepath.Join(baseDir, basePath)
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
if hasESLintRuntimeFields(typed) {
|
|
517
|
-
store.eslintRuntime = true
|
|
518
|
-
store.eslintRuntimeRequired = true
|
|
519
|
-
}
|
|
520
|
-
if extended, ok := typed["extends"]; ok {
|
|
521
|
-
if err := collectExternalExtends(store, extended, localBaseDir, path+".extends", allowRuntimeOnly); err != nil {
|
|
522
|
-
return err
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
files, err := parsePatternList(typed["files"], path+".files")
|
|
526
|
-
if err != nil {
|
|
527
|
-
return err
|
|
528
|
-
}
|
|
529
|
-
ignores, err := parsePatternList(typed["ignores"], path+".ignores")
|
|
530
|
-
if err != nil {
|
|
531
|
-
return err
|
|
532
|
-
}
|
|
533
|
-
rulesValue, hasRules := typed["rules"]
|
|
534
|
-
formatValue, hasFormat := typed["format"]
|
|
535
|
-
if hasRules || hasFormat {
|
|
536
|
-
// Expand the format block (if any) into a rules-shaped map,
|
|
537
|
-
// then overlay any explicit `rules` entries. `rules`-wins
|
|
538
|
-
// semantics match the inline path; the conflict-resolution
|
|
539
|
-
// table is identical regardless of which surface a user
|
|
540
|
-
// chose.
|
|
541
|
-
var formatRulesRaw map[string]any
|
|
542
|
-
if hasFormat {
|
|
543
|
-
fmtMap, ok := formatValue.(map[string]any)
|
|
544
|
-
if !ok {
|
|
545
|
-
return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
|
|
546
|
-
}
|
|
547
|
-
expanded, err := expandFormatBlock(fmtMap)
|
|
548
|
-
if err != nil {
|
|
549
|
-
return err
|
|
550
|
-
}
|
|
551
|
-
formatRulesRaw = expanded
|
|
552
|
-
}
|
|
553
|
-
var rulesMap map[string]any
|
|
554
|
-
if hasRules {
|
|
555
|
-
// `parseExternalRuleMapInto` accepts the raw map directly.
|
|
556
|
-
// Coerce here to feed the same merge pipeline as the
|
|
557
|
-
// inline path.
|
|
558
|
-
typedMap, ok := rulesValue.(map[string]any)
|
|
559
|
-
if !ok {
|
|
560
|
-
return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
|
|
561
|
-
}
|
|
562
|
-
rulesMap = typedMap
|
|
563
|
-
}
|
|
564
|
-
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
565
|
-
if len(merged) == 0 {
|
|
566
|
-
return nil
|
|
567
|
-
}
|
|
568
|
-
parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
|
|
569
|
-
if err != nil {
|
|
570
|
-
return err
|
|
571
|
-
}
|
|
572
|
-
store.entries = append(store.entries, ConfigEntry{
|
|
573
|
-
BaseDir: localBaseDir,
|
|
574
|
-
Files: files,
|
|
575
|
-
Ignores: ignores,
|
|
576
|
-
Rules: parsed,
|
|
577
|
-
})
|
|
578
|
-
return nil
|
|
579
|
-
}
|
|
580
|
-
if len(files) == 0 && len(ignores) > 0 {
|
|
581
|
-
store.entries = append(store.entries, ConfigEntry{
|
|
582
|
-
BaseDir: localBaseDir,
|
|
583
|
-
Ignores: ignores,
|
|
584
|
-
IgnoreOnly: true,
|
|
585
|
-
})
|
|
586
|
-
}
|
|
587
|
-
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)
|
|
588
519
|
}
|
|
589
|
-
|
|
520
|
+
location = filepath.Clean(location)
|
|
521
|
+
extendedChain, err := appendExtendsLink(chain, location)
|
|
590
522
|
if err != nil {
|
|
591
523
|
return err
|
|
592
524
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
+
}
|
|
600
532
|
}
|
|
601
|
-
}
|
|
602
533
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
610
561
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
store.eslintRuntime = true
|
|
617
|
-
store.eslintRuntimeRequired = true
|
|
618
|
-
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)
|
|
619
567
|
}
|
|
620
|
-
|
|
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 {
|
|
621
574
|
return err
|
|
622
575
|
}
|
|
576
|
+
store.entries = append(store.entries, ConfigEntry{
|
|
577
|
+
BaseDir: baseDir,
|
|
578
|
+
Files: files,
|
|
579
|
+
Ignores: ignores,
|
|
580
|
+
Rules: parsed,
|
|
581
|
+
})
|
|
623
582
|
}
|
|
624
583
|
return nil
|
|
625
|
-
default:
|
|
626
|
-
return collectExternalConfigEntries(store, raw, baseDir, path, allowRuntimeOnly)
|
|
627
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
|
|
628
594
|
}
|
|
629
595
|
|
|
630
596
|
// parseExternalRuleMapInto parses the rules map and folds any
|
|
@@ -643,7 +609,7 @@ func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleCon
|
|
|
643
609
|
|
|
644
610
|
// collectExternalRuleMapWithOptions also records the rule's options blob
|
|
645
611
|
// when the entry is a `[severity, options]` tuple. `opts` may be nil
|
|
646
|
-
// when the caller does not need option capture
|
|
612
|
+
// when the caller does not need option capture.
|
|
647
613
|
func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
|
|
648
614
|
dict, ok := raw.(map[string]any)
|
|
649
615
|
if !ok {
|
|
@@ -663,109 +629,24 @@ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw
|
|
|
663
629
|
return nil
|
|
664
630
|
}
|
|
665
631
|
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
func
|
|
670
|
-
|
|
671
|
-
"
|
|
672
|
-
"
|
|
673
|
-
"
|
|
674
|
-
"
|
|
675
|
-
"
|
|
676
|
-
"
|
|
677
|
-
"linterOptions",
|
|
678
|
-
"name",
|
|
679
|
-
"plugins",
|
|
680
|
-
"processor",
|
|
681
|
-
"rules",
|
|
682
|
-
"settings",
|
|
683
|
-
"__ttscLintEslintRuntime",
|
|
684
|
-
} {
|
|
685
|
-
if _, ok := value[key]; ok {
|
|
686
|
-
return true
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
return false
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// hasESLintRuntimeFields reports whether `value` contains keys that require the
|
|
693
|
-
// JS ESLint runtime to evaluate: languageOptions, linterOptions, processor,
|
|
694
|
-
// settings, or a plugins map that is not purely native contributors.
|
|
695
|
-
func hasESLintRuntimeFields(value map[string]any) bool {
|
|
696
|
-
for _, key := range []string{
|
|
697
|
-
"languageOptions",
|
|
698
|
-
"linterOptions",
|
|
699
|
-
"processor",
|
|
700
|
-
"settings",
|
|
701
|
-
} {
|
|
702
|
-
if _, ok := value[key]; ok {
|
|
703
|
-
return true
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
if plugins, ok := value["plugins"]; ok {
|
|
707
|
-
if !isNativePluginMap(plugins) {
|
|
708
|
-
return true
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
return false
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// isNativePluginMap reports whether every entry in a flat-config
|
|
715
|
-
// `plugins` map points at a ttsc-lint native contributor object
|
|
716
|
-
// (carrying a non-empty string `source` field). Native contributors are
|
|
717
|
-
// compiled into the lint binary at build time and require no JS ESLint
|
|
718
|
-
// runtime; only mixed or pure-ESLint plugin maps require the runtime
|
|
719
|
-
// fallback.
|
|
720
|
-
func isNativePluginMap(value any) bool {
|
|
721
|
-
dict, ok := value.(map[string]any)
|
|
722
|
-
if !ok {
|
|
723
|
-
return false
|
|
724
|
-
}
|
|
725
|
-
if len(dict) == 0 {
|
|
726
|
-
return true
|
|
727
|
-
}
|
|
728
|
-
for _, entry := range dict {
|
|
729
|
-
if !isNativePluginValue(entry) {
|
|
730
|
-
return false
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
return true
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
func isNativePluginValue(entry any) bool {
|
|
737
|
-
if entry == nil {
|
|
738
|
-
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": {},
|
|
739
643
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
|
|
744
|
-
// configs and the JSON-only `readJsonConfigPlugins` path). The JS
|
|
745
|
-
// factory resolves the specifier at load time and bakes the
|
|
746
|
-
// contributor into the binary, so the Go sidecar should not flip
|
|
747
|
-
// `eslintRuntimeRequired` for a file that already declared a
|
|
748
|
-
// native specifier.
|
|
749
|
-
return typed != ""
|
|
750
|
-
case map[string]any:
|
|
751
|
-
// Walk ESM-from-CJS `.default` indirection so a contributor authored
|
|
752
|
-
// as `export default plugin` registers as native here, matching the
|
|
753
|
-
// JS factory's `extractPluginSource` behavior.
|
|
754
|
-
current := typed
|
|
755
|
-
for i := 0; i < 4; i++ {
|
|
756
|
-
if source, ok := current["source"].(string); ok && source != "" {
|
|
757
|
-
return true
|
|
758
|
-
}
|
|
759
|
-
next, ok := current["default"].(map[string]any)
|
|
760
|
-
if !ok {
|
|
761
|
-
return false
|
|
762
|
-
}
|
|
763
|
-
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)
|
|
764
647
|
}
|
|
765
|
-
return false
|
|
766
|
-
default:
|
|
767
|
-
return false
|
|
768
648
|
}
|
|
649
|
+
return nil
|
|
769
650
|
}
|
|
770
651
|
|
|
771
652
|
// normalizeExternalRuleName strips the standard typescript-eslint namespace
|
|
@@ -807,10 +688,9 @@ func parsePatternList(raw any, path string) ([]string, error) {
|
|
|
807
688
|
}
|
|
808
689
|
}
|
|
809
690
|
|
|
810
|
-
// LoadRuleConfig resolves the lint config for one plugin entry
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
// 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.
|
|
814
694
|
func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
|
|
815
695
|
resolver, err := LoadConfigResolver(entry, cwd, tsconfigPath)
|
|
816
696
|
if err != nil {
|
|
@@ -826,22 +706,17 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
|
|
|
826
706
|
}
|
|
827
707
|
}
|
|
828
708
|
|
|
829
|
-
// LoadConfigResolver resolves one plugin entry into the engine-facing
|
|
830
|
-
//
|
|
709
|
+
// LoadConfigResolver resolves one plugin entry into the engine-facing config
|
|
710
|
+
// model.
|
|
831
711
|
//
|
|
832
|
-
//
|
|
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.
|
|
833
717
|
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
// - `config` (legacy) — accepts the same string-or-map values but
|
|
837
|
-
// emits a one-time stderr deprecation notice. Removed in a future
|
|
838
|
-
// minor.
|
|
839
|
-
//
|
|
840
|
-
// `rules` and `extends` are mutually exclusive on a single plugin
|
|
841
|
-
// entry; mixing legacy `config` with either new field is rejected.
|
|
842
|
-
// `configFile` and `configPath` remain reserved keywords surfaced with
|
|
843
|
-
// a hint pointing at `extends`, in case a user mistakenly reaches for
|
|
844
|
-
// 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.
|
|
845
720
|
func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
|
|
846
721
|
if entry == nil {
|
|
847
722
|
return RuleConfig{}, nil
|
|
@@ -850,102 +725,17 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
|
|
|
850
725
|
if inline == nil {
|
|
851
726
|
inline = map[string]any{}
|
|
852
727
|
}
|
|
853
|
-
for _, key := range []string{"configFile", "configPath"} {
|
|
854
|
-
if _, ok := inline[key]; ok {
|
|
855
|
-
return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
rulesValue, hasRules := inline["rules"]
|
|
860
|
-
extendsValue, hasExtends := inline["extends"]
|
|
861
|
-
legacyValue, hasLegacy := inline["config"]
|
|
862
|
-
formatValue, hasFormat := inline["format"]
|
|
863
|
-
|
|
864
|
-
if hasLegacy && (hasRules || hasExtends || hasFormat) {
|
|
865
|
-
sibling := ""
|
|
866
|
-
switch {
|
|
867
|
-
case hasFormat && !hasRules && !hasExtends:
|
|
868
|
-
sibling = "format"
|
|
869
|
-
case hasExtends && !hasRules && !hasFormat:
|
|
870
|
-
sibling = "extends"
|
|
871
|
-
case hasRules && !hasExtends && !hasFormat:
|
|
872
|
-
sibling = "rules"
|
|
873
|
-
default:
|
|
874
|
-
sibling = "rules/extends/format"
|
|
875
|
-
}
|
|
876
|
-
return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
|
|
877
|
-
}
|
|
878
|
-
if hasRules && hasExtends {
|
|
879
|
-
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")
|
|
880
|
-
}
|
|
881
|
-
if hasFormat && hasExtends {
|
|
882
|
-
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")
|
|
883
|
-
}
|
|
884
728
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if hasFormat {
|
|
888
|
-
formatMap, ok := formatValue.(map[string]any)
|
|
889
|
-
if !ok {
|
|
890
|
-
return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
|
|
891
|
-
}
|
|
892
|
-
expanded, err := expandFormatBlock(formatMap)
|
|
893
|
-
if err != nil {
|
|
894
|
-
return nil, err
|
|
895
|
-
}
|
|
896
|
-
formatRulesRaw = expanded
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if hasRules || hasFormat {
|
|
900
|
-
// Merge format defaults with inline rule overrides; the `rules`
|
|
901
|
-
// entry wins on key collisions (more explicit surface).
|
|
902
|
-
var rulesMap map[string]any
|
|
903
|
-
if hasRules {
|
|
904
|
-
typed, ok := rulesValue.(map[string]any)
|
|
905
|
-
if !ok {
|
|
906
|
-
return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
|
|
907
|
-
}
|
|
908
|
-
rulesMap = typed
|
|
909
|
-
}
|
|
910
|
-
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
911
|
-
if len(merged) == 0 {
|
|
912
|
-
return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
|
|
913
|
-
}
|
|
914
|
-
cfg, opts, err := ParseRulesWithOptions(merged)
|
|
915
|
-
if err != nil {
|
|
916
|
-
return nil, err
|
|
917
|
-
}
|
|
918
|
-
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
919
|
-
}
|
|
920
|
-
if hasExtends {
|
|
921
|
-
extendsStr, ok := extendsValue.(string)
|
|
729
|
+
if configFileValue, ok := inline["configFile"]; ok {
|
|
730
|
+
configFile, ok := configFileValue.(string)
|
|
922
731
|
if !ok {
|
|
923
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
732
|
+
return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must be a string path, got %T", configFileValue)
|
|
924
733
|
}
|
|
925
|
-
if strings.TrimSpace(
|
|
926
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
927
|
-
}
|
|
928
|
-
location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
|
|
929
|
-
return loadExternalConfigResolver(location)
|
|
930
|
-
}
|
|
931
|
-
if hasLegacy {
|
|
932
|
-
emitLegacyConfigDeprecation()
|
|
933
|
-
switch typed := legacyValue.(type) {
|
|
934
|
-
case string:
|
|
935
|
-
if strings.TrimSpace(typed) == "" {
|
|
936
|
-
return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
|
|
937
|
-
}
|
|
938
|
-
location := resolveConfigFilePath(typed, cwd, tsconfigPath)
|
|
939
|
-
return loadExternalConfigResolver(location)
|
|
940
|
-
case map[string]any:
|
|
941
|
-
cfg, opts, err := ParseRulesWithOptions(typed)
|
|
942
|
-
if err != nil {
|
|
943
|
-
return nil, err
|
|
944
|
-
}
|
|
945
|
-
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
946
|
-
default:
|
|
947
|
-
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")
|
|
948
736
|
}
|
|
737
|
+
location := resolveConfigFilePath(configFile, cwd, tsconfigPath)
|
|
738
|
+
return loadConfigResolver(location)
|
|
949
739
|
}
|
|
950
740
|
|
|
951
741
|
discovered, err := findLintConfigFile(cwd, tsconfigPath)
|
|
@@ -953,32 +743,22 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
|
|
|
953
743
|
return nil, err
|
|
954
744
|
}
|
|
955
745
|
if discovered == "" {
|
|
956
|
-
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)
|
|
957
747
|
}
|
|
958
|
-
return
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
var legacyConfigDeprecationOnce sync.Once
|
|
962
|
-
|
|
963
|
-
func emitLegacyConfigDeprecation() {
|
|
964
|
-
legacyConfigDeprecationOnce.Do(func() {
|
|
965
|
-
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.")
|
|
966
|
-
})
|
|
748
|
+
return loadConfigResolver(discovered)
|
|
967
749
|
}
|
|
968
750
|
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
|
|
972
|
-
func loadExternalConfigResolver(location string) (RuleResolver, error) {
|
|
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) {
|
|
973
754
|
raw, err := loadConfigFile(location)
|
|
974
755
|
if err != nil {
|
|
975
756
|
return nil, err
|
|
976
757
|
}
|
|
977
|
-
store, err :=
|
|
758
|
+
store, err := collectConfigStore(raw, filepath.Dir(location), location)
|
|
978
759
|
if err != nil {
|
|
979
760
|
return nil, err
|
|
980
761
|
}
|
|
981
|
-
store.externalConfigPath = location
|
|
982
762
|
return store, nil
|
|
983
763
|
}
|
|
984
764
|
|
|
@@ -1001,12 +781,6 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
1001
781
|
"ttsc-lint.config.ts",
|
|
1002
782
|
"ttsc-lint.config.mts",
|
|
1003
783
|
"ttsc-lint.config.cts",
|
|
1004
|
-
"eslint.config.js",
|
|
1005
|
-
"eslint.config.mjs",
|
|
1006
|
-
"eslint.config.cjs",
|
|
1007
|
-
"eslint.config.ts",
|
|
1008
|
-
"eslint.config.mts",
|
|
1009
|
-
"eslint.config.cts",
|
|
1010
784
|
} {
|
|
1011
785
|
candidate := filepath.Join(dir, name)
|
|
1012
786
|
if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
|
|
@@ -1018,7 +792,7 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
1018
792
|
for _, m := range matches {
|
|
1019
793
|
names = append(names, filepath.Base(m))
|
|
1020
794
|
}
|
|
1021
|
-
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, ", "))
|
|
1022
796
|
}
|
|
1023
797
|
if len(matches) == 1 {
|
|
1024
798
|
return matches[0], nil
|
|
@@ -1073,20 +847,166 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
|
|
|
1073
847
|
// loadConfigFile loads and deserializes a lint config file at `location`.
|
|
1074
848
|
// The file format is determined by extension: .json is parsed natively;
|
|
1075
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.
|
|
1076
853
|
func loadConfigFile(location string) (any, error) {
|
|
1077
854
|
ext := strings.ToLower(filepath.Ext(location))
|
|
1078
855
|
switch ext {
|
|
1079
856
|
case ".json":
|
|
1080
857
|
return loadJSONConfigFile(location)
|
|
1081
858
|
case ".js", ".cjs", ".mjs":
|
|
1082
|
-
return
|
|
859
|
+
return loadCachedConfigFile(location, loadScriptConfigFile)
|
|
1083
860
|
case ".ts", ".cts", ".mts":
|
|
1084
|
-
return
|
|
861
|
+
return loadCachedConfigFile(location, loadTypeScriptConfigFile)
|
|
1085
862
|
default:
|
|
1086
863
|
return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
|
|
1087
864
|
}
|
|
1088
865
|
}
|
|
1089
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
|
+
|
|
1090
1010
|
// loadJSONConfigFile reads and JSON-parses a lint config file. A leading UTF-8
|
|
1091
1011
|
// BOM is stripped before parsing so files saved by Windows editors are accepted.
|
|
1092
1012
|
func loadJSONConfigFile(location string) (any, error) {
|
|
@@ -1103,8 +1023,8 @@ func loadJSONConfigFile(location string) (any, error) {
|
|
|
1103
1023
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
1104
1024
|
return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
|
|
1105
1025
|
}
|
|
1106
|
-
if !
|
|
1107
|
-
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)
|
|
1108
1028
|
}
|
|
1109
1029
|
return out, nil
|
|
1110
1030
|
}
|
|
@@ -1140,8 +1060,8 @@ const { pathToFileURL } = require("node:url");
|
|
|
1140
1060
|
break;
|
|
1141
1061
|
}
|
|
1142
1062
|
const value = typeof current === "function" ? await current() : current;
|
|
1143
|
-
if (value === null || typeof value !== "object") {
|
|
1144
|
-
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");
|
|
1145
1065
|
}
|
|
1146
1066
|
process.stdout.write(JSON.stringify(toSerializableConfig(value)));
|
|
1147
1067
|
})().catch((error) => {
|
|
@@ -1149,114 +1069,18 @@ const { pathToFileURL } = require("node:url");
|
|
|
1149
1069
|
process.exit(1);
|
|
1150
1070
|
});
|
|
1151
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.
|
|
1152
1076
|
function toSerializableConfig(value) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
return value;
|
|
1158
|
-
}
|
|
1159
|
-
if (isESLintConfigObject(value)) {
|
|
1160
|
-
const out = {};
|
|
1161
|
-
if (hasESLintRuntimeFields(value)) {
|
|
1162
|
-
out.__ttscLintEslintRuntime = true;
|
|
1163
|
-
}
|
|
1164
|
-
if (Object.prototype.hasOwnProperty.call(value, "basePath")) {
|
|
1165
|
-
out.basePath = value.basePath;
|
|
1166
|
-
}
|
|
1167
|
-
if (Object.prototype.hasOwnProperty.call(value, "extends")) {
|
|
1168
|
-
out.extends = toSerializableConfig(value.extends);
|
|
1169
|
-
}
|
|
1170
|
-
if (Object.prototype.hasOwnProperty.call(value, "files")) {
|
|
1171
|
-
out.files = toSerializablePatterns(value.files, "files");
|
|
1172
|
-
}
|
|
1173
|
-
if (Object.prototype.hasOwnProperty.call(value, "ignores")) {
|
|
1174
|
-
out.ignores = toSerializablePatterns(value.ignores, "ignores");
|
|
1175
|
-
}
|
|
1176
|
-
if (Object.prototype.hasOwnProperty.call(value, "rules")) {
|
|
1177
|
-
out.rules = toSerializableRules(value.rules);
|
|
1178
|
-
}
|
|
1179
|
-
return out;
|
|
1180
|
-
}
|
|
1181
|
-
return { rules: toSerializableRules(value) };
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
function toSerializableRules(value) {
|
|
1185
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1186
|
-
throw new Error("rules must be an object");
|
|
1187
|
-
}
|
|
1188
|
-
return Object.fromEntries(Object.entries(value));
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
function toSerializablePatterns(value, key) {
|
|
1192
|
-
if (typeof value === "string") {
|
|
1193
|
-
return value;
|
|
1194
|
-
}
|
|
1195
|
-
if (Array.isArray(value)) {
|
|
1196
|
-
return value.map((item, index) => {
|
|
1197
|
-
if (typeof item !== "string") {
|
|
1198
|
-
throw new Error(key + "[" + index + "] must be a string");
|
|
1199
|
-
}
|
|
1200
|
-
return item;
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
throw new Error(key + " must be a string or string array");
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
function isESLintConfigObject(value) {
|
|
1207
|
-
return [
|
|
1208
|
-
"basePath",
|
|
1209
|
-
"extends",
|
|
1210
|
-
"files",
|
|
1211
|
-
"ignores",
|
|
1212
|
-
"languageOptions",
|
|
1213
|
-
"linterOptions",
|
|
1214
|
-
"name",
|
|
1215
|
-
"plugins",
|
|
1216
|
-
"processor",
|
|
1217
|
-
"rules",
|
|
1218
|
-
"settings",
|
|
1219
|
-
].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
function hasESLintRuntimeFields(value) {
|
|
1223
|
-
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1224
|
-
if (Object.prototype.hasOwnProperty.call(value, key)) return true;
|
|
1225
|
-
}
|
|
1226
|
-
if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
|
|
1227
|
-
if (!isNativePluginMap(value.plugins)) return true;
|
|
1228
|
-
}
|
|
1229
|
-
return false;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
function isNativePluginMap(value) {
|
|
1233
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1234
|
-
return false;
|
|
1235
|
-
}
|
|
1236
|
-
const entries = Object.values(value);
|
|
1237
|
-
if (entries.length === 0) return true;
|
|
1238
|
-
for (const entry of entries) {
|
|
1239
|
-
if (!isNativePluginValue(entry)) return false;
|
|
1240
|
-
}
|
|
1241
|
-
return true;
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
function isNativePluginValue(entry) {
|
|
1245
|
-
// A non-empty string is a native specifier — JS factory resolves it
|
|
1246
|
-
// at load time, so the loader must not flip the ESLint-runtime flag.
|
|
1247
|
-
if (typeof entry === "string") return entry.length > 0;
|
|
1248
|
-
if (entry === null || typeof entry !== "object") return false;
|
|
1249
|
-
let current = entry;
|
|
1250
|
-
for (let i = 0; i < 4; i++) {
|
|
1251
|
-
if (typeof current.source === "string" && current.source.length > 0) {
|
|
1252
|
-
return true;
|
|
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];
|
|
1253
1081
|
}
|
|
1254
|
-
if (current.default === null || typeof current.default !== "object") {
|
|
1255
|
-
return false;
|
|
1256
|
-
}
|
|
1257
|
-
current = current.default;
|
|
1258
1082
|
}
|
|
1259
|
-
return
|
|
1083
|
+
return out;
|
|
1260
1084
|
}
|
|
1261
1085
|
`
|
|
1262
1086
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
@@ -1284,8 +1108,8 @@ function isNativePluginValue(entry) {
|
|
|
1284
1108
|
if err := json.Unmarshal(output, &out); err != nil {
|
|
1285
1109
|
return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
|
|
1286
1110
|
}
|
|
1287
|
-
if !
|
|
1288
|
-
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)
|
|
1289
1113
|
}
|
|
1290
1114
|
return out, nil
|
|
1291
1115
|
}
|
|
@@ -1327,6 +1151,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
|
|
|
1327
1151
|
"--project", tsconfig,
|
|
1328
1152
|
"--cwd", tempDir,
|
|
1329
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",
|
|
1330
1163
|
}
|
|
1331
1164
|
if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
|
|
1332
1165
|
args = append(args, "--binary", tsgo)
|
|
@@ -1355,22 +1188,19 @@ func loadTypeScriptConfigFile(location string) (any, error) {
|
|
|
1355
1188
|
if err := json.Unmarshal(output, &out); err != nil {
|
|
1356
1189
|
return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
|
|
1357
1190
|
}
|
|
1358
|
-
if !
|
|
1359
|
-
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)
|
|
1360
1193
|
}
|
|
1361
1194
|
return out, nil
|
|
1362
1195
|
}
|
|
1363
1196
|
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
// clear error instead of an opaque parse
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
default:
|
|
1372
|
-
return false
|
|
1373
|
-
}
|
|
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
|
|
1374
1204
|
}
|
|
1375
1205
|
|
|
1376
1206
|
// relativeImportSpecifier computes the ESM import specifier for `location`
|
|
@@ -1405,8 +1235,8 @@ declare const process: {
|
|
|
1405
1235
|
|
|
1406
1236
|
try {
|
|
1407
1237
|
const value = await resolveConfig(importedConfig, true);
|
|
1408
|
-
if (!isObject(value)) {
|
|
1409
|
-
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");
|
|
1410
1240
|
}
|
|
1411
1241
|
process.stdout.write(JSON.stringify(toSerializableConfig(value)));
|
|
1412
1242
|
} catch (error) {
|
|
@@ -1443,126 +1273,18 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
|
1443
1273
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
1444
1274
|
}
|
|
1445
1275
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
if (hasESLintRuntimeFields(value)) {
|
|
1456
|
-
out.__ttscLintEslintRuntime = true;
|
|
1457
|
-
}
|
|
1458
|
-
if (hasOwn(value, "basePath")) {
|
|
1459
|
-
out.basePath = value.basePath;
|
|
1460
|
-
}
|
|
1461
|
-
if (hasOwn(value, "extends")) {
|
|
1462
|
-
out.extends = toSerializableConfig(value.extends);
|
|
1463
|
-
}
|
|
1464
|
-
if (hasOwn(value, "files")) {
|
|
1465
|
-
out.files = toSerializablePatterns(value.files, "files");
|
|
1466
|
-
}
|
|
1467
|
-
if (hasOwn(value, "ignores")) {
|
|
1468
|
-
out.ignores = toSerializablePatterns(value.ignores, "ignores");
|
|
1469
|
-
}
|
|
1470
|
-
if (hasOwn(value, "rules")) {
|
|
1471
|
-
out.rules = toSerializableRules(value.rules);
|
|
1472
|
-
}
|
|
1473
|
-
return out;
|
|
1474
|
-
}
|
|
1475
|
-
return { rules: toSerializableRules(value) };
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
function toSerializableRules(value: unknown): Record<string, unknown> {
|
|
1479
|
-
if (!isObject(value) || Array.isArray(value)) {
|
|
1480
|
-
throw new Error("rules must be an object");
|
|
1481
|
-
}
|
|
1482
|
-
return Object.fromEntries(Object.entries(value));
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
function toSerializablePatterns(value: unknown, key: string): string | string[] {
|
|
1486
|
-
if (typeof value === "string") {
|
|
1487
|
-
return value;
|
|
1488
|
-
}
|
|
1489
|
-
if (Array.isArray(value)) {
|
|
1490
|
-
return value.map((item, index) => {
|
|
1491
|
-
if (typeof item !== "string") {
|
|
1492
|
-
throw new Error(key + "[" + index + "] must be a string");
|
|
1493
|
-
}
|
|
1494
|
-
return item;
|
|
1495
|
-
});
|
|
1496
|
-
}
|
|
1497
|
-
throw new Error(key + " must be a string or string array");
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
function isESLintConfigObject(value: Record<string, unknown>): boolean {
|
|
1501
|
-
return [
|
|
1502
|
-
"basePath",
|
|
1503
|
-
"extends",
|
|
1504
|
-
"files",
|
|
1505
|
-
"ignores",
|
|
1506
|
-
"languageOptions",
|
|
1507
|
-
"linterOptions",
|
|
1508
|
-
"name",
|
|
1509
|
-
"plugins",
|
|
1510
|
-
"processor",
|
|
1511
|
-
"rules",
|
|
1512
|
-
"settings",
|
|
1513
|
-
].some((key) => hasOwn(value, key));
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
|
|
1517
|
-
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1518
|
-
if (hasOwn(value, key)) return true;
|
|
1519
|
-
}
|
|
1520
|
-
if (hasOwn(value, "plugins")) {
|
|
1521
|
-
const plugins = value.plugins;
|
|
1522
|
-
if (!isNativePluginMap(plugins)) return true;
|
|
1523
|
-
}
|
|
1524
|
-
return false;
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// isNativePluginMap reports whether every entry of a plugins map points
|
|
1528
|
-
// at a ttsc-lint native contributor (an object with a string "source"
|
|
1529
|
-
// field). Native plugins are compiled into the lint binary at build
|
|
1530
|
-
// time, so their presence does NOT require the JavaScript ESLint
|
|
1531
|
-
// runtime; only mixed or pure-ESLint plugin maps do.
|
|
1532
|
-
function isNativePluginMap(value: unknown): boolean {
|
|
1533
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1534
|
-
return false;
|
|
1535
|
-
}
|
|
1536
|
-
const entries = Object.values(value as Record<string, unknown>);
|
|
1537
|
-
if (entries.length === 0) return true;
|
|
1538
|
-
for (const entry of entries) {
|
|
1539
|
-
if (!isNativePluginValue(entry)) return false;
|
|
1540
|
-
}
|
|
1541
|
-
return true;
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
function isNativePluginValue(entry: unknown): boolean {
|
|
1545
|
-
// A non-empty string is a native specifier — see the matching Go-side
|
|
1546
|
-
// and JS-loader implementations.
|
|
1547
|
-
if (typeof entry === "string") return entry.length > 0;
|
|
1548
|
-
if (entry === null || typeof entry !== "object") return false;
|
|
1549
|
-
// ESM-from-CJS interop wraps CJS modules' "exports.default" so a
|
|
1550
|
-
// contributor authored as "export default plugin" lands under a
|
|
1551
|
-
// ".default" indirection. Walk a few hops so both "export default"
|
|
1552
|
-
// and plain "module.exports = plugin" contributors register as
|
|
1553
|
-
// native here.
|
|
1554
|
-
let current = entry as Record<string, unknown>;
|
|
1555
|
-
for (let i = 0; i < 4; i++) {
|
|
1556
|
-
if (typeof current.source === "string" && (current.source as string).length > 0) {
|
|
1557
|
-
return 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];
|
|
1558
1285
|
}
|
|
1559
|
-
const next = current.default;
|
|
1560
|
-
if (next === null || typeof next !== "object" || Array.isArray(next)) {
|
|
1561
|
-
return false;
|
|
1562
|
-
}
|
|
1563
|
-
current = next as Record<string, unknown>;
|
|
1564
1286
|
}
|
|
1565
|
-
return
|
|
1287
|
+
return out;
|
|
1566
1288
|
}
|
|
1567
1289
|
`, importLiteral)
|
|
1568
1290
|
}
|
|
@@ -1740,11 +1462,8 @@ func setEnv(env []string, key, value string) []string {
|
|
|
1740
1462
|
return append(env, prefix+value)
|
|
1741
1463
|
}
|
|
1742
1464
|
|
|
1743
|
-
// parseExternalRuleEntry delegates to parseRuleEntry.
|
|
1744
|
-
//
|
|
1745
|
-
// same severity-tuple grammar, so a single implementation suffices.
|
|
1746
|
-
// The name is preserved because test files in the same package call it
|
|
1747
|
-
// directly.
|
|
1465
|
+
// parseExternalRuleEntry delegates to parseRuleEntry. It is kept under this
|
|
1466
|
+
// name because test files in the same package call it directly.
|
|
1748
1467
|
func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
|
|
1749
1468
|
return parseRuleEntry(v)
|
|
1750
1469
|
}
|
|
@@ -1842,19 +1561,111 @@ func normalizeGlobPattern(pattern string) string {
|
|
|
1842
1561
|
// matchGlob tests whether `name` matches `pattern` using the ESLint-compatible
|
|
1843
1562
|
// glob semantics implemented by matchGlobParts. Both strings are trimmed of
|
|
1844
1563
|
// leading/trailing slashes before splitting on "/" so that empty segments do
|
|
1845
|
-
// not appear in the part slices.
|
|
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.
|
|
1846
1567
|
func matchGlob(pattern, name string) bool {
|
|
1847
1568
|
pattern = strings.Trim(pattern, "/")
|
|
1848
1569
|
name = strings.Trim(name, "/")
|
|
1849
1570
|
if pattern == "" {
|
|
1850
1571
|
return name == ""
|
|
1851
1572
|
}
|
|
1852
|
-
patternParts := strings.Split(pattern, "/")
|
|
1853
1573
|
nameParts := []string{}
|
|
1854
1574
|
if name != "" {
|
|
1855
1575
|
nameParts = strings.Split(name, "/")
|
|
1856
1576
|
}
|
|
1857
|
-
|
|
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
|
|
1858
1669
|
}
|
|
1859
1670
|
|
|
1860
1671
|
// matchGlobParts recursively matches path segments against pattern segments.
|