@mindlace/chreode 0.0.0-stage → 0.2.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/LICENSE +21 -0
  2. package/README.md +249 -2
  3. package/dist/burn-in/run.d.ts +18 -0
  4. package/dist/burn-in/run.js +71 -0
  5. package/dist/burn-in/select.d.ts +30 -0
  6. package/dist/burn-in/select.js +78 -0
  7. package/dist/burn-in/step.d.ts +10 -0
  8. package/dist/burn-in/step.js +14 -0
  9. package/dist/cli.d.ts +37 -0
  10. package/dist/cli.js +121 -0
  11. package/dist/config/load.d.ts +5 -0
  12. package/dist/config/load.js +13 -0
  13. package/dist/config/types.d.ts +114 -0
  14. package/dist/config/types.js +19 -0
  15. package/dist/config/validate.d.ts +9 -0
  16. package/dist/config/validate.js +278 -0
  17. package/dist/flakes/gh.d.ts +5 -0
  18. package/dist/flakes/gh.js +74 -0
  19. package/dist/flakes/ledger.d.ts +53 -0
  20. package/dist/flakes/ledger.js +108 -0
  21. package/dist/flakes/log.d.ts +39 -0
  22. package/dist/flakes/log.js +89 -0
  23. package/dist/flakes/phase.d.ts +34 -0
  24. package/dist/flakes/phase.js +66 -0
  25. package/dist/flakes/report.d.ts +14 -0
  26. package/dist/flakes/report.js +45 -0
  27. package/dist/git/change-set.d.ts +18 -0
  28. package/dist/git/change-set.js +42 -0
  29. package/dist/git/merge.d.ts +21 -0
  30. package/dist/git/merge.js +33 -0
  31. package/dist/github/protect.d.ts +20 -0
  32. package/dist/github/protect.js +90 -0
  33. package/dist/github/status.d.ts +24 -0
  34. package/dist/github/status.js +54 -0
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +2 -0
  37. package/dist/lock/machine-lock.d.ts +40 -0
  38. package/dist/lock/machine-lock.js +76 -0
  39. package/dist/reporters/options.d.ts +17 -0
  40. package/dist/reporters/options.js +18 -0
  41. package/dist/reporters/playwright.d.ts +15 -0
  42. package/dist/reporters/playwright.js +47 -0
  43. package/dist/reporters/vitest.d.ts +15 -0
  44. package/dist/reporters/vitest.js +47 -0
  45. package/dist/run/exec.d.ts +8 -0
  46. package/dist/run/exec.js +15 -0
  47. package/dist/run/stderr.d.ts +22 -0
  48. package/dist/run/stderr.js +30 -0
  49. package/dist/run/step.d.ts +55 -0
  50. package/dist/run/step.js +182 -0
  51. package/dist/signoff/plan.d.ts +50 -0
  52. package/dist/signoff/plan.js +112 -0
  53. package/dist/signoff/preconditions.d.ts +12 -0
  54. package/dist/signoff/preconditions.js +59 -0
  55. package/dist/signoff/run.d.ts +41 -0
  56. package/dist/signoff/run.js +226 -0
  57. package/dist/signoff/tail.d.ts +32 -0
  58. package/dist/signoff/tail.js +55 -0
  59. package/package.json +92 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethan Fremen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,250 @@
1
- # Temporary Holding Version
1
+ # chreode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## Why this exists
4
+
5
+ A _chreode_ (Waddington) is the canalized valley a developing system rolls
6
+ down: perturb it and it returns to the path, because the landscape is shaped
7
+ to select for it. Carcinization is the same idea seen from the outside —
8
+ independent lineages converging on the same form because the selective
9
+ pressure is consistent.
10
+
11
+ Agent-written software needs both. An agent will produce _something_ for any
12
+ prompt; whether the codebase drifts toward the shape you want depends on
13
+ whether the feedback it receives is **deterministic, selective, and
14
+ unavoidable**:
15
+
16
+ - **Deterministic** — the same change gets the same verdict, every time, on
17
+ any machine. No "it passed when I re-ran it".
18
+ - **Selective** — the feedback discriminates: a change that weakens the
19
+ codebase fails, and says why, in terms the agent can act on.
20
+ - **Unavoidable** — the agent cannot route around it: no wrapping the gate in
21
+ `| tail`, no running a hand-picked subset and calling it green, no merging
22
+ without the gate's status.
23
+
24
+ chreode packages a working implementation of that landscape, extracted from a
25
+ production codebase where agents write most of the code. It is a set of
26
+ layers; the **gate** (`chreode signoff`) is the terminal one, and it is only as
27
+ good as the layers feeding it (lint rules, e2e coverage, visual checks, the
28
+ guardrail hooks). This spec covers the core — the gate and its plumbing — and
29
+ names the later layers so the structure has room for them.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install --save-dev @mindlace/chreode
35
+ ```
36
+
37
+ Requires Node ≥ 22.12, `git`, and an authenticated [`gh`](https://cli.github.com/).
38
+
39
+ ## Usage
40
+
41
+ Add `chreode.config.ts` at the repo root (see below), then:
42
+
43
+ ```bash
44
+ npx chreode signoff # merge origin/main, run what changed, push, post the status
45
+ npx chreode signoff --dry-run # merge, then print the plan and stop
46
+ npx chreode protect # require the status on main (idempotent); --dry-run previews
47
+ npx chreode flake-report # report logged flakes to GitHub issues (signoff does this itself)
48
+ npx chreode burn-in --base <sha> --head <sha> # run changed test files N times, retries off
49
+ ```
50
+
51
+ Wire them into `package.json` as `"signoff": "chreode signoff"` and
52
+ `"branch-protection": "chreode protect"`. Run signoff instead of pushing: it pushes for you.
53
+
54
+ ### `chreode signoff`
55
+
56
+ 1. **Preconditions**: clean tree, a branch (not detached, not the base branch), not behind its
57
+ upstream, `gh auth status` ok, `git config user.email` set.
58
+ 2. **Merge** `origin/<baseBranch>` (unless `--no-merge`). A conflict exits 1 and leaves the
59
+ merge in progress. When the merge changed the branch, `afterMerge` runs and the config is
60
+ reloaded, so a merged-in `chreode.config.ts` governs the rest of the run.
61
+ 3. **Base**: `--base`, else the nearest commit on the branch carrying a `refs/notes/chreode`
62
+ note (the last successful signoff), else the merge-base with `origin/<baseBranch>`.
63
+ 4. **Plan**: changed paths (both sides of renames, deletions included) → triggers (first
64
+ matching rule; a path no rule matches fires every trigger) → steps (`when`, `if`,
65
+ `--skip`, `supersededBy`). The plan and every skipped step's reason are printed.
66
+ 5. **Run** the steps in order under one machine-wide lock shared by every chreode repo on the
67
+ machine. Each step runs in its own process group with a hard timeout; policed steps fail
68
+ on unexpected stderr. Children see `CHREODE_SIGNOFF=1`.
69
+ 6. **Tail**: refuse if the steps changed the checkout; push; post `<statusContext>` = success
70
+ naming the signer and disclosing any `--skip`'d steps, stderr waived by `--allow-stderr`,
71
+ and `base=<ref>` for `--base`; write the note. A partial run (any of those three) writes
72
+ no note, so it never becomes the incremental base: the next signoff diffs from the last
73
+ full one and re-plans what was skipped.
74
+
75
+ | Flag | Env | Effect |
76
+ | ----------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
77
+ | `--full` | `CHREODE_FULL=1` | Ignore the incremental base; run every step |
78
+ | `--dry-run` | | Merge and run `afterMerge`, then print the plan and stop without running steps; add `--no-merge` for a side-effect-free look |
79
+ | `--no-merge` | `CHREODE_NO_MERGE=1` | Do not merge `origin/<baseBranch>` first |
80
+ | `--no-push` | `CHREODE_NO_PUSH=1` | Do not push (the commit must already be on origin) |
81
+ | `--allow-stderr` | `CHREODE_ALLOW_STDERR=1` | Downgrade policed stderr to a warning (named in the status; no note) |
82
+ | `--base <ref>` | | Diff from this ref (named in the status; no note) |
83
+ | `--skip <label>` | | Skip a planned step (repeatable; named in the status; no note) |
84
+ | `--config <path>` | | Config file (default `<repo root>/chreode.config.ts`) |
85
+
86
+ Exit codes: `0` signed off, already signed off, or dry run; `1` anything else.
87
+
88
+ The lock lives at `$TMPDIR/chreode-signoff.lock`. A waiter logs once and polls for up to an
89
+ hour; a lock whose holder died goes stale after 30 s and is stolen. `CHREODE_LOCK_PATH`
90
+ overrides the path (for test isolation only).
91
+
92
+ ### `chreode protect`
93
+
94
+ `PUT`s branch protection on `<baseBranch>` requiring `<statusContext>` (strict), with admins
95
+ enforced, linear history, PRs required (0 approvals, stale reviews dismissed), and `PATCH`es
96
+ the repo to squash-only merges that delete head branches. Override either payload under
97
+ `protection` in the config. `--repo owner/name` targets another repo; `--dry-run` prints the
98
+ payloads.
99
+
100
+ ## Flakes
101
+
102
+ A test that fails and then passes on retry is a flake, and a flake is a bug. chreode records
103
+ each one, files it as a GitHub issue, and fails signoff when the same flake keeps coming back.
104
+ The doctrine and the fix playbook are in [docs/flakes.md](docs/flakes.md).
105
+
106
+ **1. Record.** Give your runners at least one retry locally and register the reporters. They record only
107
+ tests that pass on a retry, so with retries off nothing is recorded. Both take
108
+ `{ kind?, projects? }`: `kind` (a string, or `(project) => string`) is the issue-title bucket
109
+ (default `'e2e'` for Playwright, the project name or `'unit'` for vitest); `projects` limits
110
+ recording to those runner projects.
111
+
112
+ ```ts
113
+ // playwright.config.ts
114
+ export default defineConfig({
115
+ retries: 1,
116
+ use: { trace: 'retain-on-failure' }, // 'on-first-retry' records no trace of the failed attempt
117
+ reporter: [['list'], ['@mindlace/chreode/reporters/playwright']]
118
+ });
119
+ ```
120
+
121
+ ```ts
122
+ // vitest.config.ts (reporters are root-level only)
123
+ import VitestFlakeReporter from '@mindlace/chreode/reporters/vitest';
124
+
125
+ export default defineConfig({
126
+ test: { retry: 1, reporters: ['default', new VitestFlakeReporter()] }
127
+ });
128
+ ```
129
+
130
+ Events go to `.chreode/flakes.jsonl` (signoff tells each step the absolute path in
131
+ `CHREODE_FLAKE_LOG`, and the sha under test in `CHREODE_HEAD`). Add `.chreode/` to
132
+ `.gitignore`; when `flakes` is configured, signoff refuses to run otherwise. Registering the
133
+ reporters without a `flakes` block just accumulates events in `.chreode/flakes.jsonl`, unreported.
134
+ Filed issues carry the Playwright trace's local path, valid until the next Playwright run wipes
135
+ `test-results/`, not the trace itself. `playwright` and `vitest` are optional peer
136
+ dependencies: the reporters import only their types.
137
+
138
+ **2. Ledger.** Add `flakes: {}` to the config. After the steps, pass or fail, signoff gives
139
+ each flaking test one open issue labelled `flake` (the label is created if missing) and adds an
140
+ occurrence comment each time it recurs. At `threshold` occurrences (default 3) signoff fails,
141
+ any run that sees that flake again fails, listing the issue URLs in its failure output (no
142
+ commit status is posted), until the flake is fixed or its issue is labelled `snoozeLabel`
143
+ (default `flake-snoozed`). A run that sees no flake events is silent. A GitHub error is a
144
+ warning: the events not yet recorded stay in the log for the next run.
145
+ `chreode flake-report` runs the same report outside signoff and exits 1 on escalation.
146
+
147
+ **3. Burn-in.** `burnInStep({ when, timeoutMs? })` adds a step that runs every test file changed since the base
148
+ `repeats` times with retries off, so a new flake fails before it lands rather than after three
149
+ victims. Pass your test steps' triggers as `when`, and place it after them. Its stderr is policed like a
150
+ test step's, so it needs the same `stderrAllowlist` entries:
151
+
152
+ ```ts
153
+ import { burnInStep, defineConfig } from '@mindlace/chreode';
154
+
155
+ export default defineConfig({
156
+ steps: [
157
+ { label: 'test:unit', run: 'npm run test:unit', when: ['unit'], policeStderr: true },
158
+ { label: 'test:e2e', run: 'npm run test:e2e', when: ['e2e'], policeStderr: true },
159
+ burnInStep({ when: ['unit', 'e2e'] }) // timeoutMs defaults to 10 minutes
160
+ ],
161
+ flakes: {}, // threshold: 3, snoozeLabel: 'flake-snoozed', playbookUrl: chreode's docs/flakes.md on GitHub
162
+ burnIn: {
163
+ repeats: 5, // default
164
+ maxFiles: 12, // default; the overflow is listed, never silently dropped
165
+ runners: [
166
+ {
167
+ name: 'e2e',
168
+ match: 'e2e/**/*.spec.ts',
169
+ ignore: 'e2e/helpers/**',
170
+ // Pin a project: --repeat-each multiplies across every project otherwise.
171
+ command: (files, n) =>
172
+ `npx playwright test --project chromium --repeat-each=${n} --retries=0 ${files.join(' ')}`
173
+ },
174
+ {
175
+ name: 'unit',
176
+ match: 'src/**/*.spec.ts',
177
+ // vitest has no --repeat-each: return one invocation per repeat.
178
+ command: (files, n) =>
179
+ Array.from({ length: n }, () => `npx vitest --run --retry=0 ${files.join(' ')}`)
180
+ }
181
+ ]
182
+ }
183
+ });
184
+ ```
185
+
186
+ A changed file goes to the first runner that matches it and does not `ignore` it; files no
187
+ runner takes are not burned in, and deleted files never are. Commands run from the repo root
188
+ with inherited output; the first failure fails the step and names the files and the command.
189
+
190
+ ## Config reference
191
+
192
+ ```ts
193
+ import { defineConfig } from '@mindlace/chreode';
194
+
195
+ export default defineConfig({
196
+ baseBranch: 'main', // default
197
+ statusContext: 'chreode/signoff', // default
198
+ rules: [
199
+ { match: ['docs/**', '**/*.md'], triggers: ['format'] },
200
+ { match: 'src/**', triggers: ['lint', 'check', 'unit', 'e2e'] },
201
+ { match: 'generated/**', triggers: [] } // matched, nothing to run
202
+ ],
203
+ steps: [
204
+ { label: 'format', run: 'npx prettier --check .', when: ['format'], supersededBy: ['lint'] },
205
+ { label: 'lint', run: 'npm run lint', when: ['lint'] },
206
+ { label: 'check', run: 'npm run check', when: ['check'] },
207
+ { label: 'test:unit', run: 'npm run test:unit', when: ['unit'], policeStderr: true },
208
+ {
209
+ label: 'test:e2e',
210
+ run: 'npm run test:e2e',
211
+ when: ['e2e'],
212
+ policeStderr: true,
213
+ timeoutMs: 15 * 60_000
214
+ }
215
+ ],
216
+ afterMerge: 'npm ci', // not `npm install`: see below
217
+ stderrAllowlist: [{ pattern: /^\[WebServer\] /, reason: 'Playwright echoes the preview server' }]
218
+ });
219
+ ```
220
+
221
+ **Rules** are tried in order; the first whose `match` (a [picomatch](https://github.com/micromatch/picomatch)
222
+ glob, dotfiles included, or a RegExp, or an array of them) matches a changed path decides its
223
+ triggers. A path no rule matches fires every trigger: rules can only make runs smaller, so a
224
+ forgotten rule costs time, never coverage. Triggers are free-form strings.
225
+
226
+ **Steps** run in config order.
227
+
228
+ | Field | Type | Meaning |
229
+ | -------------- | --------------------------- | ---------------------------------------------------------------------- |
230
+ | `label` | string | Unique, no whitespace. Used by `--skip`, the note and the status. |
231
+ | `run` | `string \| (ctx) => string` | Shell command. `ctx` is `{ base, head, paths, triggers, isWorktree }`. |
232
+ | `when` | string[] | Planned if any listed trigger fired. |
233
+ | `if` | `(ctx) => boolean` | Extra predicate, e.g. only in a worktree. |
234
+ | `supersededBy` | string[] | Dropped when any listed step will also run. |
235
+ | `policeStderr` | boolean | Fail a clean exit that wrote non-allowlisted stderr. |
236
+ | `timeoutMs` | number | Hard ceiling; default 10 minutes. |
237
+
238
+ **`afterMerge`** runs only when merging `origin/<baseBranch>` changed the branch. It must
239
+ leave the checkout clean (signoff refuses to run otherwise), so use `npm ci`, which installs
240
+ exactly the lockfile, rather than `npm install`, which can rewrite it.
241
+ **`stderrAllowlist`** entries need a `reason`: allowlisting noise is a reviewed decision.
242
+ Unknown keys, duplicate labels, and `supersededBy` naming a missing step are load errors.
243
+
244
+ **`flakes`** (presence turns flake tracking on) and **`burnIn`** are described under
245
+ [Flakes](#flakes). `threshold`, `repeats` and `maxFiles` must be positive integers, and
246
+ `burnIn.runners` must be non-empty.
247
+
248
+ ## License
249
+
250
+ MIT
@@ -0,0 +1,18 @@
1
+ import type { ResolvedBurnIn } from '../config/types.js';
2
+ import { type Exec, type Run } from '../run/exec.js';
3
+ export interface BurnInDeps {
4
+ exec: Exec;
5
+ /** Inherited stdio: the test runners' own output streams through. */
6
+ run: Run;
7
+ mtime: (path: string) => number | null;
8
+ log: (m: string) => void;
9
+ }
10
+ /**
11
+ * Run every test file added or changed in base..head `repeats` times with retries off. The
12
+ * first failure exits 1. All of chreode's own output goes to stdout: this runs inside a
13
+ * policed signoff step.
14
+ */
15
+ export declare function runBurnIn(config: ResolvedBurnIn | null, range: {
16
+ base: string;
17
+ head: string;
18
+ }, overrides?: Partial<BurnInDeps>): number;
@@ -0,0 +1,71 @@
1
+ import { statSync } from 'node:fs';
2
+ import { defaultExec, defaultRun } from '../run/exec.js';
3
+ import { BurnInError, burnInCandidates, burnInCommands, selectBurnIn } from './select.js';
4
+ function mtimeOf(path) {
5
+ try {
6
+ return statSync(path).mtimeMs;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ /**
13
+ * Run every test file added or changed in base..head `repeats` times with retries off. The
14
+ * first failure exits 1. All of chreode's own output goes to stdout: this runs inside a
15
+ * policed signoff step.
16
+ */
17
+ export function runBurnIn(config, range, overrides = {}) {
18
+ const d = {
19
+ exec: defaultExec,
20
+ run: (command) => defaultRun(command),
21
+ mtime: mtimeOf,
22
+ log: (m) => console.log(m),
23
+ ...overrides
24
+ };
25
+ if (config === null) {
26
+ d.log('[chreode] burn-in: chreode.config.ts has no burnIn block; nothing knows how to run tests');
27
+ return 1;
28
+ }
29
+ const candidates = burnInCandidates(range.base, range.head, d.exec, d.mtime);
30
+ const plan = selectBurnIn(candidates, config);
31
+ let commands;
32
+ try {
33
+ commands = burnInCommands(plan, config.repeats);
34
+ }
35
+ catch (err) {
36
+ if (err instanceof BurnInError) {
37
+ d.log(`[chreode] ${err.message}`);
38
+ return 1;
39
+ }
40
+ throw err;
41
+ }
42
+ const kept = plan.groups.reduce((n, g) => n + g.files.length, 0);
43
+ if (kept === 0) {
44
+ d.log('[chreode] burn-in: no changed test files; nothing to burn in');
45
+ return 0;
46
+ }
47
+ d.log(`[chreode] burn-in: ${kept} changed test file(s) × ${config.repeats}, retries off:`);
48
+ for (const group of plan.groups) {
49
+ for (const file of group.files)
50
+ d.log(` ${file} (${group.runner.name})`);
51
+ }
52
+ if (plan.skipped.length > 0) {
53
+ d.log(`[chreode] burn-in: over the ${config.maxFiles}-file cap, NOT burned in:`);
54
+ for (const file of plan.skipped)
55
+ d.log(` ${file}`);
56
+ }
57
+ for (const c of commands) {
58
+ d.log(`[chreode] burn-in (${c.runner}): ${c.command}`);
59
+ try {
60
+ d.run(c.command);
61
+ }
62
+ catch {
63
+ d.log(`\n[chreode] burn-in FAILED (${c.runner}): ${c.files.join(' ')}\n` +
64
+ `[chreode] command: ${c.command}\n` +
65
+ `[chreode] A test that flakes at birth flakes forever: fix it before it lands.`);
66
+ return 1;
67
+ }
68
+ }
69
+ d.log('[chreode] burn-in: every changed test file survived');
70
+ return 0;
71
+ }
@@ -0,0 +1,30 @@
1
+ import type { BurnInRunner, ResolvedBurnIn } from '../config/types.js';
2
+ import type { Exec } from '../run/exec.js';
3
+ export declare class BurnInError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /**
7
+ * Paths added or modified in base..head, newest mtime first so the file cap keeps the most
8
+ * recently touched. --no-renames lists a rename as delete + add and --diff-filter=d drops
9
+ * deletions, so a renamed file appears under its new name only. Files missing on disk
10
+ * (mtime null) are dropped.
11
+ */
12
+ export declare function burnInCandidates(base: string, head: string, exec: Exec, mtime: (path: string) => number | null): string[];
13
+ /** The first runner that matches the path and does not ignore it. */
14
+ export declare function runnerFor(path: string, runners: readonly BurnInRunner[]): BurnInRunner | null;
15
+ export interface BurnInGroup {
16
+ runner: BurnInRunner;
17
+ files: string[];
18
+ }
19
+ export interface BurnInPlan {
20
+ groups: BurnInGroup[];
21
+ /** Burnable files past maxFiles: listed, never silently dropped. */
22
+ skipped: string[];
23
+ }
24
+ export declare function selectBurnIn(paths: readonly string[], config: ResolvedBurnIn): BurnInPlan;
25
+ export interface BurnInCommand {
26
+ runner: string;
27
+ files: string[];
28
+ command: string;
29
+ }
30
+ export declare function burnInCommands(plan: BurnInPlan, repeats: number): BurnInCommand[];
@@ -0,0 +1,78 @@
1
+ import { matchesAny } from '../signoff/plan.js';
2
+ export class BurnInError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'BurnInError';
6
+ }
7
+ }
8
+ /**
9
+ * Paths added or modified in base..head, newest mtime first so the file cap keeps the most
10
+ * recently touched. --no-renames lists a rename as delete + add and --diff-filter=d drops
11
+ * deletions, so a renamed file appears under its new name only. Files missing on disk
12
+ * (mtime null) are dropped.
13
+ */
14
+ export function burnInCandidates(base, head, exec, mtime) {
15
+ if (base === head)
16
+ return [];
17
+ const out = exec('git', [
18
+ 'diff',
19
+ '--name-only',
20
+ '--no-renames',
21
+ '--diff-filter=d',
22
+ '-z',
23
+ `${base}..${head}`
24
+ ]);
25
+ return out
26
+ .split('\0')
27
+ .filter((p) => p !== '')
28
+ .map((path) => ({ path, time: mtime(path) }))
29
+ .filter((c) => c.time !== null)
30
+ .sort((a, b) => b.time - a.time)
31
+ .map((c) => c.path);
32
+ }
33
+ /** The first runner that matches the path and does not ignore it. */
34
+ export function runnerFor(path, runners) {
35
+ for (const runner of runners) {
36
+ if (!matchesAny(path, runner.match))
37
+ continue;
38
+ if (runner.ignore !== undefined && matchesAny(path, runner.ignore))
39
+ continue;
40
+ return runner;
41
+ }
42
+ return null;
43
+ }
44
+ export function selectBurnIn(paths, config) {
45
+ const byRunner = new Map();
46
+ const skipped = [];
47
+ let kept = 0;
48
+ for (const path of paths) {
49
+ const runner = runnerFor(path, config.runners);
50
+ if (runner === null)
51
+ continue;
52
+ if (kept >= config.maxFiles) {
53
+ skipped.push(path);
54
+ continue;
55
+ }
56
+ byRunner.set(runner, [...(byRunner.get(runner) ?? []), path]);
57
+ kept += 1;
58
+ }
59
+ const groups = config.runners
60
+ .filter((runner) => byRunner.has(runner))
61
+ .map((runner) => ({ runner, files: byRunner.get(runner) }));
62
+ return { groups, skipped };
63
+ }
64
+ export function burnInCommands(plan, repeats) {
65
+ const out = [];
66
+ for (const { runner, files } of plan.groups) {
67
+ const produced = runner.command([...files], repeats);
68
+ const commands = Array.isArray(produced) ? produced : [produced];
69
+ const ok = commands.length > 0 && commands.every((c) => typeof c === 'string' && c.trim() !== '');
70
+ if (!ok) {
71
+ throw new BurnInError(`burnIn runner "${runner.name}": command() must return a non-empty command or a ` +
72
+ `non-empty array of them`);
73
+ }
74
+ for (const command of commands)
75
+ out.push({ runner: runner.name, files, command });
76
+ }
77
+ return out;
78
+ }
@@ -0,0 +1,10 @@
1
+ import { type StepConfig } from '../config/types.js';
2
+ export declare const BURN_IN_LABEL = "burn-in";
3
+ export interface BurnInStepOptions {
4
+ /** The triggers of your test steps: chreode cannot tell which steps are tests. */
5
+ when: string[];
6
+ timeoutMs?: number;
7
+ }
8
+ /** A signoff step that burns in the test files changed since the base. Place it after the
9
+ * test steps. */
10
+ export declare function burnInStep(opts: BurnInStepOptions): StepConfig;
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_TIMEOUT_MS } from '../config/types.js';
2
+ export const BURN_IN_LABEL = 'burn-in';
3
+ /** A signoff step that burns in the test files changed since the base. Place it after the
4
+ * test steps. */
5
+ export function burnInStep(opts) {
6
+ return {
7
+ label: BURN_IN_LABEL,
8
+ run: ({ base, head }) => `npx chreode burn-in --base ${base} --head ${head}`,
9
+ when: [...opts.when],
10
+ if: ({ base, head }) => base !== head,
11
+ policeStderr: true,
12
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
13
+ };
14
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { type SignoffOptions } from './signoff/run.js';
4
+ export interface SignoffFlags {
5
+ full?: boolean;
6
+ dryRun?: boolean;
7
+ merge?: boolean;
8
+ push?: boolean;
9
+ allowStderr?: boolean;
10
+ base?: string;
11
+ skip?: string[];
12
+ config?: string;
13
+ }
14
+ export interface ProtectFlags {
15
+ dryRun?: boolean;
16
+ repo?: string;
17
+ config?: string;
18
+ }
19
+ export interface FlakeReportFlags {
20
+ config?: string;
21
+ }
22
+ export interface BurnInFlags {
23
+ base: string;
24
+ head: string;
25
+ config?: string;
26
+ }
27
+ /** Each flag also has an env form, for harnesses that forbid flags after `--`. */
28
+ export declare function resolveSignoffOptions(flags: SignoffFlags, env: NodeJS.ProcessEnv): SignoffOptions;
29
+ export declare function buildProgram(actions: {
30
+ signoff: (flags: SignoffFlags) => Promise<number>;
31
+ protect: (flags: ProtectFlags) => Promise<number>;
32
+ flakeReport: (flags: FlakeReportFlags) => Promise<number>;
33
+ burnIn: (flags: BurnInFlags) => Promise<number>;
34
+ }, setExit: (code: number) => void): Command;
35
+ export declare function main(argv?: string[], env?: NodeJS.ProcessEnv): Promise<number>;
36
+ /** npm installs the bin as a symlink, so compare real paths, not the invoked path. */
37
+ export declare function isInvokedDirectly(moduleUrl: string, argv1: string | undefined): boolean;
package/dist/cli.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { Command } from 'commander';
6
+ import { runBurnIn } from './burn-in/run.js';
7
+ import { CONFIG_FILE, loadConfig } from './config/load.js';
8
+ import { runFlakeReport } from './flakes/report.js';
9
+ import { parseRepo } from './github/status.js';
10
+ import { runProtect } from './github/protect.js';
11
+ import { defaultExec } from './run/exec.js';
12
+ import { runSignoff } from './signoff/run.js';
13
+ /** Each flag also has an env form, for harnesses that forbid flags after `--`. */
14
+ export function resolveSignoffOptions(flags, env) {
15
+ const on = (key) => env[key] === '1';
16
+ return {
17
+ full: flags.full === true || on('CHREODE_FULL'),
18
+ dryRun: flags.dryRun === true,
19
+ noMerge: flags.merge === false || on('CHREODE_NO_MERGE'),
20
+ noPush: flags.push === false || on('CHREODE_NO_PUSH'),
21
+ allowStderr: flags.allowStderr === true || on('CHREODE_ALLOW_STDERR'),
22
+ base: flags.base ?? null,
23
+ skip: flags.skip ?? []
24
+ };
25
+ }
26
+ function readVersion() {
27
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
28
+ return pkg.version;
29
+ }
30
+ const collect = (value, previous) => [...previous, value];
31
+ export function buildProgram(actions, setExit) {
32
+ const program = new Command('chreode')
33
+ .description('A deterministic, selective, unavoidable local-CI gate')
34
+ .version(readVersion());
35
+ program
36
+ .command('signoff')
37
+ .description('merge the base branch, run the steps the changes need, push, post the status')
38
+ .option('--full', 'ignore the incremental base and run every step (env CHREODE_FULL=1)')
39
+ .option('--dry-run', 'merge origin/<baseBranch> and run afterMerge as usual, then print the plan and stop ' +
40
+ 'without running steps (add --no-merge for a side-effect-free look)')
41
+ .option('--no-merge', 'do not merge origin/<baseBranch> first (env CHREODE_NO_MERGE=1)')
42
+ .option('--no-push', 'do not push; the commit must already be on origin (env CHREODE_NO_PUSH=1)')
43
+ .option('--allow-stderr', 'downgrade policed stderr to a warning (env CHREODE_ALLOW_STDERR=1)')
44
+ .option('--base <ref>', 'diff from this ref instead of the last signoff')
45
+ .option('--skip <label>', 'skip a planned step; repeatable; named in the status', collect, [])
46
+ .option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
47
+ .action(async (flags) => setExit(await actions.signoff(flags)));
48
+ program
49
+ .command('protect')
50
+ .description('require the signoff status on the base branch (idempotent)')
51
+ .option('--dry-run', 'print the payloads without applying them')
52
+ .option('--repo <owner/name>', 'target repository (default: gh repo view)')
53
+ .option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
54
+ .action(async (flags) => setExit(await actions.protect(flags)));
55
+ program
56
+ .command('flake-report')
57
+ .description('report logged flakes to GitHub issues; exit 1 if any reached the threshold')
58
+ .option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
59
+ .action(async (flags) => setExit(await actions.flakeReport(flags)));
60
+ program
61
+ .command('burn-in')
62
+ .description('run the test files changed in base..head repeatedly, with retries off')
63
+ .requiredOption('--base <sha>', 'diff from this commit')
64
+ .requiredOption('--head <sha>', 'diff to this commit')
65
+ .option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
66
+ .action(async (flags) => setExit(await actions.burnIn(flags)));
67
+ return program;
68
+ }
69
+ /** Steps and git commands run from the repo root, whatever directory chreode was started in.
70
+ * Returns a loader rather than a config: signoff reloads it after a merge. */
71
+ function repoConfigLoader(configFlag) {
72
+ const explicit = configFlag ? path.resolve(configFlag) : null;
73
+ const root = defaultExec('git', ['rev-parse', '--show-toplevel']).trim();
74
+ process.chdir(root);
75
+ const file = explicit ?? path.join(root, CONFIG_FILE);
76
+ return () => loadConfig(file);
77
+ }
78
+ export async function main(argv = process.argv, env = process.env) {
79
+ let code = 0;
80
+ const program = buildProgram({
81
+ signoff: async (flags) => runSignoff(repoConfigLoader(flags.config), resolveSignoffOptions(flags, env)),
82
+ protect: async (flags) => runProtect(await repoConfigLoader(flags.config)(), {
83
+ dryRun: flags.dryRun === true,
84
+ repo: flags.repo ? parseRepo(flags.repo) : null
85
+ }),
86
+ flakeReport: async (flags) => runFlakeReport(await repoConfigLoader(flags.config)()),
87
+ burnIn: async (flags) => runBurnIn((await repoConfigLoader(flags.config)()).burnIn, {
88
+ base: flags.base,
89
+ head: flags.head
90
+ })
91
+ }, (c) => {
92
+ code = c;
93
+ });
94
+ try {
95
+ await program.parseAsync(argv);
96
+ }
97
+ catch (err) {
98
+ console.error(`[chreode] ${err instanceof Error ? err.message : String(err)}`);
99
+ return 1;
100
+ }
101
+ return code;
102
+ }
103
+ /** npm installs the bin as a symlink, so compare real paths, not the invoked path. */
104
+ export function isInvokedDirectly(moduleUrl, argv1) {
105
+ if (!argv1)
106
+ return false;
107
+ try {
108
+ return moduleUrl === pathToFileURL(realpathSync(argv1)).href;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ if (isInvokedDirectly(import.meta.url, process.argv[1])) {
115
+ main().then((c) => {
116
+ process.exitCode = c;
117
+ }, (err) => {
118
+ console.error(err);
119
+ process.exitCode = 1;
120
+ });
121
+ }
@@ -0,0 +1,5 @@
1
+ import type { ResolvedConfig } from './types.js';
2
+ export declare const CONFIG_FILE = "chreode.config.ts";
3
+ /** Load and validate a config file. jiti transpiles TypeScript on the fly, so consumers
4
+ * need no build step and no tsx. */
5
+ export declare function loadConfig(file: string): Promise<ResolvedConfig>;