coderifts 4.12.0 → 5.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderifts",
3
- "version": "4.12.0",
3
+ "version": "5.0.1",
4
4
  "description": "Detect breaking API changes from the command line. Works locally or with the CodeRifts cloud API.",
5
5
  "author": "CodeRifts <hello@coderifts.com>",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "test": "node --test test/*.test.js"
46
46
  },
47
47
  "dependencies": {
48
- "@coderifts/agent-guard": "^10.0.0",
48
+ "@coderifts/agent-guard": "^12.0.0",
49
49
  "chalk": "^4.1.2",
50
50
  "cli-table3": "^0.6.4",
51
51
  "commander": "^12.0.0",
@@ -15,13 +15,43 @@
15
15
  * 4.12.0: floor raised to 10 (ENFORCING_STRICT requires the execution chain — a strict
16
16
  * composition that built under 9.x refuses to construct under 10.x, so a CLI that documents or
17
17
  * scaffolds strict must not run against a guard with the older meaning).
18
+ * 4.13.0: floor raised to 11 (computeBundleFingerprint returned a WRONG digest in 10.x — it
19
+ * omitted the artifact count and the whole context block, so on identical inputs it produced
20
+ * sha256:1a0e7470… where the server produced sha256:049650f2…; 11.0.0 delegates to the single
21
+ * canonical crbundle.v1 preimage and matches byte for byte. COST MEASURED AS ZERO, the same way
22
+ * the 10-raise was: the CLI imports only deployGate, asVerifiedDeployReceiptView,
23
+ * DEPLOY_RECEIPT_VIEW_SPEC and matchGlob, none of which 11.0.0 touched, and the suite staged
24
+ * against 11.0.0 returned an identical 532/530/2 with a byte-identical failure set. The floor
25
+ * rises anyway, because a semantics floor is about what the CLI may RUN against, not about what
26
+ * it happens to import today — a fingerprint that disagrees with the server is exactly the class
27
+ * of difference this gate exists to refuse.)
28
+ * 5.0.0: floor raised to 12 (requireConditionalWrite now FIRES on an ordinary mutation. Through
29
+ * 11.0.1 the policy gated on isWriteStyleCall, which answers "did the caller supply a both-sides
30
+ * snapshot" — the right question for FRESHNESS, which it was written for, and the wrong one for
31
+ * atomicity. So a mutation carrying artifacts[] with non-empty before AND after classified as
32
+ * not-write-style and the demand never fired: reproduced against the published 11.0.1 tarball,
33
+ * where the side effect RAN with enforced:true and conditional_write:"not_reported". It bites on
34
+ * the NORMAL path, not an exotic config, because the tool-registry defaultBinder lifts
35
+ * old_string/new_string and edits[] into exactly that shape — so an agent editing a contract
36
+ * artifact under ENFORCING_STRICT wrote unconditionally. 12.0.0 adds isMutatingCall and gates on
37
+ * it; ConditionalWriteBasis gains a required `mutating` field. COST MEASURED AS ZERO, the same
38
+ * way the 11-raise was: the CLI imports only deployGate, asVerifiedDeployReceiptView,
39
+ * DEPLOY_RECEIPT_VIEW_SPEC and matchGlob, whose implementing modules (deploy-gate.js,
40
+ * resolver-glob.js, deploy-receipt-token.js) are BYTE-IDENTICAL 11.0.1 -> 12.0.0; the CLI
41
+ * constructs no ConditionalWriteBasis and never sets requireConditionalWrite, so the one
42
+ * type-level break cannot reach it — it is JS besides. Suite on 12.0.0: 556/554/2, identical to
43
+ * 11.0.0 with a byte-identical failure set; the only edits the raise needed were this gate's own
44
+ * two value-pins in assert-guard-major-coverage.test.js, which exist to move with each recorded
45
+ * raise, and no product code. The floor rises anyway, because a semantics floor is about
46
+ * what the CLI may RUN against: a guard that silently skips the atomicity demand on an ordinary
47
+ * edit is exactly the class of difference this gate exists to refuse.)
18
48
  */
19
49
 
20
50
  const fs = require('fs');
21
51
  const path = require('path');
22
52
 
23
53
  /** Minimum acceptable major for @coderifts/agent-guard in THIS package (the CLI). */
24
- const MIN_GUARD_MAJOR = 10;
54
+ const MIN_GUARD_MAJOR = 12;
25
55
  const DEP = '@coderifts/agent-guard';
26
56
 
27
57
  /**
@@ -55,17 +85,54 @@ const CHECKOUTS = [
55
85
  /**
56
86
  * DELIBERATE, MEASURED, AND LOWER THAN THE CLI'S — recorded so it is a decision, not a default.
57
87
  *
58
- * 2026-08-26: bumping the root to ^10 breaks three merge-gate tests. gateDecision under 10.0.0
59
- * returns residuals: [] where the app expects 'required_check_app_not_bound'
60
- * (test/mergegate-webhook.test.js: 46/46 on 3.0.0, 3 failures on 10.0.0). The app's own comment
61
- * at webhook-integration.js:336-339 records why 3.x has been SAFE rather than merely unnoticed:
62
- * the app ANDs three conditions so a stale package can never fail-open.
88
+ * 2026-08-26: bumping the root to ^10 breaks three merge-gate tests
89
+ * (test/mergegate-webhook.test.js: 46/46 on 3.0.0, 43/46 on 10.x). The app's own comment at
90
+ * webhook-integration.js:336-339 records why 3.x has been SAFE rather than merely unnoticed:
91
+ * the app ANDs three conditions (protection_inescapable && change_set_rebound && app_bound) so
92
+ * a stale package can never fail-open. That AND is load-bearing — measured against 3.0.0,
93
+ * gateDecision claims inescapable_merge:true even when required_check_app_bound is false or
94
+ * absent; the published claim is correct only because the app re-derives it.
63
95
  *
64
- * So the floor is pinned at 3 to stop it drifting LOWER unnoticed, and raising it is a separate
65
- * round that must first reconcile the residual vocabulary. Raise this number in that round and
66
- * record the reason on the line below, in the style of the CLI history above.
96
+ * RE-MEASURED 2026-08-26 against 11.0.0, and TWO THINGS IN THE OLD NOTE WERE WRONG:
97
+ *
98
+ * 1. It said 10.0.0 "returns residuals: []". It does not. Given the correct input shape it
99
+ * returns ['required_check_app_not_bound'] / ['required_check_app_binding_unknown'] — the
100
+ * app's own vocabulary. The earlier reading passed protection.enforcement_state; the field
101
+ * the gate reads is protection.enforcement, so the gate saw no protection at all.
102
+ * 2. The real difference is PRECEDENCE, not absence. The guard emits exactly ONE residual and
103
+ * short-circuits on the enforcement gap, so where two gaps hold at once it reports
104
+ * 'admin_bypass_open' and the app expects 'required_check_app_binding_unknown'. The app's
105
+ * collectBindingResiduals emits EVERY residual that holds — its docstring says so: "the
106
+ * pure gate's published shape is a single honesty slot ... the second was suppressed".
107
+ * That is why the app duplicates rather than consumes, and it is the whole reconciliation.
108
+ *
109
+ * 11.0.0 vs 10.0.0: NO CHANGE. Residual output is byte-identical across all three app-binding
110
+ * cases, and 11.0.0 touched only computeBundleFingerprint, which this path does not import. So
111
+ * 11 makes the bump neither easier nor harder — the blocker is unchanged and the deferral still
112
+ * holds for exactly the reason above, not for a residual shape that moved.
113
+ *
114
+ *
115
+ * WHAT THE RESOLVED-RANGE CHECK SAYS ABOUT THIS ENTRY: NOTHING, and that is the correct
116
+ * answer rather than a lucky one. The check added for the 5.0.0 finding grades whether the
117
+ * DECLARED range and the RESOLVED install AGREE. The root declares ^3.0.0 and resolves 3.0.0,
118
+ * so they agree exactly and it reports "in range". The root's gap is not drift between its
119
+ * manifest and its tree — it is a deliberately LOW FLOOR, and a floor is a human decision this
120
+ * gate records rather than derives. That decision lives in `floor: 3` and the reason above.
121
+ *
122
+ * A check that DID flag it — "resolved major must equal the newest published major" — was
123
+ * considered and rejected: it would force exactly the bump the paragraphs above say must not
124
+ * be forced before the residual-precedence reconciliation. The gate must not legislate a
125
+ * decision it is only supposed to remember.
126
+ * WHAT THE BUMP ROUND MUST DECIDE: whether the guard should emit all holding residuals (then
127
+ * the app consumes gateOut.residuals and collectBindingResiduals is deleted), or whether the
128
+ * single-slot shape is deliberate (then the app keeps its collector and the duplication is
129
+ * documented as intended rather than left looking accidental). Do not bump before that choice.
130
+ *
131
+ * So the floor is pinned at 3 to stop it drifting LOWER unnoticed. Raise this number in that
132
+ * round and record the reason on the line below, in the style of the CLI history above.
67
133
  */
68
- why: 'root pinned at 3 pending the gateDecision residual-vocabulary reconciliation (see comment).',
134
+ why: 'root pinned at 3 pending the gateDecision residual-PRECEDENCE reconciliation (see comment; '
135
+ + 're-measured against 11.0.0 — unchanged from 10.0.0).',
69
136
  },
70
137
  ];
71
138
 
@@ -113,6 +180,94 @@ function resolvedVersionFor(startDir) {
113
180
  return null;
114
181
  }
115
182
 
183
+ /**
184
+ * RESOLVED-VERSION GRADING — the half the floor check cannot do.
185
+ *
186
+ * WHY THIS EXISTS. coderifts@5.0.0 published declaring `@coderifts/agent-guard: ^11.0.0` while
187
+ * guard 12.0.0 was already out carrying a semantics break (requireConditionalWrite began firing on
188
+ * ordinary mutations). This gate PASSED, and it was not wrong to: it graded the declared floor
189
+ * (11 >= 11) and the resolved version (11.0.0 >= 11) against a constant a human maintains by hand.
190
+ * Both facts were true. Both were stale. A gate that can only compare against the last floor
191
+ * someone remembered to raise cannot notice a major it has never been told about.
192
+ *
193
+ * So the constant is not the whole gate. What CAN be checked without a registry call is whether
194
+ * the tree and the manifest AGREE — and they must agree in both directions:
195
+ *
196
+ * · resolved BELOW the declared range → a stale install. The suite measured an older guard than
197
+ * the one we ship against, so every "cost measured as zero" conclusion is about the wrong
198
+ * package.
199
+ * · resolved ABOVE the declared range → the more dangerous direction, and the one that produced
200
+ * this finding's shape. Staging a NEWER guard into node_modules to measure a raise, then
201
+ * publishing with the old range, means the suite proved something adopters never receive: they
202
+ * resolve the range, not our node_modules. A green suite over a version we do not declare is a
203
+ * false confidence, not a measurement.
204
+ *
205
+ * NOT RESOLVED IS A FAILURE, not a pass. The previous code returned a success line reading
206
+ * "not resolved in tree" — "we could not look" recorded as "we looked and it is fine", which is
207
+ * the exact fail-open class this repository refuses elsewhere (a 404 on the source_ref anchor is
208
+ * deliberately on the FAIL side for the same reason). A publish gate that verifies nothing must
209
+ * say so by failing.
210
+ *
211
+ * WHAT THIS STILL CANNOT DO, named rather than implied: it makes no network call, so it cannot
212
+ * know that a newer major exists. Raising the floor stays a human act with a recorded reason. This
213
+ * check only guarantees that what we measured is what we declare.
214
+ */
215
+
216
+ /** Parse "1.2.3" (ignoring any prerelease/build suffix) to [1,2,3], or null. */
217
+ function parseVersion(v) {
218
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v || '').trim());
219
+ return m ? [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] : null;
220
+ }
221
+
222
+ /** -1 / 0 / 1 over [maj,min,pat] triples. */
223
+ function compareVersions(a, b) {
224
+ for (let i = 0; i < 3; i += 1) {
225
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
226
+ }
227
+ return 0;
228
+ }
229
+
230
+ /**
231
+ * Inclusive lower bound and EXCLUSIVE upper bound for the range forms this gate already accepts.
232
+ * Deliberately dependency-free — the rest of this file is, and a publish gate that needs an
233
+ * install to run is a gate that stops running.
234
+ *
235
+ * ^1.2.3 -> [1.2.3, 2.0.0) ~1.2.3 -> [1.2.3, 1.3.0) >=1.2.3 -> [1.2.3, inf)
236
+ * 1.2.3 -> [1.2.3, 1.2.4) 1.x -> [1.0.0, 2.0.0)
237
+ * Anything else -> null (caller treats an unparseable range as unverifiable, never as satisfied).
238
+ */
239
+ function rangeBounds(range) {
240
+ const s = String(range || '').trim();
241
+ const m = /^(\^|~|>=|=)?\s*(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(s);
242
+ if (!m) return null;
243
+ const op = m[1] || '';
244
+ const maj = parseInt(m[2], 10);
245
+ const minRaw = m[3];
246
+ const patRaw = m[4];
247
+ const wild = (t) => t === undefined || t === 'x' || t === '*';
248
+ const min = wild(minRaw) ? 0 : parseInt(minRaw, 10);
249
+ const pat = wild(patRaw) ? 0 : parseInt(patRaw, 10);
250
+ const lower = [maj, min, pat];
251
+ if (wild(minRaw)) return { lower, upper: [maj + 1, 0, 0] }; // 1.x
252
+ if (op === '^') return { lower, upper: [maj + 1, 0, 0] };
253
+ if (op === '~') return { lower, upper: [maj, min + 1, 0] };
254
+ if (op === '>=') return { lower, upper: null };
255
+ if (wild(patRaw)) return { lower, upper: [maj, min + 1, 0] }; // 1.2.x
256
+ return { lower, upper: [maj, min, pat + 1] }; // exact
257
+ }
258
+
259
+ /**
260
+ * @returns {'in'|'below'|'above'|'unknown'} where `version` sits relative to `range`.
261
+ */
262
+ function versionVsRange(version, range) {
263
+ const v = parseVersion(version);
264
+ const b = rangeBounds(range);
265
+ if (!v || !b) return 'unknown';
266
+ if (compareVersions(v, b.lower) < 0) return 'below';
267
+ if (b.upper && compareVersions(v, b.upper) >= 0) return 'above';
268
+ return 'in';
269
+ }
270
+
116
271
  /** Check ONE checkout. Returns a line for the summary; calls fail() and exits on a violation. */
117
272
  function checkCheckout(c) {
118
273
  if (!fs.existsSync(c.pkgPath)) {
@@ -138,7 +293,15 @@ function checkCheckout(c) {
138
293
 
139
294
  const resolved = resolvedVersionFor(path.dirname(c.pkgPath));
140
295
  if (!resolved) {
141
- return `${c.label}: declared ${range} (floor ${floor} >= ${c.floor}); not resolved in tree`;
296
+ // FAIL-CLOSED, with NO opt-out. An `allowUnresolved` flag was written and then removed on
297
+ // purpose: the first time this fires in CI the cheapest response is to set the flag, and the
298
+ // hole is back with a config line to justify it. If a checkout ever legitimately ships
299
+ // uninstalled, add the exemption THEN, with the measurement that earned it.
300
+ fail(
301
+ `${c.label}: ${DEP} is declared ${JSON.stringify(range)} but is NOT RESOLVABLE from `
302
+ + `${path.dirname(c.pkgPath)}. This gate cannot verify what will ship, and an unverified `
303
+ + 'publish is not a verified one. Install dependencies for this checkout and re-run.',
304
+ );
142
305
  }
143
306
  const rm = /^(\d+)/.exec(resolved);
144
307
  const rMajor = rm ? parseInt(rm[1], 10) : null;
@@ -148,7 +311,32 @@ function checkCheckout(c) {
148
311
  if (rMajor < floor) {
149
312
  fail(`${c.label}: resolved ${DEP}@${resolved} major ${rMajor} is below declared range floor ${floor}`);
150
313
  }
151
- return `${c.label}: declared ${range} (floor ${floor} >= ${c.floor}); resolved ${resolved}`;
314
+
315
+ // THE DECLARED RANGE AND THE INSTALLED TREE MUST AGREE — both directions, full precision.
316
+ const where = versionVsRange(resolved, range);
317
+ if (where === 'below') {
318
+ fail(
319
+ `${c.label}: resolved ${DEP}@${resolved} is BELOW the declared range ${JSON.stringify(range)} `
320
+ + '— a stale install. Every cost measured against this tree describes an older guard than the '
321
+ + 'one this package declares. Run npm install for this checkout and re-measure.',
322
+ );
323
+ }
324
+ if (where === 'above') {
325
+ fail(
326
+ `${c.label}: resolved ${DEP}@${resolved} is ABOVE the declared range ${JSON.stringify(range)}. `
327
+ + 'The tree runs a guard this package does not declare, so the suite measured a version '
328
+ + 'adopters will never receive — they resolve the range, not our node_modules. Either raise '
329
+ + `the declared range to admit ${resolved} (and record WHY in the history block), or remove `
330
+ + 'the staged install.',
331
+ );
332
+ }
333
+ if (where === 'unknown') {
334
+ fail(
335
+ `${c.label}: cannot decide whether resolved ${DEP}@${resolved} satisfies declared range `
336
+ + `${JSON.stringify(range)} — unparseable. A range this gate cannot grade must not ship.`,
337
+ );
338
+ }
339
+ return `${c.label}: declared ${range} (floor ${floor} >= ${c.floor}); resolved ${resolved} (in range)`;
152
340
  }
153
341
 
154
342
  function main() {
@@ -171,4 +359,7 @@ function main() {
171
359
  // floorMajorFromRange without triggering a process.exit.
172
360
  if (require.main === module) main();
173
361
 
174
- module.exports = { CHECKOUTS, floorMajorFromRange, MIN_GUARD_MAJOR, DEP };
362
+ module.exports = {
363
+ CHECKOUTS, floorMajorFromRange, MIN_GUARD_MAJOR, DEP,
364
+ parseVersion, compareVersions, rangeBounds, versionVsRange, resolvedVersionFor,
365
+ };