@githolon/dsl 0.87.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.87.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",
@@ -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(contracts: readonly import("./contract.js").ContractHandle[]): string {
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
- else if (f.type === "string" && f.brand !== undefined) brandNames.add(f.brand);
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
  };
@@ -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 readable;
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
- const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
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>): string | undefined {
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 with no parent-birth authority path: seat its relation via the SAME granter/seed
2324
- // discovery the relation-bootstrap leg uses (the law's own authority path, e.g. co2's
2325
- // seedPlatformFounder → seedPlatformAdmin → grantCo2EstateCreator → birthEstateWorkspace).
2326
- if (withParent.birthRequiresRelation !== undefined && withParent.parentSetup === undefined) {
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(modules: readonly DomainModule[]): DartSharedSymbols {
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
- const sharedVoNames: ReadonlySet<string> = new Set(
604
- [...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
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 shared = collectDartSharedSymbols(modules);
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
@@ -1016,7 +1050,7 @@ const NOMOS_TYPES_DEP = "^0.4.1";
1016
1050
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1017
1051
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1018
1052
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1019
- const NOMOS_CLIENT_DEP = "^0.55.0";
1053
+ const NOMOS_CLIENT_DEP = "^0.56.0";
1020
1054
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1021
1055
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1022
1056
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -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 shared = collectDartSharedSymbols(modules);
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) writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts));
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) {
@@ -2676,6 +2735,72 @@ async function main(): Promise<void> {
2676
2735
  typeof dartCfg === "object" && dartCfg.barrel !== undefined ? dartCfg.barrel : defaultBarrel;
2677
2736
  dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport, dartBarrel, nomosCompat, dartNames);
2678
2737
  }
2738
+ // COMPILE-VALID MUST MEAN COMPILABLE (co2): `githolon compile` reported success while the generated Dart
2739
+ // had hard compiler errors (a duplicate id symbol, missing contract-id defs). Run `dart analyze` over the
2740
+ // emitted Dart as a SYMBOL-RESOLUTION GATE — a real analysis ERROR fails the compile (with the analyzer's
2741
+ // own output), so a broken package never passes `mountable ✓`. Best-effort: skipped (with a note, never a
2742
+ // false red) when `dart` is absent or the package's deps aren't resolved yet (needs `pub get`). Opt out
2743
+ // with NOMOS_SKIP_DART_ANALYZE=1.
2744
+ if (dartOut !== undefined && process.env.NOMOS_SKIP_DART_ANALYZE !== "1") {
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);
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)`);
2802
+ }
2803
+ }
2679
2804
  }
2680
2805
 
2681
2806
  // ── summary ──
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 }
@@ -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 brand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND]
366
- ?? (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
367
- // idOf's brand is the aggregate type name; a payload idOf surfaces as `<Agg>Id`.
368
- const brandName =
369
- typeof (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND] === "string"
370
- ? (brand as string)
371
- : typeof brand === "string"
372
- ? `${brand}Id`
373
- : undefined;
374
- return brandName !== undefined ? { type: "string", brand: brandName } : { type: "string" };
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") {