@savvy-web/lint-staged 0.3.2 → 0.4.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 CHANGED
@@ -1,4 +1,4 @@
1
- import { Command, Options } from "@effect/cli";
1
+ import { Args, Command, Options } from "@effect/cli";
2
2
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
3
  import { Effect } from "effect";
4
4
  import { isDeepStrictEqual } from "node:util";
@@ -7,19 +7,36 @@ import { applyEdits, modify, parse } from "jsonc-parser";
7
7
  import { execSync } from "node:child_process";
8
8
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
10
+ import { findProjectRoot, getWorkspaceInfos } from "workspace-tools";
10
11
  import { cosmiconfigSync, defaultLoaders } from "cosmiconfig";
11
12
  import parser from "@typescript-eslint/parser";
12
13
  import { ESLint } from "eslint";
13
14
  import eslint_plugin_tsdoc from "eslint-plugin-tsdoc";
14
- import { getWorkspaceInfos } from "workspace-tools";
15
15
  import typescript from "typescript";
16
+ import sort_package_json from "sort-package-json";
17
+ import { parse as external_yaml_parse, stringify } from "yaml";
18
+ import { format, resolveConfig } from "prettier";
19
+ import { lint } from "yaml-lint";
16
20
  const VALID_COMMAND_PATTERN = /^[\w@/-]+$/;
17
21
  function validateCommandName(name) {
18
22
  if (!VALID_COMMAND_PATTERN.test(name)) throw new Error(`Invalid command name: "${name}". Only alphanumeric characters, hyphens, underscores, @ and / are allowed.`);
19
23
  }
20
24
  class Command_Command {
21
25
  static cachedPackageManager = null;
22
- static detectPackageManager(cwd = process.cwd()) {
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()) {
23
40
  if (null !== Command_Command.cachedPackageManager) return Command_Command.cachedPackageManager;
24
41
  const packageJsonPath = join(cwd, "package.json");
25
42
  if (!existsSync(packageJsonPath)) {
@@ -54,7 +71,9 @@ class Command_Command {
54
71
  ];
55
72
  case "bun":
56
73
  return [
57
- "bunx"
74
+ "bun",
75
+ "x",
76
+ "--no-install"
58
77
  ];
59
78
  default:
60
79
  return [
@@ -65,6 +84,7 @@ class Command_Command {
65
84
  }
66
85
  static clearCache() {
67
86
  Command_Command.cachedPackageManager = null;
87
+ Command_Command.cachedRoot = null;
68
88
  }
69
89
  static isAvailable(command) {
70
90
  validateCommandName(command);
@@ -111,6 +131,12 @@ class Command_Command {
111
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.`);
112
132
  return result.command;
113
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
+ }
114
140
  static exec(command) {
115
141
  return execSync(command, {
116
142
  encoding: "utf-8"
@@ -190,6 +216,15 @@ const TOOL_CONFIGS = {
190
216
  "prettier.config.js",
191
217
  "package.json"
192
218
  ]
219
+ },
220
+ yamllint: {
221
+ moduleName: "yaml-lint",
222
+ libConfigFiles: [
223
+ ".yaml-lint.json"
224
+ ],
225
+ standardPlaces: [
226
+ ".yaml-lint.json"
227
+ ]
193
228
  }
194
229
  };
195
230
  class ConfigSearch {
@@ -846,36 +881,39 @@ class TypeScript {
846
881
  "__test__",
847
882
  "__tests__"
848
883
  ];
849
- static detectCompiler(cwd = process.cwd()) {
850
- const packageJsonPath = join(cwd, "package.json");
851
- if (!existsSync(packageJsonPath)) return;
852
- try {
853
- const content = readFileSync(packageJsonPath, "utf-8");
854
- const pkg = JSON.parse(content);
855
- const allDeps = {
856
- ...pkg.dependencies,
857
- ...pkg.devDependencies
884
+ static cachedCompilerResult = null;
885
+ static detectCompiler(_cwd) {
886
+ if (null !== TypeScript.cachedCompilerResult) return TypeScript.cachedCompilerResult.compiler;
887
+ const tsgo = Command_Command.findTool("tsgo");
888
+ if (tsgo.available) {
889
+ TypeScript.cachedCompilerResult = {
890
+ compiler: "tsgo",
891
+ tool: tsgo
858
892
  };
859
- if ("@typescript/native-preview" in allDeps) return "tsgo";
860
- if ("typescript" in allDeps) return "tsc";
861
- } catch {}
893
+ return "tsgo";
894
+ }
895
+ const tsc = Command_Command.findTool("tsc");
896
+ if (tsc.available) {
897
+ TypeScript.cachedCompilerResult = {
898
+ compiler: "tsc",
899
+ tool: tsc
900
+ };
901
+ return "tsc";
902
+ }
862
903
  }
863
904
  static isAvailable() {
864
905
  return void 0 !== TypeScript.detectCompiler();
865
906
  }
866
907
  static getDefaultTypecheckCommand() {
867
908
  const compiler = TypeScript.detectCompiler();
868
- if (!compiler) throw new Error("No TypeScript compiler found. Install 'typescript' or '@typescript/native-preview' as a dev dependency.");
869
- const pm = Command_Command.detectPackageManager();
870
- const prefix = Command_Command.getExecPrefix(pm);
871
- return [
872
- ...prefix,
873
- compiler,
874
- "--noEmit"
875
- ].join(" ");
909
+ if (!compiler || !TypeScript.cachedCompilerResult) throw new Error("No TypeScript compiler found. Install 'typescript' or '@typescript/native-preview' as a dev dependency.");
910
+ return `${TypeScript.cachedCompilerResult.tool.command} --noEmit`;
911
+ }
912
+ static clearCache() {
913
+ TypeScript.cachedCompilerResult = null;
876
914
  }
877
915
  static handler = TypeScript.create();
878
- static isTsdocAvailable(cwd = process.cwd()) {
916
+ static isTsdocAvailable(cwd = Command_Command.findRoot()) {
879
917
  const tsdocPath = join(cwd, "tsdoc.json");
880
918
  return existsSync(tsdocPath);
881
919
  }
@@ -888,7 +926,7 @@ class TypeScript {
888
926
  ];
889
927
  const skipTsdoc = options.skipTsdoc ?? false;
890
928
  const skipTypecheck = options.skipTypecheck ?? false;
891
- const rootDir = options.rootDir ?? process.cwd();
929
+ const rootDir = options.rootDir ?? Command_Command.findRoot();
892
930
  let typecheckCommand;
893
931
  const getTypecheckCommand = ()=>{
894
932
  if (void 0 === typecheckCommand) typecheckCommand = options.typecheckCommand ?? TypeScript.getDefaultTypecheckCommand();
@@ -1515,16 +1553,186 @@ const checkCommand = Command.make("check", {
1515
1553
  if (hasIssues) yield* Effect.log(`${check_WARNING} Some issues found. Run 'savvy-lint init' to fix.`);
1516
1554
  else yield* Effect.log(`${check_CHECK_MARK} Lint-staged is configured correctly.`);
1517
1555
  })).pipe(Command.withDescription("Check current lint-staged configuration and tool availability"));
1556
+ const DEFAULT_STRINGIFY_OPTIONS = {
1557
+ indent: 2,
1558
+ lineWidth: 0,
1559
+ singleQuote: false
1560
+ };
1561
+ class PnpmWorkspace {
1562
+ static glob = "pnpm-workspace.yaml";
1563
+ static defaultExcludes = [];
1564
+ static handler = PnpmWorkspace.create();
1565
+ static SORTABLE_ARRAY_KEYS = new Set([
1566
+ "packages",
1567
+ "onlyBuiltDependencies",
1568
+ "publicHoistPattern"
1569
+ ]);
1570
+ static sortContent(content) {
1571
+ const result = {};
1572
+ const keys = Object.keys(content).sort((a, b)=>{
1573
+ if ("packages" === a) return -1;
1574
+ if ("packages" === b) return 1;
1575
+ return a.localeCompare(b);
1576
+ });
1577
+ for (const key of keys){
1578
+ const value = content[key];
1579
+ if (PnpmWorkspace.SORTABLE_ARRAY_KEYS.has(key) && Array.isArray(value)) result[key] = [
1580
+ ...value
1581
+ ].sort();
1582
+ else result[key] = value;
1583
+ }
1584
+ return result;
1585
+ }
1586
+ static fmtCommand() {
1587
+ return ()=>{
1588
+ if (!existsSync("pnpm-workspace.yaml")) return [];
1589
+ const cmd = Command_Command.findSavvyLint();
1590
+ return `${cmd} fmt pnpm-workspace`;
1591
+ };
1592
+ }
1593
+ static create(options = {}) {
1594
+ const skipSort = options.skipSort ?? false;
1595
+ const skipFormat = options.skipFormat ?? false;
1596
+ const skipLint = options.skipLint ?? false;
1597
+ return ()=>{
1598
+ const filepath = "pnpm-workspace.yaml";
1599
+ if (!existsSync(filepath)) return [];
1600
+ const content = readFileSync(filepath, "utf-8");
1601
+ let parsed;
1602
+ try {
1603
+ parsed = external_yaml_parse(content);
1604
+ } catch (error) {
1605
+ if (!skipLint) throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
1606
+ return [];
1607
+ }
1608
+ if (!skipSort) parsed = PnpmWorkspace.sortContent(parsed);
1609
+ if (!skipSort || !skipFormat) {
1610
+ const formatted = stringify(parsed, DEFAULT_STRINGIFY_OPTIONS);
1611
+ writeFileSync(filepath, formatted, "utf-8");
1612
+ }
1613
+ return [];
1614
+ };
1615
+ }
1616
+ }
1617
+ class Yaml {
1618
+ static glob = "**/*.{yml,yaml}";
1619
+ static defaultExcludes = [
1620
+ "pnpm-lock.yaml",
1621
+ "pnpm-workspace.yaml"
1622
+ ];
1623
+ static handler = Yaml.create();
1624
+ static findConfig() {
1625
+ const result = ConfigSearch.find("yamllint");
1626
+ return result.filepath;
1627
+ }
1628
+ static loadConfig(filepath) {
1629
+ try {
1630
+ const content = readFileSync(filepath, "utf-8");
1631
+ const config = JSON.parse(content);
1632
+ return config.schema;
1633
+ } catch {
1634
+ return;
1635
+ }
1636
+ }
1637
+ static isAvailable() {
1638
+ return true;
1639
+ }
1640
+ static async formatFile(filepath) {
1641
+ const content = readFileSync(filepath, "utf-8");
1642
+ const prettierConfig = await resolveConfig(filepath);
1643
+ const formatted = await format(content, {
1644
+ ...prettierConfig,
1645
+ filepath,
1646
+ parser: "yaml"
1647
+ });
1648
+ writeFileSync(filepath, formatted, "utf-8");
1649
+ }
1650
+ static async validateFile(filepath, schema) {
1651
+ const content = readFileSync(filepath, "utf-8");
1652
+ await lint(content, schema ? {
1653
+ schema: schema
1654
+ } : void 0);
1655
+ }
1656
+ static fmtCommand(options = {}) {
1657
+ const excludes = options.exclude ?? [
1658
+ ...Yaml.defaultExcludes
1659
+ ];
1660
+ return (filenames)=>{
1661
+ const filtered = Filter.exclude(filenames, excludes);
1662
+ if (0 === filtered.length) return [];
1663
+ const cmd = Command_Command.findSavvyLint();
1664
+ return `${cmd} fmt yaml ${Filter.shellEscape(filtered)}`;
1665
+ };
1666
+ }
1667
+ static create(options = {}) {
1668
+ const excludes = options.exclude ?? [
1669
+ ...Yaml.defaultExcludes
1670
+ ];
1671
+ const skipFormat = options.skipFormat ?? false;
1672
+ const skipValidate = options.skipValidate ?? false;
1673
+ const configPath = options.config ?? Yaml.findConfig();
1674
+ const schema = configPath ? Yaml.loadConfig(configPath) : void 0;
1675
+ return async (filenames)=>{
1676
+ const filtered = Filter.exclude(filenames, excludes);
1677
+ if (0 === filtered.length) return [];
1678
+ if (!skipFormat) for (const filepath of filtered)await Yaml.formatFile(filepath);
1679
+ if (!skipValidate) for (const filepath of filtered)try {
1680
+ await Yaml.validateFile(filepath, schema);
1681
+ } catch (error) {
1682
+ throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
1683
+ }
1684
+ return [];
1685
+ };
1686
+ }
1687
+ }
1688
+ const YAML_STRINGIFY_OPTIONS = {
1689
+ indent: 2,
1690
+ lineWidth: 0,
1691
+ singleQuote: false
1692
+ };
1693
+ const filesArg = Args.repeated(Args.file({
1694
+ name: "files",
1695
+ exists: "yes"
1696
+ }));
1697
+ const packageJsonCommand = Command.make("package-json", {
1698
+ files: filesArg
1699
+ }, ({ files })=>Effect.sync(()=>{
1700
+ for (const filepath of files){
1701
+ const content = readFileSync(filepath, "utf-8");
1702
+ const sorted = sort_package_json(content);
1703
+ if (sorted !== content) writeFileSync(filepath, sorted, "utf-8");
1704
+ }
1705
+ }));
1706
+ const pnpmWorkspaceCommand = Command.make("pnpm-workspace", {}, ()=>Effect.sync(()=>{
1707
+ const filepath = "pnpm-workspace.yaml";
1708
+ if (!existsSync(filepath)) return;
1709
+ const content = readFileSync(filepath, "utf-8");
1710
+ const parsed = external_yaml_parse(content);
1711
+ const sorted = PnpmWorkspace.sortContent(parsed);
1712
+ const formatted = stringify(sorted, YAML_STRINGIFY_OPTIONS);
1713
+ writeFileSync(filepath, formatted, "utf-8");
1714
+ }));
1715
+ const yamlCommand = Command.make("yaml", {
1716
+ files: filesArg
1717
+ }, ({ files })=>Effect.gen(function*() {
1718
+ for (const filepath of files)yield* Effect.promise(()=>Yaml.formatFile(filepath));
1719
+ }));
1720
+ const fmtCommand = Command.make("fmt").pipe(Command.withSubcommands([
1721
+ packageJsonCommand,
1722
+ pnpmWorkspaceCommand,
1723
+ yamlCommand
1724
+ ]));
1518
1725
  const rootCommand = Command.make("savvy-lint").pipe(Command.withSubcommands([
1519
1726
  initCommand,
1520
- checkCommand
1727
+ checkCommand,
1728
+ fmtCommand
1521
1729
  ]));
1522
1730
  const cli = Command.run(rootCommand, {
1523
1731
  name: "savvy-lint",
1524
- version: "0.3.2"
1732
+ version: "0.4.0"
1525
1733
  });
1526
1734
  function runCli() {
1527
1735
  const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer));
1528
1736
  NodeRuntime.runMain(main);
1529
1737
  }
1530
- export { Biome, Command_Command as Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, TsDocLinter, TsDocResolver, TypeScript, checkCommand, existsSync, initCommand, readFileSync, rootCommand, runCli, writeFileSync };
1738
+ export { Biome, Command_Command as Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, PnpmWorkspace, TsDocLinter, TsDocResolver, TypeScript, Yaml, checkCommand, fmtCommand, initCommand, readFileSync, rootCommand, runCli, sort_package_json, writeFileSync };
package/README.md CHANGED
@@ -76,27 +76,30 @@ export default {
76
76
  | `PackageJson` | `**/package.json` | Sort and format with Biome |
77
77
  | `Biome` | `*.{js,ts,jsx,tsx,json,jsonc}` | Format and lint |
78
78
  | `Markdown` | `**/*.{md,mdx}` | Lint with markdownlint-cli2 |
79
- | `Yaml` | `**/*.{yml,yaml}` | Format and validate |
79
+ | `Yaml` | `**/*.{yml,yaml}` | Format (Prettier) and validate (yaml-lint) |
80
80
  | `PnpmWorkspace` | `pnpm-workspace.yaml` | Sort and format |
81
81
  | `ShellScripts` | `**/*.sh` | Manage permissions |
82
82
  | `TypeScript` | `*.{ts,cts,mts,tsx}` | TSDoc validation + typecheck |
83
83
 
84
84
  ## CLI
85
85
 
86
- The `savvy-lint` CLI helps bootstrap and validate your setup:
86
+ The `savvy-lint` CLI helps bootstrap, validate, and format your setup:
87
87
 
88
88
  ```bash
89
89
  savvy-lint init # Bootstrap hooks, config, and tooling
90
90
  savvy-lint init --preset silk --force # Overwrite with silk preset
91
91
  savvy-lint check # Validate current configuration
92
92
  savvy-lint check --quiet # Warnings only (for postinstall)
93
+ savvy-lint fmt package-json # Sort package.json fields
94
+ savvy-lint fmt yaml # Format YAML files with Prettier
95
+ savvy-lint fmt pnpm-workspace # Sort and format pnpm-workspace.yaml
93
96
  ```
94
97
 
95
98
  ## Documentation
96
99
 
97
100
  - [Handler Configuration](./docs/handlers.md) -- Detailed options for each handler
98
101
  - [Configuration API](./docs/configuration.md) -- createConfig and Preset APIs
99
- - [CLI Reference](./docs/cli.md) -- `savvy-lint init` and `savvy-lint check`
102
+ - [CLI Reference](./docs/cli.md) -- `savvy-lint init`, `check`, and `fmt`
100
103
  - [Utilities](./docs/utilities.md) -- Command, Filter, and advanced utilities
101
104
  - [Migration Guide](./docs/migration.md) -- Migrating from raw lint-staged configs
102
105
 
package/index.d.ts CHANGED
@@ -24,7 +24,6 @@ import { FileSystem } from '@effect/platform';
24
24
  import { FileSystem as FileSystem_2 } from '@effect/platform/FileSystem';
25
25
  import { Option } from 'effect/Option';
26
26
  import { PlatformError } from '@effect/platform/Error';
27
- import { stringify } from 'yaml';
28
27
 
29
28
  /**
30
29
  * Base options shared by all handlers.
@@ -154,13 +153,37 @@ export declare const checkCommand: Command_2.Command<"check", FileSystem.FileSys
154
153
  export declare class Command {
155
154
  /** Cached package manager detection result */
156
155
  private static cachedPackageManager;
156
+ /** Cached project root path */
157
+ private static cachedRoot;
158
+ /**
159
+ * Find the project root directory using workspace-tools.
160
+ *
161
+ * Uses `findProjectRoot()` from `workspace-tools` to locate the nearest
162
+ * directory containing a `package.json`. Falls back to the provided `cwd`
163
+ * (or `process.cwd()`) on error.
164
+ *
165
+ * @remarks
166
+ * This is more reliable than `process.cwd()` in environments like Husky
167
+ * hooks where the working directory may point to `.husky/` or another
168
+ * subdirectory.
169
+ *
170
+ * @param cwd - Starting directory for the search (defaults to `process.cwd()`)
171
+ * @returns The resolved project root path
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const root = Command.findRoot();
176
+ * console.log(root); // '/Users/me/my-project'
177
+ * ```
178
+ */
179
+ static findRoot(cwd?: string): string;
157
180
  /**
158
181
  * Detect the package manager from the root package.json's `packageManager` field.
159
182
  *
160
183
  * Parses the `packageManager` field (e.g., `pnpm\@9.0.0`) and extracts the manager name.
161
184
  * Falls back to "npm" if no packageManager field is found.
162
185
  *
163
- * @param cwd - Directory to search for package.json (defaults to process.cwd())
186
+ * @param cwd - Directory to search for package.json (defaults to `Command.findRoot()`)
164
187
  * @returns The detected package manager
165
188
  *
166
189
  * @example
@@ -181,12 +204,12 @@ export declare class Command {
181
204
  * Command.getExecPrefix('pnpm'); // ['pnpm', 'exec']
182
205
  * Command.getExecPrefix('npm'); // ['npx', '--no']
183
206
  * Command.getExecPrefix('yarn'); // ['yarn', 'exec']
184
- * Command.getExecPrefix('bun'); // ['bunx']
207
+ * Command.getExecPrefix('bun'); // ['bun', 'x', "--no-install"]
185
208
  * ```
186
209
  */
187
210
  static getExecPrefix(packageManager: PackageManager): string[];
188
211
  /**
189
- * Clear the cached package manager detection.
212
+ * Clear the cached package manager and project root detection.
190
213
  * Useful for testing or when package.json changes.
191
214
  */
192
215
  static clearCache(): void;
@@ -239,6 +262,17 @@ export declare class Command {
239
262
  * ```
240
263
  */
241
264
  static requireTool(tool: string, errorMessage?: string): string;
265
+ /**
266
+ * Find the savvy-lint CLI command.
267
+ *
268
+ * @remarks
269
+ * Searches for the `savvy-lint` binary via the standard tool search,
270
+ * then falls back to the dev build at `dist/dev/bin/savvy-lint.js`
271
+ * for dogfooding scenarios where the package's own bin isn't linked.
272
+ *
273
+ * @returns The command string to invoke savvy-lint
274
+ */
275
+ static findSavvyLint(): string;
242
276
  /**
243
277
  * Execute a command and return its output.
244
278
  *
@@ -294,7 +328,7 @@ export declare class ConfigSearch {
294
328
  /**
295
329
  * Find a configuration file for a known tool.
296
330
  *
297
- * Supported tools: 'markdownlint', 'biome', 'eslint', 'prettier'
331
+ * Supported tools: 'markdownlint', 'biome', 'eslint', 'prettier', 'yamllint'
298
332
  *
299
333
  * @param tool - The tool name
300
334
  * @param options - Search options
@@ -308,7 +342,7 @@ export declare class ConfigSearch {
308
342
  * }
309
343
  * ```
310
344
  */
311
- static find(tool: "markdownlint" | "biome" | "eslint" | "prettier", options?: ConfigSearchOptions): ConfigSearchResult;
345
+ static find(tool: "markdownlint" | "biome" | "eslint" | "prettier" | "yamllint", options?: ConfigSearchOptions): ConfigSearchResult;
312
346
  /**
313
347
  * Find a configuration file with custom search locations.
314
348
  *
@@ -613,6 +647,15 @@ export declare class Filter {
613
647
  static shellEscape(filenames: readonly string[]): string;
614
648
  }
615
649
 
650
+ /** Parent fmt command with formatting subcommands. */
651
+ export declare const fmtCommand: Command_2.Command<"fmt", never, never, {
652
+ readonly subcommand: Option< {
653
+ readonly files: string[];
654
+ } | {} | {
655
+ readonly files: string[];
656
+ }>;
657
+ }>;
658
+
616
659
  /**
617
660
  * Abstract base class for lint-staged handlers.
618
661
  *
@@ -813,9 +856,18 @@ export declare const initCommand: Command_2.Command<"init", FileSystem.FileSyste
813
856
 
814
857
  /**
815
858
  * A lint-staged configuration object.
816
- * Maps glob patterns to handlers.
859
+ * Maps glob patterns to handlers, commands, or arrays of sequential steps.
860
+ *
861
+ * @remarks
862
+ * When a value is an array of functions/strings, lint-staged runs each element
863
+ * sequentially with proper staging between steps.
817
864
  */
818
- export declare type LintStagedConfig = Record<string, LintStagedHandler | string | string[]>;
865
+ export declare type LintStagedConfig = Record<string, LintStagedEntry | LintStagedEntry[]>;
866
+
867
+ /**
868
+ * A single lint-staged command entry: a handler function, a string command, or an array of strings.
869
+ */
870
+ export declare type LintStagedEntry = LintStagedHandler | string | string[];
819
871
 
820
872
  /**
821
873
  * A lint-staged handler function.
@@ -961,6 +1013,25 @@ export declare class PackageJson {
961
1013
  * Pre-configured handler with default options.
962
1014
  */
963
1015
  static readonly handler: LintStagedHandler;
1016
+ /**
1017
+ * Create a handler with custom options.
1018
+ *
1019
+ * @param options - Configuration options
1020
+ * @returns A lint-staged compatible handler function
1021
+ */
1022
+ /**
1023
+ * Create a handler that returns a CLI command to sort package.json files.
1024
+ *
1025
+ * @remarks
1026
+ * Unlike {@link create}, this does not modify files in the handler function
1027
+ * body. Instead it returns a `savvy-lint fmt package-json` command so
1028
+ * lint-staged can detect the modification and auto-stage it.
1029
+ * Use this in lint-staged array syntax for sequential execution.
1030
+ *
1031
+ * @param options - Configuration options
1032
+ * @returns A lint-staged compatible handler function
1033
+ */
1034
+ static fmtCommand(options?: PackageJsonOptions): LintStagedHandler;
964
1035
  /**
965
1036
  * Create a handler with custom options.
966
1037
  *
@@ -979,6 +1050,11 @@ export declare interface PackageJsonOptions extends BaseHandlerOptions {
979
1050
  * @defaultValue false
980
1051
  */
981
1052
  skipSort?: boolean;
1053
+ /**
1054
+ * Skip Biome formatting (sort only).
1055
+ * @defaultValue false
1056
+ */
1057
+ skipFormat?: boolean;
982
1058
  /**
983
1059
  * Path to Biome config file.
984
1060
  */
@@ -1068,6 +1144,18 @@ export declare class PnpmWorkspace {
1068
1144
  * @returns Sorted content
1069
1145
  */
1070
1146
  static sortContent(content: PnpmWorkspaceContent): PnpmWorkspaceContent;
1147
+ /**
1148
+ * Create a handler that returns a CLI command to sort/format pnpm-workspace.yaml.
1149
+ *
1150
+ * @remarks
1151
+ * Unlike {@link create}, this does not modify files in the handler function
1152
+ * body. Instead it returns a `savvy-lint fmt pnpm-workspace` command so
1153
+ * lint-staged can detect the modification and auto-stage it.
1154
+ * Use this in lint-staged array syntax for sequential execution.
1155
+ *
1156
+ * @returns A lint-staged compatible handler function
1157
+ */
1158
+ static fmtCommand(): LintStagedHandler;
1071
1159
  /**
1072
1160
  * Create a handler with custom options.
1073
1161
  *
@@ -1232,6 +1320,12 @@ export declare const rootCommand: Command_2.Command<"savvy-lint", FileSystem_2,
1232
1320
  readonly preset: "minimal" | "silk" | "standard";
1233
1321
  } | {
1234
1322
  readonly quiet: boolean;
1323
+ } | {
1324
+ readonly subcommand: Option< {
1325
+ readonly files: string[];
1326
+ } | {} | {
1327
+ readonly files: string[];
1328
+ }>;
1235
1329
  }>;
1236
1330
  }>;
1237
1331
 
@@ -1589,17 +1683,24 @@ export declare class TypeScript {
1589
1683
  * @defaultValue `['.test.', '.spec.', '__test__', '__tests__']`
1590
1684
  */
1591
1685
  static readonly defaultTsdocExcludes: readonly [".test.", ".spec.", "__test__", "__tests__"];
1686
+ /** Cached compiler detection result */
1687
+ private static cachedCompilerResult;
1592
1688
  /**
1593
- * Detect which TypeScript compiler to use based on package.json dependencies.
1689
+ * Detect which TypeScript compiler to use.
1594
1690
  *
1595
- * Checks for:
1596
- * 1. `\@typescript/native-preview` in dependencies/devDependencies `tsgo`
1597
- * 2. `typescript` in dependencies/devDependencies `tsc`
1691
+ * Uses `Command.findTool()` to check for available compilers:
1692
+ * 1. `tsgo` (native TypeScript) checked first
1693
+ * 2. `tsc` (standard TypeScript) fallback
1598
1694
  *
1599
- * @param cwd - Directory to search for package.json (defaults to process.cwd())
1600
- * @returns The compiler to use, or undefined if neither is installed
1695
+ * @remarks
1696
+ * Unlike the previous implementation that parsed `package.json` dependencies,
1697
+ * this uses runtime tool detection which works correctly with pnpm catalogs,
1698
+ * peer dependencies, and hoisted/transitive deps.
1699
+ *
1700
+ * @param _cwd - Ignored (kept for backward compatibility)
1701
+ * @returns The compiler to use, or undefined if neither is available
1601
1702
  */
1602
- static detectCompiler(cwd?: string): TypeScriptCompiler | undefined;
1703
+ static detectCompiler(_cwd?: string): TypeScriptCompiler | undefined;
1603
1704
  /**
1604
1705
  * Check if a TypeScript compiler is available.
1605
1706
  *
@@ -1607,12 +1708,21 @@ export declare class TypeScript {
1607
1708
  */
1608
1709
  static isAvailable(): boolean;
1609
1710
  /**
1610
- * Get the default type checking command for the detected package manager and compiler.
1711
+ * Get the default type checking command for the detected compiler.
1712
+ *
1713
+ * @remarks
1714
+ * Uses the cached `ToolSearchResult` from `detectCompiler()` to build
1715
+ * the command string, avoiding a separate package manager detection step.
1611
1716
  *
1612
- * @returns Command string like `pnpm exec tsgo --noEmit` or `npx --no tsc --noEmit`
1613
- * @throws Error if no TypeScript compiler is detected in package.json
1717
+ * @returns Command string like `pnpm exec tsgo --noEmit` or `tsgo --noEmit`
1718
+ * @throws Error if no TypeScript compiler is available
1614
1719
  */
1615
1720
  static getDefaultTypecheckCommand(): string;
1721
+ /**
1722
+ * Clear the cached compiler detection result.
1723
+ * Useful for testing or when the environment changes.
1724
+ */
1725
+ static clearCache(): void;
1616
1726
  /**
1617
1727
  * Pre-configured handler with default options.
1618
1728
  * Auto-discovers workspaces for TSDoc linting.
@@ -1679,14 +1789,14 @@ export declare interface TypeScriptOptions extends BaseHandlerOptions {
1679
1789
  /**
1680
1790
  * Handler for YAML files.
1681
1791
  *
1682
- * Formats and validates YAML files using the bundled yaml library.
1792
+ * Formats with Prettier and validates with yaml-lint, both as bundled dependencies.
1683
1793
  *
1684
1794
  * @remarks
1685
1795
  * Excludes pnpm-lock.yaml and pnpm-workspace.yaml by default.
1686
1796
  * pnpm-workspace.yaml has its own dedicated handler.
1687
1797
  *
1688
- * Uses the `yaml` package for both formatting and validation
1689
- * as a bundled dependency (no CLI spawning required).
1798
+ * Uses Prettier for formatting and yaml-lint for validation.
1799
+ * Both are bundled dependencies (no CLI spawning required).
1690
1800
  *
1691
1801
  * @example
1692
1802
  * ```typescript
@@ -1715,19 +1825,55 @@ export declare class Yaml {
1715
1825
  */
1716
1826
  static readonly handler: LintStagedHandler;
1717
1827
  /**
1718
- * Format a YAML file in-place.
1828
+ * Find the yaml-lint config file.
1829
+ *
1830
+ * Searches in order:
1831
+ * 1. `lib/configs/` directory
1832
+ * 2. Standard locations (repo root)
1833
+ *
1834
+ * @returns The config file path, or undefined if not found
1835
+ */
1836
+ static findConfig(): string | undefined;
1837
+ /**
1838
+ * Load the yaml-lint schema from a config file.
1839
+ *
1840
+ * @param filepath - Path to the yaml-lint config file
1841
+ * @returns The schema string, or undefined if not found
1842
+ */
1843
+ static loadConfig(filepath: string): string | undefined;
1844
+ /**
1845
+ * Check if yaml-lint is available.
1846
+ *
1847
+ * @returns Always `true` since yaml-lint is a bundled dependency
1848
+ */
1849
+ static isAvailable(): boolean;
1850
+ /**
1851
+ * Format a YAML file in-place using Prettier.
1719
1852
  *
1720
1853
  * @param filepath - Path to the YAML file
1721
- * @param options - Stringify options for the yaml package
1722
1854
  */
1723
- static formatFile(filepath: string, options?: Parameters<typeof stringify>[1]): void;
1855
+ static formatFile(filepath: string): Promise<void>;
1724
1856
  /**
1725
- * Validate a YAML file.
1857
+ * Validate a YAML file using yaml-lint.
1726
1858
  *
1727
1859
  * @param filepath - Path to the YAML file
1860
+ * @param schema - The YAML schema to validate against
1728
1861
  * @throws Error if the YAML is invalid
1729
1862
  */
1730
- static validateFile(filepath: string): void;
1863
+ static validateFile(filepath: string, schema?: string): Promise<void>;
1864
+ /**
1865
+ * Create a handler that returns a CLI command to format YAML files.
1866
+ *
1867
+ * @remarks
1868
+ * Unlike {@link create}, this does not modify files in the handler function
1869
+ * body. Instead it returns a `savvy-lint fmt yaml` command so lint-staged
1870
+ * can detect the modification and auto-stage it.
1871
+ * Use this in lint-staged array syntax for sequential execution.
1872
+ *
1873
+ * @param options - Configuration options
1874
+ * @returns A lint-staged compatible handler function
1875
+ */
1876
+ static fmtCommand(options?: YamlOptions): LintStagedHandler;
1731
1877
  /**
1732
1878
  * Create a handler with custom options.
1733
1879
  *
@@ -1741,6 +1887,10 @@ export declare class Yaml {
1741
1887
  * Options for the Yaml handler.
1742
1888
  */
1743
1889
  export declare interface YamlOptions extends BaseHandlerOptions {
1890
+ /**
1891
+ * Path to yaml-lint config file (.yaml-lint.json).
1892
+ */
1893
+ config?: string;
1744
1894
  /**
1745
1895
  * Skip YAML formatting.
1746
1896
  * @defaultValue false
package/index.js CHANGED
@@ -1,6 +1,4 @@
1
- import sort_package_json from "sort-package-json";
2
- import { parse, stringify } from "yaml";
3
- import { Biome, Markdown, readFileSync, TypeScript, writeFileSync, existsSync, Filter } from "./376.js";
1
+ import { Biome, Command as Command_Command, readFileSync, Yaml, TypeScript, sort_package_json, PnpmWorkspace, writeFileSync, Markdown, Filter } from "./376.js";
4
2
  class PackageJson {
5
3
  static glob = "**/package.json";
6
4
  static defaultExcludes = [
@@ -8,11 +6,23 @@ class PackageJson {
8
6
  "__fixtures__"
9
7
  ];
10
8
  static handler = PackageJson.create();
9
+ static fmtCommand(options = {}) {
10
+ const excludes = options.exclude ?? [
11
+ ...PackageJson.defaultExcludes
12
+ ];
13
+ return (filenames)=>{
14
+ const filtered = Filter.exclude(filenames, excludes);
15
+ if (0 === filtered.length) return [];
16
+ const cmd = Command_Command.findSavvyLint();
17
+ return `${cmd} fmt package-json ${Filter.shellEscape(filtered)}`;
18
+ };
19
+ }
11
20
  static create(options = {}) {
12
21
  const excludes = options.exclude ?? [
13
22
  ...PackageJson.defaultExcludes
14
23
  ];
15
24
  const skipSort = options.skipSort ?? false;
25
+ const skipFormat = options.skipFormat ?? false;
16
26
  return (filenames)=>{
17
27
  const filtered = Filter.exclude(filenames, excludes);
18
28
  if (0 === filtered.length) return [];
@@ -21,66 +31,13 @@ class PackageJson {
21
31
  const sorted = sort_package_json(content);
22
32
  if (sorted !== content) writeFileSync(filepath, sorted, "utf-8");
23
33
  }
34
+ if (skipFormat) return [];
24
35
  const files = Filter.shellEscape(filtered);
25
36
  const biomeCmd = options.biomeConfig ? `biome check --write --max-diagnostics=none --config-path=${options.biomeConfig} ${files}` : `biome check --write --max-diagnostics=none ${files}`;
26
37
  return biomeCmd;
27
38
  };
28
39
  }
29
40
  }
30
- const DEFAULT_STRINGIFY_OPTIONS = {
31
- indent: 2,
32
- lineWidth: 0,
33
- singleQuote: false
34
- };
35
- class PnpmWorkspace {
36
- static glob = "pnpm-workspace.yaml";
37
- static defaultExcludes = [];
38
- static handler = PnpmWorkspace.create();
39
- static SORTABLE_ARRAY_KEYS = new Set([
40
- "packages",
41
- "onlyBuiltDependencies",
42
- "publicHoistPattern"
43
- ]);
44
- static sortContent(content) {
45
- const result = {};
46
- const keys = Object.keys(content).sort((a, b)=>{
47
- if ("packages" === a) return -1;
48
- if ("packages" === b) return 1;
49
- return a.localeCompare(b);
50
- });
51
- for (const key of keys){
52
- const value = content[key];
53
- if (PnpmWorkspace.SORTABLE_ARRAY_KEYS.has(key) && Array.isArray(value)) result[key] = [
54
- ...value
55
- ].sort();
56
- else result[key] = value;
57
- }
58
- return result;
59
- }
60
- static create(options = {}) {
61
- const skipSort = options.skipSort ?? false;
62
- const skipFormat = options.skipFormat ?? false;
63
- const skipLint = options.skipLint ?? false;
64
- return ()=>{
65
- const filepath = "pnpm-workspace.yaml";
66
- if (!existsSync(filepath)) return [];
67
- const content = readFileSync(filepath, "utf-8");
68
- let parsed;
69
- try {
70
- parsed = parse(content);
71
- } catch (error) {
72
- if (!skipLint) throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
73
- return [];
74
- }
75
- if (!skipSort) parsed = PnpmWorkspace.sortContent(parsed);
76
- if (!skipSort || !skipFormat) {
77
- const formatted = stringify(parsed, DEFAULT_STRINGIFY_OPTIONS);
78
- writeFileSync(filepath, formatted, "utf-8");
79
- }
80
- return [];
81
- };
82
- }
83
- }
84
41
  class ShellScripts {
85
42
  static glob = "**/*.sh";
86
43
  static defaultExcludes = [
@@ -100,57 +57,27 @@ class ShellScripts {
100
57
  };
101
58
  }
102
59
  }
103
- const Yaml_DEFAULT_STRINGIFY_OPTIONS = {
104
- indent: 2,
105
- lineWidth: 0,
106
- singleQuote: false
107
- };
108
- class Yaml {
109
- static glob = "**/*.{yml,yaml}";
110
- static defaultExcludes = [
111
- "pnpm-lock.yaml",
112
- "pnpm-workspace.yaml"
113
- ];
114
- static handler = Yaml.create();
115
- static formatFile(filepath, options) {
116
- const content = readFileSync(filepath, "utf-8");
117
- const parsed = parse(content);
118
- const formatted = stringify(parsed, {
119
- ...Yaml_DEFAULT_STRINGIFY_OPTIONS,
120
- ...options
121
- });
122
- writeFileSync(filepath, formatted, "utf-8");
123
- }
124
- static validateFile(filepath) {
125
- const content = readFileSync(filepath, "utf-8");
126
- parse(content);
127
- }
128
- static create(options = {}) {
129
- const excludes = options.exclude ?? [
130
- ...Yaml.defaultExcludes
131
- ];
132
- const skipFormat = options.skipFormat ?? false;
133
- const skipValidate = options.skipValidate ?? false;
134
- return (filenames)=>{
135
- const filtered = Filter.exclude(filenames, excludes);
136
- if (0 === filtered.length) return [];
137
- if (!skipFormat) for (const filepath of filtered)Yaml.formatFile(filepath);
138
- if (!skipValidate) for (const filepath of filtered)try {
139
- Yaml.validateFile(filepath);
140
- } catch (error) {
141
- throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
142
- }
143
- return [];
144
- };
145
- }
146
- }
147
60
  function createConfig(options = {}) {
148
61
  const config = {};
149
- if (false !== options.packageJson) {
150
- const handlerOptions = "object" == typeof options.packageJson ? options.packageJson : {};
151
- config[PackageJson.glob] = PackageJson.create(handlerOptions);
62
+ const pkgJsonEnabled = false !== options.packageJson;
63
+ const biomeEnabled = false !== options.biome;
64
+ if (pkgJsonEnabled && biomeEnabled) {
65
+ const pkgOpts = "object" == typeof options.packageJson ? options.packageJson : {};
66
+ const biomeOpts = "object" == typeof options.biome ? options.biome : {};
67
+ config[PackageJson.glob] = [
68
+ PackageJson.fmtCommand(pkgOpts),
69
+ Biome.create({
70
+ ...biomeOpts,
71
+ exclude: [
72
+ ...PackageJson.defaultExcludes
73
+ ]
74
+ })
75
+ ];
76
+ } else if (pkgJsonEnabled) {
77
+ const pkgOpts = "object" == typeof options.packageJson ? options.packageJson : {};
78
+ config[PackageJson.glob] = PackageJson.create(pkgOpts);
152
79
  }
153
- if (false !== options.biome) {
80
+ if (biomeEnabled) {
154
81
  const handlerOptions = "object" == typeof options.biome ? options.biome : {};
155
82
  config[Biome.glob] = Biome.create(handlerOptions);
156
83
  }
@@ -158,13 +85,28 @@ function createConfig(options = {}) {
158
85
  const handlerOptions = "object" == typeof options.markdown ? options.markdown : {};
159
86
  config[Markdown.glob] = Markdown.create(handlerOptions);
160
87
  }
161
- if (false !== options.yaml) {
162
- const handlerOptions = "object" == typeof options.yaml ? options.yaml : {};
163
- config[Yaml.glob] = Yaml.create(handlerOptions);
164
- }
165
- if (false !== options.pnpmWorkspace) {
166
- const handlerOptions = "object" == typeof options.pnpmWorkspace ? options.pnpmWorkspace : {};
167
- config[PnpmWorkspace.glob] = PnpmWorkspace.create(handlerOptions);
88
+ const pnpmEnabled = false !== options.pnpmWorkspace;
89
+ const yamlEnabled = false !== options.yaml;
90
+ if (pnpmEnabled && yamlEnabled) config[PnpmWorkspace.glob] = [
91
+ PnpmWorkspace.fmtCommand(),
92
+ Yaml.create({
93
+ exclude: [],
94
+ skipFormat: true
95
+ })
96
+ ];
97
+ else if (pnpmEnabled) {
98
+ const pnpmOpts = "object" == typeof options.pnpmWorkspace ? options.pnpmWorkspace : {};
99
+ config[PnpmWorkspace.glob] = PnpmWorkspace.create(pnpmOpts);
100
+ }
101
+ if (yamlEnabled) {
102
+ const yamlOpts = "object" == typeof options.yaml ? options.yaml : {};
103
+ config[Yaml.glob] = [
104
+ Yaml.fmtCommand(yamlOpts),
105
+ Yaml.create({
106
+ ...yamlOpts,
107
+ skipFormat: true
108
+ })
109
+ ];
168
110
  }
169
111
  if (false !== options.shellScripts) {
170
112
  const handlerOptions = "object" == typeof options.shellScripts ? options.shellScripts : {};
@@ -236,5 +178,5 @@ class Handler {
236
178
  throw new Error("Handler.create() must be implemented by subclass");
237
179
  }
238
180
  }
239
- export { Biome, Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, TsDocLinter, TsDocResolver, TypeScript, checkCommand, initCommand, rootCommand, runCli } from "./376.js";
240
- export { Handler, PackageJson, PnpmWorkspace, Preset, ShellScripts, Yaml, createConfig };
181
+ export { Biome, Command, ConfigSearch, EntryExtractor, Filter, ImportGraph, Markdown, PnpmWorkspace, TsDocLinter, TsDocResolver, TypeScript, Yaml, checkCommand, fmtCommand, initCommand, rootCommand, runCli } from "./376.js";
182
+ export { Handler, PackageJson, Preset, ShellScripts, createConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/lint-staged",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Composable, configurable lint-staged handlers for pre-commit hooks. Provides reusable handlers for Biome, Markdown, YAML, TypeScript, and more.",
6
6
  "keywords": [
@@ -49,21 +49,32 @@
49
49
  "eslint": "^9.39.2",
50
50
  "eslint-plugin-tsdoc": "^0.5.0",
51
51
  "jsonc-parser": "^3.3.1",
52
+ "prettier": "^3.8.1",
52
53
  "sort-package-json": "^3.6.1",
53
54
  "workspace-tools": "^0.41.0",
54
- "yaml": "^2.8.2"
55
+ "yaml": "^2.8.2",
56
+ "yaml-lint": "^1.7.0"
55
57
  },
56
58
  "peerDependencies": {
57
59
  "@biomejs/biome": "2.3.14",
60
+ "@types/node": "^25.2.1",
61
+ "@typescript/native-preview": "7.0.0-dev.20260207.1",
58
62
  "husky": "^9.1.7",
59
63
  "lint-staged": "^16.2.7",
60
64
  "markdownlint-cli2": "^0.20.0",
61
- "markdownlint-cli2-formatter-codequality": "^0.0.7"
65
+ "markdownlint-cli2-formatter-codequality": "^0.0.7",
66
+ "typescript": "^5.9.3"
62
67
  },
63
68
  "peerDependenciesMeta": {
64
69
  "@biomejs/biome": {
65
70
  "optional": true
66
71
  },
72
+ "@types/node": {
73
+ "optional": false
74
+ },
75
+ "@typescript/native-preview": {
76
+ "optional": false
77
+ },
67
78
  "husky": {
68
79
  "optional": false
69
80
  },
@@ -75,8 +86,14 @@
75
86
  },
76
87
  "markdownlint-cli2-formatter-codequality": {
77
88
  "optional": false
89
+ },
90
+ "typescript": {
91
+ "optional": false
78
92
  }
79
93
  },
94
+ "engines": {
95
+ "node": ">=24.0.0"
96
+ },
80
97
  "scripts": {
81
98
  "postinstall": "savvy-lint check --quiet || true"
82
99
  },
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.56.2"
8
+ "packageVersion": "7.56.3"
9
9
  }
10
10
  ]
11
11
  }