@githolon/dsl 0.52.0 → 0.54.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/src/codegen_ts.ts CHANGED
@@ -55,6 +55,14 @@ const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUp
55
55
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
56
56
  const upperSnake = (s: string) => s.replace(/[-\s]+/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
57
57
 
58
+ /** A count/sum group-by field → a valid TS param identifier. A DOTTED group-by (`location.source`,
59
+ * the #30 follow-up) camel-joins into `locationSource` (a bare `location.source` is not a legal
60
+ * positional param name); a flat field is unchanged. Mirrors the Dart `dartKeyParam`. */
61
+ const tsGroupParam = (by: string) => {
62
+ const parts = by.split(".").filter((p) => p !== "");
63
+ return parts.length <= 1 ? by : parts[0]! + parts.slice(1).map((p) => p[0]!.toUpperCase() + p.slice(1)).join("");
64
+ };
65
+
58
66
  // ── NAME NORMALIZATION (no naming convention to learn) ────────────────────────────
59
67
  // `nomos.package.mjs` names and domain keys may be ANY string — kebab-case,
60
68
  // snake_case, space-separated, PascalCase. Artifact FILENAMES keep the raw name
@@ -381,8 +389,11 @@ function tsTypeOfField(field: Field): string {
381
389
  case "set":
382
390
  return "string[]"; // `t.set(t.string())` is the one set shape (fields.ts)
383
391
  case "map": {
392
+ // A map of a NAMED value object (`t.map(VO.field())`) types each entry as the VO
393
+ // interface; scalar-valued maps keep their primitive value type.
384
394
  const v =
385
- field.mapValueKind === "string" ? "string"
395
+ field.mapValueVo !== undefined ? field.mapValueVo.name
396
+ : field.mapValueKind === "string" ? "string"
386
397
  : field.mapValueKind === "int" ? "number"
387
398
  : "unknown";
388
399
  return `Record<string, ${v}>`;
@@ -399,13 +410,22 @@ const geometryCoordsTs: Record<string, string> = {
399
410
  Point: "number[]",
400
411
  LineString: "number[][]",
401
412
  Polygon: "number[][][]",
413
+ MultiPoint: "number[][]",
414
+ };
415
+
416
+ /** The `geo.*` factory name for a geometry kind (used in generated doc comments). */
417
+ const geometryFactoryName: Record<string, string> = {
418
+ Point: "point",
419
+ LineString: "lineString",
420
+ Polygon: "polygon",
421
+ MultiPoint: "multiPoint",
402
422
  };
403
423
 
404
424
  /** One descriptor field → its TS type text. NO FALLBACK (the descriptor set is closed). */
405
425
  function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
406
426
  switch (f.type) {
407
427
  case "string":
408
- return "string";
428
+ return f.brand ?? "string"; // a brandedId(...) → the emitted branded alias
409
429
  case "number":
410
430
  return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
411
431
  case "boolean":
@@ -416,18 +436,36 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
416
436
  return JSON.stringify(f.value);
417
437
  case "array":
418
438
  return `${paren(tsTypeOfVoField(f.items))}[]`;
439
+ case "map":
440
+ return `Record<string, ${tsTypeOfVoField(f.values)}>`;
419
441
  case "object":
420
442
  case "geometry":
421
443
  return f.ref; // the named VO interface (emitted alongside)
422
444
  }
423
445
  }
424
446
 
447
+ /** Every branded-id name a VO descriptor carries (recursing arrays/maps/union arms) — one
448
+ * `export type <Brand> = string & {…}` alias is emitted per distinct brand. */
449
+ function voBrandNames(d: ValueObjectDescriptor): Set<string> {
450
+ const brands = new Set<string>();
451
+ const walk = (f: ValueObjectFieldDescriptor): void => {
452
+ if (f.type === "string" && f.brand !== undefined) brands.add(f.brand);
453
+ else if (f.type === "array") walk(f.items);
454
+ else if (f.type === "map") walk(f.values);
455
+ };
456
+ for (const f of Object.values(d.fields ?? {})) walk(f);
457
+ for (const arm of Object.values(d.union ?? {})) {
458
+ if ("fields" in arm) for (const f of Object.values(arm.fields)) walk(f);
459
+ }
460
+ return brands;
461
+ }
462
+
425
463
  /** Emit one `export interface <VO> { ... }` from its canonical descriptor. A geo VO
426
464
  * emits the fixed RFC 7946 shape (`{ type: "<kind>"; coordinates: ... }`). */
427
465
  function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
428
466
  if (d.geometry !== undefined) {
429
467
  return [
430
- `/** RFC 7946 ${d.geometry} — a \`geo.${d.geometry === "Point" ? "point" : d.geometry === "LineString" ? "lineString" : "polygon"}(...)\` value object. */`,
468
+ `/** RFC 7946 ${d.geometry} — a \`geo.${geometryFactoryName[d.geometry]}(...)\` value object. */`,
431
469
  `export interface ${d.name} { type: ${JSON.stringify(d.geometry)}; coordinates: ${geometryCoordsTs[d.geometry]}; }`,
432
470
  ``,
433
471
  ];
@@ -596,7 +634,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
596
634
  ` count(countId: string, groupKey?: string): Promise<number>;`,
597
635
  ` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query().`,
598
636
  ` * OPTIONAL member: structural binding tolerates a pre-spatial holon (the accessor then throws at call). */`,
599
- ` spatialWithin?(spatialId: string, bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }): Promise<unknown>;`,
637
+ ` spatialWithin?(spatialId: string, bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }): Promise<unknown>;`,
600
638
  ` sum(sumId: string, groupKey?: string): Promise<number>;`,
601
639
  ...(anyPackedTaxonomy(modules)
602
640
  ? [
@@ -609,10 +647,33 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
609
647
  ` deadLetters(): Promise<{ id?: string; oid: string; source: string; error: string; domain?: string | null; directiveId?: string | null; at: string }[]>;`,
610
648
  ` retryDeadLetter(id: string): Promise<{ ok: boolean; head?: string; alreadyOnMain?: boolean; error?: string }>;`,
611
649
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
612
- ` /** THE CONNECT-TIME COMPAT CLASSIFIER (§5) — OPTIONAL: a holon from @githolon/client's connect()`,
613
- ` * attaches it (implemented by \`checkCompat\`); the generated factory hands over this client's baked`,
614
- ` * compat manifest so a law-hash mismatch logs a warning. DIAGNOSIS only — never throws, never gates. */`,
615
- ` checkCompat?(compat: { lawHash: string; interfaceHash: string; packageName: string }): void;`,
650
+ ` /** THE COMPAT SUPERPOWER (#72b) — OPTIONAL: a holon from @githolon/client's connect() attaches it.`,
651
+ ` * The factory hands over this client's baked compat manifest + structural interface (the requirement),`,
652
+ ` * and the holon computes the LIVE per-member verdict on \`holon.compat\`. A KNOWN-incompatible directive`,
653
+ ` * then refuses CLIENT-SIDE at write time (LawMismatchError), never as a server 422. */`,
654
+ ` checkCompat?(compat: { lawHash: string; interfaceHash: string; packageName: string }, requirement?: unknown): NomosCompatVerdict | null;`,
655
+ ` /** The live compat verdict (set by checkCompat). \`has(memberId)\` is the dark-launch feature flag;`,
656
+ ` * \`refresh()\` re-derives against the current projection. Absent until the factory binds. */`,
657
+ ` compat?: NomosCompatVerdict;`,
658
+ ` /** Subscribe to holon events. \`lawChanged\` fires when a synced install/upgrade/retire moves the`,
659
+ ` * workspace's offer under this live client (the compat verdict re-derived). Returns unsubscribe. */`,
660
+ ` on?(event: "lawChanged", cb: (compat: NomosCompatVerdict) => void): () => void;`,
661
+ `}`,
662
+ ``,
663
+ `/** THE LIVE COMPAT VERDICT (#72b) stashed on \`holon.compat\`. \`verdict\` is the three-state roll-up;`,
664
+ ` * \`members\` names each directive/aggregate's own \`{ ok, reason? }\`; \`install\` is the deployed law's`,
665
+ ` * lifecycle phase. \`has(id)\` = the feature flag; \`refresh()\` re-evaluates against the projection. */`,
666
+ `export interface NomosCompatVerdict {`,
667
+ ` generatedFor: string;`,
668
+ ` workspaceRuns: string[];`,
669
+ ` matches: boolean;`,
670
+ ` offerKnown: boolean;`,
671
+ ` structurallyCompatible: boolean | null;`,
672
+ ` verdict: "compatible" | "degraded" | "incompatible";`,
673
+ ` members: Record<string, { ok: boolean; reason?: string }>;`,
674
+ ` install: { phase: string; supersededBy?: string };`,
675
+ ` has(memberId: string): boolean;`,
676
+ ` refresh(): Promise<NomosCompatVerdict | null>;`,
616
677
  `}`,
617
678
  ``,
618
679
  `/** AUTHORITY IS UNFORGEABLE-BY-CONSTRUCTION: a GATED directive (one carrying a \`.requires(relation)\` write`,
@@ -944,6 +1005,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
944
1005
  }
945
1006
  if (voByName.size > 0) {
946
1007
  out.push(`// ── value objects (named, composable json-object leaves — value_object.ts) ──`, ``);
1008
+ // BRANDED-ID aliases (brandedId(...)) — one nominal `string & { __brand }` per distinct
1009
+ // brand any VO carries; wire-identical to a string, a compile error to cross-assign.
1010
+ const brands = new Set<string>();
1011
+ for (const [, d] of voByName) for (const b of voBrandNames(d)) brands.add(b);
1012
+ for (const b of [...brands].sort()) {
1013
+ out.push(`/** Branded id \`${b}\` — a nominal string (\`brandedId("${b}")\`); wire-identical to a string. */`);
1014
+ out.push(`export type ${b} = string & { readonly __brand: "${b}" };`, ``);
1015
+ }
947
1016
  for (const [, d] of [...voByName].sort(([a], [b]) => a.localeCompare(b))) {
948
1017
  out.push(...emitValueObjectTs(d));
949
1018
  }
@@ -1290,7 +1359,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1290
1359
  for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
1291
1360
  methods.push(
1292
1361
  ` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
1293
- ` ${lcFirst(camel(sp.id))}: (bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>,`,
1362
+ ` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>,`,
1294
1363
  );
1295
1364
  }
1296
1365
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
@@ -1326,19 +1395,21 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1326
1395
 
1327
1396
  for (const c of (mod.counts ?? []).map((raw) => finishCount(raw))) {
1328
1397
  const grouped = c.by != null;
1398
+ const p = grouped ? tsGroupParam(c.by!) : "";
1329
1399
  methods.push(
1330
1400
  ` /** Declared count \`${c.id}\` — the maintained O(1) ${grouped ? `tally per \`${c.by}\`` : "grand total"}. */`,
1331
1401
  grouped
1332
- ? ` ${lcFirst(camel(c.id))}: (${c.by}: string) => holon.count(${JSON.stringify(c.id)}, ${c.by}),`
1402
+ ? ` ${lcFirst(camel(c.id))}: (${p}: string) => holon.count(${JSON.stringify(c.id)}, ${p}),`
1333
1403
  : ` ${lcFirst(camel(c.id))}: () => holon.count(${JSON.stringify(c.id)}),`,
1334
1404
  );
1335
1405
  }
1336
1406
  for (const s of (mod.sums ?? []).map((raw) => finishSum(raw))) {
1337
1407
  const grouped = s.by != null;
1408
+ const p = grouped ? tsGroupParam(s.by!) : "";
1338
1409
  methods.push(
1339
1410
  ` /** Declared sum \`${s.id}\` — the maintained O(1) ${grouped ? `total per \`${s.by}\`` : "grand total"}. */`,
1340
1411
  grouped
1341
- ? ` ${lcFirst(camel(s.id))}: (${s.by}: string) => holon.sum(${JSON.stringify(s.id)}, ${s.by}),`
1412
+ ? ` ${lcFirst(camel(s.id))}: (${p}: string) => holon.sum(${JSON.stringify(s.id)}, ${p}),`
1342
1413
  : ` ${lcFirst(camel(s.id))}: () => holon.sum(${JSON.stringify(s.id)}),`,
1343
1414
  );
1344
1415
  }
@@ -1369,7 +1440,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1369
1440
  ` const actor = opts.actor;`,
1370
1441
  ...(encPresent ? [` const keyStore = opts.keyStore;`] : []),
1371
1442
  ...(opts.compat !== undefined
1372
- ? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
1443
+ ? [
1444
+ opts.interface !== undefined
1445
+ ? ` holon.checkCompat?.(${compatConstName(opts.packageName)}, ${interfaceConstName(opts.packageName)});`
1446
+ : ` holon.checkCompat?.(${compatConstName(opts.packageName)});`,
1447
+ ]
1373
1448
  : []),
1374
1449
  ` return {`,
1375
1450
  ` domain: ${JSON.stringify(domain)} as const,`,
@@ -0,0 +1,187 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
4
+
5
+ /**
6
+ * `githolon compat <target> [--config …] [--json]` — THE CI GATE (task #31, the compat superpower).
7
+ *
8
+ * Compares the CURRENT build's structural interface (`build/<name>.interface.json`, the client's
9
+ * REQUIREMENT) against a TARGET's installed law (the workspace OFFER), PER MEMBER, via the #72
10
+ * subtype relation (`memberVerdicts`). Exits NON-ZERO on any incompatible member — so a client/law
11
+ * mismatch is caught in CI, before it ships. The target is:
12
+ * • a deploy.json (`build/<name>.deploy.json`) — its staged `.interface` is the offer;
13
+ * • an `.interface.json` (or a build/package DIR containing one) — the offer directly;
14
+ * • a LIVE workspace URL (`https://host/v2/workspaces/<ws>` or `https://host --workspace <ws>`) —
15
+ * connect()s and reads the on-ledger offer (`holon.interfaceOffer()`).
16
+ *
17
+ * Human report by default; `--json` emits the machine surface (the per-member array + verdict).
18
+ */
19
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { memberVerdicts, type DomainInterface, type MemberVerdict } from "./shape_compat.js";
23
+
24
+ interface PackageConfig {
25
+ name: string;
26
+ }
27
+ type InterfaceMap = Record<string, DomainInterface>;
28
+
29
+ function fail(msg: string): never {
30
+ console.error(`githolon compat: ${msg}`);
31
+ process.exit(2);
32
+ }
33
+
34
+ /** Merge a per-domain interface map into ONE flat interface (directives + aggregates unioned). */
35
+ function mergeInterfaces(byDomain: InterfaceMap | undefined): DomainInterface {
36
+ const out: DomainInterface = { v: 1, domain: "*", directives: {}, aggregates: {} };
37
+ for (const di of Object.values(byDomain ?? {})) {
38
+ if (!di || typeof di !== "object") continue;
39
+ Object.assign(out.directives, di.directives ?? {});
40
+ Object.assign(out.aggregates, di.aggregates ?? {});
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** The package name from `nomos.package.mjs` (only field we need — to locate `build/<name>.interface.json`). */
46
+ async function loadPackageName(configArg: string): Promise<{ name: string; dir: string }> {
47
+ const configPath = path.resolve(process.cwd(), configArg);
48
+ if (!existsSync(configPath)) fail(`config not found: ${configPath} (pass the package config, or run from the package dir)`);
49
+ const { pathToFileURL } = await import("node:url");
50
+ const cfg = ((await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`)) as { default?: PackageConfig }).default;
51
+ if (!cfg || typeof cfg.name !== "string") fail(`config must default-export { name, … }`);
52
+ return { name: cfg.name, dir: path.dirname(configPath) };
53
+ }
54
+
55
+ /** The CURRENT build's interface — `build/<name>.interface.json`. Requires a prior `githolon compile`. */
56
+ function loadCurrentInterface(pkgDir: string, name: string): InterfaceMap {
57
+ const p = path.join(pkgDir, "build", `${name}.interface.json`);
58
+ if (!existsSync(p)) {
59
+ fail(
60
+ `no current interface at ${p} — run \`githolon compile\` first so the build carries the structural interface this checks.`,
61
+ );
62
+ }
63
+ try {
64
+ return JSON.parse(readFileSync(p, "utf8")) as InterfaceMap;
65
+ } catch (e) {
66
+ fail(`could not parse ${p}: ${(e as Error).message}`);
67
+ }
68
+ }
69
+
70
+ /** Read a TARGET offer from a file path (deploy.json / interface.json / dir). Returns `{ offer, source }`. */
71
+ function loadFileOffer(arg: string): { offer: InterfaceMap; source: string } {
72
+ const abs = path.resolve(process.cwd(), arg);
73
+ if (!existsSync(abs)) fail(`target not found: ${abs}`);
74
+ let file = abs;
75
+ if (statSync(abs).isDirectory()) {
76
+ const scan = (d: string): string | undefined => {
77
+ if (!existsSync(d)) return undefined;
78
+ const hit =
79
+ readdirSync(d).find((f) => f.endsWith(".deploy.json")) ?? readdirSync(d).find((f) => f.endsWith(".interface.json"));
80
+ return hit !== undefined ? path.join(d, hit) : undefined;
81
+ };
82
+ const found = scan(abs) ?? scan(path.join(abs, "build"));
83
+ if (found === undefined) fail(`no *.deploy.json or *.interface.json under ${abs}`);
84
+ file = found;
85
+ }
86
+ let parsed: unknown;
87
+ try {
88
+ parsed = JSON.parse(readFileSync(file, "utf8"));
89
+ } catch (e) {
90
+ fail(`could not parse ${file}: ${(e as Error).message}`);
91
+ }
92
+ const p = parsed as Record<string, unknown>;
93
+ // A deploy.json carries `{ …, interface }`; a bare interface.json IS the {[domain]: interface} map.
94
+ const offer = (p.interface !== undefined ? p.interface : p) as InterfaceMap;
95
+ if (offer === null || typeof offer !== "object") fail(`target ${file} carries no structural interface`);
96
+ return { offer, source: file };
97
+ }
98
+
99
+ /** Read a LIVE workspace's on-ledger offer via connect() (`@githolon/client`). */
100
+ async function loadLiveOffer(url: string, workspaceFlag?: string): Promise<{ offer: InterfaceMap; source: string }> {
101
+ const m = /^(https?:\/\/[^/]+)(?:\/v2\/workspaces\/([^/?#]+))?/.exec(url);
102
+ if (!m) fail(`could not parse the live target URL: ${url}`);
103
+ const cloud = m[1]!;
104
+ const workspace = workspaceFlag ?? m[2];
105
+ if (!workspace) fail(`live target needs a workspace — use https://host/v2/workspaces/<ws> or add --workspace <ws>`);
106
+ let connect: (opts: { cloud: string; workspace: string }) => Promise<{ interfaceOffer(): Promise<InterfaceMap | null> }>;
107
+ try {
108
+ ({ connect } = (await import("@githolon/client")) as unknown as { connect: typeof connect });
109
+ } catch {
110
+ fail(`live target needs @githolon/client installed (it is a dependency of githolon)`);
111
+ }
112
+ const holon = await connect({ cloud, workspace });
113
+ const offer = await holon.interfaceOffer();
114
+ if (offer === null) fail(`workspace '${workspace}' exposes no on-ledger structural interface (pre-#72e law)`);
115
+ return { offer, source: `${cloud}/v2/workspaces/${workspace}` };
116
+ }
117
+
118
+ interface MemberReport {
119
+ member: string;
120
+ kind: "directive" | "aggregate";
121
+ ok: boolean;
122
+ reason?: string;
123
+ }
124
+
125
+ async function main(): Promise<void> {
126
+ const argv = process.argv.slice(2);
127
+ const flagVal = (name: string): string | undefined => {
128
+ const i = argv.indexOf(name);
129
+ if (i >= 0) return argv[i + 1];
130
+ const inline = argv.find((a) => a.startsWith(`${name}=`));
131
+ return inline?.slice(name.length + 1);
132
+ };
133
+ const asJson = argv.includes("--json");
134
+ const configArg = flagVal("--config") ?? "nomos.package.mjs";
135
+ const workspaceFlag = flagVal("--workspace");
136
+ // The positional target = the first non-flag arg that isn't a flag VALUE.
137
+ const flagsWithVals = new Set(["--config", "--workspace"]);
138
+ const target = argv.find((a, i) => !a.startsWith("--") && !(i > 0 && flagsWithVals.has(argv[i - 1]!)));
139
+ if (!target) fail(`usage: githolon compat <workspace-url | deploy.json | interface.json | dir> [--config <cfg>] [--workspace <ws>] [--json]`);
140
+
141
+ const { name, dir } = await loadPackageName(configArg);
142
+ const current = loadCurrentInterface(dir, name);
143
+
144
+ const { offer, source } = /^https?:\/\//.test(target)
145
+ ? await loadLiveOffer(target, workspaceFlag)
146
+ : loadFileOffer(target);
147
+
148
+ // The subtype check is PER MEMBER over the merged interfaces (directives keyed by id, aggregates by label).
149
+ const verdicts = memberVerdicts(mergeInterfaces(current), mergeInterfaces(offer));
150
+ // Tag each member's kind for the report (a directive id vs an aggregate label).
151
+ const merged = mergeInterfaces(current);
152
+ const dirIds = new Set(Object.keys(merged.directives));
153
+ const reports: MemberReport[] = Object.entries(verdicts)
154
+ .map(([member, v]: [string, MemberVerdict]) => ({
155
+ member,
156
+ kind: (dirIds.has(member) ? "directive" : "aggregate") as MemberReport["kind"],
157
+ ok: v.ok,
158
+ ...(v.reason !== undefined ? { reason: v.reason } : {}),
159
+ }))
160
+ .sort((a, b) => (a.member < b.member ? -1 : a.member > b.member ? 1 : 0));
161
+
162
+ const incompatible = reports.filter((r) => !r.ok);
163
+ const verdict = incompatible.length > 0 ? "incompatible" : "compatible";
164
+
165
+ if (asJson) {
166
+ process.stdout.write(JSON.stringify({ package: name, target: source, verdict, members: reports }) + "\n");
167
+ process.exit(incompatible.length > 0 ? 1 : 0);
168
+ }
169
+
170
+ console.log(`githolon compat: package '${name}' vs ${source}`);
171
+ console.log(` ${reports.length} member(s) checked — ${verdict.toUpperCase()}`);
172
+ for (const r of reports) {
173
+ const mark = r.ok ? " ✓ " : " ✗ ";
174
+ console.log(`${mark}${r.kind} '${r.member}'${r.ok ? "" : ` — ${r.reason}`}`);
175
+ }
176
+ if (incompatible.length > 0) {
177
+ console.log(
178
+ `\ngitholon compat: ${incompatible.length} incompatible member(s) — this client cannot ship against that law. ` +
179
+ `Recompile + regenerate the client (githolon compile), or deploy a law that still exposes these members.`,
180
+ );
181
+ } else {
182
+ console.log(`\ngitholon compat: all members compatible — safe to ship.`);
183
+ }
184
+ process.exit(incompatible.length > 0 ? 1 : 0);
185
+ }
186
+
187
+ void main();
@@ -1676,6 +1676,7 @@ async function main(): Promise<void> {
1676
1676
  let proofSkip: string | undefined;
1677
1677
  let proofDomains: string[] = [];
1678
1678
  let birthWarnings: string[] = [];
1679
+ let proofNotes: string[] = [];
1679
1680
  try {
1680
1681
  const proofSrc = generateTsProof(domainModules, { packageName: cfg.name, domainHash });
1681
1682
  proofPath = path.join(outDir, `${cfg.name}.proof.mts`);
@@ -1689,6 +1690,9 @@ async function main(): Promise<void> {
1689
1690
  writeFileSync(path.join(outDir, `${cfg.name}.proof-legs.json`), JSON.stringify(legsIndex, null, 2) + "\n", "utf8");
1690
1691
  proofDomains = Object.keys(legsIndex.domains);
1691
1692
  birthWarnings = Object.values(legsIndex.domains).flatMap((l) => l.birthDiagnostics ?? []);
1693
+ // TAXONOMY AUDIT: every creating directive not covered by a leg is NAMED-WHY (exact-path synthesis
1694
+ // failure / attestation lane / gated-create authority gap) — never a silent drop.
1695
+ proofNotes = Object.values(legsIndex.domains).flatMap((l) => l.proofDiagnostics ?? []);
1692
1696
  }
1693
1697
  } catch (e) {
1694
1698
  proofSkip = (e as Error).message;
@@ -1699,7 +1703,10 @@ async function main(): Promise<void> {
1699
1703
  if (proofSkip !== undefined && birthWarnings.length === 0) {
1700
1704
  try {
1701
1705
  const idx = proofLegsIndex(domainModules);
1702
- if (idx !== null) birthWarnings = Object.values(idx.domains).flatMap((l) => l.birthDiagnostics ?? []);
1706
+ if (idx !== null) {
1707
+ birthWarnings = Object.values(idx.domains).flatMap((l) => l.birthDiagnostics ?? []);
1708
+ proofNotes = Object.values(idx.domains).flatMap((l) => l.proofDiagnostics ?? []);
1709
+ }
1703
1710
  } catch { /* diagnostics are best-effort */ }
1704
1711
  }
1705
1712
 
@@ -1817,6 +1824,9 @@ async function main(): Promise<void> {
1817
1824
  for (const w of birthWarnings) {
1818
1825
  console.log(` ⚠ births ${w}`);
1819
1826
  }
1827
+ for (const w of proofNotes) {
1828
+ console.log(` – proof NAMED-WHY (unproven directive): ${w}`);
1829
+ }
1820
1830
  // ── THE AUTHOR-TIME GATE (architecture/author_time_gate.md) — the static twins of the kernel
1821
1831
  // gate's refusal vocabulary, unified into ONE report. `githolon check` runs the same pass;
1822
1832
  // compile runs it implicitly and REFUSES on any refuse-level finding (a statically-provable
package/src/fields.ts CHANGED
@@ -76,9 +76,11 @@ export const evidenceRefSchema: z.ZodType<EvidenceRefValue> = z.object({
76
76
  // The descriptor types live HERE (dependency-free) so `Field` can carry the metadata
77
77
  // without a `fields.ts → value_object.ts` cycle; `value_object.ts` re-exports them.
78
78
 
79
- /** The RFC 7946 geometry kinds a `geo.*` value object can declare (Point is the
80
- * spatial-index-blessed one; see `spatial.ts`). */
81
- export type GeometryKind = "Point" | "LineString" | "Polygon";
79
+ /** The RFC 7946 geometry kinds a `geo.*` value object can declare. Point is the ONLY
80
+ * spatial-index-blessed one (the kernel R*Tree indexes point bounding boxes; see
81
+ * `spatial.ts`) — LineString/Polygon/MultiPoint round-trip as typed geometry but are
82
+ * NOT yet indexable (non-point R*Tree ingestion is future kernel work). */
83
+ export type GeometryKind = "Point" | "LineString" | "Polygon" | "MultiPoint";
82
84
 
83
85
  /**
84
86
  * One field of a value-object DESCRIPTOR — the canonical, JSON-schema-ish shape that
@@ -88,7 +90,10 @@ export type GeometryKind = "Point" | "LineString" | "Polygon";
88
90
  * not expressible — the v1 constrained set).
89
91
  */
90
92
  export type ValueObjectFieldDescriptor =
91
- | { readonly type: "string"; readonly optional?: true }
93
+ /** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
94
+ * — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
95
+ * to a plain string; the brand is a compile-time nominal tag only. */
96
+ | { readonly type: "string"; readonly brand?: string; readonly optional?: true }
92
97
  /** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
93
98
  * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
94
99
  | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
@@ -97,6 +102,9 @@ export type ValueObjectFieldDescriptor =
97
102
  /** A `z.literal(<string>)` — a CONSTANT discriminator-style member (generated as a getter). */
98
103
  | { readonly type: "literal"; readonly value: string; readonly optional?: true }
99
104
  | { readonly type: "array"; readonly items: ValueObjectFieldDescriptor; readonly optional?: true }
105
+ /** A MAP/RECORD (`z.record(z.string(), X)`) — string-keyed entries of `values`. TS
106
+ * `Record<string, V>`, Dart `Map<String, V>`; the wire is a plain json object. */
107
+ | { readonly type: "map"; readonly values: ValueObjectFieldDescriptor; readonly optional?: true }
100
108
  /** A nested value object, referenced BY NAME (its own descriptor rides alongside). */
101
109
  | { readonly type: "object"; readonly ref: string; readonly optional?: true }
102
110
  /** A nested GEO value object (RFC 7946 geometry), referenced by name. */
@@ -203,6 +211,15 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
203
211
  * {@link ValueObjectFieldMeta}). `undefined` for every non-VO field.
204
212
  */
205
213
  readonly valueObject?: ValueObjectFieldMeta;
214
+ /**
215
+ * For a `t.map(inner)` whose INNER value is a NAMED VALUE OBJECT (`t.map(VO.field())`):
216
+ * the inner VO's metadata, so codegen surfaces the map as `Map<String, VoClass>` /
217
+ * `Record<string, VoClass>` (each entry decoded via `VoClass.fromProjected`) instead of
218
+ * the raw `Map<String, Object?>` / `Record<string, unknown>`. FRONTEND/IR-ONLY (the wire
219
+ * driver is `MapOf(Lww)` exactly as for a plain json-valued map — NO hash change).
220
+ * `undefined` for a non-map field or a map of scalars.
221
+ */
222
+ readonly mapValueVo?: ValueObjectFieldMeta;
206
223
  /**
207
224
  * Whether a folded aggregate may LACK this field. A SCALAR field
208
225
  * (`string`/`int`/`enum`/`ref`/`json`) that a `.creates` directive does not
@@ -279,6 +296,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
279
296
  mapValueKind?: FieldKind;
280
297
  jsonShape?: "object";
281
298
  valueObject?: ValueObjectFieldMeta;
299
+ mapValueVo?: ValueObjectFieldMeta;
282
300
  encrypted?: true;
283
301
  meta?: true;
284
302
  facet?: FieldFacet;
@@ -297,6 +315,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
297
315
  ...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
298
316
  ...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
299
317
  ...(extra.valueObject !== undefined ? { valueObject: extra.valueObject } : {}),
318
+ ...(extra.mapValueVo !== undefined ? { mapValueVo: extra.mapValueVo } : {}),
300
319
  ...(extra.encrypted ? { isEncrypted: true as const } : {}),
301
320
  ...(extra.meta ? { isMeta: true as const } : {}),
302
321
  ...(extra.fromBirth ? { isFromBirth: true as const } : {}),
@@ -442,7 +461,13 @@ export const t = {
442
461
  "map",
443
462
  z.record(z.string(), inner.zod),
444
463
  { kind: "MapOf", inner: inner.driver },
445
- { mapValueKind: inner.kind },
464
+ {
465
+ mapValueKind: inner.kind,
466
+ // A map of a NAMED value object (`t.map(VO.field())`) carries the inner VO's
467
+ // metadata so codegen types each entry as the VO class (frontend-only; the wire
468
+ // driver is `MapOf(Lww)` either way — no hash change).
469
+ ...(inner.valueObject !== undefined ? { mapValueVo: inner.valueObject } : {}),
470
+ },
446
471
  );
447
472
  },
448
473
  /** Arbitrary JSON-string leaf (used inside maps); encodes as a Str at op time. The
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export {
51
51
  unit,
52
52
  idOf,
53
53
  idOfMetaOf,
54
+ brandedId,
55
+ brandedIdMetaOf,
54
56
  voMetaOf,
55
57
  voSample,
56
58
  NOMOS_VO_BRAND,
@@ -65,6 +67,7 @@ export {
65
67
  type GeoPointValue,
66
68
  type GeoLineStringValue,
67
69
  type GeoPolygonValue,
70
+ type GeoMultiPointValue,
68
71
  type ValueObjectUsage,
69
72
  } from "./value_object.js";
70
73
  export {
@@ -201,6 +204,16 @@ export {
201
204
  type OfSpatial,
202
205
  type TypelessSpatial,
203
206
  } from "./spatial.js";
207
+ // TYPED PATH HANDLES (#29): `path(Agg).location.point` — a compile-checked field-path reference that
208
+ // collapses to the dotted string, accepted by `spatial(...).on(...)` and `query(...).key(...)`.
209
+ export {
210
+ path,
211
+ isPathRef,
212
+ pathString,
213
+ type PathRef,
214
+ type PathNode,
215
+ type PathRoot,
216
+ } from "./path.js";
204
217
  export {
205
218
  sum,
206
219
  finishSum,