@ttsc/lint 0.5.0-dev.20260429-5 → 0.6.0-dev.20250430
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 +66 -8
- package/go.mod +1 -1
- package/lib/index.d.ts +10 -0
- package/lib/index.js +40 -0
- package/lib/index.js.map +1 -0
- package/lib/structures/ITtscLintConfig.d.ts +5 -0
- package/lib/structures/ITtscLintConfig.js +3 -0
- package/lib/structures/ITtscLintConfig.js.map +1 -0
- package/lib/structures/ITtscLintRule.d.ts +1 -0
- package/lib/structures/ITtscLintRule.js +3 -0
- package/lib/structures/ITtscLintRule.js.map +1 -0
- package/lib/structures/ITtscLintSeverity.d.ts +1 -0
- package/lib/structures/ITtscLintSeverity.js +3 -0
- package/lib/structures/ITtscLintSeverity.js.map +1 -0
- package/package.json +18 -9
- package/plugin/ast_helpers.go +56 -0
- package/plugin/compile.go +4 -4
- package/plugin/config.go +359 -5
- package/plugin/rules_gap.go +341 -0
- package/plugin/rules_logic.go +14 -1
- package/plugin/rules_var.go +87 -0
- package/src/index.ts +35 -0
- package/src/structures/ITtscLintConfig.ts +6 -0
- package/src/structures/ITtscLintRule.ts +131 -0
- package/src/structures/ITtscLintSeverity.ts +1 -0
- package/tsconfig.json +10 -0
- package/src/index.cjs +0 -29
package/plugin/config.go
CHANGED
|
@@ -3,6 +3,9 @@ package main
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
6
9
|
"strings"
|
|
7
10
|
)
|
|
8
11
|
|
|
@@ -29,9 +32,8 @@ func (s Severity) String() string {
|
|
|
29
32
|
|
|
30
33
|
// PluginEntry mirrors the shape ttsc serializes into `--plugins-json`.
|
|
31
34
|
//
|
|
32
|
-
// `Config` carries
|
|
33
|
-
//
|
|
34
|
-
// native descriptor.
|
|
35
|
+
// `Config` carries the tsconfig plugin entry. `Mode` and `Name` come from
|
|
36
|
+
// the native descriptor.
|
|
35
37
|
type PluginEntry struct {
|
|
36
38
|
Config map[string]any `json:"config"`
|
|
37
39
|
ContractVersion int `json:"contractVersion"`
|
|
@@ -70,7 +72,7 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
|
|
|
70
72
|
// rule name (e.g. "no-var").
|
|
71
73
|
type RuleConfig map[string]Severity
|
|
72
74
|
|
|
73
|
-
// ParseRules normalizes the
|
|
75
|
+
// ParseRules normalizes the rules map from a tsconfig plugin entry.
|
|
74
76
|
//
|
|
75
77
|
// Severity values:
|
|
76
78
|
// - `"off"` → SeverityOff
|
|
@@ -85,7 +87,7 @@ func ParseRules(raw any) (RuleConfig, error) {
|
|
|
85
87
|
}
|
|
86
88
|
dict, ok := raw.(map[string]any)
|
|
87
89
|
if !ok {
|
|
88
|
-
return nil, fmt.Errorf("@ttsc/lint: \"
|
|
90
|
+
return nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
|
|
89
91
|
}
|
|
90
92
|
out := make(RuleConfig, len(dict))
|
|
91
93
|
for name, value := range dict {
|
|
@@ -98,6 +100,358 @@ func ParseRules(raw any) (RuleConfig, error) {
|
|
|
98
100
|
return out, nil
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
// LoadRuleConfig resolves the lint config for one plugin entry. The only
|
|
104
|
+
// accepted lint-specific tsconfig key is `config`; it may be either an inline
|
|
105
|
+
// rules object or a string path to a standalone config file. Relative config
|
|
106
|
+
// paths are resolved from the tsconfig directory.
|
|
107
|
+
func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
|
|
108
|
+
if entry == nil {
|
|
109
|
+
return RuleConfig{}, nil
|
|
110
|
+
}
|
|
111
|
+
inline := entry.Config
|
|
112
|
+
if inline == nil {
|
|
113
|
+
inline = map[string]any{}
|
|
114
|
+
}
|
|
115
|
+
for _, key := range []string{"rules", "configFile", "configPath"} {
|
|
116
|
+
if _, ok := inline[key]; ok {
|
|
117
|
+
return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"config\"", key)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
value, ok := inline["config"]
|
|
122
|
+
if !ok {
|
|
123
|
+
return RuleConfig{}, nil
|
|
124
|
+
}
|
|
125
|
+
switch typed := value.(type) {
|
|
126
|
+
case string:
|
|
127
|
+
if strings.TrimSpace(typed) == "" {
|
|
128
|
+
return nil, fmt.Errorf("@ttsc/lint: \"config\" must not be empty")
|
|
129
|
+
}
|
|
130
|
+
rules, err := loadConfigFile(resolveConfigFilePath(typed, cwd, tsconfigPath))
|
|
131
|
+
if err != nil {
|
|
132
|
+
return nil, err
|
|
133
|
+
}
|
|
134
|
+
return ParseRules(rules)
|
|
135
|
+
case map[string]any:
|
|
136
|
+
return ParseRules(typed)
|
|
137
|
+
default:
|
|
138
|
+
return nil, fmt.Errorf("@ttsc/lint: \"config\" must be a string path or object, got %T", value)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
|
|
143
|
+
if filepath.IsAbs(configPath) {
|
|
144
|
+
return configPath
|
|
145
|
+
}
|
|
146
|
+
base := cwd
|
|
147
|
+
if tsconfigPath != "" {
|
|
148
|
+
resolvedTsconfig := tsconfigPath
|
|
149
|
+
if !filepath.IsAbs(resolvedTsconfig) {
|
|
150
|
+
resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
|
|
151
|
+
}
|
|
152
|
+
base = filepath.Dir(resolvedTsconfig)
|
|
153
|
+
}
|
|
154
|
+
return filepath.Join(base, configPath)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func loadConfigFile(location string) (map[string]any, error) {
|
|
158
|
+
ext := strings.ToLower(filepath.Ext(location))
|
|
159
|
+
switch ext {
|
|
160
|
+
case ".json":
|
|
161
|
+
return loadJSONConfigFile(location)
|
|
162
|
+
case ".js", ".cjs", ".mjs":
|
|
163
|
+
return loadScriptConfigFile(location)
|
|
164
|
+
case ".ts", ".cts", ".mts":
|
|
165
|
+
return loadTypeScriptConfigFile(location)
|
|
166
|
+
default:
|
|
167
|
+
return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func loadJSONConfigFile(location string) (map[string]any, error) {
|
|
172
|
+
body, err := os.ReadFile(location)
|
|
173
|
+
if err != nil {
|
|
174
|
+
return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
|
|
175
|
+
}
|
|
176
|
+
var out map[string]any
|
|
177
|
+
if err := json.Unmarshal(body, &out); err != nil {
|
|
178
|
+
return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
|
|
179
|
+
}
|
|
180
|
+
if out == nil {
|
|
181
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
|
|
182
|
+
}
|
|
183
|
+
return out, nil
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func loadScriptConfigFile(location string) (map[string]any, error) {
|
|
187
|
+
const script = `
|
|
188
|
+
const { pathToFileURL } = require("node:url");
|
|
189
|
+
|
|
190
|
+
(async () => {
|
|
191
|
+
const mod = await import(pathToFileURL(process.argv[1]).href);
|
|
192
|
+
const candidate = mod.default ?? mod.config ?? mod;
|
|
193
|
+
const value = typeof candidate === "function" ? await candidate() : candidate;
|
|
194
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
195
|
+
throw new Error("config file must export an object");
|
|
196
|
+
}
|
|
197
|
+
process.stdout.write(JSON.stringify(value));
|
|
198
|
+
})().catch((error) => {
|
|
199
|
+
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
|
202
|
+
`
|
|
203
|
+
node := os.Getenv("TTSC_NODE_BINARY")
|
|
204
|
+
if node == "" {
|
|
205
|
+
node = "node"
|
|
206
|
+
}
|
|
207
|
+
cmd := exec.Command(node, "-e", script, location)
|
|
208
|
+
output, err := cmd.Output()
|
|
209
|
+
if err != nil {
|
|
210
|
+
stderr := ""
|
|
211
|
+
if exit, ok := err.(*exec.ExitError); ok {
|
|
212
|
+
stderr = strings.TrimSpace(string(exit.Stderr))
|
|
213
|
+
}
|
|
214
|
+
if stderr != "" {
|
|
215
|
+
return nil, fmt.Errorf("@ttsc/lint: load config file %s: %s", location, stderr)
|
|
216
|
+
}
|
|
217
|
+
return nil, fmt.Errorf("@ttsc/lint: load config file %s: %w", location, err)
|
|
218
|
+
}
|
|
219
|
+
var out map[string]any
|
|
220
|
+
if err := json.Unmarshal(output, &out); err != nil {
|
|
221
|
+
return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
|
|
222
|
+
}
|
|
223
|
+
if out == nil {
|
|
224
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
|
|
225
|
+
}
|
|
226
|
+
return out, nil
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func loadTypeScriptConfigFile(location string) (map[string]any, error) {
|
|
230
|
+
tempDir, err := os.MkdirTemp("", "ttsc-lint-config-")
|
|
231
|
+
if err != nil {
|
|
232
|
+
return nil, fmt.Errorf("@ttsc/lint: create config loader tempdir: %w", err)
|
|
233
|
+
}
|
|
234
|
+
defer os.RemoveAll(tempDir)
|
|
235
|
+
|
|
236
|
+
if err := linkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
|
|
237
|
+
return nil, err
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
loader := filepath.Join(tempDir, "loader.mts")
|
|
241
|
+
tsconfig := filepath.Join(tempDir, "tsconfig.json")
|
|
242
|
+
importSpecifier, err := relativeImportSpecifier(tempDir, location)
|
|
243
|
+
if err != nil {
|
|
244
|
+
return nil, err
|
|
245
|
+
}
|
|
246
|
+
importLiteral, err := json.Marshal(importSpecifier)
|
|
247
|
+
if err != nil {
|
|
248
|
+
return nil, fmt.Errorf("@ttsc/lint: encode config import %s: %w", location, err)
|
|
249
|
+
}
|
|
250
|
+
if err := os.WriteFile(loader, []byte(typeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
|
|
251
|
+
return nil, fmt.Errorf("@ttsc/lint: write config loader: %w", err)
|
|
252
|
+
}
|
|
253
|
+
if err := os.WriteFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
|
|
254
|
+
return nil, fmt.Errorf("@ttsc/lint: write config loader tsconfig: %w", err)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
args := []string{
|
|
258
|
+
"--project", tsconfig,
|
|
259
|
+
"--cwd", tempDir,
|
|
260
|
+
"--cache-dir", filepath.Join(tempDir, "cache"),
|
|
261
|
+
}
|
|
262
|
+
if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
|
|
263
|
+
args = append(args, "--binary", tsgo)
|
|
264
|
+
}
|
|
265
|
+
args = append(args, loader)
|
|
266
|
+
|
|
267
|
+
cmd := ttsxCommand(args...)
|
|
268
|
+
cmd.Env = nodeConfigLoaderEnv(location)
|
|
269
|
+
output, err := cmd.Output()
|
|
270
|
+
if err != nil {
|
|
271
|
+
stderr := ""
|
|
272
|
+
if exit, ok := err.(*exec.ExitError); ok {
|
|
273
|
+
stderr = strings.TrimSpace(string(exit.Stderr))
|
|
274
|
+
}
|
|
275
|
+
if stderr != "" {
|
|
276
|
+
return nil, fmt.Errorf("@ttsc/lint: load TypeScript config file %s: %s", location, stderr)
|
|
277
|
+
}
|
|
278
|
+
return nil, fmt.Errorf("@ttsc/lint: load TypeScript config file %s: %w", location, err)
|
|
279
|
+
}
|
|
280
|
+
var out map[string]any
|
|
281
|
+
if err := json.Unmarshal(output, &out); err != nil {
|
|
282
|
+
return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
|
|
283
|
+
}
|
|
284
|
+
if out == nil {
|
|
285
|
+
return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
|
|
286
|
+
}
|
|
287
|
+
return out, nil
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func relativeImportSpecifier(fromDir, location string) (string, error) {
|
|
291
|
+
relative, err := filepath.Rel(fromDir, location)
|
|
292
|
+
if err != nil {
|
|
293
|
+
return "", fmt.Errorf("@ttsc/lint: resolve relative config import %s: %w", location, err)
|
|
294
|
+
}
|
|
295
|
+
relative = filepath.ToSlash(relative)
|
|
296
|
+
if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
|
|
297
|
+
return relative, nil
|
|
298
|
+
}
|
|
299
|
+
return "./" + relative, nil
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func typeScriptConfigLoaderSource(importLiteral string) string {
|
|
303
|
+
return fmt.Sprintf(`import * as importedConfig from %s;
|
|
304
|
+
|
|
305
|
+
declare const process: {
|
|
306
|
+
stdout: { write(value: string): void };
|
|
307
|
+
stderr: { write(value: string): void };
|
|
308
|
+
exit(code?: number): never;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const value = await resolveConfig(importedConfig, true);
|
|
313
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
314
|
+
throw new Error("config file must export an object");
|
|
315
|
+
}
|
|
316
|
+
process.stdout.write(JSON.stringify(value));
|
|
317
|
+
} catch (error) {
|
|
318
|
+
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise<unknown> {
|
|
323
|
+
let current = value;
|
|
324
|
+
for (let i = 0; i < 8; i++) {
|
|
325
|
+
if (isObject(current) && hasOwn(current, "default")) {
|
|
326
|
+
current = current.default;
|
|
327
|
+
allowNamedConfig = false;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (allowNamedConfig && isObject(current) && hasOwn(current, "config")) {
|
|
331
|
+
current = current.config;
|
|
332
|
+
allowNamedConfig = false;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (typeof current === "function") {
|
|
338
|
+
return await (current as () => unknown | Promise<unknown>)();
|
|
339
|
+
}
|
|
340
|
+
return current;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
344
|
+
return value !== null && typeof value === "object";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
348
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
349
|
+
}
|
|
350
|
+
`, importLiteral)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
354
|
+
content := map[string]any{
|
|
355
|
+
"compilerOptions": map[string]any{
|
|
356
|
+
"allowImportingTsExtensions": true,
|
|
357
|
+
"module": "ESNext",
|
|
358
|
+
"moduleResolution": "bundler",
|
|
359
|
+
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
360
|
+
"rewriteRelativeImportExtensions": true,
|
|
361
|
+
"rootDir": "/",
|
|
362
|
+
"skipLibCheck": true,
|
|
363
|
+
"strict": true,
|
|
364
|
+
"target": "ES2022",
|
|
365
|
+
},
|
|
366
|
+
"files": []string{
|
|
367
|
+
filepath.ToSlash(loader),
|
|
368
|
+
filepath.ToSlash(location),
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
body, err := json.MarshalIndent(content, "", " ")
|
|
372
|
+
if err != nil {
|
|
373
|
+
panic(err)
|
|
374
|
+
}
|
|
375
|
+
return string(body)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func ttsxCommand(args ...string) *exec.Cmd {
|
|
379
|
+
ttsx := os.Getenv("TTSC_TTSX_BINARY")
|
|
380
|
+
if ttsx == "" {
|
|
381
|
+
ttsx = "ttsx"
|
|
382
|
+
}
|
|
383
|
+
if shouldRunTtsxThroughNode(ttsx) {
|
|
384
|
+
node := os.Getenv("TTSC_NODE_BINARY")
|
|
385
|
+
if node == "" {
|
|
386
|
+
node = "node"
|
|
387
|
+
}
|
|
388
|
+
return exec.Command(node, append([]string{ttsx}, args...)...)
|
|
389
|
+
}
|
|
390
|
+
return exec.Command(ttsx, args...)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
func shouldRunTtsxThroughNode(binary string) bool {
|
|
394
|
+
switch strings.ToLower(filepath.Ext(binary)) {
|
|
395
|
+
case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
|
|
396
|
+
return true
|
|
397
|
+
default:
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func nodeConfigLoaderEnv(location string) []string {
|
|
403
|
+
env := os.Environ()
|
|
404
|
+
parts := make([]string, 0, 2)
|
|
405
|
+
if nodeModules := findNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
|
|
406
|
+
parts = append(parts, nodeModules)
|
|
407
|
+
}
|
|
408
|
+
if existing := os.Getenv("NODE_PATH"); existing != "" {
|
|
409
|
+
parts = append(parts, existing)
|
|
410
|
+
}
|
|
411
|
+
if len(parts) == 0 {
|
|
412
|
+
return env
|
|
413
|
+
}
|
|
414
|
+
return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func linkNearestNodeModules(tempDir, sourceDir string) error {
|
|
418
|
+
nodeModules := findNearestNodeModules(sourceDir)
|
|
419
|
+
if nodeModules == "" {
|
|
420
|
+
return nil
|
|
421
|
+
}
|
|
422
|
+
link := filepath.Join(tempDir, "node_modules")
|
|
423
|
+
if err := os.Symlink(nodeModules, link); err != nil {
|
|
424
|
+
return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
|
|
425
|
+
}
|
|
426
|
+
return nil
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func findNearestNodeModules(start string) string {
|
|
430
|
+
dir := filepath.Clean(start)
|
|
431
|
+
for {
|
|
432
|
+
candidate := filepath.Join(dir, "node_modules")
|
|
433
|
+
if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
|
|
434
|
+
return candidate
|
|
435
|
+
}
|
|
436
|
+
parent := filepath.Dir(dir)
|
|
437
|
+
if parent == dir {
|
|
438
|
+
return ""
|
|
439
|
+
}
|
|
440
|
+
dir = parent
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
func setEnv(env []string, key, value string) []string {
|
|
445
|
+
prefix := key + "="
|
|
446
|
+
for i, entry := range env {
|
|
447
|
+
if strings.HasPrefix(entry, prefix) {
|
|
448
|
+
env[i] = prefix + value
|
|
449
|
+
return env
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return append(env, prefix+value)
|
|
453
|
+
}
|
|
454
|
+
|
|
101
455
|
func parseSeverity(v any) (Severity, error) {
|
|
102
456
|
switch x := v.(type) {
|
|
103
457
|
case string:
|