@ultimat3/manifest 19.1.3 → 19.3.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/CLAUDE.md CHANGED
@@ -113,6 +113,15 @@ by the CLI, not imported.
113
113
  route's `surface`/`offline`/`hydrate`, and a query's `cacheTags` — and every one of them
114
114
  reported exactly `[{ kind: 'internal', path: 'buildId' }]` with `hasBreaking: false`. A new
115
115
  SECTION needs its own file beside the others; a new FIELD joins the file its section owns.
116
+ - **`ActionFact.mutator` is classified, and LOSING it is breaking** (`As of 2026-09`). It was
117
+ written by `sources.ts` and read by no `diff-*.ts` rule — two manifests differing only in an
118
+ action that stopped being a mutator answered `[{ kind: 'internal', path: 'buildId' }]`, the exact
119
+ failure the rule above exists for. A mutator is a client-contract CAPABILITY (it decides the HTTP
120
+ method and the idempotency the typed client and OpenAPI publish), so losing it refuses callers
121
+ written against it and gaining it refuses nobody. Folded with `=== true` rather than compared
122
+ through `diffScalar`: `sources.ts` writes the field only when true, so absence IS `false` here,
123
+ and an older manifest can therefore only under-report a mutator as newly GAINED, which is
124
+ additive. `mcp.description` joins it as `internal` — visible in the file, in no contract.
116
125
  - **The axis is what a change refuses, not how it reads.** Something that rejects input that was
117
126
  valid yesterday is breaking (an invariant added, a NOT NULL, a gained permission, a gained
118
127
  enforcement site, a lowered `retry.attempts`); something that accepts more is additive and
package/README.md CHANGED
@@ -74,15 +74,28 @@ permissions), `diff-rate-limit.ts`, `diff-entities.ts`, `diff-work.ts` (jobs, ta
74
74
  `diff-change.ts`. `diff.ts` is the orchestrator and nothing else.
75
75
 
76
76
  `verifyContract()` is the gate: a breaking change fails unless the app's **major** version
77
- moved. An unparseable version counts as "not bumped"fail-closed.
77
+ moved. The version is the app's `package.json` `version`, never `app.config.ts` `AppConfig`
78
+ has no `version` field. An unparseable version counts as "not bumped" — fail-closed.
78
79
 
79
80
  ```
80
81
  X_MANIFEST_BREAKING: contract broke without a version bump
81
82
  cause: 1 breaking change(s) from 1.4.2 to 1.5.0 with no major version bump:
82
83
  actions.publishPost: action removed
83
- fix: bump the major version in app.config.ts, or restore the removed contract
84
+ fix: bump the major version in package.json - the leading integer, so 1.4.2 becomes
85
+ 2.0.0 - or restore the removed contract
84
86
  ```
85
87
 
88
+ `from === to` is the shape this fires in first, and it gets its own cause: the drift gate forces
89
+ both sides equal in any green state, so a comparison that "moved" from 0.1.0 to 0.1.0 describes
90
+ nothing. It reads `N breaking change(s) against the committed x.manifest.json, with package.json
91
+ unchanged at 0.1.0` instead.
92
+
93
+ An app still at **0.x** gets its own fix line too. `majorOf` compares leading integers only, so
94
+ `0.1.0 -> 0.2.0` does not satisfy the gate and only `1.0.0` does — which is a strange demand of an
95
+ app `x new` scaffolded at `0.1.0` with no published clients. That branch names the two honest
96
+ actions: re-commit the baseline with `x manifest`, or `bun pm pkg set version=1.0.0` if the app
97
+ really does have clients to keep a promise to.
98
+
86
99
  ## AGENTS.md: validated, never generated
87
100
 
88
101
  `checkAgentsMd()` / `assertAgentsMd()` verify that a **hand-written** `AGENTS.md` exists and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "19.1.3",
3
+ "version": "19.3.1",
4
4
  "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "19.1.3",
35
- "@ultimat3/core": "19.1.3",
36
- "@ultimat3/entity": "19.1.3",
37
- "@ultimat3/jobs": "19.1.3",
38
- "@ultimat3/query": "19.1.3"
34
+ "@ultimat3/action": "19.3.1",
35
+ "@ultimat3/core": "19.3.1",
36
+ "@ultimat3/entity": "19.3.1",
37
+ "@ultimat3/jobs": "19.3.1",
38
+ "@ultimat3/query": "19.3.1"
39
39
  }
40
40
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { canonicalJson, isMcpExposed } from '@ultimat3/core';
4
4
  import type { ManifestChange } from './diff-change';
5
- import { index } from './diff-change';
5
+ import { diffScalar, index } from './diff-change';
6
6
  import { diffRateLimit } from './diff-rate-limit';
7
7
  import type { ActionFact, QueryFact } from './schema';
8
8
 
@@ -49,6 +49,35 @@ export function diffActions(
49
49
  detail: `mcp exposure ${String(exposed)} -> ${String(nextExposed)}`,
50
50
  });
51
51
  }
52
+ // `?.`, because `before` is a file parsed off DISK: a hand-trimmed or older manifest can carry
53
+ // no `mcp` block at all, and a description is not worth a throw out of the differ.
54
+ changes.push(
55
+ ...diffScalar(
56
+ 'internal',
57
+ `${path}.mcp.description`,
58
+ action.mcp?.description,
59
+ next.mcp?.description,
60
+ () => 'description changed',
61
+ ),
62
+ );
63
+ // Written only when TRUE (`sources.ts`), so absence IS `false` here — a fold, the way
64
+ // `isMcpExposed` folds `expose`, and deliberately not `diffScalar`'s "absence is no evidence":
65
+ // the value this field is absent FOR is the value it means. A manifest written before the
66
+ // field existed can therefore only under-report a mutator as newly gained, which is additive
67
+ // and not a wall of false breakings.
68
+ //
69
+ // The direction is the decision: a mutator is a client-contract CAPABILITY, not a label — it
70
+ // decides the HTTP method and the idempotency the typed client and the OpenAPI document
71
+ // publish — so an action that stops being one refuses callers written against it.
72
+ const mutator = action.mutator === true;
73
+ const nextMutator = next.mutator === true;
74
+ if (mutator !== nextMutator) {
75
+ changes.push({
76
+ kind: nextMutator ? 'additive' : 'breaking',
77
+ path: `${path}.mutator`,
78
+ detail: `mutator ${String(mutator)} -> ${String(nextMutator)}`,
79
+ });
80
+ }
52
81
  changes.push(...diffPermissions(path, action, next));
53
82
  changes.push(...diffRateLimit(path, action, next));
54
83
  if (canonicalJson(action.cacheInvalidates) !== canonicalJson(next.cacheInvalidates)) {
package/src/errors.ts CHANGED
@@ -49,15 +49,44 @@ export class ManifestDriftError extends UltimateError {
49
49
  }
50
50
  }
51
51
 
52
- /** A breaking contract change landed without a version bump. */
52
+ /**
53
+ * Whether this app has ever published a major. NOT `verify.ts`'s `majorOf`, which DECIDES the gate
54
+ * and is fail-closed on an unparseable version; this one only picks which sentence the reader gets,
55
+ * and a version it cannot recognise (`"0"`, `"next"`) falls to the `else` branch — the stricter of
56
+ * the two instructions, so a guess is never the permissive one.
57
+ */
58
+ const neverShippedAMajor = (version: string): boolean => version.trim().startsWith('0.');
59
+
60
+ /**
61
+ * A breaking contract change landed without a version bump.
62
+ *
63
+ * Two causes and two fixes, because the FIRST time this fires it fires in a shape the single
64
+ * message described wrongly, twice over.
65
+ *
66
+ * `from === to` is the guaranteed first-fire shape, not an edge case: the drift gate forces the
67
+ * committed manifest to match the code in any green state, so both sides carry the same
68
+ * `package.json` version and `from 0.1.0 to 0.1.0` reads as a comparison that moved when nothing
69
+ * did. And `x new` scaffolds at `0.1.0`, where the instruction to bump the major is a demand for
70
+ * `1.0.0` from an app with no published clients — `0.2.0` does not satisfy the gate, because
71
+ * `majorOf` compares leading integers only.
72
+ *
73
+ * The file was wrong too: `AppConfig` has no `version` field and `defineConfig` excess-property-
74
+ * checks its literal, so "bump the major version in app.config.ts" fails typecheck when followed
75
+ * literally. The version is read from `package.json` (`app-manifest.ts`).
76
+ */
53
77
  export class ManifestBreakingError extends UltimateError {
54
78
  constructor(input: { changes: readonly string[]; from: string; to: string }) {
55
79
  super({
56
80
  code: 'X_MANIFEST_BREAKING',
57
81
  cause:
58
- `${input.changes.length} breaking change(s) from ${input.from} to ${input.to} ` +
59
- `with no major version bump: ${summarize(input.changes)}`,
60
- fix: 'bump the major version in app.config.ts, or restore the removed contract',
82
+ input.from === input.to
83
+ ? `${input.changes.length} breaking change(s) against the committed x.manifest.json, ` +
84
+ `with package.json unchanged at ${input.from}: ${summarize(input.changes)}`
85
+ : `${input.changes.length} breaking change(s) from ${input.from} to ${input.to} ` +
86
+ `with no major version bump: ${summarize(input.changes)}`,
87
+ fix: neverShippedAMajor(input.from)
88
+ ? 'a 0.x app has published no compatibility promise, and only 1.0.0 satisfies this gate — 0.2.0 does not: re-commit the baseline with x manifest, or bun pm pkg set version=1.0.0 in package.json'
89
+ : 'bump the major version in package.json — the leading integer, so 1.4.2 becomes 2.0.0 — or restore the removed contract',
61
90
  });
62
91
  }
63
92
  }
package/src/schema.ts CHANGED
@@ -140,7 +140,12 @@ export interface ErrorCodeFact {
140
140
  export interface Manifest {
141
141
  /** Shape version. A reader checks this before anything else. */
142
142
  readonly manifestVersion: number;
143
- /** App name and semver from `app.config.ts`. Drives the breaking-change gate. */
143
+ /**
144
+ * App name and semver from the app's `package.json` (`app-manifest.ts`'s `appIdentity`), never
145
+ * from `app.config.ts` — `AppConfig` has no `version` field and `defineConfig`
146
+ * excess-property-checks its literal, so an instruction to edit one there fails typecheck.
147
+ * Drives the breaking-change gate.
148
+ */
144
149
  readonly app: { readonly name: string; readonly version: string };
145
150
  /**
146
151
  * Content hash of everything below. Deterministic — NOT a timestamp and not a git sha, so