@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 +9 -0
- package/README.md +15 -2
- package/package.json +6 -6
- package/src/diff-operations.ts +30 -1
- package/src/errors.ts +33 -4
- package/src/schema.ts +6 -1
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.
|
|
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
|
|
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
|
+
"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
|
|
35
|
-
"@ultimat3/core": "19.1
|
|
36
|
-
"@ultimat3/entity": "19.1
|
|
37
|
-
"@ultimat3/jobs": "19.1
|
|
38
|
-
"@ultimat3/query": "19.1
|
|
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
|
}
|
package/src/diff-operations.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
/**
|
|
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
|