@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
@@ -0,0 +1,112 @@
1
+ import picomatch from 'picomatch';
2
+ export class PlanError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'PlanError';
6
+ }
7
+ }
8
+ function tester(m) {
9
+ if (typeof m === 'string')
10
+ return picomatch(m, { dot: true });
11
+ return (path) => {
12
+ // A /g or /y RegExp carries lastIndex between calls; reset so every path is tested fresh.
13
+ m.lastIndex = 0;
14
+ return m.test(path);
15
+ };
16
+ }
17
+ export function matchesAny(path, matchers) {
18
+ return (Array.isArray(matchers) ? matchers : [matchers]).some((m) => tester(m)(path));
19
+ }
20
+ export function matchRule(path, rules) {
21
+ for (const rule of rules) {
22
+ if (matchesAny(path, rule.match))
23
+ return rule;
24
+ }
25
+ return null;
26
+ }
27
+ export function allTriggers(steps) {
28
+ return [...new Set(steps.flatMap((s) => s.when))].sort();
29
+ }
30
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything. */
31
+ export function fireTriggers(paths, rules, steps) {
32
+ const fired = new Set();
33
+ const unmatched = [];
34
+ for (const path of paths) {
35
+ const rule = matchRule(path, rules);
36
+ const triggers = rule === null ? allTriggers(steps) : rule.triggers;
37
+ if (rule === null)
38
+ unmatched.push(path);
39
+ for (const t of triggers)
40
+ fired.add(t);
41
+ }
42
+ return { triggers: [...fired].sort(), unmatched };
43
+ }
44
+ export function planSignoff(config, input) {
45
+ const known = config.steps.map((s) => s.label);
46
+ const unknownSkips = input.skip.filter((l) => !known.includes(l));
47
+ if (unknownSkips.length > 0) {
48
+ throw new PlanError(`--skip names unknown step(s): ${unknownSkips.join(', ')} (known: ${known.join(', ')})`);
49
+ }
50
+ const { triggers, unmatched } = input.full
51
+ ? { triggers: allTriggers(config.steps), unmatched: [] }
52
+ : fireTriggers(input.paths, config.rules, config.steps);
53
+ const ctx = {
54
+ base: input.base,
55
+ head: input.head,
56
+ paths: input.paths,
57
+ triggers,
58
+ isWorktree: input.isWorktree
59
+ };
60
+ const fired = new Set(triggers);
61
+ const skipSet = new Set(input.skip);
62
+ const skipped = [];
63
+ const skippedByFlag = [];
64
+ const survivors = [];
65
+ for (const step of config.steps) {
66
+ if (!step.when.some((t) => fired.has(t))) {
67
+ skipped.push({ label: step.label, reason: { kind: 'not-triggered' } });
68
+ }
69
+ else if (step.if && !step.if(ctx)) {
70
+ skipped.push({ label: step.label, reason: { kind: 'if-false' } });
71
+ }
72
+ else if (skipSet.has(step.label)) {
73
+ skipped.push({ label: step.label, reason: { kind: 'skip-flag' } });
74
+ skippedByFlag.push(step.label);
75
+ }
76
+ else {
77
+ survivors.push(step);
78
+ }
79
+ }
80
+ const surviving = new Set(survivors.map((s) => s.label));
81
+ const steps = [];
82
+ for (const step of survivors) {
83
+ const by = step.supersededBy.find((l) => surviving.has(l));
84
+ if (by !== undefined) {
85
+ skipped.push({ label: step.label, reason: { kind: 'superseded', by } });
86
+ continue;
87
+ }
88
+ const command = typeof step.run === 'function' ? step.run(ctx) : step.run;
89
+ if (typeof command !== 'string' || command.trim() === '') {
90
+ throw new PlanError(`step "${step.label}": run() must return a non-empty command`);
91
+ }
92
+ steps.push({
93
+ label: step.label,
94
+ command,
95
+ policeStderr: step.policeStderr,
96
+ timeoutMs: step.timeoutMs
97
+ });
98
+ }
99
+ return { triggers, unmatched, steps, skipped, skippedByFlag };
100
+ }
101
+ export function describeSkip(reason) {
102
+ switch (reason.kind) {
103
+ case 'not-triggered':
104
+ return 'no relevant changes';
105
+ case 'if-false':
106
+ return '`if` returned false';
107
+ case 'skip-flag':
108
+ return '--skip';
109
+ case 'superseded':
110
+ return `superseded by ${reason.by}`;
111
+ }
112
+ }
@@ -0,0 +1,12 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ export declare class PreconditionError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface Preconditions {
6
+ branch: string;
7
+ /** git user.email: named in the status description. */
8
+ signer: string;
9
+ }
10
+ export declare function checkPreconditions(baseBranch: string, exec?: Exec, opts?: {
11
+ flakes: boolean;
12
+ }): Preconditions;
@@ -0,0 +1,59 @@
1
+ import { FLAKE_LOG_FILE } from '../flakes/log.js';
2
+ import { defaultExec } from '../run/exec.js';
3
+ export class PreconditionError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'PreconditionError';
7
+ }
8
+ }
9
+ export function checkPreconditions(baseBranch, exec = defaultExec, opts = { flakes: false }) {
10
+ if (exec('git', ['status', '--porcelain']).trim() !== '') {
11
+ throw new PreconditionError('working tree is dirty; commit or stash before signing off');
12
+ }
13
+ let branch;
14
+ try {
15
+ branch = exec('git', ['symbolic-ref', '-q', '--short', 'HEAD']).trim();
16
+ }
17
+ catch {
18
+ throw new PreconditionError('HEAD is detached; check out a branch before signing off');
19
+ }
20
+ if (branch === baseBranch) {
21
+ throw new PreconditionError(`on ${baseBranch} itself; signoff is for branches that merge into ${baseBranch}`);
22
+ }
23
+ let behind = '';
24
+ try {
25
+ behind = exec('git', ['rev-list', 'HEAD..@{u}']).trim();
26
+ }
27
+ catch {
28
+ // No upstream yet: nothing to be behind.
29
+ }
30
+ if (behind !== '') {
31
+ throw new PreconditionError('HEAD is behind its upstream; pull before signing off');
32
+ }
33
+ if (opts.flakes) {
34
+ try {
35
+ exec('git', ['check-ignore', '-q', FLAKE_LOG_FILE]);
36
+ }
37
+ catch {
38
+ throw new PreconditionError('`flakes` is configured but .chreode/ is not gitignored (the flake log would dirty ' +
39
+ 'the tree); add the line `.chreode/` to .gitignore');
40
+ }
41
+ }
42
+ try {
43
+ exec('gh', ['auth', 'status']);
44
+ }
45
+ catch {
46
+ throw new PreconditionError('gh is not authenticated; run `gh auth login`');
47
+ }
48
+ let signer = '';
49
+ try {
50
+ signer = exec('git', ['config', 'user.email']).trim();
51
+ }
52
+ catch {
53
+ // Reported below.
54
+ }
55
+ if (signer === '') {
56
+ throw new PreconditionError('git config user.email is unset; the status description names the signer');
57
+ }
58
+ return { branch, signer };
59
+ }
@@ -0,0 +1,41 @@
1
+ import type { ResolvedConfig } from '../config/types.js';
2
+ import type { FlakeLedgerDeps } from '../flakes/ledger.js';
3
+ import { type MachineLock } from '../lock/machine-lock.js';
4
+ import { type Exec, type Run } from '../run/exec.js';
5
+ import { type StepOutcome } from '../run/step.js';
6
+ import { type Plan, type PlannedStep } from './plan.js';
7
+ import { type TailEffects } from './tail.js';
8
+ export interface SignoffOptions {
9
+ full: boolean;
10
+ dryRun: boolean;
11
+ noMerge: boolean;
12
+ noPush: boolean;
13
+ allowStderr: boolean;
14
+ base: string | null;
15
+ skip: string[];
16
+ }
17
+ export interface SignoffDeps {
18
+ exec: Exec;
19
+ run: Run;
20
+ lock: MachineLock;
21
+ runStep: (step: PlannedStep) => Promise<StepOutcome>;
22
+ tail: TailEffects;
23
+ /** Files, bumps and lists flake issues. */
24
+ ledger: FlakeLedgerDeps;
25
+ /** Absolute path of the flake log; steps see it as CHREODE_FLAKE_LOG. */
26
+ flakeLog: string;
27
+ log: (m: string) => void;
28
+ warn: (m: string) => void;
29
+ error: (m: string) => void;
30
+ }
31
+ export declare function printPlan(plan: Plan, where: {
32
+ base: string;
33
+ head: string;
34
+ paths: readonly string[];
35
+ full: boolean;
36
+ }, log: (m: string) => void): void;
37
+ /**
38
+ * `loadConfig` is called once up front and again after a merge that changed the branch, so
39
+ * a merge that brings in a new chreode.config.ts is planned and run under that config.
40
+ */
41
+ export declare function runSignoff(loadConfig: () => Promise<ResolvedConfig>, opts: SignoffOptions, overrides?: Partial<SignoffDeps>): Promise<number>;
@@ -0,0 +1,226 @@
1
+ import { resolve } from 'node:path';
2
+ import { ghLedgerDeps } from '../flakes/gh.js';
3
+ import { FLAKE_LOG_FILE } from '../flakes/log.js';
4
+ import { escalationFailure, runFlakePhase } from '../flakes/phase.js';
5
+ import { committedPaths, isWorktree, resolveBase, resolveRef } from '../git/change-set.js';
6
+ import { mergeBaseBranch } from '../git/merge.js';
7
+ import { repoInfo } from '../github/status.js';
8
+ import { signoffLock } from '../lock/machine-lock.js';
9
+ import { defaultExec, defaultRun } from '../run/exec.js';
10
+ import { StepFailure, runStep, runSteps } from '../run/step.js';
11
+ import { PlanError, describeSkip, planSignoff } from './plan.js';
12
+ import { PreconditionError, checkPreconditions } from './preconditions.js';
13
+ import { defaultTailEffects, runTail } from './tail.js';
14
+ function defaultDeps(config, opts, stepEnv = {}) {
15
+ // Children can detect the gate (e.g. to quiet build-tool stderr) via CHREODE_SIGNOFF=1.
16
+ const env = { ...process.env, CHREODE_SIGNOFF: '1', ...stepEnv };
17
+ let repo = null;
18
+ const getRepo = () => (repo ??= repoInfo(defaultExec));
19
+ return {
20
+ exec: defaultExec,
21
+ run: (command) => defaultRun(command, env),
22
+ lock: signoffLock(),
23
+ runStep: (step) => runStep(step, { allowStderr: opts.allowStderr, allowlist: config.stderrAllowlist, env }),
24
+ tail: defaultTailEffects(getRepo, config.statusContext),
25
+ ledger: ghLedgerDeps(defaultExec),
26
+ // The CLI has already chdir'd to the repo root.
27
+ flakeLog: resolve(FLAKE_LOG_FILE),
28
+ log: (m) => console.log(m),
29
+ warn: (m) => console.warn(m),
30
+ error: (m) => console.error(m)
31
+ };
32
+ }
33
+ function message(err) {
34
+ return err instanceof Error ? err.message : String(err);
35
+ }
36
+ /** Why the checkout no longer matches `head`, or null when it is clean and unmoved. */
37
+ function checkoutChange(head, exec) {
38
+ if (exec('git', ['status', '--porcelain']).trim() !== '')
39
+ return 'the working tree is dirty';
40
+ const now = exec('git', ['rev-parse', 'HEAD']).trim();
41
+ return now !== head ? `HEAD moved to ${now}` : null;
42
+ }
43
+ export function printPlan(plan, where, log) {
44
+ log(`[chreode] base=${where.base} head=${where.head}${where.full ? ' (--full)' : ''}`);
45
+ log(`[chreode] changed paths (${where.paths.length}):`);
46
+ for (const p of where.paths)
47
+ log(` ${p}`);
48
+ if (plan.unmatched.length > 0) {
49
+ log(`[chreode] matched no rule, so every trigger fired: ${plan.unmatched.join(', ')}`);
50
+ }
51
+ log(`[chreode] triggers: ${plan.triggers.length > 0 ? plan.triggers.join(', ') : '(none)'}`);
52
+ log(`[chreode] plan: ${plan.steps.length > 0 ? plan.steps.map((s) => s.label).join(', ') : '(no steps: a no-op signoff)'}`);
53
+ for (const { label, reason } of plan.skipped) {
54
+ log(`[chreode] skipping ${label} (${describeSkip(reason)})`);
55
+ }
56
+ }
57
+ /**
58
+ * `loadConfig` is called once up front and again after a merge that changed the branch, so
59
+ * a merge that brings in a new chreode.config.ts is planned and run under that config.
60
+ */
61
+ export async function runSignoff(loadConfig, opts, overrides = {}) {
62
+ let config = await loadConfig();
63
+ let d = { ...defaultDeps(config, opts), ...overrides };
64
+ const baseBranch = config.baseBranch;
65
+ // 1. Preconditions
66
+ let pre;
67
+ try {
68
+ pre = checkPreconditions(baseBranch, d.exec, { flakes: config.flakes !== null });
69
+ }
70
+ catch (err) {
71
+ if (err instanceof PreconditionError) {
72
+ d.error(`[chreode] ${err.message}`);
73
+ return 1;
74
+ }
75
+ throw err;
76
+ }
77
+ // 2. Merge
78
+ const merge = mergeBaseBranch(baseBranch, { noMerge: opts.noMerge }, d.exec, d.warn);
79
+ if (merge.kind === 'conflict') {
80
+ d.error(`\n[chreode] merging origin/${baseBranch} hit a conflict. The merge is in progress.\n` +
81
+ `Resolve it, run \`git commit --no-edit\`, then re-run signoff.\n` +
82
+ `To bail out: \`git merge --abort\`.`);
83
+ return 1;
84
+ }
85
+ if (merge.kind === 'failed') {
86
+ d.error(`[chreode] merging origin/${baseBranch} failed: ${merge.message}`);
87
+ return 1;
88
+ }
89
+ if (merge.kind === 'merged') {
90
+ d.log(`[chreode] merged origin/${baseBranch} into ${pre.branch}`);
91
+ if (config.afterMerge) {
92
+ d.log(`[chreode] afterMerge: ${config.afterMerge}`);
93
+ const before = d.exec('git', ['rev-parse', 'HEAD']).trim();
94
+ try {
95
+ d.run(config.afterMerge);
96
+ }
97
+ catch {
98
+ d.error('[chreode] afterMerge failed');
99
+ return 1;
100
+ }
101
+ const moved = checkoutChange(before, d.exec);
102
+ if (moved) {
103
+ d.error(`[chreode] afterMerge changed the checkout (${moved}); commit the change and re-run signoff.`);
104
+ return 1;
105
+ }
106
+ }
107
+ // The merge may have changed the config itself: plan under what is now checked out.
108
+ try {
109
+ config = await loadConfig();
110
+ }
111
+ catch (err) {
112
+ d.error(`[chreode] the config merged in from origin/${baseBranch} failed to load: ${message(err)}\n` +
113
+ `Fix it in a commit on this branch, then re-run signoff.`);
114
+ return 1;
115
+ }
116
+ d = { ...defaultDeps(config, opts), ...overrides };
117
+ }
118
+ // 3. Resolve base
119
+ const head = d.exec('git', ['rev-parse', 'HEAD']).trim();
120
+ let base;
121
+ let source;
122
+ try {
123
+ if (opts.base !== null) {
124
+ base = resolveRef(opts.base, d.exec);
125
+ source = 'flag';
126
+ }
127
+ else {
128
+ ({ base, source } = resolveBase(baseBranch, d.exec));
129
+ }
130
+ }
131
+ catch (err) {
132
+ d.error(`[chreode] could not resolve the base: ${message(err)}`);
133
+ return 1;
134
+ }
135
+ if (!opts.full && base === head) {
136
+ d.log(source === 'note'
137
+ ? `[chreode] already signed off at ${head}; nothing to do (--full re-runs everything)`
138
+ : `[chreode] nothing to sign off: ${base} is HEAD`);
139
+ return 0;
140
+ }
141
+ // 4. Plan
142
+ const paths = committedPaths(base, head, d.exec);
143
+ let plan;
144
+ try {
145
+ plan = planSignoff(config, {
146
+ paths,
147
+ full: opts.full,
148
+ skip: opts.skip,
149
+ base,
150
+ head,
151
+ isWorktree: isWorktree(d.exec)
152
+ });
153
+ }
154
+ catch (err) {
155
+ if (err instanceof PlanError) {
156
+ d.error(`[chreode] ${err.message}`);
157
+ return 1;
158
+ }
159
+ throw err;
160
+ }
161
+ printPlan(plan, { base, head, paths, full: opts.full }, d.log);
162
+ if (opts.dryRun) {
163
+ d.log('[chreode] --dry-run: stopping before the run');
164
+ return 0;
165
+ }
166
+ // 5. Run. Steps learn the sha under test and where reporters record flakes.
167
+ d = {
168
+ ...defaultDeps(config, opts, { CHREODE_HEAD: head, CHREODE_FLAKE_LOG: d.flakeLog }),
169
+ ...overrides
170
+ };
171
+ let allowedStderr = [];
172
+ let stepsFailed = false;
173
+ try {
174
+ allowedStderr = await runSteps(plan.steps, d.lock, d.runStep);
175
+ }
176
+ catch (err) {
177
+ if (!(err instanceof StepFailure))
178
+ d.error(`[chreode] ${message(err)}`);
179
+ stepsFailed = true;
180
+ }
181
+ // 6. Flakes: after the steps, passed or failed, so a flake is charged to the run that saw
182
+ // it. Network, not machine load: the lock is already released.
183
+ let escalated = false;
184
+ if (config.flakes !== null) {
185
+ const outcome = runFlakePhase({
186
+ logPath: d.flakeLog,
187
+ ctx: { branch: pre.branch, sha: head },
188
+ config: config.flakes,
189
+ ledger: d.ledger,
190
+ log: d.log,
191
+ warn: d.warn
192
+ });
193
+ const failure = escalationFailure(outcome, config.flakes);
194
+ if (failure !== undefined) {
195
+ d.error(failure);
196
+ escalated = true;
197
+ }
198
+ }
199
+ if (stepsFailed || escalated)
200
+ return 1;
201
+ // What was tested must be exactly HEAD.
202
+ const changed = checkoutChange(head, d.exec);
203
+ if (changed) {
204
+ d.error(`[chreode] the steps changed the checkout (${changed}); ` +
205
+ `commit the change and re-run signoff. Nothing was posted.`);
206
+ return 1;
207
+ }
208
+ // 7. Tail
209
+ try {
210
+ runTail({
211
+ sha: head,
212
+ signer: pre.signer,
213
+ noPush: opts.noPush,
214
+ ranLabels: plan.steps.map((s) => s.label),
215
+ skippedByFlag: plan.skippedByFlag,
216
+ allowedStderr,
217
+ baseFlag: opts.base,
218
+ context: config.statusContext
219
+ }, d.tail, d.log);
220
+ }
221
+ catch (err) {
222
+ d.error(`[chreode] ${message(err)}`);
223
+ return 1;
224
+ }
225
+ return 0;
226
+ }
@@ -0,0 +1,32 @@
1
+ import { type RepoInfo } from '../github/status.js';
2
+ import { type Exec, type Run } from '../run/exec.js';
3
+ export interface TailEffects {
4
+ push(): void;
5
+ postStatus(sha: string, description: string): void;
6
+ writeNote(sha: string, ranLabels: readonly string[]): void;
7
+ prUrl(): string | null;
8
+ }
9
+ export interface TailWork {
10
+ sha: string;
11
+ signer: string;
12
+ noPush: boolean;
13
+ ranLabels: readonly string[];
14
+ skippedByFlag: readonly string[];
15
+ /** Policed steps that passed only because --allow-stderr waived their stderr. */
16
+ allowedStderr: readonly string[];
17
+ /** The --base ref as given, or null when the base came from the last signoff. */
18
+ baseFlag: string | null;
19
+ context: string;
20
+ }
21
+ /** Why this run must not become an incremental base: the note means "everything the
22
+ * changes since the last note needed passed", and a partial run cannot vouch for that. */
23
+ export declare function noteWithheldReasons(work: TailWork): string[];
24
+ /**
25
+ * After every step passed: push, post the status, then write the note. The note is the
26
+ * incremental cache ("this commit is signed off"), so it goes last: a failed post must not
27
+ * leave a note that makes the next run a no-op with no status on GitHub. A partial run
28
+ * (--skip, --base, or stderr waived by --allow-stderr) posts a status that says so but
29
+ * writes no note, so the next signoff diffs from the last full one.
30
+ */
31
+ export declare function runTail(work: TailWork, effects: TailEffects, log?: (m: string) => void): void;
32
+ export declare function defaultTailEffects(repo: () => RepoInfo, context: string, exec?: Exec, run?: Run): TailEffects;
@@ -0,0 +1,55 @@
1
+ import { writeNote } from '../git/change-set.js';
2
+ import { postStatus, prUrl, statusDescription } from '../github/status.js';
3
+ import { defaultExec, defaultRun } from '../run/exec.js';
4
+ /** Why this run must not become an incremental base: the note means "everything the
5
+ * changes since the last note needed passed", and a partial run cannot vouch for that. */
6
+ export function noteWithheldReasons(work) {
7
+ return [
8
+ ...(work.skippedByFlag.length > 0 ? [`--skip ${work.skippedByFlag.join(', ')}`] : []),
9
+ ...(work.allowedStderr.length > 0
10
+ ? [`--allow-stderr waived ${work.allowedStderr.join(', ')}`]
11
+ : []),
12
+ ...(work.baseFlag !== null ? [`--base ${work.baseFlag}`] : [])
13
+ ];
14
+ }
15
+ /**
16
+ * After every step passed: push, post the status, then write the note. The note is the
17
+ * incremental cache ("this commit is signed off"), so it goes last: a failed post must not
18
+ * leave a note that makes the next run a no-op with no status on GitHub. A partial run
19
+ * (--skip, --base, or stderr waived by --allow-stderr) posts a status that says so but
20
+ * writes no note, so the next signoff diffs from the last full one.
21
+ */
22
+ export function runTail(work, effects, log = (m) => console.log(m)) {
23
+ if (work.noPush) {
24
+ log('[chreode] --no-push: not pushing (the commit must already be on origin)');
25
+ }
26
+ else {
27
+ log('\n[chreode] git push -u origin HEAD');
28
+ effects.push();
29
+ }
30
+ effects.postStatus(work.sha, statusDescription(work.signer, {
31
+ skipped: work.skippedByFlag,
32
+ allowedStderr: work.allowedStderr,
33
+ base: work.baseFlag
34
+ }));
35
+ log(`[chreode] ${work.context} = success posted on ${work.sha}`);
36
+ const withheld = noteWithheldReasons(work);
37
+ if (withheld.length === 0) {
38
+ effects.writeNote(work.sha, work.ranLabels);
39
+ }
40
+ else {
41
+ log(`[chreode] not writing the signoff note (partial run: ${withheld.join('; ')}); ` +
42
+ `the next signoff diffs from the last full one`);
43
+ }
44
+ const url = effects.prUrl();
45
+ if (url)
46
+ log(`[chreode] PR: ${url}`);
47
+ }
48
+ export function defaultTailEffects(repo, context, exec = defaultExec, run = defaultRun) {
49
+ return {
50
+ push: () => run('git push -u origin HEAD'),
51
+ postStatus: (sha, description) => postStatus(repo(), sha, context, description, exec),
52
+ writeNote: (sha, labels) => writeNote(sha, labels, exec),
53
+ prUrl: () => prUrl(exec)
54
+ };
55
+ }
package/package.json CHANGED
@@ -1,6 +1,94 @@
1
1
  {
2
2
  "name": "@mindlace/chreode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A deterministic, selective, unavoidable local-CI gate for agent-written code",
5
+ "keywords": [
6
+ "ci",
7
+ "signoff",
8
+ "git",
9
+ "github",
10
+ "agents",
11
+ "gate"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Ethan Fremen",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mindlace/chreode.git"
18
+ },
19
+ "homepage": "https://github.com/mindlace/chreode#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/mindlace/chreode/issues"
22
+ },
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.12"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "bin": {
34
+ "chreode": "./dist/cli.js"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "./reporters/playwright": {
42
+ "types": "./dist/reporters/playwright.d.ts",
43
+ "default": "./dist/reporters/playwright.js"
44
+ },
45
+ "./reporters/vitest": {
46
+ "types": "./dist/reporters/vitest.d.ts",
47
+ "default": "./dist/reporters/vitest.js"
48
+ }
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.build.json",
52
+ "prepack": "npm run build",
53
+ "typecheck": "tsc -p tsconfig.json",
54
+ "lint": "prettier --check . && eslint .",
55
+ "format": "prettier --write .",
56
+ "test": "vitest run",
57
+ "signoff": "npm run build && node dist/cli.js signoff",
58
+ "protect": "npm run build && node dist/cli.js protect"
59
+ },
60
+ "dependencies": {
61
+ "commander": "^15.0.0",
62
+ "jiti": "^2.7.0",
63
+ "picomatch": "^4.0.7",
64
+ "proper-lockfile": "^4.1.2"
65
+ },
66
+ "peerDependencies": {
67
+ "playwright": ">=1.40",
68
+ "vitest": ">=4.0.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "playwright": {
72
+ "optional": true
73
+ },
74
+ "vitest": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@eslint/js": "^10.0.1",
80
+ "@playwright/test": "^1.64.0",
81
+ "@types/node": "^24.0.0",
82
+ "@types/picomatch": "^4.0.3",
83
+ "@types/proper-lockfile": "^4.1.4",
84
+ "eslint": "^10.8.0",
85
+ "eslint-config-prettier": "^10.1.8",
86
+ "globals": "^17.7.0",
87
+ "playwright": "^1.64.0",
88
+ "prettier": "^3.9.6",
89
+ "tsx": "^4.23.1",
90
+ "typescript": "~6.0.3",
91
+ "typescript-eslint": "^8.65.0",
92
+ "vitest": "^4.1.10"
93
+ }
94
+ }