@mindlace/chreode 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +153 -2
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +102 -0
- package/dist/config/load.d.ts +5 -0
- package/dist/config/load.js +13 -0
- package/dist/config/types.d.ts +71 -0
- package/dist/config/types.js +7 -0
- package/dist/config/validate.d.ts +9 -0
- package/dist/config/validate.js +204 -0
- package/dist/git/change-set.d.ts +18 -0
- package/dist/git/change-set.js +42 -0
- package/dist/git/merge.d.ts +21 -0
- package/dist/git/merge.js +33 -0
- package/dist/github/protect.d.ts +20 -0
- package/dist/github/protect.js +90 -0
- package/dist/github/status.d.ts +24 -0
- package/dist/github/status.js +54 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lock/machine-lock.d.ts +40 -0
- package/dist/lock/machine-lock.js +76 -0
- package/dist/run/exec.d.ts +8 -0
- package/dist/run/exec.js +15 -0
- package/dist/run/stderr.d.ts +21 -0
- package/dist/run/stderr.js +27 -0
- package/dist/run/step.d.ts +55 -0
- package/dist/run/step.js +182 -0
- package/dist/signoff/plan.d.ts +49 -0
- package/dist/signoff/plan.js +110 -0
- package/dist/signoff/preconditions.d.ts +10 -0
- package/dist/signoff/preconditions.js +49 -0
- package/dist/signoff/run.d.ts +36 -0
- package/dist/signoff/run.js +194 -0
- package/dist/signoff/tail.d.ts +32 -0
- package/dist/signoff/tail.js +55 -0
- package/package.json +70 -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,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# chreode
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|
|
48
|
+
|
|
49
|
+
Wire them into `package.json` as `"signoff": "chreode signoff"` and
|
|
50
|
+
`"branch-protection": "chreode protect"`. Run signoff instead of pushing: it pushes for you.
|
|
51
|
+
|
|
52
|
+
### `chreode signoff`
|
|
53
|
+
|
|
54
|
+
1. **Preconditions**: clean tree, a branch (not detached, not the base branch), not behind its
|
|
55
|
+
upstream, `gh auth status` ok, `git config user.email` set.
|
|
56
|
+
2. **Merge** `origin/<baseBranch>` (unless `--no-merge`). A conflict exits 1 and leaves the
|
|
57
|
+
merge in progress. When the merge changed the branch, `afterMerge` runs and the config is
|
|
58
|
+
reloaded, so a merged-in `chreode.config.ts` governs the rest of the run.
|
|
59
|
+
3. **Base**: `--base`, else the nearest commit on the branch carrying a `refs/notes/chreode`
|
|
60
|
+
note (the last successful signoff), else the merge-base with `origin/<baseBranch>`.
|
|
61
|
+
4. **Plan**: changed paths (both sides of renames, deletions included) → triggers (first
|
|
62
|
+
matching rule; a path no rule matches fires every trigger) → steps (`when`, `if`,
|
|
63
|
+
`--skip`, `supersededBy`). The plan and every skipped step's reason are printed.
|
|
64
|
+
5. **Run** the steps in order under one machine-wide lock shared by every chreode repo on the
|
|
65
|
+
machine. Each step runs in its own process group with a hard timeout; policed steps fail
|
|
66
|
+
on unexpected stderr. Children see `CHREODE_SIGNOFF=1`.
|
|
67
|
+
6. **Tail**: refuse if the steps changed the checkout; push; post `<statusContext>` = success
|
|
68
|
+
naming the signer and disclosing any `--skip`'d steps, stderr waived by `--allow-stderr`,
|
|
69
|
+
and `base=<ref>` for `--base`; write the note. A partial run (any of those three) writes
|
|
70
|
+
no note, so it never becomes the incremental base: the next signoff diffs from the last
|
|
71
|
+
full one and re-plans what was skipped.
|
|
72
|
+
|
|
73
|
+
| Flag | Env | Effect |
|
|
74
|
+
| ----------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `--full` | `CHREODE_FULL=1` | Ignore the incremental base; run every step |
|
|
76
|
+
| `--dry-run` | | Merge and run `afterMerge`, then print the plan and stop without running steps; add `--no-merge` for a side-effect-free look |
|
|
77
|
+
| `--no-merge` | `CHREODE_NO_MERGE=1` | Do not merge `origin/<baseBranch>` first |
|
|
78
|
+
| `--no-push` | `CHREODE_NO_PUSH=1` | Do not push (the commit must already be on origin) |
|
|
79
|
+
| `--allow-stderr` | `CHREODE_ALLOW_STDERR=1` | Downgrade policed stderr to a warning (named in the status; no note) |
|
|
80
|
+
| `--base <ref>` | | Diff from this ref (named in the status; no note) |
|
|
81
|
+
| `--skip <label>` | | Skip a planned step (repeatable; named in the status; no note) |
|
|
82
|
+
| `--config <path>` | | Config file (default `<repo root>/chreode.config.ts`) |
|
|
83
|
+
|
|
84
|
+
Exit codes: `0` signed off, already signed off, or dry run; `1` anything else.
|
|
85
|
+
|
|
86
|
+
The lock lives at `$TMPDIR/chreode-signoff.lock`. A waiter logs once and polls for up to an
|
|
87
|
+
hour; a lock whose holder died goes stale after 30 s and is stolen. `CHREODE_LOCK_PATH`
|
|
88
|
+
overrides the path (for test isolation only).
|
|
89
|
+
|
|
90
|
+
### `chreode protect`
|
|
91
|
+
|
|
92
|
+
`PUT`s branch protection on `<baseBranch>` requiring `<statusContext>` (strict), with admins
|
|
93
|
+
enforced, linear history, PRs required (0 approvals, stale reviews dismissed), and `PATCH`es
|
|
94
|
+
the repo to squash-only merges that delete head branches. Override either payload under
|
|
95
|
+
`protection` in the config. `--repo owner/name` targets another repo; `--dry-run` prints the
|
|
96
|
+
payloads.
|
|
97
|
+
|
|
98
|
+
## Config reference
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { defineConfig } from '@mindlace/chreode';
|
|
102
|
+
|
|
103
|
+
export default defineConfig({
|
|
104
|
+
baseBranch: 'main', // default
|
|
105
|
+
statusContext: 'chreode/signoff', // default
|
|
106
|
+
rules: [
|
|
107
|
+
{ match: ['docs/**', '**/*.md'], triggers: ['format'] },
|
|
108
|
+
{ match: 'src/**', triggers: ['lint', 'check', 'unit', 'e2e'] },
|
|
109
|
+
{ match: 'generated/**', triggers: [] } // matched, nothing to run
|
|
110
|
+
],
|
|
111
|
+
steps: [
|
|
112
|
+
{ label: 'format', run: 'npx prettier --check .', when: ['format'], supersededBy: ['lint'] },
|
|
113
|
+
{ label: 'lint', run: 'npm run lint', when: ['lint'] },
|
|
114
|
+
{ label: 'check', run: 'npm run check', when: ['check'] },
|
|
115
|
+
{ label: 'test:unit', run: 'npm run test:unit', when: ['unit'], policeStderr: true },
|
|
116
|
+
{
|
|
117
|
+
label: 'test:e2e',
|
|
118
|
+
run: 'npm run test:e2e',
|
|
119
|
+
when: ['e2e'],
|
|
120
|
+
policeStderr: true,
|
|
121
|
+
timeoutMs: 15 * 60_000
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
afterMerge: 'npm ci', // not `npm install`: see below
|
|
125
|
+
stderrAllowlist: [{ pattern: /^\[WebServer\] /, reason: 'Playwright echoes the preview server' }]
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Rules** are tried in order; the first whose `match` (a [picomatch](https://github.com/micromatch/picomatch)
|
|
130
|
+
glob, dotfiles included, or a RegExp, or an array of them) matches a changed path decides its
|
|
131
|
+
triggers. A path no rule matches fires every trigger: rules can only make runs smaller, so a
|
|
132
|
+
forgotten rule costs time, never coverage. Triggers are free-form strings.
|
|
133
|
+
|
|
134
|
+
**Steps** run in config order.
|
|
135
|
+
|
|
136
|
+
| Field | Type | Meaning |
|
|
137
|
+
| -------------- | --------------------------- | ---------------------------------------------------------------------- |
|
|
138
|
+
| `label` | string | Unique, no whitespace. Used by `--skip`, the note and the status. |
|
|
139
|
+
| `run` | `string \| (ctx) => string` | Shell command. `ctx` is `{ base, head, paths, triggers, isWorktree }`. |
|
|
140
|
+
| `when` | string[] | Planned if any listed trigger fired. |
|
|
141
|
+
| `if` | `(ctx) => boolean` | Extra predicate, e.g. only in a worktree. |
|
|
142
|
+
| `supersededBy` | string[] | Dropped when any listed step will also run. |
|
|
143
|
+
| `policeStderr` | boolean | Fail a clean exit that wrote non-allowlisted stderr. |
|
|
144
|
+
| `timeoutMs` | number | Hard ceiling; default 10 minutes. |
|
|
145
|
+
|
|
146
|
+
**`afterMerge`** runs only when merging `origin/<baseBranch>` changed the branch. It must
|
|
147
|
+
leave the checkout clean (signoff refuses to run otherwise), so use `npm ci`, which installs
|
|
148
|
+
exactly the lockfile, rather than `npm install`, which can rewrite it.
|
|
149
|
+
**`stderrAllowlist`** entries need a `reason`: allowlisting noise is a reviewed decision.
|
|
150
|
+
Unknown keys, duplicate labels, and `supersededBy` naming a missing step are load errors.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
/** Each flag also has an env form, for harnesses that forbid flags after `--`. */
|
|
20
|
+
export declare function resolveSignoffOptions(flags: SignoffFlags, env: NodeJS.ProcessEnv): SignoffOptions;
|
|
21
|
+
export declare function buildProgram(actions: {
|
|
22
|
+
signoff: (flags: SignoffFlags) => Promise<number>;
|
|
23
|
+
protect: (flags: ProtectFlags) => Promise<number>;
|
|
24
|
+
}, setExit: (code: number) => void): Command;
|
|
25
|
+
export declare function main(argv?: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
26
|
+
/** npm installs the bin as a symlink, so compare real paths, not the invoked path. */
|
|
27
|
+
export declare function isInvokedDirectly(moduleUrl: string, argv1: string | undefined): boolean;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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 { CONFIG_FILE, loadConfig } from './config/load.js';
|
|
7
|
+
import { parseRepo } from './github/status.js';
|
|
8
|
+
import { runProtect } from './github/protect.js';
|
|
9
|
+
import { defaultExec } from './run/exec.js';
|
|
10
|
+
import { runSignoff } from './signoff/run.js';
|
|
11
|
+
/** Each flag also has an env form, for harnesses that forbid flags after `--`. */
|
|
12
|
+
export function resolveSignoffOptions(flags, env) {
|
|
13
|
+
const on = (key) => env[key] === '1';
|
|
14
|
+
return {
|
|
15
|
+
full: flags.full === true || on('CHREODE_FULL'),
|
|
16
|
+
dryRun: flags.dryRun === true,
|
|
17
|
+
noMerge: flags.merge === false || on('CHREODE_NO_MERGE'),
|
|
18
|
+
noPush: flags.push === false || on('CHREODE_NO_PUSH'),
|
|
19
|
+
allowStderr: flags.allowStderr === true || on('CHREODE_ALLOW_STDERR'),
|
|
20
|
+
base: flags.base ?? null,
|
|
21
|
+
skip: flags.skip ?? []
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function readVersion() {
|
|
25
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
26
|
+
return pkg.version;
|
|
27
|
+
}
|
|
28
|
+
const collect = (value, previous) => [...previous, value];
|
|
29
|
+
export function buildProgram(actions, setExit) {
|
|
30
|
+
const program = new Command('chreode')
|
|
31
|
+
.description('A deterministic, selective, unavoidable local-CI gate')
|
|
32
|
+
.version(readVersion());
|
|
33
|
+
program
|
|
34
|
+
.command('signoff')
|
|
35
|
+
.description('merge the base branch, run the steps the changes need, push, post the status')
|
|
36
|
+
.option('--full', 'ignore the incremental base and run every step (env CHREODE_FULL=1)')
|
|
37
|
+
.option('--dry-run', 'merge origin/<baseBranch> and run afterMerge as usual, then print the plan and stop ' +
|
|
38
|
+
'without running steps (add --no-merge for a side-effect-free look)')
|
|
39
|
+
.option('--no-merge', 'do not merge origin/<baseBranch> first (env CHREODE_NO_MERGE=1)')
|
|
40
|
+
.option('--no-push', 'do not push; the commit must already be on origin (env CHREODE_NO_PUSH=1)')
|
|
41
|
+
.option('--allow-stderr', 'downgrade policed stderr to a warning (env CHREODE_ALLOW_STDERR=1)')
|
|
42
|
+
.option('--base <ref>', 'diff from this ref instead of the last signoff')
|
|
43
|
+
.option('--skip <label>', 'skip a planned step; repeatable; named in the status', collect, [])
|
|
44
|
+
.option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
|
|
45
|
+
.action(async (flags) => setExit(await actions.signoff(flags)));
|
|
46
|
+
program
|
|
47
|
+
.command('protect')
|
|
48
|
+
.description('require the signoff status on the base branch (idempotent)')
|
|
49
|
+
.option('--dry-run', 'print the payloads without applying them')
|
|
50
|
+
.option('--repo <owner/name>', 'target repository (default: gh repo view)')
|
|
51
|
+
.option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
|
|
52
|
+
.action(async (flags) => setExit(await actions.protect(flags)));
|
|
53
|
+
return program;
|
|
54
|
+
}
|
|
55
|
+
/** Steps and git commands run from the repo root, whatever directory chreode was started in.
|
|
56
|
+
* Returns a loader rather than a config: signoff reloads it after a merge. */
|
|
57
|
+
function repoConfigLoader(configFlag) {
|
|
58
|
+
const explicit = configFlag ? path.resolve(configFlag) : null;
|
|
59
|
+
const root = defaultExec('git', ['rev-parse', '--show-toplevel']).trim();
|
|
60
|
+
process.chdir(root);
|
|
61
|
+
const file = explicit ?? path.join(root, CONFIG_FILE);
|
|
62
|
+
return () => loadConfig(file);
|
|
63
|
+
}
|
|
64
|
+
export async function main(argv = process.argv, env = process.env) {
|
|
65
|
+
let code = 0;
|
|
66
|
+
const program = buildProgram({
|
|
67
|
+
signoff: async (flags) => runSignoff(repoConfigLoader(flags.config), resolveSignoffOptions(flags, env)),
|
|
68
|
+
protect: async (flags) => runProtect(await repoConfigLoader(flags.config)(), {
|
|
69
|
+
dryRun: flags.dryRun === true,
|
|
70
|
+
repo: flags.repo ? parseRepo(flags.repo) : null
|
|
71
|
+
})
|
|
72
|
+
}, (c) => {
|
|
73
|
+
code = c;
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
await program.parseAsync(argv);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error(`[chreode] ${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return code;
|
|
83
|
+
}
|
|
84
|
+
/** npm installs the bin as a symlink, so compare real paths, not the invoked path. */
|
|
85
|
+
export function isInvokedDirectly(moduleUrl, argv1) {
|
|
86
|
+
if (!argv1)
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
return moduleUrl === pathToFileURL(realpathSync(argv1)).href;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (isInvokedDirectly(import.meta.url, process.argv[1])) {
|
|
96
|
+
main().then((c) => {
|
|
97
|
+
process.exitCode = c;
|
|
98
|
+
}, (err) => {
|
|
99
|
+
console.error(err);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createJiti } from 'jiti';
|
|
3
|
+
import { ConfigError, validateConfig } from './validate.js';
|
|
4
|
+
export const CONFIG_FILE = 'chreode.config.ts';
|
|
5
|
+
/** Load and validate a config file. jiti transpiles TypeScript on the fly, so consumers
|
|
6
|
+
* need no build step and no tsx. */
|
|
7
|
+
export async function loadConfig(file) {
|
|
8
|
+
if (!existsSync(file))
|
|
9
|
+
throw new ConfigError([`no config file at ${file}`]);
|
|
10
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false });
|
|
11
|
+
const mod = await jiti.import(file, { default: true });
|
|
12
|
+
return validateConfig(mod);
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A path matcher: a picomatch glob (dotfiles match) or a RegExp tested against the
|
|
2
|
+
* repo-relative POSIX path. */
|
|
3
|
+
export type Matcher = string | RegExp;
|
|
4
|
+
/** What a step's `run` function and `if` predicate see. */
|
|
5
|
+
export interface StepContext {
|
|
6
|
+
base: string;
|
|
7
|
+
head: string;
|
|
8
|
+
paths: readonly string[];
|
|
9
|
+
triggers: readonly string[];
|
|
10
|
+
isWorktree: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** First matching rule wins. `triggers: []` means "matched, nothing to run". */
|
|
13
|
+
export interface RuleConfig {
|
|
14
|
+
match: Matcher | Matcher[];
|
|
15
|
+
triggers: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface StepConfig {
|
|
18
|
+
/** Unique, no whitespace. Used by --skip, the note and the status description. */
|
|
19
|
+
label: string;
|
|
20
|
+
run: string | ((ctx: StepContext) => string);
|
|
21
|
+
/** Planned when any listed trigger fired. */
|
|
22
|
+
when: string[];
|
|
23
|
+
if?: (ctx: StepContext) => boolean;
|
|
24
|
+
/** Dropped when any listed step label is also going to run. */
|
|
25
|
+
supersededBy?: string[];
|
|
26
|
+
/** Capture stderr; a clean exit with non-allowlisted stderr fails the step. */
|
|
27
|
+
policeStderr?: boolean;
|
|
28
|
+
/** Hard wall-clock ceiling; the step's process group is SIGKILLed past it. */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface AllowlistEntry {
|
|
32
|
+
/** A stderr line (ANSI-stripped) matching this pattern is benign. */
|
|
33
|
+
pattern: RegExp;
|
|
34
|
+
/** Why this noise is unavoidable. Required: allowlisting is a reviewed decision. */
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ProtectionConfig {
|
|
38
|
+
/** Shallow-merged over the default branch-protection payload. */
|
|
39
|
+
branch?: Record<string, unknown>;
|
|
40
|
+
/** Shallow-merged over the default repository merge settings. */
|
|
41
|
+
repo?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
export interface ChreodeConfig {
|
|
44
|
+
baseBranch?: string;
|
|
45
|
+
statusContext?: string;
|
|
46
|
+
rules?: RuleConfig[];
|
|
47
|
+
steps: StepConfig[];
|
|
48
|
+
/** Runs only when the merge of origin/<baseBranch> actually changed the branch. */
|
|
49
|
+
afterMerge?: string;
|
|
50
|
+
stderrAllowlist?: AllowlistEntry[];
|
|
51
|
+
protection?: ProtectionConfig;
|
|
52
|
+
}
|
|
53
|
+
export interface ResolvedStep extends StepConfig {
|
|
54
|
+
supersededBy: string[];
|
|
55
|
+
policeStderr: boolean;
|
|
56
|
+
timeoutMs: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ResolvedConfig {
|
|
59
|
+
baseBranch: string;
|
|
60
|
+
statusContext: string;
|
|
61
|
+
rules: RuleConfig[];
|
|
62
|
+
steps: ResolvedStep[];
|
|
63
|
+
afterMerge: string | null;
|
|
64
|
+
stderrAllowlist: AllowlistEntry[];
|
|
65
|
+
protection: ProtectionConfig;
|
|
66
|
+
}
|
|
67
|
+
export declare const DEFAULT_BASE_BRANCH = "main";
|
|
68
|
+
export declare const DEFAULT_STATUS_CONTEXT = "chreode/signoff";
|
|
69
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
70
|
+
/** Identity helper so `chreode.config.ts` gets types. */
|
|
71
|
+
export declare function defineConfig(config: ChreodeConfig): ChreodeConfig;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const DEFAULT_BASE_BRANCH = 'main';
|
|
2
|
+
export const DEFAULT_STATUS_CONTEXT = 'chreode/signoff';
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
4
|
+
/** Identity helper so `chreode.config.ts` gets types. */
|
|
5
|
+
export function defineConfig(config) {
|
|
6
|
+
return config;
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ResolvedConfig } from './types.js';
|
|
2
|
+
export declare class ConfigError extends Error {
|
|
3
|
+
readonly problems: string[];
|
|
4
|
+
constructor(problems: string[]);
|
|
5
|
+
}
|
|
6
|
+
/** Cross-realm safe: a config evaluated by jiti may not share this realm's RegExp. */
|
|
7
|
+
export declare function isRegExp(v: unknown): v is RegExp;
|
|
8
|
+
export declare function isRecord(v: unknown): v is Record<string, unknown>;
|
|
9
|
+
export declare function validateConfig(raw: unknown): ResolvedConfig;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { DEFAULT_BASE_BRANCH, DEFAULT_STATUS_CONTEXT, DEFAULT_TIMEOUT_MS } from './types.js';
|
|
2
|
+
export class ConfigError extends Error {
|
|
3
|
+
problems;
|
|
4
|
+
constructor(problems) {
|
|
5
|
+
super(`invalid chreode config:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
6
|
+
this.problems = problems;
|
|
7
|
+
this.name = 'ConfigError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const TOP_KEYS = new Set([
|
|
11
|
+
'baseBranch',
|
|
12
|
+
'statusContext',
|
|
13
|
+
'rules',
|
|
14
|
+
'steps',
|
|
15
|
+
'afterMerge',
|
|
16
|
+
'stderrAllowlist',
|
|
17
|
+
'protection'
|
|
18
|
+
]);
|
|
19
|
+
const RULE_KEYS = new Set(['match', 'triggers']);
|
|
20
|
+
const STEP_KEYS = new Set([
|
|
21
|
+
'label',
|
|
22
|
+
'run',
|
|
23
|
+
'when',
|
|
24
|
+
'if',
|
|
25
|
+
'supersededBy',
|
|
26
|
+
'policeStderr',
|
|
27
|
+
'timeoutMs'
|
|
28
|
+
]);
|
|
29
|
+
const ALLOW_KEYS = new Set(['pattern', 'reason']);
|
|
30
|
+
const PROTECTION_KEYS = new Set(['branch', 'repo']);
|
|
31
|
+
/** Cross-realm safe: a config evaluated by jiti may not share this realm's RegExp. */
|
|
32
|
+
export function isRegExp(v) {
|
|
33
|
+
return Object.prototype.toString.call(v) === '[object RegExp]';
|
|
34
|
+
}
|
|
35
|
+
export function isRecord(v) {
|
|
36
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) && !isRegExp(v);
|
|
37
|
+
}
|
|
38
|
+
function isNonEmptyString(v) {
|
|
39
|
+
return typeof v === 'string' && v.length > 0;
|
|
40
|
+
}
|
|
41
|
+
function isStringArray(v) {
|
|
42
|
+
return Array.isArray(v) && v.every(isNonEmptyString);
|
|
43
|
+
}
|
|
44
|
+
function isMatcher(v) {
|
|
45
|
+
return isNonEmptyString(v) || isRegExp(v);
|
|
46
|
+
}
|
|
47
|
+
function unknownKeys(obj, allowed, where, problems) {
|
|
48
|
+
for (const key of Object.keys(obj)) {
|
|
49
|
+
if (!allowed.has(key))
|
|
50
|
+
problems.push(`${where}: unknown key "${key}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function validateRules(raw, problems) {
|
|
54
|
+
if (raw === undefined)
|
|
55
|
+
return [];
|
|
56
|
+
if (!Array.isArray(raw)) {
|
|
57
|
+
problems.push('config.rules: must be an array');
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
raw.forEach((rule, i) => {
|
|
61
|
+
const where = `rules[${i}]`;
|
|
62
|
+
if (!isRecord(rule)) {
|
|
63
|
+
problems.push(`${where}: must be an object`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
unknownKeys(rule, RULE_KEYS, where, problems);
|
|
67
|
+
const m = rule.match;
|
|
68
|
+
const ok = Array.isArray(m) ? m.length > 0 && m.every(isMatcher) : isMatcher(m);
|
|
69
|
+
if (!ok) {
|
|
70
|
+
problems.push(`${where}.match: must be a glob string, a RegExp, or a non-empty array of them`);
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(rule.triggers) || !rule.triggers.every(isNonEmptyString)) {
|
|
73
|
+
problems.push(`${where}.triggers: must be an array of trigger names ([] means "matched, run nothing")`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
function validateSteps(raw, problems) {
|
|
79
|
+
if (!Array.isArray(raw)) {
|
|
80
|
+
problems.push('config.steps: must be an array');
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const labels = new Set();
|
|
84
|
+
const out = [];
|
|
85
|
+
raw.forEach((step, i) => {
|
|
86
|
+
const where = `steps[${i}]`;
|
|
87
|
+
if (!isRecord(step)) {
|
|
88
|
+
problems.push(`${where}: must be an object`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
unknownKeys(step, STEP_KEYS, where, problems);
|
|
92
|
+
const label = step.label;
|
|
93
|
+
if (typeof label !== 'string' || !/^\S+$/.test(label)) {
|
|
94
|
+
problems.push(`${where}.label: must be non-empty with no whitespace`);
|
|
95
|
+
}
|
|
96
|
+
else if (labels.has(label)) {
|
|
97
|
+
problems.push(`${where}.label: duplicate label "${label}"`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
labels.add(label);
|
|
101
|
+
}
|
|
102
|
+
if (!isNonEmptyString(step.run) && typeof step.run !== 'function') {
|
|
103
|
+
problems.push(`${where}.run: must be a non-empty string or a function`);
|
|
104
|
+
}
|
|
105
|
+
if (!isStringArray(step.when) || step.when.length === 0) {
|
|
106
|
+
problems.push(`${where}.when: must be a non-empty array of trigger names`);
|
|
107
|
+
}
|
|
108
|
+
if (step.if !== undefined && typeof step.if !== 'function') {
|
|
109
|
+
problems.push(`${where}.if: must be a function`);
|
|
110
|
+
}
|
|
111
|
+
if (step.supersededBy !== undefined && !isStringArray(step.supersededBy)) {
|
|
112
|
+
problems.push(`${where}.supersededBy: must be an array of step labels`);
|
|
113
|
+
}
|
|
114
|
+
if (step.policeStderr !== undefined && typeof step.policeStderr !== 'boolean') {
|
|
115
|
+
problems.push(`${where}.policeStderr: must be a boolean`);
|
|
116
|
+
}
|
|
117
|
+
const t = step.timeoutMs;
|
|
118
|
+
if (t !== undefined && (typeof t !== 'number' || !Number.isFinite(t) || t <= 0)) {
|
|
119
|
+
problems.push(`${where}.timeoutMs: must be a positive number`);
|
|
120
|
+
}
|
|
121
|
+
out.push({
|
|
122
|
+
...step,
|
|
123
|
+
supersededBy: isStringArray(step.supersededBy) ? step.supersededBy : [],
|
|
124
|
+
policeStderr: step.policeStderr === true,
|
|
125
|
+
timeoutMs: typeof t === 'number' && t > 0 ? t : DEFAULT_TIMEOUT_MS
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
for (const step of out) {
|
|
129
|
+
for (const target of step.supersededBy) {
|
|
130
|
+
if (target === step.label) {
|
|
131
|
+
problems.push(`step "${step.label}": supersededBy names itself`);
|
|
132
|
+
}
|
|
133
|
+
else if (!labels.has(target)) {
|
|
134
|
+
problems.push(`step "${step.label}": supersededBy names unknown step "${target}"`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
function validateAllowlist(raw, problems) {
|
|
141
|
+
if (raw === undefined)
|
|
142
|
+
return [];
|
|
143
|
+
if (!Array.isArray(raw)) {
|
|
144
|
+
problems.push('config.stderrAllowlist: must be an array');
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
raw.forEach((entry, i) => {
|
|
148
|
+
const where = `stderrAllowlist[${i}]`;
|
|
149
|
+
if (!isRecord(entry)) {
|
|
150
|
+
problems.push(`${where}: must be an object`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
unknownKeys(entry, ALLOW_KEYS, where, problems);
|
|
154
|
+
if (!isRegExp(entry.pattern))
|
|
155
|
+
problems.push(`${where}.pattern: must be a RegExp`);
|
|
156
|
+
if (!isNonEmptyString(entry.reason))
|
|
157
|
+
problems.push(`${where}.reason: must be a non-empty string`);
|
|
158
|
+
});
|
|
159
|
+
return raw;
|
|
160
|
+
}
|
|
161
|
+
function validateProtection(raw, problems) {
|
|
162
|
+
if (raw === undefined)
|
|
163
|
+
return {};
|
|
164
|
+
if (!isRecord(raw)) {
|
|
165
|
+
problems.push('config.protection: must be an object');
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
unknownKeys(raw, PROTECTION_KEYS, 'protection', problems);
|
|
169
|
+
for (const key of ['branch', 'repo']) {
|
|
170
|
+
if (raw[key] !== undefined && !isRecord(raw[key])) {
|
|
171
|
+
problems.push(`protection.${key}: must be an object`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return raw;
|
|
175
|
+
}
|
|
176
|
+
export function validateConfig(raw) {
|
|
177
|
+
if (!isRecord(raw)) {
|
|
178
|
+
throw new ConfigError([
|
|
179
|
+
'config must be an object (does chreode.config.ts `export default defineConfig({ ... })`?)'
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
const problems = [];
|
|
183
|
+
unknownKeys(raw, TOP_KEYS, 'config', problems);
|
|
184
|
+
for (const key of ['baseBranch', 'statusContext', 'afterMerge']) {
|
|
185
|
+
if (raw[key] !== undefined && !isNonEmptyString(raw[key])) {
|
|
186
|
+
problems.push(`config.${key}: must be a non-empty string`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const rules = validateRules(raw.rules, problems);
|
|
190
|
+
const steps = validateSteps(raw.steps, problems);
|
|
191
|
+
const stderrAllowlist = validateAllowlist(raw.stderrAllowlist, problems);
|
|
192
|
+
const protection = validateProtection(raw.protection, problems);
|
|
193
|
+
if (problems.length > 0)
|
|
194
|
+
throw new ConfigError(problems);
|
|
195
|
+
return {
|
|
196
|
+
baseBranch: raw.baseBranch ?? DEFAULT_BASE_BRANCH,
|
|
197
|
+
statusContext: raw.statusContext ?? DEFAULT_STATUS_CONTEXT,
|
|
198
|
+
rules,
|
|
199
|
+
steps,
|
|
200
|
+
afterMerge: raw.afterMerge ?? null,
|
|
201
|
+
stderrAllowlist,
|
|
202
|
+
protection
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Exec } from '../run/exec.js';
|
|
2
|
+
/** Successful signoffs are recorded as notes under refs/notes/chreode. */
|
|
3
|
+
export declare const NOTES_REF = "chreode";
|
|
4
|
+
export interface BaseInfo {
|
|
5
|
+
base: string;
|
|
6
|
+
head: string;
|
|
7
|
+
source: 'note' | 'merge-base';
|
|
8
|
+
}
|
|
9
|
+
export declare function hasNote(sha: string, exec?: Exec): boolean;
|
|
10
|
+
/** The nearest commit on this branch (not on origin/<baseBranch>) carrying a signoff note;
|
|
11
|
+
* else the merge-base with origin/<baseBranch>. */
|
|
12
|
+
export declare function resolveBase(baseBranch: string, exec?: Exec): BaseInfo;
|
|
13
|
+
export declare function resolveRef(ref: string, exec?: Exec): string;
|
|
14
|
+
/** Paths changed between base and head. --no-renames lists both sides of a rename, so a
|
|
15
|
+
* file moved out of a rule's reach still fires that rule; deletions are included. */
|
|
16
|
+
export declare function committedPaths(base: string, head: string, exec?: Exec): string[];
|
|
17
|
+
export declare function isWorktree(exec?: Exec): boolean;
|
|
18
|
+
export declare function writeNote(sha: string, ranLabels: readonly string[], exec?: Exec): void;
|