@mindlace/chreode 0.1.0 → 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.
package/README.md CHANGED
@@ -44,6 +44,8 @@ Add `chreode.config.ts` at the repo root (see below), then:
44
44
  npx chreode signoff # merge origin/main, run what changed, push, post the status
45
45
  npx chreode signoff --dry-run # merge, then print the plan and stop
46
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
47
49
  ```
48
50
 
49
51
  Wire them into `package.json` as `"signoff": "chreode signoff"` and
@@ -95,6 +97,96 @@ the repo to squash-only merges that delete head branches. Override either payloa
95
97
  `protection` in the config. `--repo owner/name` targets another repo; `--dry-run` prints the
96
98
  payloads.
97
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
+
98
190
  ## Config reference
99
191
 
100
192
  ```ts
@@ -149,6 +241,10 @@ exactly the lockfile, rather than `npm install`, which can rewrite it.
149
241
  **`stderrAllowlist`** entries need a `reason`: allowlisting noise is a reviewed decision.
150
242
  Unknown keys, duplicate labels, and `supersededBy` naming a missing step are load errors.
151
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
+
152
248
  ## License
153
249
 
154
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 CHANGED
@@ -16,11 +16,21 @@ export interface ProtectFlags {
16
16
  repo?: string;
17
17
  config?: string;
18
18
  }
19
+ export interface FlakeReportFlags {
20
+ config?: string;
21
+ }
22
+ export interface BurnInFlags {
23
+ base: string;
24
+ head: string;
25
+ config?: string;
26
+ }
19
27
  /** Each flag also has an env form, for harnesses that forbid flags after `--`. */
20
28
  export declare function resolveSignoffOptions(flags: SignoffFlags, env: NodeJS.ProcessEnv): SignoffOptions;
21
29
  export declare function buildProgram(actions: {
22
30
  signoff: (flags: SignoffFlags) => Promise<number>;
23
31
  protect: (flags: ProtectFlags) => Promise<number>;
32
+ flakeReport: (flags: FlakeReportFlags) => Promise<number>;
33
+ burnIn: (flags: BurnInFlags) => Promise<number>;
24
34
  }, setExit: (code: number) => void): Command;
25
35
  export declare function main(argv?: string[], env?: NodeJS.ProcessEnv): Promise<number>;
26
36
  /** npm installs the bin as a symlink, so compare real paths, not the invoked path. */
package/dist/cli.js CHANGED
@@ -3,7 +3,9 @@ import { readFileSync, realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { Command } from 'commander';
6
+ import { runBurnIn } from './burn-in/run.js';
6
7
  import { CONFIG_FILE, loadConfig } from './config/load.js';
8
+ import { runFlakeReport } from './flakes/report.js';
7
9
  import { parseRepo } from './github/status.js';
8
10
  import { runProtect } from './github/protect.js';
9
11
  import { defaultExec } from './run/exec.js';
@@ -50,6 +52,18 @@ export function buildProgram(actions, setExit) {
50
52
  .option('--repo <owner/name>', 'target repository (default: gh repo view)')
51
53
  .option('--config <path>', `config file (default: <repo root>/${CONFIG_FILE})`)
52
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)));
53
67
  return program;
54
68
  }
55
69
  /** Steps and git commands run from the repo root, whatever directory chreode was started in.
@@ -68,6 +82,11 @@ export async function main(argv = process.argv, env = process.env) {
68
82
  protect: async (flags) => runProtect(await repoConfigLoader(flags.config)(), {
69
83
  dryRun: flags.dryRun === true,
70
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
71
90
  })
72
91
  }, (c) => {
73
92
  code = c;
@@ -40,6 +40,39 @@ export interface ProtectionConfig {
40
40
  /** Shallow-merged over the default repository merge settings. */
41
41
  repo?: Record<string, unknown>;
42
42
  }
43
+ /** Presence turns flake tracking on: retried-then-passed tests become GitHub issues. */
44
+ export interface FlakesConfig {
45
+ /** Occurrences (the issue itself counts as one) at which an unsnoozed flake fails signoff. */
46
+ threshold?: number;
47
+ /** An issue carrying this label never escalates. */
48
+ snoozeLabel?: string;
49
+ /** Linked from every filed issue. */
50
+ playbookUrl?: string;
51
+ }
52
+ export interface BurnInRunner {
53
+ /** For output. */
54
+ name: string;
55
+ match: Matcher | Matcher[];
56
+ ignore?: Matcher | Matcher[];
57
+ /** A string runs once; an array runs each in sequence (for runners with no repeat flag). */
58
+ command: (files: string[], repeats: number) => string | string[];
59
+ }
60
+ export interface BurnInConfig {
61
+ repeats?: number;
62
+ /** Changed test files beyond this many are listed as skipped, never silently dropped. */
63
+ maxFiles?: number;
64
+ runners: BurnInRunner[];
65
+ }
66
+ export interface ResolvedFlakes {
67
+ threshold: number;
68
+ snoozeLabel: string;
69
+ playbookUrl: string;
70
+ }
71
+ export interface ResolvedBurnIn {
72
+ repeats: number;
73
+ maxFiles: number;
74
+ runners: BurnInRunner[];
75
+ }
43
76
  export interface ChreodeConfig {
44
77
  baseBranch?: string;
45
78
  statusContext?: string;
@@ -49,6 +82,8 @@ export interface ChreodeConfig {
49
82
  afterMerge?: string;
50
83
  stderrAllowlist?: AllowlistEntry[];
51
84
  protection?: ProtectionConfig;
85
+ flakes?: FlakesConfig;
86
+ burnIn?: BurnInConfig;
52
87
  }
53
88
  export interface ResolvedStep extends StepConfig {
54
89
  supersededBy: string[];
@@ -63,9 +98,17 @@ export interface ResolvedConfig {
63
98
  afterMerge: string | null;
64
99
  stderrAllowlist: AllowlistEntry[];
65
100
  protection: ProtectionConfig;
101
+ flakes: ResolvedFlakes | null;
102
+ burnIn: ResolvedBurnIn | null;
66
103
  }
67
104
  export declare const DEFAULT_BASE_BRANCH = "main";
68
105
  export declare const DEFAULT_STATUS_CONTEXT = "chreode/signoff";
69
106
  export declare const DEFAULT_TIMEOUT_MS: number;
107
+ export declare const DEFAULT_FLAKE_THRESHOLD = 3;
108
+ export declare const DEFAULT_SNOOZE_LABEL = "flake-snoozed";
109
+ export declare const DEFAULT_PLAYBOOK_URL = "https://github.com/mindlace/chreode/blob/main/docs/flakes.md";
110
+ export declare const DEFAULT_BURN_IN_REPEATS = 5;
111
+ export declare const DEFAULT_BURN_IN_MAX_FILES = 12;
112
+ export declare function resolveFlakes(config: FlakesConfig): ResolvedFlakes;
70
113
  /** Identity helper so `chreode.config.ts` gets types. */
71
114
  export declare function defineConfig(config: ChreodeConfig): ChreodeConfig;
@@ -1,6 +1,18 @@
1
1
  export const DEFAULT_BASE_BRANCH = 'main';
2
2
  export const DEFAULT_STATUS_CONTEXT = 'chreode/signoff';
3
3
  export const DEFAULT_TIMEOUT_MS = 10 * 60_000;
4
+ export const DEFAULT_FLAKE_THRESHOLD = 3;
5
+ export const DEFAULT_SNOOZE_LABEL = 'flake-snoozed';
6
+ export const DEFAULT_PLAYBOOK_URL = 'https://github.com/mindlace/chreode/blob/main/docs/flakes.md';
7
+ export const DEFAULT_BURN_IN_REPEATS = 5;
8
+ export const DEFAULT_BURN_IN_MAX_FILES = 12;
9
+ export function resolveFlakes(config) {
10
+ return {
11
+ threshold: config.threshold ?? DEFAULT_FLAKE_THRESHOLD,
12
+ snoozeLabel: config.snoozeLabel ?? DEFAULT_SNOOZE_LABEL,
13
+ playbookUrl: config.playbookUrl ?? DEFAULT_PLAYBOOK_URL
14
+ };
15
+ }
4
16
  /** Identity helper so `chreode.config.ts` gets types. */
5
17
  export function defineConfig(config) {
6
18
  return config;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_BASE_BRANCH, DEFAULT_STATUS_CONTEXT, DEFAULT_TIMEOUT_MS } from './types.js';
1
+ import { DEFAULT_BASE_BRANCH, DEFAULT_BURN_IN_MAX_FILES, DEFAULT_BURN_IN_REPEATS, DEFAULT_STATUS_CONTEXT, DEFAULT_TIMEOUT_MS, resolveFlakes } from './types.js';
2
2
  export class ConfigError extends Error {
3
3
  problems;
4
4
  constructor(problems) {
@@ -14,7 +14,9 @@ const TOP_KEYS = new Set([
14
14
  'steps',
15
15
  'afterMerge',
16
16
  'stderrAllowlist',
17
- 'protection'
17
+ 'protection',
18
+ 'flakes',
19
+ 'burnIn'
18
20
  ]);
19
21
  const RULE_KEYS = new Set(['match', 'triggers']);
20
22
  const STEP_KEYS = new Set([
@@ -28,6 +30,10 @@ const STEP_KEYS = new Set([
28
30
  ]);
29
31
  const ALLOW_KEYS = new Set(['pattern', 'reason']);
30
32
  const PROTECTION_KEYS = new Set(['branch', 'repo']);
33
+ const FLAKES_KEYS = new Set(['threshold', 'snoozeLabel', 'playbookUrl']);
34
+ const BURN_IN_KEYS = new Set(['repeats', 'maxFiles', 'runners']);
35
+ const RUNNER_KEYS = new Set(['name', 'match', 'ignore', 'command']);
36
+ const MATCHER_PROBLEM = 'must be a glob string, a RegExp, or a non-empty array of them';
31
37
  /** Cross-realm safe: a config evaluated by jiti may not share this realm's RegExp. */
32
38
  export function isRegExp(v) {
33
39
  return Object.prototype.toString.call(v) === '[object RegExp]';
@@ -44,6 +50,12 @@ function isStringArray(v) {
44
50
  function isMatcher(v) {
45
51
  return isNonEmptyString(v) || isRegExp(v);
46
52
  }
53
+ function isMatcherList(v) {
54
+ return Array.isArray(v) ? v.length > 0 && v.every(isMatcher) : isMatcher(v);
55
+ }
56
+ function isPositiveInteger(v) {
57
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
58
+ }
47
59
  function unknownKeys(obj, allowed, where, problems) {
48
60
  for (const key of Object.keys(obj)) {
49
61
  if (!allowed.has(key))
@@ -64,11 +76,8 @@ function validateRules(raw, problems) {
64
76
  return;
65
77
  }
66
78
  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
- }
79
+ if (!isMatcherList(rule.match))
80
+ problems.push(`${where}.match: ${MATCHER_PROBLEM}`);
72
81
  if (!Array.isArray(rule.triggers) || !rule.triggers.every(isNonEmptyString)) {
73
82
  problems.push(`${where}.triggers: must be an array of trigger names ([] means "matched, run nothing")`);
74
83
  }
@@ -173,6 +182,67 @@ function validateProtection(raw, problems) {
173
182
  }
174
183
  return raw;
175
184
  }
185
+ function validateFlakes(raw, problems) {
186
+ if (raw === undefined)
187
+ return null;
188
+ if (!isRecord(raw)) {
189
+ problems.push('config.flakes: must be an object ({} turns flake tracking on with the defaults)');
190
+ return null;
191
+ }
192
+ unknownKeys(raw, FLAKES_KEYS, 'flakes', problems);
193
+ if (raw.threshold !== undefined && !isPositiveInteger(raw.threshold)) {
194
+ problems.push('flakes.threshold: must be a positive integer');
195
+ }
196
+ for (const key of ['snoozeLabel', 'playbookUrl']) {
197
+ if (raw[key] !== undefined && !isNonEmptyString(raw[key])) {
198
+ problems.push(`flakes.${key}: must be a non-empty string`);
199
+ }
200
+ }
201
+ return resolveFlakes(raw);
202
+ }
203
+ function validateBurnIn(raw, problems) {
204
+ if (raw === undefined)
205
+ return null;
206
+ if (!isRecord(raw)) {
207
+ problems.push('config.burnIn: must be an object');
208
+ return null;
209
+ }
210
+ unknownKeys(raw, BURN_IN_KEYS, 'burnIn', problems);
211
+ for (const key of ['repeats', 'maxFiles']) {
212
+ if (raw[key] !== undefined && !isPositiveInteger(raw[key])) {
213
+ problems.push(`burnIn.${key}: must be a positive integer`);
214
+ }
215
+ }
216
+ const runners = raw.runners;
217
+ if (!Array.isArray(runners) || runners.length === 0) {
218
+ problems.push('burnIn.runners: must be a non-empty array');
219
+ }
220
+ else {
221
+ runners.forEach((runner, i) => {
222
+ const where = `burnIn.runners[${i}]`;
223
+ if (!isRecord(runner)) {
224
+ problems.push(`${where}: must be an object`);
225
+ return;
226
+ }
227
+ unknownKeys(runner, RUNNER_KEYS, where, problems);
228
+ if (!isNonEmptyString(runner.name))
229
+ problems.push(`${where}.name: must be a non-empty string`);
230
+ if (!isMatcherList(runner.match))
231
+ problems.push(`${where}.match: ${MATCHER_PROBLEM}`);
232
+ if (runner.ignore !== undefined && !isMatcherList(runner.ignore)) {
233
+ problems.push(`${where}.ignore: ${MATCHER_PROBLEM}`);
234
+ }
235
+ if (typeof runner.command !== 'function') {
236
+ problems.push(`${where}.command: must be a function (files, repeats) => string | string[]`);
237
+ }
238
+ });
239
+ }
240
+ return {
241
+ repeats: raw.repeats ?? DEFAULT_BURN_IN_REPEATS,
242
+ maxFiles: raw.maxFiles ?? DEFAULT_BURN_IN_MAX_FILES,
243
+ runners: Array.isArray(runners) ? runners : []
244
+ };
245
+ }
176
246
  export function validateConfig(raw) {
177
247
  if (!isRecord(raw)) {
178
248
  throw new ConfigError([
@@ -190,6 +260,8 @@ export function validateConfig(raw) {
190
260
  const steps = validateSteps(raw.steps, problems);
191
261
  const stderrAllowlist = validateAllowlist(raw.stderrAllowlist, problems);
192
262
  const protection = validateProtection(raw.protection, problems);
263
+ const flakes = validateFlakes(raw.flakes, problems);
264
+ const burnIn = validateBurnIn(raw.burnIn, problems);
193
265
  if (problems.length > 0)
194
266
  throw new ConfigError(problems);
195
267
  return {
@@ -199,6 +271,8 @@ export function validateConfig(raw) {
199
271
  steps,
200
272
  afterMerge: raw.afterMerge ?? null,
201
273
  stderrAllowlist,
202
- protection
274
+ protection,
275
+ flakes,
276
+ burnIn
203
277
  };
204
278
  }
@@ -0,0 +1,5 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ import { type FlakeLedgerDeps } from './ledger.js';
3
+ /** The ledger's GitHub side over `gh`. Every call is argv (no shell), so test titles and
4
+ * error text reach GitHub verbatim. */
5
+ export declare function ghLedgerDeps(exec?: Exec): FlakeLedgerDeps;