@githolon/dsl 0.103.12 → 0.103.14

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.
Files changed (45) hide show
  1. package/dart/CHANGELOG.md +5 -0
  2. package/dart/lib/src/runner/node-entry.mjs +244 -173
  3. package/dart/lib/src/runner/offline-install.mjs +87 -112
  4. package/dart/lib/src/session_bound.dart +7 -4
  5. package/dart/lib/src/webview_bridge.dart +13 -11
  6. package/dart/pubspec.yaml +1 -1
  7. package/dart/test/webview_bridge_test.dart +10 -7
  8. package/dart_types/CHANGELOG.md +2 -2
  9. package/dart_types/lib/nomos_types.dart +4 -4
  10. package/dart_types/lib/src/versioned_nomos_ref.dart +13 -11
  11. package/dart_types/pubspec.yaml +1 -1
  12. package/package.json +1 -1
  13. package/runtime/generated-application-host.js +433 -239
  14. package/src/aggregate.ts +3 -1
  15. package/src/build_package.ts +77 -6
  16. package/src/check_package_main.ts +39 -16
  17. package/src/codegen_dart.ts +189 -52
  18. package/src/codegen_dart_application.ts +82 -16
  19. package/src/codegen_dart_client.ts +42 -14
  20. package/src/codegen_dot.ts +3 -3
  21. package/src/codegen_proof.ts +24 -2
  22. package/src/codegen_ts.ts +32 -19
  23. package/src/compile_package_main.ts +68 -11
  24. package/src/derived.ts +17 -0
  25. package/src/directive.ts +65 -0
  26. package/src/engine_entry.ts +22 -2
  27. package/src/enum_type.ts +71 -0
  28. package/src/fields.ts +135 -5
  29. package/src/flow_gate.ts +2 -1
  30. package/src/framework/workspaces.ts +73 -0
  31. package/src/idof_lint.ts +5 -1
  32. package/src/index.ts +20 -0
  33. package/src/kernel_capabilities.ts +12 -1
  34. package/src/law_check.ts +107 -28
  35. package/src/law_graph.ts +151 -20
  36. package/src/law_graph_usda.ts +23 -0
  37. package/src/law_value_type.ts +10 -3
  38. package/src/lsp_server.ts +26 -16
  39. package/src/package_config.ts +112 -9
  40. package/src/package_feature_topology.ts +174 -0
  41. package/src/plan_source_slice.ts +6 -0
  42. package/src/report.ts +6 -0
  43. package/src/stable_ids_for_module.ts +9 -4
  44. package/src/value_object.ts +9 -2
  45. package/src/workspace_sharding.ts +4 -4
package/src/law_graph.ts CHANGED
@@ -80,7 +80,11 @@ export interface LawFieldPathRef {
80
80
  * distinguish a nested projection path such as location.point. */
81
81
  readonly authoredPath: string;
82
82
  /** Stable nested field identities from the aggregate field towards the terminal value. */
83
- readonly path: readonly (LawRef<"field"> | LawStructuralRef<"value-field">)[];
83
+ readonly path: readonly (
84
+ | LawRef<"field">
85
+ | LawStructuralRef<"value-field">
86
+ | LawStructuralRef<"value-variant">
87
+ )[];
84
88
  }
85
89
 
86
90
  export interface LawDomainNode {
@@ -129,6 +133,8 @@ export interface LawFieldNode {
129
133
  * sufficient to distinguish e.g. string from JSON, or map<string> from map<json>. */
130
134
  readonly storageKind?: FieldKind;
131
135
  readonly mapValueKind?: FieldKind;
136
+ /** Semantic scalar identity retained in canonical USDA when the storage kind alone is lossy. */
137
+ readonly valueSemantic?: "epoch-milliseconds";
132
138
  /** Content identity of the complete business-value contract used to interpret
133
139
  * retained conflict observations. */
134
140
  readonly schemaIdentity?: string;
@@ -377,7 +383,7 @@ export type LawAnonymousValueTypeExpression =
377
383
  readonly constraints: readonly LawValueConstraint[];
378
384
  }
379
385
  | { readonly kind: "boolean" | "null" | "unknown" }
380
- | { readonly kind: "enum"; readonly values: readonly string[] }
386
+ | { readonly kind: "enum"; readonly values: readonly string[]; readonly name?: string }
381
387
  | { readonly kind: "literal"; readonly value: LawLiteral }
382
388
  | { readonly kind: "list"; readonly element: LawStructuralRef<"value-type"> }
383
389
  | { readonly kind: "map"; readonly value: LawStructuralRef<"value-type"> }
@@ -695,6 +701,13 @@ export interface LawSpatialReadNode extends LawReadBase<"spatial"> {
695
701
 
696
702
  export interface LawDerivedReadNode extends LawReadBase<"derived"> {}
697
703
 
704
+ export interface LawTemporalEnumReadNode extends LawReadBase<"temporalEnum"> {
705
+ readonly source: LawFieldPathRef;
706
+ readonly missing: string;
707
+ readonly initial: string;
708
+ readonly transitions: readonly { readonly afterMillis: number; readonly value: string }[];
709
+ }
710
+
698
711
  export interface LawCombinedReadNode extends LawReadBase<"combined"> {
699
712
  readonly via: LawFieldPathRef;
700
713
  readonly reads: LawRef<"aggregate">;
@@ -708,6 +721,7 @@ export type LawReadNode =
708
721
  | LawOrderedReadNode
709
722
  | LawSpatialReadNode
710
723
  | LawDerivedReadNode
724
+ | LawTemporalEnumReadNode
711
725
  | LawCombinedReadNode;
712
726
 
713
727
  export type DeclaredLawNode =
@@ -815,6 +829,7 @@ export function discoverLawGraph(
815
829
  owner: aggregateRef,
816
830
  value: value.root,
817
831
  storageKind: field.kind,
832
+ ...(field.isTimestamp === true ? { valueSemantic: "epoch-milliseconds" as const } : {}),
818
833
  ...(field.mapValueKind === undefined ? {} : { mapValueKind: field.mapValueKind }),
819
834
  ...(fieldIds.schema === undefined ? {} : { schemaIdentity: fieldIds.schema }),
820
835
  evolutions: [...(fieldIds.evolutions ?? [])].sort((left, right) => compareText(left.from, right.from)),
@@ -894,6 +909,7 @@ export function discoverLawGraph(
894
909
  owner: contractRef,
895
910
  value: value.root,
896
911
  storageKind: field.kind,
912
+ ...(field.isTimestamp === true ? { valueSemantic: "epoch-milliseconds" as const } : {}),
897
913
  ...(field.mapValueKind === undefined ? {} : { mapValueKind: field.mapValueKind }),
898
914
  evolutions: [],
899
915
  mergeDrivers: mergeDrivers(field.driver),
@@ -2043,7 +2059,14 @@ export function discoverLawGraph(
2043
2059
  fields: ReadonlyMap<string, LawFieldNode>,
2044
2060
  aggregate: string,
2045
2061
  path: string,
2046
- ): LawFieldPathRef => fieldPath(fields, aggregate, path, fieldNodes, anonymousValueNodes);
2062
+ ): LawFieldPathRef => fieldPath(
2063
+ fields,
2064
+ aggregate,
2065
+ path,
2066
+ fieldNodes,
2067
+ typeNodes,
2068
+ anonymousValueNodes,
2069
+ );
2047
2070
  const readNodes = reads.map((read): LawReadNode => {
2048
2071
  const readEntity = entity(
2049
2072
  registry,
@@ -2143,6 +2166,15 @@ export function discoverLawGraph(
2143
2166
  return { ...base, readKind: "spatial", geometry: resolveReadFieldPath(fields, read.of, read.geometry) };
2144
2167
  case "derived":
2145
2168
  return { ...base, readKind: "derived" };
2169
+ case "temporalEnum":
2170
+ return {
2171
+ ...base,
2172
+ readKind: "temporalEnum",
2173
+ source: resolveReadFieldPath(fields, read.of, read.sourceField),
2174
+ missing: read.missing,
2175
+ initial: read.initial,
2176
+ transitions: read.transitions,
2177
+ };
2146
2178
  case "combined":
2147
2179
  return {
2148
2180
  ...base,
@@ -2243,6 +2275,15 @@ type ReadInput =
2243
2275
  | (ReadInputBase & { readKind: "ordered"; of: string; orderBy: string; descending: boolean; limit: number; where?: CanonicalPred; filtered: boolean })
2244
2276
  | (ReadInputBase & { readKind: "spatial"; of: string; geometry: string })
2245
2277
  | (ReadInputBase & { readKind: "derived"; of: string; returns: z.ZodTypeAny })
2278
+ | (ReadInputBase & {
2279
+ readKind: "temporalEnum";
2280
+ of: string;
2281
+ sourceField: string;
2282
+ missing: string;
2283
+ initial: string;
2284
+ transitions: readonly { readonly afterMillis: number; readonly value: string }[];
2285
+ returns: z.ZodTypeAny;
2286
+ })
2246
2287
  | (ReadInputBase & { readKind: "combined"; of: string; via: string; reads: string; returns: z.ZodTypeAny });
2247
2288
 
2248
2289
  interface RawLawReadParameter {
@@ -2289,7 +2330,19 @@ function readInputs(module: DomainModule): ReadInput[] {
2289
2330
  }),
2290
2331
  ...(module.orderedReads ?? []).map((read) => ({ id: read.id, public: isPublic(read), readKind: "ordered" as const, of: read.of, orderBy: read.orderKey, descending: read.orderDesc, limit: read.limit, ...(read.where === undefined ? {} : { where: read.where }), filtered: read.where !== undefined })),
2291
2332
  ...(module.spatials ?? []).map((read) => ({ id: read.id, public: isPublic(read), readKind: "spatial" as const, of: read.of, geometry: read.on })),
2292
- ...(module.deriveds ?? []).map((read) => ({ id: read.id, public: isPublic(read), readKind: "derived" as const, of: read.of, returns: read.returns })),
2333
+ ...(module.deriveds ?? []).map((read) => "kind" in read && read.kind === "temporalEnum"
2334
+ ? {
2335
+ id: read.id,
2336
+ public: isPublic(read),
2337
+ readKind: "temporalEnum" as const,
2338
+ of: read.of,
2339
+ sourceField: read.sourceField,
2340
+ missing: read.missing,
2341
+ initial: read.initial,
2342
+ transitions: read.transitions,
2343
+ returns: read.returns,
2344
+ }
2345
+ : { id: read.id, public: isPublic(read), readKind: "derived" as const, of: read.of, returns: read.returns }),
2293
2346
  ...(module.combineds ?? []).map((read) => ({ id: read.id, public: isPublic(read), readKind: "combined" as const, of: read.of, via: read.refField, reads: read.reads, returns: read.returns })),
2294
2347
  ];
2295
2348
  return reads.sort((a, b) => compareText(a.id, b.id));
@@ -2359,6 +2412,7 @@ function readResult(read: ReadInput, aggregate: LawRef<"aggregate">): RawLawRead
2359
2412
  value: { kind: "nullable", value: { kind: "integer", constraints: [] } },
2360
2413
  };
2361
2414
  case "derived":
2415
+ case "temporalEnum":
2362
2416
  case "combined":
2363
2417
  return { kind: "scalar", value: lowerLawValueType(read.returns) };
2364
2418
  }
@@ -2457,7 +2511,11 @@ function descriptorValue(field: ValueObjectFieldDescriptor): LawValueType {
2457
2511
  case "boolean":
2458
2512
  return { kind: "boolean" };
2459
2513
  case "enum":
2460
- return { kind: "enum", values: [...field.values].sort(compareText) };
2514
+ return {
2515
+ kind: "enum",
2516
+ values: [...field.values].sort(compareText),
2517
+ ...(field.name === undefined ? {} : { name: field.name }),
2518
+ };
2461
2519
  case "literal":
2462
2520
  return { kind: "literal", value: field.value };
2463
2521
  case "array":
@@ -2577,7 +2635,11 @@ export function lowerAnonymousValueGraph<K extends LawEntityKind>(
2577
2635
  expression = { kind: value.kind };
2578
2636
  break;
2579
2637
  case "enum":
2580
- expression = { kind: "enum", values: value.values };
2638
+ expression = {
2639
+ kind: "enum",
2640
+ values: value.values,
2641
+ ...(value.name === undefined ? {} : { name: value.name }),
2642
+ };
2581
2643
  break;
2582
2644
  case "literal":
2583
2645
  expression = { kind: "literal", value: value.value };
@@ -2941,12 +3003,17 @@ function fieldPath(
2941
3003
  aggregate: string,
2942
3004
  path: string,
2943
3005
  allFields: readonly LawFieldNode[],
3006
+ allTypes: readonly LawTypeNode[],
2944
3007
  anonymousNodes: ReadonlyMap<string, LawAnonymousValueNode>,
2945
3008
  ): LawFieldPathRef {
2946
3009
  const [head, ...tail] = path.split(".");
2947
3010
  const field = head === undefined ? undefined : fields.get(head);
2948
3011
  if (field === undefined) fail(`read field path '${aggregate}.${path}' targets an unknown field`);
2949
- const nested: Array<LawRef<"field"> | LawStructuralRef<"value-field">> = [];
3012
+ const nested: Array<
3013
+ | LawRef<"field">
3014
+ | LawStructuralRef<"value-field">
3015
+ | LawStructuralRef<"value-variant">
3016
+ > = [];
2950
3017
  let value: LawValueTypeRef = field.value;
2951
3018
  const unwrap = (): void => {
2952
3019
  const seen = new Set<string>();
@@ -2965,21 +3032,85 @@ function fieldPath(
2965
3032
  };
2966
3033
  for (const segment of tail) {
2967
3034
  unwrap();
2968
- const declared = allFields.find((candidate) =>
2969
- candidate.owner.id === value.id && candidate.entity.label === segment);
2970
- if (declared !== undefined) {
2971
- nested.push(ref(declared.entity));
2972
- value = declared.value;
3035
+ const fieldAt = (
3036
+ owner: LawValueTypeRef,
3037
+ ): { ref: LawRef<"field"> | LawStructuralRef<"value-field">; value: LawValueTypeRef } | undefined => {
3038
+ const declared = allFields.find((candidate) =>
3039
+ candidate.owner.id === owner.id && candidate.entity.label === segment);
3040
+ if (declared !== undefined) return { ref: ref(declared.entity), value: declared.value };
3041
+ const structural = [...anonymousNodes.values()].find(
3042
+ (candidate): candidate is LawAnonymousValueFieldNode =>
3043
+ candidate.structuralKind === "value-field" &&
3044
+ candidate.owner.id === owner.id &&
3045
+ candidate.name === segment,
3046
+ );
3047
+ return structural === undefined
3048
+ ? undefined
3049
+ : { ref: structuralRef(structural.entity), value: structural.value };
3050
+ };
3051
+
3052
+ const direct = fieldAt(value);
3053
+ if (direct !== undefined) {
3054
+ nested.push(direct.ref);
3055
+ value = direct.value;
2973
3056
  continue;
2974
3057
  }
2975
- const structural = [...anonymousNodes.values()].find((candidate): candidate is LawAnonymousValueFieldNode =>
2976
- candidate.structuralKind === "value-field" &&
2977
- candidate.owner.id === value.id &&
2978
- candidate.name === segment);
2979
- if (structural !== undefined) {
2980
- nested.push(structuralRef(structural.entity));
2981
- value = structural.value;
2982
- continue;
3058
+
3059
+ // A `variant(...)` lowers to an anonymous SUM of RECORD arms. Runtime dotted indexes are
3060
+ // intentionally sparse: `scope.buildingId` exists only on the buildingLevel arm and the other
3061
+ // arms contribute no row. Walk the variant branches here too, so the canonical USDA graph and
3062
+ // the projection manifest agree instead of forcing authors back to nullable top-level ids.
3063
+ const valueNode = anonymousNodes.get(value.id);
3064
+ const variants = valueNode?.structuralKind === "value-type" && valueNode.expression.kind === "sum"
3065
+ ? valueNode.expression.variants
3066
+ : allTypes.find((candidate) => candidate.entity.id === value.id && candidate.form === "sum")?.variants;
3067
+ if (variants !== undefined) {
3068
+ if (segment === "type") {
3069
+ const first = [...variants].sort((a, b) => compareText(a.id, b.id))[0];
3070
+ const variant = first === undefined ? undefined : anonymousNodes.get(first.id);
3071
+ if (variant?.structuralKind === "value-variant") {
3072
+ // The discriminator is represented by the variant identity itself in canonical USDA;
3073
+ // it is not duplicated as a synthetic string field on every arm.
3074
+ nested.push(structuralRef(variant.entity));
3075
+ value = variant.value;
3076
+ continue;
3077
+ }
3078
+ }
3079
+ const matches: Array<{
3080
+ variant: LawAnonymousValueVariantNode;
3081
+ field: LawRef<"field"> | LawStructuralRef<"value-field">;
3082
+ value: LawValueTypeRef;
3083
+ }> = [];
3084
+ for (const variantRef of variants) {
3085
+ const variant = anonymousNodes.get(variantRef.id);
3086
+ if (variant?.structuralKind !== "value-variant") continue;
3087
+ let armValue = variant.value;
3088
+ const armSeen = new Set<string>();
3089
+ while (true) {
3090
+ if (armSeen.has(armValue.id)) fail(`read field path '${aggregate}.${path}' crosses a variant wrapper cycle`);
3091
+ armSeen.add(armValue.id);
3092
+ const armNode = anonymousNodes.get(armValue.id);
3093
+ if (
3094
+ armNode?.structuralKind !== "value-type" ||
3095
+ (armNode.expression.kind !== "nullable" &&
3096
+ armNode.expression.kind !== "default" &&
3097
+ armNode.expression.kind !== "recursive")
3098
+ ) break;
3099
+ armValue = armNode.expression.value;
3100
+ }
3101
+ const match = fieldAt(armValue);
3102
+ if (match !== undefined) matches.push({ variant, field: match.ref, value: match.value });
3103
+ }
3104
+ if (matches.length > 0) {
3105
+ // Variant options are canonicalised before lowering; sorting again makes the selected
3106
+ // structural witness explicit and deterministic when a common field (notably `type`)
3107
+ // occurs on every arm. `authoredPath` remains the executable path.
3108
+ matches.sort((a, b) => compareText(a.variant.entity.id, b.variant.entity.id));
3109
+ const match = matches[0]!;
3110
+ nested.push(structuralRef(match.variant.entity), match.field);
3111
+ value = match.value;
3112
+ continue;
3113
+ }
2983
3114
  }
2984
3115
  fail(`read field path '${aggregate}.${path}' targets unknown nested field '${segment}'`);
2985
3116
  }
@@ -1158,6 +1158,23 @@ function emitRead(
1158
1158
  break;
1159
1159
  case "derived":
1160
1160
  break;
1161
+ case "temporalEnum":
1162
+ body.push(`${pad}custom rel nomos:source = ${fieldTarget(read.source)}`);
1163
+ body.push(`${pad}custom uniform string nomos:sourcePath = ${quote(read.source.authoredPath)}`);
1164
+ body.push(`${pad}custom uniform token nomos:missing = ${quote(read.missing)}`);
1165
+ body.push(`${pad}custom uniform token nomos:initial = ${quote(read.initial)}`);
1166
+ body.push("", `${pad}def Scope "Transitions"`, `${pad}{`);
1167
+ for (const [index, transition] of read.transitions.entries()) {
1168
+ body.push(
1169
+ `${pad} def NomosTemporalTransition "After_${String(index).padStart(4, "0")}"`,
1170
+ `${pad} {`,
1171
+ `${pad} custom uniform int64 nomos:afterMillis = ${transition.afterMillis}`,
1172
+ `${pad} custom uniform token nomos:value = ${quote(transition.value)}`,
1173
+ `${pad} }`,
1174
+ );
1175
+ }
1176
+ body.push(`${pad}}`);
1177
+ break;
1161
1178
  case "combined":
1162
1179
  body.push(`${pad}custom rel nomos:refField = ${fieldTarget(read.via)}`);
1163
1180
  body.push(`${pad}custom uniform string nomos:refFieldPath = ${quote(read.via.authoredPath)}`);
@@ -1484,6 +1501,9 @@ function emitAnonymousType(
1484
1501
  case "enum":
1485
1502
  body.push(`${pad}custom uniform token nomos:typeKind = "scalar"`);
1486
1503
  body.push(`${pad}custom uniform token nomos:scalar = "string"`);
1504
+ if (expression.name !== undefined) {
1505
+ body.push(`${pad}custom uniform string nomos:declaredName = ${quote(expression.name)}`);
1506
+ }
1487
1507
  body.push(`${pad}custom uniform token[] nomos:enum = [${expression.values.map(quote).join(", ")}]`);
1488
1508
  break;
1489
1509
  case "literal":
@@ -1674,6 +1694,9 @@ function emitField(
1674
1694
  if (field.mapValueKind !== undefined) {
1675
1695
  body.push(`${pad} custom uniform token nomos:mapValueKind = ${quote(field.mapValueKind)}`);
1676
1696
  }
1697
+ if (field.valueSemantic !== undefined) {
1698
+ body.push(`${pad} custom uniform token nomos:valueSemantic = ${quote(field.valueSemantic)}`);
1699
+ }
1677
1700
  if (field.schemaIdentity !== undefined) {
1678
1701
  body.push(`${pad} custom uniform string nomos:schemaIdentity = ${quote(field.schemaIdentity)}`);
1679
1702
  }
@@ -1,4 +1,5 @@
1
1
  import type { z } from "zod";
2
+ import { enumTypeNameOf } from "./enum_type.js";
2
3
  import {
3
4
  zodArrayElement,
4
5
  zodDef,
@@ -26,7 +27,7 @@ export interface LawValueMember {
26
27
  export type LawValueType =
27
28
  | { readonly kind: "string" | "number" | "integer"; readonly constraints: readonly LawValueConstraint[] }
28
29
  | { readonly kind: "boolean" | "null" | "unknown" }
29
- | { readonly kind: "enum"; readonly values: readonly string[] }
30
+ | { readonly kind: "enum"; readonly values: readonly string[]; readonly name?: string }
30
31
  | { readonly kind: "literal"; readonly value: LawLiteral }
31
32
  | { readonly kind: "list"; readonly element: LawValueType }
32
33
  | { readonly kind: "map"; readonly value: LawValueType }
@@ -91,8 +92,14 @@ function lower(
91
92
  case "unknown":
92
93
  case "any":
93
94
  return { kind: "unknown" };
94
- case "enum":
95
- return { kind: "enum", values: zodEnumValues(schema).sort(compareText) };
95
+ case "enum": {
96
+ const name = enumTypeNameOf(schema);
97
+ return {
98
+ kind: "enum",
99
+ values: zodEnumValues(schema).sort(compareText),
100
+ ...(name === undefined ? {} : { name }),
101
+ };
102
+ }
96
103
  case "literal":
97
104
  return { kind: "literal", value: literalOf(def, path) };
98
105
  case "array":
package/src/lsp_server.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL, fileURLToPath } from "node:url";
26
+ import { bindEnumTypeExports } from "./enum_type.js";
26
27
 
27
28
  import {
28
29
  createConnection,
@@ -52,17 +53,18 @@ import {
52
53
  import { parseStableIdLockfile } from "./stable_id_lockfile.js";
53
54
  import {
54
55
  validateNomosPackageConfig,
55
- type NomosPackageConfig,
56
+ type NormalizedNomosPackageConfig,
57
+ type NormalizedNomosPackageDomainConfig,
56
58
  } from "./package_config.js";
57
59
  import { assertPublicPackageImports } from "./package_source_imports.js";
60
+ import {
61
+ assertPackageFeatureTopology,
62
+ packageFeatureTopologyFindings,
63
+ } from "./package_feature_topology.js";
58
64
  import { packageDependencyUsageFindings } from "./package_dependency_usage.js";
59
65
 
60
- interface DomainConfig {
61
- key: string;
62
- name?: string;
63
- modules: string[];
64
- }
65
- interface PackageConfig extends NomosPackageConfig {
66
+ interface DomainConfig extends NormalizedNomosPackageDomainConfig {}
67
+ interface PackageConfig extends NormalizedNomosPackageConfig {
66
68
  readonly domains: readonly DomainConfig[];
67
69
  }
68
70
 
@@ -137,7 +139,6 @@ async function composePackage(
137
139
  };
138
140
  const validated = validateNomosPackageConfig(cfgModule.default);
139
141
  const cfg = validated.config as PackageConfig;
140
-
141
142
  // Resolve every module file first so we can compute the mtime cache key before importing.
142
143
  const perDomainFiles: { key: string; name: string | undefined; files: string[] }[] = [];
143
144
  const allFiles: string[] = [];
@@ -162,6 +163,10 @@ async function composePackage(
162
163
  };
163
164
  }
164
165
 
166
+ const sourceTopologyFindings = packageFeatureTopologyFindings(
167
+ assertPackageFeatureTopology(cfg.domains, cfgDir),
168
+ cfgDir,
169
+ );
165
170
  assertPublicPackageImports(allFiles);
166
171
 
167
172
  const composed: DomainModule[] = [];
@@ -169,17 +174,22 @@ async function composePackage(
169
174
  for (const d of perDomainFiles) {
170
175
  const mods: Mod[] = [];
171
176
  for (const abs of d.files) {
172
- mods.push((await import(`${pathToFileURL(abs).href}?t=${Date.now()}`)) as Mod);
177
+ const mod = (await import(`${pathToFileURL(abs).href}?t=${Date.now()}`)) as Mod;
178
+ bindEnumTypeExports(mod, abs);
179
+ mods.push(mod);
173
180
  }
174
181
  domainFiles.set(d.key, d.files);
175
182
  composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
176
183
  }
177
- const dependencyFindings = packageDependencyUsageFindings({
178
- packageName: cfg.name,
179
- exportedDomains: cfg.domains.map((domain) => domain.key),
180
- runtimeDependencies: validated.runtimeDependencies,
181
- modules: composed,
182
- });
184
+ const dependencyFindings = [
185
+ ...sourceTopologyFindings,
186
+ ...packageDependencyUsageFindings({
187
+ packageName: cfg.name,
188
+ exportedDomains: cfg.domains.map((domain) => domain.key),
189
+ runtimeDependencies: validated.runtimeDependencies,
190
+ modules: composed,
191
+ }),
192
+ ];
183
193
  composedCache.set(configPath, { mtimeKey: key, modules: composed, domainFiles, dependencyFindings });
184
194
  return { modules: composed, domainFiles, dependencyFindings };
185
195
  }
@@ -269,7 +279,7 @@ export async function runCheckPass(configPath: string, against?: string): Promis
269
279
 
270
280
  const srcMap = new LawSourceMap(domainFiles);
271
281
  const located: LocatedFinding[] = findings.map((f) => {
272
- const loc = srcMap.locate(f.domain, f.directiveId);
282
+ const loc = f.source ?? srcMap.locate(f.domain, f.directiveId);
273
283
  return {
274
284
  finding: f,
275
285
  file: loc?.file ?? configPath,
@@ -9,13 +9,57 @@ import {
9
9
  type ApplicationHomeGenesis,
10
10
  } from "./application_home_genesis.js";
11
11
 
12
- /** The source modules which compose one domain exported by a Nomos package. */
12
+ /**
13
+ * One cohesive, vertically sliced business capability inside a domain.
14
+ *
15
+ * Features are values rather than loose names so dependencies are selected from declarations which
16
+ * actually exist. Source paths remain paths (that is their type), but the business topology does not
17
+ * regress into an unrelated string graph.
18
+ */
19
+ export interface NomosPackageFeatureConfig<Key extends string = string> {
20
+ readonly key: Key;
21
+ readonly modules: readonly string[];
22
+ /** A thunk permits honest bidirectional domain relationships without string-key dependency graphs. */
23
+ readonly dependsOn?: readonly NomosPackageFeatureConfig[] | (() => readonly NomosPackageFeatureConfig[]);
24
+ }
25
+
26
+ export function featureDependenciesOf(
27
+ feature: NomosPackageFeatureConfig,
28
+ ): readonly NomosPackageFeatureConfig[] {
29
+ return typeof feature.dependsOn === "function" ? feature.dependsOn() : feature.dependsOn ?? [];
30
+ }
31
+
32
+ /** Define a typed feature value which may be used directly by dependent features. */
33
+ export function defineFeature<const Config extends NomosPackageFeatureConfig>(
34
+ config: Config,
35
+ ): Config {
36
+ return config;
37
+ }
38
+
39
+ /** The named feature composition which forms one domain exported by a Nomos package. */
13
40
  export interface NomosPackageDomainConfig {
14
41
  readonly key: string;
15
42
  readonly name?: string;
43
+ readonly features: readonly NomosPackageFeatureConfig[];
44
+ }
45
+
46
+ /** Define a domain composition root. Domain roots own topology only, never business declarations. */
47
+ export function defineDomain<const Config extends NomosPackageDomainConfig>(
48
+ config: Config,
49
+ ): Config {
50
+ return config;
51
+ }
52
+
53
+ /** Compiler-normalized domain. Raw modules are deliberately absent from the public authoring API. */
54
+ export interface NormalizedNomosPackageDomainConfig extends NomosPackageDomainConfig {
16
55
  readonly modules: readonly string[];
17
56
  }
18
57
 
58
+ /** The checked package shape consumed by compiler/check/LSP internals. */
59
+ export interface NormalizedNomosPackageConfig extends Omit<NomosPackageConfig, "domains"> {
60
+ readonly domains: readonly NormalizedNomosPackageDomainConfig[];
61
+ }
62
+
19
63
  /**
20
64
  * The common, law-bearing part of `nomos.project.ts`.
21
65
  *
@@ -74,7 +118,7 @@ export type NomosPackageDefinition<Config extends NomosPackageConfig> = Config &
74
118
 
75
119
  /** Validate the common package contract and return its canonical runtime requirements. */
76
120
  export function validateNomosPackageConfig(value: unknown): {
77
- readonly config: NomosPackageConfig;
121
+ readonly config: NormalizedNomosPackageConfig;
78
122
  readonly runtimeDependencies: readonly RuntimePackageDependency[];
79
123
  } {
80
124
  if (value === null || typeof value !== "object") {
@@ -85,24 +129,80 @@ export function validateNomosPackageConfig(value: unknown): {
85
129
  throw new Error("config needs a non-empty string name");
86
130
  }
87
131
  if (!Array.isArray(config.domains) || config.domains.length === 0) {
88
- throw new Error("config needs domains: [{ key, modules }, ...]");
132
+ throw new Error("config needs domains: [{ key, features }, ...]");
89
133
  }
90
134
  const domainKeys = new Set<string>();
91
- for (const domain of config.domains) {
135
+ const normalizedDomains: NormalizedNomosPackageDomainConfig[] = [];
136
+ for (const candidate of config.domains) {
137
+ const domain = candidate as NomosPackageDomainConfig;
92
138
  if (
93
139
  domain === null
94
140
  || typeof domain !== "object"
95
141
  || typeof domain.key !== "string"
96
142
  || domain.key.length === 0
97
- || !Array.isArray(domain.modules)
98
- || domain.modules.length === 0
99
- || domain.modules.some((module: unknown) => typeof module !== "string" || module.length === 0)
143
+ || !Array.isArray(domain.features)
144
+ || domain.features.length === 0
100
145
  ) {
101
- throw new Error(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(domain)}`);
146
+ throw new Error(`each domain needs { key, features: [defineFeature(...), ...] }; got ${JSON.stringify(domain)}`);
102
147
  }
103
148
  if (!domainKeys.add(domain.key)) {
104
149
  throw new Error(`package '${config.name}' repeats domain key '${domain.key}'`);
105
150
  }
151
+ const featureKeys = new Set<string>();
152
+ const featureObjects = new Set<NomosPackageFeatureConfig>();
153
+ const moduleOwners = new Map<string, string>();
154
+ for (const feature of domain.features) {
155
+ if (
156
+ feature === null
157
+ || typeof feature !== "object"
158
+ || typeof feature.key !== "string"
159
+ || feature.key.length === 0
160
+ || !Array.isArray(feature.modules)
161
+ || feature.modules.length === 0
162
+ || feature.modules.some((module: unknown) => typeof module !== "string" || module.length === 0)
163
+ ) {
164
+ throw new Error(
165
+ `domain '${domain.key}' features need { key, modules: [path, ...] }; got ${JSON.stringify(feature)}`,
166
+ );
167
+ }
168
+ if (!featureKeys.add(feature.key)) {
169
+ throw new Error(`domain '${domain.key}' repeats feature key '${feature.key}'`);
170
+ }
171
+ featureObjects.add(feature);
172
+ for (const module of feature.modules) {
173
+ const prior = moduleOwners.get(module);
174
+ if (prior !== undefined) {
175
+ throw new Error(
176
+ `domain '${domain.key}' module '${module}' belongs to both '${prior}' and '${feature.key}'; ` +
177
+ "every law source must have exactly one feature owner",
178
+ );
179
+ }
180
+ moduleOwners.set(module, feature.key);
181
+ }
182
+ }
183
+ for (const feature of domain.features) {
184
+ if (
185
+ feature.dependsOn !== undefined
186
+ && !Array.isArray(feature.dependsOn)
187
+ && typeof feature.dependsOn !== "function"
188
+ ) {
189
+ throw new Error(`domain '${domain.key}' feature '${feature.key}' dependsOn must be an array or thunk`);
190
+ }
191
+ for (const dependency of featureDependenciesOf(feature)) {
192
+ if (!featureObjects.has(dependency)) {
193
+ throw new Error(
194
+ `domain '${domain.key}' feature '${feature.key}' depends on a feature value outside its domain`,
195
+ );
196
+ }
197
+ if (dependency === feature) {
198
+ throw new Error(`domain '${domain.key}' feature '${feature.key}' cannot depend on itself`);
199
+ }
200
+ }
201
+ }
202
+ normalizedDomains.push({
203
+ ...domain,
204
+ modules: domain.features.flatMap((feature) => feature.modules),
205
+ });
106
206
  }
107
207
  if (config.dependencies !== undefined && !Array.isArray(config.dependencies)) {
108
208
  throw new Error("package dependencies must be an array");
@@ -130,7 +230,10 @@ export function validateNomosPackageConfig(value: unknown): {
130
230
  config.dependencies ?? [],
131
231
  { requiresNomos: true },
132
232
  );
133
- return { config: config as NomosPackageConfig, runtimeDependencies };
233
+ return {
234
+ config: { ...config, domains: normalizedDomains } as NormalizedNomosPackageConfig,
235
+ runtimeDependencies,
236
+ };
134
237
  }
135
238
 
136
239
  /**