@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,76 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import lockfile from 'proper-lockfile';
4
+ // Steps hold gigabytes. Several agents in parallel worktrees running a suite at once swap
5
+ // the machine to death and produce spurious failures, and the contended resource is the
6
+ // machine, not the repo, so every chreode repo on the box shares ONE lock.
7
+ //
8
+ // A live holder refreshes the lock mtime every UPDATE; a holder that dies stops refreshing,
9
+ // so the lock goes stale after STALE and the next waiter steals it.
10
+ export const LOCK_STALE_MS = 30_000;
11
+ export const LOCK_UPDATE_MS = 10_000;
12
+ export const LOCK_POLL_MS = 2_000;
13
+ export const LOCK_MAX_WAIT_MS = 60 * 60_000;
14
+ /** Poll-until-free around a one-shot lock. All I/O injected so the retry/log/timeout logic
15
+ * is testable without the filesystem or real timers. */
16
+ export function createRetryingLock(deps) {
17
+ return {
18
+ async acquire() {
19
+ const deadline = deps.now() + deps.maxWaitMs;
20
+ let logged = false;
21
+ for (;;) {
22
+ try {
23
+ return await deps.tryLock();
24
+ }
25
+ catch (err) {
26
+ if (err.code !== 'ELOCKED')
27
+ throw err;
28
+ if (!logged) {
29
+ deps.log(`[chreode] another run holds the ${deps.label} lock; waiting…`);
30
+ logged = true;
31
+ }
32
+ if (deps.now() >= deadline) {
33
+ const hint = deps.hint ? ` ${deps.hint}` : '';
34
+ throw new Error(`[chreode] timed out after ${Math.round(deps.maxWaitMs / 60_000)}m waiting for ` +
35
+ `the ${deps.label} lock (${deps.lockPath}).${hint}`, { cause: err });
36
+ }
37
+ await deps.sleep(deps.pollMs);
38
+ }
39
+ }
40
+ }
41
+ };
42
+ }
43
+ /** proper-lockfile at a fixed path, with stale-steal. */
44
+ export function fileLock(opts) {
45
+ return createRetryingLock({
46
+ tryLock: () => lockfile.lock(opts.lockPath, {
47
+ realpath: false, // the target need not exist; the lock is the `${lockPath}.lock` dir
48
+ stale: opts.staleMs ?? LOCK_STALE_MS,
49
+ update: opts.updateMs ?? LOCK_UPDATE_MS,
50
+ retries: 0, // we own the retry loop so we can log once and bound the wait
51
+ // The default handler throws from a timer: a stalled mtime refresh (laptop sleep) would crash
52
+ // chreode uncaught and orphan the detached step group. Log and carry on instead.
53
+ onCompromised: (err) => console.error(`[chreode] lock compromised: ${err.message}`)
54
+ }),
55
+ label: opts.label,
56
+ lockPath: opts.lockPath,
57
+ pollMs: opts.pollMs ?? LOCK_POLL_MS,
58
+ maxWaitMs: opts.maxWaitMs ?? LOCK_MAX_WAIT_MS,
59
+ now: () => Date.now(),
60
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
61
+ log: opts.log ?? ((msg) => console.log(msg)),
62
+ hint: opts.hint
63
+ });
64
+ }
65
+ /** `CHREODE_LOCK_PATH` exists for test isolation: chreode's own suite runs `chreode signoff`
66
+ * while an outer signoff holds the real lock. */
67
+ export function defaultLockPath(env = process.env) {
68
+ return env.CHREODE_LOCK_PATH || path.join(os.tmpdir(), 'chreode-signoff.lock');
69
+ }
70
+ export function signoffLock(env = process.env) {
71
+ return fileLock({
72
+ label: 'signoff',
73
+ lockPath: defaultLockPath(env),
74
+ hint: 'The holder is another chreode signoff on this machine (any repo). Do not kill it; wait it out.'
75
+ });
76
+ }
@@ -0,0 +1,17 @@
1
+ import { type FlakeEvent } from '../flakes/log.js';
2
+ export interface FlakeReporterOptions {
3
+ /** Issue-title bucket: fixed, or derived from the runner project name. */
4
+ kind?: string | ((project: string) => string);
5
+ /** Record only these runner projects (default: all). */
6
+ projects?: string[];
7
+ /** Where events go (default: appended to the flake log). A seam for tests. */
8
+ append?: (event: FlakeEvent) => void;
9
+ }
10
+ export declare function resolveKind(kind: FlakeReporterOptions['kind'], project: string, fallback: string): string;
11
+ export declare function wants(projects: readonly string[] | undefined, project: string): boolean;
12
+ /** The sha under test when running under signoff. */
13
+ export declare function headSha(env?: NodeJS.ProcessEnv): {
14
+ sha?: string;
15
+ };
16
+ /** Resolved at write time, so CHREODE_FLAKE_LOG set by signoff is honoured. */
17
+ export declare function defaultAppend(event: FlakeEvent): void;
@@ -0,0 +1,18 @@
1
+ import { appendFlakeEvent, flakeLogPath } from '../flakes/log.js';
2
+ export function resolveKind(kind, project, fallback) {
3
+ if (typeof kind === 'function')
4
+ return kind(project);
5
+ return kind ?? fallback;
6
+ }
7
+ export function wants(projects, project) {
8
+ return projects === undefined || projects.includes(project);
9
+ }
10
+ /** The sha under test when running under signoff. */
11
+ export function headSha(env = process.env) {
12
+ const sha = env.CHREODE_HEAD;
13
+ return sha ? { sha } : {};
14
+ }
15
+ /** Resolved at write time, so CHREODE_FLAKE_LOG set by signoff is honoured. */
16
+ export function defaultAppend(event) {
17
+ appendFlakeEvent(event, flakeLogPath());
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { Reporter, TestCase, TestResult } from 'playwright/types/testReporter';
2
+ import { type FlakeReporterOptions } from './options.js';
3
+ /**
4
+ * Records every test that failed (or timed out) and then passed on a retry. Playwright loads
5
+ * reporters by module specifier, hence the default export. Imports Playwright's types only.
6
+ */
7
+ export default class PlaywrightFlakeReporter implements Reporter {
8
+ private readonly options;
9
+ private readonly append;
10
+ /** The first failed attempt of each test that has not passed yet. */
11
+ private readonly failed;
12
+ constructor(options?: FlakeReporterOptions);
13
+ onTestEnd(test: TestCase, result: TestResult): void;
14
+ printsToStdio(): boolean;
15
+ }
@@ -0,0 +1,47 @@
1
+ import { repoRelative, truncateError } from '../flakes/log.js';
2
+ import { defaultAppend, headSha, resolveKind, wants } from './options.js';
3
+ /**
4
+ * Records every test that failed (or timed out) and then passed on a retry. Playwright loads
5
+ * reporters by module specifier, hence the default export. Imports Playwright's types only.
6
+ */
7
+ export default class PlaywrightFlakeReporter {
8
+ options;
9
+ append;
10
+ /** The first failed attempt of each test that has not passed yet. */
11
+ failed = new Map();
12
+ constructor(options = {}) {
13
+ this.options = options;
14
+ this.append = options.append ?? defaultAppend;
15
+ }
16
+ onTestEnd(test, result) {
17
+ if (result.status === 'failed' || result.status === 'timedOut') {
18
+ if (!this.failed.has(test.id))
19
+ this.failed.set(test.id, result);
20
+ return;
21
+ }
22
+ const first = this.failed.get(test.id);
23
+ if (result.status !== 'passed' || result.retry === 0 || first === undefined)
24
+ return;
25
+ this.failed.delete(test.id);
26
+ // ['', project, file, ...describes, title]
27
+ const titlePath = test.titlePath();
28
+ const project = titlePath[1] ?? '';
29
+ if (!wants(this.options.projects, project))
30
+ return;
31
+ const trace = first.attachments.find((a) => a.name === 'trace')?.path;
32
+ this.append({
33
+ kind: resolveKind(this.options.kind, project, 'e2e'),
34
+ runner: 'playwright',
35
+ project,
36
+ file: repoRelative(test.location.file),
37
+ title: titlePath.slice(3).join(' › '),
38
+ firstAttemptError: truncateError(first.error?.message),
39
+ ...(trace ? { tracePath: trace } : {}),
40
+ ...headSha(),
41
+ at: new Date().toISOString()
42
+ });
43
+ }
44
+ printsToStdio() {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,15 @@
1
+ import type { Reporter, TestModule } from 'vitest/node';
2
+ import { type FlakeReporterOptions } from './options.js';
3
+ /**
4
+ * Records every test that passed after at least one retry. vitest reporters are a root-level
5
+ * option, so instantiate this in the root config's `reporters`; it sees every project and
6
+ * filters with `projects`. Imports vitest's types only.
7
+ */
8
+ export declare class VitestFlakeReporter implements Reporter {
9
+ private readonly options;
10
+ private readonly append;
11
+ constructor(options?: FlakeReporterOptions);
12
+ onTestRunEnd(testModules: ReadonlyArray<TestModule>): void;
13
+ private walk;
14
+ }
15
+ export default VitestFlakeReporter;
@@ -0,0 +1,47 @@
1
+ import { repoRelative, truncateError } from '../flakes/log.js';
2
+ import { defaultAppend, headSha, resolveKind, wants } from './options.js';
3
+ /**
4
+ * Records every test that passed after at least one retry. vitest reporters are a root-level
5
+ * option, so instantiate this in the root config's `reporters`; it sees every project and
6
+ * filters with `projects`. Imports vitest's types only.
7
+ */
8
+ export class VitestFlakeReporter {
9
+ options;
10
+ append;
11
+ constructor(options = {}) {
12
+ this.options = options;
13
+ this.append = options.append ?? defaultAppend;
14
+ }
15
+ onTestRunEnd(testModules) {
16
+ for (const testModule of testModules) {
17
+ const project = testModule.project.name;
18
+ if (!wants(this.options.projects, project))
19
+ continue;
20
+ const where = { project, file: repoRelative(testModule.moduleId) };
21
+ this.walk(testModule.children.array(), [], where);
22
+ }
23
+ }
24
+ walk(entities, titles, where) {
25
+ for (const entity of entities) {
26
+ if (entity.type === 'suite') {
27
+ this.walk(entity.children.array(), [...titles, entity.name], where);
28
+ continue;
29
+ }
30
+ const result = entity.result();
31
+ if (result.state !== 'passed' || (entity.diagnostic()?.retryCount ?? 0) === 0)
32
+ continue;
33
+ this.append({
34
+ kind: resolveKind(this.options.kind, where.project, where.project || 'unit'),
35
+ runner: 'vitest',
36
+ project: where.project,
37
+ file: where.file,
38
+ title: [...titles, entity.name].join(' › '),
39
+ // A test that passed on retry still reports the errors of its failed attempts.
40
+ firstAttemptError: truncateError(result.errors?.[0]?.message),
41
+ ...headSha(),
42
+ at: new Date().toISOString()
43
+ });
44
+ }
45
+ }
46
+ }
47
+ export default VitestFlakeReporter;
@@ -0,0 +1,8 @@
1
+ /** Run a program (no shell) and return its stdout. Throws on non-zero exit; the error
2
+ * message carries the program's stderr. */
3
+ export type Exec = (file: string, args: readonly string[]) => string;
4
+ export declare const defaultExec: Exec;
5
+ /** Run a shell command with inherited stdio (for long, chatty commands). */
6
+ export type Run = (command: string, env?: NodeJS.ProcessEnv) => void;
7
+ export declare const defaultRun: Run;
8
+ export declare function lines(out: string): string[];
@@ -0,0 +1,15 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ export const defaultExec = (file, args) => execFileSync(file, [...args], {
3
+ encoding: 'utf8',
4
+ stdio: ['ignore', 'pipe', 'pipe'],
5
+ maxBuffer: 64 * 1024 * 1024
6
+ });
7
+ export const defaultRun = (command, env) => {
8
+ execSync(command, { stdio: 'inherit', env: env ?? process.env });
9
+ };
10
+ export function lines(out) {
11
+ return out
12
+ .split('\n')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean);
15
+ }
@@ -0,0 +1,22 @@
1
+ import type { AllowlistEntry } from '../config/types.js';
2
+ export declare function stripAnsi(s: string): string;
3
+ /** Classify captured stderr line by line: blank and allowlisted lines are benign,
4
+ * everything else is returned ANSI-stripped. */
5
+ export declare function policeStderr(buffer: string, allowlist: readonly AllowlistEntry[]): {
6
+ offendingLines: string[];
7
+ };
8
+ export type PolicedOutcome = {
9
+ kind: 'pass';
10
+ } | {
11
+ kind: 'fail-exit';
12
+ rawStderr: string;
13
+ } | {
14
+ kind: 'fail-stderr';
15
+ offendingLines: string[];
16
+ };
17
+ /** A non-zero/null exit is a real failure (surface everything). A clean exit with
18
+ * non-allowlisted stderr is a policy failure (surface just the offending lines). */
19
+ export declare function decidePolicedOutcome(result: {
20
+ code: number | null;
21
+ stderr?: string;
22
+ }, allowlist: readonly AllowlistEntry[]): PolicedOutcome;
@@ -0,0 +1,30 @@
1
+ // ANSI SGR color codes must not defeat allowlist matching. The escape character is built at
2
+ // runtime so no control character appears in source (keeps no-control-regex quiet).
3
+ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
4
+ export function stripAnsi(s) {
5
+ return s.replace(ANSI_SGR, '');
6
+ }
7
+ /** Classify captured stderr line by line: blank and allowlisted lines are benign,
8
+ * everything else is returned ANSI-stripped. */
9
+ export function policeStderr(buffer, allowlist) {
10
+ const offendingLines = buffer
11
+ .split(/\r?\n/)
12
+ .map(stripAnsi)
13
+ .filter((line) => line.trim() !== '')
14
+ .filter((line) => !allowlist.some((entry) => {
15
+ entry.pattern.lastIndex = 0;
16
+ return entry.pattern.test(line);
17
+ }));
18
+ return { offendingLines };
19
+ }
20
+ /** A non-zero/null exit is a real failure (surface everything). A clean exit with
21
+ * non-allowlisted stderr is a policy failure (surface just the offending lines). */
22
+ export function decidePolicedOutcome(result, allowlist) {
23
+ const buffer = result.stderr ?? '';
24
+ if (result.code !== 0)
25
+ return { kind: 'fail-exit', rawStderr: buffer };
26
+ const { offendingLines } = policeStderr(buffer, allowlist);
27
+ if (offendingLines.length > 0)
28
+ return { kind: 'fail-stderr', offendingLines };
29
+ return { kind: 'pass' };
30
+ }
@@ -0,0 +1,55 @@
1
+ import type { AllowlistEntry } from '../config/types.js';
2
+ import type { MachineLock } from '../lock/machine-lock.js';
3
+ import type { PlannedStep } from '../signoff/plan.js';
4
+ export interface RunResult {
5
+ /** Exit code, or null when killed by a signal. */
6
+ code: number | null;
7
+ timedOut: boolean;
8
+ interrupted: boolean;
9
+ /** Present only when captureStderr was set. */
10
+ stderr?: string;
11
+ }
12
+ export interface SignalSource {
13
+ on(event: 'SIGINT' | 'SIGTERM', fn: () => void): unknown;
14
+ off(event: 'SIGINT' | 'SIGTERM', fn: () => void): unknown;
15
+ }
16
+ export interface RunOptions {
17
+ /** stdout stays live; stderr is piped, buffered and returned. */
18
+ captureStderr?: boolean;
19
+ env?: NodeJS.ProcessEnv;
20
+ cwd?: string;
21
+ /** Where SIGINT/SIGTERM arrive; defaults to the process. Injectable for tests. */
22
+ signals?: SignalSource;
23
+ }
24
+ /** After forwarding SIGINT/SIGTERM, how long a step gets to exit before its group is SIGKILLed. */
25
+ export declare const INTERRUPT_GRACE_MS = 3000;
26
+ /**
27
+ * Run a shell command with a hard wall-clock timeout. The command runs in its OWN process
28
+ * group (detached) so a timeout can SIGKILL the whole tree, not just the wrapping shell.
29
+ * Being in its own group, it no longer receives the terminal's Ctrl-C, so SIGINT/SIGTERM
30
+ * delivered to us are forwarded to the group. Never rejects.
31
+ */
32
+ export declare function runWithTimeout(cmd: string, timeoutMs: number, opts?: RunOptions): Promise<RunResult>;
33
+ export declare class StepFailure extends Error {
34
+ readonly label: string;
35
+ constructor(label: string, message: string);
36
+ }
37
+ export declare function timeoutMessage(label: string, timeoutMs: number): string;
38
+ export interface StepRunOptions {
39
+ allowStderr: boolean;
40
+ allowlist: readonly AllowlistEntry[];
41
+ env?: NodeJS.ProcessEnv;
42
+ log?: (m: string) => void;
43
+ error?: (m: string) => void;
44
+ writeStderr?: (s: string) => void;
45
+ runImpl?: (cmd: string, timeoutMs: number, opts: RunOptions) => Promise<RunResult>;
46
+ }
47
+ /** How a step passed: 'allowed-stderr' means it would have failed stderr policing but for
48
+ * --allow-stderr, which makes the run partial. */
49
+ export type StepOutcome = 'passed' | 'allowed-stderr';
50
+ /** Run one planned step. Every failure throws StepFailure so the caller's `finally`
51
+ * releases the lock before the run exits. */
52
+ export declare function runStep(step: PlannedStep, opts: StepRunOptions): Promise<StepOutcome>;
53
+ /** Run the plan holding the machine lock; an empty plan touches no lock. The lock is
54
+ * released on every exit path. Returns the labels whose stderr --allow-stderr waived. */
55
+ export declare function runSteps(steps: readonly PlannedStep[], lock: MachineLock, run: (step: PlannedStep) => Promise<StepOutcome>): Promise<string[]>;
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { decidePolicedOutcome } from './stderr.js';
3
+ /** After exit, how long to wait for a captured stderr pipe to drain. A grandchild that
4
+ * inherited the pipe can hold it open; this bounds the wait. */
5
+ const STDERR_DRAIN_MS = 2_000;
6
+ /** After forwarding SIGINT/SIGTERM, how long a step gets to exit before its group is SIGKILLed. */
7
+ export const INTERRUPT_GRACE_MS = 3_000;
8
+ /**
9
+ * Run a shell command with a hard wall-clock timeout. The command runs in its OWN process
10
+ * group (detached) so a timeout can SIGKILL the whole tree, not just the wrapping shell.
11
+ * Being in its own group, it no longer receives the terminal's Ctrl-C, so SIGINT/SIGTERM
12
+ * delivered to us are forwarded to the group. Never rejects.
13
+ */
14
+ export function runWithTimeout(cmd, timeoutMs, opts = {}) {
15
+ const capture = opts.captureStderr ?? false;
16
+ const signals = opts.signals ?? process;
17
+ return new Promise((resolve) => {
18
+ const child = spawn(cmd, {
19
+ shell: true,
20
+ stdio: capture ? ['inherit', 'inherit', 'pipe'] : 'inherit',
21
+ detached: true,
22
+ env: opts.env ?? process.env,
23
+ cwd: opts.cwd
24
+ });
25
+ let stderrBuf = '';
26
+ let stderrEnded = !capture || child.stderr === null;
27
+ let exitCode;
28
+ let timedOut = false;
29
+ let interrupted = false;
30
+ let settled = false;
31
+ const killGroup = (signal) => {
32
+ try {
33
+ if (child.pid !== undefined)
34
+ process.kill(-child.pid, signal);
35
+ }
36
+ catch {
37
+ // Group already gone.
38
+ }
39
+ };
40
+ let graceTimer;
41
+ const onSignal = () => {
42
+ if (interrupted) {
43
+ // A second Ctrl-C means "now".
44
+ killGroup('SIGKILL');
45
+ return;
46
+ }
47
+ interrupted = true;
48
+ killGroup('SIGTERM');
49
+ graceTimer = setTimeout(() => killGroup('SIGKILL'), INTERRUPT_GRACE_MS);
50
+ graceTimer.unref();
51
+ };
52
+ const timer = setTimeout(() => {
53
+ timedOut = true;
54
+ killGroup('SIGKILL');
55
+ }, timeoutMs);
56
+ signals.on('SIGINT', onSignal);
57
+ signals.on('SIGTERM', onSignal);
58
+ const finish = (code) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ clearTimeout(timer);
63
+ clearTimeout(graceTimer);
64
+ // Reap survivors of the leader's exit (e.g. TERM-ignoring grandchildren).
65
+ if (interrupted)
66
+ killGroup('SIGKILL');
67
+ signals.off('SIGINT', onSignal);
68
+ signals.off('SIGTERM', onSignal);
69
+ resolve({ code, timedOut, interrupted, stderr: capture ? stderrBuf : undefined });
70
+ };
71
+ if (capture && child.stderr) {
72
+ // setEncoding decodes through a StringDecoder, so a multi-byte character split across
73
+ // chunks is not corrupted.
74
+ child.stderr.setEncoding('utf8');
75
+ child.stderr.on('data', (chunk) => {
76
+ stderrBuf += chunk;
77
+ });
78
+ child.stderr.on('end', () => {
79
+ stderrEnded = true;
80
+ if (exitCode !== undefined)
81
+ finish(exitCode);
82
+ });
83
+ }
84
+ child.on('exit', (code) => {
85
+ exitCode = code;
86
+ if (stderrEnded)
87
+ finish(code);
88
+ else
89
+ setTimeout(() => finish(code), STDERR_DRAIN_MS).unref();
90
+ });
91
+ child.on('error', () => finish(1));
92
+ });
93
+ }
94
+ export class StepFailure extends Error {
95
+ label;
96
+ constructor(label, message) {
97
+ super(message);
98
+ this.label = label;
99
+ this.name = 'StepFailure';
100
+ }
101
+ }
102
+ function formatDuration(ms) {
103
+ return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
104
+ }
105
+ export function timeoutMessage(label, timeoutMs) {
106
+ return (`\n[chreode] TIMED OUT at: ${label} after ${formatDuration(timeoutMs)}. Suspect a ` +
107
+ `contending process or a genuinely slow machine. Killed the step's process tree.`);
108
+ }
109
+ /** Run one planned step. Every failure throws StepFailure so the caller's `finally`
110
+ * releases the lock before the run exits. */
111
+ export async function runStep(step, opts) {
112
+ const log = opts.log ?? ((m) => console.log(m));
113
+ const error = opts.error ?? ((m) => console.error(m));
114
+ const writeStderr = opts.writeStderr ?? ((s) => void process.stderr.write(s));
115
+ const runImpl = opts.runImpl ?? runWithTimeout;
116
+ log(`\n[chreode] ${step.label}: ${step.command}`);
117
+ const r = await runImpl(step.command, step.timeoutMs, {
118
+ captureStderr: step.policeStderr,
119
+ env: { ...(opts.env ?? process.env), CHREODE_SIGNOFF: '1' }
120
+ });
121
+ if (r.timedOut) {
122
+ if (r.stderr)
123
+ writeStderr(r.stderr);
124
+ error(timeoutMessage(step.label, step.timeoutMs));
125
+ throw new StepFailure(step.label, `${step.label} timed out`);
126
+ }
127
+ if (r.interrupted) {
128
+ if (r.stderr)
129
+ writeStderr(r.stderr);
130
+ error(`\n[chreode] INTERRUPTED at: ${step.label}`);
131
+ throw new StepFailure(step.label, `${step.label} interrupted`);
132
+ }
133
+ if (!step.policeStderr) {
134
+ if (r.code !== 0) {
135
+ error(`\n[chreode] FAILED at: ${step.label} (exit ${r.code})`);
136
+ throw new StepFailure(step.label, `${step.label} failed`);
137
+ }
138
+ return 'passed';
139
+ }
140
+ const outcome = decidePolicedOutcome({ code: r.code, stderr: r.stderr }, opts.allowlist);
141
+ if (outcome.kind === 'fail-exit') {
142
+ if (outcome.rawStderr)
143
+ writeStderr(outcome.rawStderr);
144
+ error(`\n[chreode] FAILED at: ${step.label} (exit ${r.code})`);
145
+ throw new StepFailure(step.label, `${step.label} failed`);
146
+ }
147
+ if (outcome.kind === 'fail-stderr') {
148
+ if (opts.allowStderr) {
149
+ error(`\n[chreode] ${step.label} wrote unexpected stderr, allowed by --allow-stderr:`);
150
+ for (const line of outcome.offendingLines)
151
+ writeStderr(`${line}\n`);
152
+ return 'allowed-stderr';
153
+ }
154
+ error(`\n[chreode] FAILED at: ${step.label}: it exited 0 but wrote unexpected stderr.\n` +
155
+ `[chreode] A passing step that logs errors is still a failure: fix it at the source.\n` +
156
+ `[chreode] Allowlisting genuinely benign noise is a reviewed change to stderrAllowlist\n` +
157
+ `[chreode] in chreode.config.ts, with a reason. --allow-stderr bypasses for one run.\n` +
158
+ `[chreode] Offending lines:`);
159
+ for (const line of outcome.offendingLines)
160
+ writeStderr(`${line}\n`);
161
+ throw new StepFailure(step.label, `${step.label} wrote unexpected stderr`);
162
+ }
163
+ return 'passed';
164
+ }
165
+ /** Run the plan holding the machine lock; an empty plan touches no lock. The lock is
166
+ * released on every exit path. Returns the labels whose stderr --allow-stderr waived. */
167
+ export async function runSteps(steps, lock, run) {
168
+ if (steps.length === 0)
169
+ return [];
170
+ const release = await lock.acquire();
171
+ const allowed = [];
172
+ try {
173
+ for (const step of steps) {
174
+ if ((await run(step)) === 'allowed-stderr')
175
+ allowed.push(step.label);
176
+ }
177
+ return allowed;
178
+ }
179
+ finally {
180
+ await release();
181
+ }
182
+ }
@@ -0,0 +1,50 @@
1
+ import type { Matcher, ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
2
+ export declare class PlanError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface PlanInput {
6
+ paths: readonly string[];
7
+ full: boolean;
8
+ skip: readonly string[];
9
+ base: string;
10
+ head: string;
11
+ isWorktree: boolean;
12
+ }
13
+ export interface PlannedStep {
14
+ label: string;
15
+ command: string;
16
+ policeStderr: boolean;
17
+ timeoutMs: number;
18
+ }
19
+ export type SkipReason = {
20
+ kind: 'not-triggered';
21
+ } | {
22
+ kind: 'if-false';
23
+ } | {
24
+ kind: 'skip-flag';
25
+ } | {
26
+ kind: 'superseded';
27
+ by: string;
28
+ };
29
+ export interface Plan {
30
+ triggers: string[];
31
+ /** Changed paths no rule matched; each one fired every trigger. */
32
+ unmatched: string[];
33
+ steps: PlannedStep[];
34
+ skipped: {
35
+ label: string;
36
+ reason: SkipReason;
37
+ }[];
38
+ /** Steps that would have run but for --skip: these go in the status description. */
39
+ skippedByFlag: string[];
40
+ }
41
+ export declare function matchesAny(path: string, matchers: Matcher | Matcher[]): boolean;
42
+ export declare function matchRule(path: string, rules: readonly RuleConfig[]): RuleConfig | null;
43
+ export declare function allTriggers(steps: readonly ResolvedStep[]): string[];
44
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything. */
45
+ export declare function fireTriggers(paths: readonly string[], rules: readonly RuleConfig[], steps: readonly ResolvedStep[]): {
46
+ triggers: string[];
47
+ unmatched: string[];
48
+ };
49
+ export declare function planSignoff(config: ResolvedConfig, input: PlanInput): Plan;
50
+ export declare function describeSkip(reason: SkipReason): string;