@ggui-ai/ui-gen 0.8.0 → 0.10.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/dist/adapters/generation-dispatch.d.ts +18 -1
- package/dist/adapters/generation-dispatch.js +819 -1577
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +28 -11
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +5 -3
- package/dist/advanced/index.js +838 -1579
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +109 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +17 -4
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +11 -1
- package/dist/check/index.js.map +1 -1
- package/dist/compose.js +5 -5
- package/dist/compose.js.map +1 -1
- package/dist/{create-ui-generator-GX7tvbyH.d.ts → create-ui-generator-CF0EL0Li.d.ts} +26 -1
- package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js +133 -1
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +1 -1
- package/dist/evaluation/axis-checks/registry.js +133 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +46 -3
- package/dist/evaluation/index.js +410 -24
- package/dist/evaluation/index.js.map +1 -1
- package/dist/fragments/index.js +5 -5
- package/dist/fragments/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -2
- package/dist/harness/check/runtime-render/index.js +110 -3
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/index.d.ts +5 -126
- package/dist/harness/index.js +292 -32
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +1 -1
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.js +17 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +1 -1
- package/dist/harness/runtime.d.ts +17 -1
- package/dist/harness/runtime.js +23 -8
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +1 -1
- package/dist/harness/types-public.js +22 -9
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +838 -1579
- package/dist/index.js.map +1 -1
- package/dist/llm-router-BEVtbAb9.d.ts +213 -0
- package/dist/patch.d.ts +1 -1
- package/dist/provider-adapter.d.ts +16 -6
- package/dist/provider-adapter.js.map +1 -1
- package/dist/providers/index.d.ts +54 -99
- package/dist/providers/index.js +13 -8
- package/dist/providers/index.js.map +1 -1
- package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
- package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
- package/dist/validation/index.js +2 -2
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +18 -18
- package/src/adapters/generation-dispatch.ts +28 -2
- package/src/adapters/openrouter/client.ts +2 -2
- package/src/adapters/openrouter/types.ts +10 -0
- package/src/boilerplate/generate.ts +10 -2
- package/src/boilerplate/json-schema-ts.ts +22 -2
- package/src/boilerplate/system-prompt.ts +4 -2
- package/src/compile.ts +1 -1
- package/src/contract-context.ts +10 -2
- package/src/create-ui-generator.ts +58 -11
- package/src/design-system-docs.ts +6 -4
- package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
- package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
- package/src/evaluation/axis-checks/checks/universal.ts +39 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/index.ts +11 -0
- package/src/evaluation/llm-evaluator.ts +26 -3
- package/src/evaluation/visual-evaluator.ts +49 -66
- package/src/fragments/realtime.ts +3 -3
- package/src/fragments/state.ts +2 -2
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/index.ts +8 -0
- package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
- package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
- package/src/harness/check/runtime-render/render-check.ts +72 -1
- package/src/harness/coding/init-session.ts +4 -1
- package/src/harness/coding/run-eval-round.ts +67 -5
- package/src/harness/index.ts +1 -1
- package/src/harness/llm-router.ts +451 -27
- package/src/harness/llm-trace-sink.ts +2 -1
- package/src/harness/runtime.ts +15 -0
- package/src/index.ts +5 -5
- package/src/provider-adapter.ts +16 -6
- package/src/providers/bedrock.ts +89 -26
- package/src/providers/index.ts +16 -4
- package/src/tools/render-check.ts +19 -7
- package/src/validation/ui-compiler.ts +2 -2
package/dist/workflows.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { C as Classification } from './axes-CzLEMDeB.js';
|
|
2
2
|
import { Workflow } from './harness/types-public.js';
|
|
3
3
|
import '@ggui-ai/protocol';
|
|
4
|
-
import './types-public-
|
|
4
|
+
import './types-public-DIpjC-OA.js';
|
|
5
5
|
import './types-BOvHNG7K.js';
|
|
6
6
|
import './llm.js';
|
|
7
7
|
import './policy.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/ui-gen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -185,12 +185,12 @@
|
|
|
185
185
|
"peerDependencies": {
|
|
186
186
|
"@google/adk": "^0.5.0",
|
|
187
187
|
"@openai/agents": "^0.7.1",
|
|
188
|
-
"puppeteer-core": "^24.40.0",
|
|
188
|
+
"puppeteer-core": "^24.40.0 || ^25.0.0",
|
|
189
189
|
"react": "^18.0.0 || ^19.0.0",
|
|
190
190
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
191
|
-
"@ggui-ai/mcp-server-core": "0.
|
|
192
|
-
"@ggui-ai/project-config": "0.
|
|
193
|
-
"@ggui-ai/protocol": "0.
|
|
191
|
+
"@ggui-ai/mcp-server-core": "0.10.0",
|
|
192
|
+
"@ggui-ai/project-config": "0.10.0",
|
|
193
|
+
"@ggui-ai/protocol": "0.10.0"
|
|
194
194
|
},
|
|
195
195
|
"peerDependenciesMeta": {
|
|
196
196
|
"@google/adk": {
|
|
@@ -210,9 +210,9 @@
|
|
|
210
210
|
}
|
|
211
211
|
},
|
|
212
212
|
"dependencies": {
|
|
213
|
-
"@anthropic-ai/bedrock-sdk": "^0.
|
|
214
|
-
"@anthropic-ai/claude-agent-sdk": "0.
|
|
215
|
-
"@anthropic-ai/sdk": "^0.
|
|
213
|
+
"@anthropic-ai/bedrock-sdk": "^0.32.1",
|
|
214
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.229",
|
|
215
|
+
"@anthropic-ai/sdk": "^0.116.0",
|
|
216
216
|
"@google/genai": "^2.8.0",
|
|
217
217
|
"@testing-library/dom": "^10.4.0",
|
|
218
218
|
"@testing-library/react": "^16.0.0",
|
|
@@ -229,28 +229,28 @@
|
|
|
229
229
|
"openai": "^6.29.0",
|
|
230
230
|
"typescript": "^5.0.0",
|
|
231
231
|
"zod": "^4.3.6",
|
|
232
|
-
"@ggui-ai/
|
|
233
|
-
"@ggui-ai/
|
|
234
|
-
"@ggui-ai/
|
|
235
|
-
"@ggui-ai/
|
|
236
|
-
"@ggui-ai/
|
|
232
|
+
"@ggui-ai/gadgets": "0.10.0",
|
|
233
|
+
"@ggui-ai/design": "0.10.0",
|
|
234
|
+
"@ggui-ai/sandbox": "0.10.0",
|
|
235
|
+
"@ggui-ai/ui-visual-tester": "0.10.0",
|
|
236
|
+
"@ggui-ai/wire": "0.10.0"
|
|
237
237
|
},
|
|
238
238
|
"devDependencies": {
|
|
239
239
|
"@google/adk": "^0.5.0",
|
|
240
240
|
"@openai/agents": "^0.7.1",
|
|
241
|
-
"@sparticuz/chromium": "^
|
|
241
|
+
"@sparticuz/chromium": "^149.0.0",
|
|
242
242
|
"@types/eslint": "^9.6.1",
|
|
243
243
|
"@types/node": "^22.0.0",
|
|
244
244
|
"@types/react": "^19.0.0",
|
|
245
245
|
"@types/react-dom": "^19.0.0",
|
|
246
|
-
"puppeteer-core": "^
|
|
246
|
+
"puppeteer-core": "^25.6.0",
|
|
247
247
|
"react": "^19.0.0",
|
|
248
248
|
"react-dom": "^19.0.0",
|
|
249
249
|
"tsup": "^8.5.0",
|
|
250
250
|
"vitest": "^3.2.6",
|
|
251
|
-
"@ggui-ai/
|
|
252
|
-
"@ggui-ai/
|
|
253
|
-
"@ggui-ai/
|
|
251
|
+
"@ggui-ai/mcp-server-core": "0.10.0",
|
|
252
|
+
"@ggui-ai/project-config": "0.10.0",
|
|
253
|
+
"@ggui-ai/protocol": "0.10.0"
|
|
254
254
|
},
|
|
255
255
|
"engines": {
|
|
256
256
|
"node": ">=20.0.0"
|
|
@@ -53,6 +53,7 @@ import type {
|
|
|
53
53
|
} from "../harness/result-types.js";
|
|
54
54
|
import type { AdapterResult, ProviderName, ToolDefinition } from "./types.js";
|
|
55
55
|
import type { QualityConfig } from "../evaluation/types-public.js";
|
|
56
|
+
import type { AgentConfig } from "../harness/llm-router.js";
|
|
56
57
|
import {
|
|
57
58
|
type GadgetDescriptor,
|
|
58
59
|
type JsonObject,
|
|
@@ -147,6 +148,21 @@ export interface GenerationDispatchParams {
|
|
|
147
148
|
* STDLIB-only / bench / direct callers.
|
|
148
149
|
*/
|
|
149
150
|
gadgetTypes?: Readonly<Record<string, string>>;
|
|
151
|
+
/**
|
|
152
|
+
* Explicit per-call credentials/routing (#484) — see
|
|
153
|
+
* `AgentConfig.routeOverride`. Threaded onto the coding + evaluation
|
|
154
|
+
* agent specs so `resolveSessionAgents` → `createAgent` never falls
|
|
155
|
+
* back to reading `process.env`. Absent (default) preserves
|
|
156
|
+
* existing behavior for callers that don't set it.
|
|
157
|
+
*/
|
|
158
|
+
routeOverride?: AgentConfig["routeOverride"];
|
|
159
|
+
/**
|
|
160
|
+
* Retry observer (#489) — see `AgentConfig.onRetry`. Threaded onto
|
|
161
|
+
* the coding + evaluation agent specs so a rate-limited retry inside
|
|
162
|
+
* either agent's `apiCall()` reaches the caller. Absent (default) is
|
|
163
|
+
* a no-op.
|
|
164
|
+
*/
|
|
165
|
+
onRetry?: AgentConfig["onRetry"];
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/**
|
|
@@ -186,9 +202,19 @@ export async function dispatchGeneration(
|
|
|
186
202
|
|
|
187
203
|
const routerProvider = mapProviderForLLMRouter(params.provider);
|
|
188
204
|
const resolvedModel = params.models?.coding ?? params.models?.default ?? params.model;
|
|
189
|
-
const codingAgent = {
|
|
205
|
+
const codingAgent = {
|
|
206
|
+
provider: routerProvider,
|
|
207
|
+
model: resolvedModel,
|
|
208
|
+
routeOverride: params.routeOverride,
|
|
209
|
+
onRetry: params.onRetry,
|
|
210
|
+
};
|
|
190
211
|
const evaluationAgent = params.models?.evaluation
|
|
191
|
-
? {
|
|
212
|
+
? {
|
|
213
|
+
provider: routerProvider,
|
|
214
|
+
model: params.models.evaluation,
|
|
215
|
+
routeOverride: params.routeOverride,
|
|
216
|
+
onRetry: params.onRetry,
|
|
217
|
+
}
|
|
192
218
|
: undefined;
|
|
193
219
|
// ModelRoles doesn't currently expose a visualEval slot — falls through
|
|
194
220
|
// to the documented evaluation → coding chain inside resolveSessionAgents.
|
|
@@ -49,7 +49,7 @@ export class OpenRouterClient {
|
|
|
49
49
|
if (!response.ok) {
|
|
50
50
|
const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
|
|
51
51
|
const message = (errorBody as { error?: { message?: string } }).error?.message ?? response.statusText;
|
|
52
|
-
throw new OpenRouterError(message, response.status);
|
|
52
|
+
throw new OpenRouterError(message, response.status, undefined, response.headers);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
return response.json() as Promise<OpenRouterChatResponse>;
|
|
@@ -72,7 +72,7 @@ export class OpenRouterClient {
|
|
|
72
72
|
if (!response.ok) {
|
|
73
73
|
const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
|
|
74
74
|
const message = (errorBody as { error?: { message?: string } }).error?.message ?? response.statusText;
|
|
75
|
-
throw new OpenRouterError(message, response.status);
|
|
75
|
+
throw new OpenRouterError(message, response.status, undefined, response.headers);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
if (!response.body) {
|
|
@@ -127,6 +127,16 @@ export class OpenRouterError extends Error {
|
|
|
127
127
|
message: string,
|
|
128
128
|
public readonly status: number,
|
|
129
129
|
public readonly code?: string,
|
|
130
|
+
/**
|
|
131
|
+
* Response headers, when the caller had access to them at throw
|
|
132
|
+
* time (#489). Only `client.ts`'s two HTTP-response-driven throw
|
|
133
|
+
* sites populate this — the "no response body" throw has no
|
|
134
|
+
* response to read headers from. Duck-typed against the same
|
|
135
|
+
* `{ get(name): string | null }` shape `llm-router.ts`'s
|
|
136
|
+
* `extractRetryAfterSec` already reads from Anthropic/OpenAI SDK
|
|
137
|
+
* errors, so OpenRouter gets retry-after support for free.
|
|
138
|
+
*/
|
|
139
|
+
public readonly headers?: { get(name: string): string | null },
|
|
130
140
|
) {
|
|
131
141
|
super(message);
|
|
132
142
|
this.name = 'OpenRouterError';
|
|
@@ -191,14 +191,22 @@ export function generateBoilerplate(
|
|
|
191
191
|
const propsFields: string[] = [];
|
|
192
192
|
const propsProperties = contract?.propsSpec?.properties ?? {};
|
|
193
193
|
for (const [key, entry] of Object.entries(propsProperties)) {
|
|
194
|
-
|
|
194
|
+
// Required is OPT-IN (`required === true`) — the same reading the
|
|
195
|
+
// wire gate applies (`contract-validator.ts` builds the JSON-Schema
|
|
196
|
+
// `required` list from `=== true`), the contract summary's `!`
|
|
197
|
+
// marker uses, and coverage annotates with. The old `!== false`
|
|
198
|
+
// reading typed omitted-`required` props as always-present, so a
|
|
199
|
+
// render the gate legitimately accepts (agent omits an optional
|
|
200
|
+
// prop) crashed on `props.x.map(...)` — ggui#528.
|
|
201
|
+
const required = entry.required === true;
|
|
195
202
|
const nullable = entry.schema.nullable === true;
|
|
196
203
|
const tsType = jsonSchemaTypeToTs(entry.schema);
|
|
197
204
|
const fullType = nullable ? `${tsType} | null` : tsType;
|
|
198
|
-
// Build comment: description + default
|
|
205
|
+
// Build comment: description + default hint + optionality contract
|
|
199
206
|
const parts: string[] = [];
|
|
200
207
|
if (entry.description) parts.push(entry.description);
|
|
201
208
|
if (entry.default !== undefined) parts.push(`(default: ${JSON.stringify(entry.default)})`);
|
|
209
|
+
if (!required) parts.push("(optional — the agent may omit it; guard or fall back)");
|
|
202
210
|
const desc = parts.length > 0 ? ` // ${parts.join(' ')}` : "";
|
|
203
211
|
propsFields.push(` ${key}${required ? "" : "?"}: ${fullType};${desc}`);
|
|
204
212
|
}
|
|
@@ -35,6 +35,21 @@ export function jsonSchemaTypeToTs(schema: JsonSchema): string {
|
|
|
35
35
|
.join(' | ');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ── Draft-07 type array (`['string','null']` — the canonical
|
|
39
|
+
// nullable form since draft-2026-08-19): members' TS union. Each
|
|
40
|
+
// member re-enters with the single type so object/array members
|
|
41
|
+
// keep their properties/items; `nullable` is folded in here (not in
|
|
42
|
+
// the recursion) so it can't double-append. ──
|
|
43
|
+
if (Array.isArray(schema.type)) {
|
|
44
|
+
const { nullable: _nullable, ...rest } = schema;
|
|
45
|
+
const members = [...new Set(schema.type)].map((member) =>
|
|
46
|
+
jsonSchemaTypeToTs({ ...rest, type: member }),
|
|
47
|
+
);
|
|
48
|
+
const unique = [...new Set(members)];
|
|
49
|
+
if (schema.nullable && !unique.includes('null')) unique.push('null');
|
|
50
|
+
return unique.join(' | ');
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
let result: string;
|
|
39
54
|
|
|
40
55
|
switch (schema.type) {
|
|
@@ -54,8 +69,13 @@ export function jsonSchemaTypeToTs(schema: JsonSchema): string {
|
|
|
54
69
|
case 'array': {
|
|
55
70
|
if (schema.items) {
|
|
56
71
|
const itemType = jsonSchemaTypeToTs(schema.items);
|
|
57
|
-
// Use Array<T> for complex object types, T[] for simple
|
|
58
|
-
|
|
72
|
+
// Use Array<T> for complex object types, T[] for simple.
|
|
73
|
+
// `items.type` may be a type SET — treat any set containing
|
|
74
|
+
// 'object' like the single-string form.
|
|
75
|
+
const itemsDeclareObject = Array.isArray(schema.items.type)
|
|
76
|
+
? schema.items.type.includes('object')
|
|
77
|
+
: schema.items.type === 'object';
|
|
78
|
+
result = (itemsDeclareObject && schema.items.properties)
|
|
59
79
|
? `Array<${itemType}>`
|
|
60
80
|
: (itemType.includes('|') ? `(${itemType})[]` : `${itemType}[]`);
|
|
61
81
|
} else {
|
|
@@ -347,13 +347,15 @@ A \`DataContract\` declares everything a GguiSession exchanges with the outside
|
|
|
347
347
|
|
|
348
348
|
## Defensive coding for absent / late-arriving data
|
|
349
349
|
|
|
350
|
-
Props arrive
|
|
350
|
+
Props arrive at mount and may be partial on first render; later prop changes reach the SAME mounted component via \`ggui_amend\` (in-place repaint) on the live channel. Stream channels start empty and fill over time. Context slots start at their declared default (often \`null\`). **Never assume a field exists before you read it.**
|
|
351
351
|
|
|
352
|
+
- **Optional contract props**: a \`Props\` field marked \`?\` in the boilerplate may be omitted by the agent ENTIRELY — the wire gate only enforces \`required: true\` entries, and the harness verifies your component with those props stripped. Guard them (\`?? default\`, \`?.\`, or an empty-state branch) from the FIRST render.
|
|
352
353
|
- **Array iteration**: always default to \`[]\` before \`.map\`/\`.filter\`/\`.length\`. Use \`(props.items ?? []).map(...)\` not \`props.items.map(...)\`. Same for stream.all, stream.latest, etc.
|
|
353
354
|
- **Object access**: optional-chain through nested fields. \`props.user?.name ?? 'Anonymous'\` not \`props.user.name\`.
|
|
354
355
|
- **Number ops**: default before arithmetic. \`(props.count ?? 0) + 1\` not \`props.count + 1\`.
|
|
355
356
|
- **Stream latest**: \`useStream\` returns \`{latest: T | null, all: T[], isComplete: boolean}\`. The default \`all\` is \`[]\` so it's safe to map; \`latest\` is null until the first frame arrives — guard before reading \`.foo\`.
|
|
356
357
|
- **Stream reconciliation**: when a stream event carries an \`action\` discriminant (e.g. \`create | move | edit | delete\`), the channel is a CRUD feed — your handler MUST branch on EVERY value: append on \`create\`, drop on \`delete\`, replace-by-id on \`move\` / \`edit\`. Merging only the "edit" case silently loses created and deleted items. Reconcile into the SAME state that seeds from \`props\` (e.g. \`useState(() => props.tasks ?? [])\`) so the seed data and the live feed render as one list — and handle an event whose id is not yet present (a \`create\` for an unknown item) by inserting it, not ignoring it.
|
|
358
|
+
- **Props re-sync on repaint**: props the agent owns are the source of truth for display — prefer rendering DIRECTLY from \`props\` (controlled, no local copy). When local optimistic state genuinely needs a prop seed (a click bumps a count before the agent confirms), it MUST re-sync on prop change — \`useEffect(() => setCount(props.count ?? 0), [props.count])\` — or a \`ggui_amend\` repaint applies the new props while the display keeps the stale local value. BAD: \`const [count, setCount] = useState(props.count ?? 0)\` alone. GOOD: the same seed + the one-line re-sync effect.
|
|
357
359
|
- **Loading state**: while data is still absent, render \`<Skeleton>\` placeholders — never a blank screen. \`<Skeleton variant="text" />\` for a text line, \`variant="circle"\` for an avatar slot, default \`rect\` for a block.
|
|
358
360
|
- **Empty state**: when a list or results array is empty, render \`<EmptyState title="…" description="…" />\` — a region that renders nothing when empty looks broken to the user.
|
|
359
361
|
|
|
@@ -481,7 +483,7 @@ Every value change is mirrored to the host LLM's context automatically (debounce
|
|
|
481
483
|
|
|
482
484
|
**When NOT to use it.** Local UI state the contract did NOT declare — \`isDropdownOpen\`, hover flags, animation phase, ephemeral toggles. For those, use a plain \`useState\` directly. The runtime ignores undeclared state.
|
|
483
485
|
|
|
484
|
-
**\`contextSpec\` direction is one-way: client → agent.** The agent uses \`propsSpec\` (via \`ggui_update\`) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` — there is no return path.
|
|
486
|
+
**\`contextSpec\` direction is one-way: client → agent.** The agent uses \`propsSpec\` (via \`ggui_amend\` for in-place repaints of this mount, or \`ggui_update\` when it mints a new history card) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` — there is no return path.
|
|
485
487
|
|
|
486
488
|
**Schema mismatches drop silently.** If you set a value that doesn't match the slot's schema (e.g. a string into a \`{type: 'number'}\` slot), the runtime logs a dev \`console.warn\` and skips the post. Make sure your setter calls produce values that match the declared shape.
|
|
487
489
|
|
package/src/compile.ts
CHANGED
|
@@ -153,7 +153,7 @@ export async function compileComponentCode(source: string): Promise<string> {
|
|
|
153
153
|
* `response.sourceCode` so downstream consumers that want human-readable
|
|
154
154
|
* source (benchmarks, blueprint cache seeding) still have it.
|
|
155
155
|
*
|
|
156
|
-
* const raw = createUiGenerator(
|
|
156
|
+
* const raw = createUiGenerator();
|
|
157
157
|
* const oss = withBrowserCompile(raw);
|
|
158
158
|
* const out = await oss.generate({ ... });
|
|
159
159
|
* // out.response.componentCode → ESM (browser-ready)
|
package/src/contract-context.ts
CHANGED
|
@@ -257,11 +257,19 @@ export function buildContractsContext(
|
|
|
257
257
|
const uiRequirements: string[] = [];
|
|
258
258
|
if (contract.propsSpec?.properties) {
|
|
259
259
|
for (const [name, entry] of Object.entries(contract.propsSpec.properties)) {
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
// Opt-in required (`=== true`) — the wire gate's reading, ggui#528.
|
|
261
|
+
// An omitted-`required` prop is OPTIONAL: the agent may leave it
|
|
262
|
+
// out of a legitimate render, so the derived instruction must
|
|
263
|
+
// demand a presence-safe component, not unconditional display.
|
|
264
|
+
const required = (entry as { required?: boolean }).required === true;
|
|
265
|
+
if (required) {
|
|
262
266
|
uiRequirements.push(
|
|
263
267
|
`- **Required prop \`${name}\`** must appear somewhere in rendered DOM (display the value, or use it to drive a label/aria/key).`,
|
|
264
268
|
);
|
|
269
|
+
} else {
|
|
270
|
+
uiRequirements.push(
|
|
271
|
+
`- **Optional prop \`${name}\`** may be ABSENT at runtime — the component must render without it (guard the access or apply its default) and must display it when it IS provided.`,
|
|
272
|
+
);
|
|
265
273
|
}
|
|
266
274
|
}
|
|
267
275
|
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import type { RenderingContext } from './contract-context.js';
|
|
62
62
|
import { resolveRoute, applyRouteToEnv } from './adapters/provider-router.js';
|
|
63
63
|
import type { QualityConfig } from './evaluation/types-public.js';
|
|
64
|
+
import type { AgentConfig } from './harness/llm-router.js';
|
|
64
65
|
|
|
65
66
|
/** The slug for the OSS default seed generator. */
|
|
66
67
|
const DEFAULT_TIER: GeneratorTier = 'default';
|
|
@@ -138,6 +139,30 @@ export interface CreateUiGeneratorOptions {
|
|
|
138
139
|
* `appGadgets` themselves; the legacy handler path stays unchanged.
|
|
139
140
|
*/
|
|
140
141
|
readonly gadgetCatalog?: GadgetCatalogAdapter;
|
|
142
|
+
/**
|
|
143
|
+
* When `true`, `generate()` never mutates `process.env` — the
|
|
144
|
+
* resolved provider route is threaded structurally into the
|
|
145
|
+
* dispatch call instead of being written to and restored from the
|
|
146
|
+
* process environment. Use this when running concurrent
|
|
147
|
+
* generations in one process (the default backup/mutate/restore
|
|
148
|
+
* dance is NOT concurrency-safe: two simultaneous calls race on
|
|
149
|
+
* the shared `process.env`).
|
|
150
|
+
*
|
|
151
|
+
* Default `false` — preserves the original env-mutation behavior
|
|
152
|
+
* for callers that don't need concurrent generations in-process.
|
|
153
|
+
*/
|
|
154
|
+
readonly disableEnvMutation?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Retry observer (#489) — see `AgentConfig.onRetry`. Bound ONCE at
|
|
157
|
+
* factory construction time (mirrors `disableEnvMutation`), not
|
|
158
|
+
* per-call: `generate()` is invoked once per request, but the
|
|
159
|
+
* generator instance itself is typically long-lived and cached by
|
|
160
|
+
* the caller, so a single observer covers every generation it runs.
|
|
161
|
+
* Fires whenever any agent's `apiCall()` retries a rate-limited
|
|
162
|
+
* request. Absent (default) is a no-op — retries still happen and
|
|
163
|
+
* still log via `console.warn`, just without a caller-side hook.
|
|
164
|
+
*/
|
|
165
|
+
readonly onRetry?: AgentConfig['onRetry'];
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
export function createUiGenerator(
|
|
@@ -149,6 +174,8 @@ export function createUiGenerator(
|
|
|
149
174
|
maxEvalRounds,
|
|
150
175
|
qualityConfig,
|
|
151
176
|
gadgetCatalog,
|
|
177
|
+
disableEnvMutation = false,
|
|
178
|
+
onRetry,
|
|
152
179
|
} = options;
|
|
153
180
|
|
|
154
181
|
const identity = resolveIdentity(options);
|
|
@@ -193,19 +220,37 @@ export function createUiGenerator(
|
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
const envBackup: Record<string, string | undefined> = {};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
223
|
+
if (!disableEnvMutation) {
|
|
224
|
+
for (const k of Object.keys(route.env)) envBackup[k] = process.env[k];
|
|
225
|
+
const baseEnvForApply: Record<string, string> = {};
|
|
226
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
227
|
+
if (v !== undefined) baseEnvForApply[k] = v;
|
|
228
|
+
}
|
|
229
|
+
const routedEnv = applyRouteToEnv(baseEnvForApply, route);
|
|
230
|
+
for (const [k, v] of Object.entries(routedEnv)) process.env[k] = v;
|
|
231
|
+
// Also delete any keys the route asked to clear (route.env values
|
|
232
|
+
// marked undefined).
|
|
233
|
+
for (const [k, v] of Object.entries(route.env)) {
|
|
234
|
+
if (v === undefined) delete process.env[k];
|
|
235
|
+
}
|
|
207
236
|
}
|
|
208
237
|
|
|
238
|
+
// #484 — when `disableEnvMutation` is set, skip the process-env
|
|
239
|
+
// mutation above entirely and thread the resolved route
|
|
240
|
+
// structurally into the dispatch call instead. `route.env`
|
|
241
|
+
// already carries exactly the key(s) that would have gone into
|
|
242
|
+
// `process.env`, so no extra resolution work is needed here.
|
|
243
|
+
const routeOverride = disableEnvMutation
|
|
244
|
+
? {
|
|
245
|
+
apiKey:
|
|
246
|
+
route.env['ANTHROPIC_API_KEY'] ??
|
|
247
|
+
route.env['OPENAI_API_KEY'] ??
|
|
248
|
+
route.env['GEMINI_API_KEY'] ??
|
|
249
|
+
route.env['OPENROUTER_API_KEY'],
|
|
250
|
+
useBedrock: route.env['CLAUDE_CODE_USE_BEDROCK'] === '1',
|
|
251
|
+
}
|
|
252
|
+
: undefined;
|
|
253
|
+
|
|
209
254
|
try {
|
|
210
255
|
const tools = createGeneratorTools({
|
|
211
256
|
...(input.contract ? { contract: input.contract } : {}),
|
|
@@ -269,6 +314,8 @@ export function createUiGenerator(
|
|
|
269
314
|
? { gadgetTypes: input.gadgetTypes }
|
|
270
315
|
: {}),
|
|
271
316
|
enableRuntimeRender,
|
|
317
|
+
...(routeOverride !== undefined ? { routeOverride } : {}),
|
|
318
|
+
...(onRetry !== undefined ? { onRetry } : {}),
|
|
272
319
|
});
|
|
273
320
|
|
|
274
321
|
const metadata: GenerationMetadata = {
|
|
@@ -54,10 +54,12 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
|
|
|
54
54
|
- Borders/dividers → \`var(--ggui-color-outline)\` or \`var(--ggui-color-outlineVariant)\`
|
|
55
55
|
|
|
56
56
|
### State Colors
|
|
57
|
-
|
|
58
|
-
- var(--ggui-color-
|
|
59
|
-
- var(--ggui-color-
|
|
60
|
-
- var(--ggui-color-
|
|
57
|
+
Semantic families ship as SCALES; the \`500\` stop is the role color (per-mode brightness), \`50\` the tint, \`800\` the on-tint text:
|
|
58
|
+
- var(--ggui-color-success-500) — success states (tint bg: success-50, text on tint: success-800)
|
|
59
|
+
- var(--ggui-color-warning-500) — warning states (warning-50 / warning-800)
|
|
60
|
+
- var(--ggui-color-error-500) — error states, destructive actions (error-50 / error-800)
|
|
61
|
+
- var(--ggui-color-info-500) — informational (info-50 / info-800)
|
|
62
|
+
Prefer the primitives' \`tone="success|warning|error|info"\` prop over hand-written vars — it resolves to the same stops.
|
|
61
63
|
|
|
62
64
|
### IMPORTANT: Color Rules
|
|
63
65
|
- **NEVER** use hardcoded hex colors. ONLY use var(--ggui-color-*) tokens.
|
|
@@ -29,7 +29,7 @@ function runStateSeededFromProps(input: AxisCheckInput): EvalIssue[] {
|
|
|
29
29
|
mkIssue(
|
|
30
30
|
"state.merge.seeded_from_props",
|
|
31
31
|
`Entity collection "${e.name}" is not seeded from props — no useState initializer reads props.${e.name}.`,
|
|
32
|
-
`Add \`const [${e.name}, set${cap(e.name)}] = useState(props.${e.name});\` so stream/action updates
|
|
32
|
+
`Add \`const [${e.name}, set${cap(e.name)}] = useState(props.${e.name});\` paired with \`useEffect(() => set${cap(e.name)}(props.${e.name}), [props.${e.name}]);\` so stream/action updates merge into live state AND agent repaints (ggui_amend) re-seed it (#480).`,
|
|
33
33
|
),
|
|
34
34
|
);
|
|
35
35
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// packages/ui-gen/src/evaluation/axis-checks/checks/state-ui-affordance.ts
|
|
2
|
+
//
|
|
3
|
+
// `state.ui_affordance.state_aria_present` (ggui#408) — a hand-rolled
|
|
4
|
+
// stateful control must expose its state in ARIA.
|
|
5
|
+
//
|
|
6
|
+
// Why this is a DETERMINISTIC check and not (only) an LLM criterion:
|
|
7
|
+
// the Experiment-53 baseline showed the defect class is structurally
|
|
8
|
+
// invisible to the LLM leg — `state=ui-affordance` with no writes
|
|
9
|
+
// classifies `riskTier=low`, and the low-risk bypass skips tier-1+2
|
|
10
|
+
// entirely, so 10/10 ARIA-less generations were never evaluated at
|
|
11
|
+
// all. Axis checks run BEFORE the bypass decision, and a `fail` among
|
|
12
|
+
// them disables the bypass for that round — which both surfaces the
|
|
13
|
+
// finding and routes it into the coding loop. Prototype validation
|
|
14
|
+
// against the 27-cell baseline corpus: precision 1.00, recall 0.80
|
|
15
|
+
// (both false negatives closed here: lowercase tags — `<div
|
|
16
|
+
// as={Clickable}>` is the WORSE variant since `as` is inert on a
|
|
17
|
+
// native element — and callback-prop indirection in extracted
|
|
18
|
+
// subcomponents).
|
|
19
|
+
//
|
|
20
|
+
// Detection predicate (v2 — "the element's own onClick mutates the
|
|
21
|
+
// affordance state"; v1's styling-dataflow precondition scored recall
|
|
22
|
+
// 0.30, do not resurrect it): an element fires when its `onClick`
|
|
23
|
+
// references a `useState` setter, a local handler that calls one, or
|
|
24
|
+
// an `on[A-Z]…` callback prop (extracted-subcomponent shape) — AND it
|
|
25
|
+
// is not a semantics-owning primitive, carries no state ARIA
|
|
26
|
+
// (`aria-checked/pressed/selected/expanded/current` or any `role=`),
|
|
27
|
+
// and contains no state-carrying primitive in its subtree (pushing
|
|
28
|
+
// duplicate ARIA onto a wrapper around `<Checkbox>` is itself a
|
|
29
|
+
// defect the judge fails — never demand it).
|
|
30
|
+
//
|
|
31
|
+
// Gate: `{axis: "state", values: ["ui-affordance"]}` ONLY. The
|
|
32
|
+
// predicate must not reach `state=payload/draft` (form-field wrappers
|
|
33
|
+
// with onClick would false-flag) or `state=merge` (kanban's per-item
|
|
34
|
+
// onClick is a MOVE, not a boolean affordance).
|
|
35
|
+
|
|
36
|
+
import type { EvalIssue } from "../../types-public.js";
|
|
37
|
+
import type { AxisCheck, AxisCheckInput } from "../types.js";
|
|
38
|
+
import { mkIssue } from "../helpers.js";
|
|
39
|
+
|
|
40
|
+
const STATE_ARIA_OR_ROLE = /aria-(checked|pressed|selected|expanded|current)\s*=|role\s*=/;
|
|
41
|
+
const STATE_PRIMITIVE_IN_SUBTREE = /<(Checkbox|Toggle|Tabs|Accordion)[\s/>]/;
|
|
42
|
+
/** Primitives that own their own interaction semantics — never flagged. */
|
|
43
|
+
const SEMANTIC_PRIMITIVES = new Set(["Button", "Checkbox", "Toggle", "Link", "MenuItem"]);
|
|
44
|
+
|
|
45
|
+
/** Collect `const [x, setX] = useState(...)` setter names. */
|
|
46
|
+
function collectSetters(src: string): Set<string> {
|
|
47
|
+
const setters = new Set<string>();
|
|
48
|
+
for (const m of src.matchAll(/const\s*\[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState/g)) {
|
|
49
|
+
setters.add(m[1]);
|
|
50
|
+
}
|
|
51
|
+
return setters;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Collect local handler names whose body (nearby) calls a setter. */
|
|
55
|
+
function collectHandlers(src: string, setters: Set<string>): Set<string> {
|
|
56
|
+
const handlers = new Set<string>();
|
|
57
|
+
for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>\s*\{?/g)) {
|
|
58
|
+
const body = src.slice(m.index + m[0].length, m.index + m[0].length + 400);
|
|
59
|
+
for (const s of setters) {
|
|
60
|
+
if (body.includes(s)) {
|
|
61
|
+
handlers.add(m[1]);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return handlers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The attribute block of an opening tag: from after the tag name to its `>` (brace-aware). */
|
|
70
|
+
function attrBlock(src: string, start: number): { block: string; end: number } {
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (let i = start; i < src.length; i++) {
|
|
73
|
+
const c = src[i];
|
|
74
|
+
if (c === "{") depth++;
|
|
75
|
+
else if (c === "}") depth--;
|
|
76
|
+
else if (c === ">" && depth === 0) return { block: src.slice(start, i), end: i };
|
|
77
|
+
}
|
|
78
|
+
return { block: src.slice(start, start + 900), end: start + 900 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The brace-balanced expression of `onClick={…}`, or null when absent. */
|
|
82
|
+
function onClickValue(block: string): string | null {
|
|
83
|
+
const m = block.match(/onClick\s*=\s*\{/);
|
|
84
|
+
if (!m || m.index === undefined) return null;
|
|
85
|
+
const open = m.index + m[0].length - 1;
|
|
86
|
+
let depth = 0;
|
|
87
|
+
for (let i = open; i < block.length; i++) {
|
|
88
|
+
if (block[i] === "{") depth++;
|
|
89
|
+
else if (block[i] === "}") {
|
|
90
|
+
depth--;
|
|
91
|
+
if (depth === 0) return block.slice(open + 1, i);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return block.slice(open + 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function subtree(src: string, pos: number, tag: string): string {
|
|
98
|
+
const close = src.indexOf(`</${tag}>`, pos);
|
|
99
|
+
return src.slice(pos, close === -1 ? pos + 700 : close);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runStateAriaPresent(input: AxisCheckInput): EvalIssue[] {
|
|
103
|
+
if (input.compiledCode === null) return [];
|
|
104
|
+
const src = input.sourceCode;
|
|
105
|
+
const setters = collectSetters(src);
|
|
106
|
+
const handlers = collectHandlers(src, setters);
|
|
107
|
+
const issues: EvalIssue[] = [];
|
|
108
|
+
const flaggedLines = new Set<number>();
|
|
109
|
+
|
|
110
|
+
// Locally-defined components: their CALL SITES can never satisfy the
|
|
111
|
+
// check (the ARIA lives inside the definition, which this check
|
|
112
|
+
// already covers via the callback-prop clause) — flagging the call
|
|
113
|
+
// site is a permanently-unresolvable finding that stalls the
|
|
114
|
+
// eval-fix loop in the recurrence guard (Experiment 54: exactly the
|
|
115
|
+
// 2/27 stuck cells). Skip call sites of components defined in this
|
|
116
|
+
// file; the definition's own inner element carries the check.
|
|
117
|
+
const localComponents = new Set<string>();
|
|
118
|
+
for (const m of src.matchAll(/(?:function\s+([A-Z]\w*)\s*\(|const\s+([A-Z]\w*)\s*=)/g)) {
|
|
119
|
+
localComponents.add(m[1] ?? m[2]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const m of src.matchAll(/<([A-Za-z][A-Za-z0-9]*)\b/g)) {
|
|
123
|
+
const tag = m[1];
|
|
124
|
+
if (m.index === undefined) continue;
|
|
125
|
+
if (localComponents.has(tag)) continue;
|
|
126
|
+
const { block, end } = attrBlock(src, m.index + m[0].length);
|
|
127
|
+
const clickValue = onClickValue(block);
|
|
128
|
+
if (clickValue === null) continue;
|
|
129
|
+
const mutatesAffordance =
|
|
130
|
+
[...setters].some((s) => clickValue.includes(s)) ||
|
|
131
|
+
[...handlers].some((h) => new RegExp(`\\b${h}\\b`).test(clickValue)) ||
|
|
132
|
+
// Extracted-subcomponent shape: the toggle handler arrives as an
|
|
133
|
+
// `on[A-Z]…` callback prop and is invoked from onClick — inside a
|
|
134
|
+
// ui-affordance component that IS the affordance mutation.
|
|
135
|
+
/\bon[A-Z]\w*\b/.test(clickValue);
|
|
136
|
+
if (!mutatesAffordance) continue;
|
|
137
|
+
if (SEMANTIC_PRIMITIVES.has(tag)) continue;
|
|
138
|
+
if (STATE_ARIA_OR_ROLE.test(block)) continue;
|
|
139
|
+
if (STATE_PRIMITIVE_IN_SUBTREE.test(subtree(src, end, tag))) continue;
|
|
140
|
+
|
|
141
|
+
const line = src.slice(0, m.index).split("\n").length;
|
|
142
|
+
if (flaggedLines.has(line)) continue;
|
|
143
|
+
flaggedLines.add(line);
|
|
144
|
+
issues.push(
|
|
145
|
+
mkIssue(
|
|
146
|
+
"state.ui_affordance.state_aria_present",
|
|
147
|
+
`Line ${line}: <${tag}> toggles the UI affordance from its onClick but exposes no state ARIA — its checked/selected/expanded state is invisible to assistive technology (styling is the only carrier).`,
|
|
148
|
+
`Add the matching state attribute to the SAME element that carries the onClick: \`role="checkbox"\` + \`aria-checked={…}\` for a toggle row, \`aria-pressed\` for a toggle button, \`aria-selected\` for a selection, \`aria-expanded\` for an open/close affordance — or replace the hand-rolled control with the state-carrying primitive (Checkbox / Toggle / Tabs / Accordion). \`as={Clickable}\` adds button + keyboard semantics but NOT state.`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return issues;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const STATE_UI_AFFORDANCE_CHECKS: readonly AxisCheck[] = [
|
|
156
|
+
{
|
|
157
|
+
id: "state.ui_affordance.state_aria_present",
|
|
158
|
+
axis: "state",
|
|
159
|
+
values: ["ui-affordance"],
|
|
160
|
+
run: runStateAriaPresent,
|
|
161
|
+
},
|
|
162
|
+
];
|
|
@@ -86,6 +86,39 @@ function runNoPhantomUseState(input: AxisCheckInput): EvalIssue[] {
|
|
|
86
86
|
return issues;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function runPropSeedNoResync(input: AxisCheckInput): EvalIssue[] {
|
|
90
|
+
if (input.compiledCode === null) return [];
|
|
91
|
+
const src = input.sourceCode;
|
|
92
|
+
const issues: EvalIssue[] = [];
|
|
93
|
+
const re =
|
|
94
|
+
/const\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=\s*useState(?:<[^>]*>)?\s*\(\s*props\??\.(\w+)/g;
|
|
95
|
+
for (const m of src.matchAll(re)) {
|
|
96
|
+
const [full, stateVar, setter, propName] = m;
|
|
97
|
+
const idx = m.index ?? 0;
|
|
98
|
+
const after = src.slice(idx + full.length);
|
|
99
|
+
// Never-called setter is `no_prop_mirror`'s case — this check is
|
|
100
|
+
// its #480 complement: the setter IS used (real optimistic state)
|
|
101
|
+
// but nothing re-seeds when the agent repaints the prop.
|
|
102
|
+
if (!new RegExp(`\\b${setter}\\s*\\(`).test(after)) continue;
|
|
103
|
+
// A re-seed effect names the prop in a dependency array. Bracket
|
|
104
|
+
// shape is the pragmatic signal (same looseness as the sibling
|
|
105
|
+
// checks): any `[... props.X ...]` array in the source counts.
|
|
106
|
+
const resync = new RegExp(
|
|
107
|
+
`\\[[^\\[\\]]*props\\??\\.${propName}\\b[^\\[\\]]*\\]`,
|
|
108
|
+
);
|
|
109
|
+
if (resync.test(src)) continue;
|
|
110
|
+
issues.push(
|
|
111
|
+
mkIssue(
|
|
112
|
+
"universal.prop_seed_no_resync",
|
|
113
|
+
`useState(props.${propName}) seeds "${stateVar}" and mutates it, but no effect depends on [props.${propName}] — a ggui_amend repaint pushes new props that never reach this display (#480).`,
|
|
114
|
+
`Add \`useEffect(() => ${setter}(props.${propName}), [props.${propName}]);\` so agent repaints re-seed the state, or derive the display directly from props.${propName}.`,
|
|
115
|
+
"warn",
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return issues;
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
|
|
90
123
|
{
|
|
91
124
|
id: "universal.prop_coverage",
|
|
@@ -105,4 +138,10 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
|
|
|
105
138
|
values: ALL_RENDER_VALUES,
|
|
106
139
|
run: runNoPhantomUseState,
|
|
107
140
|
},
|
|
141
|
+
{
|
|
142
|
+
id: "universal.prop_seed_no_resync",
|
|
143
|
+
axis: "render",
|
|
144
|
+
values: ALL_RENDER_VALUES,
|
|
145
|
+
run: runPropSeedNoResync,
|
|
146
|
+
},
|
|
108
147
|
];
|