@homeflare/config 0.5.1 → 0.7.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 (50) hide show
  1. package/README.md +46 -0
  2. package/bin/hooks.ts +19 -0
  3. package/dist/hooks/gates.d.ts +19 -0
  4. package/dist/hooks/gates.d.ts.map +1 -0
  5. package/dist/hooks/install.d.ts +20 -0
  6. package/dist/hooks/install.d.ts.map +1 -0
  7. package/dist/hooks/report.d.ts +30 -0
  8. package/dist/hooks/report.d.ts.map +1 -0
  9. package/dist/hooks/staged.d.ts +19 -0
  10. package/dist/hooks/staged.d.ts.map +1 -0
  11. package/dist/hooks.d.ts +15 -0
  12. package/dist/hooks.d.ts.map +1 -0
  13. package/dist/hooks.js +196 -0
  14. package/dist/hooks.js.map +14 -0
  15. package/dist/repo-shape/ci.d.ts +20 -0
  16. package/dist/repo-shape/ci.d.ts.map +1 -0
  17. package/dist/repo-shape/companions.d.ts +39 -0
  18. package/dist/repo-shape/companions.d.ts.map +1 -0
  19. package/dist/repo-shape/drift.d.ts +29 -0
  20. package/dist/repo-shape/drift.d.ts.map +1 -0
  21. package/dist/repo-shape/refresh.d.ts +18 -0
  22. package/dist/repo-shape/refresh.d.ts.map +1 -0
  23. package/dist/repo-shape/render.d.ts +39 -0
  24. package/dist/repo-shape/render.d.ts.map +1 -0
  25. package/dist/repo-shape/security.d.ts +19 -0
  26. package/dist/repo-shape/security.d.ts.map +1 -0
  27. package/dist/repo-shape/shape.d.ts +138 -0
  28. package/dist/repo-shape/shape.d.ts.map +1 -0
  29. package/dist/repo-shape/yaml.d.ts +18 -0
  30. package/dist/repo-shape/yaml.d.ts.map +1 -0
  31. package/dist/repo-shape.d.ts +43 -0
  32. package/dist/repo-shape.d.ts.map +1 -0
  33. package/dist/repo-shape.js +611 -0
  34. package/dist/repo-shape.js.map +17 -0
  35. package/docs/repo-shape.md +199 -0
  36. package/package.json +11 -1
  37. package/src/hooks/gates.ts +102 -0
  38. package/src/hooks/install.ts +95 -0
  39. package/src/hooks/report.ts +72 -0
  40. package/src/hooks/staged.ts +61 -0
  41. package/src/hooks.ts +48 -0
  42. package/src/repo-shape/ci.ts +215 -0
  43. package/src/repo-shape/companions.ts +151 -0
  44. package/src/repo-shape/drift.ts +130 -0
  45. package/src/repo-shape/refresh.ts +113 -0
  46. package/src/repo-shape/render.ts +88 -0
  47. package/src/repo-shape/security.ts +109 -0
  48. package/src/repo-shape/shape.ts +214 -0
  49. package/src/repo-shape/yaml.ts +96 -0
  50. package/src/repo-shape.ts +69 -0
package/README.md CHANGED
@@ -142,6 +142,52 @@ Alchemy still owns `GitHub.Repository` (visibility, `deleteBranchOnMerge`, `hasW
142
142
  `Cloudflare.state()`. The kit `main` ruleset is `scripts/apply-main-ruleset.ts`, not an
143
143
  Alchemy resource — see `docs/github-hygiene.md`.
144
144
 
145
+ ## git hooks
146
+
147
+ One hook layer for the whole estate. The behaviour ships here; a repo commits a
148
+ delegating wrapper and nothing else.
149
+
150
+ ```sh
151
+ bun add -D @homeflare/config husky
152
+ npm pkg set scripts.prepare=husky
153
+ bun install # husky writes .husky/_
154
+ bun node_modules/@homeflare/config/bin/hooks.ts install
155
+ git add .husky/pre-commit .husky/pre-push package.json
156
+ ```
157
+
158
+ | hook | runs | cost |
159
+ | ------------ | ----------------------------------------------- | ----------------- |
160
+ | `pre-commit` | `oxfmt` + `oxlint --deny-warnings`, staged only | sub-second |
161
+ | `pre-push` | the repo's own `bun run check` | whatever CI costs |
162
+
163
+ ⚠️ **pre-commit rewrites files.** It formats the staged formattable files (`.md`
164
+ included — house `oxfmt` formats markdown, and a hook that skipped it would let an
165
+ unformatted changeset through), names the ones it changed, and restages exactly those.
166
+ Without the restage the commit would capture the unformatted bytes and CI would fail a
167
+ file that reads as clean locally.
168
+
169
+ ⛔ **A file with unstaged edits on top is checked, never rewritten.** Restaging it would
170
+ sweep work in progress into a commit nobody asked for.
171
+
172
+ ⛔ **pre-push always calls `check`, never `verify`.** `verify` means the consumer smoke
173
+ test in this repo and a _live_ adoption verifier in `homeflare-proxmox`; a hook that
174
+ guessed would run credentials-backed live checks on a push.
175
+
176
+ ⚠️ **A hook is a local convenience, not a gate.** It is skippable with `--no-verify`,
177
+ absent from a fresh clone until `bun install` runs `prepare`, and silently inert when
178
+ `core.hooksPath` points at a `.husky/_` that no install has created yet. The required
179
+ checks on `main` stay the gate; this makes the cheap mistakes cheap to find.
180
+
181
+ ```ts
182
+ import { problemsInHooks } from '@homeflare/config/hooks';
183
+
184
+ expect(await problemsInHooks(process.cwd())).toEqual([]);
185
+ ```
186
+
187
+ ⛔ `problemsInHooks` is deliberately **not** part of `checkProject`. Every repo runs that
188
+ checker from a test, so folding hook conformance in would turn every repo that has not
189
+ adopted yet red on `main` in one commit. A repo opts in by calling this.
190
+
145
191
  ## License
146
192
 
147
193
  MIT © Timothy Schneider
package/bin/hooks.ts ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The entry a repo's `.husky/` wrapper calls.
4
+ *
5
+ * ⚠️ IT IMPORTS `../src`, NOT `../dist`. `src` is published, Bun runs TypeScript
6
+ * directly, and a hook that depended on a build step would be dead in a fresh clone
7
+ * of the repo that HOSTS this package — the one place `dist/` does not exist yet.
8
+ */
9
+ import { isCommand, runCommand } from '../src/hooks.ts';
10
+
11
+ const command = process.argv[2] ?? '';
12
+
13
+ if (!isCommand(command)) {
14
+ process.stderr.write(`homeflare hooks: unknown command ${JSON.stringify(command)}\n`);
15
+ process.stderr.write(' expected one of: pre-commit, pre-push, install\n');
16
+ process.exit(2);
17
+ }
18
+
19
+ await runCommand(command, process.cwd());
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Format and lint what is staged, restaging only what the formatter rewrote.
3
+ *
4
+ * ⚠️ THIS HOOK REWRITES FILES DURING A COMMIT, and it says so on the line where it
5
+ * happens. Without the restage, oxfmt would fix the worktree while the commit kept
6
+ * the unformatted bytes — CI then fails on a file that reads as correct locally.
7
+ */
8
+ export declare function preCommit(root: string): Promise<void>;
9
+ /**
10
+ * Run the repo's own declared gate before the push reaches the runner.
11
+ *
12
+ * ★ IT CALLS `bun run check` RATHER THAN NAMING TOOLS. Every repo's `check` is the
13
+ * command CI runs; hard-coding `tsc` and `bun test` here would drift from whichever
14
+ * repo added a step, and the hook would certify a push CI rejects.
15
+ * ⛔ It does not widen a narrow `check`. If a repo's gate only looks at part of the
16
+ * tree, this hook inherits exactly that blind spot — fix the script, not the hook.
17
+ */
18
+ export declare function prePush(root: string): Promise<void>;
19
+ //# sourceMappingURL=gates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../../src/hooks/gates.ts"],"names":[],"mappings":"AAeA;;;;;;GAMG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA8C3D;AAED;;;;;;;;GAQG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBzD"}
@@ -0,0 +1,20 @@
1
+ /** The hook files this package installs, in the order a contributor meets them. */
2
+ export declare const HOOK_NAMES: readonly ['pre-commit', 'pre-push'];
3
+ /**
4
+ * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A checkout with no `node_modules` would
5
+ * otherwise fail every commit with a module-resolution error, and the first thing
6
+ * anyone would do is delete the hook. Failing open is the right trade for a
7
+ * convenience; the required checks on `main` are what must fail closed.
8
+ */
9
+ export declare const HUSKY_HOOK: string;
10
+ /** Write the wrapper into `.husky/`. Returns the paths written, relative to the project. */
11
+ export declare function installHooks(projectDir: string): Promise<readonly string[]>;
12
+ /**
13
+ * Report what stops this project's hooks from working. Empty means adopted.
14
+ *
15
+ * ⛔ DELIBERATELY NOT PART OF `checkProject`. Every repo in the estate runs that checker
16
+ * from a test; folding hook conformance into it would turn every repo that has not
17
+ * adopted yet red on `main` in the same commit. A repo opts in by calling this.
18
+ */
19
+ export declare function problemsInHooks(projectDir: string): Promise<readonly string[]>;
20
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/hooks/install.ts"],"names":[],"mappings":"AAYA,mFAAmF;AACnF,eAAO,MAAM,UAAU,YAAI,YAAY,EAAE,UAAU,CAAU,CAAC;AAK9D;;;;;GAKG;AACH,eAAO,MAAM,UAAU,EAAE,MAexB,CAAC;AAEF,4FAA4F;AAC5F,wBAAsB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAYjF;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CA+BpF"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * How a hook talks to whoever triggered it.
3
+ *
4
+ * ★ EVERY FAILURE PRINTS BOTH COMMANDS — the one that fixes it and the one that skips
5
+ * it. A hook that exits non-zero and says nothing teaches `--no-verify` as a reflex,
6
+ * and that switch turns off every check rather than the one that was wrong.
7
+ *
8
+ * ⚠️ Output goes to STDERR. Git hooks share stdout with porcelain in some flows, and a
9
+ * hook that writes there can corrupt what a caller is parsing.
10
+ */
11
+ /** The two hooks this package implements. Named exactly as the git hook files are. */
12
+ export type Hook = 'pre-commit' | 'pre-push';
13
+ /** Run a command, streaming its output. Returns its exit code. */
14
+ export declare function run(cmd: readonly string[]): Promise<number>;
15
+ /** Capture a command's stdout. Used for git plumbing only. */
16
+ export declare function capture(cmd: readonly string[]): Promise<string>;
17
+ /**
18
+ * Resolve a dev tool to the project's own copy.
19
+ *
20
+ * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
21
+ * git hook that reaches the network mid-commit is a hang waiting for a flaky link.
22
+ * husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so
23
+ * the local binary is named outright when it exists and `bunx` is only the fallback.
24
+ */
25
+ export declare function tool(root: string, name: string): readonly string[];
26
+ export declare function ok(what: string): void;
27
+ export declare function note(what: string): void;
28
+ /** ⛔ ALWAYS GIVE THE FIX. "lint failed" is a dead end; the command that repairs it is not. */
29
+ export declare function fail(hook: Hook, what: string, fix: string): never;
30
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/hooks/report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,sFAAsF;AACtF,MAAM,MAAM,IAAI,GAAG,YAAY,GAAG,UAAU,CAAC;AAO7C,kEAAkE;AAClE,wBAAsB,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAGjE;AAED,8DAA8D;AAC9D,wBAAsB,OAAO,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAKrE;AAED;;;;;;;GAOG;AACH,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAGlE;AAaD,wBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAErC;AAED,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEvC;AAED,8FAA8F;AAC9F,wBAAgB,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK,CAKjE"}
@@ -0,0 +1,19 @@
1
+ export type Staged = {
2
+ /** Fully staged and formattable — safe to rewrite and restage. */
3
+ readonly formattable: readonly string[];
4
+ /** The subset of `formattable` that oxlint understands. */
5
+ readonly code: readonly string[];
6
+ /** Staged but also dirty in the worktree — checked, never rewritten. */
7
+ readonly partial: readonly string[];
8
+ };
9
+ /** Classify the index. Deletions are excluded — there is nothing to format in them. */
10
+ export declare function staged(): Promise<Staged>;
11
+ /**
12
+ * Content fingerprints, so only the files a formatter actually changed get restaged.
13
+ *
14
+ * ★ WHY NOT RESTAGE EVERYTHING. `git add` on an untouched file is harmless but noisy:
15
+ * the hook would claim it rewrote files it left alone, and a hook that overstates
16
+ * what it did is one nobody reads.
17
+ */
18
+ export declare function fingerprints(files: readonly string[]): Promise<ReadonlyMap<string, string>>;
19
+ //# sourceMappingURL=staged.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged.d.ts","sourceRoot":"","sources":["../../src/hooks/staged.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,MAAM,GAAG;IACnB,kEAAkE;IAClE,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,2DAA2D;IAC3D,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,wEAAwE;IACxE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC,CAAC;AAOF,uFAAuF;AACvF,wBAAsB,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAU9C;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQjG"}
@@ -0,0 +1,15 @@
1
+ import { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';
2
+ import { type Hook } from './hooks/report.ts';
3
+ export { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };
4
+ export type { Hook };
5
+ /** The commands `bin/hooks.ts` accepts. */
6
+ export type Command = Hook | 'install';
7
+ export declare function isCommand(value: string): value is Command;
8
+ /**
9
+ * Run one hook, or install the wrappers.
10
+ *
11
+ * ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a
12
+ * programming error in the wrapper and is reported as such, not as a failed commit.
13
+ */
14
+ export declare function runCommand(command: Command, root: string): Promise<void>;
15
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3F,OAAO,EAAE,KAAK,IAAI,EAAY,MAAM,mBAAmB,CAAC;AAExD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,eAAe,EAAE,CAAC;AACjE,YAAY,EAAE,IAAI,EAAE,CAAC;AAErB,2CAA2C;AAC3C,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC;AAEvC,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,OAAO,CAEzD;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS9E"}
package/dist/hooks.js ADDED
@@ -0,0 +1,196 @@
1
+ // @bun
2
+ // src/hooks/report.ts
3
+ var BYPASS = {
4
+ "pre-commit": "git commit --no-verify",
5
+ "pre-push": "git push --no-verify"
6
+ };
7
+ async function run(cmd) {
8
+ const proc = Bun.spawn([...cmd], { stdout: "inherit", stderr: "inherit" });
9
+ return await proc.exited;
10
+ }
11
+ async function capture(cmd) {
12
+ const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "ignore" });
13
+ const text = await new Response(proc.stdout).text();
14
+ await proc.exited;
15
+ return text;
16
+ }
17
+ function tool(root, name) {
18
+ const local = `${root}/node_modules/.bin/${name}`;
19
+ return Bun.file(local).size > 0 ? [local] : ["bunx", name];
20
+ }
21
+ function line(text) {
22
+ process.stderr.write(`${text}
23
+ `);
24
+ }
25
+ function ok(what) {
26
+ line(`\u2713 ${what}`);
27
+ }
28
+ function note(what) {
29
+ line(` ${what}`);
30
+ }
31
+ function fail(hook, what, fix) {
32
+ line(`
33
+ \u2717 ${hook}: ${what}`);
34
+ line(` fix: ${fix}`);
35
+ line(` bypass: ${BYPASS[hook]} \u2014 CI still runs the real gate
36
+ `);
37
+ process.exit(1);
38
+ }
39
+
40
+ // src/hooks/staged.ts
41
+ var FORMATTABLE = /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;
42
+ var CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
43
+ async function names(args) {
44
+ const out = await capture(["git", ...args]);
45
+ return out.split(`
46
+ `).filter((line2) => line2.length > 0);
47
+ }
48
+ async function staged() {
49
+ const indexed = await names(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
50
+ const dirty = new Set(await names(["diff", "--name-only", "--diff-filter=ACMR"]));
51
+ const candidates = indexed.filter((file) => FORMATTABLE.test(file));
52
+ return {
53
+ formattable: candidates.filter((file) => !dirty.has(file)),
54
+ code: candidates.filter((file) => !dirty.has(file) && CODE.test(file)),
55
+ partial: candidates.filter((file) => dirty.has(file))
56
+ };
57
+ }
58
+ async function fingerprints(files) {
59
+ const out = new Map;
60
+ for (const file of files) {
61
+ const handle = Bun.file(file);
62
+ if (!await handle.exists())
63
+ continue;
64
+ out.set(file, String(Bun.hash(await handle.arrayBuffer())));
65
+ }
66
+ return out;
67
+ }
68
+
69
+ // src/hooks/gates.ts
70
+ async function preCommit(root) {
71
+ const oxfmt = tool(root, "oxfmt");
72
+ const { formattable, code, partial } = await staged();
73
+ if (partial.length > 0) {
74
+ note(`${partial.length} staged file(s) also have unstaged edits; checking without rewriting`);
75
+ if (await run([...oxfmt, "--check", ...partial]) !== 0) {
76
+ fail("pre-commit", "those partially staged files are unformatted, and rewriting them would stage your unstaged work", "bun run format, then stage the files you meant to commit");
77
+ }
78
+ }
79
+ if (formattable.length === 0) {
80
+ ok("pre-commit: nothing staged to format");
81
+ return;
82
+ }
83
+ const before = await fingerprints(formattable);
84
+ if (await run([...oxfmt, ...formattable]) !== 0) {
85
+ fail("pre-commit", "oxfmt could not format the staged files", "bun run format");
86
+ }
87
+ const after = await fingerprints(formattable);
88
+ const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
89
+ if (rewritten.length > 0) {
90
+ note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(", ")}`);
91
+ if (await run(["git", "add", "--", ...rewritten]) !== 0) {
92
+ fail("pre-commit", "could not restage the formatted files", "git add the listed files");
93
+ }
94
+ }
95
+ if (code.length > 0 && await run([...tool(root, "oxlint"), "--deny-warnings", ...code]) !== 0) {
96
+ fail("pre-commit", `oxlint found problems in ${code.length} staged file(s)`, "bun run lint:fix, then fix by hand what remains");
97
+ }
98
+ ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
99
+ }
100
+ async function prePush(root) {
101
+ const manifest = Bun.file(`${root}/package.json`);
102
+ const pkg = await manifest.exists() ? await manifest.json() : {};
103
+ if (pkg.scripts?.["check"] === undefined) {
104
+ note("pre-push: no `check` script declared in package.json; nothing to run");
105
+ return;
106
+ }
107
+ note("pre-push: running `bun run check` \u2014 the same gate CI runs");
108
+ const started = Bun.nanoseconds();
109
+ if (await run(["bun", "run", "check"]) !== 0) {
110
+ fail("pre-push", "bun run check failed \u2014 CI would fail the same way, on a shared runner", "bun run lint:fix, then bun run check until it is green");
111
+ }
112
+ ok(`pre-push: bun run check passed in ${((Bun.nanoseconds() - started) / 1e9).toFixed(1)}s`);
113
+ }
114
+
115
+ // src/hooks/install.ts
116
+ import { chmod, mkdir } from "fs/promises";
117
+ var HOOK_NAMES = ["pre-commit", "pre-push"];
118
+ var RUNNER = "node_modules/@homeflare/config/bin/hooks.ts";
119
+ var HUSKY_HOOK = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
120
+ # and the same bytes are installed as .husky/pre-commit and .husky/pre-push \u2014 the hook
121
+ # name comes from $0.
122
+ #
123
+ # \u26A0\uFE0F A hook is a local convenience, not a gate: it is skippable with --no-verify and does
124
+ # not exist in a fresh clone until \`bun install\` runs the \`prepare\` script. The
125
+ # required checks on main stay the gate.
126
+ #
127
+ # Regenerate this file with: bun ${RUNNER} install
128
+ hook="${RUNNER}"
129
+ if [ ! -f "$hook" ]; then
130
+ echo "husky: $hook is missing \u2014 run 'bun install' to enable the HomeFlare hooks; skipping"
131
+ exit 0
132
+ fi
133
+ exec bun "$hook" "$(basename "$0")"
134
+ `;
135
+ async function installHooks(projectDir) {
136
+ await mkdir(`${projectDir}/.husky`, { recursive: true });
137
+ const written = [];
138
+ for (const name of HOOK_NAMES) {
139
+ const path = `${projectDir}/.husky/${name}`;
140
+ await Bun.write(path, HUSKY_HOOK);
141
+ await chmod(path, 493);
142
+ written.push(`.husky/${name}`);
143
+ }
144
+ return written;
145
+ }
146
+ async function problemsInHooks(projectDir) {
147
+ const problems = [];
148
+ const manifest = Bun.file(`${projectDir}/package.json`);
149
+ if (!await manifest.exists())
150
+ return ["package.json: missing"];
151
+ const pkg = await manifest.json();
152
+ if (!(pkg.scripts?.["prepare"] ?? "").includes("husky")) {
153
+ problems.push('package.json: no "prepare": "husky" script \u2014 a fresh clone installs no hooks');
154
+ }
155
+ if (pkg.devDependencies?.["husky"] === undefined) {
156
+ problems.push("package.json: husky is not a devDependency");
157
+ }
158
+ for (const name of HOOK_NAMES) {
159
+ const file = Bun.file(`${projectDir}/.husky/${name}`);
160
+ if (!await file.exists()) {
161
+ problems.push(`.husky/${name}: missing \u2014 run \`bun ${RUNNER} install\``);
162
+ continue;
163
+ }
164
+ if (await file.text() !== HUSKY_HOOK) {
165
+ problems.push(`.husky/${name}: differs from the @homeflare/config wrapper \u2014 run \`bun ${RUNNER} install\`, or change it in the package`);
166
+ }
167
+ }
168
+ return problems;
169
+ }
170
+
171
+ // src/hooks.ts
172
+ function isCommand(value) {
173
+ return value === "pre-commit" || value === "pre-push" || value === "install";
174
+ }
175
+ async function runCommand(command, root) {
176
+ if (command === "install") {
177
+ const written = await installHooks(root);
178
+ ok(`wrote ${written.join(", ")} \u2014 commit them`);
179
+ note("they do nothing until `bun install` runs `prepare` (husky)");
180
+ return;
181
+ }
182
+ if (command === "pre-commit")
183
+ return await preCommit(root);
184
+ return await prePush(root);
185
+ }
186
+ export {
187
+ HOOK_NAMES,
188
+ HUSKY_HOOK,
189
+ installHooks,
190
+ isCommand,
191
+ problemsInHooks,
192
+ runCommand
193
+ };
194
+
195
+ //# debugId=C234A537C7D6F0D364756E2164756E21
196
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/hooks/report.ts", "../src/hooks/staged.ts", "../src/hooks/gates.ts", "../src/hooks/install.ts", "../src/hooks.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * How a hook talks to whoever triggered it.\n *\n * ★ EVERY FAILURE PRINTS BOTH COMMANDS — the one that fixes it and the one that skips\n * it. A hook that exits non-zero and says nothing teaches `--no-verify` as a reflex,\n * and that switch turns off every check rather than the one that was wrong.\n *\n * ⚠️ Output goes to STDERR. Git hooks share stdout with porcelain in some flows, and a\n * hook that writes there can corrupt what a caller is parsing.\n */\n\n/** The two hooks this package implements. Named exactly as the git hook files are. */\nexport type Hook = 'pre-commit' | 'pre-push';\n\nconst BYPASS: Record<Hook, string> = {\n 'pre-commit': 'git commit --no-verify',\n 'pre-push': 'git push --no-verify',\n};\n\n/** Run a command, streaming its output. Returns its exit code. */\nexport async function run(cmd: readonly string[]): Promise<number> {\n const proc = Bun.spawn([...cmd], { stdout: 'inherit', stderr: 'inherit' });\n return await proc.exited;\n}\n\n/** Capture a command's stdout. Used for git plumbing only. */\nexport async function capture(cmd: readonly string[]): Promise<string> {\n const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });\n const text = await new Response(proc.stdout).text();\n await proc.exited;\n return text;\n}\n\n/**\n * Resolve a dev tool to the project's own copy.\n *\n * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a\n * git hook that reaches the network mid-commit is a hang waiting for a flaky link.\n * husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so\n * the local binary is named outright when it exists and `bunx` is only the fallback.\n */\nexport function tool(root: string, name: string): readonly string[] {\n const local = `${root}/node_modules/.bin/${name}`;\n return Bun.file(local).size > 0 ? [local] : ['bunx', name];\n}\n\n/**\n * ⚠️ `process.stderr.write`, NOT `console`. Two reasons, and the lint rule is the lesser\n * one: a hook shares stdout with git porcelain in some flows, and a synchronous write\n * is the only kind guaranteed to land before `process.exit` below throws the buffer\n * away. Using `console.error` here would also make every consumer of this package\n * need a `no-console` exemption for code they never call directly.\n */\nfunction line(text: string): void {\n process.stderr.write(`${text}\\n`);\n}\n\nexport function ok(what: string): void {\n line(`✓ ${what}`);\n}\n\nexport function note(what: string): void {\n line(` ${what}`);\n}\n\n/** ⛔ ALWAYS GIVE THE FIX. \"lint failed\" is a dead end; the command that repairs it is not. */\nexport function fail(hook: Hook, what: string, fix: string): never {\n line(`\\n✗ ${hook}: ${what}`);\n line(` fix: ${fix}`);\n line(` bypass: ${BYPASS[hook]} — CI still runs the real gate\\n`);\n process.exit(1);\n}\n",
6
+ "/**\n * What git has staged, split by what the hook may safely touch.\n *\n * ⛔ A STAGED FILE THAT ALSO HAS UNSTAGED EDITS IS OFF LIMITS. Formatting it in place\n * and running `git add` would sweep the contributor's work-in-progress into a commit\n * they did not ask for — the single worst thing a hook can do. Those files are\n * reported and checked, never rewritten.\n */\nimport { capture } from './report.ts';\n\n/**\n * ⚠️ `.md` IS IN THIS LIST ON PURPOSE. House `oxfmt` formats markdown, so a hook that\n * skipped it would let an unformatted changeset through and CI would fail a commit\n * that looked clean locally. A hook must check what CI checks or it trains distrust.\n */\nconst FORMATTABLE = /\\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;\nconst CODE = /\\.(ts|tsx|js|jsx|mjs|cjs)$/;\n\nexport type Staged = {\n /** Fully staged and formattable — safe to rewrite and restage. */\n readonly formattable: readonly string[];\n /** The subset of `formattable` that oxlint understands. */\n readonly code: readonly string[];\n /** Staged but also dirty in the worktree — checked, never rewritten. */\n readonly partial: readonly string[];\n};\n\nasync function names(args: readonly string[]): Promise<readonly string[]> {\n const out = await capture(['git', ...args]);\n return out.split('\\n').filter((line) => line.length > 0);\n}\n\n/** Classify the index. Deletions are excluded — there is nothing to format in them. */\nexport async function staged(): Promise<Staged> {\n const indexed = await names(['diff', '--cached', '--name-only', '--diff-filter=ACMR']);\n const dirty = new Set(await names(['diff', '--name-only', '--diff-filter=ACMR']));\n const candidates = indexed.filter((file) => FORMATTABLE.test(file));\n\n return {\n formattable: candidates.filter((file) => !dirty.has(file)),\n code: candidates.filter((file) => !dirty.has(file) && CODE.test(file)),\n partial: candidates.filter((file) => dirty.has(file)),\n };\n}\n\n/**\n * Content fingerprints, so only the files a formatter actually changed get restaged.\n *\n * ★ WHY NOT RESTAGE EVERYTHING. `git add` on an untouched file is harmless but noisy:\n * the hook would claim it rewrote files it left alone, and a hook that overstates\n * what it did is one nobody reads.\n */\nexport async function fingerprints(files: readonly string[]): Promise<ReadonlyMap<string, string>> {\n const out = new Map<string, string>();\n for (const file of files) {\n const handle = Bun.file(file);\n if (!(await handle.exists())) continue;\n out.set(file, String(Bun.hash(await handle.arrayBuffer())));\n }\n return out;\n}\n",
7
+ "/**\n * The two gates every HomeFlare repo gets, from one place.\n *\n * ★ THE SPLIT IS BY COST. `pre-commit` touches only the staged files and is measured in\n * hundreds of milliseconds, so it can run on every commit without anyone resenting\n * it. `pre-push` runs the repo's own `bun run check` — seconds, once, before the\n * change costs a slot on the shared self-hosted runner.\n *\n * ⚠️ NEITHER IS A GATE. Both are skippable with `--no-verify` and neither exists in a\n * fresh clone until `bun install` runs `prepare`. The required checks on `main` stay\n * the gate; these only make the cheap mistakes cheap to find.\n */\nimport { fail, note, ok, run, tool } from './report.ts';\nimport { fingerprints, staged } from './staged.ts';\n\n/**\n * Format and lint what is staged, restaging only what the formatter rewrote.\n *\n * ⚠️ THIS HOOK REWRITES FILES DURING A COMMIT, and it says so on the line where it\n * happens. Without the restage, oxfmt would fix the worktree while the commit kept\n * the unformatted bytes — CI then fails on a file that reads as correct locally.\n */\nexport async function preCommit(root: string): Promise<void> {\n const oxfmt = tool(root, 'oxfmt');\n const { formattable, code, partial } = await staged();\n\n // ⛔ Checked, never rewritten — see staged.ts for why `git add` here would be theft.\n if (partial.length > 0) {\n note(`${partial.length} staged file(s) also have unstaged edits; checking without rewriting`);\n if ((await run([...oxfmt, '--check', ...partial])) !== 0) {\n fail(\n 'pre-commit',\n 'those partially staged files are unformatted, and rewriting them would stage your unstaged work',\n 'bun run format, then stage the files you meant to commit',\n );\n }\n }\n\n if (formattable.length === 0) {\n ok('pre-commit: nothing staged to format');\n return;\n }\n\n const before = await fingerprints(formattable);\n if ((await run([...oxfmt, ...formattable])) !== 0) {\n fail('pre-commit', 'oxfmt could not format the staged files', 'bun run format');\n }\n const after = await fingerprints(formattable);\n const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));\n\n if (rewritten.length > 0) {\n note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(', ')}`);\n if ((await run(['git', 'add', '--', ...rewritten])) !== 0) {\n fail('pre-commit', 'could not restage the formatted files', 'git add the listed files');\n }\n }\n\n // ⚠️ `--deny-warnings` matches what CI runs. A hook that is laxer than CI is worse\n // than no hook: it certifies a change CI will reject.\n if (code.length > 0 && (await run([...tool(root, 'oxlint'), '--deny-warnings', ...code])) !== 0) {\n fail(\n 'pre-commit',\n `oxlint found problems in ${code.length} staged file(s)`,\n 'bun run lint:fix, then fix by hand what remains',\n );\n }\n\n ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);\n}\n\n/**\n * Run the repo's own declared gate before the push reaches the runner.\n *\n * ★ IT CALLS `bun run check` RATHER THAN NAMING TOOLS. Every repo's `check` is the\n * command CI runs; hard-coding `tsc` and `bun test` here would drift from whichever\n * repo added a step, and the hook would certify a push CI rejects.\n * ⛔ It does not widen a narrow `check`. If a repo's gate only looks at part of the\n * tree, this hook inherits exactly that blind spot — fix the script, not the hook.\n */\nexport async function prePush(root: string): Promise<void> {\n const manifest = Bun.file(`${root}/package.json`);\n const pkg = (await manifest.exists())\n ? ((await manifest.json()) as { scripts?: Record<string, string> })\n : {};\n\n if (pkg.scripts?.['check'] === undefined) {\n note('pre-push: no `check` script declared in package.json; nothing to run');\n return;\n }\n\n note('pre-push: running `bun run check` — the same gate CI runs');\n const started = Bun.nanoseconds();\n if ((await run(['bun', 'run', 'check'])) !== 0) {\n fail(\n 'pre-push',\n 'bun run check failed — CI would fail the same way, on a shared runner',\n 'bun run lint:fix, then bun run check until it is green',\n );\n }\n\n ok(`pre-push: bun run check passed in ${((Bun.nanoseconds() - started) / 1e9).toFixed(1)}s`);\n}\n",
8
+ "/**\n * Adoption: the one wrapper every repo commits, and the check that it has not drifted.\n *\n * ★ WHY A WRAPPER AT ALL. husky can only run a file that is tracked in the repo, so\n * something must be committed per repo. This keeps that something to a delegation\n * whose text is owned HERE — the behaviour lives in one package, and a repo that\n * edits its copy is reported as drift rather than quietly diverging.\n * ★ ONE FILE, TWO NAMES. The wrapper reads the hook name from `$0`, so `pre-commit`\n * and `pre-push` are byte-identical and there is a single text to keep in step.\n */\nimport { chmod, mkdir } from 'node:fs/promises';\n\n/** The hook files this package installs, in the order a contributor meets them. */\nexport const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;\n\n/** Where the runner lives once `bun install` has run. Relative: git runs hooks at the root. */\nconst RUNNER = 'node_modules/@homeflare/config/bin/hooks.ts';\n\n/**\n * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A checkout with no `node_modules` would\n * otherwise fail every commit with a module-resolution error, and the first thing\n * anyone would do is delete the hook. Failing open is the right trade for a\n * convenience; the required checks on `main` are what must fail closed.\n */\nexport const HUSKY_HOOK: string = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,\n# and the same bytes are installed as .husky/pre-commit and .husky/pre-push — the hook\n# name comes from $0.\n#\n# ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify and does\n# not exist in a fresh clone until \\`bun install\\` runs the \\`prepare\\` script. The\n# required checks on main stay the gate.\n#\n# Regenerate this file with: bun ${RUNNER} install\nhook=\"${RUNNER}\"\nif [ ! -f \"$hook\" ]; then\n echo \"husky: $hook is missing — run 'bun install' to enable the HomeFlare hooks; skipping\"\n exit 0\nfi\nexec bun \"$hook\" \"$(basename \"$0\")\"\n`;\n\n/** Write the wrapper into `.husky/`. Returns the paths written, relative to the project. */\nexport async function installHooks(projectDir: string): Promise<readonly string[]> {\n await mkdir(`${projectDir}/.husky`, { recursive: true });\n const written: string[] = [];\n for (const name of HOOK_NAMES) {\n const path = `${projectDir}/.husky/${name}`;\n await Bun.write(path, HUSKY_HOOK);\n // ⚠️ husky's own runner does `sh -e \"$s\"`, which does not need the execute bit, but\n // `core.hooksPath=.husky` without husky does. Set it so both mechanisms work.\n await chmod(path, 0o755);\n written.push(`.husky/${name}`);\n }\n return written;\n}\n\n/**\n * Report what stops this project's hooks from working. Empty means adopted.\n *\n * ⛔ DELIBERATELY NOT PART OF `checkProject`. Every repo in the estate runs that checker\n * from a test; folding hook conformance into it would turn every repo that has not\n * adopted yet red on `main` in the same commit. A repo opts in by calling this.\n */\nexport async function problemsInHooks(projectDir: string): Promise<readonly string[]> {\n const problems: string[] = [];\n const manifest = Bun.file(`${projectDir}/package.json`);\n\n if (!(await manifest.exists())) return ['package.json: missing'];\n const pkg = (await manifest.json()) as {\n scripts?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n\n if (!(pkg.scripts?.['prepare'] ?? '').includes('husky')) {\n problems.push('package.json: no \"prepare\": \"husky\" script — a fresh clone installs no hooks');\n }\n if (pkg.devDependencies?.['husky'] === undefined) {\n problems.push('package.json: husky is not a devDependency');\n }\n\n for (const name of HOOK_NAMES) {\n const file = Bun.file(`${projectDir}/.husky/${name}`);\n if (!(await file.exists())) {\n problems.push(`.husky/${name}: missing — run \\`bun ${RUNNER} install\\``);\n continue;\n }\n if ((await file.text()) !== HUSKY_HOOK) {\n problems.push(\n `.husky/${name}: differs from the @homeflare/config wrapper — run \\`bun ${RUNNER} install\\`, or change it in the package`,\n );\n }\n }\n\n return problems;\n}\n",
9
+ "/**\n * The HomeFlare git hooks, as a package.\n *\n * ★ WHY THIS IS NOT A SCRIPT IN EVERY REPO. Fourteen copies of a hook script is the\n * exact drift `@homeflare/config` exists to prevent: the copies diverge, nobody\n * notices, and two repos disagree about what a commit must satisfy. Here the repo\n * commits a delegating wrapper and the behaviour ships with the package, so changing\n * the rule is one release and a version bump rather than fourteen edits.\n *\n * ⚠️ HUSKY, NOT lefthook OR A BARE `core.hooksPath`. It is already the estate's\n * mechanism in the repos that have working hooks, it installs from `prepare` on a\n * plain `bun install`, and it keeps the hook files tracked and reviewable. A second\n * mechanism alongside it would mean two ways to answer \"are hooks on in this repo\".\n *\n * Usage from a hook file — see `HUSKY_HOOK`:\n *\n * bun node_modules/@homeflare/config/bin/hooks.ts pre-commit\n */\nimport { preCommit, prePush } from './hooks/gates.ts';\nimport { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';\nimport { type Hook, note, ok } from './hooks/report.ts';\n\nexport { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };\nexport type { Hook };\n\n/** The commands `bin/hooks.ts` accepts. */\nexport type Command = Hook | 'install';\n\nexport function isCommand(value: string): value is Command {\n return value === 'pre-commit' || value === 'pre-push' || value === 'install';\n}\n\n/**\n * Run one hook, or install the wrappers.\n *\n * ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a\n * programming error in the wrapper and is reported as such, not as a failed commit.\n */\nexport async function runCommand(command: Command, root: string): Promise<void> {\n if (command === 'install') {\n const written = await installHooks(root);\n ok(`wrote ${written.join(', ')} — commit them`);\n note('they do nothing until `bun install` runs `prepare` (husky)');\n return;\n }\n if (command === 'pre-commit') return await preCommit(root);\n return await prePush(root);\n}\n"
10
+ ],
11
+ "mappings": "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",
12
+ "debugId": "C234A537C7D6F0D364756E2164756E21",
13
+ "names": []
14
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `.github/workflows/ci.yml`, rendered.
3
+ *
4
+ * ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried
5
+ * thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header
6
+ * comment alone had four different wordings and the actionlint block had three. Written
7
+ * here once, every repository gets the same explanation, and correcting it is one edit.
8
+ *
9
+ * ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through
10
+ * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`
11
+ * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.
12
+ */
13
+ import type { RepoShape } from './shape.ts';
14
+ /** Bun the whole estate is pinned to. One line, one place. */
15
+ export declare const BUN_VERSION = "1.4.0";
16
+ /** actionlint the `workflow lint` job runs, and the mini's job image preloads. */
17
+ export declare const ACTIONLINT_VERSION = "1.7.12";
18
+ /** The whole `ci.yml` for a shape. */
19
+ export declare function renderCi(shape: RepoShape): string;
20
+ //# sourceMappingURL=ci.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci.d.ts","sourceRoot":"","sources":["../../src/repo-shape/ci.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAY,SAAS,EAAE,MAAM,YAAY,CAAC;AAItD,8DAA8D;AAC9D,eAAO,MAAM,WAAW,UAAU,CAAC;AACnC,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAgK3C,sCAAsC;AACtC,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAkCjD"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The three smaller rendered files: actionlint's config, the changeset config, and
3
+ * Dependabot.
4
+ *
5
+ * ★ EACH ONE WAS MEASURED BEFORE IT WAS RENDERED (2026-09-22, across 13 repositories):
6
+ * · `.changeset/config.json` — byte-identical apart from the repository name in 11 of
7
+ * 13. The three that differed did so by accident: two pinned an older `$schema`
8
+ * (3.1.4 against 4.0.1) and one used `@changesets/cli/changelog` instead of the
9
+ * GitHub changelog everyone else had, which silently drops PR links from releases.
10
+ * · `.github/actionlint.yaml` — 12 of 13 had it, with three wordings of one comment.
11
+ * The one without it is the one repository still on `ubuntu-latest`, which is the
12
+ * only case where it is genuinely not needed. That is an input, not an exception.
13
+ * · `.github/dependabot.yml` — 1 of 14. Twelve repositories take no dependency or
14
+ * Action updates at all, and nothing said so. Rendering it is the fix.
15
+ */
16
+ import type { RepoShape } from './shape.ts';
17
+ /**
18
+ * `.changeset/config.json`.
19
+ *
20
+ * ⛔ `privatePackages` IS WRITTEN ONLY FOR A REPOSITORY THAT DOES NOT PUBLISH, AND BOTH
21
+ * OF ITS FIELDS ARE REQUIRED. `@changesets/cli` silently versions nothing when
22
+ * `version` is absent, so a private repository without this key opens a Version
23
+ * Packages PR that changes no version and tags no release — a release pipeline that
24
+ * reports success and ships nothing.
25
+ */
26
+ export declare function renderChangesetConfig(shape: RepoShape): string;
27
+ /**
28
+ * `.github/actionlint.yaml` — rendered only for a self-hosted runner.
29
+ *
30
+ * ★ DECLARED, NOT SUPPRESSED. actionlint's `runner-label` rule stays on, so a typo'd
31
+ * label (`homeflare-mnii`) still fails the `workflow lint` job rather than silently
32
+ * queueing a job no runner ever claims. Measured 2026-09-22 with actionlint 1.7.12: an
33
+ * undeclared `homeflare-mini` is an error, not a warning, so this file is the reason
34
+ * twelve repositories are green rather than a nicety.
35
+ */
36
+ export declare function renderActionlintConfig(shape: RepoShape): string | undefined;
37
+ /** `.github/dependabot.yml`. */
38
+ export declare function renderDependabot(shape: RepoShape): string;
39
+ //# sourceMappingURL=companions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"companions.d.ts","sourceRoot":"","sources":["../../src/repo-shape/companions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAS5C;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAiB9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAgB3E;AAED,gCAAgC;AAChC,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAsEzD"}
@@ -0,0 +1,29 @@
1
+ import type { RepoShape, RepoShapeException } from './shape.ts';
2
+ /** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */
3
+ export type Problem = string;
4
+ /** The command that makes a difference go away. Printed with every drift problem. */
5
+ export declare const REFRESH_COMMAND = "bun run repo-shape:refresh";
6
+ export interface DriftReport {
7
+ /** Empty when the committed files are the rendered ones. */
8
+ readonly problems: readonly Problem[];
9
+ /** Paths whose committed text differs from the render, excluding excepted files. */
10
+ readonly drifted: readonly string[];
11
+ /** Paths the repository declared it owns, with the reason it gave. */
12
+ readonly excepted: readonly RepoShapeException[];
13
+ }
14
+ /**
15
+ * Compare a repository's committed tooling files against its declared shape.
16
+ *
17
+ * const report = await driftInRepoShape(process.cwd(), shape);
18
+ * expect(report.problems).toEqual([]);
19
+ *
20
+ * A declared exception passes. An undeclared difference fails, and so does a missing file.
21
+ */
22
+ export declare function driftInRepoShape(projectDir: string, shape: RepoShape): Promise<DriftReport>;
23
+ /**
24
+ * Every rendered path that is NOT compared, with why. For a report, not for a gate.
25
+ * ★ Printed by `refresh --check` so a passing run still names what it did not check.
26
+ * An exception that stops being visible is an exception that stops being reconsidered.
27
+ */
28
+ export declare function exceptionSummary(shape: RepoShape): readonly string[];
29
+ //# sourceMappingURL=drift.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drift.d.ts","sourceRoot":"","sources":["../../src/repo-shape/drift.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAgB,SAAS,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAE9E,wFAAwF;AACxF,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,qFAAqF;AACrF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AA6C5D,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,SAAS,kBAAkB,EAAE,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CA6BjG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAKpE"}
@@ -0,0 +1,18 @@
1
+ import { type RepoShape } from './shape.ts';
2
+ export interface RefreshResult {
3
+ /** Paths written because their content changed or they were absent. */
4
+ readonly written: readonly string[];
5
+ /** Paths already correct. */
6
+ readonly unchanged: readonly string[];
7
+ /** Paths skipped because the shape declares an exception for them. */
8
+ readonly skipped: readonly string[];
9
+ }
10
+ /** Write a repository's rendered files into `projectDir`. */
11
+ export declare function refreshRepoShape(projectDir: string, shape: RepoShape): Promise<RefreshResult>;
12
+ /**
13
+ * The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero
14
+ * instead of writing, which is what a repository puts in CI when it does not want the
15
+ * check inside `bun test`.
16
+ */
17
+ export declare function repoShapeCli(projectDir: string, shape: RepoShape, argv: readonly string[]): Promise<number>;
18
+ //# sourceMappingURL=refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../../src/repo-shape/refresh.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,KAAK,SAAS,EAAc,MAAM,YAAY,CAAC;AAGxD,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,6DAA6D;AAC7D,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,aAAa,CAAC,CAsBxB;AAiBD;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,MAAM,CAAC,CA+BjB"}
@@ -0,0 +1,39 @@
1
+ import type { RenderedPath, RepoShape } from './shape.ts';
2
+ /** The status-check contexts a rendered repository reports. */
3
+ export interface RepoShapePolicy {
4
+ readonly owner: string;
5
+ readonly repository: string;
6
+ /**
7
+ * ⛔ EXACTLY THE AGGREGATES, NEVER THE LEAF JOBS. `ci` is `if: always()` and fails when
8
+ * any job it needs did not succeed, so requiring it requires all of them. Requiring a
9
+ * leaf job instead would mean a ruleset edit every time a job is added, and a context
10
+ * that stops reporting leaves every pull request pending rather than failing.
11
+ */
12
+ readonly checks: readonly string[];
13
+ }
14
+ export interface RenderedRepo {
15
+ /** Every rendered file, by its path relative to the repository root. */
16
+ readonly files: Readonly<Record<string, string>>;
17
+ /**
18
+ * The options `declareRepoPolicy(id, { ...rendered.policy, settings })` takes.
19
+ * Squash-only, auto-merge, delete-branch-on-merge and the `main` ruleset come from
20
+ * there; the check names come from here.
21
+ */
22
+ readonly policy: RepoShapePolicy;
23
+ }
24
+ /**
25
+ * Render a repository's tooling files and the policy that matches them.
26
+ *
27
+ * const rendered = renderRepoShape(shape);
28
+ * rendered.files['.github/workflows/ci.yml'] // the file
29
+ * rendered.policy.checks // ['ci', 'secret scan']
30
+ *
31
+ * ⚠️ EXCEPTED FILES ARE STILL RENDERED. `files` is what the standard says this repository
32
+ * should have; `drift.ts` is what decides which of them are compared. Keeping them here
33
+ * means `--show` can print the standard version of an excepted file, which is how
34
+ * anyone judges whether the exception is still worth its reason.
35
+ */
36
+ export declare function renderRepoShape(shape: RepoShape): RenderedRepo;
37
+ /** Every path this renderer can emit, whatever a given shape asks for. */
38
+ export declare const RENDERED_PATHS: readonly RenderedPath[];
39
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/repo-shape/render.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE1D,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjD;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,CAe9D;AAED,0EAA0E;AAC1E,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC"}