@savvy-web/cli 3.4.3 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ npx savvy clean --globs dist,.turbo,coverage
54
54
  - `savvy clean` — removes build and cache artifacts (`dist`, `.turbo`, `coverage`, `node_modules`, `.rslib` by default) from every workspace package (leaves first) and the repo root (last); `--globs` to customize, `--dry-run` to preview.
55
55
  - `savvy commit` — the husky/Claude hook handlers (session-start, pre-commit-message, post-commit-verify).
56
56
  - `savvy changeset` — changeset lint, check, transform, version, config validation, and dependency changesets.
57
- - `savvy lint` — formatters for package.json, the pnpm workspace file and YAML.
57
+ - `savvy lint` — formatters for package.json, the pnpm workspace file and YAML, plus `savvy lint text`, which fails files that contain a NUL byte or invalid UTF-8 (`--staged` checks the git index copy).
58
58
  - `savvy repos` — the vendored reference repos declared in `.repos/config.json`: `status` (with `--drift`), `sync`, `pin`, `add`, `note`, `remove`, `rename` and `restore`.
59
59
 
60
60
  ## Vendored repos are read-only
@@ -39,6 +39,16 @@ import { Console, Effect, Option } from "effect";
39
39
  * (the regen default) and protocol specifiers (`catalog:`/`workspace:`)
40
40
  * are resolved to concrete versions.
41
41
  *
42
+ * **Dry-run reporting.** With `--dry-run` nothing is written or deleted, so
43
+ * the human output is phrased as a plan (`Would delete N pure dependency
44
+ * changeset(s):` / `Would write N dependency changeset(s):`) rather than the
45
+ * real run's `✓ Deleted …` / `✓ Wrote …`. A real run reports what
46
+ * `execute()` returned, not what the plan listed: deletes are tolerant, so a
47
+ * planned delete that found nothing or could not remove the file is reported
48
+ * as not removed. `--json`
49
+ * emits the plan's fields plus an explicit `dryRun` boolean in both modes,
50
+ * and a real run adds `execute()`'s `result`.
51
+ *
42
52
  * @example
43
53
  * ```bash
44
54
  * savvy changeset deps regen
@@ -69,22 +79,48 @@ function runDepsRegen(cwd, base, pkg, dryRun, json) {
69
79
  ...Option.isSome(base) ? { base: base.value } : {},
70
80
  ...Option.isSome(pkg) ? { package: pkg.value } : {}
71
81
  }).pipe(Effect.tapError(() => CliExit.set(1)));
72
- if (!dryRun) yield* service.execute(plan);
73
- if (json) yield* Console.log(JSON.stringify(plan, null, 2));
74
- else yield* renderHumanPlan(plan);
82
+ const result = dryRun ? void 0 : yield* service.execute(plan);
83
+ if (json) yield* Console.log(JSON.stringify({
84
+ ...plan,
85
+ dryRun,
86
+ ...result ? { result } : {}
87
+ }, null, 2));
88
+ else yield* renderHumanPlan(plan, result);
75
89
  });
76
90
  }
77
- function renderHumanPlan(plan) {
91
+ /**
92
+ * Render the plan for a person. A dry run (no `result`) changed nothing, so its
93
+ * headings are plan-phrased and carry no `✓`. A real run reports what
94
+ * `execute` actually did: deletes are tolerant, so a planned delete absent from
95
+ * `result.deleted` was already gone or could not be removed, and is reported as
96
+ * not removed, never as deleted.
97
+ */
98
+ function renderHumanPlan(plan, result) {
78
99
  return Effect.gen(function* () {
100
+ const dryRun = result === void 0;
79
101
  if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Output.ok("No dependency changes to regenerate");
80
102
  else {
81
- if (plan.toDelete.length > 0) {
82
- yield* Output.ok(`Deleted ${plan.toDelete.length} pure dependency changeset(s):`);
103
+ if (dryRun && plan.toDelete.length > 0) {
104
+ yield* Output.heading(`Would delete ${plan.toDelete.length} pure dependency changeset(s):`);
83
105
  for (const entry of plan.toDelete) yield* Output.detail(`${entry.file} (${entry.package})`);
84
106
  }
85
- if (plan.toWrite.length > 0) {
86
- yield* Output.ok(`Wrote ${plan.toWrite.length} fresh dependency changeset(s):`);
87
- for (const entry of plan.toWrite) yield* Output.detail(`+ ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
107
+ if (!dryRun) {
108
+ const deleted = new Set(result.deleted);
109
+ const removed = plan.toDelete.filter((entry) => deleted.has(entry.file));
110
+ const notRemoved = plan.toDelete.filter((entry) => !deleted.has(entry.file));
111
+ if (removed.length > 0) {
112
+ yield* Output.ok(`Deleted ${removed.length} pure dependency changeset(s):`);
113
+ for (const entry of removed) yield* Output.detail(`${entry.file} (${entry.package})`);
114
+ }
115
+ if (notRemoved.length > 0) {
116
+ yield* Output.skip(`${notRemoved.length} planned deletion(s) not removed (already gone or undeletable):`);
117
+ for (const entry of notRemoved) yield* Output.detail(`${entry.file} (${entry.package})`);
118
+ }
119
+ }
120
+ const writes = dryRun ? plan.toWrite : plan.toWrite.filter((entry) => result.written.includes(entry.file));
121
+ if (writes.length > 0) {
122
+ yield* dryRun ? Output.heading(`Would write ${writes.length} dependency changeset(s):`) : Output.ok(`Wrote ${writes.length} fresh dependency changeset(s):`);
123
+ for (const entry of writes) yield* Output.detail(`+ ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
88
124
  }
89
125
  }
90
126
  if (plan.skippedMixed.length > 0) {
@@ -1,11 +1,12 @@
1
1
  import { runLintCheck } from "./check.js";
2
2
  import { runLintInit } from "./init.js";
3
3
  import { fmtCommand } from "./fmt.js";
4
+ import { runLintText, textCommand } from "./text.js";
4
5
  import { Command } from "effect/cli";
5
6
 
6
7
  //#region src/commands/lint/index.ts
7
8
  /* 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
+ const _lintCommand = Command.make("lint").pipe(Command.withSubcommands([fmtCommand, textCommand]), Command.withDescription("Code-quality: lint-staged config, checks, and in-place formatting"));
9
10
  /**
10
11
  * The `savvy lint` command group for use in Task B7 root assembly.
11
12
  *
@@ -0,0 +1,71 @@
1
+ import { Output } from "../../internal/output.js";
2
+ import { Argument, Command, Flag } from "effect/cli";
3
+ import { CliExit } from "@effected/cli";
4
+ import { Lint } from "@savvy-web/silk-effects";
5
+ import { Effect } from "effect";
6
+
7
+ //#region src/commands/lint/text.ts
8
+ /**
9
+ * `savvy lint text` — fail when a source file is not grep-visible text.
10
+ *
11
+ * @remarks
12
+ * A NUL byte or invalid UTF-8 makes `grep`/`rg` silently skip a file, so a
13
+ * search returns a false negative instead of an error (savvy-web/systems#388).
14
+ * With file arguments the command checks those files (the lint-staged path,
15
+ * `Lint.TextFiles.create`); with none it checks every git-tracked file matching
16
+ * `Lint.TextFiles.glob`. `--staged` reads each file's bytes from the git
17
+ * index instead of the working tree — what the lint-staged handler passes, so
18
+ * a concurrent Biome `--write` on the worktree cannot race the read, and the
19
+ * check sees exactly what the commit records. The classification itself lives in silk-effects, so
20
+ * this command and the lint-staged handler cannot drift.
21
+ *
22
+ * @example
23
+ * ```bash
24
+ * savvy lint text # every tracked source/text file
25
+ * savvy lint text src/a.ts docs/b.md
26
+ * savvy lint text --staged src/a.ts # the index copy, as lint-staged runs it
27
+ * ```
28
+ *
29
+ * @internal
30
+ */
31
+ /* v8 ignore start -- CLI option definitions */
32
+ const filesArg = Argument.File("files", { mustExist: true }).pipe(Argument.withDescription("Files to check; omit to check every git-tracked source/text file"), Argument.variadic());
33
+ const stagedFlag = Flag.Boolean("staged").pipe(Flag.withDescription("Read each file's staged content from the git index instead of the working tree"), Flag.withDefault(false));
34
+ /**
35
+ * Check `files` (or, when empty, every tracked file under `cwd`) and print one
36
+ * line per finding; any finding sets exit code 1. Under `--staged`, every file
37
+ * is still checked when one has no index entry: that file's read error is
38
+ * logged to stderr, counted as failed, and also sets exit code 1. Exported for
39
+ * tests.
40
+ *
41
+ * @internal
42
+ */
43
+ const runLintText = (files, options = {}) => Effect.gen(function* () {
44
+ const staged = options.staged ?? false;
45
+ const targets = files.length > 0 ? files : yield* Lint.TextFiles.listTracked(options.cwd ?? ".", { staged });
46
+ const { findings, unreadable } = staged ? yield* Lint.TextFiles.checkStagedFiles(targets) : {
47
+ findings: yield* Lint.TextFiles.checkFiles(targets),
48
+ unreadable: []
49
+ };
50
+ for (const error of unreadable) yield* Effect.logError(error.message);
51
+ if (findings.length === 0 && unreadable.length === 0) {
52
+ yield* Output.ok(`${targets.length} ${targets.length === 1 ? "file is" : "files are"} grep-visible text`);
53
+ return findings;
54
+ }
55
+ for (const finding of findings) yield* Output.fail(`${finding.location} ${finding.message}`);
56
+ const failedFiles = (/* @__PURE__ */ new Set([...findings.map((finding) => finding.path), ...unreadable.map((error) => error.path)])).size;
57
+ yield* Output.summary({
58
+ ok: targets.length - failedFiles,
59
+ fail: failedFiles
60
+ });
61
+ yield* CliExit.set(1);
62
+ return findings;
63
+ });
64
+ /** The `savvy lint text` subcommand. */
65
+ const textCommand = Command.make("text", {
66
+ files: filesArg,
67
+ staged: stagedFlag
68
+ }, ({ files, staged }) => runLintText(files, { staged })).pipe(Command.withDescription("Fail when a source file contains a NUL byte or is not valid UTF-8 (grep-invisible)"));
69
+
70
+ //#endregion
71
+ export { runLintText, textCommand };
package/index.d.ts CHANGED
@@ -206,7 +206,7 @@ export declare function runLintInit(opts: {
206
206
  * errors from Effect's internal types. Task B7 should import and use this directly
207
207
  * as `Command.withSubcommands([lintCommand])` — the cast is for declaration emit only.
208
208
  */
209
- export declare const lintCommand: Command.Command<"lint", {}, {}, import("@effected/yaml").YamlParseError, never>;
209
+ export declare const lintCommand: Command.Command<"lint", {}, {}, import("@effected/git").GitCommandError | import("@effected/git").NotARepositoryError | import("effect/PlatformError").PlatformError | import("@effected/git").UnknownRefError | import("@effected/yaml").YamlParseError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | import("@effected/cli").CliExit | import("effect/FileSystem").FileSystem | import("@effected/git").Git | import("effect/Path").Path | import("effect/Stdio").Stdio>;
210
210
  //#endregion
211
211
  //#region src/commands/repos/commands/status.d.ts
212
212
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/cli",
3
- "version": "3.4.3",
3
+ "version": "3.5.1",
4
4
  "private": false,
5
5
  "description": "The savvy CLI — unified commit, changeset, and lint commands for the Silk Suite",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/cli",
@@ -40,12 +40,12 @@
40
40
  "@effected/cli": "^0.10.0",
41
41
  "@effected/commands": "^0.10.0",
42
42
  "@effected/engine": "^0.2.0",
43
- "@effected/git": "^0.18.1",
43
+ "@effected/git": "^0.19.0",
44
44
  "@effected/jsonc": "^0.14.0",
45
45
  "@effected/templates": "^0.9.1",
46
- "@effected/workspaces": "^0.30.1",
46
+ "@effected/workspaces": "^0.30.3",
47
47
  "@effected/yaml": "^0.18.0",
48
- "@savvy-web/silk-effects": "9.2.2",
48
+ "@savvy-web/silk-effects": "9.3.1",
49
49
  "effect": "4.0.0-rc.118"
50
50
  }
51
51
  }
package/version.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
  /** @internal */
10
- const CLI_VERSION = "3.4.3";
10
+ const CLI_VERSION = "3.5.1";
11
11
 
12
12
  //#endregion
13
13
  export { CLI_VERSION };