@nanobpm/urban 0.46.0 → 0.48.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.
@@ -337,12 +337,37 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
337
337
  // (default danger), the label defaults to "1", and the full field text becomes
338
338
  // the tooltip.
339
339
  assert.match(js, /if\s*\(col\.badge\)/);
340
- assert.match(js, /text\.trim\(\)\s*===\s*""/);
340
+ assert.match(js, /rawText\.trim\(\)\s*===\s*""/);
341
341
  assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
342
342
  assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
343
343
  assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
344
- assert.match(js, /title:\s*text/);
345
- assert.match(js, /"aria-label":\s*text/);
344
+ assert.match(js, /title:\s*rawText/);
345
+ assert.match(js, /"aria-label":\s*rawText/);
346
+ });
347
+
348
+ test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
349
+ const res = await dispatch("GET", "/app/runtime.js");
350
+ const js = res.body ?? "";
351
+ // A column declaring `template: "{{a}}/{{b}}"` renders one cell by splicing the
352
+ // named row fields into a string, coerced to text and set only as DOM text
353
+ // (never markup). Guard the whole shape so the primitive can't silently
354
+ // regress: interpTemplate exists, replaces {{field}} tokens via a function
355
+ // replacement (so a "$"-bearing value can't be reinterpreted as a replacement
356
+ // pattern), coerces null/undefined to "" (unknown tokens render blank, no
357
+ // throw), and gridCell prefers a string col.template over the raw field value
358
+ // for the visible text while the badge stays keyed to the raw field.
359
+ assert.match(js, /function interpTemplate\(tpl, row\)/);
360
+ assert.match(js, /replace\(\/\\\{\\\{\(\[\^\{\}\]\+\)\\\}\\\}\/g,/);
361
+ assert.match(js, /const key = name\.trim\(\);/);
362
+ // Unknown tokens (no such own field) render blank and, crucially, an inherited
363
+ // Object.prototype key (toString, constructor, …) counts as unknown rather than
364
+ // resolving to prototype cruft — guard the own-property gate explicitly.
365
+ assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
366
+ assert.match(js, /return v == null \? "" : String\(v\);/);
367
+ // gridCell keeps the raw single-field value separate (drives the badge gate)
368
+ // and lets a string template win for the rendered text.
369
+ assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
370
+ assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
346
371
  });
347
372
 
348
373
  test("renderer wires a column's processExplorer link to the console explorer", async () => {
@@ -502,6 +527,48 @@ test("the renderer honours boolean (checkbox) actionForm fields", async () => {
502
527
  assert.match(js, /input\.checked = input\.defaultChecked;/);
503
528
  });
504
529
 
530
+ test("the renderer enforces required actionForm fields client-side, blocking submit with an inline hint", async () => {
531
+ const res = await dispatch("GET", "/app/runtime.js");
532
+ const js = res.body ?? "";
533
+ // A field declared `required: true` is tracked in a Set and given an inline
534
+ // error node; the label carries a danger-toned "*" marker before submit.
535
+ assert.match(js, /required\.add\(f\.key\)/);
536
+ assert.match(js, /reqMark = \(f\) =>/);
537
+ assert.match(js, /class: "pc-req"/);
538
+ // The input is marked aria-required, and a clear-on-edit handler wipes the hint
539
+ // the moment the user starts fixing the field (input for text/number, change for
540
+ // a checkbox).
541
+ assert.match(js, /input\.setAttribute\("aria-required", "true"\)/);
542
+ assert.match(js, /wireField\(f, input, ferr, "input"\)/);
543
+ assert.match(js, /wireField\(f, input, ferr, "change"\)/);
544
+ // Submit is GATED: an empty required text/number or an unchecked required
545
+ // checkbox is "missing"; the first offender is focused and the submit RETURNS
546
+ // early (never round-trips to the server).
547
+ assert.match(js, /input\.checked !== true/);
548
+ assert.match(js, /!Number\.isFinite\(Number\(val\)\)/);
549
+ assert.match(js, /if \(firstInvalid\)/);
550
+ assert.match(js, /Please fill in the required fields\./);
551
+ assert.match(js, /firstInvalid\.focus\(\)/);
552
+ assert.match(js, /input\.classList\.add\("pc-invalid"\)/);
553
+ // Editing a required field after a blocked submit clears the summary banner too,
554
+ // so "Please fill in the required fields." never lingers stale while correcting.
555
+ assert.match(js, /msg\.textContent = "";\s*\n\s*msg\.className = "pc-msg";/);
556
+ // A per-field custom message (`requiredMessage`) overrides the default "Required".
557
+ assert.match(js, /reqMsg\.get\(k\) \|\| "Required"/);
558
+ assert.match(js, /f\.requiredMessage/);
559
+ });
560
+
561
+ test("the shell styles required-field markers and inline field errors", async () => {
562
+ const res = await dispatch("GET", "/");
563
+ const html = res.body ?? "";
564
+ // The "*" required marker and the inline per-field error both resolve through
565
+ // the shared danger token, and an empty error node collapses (no reserved gap).
566
+ assert.match(html, /\.pc-req \{ color:var\(--nano-danger\)/);
567
+ assert.match(html, /\.pc-field-err \{ font-size:.75rem; color:var\(--nano-danger\)/);
568
+ assert.match(html, /\.pc-field-err:empty \{ display:none; \}/);
569
+ assert.match(html, /input\.pc-invalid/);
570
+ });
571
+
505
572
  test("renderer makes collapsible nodes persist their state across sessions", async () => {
506
573
  // A node with `collapsible` gets a clickable header that hides/shows the card
507
574
  // body, and the collapsed state is written to localStorage keyed by the home
@@ -533,6 +533,10 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
533
533
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
534
534
  .pc-msg.err { color:var(--nano-danger); }
535
535
  .pc-msg.ok { color:var(--nano-ok); }
536
+ .pc-req { color:var(--nano-danger); font-weight:600; }
537
+ .pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
538
+ .pc-field-err:empty { display:none; }
539
+ .pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
536
540
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
537
541
  /* table-layout:fixed divides the available width across columns and wraps text
538
542
  within each, so a grid with several long free-text columns (e.g. the epic
@@ -834,6 +838,27 @@ function el(tag, attrs = {}, ...kids) {
834
838
  function interpParam(s) {
835
839
  return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
836
840
  }
841
+
842
+ // Interpolate {{field}} tokens in a column's per-cell template with values from
843
+ // the row, coercing each to a string (null/undefined → ""). Like interpParam the
844
+ // result is only ever set as DOM text (textContent via el()), so a substituted
845
+ // row value cannot inject markup or attributes. A function replacement inserts
846
+ // each value literally, so a "$"-bearing value can't be reinterpreted as a
847
+ // replacement pattern. Unknown tokens (no such field) render empty rather than
848
+ // throwing, matching interpParam. This is a dumb text formatter: no expressions,
849
+ // arithmetic, or conditionals — just field splicing. Only the row's own
850
+ // fields resolve (own-property gate, enumerable or not): an inherited
851
+ // Object.prototype key (toString, constructor, …) counts as unknown and
852
+ // renders "", so a token can't pick up prototype cruft instead of blank.
853
+ function interpTemplate(tpl, row) {
854
+ return typeof tpl === "string"
855
+ ? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
856
+ const key = name.trim();
857
+ const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
858
+ return v == null ? "" : String(v);
859
+ })
860
+ : tpl;
861
+ }
837
862
  function renderText(node) {
838
863
  const v = node.props.variant;
839
864
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
@@ -998,8 +1023,27 @@ function renderButton(node) {
998
1023
  // host's own explorer view in place via the nano-navigate postMessage bridge
999
1024
  // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
1000
1025
  // grid and child grids.
1026
+ //
1027
+ // Cell TEXT (the 'text' local below): the visible string a cell renders. By
1028
+ // default it is the column's single 'field' value, but an optional
1029
+ // 'col.template' string wins: its {{field}} tokens are spliced from the row
1030
+ // (interpTemplate) into DOM text. A template lets one cell combine several
1031
+ // fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
1032
+ // without denormalising a display column into the datasource. The template
1033
+ // drives the rendered text for the plain, linkField and link
1034
+ // (page/processExplorer) paths — a link's href still comes from its own
1035
+ // keyField/linkField, never the template. The 'badge' presence gate and its
1036
+ // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
1037
+ // the field text), so a template does not change badge behaviour. Sorting,
1038
+ // groupBy and orderBy also key off real fields, never the rendered template.
1001
1039
  function gridCell(col, row) {
1002
- const text = row[col.field] == null ? "" : String(row[col.field]);
1040
+ // Raw single-field value: drives the badge presence gate + tooltip and any
1041
+ // field-derived defaults. Kept separate from the (possibly templated) display
1042
+ // text so a template never re-lights or blanks a badge.
1043
+ const rawText = row[col.field] == null ? "" : String(row[col.field]);
1044
+ // Visible text: a col.template (interpolated from the row) wins over the
1045
+ // raw field value, else the field value is shown verbatim.
1046
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
1003
1047
  // 1. badge — a compact status indicator. When the row's field value is
1004
1048
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
1005
1049
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -1011,14 +1055,14 @@ function gridCell(col, row) {
1011
1055
  // field text is also mirrored into aria-label so assistive tech announces
1012
1056
  // the incident/status text (the visible "1" glyph alone is not meaningful).
1013
1057
  if (col.badge) {
1014
- if (text.trim() === "") return el("td", {});
1058
+ if (rawText.trim() === "") return el("td", {});
1015
1059
  const t = col.badge.tone;
1016
1060
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
1017
1061
  const label = col.badge.label == null ? "1" : String(col.badge.label);
1018
1062
  return el(
1019
1063
  "td",
1020
1064
  {},
1021
- el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
1065
+ el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1022
1066
  );
1023
1067
  }
1024
1068
  if (col.linkField) {
@@ -1083,9 +1127,38 @@ function renderActionForm(node) {
1083
1127
  // pollute Object.prototype or shadow inherited props (prototype-pollution class).
1084
1128
  const inputs = new Map();
1085
1129
  const fieldTypes = new Map();
1130
+ // Client-side required-field validation state: the set of required keys, each
1131
+ // field's custom message, and its inline error node (populated on a blocked
1132
+ // submit, cleared as soon as the user edits the field).
1133
+ const required = new Set();
1134
+ const reqMsg = new Map();
1135
+ const errs = new Map();
1136
+ // A required field's label carries a danger-toned "*" so the requirement is
1137
+ // visible BEFORE submit; spread into el(...) — an empty array adds no child.
1138
+ const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
1139
+ // Register a field's inline error node and, when required, mark the input
1140
+ // (aria-required) and wire a clear-on-edit handler so a hint disappears the
1141
+ // moment the user starts fixing it. Editing a required field also clears the
1142
+ // blocked-submit summary ("Please fill in the required fields.") so it never
1143
+ // lingers as a stale/misleading banner while the user is correcting input.
1144
+ const wireField = (f, input, ferr, evt) => {
1145
+ errs.set(f.key, ferr);
1146
+ if (f.required !== true) return;
1147
+ required.add(f.key);
1148
+ if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
1149
+ input.setAttribute("aria-required", "true");
1150
+ input.addEventListener(evt, () => {
1151
+ input.classList.remove("pc-invalid");
1152
+ input.removeAttribute("aria-invalid");
1153
+ ferr.textContent = "";
1154
+ msg.textContent = "";
1155
+ msg.className = "pc-msg";
1156
+ });
1157
+ };
1086
1158
  for (const f of p.fields || []) {
1087
1159
  const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
1088
1160
  fieldTypes.set(f.key, kind);
1161
+ const ferr = el("p", { class: "pc-field-err" });
1089
1162
  if (kind === "checkbox") {
1090
1163
  // A boolean field renders a real checkbox; its default checked state comes
1091
1164
  // from default/checked on the field (unset -> unchecked). We record that on
@@ -1096,9 +1169,10 @@ function renderActionForm(node) {
1096
1169
  input.defaultChecked = checked;
1097
1170
  input.checked = checked;
1098
1171
  inputs.set(f.key, input);
1172
+ wireField(f, input, ferr, "change");
1099
1173
  // Input-first, wrapped in the label so the whole row is a click target.
1100
1174
  card.append(
1101
- el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
1175
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
1102
1176
  );
1103
1177
  continue;
1104
1178
  }
@@ -1111,11 +1185,43 @@ function renderActionForm(node) {
1111
1185
  }
1112
1186
  const input = el("input", attrs);
1113
1187
  inputs.set(f.key, input);
1114
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
1188
+ wireField(f, input, ferr, "input");
1189
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
1115
1190
  }
1116
1191
  const msg = el("p", { class: "pc-msg" });
1117
1192
  const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
1118
1193
  btn.addEventListener("click", async () => {
1194
+ // Required-field gate: block the submit and surface an inline hint on each
1195
+ // empty required field (a text/number left blank, a required checkbox left
1196
+ // unchecked), focusing the first offender — so a missing value never
1197
+ // round-trips to the server only to bounce back as a generic error.
1198
+ let firstInvalid = null;
1199
+ for (const [k, input] of inputs) {
1200
+ const ferr = errs.get(k);
1201
+ if (ferr) ferr.textContent = "";
1202
+ input.classList.remove("pc-invalid");
1203
+ input.removeAttribute("aria-invalid");
1204
+ if (!required.has(k)) continue;
1205
+ const type = fieldTypes.get(k);
1206
+ const val = String(input.value).trim();
1207
+ const missing = type === "checkbox"
1208
+ ? input.checked !== true
1209
+ : type === "number"
1210
+ ? (val === "" || !Number.isFinite(Number(val)))
1211
+ : val === "";
1212
+ if (missing) {
1213
+ if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
1214
+ input.classList.add("pc-invalid");
1215
+ input.setAttribute("aria-invalid", "true");
1216
+ if (!firstInvalid) firstInvalid = input;
1217
+ }
1218
+ }
1219
+ if (firstInvalid) {
1220
+ msg.className = "pc-msg err";
1221
+ msg.textContent = "Please fill in the required fields.";
1222
+ firstInvalid.focus();
1223
+ return;
1224
+ }
1119
1225
  const variables = Object.create(null);
1120
1226
  for (const [k, input] of inputs) {
1121
1227
  if (fieldTypes.get(k) === "checkbox") {
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  emitDomainModelJson,
5
5
  resolveShapes,
6
+ scanModelShapes,
6
7
  type ShapeDecl,
7
8
  } from "./shapes.ts";
8
9
  import type { DomainTypeRegistry, SourceSchema } from "./domain.ts";
@@ -169,3 +170,68 @@ test("resolveShapes returns an empty resolution for no shapes", () => {
169
170
  assert.deepEqual(types, {});
170
171
  assert.deepEqual(diagnostics, []);
171
172
  });
173
+
174
+ // --- scanModelShapes: the standalone-`urban gen` port of the `nano:shape` XML scan --------------
175
+
176
+ const SHAPES_BPMN = `<?xml version="1.0"?>
177
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
178
+ xmlns:nano="http://nanobpm.io/schema/nano/1.0">
179
+ <bpmn:process id="p1">
180
+ <bpmn:extensionElements>
181
+ <nano:shapes>
182
+ <nano:shape id="ReviewIn" name="Review — input">
183
+ <nano:extend name="prUrl" type="string" />
184
+ <nano:extend name="round" type="integer" />
185
+ <nano:extend name="answer" type="string" optional="true" />
186
+ </nano:shape>
187
+ <nano:shape id="Composed">
188
+ <nano:carry ref="ReviewIn" />
189
+ <nano:project ref="Customer" fields="tier, region" via="Order.customerId" />
190
+ <nano:reference name="lines" ref="OrderLine" spread="false" list="true" />
191
+ </nano:shape>
192
+ <nano:shape name="no-id-dropped"><nano:carry ref="ReviewIn" /></nano:shape>
193
+ </nano:shapes>
194
+ </bpmn:extensionElements>
195
+ </bpmn:process>
196
+ </bpmn:definitions>`;
197
+
198
+ test("scanModelShapes lifts nano:shape declarations with ops in author order", () => {
199
+ const shapes = scanModelShapes(SHAPES_BPMN);
200
+ // The id-less shape is dropped; the two identified shapes survive, process-tagged.
201
+ assert.deepEqual(shapes.map((s) => s.id), ["ReviewIn", "Composed"]);
202
+ assert.equal(shapes[0].process, "p1");
203
+ assert.equal(shapes[0].name, "Review — input");
204
+ assert.deepEqual(shapes[0].ops, [
205
+ { op: "extend", name: "prUrl", type: "string", optional: false, list: false },
206
+ { op: "extend", name: "round", type: "integer", optional: false, list: false },
207
+ { op: "extend", name: "answer", type: "string", optional: true, list: false },
208
+ ]);
209
+ assert.deepEqual(shapes[1].ops, [
210
+ { op: "carry", ref: "ReviewIn" },
211
+ { op: "project", ref: "Customer", fields: ["tier", "region"], via: "Order.customerId" },
212
+ { op: "reference", name: "lines", ref: "OrderLine", spread: false, list: true },
213
+ ]);
214
+ });
215
+
216
+ test("scanModelShapes → resolveShapes round-trips an all-extend envelope into DomainTypes", () => {
217
+ const shapes = scanModelShapes(SHAPES_BPMN).filter((s) => s.id === "ReviewIn");
218
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
219
+ assert.deepEqual(diagnostics, []);
220
+ assert.deepEqual(types.ReviewIn.fields, {
221
+ prUrl: { type: "string" },
222
+ round: { type: "integer" },
223
+ answer: { type: "string", optional: true },
224
+ });
225
+ });
226
+
227
+ test("scanModelShapes drops an op missing a required attribute", () => {
228
+ const xml = `<bpmn:process id="p"><bpmn:extensionElements><nano:shapes>
229
+ <nano:shape id="S">
230
+ <nano:extend name="ok" type="string" />
231
+ <nano:extend name="noType" />
232
+ <nano:carry />
233
+ </nano:shape>
234
+ </nano:shapes></bpmn:extensionElements></bpmn:process>`;
235
+ const [s] = scanModelShapes(xml);
236
+ assert.deepEqual(s.ops, [{ op: "extend", name: "ok", type: "string", optional: false, list: false }]);
237
+ });
@@ -50,6 +50,99 @@ export interface ShapeDecl {
50
50
  meta?: Record<string, string>;
51
51
  }
52
52
 
53
+ // --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
54
+ // The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
55
+ // `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
56
+ // lift model-authored shapes itself rather than receive them from the host reifier.
57
+
58
+ /** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
59
+ function shapeAttr(tag: string, name: string): string | undefined {
60
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
61
+ const v = m?.[1]?.trim();
62
+ return v != null && v.length > 0 ? v : undefined;
63
+ }
64
+
65
+ /** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
66
+ function shapeBool(tag: string, name: string): boolean {
67
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
68
+ return m?.[1]?.trim() === "true";
69
+ }
70
+
71
+ /** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
72
+ function splitFields(raw: string | undefined): string[] {
73
+ return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
74
+ }
75
+
76
+ /** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
77
+ function processId(xml: string): string | undefined {
78
+ const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
79
+ return m ? shapeAttr(m[0], "id") : undefined;
80
+ }
81
+
82
+ // One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
83
+ const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
84
+ // One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
85
+ const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
86
+
87
+ /** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
88
+ * (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
89
+ * mirrors `parse_shape_op` in `envelope_scan.rs`. */
90
+ function parseShapeOp(local: string, attrs: string): ShapeOp | undefined {
91
+ const tag = `<x ${attrs}>`;
92
+ switch (local) {
93
+ case "carry": {
94
+ const ref = shapeAttr(tag, "ref");
95
+ return ref ? { op: "carry", ref } : undefined;
96
+ }
97
+ case "project": {
98
+ const ref = shapeAttr(tag, "ref");
99
+ if (!ref) return undefined;
100
+ return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
101
+ }
102
+ case "extend": {
103
+ const name = shapeAttr(tag, "name");
104
+ const type = shapeAttr(tag, "type");
105
+ if (!name || !type) return undefined;
106
+ return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
107
+ }
108
+ case "reference": {
109
+ const name = shapeAttr(tag, "name");
110
+ const ref = shapeAttr(tag, "ref");
111
+ if (!name || !ref) return undefined;
112
+ return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
113
+ }
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ /** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
120
+ * tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
121
+ * are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
122
+ * half of the console's `envelope_scan.rs`. */
123
+ export function scanModelShapes(xml: string): ShapeDecl[] {
124
+ const proc = processId(xml);
125
+ const out: ShapeDecl[] = [];
126
+ let sm: RegExpExecArray | null;
127
+ SHAPE_RE.lastIndex = 0;
128
+ while ((sm = SHAPE_RE.exec(xml)) !== null) {
129
+ const open = `<x ${sm[2]}>`;
130
+ const id = shapeAttr(open, "id");
131
+ if (!id) continue;
132
+ const name = shapeAttr(open, "name");
133
+ const body = sm[3] ?? "";
134
+ const ops: ShapeOp[] = [];
135
+ let om: RegExpExecArray | null;
136
+ SHAPE_OP_RE.lastIndex = 0;
137
+ while ((om = SHAPE_OP_RE.exec(body)) !== null) {
138
+ const op = parseShapeOp(om[2], om[3] ?? "");
139
+ if (op) ops.push(op);
140
+ }
141
+ out.push({ id, name, process: proc, ops });
142
+ }
143
+ return out;
144
+ }
145
+
53
146
  /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
54
147
  * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
55
148
  * deliberate field shadow) still resolves. */
@@ -2,11 +2,14 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  deriveWorkerBindings,
5
+ detectEnvelopeConflicts,
5
6
  emitWorkerBindings,
7
+ formatEnvelopeConflict,
6
8
  scanModelWorkers,
7
9
  byModelPath,
8
10
  type WorkerIo,
9
11
  } from "./worker-io.ts";
12
+ import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
10
13
 
11
14
  test("byModelPath is a spec-compliant comparator: returns 0 for equal paths", () => {
12
15
  assert.equal(byModelPath({ path: "a" }, { path: "a" }), 0);
@@ -72,6 +75,134 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
72
75
  assert.match(out, /export interface WorkerInputs \{\}/);
73
76
  });
74
77
 
78
+ test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
79
+ // The same worker services two processes, each declaring its own envelope. Keyed by taskType,
80
+ // the member is the UNION — a shared handler reads only what every caller guarantees.
81
+ const out = emitWorkerBindings(
82
+ [
83
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
84
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
85
+ ],
86
+ ["PrPersistEscalationIn", "MergeEscalationIn"],
87
+ );
88
+ assert.match(
89
+ out,
90
+ /"pr.persist-escalation": DomainTypes\["PrPersistEscalationIn"\] \| DomainTypes\["MergeEscalationIn"\];/,
91
+ );
92
+ });
93
+
94
+ test("emitWorkerBindings collapses a repeated identical envelope to a single ref (no dup keys)", () => {
95
+ const out = emitWorkerBindings(
96
+ [
97
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
98
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
99
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
100
+ ],
101
+ ["EscalationIn"],
102
+ );
103
+ // Exactly one member, no union, and the interface still parses (no duplicate identifier).
104
+ assert.match(out, /export interface WorkerInputs \{\n {2}"pr.persist-escalation": DomainTypes\["EscalationIn"\];\n\}/);
105
+ });
106
+
107
+ test("detectEnvelopeConflicts stays silent for a compatible subset (nwf escalation shape)", () => {
108
+ const types: DomainTypeRegistry = {
109
+ PrPersistEscalationIn: {
110
+ fields: {
111
+ prKey: { type: "string" },
112
+ round: { type: "integer" },
113
+ recordRound: { type: "boolean", optional: true },
114
+ },
115
+ },
116
+ MergeEscalationIn: {
117
+ fields: { prKey: { type: "string" }, round: { type: "integer" } },
118
+ },
119
+ };
120
+ const conflicts = detectEnvelopeConflicts(
121
+ [
122
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
123
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
124
+ ],
125
+ types,
126
+ );
127
+ // A field present in one and absent in the other, or optional in one, is NOT a conflict.
128
+ assert.deepEqual(conflicts, []);
129
+ });
130
+
131
+ test("detectEnvelopeConflicts flags a genuine field-type clash across envelopes", () => {
132
+ const types: DomainTypeRegistry = {
133
+ A: { fields: { round: { type: "integer" } } },
134
+ B: { fields: { round: { type: "string" } } },
135
+ };
136
+ const conflicts = detectEnvelopeConflicts(
137
+ [
138
+ { taskType: "pr.review", inputType: "A" },
139
+ { taskType: "pr.review", inputType: "B" },
140
+ ],
141
+ types,
142
+ );
143
+ assert.equal(conflicts.length, 1);
144
+ assert.equal(conflicts[0].taskType, "pr.review");
145
+ assert.equal(conflicts[0].slot, "in");
146
+ assert.equal(conflicts[0].field, "round");
147
+ assert.deepEqual(conflicts[0].types.sort(), ["integer", "string"]);
148
+ assert.match(formatEnvelopeConflict(conflicts[0]), /field "round" is integer vs string/);
149
+ });
150
+
151
+ test("detectEnvelopeConflicts treats a list mismatch as a conflict", () => {
152
+ const types: DomainTypeRegistry = {
153
+ A: { fields: { tags: { type: "string" } } },
154
+ B: { fields: { tags: { type: "string", list: true } } },
155
+ };
156
+ const conflicts = detectEnvelopeConflicts(
157
+ [
158
+ { taskType: "t", outputType: "A" },
159
+ { taskType: "t", outputType: "B" },
160
+ ],
161
+ types,
162
+ );
163
+ assert.equal(conflicts.length, 1);
164
+ assert.equal(conflicts[0].slot, "out");
165
+ assert.deepEqual(conflicts[0].types.sort(), ["string", "string[]"]);
166
+ });
167
+
168
+ test("detectEnvelopeConflicts uses own-property checks for envelope ids (no prototype-chain false positives)", () => {
169
+ const types: DomainTypeRegistry = {
170
+ Real: { fields: { prKey: { type: "string" } } },
171
+ };
172
+ // An author-named envelope id that only exists on Object.prototype ("toString", "constructor")
173
+ // must be treated as undeclared and skipped — not read through the prototype chain, which would
174
+ // yield a non-registry value and (previously) crash on `.fields`.
175
+ const conflicts = detectEnvelopeConflicts(
176
+ [
177
+ { taskType: "t", inputType: "Real" },
178
+ { taskType: "t", inputType: "toString" },
179
+ { taskType: "t", inputType: "constructor" },
180
+ ],
181
+ types,
182
+ );
183
+ assert.deepEqual(conflicts, []);
184
+ });
185
+
186
+ test("detectEnvelopeConflicts uses own-property checks for field names (no prototype-chain false positives)", () => {
187
+ // Envelope A carries an own field literally named "toString"; B does not carry it at all.
188
+ // A prototype-chain read (`B.fields["toString"]`) would resolve to Object.prototype.toString and
189
+ // fabricate a spurious type conflict; an own-property check correctly sees it as a presence
190
+ // difference, which is not a conflict.
191
+ const aFields: Record<string, DomainFieldDef> = { toString: { type: "string" } };
192
+ const types: DomainTypeRegistry = {
193
+ A: { fields: aFields },
194
+ B: { fields: { prKey: { type: "string" } } },
195
+ };
196
+ const conflicts = detectEnvelopeConflicts(
197
+ [
198
+ { taskType: "t", inputType: "A" },
199
+ { taskType: "t", inputType: "B" },
200
+ ],
201
+ types,
202
+ );
203
+ assert.deepEqual(conflicts, []);
204
+ });
205
+
75
206
  test("deriveWorkerBindings is deterministic and emits worker-io.d.ts", () => {
76
207
  const models = [{ path: "processes/orders.bpmn", xml: XML }];
77
208
  const a1 = deriveWorkerBindings(models, ["Payment", "Receipt"]);