@homeflare/config 0.9.0 → 0.11.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 (59) hide show
  1. package/README.md +5 -40
  2. package/bin/hooks.ts +8 -4
  3. package/dist/hooks/activate.d.ts +7 -0
  4. package/dist/hooks/activate.d.ts.map +1 -0
  5. package/dist/hooks/gates.d.ts +7 -14
  6. package/dist/hooks/gates.d.ts.map +1 -1
  7. package/dist/hooks/install.d.ts +7 -1
  8. package/dist/hooks/install.d.ts.map +1 -1
  9. package/dist/hooks/push-plan.d.ts +59 -0
  10. package/dist/hooks/push-plan.d.ts.map +1 -0
  11. package/dist/hooks/push-range.d.ts +51 -0
  12. package/dist/hooks/push-range.d.ts.map +1 -0
  13. package/dist/hooks/report.d.ts +29 -2
  14. package/dist/hooks/report.d.ts.map +1 -1
  15. package/dist/hooks/secrets.d.ts +2 -0
  16. package/dist/hooks/secrets.d.ts.map +1 -0
  17. package/dist/hooks.d.ts +30 -6
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/hooks.js +339 -31
  20. package/dist/hooks.js.map +11 -7
  21. package/dist/repo-shape/automerge.d.ts +17 -0
  22. package/dist/repo-shape/automerge.d.ts.map +1 -0
  23. package/dist/repo-shape/ci.d.ts.map +1 -1
  24. package/dist/repo-shape/companions.d.ts +2 -6
  25. package/dist/repo-shape/companions.d.ts.map +1 -1
  26. package/dist/repo-shape/dependabot.d.ts +35 -0
  27. package/dist/repo-shape/dependabot.d.ts.map +1 -0
  28. package/dist/repo-shape/guards.d.ts +25 -0
  29. package/dist/repo-shape/guards.d.ts.map +1 -0
  30. package/dist/repo-shape/render.d.ts.map +1 -1
  31. package/dist/repo-shape/shape.d.ts +40 -2
  32. package/dist/repo-shape/shape.d.ts.map +1 -1
  33. package/dist/repo-shape.d.ts +5 -2
  34. package/dist/repo-shape.d.ts.map +1 -1
  35. package/dist/repo-shape.js +269 -105
  36. package/dist/repo-shape.js.map +10 -7
  37. package/dist/versions.js +68 -31
  38. package/dist/versions.js.map +6 -5
  39. package/docs/hooks.md +99 -0
  40. package/docs/repo-shape-dependabot.md +126 -0
  41. package/docs/repo-shape-inputs.md +83 -0
  42. package/docs/repo-shape.md +5 -23
  43. package/package.json +1 -1
  44. package/src/hooks/activate.ts +71 -0
  45. package/src/hooks/gates.ts +87 -28
  46. package/src/hooks/install.ts +39 -20
  47. package/src/hooks/push-plan.ts +167 -0
  48. package/src/hooks/push-range.ts +177 -0
  49. package/src/hooks/report.ts +31 -6
  50. package/src/hooks/secrets.ts +42 -0
  51. package/src/hooks.ts +30 -14
  52. package/src/repo-shape/automerge.ts +144 -0
  53. package/src/repo-shape/ci.ts +35 -16
  54. package/src/repo-shape/companions.ts +2 -77
  55. package/src/repo-shape/dependabot.ts +136 -0
  56. package/src/repo-shape/guards.ts +68 -0
  57. package/src/repo-shape/render.ts +5 -1
  58. package/src/repo-shape/shape.ts +51 -35
  59. package/src/repo-shape.ts +8 -4
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Turn the hooks on for a clone and EVERY worktree of it, from `prepare`.
3
+ *
4
+ * 🔴 THE GAP THIS CLOSES, measured 2026-09-23. husky sets `core.hooksPath=.husky/_`, and
5
+ * `.husky/_` is generated and gitignored: it exists only in the worktree where `bun
6
+ * install` ran husky. `core.hooksPath` lives in the clone's SHARED config, so every other
7
+ * worktree — every `git worktree add` an agent makes — points at a directory it does not
8
+ * have, and git runs no hooks there, silently. In homeflare-kit that was every worktree
9
+ * until `bun install` had run husky inside it.
10
+ * ★ THE FIX IS A TRACKED PATH. `core.hooksPath=.husky` is relative, and git resolves a
11
+ * relative hooks path against the worktree running the hook — so each worktree runs its
12
+ * OWN checked-out `.husky/`, present from the moment the worktree exists. Measured on git
13
+ * 2.55: a fresh `git worktree add` ran the tracked hook with no install at all. (The
14
+ * wrapper then needs that worktree's `node_modules` to do its work — install.ts says so
15
+ * out loud rather than failing.)
16
+ * ⛔ IT NEVER FAILS. `prepare` runs inside `bun install`; a hook problem must not break an
17
+ * install. Every outcome, including "did nothing", is a message and exit 0.
18
+ */
19
+ import { probe } from './report.ts';
20
+
21
+ export type Activation = {
22
+ /** The hooks path is set (now or already). */
23
+ readonly active: boolean;
24
+ readonly message: string;
25
+ };
26
+
27
+ /**
28
+ * ⚠️ CI IS SKIPPED ON PURPOSE. Hooks are a local convenience and CI runs the real gate; a
29
+ * runner with hooks on would run them on the commits it makes itself (a release's
30
+ * "Version Packages" commit) on a machine that may have no gitleaks.
31
+ */
32
+ function inCi(env: Readonly<Record<string, string | undefined>>): boolean {
33
+ const ci = env['CI'];
34
+ return ci !== undefined && ci !== '' && ci !== '0' && ci.toLowerCase() !== 'false';
35
+ }
36
+
37
+ export async function activateHooks(
38
+ root: string,
39
+ env: Readonly<Record<string, string | undefined>> = process.env,
40
+ ): Promise<Activation> {
41
+ if (inCi(env)) return { active: false, message: 'CI is set — hooks stay off; CI runs the gate' };
42
+
43
+ // ★ `--show-prefix` answers two questions at once: whether this is a work tree at all
44
+ // (it fails outside one — a tarball install), and where `root` sits inside it.
45
+ const where = await probe(['git', '-C', root, 'rev-parse', '--show-prefix']);
46
+ if (where.code !== 0) return { active: false, message: 'not a git work tree — nothing to do' };
47
+
48
+ const tracked = await Promise.all(
49
+ ['pre-commit', 'pre-push'].map((name) => Bun.file(`${root}/.husky/${name}`).exists()),
50
+ );
51
+ if (!tracked.some(Boolean)) {
52
+ return { active: false, message: 'no .husky/pre-commit or .husky/pre-push to point git at' };
53
+ }
54
+
55
+ // ⚠️ RELATIVE TO THE TOP OF THE WORK TREE, which is where git resolves it — a package
56
+ // below the root (`<prefix>.husky`) still points at its own directory.
57
+ const want = `${where.stdout.trim()}.husky`;
58
+ const current = (
59
+ await probe(['git', '-C', root, 'config', '--get', 'core.hooksPath'])
60
+ ).stdout.trim();
61
+ if (current === want) return { active: true, message: `core.hooksPath is already ${want}` };
62
+
63
+ // ★ NO `--worktree`: the clone's shared config, so ONE install covers every worktree.
64
+ const set = await probe(['git', '-C', root, 'config', 'core.hooksPath', want]);
65
+ if (set.code !== 0) return { active: false, message: 'git config core.hooksPath failed' };
66
+ const was = current === '' ? 'unset' : current;
67
+ return {
68
+ active: true,
69
+ message: `core.hooksPath ${was} → ${want}, for every worktree of this clone`,
70
+ };
71
+ }
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * The two gates every HomeFlare repo gets, from one place.
3
3
  *
4
- * ★ THE SPLIT IS BY COST. `pre-commit` touches only the staged files and is measured in
5
- * hundreds of milliseconds, so it can run on every commit without anyone resenting
6
- * it. `pre-push` runs the repo's own `bun run check` — seconds, once, before the
7
- * change costs a slot on the shared self-hosted runner.
8
- *
9
- * ⚠️ NEITHER IS A GATE. Both are skippable with `--no-verify` and neither exists in a
10
- * fresh clone until `bun install` runs `prepare`. The required checks on `main` stay
11
- * the gate; these only make the cheap mistakes cheap to find.
4
+ * ★ THE SPLIT IS BY COST. `pre-commit` touches only the staged files — a secret scan, then
5
+ * format and lint — and is measured in hundreds of milliseconds, so it can run on every
6
+ * commit without anyone resenting it. `pre-push` runs the repository's own `check`
7
+ * narrowed to what the push can affect (push-plan.ts): seconds, once per push.
8
+ * ⚠️ NEITHER IS A GATE. Both are skippable with `--no-verify`, and a worktree has them only
9
+ * once `bun install` has run there. The required checks on `main` stay the gate — CI
10
+ * runs the whole `check`, every test, the build and the smoke test on every pull request.
12
11
  */
13
- import { fail, note, ok, run, tool } from './report.ts';
12
+ import { existsSync } from 'node:fs';
13
+ import { type Lane, planLanes } from './push-plan.ts';
14
+ import { changesEverything, parsePushRefs, pushScope } from './push-range.ts';
15
+ import { fail, note, ok, run, runLane, tool } from './report.ts';
16
+ import { scanStagedSecrets } from './secrets.ts';
14
17
  import { fingerprints, staged } from './staged.ts';
15
18
 
16
19
  /**
@@ -20,7 +23,26 @@ import { fingerprints, staged } from './staged.ts';
20
23
  * happens. Without the restage, oxfmt would fix the worktree while the commit kept
21
24
  * the unformatted bytes — CI then fails on a file that reads as correct locally.
22
25
  */
26
+ /**
27
+ * Has `bun install` run in this worktree?
28
+ *
29
+ * ⚠️ WITHOUT IT, SKIP — LOUDLY — RATHER THAN IMPROVISE. A fresh worktree now runs its hooks
30
+ * (activate.ts), and in the repo that HOSTS this package they are reached by workspace
31
+ * path, not through node_modules. There `tool()` would fall back to `bunx`, fetching an
32
+ * unpinned oxfmt mid-commit, and a pre-push lane would die on "command not found". The
33
+ * wrapper every other repo commits makes the same call one step earlier (install.ts).
34
+ */
35
+ function installed(root: string, hook: 'pre-commit' | 'pre-push'): boolean {
36
+ if (existsSync(`${root}/node_modules`)) return true;
37
+ note(`${hook}: no node_modules in this worktree — run 'bun install'; skipping the rest`);
38
+ return false;
39
+ }
40
+
23
41
  export async function preCommit(root: string): Promise<void> {
42
+ // ⛔ SECRETS FIRST, before anything can rewrite or pass — see secrets.ts. It needs only the
43
+ // gitleaks binary, so it runs even in a worktree nobody has installed yet.
44
+ await scanStagedSecrets();
45
+ if (!installed(root, 'pre-commit')) return;
24
46
  const oxfmt = tool(root, 'oxfmt');
25
47
  const { formattable, code, partial } = await staged();
26
48
 
@@ -68,37 +90,74 @@ export async function preCommit(root: string): Promise<void> {
68
90
  ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
69
91
  }
70
92
 
93
+ /** The one-line reason a lane is in the run, printed before it starts. */
94
+ function describe(lane: Lane): string {
95
+ if (lane.kind === 'skip') return `skip ${lane.label} — ${lane.why}`;
96
+ if (lane.kind === 'test' && lane.scoped)
97
+ return `run ${lane.command} (only the tests the push can reach)`;
98
+ if (lane.kind === 'test') return `run ${lane.command} (IN FULL)`;
99
+ return `run ${lane.command}`;
100
+ }
101
+
71
102
  /**
72
- * Run the repo's own declared gate before the push reaches the runner.
103
+ * Run the repository's own `check`, narrowed to what the push can affect.
73
104
  *
74
- * ★ IT CALLS `bun run check` RATHER THAN NAMING TOOLS. Every repo's `check` is the
75
- * command CI runs; hard-coding `tsc` and `bun test` here would drift from whichever
76
- * repo added a step, and the hook would certify a push CI rejects.
77
- * ⛔ It does not widen a narrow `check`. If a repo's gate only looks at part of the
78
- * tree, this hook inherits exactly that blind spot — fix the script, not the hook.
105
+ * ★ `args` ARE GIT'S: the remote name and URL. `stdin` is git's ref list — see
106
+ * push-range.ts for how the base is chosen and why it never narrows to nothing.
107
+ * ⛔ IT DOES NOT CERTIFY WHAT CI WILL SAY. It certifies that `check`'s own lint and type
108
+ * lanes pass and that every test the pushed files can reach passes. The build, the smoke
109
+ * test and the unreachable tests are CI's, and the success line says so.
79
110
  */
80
- export async function prePush(root: string): Promise<void> {
111
+ export async function prePush(root: string, args: readonly string[], stdin: string): Promise<void> {
81
112
  const manifest = Bun.file(`${root}/package.json`);
82
113
  const pkg = (await manifest.exists())
83
114
  ? ((await manifest.json()) as { scripts?: Record<string, string> })
84
115
  : {};
85
-
86
- if (pkg.scripts?.['check'] === undefined) {
116
+ const scripts = pkg.scripts ?? {};
117
+ if (scripts['check'] === undefined) {
87
118
  note('pre-push: no `check` script declared in package.json; nothing to run');
88
119
  return;
89
120
  }
121
+ if (!installed(root, 'pre-push')) return;
90
122
 
91
- note('pre-push: running `bun run check` — the same gate CI runs');
92
- const started = Bun.nanoseconds();
93
- // 🔴 `isolated`: strip the GIT_* this hook inherited before running the test suite.
94
- // See report.ts — without it a test's throwaway git repository commits into this one.
95
- if ((await run(['bun', 'run', 'check'], true)) !== 0) {
96
- fail(
97
- 'pre-push',
98
- 'bun run check failed — CI would fail the same way, on a shared runner',
99
- 'bun run lint:fix, then bun run check until it is green',
123
+ const scope = await pushScope(root, args[0] ?? 'origin', parsePushRefs(stdin));
124
+ if (scope.kind === 'empty') {
125
+ ok(`pre-push: ${scope.why} — nothing to check`);
126
+ return;
127
+ }
128
+ if (scope.kind === 'elsewhere') {
129
+ // ⛔ NOT A PASS, AND IT DOES NOT SAY ONE. The working tree is another commit; running the
130
+ // lanes would certify content nobody checked. Failing would teach `--no-verify` for an
131
+ // ordinary push, so it says what it did not do, and CI checks the ref.
132
+ note(`pre-push: ${scope.why} — the working tree is not what is being pushed`);
133
+ note(
134
+ ' NOT CHECKED here; CI checks it. To check it locally, check it out and push from there.',
100
135
  );
136
+ return;
137
+ }
138
+ let base: string | undefined;
139
+ if (scope.kind === 'unscoped') {
140
+ note(`pre-push: ${scope.why} — every lane runs, tests in full`);
141
+ } else {
142
+ const global = scope.changed.filter(changesEverything);
143
+ note(`pre-push: ${String(scope.changed.length)} file(s) changed ${scope.why}`);
144
+ if (global.length > 0)
145
+ note(` ${global.join(', ')} changes what every test runs on — tests in full`);
146
+ else base = scope.base;
101
147
  }
102
148
 
103
- ok(`pre-push: bun run check passed in ${((Bun.nanoseconds() - started) / 1e9).toFixed(1)}s`);
149
+ const lanes = planLanes(scripts, base);
150
+ const started = Bun.nanoseconds();
151
+ let ran = 0;
152
+ for (const lane of lanes) {
153
+ note(describe(lane));
154
+ if (lane.kind === 'skip') continue;
155
+ // 🔴 `runLane` strips the GIT_* this hook inherited — see report.ts.
156
+ if ((await runLane(lane.command, root)) !== 0) {
157
+ fail('pre-push', `\`${lane.label}\` failed`, `${lane.command} — until it is green`);
158
+ }
159
+ ran += 1;
160
+ }
161
+ const seconds = ((Bun.nanoseconds() - started) / 1e9).toFixed(1);
162
+ ok(`pre-push: ${String(ran)} lane(s) of \`check\` passed in ${seconds}s — CI runs the full gate`);
104
163
  }
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Adoption: the one wrapper every repo commits, and the check that it has not drifted.
3
3
  *
4
- * ★ WHY A WRAPPER AT ALL. husky can only run a file that is tracked in the repo, so
4
+ * ★ WHY A WRAPPER AT ALL. git can only run a file that is present in the worktree, so
5
5
  * something must be committed per repo. This keeps that something to a delegation
6
6
  * whose text is owned HERE — the behaviour lives in one package, and a repo that
7
7
  * edits its copy is reported as drift rather than quietly diverging.
8
8
  * ★ ONE FILE, TWO NAMES. The wrapper reads the hook name from `$0`, so `pre-commit`
9
9
  * and `pre-push` are byte-identical and there is a single text to keep in step.
10
+ * ★ THE DIRECTORY KEEPS HUSKY'S NAME, NOT HUSKY. `.husky/` is where the estate's hook files
11
+ * already live (homeflare-kit, homeflare-alerts, the house monorepo); since 2026-09-23 git
12
+ * runs them directly through `core.hooksPath` — see activate.ts for why husky's own
13
+ * `.husky/_` left every fresh worktree without hooks.
10
14
  */
11
- import { chmod, mkdir } from 'node:fs/promises';
15
+ import { chmod, mkdir, stat } from 'node:fs/promises';
12
16
 
13
17
  /** The hook files this package installs, in the order a contributor meets them. */
14
18
  export const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;
@@ -16,27 +20,34 @@ export const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;
16
20
  /** Where the runner lives once `bun install` has run. Relative: git runs hooks at the root. */
17
21
  const RUNNER = 'node_modules/@homeflare/config/bin/hooks.ts';
18
22
 
23
+ /** What a consumer's `prepare` script runs, so every `bun install` activates the hooks. */
24
+ export const PREPARE: string = `bun ${RUNNER} activate`;
25
+
19
26
  /**
20
- * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A checkout with no `node_modules` would
27
+ * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A worktree with no `node_modules` would
21
28
  * otherwise fail every commit with a module-resolution error, and the first thing
22
29
  * anyone would do is delete the hook. Failing open is the right trade for a
23
30
  * convenience; the required checks on `main` are what must fail closed.
31
+ * ★ `"$@"` AND STDIN PASS THROUGH. `pre-push` reads the remote name from its first argument
32
+ * and the pushed refs from stdin (push-range.ts); `exec` keeps both.
33
+ * ⚠️ NO SHEBANG, AND THAT IS MEASURED, NOT FORGOTTEN: git 2.55 runs an executable hook that
34
+ * has none through `sh` (2026-09-23), and the husky-era files in the estate have none.
24
35
  */
25
36
  export const HUSKY_HOOK: string = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
26
37
  # and the same bytes are installed as .husky/pre-commit and .husky/pre-push — the hook
27
- # name comes from $0.
38
+ # name comes from $0, and git's arguments and stdin pass straight through.
28
39
  #
29
- # ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify and does
30
- # not exist in a fresh clone until \`bun install\` runs the \`prepare\` script. The
31
- # required checks on main stay the gate.
40
+ # ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify, and a
41
+ # worktree runs it only once \`bun install\` has run there. The required checks on main
42
+ # stay the gate.
32
43
  #
33
44
  # Regenerate this file with: bun ${RUNNER} install
34
45
  hook="${RUNNER}"
35
46
  if [ ! -f "$hook" ]; then
36
- echo "husky: $hook is missing — run 'bun install' to enable the HomeFlare hooks; skipping"
47
+ echo "homeflare hooks: $hook is missing — run 'bun install' in this worktree; skipping" >&2
37
48
  exit 0
38
49
  fi
39
- exec bun "$hook" "$(basename "$0")"
50
+ exec bun "$hook" "$(basename "$0")" "$@"
40
51
  `;
41
52
 
42
53
  /** Write the wrapper into `.husky/`. Returns the paths written, relative to the project. */
@@ -46,8 +57,8 @@ export async function installHooks(projectDir: string): Promise<readonly string[
46
57
  for (const name of HOOK_NAMES) {
47
58
  const path = `${projectDir}/.husky/${name}`;
48
59
  await Bun.write(path, HUSKY_HOOK);
49
- // ⚠️ husky's own runner does `sh -e "$s"`, which does not need the execute bit, but
50
- // `core.hooksPath=.husky` without husky does. Set it so both mechanisms work.
60
+ // ⛔ THE EXECUTE BIT IS REQUIRED. git runs a `core.hooksPath` file directly and IGNORES
61
+ // a non-executable hook, with nothing but an advice line to say so.
51
62
  await chmod(path, 0o755);
52
63
  written.push(`.husky/${name}`);
53
64
  }
@@ -66,20 +77,25 @@ export async function problemsInHooks(projectDir: string): Promise<readonly stri
66
77
  const manifest = Bun.file(`${projectDir}/package.json`);
67
78
 
68
79
  if (!(await manifest.exists())) return ['package.json: missing'];
69
- const pkg = (await manifest.json()) as {
70
- scripts?: Record<string, string>;
71
- devDependencies?: Record<string, string>;
72
- };
80
+ const pkg = (await manifest.json()) as { scripts?: Record<string, string> };
73
81
 
74
- if (!(pkg.scripts?.['prepare'] ?? '').includes('husky')) {
75
- problems.push('package.json: no "prepare": "husky" script — a fresh clone installs no hooks');
82
+ const prepare = pkg.scripts?.['prepare'] ?? '';
83
+ if (!/bin\/hooks\.ts activate/.test(prepare)) {
84
+ problems.push(
85
+ `package.json: "prepare" does not run \`${PREPARE}\` — no clone or worktree gets hooks`,
86
+ );
76
87
  }
77
- if (pkg.devDependencies?.['husky'] === undefined) {
78
- problems.push('package.json: husky is not a devDependency');
88
+ // ⚠️ husky POINTS core.hooksPath AT AN UNTRACKED `.husky/_` that exists only where it ran —
89
+ // the gap activate.ts closes. Left in `prepare`, it undoes the activation.
90
+ if (/\bhusky\b/.test(prepare)) {
91
+ problems.push(
92
+ 'package.json: "prepare" still runs husky, which re-points core.hooksPath at .husky/_',
93
+ );
79
94
  }
80
95
 
81
96
  for (const name of HOOK_NAMES) {
82
- const file = Bun.file(`${projectDir}/.husky/${name}`);
97
+ const path = `${projectDir}/.husky/${name}`;
98
+ const file = Bun.file(path);
83
99
  if (!(await file.exists())) {
84
100
  problems.push(`.husky/${name}: missing — run \`bun ${RUNNER} install\``);
85
101
  continue;
@@ -89,6 +105,9 @@ export async function problemsInHooks(projectDir: string): Promise<readonly stri
89
105
  `.husky/${name}: differs from the @homeflare/config wrapper — run \`bun ${RUNNER} install\`, or change it in the package`,
90
106
  );
91
107
  }
108
+ if (((await stat(path)).mode & 0o111) === 0) {
109
+ problems.push(`.husky/${name}: not executable, so git ignores it — chmod +x it and commit`);
110
+ }
92
111
  }
93
112
 
94
113
  return problems;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What `pre-push` runs: the repository's own `check`, with the expensive lanes narrowed to
3
+ * the push or left to CI.
4
+ *
5
+ * ★ IT READS `check` RATHER THAN NAMING TOOLS. `check` is the command CI runs, so the lanes
6
+ * here are that command's lanes — a repository that adds a step to `check` gets it in the
7
+ * hook with no change to this package. Hard-coding `tsc` and `bun test` would drift from
8
+ * whichever repository added a step first, and certify a push CI rejects.
9
+ * ★ THREE THINGS CHANGE, AND ONLY THREE:
10
+ * 1. every `bun test …` becomes `bun test … --changed=<base>` — Bun's own import-graph
11
+ * answer to "which test files can these changed files reach" (bun 1.4, measured
12
+ * 2026-09-23: 11 changed files ran 2 of 246 test files in homeflare-kit);
13
+ * 2. `build` and `smoke` scripts are skipped — minutes in a big workspace, and CI runs
14
+ * both on every pull request;
15
+ * 3. everything else (lint, types, a `--check` script) runs exactly as `check` spells it.
16
+ * They are seconds, whole-program by nature, and deterministic.
17
+ * ⛔ PURE. No git, no filesystem, no process: the whole contract is a function of the
18
+ * scripts table and the base, so the estate's real `check` shapes are pinned by a table
19
+ * test (tests/hooks-push-plan.test.ts) instead of by a live push.
20
+ */
21
+
22
+ /** One step of the pre-push run. */
23
+ export type Lane =
24
+ /** Run `command` through `sh -c` at the repository root. */
25
+ | { readonly kind: 'run'; readonly label: string; readonly command: string }
26
+ /** A test runner; `scoped` says whether it was narrowed to the push. */
27
+ | {
28
+ readonly kind: 'test';
29
+ readonly label: string;
30
+ readonly command: string;
31
+ readonly scoped: boolean;
32
+ }
33
+ /** Not run here, and why. */
34
+ | { readonly kind: 'skip'; readonly label: string; readonly why: string };
35
+
36
+ /**
37
+ * ⚠️ BY NAME, AND ONLY THESE. A build in homeflare-kit is every package; a smoke test packs
38
+ * and installs tarballs. Both are CI jobs on every pull request, and neither says anything
39
+ * a type check and the reachable tests did not already say about a typical push.
40
+ */
41
+ const LEFT_TO_CI = /^(build|smoke)(:|$)/;
42
+
43
+ /** Recursion guard: a script that names itself, directly or through another, stops here. */
44
+ const MAX_DEPTH = 8;
45
+
46
+ /**
47
+ * Split a script on top-level `&&`, or return undefined when it is anything else.
48
+ *
49
+ * ⛔ UNDEFINED MEANS "RUN IT WHOLE". `||`, `;`, a pipe, a redirect, a background `&` or a
50
+ * substitution each change what the pieces mean together, and a hook that re-plumbed them
51
+ * would run something the repository never wrote. Quoted text is left alone, so
52
+ * `--path-ignore-patterns="homeflare-*\/**"` survives intact.
53
+ */
54
+ export function andChain(script: string): readonly string[] | undefined {
55
+ const parts: string[] = [];
56
+ let quote: string | undefined;
57
+ let current = '';
58
+ for (let i = 0; i < script.length; i++) {
59
+ const char = script[i] ?? '';
60
+ if (quote !== undefined) {
61
+ if (char === quote) quote = undefined;
62
+ current += char;
63
+ continue;
64
+ }
65
+ if (char === '"' || char === "'") {
66
+ quote = char;
67
+ current += char;
68
+ continue;
69
+ }
70
+ if (char === '&' && script[i + 1] === '&') {
71
+ parts.push(current.trim());
72
+ current = '';
73
+ i += 1;
74
+ continue;
75
+ }
76
+ if ('|;&<>`'.includes(char) || (char === '$' && script[i + 1] === '(')) return undefined;
77
+ current += char;
78
+ }
79
+ if (quote !== undefined) return undefined;
80
+ parts.push(current.trim());
81
+ return parts.some((part) => part === '') ? undefined : parts;
82
+ }
83
+
84
+ /** The script a segment names: `bun run x`, `npm run x`, or `npm test`. Else undefined. */
85
+ function scriptRef(segment: string, scripts: Readonly<Record<string, string>>): string | undefined {
86
+ const words = segment.split(/\s+/);
87
+ if (words.length === 2 && words[0] === 'npm' && words[1] === 'test') return 'test';
88
+ const [runner, verb, name] = words;
89
+ if (words.length !== 3 || verb !== 'run' || name === undefined) return undefined;
90
+ if (runner !== 'bun' && runner !== 'npm') return undefined;
91
+ return Object.hasOwn(scripts, name) ? name : undefined;
92
+ }
93
+
94
+ /** `bun test …` — the runner, not a script called `test`. */
95
+ const isBunTest = (segment: string): boolean => /^bun\s+test(\s|$)/.test(segment);
96
+
97
+ function testLane(segment: string, base: string | undefined): Lane {
98
+ return base === undefined
99
+ ? { kind: 'test', label: segment, command: segment, scoped: false }
100
+ : { kind: 'test', label: segment, command: `${segment} --changed=${base}`, scoped: true };
101
+ }
102
+
103
+ function expand(
104
+ script: string,
105
+ scripts: Readonly<Record<string, string>>,
106
+ base: string | undefined,
107
+ depth: number,
108
+ ): readonly Lane[] | undefined {
109
+ const segments = andChain(script);
110
+ if (segments === undefined || depth > MAX_DEPTH) return undefined;
111
+ const lanes: Lane[] = [];
112
+ for (const segment of segments) {
113
+ if (isBunTest(segment)) {
114
+ lanes.push(testLane(segment, base));
115
+ continue;
116
+ }
117
+ const name = scriptRef(segment, scripts);
118
+ if (name === undefined) {
119
+ lanes.push({ kind: 'run', label: segment, command: segment });
120
+ continue;
121
+ }
122
+ if (LEFT_TO_CI.test(name)) {
123
+ lanes.push({ kind: 'skip', label: segment, why: 'CI runs it on every pull request' });
124
+ continue;
125
+ }
126
+ const inner = expand(scripts[name] ?? '', scripts, base, depth + 1);
127
+ if (inner !== undefined && inner.some((lane) => lane.kind !== 'run')) {
128
+ // Something inside needs narrowing or skipping: open the script up.
129
+ lanes.push(...inner);
130
+ } else if (/^test(:|$)/.test(name)) {
131
+ // ⚠️ A TEST SCRIPT WITH NO `bun test` IN IT RUNS IN FULL, AND SAYS SO — vitest with
132
+ // coverage thresholds (a narrowed run would fail them), `node --test`, anything piped.
133
+ // Narrowing it would mean rewriting a command this package does not understand.
134
+ lanes.push({ kind: 'test', label: segment, command: segment, scoped: false });
135
+ } else {
136
+ // ★ A SCRIPT WITH NOTHING TO NARROW RUNS UNDER ITS OWN NAME. `bun run lint` keeps
137
+ // Bun's PATH handling and reads the way the repository wrote it.
138
+ lanes.push({ kind: 'run', label: segment, command: segment });
139
+ }
140
+ }
141
+ return lanes;
142
+ }
143
+
144
+ /**
145
+ * The lanes `pre-push` runs for this `scripts` table.
146
+ *
147
+ * `base` is the commit the push is measured from; `undefined` runs every test lane in full
148
+ * (an unknown base, or a push that changes what every test runs on).
149
+ * ⚠️ AN EMPTY LIST MEANS NO `check` SCRIPT — the caller reports that; it is not a pass.
150
+ */
151
+ export function planLanes(
152
+ scripts: Readonly<Record<string, string>>,
153
+ base: string | undefined,
154
+ ): readonly Lane[] {
155
+ const check = scripts['check'];
156
+ if (check === undefined) return [];
157
+ // ⚠️ A `check` THAT IS NOT A PLAIN `&&` CHAIN IS RUN WHOLE, tests and all, and reported
158
+ // as unscoped. No estate repository has one (surveyed 2026-09-23); the fallback exists so
159
+ // an unusual one is checked rather than skipped.
160
+ const whole: Lane = {
161
+ kind: 'test',
162
+ label: 'bun run check',
163
+ command: 'bun run check',
164
+ scoped: false,
165
+ };
166
+ return expand(check, scripts, base, 0) ?? [whole];
167
+ }