@githolon/dsl 0.48.1 → 0.50.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/lib/src/test_harness.dart +46 -14
- package/dart/lib/src/webview_bridge.dart +160 -6
- package/dart/test/test_harness_test.dart +42 -0
- package/package.json +3 -2
- package/src/build_package.ts +44 -1
- package/src/check_package_main.ts +83 -0
- package/src/codegen_dart.ts +8 -0
- package/src/codegen_dart_client.ts +103 -8
- package/src/codegen_proof.ts +28 -1
- package/src/codegen_ts.ts +289 -14
- package/src/compile_package_main.ts +75 -3
- package/src/fields.ts +12 -0
- package/src/law_check.ts +375 -0
- package/src/secondary_ensure.ts +86 -0
- package/src/value_object.ts +76 -6
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
3
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
4
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
5
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
6
|
+
|
|
7
|
+
// `githolon check` launcher — the twin of `compile_package.mjs`: resolve `tsx`
|
|
8
|
+
// (the caller's, else the DSL's own dependency) and run `src/check_package_main.ts`
|
|
9
|
+
// under it (the main dynamically imports the caller's TS domain modules, which
|
|
10
|
+
// needs the tsx ESM loader). Resolution goes through Node's resolver (createRequire),
|
|
11
|
+
// NOT node_modules/.bin paths — hoisted/workspace installs put the bin shims elsewhere.
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const MAIN = path.join(DSL_DIR, "src", "check_package_main.ts");
|
|
20
|
+
|
|
21
|
+
function resolvePkgDir(name, fromDir) {
|
|
22
|
+
try {
|
|
23
|
+
const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
|
|
24
|
+
return path.dirname(req.resolve(`${name}/package.json`));
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
|
|
31
|
+
if (!tsxDir) {
|
|
32
|
+
console.error("githolon check: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
|
|
36
|
+
const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
|
|
37
|
+
const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
|
|
41
|
+
stdio: "inherit",
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
process.exit(e.status ?? 1);
|
|
46
|
+
}
|
|
@@ -66,7 +66,11 @@ class NomosTestHarness {
|
|
|
66
66
|
/// [runtimeDir] — the runtime cache (default `~/.holon/runtime`); must hold `holon.wasm` + `packages.json`.
|
|
67
67
|
/// [nodeEntry] — path to `node-entry.mjs`; when omitted it is resolved from [resolveFrom] via
|
|
68
68
|
/// `require.resolve('@githolon/client/runner/node-entry.mjs')`.
|
|
69
|
-
/// [installHelper] —
|
|
69
|
+
/// [installHelper] — the offline law-install lane. An EXISTING `.mjs` file is run as a node script (the
|
|
70
|
+
/// in-repo `offline-install.mjs`, the default when it exists beside [nodeEntry]); anything else is a
|
|
71
|
+
/// githolon COMMAND LINE whose `harness-install` subcommand is driven (the default `npx githolon` when the
|
|
72
|
+
/// in-repo helper is absent — a published tenant needs nothing but `githolon`). E.g. force the subcommand
|
|
73
|
+
/// with `installHelper: 'node /abs/cli/dist/cli.mjs'`.
|
|
70
74
|
/// [registerTearDown] — pass the test framework's `addTearDown` to auto-[dispose] when the test ends
|
|
71
75
|
/// (feature-detecting `package:test` from a library is not clean in Dart, so this is explicit and safe).
|
|
72
76
|
/// [autoTearDown] — when true (default) and [registerTearDown] is given, registers [dispose] as a teardown.
|
|
@@ -90,8 +94,18 @@ class NomosTestHarness {
|
|
|
90
94
|
}
|
|
91
95
|
final entry = nodeEntry ??
|
|
92
96
|
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
// THE INSTALL LANE. [installHelper] is the one seam (PACKAGING NOTE above): either the in-repo
|
|
98
|
+
// `offline-install.mjs` node script, OR the published `githolon harness-install` subcommand (the CLI
|
|
99
|
+
// bundles the engine + this exact install sequence, so a published tenant needs nothing but `githolon`).
|
|
100
|
+
// • an EXISTING `.mjs` file ⇒ run it as a node script (the in-repo default when present);
|
|
101
|
+
// • anything else ⇒ treat it as a githolon COMMAND LINE and drive `harness-install`
|
|
102
|
+
// (default `npx githolon` when the in-repo helper is absent; the test forces this lane by passing
|
|
103
|
+
// e.g. `installHelper: 'node /abs/cli/dist/cli.mjs'`).
|
|
104
|
+
final resolvedHelper =
|
|
105
|
+
installHelper ?? '${File(entry).parent.path}/offline-install.mjs';
|
|
106
|
+
final useInRepoScript =
|
|
107
|
+
resolvedHelper.endsWith('.mjs') && File(resolvedHelper).existsSync();
|
|
108
|
+
final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
|
|
95
109
|
|
|
96
110
|
// A FRESH temp dir per harness — the snapshot + any transient runner state live here and nowhere else,
|
|
97
111
|
// so dispose() leaves nothing behind.
|
|
@@ -102,7 +116,9 @@ class NomosTestHarness {
|
|
|
102
116
|
final snapshot = '${workdir.path}/snapshot.bin';
|
|
103
117
|
await _runInstall(
|
|
104
118
|
node: node,
|
|
105
|
-
|
|
119
|
+
useInRepoScript: useInRepoScript,
|
|
120
|
+
inRepoScript: resolvedHelper,
|
|
121
|
+
githolonCmd: githolonCmd,
|
|
106
122
|
deployJsonPaths: deployJsonPaths,
|
|
107
123
|
workspace: workspace,
|
|
108
124
|
snapshot: snapshot,
|
|
@@ -182,29 +198,45 @@ class NomosTestHarness {
|
|
|
182
198
|
|
|
183
199
|
static Future<void> _runInstall({
|
|
184
200
|
required String node,
|
|
185
|
-
required
|
|
201
|
+
required bool useInRepoScript,
|
|
202
|
+
required String inRepoScript,
|
|
203
|
+
required String githolonCmd,
|
|
186
204
|
required List<String> deployJsonPaths,
|
|
187
205
|
required String workspace,
|
|
188
206
|
required String snapshot,
|
|
189
207
|
String? runtimeDir,
|
|
190
208
|
void Function(String line)? onDiagnostic,
|
|
191
209
|
}) async {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
'(it ships beside node-entry.mjs)');
|
|
196
|
-
}
|
|
197
|
-
final r = await Process.run(node, [
|
|
198
|
-
helper,
|
|
210
|
+
// Both lanes take the SAME flags (--deploy/--workspace/--out/--runtime) and print the SAME one-line JSON
|
|
211
|
+
// contract; only the leading command differs (node <script> vs `<githolon...> harness-install`).
|
|
212
|
+
final common = <String>[
|
|
199
213
|
'--deploy', deployJsonPaths.join(','),
|
|
200
214
|
'--workspace', workspace,
|
|
201
215
|
'--out', snapshot,
|
|
202
216
|
if (runtimeDir != null) ...['--runtime', runtimeDir],
|
|
203
|
-
]
|
|
217
|
+
];
|
|
218
|
+
final String executable;
|
|
219
|
+
final List<String> args;
|
|
220
|
+
if (useInRepoScript) {
|
|
221
|
+
executable = node;
|
|
222
|
+
args = [inRepoScript, ...common];
|
|
223
|
+
} else {
|
|
224
|
+
// `githolonCmd` is a command line (e.g. `npx githolon` or `node /abs/cli.mjs`) — split into tokens.
|
|
225
|
+
final tokens =
|
|
226
|
+
githolonCmd.trim().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
|
227
|
+
if (tokens.isEmpty) {
|
|
228
|
+
throw NomosTestHarnessException(
|
|
229
|
+
'no install helper resolved — pass installHelper: (an offline-install.mjs path, or a githolon command)');
|
|
230
|
+
}
|
|
231
|
+
executable = tokens.first;
|
|
232
|
+
args = [...tokens.skip(1), 'harness-install', ...common];
|
|
233
|
+
}
|
|
234
|
+
final tag = useInRepoScript ? 'offline-install' : 'harness-install';
|
|
235
|
+
final r = await Process.run(executable, args);
|
|
204
236
|
final err = (r.stderr as String).trim();
|
|
205
237
|
if (err.isNotEmpty) {
|
|
206
238
|
for (final line in err.split('\n')) {
|
|
207
|
-
(onDiagnostic ?? stderr.writeln)('[
|
|
239
|
+
(onDiagnostic ?? stderr.writeln)('[$tag] $line');
|
|
208
240
|
}
|
|
209
241
|
}
|
|
210
242
|
if (r.exitCode != 0) {
|
|
@@ -292,6 +292,96 @@ class NomosBbox {
|
|
|
292
292
|
{'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// ── E2E SEALING (the sealing client) — the GENERIC key-custody surface. Generated domain clients bake the
|
|
296
|
+
// per-domain map + seal/read helpers, but these types are domain-agnostic, so they live here (in the
|
|
297
|
+
// nomos_client library) exactly like [MissingOfferAuthority] — one definition, re-exported per client. ──
|
|
298
|
+
|
|
299
|
+
/// A held E2E scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation).
|
|
300
|
+
class NomosScopeKey {
|
|
301
|
+
final String scope;
|
|
302
|
+
final int epoch;
|
|
303
|
+
final String key;
|
|
304
|
+
const NomosScopeKey(
|
|
305
|
+
{required this.scope, required this.epoch, required this.key});
|
|
306
|
+
Map<String, Object?> toJson() => {'scope': scope, 'epoch': epoch, 'key': key};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// The app-supplied KEY CUSTODY surface (E2E sealing). Supply one to a generated `<Domain>Client` and its
|
|
310
|
+
/// encrypted directive fields SEAL automatically before dispatch + encrypted read fields DECRYPT
|
|
311
|
+
/// automatically. [WrappedKeyStore] is the default over the on-chain WrappedKeys; an app may implement this
|
|
312
|
+
/// to source keys from secure storage (the key custody policy stays the app's; the protocol plumbing does not).
|
|
313
|
+
abstract class NomosKeyStore {
|
|
314
|
+
/// The CURRENT (highest-epoch) scope key, for SEALING a write. null ⇒ EncryptionKeyUnavailable.
|
|
315
|
+
Future<NomosScopeKey?> scopeKey(String scope);
|
|
316
|
+
|
|
317
|
+
/// EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts.
|
|
318
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the write
|
|
322
|
+
/// NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway; we pre-fire).
|
|
323
|
+
class EncryptionKeyUnavailable implements Exception {
|
|
324
|
+
final String scope;
|
|
325
|
+
final String field;
|
|
326
|
+
String get code => 'nomos.crypto.key_unavailable';
|
|
327
|
+
String get recommendedAction => 'obtainScopeKey';
|
|
328
|
+
EncryptionKeyUnavailable(this.scope, this.field);
|
|
329
|
+
@override
|
|
330
|
+
String toString() =>
|
|
331
|
+
'EncryptionKeyUnavailable: cannot seal "$field": no scope key held for "$scope" (obtain the cap + wrap the scope epoch first)';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching per
|
|
335
|
+
/// scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side, the
|
|
336
|
+
/// operator stays blind. Key custody (where `deviceSecret` lives — enclave, password-derived, …) is the app's.
|
|
337
|
+
class WrappedKeyStore implements NomosKeyStore {
|
|
338
|
+
final NomosBridge bridge;
|
|
339
|
+
final String principal;
|
|
340
|
+
final String deviceSecret;
|
|
341
|
+
final Map<String, Future<List<NomosScopeKey>>> _cache = {};
|
|
342
|
+
WrappedKeyStore(
|
|
343
|
+
{required this.bridge,
|
|
344
|
+
required this.principal,
|
|
345
|
+
required this.deviceSecret});
|
|
346
|
+
|
|
347
|
+
Future<List<NomosScopeKey>> _loadScope(String scope) {
|
|
348
|
+
return _cache.putIfAbsent(scope, () async {
|
|
349
|
+
final rows = await bridge
|
|
350
|
+
.query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
|
|
351
|
+
final held = <NomosScopeKey>[];
|
|
352
|
+
for (final row in rows) {
|
|
353
|
+
final d = ((row['data'] ?? row) as Map);
|
|
354
|
+
if (d['scope']?.toString() != scope) continue;
|
|
355
|
+
final key = await bridge.cryptoUnwrapKey(
|
|
356
|
+
secret: deviceSecret,
|
|
357
|
+
hpkeEpk: d['hpkeEpk'].toString(),
|
|
358
|
+
ct: d['ct'].toString());
|
|
359
|
+
held.add(NomosScopeKey(
|
|
360
|
+
scope: scope,
|
|
361
|
+
epoch: (d['epoch'] as num?)?.toInt() ?? 0,
|
|
362
|
+
key: key));
|
|
363
|
+
}
|
|
364
|
+
held.sort((a, b) => b.epoch - a.epoch);
|
|
365
|
+
return held;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@override
|
|
370
|
+
Future<NomosScopeKey?> scopeKey(String scope) async {
|
|
371
|
+
final ks = await _loadScope(scope);
|
|
372
|
+
return ks.isEmpty ? null : ks.first;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
@override
|
|
376
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes) async {
|
|
377
|
+
final all = <NomosScopeKey>[];
|
|
378
|
+
for (final s in scopes) {
|
|
379
|
+
all.addAll(await _loadScope(s));
|
|
380
|
+
}
|
|
381
|
+
return all;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
295
385
|
/// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
|
|
296
386
|
class NomosOfferOutcome {
|
|
297
387
|
final String head;
|
|
@@ -771,14 +861,78 @@ class NomosBridge {
|
|
|
771
861
|
Map<String, Object?> args) async =>
|
|
772
862
|
_asMap(await _call('attestationSign', {'args': args}));
|
|
773
863
|
|
|
774
|
-
/// A declared query's rows from the local projection.
|
|
864
|
+
/// A declared query's rows from the local projection. `keys` (E2E sealing) injects the reader's held
|
|
865
|
+
/// scope keys so encrypted fields AEAD-open in-wasm; absent ⇒ they stay opaque envelopes (today's
|
|
866
|
+
/// behavior). `principal` stamps the private-read gate (defaults to the connection's bound principal).
|
|
867
|
+
/// Both are OMITTED from the wire when absent, so a public un-keyed read is byte-identical to before.
|
|
775
868
|
Future<List<Map<String, dynamic>>> query(String queryId,
|
|
776
|
-
[Map<String, Object?> params = const {}
|
|
777
|
-
|
|
869
|
+
[Map<String, Object?> params = const {},
|
|
870
|
+
List<NomosScopeKey>? keys,
|
|
871
|
+
String? principal]) async =>
|
|
872
|
+
_asRows(await _call('query', {
|
|
873
|
+
'queryId': queryId,
|
|
874
|
+
'params': params,
|
|
875
|
+
...?_readAuthArgs(keys, principal),
|
|
876
|
+
}));
|
|
877
|
+
|
|
878
|
+
/// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
|
|
879
|
+
Future<List<Map<String, dynamic>>> queryById(String id,
|
|
880
|
+
[List<NomosScopeKey>? keys, String? principal]) async =>
|
|
881
|
+
_asRows(await _call('queryById', {
|
|
882
|
+
'id': id,
|
|
883
|
+
...?_readAuthArgs(keys, principal),
|
|
884
|
+
}));
|
|
885
|
+
|
|
886
|
+
/// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
|
|
887
|
+
/// neither is present (so the wire is byte-identical to before this existed).
|
|
888
|
+
Map<String, Object?>? _readAuthArgs(List<NomosScopeKey>? keys, String? principal) {
|
|
889
|
+
final out = <String, Object?>{};
|
|
890
|
+
if (principal != null && principal.isNotEmpty) out['principal'] = principal;
|
|
891
|
+
if (keys != null && keys.isNotEmpty) out['keys'] = keys.map((k) => k.toJson()).toList();
|
|
892
|
+
return out.isEmpty ? null : out;
|
|
893
|
+
}
|
|
778
894
|
|
|
779
|
-
///
|
|
780
|
-
|
|
781
|
-
|
|
895
|
+
/// E2E CRYPTO OPS (operator-blind sealing) — routed to the kernel via the WebView runner. XChaCha isn't in
|
|
896
|
+
/// WebCrypto/Dart, so these drive the wasm; the scope key + device secret are INJECTED per call and never
|
|
897
|
+
/// touch the chain. Mirror of the TS holon's crypto seam (@githolon/client).
|
|
898
|
+
/// Mint an X25519 device keypair for the handshake: `{secret, public}` (base64). Secret stays on device.
|
|
899
|
+
Future<Map<String, Object?>> cryptoKeygen() async =>
|
|
900
|
+
_asMap(await _call('cryptoKeygen'));
|
|
901
|
+
|
|
902
|
+
/// AUTHOR-SIDE SEAL: encrypt `plaintext` into its on-chain ciphertext envelope, bound to the scope + #58
|
|
903
|
+
/// stable sids (rename-safe). Returns the envelope string the encrypted field value is written as.
|
|
904
|
+
Future<String> cryptoSealField(
|
|
905
|
+
{required String scopeKey,
|
|
906
|
+
required String scope,
|
|
907
|
+
required int epoch,
|
|
908
|
+
required String aggSid,
|
|
909
|
+
required String fieldSid,
|
|
910
|
+
required String plaintext}) async =>
|
|
911
|
+
(_asMap(await _call('cryptoSealField', {
|
|
912
|
+
'scopeKey': scopeKey,
|
|
913
|
+
'scope': scope,
|
|
914
|
+
'epoch': epoch,
|
|
915
|
+
'aggSid': aggSid,
|
|
916
|
+
'fieldSid': fieldSid,
|
|
917
|
+
'plaintext': plaintext,
|
|
918
|
+
}))['envelope'] ?? '')
|
|
919
|
+
.toString();
|
|
920
|
+
|
|
921
|
+
/// Wrap a scope key TO a recipient's public key (committer side): `{hpkeEpk, ct}` base64 ciphertext.
|
|
922
|
+
Future<Map<String, Object?>> cryptoWrapKey(
|
|
923
|
+
{required String recipientPub, required String scopeKey}) async =>
|
|
924
|
+
_asMap(await _call('cryptoWrapKey',
|
|
925
|
+
{'recipientPub': recipientPub, 'scopeKey': scopeKey}));
|
|
926
|
+
|
|
927
|
+
/// Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (reader side).
|
|
928
|
+
Future<String> cryptoUnwrapKey(
|
|
929
|
+
{required String secret,
|
|
930
|
+
required String hpkeEpk,
|
|
931
|
+
required String ct}) async =>
|
|
932
|
+
(_asMap(await _call('cryptoUnwrapKey',
|
|
933
|
+
{'secret': secret, 'hpkeEpk': hpkeEpk, 'ct': ct}))['scopeKey'] ??
|
|
934
|
+
'')
|
|
935
|
+
.toString();
|
|
782
936
|
|
|
783
937
|
/// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
|
|
784
938
|
/// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
|
|
@@ -20,6 +20,9 @@ String get _guestbookDeployJson =>
|
|
|
20
20
|
'${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
|
|
21
21
|
String get _runtimeDir =>
|
|
22
22
|
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
23
|
+
// The built CLI bin — drives the `githolon harness-install` SUBCOMMAND lane (the published-harness path).
|
|
24
|
+
String get _cliDist =>
|
|
25
|
+
'${Directory.current.path}/../../cli/dist/cli.mjs';
|
|
23
26
|
|
|
24
27
|
/// The prerequisite that isn't installable in-test (a warm runtime cache + the compiled fixture). Returns a
|
|
25
28
|
/// skip reason, or null when everything the harness needs is present.
|
|
@@ -100,4 +103,43 @@ void main() {
|
|
|
100
103
|
await expectLater(
|
|
101
104
|
harness.bridge.query('entriesByAuthor', {'author': 'x'}), throwsA(anything));
|
|
102
105
|
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
106
|
+
|
|
107
|
+
test('OFFLINE: the `githolon harness-install` SUBCOMMAND lane installs law and drives a read', () async {
|
|
108
|
+
final skip = _missingPrereq();
|
|
109
|
+
if (skip != null) {
|
|
110
|
+
markTestSkipped(skip);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!File(_cliDist).existsSync()) {
|
|
114
|
+
markTestSkipped('build the CLI first: cd cli && npm run build (needs dist/cli.mjs)');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// FORCE the published subcommand lane via the installHelper seam: a githolon command line (here the
|
|
118
|
+
// in-repo built bin) whose `harness-install` subcommand does the offline install a published tenant gets.
|
|
119
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
120
|
+
deployJsonPaths: [_guestbookDeployJson],
|
|
121
|
+
nodeEntry: _repoEntry,
|
|
122
|
+
runtimeDir: _runtimeDir,
|
|
123
|
+
installHelper: 'node $_cliDist', // NOT an existing .mjs file ⇒ the subcommand lane
|
|
124
|
+
registerTearDown: addTearDown,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
final outcome = await harness.bridge.offerCreates(
|
|
128
|
+
domain: 'guestbook',
|
|
129
|
+
directiveId: 'signGuestbook',
|
|
130
|
+
payload: {
|
|
131
|
+
'author': 'subcommand',
|
|
132
|
+
'message': 'installed via githolon harness-install',
|
|
133
|
+
'mood': 'happy',
|
|
134
|
+
'signedAt': DateTime.now().toUtc().toIso8601String(),
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
expect(outcome.created, hasLength(1));
|
|
138
|
+
final rows = await harness.bridge.query('entriesByAuthor', {'author': 'subcommand'});
|
|
139
|
+
expect(rows, hasLength(1));
|
|
140
|
+
expect((rows.first['data'] as Map)['message'], 'installed via githolon harness-install');
|
|
141
|
+
expect((rows.first['data'] as Map)['moodEmoji'], '😊'); // the law's plan ran through the subcommand-built snapshot
|
|
142
|
+
|
|
143
|
+
stdout.writeln('subcommand harness green: ws=${harness.workspace}');
|
|
144
|
+
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
103
145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"dart",
|
|
47
47
|
"!dart/.dart_tool",
|
|
48
48
|
"!dart/build",
|
|
49
|
-
"compile_package.mjs"
|
|
49
|
+
"compile_package.mjs",
|
|
50
|
+
"check_package.mjs"
|
|
50
51
|
],
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|
package/src/build_package.ts
CHANGED
|
@@ -68,6 +68,7 @@ import { emitUsd } from "./usd.js";
|
|
|
68
68
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
69
69
|
import { RelationTuple } from "./framework/relations.js";
|
|
70
70
|
import { parentAttestedBirthMarker } from "./births.js";
|
|
71
|
+
import { discoverUnsanctionedEnsures, sanctioningEnsureDirective } from "./secondary_ensure.js";
|
|
71
72
|
|
|
72
73
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
73
74
|
// composeDomainModule — the named-keys `module(...)`
|
|
@@ -185,6 +186,24 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
185
186
|
if (!byId.has(a.id)) byId.set(a.id, a);
|
|
186
187
|
}
|
|
187
188
|
}
|
|
189
|
+
// SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts — the id-mint-gate DX): a plan may `ensure(B, "det:id")`
|
|
190
|
+
// a SECOND aggregate at a deterministic id of a type its directive does NOT declare `.ensures()` for. The
|
|
191
|
+
// id-mint gate refuses such an id unless the certified manifest carries an `Ensure → B` declaration. Plan-
|
|
192
|
+
// probe the directives, and for every ensured type NO directive already declares, inject a NO-OP sanctioning
|
|
193
|
+
// `.ensures(B)` directive (modelled on `nomosBirthRecordEnsure`) so the manifest opens the declared-ensures
|
|
194
|
+
// lane. Best-effort + era-safe: a domain with no (unsanctioned) secondary ensure synthesizes nothing and is
|
|
195
|
+
// byte-identical; a domain that DOES could not have deployed before without the author hand-writing this.
|
|
196
|
+
const autoDeclaredEnsures: string[] = [];
|
|
197
|
+
for (const ty of discoverUnsanctionedEnsures(directives, spec.domain ?? spec.name)) {
|
|
198
|
+
const agg = byId.get(ty);
|
|
199
|
+
// Only sanction a type whose handle this domain actually carries (its schema must be present for the
|
|
200
|
+
// upsert to fold); a probe pointing at a foreign/unknown type is left to the runtime gate (no schema).
|
|
201
|
+
if (agg === undefined) continue;
|
|
202
|
+
const dir = sanctioningEnsureDirective(agg);
|
|
203
|
+
if (directives.some((d) => d.id === dir.id)) continue;
|
|
204
|
+
directives.push(dir);
|
|
205
|
+
autoDeclaredEnsures.push(ty);
|
|
206
|
+
}
|
|
188
207
|
const aggregates = [...byId.values()];
|
|
189
208
|
const directiveIds = new Set(directives.map((d) => d.id));
|
|
190
209
|
for (const task of spec.impureCapabilities ?? []) {
|
|
@@ -330,6 +349,8 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
330
349
|
domain: spec.domain ?? spec.name,
|
|
331
350
|
aggregates,
|
|
332
351
|
directives,
|
|
352
|
+
// Diagnostic metadata for the compile summary — NOT law (not hashed). Omit-when-empty.
|
|
353
|
+
...(autoDeclaredEnsures.length > 0 ? { autoDeclaredEnsures } : {}),
|
|
333
354
|
...(queries.length > 0 ? { queries } : {}),
|
|
334
355
|
...(counts.length > 0 ? { counts } : {}),
|
|
335
356
|
...(spatials.length > 0 ? { spatials } : {}),
|
|
@@ -878,7 +899,29 @@ export function hexUtf8(text: string): string {
|
|
|
878
899
|
* domainHash the deploy names is sha256 over the WHOLE returned string (trailing
|
|
879
900
|
* newline included) exactly as committed.
|
|
880
901
|
*/
|
|
881
|
-
export function packageUsda(usdJson: string, javascript: string): string {
|
|
902
|
+
export function packageUsda(usdJson: string, javascript: string, opts: { compact?: boolean } = {}): string {
|
|
903
|
+
// THE COMPACT ERA (opt-in, 2026-07-04): base64 blob fields (`*B64`, 1.33× text) instead of hex
|
|
904
|
+
// (2×). Requires every consumer to carry the decode arm (kernel domain_package.rs extract_blob +
|
|
905
|
+
// the JS parsers) — shipped ahead of this emit, so `compact: true` is the tenant's explicit
|
|
906
|
+
// choice once their stack is current. Hex stays the default emit until a future major; hex
|
|
907
|
+
// packages parse FOREVER (the era rule). sha256(the returned string) is the domainHash either
|
|
908
|
+
// way — a compact recompile is ONE named hash move (batched with minify + locale-strip).
|
|
909
|
+
if (opts.compact) {
|
|
910
|
+
return `#usda 1.0
|
|
911
|
+
(
|
|
912
|
+
customData = {
|
|
913
|
+
string nomos:format = "${NOMOS_DOMAIN_PACKAGE_FORMAT}"
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
def "NomosDomainPackage"
|
|
918
|
+
{
|
|
919
|
+
custom string nomos:usdJsonB64 = "${Buffer.from(usdJson, "utf8").toString("base64")}"
|
|
920
|
+
custom string nomos:executable:language = "javascript"
|
|
921
|
+
custom string nomos:executable:javascriptB64 = "${Buffer.from(javascript, "utf8").toString("base64")}"
|
|
922
|
+
}
|
|
923
|
+
`;
|
|
924
|
+
}
|
|
882
925
|
return `#usda 1.0
|
|
883
926
|
(
|
|
884
927
|
customData = {
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* `githolon check` — the author-time gate as a standalone command
|
|
8
|
+
* (architecture/author_time_gate.md, slice 1). Loads `nomos.package.mjs`, composes each
|
|
9
|
+
* domain the SAME way `compile` does (`composeDomainModule` — the one owner of directive
|
|
10
|
+
* discovery), runs {@link checkDomainModules}, prints the unified report, and exits
|
|
11
|
+
* non-zero on any REFUSE-level finding. `compile` runs the same twins implicitly.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { composeDomainModule, type Mod } from "./build_package.js";
|
|
18
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
19
|
+
import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
|
|
20
|
+
|
|
21
|
+
interface DomainConfig {
|
|
22
|
+
key: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
modules: string[];
|
|
25
|
+
}
|
|
26
|
+
interface PackageConfig {
|
|
27
|
+
name: string;
|
|
28
|
+
domains: DomainConfig[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(msg: string): never {
|
|
32
|
+
console.error(`githolon check: ${msg}`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compose the package's domain modules from `nomos.package.mjs` — the check-time subset of what
|
|
38
|
+
* `compile_package_main` does: import the config, import each domain's TS modules, and hand them to
|
|
39
|
+
* `composeDomainModule` (the shared composition primitive that discovers directives/aggregates and
|
|
40
|
+
* applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
|
|
43
|
+
const configPath = path.resolve(process.cwd(), configArg);
|
|
44
|
+
if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
|
|
45
|
+
const cfgDir = path.dirname(configPath);
|
|
46
|
+
const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
|
|
47
|
+
const cfg = cfgModule.default;
|
|
48
|
+
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
49
|
+
fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
|
|
50
|
+
}
|
|
51
|
+
const composed: DomainModule[] = [];
|
|
52
|
+
for (const d of cfg.domains) {
|
|
53
|
+
if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
|
|
54
|
+
fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
|
|
55
|
+
}
|
|
56
|
+
const mods: Mod[] = [];
|
|
57
|
+
for (const rel of d.modules) {
|
|
58
|
+
const abs = path.resolve(cfgDir, rel);
|
|
59
|
+
if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
|
|
60
|
+
mods.push((await import(pathToFileURL(abs).href)) as Mod);
|
|
61
|
+
}
|
|
62
|
+
composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
|
|
63
|
+
}
|
|
64
|
+
return composed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main(): Promise<void> {
|
|
68
|
+
const argv = process.argv.slice(2);
|
|
69
|
+
const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
|
|
70
|
+
const modules = await loadComposedModules(configArg);
|
|
71
|
+
const findings = checkDomainModules(modules);
|
|
72
|
+
|
|
73
|
+
console.log(`githolon check: ${findings.length} finding(s)`);
|
|
74
|
+
for (const line of formatFindings(findings)) {
|
|
75
|
+
const level = line.includes("REFUSE") ? " ✗ " : line.includes("WARN") ? " ⚠ " : " • ";
|
|
76
|
+
console.log(level + line);
|
|
77
|
+
}
|
|
78
|
+
if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
|
|
79
|
+
|
|
80
|
+
process.exit(hasRefusal(findings) ? 1 : 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void main();
|
package/src/codegen_dart.ts
CHANGED
|
@@ -2985,6 +2985,14 @@ export interface DomainModule {
|
|
|
2985
2985
|
domain?: string;
|
|
2986
2986
|
aggregates: AggregateHandle[];
|
|
2987
2987
|
directives: AnyDirective[];
|
|
2988
|
+
/**
|
|
2989
|
+
* SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts) — the aggregate TYPES whose deterministic
|
|
2990
|
+
* secondary `ensure(...)` `composeDomainModule` plan-probed and SANCTIONED by injecting a no-op
|
|
2991
|
+
* `.ensures(Type)` directive into {@link directives}. Diagnostic metadata for the compile summary
|
|
2992
|
+
* ONLY — NOT law, NOT hashed (the manifest reads named fields; this one is not among them). Absent
|
|
2993
|
+
* when the domain has no unsanctioned secondary ensure (the common case).
|
|
2994
|
+
*/
|
|
2995
|
+
autoDeclaredEnsures?: string[];
|
|
2988
2996
|
/**
|
|
2989
2997
|
* STABLE-ID CONTINUITY (#58 — names are labels, identity is minted). When present
|
|
2990
2998
|
* (attached by `nomos-compile` from the PRIOR build's identity manifests), the
|