@ggui-ai/ui-gen 0.10.0 → 0.12.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.
Files changed (35) hide show
  1. package/dist/adapters/generation-dispatch.js +328 -24
  2. package/dist/adapters/generation-dispatch.js.map +1 -1
  3. package/dist/advanced/index.d.ts +1 -1
  4. package/dist/advanced/index.js +333 -29
  5. package/dist/advanced/index.js.map +1 -1
  6. package/dist/blueprint-validator.js +307 -3
  7. package/dist/blueprint-validator.js.map +1 -1
  8. package/dist/boilerplate.js +7 -7
  9. package/dist/boilerplate.js.map +1 -1
  10. package/dist/check/index.js +27 -11
  11. package/dist/check/index.js.map +1 -1
  12. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  13. package/dist/harness/check/runtime-render/index.js +307 -3
  14. package/dist/harness/check/runtime-render/index.js.map +1 -1
  15. package/dist/harness/check/runtime-render/render-check-worker.d.ts +2 -0
  16. package/dist/harness/check/runtime-render/render-check-worker.js +1458 -0
  17. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -0
  18. package/dist/harness/index.js +7 -7
  19. package/dist/harness/index.js.map +1 -1
  20. package/dist/harness/runtime.js +7 -7
  21. package/dist/harness/runtime.js.map +1 -1
  22. package/dist/harness/types-public.js +7 -7
  23. package/dist/harness/types-public.js.map +1 -1
  24. package/dist/index.js +327 -23
  25. package/dist/index.js.map +1 -1
  26. package/dist/provider-adapter-contract.js +1161 -387
  27. package/dist/provider-adapter-contract.js.map +1 -1
  28. package/dist/{render-check-BNYGVDJd.d.ts → render-check-WfOVO9kU.d.ts} +21 -1
  29. package/package.json +12 -12
  30. package/src/boilerplate/system-prompt.ts +7 -7
  31. package/src/check/run-tier0.ts +35 -16
  32. package/src/harness/check/runtime-render/adapter.ts +28 -0
  33. package/src/harness/check/runtime-render/render-check-host.ts +201 -0
  34. package/src/harness/check/runtime-render/render-check-worker.ts +80 -0
  35. package/src/harness/check/runtime-render/render-check.ts +264 -1
@@ -21,7 +21,12 @@ import type {
21
21
  PropsSpec,
22
22
  } from "@ggui-ai/protocol";
23
23
  import { HOOK_NAME_RE, listContractGadgets } from "@ggui-ai/protocol";
24
- import { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
24
+ import {
25
+ createProbe,
26
+ createProbeWireConfig,
27
+ type ActionFiredEvent,
28
+ type Probe,
29
+ } from "./probe.js";
25
30
  import { loadComponent } from "./load-component.js";
26
31
  import { findWiring, type WiringDetection } from "./find-wiring.js";
27
32
  import { installProductionActShim } from "./production-act-shim.js";
@@ -70,7 +75,9 @@ export interface RenderCheckIssue {
70
75
  readonly check:
71
76
  | "render-no-throw"
72
77
  | "action-wiring"
78
+ | "selection-identity"
73
79
  | "prop-coverage"
80
+ | "prop-sensitivity"
74
81
  | "stream-rerender"
75
82
  | "optional-props-omitted";
76
83
  readonly outcome: CheckOutcome;
@@ -114,8 +121,48 @@ export interface RunRenderCheckInput {
114
121
  // Public entry
115
122
  // ─────────────────────────────────────────────────────────────────────────────
116
123
 
124
+ /**
125
+ * Run the in-loop runtime render check.
126
+ *
127
+ * Isolation dispatcher (#592): when the CALLING environment already
128
+ * owns a DOM (`window` + `document` on `globalThis` — e.g. a vitest
129
+ * `happy-dom` environment, where the environment owner installed the
130
+ * globals deliberately), the check runs in-process exactly as before.
131
+ * In a plain Node process it runs in an ISOLATED SUBPROCESS
132
+ * (`render-check-host.ts` → `render-check-worker.ts`), because the
133
+ * in-process path installs happy-dom globals on `globalThis`
134
+ * process-wide — a TOCTOU hazard for anything that sniffs `window`
135
+ * concurrently (provider SDKs throw "browser-like environment" at
136
+ * client construction; two overlapping checks corrupt each other's
137
+ * teardown). See #592 for the measured incident (4 bench cells hard-
138
+ * failed on the 2026-08-19 run).
139
+ *
140
+ * The host is loaded lazily so the worker bundle (which imports this
141
+ * module for `runRenderCheckInProcess`) never drags in the sandbox
142
+ * spawn machinery.
143
+ */
117
144
  export async function runRenderCheck(
118
145
  input: RunRenderCheckInput,
146
+ ): Promise<RenderCheckResult> {
147
+ const g = hostGlobals();
148
+ if ("window" in g && "document" in g) {
149
+ return runRenderCheckInProcess(input);
150
+ }
151
+ const { runRenderCheckViaWorker } = await import("./render-check-host.js");
152
+ return runRenderCheckViaWorker(input);
153
+ }
154
+
155
+ /**
156
+ * The actual check, executed IN THE CURRENT PROCESS — installs
157
+ * happy-dom globals on `globalThis` for the duration when the
158
+ * environment doesn't already provide a DOM. Call sites outside this
159
+ * module should use {@link runRenderCheck}; this export exists for the
160
+ * isolation worker (which IS the private process the globals may own)
161
+ * and for DOM-owning test environments that want to bypass the
162
+ * dispatcher explicitly.
163
+ */
164
+ export async function runRenderCheckInProcess(
165
+ input: RunRenderCheckInput,
119
166
  ): Promise<RenderCheckResult> {
120
167
  const t0 = Date.now();
121
168
  const issues: RenderCheckIssue[] = [];
@@ -479,6 +526,35 @@ export async function runRenderCheck(
479
526
  }
480
527
  }
481
528
 
529
+ // ── Check 2b: Selection identity — duplicate-label cells (#601) ───
530
+ // The live bug: a slot grid rendered several cells with the same
531
+ // visible label ("09:00" across stylist rows); clicking one
532
+ // selected ALL of them because the generated code keyed
533
+ // selection/payload on the DISPLAY VALUE instead of the cell's
534
+ // identity — even though the action schema carried an id field.
535
+ // Wire-behavior check, DOM-shape independent: click two
536
+ // same-label clickables and require their fired payloads to
537
+ // differ on the id-like field. Conservative: any ambiguity
538
+ // (no duplicate labels, clicks that fire nothing, payloads
539
+ // missing the field) yields NO issue — those belong to other
540
+ // checks.
541
+ if (input.contract?.actionSpec) {
542
+ for (const [name, entry] of Object.entries(input.contract.actionSpec)) {
543
+ const idField = idLikeRequiredStringField(
544
+ entry.schema as Record<string, unknown> | undefined,
545
+ );
546
+ if (idField === null) continue;
547
+ const issue = await checkSelectionIdentity({
548
+ container,
549
+ probe,
550
+ user,
551
+ actionName: name,
552
+ idField,
553
+ });
554
+ if (issue) issues.push(issue);
555
+ }
556
+ }
557
+
482
558
  // ── Check 3: Agent-tool wiring — RETIRED ──────────────────────────
483
559
  // `useWiredTool` is retired. The contract's `agentCapabilities.tools`
484
560
  // is a catalog of tools the AGENT invokes — there is no component hook
@@ -505,6 +581,72 @@ export async function runRenderCheck(
505
581
  }
506
582
  }
507
583
 
584
+ // ── Check 5c: Prop sensitivity (#563) ─────────────────────────────
585
+ // The measured class (economy/001 run 6): generated code renders a
586
+ // value the contract delivers via props but BAKES the request's
587
+ // literal — `props.month ?? 'January'` date math, hardcoded display
588
+ // text — so a cached blueprint reused under a different value serves
589
+ // the wrong surface. Deterministic detection for the DISPLAYED-value
590
+ // half: re-render with a distinct value for each required scalar
591
+ // prop whose original value visibly appears in the DOM; if the
592
+ // rendered text is IDENTICAL under the change, the prop's value is
593
+ // dead — baked. Scope honesty: value-ANCHORED logic that still
594
+ // reacts (differently-wrong branches) is not deterministically
595
+ // decidable here; the matcher-side pair armor and the judge track
596
+ // that half. Conservative nulls: non-displayed props (ids are
597
+ // legitimately non-visual), varied-value crashes (the code READ the
598
+ // prop — not dead), and non-scalar props are all skipped.
599
+ if (input.contract?.propsSpec) {
600
+ const propsSpec5c = input.contract.propsSpec as PropsSpec;
601
+ const normalize = (s: string | null): string =>
602
+ (s ?? "").replace(/\s+/g, " ").trim();
603
+ const baseText = normalize(container.textContent);
604
+ for (const [propName, entry] of Object.entries(propsSpec5c.properties)) {
605
+ if (!entry.required) continue;
606
+ const orig = input.mockupProps[propName];
607
+ if (typeof orig !== "string" && typeof orig !== "number") continue;
608
+ const marker = pickScalarMarker(orig);
609
+ if (marker === null || !baseText.includes(marker)) continue;
610
+ // Distinct same-type value. A token that never substring-matches
611
+ // the original, so value-anchored branches (`includes(orig)`)
612
+ // genuinely flip.
613
+ const varied: string | number =
614
+ typeof orig === "string" ? "Zzyzx Qw" : (orig as number) + 137731;
615
+ // Isolated probe/wire per varied mount — events must not leak
616
+ // into the primary probe's fire log.
617
+ const variedProbe = createProbe();
618
+ const variedConfig = createProbeWireConfig(variedProbe);
619
+ let variedResult: ReturnType<typeof render> | undefined;
620
+ try {
621
+ variedResult = render(
622
+ React.createElement(GguiWireProvider, {
623
+ config: variedConfig,
624
+ children: React.createElement(ProbeErrorBoundary, {
625
+ children: React.createElement(Component, {
626
+ ...input.mockupProps,
627
+ [propName]: varied,
628
+ }),
629
+ }),
630
+ }),
631
+ );
632
+ } catch {
633
+ continue; // crash on varied value ⇒ the prop is read, not dead
634
+ }
635
+ await flushPromises();
636
+ const variedText = normalize(variedResult.container.textContent);
637
+ variedResult.unmount();
638
+ if (variedText === baseText) {
639
+ issues.push({
640
+ check: "prop-sensitivity",
641
+ outcome: "failed",
642
+ subject: propName,
643
+ reason:
644
+ `props.${propName} is displayed ('${marker}' appears in the DOM) but changing its value to '${String(varied)}' left the rendered output BYTE-IDENTICAL — the value is baked into the component instead of derived from the prop. A cached reuse of this blueprint under a different ${propName} would show the original value.`,
645
+ });
646
+ }
647
+ }
648
+ }
649
+
508
650
  // ── Check 6: Stream re-render (WARN) ──────────────────────────────
509
651
  if (input.contract?.streamSpec) {
510
652
  const streamSpec = input.contract.streamSpec;
@@ -1041,6 +1183,127 @@ async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIs
1041
1183
  // `streamSpec[*].source.tool` ride through the action / stream check
1042
1184
  // arms in `runRenderCheck` instead.
1043
1185
 
1186
+ // ─────────────────────────────────────────────────────────────────────────────
1187
+ // Selection identity (#601) — duplicate-label cells must carry their own id
1188
+ // ─────────────────────────────────────────────────────────────────────────────
1189
+
1190
+ /**
1191
+ * Find the id-like required string property in an action's payload
1192
+ * schema, or null. "id-like" = a required property whose name ends in
1193
+ * `id`/`Id`/`ID` and whose declared type is string — the shape the
1194
+ * matcher's booking/selection contracts carry (`bookSlot { slotId }`).
1195
+ */
1196
+ function idLikeRequiredStringField(
1197
+ schema: Record<string, unknown> | undefined,
1198
+ ): string | null {
1199
+ if (schema === undefined) return null;
1200
+ const properties = schema['properties'];
1201
+ const required = schema['required'];
1202
+ if (properties === null || typeof properties !== 'object') return null;
1203
+ if (!Array.isArray(required)) return null;
1204
+ for (const [propName, propSchema] of Object.entries(
1205
+ properties as Record<string, unknown>,
1206
+ )) {
1207
+ if (!/id$/i.test(propName)) continue;
1208
+ if (!required.includes(propName)) continue;
1209
+ const t =
1210
+ propSchema !== null && typeof propSchema === 'object'
1211
+ ? (propSchema as Record<string, unknown>)['type']
1212
+ : undefined;
1213
+ if (t === 'string') return propName;
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ interface CheckSelectionIdentityInput {
1219
+ container: MinimalElement;
1220
+ probe: Probe;
1221
+ user: { click: (el: MinimalElement) => Promise<void> };
1222
+ actionName: string;
1223
+ idField: string;
1224
+ }
1225
+
1226
+ /**
1227
+ * Click two clickables that share a visible label and require the two
1228
+ * fired payloads to DIFFER on the id-like field. Identical values mean
1229
+ * the component keys the action on the display value, not the element's
1230
+ * identity — the ggui#601 class (every "09:00" cell selecting together).
1231
+ *
1232
+ * Emits at most one `failed` issue; every ambiguous situation returns
1233
+ * null (covered by other checks or genuinely unjudgeable here).
1234
+ */
1235
+ async function checkSelectionIdentity(
1236
+ input: CheckSelectionIdentityInput,
1237
+ ): Promise<RenderCheckIssue | null> {
1238
+ const { container, probe, user, actionName, idField } = input;
1239
+
1240
+ const clickables = Array.from(
1241
+ container.querySelectorAll<MinimalElement>('button, [role="button"]'),
1242
+ );
1243
+ const byLabel = new Map<string, MinimalElement[]>();
1244
+ for (const el of clickables) {
1245
+ const label = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
1246
+ if (label.length === 0) continue;
1247
+ const group = byLabel.get(label);
1248
+ if (group) group.push(el);
1249
+ else byLabel.set(label, [el]);
1250
+ }
1251
+
1252
+ for (const [label, group] of byLabel) {
1253
+ if (group.length < 2) continue;
1254
+
1255
+ const payloadFor = async (
1256
+ el: MinimalElement,
1257
+ ): Promise<unknown | undefined> => {
1258
+ const before = probe.getFireLog().length;
1259
+ try {
1260
+ await user.click(el);
1261
+ } catch {
1262
+ return undefined;
1263
+ }
1264
+ await flushPromises();
1265
+ const events = probe
1266
+ .getFireLog()
1267
+ .slice(before)
1268
+ .filter(
1269
+ (e): e is ActionFiredEvent =>
1270
+ e.kind === 'action.fired' && e.name === actionName,
1271
+ );
1272
+ return events.length > 0 ? events[events.length - 1]!.payload : undefined;
1273
+ };
1274
+
1275
+ const first = await payloadFor(group[0]!);
1276
+ const second = await payloadFor(group[1]!);
1277
+ // Either click firing nothing for this action = not this check's
1278
+ // question (wiring check owns "does it fire").
1279
+ if (first === undefined || second === undefined) continue;
1280
+
1281
+ const idOf = (payload: unknown): string | undefined => {
1282
+ if (payload === null || typeof payload !== 'object') return undefined;
1283
+ const v = (payload as Record<string, unknown>)[idField];
1284
+ return typeof v === 'string' ? v : undefined;
1285
+ };
1286
+ const firstId = idOf(first);
1287
+ const secondId = idOf(second);
1288
+ // Payloads missing the required field = schema territory, not ours.
1289
+ if (firstId === undefined || secondId === undefined) continue;
1290
+
1291
+ if (firstId === secondId) {
1292
+ return {
1293
+ check: 'selection-identity',
1294
+ outcome: 'failed',
1295
+ subject: actionName,
1296
+ reason:
1297
+ `Two clickable elements share the visible label '${label}', and clicking EACH fired '${actionName}' with the same ${idField} ('${firstId}') — the action payload is keyed on the display value, not the element's identity. Distinct cells must send their own ${idField} (the contract requires it), or selecting one "${label}" acts on every "${label}".`,
1298
+ elementHint: describeElement(group[0]!),
1299
+ };
1300
+ }
1301
+ // Distinct ids on the first duplicate group — identity keying holds.
1302
+ return null;
1303
+ }
1304
+ return null;
1305
+ }
1306
+
1044
1307
  // ─────────────────────────────────────────────────────────────────────────────
1045
1308
  // Trigger simulators — narrow set, deterministic only
1046
1309
  // ─────────────────────────────────────────────────────────────────────────────