@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,277 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect, Option } from "effect";
4
+ import { WorkspaceDiscovery } from "workspaces-effect";
5
+ import { join, resolve } from "node:path";
6
+ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
7
+
8
+ //#region src/commands/changeset/commands/deps-regen.ts
9
+ /**
10
+ * `deps regen` command — delete all pure dependency changesets and
11
+ * write fresh single-package, patch-bump changesets reflecting the
12
+ * cumulative dep diff from base to working tree.
13
+ *
14
+ * @remarks
15
+ * **The single-package-per-changeset rule.** This command enforces our
16
+ * convention that each `.changeset/*.md` file lists exactly one package
17
+ * in its frontmatter. `@changesets/cli` technically supports multi-package
18
+ * frontmatter, but our agent (and this command) always produces single-
19
+ * package files for clarity and easier hand-editing.
20
+ *
21
+ * **Strict "pure dependency changeset" detection.** A changeset is
22
+ * eligible for deletion-and-regeneration if and only if:
23
+ *
24
+ * 1. Its frontmatter declares exactly one package, and
25
+ * 2. Its body contains exactly one `##` heading, and
26
+ * 3. That heading is `Dependencies`.
27
+ *
28
+ * Anything else (multi-package frontmatter, additional sections, comments,
29
+ * `### Sub-headings`, etc.) is treated as "mixed" and left untouched.
30
+ * That's the safe default — if a human authored something idiosyncratic,
31
+ * we don't clobber it.
32
+ *
33
+ * **The algorithm:**
34
+ *
35
+ * 1. Compute dep diff from `merge-base(baseBranch, HEAD)` to working
36
+ * tree, grouped by workspace package.
37
+ * 2. Find every pure-dependency changeset (strict definition).
38
+ * 3. Delete every one of them — even those for packages with no current
39
+ * dep changes (their changeset is stale by definition).
40
+ * 4. Write a fresh `<adjective>-<noun>-<verb>.md` per workspace package
41
+ * that has current dep changes: single-package frontmatter, `patch`
42
+ * bump, one `## Dependencies` section, one CSH005 table.
43
+ *
44
+ * @example
45
+ * ```bash
46
+ * savvy changeset deps regen
47
+ * savvy changeset deps regen --dry-run --json
48
+ * savvy changeset deps regen --package @scope/foo
49
+ * ```
50
+ *
51
+ * @internal
52
+ */
53
+ const { ConfigInspector, WorkspaceSnapshotReader, computeWorkspaceDependencyDiffs, gitMergeBase, listPublishablePackageNames, serializeDependencyTableToMarkdown, snapshotFromWorktree } = Changesets;
54
+ /* v8 ignore start -- CLI option definitions */
55
+ const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
56
+ const baseOption = Options.text("base").pipe(Options.withDescription("Override the base branch (defaults to config baseBranch)"), Options.optional);
57
+ const packageOption = Options.text("package").pipe(Options.withDescription("Restrict regeneration to a single workspace package"), Options.optional);
58
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withDescription("Print the plan without writing or deleting"), Options.withDefault(false));
59
+ const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit a structured plan as JSON"), Options.withDefault(false));
60
+ /* v8 ignore stop */
61
+ const ADJECTIVES = [
62
+ "brave",
63
+ "clever",
64
+ "swift",
65
+ "silver",
66
+ "lucky",
67
+ "happy",
68
+ "calm",
69
+ "bright",
70
+ "quiet",
71
+ "wild"
72
+ ];
73
+ const NOUNS = [
74
+ "dogs",
75
+ "cats",
76
+ "wolves",
77
+ "foxes",
78
+ "cups",
79
+ "ships",
80
+ "trees",
81
+ "owls",
82
+ "cranes",
83
+ "hills"
84
+ ];
85
+ const VERBS = [
86
+ "laugh",
87
+ "dream",
88
+ "fly",
89
+ "sing",
90
+ "dance",
91
+ "wander",
92
+ "soar",
93
+ "rest",
94
+ "leap",
95
+ "ponder"
96
+ ];
97
+ function pickRandomTriplet() {
98
+ return `${ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]}-${NOUNS[Math.floor(Math.random() * NOUNS.length)]}-${VERBS[Math.floor(Math.random() * VERBS.length)]}`;
99
+ }
100
+ /**
101
+ * Pick a `<adjective>-<noun>-<verb>` filename slug that does not collide
102
+ * with an existing `.changeset/*.md`. The triplet space is 1,000
103
+ * combinations, so a busy repo can plausibly exhaust it across runs;
104
+ * fall back to a timestamp suffix after 20 unlucky picks.
105
+ *
106
+ * @internal
107
+ */
108
+ function randomFilename(changesetDir) {
109
+ for (let i = 0; i < 20; i++) {
110
+ const candidate = pickRandomTriplet();
111
+ if (!existsSync(join(changesetDir, `${candidate}.md`))) return candidate;
112
+ }
113
+ return `${pickRandomTriplet()}-${Date.now()}`;
114
+ }
115
+ /**
116
+ * Strict detection of "pure dependency changesets" per the documented
117
+ * rules: single-package frontmatter, single `## Dependencies` heading,
118
+ * no other body content beyond that section.
119
+ *
120
+ * @internal
121
+ */
122
+ function isPureDependencyChangeset(content) {
123
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
124
+ if (!fmMatch) return {
125
+ isPure: false,
126
+ package: null
127
+ };
128
+ const frontmatter = fmMatch[1];
129
+ const body = (fmMatch[2] ?? "").trim();
130
+ const fmLines = frontmatter.split(/\r?\n/).filter((l) => l.trim().length > 0 && !/^\s*#/.test(l));
131
+ if (fmLines.length !== 1) return {
132
+ isPure: false,
133
+ package: null
134
+ };
135
+ const pkgMatch = fmLines[0].match(/^\s*["']?([^"':\s]+)["']?\s*:\s*([a-z]+)\s*$/);
136
+ if (!pkgMatch) return {
137
+ isPure: false,
138
+ package: null
139
+ };
140
+ const pkg = pkgMatch[1];
141
+ const bodyTrimmed = body.replace(/^\s+/, "");
142
+ if (!/^## Dependencies\b/.test(bodyTrimmed)) return {
143
+ isPure: false,
144
+ package: null
145
+ };
146
+ if ((bodyTrimmed.match(/^## /gm) ?? []).length !== 1) return {
147
+ isPure: false,
148
+ package: null
149
+ };
150
+ if (/^# /m.test(bodyTrimmed)) return {
151
+ isPure: false,
152
+ package: null
153
+ };
154
+ return {
155
+ isPure: true,
156
+ package: pkg
157
+ };
158
+ }
159
+ function listChangesetFiles(changesetDir) {
160
+ if (!existsSync(changesetDir)) return [];
161
+ return readdirSync(changesetDir).filter((f) => f.endsWith(".md") && f !== "README.md").map((f) => join(changesetDir, f));
162
+ }
163
+ function findPureDependencyChangesets(changesetDir) {
164
+ const result = [];
165
+ for (const file of listChangesetFiles(changesetDir)) {
166
+ let content;
167
+ try {
168
+ content = readFileSync(file, "utf8");
169
+ } catch {
170
+ continue;
171
+ }
172
+ const detection = isPureDependencyChangeset(content);
173
+ if (detection.isPure && detection.package) result.push({
174
+ file,
175
+ package: detection.package
176
+ });
177
+ }
178
+ return result;
179
+ }
180
+ function findMixedDependencyChangesets(changesetDir) {
181
+ const result = [];
182
+ for (const file of listChangesetFiles(changesetDir)) {
183
+ let content;
184
+ try {
185
+ content = readFileSync(file, "utf8");
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (/^## Dependencies\b/m.test(content) && !isPureDependencyChangeset(content).isPure) result.push(file);
190
+ }
191
+ return result;
192
+ }
193
+ function renderChangesetContent(diff) {
194
+ return `${`---\n"${diff.package}": patch\n---`}\n\n## Dependencies\n\n${serializeDependencyTableToMarkdown([...diff.rows])}\n`;
195
+ }
196
+ /**
197
+ * Handler exported for direct invocation in tests.
198
+ *
199
+ * @internal
200
+ */
201
+ function runDepsRegen(cwd, base, pkg, dryRun, json) {
202
+ return Effect.gen(function* () {
203
+ const resolvedCwd = resolve(cwd);
204
+ const changesetDir = join(resolvedCwd, ".changeset");
205
+ const reader = yield* WorkspaceSnapshotReader;
206
+ let baseBranch = Option.getOrUndefined(base);
207
+ if (!baseBranch) baseBranch = (yield* (yield* ConfigInspector).inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", () => Effect.succeed({ baseBranch: "main" })))).baseBranch;
208
+ const mergeBase = yield* gitMergeBase(resolvedCwd, baseBranch).pipe(Effect.catchTag("GitError", (err) => {
209
+ process.exitCode = 1;
210
+ return Effect.fail(err);
211
+ }));
212
+ let diffs = computeWorkspaceDependencyDiffs(yield* reader.snapshotAt(resolvedCwd, mergeBase).pipe(Effect.catchTag("GitError", (err) => {
213
+ process.exitCode = 1;
214
+ return Effect.fail(err);
215
+ })), snapshotFromWorktree(resolvedCwd));
216
+ const targetPkg = Option.getOrUndefined(pkg);
217
+ const publishable = yield* listPublishablePackageNames(yield* (yield* WorkspaceDiscovery).listPackages(resolvedCwd).pipe(Effect.catchAll(() => Effect.succeed([]))));
218
+ if (targetPkg) diffs = diffs.filter((d) => d.package === targetPkg);
219
+ else diffs = diffs.filter((d) => publishable.has(d.package));
220
+ const existingPure = findPureDependencyChangesets(changesetDir);
221
+ const skippedMixed = findMixedDependencyChangesets(changesetDir);
222
+ const toDelete = targetPkg ? existingPure.filter((p) => p.package === targetPkg) : existingPure.filter((p) => publishable.has(p.package));
223
+ const toWrite = diffs.map((diff) => ({
224
+ file: join(changesetDir, `${randomFilename(changesetDir)}.md`),
225
+ package: diff.package,
226
+ diff
227
+ }));
228
+ const plan = {
229
+ toDelete,
230
+ toWrite,
231
+ skippedMixed
232
+ };
233
+ if (dryRun) {
234
+ if (json) yield* Effect.log(JSON.stringify(plan, null, 2));
235
+ else yield* renderHumanPlan(plan);
236
+ return;
237
+ }
238
+ for (const entry of toDelete) try {
239
+ unlinkSync(entry.file);
240
+ } catch (error) {
241
+ yield* Effect.logWarning(`Failed to delete ${entry.file}: ${error instanceof Error ? error.message : String(error)}`);
242
+ }
243
+ for (const entry of toWrite) writeFileSync(entry.file, renderChangesetContent(entry.diff));
244
+ if (json) yield* Effect.log(JSON.stringify(plan, null, 2));
245
+ else yield* renderHumanPlan(plan);
246
+ });
247
+ }
248
+ function renderHumanPlan(plan) {
249
+ return Effect.gen(function* () {
250
+ if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Effect.log("No dependency changes to regenerate.");
251
+ else {
252
+ if (plan.toDelete.length > 0) {
253
+ yield* Effect.log(`Deleted ${plan.toDelete.length} pure dependency changeset(s):`);
254
+ for (const entry of plan.toDelete) yield* Effect.log(` - ${entry.file} (${entry.package})`);
255
+ }
256
+ if (plan.toWrite.length > 0) {
257
+ yield* Effect.log(`Wrote ${plan.toWrite.length} fresh dependency changeset(s):`);
258
+ for (const entry of plan.toWrite) yield* Effect.log(` + ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
259
+ }
260
+ }
261
+ if (plan.skippedMixed.length > 0) {
262
+ yield* Effect.log(`\nSkipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`);
263
+ for (const file of plan.skippedMixed) yield* Effect.log(` ~ ${file}`);
264
+ }
265
+ });
266
+ }
267
+ /* v8 ignore next 8 */
268
+ const depsRegenCommand = Command.make("regen", {
269
+ cwd: cwdOption,
270
+ base: baseOption,
271
+ package: packageOption,
272
+ dryRun: dryRunOption,
273
+ json: jsonOption
274
+ }, ({ cwd, base, package: pkg, dryRun, json }) => runDepsRegen(cwd, base, pkg, dryRun, json)).pipe(Command.withDescription("Delete pure dependency changesets and regenerate them from the current diff"));
275
+
276
+ //#endregion
277
+ export { depsRegenCommand };