@savvy-web/cli 0.3.1 → 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.
- package/bin/savvy.d.ts +1 -0
- package/bin/savvy.js +17 -1
- package/cli/index.js +123 -0
- package/commands/changeset/commands/analyze-branch.js +108 -0
- package/commands/changeset/commands/check.js +71 -0
- package/commands/changeset/commands/classify.js +69 -0
- package/commands/changeset/commands/config-show.js +100 -0
- package/commands/changeset/commands/config-validate.js +63 -0
- package/commands/changeset/commands/deps-detect.js +103 -0
- package/commands/changeset/commands/deps-regen.js +277 -0
- package/commands/changeset/commands/init.js +634 -0
- package/commands/changeset/commands/lint.js +62 -0
- package/commands/changeset/commands/release-surface.js +96 -0
- package/commands/changeset/commands/transform.js +88 -0
- package/commands/changeset/commands/validate-file.js +52 -0
- package/commands/changeset/commands/version.js +178 -0
- package/commands/changeset/index.js +42 -0
- package/commands/changeset/utils/config-gate.js +59 -0
- package/commands/check.js +74 -0
- package/commands/clean.js +186 -0
- package/commands/commit/check.js +170 -0
- package/commands/commit/constants.js +10 -0
- package/commands/commit/hook.js +22 -0
- package/commands/commit/hooks/post-commit-verify.js +121 -0
- package/commands/commit/hooks/pre-commit-message.js +64 -0
- package/commands/commit/hooks/session-start.js +69 -0
- package/commands/commit/hooks/user-prompt-submit.js +42 -0
- package/commands/commit/index.js +20 -0
- package/commands/commit/init.js +127 -0
- package/commands/init.js +88 -0
- package/commands/lint/check.js +306 -0
- package/commands/lint/fmt.js +64 -0
- package/commands/lint/index.js +20 -0
- package/commands/lint/init.js +221 -0
- package/index.d.ts +237 -244
- package/index.js +14 -1
- package/package.json +39 -51
- package/841.js +0 -2394
- 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 };
|
package/commands/init.js
ADDED
|
@@ -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 };
|