@savvy-web/silk-core 0.1.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.
@@ -0,0 +1,105 @@
1
+ //#region src/pr-body/region.ts
2
+ /**
3
+ * The generic marker-delimited region grammar every silk-managed document
4
+ * uses: `<!-- token:start -->` … `<!-- token:end -->`.
5
+ *
6
+ * @remarks
7
+ * Extracted from `silk-release-action` (its `pr-body.ts` and
8
+ * `managed-sections.ts` both carried a private copy) so the grammar has one
9
+ * owner. **Every marker is a pair.** A lone opening marker can only be located
10
+ * by scanning forward to whatever happens to follow it, which makes the
11
+ * region's extent a function of its neighbours rather than of itself — moving
12
+ * anything nearby silently redefines it. The token is free-form; `:start` and
13
+ * `:end` are the whole contract, so pairs nest and a region can contain
14
+ * sub-regions without either needing to know about the other.
15
+ */
16
+ /**
17
+ * Pure helpers over the `<!-- token:start -->` / `<!-- token:end -->` region
18
+ * grammar.
19
+ *
20
+ * @remarks
21
+ * Every operation is total: a body with no region (or a broken pair) degrades
22
+ * to the documented fail-safe result rather than failing, because the callers
23
+ * are regenerating actions that must still produce a body when the prior one
24
+ * is malformed. Use `PrBodyDiagnostic.scan` when a caller wants to be told
25
+ * about a broken pair instead of silently tolerating it.
26
+ *
27
+ * @public
28
+ */
29
+ var Region = class Region {
30
+ constructor() {}
31
+ /**
32
+ * The opening delimiter for a named region.
33
+ *
34
+ * @public
35
+ */
36
+ static start(token) {
37
+ return `<!-- ${token}:start -->`;
38
+ }
39
+ /**
40
+ * The closing delimiter for a named region.
41
+ *
42
+ * @public
43
+ */
44
+ static end(token) {
45
+ return `<!-- ${token}:end -->`;
46
+ }
47
+ /**
48
+ * The content between a region's delimiters, or `undefined` when absent.
49
+ *
50
+ * @remarks
51
+ * Finds the FIRST opening marker and the matching close after it, so a
52
+ * nested region of a different token is returned as part of the content
53
+ * rather than truncating it.
54
+ *
55
+ * @public
56
+ */
57
+ static read(body, token) {
58
+ const from = body.indexOf(Region.start(token));
59
+ const to = body.indexOf(Region.end(token), from === -1 ? 0 : from);
60
+ if (from === -1 || to === -1 || to < from) return void 0;
61
+ return body.slice(from + Region.start(token).length, to);
62
+ }
63
+ /**
64
+ * Everything outside a region, with the region and its delimiters removed.
65
+ *
66
+ * @remarks
67
+ * A body without the region comes back unchanged — removal of an absent
68
+ * region is a no-op, not an error.
69
+ *
70
+ * @public
71
+ */
72
+ static strip(body, token) {
73
+ const from = body.indexOf(Region.start(token));
74
+ const to = body.indexOf(Region.end(token), from === -1 ? 0 : from);
75
+ if (from === -1 || to === -1 || to < from) return body;
76
+ return `${body.slice(0, from)}${body.slice(to + Region.end(token).length)}`;
77
+ }
78
+ /**
79
+ * Put `rendered` (a fully rendered region, its own markers included) into
80
+ * `body`, replacing a previous region of the same token and leaving
81
+ * everything else alone.
82
+ *
83
+ * @remarks
84
+ * **Human edits outside the markers survive.** A predecessor spliced on a
85
+ * markdown heading, which silently ate any content a human happened to put
86
+ * under a heading of that name and could not tell generated text from
87
+ * theirs. An explicit marker pair can.
88
+ *
89
+ * A body with no markers keeps its content and gains the region **below**
90
+ * it, so an existing hand-written document is not displaced. The result is
91
+ * trimmed.
92
+ *
93
+ * @public
94
+ */
95
+ static upsert(body, token, rendered) {
96
+ const start = body.indexOf(Region.start(token));
97
+ const end = body.indexOf(Region.end(token));
98
+ if (start !== -1 && end !== -1 && end > start) return `${body.slice(0, start)}${rendered}${body.slice(end + Region.end(token).length)}`.trim();
99
+ const trimmed = body.trim();
100
+ return trimmed === "" ? rendered : `${trimmed}\n\n${rendered}`;
101
+ }
102
+ };
103
+
104
+ //#endregion
105
+ export { Region };
@@ -0,0 +1,36 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ //#region src/schemas/BiomeConfig.ts
4
+ /**
5
+ * Result of a Biome schema URL sync or check operation.
6
+ *
7
+ * @remarks
8
+ * - `updated` — paths of config files whose `$schema` URL was changed (or would be changed on `check`).
9
+ * - `skipped` — paths of config files with no `$schema` field or a non-biomejs.dev URL.
10
+ * - `current` — paths of config files already pointing to the expected schema URL.
11
+ *
12
+ * @since 0.1.0
13
+ * @public
14
+ */
15
+ const BiomeSyncResult = Schema.Struct({
16
+ updated: Schema.Array(Schema.String),
17
+ skipped: Schema.Array(Schema.String),
18
+ current: Schema.Array(Schema.String)
19
+ });
20
+ /**
21
+ * Options for `BiomeSchemaSync` operations.
22
+ *
23
+ * @remarks
24
+ * `cwd` overrides the working directory used to locate `biome.json` / `biome.jsonc`.
25
+ * `gitignore` is reserved for future use to skip gitignored config files (defaults to `true`).
26
+ *
27
+ * @since 0.1.0
28
+ * @public
29
+ */
30
+ const BiomeSyncOptions = Schema.Struct({
31
+ cwd: Schema.optional(Schema.String),
32
+ gitignore: Schema.Boolean.pipe(Schema.withDecodingDefaultType(Effect.succeed(true)), Schema.withConstructorDefault(Effect.succeed(true)))
33
+ });
34
+
35
+ //#endregion
36
+ export { BiomeSyncOptions, BiomeSyncResult };
@@ -0,0 +1,50 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/schemas/ConfigDiscoverySchemas.ts
4
+ /**
5
+ * The discovery strategy used to locate a config file.
6
+ *
7
+ * @remarks
8
+ * - `"lib"` — found under `lib/configs/{name}` relative to the workspace root.
9
+ * - `"root"` — found directly in the workspace root as `{name}`.
10
+ * - `"cosmiconfig"` — reserved for future cosmiconfig-based discovery.
11
+ *
12
+ * @since 0.1.0
13
+ * @public
14
+ */
15
+ const ConfigSource = Schema.Literals([
16
+ "lib",
17
+ "root",
18
+ "cosmiconfig"
19
+ ]);
20
+ /**
21
+ * The resolved location of a discovered config file.
22
+ *
23
+ * @remarks
24
+ * Produced by `ConfigDiscovery.find` and `ConfigDiscovery.findAll`.
25
+ * `path` is the absolute file path; `source` indicates how it was discovered.
26
+ *
27
+ * @since 0.1.0
28
+ * @public
29
+ */
30
+ const ConfigLocation = Schema.Struct({
31
+ path: Schema.String,
32
+ source: ConfigSource
33
+ });
34
+ /**
35
+ * Options passed to config discovery methods.
36
+ *
37
+ * @remarks
38
+ * `cwd` overrides the working directory for path resolution (defaults to the current working directory).
39
+ * `tool` is reserved for future use as a tool-specific discovery hint.
40
+ *
41
+ * @since 0.1.0
42
+ * @public
43
+ */
44
+ const ConfigDiscoveryOptions = Schema.Struct({
45
+ cwd: Schema.optional(Schema.String),
46
+ tool: Schema.optional(Schema.String)
47
+ });
48
+
49
+ //#endregion
50
+ export { ConfigDiscoveryOptions, ConfigLocation, ConfigSource };
@@ -0,0 +1,232 @@
1
+ import { CommentStyle, SectionId } from "@effected/templates";
2
+
3
+ //#region src/schemas/SavvyInstallSection.ts
4
+ /**
5
+ * Section identity for the dependency auto-install block.
6
+ *
7
+ * `toolName` is `"savvy-install"`; pair with {@link savvyInstallDeps}.
8
+ *
9
+ * @since 7.5.0
10
+ * @public
11
+ */
12
+ const SavvyInstallSection = SectionId.make({
13
+ key: "SAVVY-INSTALL",
14
+ commentStyle: CommentStyle.hash
15
+ });
16
+ /**
17
+ * Pathspecs whose change across a checkout or merge means `node_modules` is stale.
18
+ *
19
+ * @remarks
20
+ * Git's default pathspec globbing does not stop a wildcard at a slash, so the
21
+ * one nested-manifest entry below covers every workspace manifest at any depth
22
+ * without needing a recursive spelling. Manifests are listed alongside lockfiles
23
+ * because a dependency edit that has not been installed yet moves the manifest
24
+ * first and the lockfile not at all.
25
+ */
26
+ const DEPENDENCY_PATHS = [
27
+ "package.json",
28
+ "*/package.json",
29
+ "pnpm-lock.yaml",
30
+ "pnpm-workspace.yaml",
31
+ "package-lock.json",
32
+ "npm-shrinkwrap.json",
33
+ "yarn.lock",
34
+ "bun.lock",
35
+ "bun.lockb"
36
+ ].map((path) => `'${path}'`).join(" ");
37
+ /**
38
+ * Dependency auto-install shared across Silk Suite hook files.
39
+ *
40
+ * @remarks
41
+ * Brings `node_modules` back in line after a branch switch or a pull, so a batch
42
+ * of repos pulled to align their dependencies does not each need a manual
43
+ * install. Installs with lifecycle scripts skipped — `--ignore-scripts`, or
44
+ * `--mode=skip-build` for Yarn Berry, which dropped the former — because the goal
45
+ * is to get the dependency tree on disk, not to run a full postinstall.
46
+ *
47
+ * **That flag has a cost, and one shape of repo cannot pay it.** Where a package
48
+ * publishes through a built link directory — `publishConfig.directory` with
49
+ * `linkDirectory: true`, which is how this monorepo wires `dist/dev/pkg` — the
50
+ * workspace resolves its own dependencies through a directory that a `prepare`
51
+ * script has to build, and skipping scripts yields a populated `node_modules`
52
+ * pointing at nothing.
53
+ *
54
+ * Those repos ask for a full install through the LOCAL git config key
55
+ * `savvy.installLifecycleScripts`, which `savvy init` sets when it finds the
56
+ * shape. The decision deliberately does NOT read the checked-out tree. Doing so
57
+ * failed in both directions at once: a branch that merely declared the shape
58
+ * could turn lifecycle scripts back on just by being checked out, making
59
+ * `git checkout` of an untrusted revision a code-execution path; and the `jq`
60
+ * the scan needed is absent on stock macOS and Ubuntu, where the missing answer
61
+ * silently skipped scripts in precisely the repos that cannot survive it.
62
+ * `.git/config` is neither checked out nor parsed with `jq`, so it has neither
63
+ * failure mode.
64
+ *
65
+ * The package managers gate dependency scripts themselves on top of this —
66
+ * pnpm's `strictDepBuilds` with an `allowBuilds` allowlist being the case in
67
+ * point — but workspace and root lifecycle scripts still run, which is why the
68
+ * opt-in is local rather than inferred. With the flag on, the hook says on the
69
+ * way out that scripts were skipped rather than leaving it to be discovered.
70
+ *
71
+ * Deliberately self-contained, like `savvyToolchainCheck`: its homes carry
72
+ * `SavvyHooksSection` but no `SavvyBaseSection`, so it defines its own root, CI
73
+ * and package-manager lookups rather than depending on `ROOT`, `in_ci` or `PM`.
74
+ *
75
+ * Four guards keep it from firing on the many checkouts that are not dependency
76
+ * events, each a silent no-op:
77
+ *
78
+ * - CI, where the runtime action owns installation by construction.
79
+ * - `SAVVY_SKIP_INSTALL`, the escape hatch for a bisect or a scripted sweep that
80
+ * does not want an install between steps.
81
+ * - `post-checkout` only: a branch-flag of `0`, which is `git checkout -- <file>`
82
+ * and not a move between commits.
83
+ * - **The gate that makes this affordable:** nothing dependency-related actually
84
+ * changed across the move. Without it every branch switch would pay for a full
85
+ * install. A missing `node_modules` overrides it, since there is nothing to be
86
+ * stale.
87
+ *
88
+ * A fresh clone is not among the cases it handles, and cannot be: husky sets
89
+ * `core.hooksPath` from its own `prepare` script, so until the first manual
90
+ * install has run there is no hook installed to fire.
91
+ *
92
+ * The install's exit status is swallowed and its output goes to stderr. Git
93
+ * ignores what `post-checkout` and `post-merge` return, so a failure here must
94
+ * not look like a failed checkout; the hint names the escape hatch instead.
95
+ *
96
+ * Every probe that can legitimately fail — no `ORIG_HEAD` to compare against, an
97
+ * unresolvable range — is neutralised with `|| true` rather than left to its own
98
+ * status. These blocks are co-owned and consumer content can sit below them, so
99
+ * under a hook running `set -e` a bare failing substitution would abort the whole
100
+ * file and take those later sections with it.
101
+ *
102
+ * @param hook - Which hook the block is being generated for; decides the
103
+ * argument guard and how the comparison range is recovered.
104
+ * @returns The install shell, with no surrounding markers or trailing newline.
105
+ *
106
+ * @since 7.5.0
107
+ * @public
108
+ */
109
+ function savvyInstallDeps(hook) {
110
+ const skip = `! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ] || [ -n "$SAVVY_SKIP_INSTALL" ]; }`;
111
+ return `if ${hook === "post-checkout" ? `${skip} && [ "$3" = "1" ]` : skip}; then
112
+ install_root=$(git rev-parse --show-toplevel 2>/dev/null) || true
113
+ ${hook === "post-checkout" ? ` install_from="$1"
114
+ install_to="$2"` : ` install_from=$(git rev-parse --verify --quiet ORIG_HEAD 2>/dev/null) || true
115
+ install_to="HEAD"`}
116
+ install_pm=""
117
+ if [ -n "$install_root" ] && [ -f "$install_root/package.json" ]; then
118
+ if command -v jq >/dev/null 2>&1; then
119
+ install_pm=$(jq -r '.packageManager // empty' "$install_root/package.json" 2>/dev/null | cut -d'@' -f1)
120
+ fi
121
+ if [ -z "$install_pm" ]; then
122
+ if [ -f "$install_root/pnpm-lock.yaml" ]; then install_pm="pnpm"
123
+ elif [ -f "$install_root/yarn.lock" ]; then install_pm="yarn"
124
+ elif [ -f "$install_root/bun.lock" ] || [ -f "$install_root/bun.lockb" ]; then install_pm="bun"
125
+ else install_pm="npm"; fi
126
+ fi
127
+ fi
128
+ # packageManager comes from the checked-out revision, so it names an executable
129
+ # the tree controls. 'command -v' proves a binary exists, not that it is a
130
+ # package manager, so the name is checked against the supported four first.
131
+ case "$install_pm" in
132
+ npm|pnpm|yarn|bun) ;;
133
+ *) install_pm="" ;;
134
+ esac
135
+ # Nothing to bring up to date unless a manifest or lockfile actually moved.
136
+ # A missing node_modules skips the diff outright: there is no tree to be stale.
137
+ install_stale=""
138
+ if [ -n "$install_pm" ]; then
139
+ if [ ! -d "$install_root/node_modules" ]; then
140
+ install_stale=1
141
+ elif [ -n "$install_from" ] && [ -n "$install_to" ]; then
142
+ install_stale=$(git diff --name-only "$install_from" "$install_to" -- ${DEPENDENCY_PATHS} 2>/dev/null | head -n 1) || true
143
+ fi
144
+ fi
145
+ if [ -n "$install_stale" ] && command -v "$install_pm" >/dev/null 2>&1; then
146
+ # Whether lifecycle scripts run is a LOCAL decision, read from .git/config,
147
+ # which no checkout can rewrite. Default off. 'savvy init' turns it on for a
148
+ # workspace that publishes through built link directories, where the links
149
+ # point at directories a prepare script has to produce.
150
+ install_scripts=$(git -C "$install_root" config --bool --get savvy.installLifecycleScripts 2>/dev/null) || true
151
+ if [ "$install_scripts" = "true" ]; then
152
+ install_flag=""
153
+ else
154
+ # Berry dropped --ignore-scripts for the install mode; Classic never knew the
155
+ # mode. An unreadable major reads as Berry, the likelier of the two today.
156
+ install_flag="--ignore-scripts"
157
+ if [ "$install_pm" = "yarn" ]; then
158
+ case "$(yarn --version 2>/dev/null | cut -d. -f1)" in
159
+ 1) ;;
160
+ *) install_flag="--mode=skip-build" ;;
161
+ esac
162
+ fi
163
+ fi
164
+ # ':+' so a full install announces itself as "pnpm install", not "pnpm install ".
165
+ printf '↻ dependencies changed, running %s install%s\\n' "$install_pm" "\${install_flag:+ $install_flag}" >&2
166
+ # Unquoted on purpose: empty must expand to NO argument rather than an empty
167
+ # one. Safe because every value is a static flag literal from this generator.
168
+ if ( cd "$install_root" && "$install_pm" install $install_flag ) >&2; then
169
+ if [ -n "$install_flag" ]; then
170
+ printf ' Lifecycle scripts were skipped. Run a full install if you need build outputs.\\n' >&2
171
+ fi
172
+ else
173
+ printf '⚠ %s install failed; run it yourself to see why.\\n' "$install_pm" >&2
174
+ printf ' Set SAVVY_SKIP_INSTALL=1 to stop this hook from trying.\\n' >&2
175
+ fi
176
+ fi
177
+ unset install_root install_from install_to install_pm install_stale install_scripts install_flag
178
+ fi`;
179
+ }
180
+ /**
181
+ * The local git config key that authorizes lifecycle scripts during a hook install.
182
+ *
183
+ * @remarks
184
+ * Local scope only. It lives in `.git/config`, which is never checked out, so no
185
+ * incoming revision can set it — that is the whole point of reading the decision
186
+ * from here rather than from a manifest in the tree.
187
+ *
188
+ * @since 7.5.0
189
+ * @public
190
+ */
191
+ const LIFECYCLE_SCRIPTS_CONFIG_KEY = "savvy.installLifecycleScripts";
192
+ /**
193
+ * Whether `manifest` publishes through a built link directory.
194
+ *
195
+ * @remarks
196
+ * `publishConfig.directory` with `linkDirectory: true` means consumers of this
197
+ * package resolve it through a directory that a `prepare` script has to produce,
198
+ * so an install that skips lifecycle scripts leaves the link pointing at nothing.
199
+ * A workspace containing any such package is one whose owner probably wants
200
+ * {@link LIFECYCLE_SCRIPTS_CONFIG_KEY} set.
201
+ *
202
+ * Reporting the shape is deliberately separate from acting on it: this answers
203
+ * "does this repo need scripts", and a human still decides whether hook-time
204
+ * installs may run them.
205
+ *
206
+ * @param manifest - A parsed `package.json`; any non-object reads as `false`.
207
+ *
208
+ * @since 7.5.0
209
+ * @public
210
+ */
211
+ function publishesBuiltLinkDirectory(manifest) {
212
+ if (typeof manifest !== "object" || manifest === null) return false;
213
+ const { publishConfig } = manifest;
214
+ if (typeof publishConfig !== "object" || publishConfig === null) return false;
215
+ const { directory, linkDirectory } = publishConfig;
216
+ return typeof directory === "string" && directory.length > 0 && linkDirectory === true;
217
+ }
218
+ /**
219
+ * Build the dependency auto-install block for `hook`.
220
+ *
221
+ * @param hook - Which hook the section is destined for.
222
+ * @returns A shell `Section` (`commentStyle: hash`) keyed `SAVVY-INSTALL`.
223
+ *
224
+ * @since 7.5.0
225
+ * @public
226
+ */
227
+ function savvyInstallBlock(hook) {
228
+ return SavvyInstallSection.section(savvyInstallDeps(hook));
229
+ }
230
+
231
+ //#endregion
232
+ export { LIFECYCLE_SCRIPTS_CONFIG_KEY, SavvyInstallSection, publishesBuiltLinkDirectory, savvyInstallBlock, savvyInstallDeps };
@@ -0,0 +1,194 @@
1
+ import { CommentStyle, SectionId } from "@effected/templates";
2
+
3
+ //#region src/schemas/SavvySections.ts
4
+ /**
5
+ * Build a shell-hook section identity from a Silk tool name.
6
+ *
7
+ * @remarks
8
+ * **The key is uppercased here, and that is load-bearing.** The kit renders a
9
+ * key verbatim into its markers (`# --- BEGIN <key> MANAGED SECTION ---`),
10
+ * while the section model this replaces uppercased `toolName` on the way in. A
11
+ * lowercase key would therefore emit `# --- BEGIN savvy-base MANAGED SECTION ---`
12
+ * and no longer match the `SAVVY-BASE` markers already written into every
13
+ * consumer repo's hook files — `check` would report the section absent and
14
+ * `sync` would append a second copy beside the first. Uppercasing keeps the
15
+ * marker bytes identical across the migration.
16
+ */
17
+ const shellSection = (toolName) => SectionId.make({
18
+ key: toolName.toUpperCase(),
19
+ commentStyle: CommentStyle.hash
20
+ });
21
+ /**
22
+ * Section identity for the shared package-manager preamble.
23
+ *
24
+ * `toolName` is `"savvy-base"`; pair with {@link savvyBasePreamble} to build the block:
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const section = SavvyBaseSection.section(savvyBasePreamble());
29
+ * ```
30
+ *
31
+ * @since 0.5.0
32
+ * @public
33
+ */
34
+ const SavvyBaseSection = shellSection("savvy-base");
35
+ /**
36
+ * Section identity for the shared repo-hygiene block.
37
+ *
38
+ * `toolName` is `"savvy-hooks"`; pair with {@link savvyHooksHygiene}.
39
+ *
40
+ * @since 0.5.0
41
+ * @public
42
+ */
43
+ const SavvyHooksSection = shellSection("savvy-hooks");
44
+ /**
45
+ * Package-manager detection preamble shared across Silk Suite hook files.
46
+ *
47
+ * @remarks
48
+ * Side-effect-free definitions meant to run unconditionally — no markers, no outer CI
49
+ * guard. Defines `ROOT`, the `in_ci` predicate, `PM` (via `detect_pm`), and `pm_exec`.
50
+ * `pm_exec` uses local/exec semantics for every package manager and `bun x` (space form),
51
+ * which works regardless of how bun was installed (the `bunx` shim is not always on PATH).
52
+ *
53
+ * @returns The preamble shell, with no surrounding markers or trailing newline.
54
+ *
55
+ * @since 0.5.0
56
+ * @public
57
+ */
58
+ function savvyBasePreamble() {
59
+ return `ROOT=$(git rev-parse --show-toplevel)
60
+
61
+ in_ci() { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }
62
+
63
+ detect_pm() {
64
+ if [ -f "$ROOT/package.json" ]; then
65
+ pm=$(jq -r '.packageManager // empty' "$ROOT/package.json" 2>/dev/null | cut -d'@' -f1)
66
+ if [ -n "$pm" ]; then echo "$pm"; return; fi
67
+ fi
68
+ if [ -f "$ROOT/pnpm-lock.yaml" ]; then echo "pnpm"
69
+ elif [ -f "$ROOT/yarn.lock" ]; then echo "yarn"
70
+ elif [ -f "$ROOT/bun.lock" ]; then echo "bun"
71
+ else echo "npm"; fi
72
+ }
73
+ PM=$(detect_pm)
74
+
75
+ pm_exec() {
76
+ case "$PM" in
77
+ pnpm) pnpm exec "$@" ;;
78
+ yarn) yarn exec "$@" ;;
79
+ bun) bun x "$@" ;;
80
+ *) npx --no -- "$@" ;;
81
+ esac
82
+ }`;
83
+ }
84
+ /**
85
+ * Repo-hygiene block shared across Silk Suite hook files.
86
+ *
87
+ * @remarks
88
+ * Self-guarded against CI and needs no package manager: disables Git's `core.fileMode`
89
+ * tracking and marks tracked shell scripts executable.
90
+ *
91
+ * @returns The hygiene shell, with no surrounding markers or trailing newline.
92
+ *
93
+ * @since 0.5.0
94
+ * @public
95
+ */
96
+ function savvyHooksHygiene() {
97
+ return `if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
98
+ git config core.fileMode false
99
+ git ls-files -z '*.sh' | xargs -0 chmod +x 2>/dev/null || true
100
+ fi`;
101
+ }
102
+ /**
103
+ * Build a consumer's one-line tool section so every consumer calls the shared base
104
+ * helpers identically.
105
+ *
106
+ * @remarks
107
+ * The returned block's content is exactly `in_ci || pm_exec <command>` with `command`
108
+ * appended verbatim — it is not parsed, quoted, or interpolated, so shell tokens like
109
+ * `$ROOT` and `$1` survive into the generated literal.
110
+ *
111
+ * **Precondition:** a {@link SavvyBaseSection} block must precede this section in the same
112
+ * hook file so `in_ci` and `pm_exec` are defined. Consumers guarantee this by passing both
113
+ * to `ManagedSection.syncAll` in order:
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * yield* sections.syncAll(".husky/commit-msg", [
118
+ * SavvyBaseSection.section(savvyBasePreamble()),
119
+ * savvyToolSection("savvy-commit", 'commitlint --config "$ROOT/lib/configs/commitlint.config.ts" --edit "$1"'),
120
+ * ]);
121
+ * ```
122
+ *
123
+ * @param toolName - Section identity; also drives the marker names (uppercased).
124
+ * @param command - The command passed verbatim to `pm_exec`, run only outside CI.
125
+ * @returns A shell `Section` (`commentStyle: hash`) for `toolName`.
126
+ *
127
+ * @since 0.5.0
128
+ * @public
129
+ */
130
+ function savvyToolSection(toolName, command) {
131
+ return shellSection(toolName).section(`in_ci || pm_exec ${command}`);
132
+ }
133
+ /**
134
+ * Section identity for the package-manager toolchain drift check.
135
+ *
136
+ * `toolName` is `"savvy-toolchain"`; pair with {@link savvyToolchainCheck}.
137
+ *
138
+ * @since 7.3.0
139
+ * @public
140
+ */
141
+ const SavvyToolchainSection = shellSection("savvy-toolchain");
142
+ /**
143
+ * Package-manager drift check shared across Silk Suite hook files.
144
+ *
145
+ * @remarks
146
+ * Compares the running package manager's version against the repo's
147
+ * `devEngines.packageManager` pin and prints a warning on mismatch. **Warn only** —
148
+ * it never blocks the hook and never installs anything, so nobody mid-bisect or
149
+ * mid-rebase on an older pin is stranded.
150
+ *
151
+ * Deliberately self-contained: its homes are `.husky/post-checkout` and
152
+ * `.husky/post-merge`, which carry {@link SavvyHooksSection} but no
153
+ * {@link SavvyBaseSection}, so it defines its own root/CI/pin lookups rather than
154
+ * depending on `ROOT`, `in_ci` or `PM`. It honours the `name` recorded in the pin
155
+ * rather than assuming pnpm.
156
+ *
157
+ * Every input is treated as optional: no `git` root, no `jq`, no `devEngines` block,
158
+ * or a package manager that is not on `PATH` all mean "say nothing". Only an exact
159
+ * pin is comparable, so ranges (`^1.2.3`, `>=1 || <2`) and wildcards (`1.x`) are
160
+ * skipped, and the `+sha512…` integrity tail `devEngines` versions routinely carry is
161
+ * stripped before comparison. Skipped under CI, where the runtime action installs the
162
+ * pin by construction.
163
+ *
164
+ * @returns The drift-check shell, with no surrounding markers or trailing newline.
165
+ *
166
+ * @since 7.3.0
167
+ * @public
168
+ */
169
+ function savvyToolchainCheck() {
170
+ return `if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
171
+ toolchain_root=$(git rev-parse --show-toplevel 2>/dev/null)
172
+ toolchain_pm=""
173
+ toolchain_pin=""
174
+ if [ -n "$toolchain_root" ] && [ -f "$toolchain_root/package.json" ] && command -v jq >/dev/null 2>&1; then
175
+ toolchain_pm=$(jq -r '.devEngines.packageManager.name // empty' "$toolchain_root/package.json" 2>/dev/null)
176
+ toolchain_pin=$(jq -r '.devEngines.packageManager.version // empty' "$toolchain_root/package.json" 2>/dev/null | cut -d'+' -f1)
177
+ fi
178
+ # Only an exact pin is comparable: drop ranges (^ ~ >= ||) and wildcards (x, *).
179
+ case "$toolchain_pin" in ""|[!0-9]*|*[!0-9A-Za-z.-]*|*x*|*X*) toolchain_pin="" ;; esac
180
+ if [ -n "$toolchain_pm" ] && [ -n "$toolchain_pin" ] && command -v "$toolchain_pm" >/dev/null 2>&1; then
181
+ toolchain_have=$("$toolchain_pm" --version 2>/dev/null | head -n 1 | tr -d '[:space:]')
182
+ # A manager that failed or answered with prose says nothing about drift.
183
+ case "$toolchain_have" in [!0-9]*|*[!0-9A-Za-z.-]*) toolchain_have="" ;; esac
184
+ if [ -n "$toolchain_have" ] && [ "$toolchain_have" != "$toolchain_pin" ]; then
185
+ printf '⚠ %s %s does not match %s, the version pinned in devEngines.packageManager.\\n' "$toolchain_pm" "$toolchain_have" "$toolchain_pin" >&2
186
+ printf ' Lockfiles written by this version may differ from CI. Fix: corepack use %s@%s\\n' "$toolchain_pm" "$toolchain_pin" >&2
187
+ fi
188
+ fi
189
+ unset toolchain_root toolchain_pm toolchain_pin toolchain_have
190
+ fi`;
191
+ }
192
+
193
+ //#endregion
194
+ export { SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyToolSection, savvyToolchainCheck };