@ultimat3/manifest 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @ultimat3/manifest 📋
2
+
3
+ Generated facts. `x.manifest.json` is emitted from code, committed, and diffed in review —
4
+ it is how an agent learns what the app *is* without reading every file.
5
+
6
+ ```ts
7
+ import { buildManifest, emitManifest, assertNoDrift, verifyContract } from '@ultimat3/manifest';
8
+
9
+ const manifest = buildManifest(frameworkSources({ app, routes, policies }));
10
+ await emitManifest({ manifest }); // x manifest
11
+ await assertNoDrift({ manifest }); // x verify
12
+ verifyContract({ before: committed, after: manifest });
13
+ ```
14
+
15
+ ## What it contains
16
+
17
+ | Section | Facts |
18
+ |---|---|
19
+ | `routes` | url, render mode, offline strategy, hydrate, revalidate tags, budget |
20
+ | `entities` | table, columns (type, nullability, PK, FK), named invariants |
21
+ | `actions` | input + output schema, policy, cache invalidations, MCP exposure, `mutator` when it is one |
22
+ | `queries` | input schema, policy, live, cache tags |
23
+ | `jobs` | input schema, queue, retry policy, step names |
24
+ | `tasks` | cron, tz, jobs enqueued |
25
+ | `policies` | permission, where enforced |
26
+ | `permissions` | **derived** from policies + primitives, never declared twice |
27
+ | `locales`, `errorCodes` | catalogs a tool can enumerate |
28
+
29
+ Plus `manifestVersion` (shape version, so a reader can check compatibility), `app`, and
30
+ `buildId`.
31
+
32
+ ## Determinism
33
+
34
+ The file is committed and reviewed, so two builds of the same tree must produce identical
35
+ bytes. Enforced, not hoped for:
36
+
37
+ - **No timestamps, no git sha, no hostname, no build counter.**
38
+ - Every collection is sorted by a stable key before writing — `Map`/`Set` iteration order is
39
+ insertion order, and insertion order depends on module load order, which depends on the
40
+ filesystem.
41
+ - Object keys are written in a fixed order, not `JSON.stringify` order, so reordering a
42
+ struct literal produces no diff.
43
+ - `buildId` is a sha256 of the canonical body, so it changes if and only if a fact changed —
44
+ and `verifyBuildId()` re-derives it from the file, catching a hand edit.
45
+ - Job `steps` keep declared order. A job's steps are a sequence, not a set.
46
+
47
+ A manifest that churns on every build trains reviewers to ignore its diff, which defeats the
48
+ whole mechanism.
49
+
50
+ ## The contract diff
51
+
52
+ `diffManifest(before, after)` classifies every change:
53
+
54
+ | Class | Examples |
55
+ |---|---|
56
+ | **breaking** | action/query/route/job/entity removed; input or output schema changed; policy changed; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; live query became non-live |
57
+ | **additive** | primitive added; nullable column added; MCP exposure granted; locale added |
58
+ | **internal** | cache tags changed; render mode changed; job steps reordered; `buildId` |
59
+
60
+ `verifyContract()` is the gate: a breaking change fails unless the app's **major** version
61
+ moved. An unparseable version counts as "not bumped" — fail-closed.
62
+
63
+ ```
64
+ X_MANIFEST_BREAKING: contract broke without a version bump
65
+ cause: 1 breaking change(s) from 1.4.2 to 1.5.0 with no major version bump:
66
+ actions.publishPost: action removed
67
+ fix: bump the major version in app.config.ts, or restore the removed contract
68
+ ```
69
+
70
+ ## AGENTS.md: validated, never generated
71
+
72
+ `checkAgentsMd()` / `assertAgentsMd()` verify that a **hand-written** `AGENTS.md` exists and
73
+ is under 12kB. They do not generate prose, and there is deliberately no generator to point at.
74
+
75
+ Research shows LLM-generated context files *reduce* task success and add steps: the prose
76
+ reads plausibly, drifts from reality the moment anything changes, and an agent trusts it over
77
+ the code. So the split is fixed:
78
+
79
+ - **facts are generated** → `x.manifest.json`, regenerated every build;
80
+ - **conventions are human-authored** → `AGENTS.md`, short enough to be read every time.
81
+
82
+ The checker warns (never fails) when `AGENTS.md` starts tabulating schema or route facts, runs
83
+ past 200 lines, or claims to be generated. Those warnings ride in the step's `output`, so
84
+ `x verify --json` carries them for a human to judge.
85
+
86
+ `x verify` runs `assertAgentsMd()` inside its `manifest` step — the same step that checks the
87
+ generated half — and unlike the drift check it applies everywhere, including a repo that has
88
+ never run `x manifest`. Enforced, not documented: both codes below can actually fail a build.
89
+
90
+ ## Errors
91
+
92
+ | Code | Meaning | Fix |
93
+ |---|---|---|
94
+ | `X_MANIFEST_DRIFT` | committed file no longer matches the code, or was hand-edited so its `buildId` no longer hashes its own body | `x manifest` |
95
+ | `X_MANIFEST_BREAKING` | contract broke with no major bump | bump the version, or restore |
96
+ | `X_AGENTS_MD_MISSING` | no `AGENTS.md` | write one by hand |
97
+ | `X_AGENTS_MD_TOO_LARGE` | over the byte budget | move facts to `x.manifest.json` |
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@ultimat3/manifest",
3
+ "version": "1.0.0",
4
+ "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/manifest"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/action": "1.0.0",
34
+ "@ultimat3/core": "1.0.0",
35
+ "@ultimat3/entity": "1.0.0",
36
+ "@ultimat3/jobs": "1.0.0",
37
+ "@ultimat3/query": "1.0.0"
38
+ }
39
+ }
@@ -0,0 +1,94 @@
1
+ // AGENTS.md validation. Read-only, deliberately narrow.
2
+ //
3
+ // WHY THIS DOES NOT GENERATE PROSE
4
+ // --------------------------------
5
+ // The obvious feature request is "generate AGENTS.md from the code". We refuse, on evidence:
6
+ // LLM-generated context files measurably REDUCE task success and increase the number of
7
+ // steps an agent takes. The generated prose reads plausibly, drifts from reality the moment
8
+ // anything changes, and — worst of all — an agent trusts it over the code.
9
+ //
10
+ // So the split is: FACTS ARE GENERATED, CONVENTIONS ARE HUMAN-AUTHORED. `x.manifest.json`
11
+ // carries everything derivable from code and is regenerated on every build. `AGENTS.md`
12
+ // carries what a human knows and code cannot express — why this queue is separate, which
13
+ // service is load-bearing, what not to touch — and stays short enough to be read every time.
14
+ //
15
+ // This module therefore checks exactly two things: that the file exists, and that it has not
16
+ // grown into a document nobody reads. It never writes.
17
+
18
+ import { AgentsMdMissingError, AgentsMdTooLargeError } from './errors';
19
+
20
+ export const AGENTS_MD_FILENAME = 'AGENTS.md';
21
+
22
+ /**
23
+ * Default budget, in bytes. Roughly 3k tokens — long enough for stack, commands, conventions,
24
+ * and hard-won gotchas; short enough that an agent reads all of it rather than skimming.
25
+ */
26
+ export const AGENTS_MD_MAX_BYTES = 12_000;
27
+
28
+ export interface AgentsMdCheck {
29
+ readonly path: string;
30
+ readonly exists: boolean;
31
+ readonly bytes: number;
32
+ readonly maxBytes: number;
33
+ readonly ok: boolean;
34
+ /** Non-fatal observations, surfaced by `x verify --json` for a human to judge. */
35
+ readonly warnings: readonly string[];
36
+ }
37
+
38
+ export interface CheckAgentsMdInput {
39
+ readonly path?: string;
40
+ readonly maxBytes?: number;
41
+ }
42
+
43
+ /** Inspect without throwing — `x verify --json` reports, `assertAgentsMd` enforces. */
44
+ export async function checkAgentsMd(input: CheckAgentsMdInput = {}): Promise<AgentsMdCheck> {
45
+ const path = input.path ?? `./${AGENTS_MD_FILENAME}`;
46
+ const maxBytes = input.maxBytes ?? AGENTS_MD_MAX_BYTES;
47
+ const file = Bun.file(path);
48
+
49
+ if (!(await file.exists())) {
50
+ return { path, exists: false, bytes: 0, maxBytes, ok: false, warnings: [] };
51
+ }
52
+ const text = await file.text();
53
+ const bytes = Buffer.byteLength(text, 'utf8');
54
+ return {
55
+ path,
56
+ exists: true,
57
+ bytes,
58
+ maxBytes,
59
+ ok: bytes <= maxBytes,
60
+ warnings: warningsFor(text),
61
+ };
62
+ }
63
+
64
+ /** The `x verify` gate. Throws `X_AGENTS_MD_*`; never repairs the file. */
65
+ export async function assertAgentsMd(input: CheckAgentsMdInput = {}): Promise<AgentsMdCheck> {
66
+ const check = await checkAgentsMd(input);
67
+ if (!check.exists) throw new AgentsMdMissingError({ path: check.path });
68
+ if (check.bytes > check.maxBytes) {
69
+ throw new AgentsMdTooLargeError({
70
+ path: check.path,
71
+ bytes: check.bytes,
72
+ maxBytes: check.maxBytes,
73
+ });
74
+ }
75
+ return check;
76
+ }
77
+
78
+ /**
79
+ * Warn when the file has started duplicating generated facts. Duplication is how a context
80
+ * file goes stale: the copy and the source drift, and the agent believes the copy.
81
+ */
82
+ function warningsFor(text: string): readonly string[] {
83
+ const warnings: string[] = [];
84
+ if (/^\s*\|.*\|\s*$/m.test(text) && /\b(column|entity|route|endpoint)\b/i.test(text)) {
85
+ warnings.push('looks like it tabulates schema or route facts; those live in x.manifest.json');
86
+ }
87
+ if (text.split('\n').length > 200) {
88
+ warnings.push('over 200 lines; a context file that long is skimmed, not read');
89
+ }
90
+ if (/generated by|do not edit/i.test(text)) {
91
+ warnings.push('claims to be generated; AGENTS.md is human-authored by design');
92
+ }
93
+ return warnings;
94
+ }
package/src/build.ts ADDED
@@ -0,0 +1,143 @@
1
+ // `buildManifest` — the generated facts, emitted from code.
2
+ //
3
+ // DETERMINISM IS THE WHOLE POINT. `x.manifest.json` is committed and diffed in review, so
4
+ // two builds of the same tree must produce identical bytes. That means:
5
+ // - no timestamps, no git sha, no build counter, no hostname;
6
+ // - every collection is sorted by a stable key before it is written, because `Map` and
7
+ // `Set` iteration order is insertion order and insertion order depends on module load
8
+ // order, which depends on the filesystem;
9
+ // - object keys are written in a fixed order (see `emit.ts`), not `JSON.stringify` order;
10
+ // - `buildId` is a content hash of the sorted body, so it changes if and only if a fact
11
+ // changed.
12
+ // A manifest that churns on every build trains reviewers to ignore its diff, which defeats
13
+ // the entire mechanism.
14
+ //
15
+ // Sources are injected. Route facts come from `@ultimat3/render` and policy facts are
16
+ // assembled per app — both outside what this tier may import — so the CLI supplies them and
17
+ // this function stays pure and unit-testable.
18
+
19
+ import type {
20
+ ActionFact,
21
+ EntityFact,
22
+ ErrorCodeFact,
23
+ JobFact,
24
+ Manifest,
25
+ PolicyFact,
26
+ QueryFact,
27
+ RouteFact,
28
+ TaskFact,
29
+ } from './schema';
30
+ import { MANIFEST_VERSION } from './schema';
31
+
32
+ export interface ManifestSources {
33
+ readonly app: { readonly name: string; readonly version: string };
34
+ readonly routes?: readonly RouteFact[];
35
+ readonly entities?: readonly EntityFact[];
36
+ readonly actions?: readonly ActionFact[];
37
+ readonly queries?: readonly QueryFact[];
38
+ readonly jobs?: readonly JobFact[];
39
+ readonly tasks?: readonly TaskFact[];
40
+ readonly policies?: readonly PolicyFact[];
41
+ readonly locales?: readonly string[];
42
+ readonly errorCodes?: readonly ErrorCodeFact[];
43
+ }
44
+
45
+ export function buildManifest(sources: ManifestSources): Manifest {
46
+ const routes = sortBy(sources.routes ?? [], (r) => r.url);
47
+ const entities = sortBy(sources.entities ?? [], (e) => e.name).map(normalizeEntity);
48
+ const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
49
+ const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
50
+ const jobs = sortBy(sources.jobs ?? [], (j) => j.name).map(normalizeJob);
51
+ const tasks = sortBy(sources.tasks ?? [], (t) => t.name).map((t) => ({
52
+ ...t,
53
+ enqueues: [...t.enqueues].sort(),
54
+ }));
55
+ const policies = sortBy(sources.policies ?? [], (p) => p.permission).map((p) => ({
56
+ ...p,
57
+ enforcedIn: [...p.enforcedIn].sort(),
58
+ }));
59
+ const errorCodes = sortBy(sources.errorCodes ?? [], (e) => `${e.package}:${e.code}`);
60
+
61
+ // Derived, never declared twice: the permission list IS the set of policy permissions
62
+ // plus anything an action asserts. Two lists that must agree eventually disagree.
63
+ const permissions = unique([
64
+ ...policies.map((p) => p.permission),
65
+ ...actions.map((a) => a.policy).filter((p): p is string => p !== null),
66
+ ...queries.map((q) => q.policy).filter((p): p is string => p !== null),
67
+ ]);
68
+
69
+ const body = {
70
+ manifestVersion: MANIFEST_VERSION,
71
+ app: sources.app,
72
+ routes,
73
+ entities,
74
+ actions,
75
+ queries,
76
+ jobs,
77
+ tasks,
78
+ policies,
79
+ permissions,
80
+ locales: [...(sources.locales ?? [])].sort(),
81
+ errorCodes,
82
+ };
83
+
84
+ return { ...body, buildId: contentHash(body) };
85
+ }
86
+
87
+ /**
88
+ * Content hash of the manifest body. Deliberately excludes `buildId` itself, and is computed
89
+ * over the same canonical serialisation `emit.ts` writes — so `buildId` is verifiable from
90
+ * the file alone.
91
+ */
92
+ export function contentHash(body: Omit<Manifest, 'buildId'>): string {
93
+ const hasher = new Bun.CryptoHasher('sha256');
94
+ hasher.update(canonical(body));
95
+ return hasher.digest('hex').slice(0, 16);
96
+ }
97
+
98
+ /** Sorted-key JSON. Never `JSON.stringify(value)` directly — key order is not a contract. */
99
+ export function canonical(value: unknown): string {
100
+ return JSON.stringify(sortKeys(value));
101
+ }
102
+
103
+ function sortKeys(value: unknown): unknown {
104
+ if (Array.isArray(value)) return value.map(sortKeys);
105
+ if (typeof value !== 'object' || value === null) return value;
106
+ const record = value as Record<string, unknown>;
107
+ const out: Record<string, unknown> = {};
108
+ for (const key of Object.keys(record).sort()) out[key] = sortKeys(record[key]);
109
+ return out;
110
+ }
111
+
112
+ function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
113
+ return [...items].sort((a, b) => {
114
+ const ka = key(a);
115
+ const kb = key(b);
116
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
117
+ });
118
+ }
119
+
120
+ function unique(values: readonly string[]): readonly string[] {
121
+ return [...new Set(values)].sort();
122
+ }
123
+
124
+ // Inner collections are sorted too: a reordered column list is a spurious diff.
125
+ const normalizeEntity = (entity: EntityFact): EntityFact => ({
126
+ ...entity,
127
+ columns: sortBy(entity.columns, (c) => c.name),
128
+ invariants: [...entity.invariants].sort(),
129
+ });
130
+
131
+ const normalizeAction = (action: ActionFact): ActionFact => ({
132
+ ...action,
133
+ cacheInvalidates: [...action.cacheInvalidates].sort(),
134
+ });
135
+
136
+ const normalizeQuery = (query: QueryFact): QueryFact => ({
137
+ ...query,
138
+ cacheTags: [...query.cacheTags].sort(),
139
+ });
140
+
141
+ // Job steps keep their DECLARED order — a job's steps are a sequence, not a set, and
142
+ // sorting them would misrepresent the program.
143
+ const normalizeJob = (job: JobFact): JobFact => ({ ...job, steps: [...job.steps] });
package/src/diff.ts ADDED
@@ -0,0 +1,308 @@
1
+ // `diffManifest` — the contract diff `x verify` gates on.
2
+ //
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 on something that already shipped)
6
+ // additive — a new capability; nothing that worked stops working
7
+ // internal — visible in the file but not in the contract (a description, a cache tag, the
8
+ // buildId itself)
9
+ // `x verify` fails on a breaking change without a major version bump. Additive and internal
10
+ // changes never fail, which is what makes the gate credible enough to leave on.
11
+
12
+ import { canonical } from './build';
13
+ import type { ActionFact, JobFact, Manifest, QueryFact, RouteFact } from './schema';
14
+
15
+ export type ChangeKind = 'breaking' | 'additive' | 'internal';
16
+
17
+ export interface ManifestChange {
18
+ readonly kind: ChangeKind;
19
+ /** Dotted path into the manifest, e.g. `actions.publishPost.policy`. */
20
+ readonly path: string;
21
+ readonly detail: string;
22
+ }
23
+
24
+ export interface ManifestDiff {
25
+ readonly changes: readonly ManifestChange[];
26
+ readonly breaking: readonly ManifestChange[];
27
+ readonly additive: readonly ManifestChange[];
28
+ readonly internal: readonly ManifestChange[];
29
+ readonly hasBreaking: boolean;
30
+ }
31
+
32
+ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
33
+ const changes: ManifestChange[] = [];
34
+
35
+ if (before.manifestVersion !== after.manifestVersion) {
36
+ changes.push({
37
+ kind: 'breaking',
38
+ path: 'manifestVersion',
39
+ detail: `manifest shape ${before.manifestVersion} -> ${after.manifestVersion}`,
40
+ });
41
+ }
42
+ if (before.buildId !== after.buildId) {
43
+ changes.push({ kind: 'internal', path: 'buildId', detail: 'content changed' });
44
+ }
45
+
46
+ changes.push(...diffActions(before.actions, after.actions));
47
+ changes.push(...diffQueries(before.queries, after.queries));
48
+ changes.push(...diffRoutes(before.routes, after.routes));
49
+ changes.push(...diffJobs(before.jobs, after.jobs));
50
+ changes.push(...diffEntities(before, after));
51
+ changes.push(...diffNamedSet('permissions', before.permissions, after.permissions));
52
+ changes.push(...diffNamedSet('locales', before.locales, after.locales, 'additive'));
53
+
54
+ const sorted = [...changes].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
55
+ const breaking = sorted.filter((c) => c.kind === 'breaking');
56
+ return {
57
+ changes: sorted,
58
+ breaking,
59
+ additive: sorted.filter((c) => c.kind === 'additive'),
60
+ internal: sorted.filter((c) => c.kind === 'internal'),
61
+ hasBreaking: breaking.length > 0,
62
+ };
63
+ }
64
+
65
+ function diffActions(
66
+ before: readonly ActionFact[],
67
+ after: readonly ActionFact[],
68
+ ): readonly ManifestChange[] {
69
+ const changes: ManifestChange[] = [];
70
+ const afterByName = index(after, (a) => a.name);
71
+ const beforeByName = index(before, (a) => a.name);
72
+
73
+ for (const action of before) {
74
+ const next = afterByName.get(action.name);
75
+ const path = `actions.${action.name}`;
76
+ if (next === undefined) {
77
+ // The canonical breaking change: a caller that compiled yesterday no longer does.
78
+ changes.push({ kind: 'breaking', path, detail: 'action removed' });
79
+ continue;
80
+ }
81
+ if (canonical(action.input) !== canonical(next.input)) {
82
+ changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
83
+ }
84
+ if (canonical(action.output) !== canonical(next.output)) {
85
+ changes.push({ kind: 'breaking', path: `${path}.output`, detail: 'output schema changed' });
86
+ }
87
+ if (action.policy !== next.policy) {
88
+ changes.push({
89
+ kind: 'breaking',
90
+ path: `${path}.policy`,
91
+ detail: `policy ${action.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
92
+ });
93
+ }
94
+ if (action.mcp.expose !== next.mcp.expose) {
95
+ // Widening the surface is additive; withdrawing a tool an agent depends on is not.
96
+ changes.push({
97
+ kind: next.mcp.expose ? 'additive' : 'breaking',
98
+ path: `${path}.mcp.expose`,
99
+ detail: `mcp exposure ${String(action.mcp.expose)} -> ${String(next.mcp.expose)}`,
100
+ });
101
+ }
102
+ if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) {
103
+ changes.push({
104
+ kind: 'internal',
105
+ path: `${path}.cacheInvalidates`,
106
+ detail: 'cache tags changed',
107
+ });
108
+ }
109
+ }
110
+ for (const action of after) {
111
+ if (!beforeByName.has(action.name)) {
112
+ changes.push({ kind: 'additive', path: `actions.${action.name}`, detail: 'action added' });
113
+ }
114
+ }
115
+ return changes;
116
+ }
117
+
118
+ function diffQueries(
119
+ before: readonly QueryFact[],
120
+ after: readonly QueryFact[],
121
+ ): readonly ManifestChange[] {
122
+ const changes: ManifestChange[] = [];
123
+ const afterByName = index(after, (q) => q.name);
124
+ const beforeByName = index(before, (q) => q.name);
125
+
126
+ for (const query of before) {
127
+ const next = afterByName.get(query.name);
128
+ const path = `queries.${query.name}`;
129
+ if (next === undefined) {
130
+ changes.push({ kind: 'breaking', path, detail: 'query removed' });
131
+ continue;
132
+ }
133
+ if (canonical(query.input) !== canonical(next.input)) {
134
+ changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
135
+ }
136
+ if (query.policy !== next.policy) {
137
+ changes.push({
138
+ kind: 'breaking',
139
+ path: `${path}.policy`,
140
+ detail: `policy ${query.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
141
+ });
142
+ }
143
+ if (query.live !== next.live) {
144
+ // Losing live-ness breaks subscribers; gaining it breaks nobody.
145
+ changes.push({
146
+ kind: next.live ? 'additive' : 'breaking',
147
+ path: `${path}.live`,
148
+ detail: `live ${String(query.live)} -> ${String(next.live)}`,
149
+ });
150
+ }
151
+ }
152
+ for (const query of after) {
153
+ if (!beforeByName.has(query.name)) {
154
+ changes.push({ kind: 'additive', path: `queries.${query.name}`, detail: 'query added' });
155
+ }
156
+ }
157
+ return changes;
158
+ }
159
+
160
+ function diffRoutes(
161
+ before: readonly RouteFact[],
162
+ after: readonly RouteFact[],
163
+ ): readonly ManifestChange[] {
164
+ const changes: ManifestChange[] = [];
165
+ const afterByUrl = index(after, (r) => r.url);
166
+ const beforeByUrl = index(before, (r) => r.url);
167
+
168
+ for (const route of before) {
169
+ const next = afterByUrl.get(route.url);
170
+ if (next === undefined) {
171
+ // A removed URL is a 404 for anyone holding a link to it.
172
+ changes.push({ kind: 'breaking', path: `routes.${route.url}`, detail: 'route removed' });
173
+ continue;
174
+ }
175
+ if (route.render !== next.render) {
176
+ changes.push({
177
+ kind: 'internal',
178
+ path: `routes.${route.url}.render`,
179
+ detail: `render ${route.render} -> ${next.render}`,
180
+ });
181
+ }
182
+ }
183
+ for (const route of after) {
184
+ if (!beforeByUrl.has(route.url)) {
185
+ changes.push({ kind: 'additive', path: `routes.${route.url}`, detail: 'route added' });
186
+ }
187
+ }
188
+ return changes;
189
+ }
190
+
191
+ function diffJobs(
192
+ before: readonly JobFact[],
193
+ after: readonly JobFact[],
194
+ ): readonly ManifestChange[] {
195
+ const changes: ManifestChange[] = [];
196
+ const afterByName = index(after, (j) => j.name);
197
+ const beforeByName = index(before, (j) => j.name);
198
+
199
+ for (const job of before) {
200
+ const next = afterByName.get(job.name);
201
+ if (next === undefined) {
202
+ // Enqueued-but-undeliverable work is silent data loss, so a removal is breaking.
203
+ changes.push({ kind: 'breaking', path: `jobs.${job.name}`, detail: 'job removed' });
204
+ continue;
205
+ }
206
+ if (canonical(job.input) !== canonical(next.input)) {
207
+ changes.push({
208
+ kind: 'breaking',
209
+ path: `jobs.${job.name}.input`,
210
+ detail: 'input schema changed; in-flight payloads will not parse',
211
+ });
212
+ }
213
+ if (canonical(job.steps) !== canonical(next.steps)) {
214
+ changes.push({
215
+ kind: 'internal',
216
+ path: `jobs.${job.name}.steps`,
217
+ detail: 'steps changed; resumed runs may replay differently',
218
+ });
219
+ }
220
+ }
221
+ for (const job of after) {
222
+ if (!beforeByName.has(job.name)) {
223
+ changes.push({ kind: 'additive', path: `jobs.${job.name}`, detail: 'job added' });
224
+ }
225
+ }
226
+ return changes;
227
+ }
228
+
229
+ function diffEntities(before: Manifest, after: Manifest): readonly ManifestChange[] {
230
+ const changes: ManifestChange[] = [];
231
+ const afterByName = index(after.entities, (e) => e.name);
232
+ const beforeByName = index(before.entities, (e) => e.name);
233
+
234
+ for (const entity of before.entities) {
235
+ const next = afterByName.get(entity.name);
236
+ if (next === undefined) {
237
+ changes.push({ kind: 'breaking', path: `entities.${entity.name}`, detail: 'entity removed' });
238
+ continue;
239
+ }
240
+ const nextColumns = index(next.columns, (c) => c.name);
241
+ for (const column of entity.columns) {
242
+ const nextColumn = nextColumns.get(column.name);
243
+ const path = `entities.${entity.name}.columns.${column.name}`;
244
+ if (nextColumn === undefined) {
245
+ changes.push({ kind: 'breaking', path, detail: 'column removed' });
246
+ continue;
247
+ }
248
+ if (column.type !== nextColumn.type) {
249
+ changes.push({
250
+ kind: 'breaking',
251
+ path: `${path}.type`,
252
+ detail: `${column.type} -> ${nextColumn.type}`,
253
+ });
254
+ }
255
+ if (column.nullable && !nextColumn.nullable) {
256
+ // Tightening nullability rejects rows that were valid a moment ago.
257
+ changes.push({ kind: 'breaking', path: `${path}.nullable`, detail: 'became NOT NULL' });
258
+ }
259
+ }
260
+ const beforeColumns = index(entity.columns, (c) => c.name);
261
+ for (const column of next.columns) {
262
+ if (!beforeColumns.has(column.name)) {
263
+ changes.push({
264
+ kind: column.nullable ? 'additive' : 'breaking',
265
+ path: `entities.${entity.name}.columns.${column.name}`,
266
+ detail: column.nullable ? 'column added' : 'NOT NULL column added with no default',
267
+ });
268
+ }
269
+ }
270
+ }
271
+ for (const entity of after.entities) {
272
+ if (!beforeByName.has(entity.name)) {
273
+ changes.push({ kind: 'additive', path: `entities.${entity.name}`, detail: 'entity added' });
274
+ }
275
+ }
276
+ return changes;
277
+ }
278
+
279
+ function diffNamedSet(
280
+ path: string,
281
+ before: readonly string[],
282
+ after: readonly string[],
283
+ removalKind: ChangeKind = 'breaking',
284
+ ): readonly ManifestChange[] {
285
+ const changes: ManifestChange[] = [];
286
+ const afterSet = new Set(after);
287
+ const beforeSet = new Set(before);
288
+ for (const name of before) {
289
+ if (!afterSet.has(name)) {
290
+ changes.push({ kind: removalKind, path: `${path}.${name}`, detail: 'removed' });
291
+ }
292
+ }
293
+ for (const name of after) {
294
+ if (!beforeSet.has(name)) {
295
+ changes.push({ kind: 'additive', path: `${path}.${name}`, detail: 'added' });
296
+ }
297
+ }
298
+ return changes;
299
+ }
300
+
301
+ function index<T>(items: readonly T[], key: (item: T) => string): Map<string, T> {
302
+ return new Map(items.map((item) => [key(item), item]));
303
+ }
304
+
305
+ /** One line per change, `--json`-free, for a terminal summary. */
306
+ export function formatDiff(diff: ManifestDiff): readonly string[] {
307
+ return diff.changes.map((c) => `${c.kind.padEnd(8)} ${c.path}: ${c.detail}`);
308
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,137 @@
1
+ // Serialisation and I/O for `x.manifest.json`, plus the drift check `x verify` runs.
2
+ //
3
+ // The serialiser writes keys in a FIXED order rather than whatever `JSON.stringify` produces,
4
+ // so a refactor that reorders a struct literal does not produce a diff. Two-space indent and
5
+ // a trailing newline: the file is reviewed by humans and diffed by git.
6
+
7
+ import { canonical, contentHash } from './build';
8
+ import { ManifestDriftError } from './errors';
9
+ import type { Manifest } from './schema';
10
+ import { isManifest } from './schema';
11
+
12
+ export const MANIFEST_FILENAME = 'x.manifest.json';
13
+
14
+ /** Top-level key order. Explicit so the file reads in a sensible order every time. */
15
+ const KEY_ORDER: readonly (keyof Manifest)[] = [
16
+ 'manifestVersion',
17
+ 'buildId',
18
+ 'app',
19
+ 'routes',
20
+ 'entities',
21
+ 'actions',
22
+ 'queries',
23
+ 'jobs',
24
+ 'tasks',
25
+ 'policies',
26
+ 'permissions',
27
+ 'locales',
28
+ 'errorCodes',
29
+ ];
30
+
31
+ /** The exact bytes written to disk. Deterministic for a given manifest. */
32
+ export function manifestJson(manifest: Manifest): string {
33
+ const ordered: Record<string, unknown> = {};
34
+ for (const key of KEY_ORDER) ordered[key] = manifest[key];
35
+ return `${JSON.stringify(ordered, null, 2)}\n`;
36
+ }
37
+
38
+ export interface EmitInput {
39
+ readonly manifest: Manifest;
40
+ /** Defaults to `./x.manifest.json`. */
41
+ readonly path?: string;
42
+ /** `--json`: print to stdout instead of writing. Machine-readable output on everything. */
43
+ readonly stdout?: boolean;
44
+ }
45
+
46
+ export interface EmitResult {
47
+ readonly path: string;
48
+ readonly bytes: number;
49
+ readonly buildId: string;
50
+ /** False when the file already contained these exact bytes. */
51
+ readonly changed: boolean;
52
+ }
53
+
54
+ export async function emitManifest(input: EmitInput): Promise<EmitResult> {
55
+ const path = input.path ?? `./${MANIFEST_FILENAME}`;
56
+ const text = manifestJson(input.manifest);
57
+
58
+ if (input.stdout === true) {
59
+ // stdout is the wire in `--json` mode; nothing else may be written to it.
60
+ Bun.stdout.write(text);
61
+ return { path, bytes: text.length, buildId: input.manifest.buildId, changed: false };
62
+ }
63
+
64
+ const existing = await readIfExists(path);
65
+ // Skip the write when nothing moved: an unchanged mtime keeps file watchers quiet.
66
+ if (existing === text) {
67
+ return { path, bytes: text.length, buildId: input.manifest.buildId, changed: false };
68
+ }
69
+ await Bun.write(path, text);
70
+ return { path, bytes: text.length, buildId: input.manifest.buildId, changed: true };
71
+ }
72
+
73
+ /** Read and structurally validate a manifest. `undefined` when absent or unparseable. */
74
+ export async function readManifest(path?: string): Promise<Manifest | undefined> {
75
+ const text = await readIfExists(path ?? `./${MANIFEST_FILENAME}`);
76
+ if (text === undefined) return undefined;
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ return isManifest(parsed) ? parsed : undefined;
84
+ }
85
+
86
+ /**
87
+ * A file that does not describe itself. Kept apart from `describeDrift`'s section list because
88
+ * it is a different repair story: the code did not move, someone typed into the generated file.
89
+ */
90
+ const HAND_EDITED = 'hand-edited — its buildId does not hash its own contents';
91
+
92
+ /**
93
+ * Fail if the committed file does not match a freshly built manifest. Drift means an agent
94
+ * reading `x.manifest.json` is reading a description of a program that no longer exists —
95
+ * strictly worse than no manifest at all.
96
+ */
97
+ export async function assertNoDrift(input: {
98
+ readonly manifest: Manifest;
99
+ readonly path?: string;
100
+ }): Promise<void> {
101
+ const path = input.path ?? `./${MANIFEST_FILENAME}`;
102
+ const onDisk = await readManifest(path);
103
+ if (onDisk === undefined) {
104
+ throw new ManifestDriftError({ path, differences: ['file is missing or unreadable'] });
105
+ }
106
+ // Before the ids are compared, not after: a body edited by hand with its `buildId` left alone
107
+ // still carries the id a fresh build produces, so an id-only gate waves through the one
108
+ // manifest that lies about the code. `buildId` hashes the body, so the file convicts itself.
109
+ if (!verifyBuildId(onDisk)) {
110
+ throw new ManifestDriftError({ path, differences: [HAND_EDITED] });
111
+ }
112
+ if (onDisk.buildId === input.manifest.buildId) return;
113
+
114
+ throw new ManifestDriftError({ path, differences: describeDrift(onDisk, input.manifest) });
115
+ }
116
+
117
+ /** Which top-level sections moved. Enough to point at the change without dumping the file. */
118
+ function describeDrift(onDisk: Manifest, fresh: Manifest): readonly string[] {
119
+ const differences: string[] = [];
120
+ for (const key of KEY_ORDER) {
121
+ if (key === 'buildId') continue;
122
+ if (canonical(onDisk[key]) !== canonical(fresh[key])) differences.push(`${key} differs`);
123
+ }
124
+ return differences.length > 0 ? differences : ['buildId differs'];
125
+ }
126
+
127
+ /** Verify a file's `buildId` against its own contents — catches a hand-edited manifest. */
128
+ export function verifyBuildId(manifest: Manifest): boolean {
129
+ const { buildId, ...body } = manifest;
130
+ return contentHash(body) === buildId;
131
+ }
132
+
133
+ async function readIfExists(path: string): Promise<string | undefined> {
134
+ const file = Bun.file(path);
135
+ if (!(await file.exists())) return undefined;
136
+ return file.text();
137
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,93 @@
1
+ // The X_* codes owned by @ultimat3/manifest. `x verify` raises these, so each fix line is a
2
+ // command the developer (or the agent) can run verbatim.
3
+
4
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+
6
+ export const MANIFEST_ERROR_CODES = [
7
+ 'X_MANIFEST_DRIFT',
8
+ 'X_MANIFEST_BREAKING',
9
+ 'X_AGENTS_MD_MISSING',
10
+ 'X_AGENTS_MD_TOO_LARGE',
11
+ ] as const;
12
+
13
+ export type ManifestErrorCode = (typeof MANIFEST_ERROR_CODES)[number];
14
+
15
+ export const MANIFEST_ERROR_TITLES: Readonly<Record<ManifestErrorCode, string>> = {
16
+ X_MANIFEST_DRIFT: 'x.manifest.json differs from the code',
17
+ X_MANIFEST_BREAKING: 'a published contract was removed or narrowed',
18
+ X_AGENTS_MD_MISSING: 'no AGENTS.md',
19
+ X_AGENTS_MD_TOO_LARGE: 'AGENTS.md grew past its cap',
20
+ };
21
+
22
+ // Titles must be registered for format() to render the contract's first line. Every code above is
23
+ // owned here and none is borrowed, so the call is unconditional: a second package claiming one has
24
+ // to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
25
+ registerErrorCodes(
26
+ Object.fromEntries(
27
+ Object.entries(MANIFEST_ERROR_TITLES).map(([code, title]) => [code, { title }]),
28
+ ),
29
+ );
30
+
31
+ const docsFor = (code: ManifestErrorCode): string => `https://ultimate.dev/errors/${code}`;
32
+
33
+ /**
34
+ * The committed `x.manifest.json` no longer matches the code. Drift means an agent reading
35
+ * the manifest is reading a description of a program that no longer exists.
36
+ */
37
+ export class ManifestDriftError extends UltimateError {
38
+ constructor(input: { path: string; differences: readonly string[] }) {
39
+ super({
40
+ code: 'X_MANIFEST_DRIFT',
41
+ cause: `${input.path} is stale: ${summarize(input.differences)}`,
42
+ fix: 'x manifest',
43
+ docs: docsFor('X_MANIFEST_DRIFT'),
44
+ });
45
+ }
46
+ }
47
+
48
+ /** A breaking contract change landed without a version bump. */
49
+ export class ManifestBreakingError extends UltimateError {
50
+ constructor(input: { changes: readonly string[]; from: string; to: string }) {
51
+ super({
52
+ code: 'X_MANIFEST_BREAKING',
53
+ cause:
54
+ `${input.changes.length} breaking change(s) from ${input.from} to ${input.to} ` +
55
+ `with no major version bump: ${summarize(input.changes)}`,
56
+ fix: 'bump the major version in app.config.ts, or restore the removed contract',
57
+ docs: docsFor('X_MANIFEST_BREAKING'),
58
+ });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * `AGENTS.md` is absent. It is hand-written on purpose and is not generated, so the fix is
64
+ * to write one — see the note in `agents-md.ts`.
65
+ */
66
+ export class AgentsMdMissingError extends UltimateError {
67
+ constructor(input: { path: string }) {
68
+ super({
69
+ code: 'X_AGENTS_MD_MISSING',
70
+ cause: `${input.path} does not exist`,
71
+ fix: `create ${input.path} by hand: stack, commands, conventions. Keep it short; facts live in x.manifest.json`,
72
+ docs: docsFor('X_AGENTS_MD_MISSING'),
73
+ });
74
+ }
75
+ }
76
+
77
+ /** `AGENTS.md` grew past its budget. A long context file measurably lowers task success. */
78
+ export class AgentsMdTooLargeError extends UltimateError {
79
+ constructor(input: { path: string; bytes: number; maxBytes: number }) {
80
+ super({
81
+ code: 'X_AGENTS_MD_TOO_LARGE',
82
+ cause: `${input.path} is ${input.bytes}B, over the ${input.maxBytes}B budget`,
83
+ fix: 'move generated facts out of AGENTS.md and let x.manifest.json carry them',
84
+ docs: docsFor('X_AGENTS_MD_TOO_LARGE'),
85
+ });
86
+ }
87
+ }
88
+
89
+ /** First three items plus a count — a message with 400 entries is a message nobody reads. */
90
+ function summarize(items: readonly string[]): string {
91
+ if (items.length <= 3) return items.join('; ');
92
+ return `${items.slice(0, 3).join('; ')} (+${items.length - 3} more)`;
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Public API of @ultimat3/manifest. Explicit — `x verify`, `x manifest`, and the MCP
2
+ // `manifest.read` resource are all built from exactly these exports.
3
+
4
+ export type { AgentsMdCheck, CheckAgentsMdInput } from './agents-md';
5
+ export {
6
+ AGENTS_MD_FILENAME,
7
+ AGENTS_MD_MAX_BYTES,
8
+ assertAgentsMd,
9
+ checkAgentsMd,
10
+ } from './agents-md';
11
+ export type { ManifestSources } from './build';
12
+ export { buildManifest, canonical, contentHash } from './build';
13
+ export type { ChangeKind, ManifestChange, ManifestDiff } from './diff';
14
+ export { diffManifest, formatDiff } from './diff';
15
+ export type { EmitInput, EmitResult } from './emit';
16
+ export {
17
+ assertNoDrift,
18
+ emitManifest,
19
+ MANIFEST_FILENAME,
20
+ manifestJson,
21
+ readManifest,
22
+ verifyBuildId,
23
+ } from './emit';
24
+ export type { ManifestErrorCode } from './errors';
25
+ export {
26
+ AgentsMdMissingError,
27
+ AgentsMdTooLargeError,
28
+ MANIFEST_ERROR_CODES,
29
+ MANIFEST_ERROR_TITLES,
30
+ ManifestBreakingError,
31
+ ManifestDriftError,
32
+ } from './errors';
33
+ export type {
34
+ ActionFact,
35
+ ColumnFact,
36
+ EntityFact,
37
+ ErrorCodeFact,
38
+ HydrateStrategy,
39
+ JobFact,
40
+ JsonValue,
41
+ Manifest,
42
+ OfflineStrategy,
43
+ PolicyFact,
44
+ QueryFact,
45
+ RenderMode,
46
+ RouteFact,
47
+ TaskFact,
48
+ } from './schema';
49
+ export { isCompatible, isManifest, MANIFEST_VERSION } from './schema';
50
+ export type { FrameworkSourcesInput } from './sources';
51
+ export { frameworkSources } from './sources';
52
+ export type { VerifyContractInput, VerifyContractResult } from './verify';
53
+ export { verifyContract } from './verify';
package/src/schema.ts ADDED
@@ -0,0 +1,135 @@
1
+ // The manifest's own typed schema, plus the version field that lets a tool decide whether it
2
+ // can read a given file. `x.manifest.json` is a public contract consumed by agents, editors,
3
+ // and CI — a shape change without a version bump silently breaks all three.
4
+ //
5
+ // Every collection is `readonly` and every field is a plain JSON value: the manifest must
6
+ // round-trip through `JSON.stringify` without loss, because that is how it is stored.
7
+
8
+ /** Bumped when the manifest SHAPE changes, not when an app's contents change. */
9
+ export const MANIFEST_VERSION = 1;
10
+
11
+ export type JsonValue =
12
+ | string
13
+ | number
14
+ | boolean
15
+ | null
16
+ | readonly JsonValue[]
17
+ | { readonly [key: string]: JsonValue };
18
+
19
+ export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
20
+ export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
21
+ export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
22
+
23
+ export interface RouteFact {
24
+ readonly url: string;
25
+ readonly render: RenderMode;
26
+ readonly offline?: OfflineStrategy;
27
+ readonly hydrate?: HydrateStrategy;
28
+ readonly revalidateTags?: readonly string[];
29
+ readonly budget?: { readonly js?: string; readonly lcp?: number };
30
+ /** Which surface the route lives in — `site` may never import from `app`. */
31
+ readonly surface?: 'site' | 'app' | 'api';
32
+ }
33
+
34
+ export interface ColumnFact {
35
+ readonly name: string;
36
+ readonly type: string;
37
+ readonly nullable: boolean;
38
+ readonly primaryKey?: boolean;
39
+ readonly references?: string;
40
+ }
41
+
42
+ export interface EntityFact {
43
+ readonly name: string;
44
+ readonly table: string;
45
+ readonly columns: readonly ColumnFact[];
46
+ /** Named invariants, so an agent can see the rules without reading the migration. */
47
+ readonly invariants: readonly string[];
48
+ }
49
+
50
+ export interface ActionFact {
51
+ readonly name: string;
52
+ readonly input: JsonValue;
53
+ readonly output: JsonValue;
54
+ /** Permission string the policy asserts, e.g. `post:publish`. */
55
+ readonly policy: string | null;
56
+ readonly cacheInvalidates: readonly string[];
57
+ readonly mcp: { readonly expose: boolean; readonly description?: string };
58
+ readonly mutator?: boolean;
59
+ }
60
+
61
+ export interface QueryFact {
62
+ readonly name: string;
63
+ /** Optional: `QueryDescriptor` is schema-erased, so a live query may not expose one. */
64
+ readonly input?: JsonValue;
65
+ readonly policy: string | null;
66
+ readonly live: boolean;
67
+ readonly cacheTags: readonly string[];
68
+ }
69
+
70
+ export interface JobFact {
71
+ readonly name: string;
72
+ readonly input: JsonValue;
73
+ readonly queue: string;
74
+ readonly retry: { readonly attempts: number; readonly backoff: string };
75
+ readonly steps: readonly string[];
76
+ }
77
+
78
+ export interface TaskFact {
79
+ readonly name: string;
80
+ readonly cron: string;
81
+ readonly tz: string;
82
+ readonly enqueues: readonly string[];
83
+ }
84
+
85
+ export interface PolicyFact {
86
+ readonly permission: string;
87
+ readonly description?: string;
88
+ /** Where this policy is enforced. One policy, N surfaces — this lists them. */
89
+ readonly enforcedIn: readonly string[];
90
+ }
91
+
92
+ export interface ErrorCodeFact {
93
+ readonly code: string;
94
+ readonly package: string;
95
+ }
96
+
97
+ export interface Manifest {
98
+ /** Shape version. A reader checks this before anything else. */
99
+ readonly manifestVersion: number;
100
+ /** App name and semver from `app.config.ts`. Drives the breaking-change gate. */
101
+ readonly app: { readonly name: string; readonly version: string };
102
+ /**
103
+ * Content hash of everything below. Deterministic — NOT a timestamp and not a git sha, so
104
+ * two builds of the same tree produce the same manifest byte-for-byte.
105
+ */
106
+ readonly buildId: string;
107
+ readonly routes: readonly RouteFact[];
108
+ readonly entities: readonly EntityFact[];
109
+ readonly actions: readonly ActionFact[];
110
+ readonly queries: readonly QueryFact[];
111
+ readonly jobs: readonly JobFact[];
112
+ readonly tasks: readonly TaskFact[];
113
+ readonly policies: readonly PolicyFact[];
114
+ readonly permissions: readonly string[];
115
+ readonly locales: readonly string[];
116
+ readonly errorCodes: readonly ErrorCodeFact[];
117
+ }
118
+
119
+ /** Whether a reader built for `MANIFEST_VERSION` can consume `manifest`. */
120
+ export function isCompatible(manifest: { manifestVersion: number }): boolean {
121
+ return manifest.manifestVersion === MANIFEST_VERSION;
122
+ }
123
+
124
+ /** Structural check for a value read off disk, before it is trusted as a `Manifest`. */
125
+ export function isManifest(value: unknown): value is Manifest {
126
+ if (typeof value !== 'object' || value === null) return false;
127
+ const m = value as Record<string, unknown>;
128
+ return (
129
+ typeof m['manifestVersion'] === 'number' &&
130
+ typeof m['buildId'] === 'string' &&
131
+ Array.isArray(m['actions']) &&
132
+ Array.isArray(m['routes']) &&
133
+ Array.isArray(m['entities'])
134
+ );
135
+ }
package/src/sources.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Wires the framework's description functions into `ManifestSources`.
2
+ //
3
+ // Split from `build.ts` so `buildManifest` stays a pure function of its input — the
4
+ // determinism guarantee is much easier to trust (and to test) when the builder cannot reach
5
+ // a global registry. Routes and policies are supplied by the caller: the route table lives
6
+ // in `@ultimat3/render`, which is this same tier.
7
+
8
+ import { describeActions } from '@ultimat3/action';
9
+ import { describeEntities } from '@ultimat3/entity';
10
+ import { describeJobs } from '@ultimat3/jobs';
11
+ import { describeQueries } from '@ultimat3/query';
12
+ import type { ManifestSources } from './build';
13
+ import type { ErrorCodeFact, JsonValue, PolicyFact, RouteFact, TaskFact } from './schema';
14
+
15
+ export interface FrameworkSourcesInput {
16
+ readonly app: { readonly name: string; readonly version: string };
17
+ /** From `@ultimat3/render`'s `describeRoutes()`. */
18
+ readonly routes?: readonly RouteFact[];
19
+ /** Assembled per app from its policy modules. */
20
+ readonly policies?: readonly PolicyFact[];
21
+ readonly tasks?: readonly TaskFact[];
22
+ readonly locales?: readonly string[];
23
+ /** Each package's `*_ERROR_CODES`, flattened by the CLI. */
24
+ readonly errorCodes?: readonly ErrorCodeFact[];
25
+ }
26
+
27
+ /**
28
+ * Read every primitive registry and combine with the caller-supplied facts. The `describe*`
29
+ * functions return the framework's own descriptors; they are narrowed to the manifest's
30
+ * fact shapes here, which is the one place that mapping lives.
31
+ */
32
+ /**
33
+ * A `JsonSchemaObject` is JSON by construction, but TypeScript will not assign an interface
34
+ * to an index-signature type. Converted in exactly one place rather than widening every fact.
35
+ */
36
+ const asJson = (value: object): JsonValue => value as JsonValue;
37
+
38
+ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources {
39
+ return {
40
+ app: input.app,
41
+ routes: input.routes ?? [],
42
+ policies: input.policies ?? [],
43
+ tasks: input.tasks ?? [],
44
+ locales: input.locales ?? [],
45
+ errorCodes: input.errorCodes ?? [],
46
+ // Projected field by field, never cast: the primitive registries own richer shapes
47
+ // than the manifest publishes, and a cast would silently rot when either side moves.
48
+ entities: describeEntities().map((entity) => ({
49
+ name: entity.name,
50
+ table: entity.table,
51
+ columns: entity.columns.map((column) => ({
52
+ name: column.column,
53
+ type: column.kind,
54
+ nullable: !column.notNull,
55
+ primaryKey: column.primaryKey,
56
+ ...(column.references === null ? {} : { references: column.references }),
57
+ })),
58
+ invariants: entity.invariants.map((invariant) => invariant.name),
59
+ })),
60
+ actions: describeActions().map((action) => ({
61
+ name: action.name,
62
+ input: asJson(action.input),
63
+ output: asJson(action.output),
64
+ policy: action.capability,
65
+ cacheInvalidates: action.invalidates,
66
+ mcp: {
67
+ expose: action.mcp.expose,
68
+ ...(action.mcp.description === null ? {} : { description: action.mcp.description }),
69
+ },
70
+ // Written only when true, exactly like `mcp.description`: `ActionFact.mutator` is optional,
71
+ // so absence already reads as "a plain action", and a `false` on every other action would
72
+ // be bytes added to a file that is reviewed by hand for no fact gained.
73
+ ...(action.mutator ? { mutator: true } : {}),
74
+ })),
75
+ queries: describeQueries().map((query) => ({
76
+ name: query.name,
77
+ policy: query.capability,
78
+ live: query.live,
79
+ cacheTags: query.tags,
80
+ })),
81
+ jobs: describeJobs().map((job) => ({
82
+ name: job.name,
83
+ input: asJson(job.input),
84
+ queue: job.queue,
85
+ retry: { attempts: job.retry.attempts, backoff: job.retry.backoff },
86
+ // Empty by construction, not dropped by the projection: a step name is chosen inside
87
+ // `run()` at execution time, so no static reader can know it. `x jobs show` reports the
88
+ // steps an actual run recorded.
89
+ steps: job.steps,
90
+ })),
91
+ };
92
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,52 @@
1
+ // The contract gate `x verify` runs: a breaking change is allowed, but only with a major
2
+ // version bump. This is the enforcement half of `diff.ts` — the diff classifies, this decides.
3
+ //
4
+ // Enforced, not documented: a convention that isn't a build error doesn't exist.
5
+
6
+ import type { ManifestDiff } from './diff';
7
+ import { diffManifest } from './diff';
8
+ import { ManifestBreakingError } from './errors';
9
+ import type { Manifest } from './schema';
10
+
11
+ export interface VerifyContractInput {
12
+ /** The manifest currently committed — the published contract. */
13
+ readonly before: Manifest;
14
+ /** The manifest just built from code. */
15
+ readonly after: Manifest;
16
+ }
17
+
18
+ export interface VerifyContractResult {
19
+ readonly diff: ManifestDiff;
20
+ readonly majorBumped: boolean;
21
+ readonly ok: boolean;
22
+ }
23
+
24
+ /**
25
+ * Classify, then gate. Throws `X_MANIFEST_BREAKING` when the contract broke and the major
26
+ * version did not move; returns the diff otherwise so the caller can print it.
27
+ */
28
+ export function verifyContract(input: VerifyContractInput): VerifyContractResult {
29
+ const diff = diffManifest(input.before, input.after);
30
+ const from = majorOf(input.before.app.version);
31
+ const to = majorOf(input.after.app.version);
32
+ // Fail-closed: an unparseable version on EITHER side counts as "not bumped", so a
33
+ // malformed version string can never wave a breaking change through.
34
+ const majorBumped = from >= 0 && to >= 0 && to > from;
35
+ const ok = !diff.hasBreaking || majorBumped;
36
+
37
+ if (!ok) {
38
+ throw new ManifestBreakingError({
39
+ changes: diff.breaking.map((c) => `${c.path}: ${c.detail}`),
40
+ from: input.before.app.version,
41
+ to: input.after.app.version,
42
+ });
43
+ }
44
+ return { diff, majorBumped, ok };
45
+ }
46
+
47
+ /** Major component of a semver string, or -1 when it cannot be parsed. */
48
+ function majorOf(version: string): number {
49
+ const match = /^(\d+)\./.exec(version.trim());
50
+ if (match === null) return -1;
51
+ return Number.parseInt(match[1] ?? '', 10);
52
+ }