@githolon/dsl 0.48.1 → 0.49.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/lib/src/test_harness.dart +46 -14
- package/dart/test/test_harness_test.dart +42 -0
- package/package.json +1 -1
- package/src/build_package.ts +44 -1
- package/src/codegen_dart.ts +8 -0
- package/src/codegen_proof.ts +27 -0
- package/src/compile_package_main.ts +56 -3
- package/src/fields.ts +12 -0
- package/src/secondary_ensure.ts +86 -0
- package/src/value_object.ts +76 -6
|
@@ -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) {
|
|
@@ -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.49.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/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 = {
|
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
|
package/src/codegen_proof.ts
CHANGED
|
@@ -659,6 +659,33 @@ function probePlanOps(
|
|
|
659
659
|
return { ops, synth, probePayload };
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Compile-side plan-probe of a directive's SECONDARY deterministic ENSURES — the aggregate TYPES its
|
|
664
|
+
* plan introduces via `ensure(Agg, "det:id")` (an `ensures`-marked op at a dev-chosen deterministic id),
|
|
665
|
+
* beyond whatever its PRIMARY referential marker declares. This is the discovery half of the "secondary
|
|
666
|
+
* create auto-declare" DX (`secondary_ensure.ts`): the kernel's id-mint gate admits an Ensure-introduced
|
|
667
|
+
* typed id ONLY when the certified manifest declares `Ensure → Type`, so `composeDomainModule` sanctions
|
|
668
|
+
* each discovered type with a no-op ensures directive.
|
|
669
|
+
*
|
|
670
|
+
* BEST-EFFORT (the reliability caveat): runs the plan ONCE under a synthesized payload + the deterministic
|
|
671
|
+
* probe ports. A plan that can't be probed (unsynthesizable payload / throws) or that ensures CONDITIONALLY
|
|
672
|
+
* on a payload shape the synthesis didn't hit yields fewer (or no) types — in which case the runtime
|
|
673
|
+
* id-mint gate still refuses the un-sanctioned ensure exactly as before (NO regression, NO weakening: the
|
|
674
|
+
* courtesy simply didn't fire). Returns the sorted set of ensured types (INCLUDING the directive's own
|
|
675
|
+
* primary target if it marks `ensures`; the caller subtracts the domain-wide declared set).
|
|
676
|
+
*/
|
|
677
|
+
export function probeEnsuredTypes(dir: Directive<unknown>, where: string): string[] {
|
|
678
|
+
const probed = probePlanOps(dir, where);
|
|
679
|
+
if (probed === undefined) return [];
|
|
680
|
+
const out = new Set<string>();
|
|
681
|
+
for (const o of probed.ops) {
|
|
682
|
+
if ((o as { marker?: string }).marker !== "ensures") continue;
|
|
683
|
+
const ty = o.aggregateType;
|
|
684
|
+
if (typeof ty === "string" && ty.length > 0) out.add(ty);
|
|
685
|
+
}
|
|
686
|
+
return [...out].sort();
|
|
687
|
+
}
|
|
688
|
+
|
|
662
689
|
/** The `status` of a `subjects` setEntry leaf (`writeTuple`/`removeTuple` lowers a JSON `{status, …}` leaf).
|
|
663
690
|
* `"active"` = a POSITIVE grant; `"revoked"` = a REVOKE. `undefined` ⇒ not a parseable subject leaf. */
|
|
664
691
|
function subjectLeafStatus(op: FieldOp): string | undefined {
|
|
@@ -212,6 +212,18 @@ interface PackageConfig {
|
|
|
212
212
|
* `docs/usd_layered_composition.md`).
|
|
213
213
|
*/
|
|
214
214
|
readonly layered?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* THE COMPACT PACKAGE ERA (opt-in; also `--compact` / NOMOS_COMPACT=1). ONE batched,
|
|
217
|
+
* NAMED domainHash move that shrinks the package ~4x (guestbook 1.48MB → ~0.35MB):
|
|
218
|
+
* • minified engine lump (esbuild minify — string literals, and so refusal codes, unchanged);
|
|
219
|
+
* • zod locales stripped to `en` only (the locale barrel is 35% of every lump and is
|
|
220
|
+
* namespace-re-exported so tree-shaking can't drop it; `en` keeps error messages
|
|
221
|
+
* byte-identical to today's default);
|
|
222
|
+
* • base64 blob fields (`*B64`, 1.33x text) instead of hex (2x).
|
|
223
|
+
* REQUIRES the whole consuming stack (cloud kernel + clients) at ≥ the release that shipped
|
|
224
|
+
* the decode arms. Default stays off until a future major; hex packages parse forever.
|
|
225
|
+
*/
|
|
226
|
+
readonly compact?: boolean;
|
|
215
227
|
}
|
|
216
228
|
|
|
217
229
|
function fail(msg: string): never {
|
|
@@ -608,6 +620,7 @@ function emitDart(
|
|
|
608
620
|
async function main(): Promise<void> {
|
|
609
621
|
const argv = process.argv.slice(2);
|
|
610
622
|
const layeredFlag = argv.includes("--layered");
|
|
623
|
+
const compactFlag = argv.includes("--compact") || process.env["NOMOS_COMPACT"] === "1";
|
|
611
624
|
// §"Shipped (1–4)" follow-up: `githolon compile --analyze` (or NOMOS_ANALYZE=1) runs
|
|
612
625
|
// `dart analyze` on the generated Dart package after emission and fails the compile on red.
|
|
613
626
|
const analyzeFlag = argv.includes("--analyze") || process.env.NOMOS_ANALYZE === "1";
|
|
@@ -1346,12 +1359,44 @@ async function main(): Promise<void> {
|
|
|
1346
1359
|
// JS shim with the platform-native binary, which only an exec can run.
|
|
1347
1360
|
const esbuildBin = path.join(path.dirname(esbuildPkgJson), esbuildBinRel ?? "bin/esbuild");
|
|
1348
1361
|
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1362
|
+
const compact = compactFlag || cfg.compact === true;
|
|
1363
|
+
let javascript: string;
|
|
1364
|
+
if (compact) {
|
|
1365
|
+
// COMPACT LUMP (see PackageConfig.compact): the programmatic esbuild API (same pinned
|
|
1366
|
+
// package as the binary) so a resolve-plugin can stub zod's locale barrel to en-only —
|
|
1367
|
+
// the CLI binary has no plugin surface. Same bundle options + minify; deterministic
|
|
1368
|
+
// (pinned esbuild version, deterministic minifier).
|
|
1369
|
+
const esbuildMod = (await import(pathToFileURL(path.join(path.dirname(esbuildPkgJson), "lib", "main.js")).href)) as typeof import("esbuild");
|
|
1370
|
+
const localeStripToEn = {
|
|
1371
|
+
name: "nomos-zod-locales-en-only",
|
|
1372
|
+
setup(build: import("esbuild").PluginBuild) {
|
|
1373
|
+
build.onResolve({ filter: /locales\/index\.js$/ }, (args) => {
|
|
1374
|
+
const resolved = path.resolve(args.resolveDir, args.path);
|
|
1375
|
+
if (!/[\/\\]zod[\/\\]/.test(resolved)) return null; // only zod's own barrel
|
|
1376
|
+
return { path: resolved, namespace: "nomos-zod-locales-en" };
|
|
1377
|
+
});
|
|
1378
|
+
build.onLoad({ filter: /.*/, namespace: "nomos-zod-locales-en" }, (args) => ({
|
|
1379
|
+
// en ONLY: the default error map — messages stay byte-identical to today's default,
|
|
1380
|
+
// so typed refusal payloads (which carry codes + en text) replay unchanged.
|
|
1381
|
+
contents: 'export { default as en } from "./en.js";',
|
|
1382
|
+
resolveDir: path.dirname(args.path),
|
|
1383
|
+
loader: "js" as const,
|
|
1384
|
+
}));
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
const built = await esbuildMod.build({
|
|
1388
|
+
entryPoints: [entryPath], bundle: true, format: "iife", target: "es2020",
|
|
1389
|
+
platform: "neutral", charset: "utf8", minify: true, write: false,
|
|
1390
|
+
absWorkingDir: cfgDir, plugins: [localeStripToEn], logLevel: "silent",
|
|
1391
|
+
});
|
|
1392
|
+
javascript = built.outputFiles![0]!.text.trimEnd() + "\n";
|
|
1393
|
+
} else {
|
|
1394
|
+
javascript = execFileSync(
|
|
1351
1395
|
esbuildBin,
|
|
1352
1396
|
[entryPath, "--bundle", "--format=iife", "--target=es2020", "--platform=neutral", "--charset=utf8"],
|
|
1353
1397
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd: cfgDir, maxBuffer: 256 * 1024 * 1024 },
|
|
1354
1398
|
).trimEnd() + "\n";
|
|
1399
|
+
}
|
|
1355
1400
|
if (!/globalThis\.plan\s*=/.test(javascript) && !/globalThis\["plan"\]\s*=/.test(javascript)) {
|
|
1356
1401
|
fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
|
|
1357
1402
|
}
|
|
@@ -1462,7 +1507,7 @@ async function main(): Promise<void> {
|
|
|
1462
1507
|
|
|
1463
1508
|
// ── 2. USD IR + the package envelope ──
|
|
1464
1509
|
const usdJson = emitUsdJsonForModules(domainModules);
|
|
1465
|
-
const text = packageUsda(usdJson, javascript);
|
|
1510
|
+
const text = packageUsda(usdJson, javascript, { compact });
|
|
1466
1511
|
const pkgPath = path.join(outDir, `${cfg.name}.package.usda`);
|
|
1467
1512
|
writeFileSync(pkgPath, text, "utf8");
|
|
1468
1513
|
const domainHash = sha256HexUtf8(text);
|
|
@@ -1732,6 +1777,14 @@ async function main(): Promise<void> {
|
|
|
1732
1777
|
domainModules.reduce((n, m) => n + (pick(m)?.length ?? 0), 0);
|
|
1733
1778
|
console.log(` reads ${sum((m) => m.queries)} quer(y/ies), ${sum((m) => m.counts)} count(s), ${sum((m) => m.sums)} sum(s) — declared (routed via the openapi)`);
|
|
1734
1779
|
}
|
|
1780
|
+
// SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts): surface every type the compiler sanctioned
|
|
1781
|
+
// from a plan's deterministic secondary `ensure(...)` — the id-mint-gate DX made visible (the author
|
|
1782
|
+
// wrote `ensure(B, …)`; the compiler declared `Ensure → B` so the deterministic id admits).
|
|
1783
|
+
for (const m of domainModules) {
|
|
1784
|
+
for (const ty of m.autoDeclaredEnsures ?? []) {
|
|
1785
|
+
console.log(` ensure auto-declared Ensure→${ty} in '${m.domain ?? m.name}' from a plan's secondary ensure(...) (id-mint gate opened)`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1735
1788
|
console.log(` identity ${Object.keys(identity.manifests).length} domain(s) (carried in the package IR + nomos.stable-ids.json; no sidecar files)${identity.excluded.length ? ` — ${identity.excluded.length} EXCLUDED (palette gap)` : ""}`);
|
|
1736
1789
|
for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
|
|
1737
1790
|
for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
|
package/src/fields.ts
CHANGED
|
@@ -404,6 +404,18 @@ export const t = {
|
|
|
404
404
|
position(): Field<string, "position"> {
|
|
405
405
|
return makeField<string, "position">("position", z.string(), LastPosition);
|
|
406
406
|
},
|
|
407
|
+
/**
|
|
408
|
+
* An ARRAY of a value object (or any zod) as a VALUE-OBJECT-SHAPE / directive-PAYLOAD
|
|
409
|
+
* element — the ergonomic sugar for `z.array(SomeVo.zod)`. Accepts a VO handle
|
|
410
|
+
* (`t.array(DrawingGeoControlPoint)`), a Field, or a raw zod, and returns the branded
|
|
411
|
+
* `z.array(...)` so the nested VO's descriptor tree threads (vo_first_class.md slice 1).
|
|
412
|
+
* NOTE: this returns a ZOD SCHEMA — a shape/payload element — NOT a `t.*` aggregate field
|
|
413
|
+
* (an array of VOs is stored as one wholesale-replace json leaf; place it via a value object).
|
|
414
|
+
*/
|
|
415
|
+
array(inner: { readonly zod: z.ZodTypeAny } | z.ZodTypeAny): z.ZodArray<z.ZodTypeAny> {
|
|
416
|
+
const el = (inner as { zod?: z.ZodTypeAny }).zod ?? (inner as z.ZodTypeAny);
|
|
417
|
+
return z.array(el);
|
|
418
|
+
},
|
|
407
419
|
/** Enum via `as const` literal union — value typos squeal at compile time. */
|
|
408
420
|
enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
|
|
409
421
|
return makeField<E[number], "enum">(
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
* SECONDARY-CREATE AUTO-DECLARE (the co2-dev id-mint-gate DX).
|
|
8
|
+
*
|
|
9
|
+
* A directive carries ONE referential marker for its PRIMARY aggregate (`.creates(A)` /
|
|
10
|
+
* `.ensures(A)` / …). But a plan may ALSO introduce a SECOND aggregate at a caller-chosen
|
|
11
|
+
* DETERMINISTIC id — `ensure(B, "b:singleton")` — of a type the directive does NOT declare
|
|
12
|
+
* `.ensures()` for. The kernel's id-mint gate admits an Ensure-introduced typed id ONLY through
|
|
13
|
+
* the sanctioned declared-ensures lane: the CERTIFIED manifest must carry a directive whose marker
|
|
14
|
+
* is `Ensure` targeting that type (exactly how `birth_meter_ensure.ts` sanctions `NomosBirthRecord`).
|
|
15
|
+
* With no such declaration the deterministic id is REFUSED ("[id-mint gate]") — correct BY DESIGN
|
|
16
|
+
* (an unsanctioned dev-chosen id can't be forged) but an opaque footgun for the author.
|
|
17
|
+
*
|
|
18
|
+
* This module closes the footgun WITHOUT weakening the gate: `composeDomainModule` PLAN-PROBES each
|
|
19
|
+
* directive (the same probe machinery `codegen_proof.ts` uses), discovers the secondary types its
|
|
20
|
+
* plan ensures, and — for any type NO directive in the domain already declares `.ensures()` for —
|
|
21
|
+
* injects a NO-OP sanctioning `.ensures(Type)` directive (a {@link sanctioningEnsureDirective},
|
|
22
|
+
* modelled on `nomosBirthRecordEnsure`). The manifest/USD then carries the `Ensure → Type`
|
|
23
|
+
* declaration the id-mint gate reads, so the deterministic id is admitted. The synthesized directive
|
|
24
|
+
* is DELIBERATELY USELESS TO DISPATCH (its plan writes nothing); its only load-bearing property is
|
|
25
|
+
* its manifest presence.
|
|
26
|
+
*
|
|
27
|
+
* ERA-SAFE: a domain whose plans do NO secondary ensures is returned untouched (byte-identical). A
|
|
28
|
+
* domain that DOES gains the sanction — and such a domain could not have deployed before this without
|
|
29
|
+
* the author hand-writing the declaration, so no already-shippable manifest hash silently drifts.
|
|
30
|
+
*
|
|
31
|
+
* BEST-EFFORT (graceful degradation): the probe runs each plan ONCE under a synthesized payload. A
|
|
32
|
+
* plan that ensures CONDITIONALLY on a payload shape the synthesis didn't hit (or that can't be
|
|
33
|
+
* probed) simply gets no auto-sanction — and the runtime id-mint gate refuses the ensure exactly as
|
|
34
|
+
* before (no regression, no forged-id hole). Auto-declare only ever ADDS a sanction the author's own
|
|
35
|
+
* plan already proves it intends; it never blocks a valid deploy.
|
|
36
|
+
*/
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import { directive } from "./directive.js";
|
|
39
|
+
import type { Directive } from "./directive.js";
|
|
40
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
41
|
+
import { probeEnsuredTypes } from "./codegen_proof.js";
|
|
42
|
+
|
|
43
|
+
/** The synthesized directive id sanctioning `Ensure → <aggId>`. Derived purely from the aggregate id
|
|
44
|
+
* so recompiles are stable; non-id chars are flattened so the id stays route-safe. */
|
|
45
|
+
export function sanctioningEnsureDirectiveId(aggId: string): string {
|
|
46
|
+
return `nomosEnsure_${aggId.replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A no-op sanctioning `.ensures(agg)` directive — the twin of `nomosBirthRecordEnsure`, parameterized
|
|
51
|
+
* by an arbitrary aggregate. Its plan emits ZERO ops (it cannot forge a row); its ONLY purpose is the
|
|
52
|
+
* `Ensure → agg` marker the certified manifest carries so the id-mint gate opens the declared-ensures
|
|
53
|
+
* lane for a plan's secondary deterministic `ensure(agg, …)`.
|
|
54
|
+
*/
|
|
55
|
+
export function sanctioningEnsureDirective(agg: AggregateHandle): Directive<unknown> {
|
|
56
|
+
return directive(sanctioningEnsureDirectiveId(agg.id))
|
|
57
|
+
.ensures(agg)
|
|
58
|
+
.payload(z.object({}).strict())
|
|
59
|
+
.plan(() => []) as unknown as Directive<unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Discover the secondary-ensured types a domain's directives introduce that NO directive already
|
|
64
|
+
* declares `.ensures()` for — the SORTED gap set the compose seam must sanction. Pure discovery:
|
|
65
|
+
* probes every directive, unions the ensured types, subtracts the domain-wide DECLARED-ensures set.
|
|
66
|
+
*/
|
|
67
|
+
export function discoverUnsanctionedEnsures(
|
|
68
|
+
directives: readonly Directive<unknown>[],
|
|
69
|
+
domain: string,
|
|
70
|
+
): string[] {
|
|
71
|
+
// Types some directive already DECLARES as its primary `.ensures(...)` target — already sanctioned.
|
|
72
|
+
const declared = new Set<string>();
|
|
73
|
+
for (const d of directives) {
|
|
74
|
+
if ((d as { marker?: string }).marker === "ensures") declared.add(d.aggregateId);
|
|
75
|
+
}
|
|
76
|
+
const gap = new Set<string>();
|
|
77
|
+
for (const d of directives) {
|
|
78
|
+
// A `.births()` directive's genesis-recipe plan runs under a different probe surface; its meter
|
|
79
|
+
// record is sanctioned by `birth_meter_ensure.ts`, so leave it to that lane (avoid double probing).
|
|
80
|
+
if ((d as { birthsChild?: boolean }).birthsChild === true) continue;
|
|
81
|
+
for (const ty of probeEnsuredTypes(d, `${domain}/${d.id}`)) {
|
|
82
|
+
if (!declared.has(ty)) gap.add(ty);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...gap].sort();
|
|
86
|
+
}
|
package/src/value_object.ts
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
*/
|
|
53
53
|
import { z } from "zod";
|
|
54
54
|
|
|
55
|
+
import type { Driver } from "./drivers.js";
|
|
55
56
|
import {
|
|
56
57
|
valueObjectField,
|
|
57
58
|
type Field,
|
|
@@ -129,6 +130,17 @@ export interface ValueObject<T = unknown> {
|
|
|
129
130
|
* whole), `.optional()`, `.encrypted()` (under the existing `requires(cap)` rules).
|
|
130
131
|
*/
|
|
131
132
|
field(): Field<T, "json">;
|
|
133
|
+
/**
|
|
134
|
+
* SUGAR for `.field().merge(driver)` — lower this VO DIRECTLY to an aggregate field with
|
|
135
|
+
* the chosen merge driver: `location: SiteLocation.merge(Lww).optional()`. A VO field is
|
|
136
|
+
* ONE Lww-class json leaf replaced WHOLESALE, so only the Lww-class family
|
|
137
|
+
* (`Lww` / `LwwCausal` / `LwwBy`) is accepted — any other driver throws (field-level merge
|
|
138
|
+
* composition of a VO's subfields is a later slice; model those as aggregate fields / a
|
|
139
|
+
* `t.map(...)`). The returned `Field` chains the usual modifiers (`.optional()`,
|
|
140
|
+
* `.encrypted()`, …), so `SiteLocation.merge(Lww).optional()` == `SiteLocation.field()
|
|
141
|
+
* .merge(Lww).optional()` byte-for-byte.
|
|
142
|
+
*/
|
|
143
|
+
merge(driver: Driver): Field<T, "json">;
|
|
132
144
|
/** Phantom to keep T load-bearing for inference. */
|
|
133
145
|
readonly _t?: T;
|
|
134
146
|
}
|
|
@@ -255,12 +267,48 @@ export function idOfMetaOf(zt: z.ZodTypeAny): string | undefined {
|
|
|
255
267
|
return undefined;
|
|
256
268
|
}
|
|
257
269
|
|
|
270
|
+
/** Thread a nested VO's own descriptor + transitive dependencies into `deps` (divergent
|
|
271
|
+
* same-name shapes throw — one name, one shape), then return its ref field descriptor. This
|
|
272
|
+
* is what makes `z.array(SomeVo.zod)` / a raw `SomeVo.zod` entry plug-and-play: the VO brand
|
|
273
|
+
* rides the `.zod`, so the descriptor TREE is threaded rather than lost (vo_first_class.md
|
|
274
|
+
* slice 0). */
|
|
275
|
+
function takeVoField(
|
|
276
|
+
meta: ValueObjectFieldMeta,
|
|
277
|
+
deps: Map<string, ValueObjectDescriptor>,
|
|
278
|
+
): ValueObjectFieldDescriptor {
|
|
279
|
+
for (const d of [meta.descriptor, ...meta.dependencies]) {
|
|
280
|
+
const existing = deps.get(d.name);
|
|
281
|
+
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`valueObject: two DIVERGENT value objects share the name '${d.name}' — one name, one shape. ` +
|
|
284
|
+
`Rename one of them (the descriptor is identity-bearing).`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
deps.set(d.name, d);
|
|
288
|
+
}
|
|
289
|
+
return meta.geometry !== undefined
|
|
290
|
+
? { type: "geometry", ref: meta.name, geometry: meta.geometry }
|
|
291
|
+
: { type: "object", ref: meta.name };
|
|
292
|
+
}
|
|
293
|
+
|
|
258
294
|
/**
|
|
259
295
|
* Describe one CONSTRAINED zod schema as a descriptor field (NO FALLBACK — an
|
|
260
296
|
* out-of-set shape throws with the field's path so the author fixes the declaration,
|
|
261
|
-
* never ships an undescribed structure).
|
|
297
|
+
* never ships an undescribed structure). `deps` collects every nested VO the schema
|
|
298
|
+
* references BY BRAND (`z.array(SomeVo.zod)`, a raw `SomeVo.zod`) so its descriptor rides
|
|
299
|
+
* alongside — the caller unions it into the VO's `dependencies`.
|
|
262
300
|
*/
|
|
263
|
-
function describeZod(
|
|
301
|
+
function describeZod(
|
|
302
|
+
voName: string,
|
|
303
|
+
path: string,
|
|
304
|
+
schema: z.ZodTypeAny,
|
|
305
|
+
deps: Map<string, ValueObjectDescriptor>,
|
|
306
|
+
): ValueObjectFieldDescriptor {
|
|
307
|
+
// A VO referenced through its RAW zod (its own brand, no optional wrapper) — emit the
|
|
308
|
+
// ref + thread its descriptor tree. (Optional wrappers are handled by the switch below,
|
|
309
|
+
// which recurses to the branded inner schema.)
|
|
310
|
+
const ownBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_VO_BRAND];
|
|
311
|
+
if (ownBrand !== undefined) return takeVoField(ownBrand as ValueObjectFieldMeta, deps);
|
|
264
312
|
switch (zodKind(schema)) {
|
|
265
313
|
case "string":
|
|
266
314
|
return { type: "string" };
|
|
@@ -285,7 +333,7 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
|
|
|
285
333
|
return { type: "literal", value: v };
|
|
286
334
|
}
|
|
287
335
|
case "array": {
|
|
288
|
-
const items = describeZod(voName, `${path}[]`, zodArrayElement(schema));
|
|
336
|
+
const items = describeZod(voName, `${path}[]`, zodArrayElement(schema), deps);
|
|
289
337
|
if (items.optional === true) {
|
|
290
338
|
throw new Error(
|
|
291
339
|
`valueObject '${voName}': field '${path}' is an array of optionals — not expressible in the v1 set.`,
|
|
@@ -295,7 +343,7 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
|
|
|
295
343
|
}
|
|
296
344
|
case "optional":
|
|
297
345
|
case "default": {
|
|
298
|
-
const inner = describeZod(voName, path, zodDef(schema).innerType
|
|
346
|
+
const inner = describeZod(voName, path, zodDef(schema).innerType!, deps);
|
|
299
347
|
return { ...inner, optional: true };
|
|
300
348
|
}
|
|
301
349
|
default:
|
|
@@ -452,7 +500,29 @@ function makeValueObject<T>(
|
|
|
452
500
|
field(): Field<T, "json"> {
|
|
453
501
|
return valueObjectField(zodSchema, meta);
|
|
454
502
|
},
|
|
503
|
+
merge(driver: Driver): Field<T, "json"> {
|
|
504
|
+
const k = driver.kind;
|
|
505
|
+
if (k !== "Lww" && k !== "LwwCausal" && k !== "LwwBy") {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`valueObject '${name}': .merge(${k}) is not a valid value-object field merge — a VO is ONE ` +
|
|
508
|
+
`Lww-class json leaf replaced WHOLESALE, so only Lww / LwwCausal / LwwBy apply. Field-level ` +
|
|
509
|
+
`merge composition of a VO's subfields is a later slice; model independently-merging subfields ` +
|
|
510
|
+
`as aggregate fields or a t.map(...).`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return valueObjectField(zodSchema, meta).merge(driver);
|
|
514
|
+
},
|
|
455
515
|
};
|
|
516
|
+
// THE HANDLE IS A FIRST-CLASS ZOD SCHEMA (vo_first_class.md slice 1): delegate zod's
|
|
517
|
+
// internals so the raw HANDLE composes DIRECTLY where a zod is expected — `z.array(Vo)`,
|
|
518
|
+
// `z.object({ f: Vo })`, a directive payload — with NO `.zod` peeling. Non-enumerable (never
|
|
519
|
+
// serialized, domainHash-inert) and stamped with the VO brand so describeZod / voMetaOf resolve
|
|
520
|
+
// the handle exactly like its `.zod`.
|
|
521
|
+
const inner = zodSchema as unknown as { _zod?: unknown; _def?: unknown; def?: unknown };
|
|
522
|
+
Object.defineProperty(vo, "_zod", { get: () => inner._zod, enumerable: false });
|
|
523
|
+
Object.defineProperty(vo, "_def", { get: () => inner._def, enumerable: false });
|
|
524
|
+
Object.defineProperty(vo, "def", { get: () => inner.def, enumerable: false });
|
|
525
|
+
Object.defineProperty(vo, NOMOS_VO_BRAND, { value: meta, enumerable: false });
|
|
456
526
|
return vo;
|
|
457
527
|
}
|
|
458
528
|
|
|
@@ -495,7 +565,7 @@ export function valueObject<const Name extends string, S extends Record<string,
|
|
|
495
565
|
: { type: "object", ref: entry.name };
|
|
496
566
|
collectDescriptors(entry, deps);
|
|
497
567
|
} else {
|
|
498
|
-
fields[key] = describeZod(name, key, entry);
|
|
568
|
+
fields[key] = describeZod(name, key, entry, deps);
|
|
499
569
|
zodShape[key] = entry;
|
|
500
570
|
}
|
|
501
571
|
}
|
|
@@ -584,7 +654,7 @@ export function variant<const Name extends string, Arms extends Record<string, U
|
|
|
584
654
|
: { type: "object", ref: entry.name };
|
|
585
655
|
collectDescriptors(entry, deps);
|
|
586
656
|
} else {
|
|
587
|
-
fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry);
|
|
657
|
+
fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry, deps);
|
|
588
658
|
zodShape[fieldKey] = entry;
|
|
589
659
|
}
|
|
590
660
|
}
|