@ttsc/lint 0.10.1 → 0.11.0-dev.20260517

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.
Files changed (113) hide show
  1. package/README.md +172 -33
  2. package/lib/defineConfig.d.ts +55 -0
  3. package/lib/defineConfig.js +39 -0
  4. package/lib/defineConfig.js.map +1 -0
  5. package/lib/index.d.ts +25 -1
  6. package/lib/index.js +622 -2
  7. package/lib/index.js.map +1 -1
  8. package/lib/structures/ITtscLintPlugin.d.ts +53 -0
  9. package/lib/structures/ITtscLintPlugin.js +3 -0
  10. package/lib/structures/ITtscLintPlugin.js.map +1 -0
  11. package/lib/structures/ITtscLintPluginConfig.d.ts +82 -5
  12. package/lib/structures/ITtscLintPluginMeta.d.ts +12 -0
  13. package/lib/structures/ITtscLintPluginMeta.js +3 -0
  14. package/lib/structures/ITtscLintPluginMeta.js.map +1 -0
  15. package/lib/structures/PluginRuleNames.d.ts +14 -0
  16. package/lib/structures/PluginRuleNames.js +3 -0
  17. package/lib/structures/PluginRuleNames.js.map +1 -0
  18. package/lib/structures/TtscLintConfig.d.ts +24 -8
  19. package/lib/structures/TtscLintConfigEntry.d.ts +39 -0
  20. package/lib/structures/TtscLintConfigEntry.js +3 -0
  21. package/lib/structures/TtscLintConfigEntry.js.map +1 -0
  22. package/lib/structures/TtscLintFormatConfig.d.ts +154 -0
  23. package/lib/structures/TtscLintFormatConfig.js +3 -0
  24. package/lib/structures/TtscLintFormatConfig.js.map +1 -0
  25. package/lib/structures/TtscLintPlugins.d.ts +9 -0
  26. package/lib/structures/TtscLintPlugins.js +3 -0
  27. package/lib/structures/TtscLintPlugins.js.map +1 -0
  28. package/lib/structures/TtscLintRule.d.ts +1 -1
  29. package/lib/structures/TtscLintRuleEntry.d.ts +22 -0
  30. package/lib/structures/TtscLintRuleEntry.js +3 -0
  31. package/lib/structures/TtscLintRuleEntry.js.map +1 -0
  32. package/lib/structures/TtscLintRuleMap.d.ts +44 -0
  33. package/lib/structures/TtscLintRuleMap.js +3 -0
  34. package/lib/structures/TtscLintRuleMap.js.map +1 -0
  35. package/lib/structures/TtscLintRuleOptions.d.ts +149 -0
  36. package/lib/structures/TtscLintRuleOptions.js +3 -0
  37. package/lib/structures/TtscLintRuleOptions.js.map +1 -0
  38. package/lib/structures/defaultFormat.d.ts +31 -0
  39. package/lib/structures/defaultFormat.js +35 -0
  40. package/lib/structures/defaultFormat.js.map +1 -0
  41. package/lib/structures/index.d.ts +10 -0
  42. package/lib/structures/index.js +10 -0
  43. package/lib/structures/index.js.map +1 -1
  44. package/{plugin → linthost}/ast_helpers.go +92 -1
  45. package/{plugin → linthost}/compile.go +1 -1
  46. package/{plugin → linthost}/config.go +583 -66
  47. package/linthost/config_format.go +360 -0
  48. package/linthost/contrib_adapter.go +134 -0
  49. package/{plugin → linthost}/directives.go +1 -1
  50. package/linthost/dispatch.go +62 -0
  51. package/{plugin → linthost}/engine.go +156 -11
  52. package/{plugin → linthost}/eslint_runtime.go +76 -2
  53. package/linthost/fix.go +249 -0
  54. package/linthost/format.go +107 -0
  55. package/{plugin → linthost}/host.go +1 -1
  56. package/linthost/print_dispatch.go +118 -0
  57. package/linthost/print_doc.go +168 -0
  58. package/linthost/print_engine.go +343 -0
  59. package/linthost/print_nodes_array.go +46 -0
  60. package/linthost/print_nodes_call.go +180 -0
  61. package/linthost/print_nodes_imports.go +171 -0
  62. package/linthost/print_nodes_list.go +64 -0
  63. package/linthost/print_nodes_object.go +51 -0
  64. package/{plugin → linthost}/rules_arrays.go +1 -1
  65. package/{plugin → linthost}/rules_console.go +1 -1
  66. package/{plugin → linthost}/rules_debugger.go +1 -1
  67. package/{plugin → linthost}/rules_dupes.go +1 -1
  68. package/{plugin → linthost}/rules_empty.go +1 -1
  69. package/linthost/rules_escape.go +238 -0
  70. package/{plugin → linthost}/rules_eval.go +1 -1
  71. package/{plugin → linthost}/rules_finally.go +1 -1
  72. package/linthost/rules_format_jsdoc.go +230 -0
  73. package/linthost/rules_format_print_width.go +332 -0
  74. package/linthost/rules_format_quotes.go +219 -0
  75. package/linthost/rules_format_semi.go +190 -0
  76. package/linthost/rules_format_sort_imports.go +462 -0
  77. package/linthost/rules_format_trailing_comma.go +460 -0
  78. package/{plugin → linthost}/rules_gap.go +101 -6
  79. package/linthost/rules_imports.go +101 -0
  80. package/{plugin → linthost}/rules_logic.go +55 -3
  81. package/{plugin → linthost}/rules_loops.go +1 -1
  82. package/{plugin → linthost}/rules_misc.go +1 -1
  83. package/linthost/rules_params.go +64 -0
  84. package/{plugin → linthost}/rules_problems.go +10 -2
  85. package/linthost/rules_promise.go +111 -0
  86. package/{plugin → linthost}/rules_protos.go +1 -1
  87. package/{plugin → linthost}/rules_self.go +1 -1
  88. package/{plugin → linthost}/rules_strings.go +1 -1
  89. package/{plugin → linthost}/rules_suggestions.go +51 -9
  90. package/{plugin → linthost}/rules_throw.go +1 -1
  91. package/{plugin → linthost}/rules_ts.go +12 -2
  92. package/{plugin → linthost}/rules_ts_extra.go +90 -4
  93. package/{plugin → linthost}/rules_var.go +36 -5
  94. package/package.json +8 -6
  95. package/plugin/main.go +14 -27
  96. package/rule/astutil/astutil.go +148 -0
  97. package/rule/rule.go +291 -0
  98. package/src/defineConfig.ts +69 -0
  99. package/src/index.ts +807 -3
  100. package/src/structures/ITtscLintPlugin.ts +56 -0
  101. package/src/structures/ITtscLintPluginConfig.ts +86 -5
  102. package/src/structures/ITtscLintPluginMeta.ts +14 -0
  103. package/src/structures/PluginRuleNames.ts +19 -0
  104. package/src/structures/TtscLintConfig.ts +27 -8
  105. package/src/structures/TtscLintConfigEntry.ts +50 -0
  106. package/src/structures/TtscLintFormatConfig.ts +169 -0
  107. package/src/structures/TtscLintPlugins.ts +10 -0
  108. package/src/structures/TtscLintRule.ts +14 -1
  109. package/src/structures/TtscLintRuleEntry.ts +28 -0
  110. package/src/structures/TtscLintRuleMap.ts +52 -0
  111. package/src/structures/TtscLintRuleOptions.ts +162 -0
  112. package/src/structures/defaultFormat.ts +33 -0
  113. package/src/structures/index.ts +10 -0
package/src/index.ts CHANGED
@@ -1,13 +1,24 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import os from "node:os";
1
5
  import path from "node:path";
2
6
 
3
- import type { ITtscLintPluginConfig } from "./structures";
7
+ import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures";
4
8
 
9
+ export * from "./defineConfig";
5
10
  export * from "./structures/index";
6
11
 
12
+ type TtscPluginContributor = {
13
+ name: string;
14
+ source: string;
15
+ };
16
+
7
17
  type TtscPluginDescriptor = {
8
18
  name: string;
9
19
  source: string;
10
20
  stage?: "check" | "transform";
21
+ contributors?: TtscPluginContributor[];
11
22
  };
12
23
 
13
24
  type TtscPluginFactoryContext<TConfig> = {
@@ -18,12 +29,805 @@ type TtscPluginFactoryContext<TConfig> = {
18
29
  tsconfig: string;
19
30
  };
20
31
 
32
+ // Namespace becomes the rule-name prefix (`<ns>/<rule>`). Mirrors ESLint
33
+ // plugin namespace conventions: lowercase ASCII, digits, hyphens, and
34
+ // underscores; leading character must be alphabetic so the prefix never
35
+ // collides with a rule name that itself starts with a digit. Hyphens
36
+ // are encoded into underscores for the Go sub-package name (see
37
+ // `goSubpackageName`); the user-facing prefix keeps the original form.
38
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9_-]*$/;
39
+
40
+ /**
41
+ * Map a user-facing namespace (`react-hooks`) to a Go-valid sub-package name
42
+ * (`react_hooks`). Required because ttsc's plugin builder uses the `name` field
43
+ * as a directory and import-path suffix, both of which must satisfy Go's
44
+ * stricter `[a-z][a-z0-9_]*` identifier rules. The function is total over
45
+ * namespaces that already passed `NAMESPACE_PATTERN`.
46
+ */
47
+ function goSubpackageName(namespace: string): string {
48
+ return namespace.replace(/-/g, "_");
49
+ }
50
+
51
+ const LINT_CONFIG_FILENAMES = [
52
+ "lint.config.ts",
53
+ "lint.config.mts",
54
+ "lint.config.cts",
55
+ "lint.config.mjs",
56
+ "lint.config.cjs",
57
+ "lint.config.js",
58
+ "lint.config.json",
59
+ "ttsc-lint.config.ts",
60
+ "ttsc-lint.config.mts",
61
+ "ttsc-lint.config.cts",
62
+ "ttsc-lint.config.mjs",
63
+ "ttsc-lint.config.cjs",
64
+ "ttsc-lint.config.js",
65
+ "ttsc-lint.config.json",
66
+ "eslint.config.ts",
67
+ "eslint.config.mts",
68
+ "eslint.config.cts",
69
+ "eslint.config.mjs",
70
+ "eslint.config.cjs",
71
+ "eslint.config.js",
72
+ ];
73
+
74
+ /**
75
+ * Plugin descriptor factory for `@ttsc/lint`.
76
+ *
77
+ * Two discovery surfaces feed the descriptor's `contributors` field:
78
+ *
79
+ * 1. The tsconfig plugin entry's `plugins` map — namespace → npm specifier. Inline
80
+ * for projects that prefer to keep everything in `tsconfig.json`.
81
+ * 2. The companion `lint.config.{ts,cts,mts,js,cjs,mjs,json}` (or
82
+ * `eslint.config.*`) file — flat-config-style with an in-memory `plugins: {
83
+ * ns: pluginObject }` map. The factory evaluates the config (via ttsx for TS
84
+ * / ESM sources, `require` for CommonJS, `JSON.parse` for JSON) and walks
85
+ * every entry's `plugins` field.
86
+ *
87
+ * Contributions from both sources are merged with the tsconfig entry winning on
88
+ * namespace collisions, so a project can opt into a hand-curated subset of an
89
+ * external `lint.config.ts` by overriding specific namespaces in
90
+ * `tsconfig.json`.
91
+ */
21
92
  export default function createTtscPlugin(
22
- _context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
93
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
23
94
  ): TtscPluginDescriptor {
24
- return {
95
+ const inline = resolveInlineContributors(context);
96
+ const fromConfig = resolveConfigFileContributors(
97
+ context,
98
+ inline.map((c) => c.name),
99
+ );
100
+ const contributors = [...inline, ...fromConfig];
101
+ // Build the descriptor without a `contributors` key when none were
102
+ // declared, so consumers (and the existing key-shape regression
103
+ // tests) see the same surface as before this feature shipped.
104
+ const descriptor: TtscPluginDescriptor = {
25
105
  name: "@ttsc/lint",
26
106
  source: path.resolve(__dirname, "..", "plugin"),
27
107
  stage: "check",
28
108
  };
109
+ if (contributors.length > 0) {
110
+ descriptor.contributors = contributors;
111
+ }
112
+ return descriptor;
113
+ }
114
+
115
+ // ────────────────────────────────────────────────────────────────────────────
116
+ // tsconfig-inline `plugins` map (the original MVP path)
117
+ // ────────────────────────────────────────────────────────────────────────────
118
+
119
+ function resolveInlineContributors(
120
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
121
+ ): TtscPluginContributor[] {
122
+ const declared = (context.plugin as { plugins?: unknown }).plugins;
123
+ if (declared === undefined) return [];
124
+ if (
125
+ typeof declared !== "object" ||
126
+ declared === null ||
127
+ Array.isArray(declared)
128
+ ) {
129
+ throw new Error(
130
+ `@ttsc/lint: "plugins" in tsconfig plugin entry must be an object map of namespace → package specifier`,
131
+ );
132
+ }
133
+ const out: TtscPluginContributor[] = [];
134
+ // Track the post-`goSubpackageName` form so `a-b` and `a_b` are
135
+ // caught as colliding aliases before they reach the downstream
136
+ // contributor validator's opaque `duplicate name "a_b"` error.
137
+ // (`Object.entries` cannot itself surface duplicate string keys, so
138
+ // a verbatim-namespace guard is unreachable.)
139
+ const seenGoNames = new Map<string, string>();
140
+ for (const [namespace, specifier] of Object.entries(declared)) {
141
+ if (!NAMESPACE_PATTERN.test(namespace)) {
142
+ throw new Error(
143
+ `@ttsc/lint: contributor namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
144
+ );
145
+ }
146
+ if (typeof specifier !== "string" || specifier.length === 0) {
147
+ throw new Error(
148
+ `@ttsc/lint: contributor ${JSON.stringify(namespace)} must point at a non-empty package specifier or path`,
149
+ );
150
+ }
151
+ const goName = goSubpackageName(namespace);
152
+ const earlier = seenGoNames.get(goName);
153
+ if (earlier !== undefined) {
154
+ throw new Error(
155
+ `@ttsc/lint: contributor namespaces ${JSON.stringify(earlier)} and ${JSON.stringify(namespace)} both map to Go sub-package ${JSON.stringify(goName)}; pick one form (hyphens collapse to underscores for the Go identifier)`,
156
+ );
157
+ }
158
+ seenGoNames.set(goName, namespace);
159
+ const plugin = loadContributorPluginViaRequire(
160
+ specifier,
161
+ context,
162
+ namespace,
163
+ );
164
+ out.push({ name: goName, source: plugin.source });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function loadContributorPluginViaRequire(
170
+ specifier: string,
171
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
172
+ namespace: string,
173
+ anchorFile?: string,
174
+ ): ITtscLintPlugin {
175
+ // Resolve relative paths and node_modules lookups from the file that
176
+ // declared the specifier, when one is available — `lint.config.json`
177
+ // / `lint.config.cjs` reach this code with their own path on disk,
178
+ // and ttsc's tsconfig plugin entry falls back to the project root.
179
+ const anchor =
180
+ anchorFile ??
181
+ path.join(
182
+ path.resolve(context.cwd ?? context.projectRoot),
183
+ "__lint_contributor_resolve__.cjs",
184
+ );
185
+ const requireFromProject = createRequire(anchor);
186
+ let resolved: string;
187
+ try {
188
+ resolved = requireFromProject.resolve(specifier);
189
+ } catch (error) {
190
+ throw new Error(
191
+ `@ttsc/lint: failed to resolve contributor "${namespace}" via "${specifier}": ${
192
+ error instanceof Error ? error.message : String(error)
193
+ }`,
194
+ );
195
+ }
196
+ let mod: unknown;
197
+ try {
198
+ mod = requireFromProject(resolved);
199
+ } catch (error) {
200
+ throw new Error(
201
+ `@ttsc/lint: failed to load contributor "${namespace}" from ${resolved}: ${
202
+ error instanceof Error ? error.message : String(error)
203
+ }`,
204
+ );
205
+ }
206
+ return validatePluginShape(unwrapDefault(mod), namespace, resolved);
207
+ }
208
+
209
+ // ────────────────────────────────────────────────────────────────────────────
210
+ // lint.config.* discovery + evaluation
211
+ // ────────────────────────────────────────────────────────────────────────────
212
+
213
+ /** Plugin entries observed in the flat-config file, normalized per file. */
214
+ type ConfigPluginEntry = { namespace: string; source: string };
215
+
216
+ function resolveConfigFileContributors(
217
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
218
+ inlineNames: readonly string[],
219
+ ): TtscPluginContributor[] {
220
+ // Read the new `rules` / `extends` fields with a one-time fallback to
221
+ // the legacy `config` field. The legacy fallback warns once per
222
+ // ttsc invocation so existing tsconfigs keep working through the
223
+ // deprecation window without crashing CI.
224
+ const { hasInlineRules, extendsPath } = readSeverityConfig(context);
225
+ if (hasInlineRules) {
226
+ // Inline rules → no lint.config.* file involved. Skip discovery so
227
+ // we don't pull in plugins from an unrelated file.
228
+ return [];
229
+ }
230
+
231
+ const configPath =
232
+ extendsPath !== undefined
233
+ ? path.resolve(tsconfigBaseDir(context), extendsPath)
234
+ : findLintConfigFile(context);
235
+ if (!configPath || !fs.existsSync(configPath)) return [];
236
+
237
+ const entries = readConfigPluginEntries(configPath, context);
238
+ // Dedup against the Go-subpackage form (post hyphen→underscore
239
+ // transform). The inline arm has already applied `goSubpackageName`
240
+ // when it produced `inlineNames`, so comparing on the original
241
+ // hyphenated namespace would always miss for hyphenated namespaces
242
+ // and emit a colliding contributor that `validatePluginContributors`
243
+ // later rejects as a duplicate name.
244
+ const occupied = new Set(inlineNames);
245
+ const out: TtscPluginContributor[] = [];
246
+ for (const entry of entries) {
247
+ const goName = goSubpackageName(entry.namespace);
248
+ if (occupied.has(goName)) continue; // tsconfig inline wins
249
+ occupied.add(goName);
250
+ out.push({ name: goName, source: entry.source });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * Resolves the inline-rule vs file-path split between the new `rules` /
257
+ * `extends` fields and the legacy `config` field.
258
+ *
259
+ * - `rules` (object) routes the discovery loop away from any `lint.config.*` file
260
+ * — the inline map is authoritative.
261
+ * - `extends` (string) routes the file walk to a fixed path.
262
+ * - `config` (legacy) silently maps onto the equivalent new field. The
263
+ * user-facing deprecation notice is emitted by the Go sidecar so that a
264
+ * single ttsc invocation prints exactly one warning regardless of how many
265
+ * entry points (JS factory, Go binary) parse the same key.
266
+ * - Mixing legacy and new keys, or mixing `rules` with `extends`, is rejected
267
+ * outright so users don't end up with silent precedence surprises.
268
+ */
269
+ function readSeverityConfig(
270
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
271
+ ): { hasInlineRules: boolean; extendsPath: string | undefined } {
272
+ const entry = context.plugin as Record<string, unknown>;
273
+ const rules = entry.rules;
274
+ const extendsRaw = entry.extends;
275
+ const legacy = entry.config;
276
+ const hasNewRules = rules !== undefined;
277
+ const hasExtends = extendsRaw !== undefined;
278
+ const hasLegacy = legacy !== undefined;
279
+
280
+ if (hasLegacy && (hasNewRules || hasExtends)) {
281
+ throw new Error(
282
+ `@ttsc/lint: tsconfig plugin entry mixes legacy "config" with the new "rules"/"extends" fields; remove "config" (deprecated)`,
283
+ );
284
+ }
285
+ if (hasNewRules && hasExtends) {
286
+ throw new Error(
287
+ `@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`,
288
+ );
289
+ }
290
+
291
+ if (hasNewRules) {
292
+ if (typeof rules !== "object" || rules === null || Array.isArray(rules)) {
293
+ const actual = Array.isArray(rules)
294
+ ? "array"
295
+ : rules === null
296
+ ? "null"
297
+ : typeof rules;
298
+ throw new Error(
299
+ `@ttsc/lint: "rules" must be a rule severity map, got ${actual}`,
300
+ );
301
+ }
302
+ return { hasInlineRules: true, extendsPath: undefined };
303
+ }
304
+ if (hasExtends) {
305
+ if (typeof extendsRaw !== "string" || extendsRaw.length === 0) {
306
+ throw new Error(`@ttsc/lint: "extends" must be a non-empty string path`);
307
+ }
308
+ return { hasInlineRules: false, extendsPath: extendsRaw };
309
+ }
310
+ if (hasLegacy) {
311
+ if (
312
+ typeof legacy === "object" &&
313
+ legacy !== null &&
314
+ !Array.isArray(legacy)
315
+ ) {
316
+ return { hasInlineRules: true, extendsPath: undefined };
317
+ }
318
+ if (typeof legacy === "string" && legacy.length > 0) {
319
+ return { hasInlineRules: false, extendsPath: legacy };
320
+ }
321
+ throw new Error(
322
+ `@ttsc/lint: legacy "config" must be a non-empty string path or a rule severity map, got ${typeof legacy}`,
323
+ );
324
+ }
325
+ return { hasInlineRules: false, extendsPath: undefined };
326
+ }
327
+
328
+ function findLintConfigFile(
329
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
330
+ ): string | undefined {
331
+ // Mirror the Go-side discovery loop: walk from the tsconfig directory
332
+ // upward, returning the first directory that has exactly one of the
333
+ // candidate filenames. Multiple files in the same directory is treated
334
+ // as ambiguous and skipped (the Go side raises a hard error on the
335
+ // duplicate; here we leave it to the binary's own discovery to surface
336
+ // the issue once with one canonical message).
337
+ let dir = tsconfigBaseDir(context);
338
+ while (true) {
339
+ const matches = LINT_CONFIG_FILENAMES.map((name) =>
340
+ path.join(dir, name),
341
+ ).filter(
342
+ (candidate) =>
343
+ fs.existsSync(candidate) && fs.statSync(candidate).isFile(),
344
+ );
345
+ if (matches.length === 1) {
346
+ return matches[0];
347
+ }
348
+ if (matches.length > 1) {
349
+ return undefined; // ambiguous — defer to the Go side's error
350
+ }
351
+ const parent = path.dirname(dir);
352
+ if (parent === dir) return undefined;
353
+ dir = parent;
354
+ }
355
+ }
356
+
357
+ function tsconfigBaseDir(
358
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
359
+ ): string {
360
+ const cwd = context.cwd ?? context.projectRoot;
361
+ if (context.tsconfig) {
362
+ return path.dirname(
363
+ path.isAbsolute(context.tsconfig)
364
+ ? context.tsconfig
365
+ : path.join(cwd, context.tsconfig),
366
+ );
367
+ }
368
+ return path.resolve(cwd);
369
+ }
370
+
371
+ function readConfigPluginEntries(
372
+ configPath: string,
373
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
374
+ ): ConfigPluginEntry[] {
375
+ const ext = path.extname(configPath).toLowerCase();
376
+ if (ext === ".json") {
377
+ return readJsonConfigPlugins(configPath, context);
378
+ }
379
+ if (ext === ".js" || ext === ".cjs") {
380
+ return readCjsConfigPlugins(configPath);
381
+ }
382
+ // .ts, .cts, .mts, .mjs all need ttsx-side evaluation. .mjs sneaks in
383
+ // here because Node can't `require()` an ESM file synchronously.
384
+ return readTtsxConfigPlugins(configPath, context);
385
+ }
386
+
387
+ function readJsonConfigPlugins(
388
+ configPath: string,
389
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
390
+ ): ConfigPluginEntry[] {
391
+ let parsed: unknown;
392
+ try {
393
+ // Strip a leading UTF-8 BOM so files saved by Windows editors
394
+ // (Notepad++, some VS Code setups) round-trip through `JSON.parse`
395
+ // without an opaque "Unexpected token" failure.
396
+ const text = fs.readFileSync(configPath, "utf8").replace(/^\uFEFF/, "");
397
+ parsed = JSON.parse(text);
398
+ } catch (error) {
399
+ throw new Error(
400
+ `@ttsc/lint: failed to parse lint config ${configPath}: ${
401
+ error instanceof Error ? error.message : String(error)
402
+ }`,
403
+ );
404
+ }
405
+ // In JSON, plugin values can only be strings (npm specifiers) — there
406
+ // is no way to attach an in-memory plugin object inside a JSON file.
407
+ return collectPluginObjectsFromConfig(parsed)
408
+ .flatMap((map) => Object.entries(map))
409
+ .map(([namespace, value]): ConfigPluginEntry => {
410
+ if (!NAMESPACE_PATTERN.test(namespace)) {
411
+ throw new Error(
412
+ `@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
413
+ );
414
+ }
415
+ if (typeof value !== "string" || value.length === 0) {
416
+ throw new Error(
417
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(namespace)} must point at a package specifier string`,
418
+ );
419
+ }
420
+ const plugin = loadContributorPluginViaRequire(
421
+ value,
422
+ context,
423
+ namespace,
424
+ configPath,
425
+ );
426
+ return { namespace, source: plugin.source };
427
+ });
428
+ }
429
+
430
+ function readCjsConfigPlugins(configPath: string): ConfigPluginEntry[] {
431
+ let mod: unknown;
432
+ try {
433
+ const requireFromConfig = createRequire(configPath);
434
+ mod = requireFromConfig(configPath);
435
+ } catch (error) {
436
+ throw new Error(
437
+ `@ttsc/lint: failed to load lint config ${configPath}: ${
438
+ error instanceof Error ? error.message : String(error)
439
+ }`,
440
+ );
441
+ }
442
+ return collectPluginObjectsFromConfig(unwrapDefault(mod))
443
+ .flatMap((map) => Object.entries(map))
444
+ .map(([namespace, value]) =>
445
+ normalizePluginValue(namespace, value, configPath),
446
+ );
447
+ }
448
+
449
+ const TTSX_EXTRACTOR_SCRIPT = `import * as importedConfig from %CONFIG_IMPORT%;
450
+
451
+ declare const process: {
452
+ argv: string[];
453
+ cwd(): string;
454
+ stdout: { write(value: string): void };
455
+ stderr: { write(value: string): void };
456
+ exit(code?: number): never;
457
+ };
458
+
459
+ try {
460
+ let current: unknown = importedConfig;
461
+ for (let i = 0; i < 8; i++) {
462
+ if (isObject(current) && hasOwn(current, "default")) {
463
+ current = (current as Record<string, unknown>).default;
464
+ continue;
465
+ }
466
+ break;
467
+ }
468
+ if (typeof current === "function") {
469
+ current = await (current as () => unknown | Promise<unknown>)();
470
+ }
471
+ const pluginMaps = collectPluginObjects(current);
472
+ const entries: Array<{ namespace: string; source: string }> = [];
473
+ for (const map of pluginMaps) {
474
+ for (const [namespace, value] of Object.entries(map)) {
475
+ const source = extractPluginSource(value);
476
+ if (source === undefined) continue;
477
+ entries.push({ namespace, source });
478
+ }
479
+ }
480
+ process.stdout.write(JSON.stringify({ entries }));
481
+ } catch (error) {
482
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
483
+ process.exit(1);
484
+ }
485
+
486
+ function isObject(value: unknown): value is Record<string, unknown> {
487
+ return value !== null && typeof value === "object";
488
+ }
489
+
490
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
491
+ return Object.prototype.hasOwnProperty.call(value, key);
492
+ }
493
+
494
+ function collectPluginObjects(value: unknown): Array<Record<string, unknown>> {
495
+ const out: Array<Record<string, unknown>> = [];
496
+ visit(value);
497
+ return out;
498
+
499
+ function visit(node: unknown): void {
500
+ if (Array.isArray(node)) {
501
+ for (const item of node) visit(item);
502
+ return;
503
+ }
504
+ if (!isObject(node)) return;
505
+ if (hasOwn(node, "plugins") && isObject(node.plugins)) {
506
+ out.push(node.plugins as Record<string, unknown>);
507
+ }
508
+ }
509
+ }
510
+
511
+ function extractPluginSource(value: unknown): string | undefined {
512
+ if (typeof value === "string") return value;
513
+ if (!isObject(value)) return undefined;
514
+ // ESM-from-CJS interop wraps CJS modules' \`exports.default\` so the
515
+ // plugin object can land under a \`.default\` indirection. Walk a few
516
+ // hops so contributors authored as \`export default plugin\` and
517
+ // contributors authored as plain \`module.exports = plugin\` both
518
+ // resolve identically.
519
+ let current: Record<string, unknown> = value;
520
+ for (let i = 0; i < 4; i++) {
521
+ if (typeof current.source === "string") break;
522
+ const next = current.default;
523
+ if (!isObject(next)) break;
524
+ current = next;
525
+ }
526
+ const source = current.source;
527
+ return typeof source === "string" ? source : undefined;
528
+ }
529
+ `;
530
+
531
+ function readTtsxConfigPlugins(
532
+ configPath: string,
533
+ _context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
534
+ ): ConfigPluginEntry[] {
535
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-lint-cfg-"));
536
+ try {
537
+ linkNearestNodeModules(tempDir, path.dirname(configPath));
538
+ const loaderPath = path.join(tempDir, "loader.mts");
539
+ const tsconfigPath = path.join(tempDir, "tsconfig.json");
540
+ const importSpecifier = relativeImportSpecifier(tempDir, configPath);
541
+ const loaderSource = TTSX_EXTRACTOR_SCRIPT.replace(
542
+ "%CONFIG_IMPORT%",
543
+ JSON.stringify(importSpecifier),
544
+ );
545
+ fs.writeFileSync(loaderPath, loaderSource, "utf8");
546
+ fs.writeFileSync(
547
+ tsconfigPath,
548
+ JSON.stringify(
549
+ {
550
+ compilerOptions: {
551
+ // `.mjs` and `.js` configs reach this loader because Node
552
+ // cannot `require()` ESM synchronously; ttsx's TypeScript
553
+ // project compiler refuses to admit them without `allowJs`,
554
+ // so set it alongside the strict bypass to make the loader
555
+ // tolerant of any of `lint.config.{ts,cts,mts,js,mjs,cjs}`.
556
+ allowImportingTsExtensions: true,
557
+ allowJs: true,
558
+ checkJs: false,
559
+ module: "ESNext",
560
+ moduleResolution: "bundler",
561
+ noImplicitAny: false,
562
+ outDir: path.join(tempDir, "out").replace(/\\/g, "/"),
563
+ rewriteRelativeImportExtensions: true,
564
+ rootDir: "/",
565
+ skipLibCheck: true,
566
+ strict: false,
567
+ target: "ES2022",
568
+ },
569
+ files: [
570
+ loaderPath.replace(/\\/g, "/"),
571
+ configPath.replace(/\\/g, "/"),
572
+ ],
573
+ },
574
+ null,
575
+ 2,
576
+ ),
577
+ "utf8",
578
+ );
579
+
580
+ const ttsxBinary = process.env.TTSC_TTSX_BINARY ?? "ttsx";
581
+ const args = ["--project", tsconfigPath, "--cwd", tempDir, loaderPath];
582
+ if (process.env.TTSC_TSGO_BINARY) {
583
+ args.unshift("--binary", process.env.TTSC_TSGO_BINARY);
584
+ }
585
+ const env = nodeConfigLoaderEnv(configPath);
586
+ const command = ttsxThroughNodeIfNeeded(ttsxBinary);
587
+ const result = spawnSync(command.binary, [...command.prefix, ...args], {
588
+ cwd: tempDir,
589
+ env,
590
+ encoding: "utf8",
591
+ maxBuffer: 1024 * 1024 * 16,
592
+ // 60s cap so a runaway top-level await / infinite loop in the
593
+ // user's lint config can't hang the entire ttsc invocation.
594
+ timeout: 60_000,
595
+ windowsHide: true,
596
+ });
597
+ if (result.error) {
598
+ throw new Error(
599
+ `@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`,
600
+ );
601
+ }
602
+ if (result.signal) {
603
+ throw new Error(
604
+ `@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal} ` +
605
+ `(likely the 60s timeout). Simplify the config or move heavy work out of top-level.`,
606
+ );
607
+ }
608
+ if (result.status !== 0) {
609
+ throw new Error(
610
+ `@ttsc/lint: lint config ${configPath} evaluation failed:\n${result.stderr || result.stdout}`,
611
+ );
612
+ }
613
+ let payload: { entries?: ConfigPluginEntry[] };
614
+ try {
615
+ payload = JSON.parse(result.stdout) as { entries?: ConfigPluginEntry[] };
616
+ } catch (error) {
617
+ throw new Error(
618
+ `@ttsc/lint: lint config ${configPath} evaluator returned invalid JSON: ${
619
+ error instanceof Error ? error.message : String(error)
620
+ }`,
621
+ );
622
+ }
623
+ const entries = payload.entries ?? [];
624
+ return entries.map((entry) => {
625
+ // The ttsx extractor already resolved each plugin object's
626
+ // `source` to an absolute directory path. Validate the shape but
627
+ // skip the specifier-resolution branch — re-routing a directory
628
+ // through `createRequire().resolve` would fail.
629
+ if (!NAMESPACE_PATTERN.test(entry.namespace)) {
630
+ throw new Error(
631
+ `@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(entry.namespace)} must match /^[a-z][a-z0-9_-]*$/`,
632
+ );
633
+ }
634
+ if (typeof entry.source !== "string" || entry.source.length === 0) {
635
+ throw new Error(
636
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} did not expose a "source" string`,
637
+ );
638
+ }
639
+ if (!path.isAbsolute(entry.source)) {
640
+ throw new Error(
641
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} "source" must be absolute; got ${JSON.stringify(entry.source)}`,
642
+ );
643
+ }
644
+ if (
645
+ !fs.existsSync(entry.source) ||
646
+ !fs.statSync(entry.source).isDirectory()
647
+ ) {
648
+ throw new Error(
649
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} "source" must be an existing directory: ${entry.source}`,
650
+ );
651
+ }
652
+ return { namespace: entry.namespace, source: entry.source };
653
+ });
654
+ } finally {
655
+ fs.rmSync(tempDir, { recursive: true, force: true });
656
+ }
657
+ }
658
+
659
+ // ────────────────────────────────────────────────────────────────────────────
660
+ // Shared helpers
661
+ // ────────────────────────────────────────────────────────────────────────────
662
+
663
+ function collectPluginObjectsFromConfig(
664
+ value: unknown,
665
+ ): Record<string, unknown>[] {
666
+ const out: Record<string, unknown>[] = [];
667
+ const visit = (node: unknown): void => {
668
+ if (Array.isArray(node)) {
669
+ for (const item of node) visit(item);
670
+ return;
671
+ }
672
+ if (typeof node !== "object" || node === null) return;
673
+ const obj = node as Record<string, unknown>;
674
+ const plugins = obj.plugins;
675
+ if (
676
+ typeof plugins === "object" &&
677
+ plugins !== null &&
678
+ !Array.isArray(plugins)
679
+ ) {
680
+ out.push(plugins as Record<string, unknown>);
681
+ }
682
+ };
683
+ visit(value);
684
+ return out;
685
+ }
686
+
687
+ function normalizePluginValue(
688
+ namespace: string,
689
+ value: unknown,
690
+ configPath: string,
691
+ ): ConfigPluginEntry {
692
+ if (!NAMESPACE_PATTERN.test(namespace)) {
693
+ throw new Error(
694
+ `@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
695
+ );
696
+ }
697
+ if (typeof value === "string") {
698
+ // For .js/.cjs configs, a string value would be an npm specifier
699
+ // (matching JSON behavior). require it through the config's own
700
+ // module resolution.
701
+ const requireFromConfig = createRequire(configPath);
702
+ let resolved: string;
703
+ try {
704
+ resolved = requireFromConfig.resolve(value);
705
+ } catch (error) {
706
+ throw new Error(
707
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(namespace)} failed to resolve "${value}": ${
708
+ error instanceof Error ? error.message : String(error)
709
+ }`,
710
+ );
711
+ }
712
+ const mod = requireFromConfig(resolved);
713
+ const plugin = validatePluginShape(unwrapDefault(mod), namespace, resolved);
714
+ return { namespace, source: plugin.source };
715
+ }
716
+ if (typeof value === "object" && value !== null) {
717
+ const plugin = validatePluginShape(value, namespace, configPath);
718
+ return { namespace, source: plugin.source };
719
+ }
720
+ throw new Error(
721
+ `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(namespace)} must be a plugin object or specifier string; got ${typeof value}`,
722
+ );
723
+ }
724
+
725
+ function validatePluginShape(
726
+ candidate: unknown,
727
+ namespace: string,
728
+ origin: string,
729
+ ): ITtscLintPlugin {
730
+ if (typeof candidate !== "object" || candidate === null) {
731
+ throw new Error(
732
+ `@ttsc/lint: contributor "${namespace}" loaded from ${origin} did not export an object`,
733
+ );
734
+ }
735
+ const obj = candidate as Record<string, unknown>;
736
+ if (typeof obj.source !== "string" || obj.source.length === 0) {
737
+ throw new Error(
738
+ `@ttsc/lint: contributor "${namespace}" from ${origin} is missing a string "source" field`,
739
+ );
740
+ }
741
+ if (!path.isAbsolute(obj.source)) {
742
+ throw new Error(
743
+ `@ttsc/lint: contributor "${namespace}" from ${origin} "source" must be an absolute path; got ${JSON.stringify(obj.source)}. Use path.resolve(__dirname, ...).`,
744
+ );
745
+ }
746
+ if (!fs.existsSync(obj.source) || !fs.statSync(obj.source).isDirectory()) {
747
+ throw new Error(
748
+ `@ttsc/lint: contributor "${namespace}" from ${origin} "source" must be an existing directory: ${obj.source}`,
749
+ );
750
+ }
751
+ return obj as unknown as ITtscLintPlugin;
752
+ }
753
+
754
+ function unwrapDefault(mod: unknown): unknown {
755
+ let current: unknown = mod;
756
+ for (let i = 0; i < 8; i++) {
757
+ if (
758
+ current !== null &&
759
+ typeof current === "object" &&
760
+ "default" in current
761
+ ) {
762
+ const next = (current as Record<string, unknown>).default;
763
+ if (next !== undefined) {
764
+ current = next;
765
+ continue;
766
+ }
767
+ }
768
+ break;
769
+ }
770
+ return current;
771
+ }
772
+
773
+ function findNearestNodeModules(start: string): string | undefined {
774
+ let dir = path.resolve(start);
775
+ while (true) {
776
+ const candidate = path.join(dir, "node_modules");
777
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
778
+ return candidate;
779
+ }
780
+ const parent = path.dirname(dir);
781
+ if (parent === dir) return undefined;
782
+ dir = parent;
783
+ }
784
+ }
785
+
786
+ function linkNearestNodeModules(tempDir: string, sourceDir: string): void {
787
+ const nodeModules = findNearestNodeModules(sourceDir);
788
+ if (!nodeModules) return;
789
+ const link = path.join(tempDir, "node_modules");
790
+ try {
791
+ fs.symlinkSync(nodeModules, link, "junction");
792
+ } catch (error) {
793
+ const code = (error as NodeJS.ErrnoException).code;
794
+ if (code !== "EEXIST") {
795
+ throw new Error(
796
+ `@ttsc/lint: failed to link node_modules from ${nodeModules}: ${
797
+ error instanceof Error ? error.message : String(error)
798
+ }`,
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ function relativeImportSpecifier(fromDir: string, target: string): string {
805
+ let rel = path.relative(fromDir, target).replace(/\\/g, "/");
806
+ if (!rel.startsWith("./") && !rel.startsWith("../")) {
807
+ rel = "./" + rel;
808
+ }
809
+ return rel;
810
+ }
811
+
812
+ function nodeConfigLoaderEnv(configPath: string): NodeJS.ProcessEnv {
813
+ const env: NodeJS.ProcessEnv = { ...process.env };
814
+ const parts: string[] = [];
815
+ const nodeModules = findNearestNodeModules(path.dirname(configPath));
816
+ if (nodeModules) parts.push(nodeModules);
817
+ if (env.NODE_PATH) parts.push(env.NODE_PATH);
818
+ if (parts.length > 0) {
819
+ env.NODE_PATH = parts.join(path.delimiter);
820
+ }
821
+ return env;
822
+ }
823
+
824
+ function ttsxThroughNodeIfNeeded(binary: string): {
825
+ binary: string;
826
+ prefix: string[];
827
+ } {
828
+ const ext = path.extname(binary).toLowerCase();
829
+ if ([".js", ".cjs", ".mjs", ".ts", ".cts", ".mts"].includes(ext)) {
830
+ return { binary: process.execPath, prefix: [binary] };
831
+ }
832
+ return { binary, prefix: [] };
29
833
  }