@githolon/dsl 0.45.0 → 0.47.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/dart/CHANGELOG.md +5 -0
- package/dart/lib/src/errors.dart +250 -0
- package/dart/lib/src/test_harness.dart +182 -175
- package/dart/lib/src/webview_bridge.dart +38 -4
- package/dart/pubspec.yaml +1 -1
- package/dart/test/errors_test.dart +77 -0
- package/dart/test/test_harness_test.dart +85 -57
- package/package.json +1 -1
- package/src/codegen_dart.ts +25 -14
- package/src/codegen_dart_client.ts +33 -1
- package/src/codegen_proof.ts +255 -23
- package/src/codegen_ts.ts +33 -0
- package/src/compat_manifest.ts +20 -0
- package/src/compile_package_main.ts +174 -16
- package/src/framework/framework_aggregates.ts +18 -0
- package/src/read_projection_composition.ts +9 -9
- package/src/read_projection_namespace.ts +4 -5
- package/src/usd.ts +3 -3
- package/src/value_object.ts +10 -9
|
@@ -1,75 +1,103 @@
|
|
|
1
|
-
// NomosTestHarness
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
1
|
+
// NomosTestHarness — the EPHEMERAL, FULLY-OFFLINE fixture loop: install real tenant law into a fresh local
|
|
2
|
+
// workspace (no cloud), drive one directive + one read through the REAL githolon over the node runner, and
|
|
3
|
+
// tear down deterministically. This is the headless app-test lane: the same bridge + generated-client surface
|
|
4
|
+
// the app UI uses, under `dart test` / `flutter test`, with no network.
|
|
5
|
+
//
|
|
6
|
+
// Prerequisites (skipped-with-reason when absent, so CI stays green either way): `node` on PATH; the
|
|
7
|
+
// in-repo runner; a warm `~/.holon/runtime` cache (holon.wasm + packages.json — one `githolon` run fills it);
|
|
8
|
+
// and the compiled `examples/guestbook/build/guestbook.deploy.json` (`cd examples/guestbook && npx nomos-compile`).
|
|
4
9
|
@TestOn('vm')
|
|
5
10
|
library;
|
|
6
11
|
|
|
7
|
-
import 'dart:convert';
|
|
8
12
|
import 'dart:io';
|
|
9
13
|
|
|
10
14
|
import 'package:nomos_client/nomos_headless.dart';
|
|
11
15
|
import 'package:test/test.dart';
|
|
12
16
|
|
|
13
|
-
String _repoEntry
|
|
17
|
+
String get _repoEntry =>
|
|
14
18
|
'${Directory.current.path}/../../cloud/web-client/runner/node-entry.mjs';
|
|
15
|
-
|
|
16
|
-
String _guestbookDeployJson() =>
|
|
19
|
+
String get _guestbookDeployJson =>
|
|
17
20
|
'${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
|
|
21
|
+
String get _runtimeDir =>
|
|
22
|
+
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
23
|
+
|
|
24
|
+
/// The prerequisite that isn't installable in-test (a warm runtime cache + the compiled fixture). Returns a
|
|
25
|
+
/// skip reason, or null when everything the harness needs is present.
|
|
26
|
+
String? _missingPrereq() {
|
|
27
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
28
|
+
if (!File(_guestbookDeployJson).existsSync()) {
|
|
29
|
+
return 'compile the fixture first: cd examples/guestbook && npx nomos-compile';
|
|
30
|
+
}
|
|
31
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
32
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
33
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once to cache the runtime)';
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
18
37
|
|
|
19
38
|
void main() {
|
|
20
|
-
test('ephemeral
|
|
21
|
-
// isolate from a real ~/.holon by pointing HOME at an empty dir
|
|
22
|
-
// (can't re-env the current isolate — so only run when no creds file exists).
|
|
23
|
-
final home = Platform.environment['HOME'] ?? '';
|
|
24
|
-
if (File('$home/.holon/credentials.json').existsSync()) {
|
|
25
|
-
markTestSkipped('a githolon session exists — the fallback would find it');
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
39
|
+
test('ephemeral rejects an empty deploy list with a named remedy', () async {
|
|
28
40
|
await expectLater(
|
|
29
|
-
NomosTestHarness.ephemeral(),
|
|
41
|
+
NomosTestHarness.ephemeral(deployJsonPaths: const []),
|
|
30
42
|
throwsA(isA<NomosTestHarnessException>()),
|
|
31
43
|
);
|
|
32
44
|
});
|
|
33
45
|
|
|
34
|
-
test(
|
|
35
|
-
|
|
36
|
-
skip
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
46
|
+
test('OFFLINE: install real law, write one directive, read it back, dispose', () async {
|
|
47
|
+
final skip = _missingPrereq();
|
|
48
|
+
if (skip != null) {
|
|
49
|
+
markTestSkipped(skip);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
53
|
+
deployJsonPaths: [_guestbookDeployJson],
|
|
54
|
+
nodeEntry: _repoEntry,
|
|
55
|
+
runtimeDir: _runtimeDir,
|
|
56
|
+
registerTearDown: addTearDown, // deterministic auto-dispose even if an expect() throws
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Drive the REAL law: a create directive + a declared query — the same ops a generated client emits.
|
|
60
|
+
final outcome = await harness.bridge.offerCreates(
|
|
61
|
+
domain: 'guestbook',
|
|
62
|
+
directiveId: 'signGuestbook',
|
|
63
|
+
payload: {
|
|
64
|
+
'author': 'harness',
|
|
65
|
+
'message': 'offline fixture green',
|
|
66
|
+
'mood': 'happy',
|
|
67
|
+
'signedAt': DateTime.now().toUtc().toIso8601String(),
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
expect(outcome.created, hasLength(1));
|
|
71
|
+
expect(outcome.created.single.type, 'guestbook.GuestbookEntry');
|
|
72
|
+
|
|
73
|
+
final rows = await harness.bridge.query('entriesByAuthor', {'author': 'harness'});
|
|
74
|
+
expect(rows, hasLength(1));
|
|
75
|
+
expect(rows.first['id'], outcome.created.single.id);
|
|
76
|
+
expect((rows.first['data'] as Map)['message'], 'offline fixture green');
|
|
77
|
+
expect((rows.first['data'] as Map)['moodEmoji'], '😊'); // proves the law's plan ran, not a stub
|
|
78
|
+
|
|
79
|
+
stdout.writeln('offline harness green: ws=${harness.workspace}');
|
|
80
|
+
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
81
|
+
|
|
82
|
+
test('OFFLINE: dispose is idempotent and removes the temp dir', () async {
|
|
83
|
+
final skip = _missingPrereq();
|
|
84
|
+
if (skip != null) {
|
|
85
|
+
markTestSkipped(skip);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
89
|
+
deployJsonPaths: [_guestbookDeployJson],
|
|
90
|
+
nodeEntry: _repoEntry,
|
|
91
|
+
runtimeDir: _runtimeDir,
|
|
92
|
+
autoTearDown: false, // drive dispose explicitly to assert its determinism
|
|
93
|
+
);
|
|
94
|
+
// A read proves the holon is live before teardown.
|
|
95
|
+
expect(await harness.bridge.query('entriesByAuthor', {'author': 'nobody'}), isEmpty);
|
|
96
|
+
|
|
97
|
+
await harness.dispose();
|
|
98
|
+
await harness.dispose(); // idempotent: the second call is a no-op, never throws
|
|
99
|
+
// The bridge is dead after dispose — a call fails rather than silently reaching a leaked runner.
|
|
100
|
+
await expectLater(
|
|
101
|
+
harness.bridge.query('entriesByAuthor', {'author': 'x'}), throwsA(anything));
|
|
102
|
+
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
75
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/codegen_dart.ts
CHANGED
|
@@ -810,19 +810,21 @@ function collectEnums(
|
|
|
810
810
|
// hand-maintained `hide` list on the package barrel. The prefix is the field's
|
|
811
811
|
// semantic home, not an accident of import order.
|
|
812
812
|
const domainPrefix = dartTypeNameFromField(domainKey);
|
|
813
|
-
|
|
813
|
+
// SEMANTIC ENUM NAMES (co2 feedback): the type is named by its SEMANTIC HOME —
|
|
814
|
+
// `<Domain><Owner><Field>` (`EstateSiteStatus`, not `EstateStatus5Enum`). The owner is the
|
|
815
|
+
// aggregate (or the payload's target aggregate / directive) the field lives on. A value-set
|
|
816
|
+
// shared across owners keeps the FIRST owner's name (one identical type, aggregates register
|
|
817
|
+
// before payloads so the aggregate's name wins). Numeric suffixes survive only as the
|
|
818
|
+
// last-resort collision guard — with owner-qualified bases they should never fire.
|
|
819
|
+
const register = (preferredName: string, values: readonly string[], owner = "") => {
|
|
814
820
|
const key = values.join("|");
|
|
815
821
|
if (dartTypes.has(key)) return;
|
|
816
|
-
|
|
817
|
-
// several `status` fields with distinct literal lists all want `StatusEnum`).
|
|
818
|
-
// Suffix on collision so each distinct value-set gets a UNIQUE Dart enum
|
|
819
|
-
// (`EstateStatusEnum`, `EstateStatus2Enum`).
|
|
820
|
-
const base = `${domainPrefix}${dartTypeNameFromField(preferredName)}Enum`;
|
|
822
|
+
const base = `${domainPrefix}${dartTypeNameFromField(owner)}${dartTypeNameFromField(preferredName)}`;
|
|
821
823
|
let name = base;
|
|
822
824
|
let n = 1;
|
|
823
825
|
while (usedNames.has(name)) {
|
|
824
826
|
n += 1;
|
|
825
|
-
name = `${
|
|
827
|
+
name = `${base}${n}`;
|
|
826
828
|
}
|
|
827
829
|
usedNames.add(name);
|
|
828
830
|
dartTypes.set(key, name);
|
|
@@ -846,7 +848,7 @@ function collectEnums(
|
|
|
846
848
|
)) {
|
|
847
849
|
if (field.kind === "enum") {
|
|
848
850
|
// Unwrap `.optional()` so an optional enum field still registers its options.
|
|
849
|
-
register(name, zodEnumValues(unwrapZod(field.zod)));
|
|
851
|
+
register(name, zodEnumValues(unwrapZod(field.zod)), agg.id);
|
|
850
852
|
}
|
|
851
853
|
}
|
|
852
854
|
}
|
|
@@ -856,7 +858,7 @@ function collectEnums(
|
|
|
856
858
|
for (const [name, zt] of Object.entries(shape)) {
|
|
857
859
|
const inner = unwrapZod(zt as z.ZodTypeAny);
|
|
858
860
|
if (zodTypeName(inner) === "ZodEnum") {
|
|
859
|
-
register(name, zodEnumValues(inner));
|
|
861
|
+
register(name, zodEnumValues(inner), (d as { aggregateId?: string }).aggregateId ?? d.id);
|
|
860
862
|
}
|
|
861
863
|
}
|
|
862
864
|
}
|
|
@@ -1246,7 +1248,8 @@ const geometryCoordsDart: Record<string, string> = {
|
|
|
1246
1248
|
* VOs stay separate types (VO names are package-unique, so so are these).
|
|
1247
1249
|
*/
|
|
1248
1250
|
function voEnumName(voName: string, fieldName: string): string {
|
|
1249
|
-
|
|
1251
|
+
// SEMANTIC NAME (co2 feedback): `<VoName><Field>` — `SiteLocationSource`, not `…SourceEnum`.
|
|
1252
|
+
return `${voName}${dartTypeNameFromField(fieldName)}`;
|
|
1250
1253
|
}
|
|
1251
1254
|
|
|
1252
1255
|
/** The tolerant-decode SENTINEL case name — dodge a literal enum value that would
|
|
@@ -1399,16 +1402,24 @@ function emitVoHelpers(): string {
|
|
|
1399
1402
|
`/// Deep json equality for generated value-object classes (toJson-based).`,
|
|
1400
1403
|
`bool _voJsonEq(Object? a, Object? b) {`,
|
|
1401
1404
|
` if (a is Map && b is Map) {`,
|
|
1402
|
-
` if (a.length != b.length)
|
|
1405
|
+
` if (a.length != b.length) {`,
|
|
1406
|
+
` return false;`,
|
|
1407
|
+
` }`,
|
|
1403
1408
|
` for (final k in a.keys) {`,
|
|
1404
|
-
` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k]))
|
|
1409
|
+
` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k])) {`,
|
|
1410
|
+
` return false;`,
|
|
1411
|
+
` }`,
|
|
1405
1412
|
` }`,
|
|
1406
1413
|
` return true;`,
|
|
1407
1414
|
` }`,
|
|
1408
1415
|
` if (a is List && b is List) {`,
|
|
1409
|
-
` if (a.length != b.length)
|
|
1416
|
+
` if (a.length != b.length) {`,
|
|
1417
|
+
` return false;`,
|
|
1418
|
+
` }`,
|
|
1410
1419
|
` for (var i = 0; i < a.length; i++) {`,
|
|
1411
|
-
` if (!_voJsonEq(a[i], b[i]))
|
|
1420
|
+
` if (!_voJsonEq(a[i], b[i])) {`,
|
|
1421
|
+
` return false;`,
|
|
1422
|
+
` }`,
|
|
1412
1423
|
` }`,
|
|
1413
1424
|
` return true;`,
|
|
1414
1425
|
` }`,
|
|
@@ -60,6 +60,9 @@ export const dartHashConstName = (fileBase: string): string => `${lcFirst(camel(
|
|
|
60
60
|
/** `todo-nomos` → `todoNomosInterfaceJson` — the baked structural-interface JSON const (#72). */
|
|
61
61
|
export const interfaceConstNameDart = (fileBase: string): string => `${lcFirst(camel(fileBase))}InterfaceJson`;
|
|
62
62
|
|
|
63
|
+
/** `todo-nomos` → `todoNomosCompatJson` — the baked client/law compat manifest JSON const (§5). */
|
|
64
|
+
export const compatConstNameDart = (fileBase: string): string => `${lcFirst(camel(fileBase))}CompatJson`;
|
|
65
|
+
|
|
63
66
|
/** Emit a Dart RAW triple-quoted string literal of an object's JSON (the interface is JSON-only
|
|
64
67
|
* data — no `'`/`$`/backslash escaping concerns under `r'''…'''`; JSON never contains `'''`). */
|
|
65
68
|
function dartRawJson(value: unknown): string {
|
|
@@ -97,6 +100,9 @@ export interface DartBridgeClientOptions {
|
|
|
97
100
|
* (read shapes, sid-keyed). Baked in as JSON so the app can negotiate shape-compatibility
|
|
98
101
|
* against the workspace's installed offer, NOT by law hash. */
|
|
99
102
|
readonly interface?: unknown;
|
|
103
|
+
/** The CLIENT/LAW COMPAT MANIFEST (§5) — the law hash + interface hash + generator provenance
|
|
104
|
+
* this client was generated against. Baked in as JSON (DIAGNOSIS only, never a gate). */
|
|
105
|
+
readonly compat?: unknown;
|
|
100
106
|
/** Where the bridge/runtime support types are imported from. */
|
|
101
107
|
readonly supportImport?: "vendored" | "package";
|
|
102
108
|
/**
|
|
@@ -137,7 +143,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
137
143
|
// appear — only the actor identity (the bridge owns signing).
|
|
138
144
|
const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
|
|
139
145
|
const gateGuard = gate !== undefined
|
|
140
|
-
? ` if (actor == null || actor!.trim().isEmpty) throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)})
|
|
146
|
+
? ` if (actor == null || actor!.trim().isEmpty) {\n throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)});\n }`
|
|
141
147
|
: "";
|
|
142
148
|
const gateDoc = gate !== undefined
|
|
143
149
|
? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
|
|
@@ -218,6 +224,23 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
// ── declared spatial indexes → typed bbox reads (lane-2 read-serving) ──
|
|
228
|
+
for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
|
|
229
|
+
const model = readModelClassName(sp.of);
|
|
230
|
+
const m = safeMethod(lcFirst(camel(sp.id)));
|
|
231
|
+
methods.push(
|
|
232
|
+
` /// Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry`,
|
|
233
|
+
` /// intersects [bbox] (an R*Tree probe on the local projection, never a scan).`,
|
|
234
|
+
` Future<List<${model}>> ${m}({required NomosBbox bbox}) async =>`,
|
|
235
|
+
` (await bridge.spatialWithin(${dartStr(sp.id)}, bbox)).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
236
|
+
``,
|
|
237
|
+
` /// Reactive \`${sp.id}\` — re-emits when the probe's rows change locally.`,
|
|
238
|
+
` Stream<List<${model}>> watch${cap(camel(sp.id))}({required NomosBbox bbox}) =>`,
|
|
239
|
+
` bridge.watchSpatial(${dartStr(sp.id)}, bbox).map((rows) => rows.map((r) => ${rowToModel(model, "r")}).toList());`,
|
|
240
|
+
``,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
221
244
|
// ── counts / sums → O(1) typed reads ──
|
|
222
245
|
for (const c of (mod.counts ?? []).map((raw) => finishCount(raw))) {
|
|
223
246
|
const m = safeMethod(lcFirst(camel(c.id)));
|
|
@@ -297,6 +320,15 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
297
320
|
``,
|
|
298
321
|
]
|
|
299
322
|
: []),
|
|
323
|
+
...(opts.compat !== undefined
|
|
324
|
+
? [
|
|
325
|
+
`/// The CLIENT/LAW COMPAT MANIFEST (§5) as JSON — the law hash, structural interface hash, and`,
|
|
326
|
+
`/// generator provenance this client was generated against. A DIAGNOSIS surface for connect-time`,
|
|
327
|
+
`/// classification (silent / shape-compatible / incompatible); it NEVER gates.`,
|
|
328
|
+
`const String ${compatConstNameDart(mod.name)} = ${dartRawJson(opts.compat)};`,
|
|
329
|
+
``,
|
|
330
|
+
]
|
|
331
|
+
: []),
|
|
300
332
|
`/// Typed async client for the \`${domain}\` domain, bound to a connected [NomosBridge].`,
|
|
301
333
|
`///`,
|
|
302
334
|
`/// Pass \`actor\` (the verified principal uid) to author AS that user — the bridge's \`offerDirective\` op`,
|