@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.
- package/lib/defaultFormat.d.ts +10 -11
- package/lib/defaultFormat.js +10 -11
- package/lib/defaultFormat.js.map +1 -1
- package/lib/index.js +233 -135
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintConfig.d.ts +2 -2
- package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
- package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
- package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
- package/lib/structures/TtscLintRule.d.ts +10 -1
- package/lib/structures/TtscLintRuleMap.d.ts +2 -2
- package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
- package/linthost/ast_helpers.go +80 -11
- package/linthost/compile.go +116 -112
- package/linthost/config.go +647 -687
- package/linthost/config_format.go +272 -247
- package/linthost/contrib_adapter.go +7 -0
- package/linthost/directives.go +116 -0
- package/linthost/dispatch.go +33 -33
- package/linthost/engine.go +156 -43
- package/linthost/fix.go +47 -27
- package/linthost/flags_gen.go +31 -0
- package/linthost/format.go +119 -3
- package/linthost/host.go +115 -5
- package/linthost/print_dispatch.go +128 -28
- package/linthost/print_doc.go +23 -0
- package/linthost/print_engine.go +168 -4
- package/linthost/print_nodes_array.go +17 -7
- package/linthost/print_nodes_call.go +138 -20
- package/linthost/print_nodes_function.go +353 -0
- package/linthost/print_nodes_imports.go +46 -29
- package/linthost/print_nodes_list.go +86 -5
- package/linthost/print_nodes_object.go +56 -11
- package/linthost/rules_arrays.go +5 -2
- package/linthost/rules_debugger.go +3 -2
- package/linthost/rules_dupes.go +7 -4
- package/linthost/rules_empty.go +3 -2
- package/linthost/rules_escape.go +35 -3
- package/linthost/rules_eval.go +3 -0
- package/linthost/rules_finally.go +11 -0
- package/linthost/rules_format_jsdoc.go +7 -0
- package/linthost/rules_format_print_width.go +270 -15
- package/linthost/rules_format_quotes.go +3 -0
- package/linthost/rules_format_sort_imports.go +4 -0
- package/linthost/rules_gap.go +75 -3
- package/linthost/rules_imports.go +5 -7
- package/linthost/rules_logic.go +75 -5
- package/linthost/rules_loops.go +4 -0
- package/linthost/rules_misc.go +4 -2
- package/linthost/rules_params.go +7 -11
- package/linthost/rules_problems.go +89 -22
- package/linthost/rules_promise.go +15 -0
- package/linthost/rules_protos.go +3 -2
- package/linthost/rules_self.go +6 -0
- package/linthost/rules_strings.go +10 -0
- package/linthost/rules_suggestions.go +174 -9
- package/linthost/rules_throw.go +2 -0
- package/linthost/rules_ts.go +13 -0
- package/linthost/rules_ts_extra.go +25 -8
- package/linthost/rules_var.go +15 -2
- package/package.json +3 -3
- package/plugin/main.go +4 -4
- package/rule/astutil/astutil.go +12 -0
- package/rule/rule.go +123 -123
- package/src/defaultFormat.ts +10 -11
- package/src/index.ts +257 -168
- package/src/structures/ITtscLintConfig.ts +2 -2
- package/src/structures/ITtscLintFormatConfig.ts +53 -55
- package/src/structures/ITtscLintPluginConfig.ts +10 -83
- package/src/structures/ITtscLintPluginMeta.ts +9 -1
- package/src/structures/TtscLintRule.ts +10 -1
- package/src/structures/TtscLintRuleMap.ts +17 -18
- package/src/structures/TtscLintRuleOptions.ts +15 -0
- 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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
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
|
-
|
|
98
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
? path.resolve(tsconfigBaseDir(context),
|
|
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
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
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;
|
|
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
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
|
272
|
-
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
|
|
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:
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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. */
|