@ttsc/lint 0.10.2 → 0.11.0-dev.20260518
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -33
- package/lib/defineConfig.d.ts +55 -0
- package/lib/defineConfig.js +39 -0
- package/lib/defineConfig.js.map +1 -0
- package/lib/index.d.ts +25 -1
- package/lib/index.js +622 -2
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintPlugin.d.ts +53 -0
- package/lib/structures/ITtscLintPlugin.js +3 -0
- package/lib/structures/ITtscLintPlugin.js.map +1 -0
- package/lib/structures/ITtscLintPluginConfig.d.ts +82 -5
- package/lib/structures/ITtscLintPluginMeta.d.ts +12 -0
- package/lib/structures/ITtscLintPluginMeta.js +3 -0
- package/lib/structures/ITtscLintPluginMeta.js.map +1 -0
- package/lib/structures/PluginRuleNames.d.ts +14 -0
- package/lib/structures/PluginRuleNames.js +3 -0
- package/lib/structures/PluginRuleNames.js.map +1 -0
- package/lib/structures/TtscLintConfig.d.ts +24 -8
- package/lib/structures/TtscLintConfigEntry.d.ts +39 -0
- package/lib/structures/TtscLintConfigEntry.js +3 -0
- package/lib/structures/TtscLintConfigEntry.js.map +1 -0
- package/lib/structures/TtscLintFormatConfig.d.ts +154 -0
- package/lib/structures/TtscLintFormatConfig.js +3 -0
- package/lib/structures/TtscLintFormatConfig.js.map +1 -0
- package/lib/structures/TtscLintPlugins.d.ts +9 -0
- package/lib/structures/TtscLintPlugins.js +3 -0
- package/lib/structures/TtscLintPlugins.js.map +1 -0
- package/lib/structures/TtscLintRule.d.ts +1 -1
- package/lib/structures/TtscLintRuleEntry.d.ts +22 -0
- package/lib/structures/TtscLintRuleEntry.js +3 -0
- package/lib/structures/TtscLintRuleEntry.js.map +1 -0
- package/lib/structures/TtscLintRuleMap.d.ts +44 -0
- package/lib/structures/TtscLintRuleMap.js +3 -0
- package/lib/structures/TtscLintRuleMap.js.map +1 -0
- package/lib/structures/TtscLintRuleOptions.d.ts +149 -0
- package/lib/structures/TtscLintRuleOptions.js +3 -0
- package/lib/structures/TtscLintRuleOptions.js.map +1 -0
- package/lib/structures/defaultFormat.d.ts +31 -0
- package/lib/structures/defaultFormat.js +35 -0
- package/lib/structures/defaultFormat.js.map +1 -0
- package/lib/structures/index.d.ts +10 -0
- package/lib/structures/index.js +10 -0
- package/lib/structures/index.js.map +1 -1
- package/{plugin → linthost}/ast_helpers.go +92 -1
- package/{plugin → linthost}/compile.go +1 -1
- package/{plugin → linthost}/config.go +583 -66
- package/linthost/config_format.go +360 -0
- package/linthost/contrib_adapter.go +134 -0
- package/{plugin → linthost}/directives.go +1 -1
- package/linthost/dispatch.go +65 -0
- package/{plugin → linthost}/engine.go +156 -11
- package/{plugin → linthost}/eslint_runtime.go +76 -2
- package/linthost/fix.go +249 -0
- package/linthost/format.go +107 -0
- package/{plugin → linthost}/host.go +1 -1
- package/linthost/print_dispatch.go +118 -0
- package/linthost/print_doc.go +168 -0
- package/linthost/print_engine.go +343 -0
- package/linthost/print_nodes_array.go +46 -0
- package/linthost/print_nodes_call.go +180 -0
- package/linthost/print_nodes_imports.go +171 -0
- package/linthost/print_nodes_list.go +64 -0
- package/linthost/print_nodes_object.go +51 -0
- package/{plugin → linthost}/rules_arrays.go +1 -1
- package/{plugin → linthost}/rules_console.go +1 -1
- package/{plugin → linthost}/rules_debugger.go +1 -1
- package/{plugin → linthost}/rules_dupes.go +1 -1
- package/{plugin → linthost}/rules_empty.go +1 -1
- package/linthost/rules_escape.go +238 -0
- package/{plugin → linthost}/rules_eval.go +1 -1
- package/{plugin → linthost}/rules_finally.go +1 -1
- package/linthost/rules_format_jsdoc.go +230 -0
- package/linthost/rules_format_print_width.go +332 -0
- package/linthost/rules_format_quotes.go +219 -0
- package/linthost/rules_format_semi.go +190 -0
- package/linthost/rules_format_sort_imports.go +462 -0
- package/linthost/rules_format_trailing_comma.go +460 -0
- package/{plugin → linthost}/rules_gap.go +101 -6
- package/linthost/rules_imports.go +101 -0
- package/{plugin → linthost}/rules_logic.go +55 -3
- package/{plugin → linthost}/rules_loops.go +1 -1
- package/{plugin → linthost}/rules_misc.go +1 -1
- package/linthost/rules_params.go +64 -0
- package/{plugin → linthost}/rules_problems.go +10 -2
- package/linthost/rules_promise.go +111 -0
- package/{plugin → linthost}/rules_protos.go +1 -1
- package/{plugin → linthost}/rules_self.go +1 -1
- package/{plugin → linthost}/rules_strings.go +1 -1
- package/{plugin → linthost}/rules_suggestions.go +51 -9
- package/{plugin → linthost}/rules_throw.go +1 -1
- package/{plugin → linthost}/rules_ts.go +12 -2
- package/{plugin → linthost}/rules_ts_extra.go +90 -4
- package/{plugin → linthost}/rules_var.go +36 -5
- package/package.json +8 -6
- package/plugin/main.go +14 -27
- package/rule/astutil/astutil.go +148 -0
- package/rule/rule.go +291 -0
- package/src/defineConfig.ts +69 -0
- package/src/index.ts +807 -3
- package/src/structures/ITtscLintPlugin.ts +56 -0
- package/src/structures/ITtscLintPluginConfig.ts +86 -5
- package/src/structures/ITtscLintPluginMeta.ts +14 -0
- package/src/structures/PluginRuleNames.ts +19 -0
- package/src/structures/TtscLintConfig.ts +27 -8
- package/src/structures/TtscLintConfigEntry.ts +50 -0
- package/src/structures/TtscLintFormatConfig.ts +169 -0
- package/src/structures/TtscLintPlugins.ts +10 -0
- package/src/structures/TtscLintRule.ts +14 -1
- package/src/structures/TtscLintRuleEntry.ts +28 -0
- package/src/structures/TtscLintRuleMap.ts +52 -0
- package/src/structures/TtscLintRuleOptions.ts +162 -0
- package/src/structures/defaultFormat.ts +33 -0
- package/src/structures/index.ts +10 -0
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
package
|
|
1
|
+
package linthost
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
4
6
|
"encoding/json"
|
|
5
7
|
"fmt"
|
|
6
8
|
"os"
|
|
7
9
|
"os/exec"
|
|
8
10
|
"path/filepath"
|
|
11
|
+
"runtime"
|
|
9
12
|
"sort"
|
|
10
13
|
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
11
16
|
)
|
|
12
17
|
|
|
18
|
+
// configLoaderTimeout caps every `ttsx`/`node -e` subprocess that
|
|
19
|
+
// evaluates a user-supplied lint config. The JS factory imposes the
|
|
20
|
+
// same 60 s budget on its mirroring spawnSync; without the Go-side cap
|
|
21
|
+
// a runaway user config would hang `ttsc-lint` forever, while
|
|
22
|
+
// `ttsc`/`pnpm` upstream of it stays responsive. 60 s is generous
|
|
23
|
+
// enough for cold ttsx starts on CI runners and tight enough to keep
|
|
24
|
+
// user-visible feedback under a minute.
|
|
25
|
+
const configLoaderTimeout = 60 * time.Second
|
|
26
|
+
|
|
13
27
|
// Severity is the `error | warning | off` ladder.
|
|
14
28
|
type Severity int
|
|
15
29
|
|
|
@@ -69,6 +83,13 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
|
|
|
69
83
|
// rule name (e.g. "no-var").
|
|
70
84
|
type RuleConfig map[string]Severity
|
|
71
85
|
|
|
86
|
+
// RuleOptionsMap captures the rule-specific options blob, keyed by rule
|
|
87
|
+
// name. Severity-only rules never appear here. The values are the raw
|
|
88
|
+
// JSON the user wrote in the second tuple slot of an ESLint-style
|
|
89
|
+
// `["error", { ... }]` setting; each rule decodes the blob into its own
|
|
90
|
+
// option struct on demand.
|
|
91
|
+
type RuleOptionsMap map[string]json.RawMessage
|
|
92
|
+
|
|
72
93
|
// ResolvedRuleConfig is the rule map that applies to one source file.
|
|
73
94
|
// `Ignored` means an external ESLint-style ignore-only config matched the
|
|
74
95
|
// file and the engine should skip linting it entirely.
|
|
@@ -81,6 +102,10 @@ type RuleResolver interface {
|
|
|
81
102
|
ResolveRules(fileName string) ResolvedRuleConfig
|
|
82
103
|
ActiveRuleNames() []string
|
|
83
104
|
EnabledRuleConfig() RuleConfig
|
|
105
|
+
// RuleOptions returns the raw JSON options for `name`, or nil when the
|
|
106
|
+
// rule was configured with a severity alone. Returns nil for unknown
|
|
107
|
+
// rule names too — rules treat that as "use defaults".
|
|
108
|
+
RuleOptions(name string) json.RawMessage
|
|
84
109
|
}
|
|
85
110
|
|
|
86
111
|
func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
|
|
@@ -101,11 +126,57 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
|
|
|
101
126
|
return out
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
// RuleOptions on a bare RuleConfig is always nil — this form is the
|
|
130
|
+
// severity-only path used by Go unit tests and the legacy inline-rules
|
|
131
|
+
// surface that predates option support.
|
|
132
|
+
func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
|
|
133
|
+
|
|
134
|
+
// InlineRuleResolver pairs a severity map with an options map for
|
|
135
|
+
// tsconfig-inline rule blocks. The fields are public so tests can
|
|
136
|
+
// construct one without going through ParseRulesWithOptions.
|
|
137
|
+
type InlineRuleResolver struct {
|
|
138
|
+
Rules RuleConfig
|
|
139
|
+
Options RuleOptionsMap
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
|
|
143
|
+
return ResolvedRuleConfig{Rules: r.Rules}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func (r InlineRuleResolver) ActiveRuleNames() []string {
|
|
147
|
+
return r.Rules.ActiveRuleNames()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func (r InlineRuleResolver) EnabledRuleConfig() RuleConfig {
|
|
151
|
+
return r.Rules.EnabledRuleConfig()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
|
|
155
|
+
if r.Options == nil {
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
return r.Options[name]
|
|
159
|
+
}
|
|
160
|
+
|
|
104
161
|
type ConfigStore struct {
|
|
105
162
|
entries []ConfigEntry
|
|
106
163
|
externalConfigPath string
|
|
107
164
|
eslintRuntime bool
|
|
108
165
|
eslintRuntimeRequired bool
|
|
166
|
+
// options is a flat rule-name → JSON map. Options are not scoped by
|
|
167
|
+
// `files` / `ignores`: a rule's behavior is a single project-wide
|
|
168
|
+
// configuration even when its severity is per-file. The simplification
|
|
169
|
+
// matches prettier-style options (one setting per project) while
|
|
170
|
+
// keeping severity layering intact.
|
|
171
|
+
options RuleOptionsMap
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// RuleOptions implements RuleResolver.RuleOptions on ConfigStore.
|
|
175
|
+
func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
|
|
176
|
+
if s == nil {
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
return s.options[name]
|
|
109
180
|
}
|
|
110
181
|
|
|
111
182
|
type ConfigEntry struct {
|
|
@@ -242,22 +313,77 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
|
|
|
242
313
|
// Anything else returns an error (no silent fallback — typos in a rule
|
|
243
314
|
// severity should be loud).
|
|
244
315
|
func ParseRules(raw any) (RuleConfig, error) {
|
|
316
|
+
cfg, _, err := ParseRulesWithOptions(raw)
|
|
317
|
+
return cfg, err
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ParseRulesWithOptions accepts either a severity literal or a
|
|
321
|
+
// `[severity, options]` tuple per rule and returns the severity map
|
|
322
|
+
// alongside an options map keyed by rule name. The options map only
|
|
323
|
+
// contains entries for rules whose configuration was the tuple form.
|
|
324
|
+
func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
|
|
245
325
|
if raw == nil {
|
|
246
|
-
return RuleConfig{}, nil
|
|
326
|
+
return RuleConfig{}, RuleOptionsMap{}, nil
|
|
247
327
|
}
|
|
248
328
|
dict, ok := raw.(map[string]any)
|
|
249
329
|
if !ok {
|
|
250
|
-
return nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
|
|
330
|
+
return nil, nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
|
|
251
331
|
}
|
|
252
|
-
|
|
332
|
+
cfg := make(RuleConfig, len(dict))
|
|
333
|
+
opts := make(RuleOptionsMap)
|
|
253
334
|
for name, value := range dict {
|
|
254
|
-
sev, err :=
|
|
335
|
+
sev, raw, err := parseRuleEntry(value)
|
|
255
336
|
if err != nil {
|
|
256
|
-
return nil, fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
|
|
337
|
+
return nil, nil, fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
|
|
338
|
+
}
|
|
339
|
+
cfg[name] = sev
|
|
340
|
+
if len(raw) > 0 {
|
|
341
|
+
opts[name] = raw
|
|
257
342
|
}
|
|
258
|
-
out[name] = sev
|
|
259
343
|
}
|
|
260
|
-
return
|
|
344
|
+
return cfg, opts, nil
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// parseRuleEntry splits an ESLint-shaped rule entry into its severity
|
|
348
|
+
// and (optional) options payload. Bare severity literals produce a nil
|
|
349
|
+
// options blob; `[severity]` (no options) does the same; `[severity,
|
|
350
|
+
// options]` re-serializes the options to JSON so each rule can decode it
|
|
351
|
+
// into its own struct later.
|
|
352
|
+
func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
|
|
353
|
+
if tuple, ok := value.([]any); ok {
|
|
354
|
+
if len(tuple) == 0 {
|
|
355
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
|
|
356
|
+
}
|
|
357
|
+
sev, err := parseSeverity(tuple[0])
|
|
358
|
+
if err != nil {
|
|
359
|
+
return SeverityOff, nil, err
|
|
360
|
+
}
|
|
361
|
+
if len(tuple) == 1 {
|
|
362
|
+
return sev, nil, nil
|
|
363
|
+
}
|
|
364
|
+
if len(tuple) > 2 {
|
|
365
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
|
|
366
|
+
}
|
|
367
|
+
if tuple[1] == nil {
|
|
368
|
+
return sev, nil, nil
|
|
369
|
+
}
|
|
370
|
+
if _, ok := tuple[1].(map[string]any); !ok {
|
|
371
|
+
// ESLint accepts string-typed positional options as a shorthand
|
|
372
|
+
// (e.g. `["error", "single"]` for the `quotes` rule). ttsc does
|
|
373
|
+
// not: every option struct in TtscLintRuleOptions is an object,
|
|
374
|
+
// and silently encoding a non-object slot would land in
|
|
375
|
+
// DecodeOptions as a decode error that every rule discards. Fail
|
|
376
|
+
// loudly so users discover the proper `["error", { … }]` form.
|
|
377
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
|
|
378
|
+
}
|
|
379
|
+
encoded, err := json.Marshal(tuple[1])
|
|
380
|
+
if err != nil {
|
|
381
|
+
return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
|
|
382
|
+
}
|
|
383
|
+
return sev, encoded, nil
|
|
384
|
+
}
|
|
385
|
+
sev, err := parseSeverity(value)
|
|
386
|
+
return sev, nil, err
|
|
261
387
|
}
|
|
262
388
|
|
|
263
389
|
func parseExternalConfigRules(raw any) (RuleConfig, error) {
|
|
@@ -331,8 +457,43 @@ func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path str
|
|
|
331
457
|
if err != nil {
|
|
332
458
|
return err
|
|
333
459
|
}
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
// `format: { severity: "off" }` and no rules — drop entry.
|
|
494
|
+
return nil
|
|
495
|
+
}
|
|
496
|
+
parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
|
|
336
497
|
if err != nil {
|
|
337
498
|
return err
|
|
338
499
|
}
|
|
@@ -353,7 +514,7 @@ func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path str
|
|
|
353
514
|
}
|
|
354
515
|
return nil
|
|
355
516
|
}
|
|
356
|
-
parsed, err :=
|
|
517
|
+
parsed, err := parseExternalRuleMapInto(typed, path, store)
|
|
357
518
|
if err != nil {
|
|
358
519
|
return err
|
|
359
520
|
}
|
|
@@ -394,25 +555,38 @@ func collectExternalExtends(store *ConfigStore, raw any, baseDir, path string, a
|
|
|
394
555
|
}
|
|
395
556
|
}
|
|
396
557
|
|
|
397
|
-
|
|
558
|
+
// parseExternalRuleMapInto parses the rules map and folds any
|
|
559
|
+
// option blobs into `store.options`. Used by entry-creation paths so
|
|
560
|
+
// the store ends with a unified options map for RuleResolver consumers.
|
|
561
|
+
func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleConfig, error) {
|
|
398
562
|
out := RuleConfig{}
|
|
399
|
-
if
|
|
563
|
+
if store.options == nil {
|
|
564
|
+
store.options = RuleOptionsMap{}
|
|
565
|
+
}
|
|
566
|
+
if err := collectExternalRuleMapWithOptions(out, store.options, raw, path); err != nil {
|
|
400
567
|
return nil, err
|
|
401
568
|
}
|
|
402
569
|
return out, nil
|
|
403
570
|
}
|
|
404
571
|
|
|
405
|
-
|
|
572
|
+
// collectExternalRuleMapWithOptions also records the rule's options blob
|
|
573
|
+
// when the entry is a `[severity, options]` tuple. `opts` may be nil
|
|
574
|
+
// when the caller does not need option capture (legacy paths).
|
|
575
|
+
func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
|
|
406
576
|
dict, ok := raw.(map[string]any)
|
|
407
577
|
if !ok {
|
|
408
578
|
return fmt.Errorf("@ttsc/lint: %s must be a rules object, got %T", path, raw)
|
|
409
579
|
}
|
|
410
580
|
for name, value := range dict {
|
|
411
|
-
sev, err :=
|
|
581
|
+
sev, ruleOpts, err := parseExternalRuleEntry(value)
|
|
412
582
|
if err != nil {
|
|
413
583
|
return fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
|
|
414
584
|
}
|
|
415
|
-
|
|
585
|
+
canonical := normalizeExternalRuleName(name)
|
|
586
|
+
out[canonical] = sev
|
|
587
|
+
if opts != nil && len(ruleOpts) > 0 {
|
|
588
|
+
opts[canonical] = ruleOpts
|
|
589
|
+
}
|
|
416
590
|
}
|
|
417
591
|
return nil
|
|
418
592
|
}
|
|
@@ -422,6 +596,7 @@ func isESLintConfigObject(value map[string]any) bool {
|
|
|
422
596
|
"basePath",
|
|
423
597
|
"extends",
|
|
424
598
|
"files",
|
|
599
|
+
"format",
|
|
425
600
|
"ignores",
|
|
426
601
|
"languageOptions",
|
|
427
602
|
"linterOptions",
|
|
@@ -443,7 +618,6 @@ func hasESLintRuntimeFields(value map[string]any) bool {
|
|
|
443
618
|
for _, key := range []string{
|
|
444
619
|
"languageOptions",
|
|
445
620
|
"linterOptions",
|
|
446
|
-
"plugins",
|
|
447
621
|
"processor",
|
|
448
622
|
"settings",
|
|
449
623
|
} {
|
|
@@ -451,9 +625,71 @@ func hasESLintRuntimeFields(value map[string]any) bool {
|
|
|
451
625
|
return true
|
|
452
626
|
}
|
|
453
627
|
}
|
|
628
|
+
if plugins, ok := value["plugins"]; ok {
|
|
629
|
+
if !isNativePluginMap(plugins) {
|
|
630
|
+
return true
|
|
631
|
+
}
|
|
632
|
+
}
|
|
454
633
|
return false
|
|
455
634
|
}
|
|
456
635
|
|
|
636
|
+
// isNativePluginMap reports whether every entry in a flat-config
|
|
637
|
+
// `plugins` map points at a ttsc-lint native contributor object
|
|
638
|
+
// (carrying a non-empty string `source` field). Native contributors are
|
|
639
|
+
// compiled into the lint binary at build time and require no JS ESLint
|
|
640
|
+
// runtime; only mixed or pure-ESLint plugin maps require the runtime
|
|
641
|
+
// fallback.
|
|
642
|
+
func isNativePluginMap(value any) bool {
|
|
643
|
+
dict, ok := value.(map[string]any)
|
|
644
|
+
if !ok {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
if len(dict) == 0 {
|
|
648
|
+
return true
|
|
649
|
+
}
|
|
650
|
+
for _, entry := range dict {
|
|
651
|
+
if !isNativePluginValue(entry) {
|
|
652
|
+
return false
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func isNativePluginValue(entry any) bool {
|
|
659
|
+
if entry == nil {
|
|
660
|
+
return false
|
|
661
|
+
}
|
|
662
|
+
switch typed := entry.(type) {
|
|
663
|
+
case string:
|
|
664
|
+
// A non-empty string is a native npm specifier (matching the JS
|
|
665
|
+
// factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
|
|
666
|
+
// configs and the JSON-only `readJsonConfigPlugins` path). The JS
|
|
667
|
+
// factory resolves the specifier at load time and bakes the
|
|
668
|
+
// contributor into the binary, so the Go sidecar should not flip
|
|
669
|
+
// `eslintRuntimeRequired` for a file that already declared a
|
|
670
|
+
// native specifier.
|
|
671
|
+
return typed != ""
|
|
672
|
+
case map[string]any:
|
|
673
|
+
// Walk ESM-from-CJS `.default` indirection so a contributor authored
|
|
674
|
+
// as `export default plugin` registers as native here, matching the
|
|
675
|
+
// JS factory's `extractPluginSource` behavior.
|
|
676
|
+
current := typed
|
|
677
|
+
for i := 0; i < 4; i++ {
|
|
678
|
+
if source, ok := current["source"].(string); ok && source != "" {
|
|
679
|
+
return true
|
|
680
|
+
}
|
|
681
|
+
next, ok := current["default"].(map[string]any)
|
|
682
|
+
if !ok {
|
|
683
|
+
return false
|
|
684
|
+
}
|
|
685
|
+
current = next
|
|
686
|
+
}
|
|
687
|
+
return false
|
|
688
|
+
default:
|
|
689
|
+
return false
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
457
693
|
func normalizeExternalRuleName(name string) string {
|
|
458
694
|
name = strings.TrimPrefix(name, "@typescript-eslint/")
|
|
459
695
|
return strings.TrimPrefix(name, "typescript-eslint/")
|
|
@@ -506,10 +742,22 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
|
|
|
506
742
|
}
|
|
507
743
|
}
|
|
508
744
|
|
|
509
|
-
// LoadConfigResolver resolves one plugin entry into the engine-facing
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
745
|
+
// LoadConfigResolver resolves one plugin entry into the engine-facing
|
|
746
|
+
// config model.
|
|
747
|
+
//
|
|
748
|
+
// Two equivalent input shapes are accepted:
|
|
749
|
+
//
|
|
750
|
+
// - `rules` (inline severity map) + `extends` (config file path) —
|
|
751
|
+
// the canonical fields mirroring ESLint flat-config vocabulary.
|
|
752
|
+
// - `config` (legacy) — accepts the same string-or-map values but
|
|
753
|
+
// emits a one-time stderr deprecation notice. Removed in a future
|
|
754
|
+
// minor.
|
|
755
|
+
//
|
|
756
|
+
// `rules` and `extends` are mutually exclusive on a single plugin
|
|
757
|
+
// entry; mixing legacy `config` with either new field is rejected.
|
|
758
|
+
// `configFile` and `configPath` remain reserved keywords surfaced with
|
|
759
|
+
// a hint pointing at `extends`, in case a user mistakenly reaches for
|
|
760
|
+
// either spelling.
|
|
513
761
|
func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
|
|
514
762
|
if entry == nil {
|
|
515
763
|
return RuleConfig{}, nil
|
|
@@ -518,35 +766,124 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
|
|
|
518
766
|
if inline == nil {
|
|
519
767
|
inline = map[string]any{}
|
|
520
768
|
}
|
|
521
|
-
for _, key := range []string{"
|
|
769
|
+
for _, key := range []string{"configFile", "configPath"} {
|
|
522
770
|
if _, ok := inline[key]; ok {
|
|
523
|
-
return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"
|
|
771
|
+
return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
|
|
524
772
|
}
|
|
525
773
|
}
|
|
526
774
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
775
|
+
rulesValue, hasRules := inline["rules"]
|
|
776
|
+
extendsValue, hasExtends := inline["extends"]
|
|
777
|
+
legacyValue, hasLegacy := inline["config"]
|
|
778
|
+
formatValue, hasFormat := inline["format"]
|
|
779
|
+
|
|
780
|
+
if hasLegacy && (hasRules || hasExtends || hasFormat) {
|
|
781
|
+
sibling := ""
|
|
782
|
+
switch {
|
|
783
|
+
case hasFormat && !hasRules && !hasExtends:
|
|
784
|
+
sibling = "format"
|
|
785
|
+
case hasExtends && !hasRules && !hasFormat:
|
|
786
|
+
sibling = "extends"
|
|
787
|
+
case hasRules && !hasExtends && !hasFormat:
|
|
788
|
+
sibling = "rules"
|
|
789
|
+
default:
|
|
790
|
+
sibling = "rules/extends/format"
|
|
791
|
+
}
|
|
792
|
+
return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
|
|
793
|
+
}
|
|
794
|
+
if hasRules && hasExtends {
|
|
795
|
+
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")
|
|
796
|
+
}
|
|
797
|
+
if hasFormat && hasExtends {
|
|
798
|
+
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")
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Expand the format block (if any) into a rules-shaped map. The
|
|
802
|
+
// returned map is empty when `format.severity === "off"` so the
|
|
803
|
+
// downstream merge with `rules` still produces the right result.
|
|
804
|
+
var formatRulesRaw map[string]any
|
|
805
|
+
if hasFormat {
|
|
806
|
+
formatMap, ok := formatValue.(map[string]any)
|
|
807
|
+
if !ok {
|
|
808
|
+
return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
|
|
809
|
+
}
|
|
810
|
+
expanded, err := expandFormatBlock(formatMap)
|
|
530
811
|
if err != nil {
|
|
531
812
|
return nil, err
|
|
532
813
|
}
|
|
533
|
-
|
|
534
|
-
|
|
814
|
+
formatRulesRaw = expanded
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if hasRules || hasFormat {
|
|
818
|
+
// Merge format defaults with inline rule overrides; the `rules`
|
|
819
|
+
// entry wins on key collisions (more explicit surface).
|
|
820
|
+
var rulesMap map[string]any
|
|
821
|
+
if hasRules {
|
|
822
|
+
typed, ok := rulesValue.(map[string]any)
|
|
823
|
+
if !ok {
|
|
824
|
+
return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
|
|
825
|
+
}
|
|
826
|
+
rulesMap = typed
|
|
827
|
+
}
|
|
828
|
+
merged := mergeRuleMaps(formatRulesRaw, rulesMap)
|
|
829
|
+
if len(merged) == 0 {
|
|
830
|
+
// `format: { severity: "off" }` with no `rules` field — no
|
|
831
|
+
// rules to register.
|
|
832
|
+
return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
|
|
833
|
+
}
|
|
834
|
+
cfg, opts, err := ParseRulesWithOptions(merged)
|
|
835
|
+
if err != nil {
|
|
836
|
+
return nil, err
|
|
535
837
|
}
|
|
536
|
-
return
|
|
838
|
+
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
537
839
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if
|
|
541
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
840
|
+
if hasExtends {
|
|
841
|
+
extendsStr, ok := extendsValue.(string)
|
|
842
|
+
if !ok {
|
|
843
|
+
return nil, fmt.Errorf("@ttsc/lint: \"extends\" must be a string path, got %T", extendsValue)
|
|
844
|
+
}
|
|
845
|
+
if strings.TrimSpace(extendsStr) == "" {
|
|
846
|
+
return nil, fmt.Errorf("@ttsc/lint: \"extends\" must not be empty")
|
|
542
847
|
}
|
|
543
|
-
location := resolveConfigFilePath(
|
|
848
|
+
location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
|
|
544
849
|
return loadExternalConfigResolver(location)
|
|
545
|
-
case map[string]any:
|
|
546
|
-
return ParseRules(typed)
|
|
547
|
-
default:
|
|
548
|
-
return nil, fmt.Errorf("@ttsc/lint: \"config\" must be a string path or object, got %T", value)
|
|
549
850
|
}
|
|
851
|
+
if hasLegacy {
|
|
852
|
+
emitLegacyConfigDeprecation()
|
|
853
|
+
switch typed := legacyValue.(type) {
|
|
854
|
+
case string:
|
|
855
|
+
if strings.TrimSpace(typed) == "" {
|
|
856
|
+
return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
|
|
857
|
+
}
|
|
858
|
+
location := resolveConfigFilePath(typed, cwd, tsconfigPath)
|
|
859
|
+
return loadExternalConfigResolver(location)
|
|
860
|
+
case map[string]any:
|
|
861
|
+
cfg, opts, err := ParseRulesWithOptions(typed)
|
|
862
|
+
if err != nil {
|
|
863
|
+
return nil, err
|
|
864
|
+
}
|
|
865
|
+
return InlineRuleResolver{Rules: cfg, Options: opts}, nil
|
|
866
|
+
default:
|
|
867
|
+
return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must be a string path or object, got %T", legacyValue)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
discovered, err := findLintConfigFile(cwd, tsconfigPath)
|
|
872
|
+
if err != nil {
|
|
873
|
+
return nil, err
|
|
874
|
+
}
|
|
875
|
+
if discovered == "" {
|
|
876
|
+
return nil, fmt.Errorf("@ttsc/lint: \"rules\" or \"extends\" is required when no lint.config.*, ttsc-lint.config.*, or supported eslint.config.* file can be discovered (searched upward from %s)", cwd)
|
|
877
|
+
}
|
|
878
|
+
return loadExternalConfigResolver(discovered)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
var legacyConfigDeprecationOnce sync.Once
|
|
882
|
+
|
|
883
|
+
func emitLegacyConfigDeprecation() {
|
|
884
|
+
legacyConfigDeprecationOnce.Do(func() {
|
|
885
|
+
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.")
|
|
886
|
+
})
|
|
550
887
|
}
|
|
551
888
|
|
|
552
889
|
func loadExternalConfigResolver(location string) (RuleResolver, error) {
|
|
@@ -594,7 +931,11 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
|
|
|
594
931
|
}
|
|
595
932
|
}
|
|
596
933
|
if len(matches) > 1 {
|
|
597
|
-
|
|
934
|
+
names := make([]string, 0, len(matches))
|
|
935
|
+
for _, m := range matches {
|
|
936
|
+
names = append(names, filepath.Base(m))
|
|
937
|
+
}
|
|
938
|
+
return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"extends\" explicitly", dir, strings.Join(names, ", "))
|
|
598
939
|
}
|
|
599
940
|
if len(matches) == 1 {
|
|
600
941
|
return matches[0], nil
|
|
@@ -655,6 +996,11 @@ func loadJSONConfigFile(location string) (any, error) {
|
|
|
655
996
|
if err != nil {
|
|
656
997
|
return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
|
|
657
998
|
}
|
|
999
|
+
// Strip a leading UTF-8 BOM so files saved by Windows editors round
|
|
1000
|
+
// trip through `json.Unmarshal` without an opaque "invalid character"
|
|
1001
|
+
// failure. Mirrors the equivalent JS-side guard in
|
|
1002
|
+
// `packages/lint/src/index.ts::readJsonConfigPlugins`.
|
|
1003
|
+
body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
|
|
658
1004
|
var out any
|
|
659
1005
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
660
1006
|
return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
|
|
@@ -671,8 +1017,26 @@ const { pathToFileURL } = require("node:url");
|
|
|
671
1017
|
|
|
672
1018
|
(async () => {
|
|
673
1019
|
const mod = await import(pathToFileURL(process.argv[1]).href);
|
|
674
|
-
|
|
675
|
-
|
|
1020
|
+
let current = mod;
|
|
1021
|
+
let allowNamedConfig = true;
|
|
1022
|
+
// Match the 8-hop walk used by the TypeScript loader at
|
|
1023
|
+
// ` + "`" + `typeScriptConfigLoaderSource` + "`" + ` so doubly-wrapped CJS/ESM
|
|
1024
|
+
// interop (e.g. ` + "`" + `{default:{default:config}}` + "`" + `) is resolved
|
|
1025
|
+
// consistently across .js/.cjs/.mjs and .ts/.cts/.mts loaders.
|
|
1026
|
+
for (let i = 0; i < 8; i++) {
|
|
1027
|
+
if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, "default")) {
|
|
1028
|
+
current = current.default;
|
|
1029
|
+
allowNamedConfig = false;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (allowNamedConfig && current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, "config")) {
|
|
1033
|
+
current = current.config;
|
|
1034
|
+
allowNamedConfig = false;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
const value = typeof current === "function" ? await current() : current;
|
|
676
1040
|
if (value === null || typeof value !== "object") {
|
|
677
1041
|
throw new Error("config file must export an object or flat config array");
|
|
678
1042
|
}
|
|
@@ -753,22 +1117,57 @@ function isESLintConfigObject(value) {
|
|
|
753
1117
|
}
|
|
754
1118
|
|
|
755
1119
|
function hasESLintRuntimeFields(value) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1120
|
+
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1121
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) return true;
|
|
1122
|
+
}
|
|
1123
|
+
if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
|
|
1124
|
+
if (!isNativePluginMap(value.plugins)) return true;
|
|
1125
|
+
}
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function isNativePluginMap(value) {
|
|
1130
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
const entries = Object.values(value);
|
|
1134
|
+
if (entries.length === 0) return true;
|
|
1135
|
+
for (const entry of entries) {
|
|
1136
|
+
if (!isNativePluginValue(entry)) return false;
|
|
1137
|
+
}
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function isNativePluginValue(entry) {
|
|
1142
|
+
// A non-empty string is a native specifier — JS factory resolves it
|
|
1143
|
+
// at load time, so the loader must not flip the ESLint-runtime flag.
|
|
1144
|
+
if (typeof entry === "string") return entry.length > 0;
|
|
1145
|
+
if (entry === null || typeof entry !== "object") return false;
|
|
1146
|
+
let current = entry;
|
|
1147
|
+
for (let i = 0; i < 4; i++) {
|
|
1148
|
+
if (typeof current.source === "string" && current.source.length > 0) {
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
if (current.default === null || typeof current.default !== "object") {
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
current = current.default;
|
|
1155
|
+
}
|
|
1156
|
+
return false;
|
|
763
1157
|
}
|
|
764
1158
|
`
|
|
765
1159
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
766
1160
|
if node == "" {
|
|
767
1161
|
node = "node"
|
|
768
1162
|
}
|
|
769
|
-
|
|
1163
|
+
ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
|
|
1164
|
+
defer cancel()
|
|
1165
|
+
cmd := exec.CommandContext(ctx, node, "-e", script, location)
|
|
770
1166
|
output, err := cmd.Output()
|
|
771
1167
|
if err != nil {
|
|
1168
|
+
if ctx.Err() == context.DeadlineExceeded {
|
|
1169
|
+
return nil, fmt.Errorf("@ttsc/lint: load config file %s: timed out after %s", location, configLoaderTimeout)
|
|
1170
|
+
}
|
|
772
1171
|
stderr := ""
|
|
773
1172
|
if exit, ok := err.(*exec.ExitError); ok {
|
|
774
1173
|
stderr = strings.TrimSpace(string(exit.Stderr))
|
|
@@ -826,10 +1225,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
|
|
|
826
1225
|
}
|
|
827
1226
|
args = append(args, loader)
|
|
828
1227
|
|
|
829
|
-
|
|
1228
|
+
ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
|
|
1229
|
+
defer cancel()
|
|
1230
|
+
cmd := ttsxCommandContext(ctx, args...)
|
|
830
1231
|
cmd.Env = nodeConfigLoaderEnv(location)
|
|
831
1232
|
output, err := cmd.Output()
|
|
832
1233
|
if err != nil {
|
|
1234
|
+
if ctx.Err() == context.DeadlineExceeded {
|
|
1235
|
+
return nil, fmt.Errorf("@ttsc/lint: load TypeScript config file %s: timed out after %s", location, configLoaderTimeout)
|
|
1236
|
+
}
|
|
833
1237
|
stderr := ""
|
|
834
1238
|
if exit, ok := err.(*exec.ExitError); ok {
|
|
835
1239
|
stderr = strings.TrimSpace(string(exit.Stderr))
|
|
@@ -990,28 +1394,81 @@ function isESLintConfigObject(value: Record<string, unknown>): boolean {
|
|
|
990
1394
|
}
|
|
991
1395
|
|
|
992
1396
|
function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1397
|
+
for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
|
|
1398
|
+
if (hasOwn(value, key)) return true;
|
|
1399
|
+
}
|
|
1400
|
+
if (hasOwn(value, "plugins")) {
|
|
1401
|
+
const plugins = value.plugins;
|
|
1402
|
+
if (!isNativePluginMap(plugins)) return true;
|
|
1403
|
+
}
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// isNativePluginMap reports whether every entry of a plugins map points
|
|
1408
|
+
// at a ttsc-lint native contributor (an object with a string "source"
|
|
1409
|
+
// field). Native plugins are compiled into the lint binary at build
|
|
1410
|
+
// time, so their presence does NOT require the JavaScript ESLint
|
|
1411
|
+
// runtime; only mixed or pure-ESLint plugin maps do.
|
|
1412
|
+
function isNativePluginMap(value: unknown): boolean {
|
|
1413
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
const entries = Object.values(value as Record<string, unknown>);
|
|
1417
|
+
if (entries.length === 0) return true;
|
|
1418
|
+
for (const entry of entries) {
|
|
1419
|
+
if (!isNativePluginValue(entry)) return false;
|
|
1420
|
+
}
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function isNativePluginValue(entry: unknown): boolean {
|
|
1425
|
+
// A non-empty string is a native specifier — see the matching Go-side
|
|
1426
|
+
// and JS-loader implementations.
|
|
1427
|
+
if (typeof entry === "string") return entry.length > 0;
|
|
1428
|
+
if (entry === null || typeof entry !== "object") return false;
|
|
1429
|
+
// ESM-from-CJS interop wraps CJS modules' "exports.default" so a
|
|
1430
|
+
// contributor authored as "export default plugin" lands under a
|
|
1431
|
+
// ".default" indirection. Walk a few hops so both "export default"
|
|
1432
|
+
// and plain "module.exports = plugin" contributors register as
|
|
1433
|
+
// native here.
|
|
1434
|
+
let current = entry as Record<string, unknown>;
|
|
1435
|
+
for (let i = 0; i < 4; i++) {
|
|
1436
|
+
if (typeof current.source === "string" && (current.source as string).length > 0) {
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
const next = current.default;
|
|
1440
|
+
if (next === null || typeof next !== "object" || Array.isArray(next)) {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
current = next as Record<string, unknown>;
|
|
1444
|
+
}
|
|
1445
|
+
return false;
|
|
1000
1446
|
}
|
|
1001
1447
|
`, importLiteral)
|
|
1002
1448
|
}
|
|
1003
1449
|
|
|
1004
1450
|
func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
1451
|
+
// Mirror the JS-factory loader's lenient settings (see the matching
|
|
1452
|
+
// tsconfig synthesis in `packages/lint/src/index.ts::readTtsxConfigPlugins`).
|
|
1453
|
+
// Both sides evaluate the SAME user-authored lint config; without
|
|
1454
|
+
// matching strictness, a config that loads fine through the JS
|
|
1455
|
+
// factory could be rejected by the Go sidecar (or vice versa) on
|
|
1456
|
+
// identical input. The loader is extracting data, not validating
|
|
1457
|
+
// user code, so `strict: false` + `allowJs: true` + `noImplicitAny:
|
|
1458
|
+
// false` is the right baseline.
|
|
1005
1459
|
content := map[string]any{
|
|
1006
1460
|
"compilerOptions": map[string]any{
|
|
1007
1461
|
"allowImportingTsExtensions": true,
|
|
1462
|
+
"allowJs": true,
|
|
1463
|
+
"checkJs": false,
|
|
1008
1464
|
"module": "ESNext",
|
|
1009
1465
|
"moduleResolution": "bundler",
|
|
1466
|
+
"noImplicitAny": false,
|
|
1010
1467
|
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
1011
1468
|
"rewriteRelativeImportExtensions": true,
|
|
1012
1469
|
"rootDir": "/",
|
|
1013
1470
|
"skipLibCheck": true,
|
|
1014
|
-
"strict":
|
|
1471
|
+
"strict": false,
|
|
1015
1472
|
"target": "ES2022",
|
|
1016
1473
|
},
|
|
1017
1474
|
"files": []string{
|
|
@@ -1027,6 +1484,14 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
1027
1484
|
}
|
|
1028
1485
|
|
|
1029
1486
|
func ttsxCommand(args ...string) *exec.Cmd {
|
|
1487
|
+
return ttsxCommandContext(context.Background(), args...)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// ttsxCommandContext is the timeout-aware variant. Callers that
|
|
1491
|
+
// evaluate user-supplied config should wrap their context with
|
|
1492
|
+
// `context.WithTimeout(parent, configLoaderTimeout)` so a runaway
|
|
1493
|
+
// `ttsx` subprocess can never hang the lint binary indefinitely.
|
|
1494
|
+
func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
|
|
1030
1495
|
ttsx := os.Getenv("TTSC_TTSX_BINARY")
|
|
1031
1496
|
if ttsx == "" {
|
|
1032
1497
|
ttsx = "ttsx"
|
|
@@ -1036,9 +1501,9 @@ func ttsxCommand(args ...string) *exec.Cmd {
|
|
|
1036
1501
|
if node == "" {
|
|
1037
1502
|
node = "node"
|
|
1038
1503
|
}
|
|
1039
|
-
return exec.
|
|
1504
|
+
return exec.CommandContext(ctx, node, append([]string{ttsx}, args...)...)
|
|
1040
1505
|
}
|
|
1041
|
-
return exec.
|
|
1506
|
+
return exec.CommandContext(ctx, ttsx, args...)
|
|
1042
1507
|
}
|
|
1043
1508
|
|
|
1044
1509
|
func shouldRunTtsxThroughNode(binary string) bool {
|
|
@@ -1071,8 +1536,32 @@ func linkNearestNodeModules(tempDir, sourceDir string) error {
|
|
|
1071
1536
|
return nil
|
|
1072
1537
|
}
|
|
1073
1538
|
link := filepath.Join(tempDir, "node_modules")
|
|
1074
|
-
|
|
1075
|
-
|
|
1539
|
+
err := os.Symlink(nodeModules, link)
|
|
1540
|
+
if err == nil {
|
|
1541
|
+
return nil
|
|
1542
|
+
}
|
|
1543
|
+
// Windows: a true symbolic link needs SeCreateSymbolicLinkPrivilege
|
|
1544
|
+
// (admin or Developer Mode). The JS side uses fs.symlink with the
|
|
1545
|
+
// `"junction"` type to dodge that restriction; here we shell out to
|
|
1546
|
+
// `mklink /J` to create an equivalent directory junction. Junctions
|
|
1547
|
+
// only work for absolute directory targets, which matches the input.
|
|
1548
|
+
if runtime.GOOS == "windows" {
|
|
1549
|
+
jerr := createWindowsJunction(link, nodeModules)
|
|
1550
|
+
if jerr == nil {
|
|
1551
|
+
return nil
|
|
1552
|
+
}
|
|
1553
|
+
err = fmt.Errorf("%w (junction fallback: %v)", err, jerr)
|
|
1554
|
+
}
|
|
1555
|
+
return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
func createWindowsJunction(link, target string) error {
|
|
1559
|
+
// `cmd /c mklink /J link target` is the standard recipe and works
|
|
1560
|
+
// without elevated privileges. Both arguments must be absolute paths
|
|
1561
|
+
// with native separators, which they already are here.
|
|
1562
|
+
cmd := exec.Command("cmd", "/c", "mklink", "/J", link, target)
|
|
1563
|
+
if out, err := cmd.CombinedOutput(); err != nil {
|
|
1564
|
+
return fmt.Errorf("mklink /J failed: %v: %s", err, strings.TrimSpace(string(out)))
|
|
1076
1565
|
}
|
|
1077
1566
|
return nil
|
|
1078
1567
|
}
|
|
@@ -1103,14 +1592,42 @@ func setEnv(env []string, key, value string) []string {
|
|
|
1103
1592
|
return append(env, prefix+value)
|
|
1104
1593
|
}
|
|
1105
1594
|
|
|
1106
|
-
|
|
1595
|
+
// parseExternalRuleEntry mirrors parseRuleEntry for ESLint-style config
|
|
1596
|
+
// inputs. The contract is identical to the inline path: severity
|
|
1597
|
+
// literal, `[severity]`, or `[severity, options]`. Tuples longer than
|
|
1598
|
+
// two elements are rejected because no built-in rule's option struct
|
|
1599
|
+
// models positional ESLint args, so silently encoding the tail as a
|
|
1600
|
+
// JSON array would land in `DecodeOptions` and fall back to defaults
|
|
1601
|
+
// — a silent fallback the standard parser deliberately avoids.
|
|
1602
|
+
func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
|
|
1107
1603
|
if tuple, ok := v.([]any); ok {
|
|
1108
1604
|
if len(tuple) == 0 {
|
|
1109
|
-
return SeverityOff, fmt.Errorf("severity tuple must not be empty")
|
|
1605
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
|
|
1606
|
+
}
|
|
1607
|
+
sev, err := parseSeverity(tuple[0])
|
|
1608
|
+
if err != nil {
|
|
1609
|
+
return SeverityOff, nil, err
|
|
1610
|
+
}
|
|
1611
|
+
if len(tuple) == 1 {
|
|
1612
|
+
return sev, nil, nil
|
|
1613
|
+
}
|
|
1614
|
+
if len(tuple) > 2 {
|
|
1615
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
|
|
1616
|
+
}
|
|
1617
|
+
if tuple[1] == nil {
|
|
1618
|
+
return sev, nil, nil
|
|
1619
|
+
}
|
|
1620
|
+
if _, ok := tuple[1].(map[string]any); !ok {
|
|
1621
|
+
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
|
|
1622
|
+
}
|
|
1623
|
+
encoded, err := json.Marshal(tuple[1])
|
|
1624
|
+
if err != nil {
|
|
1625
|
+
return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
|
|
1110
1626
|
}
|
|
1111
|
-
return
|
|
1627
|
+
return sev, encoded, nil
|
|
1112
1628
|
}
|
|
1113
|
-
|
|
1629
|
+
sev, err := parseSeverity(v)
|
|
1630
|
+
return sev, nil, err
|
|
1114
1631
|
}
|
|
1115
1632
|
|
|
1116
1633
|
func parseSeverity(v any) (Severity, error) {
|