@ttsc/lint 0.12.3 → 0.13.0

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 (74) hide show
  1. package/lib/defaultFormat.d.ts +10 -11
  2. package/lib/defaultFormat.js +10 -11
  3. package/lib/defaultFormat.js.map +1 -1
  4. package/lib/index.js +233 -135
  5. package/lib/index.js.map +1 -1
  6. package/lib/structures/ITtscLintConfig.d.ts +2 -2
  7. package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
  8. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  9. package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
  10. package/lib/structures/TtscLintRule.d.ts +10 -1
  11. package/lib/structures/TtscLintRuleMap.d.ts +2 -2
  12. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  13. package/linthost/ast_helpers.go +80 -11
  14. package/linthost/compile.go +116 -112
  15. package/linthost/config.go +647 -687
  16. package/linthost/config_format.go +272 -247
  17. package/linthost/contrib_adapter.go +7 -0
  18. package/linthost/directives.go +116 -0
  19. package/linthost/dispatch.go +33 -33
  20. package/linthost/engine.go +156 -43
  21. package/linthost/fix.go +47 -27
  22. package/linthost/flags_gen.go +31 -0
  23. package/linthost/format.go +119 -3
  24. package/linthost/host.go +115 -5
  25. package/linthost/print_dispatch.go +128 -28
  26. package/linthost/print_doc.go +23 -0
  27. package/linthost/print_engine.go +168 -4
  28. package/linthost/print_nodes_array.go +17 -7
  29. package/linthost/print_nodes_call.go +138 -20
  30. package/linthost/print_nodes_function.go +353 -0
  31. package/linthost/print_nodes_imports.go +46 -29
  32. package/linthost/print_nodes_list.go +86 -5
  33. package/linthost/print_nodes_object.go +56 -11
  34. package/linthost/rules_arrays.go +5 -2
  35. package/linthost/rules_debugger.go +3 -2
  36. package/linthost/rules_dupes.go +7 -4
  37. package/linthost/rules_empty.go +3 -2
  38. package/linthost/rules_escape.go +35 -3
  39. package/linthost/rules_eval.go +3 -0
  40. package/linthost/rules_finally.go +11 -0
  41. package/linthost/rules_format_jsdoc.go +7 -0
  42. package/linthost/rules_format_print_width.go +270 -15
  43. package/linthost/rules_format_quotes.go +3 -0
  44. package/linthost/rules_format_sort_imports.go +4 -0
  45. package/linthost/rules_gap.go +75 -3
  46. package/linthost/rules_imports.go +5 -7
  47. package/linthost/rules_logic.go +75 -5
  48. package/linthost/rules_loops.go +4 -0
  49. package/linthost/rules_misc.go +4 -2
  50. package/linthost/rules_params.go +7 -11
  51. package/linthost/rules_problems.go +89 -22
  52. package/linthost/rules_promise.go +15 -0
  53. package/linthost/rules_protos.go +3 -2
  54. package/linthost/rules_self.go +6 -0
  55. package/linthost/rules_strings.go +10 -0
  56. package/linthost/rules_suggestions.go +174 -9
  57. package/linthost/rules_throw.go +2 -0
  58. package/linthost/rules_ts.go +13 -0
  59. package/linthost/rules_ts_extra.go +25 -8
  60. package/linthost/rules_var.go +15 -2
  61. package/package.json +3 -3
  62. package/plugin/main.go +4 -4
  63. package/rule/astutil/astutil.go +12 -0
  64. package/rule/rule.go +123 -123
  65. package/src/defaultFormat.ts +10 -11
  66. package/src/index.ts +257 -168
  67. package/src/structures/ITtscLintConfig.ts +2 -2
  68. package/src/structures/ITtscLintFormatConfig.ts +53 -55
  69. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  70. package/src/structures/ITtscLintPluginMeta.ts +9 -1
  71. package/src/structures/TtscLintRule.ts +10 -1
  72. package/src/structures/TtscLintRuleMap.ts +17 -18
  73. package/src/structures/TtscLintRuleOptions.ts +15 -0
  74. package/linthost/eslint_runtime.go +0 -320
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";
@@ -9,18 +10,26 @@ import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures";
9
10
  export * from "./defaultFormat";
10
11
  export * from "./structures/index";
11
12
 
13
+ /** A resolved contributor: Go sub-package name + absolute source directory. */
12
14
  type TtscPluginContributor = {
13
15
  name: string;
14
16
  source: string;
15
17
  };
16
18
 
19
+ /** Descriptor shape returned to ttsc's plugin builder by the factory. */
17
20
  type TtscPluginDescriptor = {
21
+ capabilities?: { threadingArgs?: boolean };
22
+ contributors?: TtscPluginContributor[];
18
23
  name: string;
24
+ reportsTypeScriptDiagnostics?: boolean;
19
25
  source: string;
20
26
  stage?: "check" | "transform";
21
- contributors?: TtscPluginContributor[];
22
27
  };
23
28
 
29
+ /**
30
+ * Context object injected by ttsc into every plugin factory call. The generic
31
+ * `TConfig` is the tsconfig plugin entry shape.
32
+ */
24
33
  type TtscPluginFactoryContext<TConfig> = {
25
34
  binary: string;
26
35
  cwd: string;
@@ -63,48 +72,50 @@ const LINT_CONFIG_FILENAMES = [
63
72
  "ttsc-lint.config.cjs",
64
73
  "ttsc-lint.config.js",
65
74
  "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
75
  ];
73
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
+
74
91
  /**
75
92
  * Plugin descriptor factory consumed by ttsc package discovery.
76
93
  *
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 — an object 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 reads
85
- * the `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.
86
99
  *
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`.
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.
91
104
  *
92
105
  * @internal
93
106
  */
94
107
  export default function createTtscPlugin(
95
108
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
96
109
  ): TtscPluginDescriptor {
97
- const inline = resolveInlineContributors(context);
98
- const fromConfig = resolveConfigFileContributors(
99
- context,
100
- inline.map((c) => c.name),
101
- );
102
- const contributors = [...inline, ...fromConfig];
110
+ rejectUnsupportedEntryKeys(context.plugin);
111
+ const contributors = resolveConfigFileContributors(context);
103
112
  // Build the descriptor without a `contributors` key when none were
104
113
  // declared, so consumers (and the existing key-shape regression
105
114
  // tests) see the same surface as before this feature shipped.
106
115
  const descriptor: TtscPluginDescriptor = {
116
+ capabilities: { threadingArgs: true },
107
117
  name: "@ttsc/lint",
118
+ reportsTypeScriptDiagnostics: true,
108
119
  source: path.resolve(__dirname, "..", "plugin"),
109
120
  stage: "check",
110
121
  };
@@ -114,60 +125,6 @@ export default function createTtscPlugin(
114
125
  return descriptor;
115
126
  }
116
127
 
117
- // ────────────────────────────────────────────────────────────────────────────
118
- // tsconfig-inline `plugins` map (the original MVP path)
119
- // ────────────────────────────────────────────────────────────────────────────
120
-
121
- function resolveInlineContributors(
122
- context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
123
- ): TtscPluginContributor[] {
124
- const declared = (context.plugin as { plugins?: unknown }).plugins;
125
- if (declared === undefined) return [];
126
- if (
127
- typeof declared !== "object" ||
128
- declared === null ||
129
- Array.isArray(declared)
130
- ) {
131
- throw new Error(
132
- `@ttsc/lint: "plugins" in tsconfig plugin entry must be an object map of namespace → package specifier`,
133
- );
134
- }
135
- const out: TtscPluginContributor[] = [];
136
- // Track the post-`goSubpackageName` form so `a-b` and `a_b` are
137
- // caught as colliding aliases before they reach the downstream
138
- // contributor validator's opaque `duplicate name "a_b"` error.
139
- // (`Object.entries` cannot itself surface duplicate string keys, so
140
- // a verbatim-namespace guard is unreachable.)
141
- const seenGoNames = new Map<string, string>();
142
- for (const [namespace, specifier] of Object.entries(declared)) {
143
- if (!NAMESPACE_PATTERN.test(namespace)) {
144
- throw new Error(
145
- `@ttsc/lint: contributor namespace ${JSON.stringify(namespace)} must match /^[a-z][a-z0-9_-]*$/`,
146
- );
147
- }
148
- if (typeof specifier !== "string" || specifier.length === 0) {
149
- throw new Error(
150
- `@ttsc/lint: contributor ${JSON.stringify(namespace)} must point at a non-empty package specifier or path`,
151
- );
152
- }
153
- const goName = goSubpackageName(namespace);
154
- const earlier = seenGoNames.get(goName);
155
- if (earlier !== undefined) {
156
- throw new Error(
157
- `@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)`,
158
- );
159
- }
160
- seenGoNames.set(goName, namespace);
161
- const plugin = loadContributorPluginViaRequire(
162
- specifier,
163
- context,
164
- namespace,
165
- );
166
- out.push({ name: goName, source: plugin.source });
167
- }
168
- return out;
169
- }
170
-
171
128
  function loadContributorPluginViaRequire(
172
129
  specifier: string,
173
130
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
@@ -212,42 +169,41 @@ function loadContributorPluginViaRequire(
212
169
  // lint.config.* discovery + evaluation
213
170
  // ────────────────────────────────────────────────────────────────────────────
214
171
 
215
- /** Plugin entries observed in the flat-config file, normalized per file. */
172
+ /** Plugin entries observed in a lint config file, normalized per file. */
216
173
  type ConfigPluginEntry = { namespace: string; source: string };
217
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
+ */
218
187
  function resolveConfigFileContributors(
219
188
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
220
- inlineNames: readonly string[],
221
189
  ): TtscPluginContributor[] {
222
- // Read the new `rules` / `extends` fields with a one-time fallback to
223
- // the legacy `config` field. The legacy fallback warns once per
224
- // ttsc invocation so existing tsconfigs keep working through the
225
- // deprecation window without crashing CI.
226
- const { hasInlineRules, extendsPath } = readSeverityConfig(context);
227
- if (hasInlineRules) {
228
- // Inline rules → no lint.config.* file involved. Skip discovery so
229
- // we don't pull in plugins from an unrelated file.
230
- return [];
231
- }
232
-
190
+ const configFile = readConfigFileOption(context);
233
191
  const configPath =
234
- extendsPath !== undefined
235
- ? path.resolve(tsconfigBaseDir(context), extendsPath)
192
+ configFile !== undefined
193
+ ? path.resolve(tsconfigBaseDir(context), configFile)
236
194
  : findLintConfigFile(context);
237
195
  if (!configPath || !fs.existsSync(configPath)) return [];
238
196
 
239
197
  const entries = readConfigPluginEntries(configPath, context);
240
- // Dedup against the Go-subpackage form (post hyphen→underscore
241
- // transform). The inline arm has already applied `goSubpackageName`
242
- // when it produced `inlineNames`, so comparing on the original
243
- // hyphenated namespace would always miss for hyphenated namespaces
244
- // and emit a colliding contributor that `validatePluginContributors`
245
- // later rejects as a duplicate name.
246
- 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>();
247
203
  const out: TtscPluginContributor[] = [];
248
204
  for (const entry of entries) {
249
205
  const goName = goSubpackageName(entry.namespace);
250
- if (occupied.has(goName)) continue; // tsconfig inline wins
206
+ if (occupied.has(goName)) continue;
251
207
  occupied.add(goName);
252
208
  out.push({ name: goName, source: entry.source });
253
209
  }
@@ -255,76 +211,39 @@ function resolveConfigFileContributors(
255
211
  }
256
212
 
257
213
  /**
258
- * Resolves the inline-rule vs file-path split between the new `rules` /
259
- * `extends` fields and the legacy `config` field.
260
- *
261
- * - `rules` (object) routes the discovery loop away from any `lint.config.*` file
262
- * — the inline map is authoritative.
263
- * - `extends` (string) routes the file walk to a fixed path.
264
- * - `config` (legacy) silently maps onto the equivalent new field. The
265
- * user-facing deprecation notice is emitted by the Go sidecar so that a
266
- * single ttsc invocation prints exactly one warning regardless of how many
267
- * entry points (JS factory, Go binary) parse the same key.
268
- * - Mixing legacy and new keys, or mixing `rules` with `extends`, is rejected
269
- * 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.
270
218
  */
271
- function readSeverityConfig(
272
- context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
273
- ): { hasInlineRules: boolean; extendsPath: string | undefined } {
274
- const entry = context.plugin as Record<string, unknown>;
275
- const rules = entry.rules;
276
- const extendsRaw = entry.extends;
277
- const legacy = entry.config;
278
- const hasNewRules = rules !== undefined;
279
- const hasExtends = extendsRaw !== undefined;
280
- const hasLegacy = legacy !== undefined;
281
-
282
- if (hasLegacy && (hasNewRules || hasExtends)) {
283
- throw new Error(
284
- `@ttsc/lint: tsconfig plugin entry mixes legacy "config" with the new "rules"/"extends" fields; remove "config" (deprecated)`,
285
- );
286
- }
287
- 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
+ }
288
224
  throw new Error(
289
- `@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).`,
290
229
  );
291
230
  }
231
+ }
292
232
 
293
- if (hasNewRules) {
294
- if (typeof rules !== "object" || rules === null || Array.isArray(rules)) {
295
- const actual = Array.isArray(rules)
296
- ? "array"
297
- : rules === null
298
- ? "null"
299
- : typeof rules;
300
- throw new Error(
301
- `@ttsc/lint: "rules" must be a rule severity map, got ${actual}`,
302
- );
303
- }
304
- return { hasInlineRules: true, extendsPath: undefined };
305
- }
306
- if (hasExtends) {
307
- if (typeof extendsRaw !== "string" || extendsRaw.length === 0) {
308
- throw new Error(`@ttsc/lint: "extends" must be a non-empty string path`);
309
- }
310
- return { hasInlineRules: false, extendsPath: extendsRaw };
311
- }
312
- if (hasLegacy) {
313
- if (
314
- typeof legacy === "object" &&
315
- legacy !== null &&
316
- !Array.isArray(legacy)
317
- ) {
318
- return { hasInlineRules: true, extendsPath: undefined };
319
- }
320
- if (typeof legacy === "string" && legacy.length > 0) {
321
- return { hasInlineRules: false, extendsPath: legacy };
322
- }
323
- throw new Error(
324
- `@ttsc/lint: legacy "config" must be a non-empty string path or a rule severity map, got ${typeof legacy}`,
325
- );
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`);
326
245
  }
327
- return { hasInlineRules: false, extendsPath: undefined };
246
+ return value;
328
247
  }
329
248
 
330
249
  function findLintConfigFile(
@@ -448,6 +367,12 @@ function readCjsConfigPlugins(configPath: string): ConfigPluginEntry[] {
448
367
  );
449
368
  }
450
369
 
370
+ // TypeScript source written to a temp file and executed via ttsx. The
371
+ // %CONFIG_IMPORT% placeholder is replaced with a JSON-quoted relative path
372
+ // before the file hits disk. The script walks the exported config object,
373
+ // collects every `plugins` map, and serialises each plugin's `source` field
374
+ // as a JSON array for the parent process to parse — avoiding the need to
375
+ // serialise arbitrary in-memory plugin objects across the process boundary.
451
376
  const TTSX_EXTRACTOR_SCRIPT = `import * as importedConfig from %CONFIG_IMPORT%;
452
377
 
453
378
  declare const process: {
@@ -530,7 +455,61 @@ function extractPluginSource(value: unknown): string | undefined {
530
455
  }
531
456
  `;
532
457
 
458
+ /**
459
+ * Resolves the contributor plugin entries declared in a .ts/.mjs lint
460
+ * config, memoized through the shared on-disk config cache.
461
+ *
462
+ * Evaluating such a config spawns a full `ttsx` subprocess. A monorepo
463
+ * build runs one `ttsc` process per package, and each would otherwise
464
+ * re-spawn `ttsx` for the same shared config; the cache collapses that to
465
+ * a single evaluation. The cache is keyed by the config file's path and
466
+ * exact contents (see `configCacheKey`), so an edit re-evaluates cleanly.
467
+ */
533
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.
489
+ * Pure predicate — never throws — so a malformed cache entry simply
490
+ * triggers 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(
534
513
  configPath: string,
535
514
  _context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
536
515
  ): ConfigPluginEntry[] {
@@ -580,7 +559,20 @@ function readTtsxConfigPlugins(
580
559
  );
581
560
 
582
561
  const ttsxBinary = process.env.TTSC_TTSX_BINARY ?? "ttsx";
583
- 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
+ ];
584
576
  if (process.env.TTSC_TSGO_BINARY) {
585
577
  args.unshift("--binary", process.env.TTSC_TSGO_BINARY);
586
578
  }
@@ -658,6 +650,103 @@ function readTtsxConfigPlugins(
658
650
  }
659
651
  }
660
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
659
+ * sidecar's `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
665
+ * configs. The two write different files (the `kind` segment of the cache
666
+ * key keeps 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
679
+ * sidecar's `configCacheKey`: a version tag, a namespace `kind`, the
680
+ * config's absolute path, and its exact bytes. Returns "" — a "do not
681
+ * cache" signal — when the 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
704
+ * miss — a missing file, an unreadable file, or content that is not a JSON
705
+ * array. 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)
722
+ ? (parsed as ConfigPluginEntry[])
723
+ : undefined;
724
+ } catch {
725
+ return undefined;
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Writes `entries` to the config cache under `cacheKey`. Best-effort: any
731
+ * failure leaves the cache cold rather than aborting plugin discovery. The
732
+ * temp-file + rename keeps a concurrent reader in a sibling `ttsc` process
733
+ * from observing a half-written file.
734
+ */
735
+ function writeConfigPluginCache(
736
+ cacheKey: string,
737
+ entries: ConfigPluginEntry[],
738
+ ): void {
739
+ try {
740
+ const dir = configCacheDir();
741
+ fs.mkdirSync(dir, { recursive: true });
742
+ const tmp = path.join(dir, `${cacheKey}.${process.pid}.tmp`);
743
+ fs.writeFileSync(tmp, JSON.stringify(entries), "utf8");
744
+ fs.renameSync(tmp, path.join(dir, `${cacheKey}.json`));
745
+ } catch {
746
+ // Cold cache on failure — the next invocation re-evaluates.
747
+ }
748
+ }
749
+
661
750
  // ────────────────────────────────────────────────────────────────────────────
662
751
  // Shared helpers
663
752
  // ────────────────────────────────────────────────────────────────────────────
@@ -5,8 +5,8 @@ import type { TtscLintRuleMap } from "./TtscLintRuleMap";
5
5
  /**
6
6
  * Top-level object accepted by `@ttsc/lint` config files.
7
7
  *
8
- * Keep the file shape plain: users export an object and use
9
- * `satisfies ITtscLintConfig` when they want type checking.
8
+ * Keep the file shape plain: users export an object and use `satisfies
9
+ * ITtscLintConfig` when they want type checking.
10
10
  */
11
11
  export interface ITtscLintConfig {
12
12
  /** Globs that select the files this entry applies to. */