@savvy-web/cli 0.3.0 → 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.
Files changed (39) hide show
  1. package/bin/savvy.d.ts +1 -0
  2. package/bin/savvy.js +17 -1
  3. package/cli/index.js +123 -0
  4. package/commands/changeset/commands/analyze-branch.js +108 -0
  5. package/commands/changeset/commands/check.js +71 -0
  6. package/commands/changeset/commands/classify.js +69 -0
  7. package/commands/changeset/commands/config-show.js +100 -0
  8. package/commands/changeset/commands/config-validate.js +63 -0
  9. package/commands/changeset/commands/deps-detect.js +103 -0
  10. package/commands/changeset/commands/deps-regen.js +277 -0
  11. package/commands/changeset/commands/init.js +634 -0
  12. package/commands/changeset/commands/lint.js +62 -0
  13. package/commands/changeset/commands/release-surface.js +96 -0
  14. package/commands/changeset/commands/transform.js +88 -0
  15. package/commands/changeset/commands/validate-file.js +52 -0
  16. package/commands/changeset/commands/version.js +178 -0
  17. package/commands/changeset/index.js +42 -0
  18. package/commands/changeset/utils/config-gate.js +59 -0
  19. package/commands/check.js +74 -0
  20. package/commands/clean.js +186 -0
  21. package/commands/commit/check.js +170 -0
  22. package/commands/commit/constants.js +10 -0
  23. package/commands/commit/hook.js +22 -0
  24. package/commands/commit/hooks/post-commit-verify.js +121 -0
  25. package/commands/commit/hooks/pre-commit-message.js +64 -0
  26. package/commands/commit/hooks/session-start.js +69 -0
  27. package/commands/commit/hooks/user-prompt-submit.js +42 -0
  28. package/commands/commit/index.js +20 -0
  29. package/commands/commit/init.js +127 -0
  30. package/commands/init.js +88 -0
  31. package/commands/lint/check.js +306 -0
  32. package/commands/lint/fmt.js +64 -0
  33. package/commands/lint/index.js +20 -0
  34. package/commands/lint/init.js +221 -0
  35. package/index.d.ts +237 -244
  36. package/index.js +14 -1
  37. package/package.json +39 -51
  38. package/841.js +0 -2394
  39. package/tsdoc-metadata.json +0 -11
@@ -0,0 +1,42 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Commitlint } from "@savvy-web/silk-effects";
3
+ import { Effect, Schema } from "effect";
4
+
5
+ //#region src/commands/commit/hooks/user-prompt-submit.ts
6
+ /**
7
+ * `savvy commit hook user-prompt-submit` — emits a compact reminder when
8
+ * the prompt mentions commit-related verbs.
9
+ *
10
+ * @internal
11
+ */
12
+ const TRIGGER = /(\bcommit\b|\bcommitting\b|\bship (it|this)\b|\bwrap (it )?up\b|\b(create|open) a (pr|pull request)\b|\bfinalize\b|\/finalize\b|\bsquash\b|\bamend\b)/i;
13
+ function reminderForPrompt(prompt) {
14
+ if (!TRIGGER.test(prompt)) return null;
15
+ return [
16
+ "<commit_reminder>",
17
+ "Before composing this commit message, invoke the commitlint:commit-create skill.",
18
+ "It defines the complete type enum, scope rules, DCO signoff format, and body",
19
+ "constraints enforced by @savvy-web/commitlint.",
20
+ "</commit_reminder>"
21
+ ].join("\n");
22
+ }
23
+ const userPromptSubmitCommand = Command.make("user-prompt-submit", {}, () => Effect.gen(function* () {
24
+ const stdin = yield* Effect.promise(readStdin);
25
+ let envelope;
26
+ try {
27
+ envelope = Schema.decodeUnknownSync(Commitlint.UserPromptSubmitEnvelope)(JSON.parse(stdin));
28
+ } catch {
29
+ return;
30
+ }
31
+ const reminder = reminderForPrompt(envelope.prompt);
32
+ if (reminder === null) return;
33
+ yield* Effect.sync(() => process.stdout.write(`${JSON.stringify(Commitlint.userPromptSubmitContext(reminder))}\n`));
34
+ }).pipe(Effect.provide(Commitlint.HookSilencer))).pipe(Command.withDescription("Inject a commit-quality reminder when the user prompt mentions commits"));
35
+ async function readStdin() {
36
+ const chunks = [];
37
+ for await (const chunk of process.stdin) chunks.push(chunk);
38
+ return Buffer.concat(chunks).toString("utf8");
39
+ }
40
+
41
+ //#endregion
42
+ export { userPromptSubmitCommand };
@@ -0,0 +1,20 @@
1
+ import { runCommitInit } from "./init.js";
2
+ import { runCommitCheck } from "./check.js";
3
+ import { hookCommand } from "./hook.js";
4
+ import { Command } from "@effect/cli";
5
+
6
+ //#region src/commands/commit/index.ts
7
+ /* v8 ignore start -- CLI registration; each command tested via exported handler */
8
+ const _commitCommand = Command.make("commit").pipe(Command.withSubcommands([hookCommand]), Command.withDescription("Commit standards: config, checks, and Claude hook handlers"));
9
+ /**
10
+ * The `savvy commit` command group for use in Task B7 root assembly.
11
+ *
12
+ * @remarks
13
+ * Typed as `unknown` at the export boundary to avoid TypeScript declaration-emit
14
+ * errors from Effect's internal types. Task B7 should import and use this directly
15
+ * as `Command.withSubcommands([commitCommand])` — the cast is for declaration emit only.
16
+ */
17
+ const commitCommand = _commitCommand;
18
+
19
+ //#endregion
20
+ export { commitCommand };
@@ -0,0 +1,127 @@
1
+ import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
2
+ import { Command, Options } from "@effect/cli";
3
+ import { ManagedSection, SavvyBaseSection, SavvyHooksSection, SectionDefinition, savvyBasePreamble, savvyHooksHygiene, savvyToolSection } from "@savvy-web/silk-effects";
4
+ import { Effect } from "effect";
5
+ import { dirname } from "node:path";
6
+ import { FileSystem } from "@effect/platform";
7
+ import { chmod } from "node:fs/promises";
8
+
9
+ //#region src/commands/commit/init.ts
10
+ /**
11
+ * Init command - bootstrap commitlint configuration.
12
+ *
13
+ * @internal
14
+ */
15
+ /** Executable file permission mode. */
16
+ const EXECUTABLE_MODE = 493;
17
+ /** Default path for the commitlint config file. */
18
+ const DEFAULT_CONFIG_PATH = "lib/configs/commitlint.config.ts";
19
+ /** Section definition for the savvy-commit tool section (identity for read/check/remove). */
20
+ const SECTION_DEF = SectionDefinition.make({ toolName: "savvy-commit" });
21
+ /** Header written when creating a fresh commit-msg hook. */
22
+ const COMMIT_MSG_HEADER = "#!/usr/bin/env sh\n# Commit-msg hook with savvy managed sections\n# Custom hooks can go above, below, or between the managed sections\n\n";
23
+ /** Header written when creating a fresh hygiene hook (post-checkout / post-merge). */
24
+ const HYGIENE_HEADER = "#!/usr/bin/env sh\n# Managed by savvy-hooks\n# Custom hooks can go above or below the managed section\n\n";
25
+ /**
26
+ * Build the commitlint command run inside the savvy-commit tool section.
27
+ *
28
+ * @param configPath - Path to the commitlint config file (relative to repo root)
29
+ */
30
+ function commitlintCommand(configPath) {
31
+ return `commitlint --config "$ROOT/${configPath}" --edit "$1"`;
32
+ }
33
+ /**
34
+ * Build the savvy-commit tool section block for the given config path.
35
+ *
36
+ * @remarks
37
+ * Depends on the savvy-base preamble (`in_ci`, `pm_exec`) preceding it in the hook.
38
+ *
39
+ * @remarks
40
+ * Exported for reuse by the check command, which rebuilds this block to compare against the on-disk section.
41
+ */
42
+ function savvyCommitBlock(configPath) {
43
+ return savvyToolSection("savvy-commit", commitlintCommand(configPath));
44
+ }
45
+ /** Ensure a hook file exists, writing `header` if it does not. */
46
+ function ensureHookFile(path, header) {
47
+ return Effect.gen(function* () {
48
+ const fs = yield* FileSystem.FileSystem;
49
+ if (!(yield* fs.exists(path))) yield* fs.writeFileString(path, header);
50
+ });
51
+ }
52
+ /* v8 ignore start -- CLI option definitions; handler tested individually */
53
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite the commit-msg hook and config file entirely (managed sections in post-checkout/post-merge are never force-reset)"), Options.withDefault(false));
54
+ const configOption = Options.text("config").pipe(Options.withAlias("c"), Options.withDescription("Relative path for the commitlint config file (from repo root)"), Options.withDefault(DEFAULT_CONFIG_PATH));
55
+ /* v8 ignore stop */
56
+ /** Content for the commitlint config file. */
57
+ const CONFIG_CONTENT = `import { CommitlintConfig } from "@savvy-web/silk/commitlint";
58
+
59
+ export default CommitlintConfig.silk();
60
+ `;
61
+ /** Make a file executable. */
62
+ function makeExecutable(path) {
63
+ return Effect.tryPromise({
64
+ try: () => chmod(path, EXECUTABLE_MODE),
65
+ catch: (e) => new Error(String(e))
66
+ });
67
+ }
68
+ /**
69
+ * Run the full init pipeline.
70
+ *
71
+ * Exported so Task B5's unified `savvy init` orchestrator can invoke the
72
+ * commitlint init step directly without going through the CLI command layer.
73
+ *
74
+ * @param opts - The same options the CLI command receives
75
+ * @returns An Effect that performs initialization
76
+ *
77
+ * @internal
78
+ */
79
+ function runCommitInit(opts) {
80
+ const { force, config } = opts;
81
+ return Effect.gen(function* () {
82
+ const fs = yield* FileSystem.FileSystem;
83
+ const ms = yield* ManagedSection;
84
+ if (config.startsWith("/")) yield* Effect.fail(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
85
+ yield* Effect.log("Initializing commitlint configuration...\n");
86
+ yield* fs.makeDirectory(".husky", { recursive: true });
87
+ if (force) yield* fs.writeFileString(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
88
+ else yield* ensureHookFile(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
89
+ const commitResults = yield* ms.syncMany(HUSKY_HOOK_PATH, [SavvyBaseSection.block(savvyBasePreamble()), savvyCommitBlock(config)]);
90
+ yield* makeExecutable(HUSKY_HOOK_PATH);
91
+ yield* Effect.log(`${"✓"} ${force ? "Replaced" : "Synced"} ${HUSKY_HOOK_PATH} (${commitResults.map((r) => r._tag).join(", ")})`);
92
+ for (const hookPath of [POST_CHECKOUT_HOOK_PATH, POST_MERGE_HOOK_PATH]) {
93
+ yield* ensureHookFile(hookPath, HYGIENE_HEADER);
94
+ yield* ms.sync(hookPath, SavvyHooksSection.block(savvyHooksHygiene()));
95
+ yield* makeExecutable(hookPath);
96
+ yield* Effect.log(`${"✓"} Synced ${hookPath}`);
97
+ }
98
+ if ((yield* fs.exists(config)) && !force) yield* Effect.log(`${"⚠"} ${config} already exists (use --force to overwrite)`);
99
+ else {
100
+ const configDir = dirname(config);
101
+ if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
102
+ yield* fs.writeFileString(config, CONFIG_CONTENT);
103
+ yield* Effect.log(`${"✓"} Created ${config}`);
104
+ }
105
+ yield* Effect.log("\nDone! Install @commitlint/cli if not already installed.");
106
+ });
107
+ }
108
+ /**
109
+ * Init command implementation.
110
+ *
111
+ * @remarks
112
+ * Writes:
113
+ * - `.husky/commit-msg` — savvy-base preamble + savvy-commit tool section.
114
+ * - `.husky/post-checkout` and `.husky/post-merge` — savvy-hooks hygiene
115
+ * (co-owned with `@savvy-web/lint-staged`; idempotent).
116
+ * - The commitlint config file.
117
+ *
118
+ * Users may add custom commands above, below, or between the managed sections.
119
+ */
120
+ /* v8 ignore next 3 -- CLI registration; handler tested via runCommitInit */
121
+ const initCommand = Command.make("init", {
122
+ force: forceOption,
123
+ config: configOption
124
+ }, (opts) => runCommitInit(opts)).pipe(Command.withDescription("Initialize commitlint configuration and husky hooks"));
125
+
126
+ //#endregion
127
+ export { SECTION_DEF, runCommitInit, savvyCommitBlock };
@@ -0,0 +1,88 @@
1
+ import { runChangesetInit } from "./changeset/commands/init.js";
2
+ import "./changeset/index.js";
3
+ import { runCommitInit } from "./commit/init.js";
4
+ import { runLintInit } from "./lint/init.js";
5
+ import { Command, Options } from "@effect/cli";
6
+ import { Effect } from "effect";
7
+
8
+ //#region src/commands/init.ts
9
+ /**
10
+ * Unified `savvy init` orchestrator.
11
+ *
12
+ * @remarks
13
+ * Sequences the three tool-specific init handlers — changeset → commit → lint —
14
+ * in a single pass. Short-circuits on the first failure. The three step Effects
15
+ * are injected so the orchestration logic is unit-testable in isolation.
16
+ *
17
+ * Runtime layer provision (ManagedSection, FileSystem, WorkspaceRoot,
18
+ * BiomeSchemaSync, etc.) is deferred to Task B7 (root `runCli`). The Effect
19
+ * returned by `initCommand`'s handler therefore carries the full union of the
20
+ * three handlers' requirements in its R channel.
21
+ *
22
+ * @internal
23
+ */
24
+ const DEFAULT_COMMIT_CONFIG = "lib/configs/commitlint.config.ts";
25
+ const DEFAULT_LINT_CONFIG = "lib/configs/lint-staged.config.ts";
26
+ /* v8 ignore start -- CLI option definitions; orchestration logic tested via runInit */
27
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite existing config files and hooks across all tools"), Options.withDefault(false));
28
+ const commitConfigOption = Options.text("commit-config").pipe(Options.withDescription("Relative path for the commitlint config file"), Options.withDefault(DEFAULT_COMMIT_CONFIG));
29
+ const lintConfigOption = Options.text("lint-config").pipe(Options.withDescription("Relative path for the lint-staged config file"), Options.withDefault(DEFAULT_LINT_CONFIG));
30
+ const lintPresetOption = Options.choice("lint-preset", [
31
+ "minimal",
32
+ "standard",
33
+ "silk"
34
+ ]).pipe(Options.withDescription("lint-staged preset: minimal, standard, or silk"), Options.withDefault("silk"));
35
+ /* v8 ignore stop */
36
+ /**
37
+ * Run the three init step Effects in order: changeset → commit → lint.
38
+ *
39
+ * Monadic sequencing via `Effect.gen` short-circuits on the first failure —
40
+ * if changeset fails, neither commit nor lint will run.
41
+ *
42
+ * @param steps - The three step Effects to sequence. Injected for testability.
43
+ * @returns An Effect that resolves to `void` on success, or fails with the
44
+ * error of the first failing step.
45
+ */
46
+ function runInit(steps) {
47
+ return Effect.gen(function* () {
48
+ yield* steps.changeset;
49
+ yield* steps.commit;
50
+ yield* steps.lint;
51
+ });
52
+ }
53
+ /* v8 ignore start -- CLI registration; orchestration logic tested via runInit */
54
+ const _initCommand = Command.make("init", {
55
+ force: forceOption,
56
+ commitConfig: commitConfigOption,
57
+ lintConfig: lintConfigOption,
58
+ lintPreset: lintPresetOption
59
+ }, (opts) => runInit({
60
+ changeset: runChangesetInit({
61
+ force: opts.force,
62
+ quiet: false,
63
+ skipMarkdownlint: false,
64
+ check: false
65
+ }),
66
+ commit: runCommitInit({
67
+ force: opts.force,
68
+ config: opts.commitConfig
69
+ }),
70
+ lint: runLintInit({
71
+ force: opts.force,
72
+ config: opts.lintConfig,
73
+ preset: opts.lintPreset
74
+ })
75
+ })).pipe(Command.withDescription("Bootstrap a repository for all Silk Suite tools in one pass"));
76
+ /* v8 ignore stop */
77
+ /**
78
+ * The `savvy init` command for use in the Task B7 root assembly.
79
+ *
80
+ * @remarks
81
+ * Typed with `any` at the export boundary to avoid TypeScript declaration-emit
82
+ * errors from Effect's internal types. Task B7 should use this via
83
+ * `Command.withSubcommands([initCommand as never])` or re-infer the type.
84
+ */
85
+ const initCommand = _initCommand;
86
+
87
+ //#endregion
88
+ export { initCommand, runInit };
@@ -0,0 +1,306 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { CheckResult, ConfigDiscovery, Lint, ManagedSection, SavvyBaseSection, SavvyHooksSection, ToolDefinition, ToolDiscovery, savvyBasePreamble, savvyHooksHygiene } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { parse } from "jsonc-effect";
5
+ import { FileSystem } from "@effect/platform";
6
+ import { isDeepStrictEqual } from "node:util";
7
+
8
+ //#region src/commands/lint/check.ts
9
+ /**
10
+ * Check command - validate current lint-staged setup.
11
+ *
12
+ * @internal
13
+ */
14
+ /** Unicode checkmark symbol. */
15
+ const CHECK_MARK = "✓";
16
+ /** Unicode cross symbol. */
17
+ const CROSS_MARK = "✗";
18
+ /** Unicode warning symbol. */
19
+ const WARNING = "⚠";
20
+ /** Unicode bullet symbol. */
21
+ const BULLET = "•";
22
+ /** Paths to search for config files. */
23
+ const CONFIG_SEARCH_PATHS = [
24
+ "lib/configs/lint-staged.config.ts",
25
+ "lib/configs/lint-staged.config.js",
26
+ ...[
27
+ "lint-staged.config.ts",
28
+ "lint-staged.config.js",
29
+ "lint-staged.config.mjs",
30
+ "lint-staged.config.cjs",
31
+ ".lintstagedrc",
32
+ ".lintstagedrc.json",
33
+ ".lintstagedrc.yaml",
34
+ ".lintstagedrc.yml",
35
+ ".lintstagedrc.js",
36
+ ".lintstagedrc.cjs",
37
+ ".lintstagedrc.mjs"
38
+ ]
39
+ ];
40
+ /**
41
+ * Find the first existing config file.
42
+ *
43
+ * @param fs - FileSystem service
44
+ * @returns Effect yielding the config file name or null
45
+ */
46
+ function findConfigFile(fs) {
47
+ return Effect.gen(function* () {
48
+ for (const file of CONFIG_SEARCH_PATHS) if (yield* fs.exists(file)) return file;
49
+ return null;
50
+ });
51
+ }
52
+ /**
53
+ * Find the first existing config file from a list of candidates using ConfigDiscovery.
54
+ *
55
+ * @param discovery - ConfigDiscovery service
56
+ * @param names - Config file names to search for (in priority order)
57
+ * @returns The config file path or null
58
+ */
59
+ function findConfig(discovery, names) {
60
+ return Effect.gen(function* () {
61
+ for (const name of names) {
62
+ const result = yield* discovery.find(name);
63
+ if (result) return result.path;
64
+ }
65
+ return null;
66
+ });
67
+ }
68
+ /**
69
+ * Extract the config path from the managed section.
70
+ *
71
+ * @param managedContent - The content between managed section markers
72
+ * @returns The config path found, or null if not found
73
+ */
74
+ function extractConfigPathFromManaged(managedContent) {
75
+ const match = managedContent.match(/lint-staged --config "\$ROOT\/([^"]+)"/);
76
+ return match ? match[1] : null;
77
+ }
78
+ /**
79
+ * Check the markdownlint-cli2 config against the template.
80
+ *
81
+ * @param content - The existing file content
82
+ * @returns Status object with match details
83
+ */
84
+ function checkMarkdownlintConfig(content) {
85
+ return Effect.gen(function* () {
86
+ const parsed = yield* parse(content);
87
+ const schemaMatches = parsed.$schema === Lint.MARKDOWNLINT_SCHEMA;
88
+ const existingConfig = parsed.config;
89
+ const configMatches = existingConfig !== void 0 && isDeepStrictEqual(existingConfig, Lint.MARKDOWNLINT_CONFIG);
90
+ return {
91
+ exists: true,
92
+ schemaMatches,
93
+ configMatches,
94
+ isUpToDate: schemaMatches && configMatches
95
+ };
96
+ });
97
+ }
98
+ /**
99
+ * Check biome config `$schema` URLs against the expected peer dependency version.
100
+ *
101
+ * @remarks
102
+ * Uses `Lint.Biome.findAllConfigs()` for workspace-aware discovery, then validates
103
+ * each config's `$schema` URL by reading and parsing the file directly with JSONC.
104
+ *
105
+ * @returns Object with warnings and per-config status
106
+ */
107
+ function checkBiomeSchemas() {
108
+ return Effect.gen(function* () {
109
+ const version = process.env.__BIOME_PEER_VERSION__;
110
+ const statuses = [];
111
+ if (!version) return {
112
+ statuses,
113
+ warnings: []
114
+ };
115
+ const fs = yield* FileSystem.FileSystem;
116
+ const warnings = [];
117
+ const expectedSchema = `https://biomejs.dev/schemas/${version}/schema.json`;
118
+ const configPaths = Lint.Biome.findAllConfigs();
119
+ for (const configPath of configPaths) if ((yield* parse(yield* fs.readFileString(configPath))).$schema === expectedSchema) statuses.push({
120
+ path: configPath,
121
+ matches: true
122
+ });
123
+ else {
124
+ statuses.push({
125
+ path: configPath,
126
+ matches: false
127
+ });
128
+ warnings.push(`${WARNING} ${configPath}: biome $schema is outdated.\n Run 'savvy init' to update it.`);
129
+ }
130
+ return {
131
+ statuses,
132
+ warnings
133
+ };
134
+ });
135
+ }
136
+ /* v8 ignore start -- CLI option definition; handler tested individually */
137
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output warnings (for postinstall usage)"), Options.withDefault(false));
138
+ /* v8 ignore stop */
139
+ /**
140
+ * Run the lint check validation pipeline.
141
+ *
142
+ * Exported so Task B6's unified `savvy check` orchestrator can invoke the
143
+ * lint check step directly without going through the CLI command layer.
144
+ *
145
+ * @param opts - Options for the check command
146
+ * @returns An Effect that performs validation and logs results
147
+ *
148
+ * @internal
149
+ */
150
+ function runLintCheck(opts) {
151
+ const { quiet } = opts;
152
+ return Effect.gen(function* () {
153
+ const fs = yield* FileSystem.FileSystem;
154
+ const ms = yield* ManagedSection;
155
+ const td = yield* ToolDiscovery;
156
+ const discovery = yield* ConfigDiscovery;
157
+ const warnings = [];
158
+ const foundConfig = yield* findConfigFile(fs);
159
+ const hasHuskyHook = yield* fs.exists(Lint.HUSKY_HOOK_PATH);
160
+ let sectionsHealthy = true;
161
+ let baseStatusLabel = "missing";
162
+ let lintStatusLabel = "missing";
163
+ let detectedConfigPath = null;
164
+ if (hasHuskyHook) {
165
+ const baseResult = yield* ms.check(Lint.HUSKY_HOOK_PATH, SavvyBaseSection.block(savvyBasePreamble()));
166
+ if (CheckResult.$is("Found")(baseResult)) {
167
+ baseStatusLabel = baseResult.isUpToDate ? "up-to-date" : "outdated";
168
+ if (!baseResult.isUpToDate) sectionsHealthy = false;
169
+ } else sectionsHealthy = false;
170
+ const existing = yield* ms.read(Lint.HUSKY_HOOK_PATH, Lint.SavvyLintSectionDef);
171
+ if (existing) {
172
+ const configPath = extractConfigPathFromManaged(existing.content);
173
+ detectedConfigPath = configPath;
174
+ if (configPath) {
175
+ const lintResult = yield* ms.check(Lint.HUSKY_HOOK_PATH, Lint.savvyLintBlock(configPath));
176
+ if (CheckResult.$is("Found")(lintResult)) {
177
+ lintStatusLabel = lintResult.isUpToDate ? "up-to-date" : "outdated";
178
+ if (!lintResult.isUpToDate) sectionsHealthy = false;
179
+ } else {
180
+ lintStatusLabel = "outdated";
181
+ sectionsHealthy = false;
182
+ }
183
+ } else {
184
+ lintStatusLabel = "outdated";
185
+ sectionsHealthy = false;
186
+ }
187
+ } else sectionsHealthy = false;
188
+ if (baseStatusLabel !== "up-to-date" || lintStatusLabel !== "up-to-date") warnings.push(`${WARNING} Your ${Lint.HUSKY_HOOK_PATH} managed sections are out of date.\n Run 'savvy init' to update (preserves your custom hooks).`);
189
+ } else {
190
+ sectionsHealthy = false;
191
+ warnings.push(`${WARNING} No husky pre-commit hook found.\n Run 'savvy init' to create it.`);
192
+ }
193
+ if (!foundConfig) warnings.push(`${WARNING} No lint-staged config file found.\n Run 'savvy init' to create one.`);
194
+ const shellHookPaths = [Lint.POST_CHECKOUT_HOOK_PATH, Lint.POST_MERGE_HOOK_PATH];
195
+ const shellHookStatuses = [];
196
+ for (const hookPath of shellHookPaths) {
197
+ if (!(yield* fs.exists(hookPath))) {
198
+ shellHookStatuses.push({
199
+ path: hookPath,
200
+ found: false,
201
+ isUpToDate: false
202
+ });
203
+ continue;
204
+ }
205
+ const hygieneResult = yield* ms.check(hookPath, SavvyHooksSection.block(savvyHooksHygiene()));
206
+ const found = CheckResult.$is("Found")(hygieneResult);
207
+ const isUpToDate = CheckResult.$is("Found")(hygieneResult) && hygieneResult.isUpToDate;
208
+ shellHookStatuses.push({
209
+ path: hookPath,
210
+ found,
211
+ isUpToDate
212
+ });
213
+ if (!found) {
214
+ sectionsHealthy = false;
215
+ warnings.push(`${WARNING} ${hookPath} has no savvy-hooks section.\n Run 'savvy init' to add it.`);
216
+ } else if (!isUpToDate) {
217
+ sectionsHealthy = false;
218
+ warnings.push(`${WARNING} ${hookPath} savvy-hooks section is outdated.\n Run 'savvy init' to update.`);
219
+ }
220
+ }
221
+ const biomeSchemaStatus = yield* checkBiomeSchemas().pipe(Effect.catchAll(() => Effect.succeed({
222
+ statuses: [],
223
+ warnings: [`${WARNING} Could not check biome $schema URLs.`]
224
+ })));
225
+ warnings.push(...biomeSchemaStatus.warnings);
226
+ const hasMarkdownlintConfig = yield* fs.exists(Lint.MARKDOWNLINT_CONFIG_PATH);
227
+ let markdownlintStatus = {
228
+ exists: false,
229
+ schemaMatches: false,
230
+ configMatches: false,
231
+ isUpToDate: false
232
+ };
233
+ if (hasMarkdownlintConfig) {
234
+ markdownlintStatus = yield* checkMarkdownlintConfig(yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH));
235
+ if (!markdownlintStatus.schemaMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy init' to update it.`);
236
+ if (!markdownlintStatus.configMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy init --force' to overwrite.`);
237
+ }
238
+ if (quiet) {
239
+ if (warnings.length > 0) for (const warning of warnings) yield* Effect.log(warning);
240
+ return;
241
+ }
242
+ yield* Effect.log("Checking lint-staged configuration...\n");
243
+ if (foundConfig) yield* Effect.log(`${CHECK_MARK} Config file: ${foundConfig}`);
244
+ else yield* Effect.log(`${CROSS_MARK} No lint-staged config file found`);
245
+ if (hasHuskyHook) yield* Effect.log(`${CHECK_MARK} Husky hook: ${Lint.HUSKY_HOOK_PATH}`);
246
+ else yield* Effect.log(`${CROSS_MARK} No husky pre-commit hook found`);
247
+ if (hasHuskyHook) {
248
+ if (baseStatusLabel === "up-to-date") yield* Effect.log(`${CHECK_MARK} Base section: up-to-date`);
249
+ else if (baseStatusLabel === "outdated") yield* Effect.log(`${WARNING} Base section: outdated (run 'savvy init' to update)`);
250
+ else yield* Effect.log(`${BULLET} Base section: not found (run 'savvy init' to add)`);
251
+ const lintLabel = detectedConfigPath ? ` (config: ${detectedConfigPath})` : "";
252
+ if (lintStatusLabel === "up-to-date") yield* Effect.log(`${CHECK_MARK} Lint section: up-to-date${lintLabel}`);
253
+ else if (lintStatusLabel === "outdated") yield* Effect.log(`${WARNING} Lint section: outdated (run 'savvy init' to update)`);
254
+ else yield* Effect.log(`${BULLET} Lint section: not found (run 'savvy init' to add)`);
255
+ }
256
+ for (const status of shellHookStatuses) if (!status.found) yield* Effect.log(`${BULLET} ${status.path}: savvy-hooks section not found`);
257
+ else if (status.isUpToDate) yield* Effect.log(`${CHECK_MARK} ${status.path}: up-to-date`);
258
+ else yield* Effect.log(`${WARNING} ${status.path}: outdated (run 'savvy init' to update)`);
259
+ yield* Effect.log("\nTool availability:");
260
+ const biomeAvailable = yield* td.isAvailable(ToolDefinition.make({ name: "biome" }));
261
+ const biomeConfig = yield* findConfig(discovery, ["biome.jsonc", "biome.json"]);
262
+ if (biomeAvailable) {
263
+ const configInfo = biomeConfig ? ` (config: ${biomeConfig})` : "";
264
+ yield* Effect.log(` ${CHECK_MARK} Biome${configInfo}`);
265
+ } else yield* Effect.log(` ${BULLET} Biome: not installed`);
266
+ const markdownAvailable = yield* td.isAvailable(ToolDefinition.make({ name: "markdownlint-cli2" }));
267
+ const markdownConfig = yield* findConfig(discovery, [
268
+ ".markdownlint-cli2.jsonc",
269
+ ".markdownlint-cli2.json",
270
+ ".markdownlint-cli2.yaml",
271
+ ".markdownlint-cli2.cjs",
272
+ ".markdownlint.jsonc",
273
+ ".markdownlint.json",
274
+ ".markdownlint.yaml"
275
+ ]);
276
+ if (markdownAvailable) {
277
+ const configInfo = markdownConfig ? ` (config: ${markdownConfig})` : "";
278
+ yield* Effect.log(` ${CHECK_MARK} markdownlint-cli2${configInfo}`);
279
+ } else yield* Effect.log(` ${BULLET} markdownlint-cli2: not installed`);
280
+ const tsgoAvailable = yield* td.isAvailable(ToolDefinition.make({ name: "tsgo" }));
281
+ const tscAvailable = yield* td.isAvailable(ToolDefinition.make({ name: "tsc" }));
282
+ if (tsgoAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsgo)`);
283
+ else if (tscAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsc)`);
284
+ else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
285
+ if (hasMarkdownlintConfig) if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
286
+ else {
287
+ const issues = [];
288
+ if (!markdownlintStatus.schemaMatches) issues.push("$schema");
289
+ if (!markdownlintStatus.configMatches) issues.push("config");
290
+ yield* Effect.log(` ${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
291
+ }
292
+ else yield* Effect.log(` ${BULLET} ${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
293
+ for (const status of biomeSchemaStatus.statuses) if (status.matches) yield* Effect.log(` ${CHECK_MARK} ${status.path}: biome $schema up-to-date`);
294
+ else yield* Effect.log(` ${WARNING} ${status.path}: biome $schema outdated (run 'savvy init' to update)`);
295
+ yield* Effect.log("");
296
+ const hasMarkdownlintIssues = hasMarkdownlintConfig && !markdownlintStatus.isUpToDate;
297
+ const hasBiomeSchemaIssues = biomeSchemaStatus.statuses.some((s) => !s.matches);
298
+ if (!foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintIssues || hasBiomeSchemaIssues) yield* Effect.log(`${WARNING} Some issues found. Run 'savvy init' to fix.`);
299
+ else yield* Effect.log(`${CHECK_MARK} Lint-staged is configured correctly.`);
300
+ });
301
+ }
302
+ /* v8 ignore next 3 -- CLI registration; handler tested via runLintCheck */
303
+ const checkCommand = Command.make("check", { quiet: quietOption }, (opts) => runLintCheck(opts)).pipe(Command.withDescription("Check current lint-staged configuration and tool availability"));
304
+
305
+ //#endregion
306
+ export { runLintCheck };
@@ -0,0 +1,64 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Lint } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { parse, stringify } from "yaml";
6
+
7
+ //#region src/commands/lint/fmt.ts
8
+ /**
9
+ * Fmt command - format files in-place for lint-staged staging.
10
+ *
11
+ * @remarks
12
+ * These subcommands modify files via CLI commands rather than in handler
13
+ * function bodies, so lint-staged can detect the modifications and
14
+ * auto-stage them between array steps.
15
+ *
16
+ * @internal
17
+ */
18
+ /** Default YAML stringify options matching PnpmWorkspace handler. */
19
+ const YAML_STRINGIFY_OPTIONS = {
20
+ indent: 2,
21
+ lineWidth: 0,
22
+ singleQuote: false
23
+ };
24
+ /** Repeated file path arguments. */
25
+ const filesArg = Args.repeated(Args.file({
26
+ name: "files",
27
+ exists: "yes"
28
+ }));
29
+ /** Sort package.json files with sort-package-json. */
30
+ const packageJsonCommand = Command.make("package-json", { files: filesArg }, ({ files }) => Effect.sync(() => {
31
+ for (const filepath of files) {
32
+ const content = readFileSync(filepath, "utf-8");
33
+ const sorted = Lint.PackageJson.sortContent(content);
34
+ if (sorted !== content) writeFileSync(filepath, sorted, "utf-8");
35
+ }
36
+ }));
37
+ /** Sort and format pnpm-workspace.yaml. */
38
+ const pnpmWorkspaceCommand = Command.make("pnpm-workspace", {}, () => Effect.sync(() => {
39
+ const filepath = "pnpm-workspace.yaml";
40
+ if (!existsSync(filepath)) return;
41
+ const parsed = parse(readFileSync(filepath, "utf-8"));
42
+ writeFileSync(filepath, stringify(Lint.PnpmWorkspace.sortContent(parsed), YAML_STRINGIFY_OPTIONS), "utf-8");
43
+ }));
44
+ /** Format YAML files with Prettier. */
45
+ const yamlCommand = Command.make("yaml", { files: filesArg }, ({ files }) => Effect.gen(function* () {
46
+ for (const filepath of files) yield* Effect.promise(() => Lint.Yaml.formatFile(filepath));
47
+ }));
48
+ /** Parent fmt command with formatting subcommands. */
49
+ const _fmtCommand = Command.make("fmt").pipe(Command.withSubcommands([
50
+ packageJsonCommand,
51
+ pnpmWorkspaceCommand,
52
+ yamlCommand
53
+ ]));
54
+ /**
55
+ * The `savvy lint fmt` command group with package-json, pnpm-workspace, and yaml subcommands.
56
+ *
57
+ * @remarks
58
+ * Typed as `unknown` at the export boundary to avoid TypeScript declaration-emit
59
+ * errors from Effect's internal types. The cast is for declaration emit only.
60
+ */
61
+ const fmtCommand = _fmtCommand;
62
+
63
+ //#endregion
64
+ export { fmtCommand };
@@ -0,0 +1,20 @@
1
+ import { runLintCheck } from "./check.js";
2
+ import { runLintInit } from "./init.js";
3
+ import { fmtCommand } from "./fmt.js";
4
+ import { Command } from "@effect/cli";
5
+
6
+ //#region src/commands/lint/index.ts
7
+ /* v8 ignore start -- CLI registration; each command tested via exported handler */
8
+ const _lintCommand = Command.make("lint").pipe(Command.withSubcommands([fmtCommand]), Command.withDescription("Code-quality: lint-staged config, checks, and in-place formatting"));
9
+ /**
10
+ * The `savvy lint` command group for use in Task B7 root assembly.
11
+ *
12
+ * @remarks
13
+ * Typed as `unknown` at the export boundary to avoid TypeScript declaration-emit
14
+ * errors from Effect's internal types. Task B7 should import and use this directly
15
+ * as `Command.withSubcommands([lintCommand])` — the cast is for declaration emit only.
16
+ */
17
+ const lintCommand = _lintCommand;
18
+
19
+ //#endregion
20
+ export { lintCommand };