@savvy-web/lint-staged 0.6.5 → 0.7.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/878.js CHANGED
@@ -1,1005 +1,88 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
2
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
- import { Effect } from "effect";
3
+ import { BiomeSchemaSync, BiomeSchemaSyncLive, CheckResult, ConfigDiscovery, ConfigDiscoveryLive, ManagedSection, ManagedSectionLive, SectionDefinition, ShellSectionDefinition, SyncResult, ToolDefinition, ToolDiscovery, ToolDiscoveryLive } from "@savvy-web/silk-effects";
4
+ import { Effect, Layer } from "effect";
5
+ import { PackageManagerDetectorLive, WorkspaceRootLive } from "workspaces-effect";
4
6
  import { isDeepStrictEqual } from "node:util";
5
7
  import { FileSystem } from "@effect/platform";
6
8
  import { applyEdits, modify, parse } from "jsonc-effect";
7
- import { execSync } from "node:child_process";
8
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
- import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
10
- import { findProjectRoot, getWorkspaceInfos } from "workspace-tools";
11
- import { cosmiconfigSync, defaultLoaders } from "cosmiconfig";
12
- import parser from "@typescript-eslint/parser";
13
- import { ESLint } from "eslint";
14
- import eslint_plugin_tsdoc from "eslint-plugin-tsdoc";
15
- import typescript from "typescript";
16
10
  import sort_package_json from "sort-package-json";
17
11
  import { parse as external_yaml_parse, stringify } from "yaml";
12
+ import { execSync } from "node:child_process";
13
+ import { dirname, join, resolve } from "node:path";
18
14
  import { format, resolveConfig } from "prettier";
19
15
  import { lint } from "yaml-lint";
20
- const VALID_COMMAND_PATTERN = /^[\w@/-]+$/;
21
- function validateCommandName(name) {
22
- if (!VALID_COMMAND_PATTERN.test(name)) throw new Error(`Invalid command name: "${name}". Only alphanumeric characters, hyphens, underscores, @ and / are allowed.`);
23
- }
24
- class Command_Command {
25
- static cachedPackageManager = null;
26
- static cachedRoot = null;
27
- static findRoot(cwd = process.cwd()) {
28
- if (null !== Command_Command.cachedRoot) return Command_Command.cachedRoot;
29
- try {
30
- const root = findProjectRoot(cwd);
31
- if (root) {
32
- Command_Command.cachedRoot = root;
33
- return root;
34
- }
35
- } catch {}
36
- Command_Command.cachedRoot = cwd;
37
- return cwd;
38
- }
39
- static detectPackageManager(cwd = Command_Command.findRoot()) {
40
- if (null !== Command_Command.cachedPackageManager) return Command_Command.cachedPackageManager;
41
- const packageJsonPath = join(cwd, "package.json");
42
- if (!existsSync(packageJsonPath)) {
43
- Command_Command.cachedPackageManager = "npm";
44
- return "npm";
45
- }
46
- try {
47
- const content = readFileSync(packageJsonPath, "utf-8");
48
- const pkg = JSON.parse(content);
49
- if (pkg.packageManager) {
50
- const match = pkg.packageManager.match(/^(npm|pnpm|yarn|bun)@/);
51
- if (match) {
52
- Command_Command.cachedPackageManager = match[1];
53
- return Command_Command.cachedPackageManager;
54
- }
55
- }
56
- } catch {}
57
- Command_Command.cachedPackageManager = "npm";
58
- return "npm";
59
- }
60
- static getExecPrefix(packageManager) {
61
- switch(packageManager){
62
- case "pnpm":
63
- return [
64
- "pnpm",
65
- "exec"
66
- ];
67
- case "yarn":
68
- return [
69
- "yarn",
70
- "exec"
71
- ];
72
- case "bun":
73
- return [
74
- "bun",
75
- "x",
76
- "--no-install"
77
- ];
78
- default:
79
- return [
80
- "npx",
81
- "--no"
82
- ];
83
- }
84
- }
85
- static clearCache() {
86
- Command_Command.cachedPackageManager = null;
87
- Command_Command.cachedRoot = null;
88
- }
89
- static isAvailable(command) {
90
- validateCommandName(command);
91
- try {
92
- execSync(`command -v ${command}`, {
93
- stdio: "ignore"
94
- });
95
- return true;
96
- } catch {
97
- return false;
98
- }
99
- }
100
- static findTool(tool) {
101
- validateCommandName(tool);
102
- if (Command_Command.isAvailable(tool)) return {
103
- available: true,
104
- command: tool,
105
- source: "global"
106
- };
107
- const pm = Command_Command.detectPackageManager();
108
- const prefix = Command_Command.getExecPrefix(pm);
109
- const execCmd = [
110
- ...prefix,
111
- tool
112
- ].join(" ");
113
- try {
114
- execSync(`${execCmd} --version`, {
115
- stdio: "ignore"
116
- });
117
- return {
118
- available: true,
119
- command: execCmd,
120
- source: pm
121
- };
122
- } catch {}
123
- return {
124
- available: false,
125
- command: void 0,
126
- source: void 0
127
- };
128
- }
129
- static requireTool(tool, errorMessage) {
130
- const result = Command_Command.findTool(tool);
131
- 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.`);
132
- return result.command;
133
- }
134
- static findSavvyLint() {
135
- const result = Command_Command.findTool("savvy-lint");
136
- if (result.available && result.command) return result.command;
137
- const root = Command_Command.findRoot();
138
- return `node ${root}/dist/dev/bin/savvy-lint.js`;
139
- }
140
- static exec(command) {
141
- return execSync(command, {
142
- encoding: "utf-8"
143
- }).trim();
144
- }
145
- static execSilent(command) {
146
- try {
147
- execSync(command, {
148
- stdio: "ignore"
149
- });
150
- return true;
151
- } catch {
152
- return false;
153
- }
154
- }
155
- }
156
- const TOOL_CONFIGS = {
157
- markdownlint: {
158
- moduleName: "markdownlint-cli2",
159
- libConfigFiles: [
160
- ".markdownlint-cli2.jsonc",
161
- ".markdownlint-cli2.json",
162
- ".markdownlint-cli2.yaml",
163
- ".markdownlint-cli2.cjs",
164
- ".markdownlint.jsonc",
165
- ".markdownlint.json",
166
- ".markdownlint.yaml"
167
- ],
168
- standardPlaces: [
169
- ".markdownlint-cli2.jsonc",
170
- ".markdownlint-cli2.json",
171
- ".markdownlint-cli2.yaml",
172
- ".markdownlint-cli2.cjs",
173
- ".markdownlint.jsonc",
174
- ".markdownlint.json",
175
- ".markdownlint.yaml"
176
- ]
177
- },
178
- biome: {
179
- moduleName: "biome",
180
- libConfigFiles: [
181
- "biome.jsonc",
182
- "biome.json"
183
- ],
184
- standardPlaces: [
185
- "biome.jsonc",
186
- "biome.json"
187
- ]
188
- },
189
- eslint: {
190
- moduleName: "eslint",
191
- libConfigFiles: [
192
- "eslint.config.ts",
193
- "eslint.config.js",
194
- "eslint.config.mjs"
195
- ],
196
- standardPlaces: [
197
- "eslint.config.ts",
198
- "eslint.config.js",
199
- "eslint.config.mjs"
200
- ]
201
- },
202
- prettier: {
203
- moduleName: "prettier",
204
- libConfigFiles: [
205
- ".prettierrc",
206
- ".prettierrc.json",
207
- ".prettierrc.yaml",
208
- ".prettierrc.js",
209
- "prettier.config.js"
210
- ],
211
- standardPlaces: [
212
- ".prettierrc",
213
- ".prettierrc.json",
214
- ".prettierrc.yaml",
215
- ".prettierrc.js",
216
- "prettier.config.js",
217
- "package.json"
218
- ]
219
- },
220
- yamllint: {
221
- moduleName: "yaml-lint",
222
- libConfigFiles: [
223
- ".yaml-lint.json"
224
- ],
225
- standardPlaces: [
226
- ".yaml-lint.json"
227
- ]
228
- }
229
- };
230
- class ConfigSearch {
231
- static libConfigDir = "lib/configs";
232
- static find(tool, options = {}) {
233
- const config = TOOL_CONFIGS[tool];
234
- if (!config) return {
235
- filepath: void 0,
236
- found: false
237
- };
238
- return ConfigSearch.findFile(config.moduleName, {
239
- libConfigFiles: config.libConfigFiles,
240
- standardPlaces: config.standardPlaces,
241
- ...options
242
- });
243
- }
244
- static findFile(moduleName, options = {}) {
245
- const { searchFrom = process.cwd(), stopDir, libConfigFiles = [], standardPlaces = [] } = options;
246
- const loaders = {
247
- ".jsonc": defaultLoaders[".json"],
248
- ".yaml": defaultLoaders[".yaml"],
249
- ".yml": defaultLoaders[".yaml"]
250
- };
251
- const libConfigDir = join(searchFrom, ConfigSearch.libConfigDir);
252
- for (const file of libConfigFiles){
253
- const filepath = join(libConfigDir, file);
254
- if (existsSync(filepath)) return {
255
- filepath,
256
- found: true
257
- };
258
- }
259
- if (0 === standardPlaces.length) return {
260
- filepath: void 0,
261
- found: false
262
- };
263
- try {
264
- const explorer = cosmiconfigSync(moduleName, {
265
- searchPlaces: standardPlaces,
266
- loaders,
267
- ...void 0 !== stopDir && {
268
- stopDir
269
- }
270
- });
271
- const result = explorer.search(searchFrom);
272
- if (result?.filepath) return {
273
- filepath: result.filepath,
274
- found: true
275
- };
276
- } catch {}
277
- return {
278
- filepath: void 0,
279
- found: false
280
- };
281
- }
282
- static exists(filepath) {
283
- return existsSync(filepath);
284
- }
285
- static resolve(filename, fallback) {
286
- const libPath = `${ConfigSearch.libConfigDir}/${filename}`;
287
- if (ConfigSearch.exists(libPath)) return libPath;
288
- return fallback;
289
- }
290
- }
291
- class Filter {
292
- static exclude(filenames, patterns) {
293
- if (0 === patterns.length) return [
294
- ...filenames
295
- ];
296
- return filenames.filter((file)=>!patterns.some((pattern)=>file.includes(pattern)));
297
- }
298
- static include(filenames, patterns) {
299
- if (0 === patterns.length) return [];
300
- return filenames.filter((file)=>patterns.some((pattern)=>file.includes(pattern)));
301
- }
302
- static apply(filenames, options) {
303
- let result = [
304
- ...filenames
305
- ];
306
- if (options.include && options.include.length > 0) result = Filter.include(result, options.include);
307
- if (options.exclude && options.exclude.length > 0) result = Filter.exclude(result, options.exclude);
308
- return result;
309
- }
310
- static shellEscape(filenames) {
311
- return filenames.map((f)=>`'${f.replace(/'/g, "'\\''")}'`).join(" ");
312
- }
313
- }
314
- class Biome {
315
- static glob = "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}";
316
- static defaultExcludes = [
317
- "package.json",
318
- "package-lock.json",
319
- "__fixtures__",
320
- "__test__/fixtures"
321
- ];
322
- static handler = Biome.create();
323
- static findBiome() {
324
- const result = Command_Command.findTool("biome");
325
- return result.command;
326
- }
327
- static isAvailable() {
328
- return Command_Command.findTool("biome").available;
329
- }
330
- static findConfig() {
331
- const result = ConfigSearch.find("biome");
332
- return result.filepath;
333
- }
334
- static create(options = {}) {
335
- const excludes = options.exclude ?? [
336
- ...Biome.defaultExcludes
337
- ];
338
- const config = options.config ?? Biome.findConfig();
339
- return (filenames)=>{
340
- const filtered = Filter.exclude(filenames, excludes);
341
- if (0 === filtered.length) return [];
342
- const biomeCmd = Command_Command.requireTool("biome", "Biome is not available. Install it globally (recommended) or add @biomejs/biome as a dev dependency.");
343
- const files = Filter.shellEscape(filtered);
344
- const flags = options.flags ?? [];
345
- const configFlag = config ? `--config-path=${config}` : "";
346
- const cmd = [
347
- `${biomeCmd} check --write --no-errors-on-unmatched`,
348
- configFlag,
349
- ...flags,
350
- files
351
- ].filter(Boolean).join(" ");
352
- return cmd;
353
- };
354
- }
355
- }
356
- class Markdown {
357
- static glob = "**/*.{md,mdx}";
358
- static defaultExcludes = [];
359
- static handler = Markdown.create();
360
- static findMarkdownlint() {
361
- const result = Command_Command.findTool("markdownlint-cli2");
362
- return result.command;
363
- }
364
- static isAvailable() {
365
- return Command_Command.findTool("markdownlint-cli2").available;
366
- }
367
- static findConfig() {
368
- const result = ConfigSearch.find("markdownlint");
369
- return result.filepath;
370
- }
371
- static create(options = {}) {
372
- const excludes = options.exclude ?? [
373
- ...Markdown.defaultExcludes
374
- ];
375
- const noFix = options.noFix ?? false;
376
- const config = options.config ?? Markdown.findConfig();
377
- return (filenames)=>{
378
- const filtered = Filter.exclude(filenames, excludes);
379
- if (0 === filtered.length) return [];
380
- const mdlintCmd = Command_Command.requireTool("markdownlint-cli2", "markdownlint-cli2 is not available. Install it globally or add it as a dev dependency.");
381
- const files = Filter.shellEscape(filtered);
382
- const fixFlag = noFix ? "" : "--fix";
383
- const configFlag = config ? `--config '${config}'` : "";
384
- const cmd = [
385
- mdlintCmd,
386
- configFlag,
387
- fixFlag,
388
- files
389
- ].filter(Boolean).join(" ");
390
- return cmd;
391
- };
392
- }
393
- }
394
- class TsDocLinter {
395
- eslint;
396
- constructor(options = {}){
397
- const ignorePatterns = options.ignorePatterns ?? [];
398
- const config = [
399
- {
400
- ignores: [
401
- "**/node_modules/**",
402
- "**/dist/**",
403
- "**/coverage/**",
404
- ...ignorePatterns
405
- ]
406
- },
407
- {
408
- files: [
409
- "**/*.ts",
410
- "**/*.tsx",
411
- "**/*.mts",
412
- "**/*.cts"
413
- ],
414
- languageOptions: {
415
- parser: parser
416
- },
417
- plugins: {
418
- tsdoc: eslint_plugin_tsdoc
419
- },
420
- rules: {
421
- "tsdoc/syntax": "error"
422
- }
423
- }
424
- ];
425
- this.eslint = new ESLint({
426
- overrideConfigFile: true,
427
- overrideConfig: config
428
- });
429
- }
430
- async lintFiles(filePaths) {
431
- if (0 === filePaths.length) return [];
432
- const results = await this.eslint.lintFiles(filePaths);
433
- return results.map((result)=>({
434
- filePath: result.filePath,
435
- errorCount: result.errorCount,
436
- warningCount: result.warningCount,
437
- messages: result.messages.map((msg)=>({
438
- line: msg.line,
439
- column: msg.column,
440
- severity: msg.severity,
441
- message: msg.message,
442
- ruleId: msg.ruleId
443
- }))
444
- }));
445
- }
446
- async lintFilesAndThrow(filePaths) {
447
- const results = await this.lintFiles(filePaths);
448
- const errors = [];
449
- for (const result of results)if (result.errorCount > 0) {
450
- for (const msg of result.messages)if (2 === msg.severity) errors.push(`${result.filePath}:${msg.line}:${msg.column} - ${msg.message}`);
451
- }
452
- if (errors.length > 0) throw new Error(`TSDoc validation failed:\n${errors.join("\n")}`);
453
- }
454
- static formatResults(results) {
455
- const lines = [];
456
- for (const result of results)if (0 !== result.errorCount || 0 !== result.warningCount) {
457
- lines.push(`\n${result.filePath}`);
458
- for (const msg of result.messages){
459
- const severity = 2 === msg.severity ? "error" : "warning";
460
- const rule = msg.ruleId ? ` (${msg.ruleId})` : "";
461
- lines.push(` ${msg.line}:${msg.column} ${severity} ${msg.message}${rule}`);
462
- }
463
- }
464
- const totalErrors = results.reduce((sum, r)=>sum + r.errorCount, 0);
465
- const totalWarnings = results.reduce((sum, r)=>sum + r.warningCount, 0);
466
- if (totalErrors > 0 || totalWarnings > 0) lines.push(`\n✖ ${totalErrors} error(s), ${totalWarnings} warning(s)`);
467
- return lines.join("\n");
468
- }
469
- static hasErrors(results) {
470
- return results.some((r)=>r.errorCount > 0);
471
- }
472
- }
473
- const TS_EXTENSIONS = [
474
- ".ts",
475
- ".tsx",
476
- ".mts",
477
- ".cts"
478
- ];
479
- class EntryExtractor {
480
- extract(packageJson) {
481
- const entries = {};
482
- const unresolved = [];
483
- const { exports } = packageJson;
484
- if (!exports) {
485
- const mainEntry = packageJson.module ?? packageJson.main;
486
- if (mainEntry && this.isTypeScriptFile(mainEntry)) entries["."] = mainEntry;
487
- else if (mainEntry) unresolved.push(".");
488
- return {
489
- entries,
490
- unresolved
491
- };
492
- }
493
- if ("string" == typeof exports) {
494
- if (this.isTypeScriptFile(exports)) entries["."] = exports;
495
- else unresolved.push(".");
496
- return {
497
- entries,
498
- unresolved
499
- };
500
- }
501
- this.extractFromObject(exports, entries, unresolved, ".");
502
- return {
503
- entries,
504
- unresolved
505
- };
506
- }
507
- extractFromObject(obj, entries, unresolved, currentPath) {
508
- for (const [key, value] of Object.entries(obj)){
509
- const exportPath = key.startsWith(".") ? key : currentPath;
510
- if ("string" == typeof value) {
511
- if (this.isTypeScriptFile(value)) entries[exportPath] = value;
512
- else if (key.startsWith(".")) unresolved.push(exportPath);
513
- } else if (value && "object" == typeof value && !Array.isArray(value)) {
514
- const nested = value;
515
- const tsPath = this.findTypeScriptCondition(nested);
516
- if (tsPath) entries[exportPath] = tsPath;
517
- else if (key.startsWith(".")) this.extractFromObject(nested, entries, unresolved, exportPath);
518
- else {
519
- const sourcePath = this.findSourceCondition(nested);
520
- if (sourcePath && this.isTypeScriptFile(sourcePath)) entries[exportPath] = sourcePath;
521
- }
522
- }
523
- }
524
- }
525
- findTypeScriptCondition(conditions) {
526
- const priorityKeys = [
527
- "source",
528
- "typescript",
529
- "development",
530
- "default"
531
- ];
532
- for (const key of priorityKeys){
533
- const value = conditions[key];
534
- if ("string" == typeof value && this.isTypeScriptFile(value)) return value;
535
- if (value && "object" == typeof value) {
536
- const nested = this.findTypeScriptCondition(value);
537
- if (nested) return nested;
538
- }
539
- }
540
- return null;
541
- }
542
- findSourceCondition(conditions) {
543
- const priorityKeys = [
544
- "source",
545
- "import",
546
- "require",
547
- "default"
548
- ];
549
- for (const key of priorityKeys){
550
- const value = conditions[key];
551
- if ("string" == typeof value) return value;
552
- if (value && "object" == typeof value) {
553
- const nested = this.findSourceCondition(value);
554
- if (nested) return nested;
555
- }
556
- }
557
- return null;
558
- }
559
- isTypeScriptFile(filePath) {
560
- return TS_EXTENSIONS.some((ext)=>filePath.endsWith(ext));
561
- }
562
- }
563
- class ImportGraph {
564
- options;
565
- program = null;
566
- compilerOptions = null;
567
- moduleResolutionCache = null;
568
- constructor(options){
569
- this.options = options;
570
- }
571
- traceFromEntries(entryPaths) {
572
- const errors = [];
573
- const visited = new Set();
574
- const entries = [];
575
- const initResult = this.initializeProgram();
576
- if (!initResult.success) return {
577
- files: [],
578
- entries: [],
579
- errors: [
580
- initResult.error
581
- ]
582
- };
583
- for (const entryPath of entryPaths){
584
- const absolutePath = this.resolveEntryPath(entryPath);
585
- if (!existsSync(absolutePath)) {
586
- errors.push({
587
- type: "entry_not_found",
588
- message: `Entry file not found: ${entryPath}`,
589
- path: absolutePath
590
- });
591
- continue;
592
- }
593
- entries.push(absolutePath);
594
- this.traceImports(absolutePath, visited, errors);
595
- }
596
- const files = Array.from(visited).filter((file)=>this.isSourceFile(file));
597
- return {
598
- files: files.sort(),
599
- entries,
600
- errors
601
- };
602
- }
603
- traceFromPackageExports(packageJsonPath) {
604
- const absolutePath = this.resolveEntryPath(packageJsonPath);
605
- let packageJson;
606
- try {
607
- if (!existsSync(absolutePath)) return {
608
- files: [],
609
- entries: [],
610
- errors: [
611
- {
612
- type: "package_json_not_found",
613
- message: `Failed to read package.json: File not found at ${absolutePath}`,
614
- path: absolutePath
615
- }
616
- ]
617
- };
618
- const content = readFileSync(absolutePath, "utf-8");
619
- packageJson = JSON.parse(content);
620
- } catch (error) {
621
- const message = error instanceof Error ? error.message : String(error);
622
- return {
623
- files: [],
624
- entries: [],
625
- errors: [
626
- {
627
- type: "package_json_parse_error",
628
- message: `Failed to parse package.json: ${message}`,
629
- path: absolutePath
630
- }
631
- ]
632
- };
633
- }
634
- const extractor = new EntryExtractor();
635
- const { entries } = extractor.extract(packageJson);
636
- const packageDir = dirname(absolutePath);
637
- const entryPaths = Object.values(entries).map((p)=>resolve(packageDir, p));
638
- return this.traceFromEntries(entryPaths);
639
- }
640
- initializeProgram() {
641
- if (this.program) return {
642
- success: true
643
- };
644
- const configPath = this.findTsConfig();
645
- if (!configPath) {
646
- this.compilerOptions = {
647
- moduleResolution: typescript.ModuleResolutionKind.NodeNext,
648
- module: typescript.ModuleKind.NodeNext,
649
- target: typescript.ScriptTarget.ESNext,
650
- strict: true
651
- };
652
- this.moduleResolutionCache = typescript.createModuleResolutionCache(this.options.rootDir, (fileName)=>fileName.toLowerCase(), this.compilerOptions);
653
- const host = typescript.createCompilerHost(this.compilerOptions, true);
654
- host.getCurrentDirectory = ()=>this.options.rootDir;
655
- this.program = typescript.createProgram([], this.compilerOptions, host);
656
- return {
657
- success: true
658
- };
659
- }
660
- const configFile = typescript.readConfigFile(configPath, (path)=>readFileSync(path, "utf-8"));
661
- if (configFile.error) {
662
- const message = typescript.flattenDiagnosticMessageText(configFile.error.messageText, "\n");
663
- return {
664
- success: false,
665
- error: {
666
- type: "tsconfig_read_error",
667
- message: `Failed to read tsconfig.json: ${message}`,
668
- path: configPath
669
- }
670
- };
671
- }
672
- const parsed = typescript.parseJsonConfigFileContent(configFile.config, typescript.sys, dirname(configPath));
673
- if (parsed.errors.length > 0) {
674
- const messages = parsed.errors.map((e)=>typescript.flattenDiagnosticMessageText(e.messageText, "\n")).join("\n");
675
- return {
676
- success: false,
677
- error: {
678
- type: "tsconfig_parse_error",
679
- message: `Failed to parse tsconfig.json: ${messages}`,
680
- path: configPath
681
- }
682
- };
683
- }
684
- this.compilerOptions = parsed.options;
685
- this.moduleResolutionCache = typescript.createModuleResolutionCache(this.options.rootDir, (fileName)=>fileName.toLowerCase(), this.compilerOptions);
686
- const host = typescript.createCompilerHost(this.compilerOptions, true);
687
- host.getCurrentDirectory = ()=>this.options.rootDir;
688
- this.program = typescript.createProgram([], this.compilerOptions, host);
689
- return {
690
- success: true
691
- };
692
- }
693
- findTsConfig() {
694
- if (this.options.tsconfigPath) {
695
- const customPath = isAbsolute(this.options.tsconfigPath) ? this.options.tsconfigPath : resolve(this.options.rootDir, this.options.tsconfigPath);
696
- if (existsSync(customPath)) return customPath;
697
- return null;
698
- }
699
- const configPath = typescript.findConfigFile(this.options.rootDir, (path)=>existsSync(path));
700
- return configPath ?? null;
701
- }
702
- resolveEntryPath(entryPath) {
703
- if (isAbsolute(entryPath)) return normalize(entryPath);
704
- return normalize(resolve(this.options.rootDir, entryPath));
705
- }
706
- traceImports(filePath, visited, errors) {
707
- const normalizedPath = normalize(filePath);
708
- if (visited.has(normalizedPath)) return;
709
- if (this.isExternalModule(normalizedPath)) return;
710
- visited.add(normalizedPath);
711
- let content;
712
- try {
713
- content = readFileSync(normalizedPath, "utf-8");
714
- } catch {
715
- errors.push({
716
- type: "file_read_error",
717
- message: `Failed to read file: ${normalizedPath}`,
718
- path: normalizedPath
719
- });
720
- return;
721
- }
722
- const sourceFile = typescript.createSourceFile(normalizedPath, content, typescript.ScriptTarget.Latest, true);
723
- const imports = this.extractImports(sourceFile);
724
- for (const importPath of imports){
725
- const resolved = this.resolveImport(importPath, normalizedPath);
726
- if (resolved) this.traceImports(resolved, visited, errors);
727
- }
728
- }
729
- extractImports(sourceFile) {
730
- const imports = [];
731
- const visit = (node)=>{
732
- if (typescript.isImportDeclaration(node)) {
733
- const specifier = node.moduleSpecifier;
734
- if (typescript.isStringLiteral(specifier)) imports.push(specifier.text);
735
- } else if (typescript.isExportDeclaration(node)) {
736
- const specifier = node.moduleSpecifier;
737
- if (specifier && typescript.isStringLiteral(specifier)) imports.push(specifier.text);
738
- } else if (typescript.isCallExpression(node)) {
739
- const expression = node.expression;
740
- if (expression.kind === typescript.SyntaxKind.ImportKeyword && node.arguments.length > 0) {
741
- const arg = node.arguments[0];
742
- if (arg && typescript.isStringLiteral(arg)) imports.push(arg.text);
743
- }
744
- }
745
- typescript.forEachChild(node, visit);
746
- };
747
- visit(sourceFile);
748
- return imports;
749
- }
750
- resolveImport(specifier, fromFile) {
751
- if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
752
- if (!this.compilerOptions?.paths || !Object.keys(this.compilerOptions.paths).length) return null;
753
- }
754
- if (!this.compilerOptions || !this.moduleResolutionCache) return null;
755
- const resolved = typescript.resolveModuleName(specifier, fromFile, this.compilerOptions, typescript.sys, this.moduleResolutionCache);
756
- if (resolved.resolvedModule) {
757
- const resolvedPath = resolved.resolvedModule.resolvedFileName;
758
- if (resolved.resolvedModule.isExternalLibraryImport) return null;
759
- if (resolvedPath.endsWith(".d.ts")) {
760
- const sourcePath = resolvedPath.replace(/\.d\.ts$/, ".ts");
761
- if (existsSync(sourcePath)) return sourcePath;
762
- return null;
763
- }
764
- return resolvedPath;
765
- }
766
- return null;
767
- }
768
- isExternalModule(filePath) {
769
- return filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\");
770
- }
771
- isSourceFile(filePath) {
772
- if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx") && !filePath.endsWith(".mts") && !filePath.endsWith(".cts")) return false;
773
- if (filePath.endsWith(".d.ts") || filePath.endsWith(".d.mts") || filePath.endsWith(".d.cts")) return false;
774
- if (filePath.includes(".test.") || filePath.includes(".spec.")) return false;
775
- if (filePath.includes("/__test__/") || filePath.includes("\\__test__\\")) return false;
776
- if (filePath.includes("/__tests__/") || filePath.includes("\\__tests__\\")) return false;
777
- const excludePatterns = this.options.excludePatterns ?? [];
778
- for (const pattern of excludePatterns)if (filePath.includes(pattern)) return false;
779
- return true;
780
- }
781
- static fromEntries(entryPaths, options) {
782
- const graph = new ImportGraph(options);
783
- return graph.traceFromEntries(entryPaths);
784
- }
785
- static fromPackageExports(packageJsonPath, options) {
786
- const graph = new ImportGraph(options);
787
- return graph.traceFromPackageExports(packageJsonPath);
788
- }
789
- }
790
- class TsDocResolver {
791
- options;
792
- constructor(options){
793
- this.options = options;
794
- }
795
- resolve() {
796
- const { rootDir } = this.options;
797
- const workspaces = [];
798
- const repoTsdocPath = join(rootDir, "tsdoc.json");
799
- const repoTsdocConfig = existsSync(repoTsdocPath) ? repoTsdocPath : void 0;
800
- const workspaceInfos = getWorkspaceInfos(rootDir);
801
- const isMonorepo = void 0 !== workspaceInfos && workspaceInfos.length > 1;
802
- if (void 0 === workspaceInfos || 0 === workspaceInfos.length) {
803
- const result = this.resolveWorkspace(rootDir, repoTsdocConfig);
804
- if (result) workspaces.push(result);
805
- } else for (const info of workspaceInfos){
806
- const workspacePath = info.path;
807
- const result = this.resolveWorkspace(workspacePath, repoTsdocConfig);
808
- if (result) workspaces.push(result);
809
- }
810
- const result = {
811
- workspaces,
812
- isMonorepo
813
- };
814
- if (void 0 !== repoTsdocConfig) result.repoTsdocConfig = repoTsdocConfig;
815
- return result;
816
- }
817
- resolveWorkspace(workspacePath, repoTsdocConfig) {
818
- const packageJsonPath = join(workspacePath, "package.json");
819
- if (!existsSync(packageJsonPath)) return null;
820
- let packageJson;
821
- try {
822
- const content = readFileSync(packageJsonPath, "utf-8");
823
- packageJson = JSON.parse(content);
824
- } catch {
825
- return null;
826
- }
827
- const workspaceTsdocPath = join(workspacePath, "tsdoc.json");
828
- const workspaceTsdocConfig = existsSync(workspaceTsdocPath) ? workspaceTsdocPath : void 0;
829
- const tsdocConfigPath = workspaceTsdocConfig ?? repoTsdocConfig;
830
- if (!tsdocConfigPath) return null;
831
- if (!packageJson.exports) return null;
832
- const name = packageJson.name ?? relative(this.options.rootDir, workspacePath);
833
- const errors = [];
834
- const graphOptions = {
835
- rootDir: workspacePath
836
- };
837
- if (void 0 !== this.options.excludePatterns) graphOptions.excludePatterns = this.options.excludePatterns;
838
- const graph = new ImportGraph(graphOptions);
839
- const result = graph.traceFromPackageExports(packageJsonPath);
840
- for (const error of result.errors)errors.push(error.message);
841
- return {
842
- name,
843
- path: workspacePath,
844
- tsdocConfigPath,
845
- files: result.files,
846
- errors
847
- };
848
- }
849
- filterStagedFiles(stagedFiles) {
850
- const result = this.resolve();
851
- const output = [];
852
- for (const workspace of result.workspaces){
853
- const workspaceFiles = new Set(workspace.files);
854
- const matchedFiles = stagedFiles.filter((f)=>workspaceFiles.has(f));
855
- if (matchedFiles.length > 0) output.push({
856
- files: matchedFiles,
857
- tsdocConfigPath: workspace.tsdocConfigPath
858
- });
859
- }
860
- return output;
861
- }
862
- needsLinting(filePath) {
863
- const result = this.resolve();
864
- for (const workspace of result.workspaces)if (workspace.files.includes(filePath)) return true;
865
- return false;
866
- }
867
- getTsDocConfig(filePath) {
868
- const result = this.resolve();
869
- for (const workspace of result.workspaces)if (workspace.files.includes(filePath)) return workspace.tsdocConfigPath;
870
- }
871
- findWorkspace(filePath) {
872
- const result = this.resolve();
873
- for (const workspace of result.workspaces)if (filePath.startsWith(workspace.path)) return workspace;
874
- }
875
- }
876
- class TypeScript {
877
- static glob = "*.{ts,cts,mts,tsx}";
878
- static defaultExcludes = [];
879
- static defaultTsdocExcludes = [
880
- ".test.",
881
- ".spec.",
882
- "__test__",
883
- "__tests__"
884
- ];
885
- static cachedCompilerResult = null;
886
- static detectCompiler(_cwd) {
887
- if (null !== TypeScript.cachedCompilerResult) return TypeScript.cachedCompilerResult.compiler;
888
- const tsgo = Command_Command.findTool("tsgo");
889
- if (tsgo.available) {
890
- TypeScript.cachedCompilerResult = {
891
- compiler: "tsgo",
892
- tool: tsgo
893
- };
894
- return "tsgo";
895
- }
896
- const tsc = Command_Command.findTool("tsc");
897
- if (tsc.available) {
898
- TypeScript.cachedCompilerResult = {
899
- compiler: "tsc",
900
- tool: tsc
901
- };
902
- return "tsc";
903
- }
904
- }
905
- static isAvailable() {
906
- return void 0 !== TypeScript.detectCompiler();
907
- }
908
- static getDefaultTypecheckCommand() {
909
- const compiler = TypeScript.detectCompiler();
910
- if (!compiler || !TypeScript.cachedCompilerResult) throw new Error("No TypeScript compiler found. Install 'typescript' or '@typescript/native-preview' as a dev dependency.");
911
- return `${TypeScript.cachedCompilerResult.tool.command} --noEmit`;
912
- }
913
- static clearCache() {
914
- TypeScript.cachedCompilerResult = null;
915
- }
916
- static handler = TypeScript.create();
917
- static isTsdocAvailable(cwd = Command_Command.findRoot()) {
918
- const tsdocPath = join(cwd, "tsdoc.json");
919
- return existsSync(tsdocPath);
920
- }
921
- static create(options = {}) {
922
- const excludes = options.exclude ?? [
923
- ...TypeScript.defaultExcludes
924
- ];
925
- const tsdocExcludes = options.excludeTsdoc ?? [
926
- ...TypeScript.defaultTsdocExcludes
927
- ];
928
- const skipTsdoc = options.skipTsdoc ?? false;
929
- const skipTypecheck = options.skipTypecheck ?? false;
930
- const rootDir = options.rootDir ?? Command_Command.findRoot();
931
- let typecheckCommand;
932
- const getTypecheckCommand = ()=>{
933
- if (void 0 === typecheckCommand) typecheckCommand = options.typecheckCommand ?? TypeScript.getDefaultTypecheckCommand();
934
- return typecheckCommand;
935
- };
936
- return async (filenames)=>{
937
- const filtered = Filter.exclude(filenames, excludes);
938
- if (0 === filtered.length) return [];
939
- const commands = [];
940
- if (!skipTsdoc) {
941
- const resolver = new TsDocResolver({
942
- rootDir,
943
- excludePatterns: [
944
- ...tsdocExcludes
945
- ]
946
- });
947
- const absoluteFiles = filtered.map((f)=>isAbsolute(f) ? f : join(rootDir, f));
948
- const tsdocGroups = resolver.filterStagedFiles(absoluteFiles);
949
- for (const group of tsdocGroups)if (group.files.length > 0) {
950
- const linter = new TsDocLinter({
951
- ignorePatterns: tsdocExcludes.map((p)=>`**/*${p}*`)
952
- });
953
- const results = await linter.lintFiles(group.files);
954
- if (TsDocLinter.hasErrors(results)) {
955
- const output = TsDocLinter.formatResults(results);
956
- throw new Error(`TSDoc validation failed:\n${output}`);
957
- }
958
- }
959
- }
960
- if (!skipTypecheck && filtered.length > 0) commands.push(getTypecheckCommand());
961
- return commands;
962
- };
963
- }
964
- }
965
- const SCHEMA_URL_PREFIX = "https://biomejs.dev/schemas/";
966
- const SCHEMA_URL_SUFFIX = "/schema.json";
967
- const BIOME_GLOB_PATTERN = "**/biome.{json,jsonc}";
968
- const BIOME_EXCLUDE_DIRS = [
969
- "node_modules",
970
- "dist",
971
- ".turbo",
972
- ".git",
973
- ".rslib"
974
- ];
975
- function extractSemver(versionRange) {
976
- return versionRange.replace(/^[^\d]*/, "");
977
- }
978
- function buildSchemaUrl(version) {
979
- return `${SCHEMA_URL_PREFIX}${version}${SCHEMA_URL_SUFFIX}`;
980
- }
981
- function getBiomePeerVersion() {
982
- const raw = "catalog:silk";
983
- if (!raw) return;
984
- return extractSemver(raw);
16
+ const HUSKY_HOOK_PATH = ".husky/pre-commit";
17
+ const POST_CHECKOUT_HOOK_PATH = ".husky/post-checkout";
18
+ const POST_MERGE_HOOK_PATH = ".husky/post-merge";
19
+ const DEFAULT_CONFIG_PATH = "lib/configs/lint-staged.config.ts";
20
+ const MARKDOWNLINT_CONFIG_PATH = "lib/configs/.markdownlint-cli2.jsonc";
21
+ const SavvyLintSection = ShellSectionDefinition.make({
22
+ toolName: "SAVVY-LINT"
23
+ });
24
+ const SavvyLintSectionDef = SectionDefinition.make({
25
+ toolName: "SAVVY-LINT"
26
+ });
27
+ function generateManagedContent(configPath) {
28
+ return `# DO NOT EDIT between these markers - managed by savvy-lint
29
+ # Skip in CI environment
30
+ if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
31
+
32
+ # Get repo root directory
33
+ ROOT=$(git rev-parse --show-toplevel)
34
+
35
+ # Detect package manager from package.json or lockfiles
36
+ detect_pm() {
37
+ # Check packageManager field in package.json (e.g., "pnpm@9.0.0")
38
+ if [ -f "$ROOT/package.json" ]; then
39
+ pm=$(jq -r '.packageManager // empty' "$ROOT/package.json" 2>/dev/null | cut -d'@' -f1)
40
+ if [ -n "$pm" ]; then
41
+ echo "$pm"
42
+ return
43
+ fi
44
+ fi
45
+
46
+ # Fallback to lockfile detection
47
+ if [ -f "$ROOT/pnpm-lock.yaml" ]; then
48
+ echo "pnpm"
49
+ elif [ -f "$ROOT/yarn.lock" ]; then
50
+ echo "yarn"
51
+ elif [ -f "$ROOT/bun.lock" ]; then
52
+ echo "bun"
53
+ else
54
+ echo "npm"
55
+ fi
985
56
  }
986
- function getExpectedSchemaUrl() {
987
- const version = getBiomePeerVersion();
988
- if (!version) return;
989
- return buildSchemaUrl(version);
57
+
58
+ # Run lint-staged with the detected package manager
59
+ PM=$(detect_pm)
60
+ case "$PM" in
61
+ pnpm) pnpm exec lint-staged --config "$ROOT/${configPath}" ;;
62
+ yarn) yarn exec lint-staged --config "$ROOT/${configPath}" ;;
63
+ bun) bunx lint-staged --config "$ROOT/${configPath}" ;;
64
+ *) npx --no -- lint-staged --config "$ROOT/${configPath}" ;;
65
+ esac
66
+
67
+ fi`;
990
68
  }
991
- function findBiomeConfigs() {
992
- return Effect.tryPromise(async ()=>{
993
- const { glob } = await import("node:fs/promises");
994
- const paths = [];
995
- for await (const entry of glob(BIOME_GLOB_PATTERN, {
996
- exclude: (name)=>BIOME_EXCLUDE_DIRS.includes(name)
997
- }))paths.push(entry);
998
- return paths;
999
- });
69
+ function generateShellScriptsManagedContent() {
70
+ return `# DO NOT EDIT between these markers - managed by savvy-lint
71
+ if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
72
+
73
+ # Configure git to ignore executable bit changes
74
+ # This ensures hook scripts can be made executable locally without git tracking the change
75
+ git config core.fileMode false
76
+
77
+ # Ensure all shell scripts tracked by git are executable
78
+ git ls-files -z '*.sh' | xargs -0 -r chmod +x 2>/dev/null || true
79
+
80
+ fi`;
1000
81
  }
82
+ const preCommitBlock = SavvyLintSection.generate(generateManagedContent);
83
+ const shellScriptsBlock = ()=>SavvyLintSection.block(generateShellScriptsManagedContent());
1001
84
  const MARKDOWNLINT_TEMPLATE = {
1002
- $schema: "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.21.0/schema/markdownlint-cli2-config-schema.json",
85
+ $schema: "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.22.0/schema/markdownlint-cli2-config-schema.json",
1003
86
  globs: [
1004
87
  "**/*.{md,mdx}"
1005
88
  ],
@@ -1101,7 +184,7 @@ const MARKDOWNLINT_TEMPLATE = {
1101
184
  "changeset-dependency-table-format": false
1102
185
  }
1103
186
  };
1104
- const MARKDOWNLINT_SCHEMA = "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.21.0/schema/markdownlint-cli2-config-schema.json";
187
+ const MARKDOWNLINT_SCHEMA = "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.22.0/schema/markdownlint-cli2-config-schema.json";
1105
188
  const MARKDOWNLINT_CONFIG = {
1106
189
  default: true,
1107
190
  MD001: true,
@@ -1184,263 +267,9 @@ const MARKDOWNLINT_CONFIG = {
1184
267
  "changeset-dependency-table-format": false
1185
268
  };
1186
269
  const CHECK_MARK = "\u2713";
1187
- const WARNING = "\u26A0";
1188
- const EXECUTABLE_MODE = 493;
1189
- const HUSKY_HOOK_PATH = ".husky/pre-commit";
1190
- const POST_CHECKOUT_HOOK_PATH = ".husky/post-checkout";
1191
- const POST_MERGE_HOOK_PATH = ".husky/post-merge";
1192
- const DEFAULT_CONFIG_PATH = "lib/configs/lint-staged.config.ts";
1193
- const MARKDOWNLINT_CONFIG_PATH = "lib/configs/.markdownlint-cli2.jsonc";
1194
- const JSONC_FORMAT = {
1195
- tabSize: 1,
1196
- insertSpaces: false
1197
- };
1198
- const BEGIN_MARKER = "# --- BEGIN SAVVY-LINT MANAGED SECTION ---";
1199
- const END_MARKER = "# --- END SAVVY-LINT MANAGED SECTION ---";
1200
- function presetIncludesShellScripts(preset) {
1201
- return "minimal" !== preset;
1202
- }
1203
- function presetIncludesMarkdown(preset) {
1204
- return "minimal" !== preset;
1205
- }
1206
- function generateManagedContent(configPath) {
1207
- return `# DO NOT EDIT between these markers - managed by savvy-lint
1208
- # Skip in CI environment
1209
- if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
1210
-
1211
- # Get repo root directory
1212
- ROOT=$(git rev-parse --show-toplevel)
1213
-
1214
- # Detect package manager from package.json or lockfiles
1215
- detect_pm() {
1216
- # Check packageManager field in package.json (e.g., "pnpm@9.0.0")
1217
- if [ -f "$ROOT/package.json" ]; then
1218
- pm=$(jq -r '.packageManager // empty' "$ROOT/package.json" 2>/dev/null | cut -d'@' -f1)
1219
- if [ -n "$pm" ]; then
1220
- echo "$pm"
1221
- return
1222
- fi
1223
- fi
1224
-
1225
- # Fallback to lockfile detection
1226
- if [ -f "$ROOT/pnpm-lock.yaml" ]; then
1227
- echo "pnpm"
1228
- elif [ -f "$ROOT/yarn.lock" ]; then
1229
- echo "yarn"
1230
- elif [ -f "$ROOT/bun.lock" ]; then
1231
- echo "bun"
1232
- else
1233
- echo "npm"
1234
- fi
1235
- }
1236
-
1237
- # Run lint-staged with the detected package manager
1238
- PM=$(detect_pm)
1239
- case "$PM" in
1240
- pnpm) pnpm exec lint-staged --config "$ROOT/${configPath}" ;;
1241
- yarn) yarn exec lint-staged --config "$ROOT/${configPath}" ;;
1242
- bun) bunx lint-staged --config "$ROOT/${configPath}" ;;
1243
- *) npx --no -- lint-staged --config "$ROOT/${configPath}" ;;
1244
- esac
1245
-
1246
- fi`;
1247
- }
1248
- function generateShellScriptsManagedContent() {
1249
- return `# DO NOT EDIT between these markers - managed by savvy-lint
1250
- if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
1251
-
1252
- # Configure git to ignore executable bit changes
1253
- # This ensures hook scripts can be made executable locally without git tracking the change
1254
- git config core.fileMode false
1255
-
1256
- # Ensure all shell scripts tracked by git are executable
1257
- git ls-files -z '*.sh' | xargs -0 -r chmod +x 2>/dev/null || true
1258
-
1259
- fi`;
1260
- }
1261
- function updateManagedSectionWithContent(existingContent, managedContent) {
1262
- const { beforeSection, afterSection, found } = extractManagedSection(existingContent);
1263
- const newManagedSection = `${BEGIN_MARKER}\n${managedContent}\n${END_MARKER}`;
1264
- if (found) return `${beforeSection}${newManagedSection}${afterSection}`;
1265
- const trimmedContent = existingContent.trimEnd();
1266
- return `${trimmedContent}\n\n${newManagedSection}\n`;
1267
- }
1268
- function extractManagedSection(content) {
1269
- const beginIndex = content.indexOf(BEGIN_MARKER);
1270
- const endIndex = content.indexOf(END_MARKER);
1271
- if (-1 === beginIndex || -1 === endIndex || endIndex <= beginIndex) return {
1272
- beforeSection: content,
1273
- managedSection: "",
1274
- afterSection: "",
1275
- found: false
1276
- };
1277
- return {
1278
- beforeSection: content.slice(0, beginIndex),
1279
- managedSection: content.slice(beginIndex, endIndex + END_MARKER.length),
1280
- afterSection: content.slice(endIndex + END_MARKER.length),
1281
- found: true
1282
- };
1283
- }
1284
- function generateFullHookContentFromManaged(comment, managedContent) {
1285
- return `#!/usr/bin/env sh
1286
- # ${comment}
1287
- # Custom hooks can go above or below the managed section
1288
-
1289
- ${BEGIN_MARKER}
1290
- ${managedContent}
1291
- ${END_MARKER}
1292
- `;
1293
- }
1294
- function generateConfigContent(preset) {
1295
- return `/**
1296
- * lint-staged configuration
1297
- * Generated by savvy-lint init
1298
- */
1299
- import { Preset } from "@savvy-web/lint-staged";
1300
-
1301
- export default Preset.${preset}();
1302
- `;
1303
- }
1304
- function writeMarkdownlintConfig(fs, preset, force) {
1305
- return Effect.gen(function*() {
1306
- const configExists = yield* fs.exists(MARKDOWNLINT_CONFIG_PATH);
1307
- const fullTemplate = JSON.stringify(MARKDOWNLINT_TEMPLATE, null, "\t");
1308
- if (!configExists) {
1309
- yield* fs.makeDirectory("lib/configs", {
1310
- recursive: true
1311
- });
1312
- yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
1313
- yield* Effect.log(`${CHECK_MARK} Created ${MARKDOWNLINT_CONFIG_PATH}`);
1314
- return;
1315
- }
1316
- if ("silk" !== preset) return void (yield* Effect.log(`${CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`));
1317
- if (force) {
1318
- yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
1319
- yield* Effect.log(`${CHECK_MARK} Replaced ${MARKDOWNLINT_CONFIG_PATH} (--force)`);
1320
- return;
1321
- }
1322
- const existingText = yield* fs.readFileString(MARKDOWNLINT_CONFIG_PATH);
1323
- const existingParsed = yield* parse(existingText);
1324
- let updatedText = existingText;
1325
- let schemaUpdated = false;
1326
- if (existingParsed.$schema !== MARKDOWNLINT_SCHEMA) {
1327
- const edits = yield* modify(updatedText, [
1328
- "$schema"
1329
- ], MARKDOWNLINT_SCHEMA, {
1330
- formattingOptions: JSONC_FORMAT
1331
- });
1332
- updatedText = yield* applyEdits(updatedText, edits);
1333
- schemaUpdated = true;
1334
- }
1335
- const existingConfig = existingParsed.config;
1336
- const configMatches = void 0 !== existingConfig && isDeepStrictEqual(existingConfig, MARKDOWNLINT_CONFIG);
1337
- if (!configMatches) {
1338
- yield* Effect.log(`${WARNING} ${MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
1339
- if (schemaUpdated) {
1340
- yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, updatedText);
1341
- yield* Effect.log(`${CHECK_MARK} Updated $schema in ${MARKDOWNLINT_CONFIG_PATH}`);
1342
- }
1343
- return;
1344
- }
1345
- if (schemaUpdated) {
1346
- yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, updatedText);
1347
- yield* Effect.log(`${CHECK_MARK} Updated $schema in ${MARKDOWNLINT_CONFIG_PATH}`);
1348
- } else yield* Effect.log(`${CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
1349
- });
1350
- }
1351
- function syncBiomeSchemas(fs) {
1352
- return Effect.gen(function*() {
1353
- const expectedUrl = getExpectedSchemaUrl();
1354
- if (!expectedUrl) return;
1355
- const configs = yield* findBiomeConfigs();
1356
- for (const configPath of configs){
1357
- const content = yield* fs.readFileString(configPath);
1358
- const parsed = yield* parse(content);
1359
- if ("string" != typeof parsed.$schema) continue;
1360
- if (!parsed.$schema.startsWith(SCHEMA_URL_PREFIX)) continue;
1361
- if (parsed.$schema === expectedUrl) {
1362
- yield* Effect.log(`${CHECK_MARK} ${configPath}: biome $schema up-to-date`);
1363
- continue;
1364
- }
1365
- const edits = yield* modify(content, [
1366
- "$schema"
1367
- ], expectedUrl, {
1368
- formattingOptions: JSONC_FORMAT
1369
- });
1370
- const updated = yield* applyEdits(content, edits);
1371
- yield* fs.writeFileString(configPath, updated);
1372
- yield* Effect.log(`${CHECK_MARK} Updated $schema in ${configPath}`);
1373
- }
1374
- });
1375
- }
1376
- const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite entire hook file (not just managed section)"), Options.withDefault(false));
1377
- 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));
1378
- const presetOption = Options.choice("preset", [
1379
- "minimal",
1380
- "standard",
1381
- "silk"
1382
- ]).pipe(Options.withAlias("p"), Options.withDescription("Preset to use: minimal, standard, or silk"), Options.withDefault("silk"));
1383
- function makeExecutable(path) {
1384
- return Effect.tryPromise(()=>import("node:fs/promises").then((fs)=>fs.chmod(path, EXECUTABLE_MODE)));
1385
- }
1386
- function writeHook(fs, hookPath, managedContent, comment, force) {
1387
- return Effect.gen(function*() {
1388
- const hookExists = yield* fs.exists(hookPath);
1389
- if (hookExists && !force) {
1390
- const existingContent = yield* fs.readFileString(hookPath);
1391
- const { found } = extractManagedSection(existingContent);
1392
- const updatedContent = updateManagedSectionWithContent(existingContent, managedContent);
1393
- yield* fs.writeFileString(hookPath, updatedContent);
1394
- yield* makeExecutable(hookPath);
1395
- if (found) yield* Effect.log(`${CHECK_MARK} Updated managed section in ${hookPath}`);
1396
- else yield* Effect.log(`${CHECK_MARK} Added managed section to ${hookPath}`);
1397
- } else if (hookExists && force) {
1398
- yield* fs.writeFileString(hookPath, generateFullHookContentFromManaged(comment, managedContent));
1399
- yield* makeExecutable(hookPath);
1400
- yield* Effect.log(`${CHECK_MARK} Replaced ${hookPath} (--force)`);
1401
- } else {
1402
- yield* fs.makeDirectory(".husky", {
1403
- recursive: true
1404
- });
1405
- yield* fs.writeFileString(hookPath, generateFullHookContentFromManaged(comment, managedContent));
1406
- yield* makeExecutable(hookPath);
1407
- yield* Effect.log(`${CHECK_MARK} Created ${hookPath}`);
1408
- }
1409
- });
1410
- }
1411
- const initCommand = Command.make("init", {
1412
- force: forceOption,
1413
- config: configOption,
1414
- preset: presetOption
1415
- }, ({ force, config, preset })=>Effect.gen(function*() {
1416
- const fs = yield* FileSystem.FileSystem;
1417
- if (config.startsWith("/")) yield* Effect.fail(new Error("Config path must be relative to repository root, not absolute"));
1418
- yield* Effect.log("Initializing lint-staged configuration...\n");
1419
- yield* writeHook(fs, HUSKY_HOOK_PATH, generateManagedContent(config), "Pre-commit hook with savvy-lint managed section", force);
1420
- if (presetIncludesShellScripts(preset)) {
1421
- const shellContent = generateShellScriptsManagedContent();
1422
- yield* writeHook(fs, POST_CHECKOUT_HOOK_PATH, shellContent, "Post-checkout hook with savvy-lint managed section", force);
1423
- yield* writeHook(fs, POST_MERGE_HOOK_PATH, shellContent, "Post-merge hook with savvy-lint managed section", force);
1424
- }
1425
- if (presetIncludesMarkdown(preset)) yield* writeMarkdownlintConfig(fs, preset, force);
1426
- yield* syncBiomeSchemas(fs);
1427
- const configExists = yield* fs.exists(config);
1428
- if (configExists && !force) yield* Effect.log(`${WARNING} ${config} already exists (use --force to overwrite)`);
1429
- else {
1430
- const configDir = dirname(config);
1431
- if (configDir && "." !== configDir) yield* fs.makeDirectory(configDir, {
1432
- recursive: true
1433
- });
1434
- yield* fs.writeFileString(config, generateConfigContent(preset));
1435
- yield* Effect.log(`${CHECK_MARK} Created ${config} (preset: ${preset})`);
1436
- }
1437
- yield* Effect.log("\nDone! Lint-staged is ready to use.");
1438
- })).pipe(Command.withDescription("Initialize lint-staged configuration and husky hooks"));
1439
- const check_CHECK_MARK = "\u2713";
1440
270
  const CROSS_MARK = "\u2717";
1441
- const check_WARNING = "\u26A0";
271
+ const WARNING = "\u26A0";
1442
272
  const BULLET = "\u2022";
1443
- const check_HUSKY_HOOK_PATH = ".husky/pre-commit";
1444
273
  const CONFIG_FILES = [
1445
274
  "lint-staged.config.ts",
1446
275
  "lint-staged.config.js",
@@ -1465,43 +294,68 @@ function findConfigFile(fs) {
1465
294
  return null;
1466
295
  });
1467
296
  }
297
+ function findConfig(discovery, names) {
298
+ return Effect.gen(function*() {
299
+ for (const name of names){
300
+ const result = yield* discovery.find(name);
301
+ if (result) return result.path;
302
+ }
303
+ return null;
304
+ });
305
+ }
1468
306
  function extractConfigPathFromManaged(managedContent) {
1469
307
  const match = managedContent.match(/lint-staged --config "\$ROOT\/([^"]+)"/);
1470
308
  return match ? match[1] : null;
1471
309
  }
1472
- function checkHookManagedSection(hookContent, expectedManagedContent) {
1473
- const { managedSection, found } = extractManagedSection(hookContent);
1474
- if (!found) return {
1475
- found: false,
1476
- isUpToDate: false,
1477
- needsUpdate: false
1478
- };
1479
- const expectedSection = `${BEGIN_MARKER}\n${expectedManagedContent}\n${END_MARKER}`;
1480
- const normalizedExisting = managedSection.trim().replace(/\s+/g, " ");
1481
- const normalizedExpected = expectedSection.trim().replace(/\s+/g, " ");
1482
- const isUpToDate = normalizedExisting === normalizedExpected;
1483
- return {
1484
- found: true,
1485
- isUpToDate,
1486
- needsUpdate: !isUpToDate
1487
- };
310
+ function checkHookManagedSection(section, hookPath, block) {
311
+ return Effect.gen(function*() {
312
+ const result = yield* section.check(hookPath, block);
313
+ return CheckResult.$match(result, {
314
+ Found: ({ isUpToDate })=>({
315
+ found: true,
316
+ isUpToDate,
317
+ needsUpdate: !isUpToDate
318
+ }),
319
+ NotFound: ()=>({
320
+ found: false,
321
+ isUpToDate: false,
322
+ needsUpdate: false
323
+ })
324
+ });
325
+ });
1488
326
  }
1489
- function checkManagedSectionStatus(existingManaged) {
1490
- const configPath = extractConfigPathFromManaged(existingManaged);
1491
- if (!configPath) return {
1492
- isUpToDate: false,
1493
- configPath: null,
1494
- needsUpdate: true
1495
- };
1496
- const expectedContent = `${BEGIN_MARKER}\n${generateManagedContent(configPath)}\n${END_MARKER}`;
1497
- const normalizedExisting = existingManaged.trim().replace(/\s+/g, " ");
1498
- const normalizedExpected = expectedContent.trim().replace(/\s+/g, " ");
1499
- const isUpToDate = normalizedExisting === normalizedExpected;
1500
- return {
1501
- isUpToDate,
1502
- configPath,
1503
- needsUpdate: !isUpToDate
1504
- };
327
+ function checkManagedSectionStatus(section, hookPath) {
328
+ return Effect.gen(function*() {
329
+ const existing = yield* section.read(hookPath, SavvyLintSectionDef);
330
+ if (null === existing) return {
331
+ isUpToDate: false,
332
+ configPath: null,
333
+ needsUpdate: false,
334
+ found: false
335
+ };
336
+ const configPath = extractConfigPathFromManaged(existing.text);
337
+ if (!configPath) return {
338
+ isUpToDate: false,
339
+ configPath: null,
340
+ needsUpdate: true,
341
+ found: true
342
+ };
343
+ const result = yield* section.check(hookPath, preCommitBlock(configPath));
344
+ return CheckResult.$match(result, {
345
+ Found: ({ isUpToDate })=>({
346
+ isUpToDate,
347
+ configPath: configPath,
348
+ needsUpdate: !isUpToDate,
349
+ found: true
350
+ }),
351
+ NotFound: ()=>({
352
+ isUpToDate: false,
353
+ configPath: null,
354
+ needsUpdate: false,
355
+ found: false
356
+ })
357
+ });
358
+ });
1505
359
  }
1506
360
  function checkMarkdownlintConfig(content) {
1507
361
  return Effect.gen(function*() {
@@ -1517,26 +371,27 @@ function checkMarkdownlintConfig(content) {
1517
371
  };
1518
372
  });
1519
373
  }
1520
- function checkBiomeSchemas(fs) {
374
+ function checkBiomeSchemas() {
1521
375
  return Effect.gen(function*() {
1522
- const expectedUrl = getExpectedSchemaUrl();
376
+ const version = "catalog:silk";
1523
377
  const statuses = [];
1524
- if (!expectedUrl) return {
378
+ if (!version) return {
1525
379
  statuses,
1526
380
  warnings: []
1527
381
  };
1528
- const configs = yield* findBiomeConfigs();
382
+ const syncer = yield* BiomeSchemaSync;
383
+ const result = yield* syncer.check(version);
1529
384
  const warnings = [];
1530
- for (const configPath of configs){
1531
- const content = yield* fs.readFileString(configPath);
1532
- const parsed = yield* parse(content);
1533
- if ("string" != typeof parsed.$schema || !parsed.$schema.startsWith(SCHEMA_URL_PREFIX)) continue;
1534
- const matches = parsed.$schema === expectedUrl;
385
+ for (const configPath of result.current)statuses.push({
386
+ path: configPath,
387
+ matches: true
388
+ });
389
+ for (const configPath of result.updated){
1535
390
  statuses.push({
1536
391
  path: configPath,
1537
- matches
392
+ matches: false
1538
393
  });
1539
- if (!matches) warnings.push(`${check_WARNING} ${configPath}: biome $schema is outdated.\n Run 'savvy-lint init' to update it.`);
394
+ warnings.push(`${WARNING} ${configPath}: biome $schema is outdated.\n Run 'savvy-lint init' to update it.`);
1540
395
  }
1541
396
  return {
1542
397
  statuses,
@@ -1549,9 +404,12 @@ const checkCommand = Command.make("check", {
1549
404
  quiet: quietOption
1550
405
  }, ({ quiet })=>Effect.gen(function*() {
1551
406
  const fs = yield* FileSystem.FileSystem;
407
+ const section = yield* ManagedSection;
408
+ const td = yield* ToolDiscovery;
409
+ const discovery = yield* ConfigDiscovery;
1552
410
  const warnings = [];
1553
411
  const foundConfig = yield* findConfigFile(fs);
1554
- const hasHuskyHook = yield* fs.exists(check_HUSKY_HOOK_PATH);
412
+ const hasHuskyHook = yield* fs.exists(HUSKY_HOOK_PATH);
1555
413
  let managedStatus = {
1556
414
  isUpToDate: false,
1557
415
  configPath: null,
@@ -1559,26 +417,11 @@ const checkCommand = Command.make("check", {
1559
417
  found: false
1560
418
  };
1561
419
  if (hasHuskyHook) {
1562
- const hookContent = yield* fs.readFileString(check_HUSKY_HOOK_PATH);
1563
- const { managedSection, found } = extractManagedSection(hookContent);
1564
- if (found) {
1565
- const status = checkManagedSectionStatus(managedSection);
1566
- managedStatus = {
1567
- ...status,
1568
- found: true
1569
- };
1570
- 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).`);
1571
- } else {
1572
- managedStatus = {
1573
- isUpToDate: false,
1574
- configPath: null,
1575
- needsUpdate: false,
1576
- found: false
1577
- };
1578
- 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.`);
1579
- }
1580
- } else warnings.push(`${check_WARNING} No husky pre-commit hook found.\n Run 'savvy-lint init' to create it.`);
1581
- if (!foundConfig) warnings.push(`${check_WARNING} No lint-staged config file found.\n Run 'savvy-lint init' to create one.`);
420
+ managedStatus = yield* checkManagedSectionStatus(section, HUSKY_HOOK_PATH);
421
+ if (managedStatus.found && managedStatus.needsUpdate) warnings.push(`${WARNING} Your ${HUSKY_HOOK_PATH} managed section is outdated.\n Run 'savvy-lint init' to update it (preserves your custom hooks).`);
422
+ else if (!managedStatus.found) warnings.push(`${WARNING} Your ${HUSKY_HOOK_PATH} does not have a savvy-lint managed section.\n Run 'savvy-lint init' to add it.`);
423
+ } else warnings.push(`${WARNING} No husky pre-commit hook found.\n Run 'savvy-lint init' to create it.`);
424
+ if (!foundConfig) warnings.push(`${WARNING} No lint-staged config file found.\n Run 'savvy-lint init' to create one.`);
1582
425
  const shellHookPaths = [
1583
426
  POST_CHECKOUT_HOOK_PATH,
1584
427
  POST_MERGE_HOOK_PATH
@@ -1587,16 +430,20 @@ const checkCommand = Command.make("check", {
1587
430
  for (const hookPath of shellHookPaths){
1588
431
  const hookExists = yield* fs.exists(hookPath);
1589
432
  if (hookExists) {
1590
- const hookContent = yield* fs.readFileString(hookPath);
1591
- const status = checkHookManagedSection(hookContent, generateShellScriptsManagedContent());
433
+ const status = yield* checkHookManagedSection(section, hookPath, shellScriptsBlock());
1592
434
  shellHookStatuses.push({
1593
435
  path: hookPath,
1594
436
  ...status
1595
437
  });
1596
- if (status.found && status.needsUpdate) warnings.push(`${check_WARNING} Your ${hookPath} managed section is outdated.\n Run 'savvy-lint init' to update it (preserves your custom hooks).`);
438
+ if (status.found && status.needsUpdate) warnings.push(`${WARNING} Your ${hookPath} managed section is outdated.\n Run 'savvy-lint init' to update it (preserves your custom hooks).`);
1597
439
  }
1598
440
  }
1599
- const biomeSchemaStatus = yield* checkBiomeSchemas(fs);
441
+ const biomeSchemaStatus = yield* checkBiomeSchemas().pipe(Effect.catchTag("BiomeSyncError", ()=>Effect.succeed({
442
+ statuses: [],
443
+ warnings: [
444
+ `${WARNING} Could not check biome $schema URLs.`
445
+ ]
446
+ })));
1600
447
  warnings.push(...biomeSchemaStatus.warnings);
1601
448
  const hasMarkdownlintConfig = yield* fs.exists(MARKDOWNLINT_CONFIG_PATH);
1602
449
  let markdownlintStatus = {
@@ -1608,62 +455,220 @@ const checkCommand = Command.make("check", {
1608
455
  if (hasMarkdownlintConfig) {
1609
456
  const mdContent = yield* fs.readFileString(MARKDOWNLINT_CONFIG_PATH);
1610
457
  markdownlintStatus = yield* checkMarkdownlintConfig(mdContent);
1611
- if (!markdownlintStatus.schemaMatches) warnings.push(`${check_WARNING} ${MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy-lint init' to update it.`);
1612
- if (!markdownlintStatus.configMatches) warnings.push(`${check_WARNING} ${MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy-lint init --force' to overwrite.`);
458
+ if (!markdownlintStatus.schemaMatches) warnings.push(`${WARNING} ${MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy-lint init' to update it.`);
459
+ if (!markdownlintStatus.configMatches) warnings.push(`${WARNING} ${MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy-lint init --force' to overwrite.`);
1613
460
  }
1614
461
  if (quiet) {
1615
462
  if (warnings.length > 0) for (const warning of warnings)yield* Effect.log(warning);
1616
463
  return;
1617
464
  }
1618
465
  yield* Effect.log("Checking lint-staged configuration...\n");
1619
- if (foundConfig) yield* Effect.log(`${check_CHECK_MARK} Config file: ${foundConfig}`);
466
+ if (foundConfig) yield* Effect.log(`${CHECK_MARK} Config file: ${foundConfig}`);
1620
467
  else yield* Effect.log(`${CROSS_MARK} No lint-staged config file found`);
1621
- if (hasHuskyHook) yield* Effect.log(`${check_CHECK_MARK} Husky hook: ${check_HUSKY_HOOK_PATH}`);
468
+ if (hasHuskyHook) yield* Effect.log(`${CHECK_MARK} Husky hook: ${HUSKY_HOOK_PATH}`);
1622
469
  else yield* Effect.log(`${CROSS_MARK} No husky pre-commit hook found`);
1623
- if (hasHuskyHook) if (managedStatus.found) if (managedStatus.isUpToDate) yield* Effect.log(`${check_CHECK_MARK} Managed section: up-to-date`);
1624
- else yield* Effect.log(`${check_WARNING} Managed section: outdated (run 'savvy-lint init' to update)`);
470
+ if (hasHuskyHook) if (managedStatus.found) if (managedStatus.isUpToDate) yield* Effect.log(`${CHECK_MARK} Managed section: up-to-date`);
471
+ else yield* Effect.log(`${WARNING} Managed section: outdated (run 'savvy-lint init' to update)`);
1625
472
  else yield* Effect.log(`${BULLET} Managed section: not found (run 'savvy-lint init' to add)`);
1626
- for (const status of shellHookStatuses)if (status.found) if (status.isUpToDate) yield* Effect.log(`${check_CHECK_MARK} ${status.path}: up-to-date`);
1627
- else yield* Effect.log(`${check_WARNING} ${status.path}: outdated (run 'savvy-lint init' to update)`);
473
+ for (const status of shellHookStatuses)if (status.found) if (status.isUpToDate) yield* Effect.log(`${CHECK_MARK} ${status.path}: up-to-date`);
474
+ else yield* Effect.log(`${WARNING} ${status.path}: outdated (run 'savvy-lint init' to update)`);
1628
475
  yield* Effect.log("\nTool availability:");
1629
- const biomeAvailable = Biome.isAvailable();
1630
- const biomeConfig = Biome.findConfig();
476
+ const biomeAvailable = yield* td.isAvailable(ToolDefinition.make({
477
+ name: "biome"
478
+ }));
479
+ const biomeConfig = yield* findConfig(discovery, [
480
+ "biome.jsonc",
481
+ "biome.json"
482
+ ]);
1631
483
  if (biomeAvailable) {
1632
484
  const configInfo = biomeConfig ? ` (config: ${biomeConfig})` : "";
1633
- yield* Effect.log(` ${check_CHECK_MARK} Biome${configInfo}`);
485
+ yield* Effect.log(` ${CHECK_MARK} Biome${configInfo}`);
1634
486
  } else yield* Effect.log(` ${BULLET} Biome: not installed`);
1635
- const markdownAvailable = Markdown.isAvailable();
1636
- const markdownConfig = Markdown.findConfig();
487
+ const markdownAvailable = yield* td.isAvailable(ToolDefinition.make({
488
+ name: "markdownlint-cli2"
489
+ }));
490
+ const markdownConfig = yield* findConfig(discovery, [
491
+ ".markdownlint-cli2.jsonc",
492
+ ".markdownlint-cli2.json",
493
+ ".markdownlint-cli2.yaml",
494
+ ".markdownlint-cli2.cjs",
495
+ ".markdownlint.jsonc",
496
+ ".markdownlint.json",
497
+ ".markdownlint.yaml"
498
+ ]);
1637
499
  if (markdownAvailable) {
1638
500
  const configInfo = markdownConfig ? ` (config: ${markdownConfig})` : "";
1639
- yield* Effect.log(` ${check_CHECK_MARK} markdownlint-cli2${configInfo}`);
501
+ yield* Effect.log(` ${CHECK_MARK} markdownlint-cli2${configInfo}`);
1640
502
  } else yield* Effect.log(` ${BULLET} markdownlint-cli2: not installed`);
1641
- const typescriptAvailable = TypeScript.isAvailable();
1642
- if (typescriptAvailable) {
1643
- const compiler = TypeScript.detectCompiler();
1644
- yield* Effect.log(` ${check_CHECK_MARK} TypeScript (${compiler})`);
1645
- } else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
1646
- const tsdocAvailable = TypeScript.isTsdocAvailable();
1647
- if (tsdocAvailable) yield* Effect.log(` ${check_CHECK_MARK} TSDoc (tsdoc.json found)`);
503
+ const tsgoAvailable = yield* td.isAvailable(ToolDefinition.make({
504
+ name: "tsgo"
505
+ }));
506
+ const tscAvailable = yield* td.isAvailable(ToolDefinition.make({
507
+ name: "tsc"
508
+ }));
509
+ if (tsgoAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsgo)`);
510
+ else if (tscAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsc)`);
511
+ else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
512
+ const tsdocConfig = yield* discovery.find("tsdoc.json");
513
+ if (tsdocConfig) yield* Effect.log(` ${CHECK_MARK} TSDoc (tsdoc.json found)`);
1648
514
  else yield* Effect.log(` ${BULLET} TSDoc: no tsdoc.json found`);
1649
- if (hasMarkdownlintConfig) if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${check_CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
515
+ if (hasMarkdownlintConfig) if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
1650
516
  else {
1651
517
  const issues = [];
1652
518
  if (!markdownlintStatus.schemaMatches) issues.push("$schema");
1653
519
  if (!markdownlintStatus.configMatches) issues.push("config");
1654
- yield* Effect.log(` ${check_WARNING} ${MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
520
+ yield* Effect.log(` ${WARNING} ${MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
1655
521
  }
1656
522
  else yield* Effect.log(` ${BULLET} ${MARKDOWNLINT_CONFIG_PATH}: not found`);
1657
- for (const status of biomeSchemaStatus.statuses)if (status.matches) yield* Effect.log(` ${check_CHECK_MARK} ${status.path}: biome $schema up-to-date`);
1658
- else yield* Effect.log(` ${check_WARNING} ${status.path}: biome $schema outdated (run 'savvy-lint init' to update)`);
523
+ for (const status of biomeSchemaStatus.statuses)if (status.matches) yield* Effect.log(` ${CHECK_MARK} ${status.path}: biome $schema up-to-date`);
524
+ else yield* Effect.log(` ${WARNING} ${status.path}: biome $schema outdated (run 'savvy-lint init' to update)`);
1659
525
  yield* Effect.log("");
1660
526
  const hasShellHookIssues = shellHookStatuses.some((s)=>s.found && s.needsUpdate);
1661
527
  const hasMarkdownlintIssues = hasMarkdownlintConfig && !markdownlintStatus.isUpToDate;
1662
528
  const hasBiomeSchemaIssues = biomeSchemaStatus.statuses.some((s)=>!s.matches);
1663
529
  const hasIssues = !foundConfig || !hasHuskyHook || !managedStatus.found || managedStatus.needsUpdate || hasShellHookIssues || hasMarkdownlintIssues || hasBiomeSchemaIssues;
1664
- if (hasIssues) yield* Effect.log(`${check_WARNING} Some issues found. Run 'savvy-lint init' to fix.`);
1665
- else yield* Effect.log(`${check_CHECK_MARK} Lint-staged is configured correctly.`);
530
+ if (hasIssues) yield* Effect.log(`${WARNING} Some issues found. Run 'savvy-lint init' to fix.`);
531
+ else yield* Effect.log(`${CHECK_MARK} Lint-staged is configured correctly.`);
1666
532
  })).pipe(Command.withDescription("Check current lint-staged configuration and tool availability"));
533
+ const VALID_COMMAND_PATTERN = /^[\w@/-]+$/;
534
+ function validateCommandName(name) {
535
+ if (!VALID_COMMAND_PATTERN.test(name)) throw new Error(`Invalid command name: "${name}". Only alphanumeric characters, hyphens, underscores, @ and / are allowed.`);
536
+ }
537
+ class Command_Command {
538
+ static cachedPackageManager = null;
539
+ static cachedRoot = null;
540
+ static findRoot(cwd = process.cwd()) {
541
+ if (null !== Command_Command.cachedRoot) return Command_Command.cachedRoot;
542
+ let dir = resolve(cwd);
543
+ while(true){
544
+ if (existsSync(join(dir, "package.json"))) {
545
+ Command_Command.cachedRoot = dir;
546
+ return dir;
547
+ }
548
+ const parent = dirname(dir);
549
+ if (parent === dir) break;
550
+ dir = parent;
551
+ }
552
+ Command_Command.cachedRoot = cwd;
553
+ return cwd;
554
+ }
555
+ static detectPackageManager(cwd = Command_Command.findRoot()) {
556
+ if (null !== Command_Command.cachedPackageManager) return Command_Command.cachedPackageManager;
557
+ const packageJsonPath = join(cwd, "package.json");
558
+ if (!existsSync(packageJsonPath)) {
559
+ Command_Command.cachedPackageManager = "npm";
560
+ return "npm";
561
+ }
562
+ try {
563
+ const content = readFileSync(packageJsonPath, "utf-8");
564
+ const pkg = JSON.parse(content);
565
+ if (pkg.packageManager) {
566
+ const match = pkg.packageManager.match(/^(npm|pnpm|yarn|bun)@/);
567
+ if (match) {
568
+ Command_Command.cachedPackageManager = match[1];
569
+ return Command_Command.cachedPackageManager;
570
+ }
571
+ }
572
+ } catch {}
573
+ Command_Command.cachedPackageManager = "npm";
574
+ return "npm";
575
+ }
576
+ static getExecPrefix(packageManager) {
577
+ switch(packageManager){
578
+ case "pnpm":
579
+ return [
580
+ "pnpm",
581
+ "exec"
582
+ ];
583
+ case "yarn":
584
+ return [
585
+ "yarn",
586
+ "exec"
587
+ ];
588
+ case "bun":
589
+ return [
590
+ "bun",
591
+ "x",
592
+ "--no-install"
593
+ ];
594
+ default:
595
+ return [
596
+ "npx",
597
+ "--no"
598
+ ];
599
+ }
600
+ }
601
+ static clearCache() {
602
+ Command_Command.cachedPackageManager = null;
603
+ Command_Command.cachedRoot = null;
604
+ }
605
+ static isAvailable(command) {
606
+ validateCommandName(command);
607
+ try {
608
+ execSync(`command -v ${command}`, {
609
+ stdio: "ignore"
610
+ });
611
+ return true;
612
+ } catch {
613
+ return false;
614
+ }
615
+ }
616
+ static findTool(tool) {
617
+ validateCommandName(tool);
618
+ if (Command_Command.isAvailable(tool)) return {
619
+ available: true,
620
+ command: tool,
621
+ source: "global"
622
+ };
623
+ const pm = Command_Command.detectPackageManager();
624
+ const prefix = Command_Command.getExecPrefix(pm);
625
+ const execCmd = [
626
+ ...prefix,
627
+ tool
628
+ ].join(" ");
629
+ try {
630
+ execSync(`${execCmd} --version`, {
631
+ stdio: "ignore"
632
+ });
633
+ return {
634
+ available: true,
635
+ command: execCmd,
636
+ source: pm
637
+ };
638
+ } catch {}
639
+ return {
640
+ available: false,
641
+ command: void 0,
642
+ source: void 0
643
+ };
644
+ }
645
+ static requireTool(tool, errorMessage) {
646
+ const result = Command_Command.findTool(tool);
647
+ 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.`);
648
+ return result.command;
649
+ }
650
+ static findSavvyLint() {
651
+ const result = Command_Command.findTool("savvy-lint");
652
+ if (result.available && result.command) return result.command;
653
+ const root = Command_Command.findRoot();
654
+ return `node ${root}/dist/dev/bin/savvy-lint.js`;
655
+ }
656
+ static exec(command) {
657
+ return execSync(command, {
658
+ encoding: "utf-8"
659
+ }).trim();
660
+ }
661
+ static execSilent(command) {
662
+ try {
663
+ execSync(command, {
664
+ stdio: "ignore"
665
+ });
666
+ return true;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+ }
1667
672
  const DEFAULT_STRINGIFY_OPTIONS = {
1668
673
  indent: 2,
1669
674
  lineWidth: 0,
@@ -1725,6 +730,29 @@ class PnpmWorkspace {
1725
730
  };
1726
731
  }
1727
732
  }
733
+ class Filter {
734
+ static exclude(filenames, patterns) {
735
+ if (0 === patterns.length) return [
736
+ ...filenames
737
+ ];
738
+ return filenames.filter((file)=>!patterns.some((pattern)=>file.includes(pattern)));
739
+ }
740
+ static include(filenames, patterns) {
741
+ if (0 === patterns.length) return [];
742
+ return filenames.filter((file)=>patterns.some((pattern)=>file.includes(pattern)));
743
+ }
744
+ static apply(filenames, options) {
745
+ let result = [
746
+ ...filenames
747
+ ];
748
+ if (options.include && options.include.length > 0) result = Filter.include(result, options.include);
749
+ if (options.exclude && options.exclude.length > 0) result = Filter.exclude(result, options.exclude);
750
+ return result;
751
+ }
752
+ static shellEscape(filenames) {
753
+ return filenames.map((f)=>`'${f.replace(/'/g, "'\\''")}'`).join(" ");
754
+ }
755
+ }
1728
756
  class Yaml {
1729
757
  static glob = "**/*.{yml,yaml}";
1730
758
  static defaultExcludes = [
@@ -1734,8 +762,9 @@ class Yaml {
1734
762
  ];
1735
763
  static handler = Yaml.create();
1736
764
  static findConfig() {
1737
- const result = ConfigSearch.find("yamllint");
1738
- return result.filepath;
765
+ const libPath = "lib/configs/.yaml-lint.json";
766
+ if (existsSync(libPath)) return libPath;
767
+ if (existsSync(".yaml-lint.json")) return ".yaml-lint.json";
1739
768
  }
1740
769
  static loadConfig(filepath) {
1741
770
  try {
@@ -1834,6 +863,149 @@ const fmtCommand = Command.make("fmt").pipe(Command.withSubcommands([
1834
863
  pnpmWorkspaceCommand,
1835
864
  yamlCommand
1836
865
  ]));
866
+ const init_CHECK_MARK = "\u2713";
867
+ const init_WARNING = "\u26A0";
868
+ const EXECUTABLE_MODE = 493;
869
+ const JSONC_FORMAT = {
870
+ tabSize: 1,
871
+ insertSpaces: false
872
+ };
873
+ function presetIncludesShellScripts(preset) {
874
+ return "minimal" !== preset;
875
+ }
876
+ function presetIncludesMarkdown(preset) {
877
+ return "minimal" !== preset;
878
+ }
879
+ function generateConfigContent(preset) {
880
+ return `/**
881
+ * lint-staged configuration
882
+ * Generated by savvy-lint init
883
+ */
884
+ import { Preset } from "@savvy-web/lint-staged";
885
+
886
+ export default Preset.${preset}();
887
+ `;
888
+ }
889
+ function writeMarkdownlintConfig(fs, preset, force) {
890
+ return Effect.gen(function*() {
891
+ const configExists = yield* fs.exists(MARKDOWNLINT_CONFIG_PATH);
892
+ const fullTemplate = JSON.stringify(MARKDOWNLINT_TEMPLATE, null, "\t");
893
+ if (!configExists) {
894
+ yield* fs.makeDirectory("lib/configs", {
895
+ recursive: true
896
+ });
897
+ yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
898
+ yield* Effect.log(`${init_CHECK_MARK} Created ${MARKDOWNLINT_CONFIG_PATH}`);
899
+ return;
900
+ }
901
+ if ("silk" !== preset) return void (yield* Effect.log(`${init_CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`));
902
+ if (force) {
903
+ yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
904
+ yield* Effect.log(`${init_CHECK_MARK} Replaced ${MARKDOWNLINT_CONFIG_PATH} (--force)`);
905
+ return;
906
+ }
907
+ const existingText = yield* fs.readFileString(MARKDOWNLINT_CONFIG_PATH);
908
+ const existingParsed = yield* parse(existingText);
909
+ let updatedText = existingText;
910
+ let schemaUpdated = false;
911
+ if (existingParsed.$schema !== MARKDOWNLINT_SCHEMA) {
912
+ const edits = yield* modify(updatedText, [
913
+ "$schema"
914
+ ], MARKDOWNLINT_SCHEMA, {
915
+ formattingOptions: JSONC_FORMAT
916
+ });
917
+ updatedText = yield* applyEdits(updatedText, edits);
918
+ schemaUpdated = true;
919
+ }
920
+ const existingConfig = existingParsed.config;
921
+ const configMatches = void 0 !== existingConfig && isDeepStrictEqual(existingConfig, MARKDOWNLINT_CONFIG);
922
+ if (!configMatches) {
923
+ yield* Effect.log(`${init_WARNING} ${MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
924
+ if (schemaUpdated) {
925
+ yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, updatedText);
926
+ yield* Effect.log(`${init_CHECK_MARK} Updated $schema in ${MARKDOWNLINT_CONFIG_PATH}`);
927
+ }
928
+ return;
929
+ }
930
+ if (schemaUpdated) {
931
+ yield* fs.writeFileString(MARKDOWNLINT_CONFIG_PATH, updatedText);
932
+ yield* Effect.log(`${init_CHECK_MARK} Updated $schema in ${MARKDOWNLINT_CONFIG_PATH}`);
933
+ } else yield* Effect.log(`${init_CHECK_MARK} ${MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
934
+ });
935
+ }
936
+ function syncBiomeSchemas() {
937
+ return Effect.gen(function*() {
938
+ const version = "catalog:silk";
939
+ if (!version) return;
940
+ const syncer = yield* BiomeSchemaSync;
941
+ const result = yield* syncer.sync(version);
942
+ for (const configPath of result.current)yield* Effect.log(`${init_CHECK_MARK} ${configPath}: biome $schema up-to-date`);
943
+ for (const configPath of result.updated)yield* Effect.log(`${init_CHECK_MARK} Updated $schema in ${configPath}`);
944
+ });
945
+ }
946
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite entire hook file (not just managed section)"), Options.withDefault(false));
947
+ 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));
948
+ const presetOption = Options.choice("preset", [
949
+ "minimal",
950
+ "standard",
951
+ "silk"
952
+ ]).pipe(Options.withAlias("p"), Options.withDescription("Preset to use: minimal, standard, or silk"), Options.withDefault("silk"));
953
+ function makeExecutable(path) {
954
+ return Effect.tryPromise(()=>import("node:fs/promises").then((fs)=>fs.chmod(path, EXECUTABLE_MODE)));
955
+ }
956
+ function writeHook(fs, section, hookPath, block, comment, force) {
957
+ return Effect.gen(function*() {
958
+ const hookExists = yield* fs.exists(hookPath);
959
+ const header = `#!/usr/bin/env sh\n# ${comment}\n# Custom hooks can go above or below the managed section\n`;
960
+ if (!hookExists || force) {
961
+ if (!hookExists) yield* fs.makeDirectory(".husky", {
962
+ recursive: true
963
+ });
964
+ yield* fs.writeFileString(hookPath, header);
965
+ }
966
+ const result = yield* section.sync(hookPath, block);
967
+ yield* makeExecutable(hookPath);
968
+ if (hookExists) if (force) yield* Effect.log(`${init_CHECK_MARK} Replaced ${hookPath} (--force)`);
969
+ else yield* SyncResult.$match(result, {
970
+ Created: ()=>Effect.log(`${init_CHECK_MARK} Added managed section to ${hookPath}`),
971
+ Updated: ()=>Effect.log(`${init_CHECK_MARK} Updated managed section in ${hookPath}`),
972
+ Unchanged: ()=>Effect.log(`${init_CHECK_MARK} ${hookPath}: up-to-date`)
973
+ });
974
+ else yield* Effect.log(`${init_CHECK_MARK} Created ${hookPath}`);
975
+ });
976
+ }
977
+ const initCommand = Command.make("init", {
978
+ force: forceOption,
979
+ config: configOption,
980
+ preset: presetOption
981
+ }, ({ force, config, preset })=>Effect.gen(function*() {
982
+ const fs = yield* FileSystem.FileSystem;
983
+ const section = yield* ManagedSection;
984
+ if (config.startsWith("/")) yield* Effect.fail(new Error("Config path must be relative to repository root, not absolute"));
985
+ yield* Effect.log("Initializing lint-staged configuration...\n");
986
+ yield* writeHook(fs, section, HUSKY_HOOK_PATH, preCommitBlock(config), "Pre-commit hook with savvy-lint managed section", force);
987
+ if (presetIncludesShellScripts(preset)) {
988
+ const shellBlock = shellScriptsBlock();
989
+ yield* writeHook(fs, section, POST_CHECKOUT_HOOK_PATH, shellBlock, "Post-checkout hook with savvy-lint managed section", force);
990
+ yield* writeHook(fs, section, POST_MERGE_HOOK_PATH, shellBlock, "Post-merge hook with savvy-lint managed section", force);
991
+ }
992
+ if (presetIncludesMarkdown(preset)) yield* writeMarkdownlintConfig(fs, preset, force);
993
+ yield* syncBiomeSchemas().pipe(Effect.catchTag("BiomeSyncError", (e)=>Effect.log(`${init_WARNING} Could not sync biome $schema: ${e.message}`)));
994
+ const configExists = yield* fs.exists(config);
995
+ if (configExists && !force) yield* Effect.log(`${init_WARNING} ${config} already exists (use --force to overwrite)`);
996
+ else {
997
+ const configDir = dirname(config);
998
+ if (configDir && "." !== configDir) yield* fs.makeDirectory(configDir, {
999
+ recursive: true
1000
+ });
1001
+ yield* fs.writeFileString(config, generateConfigContent(preset));
1002
+ yield* Effect.log(`${init_CHECK_MARK} Created ${config} (preset: ${preset})`);
1003
+ }
1004
+ yield* Effect.log("\nDone! Lint-staged is ready to use.");
1005
+ })).pipe(Command.withDescription("Initialize lint-staged configuration and husky hooks"));
1006
+ const WorkspaceLive = Layer.mergeAll(PackageManagerDetectorLive, WorkspaceRootLive);
1007
+ const SilkLive = Layer.mergeAll(ManagedSectionLive, BiomeSchemaSyncLive, ConfigDiscoveryLive, ToolDiscoveryLive);
1008
+ const AppLayer = SilkLive.pipe(Layer.provideMerge(WorkspaceLive), Layer.provideMerge(NodeContext.layer));
1837
1009
  const rootCommand = Command.make("savvy-lint").pipe(Command.withSubcommands([
1838
1010
  initCommand,
1839
1011
  checkCommand,
@@ -1841,10 +1013,10 @@ const rootCommand = Command.make("savvy-lint").pipe(Command.withSubcommands([
1841
1013
  ]));
1842
1014
  const cli = Command.run(rootCommand, {
1843
1015
  name: "savvy-lint",
1844
- version: "0.6.5"
1016
+ version: "0.7.0"
1845
1017
  });
1846
1018
  function runCli() {
1847
- const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer));
1019
+ const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(AppLayer));
1848
1020
  NodeRuntime.runMain(main);
1849
1021
  }
1850
- export { Biome, Command_Command as Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, PnpmWorkspace, TsDocLinter, TsDocResolver, TypeScript, Yaml, checkCommand, fmtCommand, initCommand, rootCommand, runCli };
1022
+ export { Command_Command as Command, Filter, PnpmWorkspace, Yaml, checkCommand, fmtCommand, initCommand, rootCommand, runCli };