@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 +96 -0
- package/dist/burn-in/run.d.ts +18 -0
- package/dist/burn-in/run.js +71 -0
- package/dist/burn-in/select.d.ts +30 -0
- package/dist/burn-in/select.js +78 -0
- package/dist/burn-in/step.d.ts +10 -0
- package/dist/burn-in/step.js +14 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +19 -0
- package/dist/config/types.d.ts +43 -0
- package/dist/config/types.js +12 -0
- package/dist/config/validate.js +82 -8
- package/dist/flakes/gh.d.ts +5 -0
- package/dist/flakes/gh.js +74 -0
- package/dist/flakes/ledger.d.ts +53 -0
- package/dist/flakes/ledger.js +108 -0
- package/dist/flakes/log.d.ts +39 -0
- package/dist/flakes/log.js +89 -0
- package/dist/flakes/phase.d.ts +34 -0
- package/dist/flakes/phase.js +66 -0
- package/dist/flakes/report.d.ts +14 -0
- package/dist/flakes/report.js +45 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/reporters/options.d.ts +17 -0
- package/dist/reporters/options.js +18 -0
- package/dist/reporters/playwright.d.ts +15 -0
- package/dist/reporters/playwright.js +47 -0
- package/dist/reporters/vitest.d.ts +15 -0
- package/dist/reporters/vitest.js +47 -0
- package/dist/run/stderr.d.ts +1 -0
- package/dist/run/stderr.js +4 -1
- package/dist/signoff/plan.d.ts +2 -1
- package/dist/signoff/plan.js +4 -2
- package/dist/signoff/preconditions.d.ts +3 -1
- package/dist/signoff/preconditions.js +11 -1
- package/dist/signoff/run.d.ts +5 -0
- package/dist/signoff/run.js +39 -7
- package/package.json +23 -1
|
@@ -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;
|
package/dist/run/stderr.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AllowlistEntry } from '../config/types.js';
|
|
2
|
+
export declare function stripAnsi(s: string): string;
|
|
2
3
|
/** Classify captured stderr line by line: blank and allowlisted lines are benign,
|
|
3
4
|
* everything else is returned ANSI-stripped. */
|
|
4
5
|
export declare function policeStderr(buffer: string, allowlist: readonly AllowlistEntry[]): {
|
package/dist/run/stderr.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// ANSI SGR color codes must not defeat allowlist matching. The escape character is built at
|
|
2
2
|
// runtime so no control character appears in source (keeps no-control-regex quiet).
|
|
3
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
|
+
}
|
|
4
7
|
/** Classify captured stderr line by line: blank and allowlisted lines are benign,
|
|
5
8
|
* everything else is returned ANSI-stripped. */
|
|
6
9
|
export function policeStderr(buffer, allowlist) {
|
|
7
10
|
const offendingLines = buffer
|
|
8
11
|
.split(/\r?\n/)
|
|
9
|
-
.map(
|
|
12
|
+
.map(stripAnsi)
|
|
10
13
|
.filter((line) => line.trim() !== '')
|
|
11
14
|
.filter((line) => !allowlist.some((entry) => {
|
|
12
15
|
entry.pattern.lastIndex = 0;
|
package/dist/signoff/plan.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
|
|
1
|
+
import type { Matcher, ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
|
|
2
2
|
export declare class PlanError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
@@ -38,6 +38,7 @@ export interface Plan {
|
|
|
38
38
|
/** Steps that would have run but for --skip: these go in the status description. */
|
|
39
39
|
skippedByFlag: string[];
|
|
40
40
|
}
|
|
41
|
+
export declare function matchesAny(path: string, matchers: Matcher | Matcher[]): boolean;
|
|
41
42
|
export declare function matchRule(path: string, rules: readonly RuleConfig[]): RuleConfig | null;
|
|
42
43
|
export declare function allTriggers(steps: readonly ResolvedStep[]): string[];
|
|
43
44
|
/** Rules can only make a run smaller by matching: an unmatched path fires everything. */
|
package/dist/signoff/plan.js
CHANGED
|
@@ -14,10 +14,12 @@ function tester(m) {
|
|
|
14
14
|
return m.test(path);
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
export function matchesAny(path, matchers) {
|
|
18
|
+
return (Array.isArray(matchers) ? matchers : [matchers]).some((m) => tester(m)(path));
|
|
19
|
+
}
|
|
17
20
|
export function matchRule(path, rules) {
|
|
18
21
|
for (const rule of rules) {
|
|
19
|
-
|
|
20
|
-
if (matchers.some((m) => tester(m)(path)))
|
|
22
|
+
if (matchesAny(path, rule.match))
|
|
21
23
|
return rule;
|
|
22
24
|
}
|
|
23
25
|
return null;
|
|
@@ -7,4 +7,6 @@ export interface Preconditions {
|
|
|
7
7
|
/** git user.email: named in the status description. */
|
|
8
8
|
signer: string;
|
|
9
9
|
}
|
|
10
|
-
export declare function checkPreconditions(baseBranch: string, exec?: Exec
|
|
10
|
+
export declare function checkPreconditions(baseBranch: string, exec?: Exec, opts?: {
|
|
11
|
+
flakes: boolean;
|
|
12
|
+
}): Preconditions;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FLAKE_LOG_FILE } from '../flakes/log.js';
|
|
1
2
|
import { defaultExec } from '../run/exec.js';
|
|
2
3
|
export class PreconditionError extends Error {
|
|
3
4
|
constructor(message) {
|
|
@@ -5,7 +6,7 @@ export class PreconditionError extends Error {
|
|
|
5
6
|
this.name = 'PreconditionError';
|
|
6
7
|
}
|
|
7
8
|
}
|
|
8
|
-
export function checkPreconditions(baseBranch, exec = defaultExec) {
|
|
9
|
+
export function checkPreconditions(baseBranch, exec = defaultExec, opts = { flakes: false }) {
|
|
9
10
|
if (exec('git', ['status', '--porcelain']).trim() !== '') {
|
|
10
11
|
throw new PreconditionError('working tree is dirty; commit or stash before signing off');
|
|
11
12
|
}
|
|
@@ -29,6 +30,15 @@ export function checkPreconditions(baseBranch, exec = defaultExec) {
|
|
|
29
30
|
if (behind !== '') {
|
|
30
31
|
throw new PreconditionError('HEAD is behind its upstream; pull before signing off');
|
|
31
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
|
+
}
|
|
32
42
|
try {
|
|
33
43
|
exec('gh', ['auth', 'status']);
|
|
34
44
|
}
|
package/dist/signoff/run.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedConfig } from '../config/types.js';
|
|
2
|
+
import type { FlakeLedgerDeps } from '../flakes/ledger.js';
|
|
2
3
|
import { type MachineLock } from '../lock/machine-lock.js';
|
|
3
4
|
import { type Exec, type Run } from '../run/exec.js';
|
|
4
5
|
import { type StepOutcome } from '../run/step.js';
|
|
@@ -19,6 +20,10 @@ export interface SignoffDeps {
|
|
|
19
20
|
lock: MachineLock;
|
|
20
21
|
runStep: (step: PlannedStep) => Promise<StepOutcome>;
|
|
21
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;
|
|
22
27
|
log: (m: string) => void;
|
|
23
28
|
warn: (m: string) => void;
|
|
24
29
|
error: (m: string) => void;
|
package/dist/signoff/run.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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';
|
|
1
5
|
import { committedPaths, isWorktree, resolveBase, resolveRef } from '../git/change-set.js';
|
|
2
6
|
import { mergeBaseBranch } from '../git/merge.js';
|
|
3
7
|
import { repoInfo } from '../github/status.js';
|
|
@@ -7,9 +11,9 @@ import { StepFailure, runStep, runSteps } from '../run/step.js';
|
|
|
7
11
|
import { PlanError, describeSkip, planSignoff } from './plan.js';
|
|
8
12
|
import { PreconditionError, checkPreconditions } from './preconditions.js';
|
|
9
13
|
import { defaultTailEffects, runTail } from './tail.js';
|
|
10
|
-
function defaultDeps(config, opts) {
|
|
14
|
+
function defaultDeps(config, opts, stepEnv = {}) {
|
|
11
15
|
// Children can detect the gate (e.g. to quiet build-tool stderr) via CHREODE_SIGNOFF=1.
|
|
12
|
-
const env = { ...process.env, CHREODE_SIGNOFF: '1' };
|
|
16
|
+
const env = { ...process.env, CHREODE_SIGNOFF: '1', ...stepEnv };
|
|
13
17
|
let repo = null;
|
|
14
18
|
const getRepo = () => (repo ??= repoInfo(defaultExec));
|
|
15
19
|
return {
|
|
@@ -18,6 +22,9 @@ function defaultDeps(config, opts) {
|
|
|
18
22
|
lock: signoffLock(),
|
|
19
23
|
runStep: (step) => runStep(step, { allowStderr: opts.allowStderr, allowlist: config.stderrAllowlist, env }),
|
|
20
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),
|
|
21
28
|
log: (m) => console.log(m),
|
|
22
29
|
warn: (m) => console.warn(m),
|
|
23
30
|
error: (m) => console.error(m)
|
|
@@ -58,7 +65,7 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
|
|
|
58
65
|
// 1. Preconditions
|
|
59
66
|
let pre;
|
|
60
67
|
try {
|
|
61
|
-
pre = checkPreconditions(baseBranch, d.exec);
|
|
68
|
+
pre = checkPreconditions(baseBranch, d.exec, { flakes: config.flakes !== null });
|
|
62
69
|
}
|
|
63
70
|
catch (err) {
|
|
64
71
|
if (err instanceof PreconditionError) {
|
|
@@ -156,16 +163,41 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
|
|
|
156
163
|
d.log('[chreode] --dry-run: stopping before the run');
|
|
157
164
|
return 0;
|
|
158
165
|
}
|
|
159
|
-
// 5. Run
|
|
160
|
-
|
|
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;
|
|
161
173
|
try {
|
|
162
174
|
allowedStderr = await runSteps(plan.steps, d.lock, d.runStep);
|
|
163
175
|
}
|
|
164
176
|
catch (err) {
|
|
165
177
|
if (!(err instanceof StepFailure))
|
|
166
178
|
d.error(`[chreode] ${message(err)}`);
|
|
167
|
-
|
|
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
|
+
}
|
|
168
198
|
}
|
|
199
|
+
if (stepsFailed || escalated)
|
|
200
|
+
return 1;
|
|
169
201
|
// What was tested must be exactly HEAD.
|
|
170
202
|
const changed = checkoutChange(head, d.exec);
|
|
171
203
|
if (changed) {
|
|
@@ -173,7 +205,7 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
|
|
|
173
205
|
`commit the change and re-run signoff. Nothing was posted.`);
|
|
174
206
|
return 1;
|
|
175
207
|
}
|
|
176
|
-
//
|
|
208
|
+
// 7. Tail
|
|
177
209
|
try {
|
|
178
210
|
runTail({
|
|
179
211
|
sha: head,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindlace/chreode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A deterministic, selective, unavoidable local-CI gate for agent-written code",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ci",
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
".": {
|
|
38
38
|
"types": "./dist/index.d.ts",
|
|
39
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"
|
|
40
48
|
}
|
|
41
49
|
},
|
|
42
50
|
"scripts": {
|
|
@@ -55,14 +63,28 @@
|
|
|
55
63
|
"picomatch": "^4.0.7",
|
|
56
64
|
"proper-lockfile": "^4.1.2"
|
|
57
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
|
+
},
|
|
58
78
|
"devDependencies": {
|
|
59
79
|
"@eslint/js": "^10.0.1",
|
|
80
|
+
"@playwright/test": "^1.64.0",
|
|
60
81
|
"@types/node": "^24.0.0",
|
|
61
82
|
"@types/picomatch": "^4.0.3",
|
|
62
83
|
"@types/proper-lockfile": "^4.1.4",
|
|
63
84
|
"eslint": "^10.8.0",
|
|
64
85
|
"eslint-config-prettier": "^10.1.8",
|
|
65
86
|
"globals": "^17.7.0",
|
|
87
|
+
"playwright": "^1.64.0",
|
|
66
88
|
"prettier": "^3.9.6",
|
|
67
89
|
"tsx": "^4.23.1",
|
|
68
90
|
"typescript": "~6.0.3",
|