@ultimat3/manifest 24.0.0 → 25.1.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/CLAUDE.md CHANGED
@@ -44,7 +44,7 @@ by the CLI, not imported.
44
44
  - Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts` — `as const satisfies
45
45
  readonly (keyof Manifest)[]` AND walked by `emit.test.ts`, the treatment `ARRAY_SECTIONS` has,
46
46
  because the annotation catches a key that is not on `Manifest` and only the walk catches one that
47
- is MISSING. `manifestJson` writes those keys and no others while `contentHash` hashes the whole
47
+ is MISSING. `manifestJson` writes those keys and no others while `fingerprint` hashes the whole
48
48
  body, so a 14th field would go into the hash and be dropped from the file — after which
49
49
  `assertNoDrift` convicts the committed manifest as HAND_EDITED, a correct refusal with the wrong
50
50
  diagnosis, about a file nobody touched.
@@ -162,7 +162,7 @@ by the CLI, not imported.
162
162
  - `diff.ts` reads `mcp.expose` through `isMcpExposed` from `@ultimat3/core`, on **both** sides.
163
163
  `before` is a file parsed off disk, so an older or hand-trimmed manifest can carry an absent or
164
164
  non-boolean value that `!==` would classify from; and the fact `sources.ts` publishes has to be
165
- the answer `toMcpTools` gives, or the gate demands a major bump for a tool that never existed.
165
+ the answer `@ultimat3/mcp`'s catalog gives, or the gate demands a major bump for a tool that never existed.
166
166
 
167
167
  ## Commands
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "24.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,18 +25,18 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "24.0.0",
36
- "@ultimat3/core": "24.0.0",
37
- "@ultimat3/entity": "24.0.0",
38
- "@ultimat3/jobs": "24.0.0",
39
- "@ultimat3/query": "24.0.0",
40
- "@ultimat3/realtime": "24.0.0"
35
+ "@ultimat3/action": "25.1.0",
36
+ "@ultimat3/core": "25.1.0",
37
+ "@ultimat3/entity": "25.1.0",
38
+ "@ultimat3/jobs": "25.1.0",
39
+ "@ultimat3/query": "25.1.0",
40
+ "@ultimat3/realtime": "25.1.0"
41
41
  }
42
42
  }
package/src/build.ts CHANGED
@@ -99,20 +99,12 @@ export function buildManifest(sources: ManifestSources): Manifest {
99
99
 
100
100
  // Before the hash: a fact the written file cannot hold must never get a buildId at all.
101
101
  assertFiniteFacts(body, '');
102
- return { ...body, buildId: contentHash(body) };
103
- }
104
-
105
- /**
106
- * Content hash of the manifest body: `@ultimat3/core`'s `fingerprint`, SHA-256/16 over the same
107
- * INJECTIVE `canonicalJson` the diff compares on, so a fact that changed cannot hash the same as
108
- * the fact it replaced. It was a hand-written copy of that function, byte for byte — the value is
109
- * unchanged, the second implementation is gone. Deliberately excludes `buildId` itself.
110
- *
111
- * This is a HASH, never the published document: `manifestJson` in `emit.ts` is what reaches disk,
112
- * and it is `JSON.stringify` with a fixed key order for exactly that reason.
113
- */
114
- export function contentHash(body: Omit<Manifest, 'buildId'>): string {
115
- return fingerprint(body);
102
+ // The build id is `@ultimat3/core`'s `fingerprint` of the body — SHA-256/16 over the same
103
+ // INJECTIVE `canonicalJson` the diff compares on, so a fact that changed cannot hash the same as
104
+ // the fact it replaced — and deliberately excludes `buildId` itself. A HASH, never the published
105
+ // document: `manifestJson` in `emit.ts` is what reaches disk, with a fixed key order. The
106
+ // `contentHash` alias this package exported for it left in 25.0.0 (plan 101, M3).
107
+ return { ...body, buildId: fingerprint(body) };
116
108
  }
117
109
 
118
110
  function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
package/src/diff-admin.ts CHANGED
@@ -28,6 +28,35 @@ const flag = (
28
28
  },
29
29
  ];
30
30
 
31
+ /**
32
+ * The admin-level gate, as `adminPermissionForAction` (`@ultimat3/admin`) decides it: `destructive`
33
+ * and `matching` hold the write gate, and only then does `readonly` lower it. Restated, not
34
+ * imported: the admin is a tier above this package. `destructive` reports its own change (and its
35
+ * own `admin:destroy` gate), so this only says read or write.
36
+ */
37
+ const gateOf = (action: AdminActionFact): 'admin:read' | 'admin:write' =>
38
+ action.readonly && !action.destructive && !action.matching ? 'admin:read' : 'admin:write';
39
+
40
+ /** `readonly` / `matching` flipping: a caller sees it only when the gate moves. */
41
+ function diffGate(at: string, before: AdminActionFact, after: AdminActionFact): ManifestChange[] {
42
+ const changes: ManifestChange[] = [];
43
+ const [was, is] = [gateOf(before), gateOf(after)];
44
+ if (was !== is) {
45
+ // Write → read lets an `admin:read` caller in; read → write refuses one it served.
46
+ const kind = is === 'admin:write' ? 'breaking' : 'additive';
47
+ changes.push({ kind, path: `${at}.gate`, detail: `admin gate ${was} -> ${is}` });
48
+ }
49
+ for (const key of ['readonly', 'matching'] as const) {
50
+ // An absent flag reads as `false` (`schema.ts`): a baseline written before the field existed
51
+ // is not a change against a build that states `false`.
52
+ const [had, has] = [before[key] === true, after[key] === true];
53
+ if (had === has) continue;
54
+ const detail = `${key} ${String(had)} -> ${String(has)}`;
55
+ changes.push({ kind: 'internal', path: `${at}.${key}`, detail });
56
+ }
57
+ return changes;
58
+ }
59
+
31
60
  function diffActions(
32
61
  path: string,
33
62
  before: readonly AdminActionFact[],
@@ -58,6 +87,7 @@ function diffActions(
58
87
  ...flag(`${at}.input`, action.input, now.input, true, 'input schema'),
59
88
  ...flag(`${at}.destructive`, action.destructive, now.destructive, true, 'destructive'),
60
89
  );
90
+ changes.push(...diffGate(at, action, now));
61
91
  if (action.threshold !== now.threshold) {
62
92
  changes.push({
63
93
  kind: 'internal',
package/src/docs-scan.ts CHANGED
@@ -158,18 +158,21 @@ async function guideEntries(
158
158
  // id — silently, which is the failure mode the search's own coverage floor exists to prevent,
159
159
  // and a direct contradiction of `DocEntry.topic`'s promise to be unique within a package. No
160
160
  // shipped guide collides today; an app's own package is one heading away from it. The suffix
161
- // follows document order, so the id is still derived and still reproducible.
162
- const used = new Map<string, number>();
161
+ // follows document order, so the id is still derived and still reproducible. EVERY emitted id is
162
+ // recorded and a suffix climbs past a taken one: `## Retry`, `## Retry`, `## Retry 2` otherwise
163
+ // gave the second `Retry` and the real `Retry 2` the same `retry-2`.
164
+ const emitted = new Set<string>();
163
165
  for (const file of GUIDE_FILES) {
164
166
  const markdown = await read(join(dir, file));
165
167
  if (markdown === undefined) continue;
166
168
  const stem = basename(file, '.md');
167
169
  for (const section of parseGuideSections(markdown)) {
168
170
  const base = `${shortName(name)}.${stem}#${slug(section.heading)}`;
169
- const seen = (used.get(base) ?? 0) + 1;
170
- used.set(base, seen);
171
+ let topic = base;
172
+ for (let suffix = 2; emitted.has(topic); suffix += 1) topic = `${base}-${suffix}`;
173
+ emitted.add(topic);
171
174
  entries.push({
172
- topic: seen === 1 ? base : `${base}-${seen}`,
175
+ topic,
173
176
  package: name,
174
177
  version,
175
178
  kind: 'guide',
package/src/emit.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  // so a refactor that reorders a struct literal does not produce a diff. Two-space indent and
5
5
  // a trailing newline: the file is reviewed by humans and diffed by git.
6
6
 
7
- import { canonicalJson } from '@ultimat3/core';
8
- import { contentHash } from './build';
7
+ import { canonicalJson, fingerprint } from '@ultimat3/core';
9
8
  import { ManifestDriftError } from './errors';
10
9
  import type { Manifest } from './schema';
11
10
  import { isManifest } from './schema';
@@ -18,7 +17,7 @@ export const MANIFEST_FILENAME = 'x.manifest.json';
18
17
  * `as const satisfies` and a test that WALKS it, the treatment `ARRAY_SECTIONS` already has
19
18
  * (`schema.ts`) — the annotation catches a key that is not on `Manifest`, only a walk catches one
20
19
  * that is missing. It was a bare annotation, and `manifestJson` writes these keys and no others
21
- * while `contentHash` hashes the whole body: a 14th field added to `Manifest` would have gone into
20
+ * while `fingerprint` hashes the whole body: a 14th field added to `Manifest` would have gone into
22
21
  * the hash and been dropped from the file, after which `assertNoDrift` convicts the committed
23
22
  * manifest as HAND_EDITED — a correct refusal carrying the wrong diagnosis, about a file nobody
24
23
  * touched. Exported for that test alone; deliberately NOT re-exported by `src/index.ts`, because
@@ -150,7 +149,7 @@ function describeDrift(onDisk: Manifest, fresh: Manifest): readonly string[] {
150
149
  /** Verify a file's `buildId` against its own contents — catches a hand-edited manifest. */
151
150
  export function verifyBuildId(manifest: Manifest): boolean {
152
151
  const { buildId, ...body } = manifest;
153
- return contentHash(body) === buildId;
152
+ return fingerprint(body) === buildId;
154
153
  }
155
154
 
156
155
  async function readIfExists(path: string): Promise<string | undefined> {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export {
10
10
  checkAgentsMd,
11
11
  } from './agents-md';
12
12
  export type { ManifestSources } from './build';
13
- export { buildManifest, contentHash } from './build';
13
+ export { buildManifest } from './build';
14
14
  export type { ChangeKind, ManifestChange, ManifestDiff } from './diff';
15
15
  export { diffManifest, formatDiff } from './diff';
16
16
  export type { DocEntry, DocEntryKind } from './docs-scan';
package/src/schema.ts CHANGED
@@ -69,6 +69,13 @@ export interface AdminActionFact {
69
69
  readonly when: boolean;
70
70
  readonly batch: boolean;
71
71
  readonly threshold: number | null;
72
+ /**
73
+ * The two inputs of the admin-level gate besides `destructive`: `readonly` lowers it to
74
+ * `admin:read`, `matching` (a set-based write) holds it at `admin:write`. Absent from a file
75
+ * written before they were recorded, and read there as `false`.
76
+ */
77
+ readonly readonly: boolean;
78
+ readonly matching: boolean;
72
79
  }
73
80
 
74
81
  /** One resource of a generated admin: what its list answers, and whether a row scope narrows it. */
@@ -75,6 +75,8 @@ const actionOf = (value: unknown): AdminActionFact | undefined => {
75
75
  when: bag['when'] === true,
76
76
  batch: bag['batch'] === true,
77
77
  threshold,
78
+ readonly: bag['readonly'] === true,
79
+ matching: bag['matching'] === true,
78
80
  };
79
81
  };
80
82
 
package/src/sources.ts CHANGED
@@ -23,7 +23,7 @@ import { declaredAdmins } from './sources-admin';
23
23
 
24
24
  export interface FrameworkSourcesInput {
25
25
  readonly app: { readonly name: string; readonly version: string };
26
- /** From `@ultimat3/render`'s `describeRoutes()`. */
26
+ /** From `@ultimat3/render`'s `describePages()`. */
27
27
  readonly routes?: readonly RouteFact[];
28
28
  /** Assembled per app from its policy modules. */
29
29
  readonly policies?: readonly PolicyFact[];
@@ -1,96 +0,0 @@
1
- // TEST-ONLY. One fully-populated `ManifestSources` for the diff suites, so every section carries a
2
- // fact and every fact carries every field its type declares — a fixture with an empty `tasks` is
3
- // how a section nothing classifies stays green. Never exported from `index.ts`.
4
-
5
- import type { ManifestSources } from './build';
6
- import { buildManifest } from './build';
7
- import type { Manifest } from './schema';
8
-
9
- export const fixtureAction = (
10
- name: string,
11
- policy: string,
12
- expose = true,
13
- permissions = [policy],
14
- ) => ({
15
- name,
16
- input: { id: 'uuid' },
17
- output: { ok: 'boolean' },
18
- policy,
19
- permissions,
20
- cacheInvalidates: ['post'],
21
- mcp: { expose },
22
- });
23
-
24
- export const fixtureQuery = (name: string, policy: string, permissions = [policy]) => ({
25
- name,
26
- input: {},
27
- policy,
28
- permissions,
29
- live: true,
30
- subscribes: ['posts'],
31
- cacheTags: ['post'],
32
- });
33
-
34
- /**
35
- * Built through a helper rather than written as `{ code: 'X_…' }`: `x verify`'s `errors` step
36
- * reads a `code:` key with an `X_*` literal as a DECLARATION, and this file is not a test file, so
37
- * the literal would have published a fixture's code into `framework.manifest.json` as one this
38
- * package owns.
39
- */
40
- export const fixtureErrorCode = (code: string, owner: string) => ({ code, package: owner });
41
-
42
- /** Every section non-empty, every optional field present. */
43
- export const FIXTURE: ManifestSources = {
44
- app: { name: 'acme', version: '1.4.2' },
45
- routes: [
46
- {
47
- url: '/posts',
48
- render: 'isr',
49
- offline: 'precache',
50
- hydrate: 'idle',
51
- revalidateTags: ['post'],
52
- budget: { js: '40kb' },
53
- surface: 'site',
54
- },
55
- ],
56
- entities: [
57
- {
58
- name: 'post',
59
- table: 'posts',
60
- columns: [
61
- { name: 'id', type: 'uuid', nullable: false, primaryKey: true },
62
- { name: 'authorId', type: 'uuid', nullable: false, references: 'users.id' },
63
- { name: 'note', type: 'text', nullable: true },
64
- ],
65
- invariants: ['post_title_present'],
66
- },
67
- ],
68
- actions: [fixtureAction('publishPost', 'post:publish')],
69
- queries: [fixtureQuery('feed', 'feed:read')],
70
- jobs: [
71
- {
72
- name: 'sendMail',
73
- input: { orgId: 'uuid' },
74
- queue: 'critical',
75
- retry: { attempts: 5, backoff: 'exponential' },
76
- steps: ['a'],
77
- },
78
- ],
79
- tasks: [
80
- { name: 'nightlyDigest', cron: '0 3 * * *', tz: 'Europe/Berlin', enqueues: ['sendMail'] },
81
- ],
82
- policies: [
83
- { permission: 'post:publish', description: 'publish a draft', enforcedIn: ['actions.publish'] },
84
- ],
85
- locales: ['en'],
86
- // A code that is genuinely REGISTERED, not an invented one. `error-catalog.test.ts` scans every
87
- // non-test file under `packages/*/src` for an `X_*` literal and treats an unregistered one as a
88
- // code handed to a reader that no gate can see. A fixture is not shipped source in spirit, but
89
- // it is in fact — and widening that scanner to excuse a filename is a worse trade than picking a
90
- // real code here, since the diff classifier only ever compares the string.
91
- errorCodes: [fixtureErrorCode('X_NOT_FOUND', 'app')],
92
- };
93
-
94
- /** The fixture, built. */
95
- export const fixtureManifest = (overrides: Partial<ManifestSources> = {}): Manifest =>
96
- buildManifest({ ...FIXTURE, ...overrides });