@ggui-ai/ui-gen 0.17.0 → 0.18.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.js +252 -108
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.js +261 -113
- package/dist/advanced/index.js.map +1 -1
- package/dist/boilerplate.js +27 -3
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.js +70 -0
- package/dist/check/index.js.map +1 -1
- package/dist/evaluation/axis-checks/index.js +50 -0
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.js +50 -0
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.js +50 -0
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/index.js +27 -3
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.js +4 -0
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/runtime.js +27 -3
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.js +27 -3
- package/dist/harness/types-public.js.map +1 -1
- package/dist/index.js +261 -113
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/boilerplate/free-design-prompt.ts +2 -0
- package/src/boilerplate/generate.ts +8 -1
- package/src/boilerplate/hard-sections.ts +31 -2
- package/src/boilerplate/system-prompt.ts +2 -0
- package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
- package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
- package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
- package/src/check/run-tier0.ts +152 -0
- package/src/contract-context.ts +13 -0
- package/src/evaluation/axis-checks/checks/universal.ts +64 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/ui-gen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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": [
|
|
@@ -188,9 +188,9 @@
|
|
|
188
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/
|
|
193
|
-
"@ggui-ai/
|
|
191
|
+
"@ggui-ai/mcp-server-core": "0.18.0",
|
|
192
|
+
"@ggui-ai/project-config": "0.18.0",
|
|
193
|
+
"@ggui-ai/protocol": "0.18.0"
|
|
194
194
|
},
|
|
195
195
|
"peerDependenciesMeta": {
|
|
196
196
|
"@google/adk": {
|
|
@@ -229,11 +229,11 @@
|
|
|
229
229
|
"openai": "^7.9.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/wire": "0.
|
|
236
|
-
"@ggui-ai/
|
|
232
|
+
"@ggui-ai/design": "0.18.0",
|
|
233
|
+
"@ggui-ai/sandbox": "0.18.0",
|
|
234
|
+
"@ggui-ai/ui-visual-tester": "0.18.0",
|
|
235
|
+
"@ggui-ai/wire": "0.18.0",
|
|
236
|
+
"@ggui-ai/gadgets": "0.18.0"
|
|
237
237
|
},
|
|
238
238
|
"devDependencies": {
|
|
239
239
|
"@google/adk": "^2.0.0",
|
|
@@ -248,9 +248,9 @@
|
|
|
248
248
|
"react-dom": "^19.0.0",
|
|
249
249
|
"tsup": "^8.5.0",
|
|
250
250
|
"vitest": "^3.2.6",
|
|
251
|
-
"@ggui-ai/project-config": "0.
|
|
252
|
-
"@ggui-ai/
|
|
253
|
-
"@ggui-ai/
|
|
251
|
+
"@ggui-ai/project-config": "0.18.0",
|
|
252
|
+
"@ggui-ai/protocol": "0.18.0",
|
|
253
|
+
"@ggui-ai/mcp-server-core": "0.18.0"
|
|
254
254
|
},
|
|
255
255
|
"engines": {
|
|
256
256
|
"node": ">=22.0.0"
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
DATA_PARAMETERIZATION,
|
|
30
30
|
DEFENSIVE_CODING,
|
|
31
31
|
FRAME_SIZING,
|
|
32
|
+
TERMINAL_ACTIONS,
|
|
32
33
|
GESTURE_ROUTING,
|
|
33
34
|
PROTOCOL_NOTES,
|
|
34
35
|
renderGadgetCatalogSection,
|
|
@@ -310,6 +311,7 @@ ${axisSection}
|
|
|
310
311
|
CONTRACT_SURFACE,
|
|
311
312
|
DEFENSIVE_CODING,
|
|
312
313
|
GESTURE_ROUTING,
|
|
314
|
+
TERMINAL_ACTIONS,
|
|
313
315
|
FREE_GESTURE_SURFACE,
|
|
314
316
|
ANTI_PATTERNS,
|
|
315
317
|
CROSS_REFERENCE_RULES,
|
|
@@ -252,7 +252,14 @@ export function generateBoilerplate(
|
|
|
252
252
|
// Inline signature tells the LLM exactly how to call this action
|
|
253
253
|
const callSig = tsType === "void" ? "() => void — fire and forget" : `(data: ${tsType}) => void`;
|
|
254
254
|
const toolHint = tool ? ` → nextStep: ${tool}` : "";
|
|
255
|
-
|
|
255
|
+
// ggui#1108 (mitigation): the reminder sits at the EXACT site the model
|
|
256
|
+
// writes the handler. It is a hint, not the rule — the rule is the prompt's
|
|
257
|
+
// `TERMINAL_ACTIONS` section, because only the request and the contract say
|
|
258
|
+
// which actions are meant once. A site comment alone moves generation by
|
|
259
|
+
// roughly nothing (Exp 002b: adoption 1/36), which is why it is the
|
|
260
|
+
// smallest of the three legs and never the one relied on.
|
|
261
|
+
const onceHint = " — if this is meant ONCE, disable its control after it fires";
|
|
262
|
+
actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}${onceHint}`);
|
|
256
263
|
actionReturnFields.push(key);
|
|
257
264
|
}
|
|
258
265
|
|
|
@@ -216,6 +216,28 @@ Rules:
|
|
|
216
216
|
4. Copy comes from the props. NEVER invent display text the contract does not carry — no eyebrow / kicker / overline (a \`<Text caps>\` label), no helper line, no status text, no tagline. A caps label exists only when a prop supplies its text (\`<Text caps>{props.section}</Text>\`); a heading's fallback is the request's own words, never a slogan of your own. Text the agent cannot change is chatter on every render.
|
|
217
217
|
5. Props interface must be typed and exported.`;
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* A control that fires an action the user would not mean to repeat must not
|
|
221
|
+
* stay armed (ggui#1108 MITIGATION — the fix is one-shot-ness DECLARED on the
|
|
222
|
+
* action and enforced by the runtime; this reduces exposure meanwhile). Both
|
|
223
|
+
* arms, one HARD section: the model knows from the request and the contract
|
|
224
|
+
* which actions are terminal, which no static rule of ours can infer.
|
|
225
|
+
*/
|
|
226
|
+
export const TERMINAL_ACTIONS = `## Actions the user means once
|
|
227
|
+
|
|
228
|
+
A second press is a mistake only when it would make a SECOND THING HAPPEN IN THE WORLD — a second booking, a second charge, a second order, a second application sent. If pressing again would only write the SAME STATE AGAIN — saving, updating, renaming, setting a preference, toggling — nothing is duplicated and the control stays armed.
|
|
229
|
+
|
|
230
|
+
Guard a control only where you have evidence, not a feeling about the verb: the request itself names a one-time commitment — "submit the application", "place the order", "confirm the booking".
|
|
231
|
+
|
|
232
|
+
**When in doubt, leave it armed.** A control the user cannot press a second time has lost them something that worked — save, edit, save again is ordinary. A control left armed is only what every interface does by default.
|
|
233
|
+
|
|
234
|
+
When you do guard one:
|
|
235
|
+
- Hold a small piece of state for it and set it in the handler: \`const [submitted, setSubmitted] = useState(false)\`.
|
|
236
|
+
- \`disabled={submitted}\` on the control that fires it, and say so in the label or beside it ("Submitted", "Request sent") — a control that is disabled without explanation reads as broken.
|
|
237
|
+
- A card re-rendered with new props starts fresh; the state above lives with the component, not with the request.
|
|
238
|
+
|
|
239
|
+
Nothing in the wire DECLARES one-shot-ness yet, which is why the test is what a second press would DO — not how final the word sounds.`;
|
|
240
|
+
|
|
219
241
|
/**
|
|
220
242
|
* The frame owns the height — the component never sizes itself to the
|
|
221
243
|
* viewport (ggui#1075 Track A/B; receipted on #1096: 20 of 48 minted cells
|
|
@@ -223,12 +245,19 @@ Rules:
|
|
|
223
245
|
* card told "compact"). Identical in both modes; the eval leg's tier-0
|
|
224
246
|
* `universal.viewport_sized` fails what slips through.
|
|
225
247
|
*/
|
|
226
|
-
export const FRAME_SIZING = `## Sizing — the frame owns the height
|
|
248
|
+
export const FRAME_SIZING = `## Sizing — the frame owns the height and the width
|
|
227
249
|
|
|
228
250
|
The host sizes the iframe; the component never sizes itself to the viewport. NEVER write \`100vh\` / \`100dvh\` / \`100svh\` (or an \`h-screen\` / \`min-h-screen\` class) as a height on any element:
|
|
229
251
|
- An inline card takes its natural height. A content-sized frame is measured FROM the content, so a \`100vh\` root can never shrink and grows the frame on every re-measure.
|
|
230
252
|
- Under fullscreen the frame already stretches your root to its full height. Centre inside it with flex (\`display: flex; flex-direction: column; justify-content: center\`), never with a viewport height.
|
|
231
|
-
- To fill a parent that has a height use \`height: 100%\` or \`flex: 1\`; a viewport unit is only ever a \`max-height\` cap on a scroll region
|
|
253
|
+
- To fill a parent that has a height use \`height: 100%\` or \`flex: 1\`; a viewport unit is only ever a \`max-height\` cap on a scroll region.
|
|
254
|
+
|
|
255
|
+
The width works the same way, and the mistake is the mirror image: **your outermost element FILLS the frame; a width cap belongs INSIDE it, on the column that holds text.**
|
|
256
|
+
|
|
257
|
+
- Never put \`maxWidth\` (or a \`max-w-*\` class) on the element you return at the top. On a wide canvas that turns your card into a narrow strip with the frame's ground on either side of it, and on a narrow one it does nothing — the same composition reading differently at two sizes.
|
|
258
|
+
- DO cap the reading measure inside: a column of prose is easiest to read at 60–70 characters, so wrap the TEXT in a \`<Container maxWidth="sm">\` (or your own \`max-width\`) and leave the root filling. A hero band, a toolbar, a table or an image then still uses the whole width, which is what makes a wide card look composed rather than padded.
|
|
259
|
+
- Centre that inner column with the container's own centring, not by shrinking the root.
|
|
260
|
+
- **They are two different elements.** Even when the whole card is one column of text, the root still fills and the column sits INSIDE it — \`<Box padding="lg"><Container maxWidth="sm">…</Container></Box>\`, never a root that is itself the container. A centred card is centring inside a filled frame, not a shrunken frame.`;
|
|
232
261
|
|
|
233
262
|
/** Helper-component structure guidance (JSX depth 3–5, helpers above `Component`). Identical in both modes. */
|
|
234
263
|
export const COMPONENT_STRUCTURE = `## Component Structure
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
DEFENSIVE_CODING,
|
|
42
42
|
FRAME_SIZING,
|
|
43
43
|
GESTURE_ROUTING,
|
|
44
|
+
TERMINAL_ACTIONS,
|
|
44
45
|
INTERACTIVE_TRAITS,
|
|
45
46
|
PROTOCOL_NOTES,
|
|
46
47
|
renderGadgetCatalogSection,
|
|
@@ -383,6 +384,7 @@ ${axisSection}
|
|
|
383
384
|
CONTRACT_SURFACE,
|
|
384
385
|
DEFENSIVE_CODING,
|
|
385
386
|
GESTURE_ROUTING,
|
|
387
|
+
TERMINAL_ACTIONS,
|
|
386
388
|
INTERACTIVE_TRAITS,
|
|
387
389
|
ANTI_PATTERNS,
|
|
388
390
|
CROSS_REFERENCE_RULES,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
<
|
|
2
|
-
{/* implement your UI here
|
|
3
|
-
|
|
1
|
+
<Box padding="lg">
|
|
2
|
+
{/* implement your UI here — this root FILLS the frame.
|
|
3
|
+
Bands, toolbars, tables and images use the whole width;
|
|
4
|
+
wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
|
|
5
|
+
</Box>
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<
|
|
2
|
-
{/* implement your UI here */}
|
|
3
|
-
</
|
|
1
|
+
<Box padding="md">
|
|
2
|
+
{/* implement your UI here — this root FILLS the frame (a phone IS the measure). */}
|
|
3
|
+
</Box>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
<
|
|
2
|
-
{/* implement your UI here
|
|
3
|
-
|
|
1
|
+
<Box padding="lg">
|
|
2
|
+
{/* implement your UI here — this root FILLS the frame.
|
|
3
|
+
Bands, toolbars, tables and images use the whole width;
|
|
4
|
+
wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
|
|
5
|
+
</Box>
|
package/src/check/run-tier0.ts
CHANGED
|
@@ -352,6 +352,137 @@ function isGadgetExportImported(
|
|
|
352
352
|
return false;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
/**
|
|
356
|
+
* AST-precise detector for the compiles-but-renders-nothing class: the
|
|
357
|
+
* default-exported component's body never returns a tree.
|
|
358
|
+
*
|
|
359
|
+
* The boilerplate HANDS the model the wrapper — `base.tsx.tmpl` and
|
|
360
|
+
* `base-free.tsx.tmpl` both emit `return (` … `);` around `{{LAYOUT}}`.
|
|
361
|
+
* A model rewriting the root block can delete those two lines with it,
|
|
362
|
+
* leaving the JSX as a bare expression statement. That is legal TSX: it
|
|
363
|
+
* parses, esbuild compiles it, `typecheck()` passes it, and the
|
|
364
|
+
* component renders a blank frame. Seen once in twenty takes of the
|
|
365
|
+
* ggui#1113 A/B (`xs-chat-card`, run 2 — the judge spent a screenshot
|
|
366
|
+
* and an LLM call to report "completely blank").
|
|
367
|
+
*
|
|
368
|
+
* `compile/default-export` already guards the scaffold's
|
|
369
|
+
* `export default function` line and `types/props-interface` its
|
|
370
|
+
* `interface Props` line. The `return (` was the third scaffold line
|
|
371
|
+
* with nothing watching it.
|
|
372
|
+
*
|
|
373
|
+
* Conservative by construction — it reports ONLY when it positively
|
|
374
|
+
* identified a block-bodied default-export component:
|
|
375
|
+
* - a concise arrow body (`export default () => <X/>`) returns by
|
|
376
|
+
* construction → silent
|
|
377
|
+
* - `export default memo(Component)` or any other expression it
|
|
378
|
+
* cannot resolve to a function → silent
|
|
379
|
+
* - a `return` inside a nested helper is not the component's own (the
|
|
380
|
+
* walk stops at every nested function boundary), but ONE own-level
|
|
381
|
+
* `return <value>` anywhere — an early `return null` included —
|
|
382
|
+
* satisfies it.
|
|
383
|
+
*
|
|
384
|
+
* Returns `null` when the component returns (or could not be
|
|
385
|
+
* identified), else the 1-based line of the first bare JSX expression
|
|
386
|
+
* statement in its body — the line the fix belongs on — or `undefined`
|
|
387
|
+
* when the body has no JSX statement to point at either.
|
|
388
|
+
*/
|
|
389
|
+
function detectComponentNeverReturns(
|
|
390
|
+
sourceCode: string,
|
|
391
|
+
): { jsxStatementLine: number | undefined } | null {
|
|
392
|
+
const sf = ts.createSourceFile(
|
|
393
|
+
'source.tsx',
|
|
394
|
+
sourceCode,
|
|
395
|
+
ts.ScriptTarget.Latest,
|
|
396
|
+
/* setParentNodes */ true,
|
|
397
|
+
ts.ScriptKind.TSX,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const isFunctionLike = (n: ts.Node): boolean =>
|
|
401
|
+
ts.isFunctionDeclaration(n) ||
|
|
402
|
+
ts.isFunctionExpression(n) ||
|
|
403
|
+
ts.isArrowFunction(n) ||
|
|
404
|
+
ts.isMethodDeclaration(n) ||
|
|
405
|
+
ts.isClassDeclaration(n) ||
|
|
406
|
+
ts.isClassExpression(n);
|
|
407
|
+
|
|
408
|
+
/** The top-level declaration `name` binds to, when it is a function. */
|
|
409
|
+
function resolveTopLevel(
|
|
410
|
+
name: string,
|
|
411
|
+
): ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | undefined {
|
|
412
|
+
for (const st of sf.statements) {
|
|
413
|
+
if (ts.isFunctionDeclaration(st) && st.name?.text === name) return st;
|
|
414
|
+
if (ts.isVariableStatement(st)) {
|
|
415
|
+
for (const decl of st.declarationList.declarations) {
|
|
416
|
+
if (
|
|
417
|
+
ts.isIdentifier(decl.name) &&
|
|
418
|
+
decl.name.text === name &&
|
|
419
|
+
decl.initializer !== undefined &&
|
|
420
|
+
(ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
|
|
421
|
+
) {
|
|
422
|
+
return decl.initializer;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let component:
|
|
431
|
+
| ts.FunctionDeclaration
|
|
432
|
+
| ts.FunctionExpression
|
|
433
|
+
| ts.ArrowFunction
|
|
434
|
+
| undefined;
|
|
435
|
+
for (const st of sf.statements) {
|
|
436
|
+
if (
|
|
437
|
+
ts.isFunctionDeclaration(st) &&
|
|
438
|
+
st.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
|
|
439
|
+
) {
|
|
440
|
+
component = st;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (ts.isExportAssignment(st) && st.isExportEquals !== true) {
|
|
444
|
+
const expr = st.expression;
|
|
445
|
+
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) component = expr;
|
|
446
|
+
else if (ts.isIdentifier(expr)) component = resolveTopLevel(expr.text);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// No default export at all, or one this detector cannot resolve to a
|
|
451
|
+
// function — `compile/default-export` owns the first case and the
|
|
452
|
+
// second is not ours to guess at.
|
|
453
|
+
if (component === undefined) return null;
|
|
454
|
+
|
|
455
|
+
const body = component.body;
|
|
456
|
+
// A concise arrow body IS the returned value.
|
|
457
|
+
if (body === undefined || !ts.isBlock(body)) return null;
|
|
458
|
+
|
|
459
|
+
let returnsValue = false;
|
|
460
|
+
let jsxStatementLine: number | undefined;
|
|
461
|
+
const visit = (node: ts.Node): void => {
|
|
462
|
+
if (returnsValue) return;
|
|
463
|
+
if (ts.isReturnStatement(node)) {
|
|
464
|
+
if (node.expression !== undefined) returnsValue = true;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (
|
|
468
|
+
jsxStatementLine === undefined &&
|
|
469
|
+
ts.isExpressionStatement(node) &&
|
|
470
|
+
(ts.isJsxElement(node.expression) ||
|
|
471
|
+
ts.isJsxFragment(node.expression) ||
|
|
472
|
+
ts.isJsxSelfClosingElement(node.expression))
|
|
473
|
+
) {
|
|
474
|
+
jsxStatementLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
475
|
+
}
|
|
476
|
+
node.forEachChild((child) => {
|
|
477
|
+
// A helper component's own `return` is not this component's.
|
|
478
|
+
if (!isFunctionLike(child)) visit(child);
|
|
479
|
+
});
|
|
480
|
+
};
|
|
481
|
+
for (const st of body.statements) if (!isFunctionLike(st)) visit(st);
|
|
482
|
+
|
|
483
|
+
return returnsValue ? null : { jsxStatementLine };
|
|
484
|
+
}
|
|
485
|
+
|
|
355
486
|
/**
|
|
356
487
|
* Run all tier 0 (deterministic, no-LLM) checks against component source.
|
|
357
488
|
*
|
|
@@ -982,6 +1113,27 @@ export async function runTier0Checks(
|
|
|
982
1113
|
});
|
|
983
1114
|
}
|
|
984
1115
|
|
|
1116
|
+
// ── Component never returns its tree ──────────────────────
|
|
1117
|
+
// The scaffold hands the model `return (` … `);` around the layout.
|
|
1118
|
+
// A root-block rewrite can delete them with it, and bare JSX at
|
|
1119
|
+
// statement position compiles, type-checks and renders NOTHING.
|
|
1120
|
+
const neverReturns = detectComponentNeverReturns(sourceCode);
|
|
1121
|
+
if (neverReturns !== null) {
|
|
1122
|
+
issues.push({
|
|
1123
|
+
tier: 0,
|
|
1124
|
+
result: 'fail',
|
|
1125
|
+
category: 'compile',
|
|
1126
|
+
subcategory: 'renders-nothing',
|
|
1127
|
+
severity: 'critical',
|
|
1128
|
+
description:
|
|
1129
|
+
neverReturns.jsxStatementLine === undefined
|
|
1130
|
+
? 'The component never returns its tree — it renders a blank frame'
|
|
1131
|
+
: `The component never returns its tree: the JSX at line ${neverReturns.jsxStatementLine} sits at statement position, so it is built and thrown away and the frame renders blank`,
|
|
1132
|
+
fix: 'Wrap the tree in the return the boilerplate gave you: `return (` before the root element and `);` after it.',
|
|
1133
|
+
...(neverReturns.jsxStatementLine === undefined ? {} : { line: neverReturns.jsxStatementLine }),
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
985
1137
|
// (Controller-View checks removed — generated components use a
|
|
986
1138
|
// single-function pattern.)
|
|
987
1139
|
|
package/src/contract-context.ts
CHANGED
|
@@ -202,6 +202,19 @@ export function buildContractsContext(
|
|
|
202
202
|
if (entry.nextStep) {
|
|
203
203
|
line += `\n Next-step hint: agent intends to call \`${entry.nextStep}\``;
|
|
204
204
|
}
|
|
205
|
+
// ggui#1108: `confirm` was the one declared action field the contract
|
|
206
|
+
// renderer dropped — every other author field (label, description,
|
|
207
|
+
// example, nextStep) reaches the model and this one did not. It is
|
|
208
|
+
// rendered as INFORMATION, not as a rule: the terminal-actions section
|
|
209
|
+
// deliberately does NOT cite it, because citing it measured ZERO —
|
|
210
|
+
// two wordings, n=4 each, `save` + `confirm: true` came back armed 0/4
|
|
211
|
+
// and 0/4. The field's own doc says "whether to show confirmation
|
|
212
|
+
// BEFORE triggering", which is gravity, not one-shot-ness, and the
|
|
213
|
+
// model reads it that way. Emitted only when true, so an absent flag
|
|
214
|
+
// leaves the prompt bytes identical to before this line existed.
|
|
215
|
+
if (entry.confirm === true) {
|
|
216
|
+
line += `\n Author flag: \`confirm\` — the author marked this action grave enough to confirm before it fires, so a second press is a mistake.`;
|
|
217
|
+
}
|
|
205
218
|
return line;
|
|
206
219
|
})
|
|
207
220
|
.join('\n');
|
|
@@ -333,6 +333,64 @@ function runViewportSized(input: AxisCheckInput): EvalIssue[] {
|
|
|
333
333
|
];
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
// ── universal.terminal_action_unguarded (ggui#1108, mitigation) ────────
|
|
337
|
+
// A control whose action the user means ONCE — submit, confirm, approve,
|
|
338
|
+
// schedule, reserve, book, pay, checkout, place order — that stays armed after
|
|
339
|
+
// it fires is one double-click away from doing it twice. The check is a WARN,
|
|
340
|
+
// deliberately: terminality lives in the request and the contract, not in a
|
|
341
|
+
// name, so a static rule can suspect it and must not refuse over it. It fires
|
|
342
|
+
// only when a terminal-sounding action or control exists AND the source guards
|
|
343
|
+
// nothing at all (no `disabled=`, no `aria-disabled`), which is the shape with
|
|
344
|
+
// no reading under which the control is safe.
|
|
345
|
+
/** The words that make an action read as meant-once. Matched as WORDS, never as substrings. */
|
|
346
|
+
const TERMINAL_WORDS = new Set([
|
|
347
|
+
"submit", "confirm", "approve", "schedule", "reserve", "book", "pay", "checkout", "purchase", "order",
|
|
348
|
+
]);
|
|
349
|
+
/** In prose (a control's label) the same words, with real boundaries. */
|
|
350
|
+
const TERMINAL_LABEL_RX = /\b(submit|confirm|approve|schedule|reserve|book|pay|checkout|purchase|place\s+order)\b/i;
|
|
351
|
+
const GUARD_RX = /\b(disabled|aria-disabled)\s*=/;
|
|
352
|
+
|
|
353
|
+
/** `submitBooking` → ['submit','booking']; `place_order` → ['place','order']. A NAME is words, not a string to search. */
|
|
354
|
+
function nameWords(name: string): string[] {
|
|
355
|
+
return name
|
|
356
|
+
.split(/(?=[A-Z])|[_\-\s]+/)
|
|
357
|
+
.map((w) => w.toLowerCase())
|
|
358
|
+
.filter((w) => w.length > 0);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The terminal-sounding action names and control labels a source carries, deduped, in order. */
|
|
362
|
+
export function findUnguardedTerminalControls(sourceCode: string): string[] {
|
|
363
|
+
if (GUARD_RX.test(sourceCode)) return [];
|
|
364
|
+
const names = new Set<string>();
|
|
365
|
+
for (const m of sourceCode.matchAll(/useAction(?:<[^>]*>)?\(\s*['"`]([^'"`]+)['"`]/g)) {
|
|
366
|
+
const name = m[1] ?? "";
|
|
367
|
+
if (nameWords(name).some((w) => TERMINAL_WORDS.has(w))) names.add(name);
|
|
368
|
+
}
|
|
369
|
+
// A control's own words, taken as the plain text immediately before its close
|
|
370
|
+
// tag — attributes can carry `=>`, so a label is read from the text, never by
|
|
371
|
+
// trying to skip an attribute list with a regex.
|
|
372
|
+
for (const m of sourceCode.matchAll(/>([^<>{}]{1,60})<\/Button>/g)) {
|
|
373
|
+
const label = (m[1] ?? "").replace(/\s+/g, " ").trim();
|
|
374
|
+
if (label.length > 0 && TERMINAL_LABEL_RX.test(label)) names.add(label.slice(0, 40));
|
|
375
|
+
}
|
|
376
|
+
return [...names];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function runTerminalActionUnguarded(input: AxisCheckInput): EvalIssue[] {
|
|
380
|
+
if (input.compiledCode === null) return [];
|
|
381
|
+
const found = findUnguardedTerminalControls(input.sourceCode);
|
|
382
|
+
if (found.length === 0) return [];
|
|
383
|
+
const list = found.map((t) => `"${t}"`).join(", ");
|
|
384
|
+
return [
|
|
385
|
+
mkIssue(
|
|
386
|
+
"universal.terminal_action_unguarded",
|
|
387
|
+
`${list} reads as an action the user means once, and nothing in this component disables the control after it fires — a double-click sends it twice.`,
|
|
388
|
+
"Hold the fired state (`const [submitted, setSubmitted] = useState(false)`), set it in the handler, and pass `disabled={submitted}` to that control with a word that says so (\"Submitted\"). If the action IS meant to repeat — sending a message, adding an item — leave it armed and ignore this.",
|
|
389
|
+
"warn",
|
|
390
|
+
),
|
|
391
|
+
];
|
|
392
|
+
}
|
|
393
|
+
|
|
336
394
|
export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
|
|
337
395
|
{
|
|
338
396
|
id: "universal.icon_name_known",
|
|
@@ -352,6 +410,12 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
|
|
|
352
410
|
values: ALL_RENDER_VALUES,
|
|
353
411
|
run: runViewportSized,
|
|
354
412
|
},
|
|
413
|
+
{
|
|
414
|
+
id: "universal.terminal_action_unguarded",
|
|
415
|
+
axis: "render",
|
|
416
|
+
values: ALL_RENDER_VALUES,
|
|
417
|
+
run: runTerminalActionUnguarded,
|
|
418
|
+
},
|
|
355
419
|
{
|
|
356
420
|
id: "universal.accent_text_ink",
|
|
357
421
|
axis: "render",
|