@ttsc/lint 0.12.4 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/index.js +225 -135
  2. package/lib/index.js.map +1 -1
  3. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  4. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  5. package/linthost/ast_helpers.go +68 -16
  6. package/linthost/compile.go +117 -119
  7. package/linthost/config.go +518 -707
  8. package/linthost/config_format.go +16 -4
  9. package/linthost/contrib_adapter.go +7 -0
  10. package/linthost/directives.go +44 -0
  11. package/linthost/engine.go +152 -44
  12. package/linthost/fix.go +24 -33
  13. package/linthost/flags_gen.go +33 -0
  14. package/linthost/format.go +96 -3
  15. package/linthost/host.go +144 -8
  16. package/linthost/print_dispatch.go +121 -23
  17. package/linthost/print_doc.go +19 -0
  18. package/linthost/print_engine.go +168 -4
  19. package/linthost/print_nodes_array.go +17 -7
  20. package/linthost/print_nodes_call.go +129 -20
  21. package/linthost/print_nodes_function.go +353 -0
  22. package/linthost/print_nodes_imports.go +46 -29
  23. package/linthost/print_nodes_list.go +86 -5
  24. package/linthost/print_nodes_object.go +56 -11
  25. package/linthost/rules_escape.go +20 -3
  26. package/linthost/rules_format_print_width.go +267 -15
  27. package/linthost/rules_gap.go +55 -3
  28. package/linthost/rules_logic.go +64 -5
  29. package/linthost/rules_problems.go +65 -21
  30. package/linthost/rules_promise.go +3 -0
  31. package/linthost/rules_suggestions.go +160 -5
  32. package/linthost/rules_var.go +7 -1
  33. package/package.json +3 -3
  34. package/src/index.ts +243 -168
  35. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  36. package/src/structures/TtscLintRuleOptions.ts +15 -0
  37. package/linthost/eslint_runtime.go +0 -351
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import os from "node:os";
@@ -17,10 +18,12 @@ type TtscPluginContributor = {
17
18
 
18
19
  /** Descriptor shape returned to ttsc's plugin builder by the factory. */
19
20
  type TtscPluginDescriptor = {
21
+ capabilities?: { diagnosticsTiming?: boolean; threadingArgs?: boolean };
22
+ contributors?: TtscPluginContributor[];
20
23
  name: string;
24
+ reportsTypeScriptDiagnostics?: boolean;
21
25
  source: string;
22
26
  stage?: "check" | "transform";
23
- contributors?: TtscPluginContributor[];
24
27
  };
25
28
 
26
29
  /**
@@ -69,48 +72,50 @@ const LINT_CONFIG_FILENAMES = [
69
72
  "ttsc-lint.config.cjs",
70
73
  "ttsc-lint.config.js",
71
74
  "ttsc-lint.config.json",
72
- "eslint.config.ts",
73
- "eslint.config.mts",
74
- "eslint.config.cts",
75
- "eslint.config.mjs",
76
- "eslint.config.cjs",
77
- "eslint.config.js",
78
75
  ];
79
76
 
77
+ /**
78
+ * Tsconfig plugin-entry keys owned by the ttsc host framework. They are
79
+ * accepted alongside the single lint-specific `configFile` key; every other key
80
+ * is rejected so a stale inline option (`rules`, `format`, `extends`, legacy
81
+ * `config`, `plugins`) surfaces as a clear migration error instead of being
82
+ * silently ignored. Mirrors `@ttsc/banner` and `@ttsc/strip`.
83
+ */
84
+ const FRAMEWORK_KEYS = new Set<string>([
85
+ "enabled",
86
+ "name",
87
+ "stage",
88
+ "transform",
89
+ ]);
90
+
80
91
  /**
81
92
  * Plugin descriptor factory consumed by ttsc package discovery.
82
93
  *
83
- * Two discovery surfaces feed the descriptor's `contributors` field:
84
- *
85
- * 1. The tsconfig plugin entry's `plugins` map namespace npm specifier. Inline
86
- * for projects that prefer to keep everything in `tsconfig.json`.
87
- * 2. The companion `lint.config.{ts,cts,mts,js,cjs,mjs,json}` (or
88
- * `eslint.config.*`) file — an object with an in-memory `plugins: { ns:
89
- * pluginObject }` map. The factory evaluates the config (via ttsx for TS /
90
- * ESM sources, `require` for CommonJS, `JSON.parse` for JSON) and reads the
91
- * `plugins` field.
94
+ * Contributor lint plugins come from one place: the project's lint config file
95
+ * (`lint.config.{ts,cts,mts,js,cjs,mjs,json}` or `ttsc-lint.config.*`). The
96
+ * tsconfig plugin entry carries no rule or plugin surface it optionally names
97
+ * the config file via `configFile`, otherwise the file is discovered by walking
98
+ * upward from the tsconfig directory.
92
99
  *
93
- * Contributions from both sources are merged with the tsconfig entry winning on
94
- * namespace collisions, so a project can opt into a hand-curated subset of an
95
- * external `lint.config.ts` by overriding specific namespaces in
96
- * `tsconfig.json`.
100
+ * The factory locates the config file, evaluates it (via ttsx for TS / ESM
101
+ * sources, `require` for CommonJS, `JSON.parse` for JSON), reads its `plugins`
102
+ * map, and forwards each contributor's Go source directory to ttsc's plugin
103
+ * builder via the descriptor's `contributors` field.
97
104
  *
98
105
  * @internal
99
106
  */
100
107
  export default function createTtscPlugin(
101
108
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
102
109
  ): TtscPluginDescriptor {
103
- const inline = resolveInlineContributors(context);
104
- const fromConfig = resolveConfigFileContributors(
105
- context,
106
- inline.map((c) => c.name),
107
- );
108
- const contributors = [...inline, ...fromConfig];
110
+ rejectUnsupportedEntryKeys(context.plugin);
111
+ const contributors = resolveConfigFileContributors(context);
109
112
  // Build the descriptor without a `contributors` key when none were
110
113
  // declared, so consumers (and the existing key-shape regression
111
114
  // tests) see the same surface as before this feature shipped.
112
115
  const descriptor: TtscPluginDescriptor = {
116
+ capabilities: { diagnosticsTiming: true, threadingArgs: true },
113
117
  name: "@ttsc/lint",
118
+ reportsTypeScriptDiagnostics: true,
114
119
  source: path.resolve(__dirname, "..", "plugin"),
115
120
  stage: "check",
116
121
  };
@@ -120,60 +125,6 @@ export default function createTtscPlugin(
120
125
  return descriptor;
121
126
  }
122
127
 
123
- // ────────────────────────────────────────────────────────────────────────────
124
- // tsconfig-inline `plugins` map (the original MVP path)
125
- // ────────────────────────────────────────────────────────────────────────────
126
-
127
- function resolveInlineContributors(
128
- context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
129
- ): TtscPluginContributor[] {
130
- const declared = (context.plugin as { plugins?: unknown }).plugins;
131
- if (declared === undefined) return [];
132
- if (
133
- typeof declared !== "object" ||
134
- declared === null ||
135
- Array.isArray(declared)
136
- ) {
137
- throw new Error(
138
- `@ttsc/lint: "plugins" in tsconfig plugin entry must be an object map of namespace → package specifier`,
139
- );
140
- }
141
- const out: TtscPluginContributor[] = [];
142
- // Track the post-`goSubpackageName` form so `a-b` and `a_b` are
143
- // caught as colliding aliases before they reach the downstream
144
- // contributor validator's opaque `duplicate name "a_b"` error.
145
- // (`Object.entries` cannot itself surface duplicate string keys, so
146
- // a verbatim-namespace guard is unreachable.)
147
- const seenGoNames = new Map<string, string>();
148
- for (const [namespace, specifier] of Object.entries(declared)) {
149
- if (!NAMESPACE_PATTERN.test(namespace)) {
150
- throw new Error(
151
- `@ttsc/lint: contributor namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
152
- );
153
- }
154
- if (typeof specifier !== "string" || specifier.length === 0) {
155
- throw new Error(
156
- `@ttsc/lint: contributor ${JSON.stringify(namespace)} must point at a non-empty package specifier or path`,
157
- );
158
- }
159
- const goName = goSubpackageName(namespace);
160
- const earlier = seenGoNames.get(goName);
161
- if (earlier !== undefined) {
162
- throw new Error(
163
- `@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)`,
164
- );
165
- }
166
- seenGoNames.set(goName, namespace);
167
- const plugin = loadContributorPluginViaRequire(
168
- specifier,
169
- context,
170
- namespace,
171
- );
172
- out.push({ name: goName, source: plugin.source });
173
- }
174
- return out;
175
- }
176
-
177
128
  function loadContributorPluginViaRequire(
178
129
  specifier: string,
179
130
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
@@ -218,42 +169,41 @@ function loadContributorPluginViaRequire(
218
169
  // lint.config.* discovery + evaluation
219
170
  // ────────────────────────────────────────────────────────────────────────────
220
171
 
221
- /** Plugin entries observed in the flat-config file, normalized per file. */
172
+ /** Plugin entries observed in a lint config file, normalized per file. */
222
173
  type ConfigPluginEntry = { namespace: string; source: string };
223
174
 
175
+ /**
176
+ * Resolves the contributor lint plugins declared in the project's lint config
177
+ * file.
178
+ *
179
+ * - When the tsconfig plugin entry sets `configFile`, that exact file is loaded.
180
+ * - Otherwise a `lint.config.*` / `ttsc-lint.config.*` file is discovered by
181
+ * walking upward from the tsconfig directory.
182
+ *
183
+ * Returns an empty array when no config file is set or discovered — the Go
184
+ * sidecar surfaces the missing-config error; the factory only needs to forward
185
+ * contributors when a config file is present.
186
+ */
224
187
  function resolveConfigFileContributors(
225
188
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
226
- inlineNames: readonly string[],
227
189
  ): TtscPluginContributor[] {
228
- // Read the new `rules` / `extends` fields with a one-time fallback to
229
- // the legacy `config` field. The legacy fallback warns once per
230
- // ttsc invocation so existing tsconfigs keep working through the
231
- // deprecation window without crashing CI.
232
- const { hasInlineRules, extendsPath } = readSeverityConfig(context);
233
- if (hasInlineRules) {
234
- // Inline rules → no lint.config.* file involved. Skip discovery so
235
- // we don't pull in plugins from an unrelated file.
236
- return [];
237
- }
238
-
190
+ const configFile = readConfigFileOption(context);
239
191
  const configPath =
240
- extendsPath !== undefined
241
- ? path.resolve(tsconfigBaseDir(context), extendsPath)
192
+ configFile !== undefined
193
+ ? path.resolve(tsconfigBaseDir(context), configFile)
242
194
  : findLintConfigFile(context);
243
195
  if (!configPath || !fs.existsSync(configPath)) return [];
244
196
 
245
197
  const entries = readConfigPluginEntries(configPath, context);
246
- // Dedup against the Go-subpackage form (post hyphen→underscore
247
- // transform). The inline arm has already applied `goSubpackageName`
248
- // when it produced `inlineNames`, so comparing on the original
249
- // hyphenated namespace would always miss for hyphenated namespaces
250
- // and emit a colliding contributor that `validatePluginContributors`
251
- // later rejects as a duplicate name.
252
- const occupied = new Set(inlineNames);
198
+ // Dedup on the Go-subpackage form (post hyphen→underscore transform)
199
+ // so two namespaces that collapse to the same Go identifier surface
200
+ // here instead of as the contributor validator's opaque
201
+ // `duplicate name "a_b"` error.
202
+ const occupied = new Set<string>();
253
203
  const out: TtscPluginContributor[] = [];
254
204
  for (const entry of entries) {
255
205
  const goName = goSubpackageName(entry.namespace);
256
- if (occupied.has(goName)) continue; // tsconfig inline wins
206
+ if (occupied.has(goName)) continue;
257
207
  occupied.add(goName);
258
208
  out.push({ name: goName, source: entry.source });
259
209
  }
@@ -261,76 +211,39 @@ function resolveConfigFileContributors(
261
211
  }
262
212
 
263
213
  /**
264
- * Resolves the inline-rule vs file-path split between the new `rules` /
265
- * `extends` fields and the legacy `config` field.
266
- *
267
- * - `rules` (object) routes the discovery loop away from any `lint.config.*` file
268
- * — the inline map is authoritative.
269
- * - `extends` (string) routes the file walk to a fixed path.
270
- * - `config` (legacy) silently maps onto the equivalent new field. The
271
- * user-facing deprecation notice is emitted by the Go sidecar so that a
272
- * single ttsc invocation prints exactly one warning regardless of how many
273
- * entry points (JS factory, Go binary) parse the same key.
274
- * - Mixing legacy and new keys, or mixing `rules` with `extends`, is rejected
275
- * outright so users don't end up with silent precedence surprises.
214
+ * Rejects any tsconfig plugin-entry key that is neither a host framework key
215
+ * nor the single lint-specific `configFile` key. Rule, format, and plugin
216
+ * settings live only in the lint config file, so a leftover inline key is
217
+ * surfaced as an explicit migration error rather than silently ignored.
276
218
  */
277
- function readSeverityConfig(
278
- context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
279
- ): { hasInlineRules: boolean; extendsPath: string | undefined } {
280
- const entry = context.plugin as Record<string, unknown>;
281
- const rules = entry.rules;
282
- const extendsRaw = entry.extends;
283
- const legacy = entry.config;
284
- const hasNewRules = rules !== undefined;
285
- const hasExtends = extendsRaw !== undefined;
286
- const hasLegacy = legacy !== undefined;
287
-
288
- if (hasLegacy && (hasNewRules || hasExtends)) {
289
- throw new Error(
290
- `@ttsc/lint: tsconfig plugin entry mixes legacy "config" with the new "rules"/"extends" fields; remove "config" (deprecated)`,
291
- );
292
- }
293
- if (hasNewRules && hasExtends) {
219
+ function rejectUnsupportedEntryKeys(entry: ITtscLintPluginConfig): void {
220
+ for (const key of Object.keys(entry as Record<string, unknown>)) {
221
+ if (FRAMEWORK_KEYS.has(key) || key === "configFile") {
222
+ continue;
223
+ }
294
224
  throw new Error(
295
- `@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`,
225
+ `@ttsc/lint: tsconfig plugin entry contains unsupported key ${JSON.stringify(key)}. ` +
226
+ `Rules, format, and plugin settings must live in a ` +
227
+ `lint.config.{ts,cts,mts,js,cjs,mjs,json} file. The only accepted key ` +
228
+ `in the tsconfig entry is "configFile" (optional path to the config file).`,
296
229
  );
297
230
  }
231
+ }
298
232
 
299
- if (hasNewRules) {
300
- if (typeof rules !== "object" || rules === null || Array.isArray(rules)) {
301
- const actual = Array.isArray(rules)
302
- ? "array"
303
- : rules === null
304
- ? "null"
305
- : typeof rules;
306
- throw new Error(
307
- `@ttsc/lint: "rules" must be a rule severity map, got ${actual}`,
308
- );
309
- }
310
- return { hasInlineRules: true, extendsPath: undefined };
311
- }
312
- if (hasExtends) {
313
- if (typeof extendsRaw !== "string" || extendsRaw.length === 0) {
314
- throw new Error(`@ttsc/lint: "extends" must be a non-empty string path`);
315
- }
316
- return { hasInlineRules: false, extendsPath: extendsRaw };
317
- }
318
- if (hasLegacy) {
319
- if (
320
- typeof legacy === "object" &&
321
- legacy !== null &&
322
- !Array.isArray(legacy)
323
- ) {
324
- return { hasInlineRules: true, extendsPath: undefined };
325
- }
326
- if (typeof legacy === "string" && legacy.length > 0) {
327
- return { hasInlineRules: false, extendsPath: legacy };
328
- }
329
- throw new Error(
330
- `@ttsc/lint: legacy "config" must be a non-empty string path or a rule severity map, got ${typeof legacy}`,
331
- );
233
+ /**
234
+ * Reads the optional `configFile` key from the tsconfig plugin entry. It is the
235
+ * only lint-specific key the entry accepts; when present it overrides
236
+ * auto-discovery of the lint config file.
237
+ */
238
+ function readConfigFileOption(
239
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
240
+ ): string | undefined {
241
+ const value = (context.plugin as { configFile?: unknown }).configFile;
242
+ if (value === undefined) return undefined;
243
+ if (typeof value !== "string" || value.length === 0) {
244
+ throw new Error(`@ttsc/lint: "configFile" must be a non-empty string path`);
332
245
  }
333
- return { hasInlineRules: false, extendsPath: undefined };
246
+ return value;
334
247
  }
335
248
 
336
249
  function findLintConfigFile(
@@ -542,7 +455,61 @@ function extractPluginSource(value: unknown): string | undefined {
542
455
  }
543
456
  `;
544
457
 
458
+ /**
459
+ * Resolves the contributor plugin entries declared in a .ts/.mjs lint config,
460
+ * memoized through the shared on-disk config cache.
461
+ *
462
+ * Evaluating such a config spawns a full `ttsx` subprocess. A monorepo build
463
+ * runs one `ttsc` process per package, and each would otherwise re-spawn `ttsx`
464
+ * for the same shared config; the cache collapses that to a single evaluation.
465
+ * The cache is keyed by the config file's path and exact contents (see
466
+ * `configCacheKey`), so an edit re-evaluates cleanly.
467
+ */
545
468
  function readTtsxConfigPlugins(
469
+ configPath: string,
470
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
471
+ ): ConfigPluginEntry[] {
472
+ const cacheKey = configCacheKey("plugins", configPath);
473
+ if (cacheKey) {
474
+ const cached = readConfigPluginCache(cacheKey);
475
+ // Re-validate cached entries before trusting them: a contributor's
476
+ // resolved `source` directory may have moved since the entry was
477
+ // written. A stale entry falls through to a fresh evaluation rather
478
+ // than being forwarded to ttsc's plugin builder as a dead path.
479
+ if (cached && cached.every(isValidConfigPluginEntry)) return cached;
480
+ }
481
+ const entries = evaluateTtsxConfigPlugins(configPath, context);
482
+ if (cacheKey) writeConfigPluginCache(cacheKey, entries);
483
+ return entries;
484
+ }
485
+
486
+ /**
487
+ * Reports whether a cached plugin entry is still usable: a well-formed
488
+ * namespace and an absolute `source` that still points at a directory. Pure
489
+ * predicate — never throws — so a malformed cache entry simply triggers
490
+ * re-evaluation instead of aborting plugin discovery.
491
+ */
492
+ function isValidConfigPluginEntry(entry: unknown): entry is ConfigPluginEntry {
493
+ if (
494
+ entry == null ||
495
+ typeof entry !== "object" ||
496
+ typeof (entry as ConfigPluginEntry).namespace !== "string" ||
497
+ typeof (entry as ConfigPluginEntry).source !== "string"
498
+ ) {
499
+ return false;
500
+ }
501
+ const { namespace, source } = entry as ConfigPluginEntry;
502
+ if (!NAMESPACE_PATTERN.test(namespace) || !path.isAbsolute(source)) {
503
+ return false;
504
+ }
505
+ try {
506
+ return fs.statSync(source).isDirectory();
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+
512
+ function evaluateTtsxConfigPlugins(
546
513
  configPath: string,
547
514
  _context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
548
515
  ): ConfigPluginEntry[] {
@@ -592,7 +559,20 @@ function readTtsxConfigPlugins(
592
559
  );
593
560
 
594
561
  const ttsxBinary = process.env.TTSC_TTSX_BINARY ?? "ttsx";
595
- const args = ["--project", tsconfigPath, "--cwd", tempDir, loaderPath];
562
+ // `--no-plugins` keeps this build hermetic: the loader only needs to
563
+ // type-check and run the user's lint config to extract its plugin
564
+ // entries. Loading the host project's transform/check plugins
565
+ // (`@nestia/core`, `typia`, …) would run their project checks
566
+ // against this deliberately lenient loader tsconfig and fail the
567
+ // build — e.g. `@nestia/core` rejects the loader's `strict: false`.
568
+ const args = [
569
+ "--project",
570
+ tsconfigPath,
571
+ "--cwd",
572
+ tempDir,
573
+ "--no-plugins",
574
+ loaderPath,
575
+ ];
596
576
  if (process.env.TTSC_TSGO_BINARY) {
597
577
  args.unshift("--binary", process.env.TTSC_TSGO_BINARY);
598
578
  }
@@ -670,6 +650,101 @@ function readTtsxConfigPlugins(
670
650
  }
671
651
  }
672
652
 
653
+ // ────────────────────────────────────────────────────────────────────────────
654
+ // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
655
+ // ────────────────────────────────────────────────────────────────────────────
656
+
657
+ /**
658
+ * Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
659
+ * `configCacheVersion`; bump both when the cached shape changes.
660
+ */
661
+ const CONFIG_CACHE_VERSION = "v1";
662
+
663
+ /**
664
+ * Directory shared by this factory and the Go sidecar for cached lint configs.
665
+ * The two write different files (the `kind` segment of the cache key keeps
666
+ * their namespaces apart), so they coexist without collision.
667
+ */
668
+ function configCacheDir(): string {
669
+ return path.join(os.tmpdir(), "ttsc-lint-config-cache");
670
+ }
671
+
672
+ /** Env opt-out, mirroring the Go sidecar's `TTSC_LINT_DISABLE_CONFIG_CACHE`. */
673
+ function configCacheDisabled(): boolean {
674
+ return Boolean(process.env.TTSC_LINT_DISABLE_CONFIG_CACHE);
675
+ }
676
+
677
+ /**
678
+ * Content-addressed cache key for a lint config file. Mirrors the Go sidecar's
679
+ * `configCacheKey`: a version tag, a namespace `kind`, the config's absolute
680
+ * path, and its exact bytes. Returns "" — a "do not cache" signal — when the
681
+ * file cannot be read or the env opt-out is set.
682
+ */
683
+ function configCacheKey(kind: string, configPath: string): string {
684
+ if (configCacheDisabled()) return "";
685
+ let content: Buffer;
686
+ try {
687
+ content = fs.readFileSync(configPath);
688
+ } catch {
689
+ return "";
690
+ }
691
+ return createHash("sha256")
692
+ .update(CONFIG_CACHE_VERSION)
693
+ .update("\0")
694
+ .update(kind)
695
+ .update("\0")
696
+ .update(path.resolve(configPath))
697
+ .update("\0")
698
+ .update(content)
699
+ .digest("hex");
700
+ }
701
+
702
+ /**
703
+ * Returns the cached plugin-entry list for `cacheKey`, or undefined on any miss
704
+ * — a missing file, an unreadable file, or content that is not a JSON array.
705
+ * Every failure is a soft miss: the caller re-evaluates.
706
+ */
707
+ function readConfigPluginCache(
708
+ cacheKey: string,
709
+ ): ConfigPluginEntry[] | undefined {
710
+ let body: string;
711
+ try {
712
+ body = fs.readFileSync(
713
+ path.join(configCacheDir(), `${cacheKey}.json`),
714
+ "utf8",
715
+ );
716
+ } catch {
717
+ return undefined;
718
+ }
719
+ try {
720
+ const parsed: unknown = JSON.parse(body);
721
+ return Array.isArray(parsed) ? (parsed as ConfigPluginEntry[]) : undefined;
722
+ } catch {
723
+ return undefined;
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Writes `entries` to the config cache under `cacheKey`. Best-effort: any
729
+ * failure leaves the cache cold rather than aborting plugin discovery. The
730
+ * temp-file + rename keeps a concurrent reader in a sibling `ttsc` process from
731
+ * observing a half-written file.
732
+ */
733
+ function writeConfigPluginCache(
734
+ cacheKey: string,
735
+ entries: ConfigPluginEntry[],
736
+ ): void {
737
+ try {
738
+ const dir = configCacheDir();
739
+ fs.mkdirSync(dir, { recursive: true });
740
+ const tmp = path.join(dir, `${cacheKey}.${process.pid}.tmp`);
741
+ fs.writeFileSync(tmp, JSON.stringify(entries), "utf8");
742
+ fs.renameSync(tmp, path.join(dir, `${cacheKey}.json`));
743
+ } catch {
744
+ // Cold cache on failure — the next invocation re-evaluates.
745
+ }
746
+ }
747
+
673
748
  // ────────────────────────────────────────────────────────────────────────────
674
749
  // Shared helpers
675
750
  // ────────────────────────────────────────────────────────────────────────────
@@ -1,6 +1,3 @@
1
- import type { ITtscLintFormatConfig } from "./ITtscLintFormatConfig";
2
- import type { TtscLintRuleMap } from "./TtscLintRuleMap";
3
-
4
1
  /** `compilerOptions.plugins[]` entry shape consumed by `@ttsc/lint`. */
5
2
  export interface ITtscLintPluginConfig {
6
3
  /** Set to `false` to keep the entry while disabling this plugin. */
@@ -10,94 +7,24 @@ export interface ITtscLintPluginConfig {
10
7
  transform?: string;
11
8
 
12
9
  /**
13
- * Inline rule severity map applied to the project.
14
- *
15
- * Mirrors the `rules` field of an ESLint flat-config entry. When set, the
16
- * sidecar uses this map directly and does NOT consult any `lint.config.*`
17
- * file (use `extends` for that). Combine with `plugins` to register
18
- * contributor rule namespaces in the same entry.
19
- *
20
- * ```jsonc
21
- * {
22
- * "transform": "@ttsc/lint",
23
- * "rules": { "no-var": "error", "prefer-const": "warning" }
24
- * }
25
- * ```
26
- */
27
- rules?: TtscLintRuleMap;
28
-
29
- /**
30
- * Path to a standalone lint config file whose rules should be applied to this
31
- * project. Relative paths are resolved from the tsconfig directory. Accepts
32
- * the usual `lint.config.*` / `ttsc-lint.config.*` / `eslint.config.*`
33
- * extensions.
34
- *
35
- * Mirrors the `extends` field of an ESLint flat-config entry — "inherit this
36
- * file's configuration".
37
- *
38
- * ```jsonc
39
- * {
40
- * "transform": "@ttsc/lint",
41
- * "extends": "./lint.config.ts"
42
- * }
43
- * ```
44
- *
45
- * `rules` and `extends` are mutually exclusive on a single plugin entry; the
46
- * sidecar surfaces a loud error when both are set.
47
- */
48
- extends?: string;
49
-
50
- /**
51
- * Contributor lint plugins to compile into the `@ttsc/lint` binary.
10
+ * Path to the lint config file, overriding auto-discovery.
52
11
  *
53
- * Each entry maps a namespace (rule-name prefix) to an npm specifier or
54
- * relative path. The factory resolves the package, reads its exported
55
- * `ITtscLintPlugin` descriptor, and forwards the Go source directory to
56
- * ttsc's plugin builder via the `contributors` field.
12
+ * Relative paths are resolved from the tsconfig directory; absolute paths are
13
+ * used as-is. Accepts the usual `lint.config.*` / `ttsc-lint.config.*`
14
+ * extensions (`.ts`, `.cts`, `.mts`, `.js`, `.cjs`, `.mjs`, `.json`).
57
15
  *
58
- * ```jsonc
59
- * {
60
- * "transform": "@ttsc/lint",
61
- * "plugins": { "demo": "ttsc-lint-plugin-demo" },
62
- * "rules": { "demo/no-todo-comment": "error" }
63
- * }
64
- * ```
65
- */
66
- plugins?: Record<string, string>;
67
-
68
- /**
69
- * Prettier-style flat configuration for the `format/*` rules. Sibling of
70
- * `rules`. See {@link ITtscLintFormatConfig} for the full surface.
16
+ * When omitted, `@ttsc/lint` discovers a `lint.config.*` /
17
+ * `ttsc-lint.config.*` file by walking upward from the tsconfig directory.
71
18
  *
72
19
  * ```jsonc
73
20
  * {
74
21
  * "transform": "@ttsc/lint",
75
- * "format": { "printWidth": 100, "singleQuote": true }
22
+ * "configFile": "./lint.config.ts"
76
23
  * }
77
24
  * ```
78
25
  *
79
- * Combines with `rules` (per-rule overrides win on collision). Cannot be
80
- * combined with `extends` on the same plugin entry put format options
81
- * inside the extends-target lint.config.ts instead.
26
+ * Every rule, format, and plugin setting lives in the config file — the
27
+ * tsconfig plugin entry carries nothing but this pointer.
82
28
  */
83
- format?: ITtscLintFormatConfig;
84
-
85
- /**
86
- * Inline rule map or path to a standalone lint config file.
87
- *
88
- * @deprecated Use `rules` for inline severity maps or `extends` for a config
89
- * file path. The sidecar maps a legacy `config` entry onto the appropriate
90
- * new field and emits a one-time stderr deprecation notice. Removed in a
91
- * future minor.
92
- *
93
- * The legacy shape is intentionally narrower than `rules`/`extends`: a string
94
- * (file path) or a flat rule-name → severity map. The Go-side parser only
95
- * accepts those two shapes; widening the TS type to the full config object
96
- * would silently let unsupported nested shapes pass type-checking and fail
97
- * at runtime.
98
- */
99
- config?: string | TtscLintRuleMap;
100
-
101
- /** Extra plugin-owned fields are passed through unchanged. */
102
- [key: string]: unknown;
29
+ configFile?: string;
103
30
  }
@@ -99,6 +99,21 @@ export interface ITtscLintPrintWidthRuleOptions {
99
99
  * @default "lf"
100
100
  */
101
101
  endOfLine?: "lf" | "crlf";
102
+
103
+ /**
104
+ * Trailing-comma policy the reflow honors when it breaks a list across
105
+ * lines. Mirrors prettier's `trailingComma` and must match the
106
+ * `format/trailing-comma` rule's `mode`; otherwise the two rules
107
+ * disagree on every cascade pass and oscillate against each other.
108
+ *
109
+ * When a `format` block is configured, `format.trailingComma` is mirrored
110
+ * into this option automatically. Set it directly only when overriding
111
+ * the print-width rule via a `rules` tuple — see the conflict-resolution
112
+ * notes in the README.
113
+ *
114
+ * @default "all"
115
+ */
116
+ trailingComma?: "all" | "es5" | "none";
102
117
  }
103
118
 
104
119
  /** `format/jsdoc` rule options. */