@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.
- package/dist/adapters/generation-dispatch.d.ts +1 -1
- package/dist/adapters/generation-dispatch.js +449 -194
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +4 -6
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +2 -2
- package/dist/advanced/index.js +391 -134
- package/dist/advanced/index.js.map +1 -1
- package/dist/boilerplate.js +29 -5
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +70 -0
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-C8Ki9Wz1.d.ts → create-ui-generator-CxPvudbs.d.ts} +1 -1
- package/dist/{dispatch-D2htzi9h.d.ts → dispatch-N8wdNOX-.d.ts} +4 -2
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js +147 -3
- 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 +142 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +3 -3
- package/dist/evaluation/index.js +217 -84
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +1 -1
- package/dist/harness/index.d.ts +1 -1
- package/dist/harness/index.js +91 -64
- 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/result-types.d.ts +1 -1
- package/dist/harness/runtime.d.ts +1 -1
- package/dist/harness/runtime.js +31 -7
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +8 -1
- package/dist/harness/types-public.js +91 -64
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +391 -134
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +1 -1
- package/dist/{types-public-DVSssdc-.d.ts → types-public-DKR_h70H.d.ts} +9 -0
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +12 -12
- package/src/adapters/google/sdk.ts +11 -7
- package/src/boilerplate/free-design-prompt.ts +2 -0
- package/src/boilerplate/generate.ts +8 -1
- package/src/boilerplate/hard-sections.ts +32 -3
- package/src/boilerplate/system-prompt.ts +3 -1
- 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/coding-agent/tools.ts +58 -6
- package/src/contract-context.ts +13 -0
- package/src/create-harness.ts +1 -0
- package/src/evaluation/axis-checks/checks/universal.ts +179 -1
- package/src/evaluation/axis-checks/dispatch.ts +8 -1
- package/src/evaluation/types-public.ts +9 -0
- package/src/harness/coding/generate-task-runner.ts +1 -0
- package/src/harness/coding/run-coding-turn.ts +14 -0
- package/src/harness/types-public.ts +7 -0
- package/src/run-check.ts +2 -0
- package/src/tools/get-wire.ts +1 -1
- package/src/validation/primitives.ts +1 -1
package/dist/workflows.d.ts
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
192
|
-
"@ggui-ai/
|
|
193
|
-
"@ggui-ai/
|
|
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/
|
|
233
|
-
"@ggui-ai/gadgets": "0.
|
|
234
|
-
"@ggui-ai/
|
|
235
|
-
"@ggui-ai/wire": "0.
|
|
236
|
-
"@ggui-ai/ui-visual-tester": "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/
|
|
252
|
-
"@ggui-ai/
|
|
253
|
-
"@ggui-ai/protocol": "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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
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
|
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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': {
|
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 as one to confirm with the user before it fires.`;
|
|
217
|
+
}
|
|
205
218
|
return line;
|
|
206
219
|
})
|
|
207
220
|
.join('\n');
|
package/src/create-harness.ts
CHANGED
|
@@ -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,
|