@ttsc/lint 0.5.0 → 0.6.0-dev.20250501

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/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 arbitrary fields from the tsconfig plugin entry,
33
- // including `rules` for `@ttsc/lint`. `Mode` and `Name` come from the
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 `rules` map from a tsconfig plugin entry.
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: \"rules\" must be an object, got %T", raw)
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: