@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
@@ -1,8 +1,8 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { requireValidConfig } from "../utils/config-gate.js";
3
3
  import { Argument, Command, Flag } from "effect/cli";
4
4
  import { dirname, resolve } from "node:path";
5
- import { CliExit } from "@effected/cli";
5
+ import { CliExit, Doc } from "@effected/cli";
6
6
  import { Changesets } from "@savvy-web/silk-effects";
7
7
  import { Effect } from "effect";
8
8
  import { readFileSync, writeFileSync } from "node:fs";
@@ -21,7 +21,8 @@ import { readFileSync, writeFileSync } from "node:fs";
21
21
  * - **`--dry-run` / `-n`** -- print the transformed output to stdout
22
22
  * without writing.
23
23
  * - **`--check` / `-c`** -- compare the transformed output against the
24
- * original and exit with code 1 if they differ (useful in CI).
24
+ * original and exit with code 1 if they differ (useful in CI), showing
25
+ * the drifted lines as a capped diff (original against transformed).
25
26
  *
26
27
  * Before any of those modes run, the command requires a valid
27
28
  * `.changeset/config.json` (when one exists). A broken config indicates
@@ -39,6 +40,79 @@ import { readFileSync, writeFileSync } from "node:fs";
39
40
  * @internal
40
41
  */
41
42
  const { ChangelogTransformer } = Changesets;
43
+ /** The most diff lines `--check` shows under its finding; the rest is elided. */
44
+ const DIFF_CAP = 40;
45
+ /** A text's lines; a final line break ends the last line rather than starting an empty one. */
46
+ const lines = (text) => {
47
+ const all = text.split("\n");
48
+ if (all.at(-1) === "") all.pop();
49
+ return all;
50
+ };
51
+ /**
52
+ * Narrow `original` and `transformed` to the lines between their common
53
+ * leading and trailing lines, or `undefined` when the two differ only in the
54
+ * file's final line break. Exported for tests only.
55
+ *
56
+ * @internal
57
+ */
58
+ const driftWindow = (original, transformed) => {
59
+ const a = lines(original);
60
+ const b = lines(transformed);
61
+ let head = 0;
62
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
63
+ let tail = 0;
64
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
65
+ if (head + tail >= a.length && head + tail >= b.length) return void 0;
66
+ return {
67
+ removed: a.slice(head, a.length - tail),
68
+ added: b.slice(head, b.length - tail),
69
+ head
70
+ };
71
+ };
72
+ /** `1 line`, `3 blank lines`: a count of `lines`, called blank when every one is. */
73
+ const lineCount = (lines) => {
74
+ const blank = lines.every((line) => line.trim() === "") ? "blank " : "";
75
+ return `${lines.length} ${blank}${lines.length === 1 ? "line" : "lines"}`;
76
+ };
77
+ /**
78
+ * `lines` drawn as one side of a diff (`+ text`, or a bare `+` for a blank
79
+ * line), unless every one is blank; capped at {@link DIFF_CAP} lines with an
80
+ * elision line, as `Doc.diff` caps each side.
81
+ */
82
+ const listed = (sign, lines) => {
83
+ if (lines.every((line) => line.trim() === "")) return [];
84
+ const shown = lines.slice(0, DIFF_CAP).map((line) => line === "" ? sign : `${sign} ${line}`);
85
+ const hidden = lines.length - shown.length;
86
+ if (hidden > 0) shown.push(` … ${hidden} more ${hidden === 1 ? "line" : "lines"}`);
87
+ return [Doc.verbatim(shown.join("\n"), { indent: 2 })];
88
+ };
89
+ /**
90
+ * What `--check` shows under its finding for one drifted region.
91
+ *
92
+ * @remarks
93
+ * `Doc.diff` is not a line diff: it draws every expected line as removed and
94
+ * every received line as added, and an empty side joins to the same `""` as a
95
+ * single blank line. So the window holds only lines that changed (an unchanged
96
+ * context line would read as removed and re-added), and a region that only
97
+ * inserts or only removes lines is described in words, with its non-blank
98
+ * lines listed, rather than drawn as a diff against nothing. A replacement is
99
+ * a `Doc.diff`, capped so a long CHANGELOG never floods the report.
100
+ * Exported for tests only.
101
+ *
102
+ * @internal
103
+ */
104
+ const driftReport = (file, drift) => {
105
+ const finding = `${file} would be modified by transform`;
106
+ if (drift === void 0) return [Report.warn(finding, "only its final line break differs")];
107
+ const { removed, added, head } = drift;
108
+ if (removed.length === 0) {
109
+ const where = head === 0 ? "at the top" : `after line ${head}`;
110
+ return [Report.warn(finding, `inserts ${lineCount(added)} ${where}`), ...listed("+", added)];
111
+ }
112
+ const span = removed.length === 1 ? `line ${head + 1}` : `lines ${head + 1}-${head + removed.length}`;
113
+ if (added.length === 0) return [Report.warn(finding, `removes ${lineCount(removed)} at ${span}`), ...listed("-", removed)];
114
+ return [Report.warn(finding, `${span}:`), Doc.diff(removed.join("\n"), added.join("\n"), { cap: DIFF_CAP })];
115
+ };
42
116
  /* v8 ignore start -- CLI option definitions; handler tested via runTransform */
43
117
  const fileArg = Argument.File("file").pipe(Argument.withDefault("CHANGELOG.md"));
44
118
  const dryRunOption = Flag.Boolean("dry-run").pipe(Flag.withAlias("n"), Flag.withDescription("Print transformed output instead of writing"), Flag.withDefault(false));
@@ -65,18 +139,18 @@ function runTransform(file, dryRun, check) {
65
139
  const content = yield* Effect.try(() => readFileSync(resolved, "utf-8"));
66
140
  const result = ChangelogTransformer.transformContent(content);
67
141
  if (dryRun) {
68
- yield* Output.line(result);
142
+ yield* Report.print([Report.verbatim(result)]);
69
143
  return;
70
144
  }
71
145
  if (check) {
72
146
  if (result !== content) {
73
- yield* Output.warn(`${resolved} would be modified by transform`);
147
+ yield* Report.print(driftReport(resolved, driftWindow(content, result)));
74
148
  yield* CliExit.set(1);
75
- } else yield* Output.ok(`${resolved} is already formatted`);
149
+ } else yield* Report.print([Report.ok(`${resolved} is already formatted`)]);
76
150
  return;
77
151
  }
78
152
  yield* Effect.try(() => writeFileSync(resolved, result, "utf-8"));
79
- yield* Output.ok(`Transformed ${resolved}`);
153
+ yield* Report.print([Report.ok(`Transformed ${resolved}`)]);
80
154
  });
81
155
  }
82
156
  /* v8 ignore next 5 -- CLI registration; handler tested via runTransform */
@@ -87,4 +161,4 @@ const transformCommand = Command.make("transform", {
87
161
  }, ({ file, dryRun, check }) => runTransform(file, dryRun, check)).pipe(Command.withDescription("Post-process CHANGELOG.md"));
88
162
 
89
163
  //#endregion
90
- export { runTransform, transformCommand };
164
+ export { driftReport, driftWindow, runTransform, transformCommand };
@@ -1,4 +1,5 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
+ import { CommandError } from "../../../internal/command-error.js";
2
3
  import { Argument, Command } from "effect/cli";
3
4
  import { CliExit } from "@effected/cli";
4
5
  import { Changesets } from "@savvy-web/silk-effects";
@@ -26,8 +27,9 @@ const fileArg = Argument.File("file");
26
27
  * Run lint validation on a single changeset file.
27
28
  *
28
29
  * Outputs one line per error in `file:line:col rule message` format.
29
- * Logs "Valid." when the file passes. Sets exit code 1 through `CliExit.set`
30
- * when errors are found or the file cannot be read.
30
+ * Prints `✓ Valid` when the file passes. Sets exit code 1 through
31
+ * `CliExit.set` when errors are found; fails with a {@link CommandError}
32
+ * (reported on stderr, exit 1) when the file cannot be read.
31
33
  *
32
34
  * @param filePath - Path to the changeset `.md` file
33
35
  * @returns An Effect that performs validation and logs results
@@ -36,15 +38,14 @@ const fileArg = Argument.File("file");
36
38
  */
37
39
  function runValidateFile(filePath) {
38
40
  return Effect.gen(function* () {
39
- const result = yield* Effect.try(() => ChangesetLinter.validateFile(filePath)).pipe(Effect.catch((error) => Effect.gen(function* () {
40
- yield* Effect.logError(`Error: ${error instanceof Error ? error.message : String(error)}`);
41
+ const result = yield* Effect.try({
42
+ try: () => ChangesetLinter.validateFile(filePath),
43
+ catch: (error) => CommandError.from(error instanceof Error ? error : String(error), { message: `could not validate ${filePath}` })
44
+ });
45
+ if (result.length > 0) {
46
+ yield* Report.print([Report.verbatim(result.map((msg) => `${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`).join("\n"))]);
41
47
  yield* CliExit.set(1);
42
- return null;
43
- })));
44
- if (result === null) return;
45
- for (const msg of result) yield* Output.line(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
46
- if (result.length > 0) yield* CliExit.set(1);
47
- else yield* Output.ok("Valid");
48
+ } else yield* Report.print([Report.ok("Valid")]);
48
49
  });
49
50
  }
50
51
  /* v8 ignore next 3 -- CLI registration; handler tested via runValidateFile */
@@ -1,20 +1,11 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { requireValidConfig } from "../utils/config-gate.js";
3
3
  import { Command, Flag } from "effect/cli";
4
+ import { Doc } from "@effected/cli";
4
5
  import { Changesets } from "@savvy-web/silk-effects";
5
6
  import { Effect } from "effect";
6
7
 
7
8
  //#region src/commands/changeset/commands/version.ts
8
- /**
9
- * Version command -- natively apply pending changesets and report the result.
10
- *
11
- * Validates the config, then runs {@link ReleasePlanner.apply}, which bumps
12
- * versions, writes + transforms CHANGELOGs, deletes consumed changesets, and
13
- * updates configured versionFiles -- all without shelling out to a `changeset`
14
- * CLI binary.
15
- *
16
- * @internal
17
- */
18
9
  /* v8 ignore start -- CLI option definitions; handler tested via runVersion */
19
10
  const dryRunOption = Flag.Boolean("dry-run").pipe(Flag.withAlias("n"), Flag.withDescription("Compute and report the release without writing anything"), Flag.withDefault(false));
20
11
  /* v8 ignore stop */
@@ -28,17 +19,57 @@ function runVersion(dryRun) {
28
19
  return Effect.gen(function* () {
29
20
  const cwd = process.cwd();
30
21
  yield* requireValidConfig(cwd);
31
- const result = yield* (yield* Changesets.ReleasePlanner).apply(cwd, { dryRun });
22
+ const planner = yield* Changesets.ReleasePlanner;
23
+ if (dryRun) {
24
+ const result = yield* planner.apply(cwd, { dryRun });
25
+ if (result.releases.length === 0) {
26
+ yield* Report.print([Report.ok("No pending changesets")]);
27
+ return;
28
+ }
29
+ yield* Report.print([...releaseBlocks(result.releases, true), ...versionFileBlocks(result.versionFileUpdates, true)]);
30
+ return;
31
+ }
32
+ let engineReported = false;
33
+ let versionFilesReported = false;
34
+ const onStep = (step) => Effect.suspend(() => {
35
+ if (step._tag === "EngineApplied") {
36
+ engineReported = true;
37
+ return step.releases.length === 0 ? Effect.void : Report.print([...releaseBlocks(step.releases, false), Report.detail(`Touched ${step.touchedFiles.length} file(s)`)]);
38
+ }
39
+ versionFilesReported = true;
40
+ return Report.print(versionFileBlocks(step.updates, false));
41
+ });
42
+ const result = yield* planner.apply(cwd, { onStep });
32
43
  if (result.releases.length === 0) {
33
- yield* Output.ok("No pending changesets");
44
+ yield* Report.print([Report.ok("No pending changesets")]);
34
45
  return;
35
46
  }
36
- const verb = dryRun ? "Would release" : "Released";
37
- for (const r of result.releases) yield* Output.ok(`${verb} ${r.name}: ${r.oldVersion} -> ${r.newVersion} (${r.type})`);
38
- if (!dryRun) yield* Output.detail(`Touched ${result.touchedFiles.length} file(s)`);
39
- for (const u of result.versionFileUpdates) yield* Output.detail(`${dryRun ? "Would update" : "Updated"} ${u.filePath} -> ${u.version}`);
47
+ const remaining = [];
48
+ if (!engineReported) remaining.push(...releaseBlocks(result.releases, false), Report.detail(`Touched ${result.touchedFiles.length} file(s)`));
49
+ if (!versionFilesReported) remaining.push(...versionFileBlocks(result.versionFileUpdates, false));
50
+ if (remaining.length > 0) yield* Report.print(remaining);
40
51
  });
41
52
  }
53
+ /** The columns of the releases table. */
54
+ const RELEASE_COLUMNS = [
55
+ { header: "package" },
56
+ { header: "version" },
57
+ { header: "bump" }
58
+ ];
59
+ /**
60
+ * The releases as one status line (plan-phrased on a dry run) over a
61
+ * package / old → new / bump table.
62
+ */
63
+ const releaseBlocks = (releases, dryRun) => {
64
+ const count = `${releases.length} package${releases.length === 1 ? "" : "s"}`;
65
+ return [dryRun ? Report.line(`Would release ${count}:`) : Report.ok(`Released ${count}`), Doc.table(RELEASE_COLUMNS, releases.map((r) => [
66
+ r.name,
67
+ `${r.oldVersion} → ${r.newVersion}`,
68
+ r.type
69
+ ]))];
70
+ };
71
+ /** One detail line per versionFiles update, plan-phrased on a dry run. */
72
+ const versionFileBlocks = (updates, dryRun) => updates.map((u) => Report.detail(`${dryRun ? "Would update" : "Updated"} ${u.filePath} -> ${u.version}`));
42
73
  /* v8 ignore next 4 -- CLI registration; handler tested via runVersion */
43
74
  const versionCommand = Command.make("version", { dryRun: dryRunOption }, ({ dryRun }) => runVersion(dryRun)).pipe(Command.withDescription("Apply pending changesets: bump versions and transform CHANGELOGs"));
44
75
 
@@ -1,4 +1,4 @@
1
- import { checkCommand, runChangesetCheck } from "./commands/check.js";
1
+ import { changesetCheckSection, checkCommand, runChangesetCheck } from "./commands/check.js";
2
2
  import { configValidateCommand } from "./commands/config-validate.js";
3
3
  import { depsDetectCommand } from "./commands/deps-detect.js";
4
4
  import { depsRegenCommand } from "./commands/deps-regen.js";
@@ -6,7 +6,7 @@ import { lintCommand } from "./commands/lint.js";
6
6
  import { transformCommand } from "./commands/transform.js";
7
7
  import { validateFileCommand } from "./commands/validate-file.js";
8
8
  import { versionCommand } from "./commands/version.js";
9
- import { runChangesetInit } from "./commands/init.js";
9
+ import { runChangesetInit, runChangesetInitOrFail } from "./commands/init.js";
10
10
  import { Command } from "effect/cli";
11
11
 
12
12
  //#region src/commands/changeset/index.ts
@@ -40,4 +40,4 @@ const _changesetCommand = Command.make("changeset").pipe(Command.withSubcommands
40
40
  const changesetCommand = _changesetCommand;
41
41
 
42
42
  //#endregion
43
- export { changesetCommand, runChangesetCheck, runChangesetInit };
43
+ export { changesetCheckSection, changesetCommand, runChangesetCheck, runChangesetInit };
@@ -1,5 +1,5 @@
1
+ import { CommandError } from "../../../internal/command-error.js";
1
2
  import { join, resolve } from "node:path";
2
- import { CliExit } from "@effected/cli";
3
3
  import { Changesets } from "@savvy-web/silk-effects";
4
4
  import { Effect } from "effect";
5
5
  import { existsSync } from "node:fs";
@@ -26,9 +26,10 @@ import { existsSync } from "node:fs";
26
26
  * pre-0.9.0 behavior where `transform` and `version` worked on
27
27
  * projects that have not yet been bootstrapped.
28
28
  * - **Config present**: the gate invokes
29
- * {@link ConfigInspector.inspect}. On `ConfigurationError`, it sets
30
- * exit code 1 through `CliExit.set` and propagates the error so the caller's
31
- * `Effect.gen` short-circuits.
29
+ * {@link ConfigInspector.inspect}. A `ConfigurationError` becomes a
30
+ * {@link CommandError} (the refusal, the config problem as its detail, and
31
+ * a `config validate` hint) that short-circuits the caller's `Effect.gen`;
32
+ * the runtime reports it on stderr and exits 1.
32
33
  *
33
34
  * @internal
34
35
  */
@@ -36,12 +37,12 @@ const { ConfigInspector } = Changesets;
36
37
  /**
37
38
  * Require a valid `.changeset/config.json` (when one exists) before
38
39
  * proceeding. Resolves to `void` on a clean config OR on a project that
39
- * doesn't have a config at all; fails with {@link ConfigurationError} on
40
- * an invalid config.
40
+ * doesn't have a config at all; fails with a {@link CommandError} wrapping
41
+ * the {@link ConfigurationError} on an invalid config.
41
42
  *
42
43
  * @param cwd - Project root (will be resolved against the process cwd)
43
44
  * @returns Effect that succeeds on valid/absent config, fails with
44
- * {@link ConfigurationError} otherwise
45
+ * {@link CommandError} otherwise
45
46
  *
46
47
  * @internal
47
48
  */
@@ -50,9 +51,10 @@ function requireValidConfig(cwd) {
50
51
  const projectDir = resolve(cwd);
51
52
  const configPath = join(projectDir, ".changeset", "config.json");
52
53
  if (!existsSync(configPath)) return;
53
- yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => {
54
- return CliExit.set(1).pipe(Effect.andThen(Effect.fail(err)));
55
- }));
54
+ yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => Effect.fail(CommandError.from(err, {
55
+ message: `refusing to run: ${configPath} is invalid`,
56
+ hint: "Run savvy changeset config validate to see the full report."
57
+ }))));
56
58
  });
57
59
  }
58
60
 
@@ -0,0 +1,34 @@
1
+ import { Doc, Status } from "@effected/cli";
2
+
3
+ //#region src/commands/check-section.ts
4
+ /**
5
+ * A status cell for a hook table: the glyph and the state, e.g. `✓ up-to-date`.
6
+ * `not installed` is advisory (`↷`); `outdated`/`missing` are findings (`✗`).
7
+ *
8
+ * @internal
9
+ */
10
+ const stateCell = (state) => {
11
+ const status = state === "up-to-date" ? "success" : state === "not installed" ? "skip" : "failure";
12
+ return [
13
+ Doc.status(Status.core, status),
14
+ " ",
15
+ state
16
+ ];
17
+ };
18
+ /**
19
+ * The hook-section table: one row per `[hook, section, state]`.
20
+ *
21
+ * @internal
22
+ */
23
+ const hookTable = (rows) => Doc.table([
24
+ { header: "hook" },
25
+ { header: "section" },
26
+ { header: "state" }
27
+ ], rows.map(([hook, section, state]) => [
28
+ hook,
29
+ section,
30
+ stateCell(state)
31
+ ]));
32
+
33
+ //#endregion
34
+ export { hookTable, stateCell };
package/commands/check.js CHANGED
@@ -1,54 +1,63 @@
1
- import { Output } from "../internal/output.js";
2
- import { runChangesetCheck } from "./changeset/commands/check.js";
1
+ import { Report } from "../internal/report.js";
2
+ import { changesetCheckSection } from "./changeset/commands/check.js";
3
3
  import "./changeset/index.js";
4
- import { runCommitCheck } from "./commit/check.js";
5
- import { runLintCheck } from "./lint/check.js";
4
+ import { commitCheckSection } from "./commit/check.js";
5
+ import { lintCheckSection } from "./lint/check.js";
6
6
  import { Command, Flag } from "effect/cli";
7
+ import { CliExit, Doc } from "@effected/cli";
7
8
  import { Effect, Result } from "effect";
8
9
 
9
10
  //#region src/commands/check.ts
10
- /**
11
- * Unified `savvy check` orchestrator.
12
- *
13
- * @remarks
14
- * Runs all three tool-specific check handlers — changeset, commit, lint —
15
- * sequentially so each tool's console output is cleanly grouped rather than
16
- * interleaved. Unlike the `init` orchestrator, check MUST NOT short-circuit:
17
- * the user wants to see every failing check in a single pass. Uses
18
- * `Effect.all` with `{ concurrency: 1, mode: "validate" }` to run all three
19
- * regardless of individual failures and then surface the full set of errors.
20
- *
21
- * Runtime layer provision (ManagedSection, FileSystem, WorkspaceRoot,
22
- * ToolDiscovery, etc.) is deferred to Task B7 (root `main`). The Effect
23
- * returned by `checkCommand`'s handler therefore carries the full union of the
24
- * three handlers' requirements in its R channel.
25
- *
26
- * @internal
27
- */
28
11
  /* v8 ignore start -- CLI option definitions; orchestration logic tested via runCheck */
29
12
  const changesetDirOption = Flag.String("changeset-dir").pipe(Flag.withDescription("Path to the changeset directory"), Flag.withDefault(".changeset"));
30
13
  const quietOption = Flag.Boolean("quiet").pipe(Flag.withAlias("q"), Flag.withDescription("Only output warnings from lint check"), Flag.withDefault(false));
31
14
  /* v8 ignore stop */
32
15
  /**
33
- * Run all three check step Effects without short-circuiting.
16
+ * Run the three checks without short-circuiting and print them as one
17
+ * document.
34
18
  *
35
- * All three checks always run, sequentially (concurrency 1) so per-tool
36
- * output stays grouped. If any fail, the combined Effect fails with all
37
- * accumulated errors. Uses `Effect.all` with `{ concurrency: 1, mode: "validate" }`
38
- * to collect every failure rather than stopping at the first.
19
+ * @remarks
20
+ * Each step builds a {@link CheckSection}; each section is folded into a
21
+ * collapsible (a `::group::` under GitHub Actions, whose body is where a
22
+ * section's annotations are written, so its blocks are never nested deeper).
23
+ * A step that fails still lets the others run and report; the first failure
24
+ * is re-raised after the document is printed.
39
25
  *
40
- * @param steps - The three step Effects to run. Injected for testability.
41
- * @returns An Effect that resolves to `void` on success, or fails with the
42
- * union of all failing steps' errors.
26
+ * @param steps - The three step Effects. Injected for testability.
27
+ * @returns An Effect that prints the report and sets exit code 1 on any
28
+ * finding, or fails with the first failing step's error.
43
29
  */
44
30
  function runCheck(steps) {
45
31
  return Effect.gen(function* () {
46
- const results = yield* Effect.all([
47
- Effect.result(steps.changeset),
48
- Output.line("").pipe(Effect.andThen(Effect.result(steps.commit))),
49
- Output.line("").pipe(Effect.andThen(Effect.result(steps.lint)))
50
- ], { concurrency: 1 });
51
- for (const result of results) if (Result.isFailure(result)) return yield* Effect.fail(result.failure);
32
+ const sections = [
33
+ ["changesets", yield* Effect.result(steps.changeset)],
34
+ ["commitlint", yield* Effect.result(steps.commit)],
35
+ ["lint-staged", yield* Effect.result(steps.lint)]
36
+ ];
37
+ const blocks = [];
38
+ const verdicts = [];
39
+ let initFixes = false;
40
+ for (const [title, result] of sections) {
41
+ if (Result.isFailure(result)) {
42
+ verdicts.push("failure");
43
+ blocks.push(Report.fail(`${title}: the check could not run`));
44
+ continue;
45
+ }
46
+ const section = result.success;
47
+ verdicts.push(section.verdict);
48
+ initFixes ||= section.fixedByInit;
49
+ if (section.blocks.length > 0) blocks.push(Doc.collapsible(section.title, section.blocks), Report.line(""));
50
+ }
51
+ const count = (verdict) => verdicts.filter((v) => v === verdict).length;
52
+ blocks.push(Report.summary({
53
+ ok: count("success"),
54
+ warn: count("warning"),
55
+ fail: count("failure")
56
+ }));
57
+ if (initFixes) blocks.push(Doc.callout("tip", [Doc.paragraph("run savvy init to install or update the missing pieces")]));
58
+ yield* Report.print(blocks);
59
+ if (verdicts.includes("failure")) yield* CliExit.set(1);
60
+ for (const [, result] of sections) if (Result.isFailure(result)) return yield* Effect.fail(result.failure);
52
61
  });
53
62
  }
54
63
  /* v8 ignore start -- CLI registration; orchestration logic tested via runCheck */
@@ -56,9 +65,9 @@ const _checkCommand = Command.make("check", {
56
65
  changesetDir: changesetDirOption,
57
66
  quiet: quietOption
58
67
  }, (opts) => runCheck({
59
- changeset: runChangesetCheck(opts.changesetDir),
60
- commit: runCommitCheck(),
61
- lint: runLintCheck({ quiet: opts.quiet })
68
+ changeset: changesetCheckSection(opts.changesetDir),
69
+ commit: commitCheckSection(),
70
+ lint: lintCheckSection({ quiet: opts.quiet })
62
71
  })).pipe(Command.withDescription("Validate all Silk Suite tool configurations in one pass"));
63
72
  /* v8 ignore stop */
64
73
  /**
@@ -0,0 +1,71 @@
1
+ import { Doc, Status } from "@effected/cli";
2
+
3
+ //#region src/commands/clean/progress.ts
4
+ /** The state before a run starts. */
5
+ const initialCleanState = {
6
+ phase: "idle",
7
+ dryRun: false,
8
+ workspaces: 0,
9
+ finished: 0,
10
+ current: void 0,
11
+ removed: 0,
12
+ failed: []
13
+ };
14
+ /** Fold one event into the state; a start begins afresh. */
15
+ const reduceClean = (state, event) => {
16
+ switch (event._tag) {
17
+ case "Started": return {
18
+ ...initialCleanState,
19
+ phase: "running",
20
+ dryRun: event.dryRun,
21
+ workspaces: event.workspaces
22
+ };
23
+ case "WorkspaceStarted": return {
24
+ ...state,
25
+ current: event.name
26
+ };
27
+ case "WorkspaceDone": return {
28
+ ...state,
29
+ finished: state.finished + 1,
30
+ removed: state.removed + event.removed.length,
31
+ failed: [...state.failed, ...event.failed]
32
+ };
33
+ case "Ended": return {
34
+ ...state,
35
+ phase: "done",
36
+ current: void 0
37
+ };
38
+ }
39
+ };
40
+ /** `n removed, m failed` (or `would remove` on a dry run); a zero `failed` is left out. */
41
+ const counts = (state) => Doc.counts({
42
+ layout: "inline",
43
+ share: false,
44
+ counters: [Doc.counter(Status.core, "success", {
45
+ key: "removed",
46
+ label: state.dryRun ? "would remove" : "removed",
47
+ n: state.removed,
48
+ showZero: true
49
+ }), Doc.counter(Status.core, "failure", {
50
+ key: "failed",
51
+ label: "failed",
52
+ n: state.failed.length
53
+ })]
54
+ });
55
+ /** The paths that could not be removed, as a table. */
56
+ const failures = (failed) => failed.length === 0 ? [] : [Doc.table([
57
+ { header: "could not remove" },
58
+ { header: "kind" },
59
+ { header: "reason" }
60
+ ], failed.map((f) => [
61
+ Doc.file(f.target.path),
62
+ f.target.kind,
63
+ f.reason
64
+ ]))];
65
+ /** While the run goes: which workspace, how many are done, and the counts so far. */
66
+ const progressDoc = (state, spinner) => [Doc.line([Doc.text(spinner, "accent"), ` ${state.dryRun ? "Previewing" : "Cleaning"} ${state.current ?? ""} (${state.finished}/${state.workspaces} workspaces)`]), counts(state)];
67
+ /** The committed summary: the counts, then any failures as a table. */
68
+ const summaryDoc = (state) => [counts(state), ...failures(state.failed)];
69
+
70
+ //#endregion
71
+ export { initialCleanState, progressDoc, reduceClean, summaryDoc };
@@ -0,0 +1,26 @@
1
+ import { initialCleanState, progressDoc, reduceClean, summaryDoc } from "./progress.js";
2
+ import { DocView, useGlyphs } from "@effected/cli/ui";
3
+ import { jsx } from "react/jsx-runtime";
4
+
5
+ //#region src/commands/clean/view.tsx
6
+ /** Draws the run: progress while it goes, the summary once it has ended. */
7
+ const CleanProgress = ({ state, frame }) => {
8
+ const glyphs = useGlyphs();
9
+ if (state.phase === "done") return /* @__PURE__ */ jsx(DocView, { doc: summaryDoc(state) });
10
+ const spinner = glyphs.spinner[frame % glyphs.spinner.length] ?? "";
11
+ return /* @__PURE__ */ jsx(DocView, { doc: progressDoc(state, spinner) });
12
+ };
13
+ /** The view `savvy clean` mounts, minus its events; the tests drive this same value. */
14
+ const cleanView = {
15
+ initial: initialCleanState,
16
+ reduce: reduceClean,
17
+ render: (state, frame) => /* @__PURE__ */ jsx(CleanProgress, {
18
+ state,
19
+ frame
20
+ }),
21
+ isStart: (event) => event._tag === "Started",
22
+ isTerminal: (event) => event._tag === "Ended"
23
+ };
24
+
25
+ //#endregion
26
+ export { cleanView };