@homeflare/config 0.10.0 → 0.11.1
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 +11 -41
- 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/oxfmt-config.d.ts +25 -0
- package/dist/hooks/oxfmt-config.d.ts.map +1 -0
- 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 +39 -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 +408 -41
- package/dist/hooks.js.map +12 -7
- package/dist/repo-shape/yaml.d.ts +20 -3
- package/dist/repo-shape/yaml.d.ts.map +1 -1
- package/dist/repo-shape.js +8 -2
- package/dist/repo-shape.js.map +3 -3
- package/dist/versions.js +8 -2
- package/dist/versions.js.map +3 -3
- package/docs/hooks.md +118 -0
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +156 -39
- package/src/hooks/install.ts +39 -20
- package/src/hooks/oxfmt-config.ts +67 -0
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +46 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- package/src/repo-shape/yaml.ts +26 -4
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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `.oxfmtrc.*` file governs this repo — so the hook can name it explicitly.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ BUG, MEASURED 2026-09-23 (homeflare-desktop docs agent): the shared pre-commit ran
|
|
5
|
+
* bare `oxfmt`, and bare `oxfmt` auto-discovers only `.oxfmtrc.json` and `.oxfmtrc.jsonc`.
|
|
6
|
+
* Checked against the pinned oxfmt 0.68.0 directly: a repo with only `.oxfmtrc.mjs` and
|
|
7
|
+
* no `-c` prints "No config found, using defaults" and formats with oxfmt's built-in
|
|
8
|
+
* style, silently — even though `oxfmt --help` lists `.ts/.mts/.cts/.js/.mjs/.cjs` as
|
|
9
|
+
* valid `--config` targets. Desktop's fix was renaming to `.oxfmtrc.json`; this fixes the
|
|
10
|
+
* hook so a repo does not have to.
|
|
11
|
+
* ★ WHY A FILE SCAN, NOT A PACKAGE.JSON READ. The repo's own `format`/`check` script names
|
|
12
|
+
* the config it wants (or relies on auto-discovery finding `.oxfmtrc.json`), but its exact
|
|
13
|
+
* invocation shape varies per repo. Finding the config file on disk and handing it to
|
|
14
|
+
* oxfmt directly gets the same result without parsing an arbitrary shell command.
|
|
15
|
+
* ⛔ EVERY `.oxfmtrc.*` LIVES AT THE REPO ROOT TODAY (checked: every repo in the estate has
|
|
16
|
+
* exactly one, at root — no nested per-package overrides). Explicit `-c` disables oxfmt's
|
|
17
|
+
* own nested-config search (measured: a nested `.oxfmtrc.json` was ignored once `-c` named
|
|
18
|
+
* the root one), so this only scans `root` itself — it would misfire on a repo that added a
|
|
19
|
+
* package-level override, which none currently do.
|
|
20
|
+
*/
|
|
21
|
+
import { readdir } from 'node:fs/promises';
|
|
22
|
+
|
|
23
|
+
/** Auto-discovered by bare `oxfmt` — nothing for the hook to pass. */
|
|
24
|
+
const AUTO_DISCOVERED = ['.oxfmtrc.json', '.oxfmtrc.jsonc'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Accepted by `oxfmt -c/--config` (per `oxfmt --help`, oxfmt 0.68.0) but never found on its
|
|
28
|
+
* own — checked empirically for each extension. Listed in the order `--help` documents them.
|
|
29
|
+
*/
|
|
30
|
+
const NEEDS_EXPLICIT_CONFIG = [
|
|
31
|
+
'.oxfmtrc.ts',
|
|
32
|
+
'.oxfmtrc.mts',
|
|
33
|
+
'.oxfmtrc.cts',
|
|
34
|
+
'.oxfmtrc.js',
|
|
35
|
+
'.oxfmtrc.mjs',
|
|
36
|
+
'.oxfmtrc.cjs',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export type OxfmtConfigResolution =
|
|
40
|
+
/** `.oxfmtrc.json` or `.oxfmtrc.jsonc` exists — bare oxfmt already finds it. */
|
|
41
|
+
| { readonly kind: 'auto' }
|
|
42
|
+
/** No `.oxfmtrc.*` at all — let oxfmt use its built-in defaults, as today. */
|
|
43
|
+
| { readonly kind: 'none' }
|
|
44
|
+
/** Exactly one config oxfmt would not find unaided — pass it with `-c`. */
|
|
45
|
+
| { readonly kind: 'explicit'; readonly path: string }
|
|
46
|
+
/**
|
|
47
|
+
* Two or more configs, none of them auto-discovered — bare oxfmt (and a repo's own
|
|
48
|
+
* bare `bun run format`) would silently fall back to defaults too, honouring neither.
|
|
49
|
+
* Guessing which one the repo meant would just move the silent-defaults bug here.
|
|
50
|
+
*/
|
|
51
|
+
| { readonly kind: 'ambiguous'; readonly files: readonly string[] };
|
|
52
|
+
|
|
53
|
+
/** Every `.oxfmtrc.*` file actually present at `root`, in a fixed, deterministic order. */
|
|
54
|
+
async function present(root: string, names: readonly string[]): Promise<readonly string[]> {
|
|
55
|
+
const entries = new Set(await readdir(root).catch(() => []));
|
|
56
|
+
return names.filter((name) => entries.has(name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveOxfmtConfig(root: string): Promise<OxfmtConfigResolution> {
|
|
60
|
+
if ((await present(root, AUTO_DISCOVERED)).length > 0) return { kind: 'auto' };
|
|
61
|
+
|
|
62
|
+
const explicit = await present(root, NEEDS_EXPLICIT_CONFIG);
|
|
63
|
+
const [only, ...rest] = explicit;
|
|
64
|
+
if (only === undefined) return { kind: 'none' };
|
|
65
|
+
if (rest.length === 0) return { kind: 'explicit', path: `${root}/${only}` };
|
|
66
|
+
return { kind: 'ambiguous', files: explicit };
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a push changes: the base it is measured from, and the files between that base and
|
|
3
|
+
* the commit being pushed.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE BASE COMES FROM GIT, NOT FROM A GUESS. A pre-push hook reads
|
|
6
|
+
* `<local ref> <local sha> <remote ref> <remote sha>` on stdin. The remote sha is exactly
|
|
7
|
+
* "what the remote has now", so a second push to a branch checks only what is new.
|
|
8
|
+
* 🔴 A BRANCH-NAME BASE IS VACUOUS ON THE BASE BRANCH. Measured in the house monorepo
|
|
9
|
+
* 2026-09-08: `turbo --affected` compares against `main`, so on `main` itself it selected
|
|
10
|
+
* zero packages and a real two-commit push ran zero tests. Taking the base from stdin
|
|
11
|
+
* cannot do that — the remote sha is never the commit being pushed.
|
|
12
|
+
* ⛔ AN UNKNOWN BASE WIDENS, IT NEVER NARROWS TO NOTHING. No merge base (a shallow clone, a
|
|
13
|
+
* rewritten history), no remote-tracking branch: every lane runs, tests in full. A gate
|
|
14
|
+
* whose base is unusable must do MORE work, never quietly run nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { probe } from './report.ts';
|
|
17
|
+
|
|
18
|
+
/** One line of git's pre-push stdin. */
|
|
19
|
+
export type PushRef = {
|
|
20
|
+
readonly localRef: string;
|
|
21
|
+
readonly localSha: string;
|
|
22
|
+
readonly remoteRef: string;
|
|
23
|
+
readonly remoteSha: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PushScope =
|
|
27
|
+
/** The push changes no file (a deletion, or a branch at its base): nothing to check. */
|
|
28
|
+
| { readonly kind: 'empty'; readonly why: string }
|
|
29
|
+
/** Measured from `base`: `changed` is every path that differs between it and the tip. */
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'scoped';
|
|
32
|
+
readonly base: string;
|
|
33
|
+
readonly changed: readonly string[];
|
|
34
|
+
readonly why: string;
|
|
35
|
+
}
|
|
36
|
+
/** No usable base: run every lane, tests in full. */
|
|
37
|
+
| { readonly kind: 'unscoped'; readonly why: string }
|
|
38
|
+
/** The pushed commit is not what is checked out, so the working tree cannot vouch for it. */
|
|
39
|
+
| { readonly kind: 'elsewhere'; readonly why: string };
|
|
40
|
+
|
|
41
|
+
/** git's "no such ref" sentinel — 40 zeros (64 under SHA-256). */
|
|
42
|
+
const ZERO = /^0+$/;
|
|
43
|
+
|
|
44
|
+
export function parsePushRefs(stdin: string): readonly PushRef[] {
|
|
45
|
+
const refs: PushRef[] = [];
|
|
46
|
+
for (const line of stdin.split('\n')) {
|
|
47
|
+
const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
|
|
48
|
+
if (localRef && localSha && remoteRef && remoteSha) {
|
|
49
|
+
refs.push({ localRef, localSha, remoteRef, remoteSha });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return refs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const short = (sha: string): string => sha.slice(0, 9);
|
|
56
|
+
|
|
57
|
+
async function ok(root: string, args: readonly string[]): Promise<boolean> {
|
|
58
|
+
return (await probe(['git', '-C', root, ...args])).code === 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function out(root: string, args: readonly string[]): Promise<string | undefined> {
|
|
62
|
+
const result = await probe(['git', '-C', root, ...args]);
|
|
63
|
+
return result.code === 0 ? result.stdout.trim() : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The remote's default branch as a local ref: `<remote>/HEAD` when the clone recorded it,
|
|
68
|
+
* else `<remote>/main`, else `<remote>/master`.
|
|
69
|
+
* ⚠️ `refs/remotes/<remote>/HEAD` IS OFTEN ABSENT — `git clone` sets it, `git init` plus
|
|
70
|
+
* `git remote add` does not — so the fallbacks are the common path, not the rare one.
|
|
71
|
+
*/
|
|
72
|
+
async function defaultBranch(root: string, remote: string): Promise<string | undefined> {
|
|
73
|
+
const head = await out(root, ['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`]);
|
|
74
|
+
if (head !== undefined && head !== '') return head;
|
|
75
|
+
for (const name of ['main', 'master']) {
|
|
76
|
+
const ref = `refs/remotes/${remote}/${name}`;
|
|
77
|
+
if (await ok(root, ['rev-parse', '--verify', '--quiet', ref])) return ref;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where the pushed commit is measured from.
|
|
84
|
+
*
|
|
85
|
+
* ★ A FAST-FORWARD IS MEASURED FROM THE REMOTE SHA — only what this push adds.
|
|
86
|
+
* ⚠️ ANYTHING ELSE IS MEASURED FROM THE MERGE BASE WITH THE DEFAULT BRANCH: a new branch
|
|
87
|
+
* (zero remote sha), a remote sha this clone has not fetched, or a force-push after a
|
|
88
|
+
* rebase — where diffing against the old tip would drag in everything `main` gained since.
|
|
89
|
+
*/
|
|
90
|
+
async function baseFor(
|
|
91
|
+
root: string,
|
|
92
|
+
remote: string,
|
|
93
|
+
ref: PushRef,
|
|
94
|
+
): Promise<{ base: string; why: string } | { why: string }> {
|
|
95
|
+
const { localSha, remoteSha } = ref;
|
|
96
|
+
if (
|
|
97
|
+
!ZERO.test(remoteSha) &&
|
|
98
|
+
(await ok(root, ['cat-file', '-e', `${remoteSha}^{commit}`])) &&
|
|
99
|
+
(await ok(root, ['merge-base', '--is-ancestor', remoteSha, localSha]))
|
|
100
|
+
) {
|
|
101
|
+
return { base: remoteSha, why: `since ${short(remoteSha)}, what ${remote} has now` };
|
|
102
|
+
}
|
|
103
|
+
const branch = await defaultBranch(root, remote);
|
|
104
|
+
if (branch === undefined) return { why: `no ${remote}/main or ${remote}/master to measure from` };
|
|
105
|
+
const mergeBase = await out(root, ['merge-base', localSha, branch]);
|
|
106
|
+
if (mergeBase === undefined || mergeBase === '') {
|
|
107
|
+
return { why: `no merge base with ${branch} (a shallow clone, or unrelated history)` };
|
|
108
|
+
}
|
|
109
|
+
const name = branch.replace(/^refs\/remotes\//, '');
|
|
110
|
+
return { base: mergeBase, why: `since ${short(mergeBase)}, where this branch left ${name}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the push git described on stdin. With no stdin (a manual run) the push is
|
|
115
|
+
* `HEAD` to a new branch — measured from the merge base with the default branch.
|
|
116
|
+
* 🔴 THE LANES RUN ON THE WORKING TREE, SO ONLY A PUSH OF `HEAD` CAN BE CHECKED HERE. Found
|
|
117
|
+
* in review 2026-09-23 and reproduced: `git push origin broken` from a clean `main`
|
|
118
|
+
* measured the right files, then ran `bun test --changed` against `main`'s tree, found
|
|
119
|
+
* nothing, and printed "passed". A ref that is not checked out now comes back
|
|
120
|
+
* `elsewhere`, which the caller reports as NOT CHECKED — never as a pass. (The old
|
|
121
|
+
* whole-`check` hook had the same blind spot; it just ran unrelated tests while in it.)
|
|
122
|
+
* ⚠️ WITH SEVERAL REFS, THE ONE AT `HEAD` IS MEASURED and the rest are named as unchecked.
|
|
123
|
+
*/
|
|
124
|
+
export async function pushScope(
|
|
125
|
+
root: string,
|
|
126
|
+
remote: string,
|
|
127
|
+
refs: readonly PushRef[],
|
|
128
|
+
): Promise<PushScope> {
|
|
129
|
+
const pushed = refs.filter((ref) => !ZERO.test(ref.localSha));
|
|
130
|
+
if (refs.length > 0 && pushed.length === 0) {
|
|
131
|
+
return { kind: 'empty', why: 'this push only deletes refs' };
|
|
132
|
+
}
|
|
133
|
+
const head = await out(root, ['rev-parse', 'HEAD']);
|
|
134
|
+
const atHead = pushed.find((ref) => ref.localSha === head);
|
|
135
|
+
const others = pushed.filter((ref) => ref !== atHead).map((ref) => ref.localRef);
|
|
136
|
+
if (pushed.length > 0 && atHead === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
kind: 'elsewhere',
|
|
139
|
+
why: `pushing ${others.join(', ')}, but the checkout is at ${short(head ?? '?')}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const ref = atHead ?? {
|
|
143
|
+
localRef: 'HEAD',
|
|
144
|
+
localSha: head ?? 'HEAD',
|
|
145
|
+
remoteRef: '',
|
|
146
|
+
remoteSha: '0'.repeat(40),
|
|
147
|
+
};
|
|
148
|
+
const found = await baseFor(root, remote, ref);
|
|
149
|
+
const also = others.length > 0 ? `; ${others.join(', ')} not checked here` : '';
|
|
150
|
+
if (!('base' in found)) return { kind: 'unscoped', why: `${found.why}${also}` };
|
|
151
|
+
|
|
152
|
+
const diff = await probe([
|
|
153
|
+
'git',
|
|
154
|
+
'-C',
|
|
155
|
+
root,
|
|
156
|
+
'diff',
|
|
157
|
+
'--name-only',
|
|
158
|
+
'-z',
|
|
159
|
+
found.base,
|
|
160
|
+
ref.localSha,
|
|
161
|
+
]);
|
|
162
|
+
if (diff.code !== 0) return { kind: 'unscoped', why: `git diff ${short(found.base)} failed` };
|
|
163
|
+
const changed = diff.stdout.split('\0').filter((path) => path !== '');
|
|
164
|
+
if (changed.length === 0) return { kind: 'empty', why: `no file differs ${found.why}${also}` };
|
|
165
|
+
return { kind: 'scoped', base: found.base, changed, why: `${found.why}${also}` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A path that changes what EVERY test runs on: a manifest, a lockfile, Bun's config, a
|
|
170
|
+
* tsconfig. Bun's `--changed` follows imports and none of these is imported — measured
|
|
171
|
+
* 2026-09-23, editing package.json selected 0 of 246 test files — so a push touching one
|
|
172
|
+
* runs the tests in full rather than none of them.
|
|
173
|
+
*/
|
|
174
|
+
export function changesEverything(path: string): boolean {
|
|
175
|
+
const name = path.split('/').pop() ?? '';
|
|
176
|
+
return /^(package\.json|bun\.lockb?|bunfig\.toml|tsconfig.*\.json)$/.test(name);
|
|
177
|
+
}
|
package/src/hooks/report.ts
CHANGED
|
@@ -28,7 +28,7 @@ const BYPASS: Record<Hook, string> = {
|
|
|
28
28
|
* ⛔ So the hook strips them rather than trusting fourteen test suites to remember. The
|
|
29
29
|
* gate must see the repository through `cwd`, the way CI does.
|
|
30
30
|
*/
|
|
31
|
-
function withoutGitEnv(): Record<string, string | undefined> {
|
|
31
|
+
export function withoutGitEnv(): Record<string, string | undefined> {
|
|
32
32
|
return Object.fromEntries(
|
|
33
33
|
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
|
34
34
|
);
|
|
@@ -49,10 +49,49 @@ export async function run(cmd: readonly string[], isolated = false): Promise<num
|
|
|
49
49
|
|
|
50
50
|
/** Capture a command's stdout. Used for git plumbing only. */
|
|
51
51
|
export async function capture(cmd: readonly string[]): Promise<string> {
|
|
52
|
+
return (await probe(cmd)).stdout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run a command, relaying its stdout live-enough (after it exits) while also handing the
|
|
57
|
+
* caller the text — gates.ts reads oxfmt's own "on N files" summary from it. `stderr` stays
|
|
58
|
+
* `inherit`: diagnostics (a parse error, "no files matched") must show immediately, and nothing
|
|
59
|
+
* here needs to inspect them.
|
|
60
|
+
*/
|
|
61
|
+
export async function runCaptured(
|
|
62
|
+
cmd: readonly string[],
|
|
63
|
+
): Promise<{ readonly code: number; readonly stdout: string }> {
|
|
64
|
+
const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'inherit' });
|
|
65
|
+
const stdout = await new Response(proc.stdout).text();
|
|
66
|
+
process.stdout.write(stdout);
|
|
67
|
+
return { code: await proc.exited, stdout };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Capture a command's stdout AND its exit code — git plumbing that answers by status. */
|
|
71
|
+
export async function probe(cmd: readonly string[]): Promise<{ code: number; stdout: string }> {
|
|
52
72
|
const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
|
|
53
|
-
const
|
|
54
|
-
await proc.exited;
|
|
55
|
-
|
|
73
|
+
const stdout = await new Response(proc.stdout).text();
|
|
74
|
+
return { code: await proc.exited, stdout };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run one pre-push lane through `sh -c` at `root`, as `bun run` would run a script line.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ `node_modules/.bin` FIRST ON PATH, because a lane opened up from a script is no longer
|
|
81
|
+
* run by `bun run`, which is what used to put it there — `vitest` or `oxfmt` in an expanded
|
|
82
|
+
* script would otherwise be "command not found" on a machine without a global copy.
|
|
83
|
+
* 🔴 AND WITHOUT THE HOOK'S `GIT_*` — see `withoutGitEnv`.
|
|
84
|
+
*/
|
|
85
|
+
export async function runLane(command: string, root: string): Promise<number> {
|
|
86
|
+
const env = withoutGitEnv();
|
|
87
|
+
env['PATH'] = `${root}/node_modules/.bin:${env['PATH'] ?? ''}`;
|
|
88
|
+
const proc = Bun.spawn(['sh', '-c', command], {
|
|
89
|
+
cwd: root,
|
|
90
|
+
env,
|
|
91
|
+
stdout: 'inherit',
|
|
92
|
+
stderr: 'inherit',
|
|
93
|
+
});
|
|
94
|
+
return await proc.exited;
|
|
56
95
|
}
|
|
57
96
|
|
|
58
97
|
/**
|
|
@@ -60,8 +99,9 @@ export async function capture(cmd: readonly string[]): Promise<string> {
|
|
|
60
99
|
*
|
|
61
100
|
* ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
|
|
62
101
|
* git hook that reaches the network mid-commit is a hang waiting for a flaky link.
|
|
63
|
-
*
|
|
64
|
-
* the
|
|
102
|
+
* Git runs the hook with the caller's PATH, which has no `node_modules/.bin` (husky
|
|
103
|
+
* used to add it; the hooks no longer run through husky), so the local binary is named
|
|
104
|
+
* outright when it exists and `bunx` is only the fallback.
|
|
65
105
|
*/
|
|
66
106
|
export function tool(root: string, name: string): readonly string[] {
|
|
67
107
|
const local = `${root}/node_modules/.bin/${name}`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse to commit anything that looks like a credential.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ FIRST GATE, ALWAYS. A secret that reaches a public repository is compromised the moment
|
|
5
|
+
* it is pushed — rotating it is the only remedy, and rewriting history does not help
|
|
6
|
+
* because the object is already fetched and mirrored. Every other check can be fixed
|
|
7
|
+
* after the fact; this one cannot. Moved here from homeflare-kit's own
|
|
8
|
+
* scripts/hooks/secrets.ts (2026-09-23) so every repository commits under it, not one.
|
|
9
|
+
* ★ gitleaks IS THE TOOL, not a hand-rolled regex list. It ships hundreds of maintained rules
|
|
10
|
+
* and an entropy engine, and it reads the repository's own `.gitleaks.toml` when there is
|
|
11
|
+
* one; a homegrown pattern set covers the token shapes its author thought of.
|
|
12
|
+
* ⚠️ IT IS A GO BINARY, NOT AN npm PACKAGE — nothing in package.json can install it, so a
|
|
13
|
+
* missing binary is explained rather than surfacing as "command not found".
|
|
14
|
+
* ⚠️ `git --staged`, NOT `protect`. Measured 2026-09-15 on gitleaks 8.30.1: `protect` still
|
|
15
|
+
* runs, but the documented surface is `gitleaks git` / `dir` / `stdin`.
|
|
16
|
+
*/
|
|
17
|
+
import { fail, ok, run } from './report.ts';
|
|
18
|
+
|
|
19
|
+
const INSTALL = 'brew install gitleaks — Linux: https://github.com/gitleaks/gitleaks/releases';
|
|
20
|
+
|
|
21
|
+
export async function scanStagedSecrets(): Promise<void> {
|
|
22
|
+
// ⛔ NOT A SILENT SKIP. A secret scan that quietly does nothing is worse than none: it
|
|
23
|
+
// reads, in a log and in a reviewer's head, as coverage that does not exist.
|
|
24
|
+
if (Bun.which('gitleaks') === null) {
|
|
25
|
+
fail(
|
|
26
|
+
'pre-commit',
|
|
27
|
+
'gitleaks is not installed, so the staged changes were NOT scanned',
|
|
28
|
+
INSTALL,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
// `--redact`, so a real finding never prints the secret into a scrollback, a CI log, or an
|
|
32
|
+
// agent's context.
|
|
33
|
+
const code = await run(['gitleaks', 'git', '--staged', '--redact', '--no-banner', '.']);
|
|
34
|
+
if (code !== 0) {
|
|
35
|
+
fail(
|
|
36
|
+
'pre-commit',
|
|
37
|
+
'gitleaks found a secret in the staged changes',
|
|
38
|
+
'remove it, then ROTATE it — assume anything committed is already compromised',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
ok('pre-commit: gitleaks found no secret in the staged changes');
|
|
42
|
+
}
|