@githolon/dsl 0.58.1 → 0.59.1

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.
@@ -201,6 +201,35 @@ interface PackageConfig {
201
201
  * config `name`.
202
202
  */
203
203
  readonly barrel?: string;
204
+ /**
205
+ * LAYERED PACKAGES (blast-radius isolation for Flutter frontends). When set, the emit
206
+ * produces THREE sibling pub packages under `out` instead of one flat tree:
207
+ * • `types` — pure data (payload classes, read models, VOs/enums, typed accessors over
208
+ * the injected NomosReads seams, the aggregate-meta registry). Depends ONLY on the
209
+ * pure `nomos_types` core — frontend-safe: no bridge, no sessions, no lifecycle.
210
+ * • `client` — the effectful runtime clients (`<name>.client.dart`, offer methods over
211
+ * NomosBridge). Depends on the types package + `nomos_client`.
212
+ * • `test` — optional; the headless-harness surface for widget/integration tests.
213
+ * The layering line: UI code depends on `types`; composition/runtime code on `client`;
214
+ * generated TYPES may flow to the UI, generated CLIENTS may not.
215
+ * Each package's `pubspec.yaml` is scaffolded ONLY IF MISSING (tenant-owned versioning);
216
+ * barrels + `lib/src/generated/` are generated-owned and rewritten every compile.
217
+ * Requires `support: "package"` (vendored mode contradicts pub-package layering).
218
+ */
219
+ readonly packages?: {
220
+ readonly types: string;
221
+ readonly client: string;
222
+ readonly test?: string;
223
+ };
224
+ /**
225
+ * PUBLIC ALIASES for generated type names (`{ "EstateEstateCustomFieldDefinitionFieldType":
226
+ * "FieldType", … }`). Each entry emits `typedef <alias> = <generated>;` in the TYPES package
227
+ * barrel, so apps import humane names while the generated identity stays stable. Validated
228
+ * fail-closed: the generated name must exist in the emitted types surface, and an alias may
229
+ * not collide with any generated public name or another alias. (Works in the single-package
230
+ * emit too — aliases land in the one package barrel.)
231
+ */
232
+ readonly names?: Readonly<Record<string, string>>;
204
233
  };
205
234
  /**
206
235
  * OPT-IN layered USD emission (also `--layered` on the CLI). When true, ALSO emit
@@ -373,40 +402,148 @@ const DART_RELATIVE_IMPORT = `import 'nomos_client.dart';`;
373
402
  * vendored: nothing the codegen emits references it, and it would drag the external
374
403
  * `json_schema` pub dependency into every vendoring app.
375
404
  */
376
- const DART_SUPPORT_SRC = [
405
+ // The PURE CORE support files, vendored from the `nomos_types` package (dsl/dart_types/lib/src —
406
+ // the layered split: wire types, offer codec, NomosReads, aggregate meta, error info). Zero
407
+ // external pub deps. `schema_validation.dart` is deliberately NOT vendored: nothing the codegen
408
+ // emits references it, and it would drag the external `json_schema` pub dependency into every
409
+ // vendoring app.
410
+ const DART_SUPPORT_CORE_SRC = [
377
411
  "wire.dart",
378
412
  "driver.dart",
379
413
  "offer.dart",
380
414
  "subscriptions.dart",
381
- "provider.dart",
382
415
  // The tri-state slot (NomosSlot<T>) for `nullable`/`clearable` fields (null_semantics.md) — the
383
416
  // barrel re-exports it and generated read-model/payload classes reference it, so it must vendor.
384
- // Zero external pub deps (pure Dart).
385
417
  "null_slot.dart",
386
418
  // Runtime aggregate-meta support types (NomosAggregateMeta/NomosFieldMeta) — the generated
387
419
  // per-domain registry consts reference them through the barrel.
388
420
  "aggregate_meta.dart",
389
- // The async WebView bridge runtime (NomosBridge / BridgeTransport) the generated
390
- // `<name>.client.dart` drives — the barrel re-exports it, so it must be vendored too.
391
- "webview_bridge.dart",
392
- // Typed diagnostics (nomosErrorFromInfo / MissingOfferAuthority / …) — the vendored
393
- // bridge AND the generated `<name>.client.dart` reference these through the barrel,
394
- // so skipping it left `dart analyze` red on every vendored output (0.34.1). No
395
- // external pub deps.
421
+ // The data-only error surface (NomosErrorInfo/NomosBridgeError) + the typed diagnostics
422
+ // (nomosErrorFromInfo / MissingOfferAuthority / …) — the vendored bridge AND the generated
423
+ // `<name>.client.dart` reference these through the barrel. No external pub deps.
424
+ "error_info.dart",
396
425
  "errors.dart",
397
426
  ] as const;
398
-
399
- /** Barrel exports DROPPED in vendored mode: `schema_validation.dart` (drags the external
400
- * `json_schema` pub dep) and `home.dart` (the home-onboarding runtime — drags
401
- * `package:crypto`; nothing the codegen emits references it). Package-import mode
402
- * (a real `nomos_client` pub dep) keeps the full barrel. */
403
- const VENDORED_BARREL_EXCLUDES = ["src/schema_validation.dart", "src/home.dart"] as const;
427
+ // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
428
+ // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
429
+ // `package:nomos_types/...` imports are rewritten to the local vendored shim (see emitDart).
430
+ // (`home.dart` is NOT vendored — the home-onboarding runtime drags `package:crypto`; nothing the
431
+ // codegen emits references it.)
432
+ const DART_SUPPORT_RUNTIME_SRC = ["provider.dart", "webview_bridge.dart"] as const;
433
+ const DART_SUPPORT_SRC = [...DART_SUPPORT_CORE_SRC, ...DART_SUPPORT_RUNTIME_SRC] as const;
434
+ /** The local shim replacing `package:nomos_types/nomos_types.dart` inside vendored runtime files. */
435
+ const VENDORED_TYPES_SHIM = "nomos_types.dart";
404
436
 
405
437
  /** The generated-owned subdirectory, relative to `dartOut` (the pub package's `lib/`). WIPED and
406
438
  * rewritten on every compile — nothing hand-written may live in it. The stable facade (the package
407
439
  * barrel) stays at `dartOut/<barrel>.dart`, so a prune/regenerate can never orphan app imports. */
408
440
  const DART_GENERATED_SUBDIR = path.join("src", "generated");
409
441
 
442
+ /** The shared basename of the framework-dedup file emitted once per package. */
443
+ const FRAMEWORK_FILE = "nomos_framework";
444
+
445
+ /** FRAMEWORK-SHARED symbol dedup, shared by the flat emit and the layered-packages emit (Dart-codegen
446
+ * hygiene): framework-owned symbols fold into MANY domains, so their generated Dart would be DEFINED
447
+ * once per domain — a package barrel re-exporting ≥2 domains then COLLIDES. THREE kinds are deduped,
448
+ * each identified by the framework BRAND (object identity, so a tenant symbol that merely shares a
449
+ * name is never misclassified) and emitted EXACTLY ONCE into `nomos_framework.dart`:
450
+ * • AGGREGATES — read-model class, typed-id class, by-id accessors;
451
+ * • DIRECTIVES — the generated payload class (the domain still OFFERS them per-domain);
452
+ * • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
453
+ * Plus VALUE OBJECTS used by ≥2 domains (same collision rule). Per-domain files SKIP each and
454
+ * IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the same-library re-export). */
455
+ interface DartSharedSymbols {
456
+ readonly frameworkAggIds: ReadonlySet<string>;
457
+ readonly frameworkDirIds: ReadonlySet<string>;
458
+ readonly frameworkQueryIds: ReadonlySet<string>;
459
+ readonly sharedVoNames: ReadonlySet<string>;
460
+ readonly hasFrameworkSymbols: boolean;
461
+ /** The synthetic module whose emit IS `nomos_framework.dart` — undefined when nothing is shared. */
462
+ readonly frameworkMod: DomainModule | undefined;
463
+ }
464
+ function collectDartSharedSymbols(modules: readonly DomainModule[]): DartSharedSymbols {
465
+ type ModDirective = DomainModule["directives"][number];
466
+ type ModQuery = NonNullable<DomainModule["queries"]>[number];
467
+ const frameworkAggById = new Map<string, AggregateHandle>();
468
+ const frameworkDirById = new Map<string, ModDirective>();
469
+ const frameworkQueryById = new Map<string, ModQuery>();
470
+ for (const mod of modules) {
471
+ for (const a of mod.aggregates) if (isFrameworkAggregate(a)) frameworkAggById.set(a.id, a);
472
+ for (const d of mod.directives) if (isFrameworkDirective(d)) frameworkDirById.set(d.id, d);
473
+ for (const q of mod.queries ?? []) if (isFrameworkQuery(q)) frameworkQueryById.set(q.id, q);
474
+ }
475
+ const frameworkAggs = [...frameworkAggById.values()];
476
+ const frameworkDirs = [...frameworkDirById.values()];
477
+ const frameworkQueries = [...frameworkQueryById.values()];
478
+ const voDomainsByName = new Map<string, number>();
479
+ const voDescriptorByName = new Map<string, NonNullable<DomainModule["valueObjects"]>[number]>();
480
+ for (const mod of modules) {
481
+ for (const d of mod.valueObjects ?? []) {
482
+ voDomainsByName.set(d.name, (voDomainsByName.get(d.name) ?? 0) + 1);
483
+ voDescriptorByName.set(d.name, d);
484
+ }
485
+ }
486
+ const sharedVoNames: ReadonlySet<string> = new Set(
487
+ [...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
488
+ );
489
+ const sharedVos = [...sharedVoNames].sort().map((name) => voDescriptorByName.get(name)!);
490
+ const hasFrameworkSymbols =
491
+ frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
492
+ const frameworkMod: DomainModule | undefined = hasFrameworkSymbols
493
+ ? {
494
+ name: FRAMEWORK_FILE,
495
+ domain: FRAMEWORK_FILE,
496
+ aggregates: frameworkAggs,
497
+ directives: frameworkDirs,
498
+ readModelAggregates: frameworkAggs.map((a) => a.id),
499
+ ...(frameworkQueries.length > 0 ? { queries: frameworkQueries } : {}),
500
+ // Package-SHARED value objects (used by ≥2 domains) — their class emits here, once.
501
+ ...(sharedVos.length > 0 ? { valueObjects: sharedVos } : {}),
502
+ }
503
+ : undefined;
504
+ return {
505
+ frameworkAggIds: new Set(frameworkAggById.keys()),
506
+ frameworkDirIds: new Set(frameworkDirById.keys()),
507
+ frameworkQueryIds: new Set(frameworkQueryById.keys()),
508
+ sharedVoNames,
509
+ hasFrameworkSymbols,
510
+ frameworkMod,
511
+ };
512
+ }
513
+
514
+ /** Per-domain skip/import options against the shared-symbol dedup — the SAME derivation the flat
515
+ * emit and the layered-packages emit both apply before generateDartDomain / the bridge client. */
516
+ function domainDedupOpts(mod: DomainModule, shared: DartSharedSymbols): {
517
+ readonly modForDart: DomainModule;
518
+ readonly skips: {
519
+ readonly skipAggregateIds: Set<string>;
520
+ readonly skipDirectiveIds: Set<string>;
521
+ readonly skipQueryIds: Set<string>;
522
+ readonly skipValueObjectNames: Set<string>;
523
+ };
524
+ readonly foldsFramework: boolean;
525
+ } {
526
+ const modForDart: DomainModule =
527
+ mod.readModelAggregates === undefined
528
+ ? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
529
+ : mod;
530
+ const skipAggregateIds = new Set(
531
+ modForDart.aggregates.filter((a) => shared.frameworkAggIds.has(a.id)).map((a) => a.id),
532
+ );
533
+ const skipDirectiveIds = new Set(
534
+ modForDart.directives.filter((d) => shared.frameworkDirIds.has(d.id)).map((d) => d.id),
535
+ );
536
+ const skipQueryIds = new Set(
537
+ (modForDart.queries ?? []).filter((q) => shared.frameworkQueryIds.has(q.id)).map((q) => q.id),
538
+ );
539
+ const skipValueObjectNames = new Set(
540
+ (modForDart.valueObjects ?? []).filter((d) => shared.sharedVoNames.has(d.name)).map((d) => d.name),
541
+ );
542
+ const foldsFramework =
543
+ skipAggregateIds.size > 0 || skipDirectiveIds.size > 0 || skipQueryIds.size > 0 || skipValueObjectNames.size > 0;
544
+ return { modForDart, skips: { skipAggregateIds, skipDirectiveIds, skipQueryIds, skipValueObjectNames }, foldsFramework };
545
+ }
546
+
410
547
  /** Remove the PRE-barrel flat layout's generated files from `dartOut` root (they now live under
411
548
  * `src/generated/`): the vendored support set + the per-domain files. Targeted by exact known
412
549
  * name — `dartOut` is typically the tenant package's `lib/`, so never sweep directories. */
@@ -438,7 +575,9 @@ function emitDart(
438
575
  supportImport: DartSupportImport = "vendored",
439
576
  barrelName = "nomos_domains",
440
577
  compat?: NomosCompat,
578
+ names?: Readonly<Record<string, string>>,
441
579
  ): string[] {
580
+ const domainSources: string[] = []; // for flat-mode alias validation (dart.names)
442
581
  const genDir = path.join(dartOut, DART_GENERATED_SUBDIR);
443
582
  removeLegacyFlatDartLayout(dartOut, modules.map((m) => m.name));
444
583
  rmSync(genDir, { recursive: true, force: true });
@@ -448,15 +587,45 @@ function emitDart(
448
587
  // the barrel's schema_validation export (see DART_SUPPORT_SRC).
449
588
  if (supportImport === "vendored") {
450
589
  mkdirSync(path.join(genDir, "src"), { recursive: true });
451
- const libDir = path.join(DSL_DIR, "dart", "lib");
452
- const barrel = readFileSync(path.join(libDir, "nomos_client.dart"), "utf8")
453
- .split("\n")
454
- .filter((line) => !VENDORED_BARREL_EXCLUDES.some((x) => line.includes(x)))
455
- .join("\n");
456
- writeFileSync(path.join(genDir, "nomos_client.dart"), barrel, "utf8");
457
- for (const f of DART_SUPPORT_SRC) {
458
- copyFileSync(path.join(libDir, "src", f), path.join(genDir, "src", f));
590
+ // Core files come from the pure `nomos_types` package, runtime files from `nomos_client`
591
+ // (the layered split). Runtime files import the core via `package:nomos_types/...` — the
592
+ // vendored copy rewrites that to the local shim so the directory stays self-contained.
593
+ const coreDir = path.join(DSL_DIR, "dart_types", "lib", "src");
594
+ const clientDir = path.join(DSL_DIR, "dart", "lib", "src");
595
+ for (const f of DART_SUPPORT_CORE_SRC) copyFileSync(path.join(coreDir, f), path.join(genDir, "src", f));
596
+ for (const f of DART_SUPPORT_RUNTIME_SRC) {
597
+ const src = readFileSync(path.join(clientDir, f), "utf8").replace(
598
+ /import 'package:nomos_types\/nomos_types\.dart'/g,
599
+ `import '${VENDORED_TYPES_SHIM}'`,
600
+ );
601
+ writeFileSync(path.join(genDir, "src", f), src, "utf8");
459
602
  }
603
+ // The local shim the rewritten runtime imports resolve through (the vendored twin of the
604
+ // nomos_types barrel — core files only).
605
+ writeFileSync(
606
+ path.join(genDir, "src", VENDORED_TYPES_SHIM),
607
+ [
608
+ "// GENERATED by nomos-compile — the vendored twin of the nomos_types barrel. DO NOT EDIT.",
609
+ "library;",
610
+ "",
611
+ ...DART_SUPPORT_CORE_SRC.map((f) => `export '${f}';`),
612
+ "",
613
+ ].join("\n"),
614
+ "utf8",
615
+ );
616
+ // The vendored support barrel — explicit (never derived from the real nomos_client barrel,
617
+ // which post-split re-exports the nomos_types PACKAGE the vendored tree must not depend on).
618
+ writeFileSync(
619
+ path.join(genDir, "nomos_client.dart"),
620
+ [
621
+ "// GENERATED by nomos-compile — the vendored nomos_client support barrel. DO NOT EDIT.",
622
+ "library;",
623
+ "",
624
+ ...DART_SUPPORT_SRC.map((f) => `export 'src/${f}';`),
625
+ "",
626
+ ].join("\n"),
627
+ "utf8",
628
+ );
460
629
  }
461
630
  // (package-support mode vendors nothing — the genDir wipe above already cleared any prior set)
462
631
 
@@ -473,42 +642,8 @@ function emitDart(
473
642
  // • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
474
643
  // Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
475
644
  // same-library re-export). So a barrel over all domains needs NO `hide` clause.
476
- type ModDirective = DomainModule["directives"][number];
477
- type ModQuery = NonNullable<DomainModule["queries"]>[number];
478
- const frameworkAggById = new Map<string, AggregateHandle>();
479
- const frameworkDirById = new Map<string, ModDirective>();
480
- const frameworkQueryById = new Map<string, ModQuery>();
481
- for (const mod of modules) {
482
- for (const a of mod.aggregates) if (isFrameworkAggregate(a)) frameworkAggById.set(a.id, a);
483
- for (const d of mod.directives) if (isFrameworkDirective(d)) frameworkDirById.set(d.id, d);
484
- for (const q of mod.queries ?? []) if (isFrameworkQuery(q)) frameworkQueryById.set(q.id, q);
485
- }
486
- const frameworkAggs = [...frameworkAggById.values()];
487
- const frameworkDirs = [...frameworkDirById.values()];
488
- const frameworkQueries = [...frameworkQueryById.values()];
489
- const frameworkAggIds: ReadonlySet<string> = new Set(frameworkAggById.keys());
490
- const frameworkDirIds: ReadonlySet<string> = new Set(frameworkDirById.keys());
491
- const frameworkQueryIds: ReadonlySet<string> = new Set(frameworkQueryById.keys());
492
- // VALUE OBJECTS shared by ≥2 domains: their generated Dart class emits ONCE in the shared
493
- // `nomos_framework.dart` (the same collision rule as framework symbols — a barrel re-exporting
494
- // two domains must not define one VO class twice). A single-domain VO stays in its domain file.
495
- const voDomainsByName = new Map<string, number>();
496
- const voDescriptorByName = new Map<string, NonNullable<DomainModule["valueObjects"]>[number]>();
497
- for (const mod of modules) {
498
- for (const d of mod.valueObjects ?? []) {
499
- voDomainsByName.set(d.name, (voDomainsByName.get(d.name) ?? 0) + 1);
500
- voDescriptorByName.set(d.name, d);
501
- }
502
- }
503
- const sharedVoNames: ReadonlySet<string> = new Set(
504
- [...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
505
- );
506
- const sharedVos = [...sharedVoNames]
507
- .sort()
508
- .map((name) => voDescriptorByName.get(name)!);
509
- const hasFrameworkSymbols =
510
- frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
511
- const FRAMEWORK_FILE = "nomos_framework";
645
+ const shared = collectDartSharedSymbols(modules);
646
+ const { frameworkAggIds, frameworkDirIds, frameworkQueryIds, sharedVoNames, hasFrameworkSymbols } = shared;
512
647
  const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
513
648
 
514
649
  const written: string[] = [];
@@ -525,24 +660,15 @@ function emitDart(
525
660
  supportImport === "vendored" ? src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT) : src,
526
661
  "utf8",
527
662
  );
663
+ domainSources.push(src);
528
664
  written.push(p);
529
665
  return p;
530
666
  };
531
667
 
532
668
  // 2a. the shared framework file — the framework aggregates + directives + queries, ONCE. Its own
533
669
  // surface is emitted in full (no skip). Imported by every domain that folds any framework symbol.
534
- if (hasFrameworkSymbols) {
535
- const frameworkMod: DomainModule = {
536
- name: FRAMEWORK_FILE,
537
- domain: FRAMEWORK_FILE,
538
- aggregates: frameworkAggs,
539
- directives: frameworkDirs,
540
- readModelAggregates: frameworkAggs.map((a) => a.id),
541
- ...(frameworkQueries.length > 0 ? { queries: frameworkQueries } : {}),
542
- // Package-SHARED value objects (used by ≥2 domains) — their class emits here, once.
543
- ...(sharedVos.length > 0 ? { valueObjects: sharedVos } : {}),
544
- };
545
- writeGenerated(FRAMEWORK_FILE, generateDartDomain(frameworkMod));
670
+ if (shared.frameworkMod !== undefined) {
671
+ writeGenerated(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
546
672
  }
547
673
 
548
674
  // 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
@@ -551,32 +677,12 @@ function emitDart(
551
677
  // Default the read-model allowlist to EVERY aggregate when the domain didn't pin one, so
552
678
  // `<name>.dart` carries a read model per aggregate — the bridge client imports them. A domain
553
679
  // that pins `readModelAggregates` keeps its choice (byte-identical to before this default).
554
- const modForDart: DomainModule =
555
- mod.readModelAggregates === undefined
556
- ? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
557
- : mod;
558
680
  // Skip framework symbols this domain folds (they live in the shared file); reference them via the
559
681
  // framework import. Only pass the import when this domain actually folds a framework symbol.
560
- const domainFwAggIds = new Set(
561
- modForDart.aggregates.filter((a) => frameworkAggIds.has(a.id)).map((a) => a.id),
562
- );
563
- const domainFwDirIds = new Set(
564
- modForDart.directives.filter((d) => frameworkDirIds.has(d.id)).map((d) => d.id),
565
- );
566
- const domainFwQueryIds = new Set(
567
- (modForDart.queries ?? []).filter((q) => frameworkQueryIds.has(q.id)).map((q) => q.id),
568
- );
569
- const domainSharedVoNames = new Set(
570
- (modForDart.valueObjects ?? []).filter((d) => sharedVoNames.has(d.name)).map((d) => d.name),
571
- );
572
- const foldsFramework =
573
- domainFwAggIds.size > 0 || domainFwDirIds.size > 0 || domainFwQueryIds.size > 0 || domainSharedVoNames.size > 0;
682
+ const { modForDart, skips, foldsFramework } = domainDedupOpts(mod, shared);
574
683
  const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
575
684
  const src = generateDartDomain(modForDart, {
576
- skipAggregateIds: domainFwAggIds,
577
- skipDirectiveIds: domainFwDirIds,
578
- skipQueryIds: domainFwQueryIds,
579
- skipValueObjectNames: domainSharedVoNames,
685
+ ...skips,
580
686
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
581
687
  });
582
688
  writeGenerated(mod.name, src);
@@ -604,6 +710,10 @@ function emitDart(
604
710
  "// Regenerated on every compile; generated implementation lives under src/generated/.",
605
711
  "library;",
606
712
  "",
713
+ ...aliasImportLines(names, [
714
+ ...(hasFrameworkSymbols ? [`${genRel}/${FRAMEWORK_FILE}.dart`] : []),
715
+ ...modules.map((m) => `${genRel}/${m.name}.dart`),
716
+ ]),
607
717
  supportImport === "vendored"
608
718
  ? `export '${genRel}/nomos_client.dart';`
609
719
  : `export 'package:nomos_client/nomos_client.dart';`,
@@ -612,6 +722,13 @@ function emitDart(
612
722
  `export '${genRel}/${m.name}.dart';`,
613
723
  `export '${genRel}/${m.name}.client.dart';`,
614
724
  ]),
725
+ ...dartAliasLines(
726
+ names,
727
+ domainSources.reduce((acc, src) => {
728
+ for (const n of declaredDartTypeNames(src)) acc.add(n);
729
+ return acc;
730
+ }, new Set<string>()),
731
+ ),
615
732
  "",
616
733
  ];
617
734
  const barrelPath = path.join(dartOut, `${barrelName}.dart`);
@@ -620,6 +737,272 @@ function emitDart(
620
737
  return written;
621
738
  }
622
739
 
740
+ // ── the LAYERED-PACKAGES Dart emit (dart.packages: blast-radius isolation) ────────
741
+
742
+ /** The pure-core support import generated TYPES files carry in layered-packages mode. */
743
+ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
744
+ /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases. */
745
+ const NOMOS_TYPES_DEP = "^0.1.0";
746
+ const NOMOS_CLIENT_DEP = "^0.38.0";
747
+
748
+ const isPubPackageName = (s: string): boolean => /^[a-z][a-z0-9_]*$/.test(s);
749
+
750
+ /** `import` lines a barrel needs so its `typedef` aliases can SEE the generated symbols
751
+ * (an `export` alone re-exports but does not bring names into scope). Empty when no aliases. */
752
+ const aliasImportLines = (names: Readonly<Record<string, string>> | undefined, uris: readonly string[]): string[] =>
753
+ names !== undefined && Object.keys(names).length > 0 ? uris.map((u) => `import '${u}';`) : [];
754
+
755
+ /** Declared public type names in generated Dart source (classes, enums, mixins, extension types).
756
+ * The alias validator and the cursed-name report both read this surface. */
757
+ function declaredDartTypeNames(src: string): Set<string> {
758
+ const names = new Set<string>();
759
+ const re = /^(?:abstract\s+|final\s+|sealed\s+)*(?:class|enum|mixin|extension type)\s+([A-Za-z][A-Za-z0-9_]*)/gm;
760
+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {
761
+ if (!m[1]!.startsWith("_")) names.add(m[1]!);
762
+ }
763
+ return names;
764
+ }
765
+
766
+ /** `typedef <alias> = <generated>;` lines for the barrel, validated FAIL-CLOSED against the emitted
767
+ * types surface: every target must exist, no alias may collide with a generated name or another alias. */
768
+ function dartAliasLines(names: Readonly<Record<string, string>> | undefined, declared: ReadonlySet<string>): string[] {
769
+ if (names === undefined || Object.keys(names).length === 0) return [];
770
+ const seenAliases = new Set<string>();
771
+ const lines = [
772
+ "",
773
+ "// ---- public aliases (dart.names) — humane names over the stable generated identities ----",
774
+ ];
775
+ for (const [generated, alias] of Object.entries(names).sort(([a], [b]) => (a < b ? -1 : 1))) {
776
+ if (!declared.has(generated)) {
777
+ fail(
778
+ `dart.names: '${generated}' is not a generated public type in this package's TYPES surface — ` +
779
+ `check the spelling against the emitted src/generated/*.dart (aliases cover types, not client classes)`,
780
+ );
781
+ }
782
+ if (!/^[A-Z][A-Za-z0-9_]*$/.test(alias)) {
783
+ fail(`dart.names: alias '${alias}' (for '${generated}') must be a public Dart type identifier (UpperCamelCase)`);
784
+ }
785
+ if (declared.has(alias)) {
786
+ fail(`dart.names: alias '${alias}' collides with a generated public type of the same name`);
787
+ }
788
+ if (seenAliases.has(alias)) fail(`dart.names: alias '${alias}' is mapped from two generated names`);
789
+ seenAliases.add(alias);
790
+ lines.push(`typedef ${alias} = ${generated};`);
791
+ }
792
+ return lines;
793
+ }
794
+
795
+ /** The courtesy CURSED-NAME report: long compound generated names with no public alias. Printed in the
796
+ * compile summary (never a failure) so the `dart.names` map stays honest as the domain grows. */
797
+ function cursedNameHints(declared: ReadonlySet<string>, names: Readonly<Record<string, string>> | undefined): string[] {
798
+ const aliased = new Set(Object.keys(names ?? {}));
799
+ const humps = (s: string): number => (s.match(/[A-Z][a-z0-9]*/g) ?? []).length;
800
+ return [...declared]
801
+ .filter((n) => !aliased.has(n) && !n.startsWith("Nomos") && (n.length >= 28 || humps(n) >= 5))
802
+ .sort((a, b) => b.length - a.length);
803
+ }
804
+
805
+ /** Scaffold a pubspec ONLY IF MISSING — the tenant owns versioning/publishing; regeneration must
806
+ * never clobber their bumps. Returns true when written. */
807
+ function scaffoldPubspec(pkgDir: string, yaml: string): boolean {
808
+ const p = path.join(pkgDir, "pubspec.yaml");
809
+ if (existsSync(p)) return false;
810
+ mkdirSync(pkgDir, { recursive: true });
811
+ writeFileSync(p, yaml, "utf8");
812
+ return true;
813
+ }
814
+
815
+ /**
816
+ * THE LAYERED-PACKAGES EMIT (`dart.packages`) — three sibling pub packages under `dartOut`:
817
+ * `<types>/` (pure data, depends only on `nomos_types`), `<client>/` (effectful runtime clients,
818
+ * depends on the types package + `nomos_client`), and optionally `<test>/` (the headless-harness
819
+ * surface). Generated code lives under each package's `lib/src/generated/` (wiped per compile)
820
+ * behind a generated-owned barrel `lib/<pkg>.dart`; pubspecs are scaffolded only if missing.
821
+ * TYPES-FILE PURITY is enforced fail-closed: a generated types file may import ONLY the
822
+ * `nomos_types` barrel and the relative `nomos_framework.dart` — any runtime import is a compile
823
+ * failure here, not a review comment downstream.
824
+ */
825
+ function emitDartPackages(
826
+ modules: readonly DomainModule[],
827
+ dartOut: string,
828
+ domainHash: string,
829
+ packageInterface: Record<string, DomainInterface>,
830
+ packages: { readonly types: string; readonly client: string; readonly test?: string },
831
+ names: Readonly<Record<string, string>> | undefined,
832
+ compat?: NomosCompat,
833
+ ): { written: string[]; cursed: string[] } {
834
+ for (const [role, name] of Object.entries(packages)) {
835
+ if (typeof name !== "string" || !isPubPackageName(name)) {
836
+ fail(`dart.packages.${role}: '${String(name)}' is not a valid pub package name (lower_snake_case)`);
837
+ }
838
+ }
839
+ const roleNames = [packages.types, packages.client, ...(packages.test !== undefined ? [packages.test] : [])];
840
+ if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test names must be distinct`);
841
+
842
+ const shared = collectDartSharedSymbols(modules);
843
+ const written: string[] = [];
844
+ const genRel = DART_GENERATED_SUBDIR.split(path.sep).join("/");
845
+
846
+ const pkgDir = (name: string): string => path.join(dartOut, name);
847
+ const freshGenDir = (name: string): string => {
848
+ const g = path.join(pkgDir(name), "lib", DART_GENERATED_SUBDIR);
849
+ rmSync(g, { recursive: true, force: true });
850
+ mkdirSync(g, { recursive: true });
851
+ return g;
852
+ };
853
+
854
+ // ── the TYPES package — pure data, `nomos_types` only ──
855
+ const typesGen = freshGenDir(packages.types);
856
+ const typesSources = new Map<string, string>(); // fileBase → src (for alias validation + purity)
857
+ const writeTypes = (fileBase: string, src: string): void => {
858
+ if (!src.includes(DART_PACKAGE_IMPORT)) {
859
+ fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
860
+ }
861
+ const pure = src.replace(DART_PACKAGE_IMPORT, DART_TYPES_IMPORT);
862
+ // PURITY, fail-closed: only the nomos_types barrel + the relative framework file may be imported.
863
+ for (const line of pure.split("\n")) {
864
+ const m = /^import\s+'([^']+)'/.exec(line.trim());
865
+ if (m !== null && m[1] !== "package:nomos_types/nomos_types.dart" && m[1] !== `${FRAMEWORK_FILE}.dart`) {
866
+ fail(
867
+ `types-package purity: generated '${fileBase}.dart' imports '${m[1]}' — a types file may only ` +
868
+ `import the nomos_types core (runtime/bridge surfaces belong in the client package)`,
869
+ );
870
+ }
871
+ }
872
+ const p = path.join(typesGen, `${fileBase}.dart`);
873
+ writeFileSync(p, pure, "utf8");
874
+ typesSources.set(fileBase, pure);
875
+ written.push(p);
876
+ };
877
+
878
+ if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
879
+ const perDomain = modules.map((mod) => ({ mod, dd: domainDedupOpts(mod, shared) }));
880
+ for (const { mod, dd } of perDomain) {
881
+ writeTypes(
882
+ mod.name,
883
+ generateDartDomain(dd.modForDart, {
884
+ ...dd.skips,
885
+ ...(dd.foldsFramework ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
886
+ }),
887
+ );
888
+ }
889
+
890
+ const declared = new Set<string>();
891
+ for (const src of typesSources.values()) for (const n of declaredDartTypeNames(src)) declared.add(n);
892
+ const typesBarrel = [
893
+ "// GENERATED by nomos-compile — the TYPES package's public entrypoint. DO NOT EDIT.",
894
+ "// Pure data: payload classes, read models, VOs/enums, typed read accessors over the",
895
+ "// injected NomosReads seams. Frontend-safe — no bridge, no sessions, no lifecycle.",
896
+ "library;",
897
+ "",
898
+ ...aliasImportLines(names, [...typesSources.keys()].map((f) => `${genRel}/${f}.dart`)),
899
+ `export 'package:nomos_types/nomos_types.dart';`,
900
+ ...[...typesSources.keys()].map((f) => `export '${genRel}/${f}.dart';`),
901
+ ...dartAliasLines(names, declared),
902
+ "",
903
+ ];
904
+ const typesBarrelPath = path.join(pkgDir(packages.types), "lib", `${packages.types}.dart`);
905
+ writeFileSync(typesBarrelPath, typesBarrel.join("\n"), "utf8");
906
+ written.push(typesBarrelPath);
907
+ scaffoldPubspec(
908
+ pkgDir(packages.types),
909
+ [
910
+ `name: ${packages.types}`,
911
+ `description: Generated Nomos domain TYPES — pure data, frontend-safe (no bridge, no runtime).`,
912
+ `version: 0.1.0`,
913
+ `publish_to: none # tenant-owned: remove to publish`,
914
+ `environment:`,
915
+ ` sdk: ^3.5.0`,
916
+ `dependencies:`,
917
+ ` nomos_types: ${NOMOS_TYPES_DEP}`,
918
+ ``,
919
+ ].join("\n"),
920
+ );
921
+
922
+ // ── the CLIENT package — effectful runtime, depends on types + nomos_client ──
923
+ const clientGen = freshGenDir(packages.client);
924
+ const typesImportUri = `package:${packages.types}/${packages.types}.dart`;
925
+ for (const { mod, dd } of perDomain) {
926
+ const clientSrc = generateDartBridgeClient(dd.modForDart, {
927
+ domainHash,
928
+ interface: packageInterface[dd.modForDart.domain ?? dd.modForDart.name],
929
+ ...(compat !== undefined ? { compat } : {}),
930
+ supportImport: "package",
931
+ typesImportUri,
932
+ });
933
+ const cp = path.join(clientGen, `${mod.name}.client.dart`);
934
+ writeFileSync(cp, clientSrc, "utf8");
935
+ written.push(cp);
936
+ }
937
+ const clientBarrel = [
938
+ "// GENERATED by nomos-compile — the CLIENT package's public entrypoint. DO NOT EDIT.",
939
+ "// Effectful runtime clients (offer methods over NomosBridge). Composition/runtime layer",
940
+ "// only — UI code should depend on the sibling types package, not on this.",
941
+ "library;",
942
+ "",
943
+ `export 'package:nomos_client/nomos_client.dart';`,
944
+ `export '${typesImportUri}';`,
945
+ ...perDomain.map(({ mod }) => `export '${genRel}/${mod.name}.client.dart';`),
946
+ "",
947
+ ];
948
+ const clientBarrelPath = path.join(pkgDir(packages.client), "lib", `${packages.client}.dart`);
949
+ writeFileSync(clientBarrelPath, clientBarrel.join("\n"), "utf8");
950
+ written.push(clientBarrelPath);
951
+ scaffoldPubspec(
952
+ pkgDir(packages.client),
953
+ [
954
+ `name: ${packages.client}`,
955
+ `description: Generated Nomos domain CLIENTS — the effectful runtime layer over NomosBridge.`,
956
+ `version: 0.1.0`,
957
+ `publish_to: none # tenant-owned: remove to publish`,
958
+ `environment:`,
959
+ ` sdk: ^3.5.0`,
960
+ `dependencies:`,
961
+ ` nomos_client: ${NOMOS_CLIENT_DEP}`,
962
+ ` ${packages.types}:`,
963
+ ` path: ../${packages.types} # co-emitted sibling; replace with a hosted constraint when publishing`,
964
+ ``,
965
+ ].join("\n"),
966
+ );
967
+
968
+ // ── the TEST package (optional) — the headless-harness surface, test-only dependency ──
969
+ if (packages.test !== undefined) {
970
+ const testGen = freshGenDir(packages.test);
971
+ void testGen; // generated fixtures land here as they grow; the barrel is the surface today
972
+ const testBarrel = [
973
+ "// GENERATED by nomos-compile — the TEST package's public entrypoint. DO NOT EDIT.",
974
+ "// The headless-harness surface (NomosTestHarness + the node-runner transport) plus the",
975
+ "// full client layer, for widget/integration tests. Depend on this ONLY from dev_dependencies.",
976
+ "library;",
977
+ "",
978
+ `export 'package:nomos_client/testing.dart';`,
979
+ `export 'package:${packages.client}/${packages.client}.dart';`,
980
+ "",
981
+ ];
982
+ const testBarrelPath = path.join(pkgDir(packages.test), "lib", `${packages.test}.dart`);
983
+ writeFileSync(testBarrelPath, testBarrel.join("\n"), "utf8");
984
+ written.push(testBarrelPath);
985
+ scaffoldPubspec(
986
+ pkgDir(packages.test),
987
+ [
988
+ `name: ${packages.test}`,
989
+ `description: Generated Nomos test surface — headless harness + clients. dev_dependencies only.`,
990
+ `version: 0.1.0`,
991
+ `publish_to: none # tenant-owned: remove to publish`,
992
+ `environment:`,
993
+ ` sdk: ^3.5.0`,
994
+ `dependencies:`,
995
+ ` nomos_client: ${NOMOS_CLIENT_DEP}`,
996
+ ` ${packages.client}:`,
997
+ ` path: ../${packages.client} # co-emitted sibling; replace with a hosted constraint when publishing`,
998
+ ``,
999
+ ].join("\n"),
1000
+ );
1001
+ }
1002
+
1003
+ return { written, cursed: cursedNameHints(declared, names) };
1004
+ }
1005
+
623
1006
  // ── main ──────────────────────────────────────────────────────────────────────────
624
1007
 
625
1008
  async function main(): Promise<void> {
@@ -1761,31 +2144,53 @@ async function main(): Promise<void> {
1761
2144
  dartCfg === null ||
1762
2145
  (dartCfg.out !== undefined && typeof dartCfg.out !== "string") ||
1763
2146
  (dartCfg.support !== undefined && dartCfg.support !== "vendored" && dartCfg.support !== "package") ||
1764
- (dartCfg.barrel !== undefined && !/^[a-z][a-z0-9_]*$/.test(String(dartCfg.barrel))))
2147
+ (dartCfg.barrel !== undefined && !/^[a-z][a-z0-9_]*$/.test(String(dartCfg.barrel))) ||
2148
+ (dartCfg.names !== undefined &&
2149
+ (typeof dartCfg.names !== "object" || dartCfg.names === null || Array.isArray(dartCfg.names))) ||
2150
+ (dartCfg.packages !== undefined &&
2151
+ (typeof dartCfg.packages !== "object" ||
2152
+ dartCfg.packages === null ||
2153
+ typeof dartCfg.packages.types !== "string" ||
2154
+ typeof dartCfg.packages.client !== "string")))
1765
2155
  ) {
1766
2156
  fail(
1767
- `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name" }; got ${JSON.stringify(dartCfg)}`,
2157
+ `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name", packages?: { types, client, test? }, names?: { Generated: "Alias" } }; got ${JSON.stringify(dartCfg)}`,
2158
+ );
2159
+ }
2160
+ const dartPackages = typeof dartCfg === "object" ? dartCfg.packages : undefined;
2161
+ const dartNames = typeof dartCfg === "object" ? dartCfg.names : undefined;
2162
+ if (dartPackages !== undefined && typeof dartCfg === "object" && dartCfg.support === "vendored") {
2163
+ fail(
2164
+ `dart.packages requires support: "package" — layered pub packages depend on the published ` +
2165
+ `nomos_types/nomos_client cores; vendoring contradicts the package boundary`,
1768
2166
  );
1769
2167
  }
1770
2168
  const dartSupportImport: DartSupportImport =
1771
- typeof dartCfg === "object" && dartCfg.support !== undefined ? dartCfg.support : "vendored";
2169
+ typeof dartCfg === "object" && dartCfg.support !== undefined ? dartCfg.support : dartPackages !== undefined ? "package" : "vendored";
1772
2170
  let dartOut: string | undefined;
1773
2171
  let dartFiles: string[] = [];
2172
+ let dartCursedHints: string[] = [];
1774
2173
  if (dartCfg !== false) {
1775
2174
  dartOut = path.resolve(
1776
2175
  cfgDir,
1777
2176
  typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
1778
2177
  );
1779
- // The barrel basename: configured, else the snake_cased package name (a valid Dart library
1780
- // file name — pub packages import `package:<name>/<name>.dart`).
1781
- const defaultBarrel = cfg.name
1782
- .toLowerCase()
1783
- .replace(/[^a-z0-9]+/g, "_")
1784
- .replace(/^_+|_+$/g, "")
1785
- .replace(/^(?=[0-9])/, "n");
1786
- const dartBarrel =
1787
- typeof dartCfg === "object" && dartCfg.barrel !== undefined ? dartCfg.barrel : defaultBarrel;
1788
- dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport, dartBarrel, nomosCompat);
2178
+ if (dartPackages !== undefined) {
2179
+ const r = emitDartPackages(domainModules, dartOut, domainHash, packageInterface, dartPackages, dartNames, nomosCompat);
2180
+ dartFiles = r.written;
2181
+ dartCursedHints = r.cursed;
2182
+ } else {
2183
+ // The barrel basename: configured, else the snake_cased package name (a valid Dart library
2184
+ // file name — pub packages import `package:<name>/<name>.dart`).
2185
+ const defaultBarrel = cfg.name
2186
+ .toLowerCase()
2187
+ .replace(/[^a-z0-9]+/g, "_")
2188
+ .replace(/^_+|_+$/g, "")
2189
+ .replace(/^(?=[0-9])/, "n");
2190
+ const dartBarrel =
2191
+ typeof dartCfg === "object" && dartCfg.barrel !== undefined ? dartCfg.barrel : defaultBarrel;
2192
+ dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport, dartBarrel, nomosCompat, dartNames);
2193
+ }
1789
2194
  }
1790
2195
 
1791
2196
  // ── summary ──
@@ -1865,9 +2270,25 @@ async function main(): Promise<void> {
1865
2270
  }
1866
2271
  }
1867
2272
  if (dartOut !== undefined) {
1868
- const supportText =
1869
- dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";
1870
- console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + ${supportText} — typed Dart for Flutter)`);
2273
+ if (dartPackages !== undefined) {
2274
+ const pkgList = [
2275
+ `${dartPackages.types} (types)`,
2276
+ `${dartPackages.client} (client)`,
2277
+ ...(dartPackages.test !== undefined ? [`${dartPackages.test} (test)`] : []),
2278
+ ].join(" · ");
2279
+ console.log(` dart ${rel(dartOut)}${path.sep} — LAYERED packages: ${pkgList} (${dartFiles.length} generated file(s))`);
2280
+ console.log(` layering line: UI depends on ${dartPackages.types}; only composition/runtime depends on ${dartPackages.client}`);
2281
+ if (dartCursedHints.length > 0) {
2282
+ const shown = dartCursedHints.slice(0, 5).join(", ");
2283
+ console.log(
2284
+ ` dart names hint: ${dartCursedHints.length} long generated name(s) lack a public alias — e.g. ${shown}${dartCursedHints.length > 5 ? ", …" : ""}; map them in dart.names`,
2285
+ );
2286
+ }
2287
+ } else {
2288
+ const supportText =
2289
+ dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";
2290
+ console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + ${supportText} — typed Dart for Flutter)`);
2291
+ }
1871
2292
  }
1872
2293
  console.log(` deploy ${rel(deployPath)}`);
1873
2294
  console.log(``);