@githolon/dsl 0.49.0 → 0.51.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/check_package.mjs +46 -0
- package/dart/example/nomos_secure_key_store.dart +82 -0
- package/dart/lib/src/webview_bridge.dart +442 -6
- package/package.json +3 -2
- package/src/check_package_main.ts +194 -0
- package/src/codegen_dart_client.ts +124 -8
- package/src/codegen_proof.ts +1 -1
- package/src/codegen_ts.ts +470 -14
- package/src/compile_package_main.ts +19 -0
- package/src/evolve_preview.ts +466 -0
- package/src/law_check.ts +777 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
4
|
+
// QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE EVOLVE PREVIEW — `githolon check --against <prior>` (architecture/author_time_gate.md,
|
|
8
|
+
* the `--against` row of check slice 3). The evolve gate is the kernel's #58 diff-by-stable-id
|
|
9
|
+
* (executor/src/law58.rs::evolve_violation): a RENAME (same sid, new label) admits silently; a
|
|
10
|
+
* true REMOVAL / RETYPE refuses TYPED unless the deploy JSON carries a `dispositions`
|
|
11
|
+
* ({retired:[sid], retyped:{sid:why}, rebinds:{newSid:oldSid}}) acknowledging it in-history.
|
|
12
|
+
*
|
|
13
|
+
* This module is the AUTHOR-TIME TWIN of that gate: given the PRIOR law and the CURRENT build,
|
|
14
|
+
* it diffs them BY STABLE ID and pre-fires the gate's verdicts — with the EXACT generated
|
|
15
|
+
* dispositions fragment (pre-filled with the real sids) inside the refusal's `fix`, so the dev
|
|
16
|
+
* pastes it into their deploy JSON and the real deploy admits. It classifies:
|
|
17
|
+
*
|
|
18
|
+
* RENAME (note) — same sid, new label; admits silently, old rows read the new label.
|
|
19
|
+
* ADDITIVE (note) — a new aggregate/field/directive (incl. an additive optional VO field);
|
|
20
|
+
* the evolve gate admits it silently (vo_first_class.md slice 6).
|
|
21
|
+
* REMOVAL (refuse) — a sid disappeared; the gate REFUSES without dispositions.retired:[sid].
|
|
22
|
+
* RETYPE (refuse) — same sid, changed read-kind or merge-driver; needs dispositions.retyped.
|
|
23
|
+
* PAYLOAD (warn) — a directive's payload shape changed; clients compiled against the old
|
|
24
|
+
* law get typed 422s until regenerated (directives are not sid-tracked).
|
|
25
|
+
*
|
|
26
|
+
* The preview NEVER needs to emit a `rebinds` fragment: a rename is detected by a MATCHED sid
|
|
27
|
+
* (the compiler carries it via the nomos.stable-ids.json lockfile), so it is already a silent
|
|
28
|
+
* admit — rebinds is only for the hand-repair path where the sid was lost, which this preview,
|
|
29
|
+
* reading the carried sids, never produces.
|
|
30
|
+
*/
|
|
31
|
+
import type { Finding } from "./law_check.js";
|
|
32
|
+
import type { DirectivePayloadShape, DomainInterface } from "./shape_compat.js";
|
|
33
|
+
import { domainInterface } from "./shape_compat.js";
|
|
34
|
+
import type { StableIds } from "./stable_ids_types.js";
|
|
35
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
36
|
+
import type { WireSchema } from "./wire.js";
|
|
37
|
+
|
|
38
|
+
// ── the normalized law shape both sides of the diff speak ──────────────────────────
|
|
39
|
+
|
|
40
|
+
/** One aggregate's evolve-relevant surface: its stable id, current label, and its fields
|
|
41
|
+
* keyed by FIELD SID (so a rename is a label move, not a new key). */
|
|
42
|
+
export interface EvolveAggregate {
|
|
43
|
+
readonly sid: string;
|
|
44
|
+
readonly label: string;
|
|
45
|
+
/** keyed by FIELD SID. `driver` is the merge-law JSON (from the lockfile/usda schemas)
|
|
46
|
+
* when known — it lets the retype arm catch a driver-only change (same read kind). */
|
|
47
|
+
readonly fields: Record<string, { sid: string; label: string; kind: string; driver?: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One domain's evolve surface: aggregates (sid-keyed) + directive payload shapes (name-keyed). */
|
|
51
|
+
export interface EvolveDomain {
|
|
52
|
+
readonly domainSid?: string;
|
|
53
|
+
/** keyed by AGGREGATE SID. */
|
|
54
|
+
readonly aggregates: Record<string, EvolveAggregate>;
|
|
55
|
+
/** keyed by DIRECTIVE ID (directives are not sid-tracked — the dispatched name IS identity). */
|
|
56
|
+
readonly directives: Record<string, DirectivePayloadShape>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A whole package's evolve surface, keyed by DOMAIN KEY. */
|
|
60
|
+
export type EvolveLaw = Record<string, EvolveDomain>;
|
|
61
|
+
|
|
62
|
+
/** The dispositions fragment the deploy JSON carries to admit destructive evolves (the exact
|
|
63
|
+
* shape executor/src/law58.rs::dispositions_from_payload parses). */
|
|
64
|
+
export interface EvolveDispositionsJson {
|
|
65
|
+
retired?: string[];
|
|
66
|
+
retyped?: Record<string, string>;
|
|
67
|
+
rebinds?: Record<string, string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── normalizers: build an EvolveLaw from the artifacts a dev already has ────────────
|
|
71
|
+
|
|
72
|
+
/** Build ONE domain's aggregate surface from a #58 `StableIds` block (+ optional driver
|
|
73
|
+
* schemas keyed by CURRENT aggregate label). The sids/kinds come from the stable-id block;
|
|
74
|
+
* the drivers (when supplied) sharpen the retype arm. */
|
|
75
|
+
function aggregatesFromStableIds(
|
|
76
|
+
stableIds: StableIds | undefined,
|
|
77
|
+
schemas?: Record<string, WireSchema>,
|
|
78
|
+
): Record<string, EvolveAggregate> {
|
|
79
|
+
const out: Record<string, EvolveAggregate> = {};
|
|
80
|
+
if (stableIds?.aggregates === undefined) return out;
|
|
81
|
+
for (const [aggLabel, a] of Object.entries(stableIds.aggregates)) {
|
|
82
|
+
const fields: EvolveAggregate["fields"] = {};
|
|
83
|
+
for (const [fLabel, f] of Object.entries(a.fields ?? {})) {
|
|
84
|
+
const driver = schemas?.[aggLabel]?.[fLabel];
|
|
85
|
+
fields[f.sid] = {
|
|
86
|
+
sid: f.sid,
|
|
87
|
+
label: fLabel,
|
|
88
|
+
kind: f.kind ?? "",
|
|
89
|
+
...(driver !== undefined ? { driver: JSON.stringify(driver) } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
out[a.sid] = { sid: a.sid, label: aggLabel, fields };
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build an EvolveDomain from a DomainInterface (deploy.json's `interface`) — its aggregates are
|
|
98
|
+
* already sid-keyed with kinds+labels, its directives carry payload shapes. Drivers are absent
|
|
99
|
+
* from an interface (it exposes only read kinds), so a driver-only retype is not seen from an
|
|
100
|
+
* interface alone — the lockfile lane (aggregatesFromStableIds + schemas) carries them. */
|
|
101
|
+
function evolveDomainFromInterface(iface: DomainInterface): EvolveDomain {
|
|
102
|
+
const aggregates: Record<string, EvolveAggregate> = {};
|
|
103
|
+
for (const [aggSid, a] of Object.entries(iface.aggregates ?? {})) {
|
|
104
|
+
const fields: EvolveAggregate["fields"] = {};
|
|
105
|
+
for (const [fSid, f] of Object.entries(a.fields ?? {})) {
|
|
106
|
+
fields[fSid] = { sid: fSid, label: f.label, kind: f.kind };
|
|
107
|
+
}
|
|
108
|
+
aggregates[aggSid] = { sid: a.sid ?? aggSid, label: a.label, fields };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
...(iface.domainSid !== undefined ? { domainSid: iface.domainSid } : {}),
|
|
112
|
+
aggregates,
|
|
113
|
+
directives: iface.directives ?? {},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The committed `nomos.stable-ids.json` lockfile shape (compile_package_main writes it). */
|
|
118
|
+
export interface StableIdLockfile {
|
|
119
|
+
v: 1;
|
|
120
|
+
domains: Record<string, { stableIds: StableIds; schemas?: Record<string, WireSchema> }>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A deploy.json body (the subset this preview reads): the per-domain interface + the package
|
|
124
|
+
* bytes (for the embedded stable-ids/driver schemas when no lockfile rides alongside). */
|
|
125
|
+
export interface DeployJson {
|
|
126
|
+
interface?: Record<string, DomainInterface>;
|
|
127
|
+
packageUsda?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Extract the embedded USD-IR JSON from a `.package.usda` bundle (the `nomos:usdJsonHex` field —
|
|
131
|
+
* the SAME substring contract executor/src/law58.rs::usd_json_from_package uses). */
|
|
132
|
+
export function usdJsonFromPackage(bundle: string): unknown | undefined {
|
|
133
|
+
const key = 'nomos:usdJsonHex = "';
|
|
134
|
+
const start = bundle.indexOf(key);
|
|
135
|
+
if (start < 0) return undefined;
|
|
136
|
+
const from = start + key.length;
|
|
137
|
+
const end = bundle.indexOf('"', from);
|
|
138
|
+
if (end < 0) return undefined;
|
|
139
|
+
const hex = bundle.slice(from, end);
|
|
140
|
+
if (hex.length % 2 !== 0) return undefined;
|
|
141
|
+
try {
|
|
142
|
+
const bytes = Buffer.from(hex, "hex");
|
|
143
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Per-domain-key { stableIds, schemas } parsed out of a package's embedded USD-IR (mirrors
|
|
150
|
+
* executor/src/law58.rs::stable_ids_from_usd + the aggregate-prim schema harvest). */
|
|
151
|
+
export function stableIdsFromUsd(
|
|
152
|
+
usd: unknown,
|
|
153
|
+
): Record<string, { stableIds: StableIds; schemas: Record<string, WireSchema> }> {
|
|
154
|
+
const out: Record<string, { stableIds: StableIds; schemas: Record<string, WireSchema> }> = {};
|
|
155
|
+
const layers = (usd as { layers?: unknown[] })?.layers;
|
|
156
|
+
if (!Array.isArray(layers)) return out;
|
|
157
|
+
for (const layer of layers) {
|
|
158
|
+
const l = layer as {
|
|
159
|
+
path?: string;
|
|
160
|
+
nomosStableIds?: StableIds;
|
|
161
|
+
prims?: Array<{ kind?: string; type?: string; schema?: Record<string, unknown> }>;
|
|
162
|
+
};
|
|
163
|
+
const path = typeof l.path === "string" ? l.path : "";
|
|
164
|
+
const key = path.startsWith("/Nomos/") ? path.slice("/Nomos/".length) : "";
|
|
165
|
+
if (key === "" || l.nomosStableIds === undefined) continue;
|
|
166
|
+
const schemas: Record<string, WireSchema> = {};
|
|
167
|
+
for (const prim of l.prims ?? []) {
|
|
168
|
+
if (prim.kind === "aggregate" && typeof prim.type === "string" && prim.schema) {
|
|
169
|
+
schemas[prim.type] = prim.schema as WireSchema;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
out[key] = { stableIds: l.nomosStableIds, schemas };
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build an EvolveLaw from a deploy.json body: interface for directive payloads + sid-keyed read
|
|
178
|
+
* shapes, and the embedded package USD-IR for the driver schemas (so a driver-only retype is
|
|
179
|
+
* seen from a deploy.json alone). */
|
|
180
|
+
export function evolveLawFromDeployJson(deploy: DeployJson): EvolveLaw {
|
|
181
|
+
const law: EvolveLaw = {};
|
|
182
|
+
const ifaces = deploy.interface ?? {};
|
|
183
|
+
const usd = deploy.packageUsda !== undefined ? usdJsonFromPackage(deploy.packageUsda) : undefined;
|
|
184
|
+
const fromUsd = usd !== undefined ? stableIdsFromUsd(usd) : {};
|
|
185
|
+
for (const [key, iface] of Object.entries(ifaces)) {
|
|
186
|
+
const dom = evolveDomainFromInterface(iface);
|
|
187
|
+
// Augment fields with drivers off the package USD-IR schemas (interface carries none).
|
|
188
|
+
const usdDom = fromUsd[key];
|
|
189
|
+
if (usdDom !== undefined) {
|
|
190
|
+
for (const agg of Object.values(dom.aggregates)) {
|
|
191
|
+
const schema = usdDom.schemas[agg.label];
|
|
192
|
+
if (schema === undefined) continue;
|
|
193
|
+
for (const f of Object.values(agg.fields)) {
|
|
194
|
+
const driver = schema[f.label];
|
|
195
|
+
if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
law[key] = dom;
|
|
200
|
+
}
|
|
201
|
+
// A deploy.json with NO interface (older builds) still yields aggregate identity from the USD.
|
|
202
|
+
for (const [key, sd] of Object.entries(fromUsd)) {
|
|
203
|
+
if (law[key] !== undefined) continue;
|
|
204
|
+
law[key] = { domainSid: sd.stableIds.domain, aggregates: aggregatesFromStableIds(sd.stableIds, sd.schemas), directives: {} };
|
|
205
|
+
}
|
|
206
|
+
return law;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build an EvolveLaw from a `nomos.stable-ids.json` lockfile — aggregate identity + drivers, but
|
|
210
|
+
* NO directive payloads (a lockfile carries no interface). Directive-level warnings are then
|
|
211
|
+
* unavailable; the caller notes it. */
|
|
212
|
+
export function evolveLawFromLockfile(lock: StableIdLockfile): EvolveLaw {
|
|
213
|
+
const law: EvolveLaw = {};
|
|
214
|
+
for (const [key, d] of Object.entries(lock.domains ?? {})) {
|
|
215
|
+
law[key] = {
|
|
216
|
+
domainSid: d.stableIds?.domain,
|
|
217
|
+
aggregates: aggregatesFromStableIds(d.stableIds, d.schemas),
|
|
218
|
+
directives: {},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return law;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build the CURRENT build's EvolveLaw from the composed modules (directive payloads + sid-keyed
|
|
225
|
+
* read shapes via `domainInterface`) plus the committed lockfile (the current sids/drivers). When
|
|
226
|
+
* no lockfile is present the interface falls back to NAME-keyed identity (domainInterface's
|
|
227
|
+
* pre-#58 fallback) — a rename then reads as removal+add; the caller warns to compile first. */
|
|
228
|
+
export function currentEvolveLaw(
|
|
229
|
+
modules: readonly DomainModule[],
|
|
230
|
+
lock?: StableIdLockfile,
|
|
231
|
+
): EvolveLaw {
|
|
232
|
+
const law: EvolveLaw = {};
|
|
233
|
+
for (const mod of modules) {
|
|
234
|
+
const key = mod.domain ?? mod.name;
|
|
235
|
+
const locked = lock?.domains[mod.name] ?? lock?.domains[key];
|
|
236
|
+
const stableIds = locked?.stableIds;
|
|
237
|
+
const iface = domainInterface(mod, stableIds);
|
|
238
|
+
const dom = evolveDomainFromInterface(iface);
|
|
239
|
+
// Fold drivers from the lockfile schemas (interface has none).
|
|
240
|
+
if (locked?.schemas !== undefined) {
|
|
241
|
+
for (const agg of Object.values(dom.aggregates)) {
|
|
242
|
+
const schema = locked.schemas[agg.label];
|
|
243
|
+
if (schema === undefined) continue;
|
|
244
|
+
for (const f of Object.values(agg.fields)) {
|
|
245
|
+
const driver = schema[f.label];
|
|
246
|
+
if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
law[key] = dom;
|
|
251
|
+
}
|
|
252
|
+
return law;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── the diff ────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/** The full preview result: the findings AND the merged dispositions the destructive findings
|
|
258
|
+
* demand, keyed by DOMAIN KEY (exactly the object a deploy JSON carries per installDomain). */
|
|
259
|
+
export interface EvolveDiffResult {
|
|
260
|
+
readonly findings: Finding[];
|
|
261
|
+
/** domain key → the dispositions fragment that makes THIS domain's destructive evolves admit. */
|
|
262
|
+
readonly dispositions: Record<string, EvolveDispositionsJson>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A short, bounded render of a directive's payload field shape (for a warn's old-vs-new naming). */
|
|
266
|
+
function payloadShapeText(shape: DirectivePayloadShape): string {
|
|
267
|
+
const parts = Object.entries(shape.fields ?? {})
|
|
268
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
269
|
+
.map(([n, f]) => `${n}:${f.kind}${f.optional ? "?" : ""}`);
|
|
270
|
+
return `{ ${parts.join(", ")} }`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function ensureDisp(map: Record<string, EvolveDispositionsJson>, domain: string): EvolveDispositionsJson {
|
|
274
|
+
return (map[domain] ??= {});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Diff one domain's aggregates/fields/directives BY STABLE ID and append findings + dispositions. */
|
|
278
|
+
function diffDomain(
|
|
279
|
+
domain: string,
|
|
280
|
+
prior: EvolveDomain,
|
|
281
|
+
current: EvolveDomain,
|
|
282
|
+
findings: Finding[],
|
|
283
|
+
disp: Record<string, EvolveDispositionsJson>,
|
|
284
|
+
): void {
|
|
285
|
+
const curAggBySid = current.aggregates;
|
|
286
|
+
const priorAggBySid = prior.aggregates;
|
|
287
|
+
|
|
288
|
+
for (const [sid, oldAgg] of Object.entries(priorAggBySid)) {
|
|
289
|
+
const newAgg = curAggBySid[sid];
|
|
290
|
+
if (newAgg === undefined) {
|
|
291
|
+
// REMOVAL of a whole aggregate — retiring the agg sid satisfies the kernel's diff_by_sid.
|
|
292
|
+
const d = ensureDisp(disp, domain);
|
|
293
|
+
(d.retired ??= []).push(sid);
|
|
294
|
+
findings.push({
|
|
295
|
+
domain,
|
|
296
|
+
level: "refuse",
|
|
297
|
+
what: `aggregate '${oldAgg.label}' (stable id ${sid}) is REMOVED — the evolve gate will refuse without a disposition`,
|
|
298
|
+
fix: `paste into your deploy JSON: "dispositions": { "retired": ["${sid}"] }`,
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (newAgg.label !== oldAgg.label) {
|
|
303
|
+
findings.push({
|
|
304
|
+
domain,
|
|
305
|
+
level: "note",
|
|
306
|
+
what: `aggregate renamed '${oldAgg.label}' → '${newAgg.label}' (stable id ${sid})`,
|
|
307
|
+
fix: `admits silently; old rows read the new label — no disposition needed`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// FIELD diff within the matched aggregate.
|
|
311
|
+
for (const [fSid, oldF] of Object.entries(oldAgg.fields)) {
|
|
312
|
+
const newF = newAgg.fields[fSid];
|
|
313
|
+
if (newF === undefined) {
|
|
314
|
+
const d = ensureDisp(disp, domain);
|
|
315
|
+
(d.retired ??= []).push(fSid);
|
|
316
|
+
findings.push({
|
|
317
|
+
domain,
|
|
318
|
+
level: "refuse",
|
|
319
|
+
what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is REMOVED — the evolve gate will refuse without a disposition`,
|
|
320
|
+
fix: `paste into your deploy JSON: "dispositions": { "retired": ["${fSid}"] }`,
|
|
321
|
+
});
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (newF.label !== oldF.label) {
|
|
325
|
+
findings.push({
|
|
326
|
+
domain,
|
|
327
|
+
level: "note",
|
|
328
|
+
what: `field renamed '${oldAgg.label}.${oldF.label}' → '${newAgg.label}.${newF.label}' (stable id ${fSid})`,
|
|
329
|
+
fix: `admits silently; old rows read the new label — no disposition needed`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const kindChanged = oldF.kind !== "" && newF.kind !== "" && oldF.kind !== newF.kind;
|
|
333
|
+
const driverChanged =
|
|
334
|
+
oldF.driver !== undefined && newF.driver !== undefined && oldF.driver !== newF.driver;
|
|
335
|
+
if (kindChanged || driverChanged) {
|
|
336
|
+
const d = ensureDisp(disp, domain);
|
|
337
|
+
(d.retyped ??= {})[fSid] = "<why goes here>";
|
|
338
|
+
const how = kindChanged
|
|
339
|
+
? `read kind ${oldF.kind} → ${newF.kind}`
|
|
340
|
+
: `merge driver ${oldF.driver} → ${newF.driver}`;
|
|
341
|
+
findings.push({
|
|
342
|
+
domain,
|
|
343
|
+
level: "refuse",
|
|
344
|
+
what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is RETYPED in place (${how}) — the evolve gate will refuse without a disposition`,
|
|
345
|
+
fix: `paste into your deploy JSON: "dispositions": { "retyped": { "${fSid}": "<why goes here>" } } — or add a NEW field instead`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ADDITIVE fields (new sid within a surviving aggregate) — safe, admits silently.
|
|
350
|
+
for (const [fSid, newF] of Object.entries(newAgg.fields)) {
|
|
351
|
+
if (oldAgg.fields[fSid] !== undefined) continue;
|
|
352
|
+
findings.push({
|
|
353
|
+
domain,
|
|
354
|
+
level: "note",
|
|
355
|
+
what: `field '${newAgg.label}.${newF.label}' (stable id ${fSid}) is ADDED`,
|
|
356
|
+
fix: `additive — the evolve gate admits it silently; old rows read it absent (an optional field)`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// ADDITIVE aggregates (new sid) — safe, admits silently.
|
|
361
|
+
for (const [sid, newAgg] of Object.entries(curAggBySid)) {
|
|
362
|
+
if (priorAggBySid[sid] !== undefined) continue;
|
|
363
|
+
findings.push({
|
|
364
|
+
domain,
|
|
365
|
+
level: "note",
|
|
366
|
+
what: `aggregate '${newAgg.label}' (stable id ${sid}) is ADDED`,
|
|
367
|
+
fix: `additive — the evolve gate admits it silently`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// DIRECTIVES (name-keyed — not sid-tracked). A payload-shape change is a client break, not an
|
|
372
|
+
// evolve-gate refusal, so it is a WARN naming old vs new shape.
|
|
373
|
+
for (const [id, oldShape] of Object.entries(prior.directives)) {
|
|
374
|
+
const newShape = current.directives[id];
|
|
375
|
+
if (newShape === undefined) {
|
|
376
|
+
findings.push({
|
|
377
|
+
domain,
|
|
378
|
+
directiveId: id,
|
|
379
|
+
level: "warn",
|
|
380
|
+
what: `directive '${id}' is REMOVED — clients still calling it get a typed 422 until they drop/regenerate`,
|
|
381
|
+
fix: `keep it, or confirm every client stopped dispatching '${id}' before deploying`,
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!samePayloadShape(oldShape, newShape)) {
|
|
386
|
+
findings.push({
|
|
387
|
+
domain,
|
|
388
|
+
directiveId: id,
|
|
389
|
+
level: "warn",
|
|
390
|
+
what: `directive '${id}' payload shape changed ${payloadShapeText(oldShape)} → ${payloadShapeText(newShape)} — clients compiled against the old law get typed 422s until regenerated`,
|
|
391
|
+
fix: `regenerate + redeploy dependent clients (the '.client.ts'/Dart surface) against the new law`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const id of Object.keys(current.directives)) {
|
|
396
|
+
if (prior.directives[id] !== undefined) continue;
|
|
397
|
+
findings.push({
|
|
398
|
+
domain,
|
|
399
|
+
directiveId: id,
|
|
400
|
+
level: "note",
|
|
401
|
+
what: `directive '${id}' is ADDED`,
|
|
402
|
+
fix: `additive — a new write lane; no prior client depended on it`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Do two directive payload shapes accept the same clients? (same field set, kinds, optionality). */
|
|
408
|
+
function samePayloadShape(a: DirectivePayloadShape, b: DirectivePayloadShape): boolean {
|
|
409
|
+
const af = a.fields ?? {};
|
|
410
|
+
const bf = b.fields ?? {};
|
|
411
|
+
const names = new Set([...Object.keys(af), ...Object.keys(bf)]);
|
|
412
|
+
for (const n of names) {
|
|
413
|
+
const x = af[n];
|
|
414
|
+
const y = bf[n];
|
|
415
|
+
if (x === undefined || y === undefined) return false;
|
|
416
|
+
if (x.kind !== y.kind || !!x.optional !== !!y.optional) return false;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The full evolve preview: diff PRIOR vs CURRENT law by stable id, returning the findings AND
|
|
423
|
+
* the merged dispositions the destructive ones demand (per domain key). Domains present in only
|
|
424
|
+
* one side are ADDITIVE (new domain — note) or a whole-domain REMOVAL (refuse per aggregate).
|
|
425
|
+
*/
|
|
426
|
+
export function evolveDiff(prior: EvolveLaw, current: EvolveLaw): EvolveDiffResult {
|
|
427
|
+
const findings: Finding[] = [];
|
|
428
|
+
const dispositions: Record<string, EvolveDispositionsJson> = {};
|
|
429
|
+
|
|
430
|
+
for (const [domain, priorDom] of Object.entries(prior)) {
|
|
431
|
+
const currentDom = current[domain];
|
|
432
|
+
if (currentDom === undefined) {
|
|
433
|
+
// The whole domain uninstalled — every aggregate's stable identity disappears.
|
|
434
|
+
const d = ensureDisp(dispositions, domain);
|
|
435
|
+
for (const [sid, agg] of Object.entries(priorDom.aggregates)) {
|
|
436
|
+
(d.retired ??= []).push(sid);
|
|
437
|
+
findings.push({
|
|
438
|
+
domain,
|
|
439
|
+
level: "refuse",
|
|
440
|
+
what: `domain '${domain}' aggregate '${agg.label}' (stable id ${sid}) disappears — the current build no longer carries domain '${domain}'`,
|
|
441
|
+
fix: `restore the domain, or (a true removal) redeploy with "dispositions": { "retired": ["${sid}"] }`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
diffDomain(domain, priorDom, currentDom, findings, dispositions);
|
|
447
|
+
}
|
|
448
|
+
for (const [domain, currentDom] of Object.entries(current)) {
|
|
449
|
+
if (prior[domain] !== undefined) continue;
|
|
450
|
+
findings.push({
|
|
451
|
+
domain,
|
|
452
|
+
level: "note",
|
|
453
|
+
what: `domain '${domain}' is ADDED (${Object.keys(currentDom.aggregates).length} aggregate(s))`,
|
|
454
|
+
fix: `additive — a new domain installs beside the existing law`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const rank = { refuse: 0, warn: 1, note: 2 } as const;
|
|
459
|
+
findings.sort((a, b) => rank[a.level] - rank[b.level]);
|
|
460
|
+
return { findings, dispositions };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The findings-only surface (the `evolvePreview(prior, current): Finding[]` contract). */
|
|
464
|
+
export function evolvePreview(prior: EvolveLaw, current: EvolveLaw): Finding[] {
|
|
465
|
+
return evolveDiff(prior, current).findings;
|
|
466
|
+
}
|