@savvy-web/cli 3.5.1 → 3.6.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 (43) hide show
  1. package/cli/index.js +19 -3
  2. package/commands/changeset/commands/check.js +82 -21
  3. package/commands/changeset/commands/config-validate.js +9 -6
  4. package/commands/changeset/commands/deps-detect.js +2 -2
  5. package/commands/changeset/commands/deps-regen.js +94 -48
  6. package/commands/changeset/commands/init.js +56 -23
  7. package/commands/changeset/commands/transform.js +82 -8
  8. package/commands/changeset/commands/validate-file.js +12 -11
  9. package/commands/changeset/commands/version.js +48 -17
  10. package/commands/changeset/index.js +3 -3
  11. package/commands/changeset/utils/config-gate.js +12 -10
  12. package/commands/check-section.js +34 -0
  13. package/commands/check.js +48 -39
  14. package/commands/clean/progress.js +71 -0
  15. package/commands/clean/view.js +26 -0
  16. package/commands/clean.js +113 -50
  17. package/commands/commit/check.js +80 -77
  18. package/commands/commit/init.js +21 -10
  19. package/commands/commit/lint.js +2 -2
  20. package/commands/init.js +122 -34
  21. package/commands/lint/check.js +126 -171
  22. package/commands/lint/init.js +52 -33
  23. package/commands/lint/text.js +28 -13
  24. package/commands/repos/commands/add.js +14 -17
  25. package/commands/repos/commands/deregister.js +37 -18
  26. package/commands/repos/commands/note.js +78 -26
  27. package/commands/repos/commands/pin.js +39 -24
  28. package/commands/repos/commands/remove.js +30 -56
  29. package/commands/repos/commands/rename.js +32 -23
  30. package/commands/repos/commands/restore.js +42 -56
  31. package/commands/repos/commands/status.js +43 -47
  32. package/commands/repos/commands/sync.js +11 -40
  33. package/commands/repos/index.js +7 -10
  34. package/commands/repos/shared.js +129 -0
  35. package/index.d.ts +162 -61
  36. package/internal/command-error.js +60 -0
  37. package/internal/confirm.js +52 -0
  38. package/internal/failure-line.js +26 -12
  39. package/internal/report.js +101 -0
  40. package/main.js +23 -16
  41. package/package.json +17 -12
  42. package/version.js +1 -1
  43. package/internal/output.js +0 -52
package/commands/init.js CHANGED
@@ -1,38 +1,118 @@
1
+ import { Report } from "../internal/report.js";
2
+ import { CommandError } from "../internal/command-error.js";
1
3
  import { runChangesetInit } from "./changeset/commands/init.js";
2
4
  import "./changeset/index.js";
5
+ import { HUSKY_HOOK_PATH } from "./commit/constants.js";
3
6
  import { runCommitInit } from "./commit/init.js";
7
+ import { confirmDestructive, yesFlag } from "../internal/confirm.js";
4
8
  import { runLintInit } from "./lint/init.js";
5
9
  import { Command, Flag } from "effect/cli";
6
- import { Effect } from "effect";
10
+ import { Lint } from "@savvy-web/silk-effects";
11
+ import { Effect, FileSystem } from "effect";
12
+ import { CliUi, Select } from "@effected/cli/ui";
7
13
 
8
14
  //#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 `main`). 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
15
  const DEFAULT_COMMIT_CONFIG = "lib/configs/commitlint.config.ts";
25
16
  const DEFAULT_LINT_CONFIG = "lib/configs/lint-staged.config.ts";
26
17
  /* v8 ignore start -- CLI option definitions; orchestration logic tested via runInit */
27
18
  const forceOption = Flag.Boolean("force").pipe(Flag.withAlias("f"), Flag.withDescription("Overwrite existing config files and hooks across all tools"), Flag.withDefault(false));
28
19
  const commitConfigOption = Flag.String("commit-config").pipe(Flag.withDescription("Relative path for the commitlint config file"), Flag.withDefault(DEFAULT_COMMIT_CONFIG));
29
20
  const lintConfigOption = Flag.String("lint-config").pipe(Flag.withDescription("Relative path for the lint-staged config file"), Flag.withDefault(DEFAULT_LINT_CONFIG));
21
+ /** The preset a run that cannot ask uses: an agent, CI, a pipe. */
22
+ const DEFAULT_LINT_PRESET = "silk";
23
+ /**
24
+ * `--lint-preset`: given, it is used as is; omitted, a person at a terminal
25
+ * picks one from a `Select` (starting on `silk`), and every other run uses
26
+ * `silk` without loading Ink or React.
27
+ *
28
+ * @internal
29
+ */
30
30
  const lintPresetOption = Flag.Literals("lint-preset", [
31
31
  "minimal",
32
32
  "standard",
33
33
  "silk"
34
- ]).pipe(Flag.withDescription("lint-staged preset: minimal, standard, or silk"), Flag.withDefault("silk"));
35
- /* v8 ignore stop */
34
+ ]).pipe(Flag.withDescription("lint-staged preset: minimal, standard, or silk (asked when omitted at a terminal; default silk)"), Flag.withFallbackPrompt(CliUi.fallback(Select.screen({
35
+ message: "Which lint-staged preset?",
36
+ choices: [
37
+ {
38
+ label: "silk",
39
+ value: "silk",
40
+ detail: "Everything in standard, plus the managed markdownlint config"
41
+ },
42
+ {
43
+ label: "standard",
44
+ value: "standard",
45
+ detail: "Biome, shell-script hygiene hooks and markdown"
46
+ },
47
+ {
48
+ label: "minimal",
49
+ value: "minimal",
50
+ detail: "Biome only"
51
+ }
52
+ ]
53
+ }), {
54
+ flag: "lint-preset",
55
+ otherwise: DEFAULT_LINT_PRESET
56
+ })));
57
+ /**
58
+ * The files `savvy init --force` replaces wholesale, for the given config
59
+ * paths and preset.
60
+ *
61
+ * @remarks
62
+ * Only the files `--force` rewrites from scratch; the managed sections it
63
+ * syncs into the other hooks are re-synced with or without `--force`.
64
+ *
65
+ * @internal
66
+ */
67
+ function forceTargets(opts) {
68
+ return [
69
+ ".changeset/config.json",
70
+ ".changeset/.markdownlint.json",
71
+ HUSKY_HOOK_PATH,
72
+ opts.commitConfig,
73
+ Lint.HUSKY_HOOK_PATH,
74
+ ...opts.lintPreset === "silk" ? [Lint.MARKDOWNLINT_CONFIG_PATH] : [],
75
+ opts.lintConfig
76
+ ];
77
+ }
78
+ /**
79
+ * Whether `savvy init` may go ahead: asks before `--force` overwrites files
80
+ * that already exist.
81
+ *
82
+ * @remarks
83
+ * Asks only when `force` is set and at least one of `targets` exists; `--yes`
84
+ * and a run that cannot prompt proceed without asking, as `init --force`
85
+ * always has. A "no" prints a line saying nothing was changed and answers
86
+ * `false`.
87
+ *
88
+ * @internal
89
+ */
90
+ function confirmForce(opts) {
91
+ return Effect.gen(function* () {
92
+ if (!opts.force) return true;
93
+ const fs = yield* FileSystem.FileSystem;
94
+ const existing = yield* Effect.filter(opts.targets, (path) => fs.exists(path).pipe(Effect.orElseSucceed(() => false)));
95
+ if (existing.length === 0) return true;
96
+ const confirmed = yield* confirmDestructive({
97
+ message: `Overwrite ${existing.length} existing file(s) with --force? (${existing.join(", ")})`,
98
+ yes: opts.yes
99
+ });
100
+ if (!confirmed) yield* Report.print([Report.skip("Nothing changed: existing files kept")]);
101
+ return confirmed;
102
+ });
103
+ }
104
+ /**
105
+ * The failure a step of `savvy init` stopped with, as a `CommandError` that
106
+ * draws itself; a `CommandError` a step already raised passes through.
107
+ *
108
+ * @internal
109
+ */
110
+ function initFailure(error) {
111
+ return error instanceof CommandError ? error : CommandError.from(error, {
112
+ message: "savvy init stopped before finishing",
113
+ hint: "Fix the problem above and re-run savvy init; the steps that already ran are safe to repeat."
114
+ });
115
+ }
36
116
  /**
37
117
  * Run the three init step Effects in order: changeset → commit → lint.
38
118
  *
@@ -53,25 +133,33 @@ function runInit(steps) {
53
133
  /* v8 ignore start -- CLI registration; orchestration logic tested via runInit */
54
134
  const _initCommand = Command.make("init", {
55
135
  force: forceOption,
136
+ yes: yesFlag,
56
137
  commitConfig: commitConfigOption,
57
138
  lintConfig: lintConfigOption,
58
139
  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({
140
+ }, (opts) => Effect.gen(function* () {
141
+ if (!(yield* confirmForce({
71
142
  force: opts.force,
72
- config: opts.lintConfig,
73
- preset: opts.lintPreset
74
- })
143
+ yes: opts.yes,
144
+ targets: forceTargets(opts)
145
+ }))) return;
146
+ yield* runInit({
147
+ changeset: runChangesetInit({
148
+ force: opts.force,
149
+ quiet: false,
150
+ skipMarkdownlint: false,
151
+ check: false
152
+ }),
153
+ commit: runCommitInit({
154
+ force: opts.force,
155
+ config: opts.commitConfig
156
+ }),
157
+ lint: runLintInit({
158
+ force: opts.force,
159
+ config: opts.lintConfig,
160
+ preset: opts.lintPreset
161
+ })
162
+ }).pipe(Effect.mapError(initFailure));
75
163
  })).pipe(Command.withDescription("Bootstrap a repository for all Silk Suite tools in one pass"));
76
164
  /* v8 ignore stop */
77
165
  /**
@@ -85,4 +173,4 @@ const _initCommand = Command.make("init", {
85
173
  const initCommand = _initCommand;
86
174
 
87
175
  //#endregion
88
- export { initCommand, runInit };
176
+ export { confirmForce, forceTargets, initCommand, initFailure, lintPresetOption, runInit };
@@ -1,5 +1,7 @@
1
- import { Output } from "../../internal/output.js";
1
+ import { Report } from "../../internal/report.js";
2
+ import { hookTable } from "../check-section.js";
2
3
  import { BIOME_VERSION } from "./biome-version.js";
4
+ import { CliExit } from "@effected/cli";
3
5
  import { ConfigDiscovery, Lint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyOkfBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
4
6
  import { Effect, FileSystem, Option } from "effect";
5
7
  import { Jsonc } from "@effected/jsonc";
@@ -132,17 +134,25 @@ function checkBiomeSchemas() {
132
134
  });
133
135
  }
134
136
  /**
135
- * Run the lint check validation pipeline.
137
+ * Check the lint-staged setup and return it as a {@link CheckSection}.
136
138
  *
137
- * Exported so Task B6's unified `savvy check` orchestrator can invoke the
138
- * lint check step directly without going through the CLI command layer.
139
+ * @remarks
140
+ * Severity rule: anything `savvy init` would write or rewrite is a finding
141
+ * (`✗`, verdict `failure`) — a missing config file or `pre-commit` hook, a
142
+ * managed section that is absent or drifted in a hook file that exists, a
143
+ * biome `$schema` behind {@link BIOME_VERSION}, a markdownlint `$schema` that
144
+ * differs from the template. Advice is `⚠` (verdict `warning`): markdownlint
145
+ * rules that differ from the template (only `savvy init --force` overwrites
146
+ * them, so they may be deliberate) and biome configs that could not be read.
147
+ * Absent is not a finding where a preset may leave it out: a missing hygiene
148
+ * hook FILE (the minimal preset writes none) or an uninstalled tool is `↷`.
139
149
  *
140
- * @param opts - Options for the check command
141
- * @returns An Effect that performs validation and logs results
150
+ * Under `quiet` the body is only the collected warning lines (none when
151
+ * there are none); the verdict is the same.
142
152
  *
143
153
  * @internal
144
154
  */
145
- function runLintCheck(opts) {
155
+ function lintCheckSection(opts) {
146
156
  const { quiet } = opts;
147
157
  return Effect.gen(function* () {
148
158
  const fs = yield* FileSystem.FileSystem;
@@ -150,173 +160,115 @@ function runLintCheck(opts) {
150
160
  const td = yield* ToolDiscovery;
151
161
  const discovery = yield* ConfigDiscovery;
152
162
  const warnings = [];
163
+ const rows = [];
164
+ const stateOf = (outcome) => CheckOutcome.$is("UpToDate")(outcome) ? "up-to-date" : CheckOutcome.$is("Drifted")(outcome) ? "outdated" : "missing";
153
165
  const foundConfig = yield* findConfigFile(fs);
154
166
  const hasHuskyHook = yield* fs.exists(Lint.HUSKY_HOOK_PATH);
155
- let sectionsHealthy = true;
156
- let baseStatusLabel = "missing";
157
- let lintStatusLabel = "missing";
158
- let okfStatusLabel = "missing";
159
167
  let detectedConfigPath = null;
160
168
  if (hasHuskyHook) {
161
- const baseResult = yield* ms.check(Lint.HUSKY_HOOK_PATH, SavvyBaseSection.section(savvyBasePreamble()));
162
- if (CheckOutcome.$is("Absent")(baseResult)) sectionsHealthy = false;
163
- else {
164
- const upToDate = CheckOutcome.$is("UpToDate")(baseResult);
165
- baseStatusLabel = upToDate ? "up-to-date" : "outdated";
166
- if (!upToDate) sectionsHealthy = false;
167
- }
169
+ rows.push([
170
+ Lint.HUSKY_HOOK_PATH,
171
+ "savvy-base",
172
+ stateOf(yield* ms.check(Lint.HUSKY_HOOK_PATH, SavvyBaseSection.section(savvyBasePreamble())))
173
+ ]);
168
174
  const existing = yield* ms.read(Lint.HUSKY_HOOK_PATH, Lint.SavvyLintSectionDef);
169
175
  if (Option.isSome(existing)) {
170
- const configPath = extractConfigPathFromManaged(existing.value.content);
171
- detectedConfigPath = configPath;
172
- if (configPath) {
173
- const lintResult = yield* ms.check(Lint.HUSKY_HOOK_PATH, Lint.savvyLintBlock(configPath));
174
- if (CheckOutcome.$is("Absent")(lintResult)) {
175
- lintStatusLabel = "outdated";
176
- sectionsHealthy = false;
177
- } else {
178
- const upToDate = CheckOutcome.$is("UpToDate")(lintResult);
179
- lintStatusLabel = upToDate ? "up-to-date" : "outdated";
180
- if (!upToDate) sectionsHealthy = false;
181
- }
182
- } else {
183
- lintStatusLabel = "outdated";
184
- sectionsHealthy = false;
185
- }
186
- } else sectionsHealthy = false;
187
- const okfResult = yield* ms.check(Lint.HUSKY_HOOK_PATH, savvyOkfBlock());
188
- if (CheckOutcome.$is("Absent")(okfResult)) sectionsHealthy = false;
189
- else {
190
- const upToDate = CheckOutcome.$is("UpToDate")(okfResult);
191
- okfStatusLabel = upToDate ? "up-to-date" : "outdated";
192
- if (!upToDate) sectionsHealthy = false;
193
- }
194
- if (baseStatusLabel !== "up-to-date" || lintStatusLabel !== "up-to-date" || okfStatusLabel !== "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).`);
195
- } else {
196
- sectionsHealthy = false;
197
- warnings.push(`${WARNING} No husky pre-commit hook found.\n Run 'savvy init' to create it.`);
198
- }
176
+ detectedConfigPath = extractConfigPathFromManaged(existing.value.content);
177
+ const state = detectedConfigPath ? CheckOutcome.$is("UpToDate")(yield* ms.check(Lint.HUSKY_HOOK_PATH, Lint.savvyLintBlock(detectedConfigPath))) ? "up-to-date" : "outdated" : "outdated";
178
+ rows.push([
179
+ Lint.HUSKY_HOOK_PATH,
180
+ "savvy-lint",
181
+ state
182
+ ]);
183
+ } else rows.push([
184
+ Lint.HUSKY_HOOK_PATH,
185
+ "savvy-lint",
186
+ "missing"
187
+ ]);
188
+ rows.push([
189
+ Lint.HUSKY_HOOK_PATH,
190
+ "savvy-okf",
191
+ stateOf(yield* ms.check(Lint.HUSKY_HOOK_PATH, savvyOkfBlock()))
192
+ ]);
193
+ if (rows.some(([, , state]) => state !== "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).`);
194
+ } else warnings.push(`${WARNING} No husky pre-commit hook found.\n Run 'savvy init' to create it.`);
199
195
  if (!foundConfig) warnings.push(`${WARNING} No lint-staged config file found.\n Run 'savvy init' to create one.`);
200
- const shellHookPaths = [
196
+ for (const hookPath of [
201
197
  Lint.POST_CHECKOUT_HOOK_PATH,
202
198
  Lint.POST_MERGE_HOOK_PATH,
203
199
  Lint.POST_COMMIT_HOOK_PATH
204
- ];
205
- const shellHookStatuses = [];
206
- const toolchainStatuses = [];
207
- const installStatuses = [];
208
- for (const hookPath of shellHookPaths) {
200
+ ]) {
209
201
  if (!(yield* fs.exists(hookPath))) {
210
- shellHookStatuses.push({
211
- path: hookPath,
212
- found: false,
213
- isUpToDate: false
214
- });
202
+ rows.push([
203
+ hookPath,
204
+ "(hook file)",
205
+ "not installed"
206
+ ]);
215
207
  continue;
216
208
  }
217
- const hygieneResult = yield* ms.check(hookPath, SavvyHooksSection.section(savvyHooksHygiene()));
218
- const found = !CheckOutcome.$is("Absent")(hygieneResult);
219
- const isUpToDate = CheckOutcome.$is("UpToDate")(hygieneResult);
220
- shellHookStatuses.push({
221
- path: hookPath,
222
- found,
223
- isUpToDate
224
- });
225
- if (!found) {
226
- sectionsHealthy = false;
227
- warnings.push(`${WARNING} ${hookPath} has no savvy-hooks section.\n Run 'savvy init' to add it.`);
228
- } else if (!isUpToDate) {
229
- sectionsHealthy = false;
230
- warnings.push(`${WARNING} ${hookPath} savvy-hooks section is outdated.\n Run 'savvy init' to update.`);
209
+ const sections = [["savvy-hooks", SavvyHooksSection.section(savvyHooksHygiene())]];
210
+ if (hookPath !== Lint.POST_COMMIT_HOOK_PATH) {
211
+ const installHook = hookPath === Lint.POST_CHECKOUT_HOOK_PATH ? "post-checkout" : "post-merge";
212
+ sections.push(["savvy-install", savvyInstallBlock(installHook)], ["savvy-toolchain", SavvyToolchainSection.section(savvyToolchainCheck())]);
231
213
  }
232
- if (hookPath === Lint.POST_COMMIT_HOOK_PATH) continue;
233
- const installHook = hookPath === Lint.POST_CHECKOUT_HOOK_PATH ? "post-checkout" : "post-merge";
234
- const installResult = yield* ms.check(hookPath, savvyInstallBlock(installHook));
235
- const installFound = !CheckOutcome.$is("Absent")(installResult);
236
- const installUpToDate = CheckOutcome.$is("UpToDate")(installResult);
237
- installStatuses.push({
238
- path: hookPath,
239
- found: installFound,
240
- isUpToDate: installUpToDate
241
- });
242
- if (!installFound) {
243
- sectionsHealthy = false;
244
- warnings.push(`${WARNING} ${hookPath} has no savvy-install section.\n Run 'savvy init' to add it.`);
245
- } else if (!installUpToDate) {
246
- sectionsHealthy = false;
247
- warnings.push(`${WARNING} ${hookPath} savvy-install section is outdated.\n Run 'savvy init' to update.`);
248
- }
249
- const toolchainResult = yield* ms.check(hookPath, SavvyToolchainSection.section(savvyToolchainCheck()));
250
- const toolchainFound = !CheckOutcome.$is("Absent")(toolchainResult);
251
- const toolchainUpToDate = CheckOutcome.$is("UpToDate")(toolchainResult);
252
- toolchainStatuses.push({
253
- path: hookPath,
254
- found: toolchainFound,
255
- isUpToDate: toolchainUpToDate
256
- });
257
- if (!toolchainFound) {
258
- sectionsHealthy = false;
259
- warnings.push(`${WARNING} ${hookPath} has no savvy-toolchain section.\n Run 'savvy init' to add it.`);
260
- } else if (!toolchainUpToDate) {
261
- sectionsHealthy = false;
262
- warnings.push(`${WARNING} ${hookPath} savvy-toolchain section is outdated.\n Run 'savvy init' to update.`);
214
+ for (const [name, section] of sections) {
215
+ const state = stateOf(yield* ms.check(hookPath, section));
216
+ rows.push([
217
+ hookPath,
218
+ name,
219
+ state
220
+ ]);
221
+ if (state === "missing") warnings.push(`${WARNING} ${hookPath} has no ${name} section.\n Run 'savvy init' to add it.`);
222
+ else if (state === "outdated") warnings.push(`${WARNING} ${hookPath} ${name} section is outdated.\n Run 'savvy init' to update.`);
263
223
  }
264
224
  }
265
- const biomeSchemaStatus = yield* checkBiomeSchemas().pipe(Effect.catch(() => Effect.succeed({
225
+ const biomeSchemaStatus = yield* checkBiomeSchemas().pipe(Effect.map((status) => ({
226
+ ...status,
227
+ readable: true
228
+ })), Effect.catch(() => Effect.succeed({
266
229
  statuses: [],
267
- warnings: [`${WARNING} Could not check biome $schema URLs.`]
230
+ warnings: [`${WARNING} Could not check biome $schema URLs.`],
231
+ readable: false
268
232
  })));
269
233
  warnings.push(...biomeSchemaStatus.warnings);
270
234
  const hasMarkdownlintConfig = yield* fs.exists(Lint.MARKDOWNLINT_CONFIG_PATH);
271
- let markdownlintStatus = {
235
+ const markdownlintStatus = hasMarkdownlintConfig ? yield* checkMarkdownlintConfig(yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH)) : {
272
236
  exists: false,
273
237
  schemaMatches: false,
274
238
  configMatches: false,
275
239
  isUpToDate: false
276
240
  };
241
+ if (hasMarkdownlintConfig && !markdownlintStatus.schemaMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy init' to update it.`);
242
+ if (hasMarkdownlintConfig && !markdownlintStatus.configMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy init --force' to overwrite.`);
243
+ const sectionsHealthy = rows.every(([, , state]) => state === "up-to-date" || state === "not installed");
244
+ const findings = !foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintConfig && !markdownlintStatus.schemaMatches || biomeSchemaStatus.statuses.some((status) => !status.matches);
245
+ const advice = hasMarkdownlintConfig && !markdownlintStatus.configMatches || !biomeSchemaStatus.readable;
246
+ const verdict = findings ? "failure" : advice ? "warning" : "success";
247
+ const title = "lint-staged";
248
+ if (quiet) return {
249
+ title,
250
+ blocks: warnings.map((warning) => Report.verbatim(warning)),
251
+ verdict,
252
+ fixedByInit: findings
253
+ };
254
+ const blocks = [];
255
+ blocks.push(foundConfig ? Report.ok(`Config file: ${foundConfig}`) : Report.fail("No lint-staged config file found"));
256
+ blocks.push(hasHuskyHook ? Report.ok(`Husky hook: ${Lint.HUSKY_HOOK_PATH}`) : Report.fail("No husky pre-commit hook found"));
257
+ if (detectedConfigPath) blocks.push(Report.detail(`savvy-lint runs: ${detectedConfigPath}`));
258
+ if (rows.length > 0) blocks.push(hookTable(rows));
277
259
  if (hasMarkdownlintConfig) {
278
- markdownlintStatus = yield* checkMarkdownlintConfig(yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH));
279
- if (!markdownlintStatus.schemaMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy init' to update it.`);
280
- if (!markdownlintStatus.configMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy init --force' to overwrite.`);
281
- }
282
- if (quiet) {
283
- if (warnings.length > 0) for (const warning of warnings) yield* Output.line(warning);
284
- return;
285
- }
286
- yield* Output.heading("lint-staged configuration");
287
- if (foundConfig) yield* Output.ok(`Config file: ${foundConfig}`);
288
- else yield* Output.fail(`No lint-staged config file found`);
289
- if (hasHuskyHook) yield* Output.ok(`Husky hook: ${Lint.HUSKY_HOOK_PATH}`);
290
- else yield* Output.fail(`No husky pre-commit hook found`);
291
- if (hasHuskyHook) {
292
- if (baseStatusLabel === "up-to-date") yield* Output.ok(`Base section: up-to-date`);
293
- else if (baseStatusLabel === "outdated") yield* Output.warn(`Base section: outdated (run 'savvy init' to update)`);
294
- else yield* Output.skip(`Base section: not found (run 'savvy init' to add)`);
295
- const lintLabel = detectedConfigPath ? ` (config: ${detectedConfigPath})` : "";
296
- if (lintStatusLabel === "up-to-date") yield* Output.ok(`Lint section: up-to-date${lintLabel}`);
297
- else if (lintStatusLabel === "outdated") yield* Output.warn(`Lint section: outdated (run 'savvy init' to update)`);
298
- else yield* Output.skip(`Lint section: not found (run 'savvy init' to add)`);
299
- if (okfStatusLabel === "up-to-date") yield* Output.ok(`OKF section: up-to-date`);
300
- else if (okfStatusLabel === "outdated") yield* Output.warn(`OKF section: outdated (run 'savvy init' to update)`);
301
- else yield* Output.skip(`OKF section: not found (run 'savvy init' to add)`);
302
- }
303
- for (const status of shellHookStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-hooks section not found`);
304
- else if (status.isUpToDate) yield* Output.ok(`${status.path}: up-to-date`);
305
- else yield* Output.warn(`${status.path}: outdated (run 'savvy init' to update)`);
306
- for (const status of installStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-install section not found`);
307
- else if (status.isUpToDate) yield* Output.ok(`${status.path}: savvy-install up-to-date`);
308
- else yield* Output.warn(`${status.path}: savvy-install outdated (run 'savvy init' to update)`);
309
- for (const status of toolchainStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-toolchain section not found`);
310
- else if (status.isUpToDate) yield* Output.ok(`${status.path}: savvy-toolchain up-to-date`);
311
- else yield* Output.warn(`${status.path}: savvy-toolchain outdated (run 'savvy init' to update)`);
312
- yield* Output.line("");
313
- yield* Output.heading("Tool availability");
260
+ if (markdownlintStatus.isUpToDate) blocks.push(Report.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`));
261
+ else {
262
+ if (!markdownlintStatus.schemaMatches) blocks.push(Report.fail(`${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template`));
263
+ if (!markdownlintStatus.configMatches) blocks.push(Report.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (savvy init --force overwrites)`));
264
+ }
265
+ } else blocks.push(Report.skip(`${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`));
266
+ if (!biomeSchemaStatus.readable) blocks.push(Report.warn("Could not check biome $schema URLs"));
267
+ for (const status of biomeSchemaStatus.statuses) blocks.push(status.matches ? Report.ok(`${status.path}: biome $schema up-to-date`) : Report.fail(`${status.path}: biome $schema outdated`));
268
+ blocks.push(Report.line("Tool availability"));
314
269
  const biomeAvailable = yield* td.isAvailable(Tool.named("biome"));
315
270
  const biomeConfig = yield* findConfig(discovery, ["biome.jsonc", "biome.json"]);
316
- if (biomeAvailable) {
317
- const configInfo = biomeConfig ? ` (config: ${biomeConfig})` : "";
318
- yield* Output.ok(`Biome${configInfo}`);
319
- } else yield* Output.skip(`Biome: not installed`);
271
+ blocks.push(biomeAvailable ? Report.ok(`Biome${biomeConfig ? ` (config: ${biomeConfig})` : ""}`) : Report.skip("Biome: not installed"));
320
272
  const markdownAvailable = yield* td.isAvailable(Tool.named("markdownlint-cli2"));
321
273
  const markdownConfig = yield* findConfig(discovery, [
322
274
  ".markdownlint-cli2.jsonc",
@@ -327,33 +279,36 @@ function runLintCheck(opts) {
327
279
  ".markdownlint.json",
328
280
  ".markdownlint.yaml"
329
281
  ]);
330
- if (markdownAvailable) {
331
- const configInfo = markdownConfig ? ` (config: ${markdownConfig})` : "";
332
- yield* Output.ok(`markdownlint-cli2${configInfo}`);
333
- } else yield* Output.skip(`markdownlint-cli2: not installed`);
282
+ blocks.push(markdownAvailable ? Report.ok(`markdownlint-cli2${markdownConfig ? ` (config: ${markdownConfig})` : ""}`) : Report.skip("markdownlint-cli2: not installed"));
334
283
  const tscAvailable = yield* td.isAvailable(Tool.named("tsc"));
335
- const tsgoAvailable = yield* td.isAvailable(Tool.named("tsgo"));
336
- if (tscAvailable) yield* Output.ok(`TypeScript (tsc)`);
337
- else if (tsgoAvailable) yield* Output.ok(`TypeScript (tsgo)`);
338
- else yield* Output.skip(`TypeScript: not installed`);
339
- if (hasMarkdownlintConfig) {
340
- if (markdownlintStatus.isUpToDate) yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
341
- else {
342
- const issues = [];
343
- if (!markdownlintStatus.schemaMatches) issues.push("$schema");
344
- if (!markdownlintStatus.configMatches) issues.push("config");
345
- yield* Output.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
346
- }
347
- } else yield* Output.skip(`${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
348
- for (const status of biomeSchemaStatus.statuses) if (status.matches) yield* Output.ok(`${status.path}: biome $schema up-to-date`);
349
- else yield* Output.warn(`${status.path}: biome $schema outdated (run 'savvy init' to update)`);
350
- yield* Output.line("");
351
- const hasMarkdownlintIssues = hasMarkdownlintConfig && !markdownlintStatus.isUpToDate;
352
- const hasBiomeSchemaIssues = biomeSchemaStatus.statuses.some((s) => !s.matches);
353
- if (!foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintIssues || hasBiomeSchemaIssues) yield* Output.warn(`Some issues found. Run 'savvy init' to fix.`);
354
- else yield* Output.ok(`Lint-staged is configured correctly.`);
284
+ const tsgoAvailable = tscAvailable ? false : yield* td.isAvailable(Tool.named("tsgo"));
285
+ blocks.push(tscAvailable ? Report.ok("TypeScript (tsc)") : tsgoAvailable ? Report.ok("TypeScript (tsgo)") : Report.skip("TypeScript: not installed"));
286
+ blocks.push(verdict === "failure" ? Report.fail("lint-staged needs configuration") : verdict === "warning" ? Report.warn("lint-staged is configured, with advisories") : Report.ok("lint-staged is configured correctly"));
287
+ return {
288
+ title,
289
+ blocks,
290
+ verdict,
291
+ fixedByInit: findings
292
+ };
293
+ });
294
+ }
295
+ /**
296
+ * Check the lint-staged setup, print its report on stdout, and set exit code
297
+ * 1 when it found a misconfiguration (see {@link lintCheckSection} for the
298
+ * severity rule). Under `quiet` only the warnings are printed.
299
+ *
300
+ * @param opts - Options for the check command
301
+ * @returns An Effect that performs validation and prints its report
302
+ *
303
+ * @internal
304
+ */
305
+ function runLintCheck(opts) {
306
+ return Effect.gen(function* () {
307
+ const section = yield* lintCheckSection(opts);
308
+ yield* Report.print(opts.quiet ? section.blocks : [Report.heading(section.title), ...section.blocks]);
309
+ if (section.verdict === "failure") yield* CliExit.set(1);
355
310
  });
356
311
  }
357
312
 
358
313
  //#endregion
359
- export { runLintCheck };
314
+ export { lintCheckSection, runLintCheck };