@ggui-ai/ui-gen 0.17.0 → 0.19.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 (69) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +1 -1
  2. package/dist/adapters/generation-dispatch.js +449 -194
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +4 -6
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +2 -2
  7. package/dist/advanced/index.js +391 -134
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/boilerplate.js +29 -5
  10. package/dist/boilerplate.js.map +1 -1
  11. package/dist/check/index.d.ts +1 -1
  12. package/dist/check/index.js +70 -0
  13. package/dist/check/index.js.map +1 -1
  14. package/dist/{create-ui-generator-C8Ki9Wz1.d.ts → create-ui-generator-CxPvudbs.d.ts} +1 -1
  15. package/dist/{dispatch-D2htzi9h.d.ts → dispatch-N8wdNOX-.d.ts} +4 -2
  16. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  17. package/dist/evaluation/axis-checks/index.js +147 -3
  18. package/dist/evaluation/axis-checks/index.js.map +1 -1
  19. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  20. package/dist/evaluation/axis-checks/registry.js +142 -1
  21. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  22. package/dist/evaluation/index.d.ts +3 -3
  23. package/dist/evaluation/index.js +217 -84
  24. package/dist/evaluation/index.js.map +1 -1
  25. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +91 -64
  28. package/dist/harness/index.js.map +1 -1
  29. package/dist/harness/prompts.js +4 -0
  30. package/dist/harness/prompts.js.map +1 -1
  31. package/dist/harness/result-types.d.ts +1 -1
  32. package/dist/harness/runtime.d.ts +1 -1
  33. package/dist/harness/runtime.js +31 -7
  34. package/dist/harness/runtime.js.map +1 -1
  35. package/dist/harness/types-public.d.ts +8 -1
  36. package/dist/harness/types-public.js +91 -64
  37. package/dist/harness/types-public.js.map +1 -1
  38. package/dist/harness/validator-trace-sink.d.ts +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.js +391 -134
  41. package/dist/index.js.map +1 -1
  42. package/dist/patch.d.ts +1 -1
  43. package/dist/{types-public-DVSssdc-.d.ts → types-public-DKR_h70H.d.ts} +9 -0
  44. package/dist/validation/index.d.ts +1 -1
  45. package/dist/validation/index.js +1 -1
  46. package/dist/validation/index.js.map +1 -1
  47. package/dist/workflows.d.ts +1 -1
  48. package/package.json +12 -12
  49. package/src/adapters/google/sdk.ts +11 -7
  50. package/src/boilerplate/free-design-prompt.ts +2 -0
  51. package/src/boilerplate/generate.ts +8 -1
  52. package/src/boilerplate/hard-sections.ts +32 -3
  53. package/src/boilerplate/system-prompt.ts +3 -1
  54. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
  55. package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
  56. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
  57. package/src/check/run-tier0.ts +152 -0
  58. package/src/coding-agent/tools.ts +58 -6
  59. package/src/contract-context.ts +13 -0
  60. package/src/create-harness.ts +1 -0
  61. package/src/evaluation/axis-checks/checks/universal.ts +179 -1
  62. package/src/evaluation/axis-checks/dispatch.ts +8 -1
  63. package/src/evaluation/types-public.ts +9 -0
  64. package/src/harness/coding/generate-task-runner.ts +1 -0
  65. package/src/harness/coding/run-coding-turn.ts +14 -0
  66. package/src/harness/types-public.ts +7 -0
  67. package/src/run-check.ts +2 -0
  68. package/src/tools/get-wire.ts +1 -1
  69. package/src/validation/primitives.ts +1 -1
@@ -2,7 +2,7 @@ import { C as Classification } from './axes-CzLEMDeB.js';
2
2
  import { Workflow } from './harness/types-public.js';
3
3
  import '@ggui-ai/protocol';
4
4
  import './styling-profile-CxfvV48g.js';
5
- import './types-public-DVSssdc-.js';
5
+ import './types-public-DKR_h70H.js';
6
6
  import './design-mode-DLufhrNp.js';
7
7
  import './types-BOvHNG7K.js';
8
8
  import './llm.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.17.0",
3
+ "version": "0.19.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.17.0",
192
- "@ggui-ai/protocol": "0.17.0",
193
- "@ggui-ai/project-config": "0.17.0"
191
+ "@ggui-ai/mcp-server-core": "0.19.0",
192
+ "@ggui-ai/project-config": "0.19.0",
193
+ "@ggui-ai/protocol": "0.19.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/sandbox": "0.17.0",
233
- "@ggui-ai/gadgets": "0.17.0",
234
- "@ggui-ai/design": "0.17.0",
235
- "@ggui-ai/wire": "0.17.0",
236
- "@ggui-ai/ui-visual-tester": "0.17.0"
232
+ "@ggui-ai/design": "0.19.0",
233
+ "@ggui-ai/gadgets": "0.19.0",
234
+ "@ggui-ai/sandbox": "0.19.0",
235
+ "@ggui-ai/wire": "0.19.0",
236
+ "@ggui-ai/ui-visual-tester": "0.19.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.17.0",
252
- "@ggui-ai/mcp-server-core": "0.17.0",
253
- "@ggui-ai/protocol": "0.17.0"
251
+ "@ggui-ai/mcp-server-core": "0.19.0",
252
+ "@ggui-ai/project-config": "0.19.0",
253
+ "@ggui-ai/protocol": "0.19.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=22.0.0"
@@ -62,15 +62,10 @@ export async function runAdkLoop(
62
62
  ): Promise<AdapterResult> {
63
63
  const startTime = Date.now();
64
64
 
65
- const { FunctionTool, LlmAgent, InMemoryRunner, isFinalResponse } = await import('@google/adk');
65
+ const { FunctionTool, Gemini, LlmAgent, InMemoryRunner, isFinalResponse } = await import('@google/adk');
66
66
 
67
67
  const capture = createCapture();
68
68
 
69
- // Ensure ADK can find the API key
70
- if (params.apiKey && !process.env.GOOGLE_GENAI_API_KEY) {
71
- process.env.GOOGLE_GENAI_API_KEY = params.apiKey;
72
- }
73
-
74
69
  // Build tools with capture wrappers
75
70
  const adkTools = params.tools.map((def) =>
76
71
  new FunctionTool({
@@ -88,9 +83,18 @@ export async function runAdkLoop(
88
83
  }),
89
84
  );
90
85
 
86
+ // The key the caller passed WINS, handed to the model directly (ggui#1132).
87
+ // The ADK resolves GOOGLE_GENAI_API_KEY || GOOGLE_API_KEY || GEMINI_API_KEY
88
+ // on its own when no key is given, so writing GOOGLE_GENAI_API_KEY here —
89
+ // a variable this adapter does not own — let a stale ambient value beat a
90
+ // fresh key, and no rotation could reach it. Precedence is fixed at the
91
+ // source: an explicit key never touches process.env; no key defers to the
92
+ // ADK's order untouched.
93
+ const model = params.apiKey ? new Gemini({ model: params.model, apiKey: params.apiKey }) : params.model;
94
+
91
95
  const agent = new LlmAgent({
92
96
  name: 'ui_generator',
93
- model: params.model,
97
+ model,
94
98
  instruction: params.systemPrompt,
95
99
  tools: adkTools,
96
100
  });
@@ -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
- actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}`);
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
 
@@ -213,9 +213,31 @@ Rules:
213
213
  1. All request-specific data → props with defaults. City names, tickers, user names, dates, counts.
214
214
  2. Layout and styling are universal. Colors, spacing, structure — these are the reusable part.
215
215
  3. Default values come from the current request — so the component renders correctly standalone.
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.
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. **Do NOT add a caps label above the heading** — not \`<Text caps>LET'S BEGIN</Text>\`, not \`WELCOME\`, not a section kicker like \`GETTING STARTED\` or \`OVERVIEW\` — UNLESS a prop supplies that exact text (\`<Text caps>{props.section}</Text>\`). When the request asks for a heading and a line, render the heading and the line and NOTHING above the heading. 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, and a caps eyebrow you wrote is the most common form of it.
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,
@@ -601,7 +603,7 @@ Don't render text-only metrics. A weather widget without a sun/cloud, a stock ca
601
603
  </Row>
602
604
  \`\`\`
603
605
 
604
- Use \`<Icon name="..." />\` for line icons — ONLY the curated Lucide subset renders (call \`get_available_icons\` for the exact kebab-case names; a name outside it renders an empty box and fails the check); emoji directly for status/weather/mood. Both are valid. For per-stat tiny accents, use a small icon next to the label.
606
+ Use \`<Icon name="..." />\` for line icons — ONLY the design system's curated set renders (~200 kebab-case Lucide names); a name OUTSIDE it renders an empty box and fails the check. On a single-pass render you cannot look the set up, so use ONLY a name you are sure is in it. Safe common names: \`check\` \`x\` \`plus\` \`minus\` \`search\` \`settings\` \`user\` \`users\` \`calendar\` \`clock\` \`arrow-right\` \`arrow-left\` \`chevron-right\` \`chevron-down\` \`trash-2\` \`edit\` \`info\` \`alert-circle\` \`bell\` \`home\` \`file\` \`file-text\` \`star\` \`heart\` \`mail\` \`send\` \`download\` \`upload\` \`external-link\` \`filter\` \`check-circle\`. The numbered variants (\`edit-2\`, \`edit-3\`) and synonyms (\`pencil\`, \`pen\`, \`trash\`, \`x-circle\`) are NOT in the set — use \`edit\` and \`trash-2\`. If the icon you want is not clearly one of these, OMIT it: a card with no icon beats one with an empty box. Emoji render directly for status/weather/mood. For per-stat tiny accents, use a small icon next to the label.
605
607
 
606
608
  ### Spacing — generosity beats compactness
607
609
 
@@ -1,3 +1,5 @@
1
- <Container maxWidth="xl" padding="lg">
2
- {/* implement your UI here */}
3
- </Container>
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
- <Container maxWidth="sm" padding="md">
2
- {/* implement your UI here */}
3
- </Container>
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
- <Container maxWidth="xl" padding="lg">
2
- {/* implement your UI here */}
3
- </Container>
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>
@@ -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
 
@@ -12,6 +12,10 @@ import * as esbuild from 'esbuild';
12
12
  import { AgentWorkspace } from './workspace';
13
13
  import { getSoftWarnings } from './self-check';
14
14
  import { runTier0Checks } from '../check/index.js';
15
+ import { runGatedAxisChecks, traceAxisChecksSkipped } from '../evaluation/axis-checks/dispatch.js';
16
+ import type { AxisCheck, AxisCheckInput } from '../evaluation/types-public.js';
17
+ import type { Classification } from '../classifier/axes.js';
18
+ import type { CanvasClass } from '../design-mode.js';
15
19
  import { PRIMITIVES_DOCUMENTATION } from '../validation/index.js';
16
20
  import type { DataContract } from '@ggui-ai/protocol';
17
21
  import type {
@@ -46,6 +50,22 @@ function getComponentDocumentation(name: string): string {
46
50
  // Auto-commit helper (shared by write and apply_changes)
47
51
  // =============================================================================
48
52
 
53
+ /**
54
+ * The deterministic axis family at auto-commit (ggui#1122) — what a caller with a
55
+ * harness hands `executeTool` so the checks run on EVERY commit the coding agent
56
+ * makes, i.e. on the serve path, not only inside the eval round a serving deployment
57
+ * never enters. `checks` is the harness's pre-filtered list (`harness.check.axisChecks`)
58
+ * — the same list `run-check.ts` feeds the same pure runner. Absent: nothing runs.
59
+ */
60
+ export interface AutoCommitAxisChecks {
61
+ readonly checks: readonly AxisCheck[];
62
+ readonly classification: Classification;
63
+ /** The ORIGINAL request — several checks read it (labels invented past the copy, terminal words). */
64
+ readonly originalPrompt: string;
65
+ /** The canvas the harness was built for (ggui#1117); absent = no rendering context, never a default. */
66
+ readonly canvas?: CanvasClass;
67
+ }
68
+
49
69
  async function autoCommit(
50
70
  workspace: AgentWorkspace,
51
71
  commitMeta: Map<string, CommitMetadata>,
@@ -72,6 +92,8 @@ async function autoCommit(
72
92
  gadgetTypes?: Readonly<Record<string, string>>,
73
93
  /** Which tier-0 legs fire — see `runTier0Checks`'s `designMode`. */
74
94
  designMode: DesignMode = DEFAULT_DESIGN_MODE,
95
+ /** ggui#1122: run the harness's axis checks here too; absent, none run. */
96
+ axis?: AutoCommitAxisChecks,
75
97
  ): Promise<ToolResult> {
76
98
  const commitStart = Date.now();
77
99
  const raw = workspace.read();
@@ -123,17 +145,45 @@ async function autoCommit(
123
145
  gadgetTypes,
124
146
  designMode,
125
147
  );
126
- const tier0Fails = tier0Issues.filter(i => i.result === 'fail');
148
+ // ggui#1122: the deterministic axis family runs HERE — on every commit the coding
149
+ // agent makes, which is the serve path — through the one pure runner `run-check.ts`
150
+ // feeds (the ggui#1046 trace line prints on both paths). Posture is the founder's:
151
+ // warn-as-warn, fail-as-fail. A WARN that started blocking a served generation
152
+ // because it moved surface would be a posture change, not a relocation, and comes
153
+ // to him as a named item. Only a source that BUILT is judged: on a build failure
154
+ // the checks are not asked (the same stand-down the gate+run wrapper makes) and
155
+ // the ggui#1046 trace says so — a skipped round never reads as a clean one.
156
+ const axisFacts = axis !== undefined
157
+ ? {
158
+ sourceCode: formatted,
159
+ ...(contract !== undefined ? { contract } : {}),
160
+ originalPrompt: axis.originalPrompt,
161
+ classification: axis.classification,
162
+ designMode,
163
+ ...(axis.canvas !== undefined ? { canvas: axis.canvas } : {}),
164
+ }
165
+ : undefined;
166
+ if (axisFacts !== undefined && !buildSuccess) traceAxisChecksSkipped(axisFacts, 'build failed — no check ran');
167
+ const axisRun = axis !== undefined && axisFacts !== undefined && buildSuccess
168
+ ? runGatedAxisChecks(axis.checks, { ...axisFacts, compiledCode } satisfies AxisCheckInput)
169
+ : undefined;
170
+ const axisIssues = axisRun?.issues ?? [];
171
+ const checkIssues = [...tier0Issues, ...axisIssues];
172
+ const tier0Fails = checkIssues.filter(i => i.result === 'fail');
127
173
  const selfCheckPassed = tier0Fails.length === 0;
128
174
  // When contextPolicy.labeledTier0 is set, prefix each violation with
129
175
  // its P0/P1/P2 priority so the LLM can rank against the prompt's
130
176
  // schema. The default (off) emits unlabeled feedback.
131
- const violations = tier0Issues
177
+ const violations = checkIssues
132
178
  .filter(i => i.result === 'fail')
133
179
  .map(i => contextPolicy?.labeledTier0
134
180
  ? `[${i.priority ?? 'P0'}-${i.category}] ${i.description}\n Fix: ${i.fix}`
135
181
  : `[${i.category}] ${i.description}\n Fix: ${i.fix}`);
136
- const softWarnings = getSoftWarnings(raw);
182
+ // Axis WARNs ride the existing non-blocking channel — feedback the model sees, never a block.
183
+ const axisWarnings = axisIssues
184
+ .filter(i => i.result === 'warn')
185
+ .map(i => `[${i.subcategory}] ${i.description} Fix: ${i.fix}`);
186
+ const softWarnings = [...getSoftWarnings(raw), ...axisWarnings];
137
187
  const selfCheckMs = Date.now() - selfCheckStart;
138
188
 
139
189
  // Always commit (preserves history)
@@ -144,7 +194,7 @@ async function autoCommit(
144
194
 
145
195
  const status = buildSuccess && selfCheckPassed ? 'PASS' : 'FAIL';
146
196
  console.log(
147
- `[coding-agent] auto-commit: ${status} | build=${buildMs}ms self-check=${selfCheckMs}ms git=${gitMs}ms total=${Date.now() - commitStart}ms | violations=${violations.length}`,
197
+ `[coding-agent] auto-commit: ${status} | build=${buildMs}ms self-check=${selfCheckMs}ms git=${gitMs}ms total=${Date.now() - commitStart}ms | violations=${violations.length} | axis=${axisRun === undefined ? 'off' : `${axisIssues.length - axisWarnings.length}F/${axisWarnings.length}W`}`,
148
198
  );
149
199
  if (violations.length > 0) {
150
200
  for (const v of violations) {
@@ -202,6 +252,8 @@ export async function executeTool(
202
252
  gadgetTypes?: Readonly<Record<string, string>>,
203
253
  /** Which tier-0 legs fire on auto-commit — see `runTier0Checks`'s `designMode`. */
204
254
  designMode: DesignMode = DEFAULT_DESIGN_MODE,
255
+ /** ggui#1122: the harness's axis checks, run at every auto-commit — see `AutoCommitAxisChecks`. */
256
+ axis?: AutoCommitAxisChecks,
205
257
  ): Promise<ToolResult> {
206
258
  switch (tool) {
207
259
  case 'write':
@@ -222,7 +274,7 @@ export async function executeTool(
222
274
  const message = (input.commit_message as string) || `write ${lineCount} lines`;
223
275
 
224
276
  console.log(`[coding-agent] write: ${lineCount} lines → auto-commit`);
225
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
277
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
226
278
  }
227
279
 
228
280
  case 'apply_changes': {
@@ -529,7 +581,7 @@ export async function executeTool(
529
581
  const message = (input.commit_message as string) || 'apply changes';
530
582
 
531
583
  console.log(`[coding-agent] apply_changes: ${changes.length} changes applied → auto-commit`);
532
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
584
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
533
585
  }
534
586
 
535
587
  case 'get_components_info': {
@@ -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 as one to confirm with the user before it fires.`;
217
+ }
205
218
  return line;
206
219
  })
207
220
  .join('\n');
@@ -224,6 +224,7 @@ export function createHarness(input: CreateHarnessInput): Harness {
224
224
  name,
225
225
  classification,
226
226
  designMode,
227
+ ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
227
228
  ...(input.profile !== undefined ? { profile: input.profile } : {}),
228
229
  how,
229
230
  what,