@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 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) → steps (`when`, `if`,
65
- `--skip`, `supersededBy`). The plan and every skipped step's reason are printed.
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
@@ -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[];
@@ -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
+ }
@@ -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
- /** 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[]): {
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;
@@ -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
- export function fireTriggers(paths, rules, steps) {
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
- const triggers = rule === null ? allTriggers(steps) : rule.triggers;
37
- if (rule === null)
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) {
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindlace/chreode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A deterministic, selective, unavoidable local-CI gate for agent-written code",
5
5
  "keywords": [
6
6
  "ci",