@mindlace/chreode 0.2.0 → 0.3.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 +23 -3
- package/dist/config/types.d.ts +4 -0
- package/dist/config/validate.js +12 -0
- package/dist/git/version-only.d.ts +11 -0
- package/dist/git/version-only.js +56 -0
- package/dist/signoff/plan.d.ts +15 -2
- package/dist/signoff/plan.js +24 -9
- package/dist/signoff/run.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,8 +61,9 @@ Wire them into `package.json` as `"signoff": "chreode signoff"` and
|
|
|
61
61
|
3. **Base**: `--base`, else the nearest commit on the branch carrying a `refs/notes/chreode`
|
|
62
62
|
note (the last successful signoff), else the merge-base with `origin/<baseBranch>`.
|
|
63
63
|
4. **Plan**: changed paths (both sides of renames, deletions included) → triggers (first
|
|
64
|
-
matching rule; a path no rule matches fires every trigger
|
|
65
|
-
|
|
64
|
+
matching rule; a path no rule matches fires every trigger, unless it is a version-only
|
|
65
|
+
`package.json` / `package-lock.json` bump, which fires `versionBumpTriggers`) → steps
|
|
66
|
+
(`when`, `if`, `--skip`, `supersededBy`). The plan and every skipped step's reason are printed.
|
|
66
67
|
5. **Run** the steps in order under one machine-wide lock shared by every chreode repo on the
|
|
67
68
|
machine. Each step runs in its own process group with a hard timeout; policed steps fail
|
|
68
69
|
on unexpected stderr. Children see `CHREODE_SIGNOFF=1`.
|
|
@@ -119,7 +120,7 @@ export default defineConfig({
|
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
```ts
|
|
122
|
-
// vitest.config.ts (reporters are root-level only)
|
|
123
|
+
// vitest.config.ts (reporters are root-level only; keep 'default' explicit, see below)
|
|
123
124
|
import VitestFlakeReporter from '@mindlace/chreode/reporters/vitest';
|
|
124
125
|
|
|
125
126
|
export default defineConfig({
|
|
@@ -200,6 +201,7 @@ export default defineConfig({
|
|
|
200
201
|
{ match: 'src/**', triggers: ['lint', 'check', 'unit', 'e2e'] },
|
|
201
202
|
{ match: 'generated/**', triggers: [] } // matched, nothing to run
|
|
202
203
|
],
|
|
204
|
+
versionBumpTriggers: ['format'], // default []: see below
|
|
203
205
|
steps: [
|
|
204
206
|
{ label: 'format', run: 'npx prettier --check .', when: ['format'], supersededBy: ['lint'] },
|
|
205
207
|
{ label: 'lint', run: 'npm run lint', when: ['lint'] },
|
|
@@ -223,6 +225,15 @@ glob, dotfiles included, or a RegExp, or an array of them) matches a changed pat
|
|
|
223
225
|
triggers. A path no rule matches fires every trigger: rules can only make runs smaller, so a
|
|
224
226
|
forgotten rule costs time, never coverage. Triggers are free-form strings.
|
|
225
227
|
|
|
228
|
+
**Version bumps** are the one exception. A `package.json` or `package-lock.json` (at any depth)
|
|
229
|
+
that matches no rule, exists on both sides, and whose only change is its version (for the
|
|
230
|
+
lockfile, the top-level `version` and `packages[""].version`; key order and formatting are
|
|
231
|
+
ignored) fires `versionBumpTriggers` instead of every trigger. The default is `[]`, so a
|
|
232
|
+
release commit that only bumps the version runs nothing. The plan prints such paths as
|
|
233
|
+
`version-only: package.json`. A rule that matches the file still wins, any other change to
|
|
234
|
+
it (or an added, deleted or unparseable file) fires everything as before, and `--full`
|
|
235
|
+
ignores the distinction.
|
|
236
|
+
|
|
226
237
|
**Steps** run in config order.
|
|
227
238
|
|
|
228
239
|
| Field | Type | Meaning |
|
|
@@ -239,6 +250,15 @@ forgotten rule costs time, never coverage. Triggers are free-form strings.
|
|
|
239
250
|
leave the checkout clean (signoff refuses to run otherwise), so use `npm ci`, which installs
|
|
240
251
|
exactly the lockfile, rather than `npm install`, which can rewrite it.
|
|
241
252
|
**`stderrAllowlist`** entries need a `reason`: allowlisting noise is a reviewed decision.
|
|
253
|
+
|
|
254
|
+
**Pin vitest's `reporters`** in any project whose test steps police stderr. With no
|
|
255
|
+
`reporters` set, vitest picks its `agent` reporter whenever it detects an AI agent (`AI_AGENT`,
|
|
256
|
+
`CLAUDECODE`, `CODEX_*` and others, via std-env), and that reporter hides console output from
|
|
257
|
+
passing tests. Signoff would then pass under an agent and fail for a human on the same commit.
|
|
258
|
+
An explicit `reporters: ['default']` (plus `VitestFlakeReporter` when flakes is on) makes every
|
|
259
|
+
run show that output, so a stray `console.error` fails signoff whoever runs it. Assert expected
|
|
260
|
+
logs in the test (`vi.spyOn(console, 'error')`) rather than allowlisting them.
|
|
261
|
+
|
|
242
262
|
Unknown keys, duplicate labels, and `supersededBy` naming a missing step are load errors.
|
|
243
263
|
|
|
244
264
|
**`flakes`** (presence turns flake tracking on) and **`burnIn`** are described under
|
package/dist/config/types.d.ts
CHANGED
|
@@ -77,6 +77,9 @@ export interface ChreodeConfig {
|
|
|
77
77
|
baseBranch?: string;
|
|
78
78
|
statusContext?: string;
|
|
79
79
|
rules?: RuleConfig[];
|
|
80
|
+
/** Fired, instead of every trigger, by a package.json / package-lock.json that matched no
|
|
81
|
+
* rule and whose only change is its version. Default [] (a version bump runs nothing). */
|
|
82
|
+
versionBumpTriggers?: string[];
|
|
80
83
|
steps: StepConfig[];
|
|
81
84
|
/** Runs only when the merge of origin/<baseBranch> actually changed the branch. */
|
|
82
85
|
afterMerge?: string;
|
|
@@ -94,6 +97,7 @@ export interface ResolvedConfig {
|
|
|
94
97
|
baseBranch: string;
|
|
95
98
|
statusContext: string;
|
|
96
99
|
rules: RuleConfig[];
|
|
100
|
+
versionBumpTriggers: string[];
|
|
97
101
|
steps: ResolvedStep[];
|
|
98
102
|
afterMerge: string | null;
|
|
99
103
|
stderrAllowlist: AllowlistEntry[];
|
package/dist/config/validate.js
CHANGED
|
@@ -11,6 +11,7 @@ const TOP_KEYS = new Set([
|
|
|
11
11
|
'baseBranch',
|
|
12
12
|
'statusContext',
|
|
13
13
|
'rules',
|
|
14
|
+
'versionBumpTriggers',
|
|
14
15
|
'steps',
|
|
15
16
|
'afterMerge',
|
|
16
17
|
'stderrAllowlist',
|
|
@@ -84,6 +85,15 @@ function validateRules(raw, problems) {
|
|
|
84
85
|
});
|
|
85
86
|
return raw;
|
|
86
87
|
}
|
|
88
|
+
function validateVersionBumpTriggers(raw, problems) {
|
|
89
|
+
if (raw === undefined)
|
|
90
|
+
return [];
|
|
91
|
+
if (!Array.isArray(raw) || !raw.every(isNonEmptyString)) {
|
|
92
|
+
problems.push('config.versionBumpTriggers: must be an array of trigger names ([] runs nothing)');
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
87
97
|
function validateSteps(raw, problems) {
|
|
88
98
|
if (!Array.isArray(raw)) {
|
|
89
99
|
problems.push('config.steps: must be an array');
|
|
@@ -257,6 +267,7 @@ export function validateConfig(raw) {
|
|
|
257
267
|
}
|
|
258
268
|
}
|
|
259
269
|
const rules = validateRules(raw.rules, problems);
|
|
270
|
+
const versionBumpTriggers = validateVersionBumpTriggers(raw.versionBumpTriggers, problems);
|
|
260
271
|
const steps = validateSteps(raw.steps, problems);
|
|
261
272
|
const stderrAllowlist = validateAllowlist(raw.stderrAllowlist, problems);
|
|
262
273
|
const protection = validateProtection(raw.protection, problems);
|
|
@@ -268,6 +279,7 @@ export function validateConfig(raw) {
|
|
|
268
279
|
baseBranch: raw.baseBranch ?? DEFAULT_BASE_BRANCH,
|
|
269
280
|
statusContext: raw.statusContext ?? DEFAULT_STATUS_CONTEXT,
|
|
270
281
|
rules,
|
|
282
|
+
versionBumpTriggers,
|
|
271
283
|
steps,
|
|
272
284
|
afterMerge: raw.afterMerge ?? null,
|
|
273
285
|
stderrAllowlist,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Exec } from '../run/exec.js';
|
|
2
|
+
/**
|
|
3
|
+
* True when `path` is a package.json or package-lock.json (at any depth) present on both
|
|
4
|
+
* sides whose only difference between base and head is its version field(s) (for the
|
|
5
|
+
* lockfile, the top-level `version` and `packages[""].version`). Anything else, including
|
|
6
|
+
* an added or deleted file or unparseable JSON, is false: the caller falls back to treating
|
|
7
|
+
* the path as an ordinary change.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isVersionOnlyChange(base: string, head: string, path: string, exec?: Exec): boolean;
|
|
10
|
+
/** The subset of `paths` that are version-only changes between base and head. */
|
|
11
|
+
export declare function versionOnlyPaths(base: string, head: string, paths: readonly string[], exec?: Exec): string[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { defaultExec } from '../run/exec.js';
|
|
4
|
+
function isObject(v) {
|
|
5
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
}
|
|
7
|
+
/** Each version-bearing file: the version fields it carries, read and blanked. */
|
|
8
|
+
const VERSIONED = {
|
|
9
|
+
'package.json': (doc) => {
|
|
10
|
+
const { version, ...rest } = doc;
|
|
11
|
+
return { versions: [version], rest };
|
|
12
|
+
},
|
|
13
|
+
'package-lock.json': (doc) => {
|
|
14
|
+
const { version, ...rest } = doc;
|
|
15
|
+
const packages = rest.packages;
|
|
16
|
+
if (!isObject(packages) || !isObject(packages['']))
|
|
17
|
+
return { versions: [version], rest };
|
|
18
|
+
const { version: rootVersion, ...root } = packages[''];
|
|
19
|
+
return {
|
|
20
|
+
versions: [version, rootVersion],
|
|
21
|
+
rest: { ...rest, packages: { ...packages, '': root } }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function readJson(rev, path, exec) {
|
|
26
|
+
try {
|
|
27
|
+
const doc = JSON.parse(exec('git', ['show', `${rev}:${path}`]));
|
|
28
|
+
return isObject(doc) ? doc : null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* True when `path` is a package.json or package-lock.json (at any depth) present on both
|
|
36
|
+
* sides whose only difference between base and head is its version field(s) (for the
|
|
37
|
+
* lockfile, the top-level `version` and `packages[""].version`). Anything else, including
|
|
38
|
+
* an added or deleted file or unparseable JSON, is false: the caller falls back to treating
|
|
39
|
+
* the path as an ordinary change.
|
|
40
|
+
*/
|
|
41
|
+
export function isVersionOnlyChange(base, head, path, exec = defaultExec) {
|
|
42
|
+
const strip = VERSIONED[posix.basename(path)];
|
|
43
|
+
if (strip === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
const before = readJson(base, path, exec);
|
|
46
|
+
const after = readJson(head, path, exec);
|
|
47
|
+
if (before === null || after === null)
|
|
48
|
+
return false;
|
|
49
|
+
const a = strip(before);
|
|
50
|
+
const b = strip(after);
|
|
51
|
+
return isDeepStrictEqual(a.rest, b.rest) && !isDeepStrictEqual(a.versions, b.versions);
|
|
52
|
+
}
|
|
53
|
+
/** The subset of `paths` that are version-only changes between base and head. */
|
|
54
|
+
export function versionOnlyPaths(base, head, paths, exec = defaultExec) {
|
|
55
|
+
return paths.filter((p) => isVersionOnlyChange(base, head, p, exec));
|
|
56
|
+
}
|
package/dist/signoff/plan.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface PlanInput {
|
|
|
9
9
|
base: string;
|
|
10
10
|
head: string;
|
|
11
11
|
isWorktree: boolean;
|
|
12
|
+
/** Changed package.json / package-lock.json paths whose only change is the version. */
|
|
13
|
+
versionOnly: readonly string[];
|
|
12
14
|
}
|
|
13
15
|
export interface PlannedStep {
|
|
14
16
|
label: string;
|
|
@@ -30,6 +32,9 @@ export interface Plan {
|
|
|
30
32
|
triggers: string[];
|
|
31
33
|
/** Changed paths no rule matched; each one fired every trigger. */
|
|
32
34
|
unmatched: string[];
|
|
35
|
+
/** Changed paths no rule matched that were version-only bumps; each fired only the
|
|
36
|
+
* config's versionBumpTriggers. */
|
|
37
|
+
versionOnly: string[];
|
|
33
38
|
steps: PlannedStep[];
|
|
34
39
|
skipped: {
|
|
35
40
|
label: string;
|
|
@@ -41,10 +46,18 @@ export interface Plan {
|
|
|
41
46
|
export declare function matchesAny(path: string, matchers: Matcher | Matcher[]): boolean;
|
|
42
47
|
export declare function matchRule(path: string, rules: readonly RuleConfig[]): RuleConfig | null;
|
|
43
48
|
export declare function allTriggers(steps: readonly ResolvedStep[]): string[];
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
export interface VersionBumps {
|
|
50
|
+
/** Paths whose only change is a version bump. */
|
|
51
|
+
versionOnly: readonly string[];
|
|
52
|
+
/** What such a path fires when no rule matched it. */
|
|
53
|
+
versionBumpTriggers: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
/** Rules can only make a run smaller by matching: an unmatched path fires everything,
|
|
56
|
+
* unless it is a version-only bump, which fires just the versionBumpTriggers. */
|
|
57
|
+
export declare function fireTriggers(paths: readonly string[], rules: readonly RuleConfig[], steps: readonly ResolvedStep[], bumps?: VersionBumps): {
|
|
46
58
|
triggers: string[];
|
|
47
59
|
unmatched: string[];
|
|
60
|
+
versionOnly: string[];
|
|
48
61
|
};
|
|
49
62
|
export declare function planSignoff(config: ResolvedConfig, input: PlanInput): Plan;
|
|
50
63
|
export declare function describeSkip(reason: SkipReason): string;
|
package/dist/signoff/plan.js
CHANGED
|
@@ -27,19 +27,31 @@ export function matchRule(path, rules) {
|
|
|
27
27
|
export function allTriggers(steps) {
|
|
28
28
|
return [...new Set(steps.flatMap((s) => s.when))].sort();
|
|
29
29
|
}
|
|
30
|
-
/** Rules can only make a run smaller by matching: an unmatched path fires everything
|
|
31
|
-
|
|
30
|
+
/** Rules can only make a run smaller by matching: an unmatched path fires everything,
|
|
31
|
+
* unless it is a version-only bump, which fires just the versionBumpTriggers. */
|
|
32
|
+
export function fireTriggers(paths, rules, steps, bumps = { versionOnly: [], versionBumpTriggers: [] }) {
|
|
32
33
|
const fired = new Set();
|
|
33
34
|
const unmatched = [];
|
|
35
|
+
const versionOnly = [];
|
|
36
|
+
const bumped = new Set(bumps.versionOnly);
|
|
34
37
|
for (const path of paths) {
|
|
35
38
|
const rule = matchRule(path, rules);
|
|
36
|
-
|
|
37
|
-
if (rule
|
|
39
|
+
let triggers;
|
|
40
|
+
if (rule !== null) {
|
|
41
|
+
triggers = rule.triggers;
|
|
42
|
+
}
|
|
43
|
+
else if (bumped.has(path)) {
|
|
44
|
+
versionOnly.push(path);
|
|
45
|
+
triggers = bumps.versionBumpTriggers;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
38
48
|
unmatched.push(path);
|
|
49
|
+
triggers = allTriggers(steps);
|
|
50
|
+
}
|
|
39
51
|
for (const t of triggers)
|
|
40
52
|
fired.add(t);
|
|
41
53
|
}
|
|
42
|
-
return { triggers: [...fired].sort(), unmatched };
|
|
54
|
+
return { triggers: [...fired].sort(), unmatched, versionOnly };
|
|
43
55
|
}
|
|
44
56
|
export function planSignoff(config, input) {
|
|
45
57
|
const known = config.steps.map((s) => s.label);
|
|
@@ -47,9 +59,12 @@ export function planSignoff(config, input) {
|
|
|
47
59
|
if (unknownSkips.length > 0) {
|
|
48
60
|
throw new PlanError(`--skip names unknown step(s): ${unknownSkips.join(', ')} (known: ${known.join(', ')})`);
|
|
49
61
|
}
|
|
50
|
-
const { triggers, unmatched } = input.full
|
|
51
|
-
? { triggers: allTriggers(config.steps), unmatched: [] }
|
|
52
|
-
: fireTriggers(input.paths, config.rules, config.steps
|
|
62
|
+
const { triggers, unmatched, versionOnly } = input.full
|
|
63
|
+
? { triggers: allTriggers(config.steps), unmatched: [], versionOnly: [] }
|
|
64
|
+
: fireTriggers(input.paths, config.rules, config.steps, {
|
|
65
|
+
versionOnly: input.versionOnly,
|
|
66
|
+
versionBumpTriggers: config.versionBumpTriggers
|
|
67
|
+
});
|
|
53
68
|
const ctx = {
|
|
54
69
|
base: input.base,
|
|
55
70
|
head: input.head,
|
|
@@ -96,7 +111,7 @@ export function planSignoff(config, input) {
|
|
|
96
111
|
timeoutMs: step.timeoutMs
|
|
97
112
|
});
|
|
98
113
|
}
|
|
99
|
-
return { triggers, unmatched, steps, skipped, skippedByFlag };
|
|
114
|
+
return { triggers, unmatched, versionOnly, steps, skipped, skippedByFlag };
|
|
100
115
|
}
|
|
101
116
|
export function describeSkip(reason) {
|
|
102
117
|
switch (reason.kind) {
|
package/dist/signoff/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FLAKE_LOG_FILE } from '../flakes/log.js';
|
|
|
4
4
|
import { escalationFailure, runFlakePhase } from '../flakes/phase.js';
|
|
5
5
|
import { committedPaths, isWorktree, resolveBase, resolveRef } from '../git/change-set.js';
|
|
6
6
|
import { mergeBaseBranch } from '../git/merge.js';
|
|
7
|
+
import { versionOnlyPaths } from '../git/version-only.js';
|
|
7
8
|
import { repoInfo } from '../github/status.js';
|
|
8
9
|
import { signoffLock } from '../lock/machine-lock.js';
|
|
9
10
|
import { defaultExec, defaultRun } from '../run/exec.js';
|
|
@@ -48,6 +49,9 @@ export function printPlan(plan, where, log) {
|
|
|
48
49
|
if (plan.unmatched.length > 0) {
|
|
49
50
|
log(`[chreode] matched no rule, so every trigger fired: ${plan.unmatched.join(', ')}`);
|
|
50
51
|
}
|
|
52
|
+
if (plan.versionOnly.length > 0) {
|
|
53
|
+
log(`[chreode] version-only: ${plan.versionOnly.join(', ')}`);
|
|
54
|
+
}
|
|
51
55
|
log(`[chreode] triggers: ${plan.triggers.length > 0 ? plan.triggers.join(', ') : '(none)'}`);
|
|
52
56
|
log(`[chreode] plan: ${plan.steps.length > 0 ? plan.steps.map((s) => s.label).join(', ') : '(no steps: a no-op signoff)'}`);
|
|
53
57
|
for (const { label, reason } of plan.skipped) {
|
|
@@ -148,7 +152,8 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
|
|
|
148
152
|
skip: opts.skip,
|
|
149
153
|
base,
|
|
150
154
|
head,
|
|
151
|
-
isWorktree: isWorktree(d.exec)
|
|
155
|
+
isWorktree: isWorktree(d.exec),
|
|
156
|
+
versionOnly: opts.full ? [] : versionOnlyPaths(base, head, paths, d.exec)
|
|
152
157
|
});
|
|
153
158
|
}
|
|
154
159
|
catch (err) {
|