@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.
- package/README.md +5 -40
- package/bin/hooks.ts +8 -4
- package/dist/hooks/activate.d.ts +7 -0
- package/dist/hooks/activate.d.ts.map +1 -0
- package/dist/hooks/gates.d.ts +7 -14
- package/dist/hooks/gates.d.ts.map +1 -1
- package/dist/hooks/install.d.ts +7 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/push-plan.d.ts +59 -0
- package/dist/hooks/push-plan.d.ts.map +1 -0
- package/dist/hooks/push-range.d.ts +51 -0
- package/dist/hooks/push-range.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +29 -2
- package/dist/hooks/report.d.ts.map +1 -1
- package/dist/hooks/secrets.d.ts +2 -0
- package/dist/hooks/secrets.d.ts.map +1 -0
- package/dist/hooks.d.ts +30 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +339 -31
- package/dist/hooks.js.map +11 -7
- package/dist/repo-shape/automerge.d.ts +17 -0
- package/dist/repo-shape/automerge.d.ts.map +1 -0
- package/dist/repo-shape/ci.d.ts.map +1 -1
- package/dist/repo-shape/companions.d.ts +2 -6
- package/dist/repo-shape/companions.d.ts.map +1 -1
- package/dist/repo-shape/dependabot.d.ts +35 -0
- package/dist/repo-shape/dependabot.d.ts.map +1 -0
- package/dist/repo-shape/guards.d.ts +25 -0
- package/dist/repo-shape/guards.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts.map +1 -1
- package/dist/repo-shape/shape.d.ts +40 -2
- package/dist/repo-shape/shape.d.ts.map +1 -1
- package/dist/repo-shape.d.ts +5 -2
- package/dist/repo-shape.d.ts.map +1 -1
- package/dist/repo-shape.js +269 -105
- package/dist/repo-shape.js.map +10 -7
- package/dist/versions.js +68 -31
- package/dist/versions.js.map +6 -5
- package/docs/hooks.md +99 -0
- package/docs/repo-shape-dependabot.md +126 -0
- package/docs/repo-shape-inputs.md +83 -0
- package/docs/repo-shape.md +5 -23
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +87 -28
- package/src/hooks/install.ts +39 -20
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +31 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- package/src/repo-shape/automerge.ts +144 -0
- package/src/repo-shape/ci.ts +35 -16
- package/src/repo-shape/companions.ts +2 -77
- package/src/repo-shape/dependabot.ts +136 -0
- package/src/repo-shape/guards.ts +68 -0
- package/src/repo-shape/render.ts +5 -1
- package/src/repo-shape/shape.ts +51 -35
- 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
|
+
}
|
package/src/hooks/gates.ts
CHANGED
|
@@ -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
|
|
5
|
-
* hundreds of milliseconds, so it can run on every
|
|
6
|
-
* it. `pre-push` runs the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
103
|
+
* Run the repository's own `check`, narrowed to what the push can affect.
|
|
73
104
|
*
|
|
74
|
-
* ★
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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 (
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/hooks/install.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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 "
|
|
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
|
-
//
|
|
50
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
+
}
|