@savvy-web/lint-staged 0.1.2 → 0.2.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/376.js +1198 -0
- package/README.md +18 -6
- package/bin/savvy-lint.js +3 -0
- package/index.d.ts +83 -89
- package/index.js +39 -978
- package/package.json +39 -10
- package/tsdoc-metadata.json +11 -0
package/376.js
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import { Command, Options } from "@effect/cli";
|
|
2
|
+
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { FileSystem } from "@effect/platform";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
8
|
+
import { cosmiconfigSync, defaultLoaders } from "cosmiconfig";
|
|
9
|
+
import parser from "@typescript-eslint/parser";
|
|
10
|
+
import { ESLint } from "eslint";
|
|
11
|
+
import eslint_plugin_tsdoc from "eslint-plugin-tsdoc";
|
|
12
|
+
import { getWorkspaceInfos } from "workspace-tools";
|
|
13
|
+
import typescript from "typescript";
|
|
14
|
+
const VALID_COMMAND_PATTERN = /^[\w@/-]+$/;
|
|
15
|
+
function validateCommandName(name) {
|
|
16
|
+
if (!VALID_COMMAND_PATTERN.test(name)) throw new Error(`Invalid command name: "${name}". Only alphanumeric characters, hyphens, underscores, @ and / are allowed.`);
|
|
17
|
+
}
|
|
18
|
+
class Command_Command {
|
|
19
|
+
static cachedPackageManager = null;
|
|
20
|
+
static detectPackageManager(cwd = process.cwd()) {
|
|
21
|
+
if (null !== Command_Command.cachedPackageManager) return Command_Command.cachedPackageManager;
|
|
22
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
23
|
+
if (!existsSync(packageJsonPath)) {
|
|
24
|
+
Command_Command.cachedPackageManager = "npm";
|
|
25
|
+
return "npm";
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const content = readFileSync(packageJsonPath, "utf-8");
|
|
29
|
+
const pkg = JSON.parse(content);
|
|
30
|
+
if (pkg.packageManager) {
|
|
31
|
+
const match = pkg.packageManager.match(/^(npm|pnpm|yarn|bun)@/);
|
|
32
|
+
if (match) {
|
|
33
|
+
Command_Command.cachedPackageManager = match[1];
|
|
34
|
+
return Command_Command.cachedPackageManager;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
Command_Command.cachedPackageManager = "npm";
|
|
39
|
+
return "npm";
|
|
40
|
+
}
|
|
41
|
+
static getExecPrefix(packageManager) {
|
|
42
|
+
switch(packageManager){
|
|
43
|
+
case "pnpm":
|
|
44
|
+
return [
|
|
45
|
+
"pnpm",
|
|
46
|
+
"exec"
|
|
47
|
+
];
|
|
48
|
+
case "yarn":
|
|
49
|
+
return [
|
|
50
|
+
"yarn",
|
|
51
|
+
"exec"
|
|
52
|
+
];
|
|
53
|
+
case "bun":
|
|
54
|
+
return [
|
|
55
|
+
"bunx"
|
|
56
|
+
];
|
|
57
|
+
default:
|
|
58
|
+
return [
|
|
59
|
+
"npx",
|
|
60
|
+
"--no"
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
static clearCache() {
|
|
65
|
+
Command_Command.cachedPackageManager = null;
|
|
66
|
+
}
|
|
67
|
+
static isAvailable(command) {
|
|
68
|
+
validateCommandName(command);
|
|
69
|
+
try {
|
|
70
|
+
execSync(`command -v ${command}`, {
|
|
71
|
+
stdio: "ignore"
|
|
72
|
+
});
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
static findTool(tool) {
|
|
79
|
+
validateCommandName(tool);
|
|
80
|
+
if (Command_Command.isAvailable(tool)) return {
|
|
81
|
+
available: true,
|
|
82
|
+
command: tool,
|
|
83
|
+
source: "global"
|
|
84
|
+
};
|
|
85
|
+
const pm = Command_Command.detectPackageManager();
|
|
86
|
+
const prefix = Command_Command.getExecPrefix(pm);
|
|
87
|
+
const execCmd = [
|
|
88
|
+
...prefix,
|
|
89
|
+
tool
|
|
90
|
+
].join(" ");
|
|
91
|
+
try {
|
|
92
|
+
execSync(`${execCmd} --version`, {
|
|
93
|
+
stdio: "ignore"
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
available: true,
|
|
97
|
+
command: execCmd,
|
|
98
|
+
source: pm
|
|
99
|
+
};
|
|
100
|
+
} catch {}
|
|
101
|
+
return {
|
|
102
|
+
available: false,
|
|
103
|
+
command: void 0,
|
|
104
|
+
source: void 0
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
static requireTool(tool, errorMessage) {
|
|
108
|
+
const result = Command_Command.findTool(tool);
|
|
109
|
+
if (!result.available || !result.command) throw new Error(errorMessage ?? `Required tool '${tool}' is not available. Install it globally or add it as a dev dependency.`);
|
|
110
|
+
return result.command;
|
|
111
|
+
}
|
|
112
|
+
static exec(command) {
|
|
113
|
+
return execSync(command, {
|
|
114
|
+
encoding: "utf-8"
|
|
115
|
+
}).trim();
|
|
116
|
+
}
|
|
117
|
+
static execSilent(command) {
|
|
118
|
+
try {
|
|
119
|
+
execSync(command, {
|
|
120
|
+
stdio: "ignore"
|
|
121
|
+
});
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const TOOL_CONFIGS = {
|
|
129
|
+
markdownlint: {
|
|
130
|
+
moduleName: "markdownlint-cli2",
|
|
131
|
+
libConfigFiles: [
|
|
132
|
+
".markdownlint-cli2.jsonc",
|
|
133
|
+
".markdownlint-cli2.json",
|
|
134
|
+
".markdownlint-cli2.yaml",
|
|
135
|
+
".markdownlint-cli2.cjs",
|
|
136
|
+
".markdownlint.jsonc",
|
|
137
|
+
".markdownlint.json",
|
|
138
|
+
".markdownlint.yaml"
|
|
139
|
+
],
|
|
140
|
+
standardPlaces: [
|
|
141
|
+
".markdownlint-cli2.jsonc",
|
|
142
|
+
".markdownlint-cli2.json",
|
|
143
|
+
".markdownlint-cli2.yaml",
|
|
144
|
+
".markdownlint-cli2.cjs",
|
|
145
|
+
".markdownlint.jsonc",
|
|
146
|
+
".markdownlint.json",
|
|
147
|
+
".markdownlint.yaml"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
biome: {
|
|
151
|
+
moduleName: "biome",
|
|
152
|
+
libConfigFiles: [
|
|
153
|
+
"biome.jsonc",
|
|
154
|
+
"biome.json"
|
|
155
|
+
],
|
|
156
|
+
standardPlaces: [
|
|
157
|
+
"biome.jsonc",
|
|
158
|
+
"biome.json"
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
eslint: {
|
|
162
|
+
moduleName: "eslint",
|
|
163
|
+
libConfigFiles: [
|
|
164
|
+
"eslint.config.ts",
|
|
165
|
+
"eslint.config.js",
|
|
166
|
+
"eslint.config.mjs"
|
|
167
|
+
],
|
|
168
|
+
standardPlaces: [
|
|
169
|
+
"eslint.config.ts",
|
|
170
|
+
"eslint.config.js",
|
|
171
|
+
"eslint.config.mjs"
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
prettier: {
|
|
175
|
+
moduleName: "prettier",
|
|
176
|
+
libConfigFiles: [
|
|
177
|
+
".prettierrc",
|
|
178
|
+
".prettierrc.json",
|
|
179
|
+
".prettierrc.yaml",
|
|
180
|
+
".prettierrc.js",
|
|
181
|
+
"prettier.config.js"
|
|
182
|
+
],
|
|
183
|
+
standardPlaces: [
|
|
184
|
+
".prettierrc",
|
|
185
|
+
".prettierrc.json",
|
|
186
|
+
".prettierrc.yaml",
|
|
187
|
+
".prettierrc.js",
|
|
188
|
+
"prettier.config.js",
|
|
189
|
+
"package.json"
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
class ConfigSearch {
|
|
194
|
+
static libConfigDir = "lib/configs";
|
|
195
|
+
static find(tool, options = {}) {
|
|
196
|
+
const config = TOOL_CONFIGS[tool];
|
|
197
|
+
if (!config) return {
|
|
198
|
+
filepath: void 0,
|
|
199
|
+
found: false
|
|
200
|
+
};
|
|
201
|
+
return ConfigSearch.findFile(config.moduleName, {
|
|
202
|
+
libConfigFiles: config.libConfigFiles,
|
|
203
|
+
standardPlaces: config.standardPlaces,
|
|
204
|
+
...options
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
static findFile(moduleName, options = {}) {
|
|
208
|
+
const { searchFrom = process.cwd(), stopDir, libConfigFiles = [], standardPlaces = [] } = options;
|
|
209
|
+
const loaders = {
|
|
210
|
+
".jsonc": defaultLoaders[".json"],
|
|
211
|
+
".yaml": defaultLoaders[".yaml"],
|
|
212
|
+
".yml": defaultLoaders[".yaml"]
|
|
213
|
+
};
|
|
214
|
+
const libConfigDir = join(searchFrom, ConfigSearch.libConfigDir);
|
|
215
|
+
for (const file of libConfigFiles){
|
|
216
|
+
const filepath = join(libConfigDir, file);
|
|
217
|
+
if (existsSync(filepath)) return {
|
|
218
|
+
filepath,
|
|
219
|
+
found: true
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (0 === standardPlaces.length) return {
|
|
223
|
+
filepath: void 0,
|
|
224
|
+
found: false
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
const explorer = cosmiconfigSync(moduleName, {
|
|
228
|
+
searchPlaces: standardPlaces,
|
|
229
|
+
loaders,
|
|
230
|
+
...void 0 !== stopDir && {
|
|
231
|
+
stopDir
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
const result = explorer.search(searchFrom);
|
|
235
|
+
if (result?.filepath) return {
|
|
236
|
+
filepath: result.filepath,
|
|
237
|
+
found: true
|
|
238
|
+
};
|
|
239
|
+
} catch {}
|
|
240
|
+
return {
|
|
241
|
+
filepath: void 0,
|
|
242
|
+
found: false
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
static exists(filepath) {
|
|
246
|
+
return existsSync(filepath);
|
|
247
|
+
}
|
|
248
|
+
static resolve(filename, fallback) {
|
|
249
|
+
const libPath = `${ConfigSearch.libConfigDir}/${filename}`;
|
|
250
|
+
if (ConfigSearch.exists(libPath)) return libPath;
|
|
251
|
+
return fallback;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
class Filter {
|
|
255
|
+
static exclude(filenames, patterns) {
|
|
256
|
+
if (0 === patterns.length) return [
|
|
257
|
+
...filenames
|
|
258
|
+
];
|
|
259
|
+
return filenames.filter((file)=>!patterns.some((pattern)=>file.includes(pattern)));
|
|
260
|
+
}
|
|
261
|
+
static include(filenames, patterns) {
|
|
262
|
+
if (0 === patterns.length) return [];
|
|
263
|
+
return filenames.filter((file)=>patterns.some((pattern)=>file.includes(pattern)));
|
|
264
|
+
}
|
|
265
|
+
static apply(filenames, options) {
|
|
266
|
+
let result = [
|
|
267
|
+
...filenames
|
|
268
|
+
];
|
|
269
|
+
if (options.include && options.include.length > 0) result = Filter.include(result, options.include);
|
|
270
|
+
if (options.exclude && options.exclude.length > 0) result = Filter.exclude(result, options.exclude);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
class Biome {
|
|
275
|
+
static glob = "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}";
|
|
276
|
+
static defaultExcludes = [
|
|
277
|
+
"package-lock.json",
|
|
278
|
+
"__fixtures__"
|
|
279
|
+
];
|
|
280
|
+
static handler = Biome.create();
|
|
281
|
+
static findBiome() {
|
|
282
|
+
const result = Command_Command.findTool("biome");
|
|
283
|
+
return result.command;
|
|
284
|
+
}
|
|
285
|
+
static isAvailable() {
|
|
286
|
+
return Command_Command.findTool("biome").available;
|
|
287
|
+
}
|
|
288
|
+
static findConfig() {
|
|
289
|
+
const result = ConfigSearch.find("biome");
|
|
290
|
+
return result.filepath;
|
|
291
|
+
}
|
|
292
|
+
static create(options = {}) {
|
|
293
|
+
const excludes = options.exclude ?? [
|
|
294
|
+
...Biome.defaultExcludes
|
|
295
|
+
];
|
|
296
|
+
const config = options.config ?? Biome.findConfig();
|
|
297
|
+
return (filenames)=>{
|
|
298
|
+
const filtered = Filter.exclude(filenames, excludes);
|
|
299
|
+
if (0 === filtered.length) return [];
|
|
300
|
+
const biomeCmd = Command_Command.requireTool("biome", "Biome is not available. Install it globally (recommended) or add @biomejs/biome as a dev dependency.");
|
|
301
|
+
const files = filtered.join(" ");
|
|
302
|
+
const flags = options.flags ?? [];
|
|
303
|
+
const configFlag = config ? `--config-path=${config}` : "";
|
|
304
|
+
const cmd = [
|
|
305
|
+
`${biomeCmd} check --write --no-errors-on-unmatched`,
|
|
306
|
+
configFlag,
|
|
307
|
+
...flags,
|
|
308
|
+
files
|
|
309
|
+
].filter(Boolean).join(" ");
|
|
310
|
+
return cmd;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
class Markdown {
|
|
315
|
+
static glob = "**/*.{md,mdx}";
|
|
316
|
+
static defaultExcludes = [];
|
|
317
|
+
static handler = Markdown.create();
|
|
318
|
+
static findMarkdownlint() {
|
|
319
|
+
const result = Command_Command.findTool("markdownlint-cli2");
|
|
320
|
+
return result.command;
|
|
321
|
+
}
|
|
322
|
+
static isAvailable() {
|
|
323
|
+
return Command_Command.findTool("markdownlint-cli2").available;
|
|
324
|
+
}
|
|
325
|
+
static findConfig() {
|
|
326
|
+
const result = ConfigSearch.find("markdownlint");
|
|
327
|
+
return result.filepath;
|
|
328
|
+
}
|
|
329
|
+
static create(options = {}) {
|
|
330
|
+
const excludes = options.exclude ?? [
|
|
331
|
+
...Markdown.defaultExcludes
|
|
332
|
+
];
|
|
333
|
+
const noFix = options.noFix ?? false;
|
|
334
|
+
const config = options.config ?? Markdown.findConfig();
|
|
335
|
+
return (filenames)=>{
|
|
336
|
+
const filtered = Filter.exclude(filenames, excludes);
|
|
337
|
+
if (0 === filtered.length) return [];
|
|
338
|
+
const mdlintCmd = Command_Command.requireTool("markdownlint-cli2", "markdownlint-cli2 is not available. Install it globally or add it as a dev dependency.");
|
|
339
|
+
const files = filtered.join(" ");
|
|
340
|
+
const fixFlag = noFix ? "" : "--fix";
|
|
341
|
+
const configFlag = config ? `--config '${config}'` : "";
|
|
342
|
+
const cmd = [
|
|
343
|
+
mdlintCmd,
|
|
344
|
+
configFlag,
|
|
345
|
+
fixFlag,
|
|
346
|
+
files
|
|
347
|
+
].filter(Boolean).join(" ");
|
|
348
|
+
return cmd;
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
class TsDocLinter {
|
|
353
|
+
eslint;
|
|
354
|
+
constructor(options = {}){
|
|
355
|
+
const ignorePatterns = options.ignorePatterns ?? [];
|
|
356
|
+
const config = [
|
|
357
|
+
{
|
|
358
|
+
ignores: [
|
|
359
|
+
"**/node_modules/**",
|
|
360
|
+
"**/dist/**",
|
|
361
|
+
"**/coverage/**",
|
|
362
|
+
...ignorePatterns
|
|
363
|
+
]
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
files: [
|
|
367
|
+
"**/*.ts",
|
|
368
|
+
"**/*.tsx",
|
|
369
|
+
"**/*.mts",
|
|
370
|
+
"**/*.cts"
|
|
371
|
+
],
|
|
372
|
+
languageOptions: {
|
|
373
|
+
parser: parser
|
|
374
|
+
},
|
|
375
|
+
plugins: {
|
|
376
|
+
tsdoc: eslint_plugin_tsdoc
|
|
377
|
+
},
|
|
378
|
+
rules: {
|
|
379
|
+
"tsdoc/syntax": "error"
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
];
|
|
383
|
+
this.eslint = new ESLint({
|
|
384
|
+
overrideConfigFile: true,
|
|
385
|
+
overrideConfig: config
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async lintFiles(filePaths) {
|
|
389
|
+
if (0 === filePaths.length) return [];
|
|
390
|
+
const results = await this.eslint.lintFiles(filePaths);
|
|
391
|
+
return results.map((result)=>({
|
|
392
|
+
filePath: result.filePath,
|
|
393
|
+
errorCount: result.errorCount,
|
|
394
|
+
warningCount: result.warningCount,
|
|
395
|
+
messages: result.messages.map((msg)=>({
|
|
396
|
+
line: msg.line,
|
|
397
|
+
column: msg.column,
|
|
398
|
+
severity: msg.severity,
|
|
399
|
+
message: msg.message,
|
|
400
|
+
ruleId: msg.ruleId
|
|
401
|
+
}))
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
async lintFilesAndThrow(filePaths) {
|
|
405
|
+
const results = await this.lintFiles(filePaths);
|
|
406
|
+
const errors = [];
|
|
407
|
+
for (const result of results)if (result.errorCount > 0) {
|
|
408
|
+
for (const msg of result.messages)if (2 === msg.severity) errors.push(`${result.filePath}:${msg.line}:${msg.column} - ${msg.message}`);
|
|
409
|
+
}
|
|
410
|
+
if (errors.length > 0) throw new Error(`TSDoc validation failed:\n${errors.join("\n")}`);
|
|
411
|
+
}
|
|
412
|
+
static formatResults(results) {
|
|
413
|
+
const lines = [];
|
|
414
|
+
for (const result of results)if (0 !== result.errorCount || 0 !== result.warningCount) {
|
|
415
|
+
lines.push(`\n${result.filePath}`);
|
|
416
|
+
for (const msg of result.messages){
|
|
417
|
+
const severity = 2 === msg.severity ? "error" : "warning";
|
|
418
|
+
const rule = msg.ruleId ? ` (${msg.ruleId})` : "";
|
|
419
|
+
lines.push(` ${msg.line}:${msg.column} ${severity} ${msg.message}${rule}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const totalErrors = results.reduce((sum, r)=>sum + r.errorCount, 0);
|
|
423
|
+
const totalWarnings = results.reduce((sum, r)=>sum + r.warningCount, 0);
|
|
424
|
+
if (totalErrors > 0 || totalWarnings > 0) lines.push(`\n✖ ${totalErrors} error(s), ${totalWarnings} warning(s)`);
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
static hasErrors(results) {
|
|
428
|
+
return results.some((r)=>r.errorCount > 0);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const TS_EXTENSIONS = [
|
|
432
|
+
".ts",
|
|
433
|
+
".tsx",
|
|
434
|
+
".mts",
|
|
435
|
+
".cts"
|
|
436
|
+
];
|
|
437
|
+
class EntryExtractor {
|
|
438
|
+
extract(packageJson) {
|
|
439
|
+
const entries = {};
|
|
440
|
+
const unresolved = [];
|
|
441
|
+
const { exports } = packageJson;
|
|
442
|
+
if (!exports) {
|
|
443
|
+
const mainEntry = packageJson.module ?? packageJson.main;
|
|
444
|
+
if (mainEntry && this.isTypeScriptFile(mainEntry)) entries["."] = mainEntry;
|
|
445
|
+
else if (mainEntry) unresolved.push(".");
|
|
446
|
+
return {
|
|
447
|
+
entries,
|
|
448
|
+
unresolved
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if ("string" == typeof exports) {
|
|
452
|
+
if (this.isTypeScriptFile(exports)) entries["."] = exports;
|
|
453
|
+
else unresolved.push(".");
|
|
454
|
+
return {
|
|
455
|
+
entries,
|
|
456
|
+
unresolved
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
this.extractFromObject(exports, entries, unresolved, ".");
|
|
460
|
+
return {
|
|
461
|
+
entries,
|
|
462
|
+
unresolved
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
extractFromObject(obj, entries, unresolved, currentPath) {
|
|
466
|
+
for (const [key, value] of Object.entries(obj)){
|
|
467
|
+
const exportPath = key.startsWith(".") ? key : currentPath;
|
|
468
|
+
if ("string" == typeof value) {
|
|
469
|
+
if (this.isTypeScriptFile(value)) entries[exportPath] = value;
|
|
470
|
+
else if (key.startsWith(".")) unresolved.push(exportPath);
|
|
471
|
+
} else if (value && "object" == typeof value && !Array.isArray(value)) {
|
|
472
|
+
const nested = value;
|
|
473
|
+
const tsPath = this.findTypeScriptCondition(nested);
|
|
474
|
+
if (tsPath) entries[exportPath] = tsPath;
|
|
475
|
+
else if (key.startsWith(".")) this.extractFromObject(nested, entries, unresolved, exportPath);
|
|
476
|
+
else {
|
|
477
|
+
const sourcePath = this.findSourceCondition(nested);
|
|
478
|
+
if (sourcePath && this.isTypeScriptFile(sourcePath)) entries[exportPath] = sourcePath;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
findTypeScriptCondition(conditions) {
|
|
484
|
+
const priorityKeys = [
|
|
485
|
+
"source",
|
|
486
|
+
"typescript",
|
|
487
|
+
"development",
|
|
488
|
+
"default"
|
|
489
|
+
];
|
|
490
|
+
for (const key of priorityKeys){
|
|
491
|
+
const value = conditions[key];
|
|
492
|
+
if ("string" == typeof value && this.isTypeScriptFile(value)) return value;
|
|
493
|
+
if (value && "object" == typeof value) {
|
|
494
|
+
const nested = this.findTypeScriptCondition(value);
|
|
495
|
+
if (nested) return nested;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
findSourceCondition(conditions) {
|
|
501
|
+
const priorityKeys = [
|
|
502
|
+
"source",
|
|
503
|
+
"import",
|
|
504
|
+
"require",
|
|
505
|
+
"default"
|
|
506
|
+
];
|
|
507
|
+
for (const key of priorityKeys){
|
|
508
|
+
const value = conditions[key];
|
|
509
|
+
if ("string" == typeof value) return value;
|
|
510
|
+
if (value && "object" == typeof value) {
|
|
511
|
+
const nested = this.findSourceCondition(value);
|
|
512
|
+
if (nested) return nested;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
isTypeScriptFile(filePath) {
|
|
518
|
+
return TS_EXTENSIONS.some((ext)=>filePath.endsWith(ext));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
class ImportGraph {
|
|
522
|
+
options;
|
|
523
|
+
program = null;
|
|
524
|
+
compilerOptions = null;
|
|
525
|
+
moduleResolutionCache = null;
|
|
526
|
+
constructor(options){
|
|
527
|
+
this.options = options;
|
|
528
|
+
}
|
|
529
|
+
traceFromEntries(entryPaths) {
|
|
530
|
+
const errors = [];
|
|
531
|
+
const visited = new Set();
|
|
532
|
+
const entries = [];
|
|
533
|
+
const initResult = this.initializeProgram();
|
|
534
|
+
if (!initResult.success) return {
|
|
535
|
+
files: [],
|
|
536
|
+
entries: [],
|
|
537
|
+
errors: [
|
|
538
|
+
initResult.error
|
|
539
|
+
]
|
|
540
|
+
};
|
|
541
|
+
for (const entryPath of entryPaths){
|
|
542
|
+
const absolutePath = this.resolveEntryPath(entryPath);
|
|
543
|
+
if (!existsSync(absolutePath)) {
|
|
544
|
+
errors.push({
|
|
545
|
+
type: "entry_not_found",
|
|
546
|
+
message: `Entry file not found: ${entryPath}`,
|
|
547
|
+
path: absolutePath
|
|
548
|
+
});
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
entries.push(absolutePath);
|
|
552
|
+
this.traceImports(absolutePath, visited, errors);
|
|
553
|
+
}
|
|
554
|
+
const files = Array.from(visited).filter((file)=>this.isSourceFile(file));
|
|
555
|
+
return {
|
|
556
|
+
files: files.sort(),
|
|
557
|
+
entries,
|
|
558
|
+
errors
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
traceFromPackageExports(packageJsonPath) {
|
|
562
|
+
const absolutePath = this.resolveEntryPath(packageJsonPath);
|
|
563
|
+
let packageJson;
|
|
564
|
+
try {
|
|
565
|
+
if (!existsSync(absolutePath)) return {
|
|
566
|
+
files: [],
|
|
567
|
+
entries: [],
|
|
568
|
+
errors: [
|
|
569
|
+
{
|
|
570
|
+
type: "package_json_not_found",
|
|
571
|
+
message: `Failed to read package.json: File not found at ${absolutePath}`,
|
|
572
|
+
path: absolutePath
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
};
|
|
576
|
+
const content = readFileSync(absolutePath, "utf-8");
|
|
577
|
+
packageJson = JSON.parse(content);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
580
|
+
return {
|
|
581
|
+
files: [],
|
|
582
|
+
entries: [],
|
|
583
|
+
errors: [
|
|
584
|
+
{
|
|
585
|
+
type: "package_json_parse_error",
|
|
586
|
+
message: `Failed to parse package.json: ${message}`,
|
|
587
|
+
path: absolutePath
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const extractor = new EntryExtractor();
|
|
593
|
+
const { entries } = extractor.extract(packageJson);
|
|
594
|
+
const packageDir = dirname(absolutePath);
|
|
595
|
+
const entryPaths = Object.values(entries).map((p)=>resolve(packageDir, p));
|
|
596
|
+
return this.traceFromEntries(entryPaths);
|
|
597
|
+
}
|
|
598
|
+
initializeProgram() {
|
|
599
|
+
if (this.program) return {
|
|
600
|
+
success: true
|
|
601
|
+
};
|
|
602
|
+
const configPath = this.findTsConfig();
|
|
603
|
+
if (!configPath) {
|
|
604
|
+
this.compilerOptions = {
|
|
605
|
+
moduleResolution: typescript.ModuleResolutionKind.NodeNext,
|
|
606
|
+
module: typescript.ModuleKind.NodeNext,
|
|
607
|
+
target: typescript.ScriptTarget.ESNext,
|
|
608
|
+
strict: true
|
|
609
|
+
};
|
|
610
|
+
this.moduleResolutionCache = typescript.createModuleResolutionCache(this.options.rootDir, (fileName)=>fileName.toLowerCase(), this.compilerOptions);
|
|
611
|
+
const host = typescript.createCompilerHost(this.compilerOptions, true);
|
|
612
|
+
host.getCurrentDirectory = ()=>this.options.rootDir;
|
|
613
|
+
this.program = typescript.createProgram([], this.compilerOptions, host);
|
|
614
|
+
return {
|
|
615
|
+
success: true
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const configFile = typescript.readConfigFile(configPath, (path)=>readFileSync(path, "utf-8"));
|
|
619
|
+
if (configFile.error) {
|
|
620
|
+
const message = typescript.flattenDiagnosticMessageText(configFile.error.messageText, "\n");
|
|
621
|
+
return {
|
|
622
|
+
success: false,
|
|
623
|
+
error: {
|
|
624
|
+
type: "tsconfig_read_error",
|
|
625
|
+
message: `Failed to read tsconfig.json: ${message}`,
|
|
626
|
+
path: configPath
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const parsed = typescript.parseJsonConfigFileContent(configFile.config, typescript.sys, dirname(configPath));
|
|
631
|
+
if (parsed.errors.length > 0) {
|
|
632
|
+
const messages = parsed.errors.map((e)=>typescript.flattenDiagnosticMessageText(e.messageText, "\n")).join("\n");
|
|
633
|
+
return {
|
|
634
|
+
success: false,
|
|
635
|
+
error: {
|
|
636
|
+
type: "tsconfig_parse_error",
|
|
637
|
+
message: `Failed to parse tsconfig.json: ${messages}`,
|
|
638
|
+
path: configPath
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
this.compilerOptions = parsed.options;
|
|
643
|
+
this.moduleResolutionCache = typescript.createModuleResolutionCache(this.options.rootDir, (fileName)=>fileName.toLowerCase(), this.compilerOptions);
|
|
644
|
+
const host = typescript.createCompilerHost(this.compilerOptions, true);
|
|
645
|
+
host.getCurrentDirectory = ()=>this.options.rootDir;
|
|
646
|
+
this.program = typescript.createProgram([], this.compilerOptions, host);
|
|
647
|
+
return {
|
|
648
|
+
success: true
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
findTsConfig() {
|
|
652
|
+
if (this.options.tsconfigPath) {
|
|
653
|
+
const customPath = isAbsolute(this.options.tsconfigPath) ? this.options.tsconfigPath : resolve(this.options.rootDir, this.options.tsconfigPath);
|
|
654
|
+
if (existsSync(customPath)) return customPath;
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const configPath = typescript.findConfigFile(this.options.rootDir, (path)=>existsSync(path));
|
|
658
|
+
return configPath ?? null;
|
|
659
|
+
}
|
|
660
|
+
resolveEntryPath(entryPath) {
|
|
661
|
+
if (isAbsolute(entryPath)) return normalize(entryPath);
|
|
662
|
+
return normalize(resolve(this.options.rootDir, entryPath));
|
|
663
|
+
}
|
|
664
|
+
traceImports(filePath, visited, errors) {
|
|
665
|
+
const normalizedPath = normalize(filePath);
|
|
666
|
+
if (visited.has(normalizedPath)) return;
|
|
667
|
+
if (this.isExternalModule(normalizedPath)) return;
|
|
668
|
+
visited.add(normalizedPath);
|
|
669
|
+
let content;
|
|
670
|
+
try {
|
|
671
|
+
content = readFileSync(normalizedPath, "utf-8");
|
|
672
|
+
} catch {
|
|
673
|
+
errors.push({
|
|
674
|
+
type: "file_read_error",
|
|
675
|
+
message: `Failed to read file: ${normalizedPath}`,
|
|
676
|
+
path: normalizedPath
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const sourceFile = typescript.createSourceFile(normalizedPath, content, typescript.ScriptTarget.Latest, true);
|
|
681
|
+
const imports = this.extractImports(sourceFile);
|
|
682
|
+
for (const importPath of imports){
|
|
683
|
+
const resolved = this.resolveImport(importPath, normalizedPath);
|
|
684
|
+
if (resolved) this.traceImports(resolved, visited, errors);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
extractImports(sourceFile) {
|
|
688
|
+
const imports = [];
|
|
689
|
+
const visit = (node)=>{
|
|
690
|
+
if (typescript.isImportDeclaration(node)) {
|
|
691
|
+
const specifier = node.moduleSpecifier;
|
|
692
|
+
if (typescript.isStringLiteral(specifier)) imports.push(specifier.text);
|
|
693
|
+
} else if (typescript.isExportDeclaration(node)) {
|
|
694
|
+
const specifier = node.moduleSpecifier;
|
|
695
|
+
if (specifier && typescript.isStringLiteral(specifier)) imports.push(specifier.text);
|
|
696
|
+
} else if (typescript.isCallExpression(node)) {
|
|
697
|
+
const expression = node.expression;
|
|
698
|
+
if (expression.kind === typescript.SyntaxKind.ImportKeyword && node.arguments.length > 0) {
|
|
699
|
+
const arg = node.arguments[0];
|
|
700
|
+
if (arg && typescript.isStringLiteral(arg)) imports.push(arg.text);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
typescript.forEachChild(node, visit);
|
|
704
|
+
};
|
|
705
|
+
visit(sourceFile);
|
|
706
|
+
return imports;
|
|
707
|
+
}
|
|
708
|
+
resolveImport(specifier, fromFile) {
|
|
709
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
|
|
710
|
+
if (!this.compilerOptions?.paths || !Object.keys(this.compilerOptions.paths).length) return null;
|
|
711
|
+
}
|
|
712
|
+
if (!this.compilerOptions || !this.moduleResolutionCache) return null;
|
|
713
|
+
const resolved = typescript.resolveModuleName(specifier, fromFile, this.compilerOptions, typescript.sys, this.moduleResolutionCache);
|
|
714
|
+
if (resolved.resolvedModule) {
|
|
715
|
+
const resolvedPath = resolved.resolvedModule.resolvedFileName;
|
|
716
|
+
if (resolved.resolvedModule.isExternalLibraryImport) return null;
|
|
717
|
+
if (resolvedPath.endsWith(".d.ts")) {
|
|
718
|
+
const sourcePath = resolvedPath.replace(/\.d\.ts$/, ".ts");
|
|
719
|
+
if (existsSync(sourcePath)) return sourcePath;
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
return resolvedPath;
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
isExternalModule(filePath) {
|
|
727
|
+
return filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\");
|
|
728
|
+
}
|
|
729
|
+
isSourceFile(filePath) {
|
|
730
|
+
if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx") && !filePath.endsWith(".mts") && !filePath.endsWith(".cts")) return false;
|
|
731
|
+
if (filePath.endsWith(".d.ts") || filePath.endsWith(".d.mts") || filePath.endsWith(".d.cts")) return false;
|
|
732
|
+
if (filePath.includes(".test.") || filePath.includes(".spec.")) return false;
|
|
733
|
+
if (filePath.includes("/__test__/") || filePath.includes("\\__test__\\")) return false;
|
|
734
|
+
if (filePath.includes("/__tests__/") || filePath.includes("\\__tests__\\")) return false;
|
|
735
|
+
const excludePatterns = this.options.excludePatterns ?? [];
|
|
736
|
+
for (const pattern of excludePatterns)if (filePath.includes(pattern)) return false;
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
static fromEntries(entryPaths, options) {
|
|
740
|
+
const graph = new ImportGraph(options);
|
|
741
|
+
return graph.traceFromEntries(entryPaths);
|
|
742
|
+
}
|
|
743
|
+
static fromPackageExports(packageJsonPath, options) {
|
|
744
|
+
const graph = new ImportGraph(options);
|
|
745
|
+
return graph.traceFromPackageExports(packageJsonPath);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
class TsDocResolver {
|
|
749
|
+
options;
|
|
750
|
+
constructor(options){
|
|
751
|
+
this.options = options;
|
|
752
|
+
}
|
|
753
|
+
resolve() {
|
|
754
|
+
const { rootDir } = this.options;
|
|
755
|
+
const workspaces = [];
|
|
756
|
+
const repoTsdocPath = join(rootDir, "tsdoc.json");
|
|
757
|
+
const repoTsdocConfig = existsSync(repoTsdocPath) ? repoTsdocPath : void 0;
|
|
758
|
+
const workspaceInfos = getWorkspaceInfos(rootDir);
|
|
759
|
+
const isMonorepo = void 0 !== workspaceInfos && workspaceInfos.length > 1;
|
|
760
|
+
if (void 0 === workspaceInfos || 0 === workspaceInfos.length) {
|
|
761
|
+
const result = this.resolveWorkspace(rootDir, repoTsdocConfig);
|
|
762
|
+
if (result) workspaces.push(result);
|
|
763
|
+
} else for (const info of workspaceInfos){
|
|
764
|
+
const workspacePath = info.path;
|
|
765
|
+
const result = this.resolveWorkspace(workspacePath, repoTsdocConfig);
|
|
766
|
+
if (result) workspaces.push(result);
|
|
767
|
+
}
|
|
768
|
+
const result = {
|
|
769
|
+
workspaces,
|
|
770
|
+
isMonorepo
|
|
771
|
+
};
|
|
772
|
+
if (void 0 !== repoTsdocConfig) result.repoTsdocConfig = repoTsdocConfig;
|
|
773
|
+
return result;
|
|
774
|
+
}
|
|
775
|
+
resolveWorkspace(workspacePath, repoTsdocConfig) {
|
|
776
|
+
const packageJsonPath = join(workspacePath, "package.json");
|
|
777
|
+
if (!existsSync(packageJsonPath)) return null;
|
|
778
|
+
let packageJson;
|
|
779
|
+
try {
|
|
780
|
+
const content = readFileSync(packageJsonPath, "utf-8");
|
|
781
|
+
packageJson = JSON.parse(content);
|
|
782
|
+
} catch {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const workspaceTsdocPath = join(workspacePath, "tsdoc.json");
|
|
786
|
+
const workspaceTsdocConfig = existsSync(workspaceTsdocPath) ? workspaceTsdocPath : void 0;
|
|
787
|
+
const tsdocConfigPath = workspaceTsdocConfig ?? repoTsdocConfig;
|
|
788
|
+
if (!tsdocConfigPath) return null;
|
|
789
|
+
if (!packageJson.exports) return null;
|
|
790
|
+
const name = packageJson.name ?? relative(this.options.rootDir, workspacePath);
|
|
791
|
+
const errors = [];
|
|
792
|
+
const graphOptions = {
|
|
793
|
+
rootDir: workspacePath
|
|
794
|
+
};
|
|
795
|
+
if (void 0 !== this.options.excludePatterns) graphOptions.excludePatterns = this.options.excludePatterns;
|
|
796
|
+
const graph = new ImportGraph(graphOptions);
|
|
797
|
+
const result = graph.traceFromPackageExports(packageJsonPath);
|
|
798
|
+
for (const error of result.errors)errors.push(error.message);
|
|
799
|
+
return {
|
|
800
|
+
name,
|
|
801
|
+
path: workspacePath,
|
|
802
|
+
tsdocConfigPath,
|
|
803
|
+
files: result.files,
|
|
804
|
+
errors
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
filterStagedFiles(stagedFiles) {
|
|
808
|
+
const result = this.resolve();
|
|
809
|
+
const output = [];
|
|
810
|
+
for (const workspace of result.workspaces){
|
|
811
|
+
const workspaceFiles = new Set(workspace.files);
|
|
812
|
+
const matchedFiles = stagedFiles.filter((f)=>workspaceFiles.has(f));
|
|
813
|
+
if (matchedFiles.length > 0) output.push({
|
|
814
|
+
files: matchedFiles,
|
|
815
|
+
tsdocConfigPath: workspace.tsdocConfigPath
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return output;
|
|
819
|
+
}
|
|
820
|
+
needsLinting(filePath) {
|
|
821
|
+
const result = this.resolve();
|
|
822
|
+
for (const workspace of result.workspaces)if (workspace.files.includes(filePath)) return true;
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
getTsDocConfig(filePath) {
|
|
826
|
+
const result = this.resolve();
|
|
827
|
+
for (const workspace of result.workspaces)if (workspace.files.includes(filePath)) return workspace.tsdocConfigPath;
|
|
828
|
+
}
|
|
829
|
+
findWorkspace(filePath) {
|
|
830
|
+
const result = this.resolve();
|
|
831
|
+
for (const workspace of result.workspaces)if (filePath.startsWith(workspace.path)) return workspace;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
class TypeScript {
|
|
835
|
+
static glob = "*.{ts,cts,mts,tsx}";
|
|
836
|
+
static defaultExcludes = [];
|
|
837
|
+
static defaultTsdocExcludes = [
|
|
838
|
+
".test.",
|
|
839
|
+
".spec.",
|
|
840
|
+
"__test__",
|
|
841
|
+
"__tests__"
|
|
842
|
+
];
|
|
843
|
+
static detectCompiler(cwd = process.cwd()) {
|
|
844
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
845
|
+
if (!existsSync(packageJsonPath)) return;
|
|
846
|
+
try {
|
|
847
|
+
const content = readFileSync(packageJsonPath, "utf-8");
|
|
848
|
+
const pkg = JSON.parse(content);
|
|
849
|
+
const allDeps = {
|
|
850
|
+
...pkg.dependencies,
|
|
851
|
+
...pkg.devDependencies
|
|
852
|
+
};
|
|
853
|
+
if ("@typescript/native-preview" in allDeps) return "tsgo";
|
|
854
|
+
if ("typescript" in allDeps) return "tsc";
|
|
855
|
+
} catch {}
|
|
856
|
+
}
|
|
857
|
+
static isAvailable() {
|
|
858
|
+
return void 0 !== TypeScript.detectCompiler();
|
|
859
|
+
}
|
|
860
|
+
static getDefaultTypecheckCommand() {
|
|
861
|
+
const compiler = TypeScript.detectCompiler();
|
|
862
|
+
if (!compiler) throw new Error("No TypeScript compiler found. Install 'typescript' or '@typescript/native-preview' as a dev dependency.");
|
|
863
|
+
const pm = Command_Command.detectPackageManager();
|
|
864
|
+
const prefix = Command_Command.getExecPrefix(pm);
|
|
865
|
+
return [
|
|
866
|
+
...prefix,
|
|
867
|
+
compiler,
|
|
868
|
+
"--noEmit"
|
|
869
|
+
].join(" ");
|
|
870
|
+
}
|
|
871
|
+
static handler = TypeScript.create();
|
|
872
|
+
static isTsdocAvailable(cwd = process.cwd()) {
|
|
873
|
+
const tsdocPath = join(cwd, "tsdoc.json");
|
|
874
|
+
return existsSync(tsdocPath);
|
|
875
|
+
}
|
|
876
|
+
static create(options = {}) {
|
|
877
|
+
const excludes = options.exclude ?? [
|
|
878
|
+
...TypeScript.defaultExcludes
|
|
879
|
+
];
|
|
880
|
+
const tsdocExcludes = options.excludeTsdoc ?? [
|
|
881
|
+
...TypeScript.defaultTsdocExcludes
|
|
882
|
+
];
|
|
883
|
+
const skipTsdoc = options.skipTsdoc ?? false;
|
|
884
|
+
const skipTypecheck = options.skipTypecheck ?? false;
|
|
885
|
+
const rootDir = options.rootDir ?? process.cwd();
|
|
886
|
+
let typecheckCommand;
|
|
887
|
+
const getTypecheckCommand = ()=>{
|
|
888
|
+
if (void 0 === typecheckCommand) typecheckCommand = options.typecheckCommand ?? TypeScript.getDefaultTypecheckCommand();
|
|
889
|
+
return typecheckCommand;
|
|
890
|
+
};
|
|
891
|
+
return async (filenames)=>{
|
|
892
|
+
const filtered = Filter.exclude(filenames, excludes);
|
|
893
|
+
if (0 === filtered.length) return [];
|
|
894
|
+
const commands = [];
|
|
895
|
+
if (!skipTsdoc) {
|
|
896
|
+
const resolver = new TsDocResolver({
|
|
897
|
+
rootDir,
|
|
898
|
+
excludePatterns: [
|
|
899
|
+
...tsdocExcludes
|
|
900
|
+
]
|
|
901
|
+
});
|
|
902
|
+
const absoluteFiles = filtered.map((f)=>isAbsolute(f) ? f : join(rootDir, f));
|
|
903
|
+
const tsdocGroups = resolver.filterStagedFiles(absoluteFiles);
|
|
904
|
+
for (const group of tsdocGroups)if (group.files.length > 0) {
|
|
905
|
+
const linter = new TsDocLinter({
|
|
906
|
+
ignorePatterns: tsdocExcludes.map((p)=>`**/*${p}*`)
|
|
907
|
+
});
|
|
908
|
+
const results = await linter.lintFiles(group.files);
|
|
909
|
+
if (TsDocLinter.hasErrors(results)) {
|
|
910
|
+
const output = TsDocLinter.formatResults(results);
|
|
911
|
+
throw new Error(`TSDoc validation failed:\n${output}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (!skipTypecheck && filtered.length > 0) commands.push(getTypecheckCommand());
|
|
916
|
+
return commands;
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const CHECK_MARK = "\u2713";
|
|
921
|
+
const WARNING = "\u26A0";
|
|
922
|
+
const EXECUTABLE_MODE = 493;
|
|
923
|
+
const HUSKY_HOOK_PATH = ".husky/pre-commit";
|
|
924
|
+
const DEFAULT_CONFIG_PATH = "lib/configs/lint-staged.config.js";
|
|
925
|
+
const BEGIN_MARKER = "# --- BEGIN SAVVY-LINT MANAGED SECTION ---";
|
|
926
|
+
const END_MARKER = "# --- END SAVVY-LINT MANAGED SECTION ---";
|
|
927
|
+
function generateManagedContent(configPath) {
|
|
928
|
+
return `# DO NOT EDIT between these markers - managed by savvy-lint
|
|
929
|
+
# Skip in CI environment
|
|
930
|
+
{ [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; } && exit 0
|
|
931
|
+
|
|
932
|
+
# Get repo root directory
|
|
933
|
+
ROOT=$(git rev-parse --show-toplevel)
|
|
934
|
+
|
|
935
|
+
# Detect package manager from package.json or lockfiles
|
|
936
|
+
detect_pm() {
|
|
937
|
+
# Check packageManager field in package.json (e.g., "pnpm@9.0.0")
|
|
938
|
+
if [ -f "$ROOT/package.json" ]; then
|
|
939
|
+
pm=$(jq -r '.packageManager // empty' "$ROOT/package.json" 2>/dev/null | cut -d'@' -f1)
|
|
940
|
+
if [ -n "$pm" ]; then
|
|
941
|
+
echo "$pm"
|
|
942
|
+
return
|
|
943
|
+
fi
|
|
944
|
+
fi
|
|
945
|
+
|
|
946
|
+
# Fallback to lockfile detection
|
|
947
|
+
if [ -f "$ROOT/pnpm-lock.yaml" ]; then
|
|
948
|
+
echo "pnpm"
|
|
949
|
+
elif [ -f "$ROOT/yarn.lock" ]; then
|
|
950
|
+
echo "yarn"
|
|
951
|
+
elif [ -f "$ROOT/bun.lock" ]; then
|
|
952
|
+
echo "bun"
|
|
953
|
+
else
|
|
954
|
+
echo "npm"
|
|
955
|
+
fi
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
# Get the exec command for the detected package manager
|
|
959
|
+
PM=$(detect_pm)
|
|
960
|
+
case "$PM" in
|
|
961
|
+
pnpm) CMD="pnpm exec" ;;
|
|
962
|
+
yarn) CMD="yarn exec" ;;
|
|
963
|
+
bun) CMD="bunx" ;;
|
|
964
|
+
*) CMD="npx --no --" ;;
|
|
965
|
+
esac
|
|
966
|
+
|
|
967
|
+
$CMD lint-staged --config "$ROOT/${configPath}"`;
|
|
968
|
+
}
|
|
969
|
+
function generateFullHookContent(configPath) {
|
|
970
|
+
return `#!/usr/bin/env sh
|
|
971
|
+
# Pre-commit hook with savvy-lint managed section
|
|
972
|
+
# Custom hooks can go above or below the managed section
|
|
973
|
+
|
|
974
|
+
${BEGIN_MARKER}
|
|
975
|
+
${generateManagedContent(configPath)}
|
|
976
|
+
${END_MARKER}
|
|
977
|
+
`;
|
|
978
|
+
}
|
|
979
|
+
function extractManagedSection(content) {
|
|
980
|
+
const beginIndex = content.indexOf(BEGIN_MARKER);
|
|
981
|
+
const endIndex = content.indexOf(END_MARKER);
|
|
982
|
+
if (-1 === beginIndex || -1 === endIndex || endIndex <= beginIndex) return {
|
|
983
|
+
beforeSection: content,
|
|
984
|
+
managedSection: "",
|
|
985
|
+
afterSection: "",
|
|
986
|
+
found: false
|
|
987
|
+
};
|
|
988
|
+
return {
|
|
989
|
+
beforeSection: content.slice(0, beginIndex),
|
|
990
|
+
managedSection: content.slice(beginIndex, endIndex + END_MARKER.length),
|
|
991
|
+
afterSection: content.slice(endIndex + END_MARKER.length),
|
|
992
|
+
found: true
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function updateManagedSection(existingContent, configPath) {
|
|
996
|
+
const { beforeSection, afterSection, found } = extractManagedSection(existingContent);
|
|
997
|
+
const newManagedSection = `${BEGIN_MARKER}\n${generateManagedContent(configPath)}\n${END_MARKER}`;
|
|
998
|
+
if (found) return `${beforeSection}${newManagedSection}${afterSection}`;
|
|
999
|
+
const trimmedContent = existingContent.trimEnd();
|
|
1000
|
+
return `${trimmedContent}\n\n${newManagedSection}\n`;
|
|
1001
|
+
}
|
|
1002
|
+
function generateConfigContent(preset) {
|
|
1003
|
+
return `/**
|
|
1004
|
+
* @type {import('lint-staged').Configuration}
|
|
1005
|
+
* Generated by savvy-lint init
|
|
1006
|
+
*/
|
|
1007
|
+
import { Preset } from "@savvy-web/lint-staged";
|
|
1008
|
+
|
|
1009
|
+
export default Preset.${preset}();
|
|
1010
|
+
`;
|
|
1011
|
+
}
|
|
1012
|
+
const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite entire hook file (not just managed section)"), Options.withDefault(false));
|
|
1013
|
+
const configOption = Options.text("config").pipe(Options.withAlias("c"), Options.withDescription("Relative path for the lint-staged config file (from repo root)"), Options.withDefault(DEFAULT_CONFIG_PATH));
|
|
1014
|
+
const presetOption = Options.choice("preset", [
|
|
1015
|
+
"minimal",
|
|
1016
|
+
"standard",
|
|
1017
|
+
"full"
|
|
1018
|
+
]).pipe(Options.withAlias("p"), Options.withDescription("Preset to use: minimal, standard, or full"), Options.withDefault("full"));
|
|
1019
|
+
function makeExecutable(path) {
|
|
1020
|
+
return Effect.tryPromise(()=>import("node:fs/promises").then((fs)=>fs.chmod(path, EXECUTABLE_MODE)));
|
|
1021
|
+
}
|
|
1022
|
+
const initCommand = Command.make("init", {
|
|
1023
|
+
force: forceOption,
|
|
1024
|
+
config: configOption,
|
|
1025
|
+
preset: presetOption
|
|
1026
|
+
}, ({ force, config, preset })=>Effect.gen(function*() {
|
|
1027
|
+
const fs = yield* FileSystem.FileSystem;
|
|
1028
|
+
if (config.startsWith("/")) yield* Effect.fail(new Error("Config path must be relative to repository root, not absolute"));
|
|
1029
|
+
yield* Effect.log("Initializing lint-staged configuration...\n");
|
|
1030
|
+
const huskyExists = yield* fs.exists(HUSKY_HOOK_PATH);
|
|
1031
|
+
if (huskyExists && !force) {
|
|
1032
|
+
const existingContent = yield* fs.readFileString(HUSKY_HOOK_PATH);
|
|
1033
|
+
const { found } = extractManagedSection(existingContent);
|
|
1034
|
+
const updatedContent = updateManagedSection(existingContent, config);
|
|
1035
|
+
yield* fs.writeFileString(HUSKY_HOOK_PATH, updatedContent);
|
|
1036
|
+
yield* makeExecutable(HUSKY_HOOK_PATH);
|
|
1037
|
+
if (found) yield* Effect.log(`${CHECK_MARK} Updated managed section in ${HUSKY_HOOK_PATH}`);
|
|
1038
|
+
else yield* Effect.log(`${CHECK_MARK} Added managed section to ${HUSKY_HOOK_PATH}`);
|
|
1039
|
+
} else if (huskyExists && force) {
|
|
1040
|
+
yield* fs.writeFileString(HUSKY_HOOK_PATH, generateFullHookContent(config));
|
|
1041
|
+
yield* makeExecutable(HUSKY_HOOK_PATH);
|
|
1042
|
+
yield* Effect.log(`${CHECK_MARK} Replaced ${HUSKY_HOOK_PATH} (--force)`);
|
|
1043
|
+
} else {
|
|
1044
|
+
yield* fs.makeDirectory(".husky", {
|
|
1045
|
+
recursive: true
|
|
1046
|
+
});
|
|
1047
|
+
yield* fs.writeFileString(HUSKY_HOOK_PATH, generateFullHookContent(config));
|
|
1048
|
+
yield* makeExecutable(HUSKY_HOOK_PATH);
|
|
1049
|
+
yield* Effect.log(`${CHECK_MARK} Created ${HUSKY_HOOK_PATH}`);
|
|
1050
|
+
}
|
|
1051
|
+
const configExists = yield* fs.exists(config);
|
|
1052
|
+
if (configExists && !force) yield* Effect.log(`${WARNING} ${config} already exists (use --force to overwrite)`);
|
|
1053
|
+
else {
|
|
1054
|
+
const configDir = dirname(config);
|
|
1055
|
+
if (configDir && "." !== configDir) yield* fs.makeDirectory(configDir, {
|
|
1056
|
+
recursive: true
|
|
1057
|
+
});
|
|
1058
|
+
yield* fs.writeFileString(config, generateConfigContent(preset));
|
|
1059
|
+
yield* Effect.log(`${CHECK_MARK} Created ${config} (preset: ${preset})`);
|
|
1060
|
+
}
|
|
1061
|
+
yield* Effect.log("\nDone! Lint-staged is ready to use.");
|
|
1062
|
+
})).pipe(Command.withDescription("Initialize lint-staged configuration and husky hooks"));
|
|
1063
|
+
const check_CHECK_MARK = "\u2713";
|
|
1064
|
+
const CROSS_MARK = "\u2717";
|
|
1065
|
+
const check_WARNING = "\u26A0";
|
|
1066
|
+
const BULLET = "\u2022";
|
|
1067
|
+
const check_HUSKY_HOOK_PATH = ".husky/pre-commit";
|
|
1068
|
+
const CONFIG_FILES = [
|
|
1069
|
+
"lint-staged.config.js",
|
|
1070
|
+
"lint-staged.config.mjs",
|
|
1071
|
+
"lint-staged.config.cjs",
|
|
1072
|
+
"lint-staged.config.ts",
|
|
1073
|
+
".lintstagedrc",
|
|
1074
|
+
".lintstagedrc.json",
|
|
1075
|
+
".lintstagedrc.yaml",
|
|
1076
|
+
".lintstagedrc.yml",
|
|
1077
|
+
".lintstagedrc.js",
|
|
1078
|
+
".lintstagedrc.cjs",
|
|
1079
|
+
".lintstagedrc.mjs"
|
|
1080
|
+
];
|
|
1081
|
+
const CONFIG_SEARCH_PATHS = [
|
|
1082
|
+
"lib/configs/lint-staged.config.js",
|
|
1083
|
+
"lib/configs/lint-staged.config.ts",
|
|
1084
|
+
...CONFIG_FILES
|
|
1085
|
+
];
|
|
1086
|
+
function findConfigFile(fs) {
|
|
1087
|
+
return Effect.gen(function*() {
|
|
1088
|
+
for (const file of CONFIG_SEARCH_PATHS)if (yield* fs.exists(file)) return file;
|
|
1089
|
+
return null;
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
function extractConfigPathFromManaged(managedContent) {
|
|
1093
|
+
const match = managedContent.match(/lint-staged --config "\$ROOT\/([^"]+)"/);
|
|
1094
|
+
return match ? match[1] : null;
|
|
1095
|
+
}
|
|
1096
|
+
function checkManagedSectionStatus(existingManaged) {
|
|
1097
|
+
const configPath = extractConfigPathFromManaged(existingManaged);
|
|
1098
|
+
if (!configPath) return {
|
|
1099
|
+
isUpToDate: false,
|
|
1100
|
+
configPath: null,
|
|
1101
|
+
needsUpdate: true
|
|
1102
|
+
};
|
|
1103
|
+
const expectedContent = `${BEGIN_MARKER}\n${generateManagedContent(configPath)}\n${END_MARKER}`;
|
|
1104
|
+
const normalizedExisting = existingManaged.trim().replace(/\s+/g, " ");
|
|
1105
|
+
const normalizedExpected = expectedContent.trim().replace(/\s+/g, " ");
|
|
1106
|
+
const isUpToDate = normalizedExisting === normalizedExpected;
|
|
1107
|
+
return {
|
|
1108
|
+
isUpToDate,
|
|
1109
|
+
configPath,
|
|
1110
|
+
needsUpdate: !isUpToDate
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output warnings (for postinstall usage)"), Options.withDefault(false));
|
|
1114
|
+
const checkCommand = Command.make("check", {
|
|
1115
|
+
quiet: quietOption
|
|
1116
|
+
}, ({ quiet })=>Effect.gen(function*() {
|
|
1117
|
+
const fs = yield* FileSystem.FileSystem;
|
|
1118
|
+
const warnings = [];
|
|
1119
|
+
const foundConfig = yield* findConfigFile(fs);
|
|
1120
|
+
const hasHuskyHook = yield* fs.exists(check_HUSKY_HOOK_PATH);
|
|
1121
|
+
let managedStatus = {
|
|
1122
|
+
isUpToDate: false,
|
|
1123
|
+
configPath: null,
|
|
1124
|
+
needsUpdate: false,
|
|
1125
|
+
found: false
|
|
1126
|
+
};
|
|
1127
|
+
if (hasHuskyHook) {
|
|
1128
|
+
const hookContent = yield* fs.readFileString(check_HUSKY_HOOK_PATH);
|
|
1129
|
+
const { managedSection, found } = extractManagedSection(hookContent);
|
|
1130
|
+
if (found) {
|
|
1131
|
+
const status = checkManagedSectionStatus(managedSection);
|
|
1132
|
+
managedStatus = {
|
|
1133
|
+
...status,
|
|
1134
|
+
found: true
|
|
1135
|
+
};
|
|
1136
|
+
if (status.needsUpdate) warnings.push(`${check_WARNING} Your ${check_HUSKY_HOOK_PATH} managed section is outdated.\n Run 'savvy-lint init' to update it (preserves your custom hooks).`);
|
|
1137
|
+
} else {
|
|
1138
|
+
managedStatus = {
|
|
1139
|
+
isUpToDate: false,
|
|
1140
|
+
configPath: null,
|
|
1141
|
+
needsUpdate: false,
|
|
1142
|
+
found: false
|
|
1143
|
+
};
|
|
1144
|
+
warnings.push(`${check_WARNING} Your ${check_HUSKY_HOOK_PATH} does not have a savvy-lint managed section.\n Run 'savvy-lint init' to add it.`);
|
|
1145
|
+
}
|
|
1146
|
+
} else warnings.push(`${check_WARNING} No husky pre-commit hook found.\n Run 'savvy-lint init' to create it.`);
|
|
1147
|
+
if (!foundConfig) warnings.push(`${check_WARNING} No lint-staged config file found.\n Run 'savvy-lint init' to create one.`);
|
|
1148
|
+
if (quiet) {
|
|
1149
|
+
if (warnings.length > 0) for (const warning of warnings)yield* Effect.log(warning);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
yield* Effect.log("Checking lint-staged configuration...\n");
|
|
1153
|
+
if (foundConfig) yield* Effect.log(`${check_CHECK_MARK} Config file: ${foundConfig}`);
|
|
1154
|
+
else yield* Effect.log(`${CROSS_MARK} No lint-staged config file found`);
|
|
1155
|
+
if (hasHuskyHook) yield* Effect.log(`${check_CHECK_MARK} Husky hook: ${check_HUSKY_HOOK_PATH}`);
|
|
1156
|
+
else yield* Effect.log(`${CROSS_MARK} No husky pre-commit hook found`);
|
|
1157
|
+
if (hasHuskyHook) if (managedStatus.found) if (managedStatus.isUpToDate) yield* Effect.log(`${check_CHECK_MARK} Managed section: up-to-date`);
|
|
1158
|
+
else yield* Effect.log(`${check_WARNING} Managed section: outdated (run 'savvy-lint init' to update)`);
|
|
1159
|
+
else yield* Effect.log(`${BULLET} Managed section: not found (run 'savvy-lint init' to add)`);
|
|
1160
|
+
yield* Effect.log("\nTool availability:");
|
|
1161
|
+
const biomeAvailable = Biome.isAvailable();
|
|
1162
|
+
const biomeConfig = Biome.findConfig();
|
|
1163
|
+
if (biomeAvailable) {
|
|
1164
|
+
const configInfo = biomeConfig ? ` (config: ${biomeConfig})` : "";
|
|
1165
|
+
yield* Effect.log(` ${check_CHECK_MARK} Biome${configInfo}`);
|
|
1166
|
+
} else yield* Effect.log(` ${BULLET} Biome: not installed`);
|
|
1167
|
+
const markdownAvailable = Markdown.isAvailable();
|
|
1168
|
+
const markdownConfig = Markdown.findConfig();
|
|
1169
|
+
if (markdownAvailable) {
|
|
1170
|
+
const configInfo = markdownConfig ? ` (config: ${markdownConfig})` : "";
|
|
1171
|
+
yield* Effect.log(` ${check_CHECK_MARK} markdownlint-cli2${configInfo}`);
|
|
1172
|
+
} else yield* Effect.log(` ${BULLET} markdownlint-cli2: not installed`);
|
|
1173
|
+
const typescriptAvailable = TypeScript.isAvailable();
|
|
1174
|
+
if (typescriptAvailable) {
|
|
1175
|
+
const compiler = TypeScript.detectCompiler();
|
|
1176
|
+
yield* Effect.log(` ${check_CHECK_MARK} TypeScript (${compiler})`);
|
|
1177
|
+
} else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
|
|
1178
|
+
const tsdocAvailable = TypeScript.isTsdocAvailable();
|
|
1179
|
+
if (tsdocAvailable) yield* Effect.log(` ${check_CHECK_MARK} TSDoc (tsdoc.json found)`);
|
|
1180
|
+
else yield* Effect.log(` ${BULLET} TSDoc: no tsdoc.json found`);
|
|
1181
|
+
yield* Effect.log("");
|
|
1182
|
+
const hasIssues = !foundConfig || !hasHuskyHook || !managedStatus.found || managedStatus.needsUpdate;
|
|
1183
|
+
if (hasIssues) yield* Effect.log(`${check_WARNING} Some issues found. Run 'savvy-lint init' to fix.`);
|
|
1184
|
+
else yield* Effect.log(`${check_CHECK_MARK} Lint-staged is configured correctly.`);
|
|
1185
|
+
})).pipe(Command.withDescription("Check current lint-staged configuration and tool availability"));
|
|
1186
|
+
const rootCommand = Command.make("savvy-lint").pipe(Command.withSubcommands([
|
|
1187
|
+
initCommand,
|
|
1188
|
+
checkCommand
|
|
1189
|
+
]));
|
|
1190
|
+
const cli = Command.run(rootCommand, {
|
|
1191
|
+
name: "savvy-lint",
|
|
1192
|
+
version: "0.2.0"
|
|
1193
|
+
});
|
|
1194
|
+
function runCli() {
|
|
1195
|
+
const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer));
|
|
1196
|
+
NodeRuntime.runMain(main);
|
|
1197
|
+
}
|
|
1198
|
+
export { Biome, Command_Command as Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, TsDocLinter, TsDocResolver, TypeScript, checkCommand, existsSync, initCommand, readFileSync, rootCommand, runCli, writeFileSync };
|