@ultimat3/manifest 2.0.0 → 4.0.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/src/diff.ts CHANGED
@@ -1,27 +1,26 @@
1
- // `diffManifest` — the contract diff `x verify` gates on.
1
+ // `diffManifest` — the contract diff `x verify` gates on. This file is the orchestrator: one
2
+ // classifier per section, in `diff-*.ts` beside it, and every section of `Manifest` reaches
3
+ // exactly one of them.
2
4
  //
3
- // Three classes, and the classification is the whole value:
4
- // breaking — an existing consumer stops working (a removal, a tightened input, a changed
5
- // output, a new or changed policy, a newly REQUIRED permission, a tightened rate
6
- // limit — anything that already shipped and now refuses a caller it served)
7
- // additive — a new capability; nothing that worked stops working
8
- // internal — visible in the file but not in the contract (a description, a cache tag, the
9
- // buildId itself)
10
5
  // `x verify` fails on a breaking change without a major version bump. Additive and internal
11
- // changes never fail, which is what makes the gate credible enough to leave on.
12
-
13
- import { isMcpExposed } from '@ultimat3/core';
14
- import { canonical } from './build';
15
- import type { ActionFact, JobFact, Manifest, QueryFact, RateLimitFact, RouteFact } from './schema';
16
-
17
- export type ChangeKind = 'breaking' | 'additive' | 'internal';
18
-
19
- export interface ManifestChange {
20
- readonly kind: ChangeKind;
21
- /** Dotted path into the manifest, e.g. `actions.publishPost.policy`. */
22
- readonly path: string;
23
- readonly detail: string;
24
- }
6
+ // changes never fail, which is what makes the gate credible enough to leave on. The three kinds
7
+ // are defined in `diff-change.ts`.
8
+ //
9
+ // EVERY section is read. Two of them were not until 2026-08 — `tasks` and `errorCodes`, alongside
10
+ // ten unclassified fields so deleting every scheduled task and every error code reported
11
+ // `[{ kind: 'internal', path: 'buildId' }]` and passed. `diff.test.ts` walks `ARRAY_SECTIONS` and
12
+ // fails on a section nothing here classifies.
13
+
14
+ import type { ManifestChange } from './diff-change';
15
+ import { diffNamedSet } from './diff-change';
16
+ import { diffEntities } from './diff-entities';
17
+ import { diffActions, diffQueries } from './diff-operations';
18
+ import { diffErrorCodes, diffPolicies } from './diff-registries';
19
+ import { diffRoutes } from './diff-routes';
20
+ import { diffJobs, diffTasks } from './diff-work';
21
+ import type { Manifest } from './schema';
22
+
23
+ export type { ChangeKind, ManifestChange } from './diff-change';
25
24
 
26
25
  export interface ManifestDiff {
27
26
  readonly changes: readonly ManifestChange[];
@@ -49,7 +48,10 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
49
48
  changes.push(...diffQueries(before.queries, after.queries));
50
49
  changes.push(...diffRoutes(before.routes, after.routes));
51
50
  changes.push(...diffJobs(before.jobs, after.jobs));
52
- changes.push(...diffEntities(before, after));
51
+ changes.push(...diffTasks(before.tasks, after.tasks));
52
+ changes.push(...diffEntities(before.entities, after.entities));
53
+ changes.push(...diffPolicies(before.policies, after.policies));
54
+ changes.push(...diffErrorCodes(before.errorCodes, after.errorCodes));
53
55
  changes.push(...diffNamedSet('permissions', before.permissions, after.permissions));
54
56
  changes.push(...diffNamedSet('locales', before.locales, after.locales, 'additive'));
55
57
 
@@ -64,396 +66,7 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
64
66
  };
65
67
  }
66
68
 
67
- function diffActions(
68
- before: readonly ActionFact[],
69
- after: readonly ActionFact[],
70
- ): readonly ManifestChange[] {
71
- const changes: ManifestChange[] = [];
72
- const afterByName = index(after, (a) => a.name);
73
- const beforeByName = index(before, (a) => a.name);
74
-
75
- for (const action of before) {
76
- const next = afterByName.get(action.name);
77
- const path = `actions.${action.name}`;
78
- if (next === undefined) {
79
- // The canonical breaking change: a caller that compiled yesterday no longer does.
80
- changes.push({ kind: 'breaking', path, detail: 'action removed' });
81
- continue;
82
- }
83
- if (canonical(action.input) !== canonical(next.input)) {
84
- changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
85
- }
86
- if (canonical(action.output) !== canonical(next.output)) {
87
- changes.push({ kind: 'breaking', path: `${path}.output`, detail: 'output schema changed' });
88
- }
89
- if (action.policy !== next.policy) {
90
- changes.push({
91
- kind: 'breaking',
92
- path: `${path}.policy`,
93
- detail: `policy ${action.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
94
- });
95
- }
96
- // Through `isMcpExposed`, not the raw field: `before` is a file parsed from disk, so an
97
- // older or hand-trimmed manifest can carry an absent, `null` or non-boolean `expose` that
98
- // `!==` would read as a change and classify from. One predicate, the same one the tool
99
- // projection asks, is what makes this verdict match what the surface actually serves.
100
- const exposed = isMcpExposed(action.mcp);
101
- const nextExposed = isMcpExposed(next.mcp);
102
- if (exposed !== nextExposed) {
103
- // Widening the surface is additive; withdrawing a tool an agent depends on is not.
104
- changes.push({
105
- kind: nextExposed ? 'additive' : 'breaking',
106
- path: `${path}.mcp.expose`,
107
- detail: `mcp exposure ${String(exposed)} -> ${String(nextExposed)}`,
108
- });
109
- }
110
- changes.push(...diffPermissions(path, action, next));
111
- changes.push(...diffRateLimit(path, action, next));
112
- if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) {
113
- changes.push({
114
- kind: 'internal',
115
- path: `${path}.cacheInvalidates`,
116
- detail: 'cache tags changed',
117
- });
118
- }
119
- }
120
- for (const action of after) {
121
- if (!beforeByName.has(action.name)) {
122
- changes.push({ kind: 'additive', path: `actions.${action.name}`, detail: 'action added' });
123
- }
124
- }
125
- return changes;
126
- }
127
-
128
- function diffQueries(
129
- before: readonly QueryFact[],
130
- after: readonly QueryFact[],
131
- ): readonly ManifestChange[] {
132
- const changes: ManifestChange[] = [];
133
- const afterByName = index(after, (q) => q.name);
134
- const beforeByName = index(before, (q) => q.name);
135
-
136
- for (const query of before) {
137
- const next = afterByName.get(query.name);
138
- const path = `queries.${query.name}`;
139
- if (next === undefined) {
140
- changes.push({ kind: 'breaking', path, detail: 'query removed' });
141
- continue;
142
- }
143
- if (canonical(query.input) !== canonical(next.input)) {
144
- changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
145
- }
146
- if (query.policy !== next.policy) {
147
- changes.push({
148
- kind: 'breaking',
149
- path: `${path}.policy`,
150
- detail: `policy ${query.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
151
- });
152
- }
153
- changes.push(...diffPermissions(path, query, next));
154
- if (query.live !== next.live) {
155
- // Losing live-ness breaks subscribers; gaining it breaks nobody.
156
- changes.push({
157
- kind: next.live ? 'additive' : 'breaking',
158
- path: `${path}.live`,
159
- detail: `live ${String(query.live)} -> ${String(next.live)}`,
160
- });
161
- }
162
- }
163
- for (const query of after) {
164
- if (!beforeByName.has(query.name)) {
165
- changes.push({ kind: 'additive', path: `queries.${query.name}`, detail: 'query added' });
166
- }
167
- }
168
- return changes;
169
- }
170
-
171
- /**
172
- * The permissions an operation REQUIRES, and the direction each move points.
173
- *
174
- * Gaining one is breaking: every caller holding yesterday's grant set is refused by an operation
175
- * that served them, and the failure arrives at runtime as a 403 with nothing in the build that
176
- * said so. Losing one is additive — nothing that worked stops working — but it is still reported,
177
- * because a grant quietly dropped from an operation is a widening of access a reviewer has to see.
178
- *
179
- * Matched on `permissions`, never `policy`: `policy` is a display label, and a composite's label
180
- * (`and(post:publish, org:administer)`) equals no permission, so a rule reading it would call
181
- * every non-trivially-guarded operation unchanged while both of its real grants moved.
182
- */
183
- function diffPermissions(
184
- path: string,
185
- before: ActionFact | QueryFact,
186
- after: ActionFact | QueryFact,
187
- ): readonly ManifestChange[] {
188
- const declared = readPermissions(before);
189
- const next = readPermissions(after);
190
- // Absence is no evidence, on either side. Unlike `mcp.expose` there is no value to fold it
191
- // into: `[]` asserts "this operation requires nothing", so reading an absent field as `[]`
192
- // would report every permission of every operation as newly required the first time an app
193
- // diffs against a manifest written before the field existed — a wall of false breakings for
194
- // an upgrade that changed no authorization at all.
195
- if (declared === undefined || next === undefined) return [];
196
-
197
- const changes: ManifestChange[] = [];
198
- const declaredSet = new Set(declared);
199
- const nextSet = new Set(next);
200
- for (const permission of next) {
201
- if (!declaredSet.has(permission)) {
202
- changes.push({
203
- kind: 'breaking',
204
- path: `${path}.permissions.${permission}`,
205
- detail: 'now required; callers granted the old set are refused',
206
- });
207
- }
208
- }
209
- for (const permission of declared) {
210
- if (!nextSet.has(permission)) {
211
- changes.push({
212
- kind: 'additive',
213
- path: `${path}.permissions.${permission}`,
214
- detail: 'no longer required; access widened',
215
- });
216
- }
217
- }
218
- return changes;
219
- }
220
-
221
- /** The list as the FILE carries it, or `undefined` when it carries nothing this can compare. */
222
- function readPermissions(fact: ActionFact | QueryFact): readonly string[] | undefined {
223
- const value: unknown = fact.permissions;
224
- if (!Array.isArray(value)) return undefined;
225
- return value.every((entry) => typeof entry === 'string')
226
- ? (value as readonly string[])
227
- : undefined;
228
- }
229
-
230
- /** No declaration at all, a declaration, or one this reader cannot make sense of. */
231
- type RateLimitReading = RateLimitFact | 'none' | 'unreadable';
232
-
233
- /**
234
- * A tightened limit refuses a caller the old pair served, which is the definition of breaking —
235
- * and it is the one contract change that leaves every schema in the manifest untouched, so
236
- * nothing else here can see it. Introducing a limit where there was none is the same event at
237
- * its extreme: a client that was never throttled now can be.
238
- */
239
- function diffRateLimit(
240
- path: string,
241
- before: ActionFact,
242
- after: ActionFact,
243
- ): readonly ManifestChange[] {
244
- const declared = readRateLimit(before);
245
- const next = readRateLimit(after);
246
- if (declared === 'unreadable' || next === 'unreadable') return [];
247
- const at = `${path}.rateLimit`;
248
-
249
- if (declared === 'none') {
250
- if (next === 'none') return [];
251
- return [
252
- {
253
- kind: 'breaking',
254
- path: at,
255
- detail: `rate limit introduced (${render(next)}); an unthrottled caller can now be refused`,
256
- },
257
- ];
258
- }
259
- if (next === 'none') {
260
- return [{ kind: 'additive', path: at, detail: `rate limit removed (was ${render(declared)})` }];
261
- }
262
- if (tighter(declared, next)) {
263
- return [
264
- {
265
- kind: 'breaking',
266
- path: at,
267
- detail: `rate limit tightened ${render(declared)} -> ${render(next)}; callers at the old rate are refused`,
268
- },
269
- ];
270
- }
271
- if (tighter(next, declared)) {
272
- return [
273
- {
274
- kind: 'additive',
275
- path: at,
276
- detail: `rate limit loosened ${render(declared)} -> ${render(next)}`,
277
- },
278
- ];
279
- }
280
- return [];
281
- }
282
-
283
- /**
284
- * Both halves, because either one alone refuses somebody: `limit` is the burst a caller may
285
- * spend at once and `limit / windowMs` is the rate it refills at, so a larger burst on a slower
286
- * refill still turns away a client the old pair served. Cross-multiplied rather than divided —
287
- * both windows are positive, and an exact integer comparison cannot invent a change out of a
288
- * rounding difference in a file that is diffed on every build.
289
- */
290
- const tighter = (from: RateLimitFact, to: RateLimitFact): boolean =>
291
- to.limit < from.limit || to.limit * from.windowMs < from.limit * to.windowMs;
292
-
293
- const render = (limit: RateLimitFact): string => `${limit.limit}/${limit.windowMs}ms`;
294
-
295
- function readRateLimit(fact: ActionFact): RateLimitReading {
296
- const value: unknown = fact.rateLimit;
297
- if (value === undefined || value === null) return 'none';
298
- if (typeof value !== 'object') return 'unreadable';
299
- const record = value as Record<string, unknown>;
300
- const limit = record['limit'];
301
- const windowMs = record['windowMs'];
302
- // The same two conditions `toBucket` enforces at mount: a non-positive window is an infinite
303
- // refill and a sub-token limit closes the endpoint, so neither describes a limit to compare.
304
- if (!positive(limit) || !positive(windowMs)) return 'unreadable';
305
- return { limit, windowMs };
306
- }
307
-
308
- const positive = (value: unknown): value is number =>
309
- typeof value === 'number' && Number.isFinite(value) && value > 0;
310
-
311
- function diffRoutes(
312
- before: readonly RouteFact[],
313
- after: readonly RouteFact[],
314
- ): readonly ManifestChange[] {
315
- const changes: ManifestChange[] = [];
316
- const afterByUrl = index(after, (r) => r.url);
317
- const beforeByUrl = index(before, (r) => r.url);
318
-
319
- for (const route of before) {
320
- const next = afterByUrl.get(route.url);
321
- if (next === undefined) {
322
- // A removed URL is a 404 for anyone holding a link to it.
323
- changes.push({ kind: 'breaking', path: `routes.${route.url}`, detail: 'route removed' });
324
- continue;
325
- }
326
- if (route.render !== next.render) {
327
- changes.push({
328
- kind: 'internal',
329
- path: `routes.${route.url}.render`,
330
- detail: `render ${route.render} -> ${next.render}`,
331
- });
332
- }
333
- }
334
- for (const route of after) {
335
- if (!beforeByUrl.has(route.url)) {
336
- changes.push({ kind: 'additive', path: `routes.${route.url}`, detail: 'route added' });
337
- }
338
- }
339
- return changes;
340
- }
341
-
342
- function diffJobs(
343
- before: readonly JobFact[],
344
- after: readonly JobFact[],
345
- ): readonly ManifestChange[] {
346
- const changes: ManifestChange[] = [];
347
- const afterByName = index(after, (j) => j.name);
348
- const beforeByName = index(before, (j) => j.name);
349
-
350
- for (const job of before) {
351
- const next = afterByName.get(job.name);
352
- if (next === undefined) {
353
- // Enqueued-but-undeliverable work is silent data loss, so a removal is breaking.
354
- changes.push({ kind: 'breaking', path: `jobs.${job.name}`, detail: 'job removed' });
355
- continue;
356
- }
357
- if (canonical(job.input) !== canonical(next.input)) {
358
- changes.push({
359
- kind: 'breaking',
360
- path: `jobs.${job.name}.input`,
361
- detail: 'input schema changed; in-flight payloads will not parse',
362
- });
363
- }
364
- if (canonical(job.steps) !== canonical(next.steps)) {
365
- changes.push({
366
- kind: 'internal',
367
- path: `jobs.${job.name}.steps`,
368
- detail: 'steps changed; resumed runs may replay differently',
369
- });
370
- }
371
- }
372
- for (const job of after) {
373
- if (!beforeByName.has(job.name)) {
374
- changes.push({ kind: 'additive', path: `jobs.${job.name}`, detail: 'job added' });
375
- }
376
- }
377
- return changes;
378
- }
379
-
380
- function diffEntities(before: Manifest, after: Manifest): readonly ManifestChange[] {
381
- const changes: ManifestChange[] = [];
382
- const afterByName = index(after.entities, (e) => e.name);
383
- const beforeByName = index(before.entities, (e) => e.name);
384
-
385
- for (const entity of before.entities) {
386
- const next = afterByName.get(entity.name);
387
- if (next === undefined) {
388
- changes.push({ kind: 'breaking', path: `entities.${entity.name}`, detail: 'entity removed' });
389
- continue;
390
- }
391
- const nextColumns = index(next.columns, (c) => c.name);
392
- for (const column of entity.columns) {
393
- const nextColumn = nextColumns.get(column.name);
394
- const path = `entities.${entity.name}.columns.${column.name}`;
395
- if (nextColumn === undefined) {
396
- changes.push({ kind: 'breaking', path, detail: 'column removed' });
397
- continue;
398
- }
399
- if (column.type !== nextColumn.type) {
400
- changes.push({
401
- kind: 'breaking',
402
- path: `${path}.type`,
403
- detail: `${column.type} -> ${nextColumn.type}`,
404
- });
405
- }
406
- if (column.nullable && !nextColumn.nullable) {
407
- // Tightening nullability rejects rows that were valid a moment ago.
408
- changes.push({ kind: 'breaking', path: `${path}.nullable`, detail: 'became NOT NULL' });
409
- }
410
- }
411
- const beforeColumns = index(entity.columns, (c) => c.name);
412
- for (const column of next.columns) {
413
- if (!beforeColumns.has(column.name)) {
414
- changes.push({
415
- kind: column.nullable ? 'additive' : 'breaking',
416
- path: `entities.${entity.name}.columns.${column.name}`,
417
- detail: column.nullable ? 'column added' : 'NOT NULL column added with no default',
418
- });
419
- }
420
- }
421
- }
422
- for (const entity of after.entities) {
423
- if (!beforeByName.has(entity.name)) {
424
- changes.push({ kind: 'additive', path: `entities.${entity.name}`, detail: 'entity added' });
425
- }
426
- }
427
- return changes;
428
- }
429
-
430
- function diffNamedSet(
431
- path: string,
432
- before: readonly string[],
433
- after: readonly string[],
434
- removalKind: ChangeKind = 'breaking',
435
- ): readonly ManifestChange[] {
436
- const changes: ManifestChange[] = [];
437
- const afterSet = new Set(after);
438
- const beforeSet = new Set(before);
439
- for (const name of before) {
440
- if (!afterSet.has(name)) {
441
- changes.push({ kind: removalKind, path: `${path}.${name}`, detail: 'removed' });
442
- }
443
- }
444
- for (const name of after) {
445
- if (!beforeSet.has(name)) {
446
- changes.push({ kind: 'additive', path: `${path}.${name}`, detail: 'added' });
447
- }
448
- }
449
- return changes;
450
- }
451
-
452
- function index<T>(items: readonly T[], key: (item: T) => string): Map<string, T> {
453
- return new Map(items.map((item) => [key(item), item]));
454
- }
455
-
456
69
  /** One line per change, `--json`-free, for a terminal summary. */
457
70
  export function formatDiff(diff: ManifestDiff): readonly string[] {
458
- return diff.changes.map((c) => `${c.kind.padEnd(8)} ${c.path}: ${c.detail}`);
71
+ return diff.changes.map((c: ManifestChange) => `${c.kind.padEnd(8)} ${c.path}: ${c.detail}`);
459
72
  }
package/src/emit.ts CHANGED
@@ -54,6 +54,11 @@ export interface EmitResult {
54
54
  export async function emitManifest(input: EmitInput): Promise<EmitResult> {
55
55
  const path = input.path ?? `./${MANIFEST_FILENAME}`;
56
56
  const text = manifestJson(input.manifest);
57
+ // The bytes on disk, never `text.length`: a manifest carries the APP's strings — a locale name,
58
+ // an entity description, a title in the app's own language — and `String.length` counts UTF-16
59
+ // code units, so it under-reports every one of them and over-reports nothing. `agents-md.ts`
60
+ // measures the same quantity the same way.
61
+ const bytes = Buffer.byteLength(text, 'utf8');
57
62
 
58
63
  if (input.stdout === true) {
59
64
  // stdout is the wire in `--json` mode; nothing else may be written to it — and the write is
@@ -61,16 +66,16 @@ export async function emitManifest(input: EmitInput): Promise<EmitResult> {
61
66
  // is still queued. Unawaited, the largest payload the CLI prints was the one that lost bytes,
62
67
  // exactly as `scripts/stdout-truncation.test.ts` documents for the same bug elsewhere.
63
68
  await Bun.write(Bun.stdout, text);
64
- return { path, bytes: text.length, buildId: input.manifest.buildId, changed: false };
69
+ return { path, bytes, buildId: input.manifest.buildId, changed: false };
65
70
  }
66
71
 
67
72
  const existing = await readIfExists(path);
68
73
  // Skip the write when nothing moved: an unchanged mtime keeps file watchers quiet.
69
74
  if (existing === text) {
70
- return { path, bytes: text.length, buildId: input.manifest.buildId, changed: false };
75
+ return { path, bytes, buildId: input.manifest.buildId, changed: false };
71
76
  }
72
77
  await Bun.write(path, text);
73
- return { path, bytes: text.length, buildId: input.manifest.buildId, changed: true };
78
+ return { path, bytes, buildId: input.manifest.buildId, changed: true };
74
79
  }
75
80
 
76
81
  /** Read and structurally validate a manifest. `undefined` when absent or unparseable. */
package/src/schema.ts CHANGED
@@ -158,8 +158,13 @@ export function isCompatible(manifest: { manifestVersion: number }): boolean {
158
158
  return manifest.manifestVersion === MANIFEST_VERSION;
159
159
  }
160
160
 
161
- /** Every top-level section the type declares as an array. Checked, never assumed. */
162
- const ARRAY_SECTIONS: readonly (keyof Manifest)[] = [
161
+ /**
162
+ * Every top-level section the type declares as an array. Checked, never assumed — and exported
163
+ * because `diff.test.ts` walks it to prove each one is classified: a section added here with no
164
+ * rule in the diff is a failing test, which is the enforcement half of "a new manifest field ⇒ a
165
+ * diff rule for it".
166
+ */
167
+ export const ARRAY_SECTIONS = [
163
168
  'routes',
164
169
  'entities',
165
170
  'actions',
@@ -170,7 +175,7 @@ const ARRAY_SECTIONS: readonly (keyof Manifest)[] = [
170
175
  'permissions',
171
176
  'locales',
172
177
  'errorCodes',
173
- ];
178
+ ] as const satisfies readonly (keyof Manifest)[];
174
179
 
175
180
  /**
176
181
  * Structural check for a value read off disk, before it is trusted as a `Manifest`.