@substrat-run/cli 0.44.0 → 0.46.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.
Files changed (42) hide show
  1. package/dist/bump.d.ts +120 -0
  2. package/dist/bump.d.ts.map +1 -0
  3. package/dist/bump.js +780 -0
  4. package/dist/bump.js.map +1 -0
  5. package/dist/changeset.d.ts +129 -0
  6. package/dist/changeset.d.ts.map +1 -0
  7. package/dist/changeset.js +336 -0
  8. package/dist/changeset.js.map +1 -0
  9. package/dist/cli.js +193 -41
  10. package/dist/cli.js.map +1 -1
  11. package/dist/concept.d.ts +19 -0
  12. package/dist/concept.d.ts.map +1 -0
  13. package/dist/concept.js +25 -0
  14. package/dist/concept.js.map +1 -0
  15. package/dist/data-loss-check.d.ts +70 -0
  16. package/dist/data-loss-check.d.ts.map +1 -0
  17. package/dist/data-loss-check.js +260 -0
  18. package/dist/data-loss-check.js.map +1 -0
  19. package/dist/data-loss-replay-child.d.ts +27 -0
  20. package/dist/data-loss-replay-child.d.ts.map +1 -0
  21. package/dist/data-loss-replay-child.js +71 -0
  22. package/dist/data-loss-replay-child.js.map +1 -0
  23. package/dist/promote.d.ts +16 -1
  24. package/dist/promote.d.ts.map +1 -1
  25. package/dist/promote.js +87 -11
  26. package/dist/promote.js.map +1 -1
  27. package/dist/push.d.ts +32 -6
  28. package/dist/push.d.ts.map +1 -1
  29. package/dist/push.js +94 -32
  30. package/dist/push.js.map +1 -1
  31. package/dist/scope.d.ts +23 -2
  32. package/dist/scope.d.ts.map +1 -1
  33. package/dist/scope.js +63 -7
  34. package/dist/scope.js.map +1 -1
  35. package/dist/store-driver.d.ts +59 -0
  36. package/dist/store-driver.d.ts.map +1 -0
  37. package/dist/{store-kind.js → store-driver.js} +58 -22
  38. package/dist/store-driver.js.map +1 -0
  39. package/package.json +4 -4
  40. package/dist/store-kind.d.ts +0 -46
  41. package/dist/store-kind.d.ts.map +0 -1
  42. package/dist/store-kind.js.map +0 -1
package/dist/bump.js ADDED
@@ -0,0 +1,780 @@
1
+ /**
2
+ * The semver bump a vertical's change calls for, computed from its artifacts of record (#2364).
3
+ *
4
+ * Choosing a bump is a judgement an agent makes with confidence and gets wrong, and a breaking
5
+ * change marked `patch` looks exactly like a green release. A vertical already checks in every
6
+ * fact the judgement needs — `model.json` (`lint:model`), `openapi.json` (`lint:api`) and
7
+ * `PERMISSIONS.md` (`lint:permissions`), each held to the source by a `--check` re-emit — so the
8
+ * bump is read off the diff of those three files instead of asked for.
9
+ *
10
+ * The rules, one level per change, the bump being the highest. An INPUT is what a caller sends
11
+ * (parameters, request bodies); an OUTPUT is what a caller reads (responses, stored rows in
12
+ * `model.json`, event payloads).
13
+ *
14
+ * - **major**: something removed or narrowed. An operation, field, entity, state, transition,
15
+ * response, media type, enum value, parent edge, derived handler or export; an input that
16
+ * became required, tighter (`minLength` up, `maxLength` down, …), closed to unnamed
17
+ * properties or `readOnly`; an output that may now be omitted, looser, or `writeOnly`; a
18
+ * type, key, table, initial state or `operationId` changed; trash terms changed.
19
+ * - **minor**: something added. An operation, field, entity, state, transition, enum value,
20
+ * response or stamp; an input that became optional or looser; an output that became required
21
+ * or tighter; a `default` added, changed or dropped (a caller who omits the value gets other
22
+ * behaviour); `readOnly`/`writeOnly` dropped, or set in the direction that does not use the
23
+ * field; `erasable` gained or lost (lost is flagged). Every permission change is at least
24
+ * minor and FLAGGED for the human checkpoint (CLAUDE.md, "Two human checkpoints") — a
25
+ * narrowing and a widening alike, since a key gaining a role is the risk surface it exists for.
26
+ * - **patch**: only what describes: the schema keywords `description`, `title`, `examples`,
27
+ * `example`, `$comment`, `deprecated` and any `x-*`; an operation's `summary`, `description`,
28
+ * `tags`, `externalDocs` and `deprecated`; `info`; a permission key's description.
29
+ *
30
+ * **What it cannot read is major.** A keyword, section or key this module does not know is
31
+ * judged as a breaking change, never skipped. The CI check refuses a changeset that declares
32
+ * LESS than this, so an unknown shape that read as "no change" would let a real break ship as a
33
+ * patch. A false major costs a reviewed override line in the changeset; a false patch costs a
34
+ * broken consumer. Teach this file the new shape instead.
35
+ *
36
+ * Two known limits, both stated rather than fixed:
37
+ *
38
+ * - **Alternatives reordered rank major.** `anyOf`/`oneOf`/`allOf` branches are compared by
39
+ * position (the emitter writes them in declaration order), so a reorder with no other change
40
+ * reads as every branch changing. The safe direction: a reviewed override covers it.
41
+ * - **A component renamed with identical content ranks none.** `$ref`s are resolved before
42
+ * comparing, so `#/components/schemas/A` → `B` is invisible, although a client generated from
43
+ * `openapi.json` names its types after components. Nothing a caller sends or reads changed,
44
+ * and this repo's browser client is generated from `spec/model.ts`, not from component names.
45
+ *
46
+ * Pure: two texts in, a classification out. The filesystem and git half is `changeset.ts`. It
47
+ * reads the artifacts, never the TypeScript, which is what keeps it valid across a change of
48
+ * authoring notation (`tools/model-diff.mts`). The same classifier is what `substrat changeset`
49
+ * writes, what `--check` holds a hand-written changeset to, and what a status line can show.
50
+ */
51
+ const ORDER = ['none', 'patch', 'minor', 'major'];
52
+ export const rank = (b) => ORDER.indexOf(b);
53
+ export const maxBump = (a, b) => (rank(a) >= rank(b) ? a : b);
54
+ export const isBump = (s) => ORDER.includes(s);
55
+ /** The highest level among the changes; `none` when there are none. */
56
+ function bumpOf(changes) {
57
+ return changes.reduce((acc, c) => maxBump(acc, c.level), 'none');
58
+ }
59
+ const classification = (changes) => ({ bump: bumpOf(changes), changes });
60
+ // ─── JSON Schema ──────────────────────────────────────────────────────────────────────────────
61
+ /** Keywords that describe a schema without constraining it. A change to one is documentation. */
62
+ const ANNOTATIONS = new Set(['description', 'title', 'examples', 'example', '$comment', 'deprecated']);
63
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
64
+ /** Keywords whose keys are NAMES (a field called `title`), never keywords to strip. */
65
+ const NAME_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'schemas', 'responses', 'content']);
66
+ /**
67
+ * Keywords whose value is DATA (an enum member, a constant, a default): compared verbatim — nothing
68
+ * stripped, nothing sorted. A default of `{ enum: ['a', 'b'] }` is a value, not a schema.
69
+ */
70
+ const LITERALS = new Set(['enum', 'const', 'default']);
71
+ /**
72
+ * Keywords whose array value is a SET: order carries no meaning, so it is sorted before comparing.
73
+ * The emitter writes `required` and `enum` in declaration order, so moving a field or a member
74
+ * in `spec/model.ts` reorders them without changing anything — and the enum's consumers read it
75
+ * as a set too (`emit-sql`'s `CHECK (col IN (…))`, `emit-client`'s `'a' | 'b'` union).
76
+ */
77
+ const SET_VALUED = new Set(['required', 'enum', 'type']);
78
+ /** `false` is these keywords' default: written out, it says nothing more than absent. */
79
+ const FALSE_IS_ABSENT = new Set(['readOnly', 'writeOnly']);
80
+ /** A value as a comparable string, keys sorted, optionally without annotations at any depth. */
81
+ function canon(v, stripAnnotations) {
82
+ const walk = (x, mode = 'schema') => {
83
+ if (Array.isArray(x))
84
+ return x.map((y) => walk(y, mode === 'literal' ? 'literal' : 'schema'));
85
+ if (!isRecord(x))
86
+ return x;
87
+ const out = {};
88
+ for (const k of Object.keys(x).sort()) {
89
+ if (mode === 'schema' && stripAnnotations && (ANNOTATIONS.has(k) || k.startsWith('x-')))
90
+ continue;
91
+ if (mode === 'schema' && FALSE_IS_ABSENT.has(k) && x[k] === false)
92
+ continue;
93
+ const next = mode === 'literal' ? 'literal' : mode === 'names' ? 'schema' : LITERALS.has(k) ? 'literal' : NAME_MAPS.has(k) ? 'names' : 'schema';
94
+ const value = walk(x[k], next);
95
+ out[k] =
96
+ mode === 'schema' && SET_VALUED.has(k) && Array.isArray(value)
97
+ ? [...value].sort((a, b) => {
98
+ const [sa, sb] = [JSON.stringify(a), JSON.stringify(b)];
99
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
100
+ })
101
+ : value;
102
+ }
103
+ return out;
104
+ };
105
+ return JSON.stringify(walk(v)) ?? 'undefined';
106
+ }
107
+ /**
108
+ * Every local `$ref` (`#/…`) replaced by its target, recursively, against `root`. Compared
109
+ * unresolved, a break inside a shared component reads as an unchanged `{"$ref": …}` on both
110
+ * sides. A reference that loops back is left as the reference.
111
+ */
112
+ export function resolveRefs(node, root = node, seen = []) {
113
+ if (Array.isArray(node))
114
+ return node.map((v) => resolveRefs(v, root, seen));
115
+ if (!isRecord(node))
116
+ return node;
117
+ const ref = node.$ref;
118
+ if (typeof ref === 'string' && ref.startsWith('#/') && !seen.includes(ref)) {
119
+ let target = root;
120
+ for (const part of ref.slice(2).split('/')) {
121
+ target = isRecord(target) ? target[part.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
122
+ }
123
+ if (target !== undefined) {
124
+ const { $ref: _ref, ...siblings } = node;
125
+ const resolved = resolveRefs(target, root, [...seen, ref]);
126
+ return Object.keys(siblings).length && isRecord(resolved)
127
+ ? { ...resolved, ...resolveRefs(siblings, root, seen) }
128
+ : resolved;
129
+ }
130
+ }
131
+ const out = {};
132
+ for (const [k, v] of Object.entries(node)) {
133
+ if (k === '$defs' || k === 'definitions')
134
+ continue;
135
+ out[k] = resolveRefs(v, root, seen);
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * Numeric limits, and which way is tighter. A lower bound rising or an upper bound falling
141
+ * accepts less: that breaks a caller sending it (input) and is safe for a reader (output).
142
+ */
143
+ const LOWER_BOUNDS = new Set(['minLength', 'minItems', 'minProperties', 'minimum', 'exclusiveMinimum']);
144
+ const UPPER_BOUNDS = new Set(['maxLength', 'maxItems', 'maxProperties', 'maximum', 'exclusiveMaximum']);
145
+ const COMPOSITIONS = ['anyOf', 'oneOf', 'allOf'];
146
+ /** The keywords `compareSchema` judges on their meaning. Any other constraint change is major. */
147
+ const UNDERSTOOD = new Set([
148
+ 'type', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'default', 'readOnly', 'writeOnly',
149
+ ...LOWER_BOUNDS, ...UPPER_BOUNDS, ...COMPOSITIONS,
150
+ ]);
151
+ const requiredOf = (s) => new Set(Array.isArray(s.required) ? s.required.filter((x) => typeof x === 'string') : []);
152
+ const propertiesOf = (s) => isRecord(s.properties) ? s.properties : {};
153
+ /**
154
+ * One schema against its base, keyword by keyword, pushing a change for each difference. `push`
155
+ * carries the artifact; `path` names the place for a reader. Both schemas must be resolved.
156
+ */
157
+ function compareSchema(push, dir, path, base, head) {
158
+ if (canon(base, false) === canon(head, false))
159
+ return;
160
+ if (canon(base, true) === canon(head, true)) {
161
+ push('patch', path, 'documentation only');
162
+ return;
163
+ }
164
+ if (!isRecord(base) || !isRecord(head)) {
165
+ push('major', path, `changed from ${canon(base, true)} to ${canon(head, true)}`);
166
+ return;
167
+ }
168
+ // The safety net, checked at the end: the two schemas differ in more than documentation, so
169
+ // if no rule below ranked anything, the difference is one this classifier did not read (a
170
+ // `required` naming a property declared in another `allOf` branch, say) — and that is major.
171
+ const outer = push;
172
+ let ranked = false;
173
+ push = (level, at, detail) => {
174
+ ranked = true;
175
+ outer(level, at, detail);
176
+ };
177
+ // Compared under its key, so an array `type` is compared as the set it is.
178
+ if (canon({ type: base.type }, false) !== canon({ type: head.type }, false)) {
179
+ push('major', path, `type changed from ${canon(base.type, false)} to ${canon(head.type, false)}`);
180
+ }
181
+ if (Array.isArray(base.enum) || Array.isArray(head.enum)) {
182
+ if (!Array.isArray(base.enum) || !Array.isArray(head.enum)) {
183
+ // An enum appearing narrows the values to a list; one disappearing loses the promise.
184
+ push('major', path, Array.isArray(head.enum) ? 'is now restricted to an enum' : 'is no longer an enum');
185
+ }
186
+ else {
187
+ const b = new Set(base.enum.map((v) => canon(v, false)));
188
+ const h = new Set(head.enum.map((v) => canon(v, false)));
189
+ const removed = [...b].filter((v) => !h.has(v));
190
+ const added = [...h].filter((v) => !b.has(v));
191
+ if (removed.length)
192
+ push('major', path, `enum value(s) removed: ${removed.join(', ')}`);
193
+ if (added.length)
194
+ push('minor', path, `enum value(s) added: ${added.join(', ')}`);
195
+ }
196
+ }
197
+ const bp = propertiesOf(base);
198
+ const hp = propertiesOf(head);
199
+ const br = requiredOf(base);
200
+ const hr = requiredOf(head);
201
+ const at = (field) => (path ? `${path}.${field}` : field);
202
+ for (const field of Object.keys(bp).sort()) {
203
+ if (!(field in hp))
204
+ push('major', at(field), 'removed');
205
+ else
206
+ compareSchema(push, dir, at(field), bp[field], hp[field]);
207
+ }
208
+ for (const field of Object.keys(hp).sort()) {
209
+ if (field in bp)
210
+ continue;
211
+ if (dir === 'input' && hr.has(field))
212
+ push('major', at(field), 'added as a required input — a caller that omits it is refused');
213
+ else
214
+ push('minor', at(field), 'added');
215
+ }
216
+ for (const field of [...hr].sort()) {
217
+ if (br.has(field) || !(field in bp) || !(field in hp))
218
+ continue;
219
+ push(dir === 'input' ? 'major' : 'minor', at(field), 'is now required');
220
+ }
221
+ for (const field of [...br].sort()) {
222
+ if (hr.has(field) || !(field in hp))
223
+ continue;
224
+ push(dir === 'input' ? 'minor' : 'major', at(field), dir === 'input' ? 'is now optional' : 'may now be omitted');
225
+ }
226
+ const ba = base.additionalProperties;
227
+ const ha = head.additionalProperties;
228
+ if (canon(ba, false) !== canon(ha, false)) {
229
+ if (isRecord(ba) && isRecord(ha)) {
230
+ compareSchema(push, dir, `${path}[*]`, ba, ha);
231
+ }
232
+ else if (dir === 'input' && ha === false) {
233
+ push('major', path, 'no longer accepts properties it does not name');
234
+ }
235
+ else if (isRecord(ba) || isRecord(ha)) {
236
+ push('major', `${path}[*]`, `value schema changed from ${canon(ba, true)} to ${canon(ha, true)}`);
237
+ }
238
+ else {
239
+ push('minor', path, `additionalProperties changed from ${canon(ba, false)} to ${canon(ha, false)}`);
240
+ }
241
+ }
242
+ if (base.items !== undefined || head.items !== undefined) {
243
+ if (base.items === undefined || head.items === undefined)
244
+ push('major', `${path}[]`, 'items changed');
245
+ else
246
+ compareSchema(push, dir, `${path}[]`, base.items, head.items);
247
+ }
248
+ // `default` is what a caller who omits the value gets: changed, added or dropped, every such
249
+ // caller now sees different behaviour without having broken any contract — minor.
250
+ // Under its key, so the value is walked as the data it is (LITERALS), never as a schema.
251
+ if (canon({ default: base.default }, false) !== canon({ default: head.default }, false)) {
252
+ push('minor', path, `default changed from ${canon(base.default, false)} to ${canon(head.default, false)}`);
253
+ }
254
+ // `readOnly` is never sent, `writeOnly` is never returned. Each narrows one direction: a
255
+ // field becoming readOnly can no longer be sent (input), becoming writeOnly is no longer read
256
+ // (output). The reverse flip, or a flip in the direction that does not use the field, is minor.
257
+ for (const [k, narrows] of [['readOnly', 'input'], ['writeOnly', 'output']]) {
258
+ if (!!base[k] === !!head[k])
259
+ continue;
260
+ push(dir === narrows && head[k] ? 'major' : 'minor', path, head[k] ? `became ${k}` : `is no longer ${k}`);
261
+ }
262
+ for (const k of [...LOWER_BOUNDS, ...UPPER_BOUNDS]) {
263
+ const bv = base[k];
264
+ const hv = head[k];
265
+ if (bv === hv)
266
+ continue;
267
+ if ((bv !== undefined && typeof bv !== 'number') || (hv !== undefined && typeof hv !== 'number')) {
268
+ push('major', path, `'${k}' changed from ${canon(bv, false)} to ${canon(hv, false)}`);
269
+ continue;
270
+ }
271
+ // An absent bound is no limit at all: the loosest value there is.
272
+ const lower = LOWER_BOUNDS.has(k);
273
+ const loose = lower ? -Infinity : Infinity;
274
+ const b = bv ?? loose;
275
+ const h = hv ?? loose;
276
+ const tighter = lower ? h > b : h < b;
277
+ const breaks = dir === 'input' ? tighter : !tighter;
278
+ push(breaks ? 'major' : 'minor', path, `'${k}' ${tighter ? 'tightened' : 'loosened'} from ${bv ?? 'none'} to ${hv ?? 'none'}`);
279
+ }
280
+ // Alternatives are compared position by position: the emitter writes them in declaration
281
+ // order, so `[X, null]` stays `[X, null]` while X changes. A different count is judged whole.
282
+ for (const k of COMPOSITIONS) {
283
+ const ba = base[k];
284
+ const ha = head[k];
285
+ if (ba === undefined && ha === undefined)
286
+ continue;
287
+ if (!Array.isArray(ba) || !Array.isArray(ha) || ba.length !== ha.length) {
288
+ if (canon(ba, true) !== canon(ha, true))
289
+ push('major', path, `'${k}' alternatives changed`);
290
+ continue;
291
+ }
292
+ ba.forEach((branch, i) => compareSchema(push, dir, `${path}|${k}[${i}]`, branch, ha[i]));
293
+ }
294
+ for (const k of [...new Set([...Object.keys(base), ...Object.keys(head)])].sort()) {
295
+ if (UNDERSTOOD.has(k) || ANNOTATIONS.has(k) || k.startsWith('x-'))
296
+ continue;
297
+ if (canon(base[k], true) !== canon(head[k], true)) {
298
+ push('major', path, `'${k}' changed — not a change this classifier can rank, so judged as breaking`);
299
+ }
300
+ }
301
+ if (!ranked)
302
+ outer('major', path, 'changed in a way no rule here reads, so judged as breaking');
303
+ }
304
+ function pusher(artifact, out) {
305
+ return (level, path, detail, flagged) => out.push({ level, artifact, path, detail, ...(flagged ? { flagged: true } : {}) });
306
+ }
307
+ /** Removed keys → `removed`, added → `added`, both sides present → `both`. Sorted. */
308
+ function keyed(base, head, on) {
309
+ for (const k of Object.keys(base).sort()) {
310
+ if (!(k in head))
311
+ on.removed(k, base[k]);
312
+ else
313
+ on.both(k, base[k], head[k]);
314
+ }
315
+ for (const k of Object.keys(head).sort())
316
+ if (!(k in base))
317
+ on.added(k, head[k]);
318
+ }
319
+ const recordOf = (v) => (isRecord(v) ? v : {});
320
+ /** A value judged whole: annotations → patch, anything else → major. The unrecognised fallback. */
321
+ function wholeValue(push, path, base, head, what = 'changed') {
322
+ if (canon(base, false) === canon(head, false))
323
+ return;
324
+ if (canon(base, true) === canon(head, true))
325
+ push('patch', path, 'documentation only');
326
+ else
327
+ push('major', path, `${what} (not a shape this classifier reads, so judged as breaking)`);
328
+ }
329
+ function setDiff(base, head) {
330
+ const b = new Set(Array.isArray(base) ? base.map(String) : []);
331
+ const h = new Set(Array.isArray(head) ? head.map(String) : []);
332
+ return { removed: [...b].filter((x) => !h.has(x)).sort(), added: [...h].filter((x) => !b.has(x)).sort() };
333
+ }
334
+ function parseJson(text, what) {
335
+ if (text === null)
336
+ return null;
337
+ let parsed;
338
+ try {
339
+ parsed = JSON.parse(text);
340
+ }
341
+ catch (err) {
342
+ throw new Error(`${what} does not parse as JSON: ${err.message}`);
343
+ }
344
+ if (!isRecord(parsed))
345
+ throw new Error(`${what} is not a JSON object`);
346
+ return parsed;
347
+ }
348
+ /**
349
+ * The artifact appearing or disappearing, judged before its contents: a vertical gaining its
350
+ * first `openapi.json` adds a surface (minor), one losing it removes one (major).
351
+ */
352
+ function presence(artifact, base, head, out) {
353
+ if (base === null && head === null)
354
+ return true;
355
+ const file = { model: 'model.json', openapi: 'openapi.json', permissions: 'PERMISSIONS.md' }[artifact];
356
+ if (base === null) {
357
+ out.push({ level: 'minor', artifact, path: file, detail: 'added', ...(artifact === 'permissions' ? { flagged: true } : {}) });
358
+ return true;
359
+ }
360
+ if (head === null) {
361
+ out.push({ level: 'major', artifact, path: file, detail: 'removed', ...(artifact === 'permissions' ? { flagged: true } : {}) });
362
+ return true;
363
+ }
364
+ return false;
365
+ }
366
+ // ─── model.json ───────────────────────────────────────────────────────────────────────────────
367
+ /** Top-level `model.json` keys this classifier reads. Any other key changing is judged major. */
368
+ const MODEL_KEYS = new Set(['entities', 'lifecycles', 'handlers', 'exports', 'version']);
369
+ /** Entity keys read on their meaning. */
370
+ const ENTITY_KEYS = new Set([
371
+ 'table', 'fields', 'erasable', 'erasure', 'parents', 'key', 'primaryKey', 'archive', 'trash', 'stamps', 'outsideText',
372
+ ]);
373
+ export function classifyModel(baseText, headText) {
374
+ const out = [];
375
+ const base = parseJson(baseText, 'model.json (base)');
376
+ const head = parseJson(headText, 'model.json');
377
+ if (presence('model', base, head, out))
378
+ return classification(out);
379
+ const push = pusher('model', out);
380
+ const b = base;
381
+ const h = head;
382
+ const baseEntities = recordOf(b.entities);
383
+ keyed(baseEntities, recordOf(h.entities), {
384
+ removed: (name) => push('major', `entities.${name}`, 'entity removed'),
385
+ added: (name) => push('minor', `entities.${name}`, 'entity added'),
386
+ both: (name, be, he) => classifyEntity(push, `entities.${name}`, recordOf(be), recordOf(he)),
387
+ });
388
+ keyed(recordOf(b.lifecycles), recordOf(h.lifecycles), {
389
+ removed: (name) => push('major', `lifecycles.${name}`, 'lifecycle removed — its states are no longer enforced'),
390
+ // A state machine on an entity that existed without one refuses operations it used to accept.
391
+ added: (name) => name in baseEntities
392
+ ? push('major', `lifecycles.${name}`, 'an existing entity gained a lifecycle, which refuses transitions it used to allow')
393
+ : push('minor', `lifecycles.${name}`, 'lifecycle added with its entity'),
394
+ both: (name, bl, hl) => classifyLifecycle(push, `lifecycles.${name}`, recordOf(bl), recordOf(hl)),
395
+ });
396
+ keyed(recordOf(b.handlers), recordOf(h.handlers), {
397
+ removed: (op) => push('major', `handlers.${op}`, 'derived handler removed'),
398
+ added: (op) => push('minor', `handlers.${op}`, 'derived handler added'),
399
+ both: (op, bh, hh) => {
400
+ if (canon(bh, false) === canon(hh, false))
401
+ return;
402
+ // `authored` holds the reason a handler is written by hand. Two reasons are prose.
403
+ const authored = (v) => isRecord(v) && Object.keys(v).length === 1 && typeof v.authored === 'string';
404
+ if (authored(bh) && authored(hh))
405
+ push('patch', `handlers.${op}`, 'documentation only');
406
+ // The same operation answered by a different implementation: todo's `list-shares` went
407
+ // from answering a missing list with a 404 to answering it with an empty page this way.
408
+ else
409
+ push('major', `handlers.${op}`, `implementation changed from ${canon(bh, false)} to ${canon(hh, false)}`);
410
+ },
411
+ });
412
+ keyed(recordOf(b.exports), recordOf(h.exports), {
413
+ removed: (type) => push('major', `exports.${type}`, 'exported event removed'),
414
+ added: (type) => push('minor', `exports.${type}`, 'exported event added'),
415
+ both: (type, bx, hx) => {
416
+ const be = recordOf(bx);
417
+ const he = recordOf(hx);
418
+ if (be.schemaVersion !== he.schemaVersion) {
419
+ // K-39: a bump is a replace, and a consumer on the old version stops receiving it.
420
+ push('major', `exports.${type}`, `schemaVersion changed from ${String(be.schemaVersion)} to ${String(he.schemaVersion)}`);
421
+ return;
422
+ }
423
+ compareSchema(push, 'output', `exports.${type}.payload`, resolveRefs(be.payload), resolveRefs(he.payload));
424
+ const { schemaVersion: _a, payload: _b, ...bRest } = be;
425
+ const { schemaVersion: _c, payload: _d, ...hRest } = he;
426
+ wholeValue(push, `exports.${type}`, bRest, hRest);
427
+ },
428
+ });
429
+ // `version` is an engine's manifest version: the RESULT of a bump, never a reason for one.
430
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(h)])].sort()) {
431
+ if (!MODEL_KEYS.has(k))
432
+ wholeValue(push, k, b[k], h[k]);
433
+ }
434
+ return classification(out);
435
+ }
436
+ function classifyEntity(push, path, b, h) {
437
+ if (b.table !== h.table)
438
+ push('major', `${path}.table`, `table renamed from ${String(b.table)} to ${String(h.table)}`);
439
+ compareSchema(push, 'output', `${path}.fields`, resolveRefs(b.fields), resolveRefs(h.fields));
440
+ const erasable = setDiff(b.erasable, h.erasable);
441
+ if (erasable.added.length)
442
+ push('minor', `${path}.erasable`, `now erasable: ${erasable.added.join(', ')}`);
443
+ if (erasable.removed.length) {
444
+ push('minor', `${path}.erasable`, `no longer erasable — a subject erasure stops reaching: ${erasable.removed.join(', ')}`, true);
445
+ }
446
+ if (canon(b.erasure, false) !== canon(h.erasure, false))
447
+ push('minor', `${path}.erasure`, 'subject erasure declaration changed');
448
+ const parents = setDiff(b.parents, h.parents);
449
+ if (parents.removed.length)
450
+ push('major', `${path}.parents`, `parent edge removed: ${parents.removed.join(', ')}`);
451
+ if (parents.added.length)
452
+ push('minor', `${path}.parents`, `parent edge added: ${parents.added.join(', ')}`);
453
+ for (const k of ['key', 'primaryKey']) {
454
+ if (canon(b[k], false) !== canon(h[k], false))
455
+ push('major', `${path}.${k}`, `${k} changed`);
456
+ }
457
+ for (const k of ['archive', 'trash']) {
458
+ if (b[k] === undefined && h[k] !== undefined)
459
+ push('minor', `${path}.${k}`, `${k} declared`);
460
+ else if (b[k] !== undefined && h[k] === undefined)
461
+ push('major', `${path}.${k}`, `${k} no longer declared`);
462
+ // Its permission, its purge horizon or its purge floor: each decides who loses what, when.
463
+ else if (canon(b[k], false) !== canon(h[k], false)) {
464
+ push('major', `${path}.${k}`, `${k} terms changed from ${canon(b[k], false)} to ${canon(h[k], false)}`);
465
+ }
466
+ }
467
+ // Columns the kernel fills: a new one is a fact added, a dropped or re-pointed one is taken away.
468
+ keyed(recordOf(b.stamps), recordOf(h.stamps), {
469
+ removed: (col) => push('major', `${path}.stamps.${col}`, 'no longer stamped'),
470
+ added: (col) => push('minor', `${path}.stamps.${col}`, 'stamped by the kernel'),
471
+ both: (col, bs, hs) => {
472
+ if (bs !== hs)
473
+ push('major', `${path}.stamps.${col}`, `stamp changed from ${String(bs)} to ${String(hs)}`);
474
+ },
475
+ });
476
+ // Who wrote a field's text: a declaration about provenance, which keeps the field off events.
477
+ const outside = setDiff(b.outsideText, h.outsideText);
478
+ if (outside.added.length || outside.removed.length) {
479
+ push('minor', `${path}.outsideText`, `outside-written text: +[${outside.added.join(', ')}] -[${outside.removed.join(', ')}]`);
480
+ }
481
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(h)])].sort()) {
482
+ if (!ENTITY_KEYS.has(k))
483
+ wholeValue(push, `${path}.${k}`, b[k], h[k]);
484
+ }
485
+ }
486
+ const LIFECYCLE_KEYS = new Set(['field', 'initial', 'states']);
487
+ const STATE_KEYS = new Set(['on', 'allow', 'terminal']);
488
+ function classifyLifecycle(push, path, b, h) {
489
+ if (b.field !== h.field)
490
+ push('major', `${path}.field`, `state field changed from ${String(b.field)} to ${String(h.field)}`);
491
+ if (b.initial !== h.initial)
492
+ push('major', `${path}.initial`, `initial state changed from ${String(b.initial)} to ${String(h.initial)}`);
493
+ keyed(recordOf(b.states), recordOf(h.states), {
494
+ removed: (s) => push('major', `${path}.states.${s}`, 'state removed'),
495
+ added: (s) => push('minor', `${path}.states.${s}`, 'state added'),
496
+ both: (s, bs, hs) => {
497
+ const at = `${path}.states.${s}`;
498
+ const bS = recordOf(bs);
499
+ const hS = recordOf(hs);
500
+ keyed(recordOf(bS.on), recordOf(hS.on), {
501
+ removed: (op) => push('major', `${at}.on.${op}`, 'transition removed'),
502
+ added: (op) => push('minor', `${at}.on.${op}`, 'transition added'),
503
+ both: (op, bt, ht) => {
504
+ if (bt !== ht)
505
+ push('major', `${at}.on.${op}`, `now leads to ${String(ht)} instead of ${String(bt)}`);
506
+ },
507
+ });
508
+ const allow = setDiff(bS.allow, hS.allow);
509
+ if (allow.removed.length)
510
+ push('major', `${at}.allow`, `no longer allowed here: ${allow.removed.join(', ')}`);
511
+ if (allow.added.length)
512
+ push('minor', `${at}.allow`, `now allowed here: ${allow.added.join(', ')}`);
513
+ if (!!bS.terminal !== !!hS.terminal) {
514
+ push(hS.terminal ? 'major' : 'minor', `${at}.terminal`, hS.terminal ? 'became terminal' : 'is no longer terminal');
515
+ }
516
+ for (const k of [...new Set([...Object.keys(bS), ...Object.keys(hS)])].sort()) {
517
+ if (!STATE_KEYS.has(k))
518
+ wholeValue(push, `${at}.${k}`, bS[k], hS[k]);
519
+ }
520
+ },
521
+ });
522
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(h)])].sort()) {
523
+ if (!LIFECYCLE_KEYS.has(k))
524
+ wholeValue(push, `${path}.${k}`, b[k], h[k]);
525
+ }
526
+ }
527
+ // ─── openapi.json ─────────────────────────────────────────────────────────────────────────────
528
+ const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
529
+ /** Operation keys that only describe it. `operationId` is NOT one: a generated client is named by it. */
530
+ const OP_DOCS = new Set(['summary', 'description', 'tags', 'externalDocs', 'deprecated']);
531
+ const OP_KEYS = new Set([...OP_DOCS, 'parameters', 'requestBody', 'responses', 'operationId']);
532
+ export function classifyOpenApi(baseText, headText) {
533
+ const out = [];
534
+ const base = parseJson(baseText, 'openapi.json (base)');
535
+ const head = parseJson(headText, 'openapi.json');
536
+ if (presence('openapi', base, head, out))
537
+ return classification(out);
538
+ const push = pusher('openapi', out);
539
+ const b = base;
540
+ const h = head;
541
+ /** Each operation, refs resolved, plus whatever else a path item carries (path-level parameters). */
542
+ const surface = (doc) => {
543
+ const ops = {};
544
+ const extras = {};
545
+ for (const [p, item] of Object.entries(recordOf(doc.paths))) {
546
+ for (const [m, op] of Object.entries(recordOf(resolveRefs(item, doc)))) {
547
+ if (METHODS.includes(m))
548
+ ops[`${m.toUpperCase()} ${p}`] = recordOf(resolveRefs(op, doc));
549
+ else
550
+ (extras[p] ??= {})[m] = op;
551
+ }
552
+ }
553
+ return { ops, extras };
554
+ };
555
+ const bs = surface(b);
556
+ const hs = surface(h);
557
+ keyed(bs.ops, hs.ops, {
558
+ removed: (op) => push('major', op, 'operation removed'),
559
+ added: (op) => push('minor', op, 'operation added'),
560
+ both: (op, bo, ho) => classifyOperation(push, op, bo, ho),
561
+ });
562
+ for (const p of [...new Set([...Object.keys(bs.extras), ...Object.keys(hs.extras)])].sort()) {
563
+ wholeValue(push, p, bs.extras[p], hs.extras[p]);
564
+ }
565
+ // `info` describes the document. `paths` was judged above, and `components` is judged where
566
+ // it is referenced (an unreferenced component reaches no caller), except its security schemes,
567
+ // which change how every call authenticates.
568
+ if (canon(b.info, false) !== canon(h.info, false))
569
+ push('patch', 'info', 'documentation only');
570
+ wholeValue(push, 'components.securitySchemes', recordOf(b.components).securitySchemes, recordOf(h.components).securitySchemes);
571
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(h)])].sort()) {
572
+ if (k === 'paths' || k === 'components' || k === 'info')
573
+ continue;
574
+ wholeValue(push, k, b[k], h[k]);
575
+ }
576
+ return classification(out);
577
+ }
578
+ function classifyOperation(push, op, b, h) {
579
+ if (b.operationId !== h.operationId) {
580
+ push('major', `${op} operationId`, `renamed from ${String(b.operationId)} to ${String(h.operationId)}`);
581
+ }
582
+ for (const k of OP_DOCS) {
583
+ if (canon(b[k], false) !== canon(h[k], false))
584
+ push('patch', `${op} ${k}`, 'documentation only');
585
+ }
586
+ // Parameters, keyed by where they travel and their name.
587
+ const params = (o) => {
588
+ const found = {};
589
+ for (const p of Array.isArray(o.parameters) ? o.parameters : []) {
590
+ const r = recordOf(p);
591
+ found[`${String(r.in)} ${String(r.name)}`] = r;
592
+ }
593
+ return found;
594
+ };
595
+ keyed(params(b), params(h), {
596
+ removed: (p) => push('major', `${op} parameter ${p}`, 'removed'),
597
+ added: (p, hp) => hp.required === true
598
+ ? push('major', `${op} parameter ${p}`, 'added as required — a caller that omits it is refused')
599
+ : push('minor', `${op} parameter ${p}`, 'added'),
600
+ both: (p, bp, hp) => {
601
+ const at = `${op} parameter ${p}`;
602
+ if (!!bp.required !== !!hp.required)
603
+ push(hp.required ? 'major' : 'minor', at, hp.required ? 'is now required' : 'is now optional');
604
+ compareSchema(push, 'input', at, bp.schema, hp.schema);
605
+ const { schema: _a, required: _b, ...bRest } = bp;
606
+ const { schema: _c, required: _d, ...hRest } = hp;
607
+ wholeValue(push, at, bRest, hRest);
608
+ },
609
+ });
610
+ // The request body: what the caller sends.
611
+ const bb = b.requestBody === undefined ? undefined : recordOf(b.requestBody);
612
+ const hb = h.requestBody === undefined ? undefined : recordOf(h.requestBody);
613
+ const body = `${op} requestBody`;
614
+ if (bb && !hb)
615
+ push('major', body, 'removed');
616
+ else if (!bb && hb)
617
+ push(hb.required ? 'major' : 'minor', body, hb.required ? 'added as required' : 'added');
618
+ else if (bb && hb) {
619
+ if (!!bb.required !== !!hb.required)
620
+ push(hb.required ? 'major' : 'minor', body, hb.required ? 'is now required' : 'is now optional');
621
+ classifyContent(push, 'input', body, bb.content, hb.content);
622
+ const { content: _a, required: _b, ...bRest } = bb;
623
+ const { content: _c, required: _d, ...hRest } = hb;
624
+ wholeValue(push, body, bRest, hRest);
625
+ }
626
+ // Responses: what the caller reads.
627
+ keyed(recordOf(b.responses), recordOf(h.responses), {
628
+ removed: (status) => push('major', `${op} ${status}`, 'response removed'),
629
+ added: (status) => push('minor', `${op} ${status}`, 'response added'),
630
+ both: (status, br, hr) => {
631
+ const at = `${op} ${status}`;
632
+ const bR = recordOf(br);
633
+ const hR = recordOf(hr);
634
+ classifyContent(push, 'output', at, bR.content, hR.content);
635
+ const { content: _a, ...bRest } = bR;
636
+ const { content: _b, ...hRest } = hR;
637
+ wholeValue(push, at, bRest, hRest);
638
+ },
639
+ });
640
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(h)])].sort()) {
641
+ if (!OP_KEYS.has(k))
642
+ wholeValue(push, `${op} ${k}`, b[k], h[k]);
643
+ }
644
+ }
645
+ function classifyContent(push, dir, at, base, head) {
646
+ keyed(recordOf(base), recordOf(head), {
647
+ removed: (type) => push('major', `${at} ${type}`, 'media type removed'),
648
+ added: (type) => push('minor', `${at} ${type}`, 'media type added'),
649
+ both: (type, bm, hm) => {
650
+ const bM = recordOf(bm);
651
+ const hM = recordOf(hm);
652
+ compareSchema(push, dir, `${at} ${type}`, bM.schema, hM.schema);
653
+ const { schema: _a, ...bRest } = bM;
654
+ const { schema: _b, ...hRest } = hM;
655
+ wholeValue(push, `${at} ${type}`, bRest, hRest);
656
+ },
657
+ });
658
+ }
659
+ /**
660
+ * Sections whose content is derived from, or says nothing beyond, the ones read above: §3
661
+ * Coverage is §2 inverted, and the "not covered" notes are prose about the artifact itself.
662
+ */
663
+ const DERIVED_SECTIONS = [/^Coverage\b/, /^Not covered by this artifact/, /^No permission surface/];
664
+ const codesIn = (cell) => [...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1]).sort();
665
+ function rowsOf(section) {
666
+ const rows = [];
667
+ for (const line of section.split('\n')) {
668
+ const t = line.trim();
669
+ if (!t.startsWith('|'))
670
+ continue;
671
+ const cells = t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
672
+ if (cells.every((c) => /^:?-+:?$/.test(c)))
673
+ continue; // the separator
674
+ rows.push(cells);
675
+ }
676
+ return rows.slice(1); // the header row
677
+ }
678
+ /**
679
+ * Parse a `PERMISSIONS.md` that `tools/permission-diff.mts` emitted. Sections are found by
680
+ * their heading's words, not their number: the numbering shifts with which sections a vertical
681
+ * has (§4 is grant shapes in one file and "not covered" in another).
682
+ */
683
+ export function parsePermissions(text) {
684
+ const snap = {
685
+ registry: {},
686
+ roles: {},
687
+ grantShapes: {},
688
+ schedules: {},
689
+ unread: {},
690
+ };
691
+ const sections = text.split(/^## /m).slice(1);
692
+ for (const section of sections) {
693
+ const nl = section.indexOf('\n');
694
+ const heading = (nl === -1 ? section : section.slice(0, nl)).replace(/^\d+\.\s*/, '').trim();
695
+ // The FIRST code span names the row: a schedule's cell reads "`op` — purges a trashed `list`".
696
+ const firstKey = (cells) => /`([^`]+)`/.exec(cells[0] ?? '')?.[1] ?? cells[0] ?? '';
697
+ if (/^Registry\b/.test(heading)) {
698
+ for (const cells of rowsOf(section))
699
+ snap.registry[firstKey(cells)] = cells.slice(1);
700
+ }
701
+ else if (/^Roles\b/.test(heading)) {
702
+ for (const cells of rowsOf(section))
703
+ snap.roles[firstKey(cells)] = codesIn(cells[1] ?? '');
704
+ }
705
+ else if (/^Entity-narrowed grant shapes\b/.test(heading)) {
706
+ for (const cells of rowsOf(section))
707
+ snap.grantShapes[firstKey(cells)] = { keys: codesIn(cells[1] ?? ''), terms: cells.slice(2) };
708
+ }
709
+ else if (/^Scheduled work\b/.test(heading)) {
710
+ // | Operation | Cadence | System principal | Permissions |
711
+ for (const cells of rowsOf(section)) {
712
+ snap.schedules[firstKey(cells)] = { keys: codesIn(cells[cells.length - 1] ?? ''), terms: cells.slice(0, -1) };
713
+ }
714
+ }
715
+ else if (!DERIVED_SECTIONS.some((re) => re.test(heading))) {
716
+ snap.unread[heading] = section.slice(nl === -1 ? section.length : nl).trim();
717
+ }
718
+ }
719
+ return snap;
720
+ }
721
+ export function classifyPermissions(baseText, headText) {
722
+ const out = [];
723
+ if (presence('permissions', baseText, headText, out))
724
+ return classification(out);
725
+ const b = parsePermissions(baseText);
726
+ const h = parsePermissions(headText);
727
+ const push = pusher('permissions', out);
728
+ // Every permission change is at least minor and flagged: the human checkpoint reads both ways.
729
+ const flag = (path, detail) => push('minor', path, detail, true);
730
+ keyed(b.registry, h.registry, {
731
+ removed: (k) => flag(`key ${k}`, 'removed from the registry — whoever held it loses what it gated'),
732
+ added: (k) => flag(`key ${k}`, 'added to the registry'),
733
+ both: (k, bd, hd) => {
734
+ if (canon(bd, false) !== canon(hd, false))
735
+ push('patch', `key ${k}`, 'description or declaring module changed');
736
+ },
737
+ });
738
+ const holders = (what, base, head) => keyed(base, head, {
739
+ removed: (name, keys) => flag(`${what} ${name}`, `removed (held: ${keys.join(', ') || 'nothing'}) — narrowing`),
740
+ added: (name, keys) => flag(`${what} ${name}`, `added (holds: ${keys.join(', ') || 'nothing'}) — widening`),
741
+ both: (name, bk, hk) => {
742
+ const d = setDiff(bk, hk);
743
+ if (d.removed.length)
744
+ flag(`${what} ${name}`, `no longer holds ${d.removed.join(', ')} — narrowing`);
745
+ if (d.added.length)
746
+ flag(`${what} ${name}`, `now holds ${d.added.join(', ')} — widening`);
747
+ },
748
+ });
749
+ holders('role', b.roles, h.roles);
750
+ const keysOf = (m) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, v.keys]));
751
+ holders('entity grant shape', keysOf(b.grantShapes), keysOf(h.grantShapes));
752
+ holders('scheduled operation', keysOf(b.schedules), keysOf(h.schedules));
753
+ for (const [what, bm, hm] of [
754
+ ['entity grant shape', b.grantShapes, h.grantShapes],
755
+ ['scheduled operation', b.schedules, h.schedules],
756
+ ]) {
757
+ for (const name of Object.keys(bm)) {
758
+ if (name in hm && canon(bm[name].terms, false) !== canon(hm[name].terms, false)) {
759
+ flag(`${what} ${name}`, 'its terms changed (how it is given, bound or scheduled)');
760
+ }
761
+ }
762
+ }
763
+ for (const heading of [...new Set([...Object.keys(b.unread), ...Object.keys(h.unread)])].sort()) {
764
+ if (b.unread[heading] !== h.unread[heading]) {
765
+ push('major', `section "${heading}"`, 'changed (not a section this classifier reads, so judged as breaking)', true);
766
+ }
767
+ }
768
+ if (out.length === 0 && baseText !== headText)
769
+ push('patch', 'PERMISSIONS.md', 'prose only');
770
+ return classification(out);
771
+ }
772
+ export function classify(base, head) {
773
+ const changes = [
774
+ ...classifyModel(base.model, head.model).changes,
775
+ ...classifyOpenApi(base.openapi, head.openapi).changes,
776
+ ...classifyPermissions(base.permissions, head.permissions).changes,
777
+ ];
778
+ return classification(changes);
779
+ }
780
+ //# sourceMappingURL=bump.js.map