@githolon/dsl 0.88.0 → 0.89.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/package.json +1 -1
- package/src/codegen_dart.ts +31 -2
- package/src/codegen_proof.ts +65 -9
- package/src/compile_package_main.ts +126 -24
- package/src/fields.ts +1 -1
- package/src/value_object.ts +10 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.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
|
@@ -2139,8 +2139,32 @@ function emitDartContractClass(c: import("./contract.js").ContractHandle): strin
|
|
|
2139
2139
|
* import header so `compile_package_main`'s uniform rewrite (→ vendored relative / → nomos_types) applies;
|
|
2140
2140
|
* the classes themselves reference only nomos_types primitives (+ shared VO classes), so the file is PURE.
|
|
2141
2141
|
*/
|
|
2142
|
-
export function generateDartContracts(
|
|
2142
|
+
export function generateDartContracts(
|
|
2143
|
+
contracts: readonly import("./contract.js").ContractHandle[],
|
|
2144
|
+
opts: {
|
|
2145
|
+
/** The shared `nomos_framework.dart` import URI when that file exists — contract-used VOs (e.g. a
|
|
2146
|
+
* `ReferenceCode.field()`) render inside the DTOs, and their classes are HOISTED there (layer-zero),
|
|
2147
|
+
* so contracts.dart must import it (co2 #117b). */
|
|
2148
|
+
readonly frameworkImportUri?: string;
|
|
2149
|
+
/** Local aggregates' typed-id class names — a contract named after a local aggregate must NOT
|
|
2150
|
+
* re-declare its `<X>Id` (the aggregate's own class, with `.fromRow`, already exists). */
|
|
2151
|
+
readonly aggregateIdClassNames?: ReadonlySet<string>;
|
|
2152
|
+
} = {},
|
|
2153
|
+
): string {
|
|
2143
2154
|
const sorted = [...contracts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2155
|
+
// CONTRACT TYPED IDS (co2 #117a): `idOf(SomeContract)` in a payload types the field `<ContractId>Id` —
|
|
2156
|
+
// exactly like an aggregate's typed id — but a contract has no aggregate emitter to declare the class.
|
|
2157
|
+
// Emit each contract's id as a ZERO-COST String extension type here (the shared layer every domain
|
|
2158
|
+
// imports): a const ctor from the wire string + `.value` — the exact surface the generated payloads use.
|
|
2159
|
+
const idTypes = sorted
|
|
2160
|
+
.map((c) => typedIdClassForAggregateType(c.id))
|
|
2161
|
+
.filter((cls) => !(opts.aggregateIdClassNames?.has(cls) ?? false))
|
|
2162
|
+
.map((cls) => [
|
|
2163
|
+
`/// TYPED CONTRACT ID \`${cls}\` — a ZERO-COST nominal String (Dart extension type): the id of the`,
|
|
2164
|
+
`/// foreign aggregate behind the contract, as \`idOf(<Contract>)\` payload fields carry it. Construct`,
|
|
2165
|
+
`/// it from a \`ForeignRef.id\` / a queried row id: \`${cls}(ref.id)\`; \`.value\` is the wire string.`,
|
|
2166
|
+
`extension type const ${cls}(String value) {}`,
|
|
2167
|
+
].join("\n"));
|
|
2144
2168
|
return [
|
|
2145
2169
|
`// GENERATED by nomos-compile — the package's shared domain CONTRACTS (public DTOs). DO NOT EDIT.`,
|
|
2146
2170
|
`// Every domain depends on this shared layer; no domain imports another's concrete types (the acyclic graph).`,
|
|
@@ -2149,6 +2173,9 @@ export function generateDartContracts(contracts: readonly import("./contract.js"
|
|
|
2149
2173
|
`library;`,
|
|
2150
2174
|
``,
|
|
2151
2175
|
`import 'package:nomos_client/nomos_client.dart';`,
|
|
2176
|
+
...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
|
|
2177
|
+
``,
|
|
2178
|
+
...idTypes,
|
|
2152
2179
|
``,
|
|
2153
2180
|
...sorted.map(emitDartContractClass),
|
|
2154
2181
|
``,
|
|
@@ -4250,7 +4277,9 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
4250
4277
|
const brandNames = new Set<string>();
|
|
4251
4278
|
const walkUnitField = (f: ValueObjectFieldDescriptor): void => {
|
|
4252
4279
|
if (f.type === "number" && f.unit !== undefined) unitNames.add(f.unit);
|
|
4253
|
-
|
|
4280
|
+
// A standalone brandedId declares its own extension type; an idOf REFERENCE does NOT — the aggregate
|
|
4281
|
+
// emits the canonical `<Agg>Id` class (with `.fromRow`), so re-declaring it here dup'd the symbol (co2).
|
|
4282
|
+
else if (f.type === "string" && f.brand !== undefined && f.idOf === undefined) brandNames.add(f.brand);
|
|
4254
4283
|
else if (f.type === "array") walkUnitField(f.items);
|
|
4255
4284
|
else if (f.type === "map") walkUnitField(f.values);
|
|
4256
4285
|
};
|
package/src/codegen_proof.ts
CHANGED
|
@@ -48,7 +48,7 @@ import { clearDirectiveId } from "./clear_directive.js";
|
|
|
48
48
|
import { voMetaOf, voSample } from "./value_object.js";
|
|
49
49
|
import { mintedCreateField } from "./workspace_routing.js";
|
|
50
50
|
import { deterministicPorts } from "./ctx.js";
|
|
51
|
-
import { __setDispatchDomain } from "./framework/relations.js";
|
|
51
|
+
import { __setDispatchDomain, __setDispatchContext } from "./framework/relations.js";
|
|
52
52
|
import { __resetBirths, __drainBirths } from "./births.js";
|
|
53
53
|
import { __resetAuthoring, __drainAuthoring } from "./authoring.js";
|
|
54
54
|
import { __resetReads, __drainReads } from "./read.js";
|
|
@@ -127,10 +127,20 @@ function uniquifyBirthPayload(
|
|
|
127
127
|
disc: string,
|
|
128
128
|
schema: z.ZodTypeAny | undefined,
|
|
129
129
|
): Record<string, unknown> {
|
|
130
|
+
// Deterministic 12-hex discriminator for FORMAT-CONSTRAINED samples (a uuid-shaped subject can't take a
|
|
131
|
+
// `-<disc>` suffix — the field schema would refuse it; vary the uuid TAIL instead, format-preserving).
|
|
132
|
+
const discHex = [...disc].reduce((h, ch) => (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0, 7).toString(16).padStart(8, "0").repeat(2).slice(0, 12);
|
|
130
133
|
const suffixed: Record<string, unknown> = {};
|
|
131
134
|
let changed = false;
|
|
132
135
|
for (const [k, v] of Object.entries(payload)) {
|
|
133
136
|
if (typeof v === "string" && v.startsWith("sample-")) { suffixed[k] = `${v}-${disc}`; changed = true; }
|
|
137
|
+
else if (typeof v === "string" && v.includes(ZERO_UUID)) {
|
|
138
|
+
// The synthesized subject/uuid sentinel — uniquify the tail so sibling birth legs derive DISTINCT
|
|
139
|
+
// deterministic child names (co2 #118 follow-on: six gated births each parentSetup-birthed the SAME
|
|
140
|
+
// home — the second one found it already born, `born=[]`, and the leg died).
|
|
141
|
+
suffixed[k] = v.replace(ZERO_UUID, `00000000-0000-0000-0000-${discHex}`);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
134
144
|
else suffixed[k] = v;
|
|
135
145
|
}
|
|
136
146
|
if (!changed) return payload;
|
|
@@ -151,11 +161,28 @@ function uniquifyBirthPayload(
|
|
|
151
161
|
return suffixed;
|
|
152
162
|
}
|
|
153
163
|
|
|
164
|
+
// Constrained-string candidates probed against a field's OWN schema when the readable `sample-<field>`
|
|
165
|
+
// fails it (a `.regex()/.length()/.email()/.url()` bound — co2: a locale-pattern `defaultLocale` was
|
|
166
|
+
// handed `sample-defaultLocale`, violating its own schema and killing the whole leg downstream). Ordered
|
|
167
|
+
// common-first; the READABLE sample stays the first try so unconstrained fields are byte-stable.
|
|
168
|
+
const STRING_CANDIDATES: readonly string[] = [
|
|
169
|
+
"sample", "en", "en-GB", "AA", "A1", "a", "1", "2026-01-01", "2026-01-01T00:00:00Z",
|
|
170
|
+
"a@example.com", "https://example.com", "#000000", "00000000-0000-0000-0000-000000000000",
|
|
171
|
+
"0000000000000000000000000000000000000000000000000000000000000000", // a 64-hex digest
|
|
172
|
+
];
|
|
154
173
|
function sampleString(zt: z.ZodTypeAny, field: string): string {
|
|
155
174
|
const readable = `sample-${field}`;
|
|
156
175
|
if (zt.safeParse(readable).success) return readable;
|
|
157
176
|
for (const cand of SUBJECT_CANDIDATES) if (zt.safeParse(cand).success) return cand;
|
|
158
|
-
return
|
|
177
|
+
for (const cand of STRING_CANDIDATES) if (zt.safeParse(cand).success) return cand;
|
|
178
|
+
// NO INVALID SAMPLE (co2): returning a schema-violating string poisons every consumer downstream (the
|
|
179
|
+
// recipe probe, the proof leg, the generated .mts) with a mystery refusal — throw NAMED instead (the
|
|
180
|
+
// callers' try/catch degrades to the same honest skip an unsynthesizable shape gets).
|
|
181
|
+
throw new Error(
|
|
182
|
+
`codegen-proof: field '${field}': no sample satisfies its own string schema (a .regex()/.length() ` +
|
|
183
|
+
`bound none of the ${1 + SUBJECT_CANDIDATES.length + STRING_CANDIDATES.length} candidates fit) — ` +
|
|
184
|
+
`loosen the pattern, or prove that directive by hand in test/e2e.mts`,
|
|
185
|
+
);
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
// Candidate numbers probed against a field's own schema (in a sensible order) so a `.min()/.max()/
|
|
@@ -1408,7 +1435,17 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
|
|
|
1408
1435
|
// (2) COMPUTED-BASE: when R is computed, walk the relation-schema rewrite for the directly-grantable BASE
|
|
1409
1436
|
// relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
|
|
1410
1437
|
// granting B makes the actor satisfy R through the computed chain (what estate's seed does).
|
|
1411
|
-
|
|
1438
|
+
// A `.delegatedRole(...)` granter verifies a SIGNED delegation cert no synthesizer can produce — it can
|
|
1439
|
+
// never be seated by a proof (the cert gate refuses the sample), so it is NOT a candidate (the computed-
|
|
1440
|
+
// base/seed lanes then find the law's seatable path instead — co2 #118: acceptDelegatedCatalogueCreator
|
|
1441
|
+
// shadowed seedHomeCreator and the leg died on the cert gate).
|
|
1442
|
+
const candidates = directives.filter(
|
|
1443
|
+
(d) =>
|
|
1444
|
+
d !== gated &&
|
|
1445
|
+
typeof d?.payloadSchema === "object" &&
|
|
1446
|
+
d.birthsChild !== true &&
|
|
1447
|
+
(d as { delegatedRoleGate?: unknown }).delegatedRoleGate === undefined,
|
|
1448
|
+
);
|
|
1412
1449
|
// ALL granters of `rel`, ordered: `.grants(R)` markers first (unambiguous), then plan-probed `writeTuple(R)`.
|
|
1413
1450
|
const findGrantersFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" }[] => {
|
|
1414
1451
|
const out: { granter: Directive<unknown>; via: "marker" | "inferred" }[] = [];
|
|
@@ -1948,7 +1985,7 @@ export function discoverChildBirthLegOrReason(
|
|
|
1948
1985
|
const seedDir = (childMod.directives as Directive<unknown>[]).find((d) => d?.id === childSeedDirectiveId);
|
|
1949
1986
|
if (seedDir !== undefined) {
|
|
1950
1987
|
childSeedAggregateId = seedDir.aggregateId;
|
|
1951
|
-
childSeedInstanceId = discoverSingletonInstanceId(seedDir);
|
|
1988
|
+
childSeedInstanceId = discoverSingletonInstanceId(seedDir, childMod);
|
|
1952
1989
|
const q = ((childMod.queries ?? []) as QueryDecl[]).find((qq) => qq.returns === seedDir.aggregateId && qq.key.length > 0);
|
|
1953
1990
|
if (q !== undefined) childQuery = { id: q.id, key: q.key };
|
|
1954
1991
|
}
|
|
@@ -2038,7 +2075,19 @@ function attachParentSetup(leg: ChildBirthLeg, allBirths: readonly ChildBirthLeg
|
|
|
2038
2075
|
/** Discover the SINGLETON instance id a seed directive writes (a fixed `<Agg>:self`-shaped id), by running
|
|
2039
2076
|
* its plan with a probe payload + birth-probe globals and reading the first written op's instance id when
|
|
2040
2077
|
* it is a constant (the same id across two distinct probe payloads). `undefined` ⇒ not a fixed singleton. */
|
|
2041
|
-
function discoverSingletonInstanceId(seedDir: Directive<unknown
|
|
2078
|
+
function discoverSingletonInstanceId(seedDir: Directive<unknown>, ownerMod?: DomainModule): string | undefined {
|
|
2079
|
+
// RELATION-NAMESPACING PARITY (co2 #118 follow-on): the ENGINE qualifies a `writeTuple` object token /
|
|
2080
|
+
// bare `<type>:<id>` by the dispatching domain (`<domain>__<type>`) for the domain's local non-global
|
|
2081
|
+
// relationTypes — so the FOLDED tuple's instance id is the QUALIFIED one. Probe under the same dispatch
|
|
2082
|
+
// context (the same classification the engine builds), else the discovered singleton id is the bare
|
|
2083
|
+
// variant and the runner's read-back misses the row the genesis actually seeded.
|
|
2084
|
+
const qualifiable = new Set<string>();
|
|
2085
|
+
for (const rt of (ownerMod?.relationTypes ?? []) as unknown as Array<{ type: string; global?: boolean; relations: Map<string, unknown> | Record<string, unknown> }>) {
|
|
2086
|
+
if (rt.global === true) continue;
|
|
2087
|
+
const rels = rt.relations instanceof Map ? [...rt.relations.keys()] : Object.keys(rt.relations ?? {});
|
|
2088
|
+
for (const rel of rels) qualifiable.add(`${rt.type}#${rel}`);
|
|
2089
|
+
}
|
|
2090
|
+
const domain = ownerMod !== undefined ? (ownerMod.domain ?? ownerMod.name) : undefined;
|
|
2042
2091
|
const idsFor = (seed: number): string | undefined => {
|
|
2043
2092
|
let synth: SynthesizedPayload;
|
|
2044
2093
|
try {
|
|
@@ -2056,6 +2105,9 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
|
|
|
2056
2105
|
__resetAuthoring();
|
|
2057
2106
|
__resetReads();
|
|
2058
2107
|
__resetBirths();
|
|
2108
|
+
// Probe under the OWNER DOMAIN's dispatch context so a relation-tuple singleton id qualifies exactly
|
|
2109
|
+
// as the engine folds it (`<domain>__<type>:…`) — see the qualifiable set built above.
|
|
2110
|
+
if (domain !== undefined) __setDispatchContext({ domain, qualifiable });
|
|
2059
2111
|
try {
|
|
2060
2112
|
const returned = seedDir.plan(payload as never, deterministicPorts({ physical: seed, replica: 1 }));
|
|
2061
2113
|
const ops = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
|
|
@@ -2064,6 +2116,7 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
|
|
|
2064
2116
|
} catch {
|
|
2065
2117
|
id = undefined;
|
|
2066
2118
|
} finally {
|
|
2119
|
+
if (domain !== undefined) __setDispatchContext(undefined);
|
|
2067
2120
|
try { __drainReads(); __drainBirths(); } catch { /* drained */ }
|
|
2068
2121
|
}
|
|
2069
2122
|
});
|
|
@@ -2320,10 +2373,13 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2320
2373
|
const uniq = uniquifyBirthPayload(withParent.birthPayload, b.birthDirectiveId, schema);
|
|
2321
2374
|
if (uniq !== withParent.birthPayload) withParent = { ...withParent, birthPayload: uniq };
|
|
2322
2375
|
}
|
|
2323
|
-
// A GATED birth
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2376
|
+
// A GATED birth: seat its relation via the SAME granter/seed discovery the relation-bootstrap leg uses
|
|
2377
|
+
// (the law's own authority path, e.g. co2's seedPlatformFounder → seedPlatformAdmin →
|
|
2378
|
+
// grantCo2EstateCreator → birthEstateWorkspace). ALSO when a parentSetup exists (co2 #118): a parent
|
|
2379
|
+
// birth's genesis does NOT necessarily seat the gated relation (co2's homeCreator is claimed POST-birth
|
|
2380
|
+
// by the owner via seedHomeCreator), so the runner seats the discovered path INSIDE the born parent —
|
|
2381
|
+
// as the birth actor — before the gated birth.
|
|
2382
|
+
if (withParent.birthRequiresRelation !== undefined) {
|
|
2327
2383
|
const birthDir = (mod.directives as Directive<unknown>[]).find((d) => d?.id === b.birthDirectiveId);
|
|
2328
2384
|
const rb = birthDir !== undefined ? discoverRelationBootstrapLeg(mod, birthDir) : undefined;
|
|
2329
2385
|
// a born-context path proves INSIDE a born workspace — not a seatable pre-grant; leave it to the gap diagnostic.
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import { execFileSync } from "node:child_process";
|
|
49
49
|
import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
|
|
50
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
51
51
|
import { createRequire } from "node:module";
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
generateDartActivityRegistry,
|
|
60
60
|
collectActivityDescriptors,
|
|
61
61
|
collectPayloadIdOfTargets,
|
|
62
|
+
typedIdClassForAggregateType,
|
|
62
63
|
type DartImport,
|
|
63
64
|
type DartRefImport,
|
|
64
65
|
type DomainModule,
|
|
@@ -503,6 +504,14 @@ function collectPackageContracts(modules: readonly DomainModule[]): ContractHand
|
|
|
503
504
|
return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
/** Every aggregate's generated typed-id class name across the package — the collision guard for the
|
|
508
|
+
* contract typed-id emission (a contract named after a local aggregate must not re-declare `<X>Id`). */
|
|
509
|
+
function packageAggregateIdClassNames(modules: readonly DomainModule[]): Set<string> {
|
|
510
|
+
const out = new Set<string>();
|
|
511
|
+
for (const m of modules) for (const a of m.aggregates) out.add(typedIdClassForAggregateType(a.id));
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
506
515
|
/**
|
|
507
516
|
* CROSS-DOMAIN REF TRAVERSAL map (dart_codegen_parity.md §2a follow-on): every aggregate id in the package
|
|
508
517
|
* → its owning domain (`domain` for the generated client class name; `name` for the sibling
|
|
@@ -578,7 +587,10 @@ interface DartSharedSymbols {
|
|
|
578
587
|
/** The synthetic module whose emit IS `nomos_framework.dart` — undefined when nothing is shared. */
|
|
579
588
|
readonly frameworkMod: DomainModule | undefined;
|
|
580
589
|
}
|
|
581
|
-
function collectDartSharedSymbols(
|
|
590
|
+
function collectDartSharedSymbols(
|
|
591
|
+
modules: readonly DomainModule[],
|
|
592
|
+
packageContracts: readonly ContractHandle[] = [],
|
|
593
|
+
): DartSharedSymbols {
|
|
582
594
|
type ModDirective = DomainModule["directives"][number];
|
|
583
595
|
type ModQuery = NonNullable<DomainModule["queries"]>[number];
|
|
584
596
|
const frameworkAggById = new Map<string, AggregateHandle>();
|
|
@@ -600,9 +612,28 @@ function collectDartSharedSymbols(modules: readonly DomainModule[]): DartSharedS
|
|
|
600
612
|
voDescriptorByName.set(d.name, d);
|
|
601
613
|
}
|
|
602
614
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
)
|
|
615
|
+
// CONTRACT-USED VOs ARE LAYER-ZERO (co2 #117): a VO referenced by a contract FIELD (e.g. a
|
|
616
|
+
// `ReferenceCode.field()` inside `contract(...)`) is rendered inside `contracts.dart` — the shared layer
|
|
617
|
+
// every domain imports and which may import NO domain file (the acyclic rule). So its class must live in
|
|
618
|
+
// `nomos_framework.dart` REGARDLESS of how many domains use it (a single-domain VO would otherwise land in
|
|
619
|
+
// that domain's file, unreachable from contracts.dart). Transitive deps included (a nested VO renders too).
|
|
620
|
+
const contractVoNames = new Set<string>();
|
|
621
|
+
for (const c of packageContracts) {
|
|
622
|
+
for (const f of Object.values(c.fields)) {
|
|
623
|
+
const meta = (f as { valueObject?: { descriptor: { name: string }; dependencies: readonly { name: string }[] } }).valueObject;
|
|
624
|
+
if (meta === undefined) continue;
|
|
625
|
+
contractVoNames.add(meta.descriptor.name);
|
|
626
|
+
voDescriptorByName.set(meta.descriptor.name, meta.descriptor as NonNullable<DomainModule["valueObjects"]>[number]);
|
|
627
|
+
for (const dep of meta.dependencies) {
|
|
628
|
+
contractVoNames.add(dep.name);
|
|
629
|
+
voDescriptorByName.set(dep.name, dep as NonNullable<DomainModule["valueObjects"]>[number]);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const sharedVoNames: ReadonlySet<string> = new Set([
|
|
634
|
+
...[...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
|
|
635
|
+
...contractVoNames,
|
|
636
|
+
]);
|
|
606
637
|
const sharedVos = [...sharedVoNames].sort().map((name) => voDescriptorByName.get(name)!);
|
|
607
638
|
const hasFrameworkSymbols =
|
|
608
639
|
frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
|
|
@@ -814,7 +845,8 @@ function emitDart(
|
|
|
814
845
|
// • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
|
|
815
846
|
// Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
|
|
816
847
|
// same-library re-export). So a barrel over all domains needs NO `hide` clause.
|
|
817
|
-
const
|
|
848
|
+
const packageContracts = collectPackageContracts(modules);
|
|
849
|
+
const shared = collectDartSharedSymbols(modules, packageContracts);
|
|
818
850
|
const { frameworkAggIds, frameworkDirIds, frameworkQueryIds, sharedVoNames, hasFrameworkSymbols } = shared;
|
|
819
851
|
const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
|
|
820
852
|
|
|
@@ -845,10 +877,12 @@ function emitDart(
|
|
|
845
877
|
}
|
|
846
878
|
// 2a-bis. the shared DOMAIN CONTRACTS file (`contracts.dart`) — every public DTO class, ONCE. Pure
|
|
847
879
|
// (nomos_types + VOs). A foreignRef targeting a contract imports THIS, never the concrete domain.
|
|
848
|
-
const packageContracts = collectPackageContracts(modules);
|
|
849
880
|
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
850
881
|
if (packageContracts.length > 0) {
|
|
851
|
-
writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts
|
|
882
|
+
writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts, {
|
|
883
|
+
...(frameworkImportUri !== undefined ? { frameworkImportUri } : {}),
|
|
884
|
+
aggregateIdClassNames: packageAggregateIdClassNames(modules),
|
|
885
|
+
}));
|
|
852
886
|
}
|
|
853
887
|
// 2a-ter. the shared ACTIVITY REGISTRY (`activity_registry.dart`) — the pure `renderActivity`, ONCE. Emitted
|
|
854
888
|
// iff a directive opts in via `.activity(...)` (activity-free packages stay byte-identical). Pure
|
|
@@ -1120,7 +1154,8 @@ function emitDartPackages(
|
|
|
1120
1154
|
];
|
|
1121
1155
|
if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
|
|
1122
1156
|
|
|
1123
|
-
const
|
|
1157
|
+
const packageContracts = collectPackageContracts(modules);
|
|
1158
|
+
const shared = collectDartSharedSymbols(modules, packageContracts);
|
|
1124
1159
|
const written: string[] = [];
|
|
1125
1160
|
const genRel = DART_GENERATED_SUBDIR.split(path.sep).join("/");
|
|
1126
1161
|
|
|
@@ -1175,9 +1210,13 @@ function emitDartPackages(
|
|
|
1175
1210
|
if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
|
|
1176
1211
|
// DOMAIN CONTRACTS (layered): the shared `contracts.dart` DTO file in the TYPES package (pure —
|
|
1177
1212
|
// nomos_types + VOs). A contract-target foreignRef imports THIS sibling, never the concrete domain.
|
|
1178
|
-
const packageContracts = collectPackageContracts(modules);
|
|
1179
1213
|
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
1180
|
-
if (packageContracts.length > 0)
|
|
1214
|
+
if (packageContracts.length > 0) {
|
|
1215
|
+
writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts, {
|
|
1216
|
+
...(shared.frameworkMod !== undefined ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
|
|
1217
|
+
aggregateIdClassNames: packageAggregateIdClassNames(modules),
|
|
1218
|
+
}));
|
|
1219
|
+
}
|
|
1181
1220
|
// ACTIVITY REGISTRY (layered): the pure `renderActivity` in the TYPES package (nomos_types only). The
|
|
1182
1221
|
// client resolves it through the types-package barrel (which re-exports every types file). Opt-in only.
|
|
1183
1222
|
const packageActivity = collectActivityDescriptors(modules);
|
|
@@ -1476,6 +1515,26 @@ async function main(): Promise<void> {
|
|
|
1476
1515
|
if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
|
|
1477
1516
|
const cfgDir = path.dirname(configPath);
|
|
1478
1517
|
|
|
1518
|
+
// DUAL-INSTANCE GUARD (DX): the law's `import "@githolon/dsl"` resolves from the LAW's own node_modules,
|
|
1519
|
+
// while this compiler runs from wherever it was invoked. Two DIFFERENT dsl instances silently split every
|
|
1520
|
+
// module-level registry (the fluent-authoring drain, brands, markers) — plans probe empty, lints
|
|
1521
|
+
// false-fire, ids drift. Detect the skew and WARN LOUDLY with the two paths (never fail — a
|
|
1522
|
+
// deliberately-vendored setup may be intentional, and same-version split instances still work for
|
|
1523
|
+
// compilation itself, which imports the law through the compiler's own module graph).
|
|
1524
|
+
try {
|
|
1525
|
+
const lawReq = createRequire(pathToFileURL(path.join(cfgDir, "noop.js")));
|
|
1526
|
+
const lawDslPkg = lawReq.resolve("@githolon/dsl/package.json");
|
|
1527
|
+
const ownDslPkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1528
|
+
if (path.resolve(lawDslPkg) !== path.resolve(ownDslPkg)) {
|
|
1529
|
+
const vOf = (p: string): string => { try { return (JSON.parse(readFileSync(p, "utf8")) as { version?: string }).version ?? "?"; } catch { return "?"; } };
|
|
1530
|
+
console.error(
|
|
1531
|
+
`⚠ nomos-compile: TWO @githolon/dsl instances — the law resolves ${vOf(lawDslPkg)} at ${path.dirname(lawDslPkg)}, but this compiler runs ${vOf(path.resolve(ownDslPkg))} at ${path.dirname(path.resolve(ownDslPkg))}. ` +
|
|
1532
|
+
`Split instances split the DSL's module registries (phantom lint errors, empty plan probes, id drift) — even at the same version string when the code differs. ` +
|
|
1533
|
+
`Run the law package's OWN \`npx githolon compile\`, or align the two installs.`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
} catch { /* no dsl resolvable from the law dir (vendored/workspace setups) — nothing to compare */ }
|
|
1537
|
+
|
|
1479
1538
|
const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
|
|
1480
1539
|
const cfg = cfgModule.default;
|
|
1481
1540
|
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
@@ -2683,20 +2742,63 @@ async function main(): Promise<void> {
|
|
|
2683
2742
|
// false red) when `dart` is absent or the package's deps aren't resolved yet (needs `pub get`). Opt out
|
|
2684
2743
|
// with NOMOS_SKIP_DART_ANALYZE=1.
|
|
2685
2744
|
if (dartOut !== undefined && process.env.NOMOS_SKIP_DART_ANALYZE !== "1") {
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2745
|
+
// The emitted PACKAGE dirs (each has a pubspec.yaml): the layered emit produces several under `dartOut`;
|
|
2746
|
+
// a flat emit puts one at `dartOut`. Analyze each — but RESOLVE its deps first (`dart pub get`), else
|
|
2747
|
+
// the analyzer reports unresolved `package:` imports as errors and the gate can't tell a codegen bug
|
|
2748
|
+
// from an un-got package (the co2 "analyze skipped → false success" trap).
|
|
2749
|
+
const dOut: string = dartOut;
|
|
2750
|
+
// Analyze exactly THE FILES THIS COMPILE EMITTED (`dartFiles`) — never the whole dir, which would
|
|
2751
|
+
// sweep in the transactional `.nomos-bak` backup and any stale legacy-layout leftovers and judge the
|
|
2752
|
+
// compile on files it did not write. First `dart pub get` each emitted PACKAGE (a dir up-chain with a
|
|
2753
|
+
// pubspec) so `package:` imports resolve; a self-contained vendored emit has no pubspec (no get needed).
|
|
2754
|
+
const emittedDart = dartFiles.filter((f) => f.endsWith(".dart"));
|
|
2755
|
+
const pkgDirOf = (file: string): string | undefined => {
|
|
2756
|
+
let dir = path.dirname(file);
|
|
2757
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
2758
|
+
if (existsSync(path.join(dir, "pubspec.yaml"))) return dir;
|
|
2759
|
+
if (path.resolve(dir) === path.resolve(dOut) || dir === path.dirname(dir)) return undefined;
|
|
2760
|
+
dir = path.dirname(dir);
|
|
2699
2761
|
}
|
|
2762
|
+
return undefined;
|
|
2763
|
+
};
|
|
2764
|
+
// Only files under a PUB PACKAGE (a pubspec up-chain) are verifiable — a VENDORED no-pubspec emit's
|
|
2765
|
+
// runtime `package:nomos_client` imports cannot resolve without a package config, so judging those
|
|
2766
|
+
// files would false-fail every vendored fixture. Analyze the in-package set; note the loose remainder.
|
|
2767
|
+
const inPackage = emittedDart.filter((f) => pkgDirOf(f) !== undefined);
|
|
2768
|
+
const pkgDirs = [...new Set(inPackage.map(pkgDirOf).filter((d): d is string => d !== undefined))];
|
|
2769
|
+
let dartMissing = false;
|
|
2770
|
+
let unresolved: string | undefined;
|
|
2771
|
+
for (const pkg of pkgDirs) {
|
|
2772
|
+
try {
|
|
2773
|
+
execFileSync("dart", ["pub", "get"], { cwd: pkg, stdio: "pipe", encoding: "utf8" });
|
|
2774
|
+
} catch (e) {
|
|
2775
|
+
const err = e as { code?: string; stdout?: string; stderr?: string };
|
|
2776
|
+
if (err.code === "ENOENT") { dartMissing = true; break; }
|
|
2777
|
+
unresolved = `${path.relative(process.cwd(), pkg)}: ${`${err.stdout ?? ""}${err.stderr ?? ""}`.trim().split("\n").slice(-3).join(" ")}`;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
if (!dartMissing && inPackage.length > 0) {
|
|
2781
|
+
try {
|
|
2782
|
+
execFileSync("dart", ["analyze", "--no-fatal-warnings", ...inPackage], { stdio: "pipe", encoding: "utf8" });
|
|
2783
|
+
} catch (e) {
|
|
2784
|
+
const err = e as { code?: string; stdout?: string; stderr?: string };
|
|
2785
|
+
if (err.code === "ENOENT") { dartMissing = true; }
|
|
2786
|
+
else {
|
|
2787
|
+
const out = `${err.stdout ?? ""}${err.stderr ?? ""}`.trim();
|
|
2788
|
+
fail(`the generated Dart does NOT compile — \`dart analyze\` found errors (compile-valid must mean compilable). Fix the law/codegen or set NOMOS_SKIP_DART_ANALYZE=1 to emit anyway:\n${out}`);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
if (dartMissing) {
|
|
2793
|
+
// No `dart` on PATH — we genuinely cannot verify. Say so LOUDLY (never a `✓`), so a broken client is
|
|
2794
|
+
// NOT reported as a clean compile; installing dart turns this into a real gate.
|
|
2795
|
+
console.log(` dart ⚠ UNVERIFIED — \`dart\` is not on PATH, so the generated Dart could NOT be analyzed (install dart to gate it)`);
|
|
2796
|
+
} else if (unresolved !== undefined) {
|
|
2797
|
+
console.log(` dart ⚠ UNVERIFIED — \`dart pub get\` failed, so analysis is incomplete (${unresolved})`);
|
|
2798
|
+
} else if (inPackage.length > 0) {
|
|
2799
|
+
console.log(` dart ✓ generated Dart analyzes clean (compile-valid ⇒ compilable)`);
|
|
2800
|
+
} else if (emittedDart.length > 0) {
|
|
2801
|
+
console.log(` dart — vendored (no pub package) emit — runtime package imports are unverifiable without a pubspec; analysis skipped (use dart.packages for a fully-gated pub package)`);
|
|
2700
2802
|
}
|
|
2701
2803
|
}
|
|
2702
2804
|
}
|
package/src/fields.ts
CHANGED
|
@@ -170,7 +170,7 @@ export type ValueObjectFieldDescriptor =
|
|
|
170
170
|
/** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
|
|
171
171
|
* — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
|
|
172
172
|
* to a plain string; the brand is a compile-time nominal tag only. */
|
|
173
|
-
| { readonly type: "string"; readonly brand?: string; readonly optional?: true }
|
|
173
|
+
| { readonly type: "string"; readonly brand?: string; readonly idOf?: string; readonly optional?: true }
|
|
174
174
|
/** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
|
|
175
175
|
* was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
|
|
176
176
|
| { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
|
package/src/value_object.ts
CHANGED
|
@@ -362,16 +362,16 @@ function describeZod(
|
|
|
362
362
|
if (ownBrand !== undefined) return takeVoField(ownBrand as ValueObjectFieldMeta, deps);
|
|
363
363
|
switch (zodKind(schema)) {
|
|
364
364
|
case "string": {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
// idOf
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
return
|
|
365
|
+
const brandedBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND];
|
|
366
|
+
const idOfAgg = (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
|
|
367
|
+
// idOf(Agg) REFERENCES the aggregate's canonical typed-id class `<Agg>Id` (emitted ONCE by the
|
|
368
|
+
// aggregate) — it must NOT re-declare a standalone extension type (that duplicated the id symbol and
|
|
369
|
+
// stripped its `.fromRow`, co2). Carry `idOf` so the emitter references + imports it and skips the
|
|
370
|
+
// brand-extension-type emission. A standalone `brandedId("X")` still declares its own extension type.
|
|
371
|
+
if (typeof idOfAgg === "string") {
|
|
372
|
+
return { type: "string", brand: `${idOfAgg}Id`, idOf: idOfAgg };
|
|
373
|
+
}
|
|
374
|
+
return typeof brandedBrand === "string" ? { type: "string", brand: brandedBrand } : { type: "string" };
|
|
375
375
|
}
|
|
376
376
|
case "record": {
|
|
377
377
|
if (zodRecordKeyKind(schema) !== "string") {
|