@githolon/dsl 0.105.13 → 0.105.15
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/dart/CHANGELOG.md +13 -0
- package/dart/lib/src/runner/node-entry.mjs +19 -13
- package/dart/lib/src/runner/offline-install.mjs +25 -11
- package/dart/lib/src/session_bound.dart +7 -7
- package/dart/lib/src/webview_bridge.dart +4 -5
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/runtime/generated-application-host.js +93 -34
- package/src/build_package.ts +37 -3
- package/src/check_package_main.ts +12 -10
- package/src/codegen_dart.ts +7 -4
- package/src/codegen_dart_client.ts +5 -16
- package/src/codegen_ts.ts +32 -4
- package/src/compile_package_main.ts +100 -9
- package/src/evolution.ts +313 -0
- package/src/evolution_files.ts +31 -0
- package/src/evolve_preview.ts +64 -55
- package/src/index.ts +10 -0
- package/src/law_graph.ts +58 -1
- package/src/law_graph_usda.ts +26 -0
- package/src/stable_ids_for_module.ts +1 -1
package/src/evolution.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// NOMOS — business model evolution as a separate, composable concern.
|
|
2
|
+
//
|
|
3
|
+
// Current aggregate files describe what the business means now. An evolution file
|
|
4
|
+
// relates an earlier lawful field contract to that current meaning. The compiler
|
|
5
|
+
// discovers these typed declarations under evolutions/<domain>/**/*.evolution.ts,
|
|
6
|
+
// proves them, and lowers them into the same canonical USDA law. They are never host
|
|
7
|
+
// configuration and never deployment-time stable-id strings.
|
|
8
|
+
|
|
9
|
+
import type { AggregateHandle, StoredFields } from "./aggregate.js";
|
|
10
|
+
import type {
|
|
11
|
+
Field,
|
|
12
|
+
FieldEvolutionDeclaration,
|
|
13
|
+
FieldValue,
|
|
14
|
+
} from "./fields.js";
|
|
15
|
+
|
|
16
|
+
const EVOLUTION_BRAND = "__nomos_field_evolution_v1" as const;
|
|
17
|
+
const FORMER_AGGREGATE_BRAND = "__nomos_former_aggregate_v1" as const;
|
|
18
|
+
const FORMER_FIELD_BRAND = "__nomos_former_field_v1" as const;
|
|
19
|
+
const RETIREMENT_BRAND = "__nomos_retirement_v1" as const;
|
|
20
|
+
|
|
21
|
+
export interface FieldEvolution<
|
|
22
|
+
A extends AggregateHandle = AggregateHandle,
|
|
23
|
+
> {
|
|
24
|
+
readonly [EVOLUTION_BRAND]: true;
|
|
25
|
+
readonly aggregate: A;
|
|
26
|
+
readonly field: string;
|
|
27
|
+
readonly declaration: FieldEvolutionDeclaration;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FieldEvolutionBuilder<T> {
|
|
31
|
+
/**
|
|
32
|
+
* Relate one earlier lawful field contract to the current field. With no
|
|
33
|
+
* projection, Nomos proves identity widening. A projection is admitted only
|
|
34
|
+
* for a finite source language and is exhaustively compiled to a case table.
|
|
35
|
+
*/
|
|
36
|
+
from<From>(
|
|
37
|
+
source: Field<From>,
|
|
38
|
+
project?: (value: From) => T,
|
|
39
|
+
): FieldEvolution;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FormerField<AggregateId extends string = string, Name extends string = string> {
|
|
43
|
+
readonly [FORMER_FIELD_BRAND]: true;
|
|
44
|
+
readonly aggregate: AggregateId;
|
|
45
|
+
readonly name: Name;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FormerAggregate<
|
|
49
|
+
Id extends string = string,
|
|
50
|
+
FieldName extends string = string,
|
|
51
|
+
> {
|
|
52
|
+
readonly [FORMER_AGGREGATE_BRAND]: true;
|
|
53
|
+
readonly id: Id;
|
|
54
|
+
readonly fields: Readonly<Record<FieldName, FormerField<Id, FieldName>>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Retirement {
|
|
58
|
+
readonly [RETIREMENT_BRAND]: true;
|
|
59
|
+
readonly kind: "aggregate" | "field";
|
|
60
|
+
readonly aggregate: string;
|
|
61
|
+
readonly field?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Name a declaration from the immediately preceding model without putting it
|
|
66
|
+
* back into the current business model. The names occur once at this historical
|
|
67
|
+
* declaration site; every retirement elsewhere refers to the returned handles.
|
|
68
|
+
*/
|
|
69
|
+
export function formerAggregate<
|
|
70
|
+
const Id extends string,
|
|
71
|
+
const FieldName extends string,
|
|
72
|
+
>(
|
|
73
|
+
id: Id,
|
|
74
|
+
fieldNames: readonly FieldName[] = [],
|
|
75
|
+
): FormerAggregate<Id, FieldName> {
|
|
76
|
+
if (!id.trim()) throw new Error("formerAggregate(id) needs the prior aggregate's non-empty name");
|
|
77
|
+
const fields: Record<string, FormerField<Id, FieldName>> = {};
|
|
78
|
+
for (const name of fieldNames) {
|
|
79
|
+
if (!name.trim()) throw new Error(`formerAggregate(${id}) has an empty prior field name`);
|
|
80
|
+
if (fields[name] !== undefined) throw new Error(`formerAggregate(${id}) repeats prior field '${name}'`);
|
|
81
|
+
fields[name] = Object.freeze({
|
|
82
|
+
[FORMER_FIELD_BRAND]: true as const,
|
|
83
|
+
aggregate: id,
|
|
84
|
+
name,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
[FORMER_AGGREGATE_BRAND]: true as const,
|
|
89
|
+
id,
|
|
90
|
+
fields: Object.freeze(fields) as Readonly<Record<FieldName, FormerField<Id, FieldName>>>,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Declare one intentional tombstone in a separate evolution file. */
|
|
95
|
+
export function retirement(
|
|
96
|
+
entity: FormerAggregate | FormerField,
|
|
97
|
+
): Retirement {
|
|
98
|
+
if (entity === null || typeof entity !== "object") {
|
|
99
|
+
throw new Error("retirement(entity) needs a handle from formerAggregate(...)");
|
|
100
|
+
}
|
|
101
|
+
if ((entity as unknown as Record<string, unknown>)[FORMER_AGGREGATE_BRAND] === true) {
|
|
102
|
+
const aggregate = entity as FormerAggregate;
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
[RETIREMENT_BRAND]: true as const,
|
|
105
|
+
kind: "aggregate" as const,
|
|
106
|
+
aggregate: aggregate.id,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if ((entity as unknown as Record<string, unknown>)[FORMER_FIELD_BRAND] === true) {
|
|
110
|
+
const field = entity as FormerField;
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
[RETIREMENT_BRAND]: true as const,
|
|
113
|
+
kind: "field" as const,
|
|
114
|
+
aggregate: field.aggregate,
|
|
115
|
+
field: field.name,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
throw new Error("retirement(entity) needs a handle from formerAggregate(...)");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Begin a separately authored evolution for one current business field.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* // evolutions/publication-reference.evolution.ts
|
|
127
|
+
* export const publicationReference = evolution(Snapshot, "publicationId")
|
|
128
|
+
* .from(legacyPublicationId);
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export function evolution<
|
|
132
|
+
const Id extends string,
|
|
133
|
+
F extends Record<string, Field>,
|
|
134
|
+
K extends keyof StoredFields<F> & string,
|
|
135
|
+
>(
|
|
136
|
+
aggregate: AggregateHandle<Id, F>,
|
|
137
|
+
field: K,
|
|
138
|
+
): FieldEvolutionBuilder<FieldValue<StoredFields<F>[K]>> {
|
|
139
|
+
const target = (aggregate.fields as Record<string, Field>)[field];
|
|
140
|
+
if (target === undefined) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`evolution(${aggregate.id}, ${JSON.stringify(field)}): '${field}' is not a stored field of ${aggregate.id}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
from<From>(source: Field<From>, project?: (value: From) => FieldValue<StoredFields<F>[K]>): FieldEvolution {
|
|
147
|
+
// Reuse the field declaration's single proof implementation. The returned
|
|
148
|
+
// field is only a temporary immutable value from which we take the one new
|
|
149
|
+
// edge; the current aggregate declaration is never mutated.
|
|
150
|
+
const evolved = target.evolvesFrom(
|
|
151
|
+
source,
|
|
152
|
+
project as ((value: From) => unknown) | undefined,
|
|
153
|
+
);
|
|
154
|
+
const declarations = evolved.evolutions ?? [];
|
|
155
|
+
const declaration = declarations[declarations.length - 1];
|
|
156
|
+
if (declaration === undefined) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`evolution(${aggregate.id}, ${JSON.stringify(field)}): failed to construct an evolution edge`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
[EVOLUTION_BRAND]: true as const,
|
|
163
|
+
aggregate,
|
|
164
|
+
field,
|
|
165
|
+
declaration,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isFieldEvolution(value: unknown): value is FieldEvolution {
|
|
172
|
+
return (
|
|
173
|
+
value !== null &&
|
|
174
|
+
typeof value === "object" &&
|
|
175
|
+
(value as Record<string, unknown>)[EVOLUTION_BRAND] === true
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isRetirement(value: unknown): value is Retirement {
|
|
180
|
+
return (
|
|
181
|
+
value !== null &&
|
|
182
|
+
typeof value === "object" &&
|
|
183
|
+
(value as Record<string, unknown>)[RETIREMENT_BRAND] === true
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Evolution files are compiler-only by construction; executable/business exports would be law
|
|
188
|
+
* that is absent from QuickJS. Refuse that split-brain shape at the file boundary. */
|
|
189
|
+
export function assertEvolutionModuleExports(
|
|
190
|
+
module: Readonly<Record<string, unknown>>,
|
|
191
|
+
file: string,
|
|
192
|
+
): void {
|
|
193
|
+
for (const [name, value] of Object.entries(module)) {
|
|
194
|
+
if (isFieldEvolution(value) || isRetirement(value)) continue;
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${file}: evolution file export '${name}' is not evolution(...) or retirement(...). ` +
|
|
197
|
+
"Keep current aggregates, directives and helpers in business modules; evolution files " +
|
|
198
|
+
"are compiler-only historical law.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Collect evolution edges without allowing re-exports to duplicate one object. */
|
|
204
|
+
export function fieldEvolutionsOf(
|
|
205
|
+
modules: readonly Record<string, unknown>[],
|
|
206
|
+
): FieldEvolution[] {
|
|
207
|
+
const seen = new Set<FieldEvolution>();
|
|
208
|
+
const out: FieldEvolution[] = [];
|
|
209
|
+
for (const module of modules) {
|
|
210
|
+
for (const value of Object.values(module)) {
|
|
211
|
+
if (!isFieldEvolution(value) || seen.has(value)) continue;
|
|
212
|
+
seen.add(value);
|
|
213
|
+
out.push(value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Collect explicit tombstones; two files may not retire the same identity twice. */
|
|
220
|
+
export function retirementsOf(
|
|
221
|
+
modules: readonly Record<string, unknown>[],
|
|
222
|
+
domain: string,
|
|
223
|
+
): Retirement[] {
|
|
224
|
+
const out: Retirement[] = [];
|
|
225
|
+
const keys = new Set<string>();
|
|
226
|
+
for (const module of modules) {
|
|
227
|
+
for (const value of Object.values(module)) {
|
|
228
|
+
if (!isRetirement(value)) continue;
|
|
229
|
+
const key = value.kind === "aggregate"
|
|
230
|
+
? `aggregate:${value.aggregate}`
|
|
231
|
+
: `field:${value.aggregate}.${value.field ?? ""}`;
|
|
232
|
+
if (!keys.add(key)) {
|
|
233
|
+
throw new Error(`domain '${domain}' retires '${key}' more than once`);
|
|
234
|
+
}
|
|
235
|
+
out.push(value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function cloneFieldWithEvolution(
|
|
242
|
+
field: Field,
|
|
243
|
+
declaration: FieldEvolutionDeclaration,
|
|
244
|
+
): Field {
|
|
245
|
+
const clone = Object.defineProperties(
|
|
246
|
+
{},
|
|
247
|
+
Object.getOwnPropertyDescriptors(field),
|
|
248
|
+
) as Field;
|
|
249
|
+
Object.defineProperty(clone, "evolutions", {
|
|
250
|
+
value: Object.freeze([...(field.evolutions ?? []), declaration]),
|
|
251
|
+
enumerable: true,
|
|
252
|
+
configurable: true,
|
|
253
|
+
writable: false,
|
|
254
|
+
});
|
|
255
|
+
return clone;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Apply separately authored edges to the aggregate handles consumed by law emission. */
|
|
259
|
+
export function applyFieldEvolutionsToAggregates(
|
|
260
|
+
aggregates: readonly AggregateHandle[],
|
|
261
|
+
evolutions: readonly FieldEvolution[],
|
|
262
|
+
domain: string,
|
|
263
|
+
): AggregateHandle[] {
|
|
264
|
+
const byAggregate = new Map(aggregates.map((aggregate) => [aggregate.id, aggregate]));
|
|
265
|
+
const byTarget = new Map<string, FieldEvolution[]>();
|
|
266
|
+
for (const declaration of evolutions) {
|
|
267
|
+
const aggregate = byAggregate.get(declaration.aggregate.id);
|
|
268
|
+
if (aggregate === undefined) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`domain '${domain}' exports an evolution for '${declaration.aggregate.id}.${declaration.field}' ` +
|
|
271
|
+
"but does not export the current aggregate itself",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if ((aggregate.fields as Record<string, Field>)[declaration.field] === undefined) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`domain '${domain}' exports an evolution for missing current field ` +
|
|
277
|
+
`'${declaration.aggregate.id}.${declaration.field}'`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const key = `${declaration.aggregate.id}\u0000${declaration.field}`;
|
|
281
|
+
const current = byTarget.get(key) ?? [];
|
|
282
|
+
current.push(declaration);
|
|
283
|
+
byTarget.set(key, current);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return aggregates.map((aggregate) => {
|
|
287
|
+
const targets = [...byTarget]
|
|
288
|
+
.filter(([key]) => key.startsWith(`${aggregate.id}\u0000`))
|
|
289
|
+
.flatMap(([, declarations]) => declarations);
|
|
290
|
+
if (targets.length === 0) return aggregate;
|
|
291
|
+
const fields = Object.defineProperties(
|
|
292
|
+
{},
|
|
293
|
+
Object.getOwnPropertyDescriptors(aggregate.fields),
|
|
294
|
+
) as Record<string, Field>;
|
|
295
|
+
for (const target of targets) {
|
|
296
|
+
fields[target.field] = cloneFieldWithEvolution(
|
|
297
|
+
fields[target.field]!,
|
|
298
|
+
target.declaration,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const clone = Object.defineProperties(
|
|
302
|
+
{},
|
|
303
|
+
Object.getOwnPropertyDescriptors(aggregate),
|
|
304
|
+
) as AggregateHandle;
|
|
305
|
+
Object.defineProperty(clone, "fields", {
|
|
306
|
+
value: fields,
|
|
307
|
+
enumerable: true,
|
|
308
|
+
configurable: true,
|
|
309
|
+
writable: false,
|
|
310
|
+
});
|
|
311
|
+
return clone;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const EVOLUTION_MODULE_SUFFIX = /\.evolution\.(?:ts|tsx|mts|mjs|js)$/;
|
|
5
|
+
|
|
6
|
+
export function isEvolutionModulePath(file: string): boolean {
|
|
7
|
+
return EVOLUTION_MODULE_SUFFIX.test(file);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover compiler-only evolution concerns by convention. Both `compile` and
|
|
12
|
+
* `check` use this function so local assurance cannot inspect a different law
|
|
13
|
+
* closure from the one that will be deployed.
|
|
14
|
+
*/
|
|
15
|
+
export function discoverEvolutionModulePaths(
|
|
16
|
+
configDirectory: string,
|
|
17
|
+
domainKey: string,
|
|
18
|
+
): string[] {
|
|
19
|
+
const root = path.join(configDirectory, "evolutions", domainKey);
|
|
20
|
+
if (!existsSync(root)) return [];
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
const visit = (directory: string): void => {
|
|
23
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
24
|
+
const absolute = path.join(directory, entry.name);
|
|
25
|
+
if (entry.isDirectory()) visit(absolute);
|
|
26
|
+
else if (entry.isFile() && isEvolutionModulePath(entry.name)) out.push(absolute);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(root);
|
|
30
|
+
return out.sort((left, right) => left.localeCompare(right));
|
|
31
|
+
}
|
package/src/evolve_preview.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* THE EVOLVE PREVIEW — `githolon check --against <prior>` (architecture/author_time_gate.md,
|
|
8
8
|
* the `--against` row of check slice 3). The evolve gate is the kernel's #58 diff-by-stable-id
|
|
9
9
|
* (executor/src/law58.rs::evolve_violation): a RENAME (same sid, new label) admits silently; a
|
|
10
|
-
* true REMOVAL / merge-driver change refuses TYPED unless the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* true REMOVAL / merge-driver change refuses TYPED unless the candidate law itself carries the
|
|
11
|
+
* corresponding retirement or deterministic evolution. Application/deployment code never authors
|
|
12
|
+
* stable-id waivers.
|
|
13
13
|
*
|
|
14
14
|
* This module is the AUTHOR-TIME TWIN of that gate: given the PRIOR law and the CURRENT build,
|
|
15
15
|
* it diffs them BY STABLE ID and pre-fires the gate's verdicts — with the EXACT generated
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* RENAME (note) — same sid, new label; admits silently, old rows read the new label.
|
|
19
19
|
* ADDITIVE (note) — a new aggregate/field/directive (incl. an additive optional VO field);
|
|
20
20
|
* the evolve gate admits it silently (vo_first_class.md slice 6).
|
|
21
|
-
* REMOVAL (refuse) — a sid disappeared;
|
|
21
|
+
* REMOVAL (refuse) — a sid disappeared; declare a typed retirement in the evolution layer.
|
|
22
22
|
* CONTRACT (refuse) — same sid, changed value language without a deterministic projection;
|
|
23
23
|
* needs `.evolvesFrom(...)`, never a disposition.
|
|
24
|
-
* DRIVER (refuse) — same sid, changed merge driver; needs
|
|
24
|
+
* DRIVER (refuse) — same sid, changed merge driver; needs a typed evolution or a new field.
|
|
25
25
|
* PAYLOAD (warn) — a directive's payload shape changed; clients compiled against the old
|
|
26
26
|
* law get typed 422s until regenerated (directives are not sid-tracked).
|
|
27
27
|
*
|
|
@@ -66,19 +66,17 @@ export interface EvolveDomain {
|
|
|
66
66
|
readonly aggregates: Record<string, EvolveAggregate>;
|
|
67
67
|
/** keyed by DIRECTIVE ID (directives are not sid-tracked — the dispatched name IS identity). */
|
|
68
68
|
readonly directives: Record<string, DirectivePayloadShape>;
|
|
69
|
+
/** Authored candidate tombstones, kept label-shaped until the compiler resolves them to sids. */
|
|
70
|
+
readonly retirements?: readonly {
|
|
71
|
+
kind: "aggregate" | "field";
|
|
72
|
+
aggregate: string;
|
|
73
|
+
field?: string;
|
|
74
|
+
}[];
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/** A whole package's evolve surface, keyed by DOMAIN KEY. */
|
|
72
78
|
export type EvolveLaw = Record<string, EvolveDomain>;
|
|
73
79
|
|
|
74
|
-
/** The dispositions fragment the installDomain offer carries to admit destructive evolves (the exact
|
|
75
|
-
* shape executor/src/law58.rs::dispositions_from_payload parses). */
|
|
76
|
-
export interface EvolveDispositionsJson {
|
|
77
|
-
retired?: string[];
|
|
78
|
-
retyped?: Record<string, string>;
|
|
79
|
-
rebinds?: Record<string, string>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
80
|
// ── normalizers: build an EvolveLaw from the artifacts a dev already has ────────────
|
|
83
81
|
|
|
84
82
|
/** Build ONE domain's aggregate surface from a #58 `StableIds` block (+ optional driver
|
|
@@ -217,29 +215,39 @@ export function currentEvolveLaw(
|
|
|
217
215
|
const stableAggregate = Object.values(stableIds.aggregates).find(
|
|
218
216
|
(candidate) => candidate.sid === agg.sid,
|
|
219
217
|
);
|
|
220
|
-
const
|
|
218
|
+
const stableFields = new Map(
|
|
219
|
+
Object.values(stableAggregate?.fields ?? {})
|
|
220
|
+
.map((field) => [field.sid, field] as const),
|
|
221
|
+
);
|
|
222
|
+
const schema = locked?.schemas?.[agg.label]
|
|
223
|
+
?? Object.entries(locked?.schemas ?? {}).find(([type]) => type.endsWith(`.${agg.label}`))?.[1];
|
|
221
224
|
for (const f of Object.values(agg.fields)) {
|
|
222
|
-
const stableField =
|
|
223
|
-
(candidate) => candidate.sid === f.sid,
|
|
224
|
-
);
|
|
225
|
+
const stableField = stableFields.get(f.sid);
|
|
225
226
|
const driver = stableField?.driver ?? schema?.[f.label];
|
|
226
227
|
if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
|
|
228
|
+
if (stableField?.contract !== undefined) f.contract = stableField.contract;
|
|
229
|
+
if (stableField?.schema !== undefined) f.schema = stableField.schema;
|
|
230
|
+
if (stableField?.evolutions !== undefined) {
|
|
231
|
+
f.evolutions = stableField.evolutions.map((rule) => ({ from: rule.from, to: rule.to }));
|
|
232
|
+
}
|
|
227
233
|
}
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
|
-
law[key] =
|
|
236
|
+
law[key] = {
|
|
237
|
+
...dom,
|
|
238
|
+
...(mod.retirements !== undefined && mod.retirements.length > 0
|
|
239
|
+
? { retirements: mod.retirements }
|
|
240
|
+
: {}),
|
|
241
|
+
};
|
|
231
242
|
}
|
|
232
243
|
return law;
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
// ── the diff ────────────────────────────────────────────────────────────────────────
|
|
236
247
|
|
|
237
|
-
/** The full preview result
|
|
238
|
-
* demand, keyed by DOMAIN KEY (the object carried by an installDomain offer). */
|
|
248
|
+
/** The full author-time preview result. Mechanical transition authority stays in candidate law. */
|
|
239
249
|
export interface EvolveDiffResult {
|
|
240
250
|
readonly findings: Finding[];
|
|
241
|
-
/** domain key → the dispositions fragment that makes THIS domain's destructive evolves admit. */
|
|
242
|
-
readonly dispositions: Record<string, EvolveDispositionsJson>;
|
|
243
251
|
}
|
|
244
252
|
|
|
245
253
|
/** A short, bounded render of a directive's payload field shape (for a warn's old-vs-new naming). */
|
|
@@ -250,10 +258,6 @@ function payloadShapeText(shape: DirectivePayloadShape): string {
|
|
|
250
258
|
return `{ ${parts.join(", ")} }`;
|
|
251
259
|
}
|
|
252
260
|
|
|
253
|
-
function ensureDisp(map: Record<string, EvolveDispositionsJson>, domain: string): EvolveDispositionsJson {
|
|
254
|
-
return (map[domain] ??= {});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
261
|
/** Mirror executor::law58::evolution_reaches: one deterministic route per source,
|
|
258
262
|
* accumulated across supported releases, must terminate at the current schema. */
|
|
259
263
|
function evolutionReaches(
|
|
@@ -276,13 +280,12 @@ function evolutionReaches(
|
|
|
276
280
|
return true;
|
|
277
281
|
}
|
|
278
282
|
|
|
279
|
-
/** Diff one domain's aggregates/fields/directives BY STABLE ID and append findings
|
|
283
|
+
/** Diff one domain's aggregates/fields/directives BY STABLE ID and append findings. */
|
|
280
284
|
function diffDomain(
|
|
281
285
|
domain: string,
|
|
282
286
|
prior: EvolveDomain,
|
|
283
287
|
current: EvolveDomain,
|
|
284
288
|
findings: Finding[],
|
|
285
|
-
disp: Record<string, EvolveDispositionsJson>,
|
|
286
289
|
): void {
|
|
287
290
|
const curAggBySid = current.aggregates;
|
|
288
291
|
const priorAggBySid = prior.aggregates;
|
|
@@ -290,14 +293,18 @@ function diffDomain(
|
|
|
290
293
|
for (const [sid, oldAgg] of Object.entries(priorAggBySid)) {
|
|
291
294
|
const newAgg = curAggBySid[sid];
|
|
292
295
|
if (newAgg === undefined) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
+
const declared = current.retirements?.some(
|
|
297
|
+
(retirement) => retirement.kind === "aggregate" && retirement.aggregate === oldAgg.label,
|
|
298
|
+
) ?? false;
|
|
296
299
|
findings.push({
|
|
297
300
|
domain,
|
|
298
|
-
level: "refuse",
|
|
299
|
-
what:
|
|
300
|
-
|
|
301
|
+
level: declared ? "note" : "refuse",
|
|
302
|
+
what: declared
|
|
303
|
+
? `aggregate '${oldAgg.label}' (stable id ${sid}) is RETIRED by candidate law`
|
|
304
|
+
: `aggregate '${oldAgg.label}' (stable id ${sid}) is REMOVED without a candidate-law retirement`,
|
|
305
|
+
fix: declared
|
|
306
|
+
? "the compiler will lower this declaration to a typed USDA tombstone"
|
|
307
|
+
: `create evolutions/${domain}/<concern>.evolution.ts and export retirement(formerAggregate("${oldAgg.label}"))`,
|
|
301
308
|
});
|
|
302
309
|
continue;
|
|
303
310
|
}
|
|
@@ -313,13 +320,20 @@ function diffDomain(
|
|
|
313
320
|
for (const [fSid, oldF] of Object.entries(oldAgg.fields)) {
|
|
314
321
|
const newF = newAgg.fields[fSid];
|
|
315
322
|
if (newF === undefined) {
|
|
316
|
-
const
|
|
317
|
-
|
|
323
|
+
const declared = current.retirements?.some(
|
|
324
|
+
(retirement) => retirement.kind === "field"
|
|
325
|
+
&& retirement.aggregate === oldAgg.label
|
|
326
|
+
&& retirement.field === oldF.label,
|
|
327
|
+
) ?? false;
|
|
318
328
|
findings.push({
|
|
319
329
|
domain,
|
|
320
|
-
level: "refuse",
|
|
321
|
-
what:
|
|
322
|
-
|
|
330
|
+
level: declared ? "note" : "refuse",
|
|
331
|
+
what: declared
|
|
332
|
+
? `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is RETIRED by candidate law`
|
|
333
|
+
: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is REMOVED without a candidate-law retirement`,
|
|
334
|
+
fix: declared
|
|
335
|
+
? "the compiler will lower this declaration to a typed USDA tombstone"
|
|
336
|
+
: `create evolutions/${domain}/<concern>.evolution.ts, name formerAggregate("${oldAgg.label}", ["${oldF.label}"]), and export its retirement`,
|
|
323
337
|
});
|
|
324
338
|
continue;
|
|
325
339
|
}
|
|
@@ -336,27 +350,26 @@ function diffDomain(
|
|
|
336
350
|
oldF.driver !== undefined && newF.driver !== undefined && oldF.driver !== newF.driver;
|
|
337
351
|
const contractChanged =
|
|
338
352
|
oldF.contract !== undefined && newF.contract !== undefined && oldF.contract !== newF.contract;
|
|
339
|
-
const
|
|
353
|
+
const evolutionCovered = evolutionReaches(newF, oldF.schema, newF.schema);
|
|
354
|
+
const contractCovered = contractChanged && evolutionCovered;
|
|
340
355
|
if (contractChanged && !contractCovered) {
|
|
341
356
|
findings.push({
|
|
342
357
|
domain,
|
|
343
358
|
level: "refuse",
|
|
344
359
|
what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) changes its business value contract without deterministic evolution coverage`,
|
|
345
|
-
fix: `
|
|
360
|
+
fix: `in evolutions/${domain}/<concern>.evolution.ts export evolution(CurrentAggregate, "${newF.label}").from(oldField), optionally with an exhaustive finite business mapping`,
|
|
346
361
|
});
|
|
347
362
|
continue;
|
|
348
363
|
}
|
|
349
|
-
if ((kindChanged
|
|
350
|
-
const d = ensureDisp(disp, domain);
|
|
351
|
-
(d.retyped ??= {})[fSid] = "<why goes here>";
|
|
364
|
+
if ((kindChanged || driverChanged) && !evolutionCovered) {
|
|
352
365
|
const how = kindChanged && !contractCovered
|
|
353
366
|
? `read kind ${oldF.kind} → ${newF.kind}`
|
|
354
367
|
: `merge driver ${oldF.driver} → ${newF.driver}`;
|
|
355
368
|
findings.push({
|
|
356
369
|
domain,
|
|
357
370
|
level: "refuse",
|
|
358
|
-
what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid})
|
|
359
|
-
fix: `
|
|
371
|
+
what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) changes in place (${how}) without a candidate-law evolution`,
|
|
372
|
+
fix: `declare the old field contract and export evolution(CurrentAggregate, "${newF.label}").from(oldField) from evolutions/${domain}/<concern>.evolution.ts — or add a new field`,
|
|
360
373
|
});
|
|
361
374
|
}
|
|
362
375
|
}
|
|
@@ -433,31 +446,27 @@ function samePayloadShape(a: DirectivePayloadShape, b: DirectivePayloadShape): b
|
|
|
433
446
|
}
|
|
434
447
|
|
|
435
448
|
/**
|
|
436
|
-
* The full evolve preview: diff PRIOR vs CURRENT law by stable id
|
|
437
|
-
*
|
|
438
|
-
* one side are ADDITIVE (new domain — note) or a whole-domain REMOVAL (refuse per aggregate).
|
|
449
|
+
* The full evolve preview: diff PRIOR vs CURRENT law by stable id. Domains present in only one
|
|
450
|
+
* side are ADDITIVE (new domain — note) or a whole-domain REMOVAL (refuse per aggregate).
|
|
439
451
|
*/
|
|
440
452
|
export function evolveDiff(prior: EvolveLaw, current: EvolveLaw): EvolveDiffResult {
|
|
441
453
|
const findings: Finding[] = [];
|
|
442
|
-
const dispositions: Record<string, EvolveDispositionsJson> = {};
|
|
443
454
|
|
|
444
455
|
for (const [domain, priorDom] of Object.entries(prior)) {
|
|
445
456
|
const currentDom = current[domain];
|
|
446
457
|
if (currentDom === undefined) {
|
|
447
458
|
// The whole domain uninstalled — every aggregate's stable identity disappears.
|
|
448
|
-
const d = ensureDisp(dispositions, domain);
|
|
449
459
|
for (const [sid, agg] of Object.entries(priorDom.aggregates)) {
|
|
450
|
-
(d.retired ??= []).push(sid);
|
|
451
460
|
findings.push({
|
|
452
461
|
domain,
|
|
453
462
|
level: "refuse",
|
|
454
463
|
what: `domain '${domain}' aggregate '${agg.label}' (stable id ${sid}) disappears — the current build no longer carries domain '${domain}'`,
|
|
455
|
-
fix: `restore the domain
|
|
464
|
+
fix: `restore the domain and retire its aggregates in a typed evolution layer before removing executable law`,
|
|
456
465
|
});
|
|
457
466
|
}
|
|
458
467
|
continue;
|
|
459
468
|
}
|
|
460
|
-
diffDomain(domain, priorDom, currentDom, findings
|
|
469
|
+
diffDomain(domain, priorDom, currentDom, findings);
|
|
461
470
|
}
|
|
462
471
|
for (const [domain, currentDom] of Object.entries(current)) {
|
|
463
472
|
if (prior[domain] !== undefined) continue;
|
|
@@ -471,7 +480,7 @@ export function evolveDiff(prior: EvolveLaw, current: EvolveLaw): EvolveDiffResu
|
|
|
471
480
|
|
|
472
481
|
const rank = { refuse: 0, warn: 1, note: 2 } as const;
|
|
473
482
|
findings.sort((a, b) => rank[a.level] - rank[b.level]);
|
|
474
|
-
return { findings
|
|
483
|
+
return { findings };
|
|
475
484
|
}
|
|
476
485
|
|
|
477
486
|
/** The findings-only surface (the `evolvePreview(prior, current): Finding[]` contract). */
|
package/src/index.ts
CHANGED
|
@@ -160,6 +160,16 @@ export {
|
|
|
160
160
|
type ConflictPolicy,
|
|
161
161
|
type ConflictRule,
|
|
162
162
|
} from "./conflicts.js";
|
|
163
|
+
export {
|
|
164
|
+
evolution,
|
|
165
|
+
formerAggregate,
|
|
166
|
+
retirement,
|
|
167
|
+
type FieldEvolution,
|
|
168
|
+
type FieldEvolutionBuilder,
|
|
169
|
+
type FormerAggregate,
|
|
170
|
+
type FormerField,
|
|
171
|
+
type Retirement,
|
|
172
|
+
} from "./evolution.js";
|
|
163
173
|
export {
|
|
164
174
|
intent,
|
|
165
175
|
language,
|