@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.
@@ -1,75 +1,103 @@
1
- // NomosTestHarness: unit coverage for the identity/jwt plumbing, plus the LIVE ephemeral fixture loop
2
- // (birth a throwaway ws → deploy real law → two client replicas → write on one, converge on the other).
3
- // The live test needs a `githolon login --agent` session (or NOMOS_PRINCIPAL) and is opt-in via NOMOS_LIVE=1.
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 without any identity refuses with a named remedy', () async {
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
- 'LIVE: ephemeral fixture — birth, deploy law, write on one replica, read it on another',
36
- skip: Platform.environment['NOMOS_LIVE'] != '1'
37
- ? 'set NOMOS_LIVE=1 (needs a githolon login --agent session)'
38
- : null,
39
- () async {
40
- final harness = await NomosTestHarness.ephemeral(
41
- deployJsonPaths: [_guestbookDeployJson()],
42
- nodeEntry: _repoEntry(),
43
- );
44
- try {
45
- final writer = await harness.connect(clientId: 'writer');
46
- final reader = await harness.connect(clientId: 'reader');
47
- final minted = await writer.mintId('Fixture');
48
- expect(minted, startsWith('Fixture_'));
49
- final outcome = await writer.offerCreates(
50
- domain: 'guestbook',
51
- directiveId: 'signGuestbook',
52
- payload: {
53
- 'author': 'harness',
54
- 'message': 'ephemeral fixture green',
55
- 'mood': 'delighted',
56
- 'signedAt': DateTime.now().toUtc().toIso8601String(),
57
- });
58
- expect(outcome.created, hasLength(1));
59
- expect(outcome.created.single.type, 'guestbook.GuestbookEntry');
60
- await writer.sync(admit: true);
61
- await reader.sync(admit: false);
62
- final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
63
- expect(rows, hasLength(1));
64
- expect(rows.first['id'], outcome.created.single.id);
65
- expect(
66
- (rows.first['data'] as Map)['message'], 'ephemeral fixture green');
67
- stdout.writeln(
68
- 'live harness green: ws=${harness.workspace} rows=${jsonEncode(rows.first['data'])}');
69
- } finally {
70
- await harness.dispose();
71
- }
72
- },
73
- timeout: const Timeout(Duration(minutes: 5)),
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.45.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",
@@ -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
- const register = (preferredName: string, values: readonly string[]) => {
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
- // Two DIFFERENT value-sets WITHIN one domain can prefer the SAME name (e.g.
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 = `${domainPrefix}${dartTypeNameFromField(preferredName)}${n}Enum`;
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
- return `${voName}${dartTypeNameFromField(fieldName)}Enum`;
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) return false;`,
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])) return false;`,
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) return false;`,
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])) return false;`,
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`,