@genesislcap/blank-app-seed 5.27.2-prerelease.2 → 5.28.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 (36) hide show
  1. package/.genx/ai-consumer.json +18 -0
  2. package/.genx/configure.js +122 -0
  3. package/.genx/package.json +1 -1
  4. package/.genx/scripts/check-ai-declaration.mjs +116 -0
  5. package/.genx/scripts/check-ai-emission.sh +974 -0
  6. package/.genx/scripts/generate-test-apps.sh +38 -4
  7. package/.genx/templates/react/ai/assistant-host.ts.hbs +30 -0
  8. package/.genx/templates/react/ai/assistant.ts.hbs +52 -0
  9. package/.genx/templates/react/ai/extensions.ts.hbs +17 -0
  10. package/.genx/templates/react/ai/pbc-elements.ts.hbs +17 -0
  11. package/.genx/templates/server/ai-service-web-handler.kts.hbs +394 -0
  12. package/.genx/tests/contracts/ai/ai-resolver-cases.json +6807 -0
  13. package/.genx/tests/contracts/ai/ui-config-ai.schema.json +203 -0
  14. package/.genx/tests/fixtures/ai-config-parse-breakers.json +16 -0
  15. package/.genx/tests/fixtures/ai-config.json +41 -0
  16. package/.genx/versions.json +3 -3
  17. package/.github/workflows/build.yml +20 -4
  18. package/CHANGELOG.md +55 -36
  19. package/README.md +139 -0
  20. package/bdd-tests/build.gradle.kts +1 -1
  21. package/bdd-tests/gradle/wrapper/gradle-wrapper.properties +1 -1
  22. package/bdd-tests/gradle.properties +0 -1
  23. package/bdd-tests/settings.gradle.kts +2 -2
  24. package/client-tmp/angular/package.json +3 -0
  25. package/client-tmp/react/.oxfmtrc.json +3 -0
  26. package/client-tmp/react/package.json +6 -2
  27. package/client-tmp/web-components/package.json +3 -0
  28. package/client-tmp/web-components/settings.gradle.kts +0 -22
  29. package/gradle/wrapper/gradle-wrapper.properties +1 -1
  30. package/package.json +1 -1
  31. package/server/build.gradle.kts +8 -1
  32. package/server/gradle/wrapper/gradle-wrapper.properties +1 -1
  33. package/server/gradle.properties +3 -3
  34. package/server/settings.gradle.kts +2 -2
  35. package/server/{{appName}}-app/src/main/genesis/scripts/genesis-router.kts +10 -0
  36. package/server/{{appName}}-app/src/test/kotlin/global/genesis/EventHandlerTest.kt +1 -1
@@ -0,0 +1,974 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Check what this seed emits for the AI chat feature, and what it does NOT.
4
+ #
5
+ # The server half of the feature is gated on ONE value — `data.AI.enabled` in configure.js — and
6
+ # every assertion here is a way that gate could fail silently:
7
+ #
8
+ # off ui.ai absent and ui.ai.enabled=false generate the same app, and neither carries any
9
+ # AI file. A project that never asked for chat must not change.
10
+ # package every React app depends on @genesislcap/ai-assistant at exactly versions.UI, AI on or
11
+ # off (Create's prebuilt preview base must already carry it); a non-React app does not.
12
+ # on the proxy, the router body cap and the README section all appear; the cap is above the
13
+ # proxy's own document limit; the proxy is its template with only its two limits filled
14
+ # in, for either vendor; and no AI item lands in a system-definition file (a generator may
15
+ # rewrite those, so the proxy must not need one). The client gets its ai-config.json
16
+ # (exactly what .genx/ai-consumer.json declares, whatever the prompt holds); the
17
+ # assistant's four source files (pbc/ai-assistant/elements.ts,
18
+ # ai/generated/assistant-host.ts and assistant.ts, ai/extensions/index.ts), each its
19
+ # template as written and holding the parts the assistant needs; the AI build flag and
20
+ # an .oxfmtrc.json entry that skips ai/generated; and nothing else.
21
+ # non-react ui.ai.enabled on a non-React app emits nothing: there is no panel to call the proxy.
22
+ # C-8 the contract files shared with Create are byte-for-byte the copies Create pins, and
23
+ # each of Create's resolver cases reaches the app verbatim, minus what the declaration
24
+ # withholds, and says so for each resource and key it drops.
25
+ # declared the declaration check refuses an assistant install it cannot read, and holds the
26
+ # declaration between the baseline and what the assistant reads.
27
+ # leaks no generated file carries anything Create's export guard would refuse.
28
+ #
29
+ # Usage: .genx/scripts/check-ai-emission.sh
30
+ # Env: GRADLE=1 also build the AI app's server, require the platform's own security scan
31
+ # (checkAuthPermissions) to report no insecure endpoint, and compile both vendors'
32
+ # proxies with the platform's preCompileScripts, and render the system definition
33
+ # with canary keys to show that a key the proxy reads never reaches a build file.
34
+ # Needs Genesis artifactory credentials, as the sample-app build does.
35
+ # GRADLE_PARAMS=... extra arguments for every gradle call (CI passes -PuseDevRepo=true on
36
+ # prerelease).
37
+ # KEEP=1 keep the generated apps for inspection.
38
+ # GENX=... the genx package to generate with (default: a pinned version, so a run is
39
+ # reproducible; set GENX=@genesislcap/genx@latest to try the newest).
40
+ # BASELINE_REF=... also compare an AI-off app against this release (e.g. origin/main). Off
41
+ # unless set: the changes it allows are this branch's own, so once they are released
42
+ # any other change to an AI-off app would fail it.
43
+
44
+ set -uo pipefail
45
+
46
+ SEED_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
47
+ TEMPLATE="$SEED_DIR/.genx/templates/server/ai-service-web-handler.kts.hbs"
48
+ WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/blank-app-seed-ai.XXXXXXXX")" || exit 1
49
+ MODULE="server/demo-app/src/main/genesis"
50
+ FAILURES=()
51
+ GENX="${GENX:-@genesislcap/genx@15.35.1}"
52
+
53
+ # The Gemini app runs on the 'ai' fixture: resources of both kinds and a prompt full of Handlebars that
54
+ # renders, so a missing escape changes the file. Text Handlebars cannot even parse is a separate case:
55
+ # there a whole-file render fails and genx ships the file as written, which only the log check catches.
56
+ AI_FIXTURE="$SEED_DIR/.genx/tests/fixtures/ai-config.json"
57
+ AI_UI="$(cat "$AI_FIXTURE")"
58
+ AI_UI_BREAKERS="$(cat "$SEED_DIR/.genx/tests/fixtures/ai-config-parse-breakers.json")"
59
+ AI_UI_ANTHROPIC='{"ai":{"enabled":true,"vendor":"anthropic","tier":"high","systemPrompt":"x","resources":[]}}'
60
+ # 'extras' is the fixture plus more writes and a custom event, then with things smuggled in that the writer
61
+ # must drop: two top-level keys, a key on the request, a customCode on the request and on the custom
62
+ # event, and customCodes a writer must not pass on as sent: one with a key of its own, a name that is not
63
+ # text and a listComplete that is not true; a whole list whose listComplete is not true; a list cut to
64
+ # its names, and one that is not a list, each saying it is complete; and a null one. It must come out as
65
+ # its clean form.
66
+ AI_UI_EXTRAS_CLEAN="$(node -e '
67
+ const u = JSON.parse(process.argv[1]);
68
+ const named = (name) => u.ai.resources.find((resource) => resource.name === name);
69
+ const cut = (alsoWrites) => ({ alsoWrites, listComplete: false });
70
+ named("EVENT_TRADE_MODIFY").customCode = cut(["POSITION"]);
71
+ u.ai.resources.push(
72
+ { name: "EVENT_TRADE_DELETE", kind: "event", op: "delete", context: "Remove a trade.", customCode: cut([]) },
73
+ { name: "EVENT_POSITION_MODIFY", kind: "event", op: "modify", context: "Change a position.", customCode: cut(["TRADE"]) },
74
+ { name: "EVENT_POSITION_INSERT", kind: "event", op: "insert", context: "Open a position." },
75
+ { name: "EVENT_TRADE_CANCEL", kind: "event", op: "custom", context: "Cancel a trade." },
76
+ );
77
+ console.log(JSON.stringify(u));' "$AI_UI")"
78
+ AI_UI_EXTRAS="$(node -e '
79
+ const u = JSON.parse(process.argv[1]);
80
+ const named = (name) => u.ai.resources.find((resource) => resource.name === name);
81
+ const code = { alsoWrites: ["TRADE"], listComplete: true };
82
+ u.ai.budgetUsd = 5;
83
+ u.ai.endpoint = "https://example.invalid";
84
+ const reference = named("EVENT_TRADE_INSERT").references[0];
85
+ Object.assign(named("REQ_TRADE"), { url: "https://example.invalid", customCode: code, references: [reference] });
86
+ named("EVENT_TRADE_DELETE").references = [reference];
87
+ named("EVENT_TRADE_CANCEL").references = [reference];
88
+ named("EVENT_TRADE_CANCEL").customCode = code;
89
+ const insert = named("EVENT_TRADE_INSERT");
90
+ insert.references = [
91
+ { ...reference, url: "https://example.invalid", fields: [{ ...reference.fields[0], hint: "x" }] },
92
+ ];
93
+ insert.customCode = {
94
+ alsoWrites: [...insert.customCode.alsoWrites, 7],
95
+ listComplete: "yes",
96
+ url: "https://example.invalid",
97
+ };
98
+ named("EVENT_POSITION_MODIFY").customCode = { alsoWrites: ["TRADE"], listComplete: "yes" };
99
+ named("EVENT_TRADE_MODIFY").customCode = { alsoWrites: ["POSITION", 7], listComplete: true };
100
+ named("EVENT_TRADE_DELETE").customCode = { alsoWrites: "TRADE", listComplete: true };
101
+ named("EVENT_POSITION_INSERT").customCode = null;
102
+ console.log(JSON.stringify(u));' "$AI_UI_EXTRAS_CLEAN")"
103
+ # 'rows' is the fixture plus a row action (C-18.8) and the modify's key (C-19), then with things
104
+ # smuggled in that the writer must drop: a key that is not a field name on each, a key on an input
105
+ # and on an effect, and a key on a read and on an insert, which name no row. It must come out as its
106
+ # clean form.
107
+ AI_UI_ROWS_CLEAN="$(node -e '
108
+ const u = JSON.parse(process.argv[1]);
109
+ u.ai.resources.push({
110
+ name: "EVENT_REPRICE_TRADE", kind: "event", op: "custom",
111
+ context: "Run the REPRICE_TRADE handler on one existing TRADE row. It changes that TRADE row. It needs PRICE.",
112
+ shape: "row", entity: "TRADE", key: ["TRADE_ID"],
113
+ inputs: [{ field: "PRICE", required: true }],
114
+ effects: [{ op: "modify", table: "TRADE" }, { op: "insert", table: "POSITION" }],
115
+ });
116
+ u.ai.resources.find((resource) => resource.name === "EVENT_TRADE_MODIFY").key = ["TRADE_ID"];
117
+ console.log(JSON.stringify(u));' "$AI_UI")"
118
+ AI_UI_ROWS="$(node -e '
119
+ const u = JSON.parse(process.argv[1]);
120
+ const row = u.ai.resources.find((resource) => resource.name === "EVENT_REPRICE_TRADE");
121
+ const named = (name) => u.ai.resources.find((resource) => resource.name === name);
122
+ row.key = [...row.key, 7];
123
+ row.inputs[0].url = "https://example.invalid";
124
+ row.effects[0].endpoint = "https://example.invalid";
125
+ named("EVENT_TRADE_MODIFY").key = ["TRADE_ID", 7];
126
+ named("REQ_TRADE").key = ["TRADE_ID"];
127
+ named("EVENT_TRADE_INSERT").key = ["TRADE_ID"];
128
+ console.log(JSON.stringify(u));' "$AI_UI_ROWS_CLEAN")"
129
+ # The limits configure.js writes into the proxy, per vendor: every tier of that vendor's models.
130
+ GEMINI_MODELS='gemini-3.1-flash-lite,gemini-3.8-flash,gemini-3.1-pro-preview'
131
+ ANTHROPIC_MODELS='claude-haiku-4-5-20251001,claude-sonnet-5,claude-opus-4-8'
132
+
133
+ fail() { FAILURES+=("$1"); echo "FAIL: $1"; }
134
+
135
+ generate() {
136
+ local label="$1"; shift
137
+ mkdir -p "$WORK_DIR/$label"
138
+ (cd "$WORK_DIR/$label" && npx -y "$GENX" init demo -s "${SEED:-$SEED_DIR}" -x --no-shell \
139
+ --apiHost 'wss://localhost/gwf/' "$@" > "$WORK_DIR/$label.log" 2>&1) \
140
+ || { fail "$label: generation failed (see $WORK_DIR/$label.log)"; return 1; }
141
+ # genx logs a file it cannot render and ships it unrendered, so a broken {{#if}} in a template, or a
142
+ # {{ that escaped the ai-config writer, passes generation and only surfaces later, or never.
143
+ if grep -q 'Error interpolating variables' "$WORK_DIR/$label.log"; then
144
+ fail "$label: genx could not render a file and shipped it unrendered: $(grep -o 'Error interpolating variables in [^ ]*' "$WORK_DIR/$label.log" | head -3 | tr '\n' ' ')"
145
+ fi
146
+ }
147
+
148
+ # Every file or block the feature adds. Used both ways: all present when on, none when off. The assistant
149
+ # package is not one of them: every React app depends on it (checked under "package").
150
+ ai_artifacts_present() {
151
+ local app="$WORK_DIR/$1/demo"
152
+ local found=0
153
+ [ -f "$app/$MODULE/scripts/ai-service-web-handler.kts" ] && found=$((found + 1))
154
+ grep -q httpObjectAggregator "$app/$MODULE/scripts/genesis-router.kts" && found=$((found + 1))
155
+ grep -q '^## AI chat' "$app/README.md" && found=$((found + 1))
156
+ [ -f "$app/client/src/ai/generated/ai-config.json" ] && found=$((found + 1))
157
+ [ -f "$app/client/src/pbc/ai-assistant/elements.ts" ] && found=$((found + 1))
158
+ echo "$found"
159
+ }
160
+
161
+ # The C-8 contract files are Create's (server/shared-schemas/ai/), copied here verbatim. Create's resolver
162
+ # test pins the same digests, so an edit on either side fails until both sides bump the version together.
163
+ echo "=== C-8 contract copies"
164
+ node - "$SEED_DIR/.genx/tests/contracts/ai" "$SEED_DIR/.genx/versions.json" <<'NODE' || fail "C-8: a contract copy is not the one Create pins, or the UI pin is below what the copies need (see above)"
165
+ const { createHash } = require('crypto');
166
+ const fs = require('fs');
167
+ const path = require('path');
168
+ const pinned = {
169
+ 'ui-config-ai.schema.json': { version: '1.6.0', sha256: '99549c80f065564701c9a8b83154e36d98ad9fd46162fc7bfad00aaf1b9b3498' },
170
+ 'ai-resolver-cases.json': { version: '1.8.0', sha256: '48d47fadd396883f1858b60a066fe6a1ae99080537d73be9ab139ab8cca583c7' },
171
+ };
172
+ // The copies are Create master 1c403807d (genesis-create #1934). They carry queries, references, row
173
+ // actions and keys, which only this UI release on reads (C-17.5, C-18.8, C-19): a seed that takes the
174
+ // copies without the release would ship them to an assistant that ignores them, or drops them.
175
+ const MIN_UI_FOR_CASES = '15.50.0';
176
+ const core = (version) => String(version).split(/[-+]/)[0].split('.').map(Number);
177
+ const below = (a, b) => {
178
+ const [x, y] = [core(a), core(b)];
179
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
180
+ return false;
181
+ };
182
+ let bad = 0;
183
+ const ui = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')).UI;
184
+ if (below(ui, MIN_UI_FOR_CASES)) {
185
+ console.log(` versions.json pins UI ${ui}, below ${MIN_UI_FOR_CASES}`);
186
+ bad++;
187
+ }
188
+ for (const [file, want] of Object.entries(pinned)) {
189
+ const bytes = fs.readFileSync(path.join(process.argv[2], file));
190
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
191
+ const { version } = JSON.parse(bytes);
192
+ if (sha256 !== want.sha256 || version !== want.version) {
193
+ console.log(` ${file}: version ${version}, sha256 ${sha256}`);
194
+ bad++;
195
+ }
196
+ }
197
+ process.exit(bad ? 1 : 0);
198
+ NODE
199
+
200
+ echo "=== Generating into $WORK_DIR"
201
+ generate default --framework react
202
+ # A whole resolved block, switched off: Create can pass one through, and it must emit nothing at all
203
+ # rather than a panel that only says it is blocked.
204
+ generate off --framework react --ui "$(node -e 'const u = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); u.ai.enabled = false; console.log(JSON.stringify(u))' "$SEED_DIR/.genx/tests/fixtures/ai-config.json")"
205
+ generate on --framework react --ui "$AI_UI"
206
+ generate onanthropic --framework react --ui "$AI_UI_ANTHROPIC"
207
+ generate breakers --framework react --ui "$AI_UI_BREAKERS"
208
+ generate rows --framework react --ui "$AI_UI_ROWS"
209
+ generate extras --framework react --ui "$AI_UI_EXTRAS"
210
+ generate nonreact --framework webcomponents --ui "$AI_UI"
211
+
212
+ echo "=== off"
213
+ [ "$(ai_artifacts_present default)" = "0" ] || fail "off: a project with no ui.ai carries AI files"
214
+ [ "$(ai_artifacts_present off)" = "0" ] || fail "off: ui.ai.enabled=false still emitted AI files"
215
+ # answers.json records the seed path and a timestamped layout key, so it differs on every run.
216
+ diff -r -x node_modules -x answers.json "$WORK_DIR/default/demo" "$WORK_DIR/off/demo" > /dev/null \
217
+ || fail "off: ui.ai.enabled=false generates a different app from no ui.ai at all"
218
+
219
+ # Against the last release: an AI-off app may differ from it only in the changes this branch makes
220
+ # on purpose — the Genesis Start launcher version, the repository it needs, its client scripts, the
221
+ # README section about them, and the assistant package every React app now depends on — and in
222
+ # nothing else, line by line.
223
+ BASELINE_REF="${BASELINE_REF:-}"
224
+ if [ -z "$BASELINE_REF" ]; then
225
+ echo "=== off vs a release: skipped, set BASELINE_REF to run it"
226
+ elif ! git -C "$SEED_DIR" rev-parse --verify -q "$BASELINE_REF^{commit}" > /dev/null; then
227
+ fail "off: BASELINE_REF=$BASELINE_REF is not a commit in this clone"
228
+ else
229
+ echo "=== off vs $BASELINE_REF"
230
+ mkdir -p "$WORK_DIR/baseline-seed"
231
+ git -C "$SEED_DIR" archive "$BASELINE_REF" | tar -x -C "$WORK_DIR/baseline-seed"
232
+ SEED="$WORK_DIR/baseline-seed" generate baseline --framework react
233
+ node - "$WORK_DIR/baseline/demo" "$WORK_DIR/default/demo" "$SEED_DIR/README.md" "$SEED_DIR/.genx/versions.json" <<'NODE' \
234
+ || fail "off: an AI-off app differs from $BASELINE_REF beyond the intended changes (see above)"
235
+ const fs = require('fs');
236
+ const { spawnSync } = require('child_process');
237
+ const [base, next, seedReadme, versionsFile] = process.argv.slice(2);
238
+ const ui = JSON.parse(fs.readFileSync(versionsFile, 'utf8')).UI;
239
+ const readme = fs.readFileSync(seedReadme, 'utf8');
240
+ const from = readme.indexOf('## Running the application');
241
+ const section = from < 0 ? [] : readme.slice(from, readme.indexOf('\n# License', from)).split('\n');
242
+ // Each rule lists the exact lines this change adds or removes; each may be used once.
243
+ const exact = (...lines) => lines;
244
+ const allowed = {
245
+ 'server/gradle.properties': {
246
+ removed: exact('startVersion=0.1.9'),
247
+ added: exact(
248
+ '# Genesis Start launcher. 0.1.15 is the first release with a headless mode (-Pgenesis.start.headless=true);',
249
+ '# plain genesisStart still opens the desktop launcher.',
250
+ 'startVersion=0.1.15',
251
+ ),
252
+ },
253
+ 'server/build.gradle.kts': {
254
+ removed: exact(),
255
+ added: exact(
256
+ " // The Genesis Start launcher (0.1.12+) needs androidx.* artifacts, which none of the repositories",
257
+ " // above have. Last, and for those groups only, so nothing else is ever looked up at Google.",
258
+ ' google {',
259
+ ' content {',
260
+ ' includeGroupByRegex("androidx\\\\..*")',
261
+ ' }',
262
+ ' }',
263
+ ),
264
+ },
265
+ 'client/package.json': {
266
+ removed: exact(),
267
+ added: exact(
268
+ ` "@genesislcap/ai-assistant": "${ui}",`,
269
+ ' "genesis-start": "cd ../server && ./gradlew genesisStart",',
270
+ ' "genesis-start:headless": "cd ../server && ./gradlew genesisStart -Pgenesis.start.headless=true -Pgenesis.start.restEnabled=true -Pgenesis.start.restPort=18080",',
271
+ ' "genesis-start:write-script": "cd ../server && ./gradlew writeStartScript -Pgenesis.start.headless=true -Pgenesis.start.restEnabled=true -Pgenesis.start.restPort=18080",',
272
+ ),
273
+ },
274
+ 'README.md': { removed: exact(), added: [...section] },
275
+ };
276
+ const counts = (lines) => lines.reduce((m, l) => m.set(l, (m.get(l) || 0) + 1), new Map());
277
+ // Lines in `from` beyond their count in `to`, less the ones `allowance` accounts for.
278
+ const unexpected = (from, to, allowance) => {
279
+ const left = counts(allowance);
280
+ const extra = [];
281
+ for (const [line, n] of counts(from)) {
282
+ for (let i = counts(to).get(line) || 0; i < n; i++) {
283
+ if (left.get(line) > 0) left.set(line, left.get(line) - 1);
284
+ else extra.push(line);
285
+ }
286
+ }
287
+ return extra;
288
+ };
289
+ const out = spawnSync('diff', ['-rq', '-x', 'node_modules', '-x', 'answers.json', '-x', '.genx', base, next]).stdout.toString();
290
+ const problems = [];
291
+ for (const line of out.split('\n').filter(Boolean)) {
292
+ const m = line.match(/^Files (.+) and .+ differ$/);
293
+ if (!m) { problems.push(line); continue; }
294
+ const rel = m[1].slice(base.length + 1);
295
+ const rule = allowed[rel];
296
+ if (!rule) { problems.push(`${rel} changed`); continue; }
297
+ const a = fs.readFileSync(`${base}/${rel}`, 'utf8').split('\n');
298
+ const b = fs.readFileSync(`${next}/${rel}`, 'utf8').split('\n');
299
+ unexpected(a, b, rule.removed).forEach((l) => problems.push(`${rel}: removed ${JSON.stringify(l)}`));
300
+ unexpected(b, a, rule.added).forEach((l) => problems.push(`${rel}: added ${JSON.stringify(l)}`));
301
+ }
302
+ problems.forEach((p) => console.log(` ${p}`));
303
+ process.exit(problems.length ? 1 : 0);
304
+ NODE
305
+ fi
306
+
307
+ echo "=== on"
308
+ [ "$(ai_artifacts_present on)" = "5" ] || fail "on: expected all 5 AI artifacts, found $(ai_artifacts_present on)"
309
+ # The proxy is its template with exactly its two limits filled in, and nothing else touched.
310
+ for pair in "on:$GEMINI_MODELS" "onanthropic:$ANTHROPIC_MODELS"; do
311
+ label="${pair%%:*}"; models="${pair#*:}"
312
+ sed -e "s/{{AI.allowedModels}}/$models/" -e "s/{{AI.maxOutputTokens}}/16000/" "$TEMPLATE" \
313
+ | diff -q - "$WORK_DIR/$label/demo/$MODULE/scripts/ai-service-web-handler.kts" > /dev/null \
314
+ || fail "$label: the proxy is not its template with the $label limits filled in"
315
+ done
316
+ # Code with its comments removed: block comments whatever their inner lines start with, then whole-line
317
+ # // comments. An inline // is kept, since URLs contain one.
318
+ live_code() { perl -0pe 's{/\*.*?\*/}{}gs; s{^[ \t]*//[^\n]*}{}gm' "$1"; }
319
+
320
+ for label in on onanthropic; do
321
+ app="$WORK_DIR/$label/demo"
322
+ grep -rqs 'AI_ALLOWED_MODELS\|AI_MAX_OUTPUT_TOKENS' "$app/$MODULE/cfg/" \
323
+ && fail "$label: an AI item landed in a system-definition file; the proxy must carry its own defaults"
324
+
325
+ # The scan cannot see these: requiresAuth = false makes an endpoint anonymous and drops the AI_CHAT
326
+ # check while the scan still reports it secure, and a third endpoint could carry no permission at all.
327
+ handler="$app/$MODULE/scripts/ai-service-web-handler.kts"
328
+ live_code "$handler" | grep -q 'requiresAuth' && fail "$label: the proxy sets requiresAuth, which makes it anonymous"
329
+ # Every endpoint builder the web DSL has (endpoint and multipartEndpoint), typed or inferred.
330
+ endpoints="$(live_code "$handler" | grep -oE '(^|[^A-Za-z0-9_])(endpoint|multipartEndpoint)[[:space:]]*[<(]' | wc -l | tr -d ' ')"
331
+ guarded="$(live_code "$handler" | grep -oF 'permissionCodes("AI_CHAT")' | wc -l | tr -d ' ')"
332
+ [ "$endpoints" = "2" ] || fail "$label: expected exactly two chat endpoints, found $endpoints"
333
+ [ "$guarded" = "$endpoints" ] \
334
+ || fail "$label: $endpoints endpoints but $guarded permissionCodes(\"AI_CHAT\"); every endpoint needs one"
335
+ # Each can undo AI_CHAT after it is set, so any logged-in user could spend the key; so can a second
336
+ # permissioning block on the same endpoint.
337
+ live_code "$handler" | grep -qE 'permissionCodesDisabled|permissionCodes[[:space:]]*=|customPermissions' \
338
+ && fail "$label: the proxy loosens its AI_CHAT check"
339
+ blocks="$(live_code "$handler" | grep -oE 'permissioning[[:space:]]*\{' | wc -l | tr -d ' ')"
340
+ [ "$blocks" = "$endpoints" ] || fail "$label: $endpoints endpoints but $blocks permissioning blocks"
341
+
342
+ # The keys come from the environment. Everything read through the system definition is rendered, in
343
+ # plain text, into files under build/, so only the two settings that are not secret go through it,
344
+ # each by name, and the system definition is touched only inside readItem.
345
+ sysdef_reads="$(live_code "$handler" | grep -oE 'readItem\([^)]*\)' | LC_ALL=C sort -u | tr '\n' ' ')"
346
+ [ "$sysdef_reads" = 'readItem("AI_ALLOWED_MODELS") readItem("AI_MAX_OUTPUT_TOKENS") readItem(name: String) ' ] \
347
+ || fail "$label: the proxy reads more than its two settings through the system definition: $sysdef_reads"
348
+ sysdef_gets="$(live_code "$handler" | grep -oE 'systemDefinition[.A-Za-z]*\([^)]*\)' | tr '\n' ' ')"
349
+ [ "$sysdef_gets" = 'systemDefinition.get(name) ' ] \
350
+ || fail "$label: the proxy reads the system definition outside readItem: $sysdef_gets"
351
+
352
+ # The router must let a body just over the proxy's own limit through, so the proxy answers it with
353
+ # its REQUEST_TOO_LARGE code rather than the router refusing it with an empty 413.
354
+ router_cap="$(live_code "$app/$MODULE/scripts/genesis-router.kts" | grep -oE 'maxContentLength[[:space:]]*=[[:space:]]*[0-9_]+' | grep -oE '[0-9_]+$' | tr -d _)"
355
+ proxy_cap="$(live_code "$handler" | grep -oE 'maxDocumentLength\([0-9_]+L?\)' | grep -oE '[0-9_]+' | tr -d _)"
356
+ [ -n "$router_cap" ] && [ -n "$proxy_cap" ] && [ "$router_cap" -gt "$proxy_cap" ] \
357
+ || fail "$label: the router's body cap (${router_cap:-none}) must be above the proxy's document limit (${proxy_cap:-none})"
358
+
359
+ # Exactly the AI path's own files change, and nothing else. Genesis Create writes its code
360
+ # generation over the seed (cfg/<app>-*.kts and .xml, scripts/<app>-*.kts, never the router script, a
361
+ # web handler or the README), so a file the AI path relied on in there would be silently replaced.
362
+ # diff reports a new folder once, so client/src/ai and client/src/pbc/ai-assistant are one entry each
363
+ # and their contents are pinned below.
364
+ changed="$(diff -rq -x node_modules -x answers.json "$WORK_DIR/default/demo" "$app" \
365
+ | sed -E -e "s#^Files $WORK_DIR/default/demo/(.*) and .* differ\$#\1#" \
366
+ -e "s#^Only in $app/?(.*): (.*)\$#\1/\2#" -e 's#^/##' | sort)"
367
+ expected="$(printf '%s\n' README.md "$MODULE/scripts/ai-service-web-handler.kts" "$MODULE/scripts/genesis-router.kts" \
368
+ client/package.json client/.oxfmtrc.json client/src/ai client/src/pbc/ai-assistant | sort)"
369
+ [ "$changed" = "$expected" ] || fail "$label: the AI path changed files beyond its own: $(echo $changed)"
370
+ ai_files="$(cd "$app/client/src/ai" 2>/dev/null && find . -type f | sed 's#^\./##' | sort | tr '\n' ' ')"
371
+ [ "$ai_files" = "extensions/index.ts generated/ai-config.json generated/assistant-host.ts generated/assistant.ts " ] \
372
+ || fail "$label: client/src/ai holds $ai_files"
373
+ pbc_files="$(cd "$app/client/src/pbc/ai-assistant" 2>/dev/null && find . -type f | sed 's#^\./##' | tr '\n' ' ')"
374
+ [ "$pbc_files" = "elements.ts " ] || fail "$label: client/src/pbc/ai-assistant holds $pbc_files"
375
+
376
+ # The panel's code comes out exactly as written: a {{ in it would have been rendered on the way.
377
+ for pair in "pbc-elements.ts.hbs:pbc/ai-assistant/elements.ts" "assistant-host.ts.hbs:ai/generated/assistant-host.ts" \
378
+ "assistant.ts.hbs:ai/generated/assistant.ts" "extensions.ts.hbs:ai/extensions/index.ts"; do
379
+ cmp -s "$SEED_DIR/.genx/templates/react/ai/${pair%%:*}" "$app/client/src/${pair#*:}" \
380
+ || fail "$label: client/src/${pair#*:} is not its template as written"
381
+ done
382
+
383
+ # Byte-equality says nothing about what the templates hold, and tsc, oxlint and oxfmt all pass without
384
+ # the bubble, the target or the AI_CHAT check. So the parts the assistant needs are checked by name.
385
+ node - "$app/client/src" <<'NODE' || fail "$label: the assistant's code is missing a part it needs (see above)"
386
+ const fs = require('fs');
387
+ const src = process.argv[2];
388
+ // Comments out, strings kept, so a comment can't satisfy a check.
389
+ const code = (file) =>
390
+ fs
391
+ .readFileSync(`${src}/${file}`, 'utf8')
392
+ .replace(/("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m, str) => str ?? '');
393
+ let ok = true;
394
+ const need = (file, what, holds) => {
395
+ if (!holds) {
396
+ ok = false;
397
+ console.log(`FAIL: ${file} ${what}`);
398
+ }
399
+ };
400
+
401
+ const elements = code('pbc/ai-assistant/elements.ts');
402
+ need('elements.ts', "does not target 'layout-end'", /targetId:\s*'layout-end'/.test(elements));
403
+ need('elements.ts', 'does not import the assistant host', /^import '\.\.\/\.\.\/ai\/generated\/assistant-host';$/m.test(elements));
404
+ need('elements.ts', 'has no popout manager', elements.includes('<foundation-ai-popout-manager>'));
405
+ need('elements.ts', 'has no chat bubble', /<foundation-ai-chat-bubble[\s>]/.test(elements));
406
+ need('elements.ts', 'does not slot the host into the dialog', elements.includes('<genesis-app-assistant slot="dialog-content">'));
407
+ need(
408
+ 'elements.ts',
409
+ 'does not nest host in bubble in manager',
410
+ /<foundation-ai-popout-manager>\s*<foundation-ai-chat-bubble[^>]*>\s*<genesis-app-assistant slot="dialog-content"><\/genesis-app-assistant>\s*<\/foundation-ai-chat-bubble>\s*<\/foundation-ai-popout-manager>/.test(elements),
411
+ );
412
+
413
+ const host = code('ai/generated/assistant-host.ts');
414
+ need('assistant-host.ts', 'does not define genesis-app-assistant', /name:\s*'genesis-app-assistant'/.test(host));
415
+ need('assistant-host.ts', 'does not load the assistant lazily', host.includes("import('./assistant')"));
416
+ // The bubble looks for the assistant in the slotted element's shadow root to give it a close button.
417
+ need('assistant-host.ts', 'does not mount into its shadow root', host.includes('mountAssistant(this.shadowRoot)'));
418
+
419
+ const assistant = code('ai/generated/assistant.ts');
420
+ need(
421
+ 'assistant.ts',
422
+ 'does not register once, at module top level',
423
+ /^const registration = registerGenesisAssistant\(/m.test(assistant) && assistant.split('registerGenesisAssistant(').length === 2,
424
+ );
425
+ need(
426
+ 'assistant.ts',
427
+ 'has no AI_CHAT check',
428
+ /^const AI_CHAT_RIGHT = 'AI_CHAT';$/m.test(assistant) && assistant.includes('hasPermission(AI_CHAT_RIGHT)'),
429
+ );
430
+ need('assistant.ts', "ignores the registration's block", assistant.includes('setBlocked(true, registration.blockedReason)'));
431
+ need('assistant.ts', 'has no per-user session key', assistant.includes('`genesis-app-assistant:${getUser().userName}`'));
432
+ const mount = (assistant.match(/^export function mountAssistant\([^)]*\)[^{]*\{\n([\s\S]*?)\n\}/m) || [])[1] || '';
433
+ need('assistant.ts', 'does not check the blocks on every mount', mount.includes('applyBlocks(assistant)') && !/\breturn\b/.test(mount));
434
+ need(
435
+ 'assistant.ts',
436
+ 'does not set the session key once connected',
437
+ mount.lastIndexOf("setAttribute('session-key', sessionKey)") > mount.indexOf('host.append(assistant)') &&
438
+ mount.includes('host.append(assistant)'),
439
+ );
440
+
441
+ // The UI Builder can adopt extensions/index.ts, and an adopted copy outlives the assistant when AI is
442
+ // switched off. With no import of its own it still compiles then.
443
+ need(
444
+ 'extensions/index.ts',
445
+ 'imports something, so it breaks the build once AI is off',
446
+ !/\bimport\s*[\s{*('"]|\bfrom\s*['"]|\brequire\s*\(/.test(code('ai/extensions/index.ts')),
447
+ );
448
+ process.exit(ok ? 0 : 1);
449
+ NODE
450
+ done
451
+
452
+ # The configuration the panel reads is what the declaration passes on, exactly, whatever Handlebars-looking
453
+ # text the prompt carries, and the log says each drop and nothing else; and the client's package.json
454
+ # gains the assistant package at the UI version and the AI build flag on build and dev, and not one thing
455
+ # more.
456
+ echo "=== the panel's configuration and package"
457
+ node - "$WORK_DIR" "$SEED_DIR/.genx/versions.json" "$SEED_DIR/.genx/ai-consumer.json" \
458
+ "$AI_UI" "$AI_UI_ANTHROPIC" "$AI_UI_BREAKERS" "$AI_UI_EXTRAS_CLEAN" "$AI_UI_EXTRAS" <<'NODE' \
459
+ || fail "on: the panel's configuration or the client package.json is not what the AI path writes (see above)"
460
+ const fs = require('fs');
461
+ const path = require('path');
462
+ const { isDeepStrictEqual } = require('util');
463
+ const [work, versionsFile, consumerFile, geminiUi, anthropicUi, breakersUi, extrasCleanUi, extrasUi] = process.argv.slice(2);
464
+ const version = JSON.parse(fs.readFileSync(versionsFile, 'utf8')).UI;
465
+ const passed = `passed to @genesislcap/ai-assistant ${version}`;
466
+ // The block as sent, minus what the declaration withholds: the same file the writer reads. A customCode
467
+ // is its two values, and only an insert, modify or delete keeps one (C-18.D.5).
468
+ const consumer = JSON.parse(fs.readFileSync(consumerFile, 'utf8'));
469
+ const projected = ({ op, customCode: code }) => {
470
+ if (!code || !['insert', 'modify', 'delete'].includes(op)) return undefined;
471
+ // Complete only when it says so and nothing had to be cut (v2.83).
472
+ const whole = Array.isArray(code.alsoWrites) && code.alsoWrites.every((name) => typeof name === 'string');
473
+ return {
474
+ alsoWrites: Array.isArray(code.alsoWrites) ? code.alsoWrites.filter((name) => typeof name === 'string') : [],
475
+ listComplete: whole && code.listComplete === true,
476
+ };
477
+ };
478
+ const passedOn = (resources) => (resources || [])
479
+ .filter((resource) => consumer.kinds.includes(resource.kind))
480
+ .map((resource) => {
481
+ const kept = Object.fromEntries(Object.entries(resource).filter(([key]) => consumer.resourceFields.includes(key)));
482
+ if ('customCode' in kept) kept.customCode = projected(resource);
483
+ return kept;
484
+ });
485
+ const declared = ({ enabled, vendor, tier, systemPrompt, resources }) =>
486
+ JSON.parse(JSON.stringify({ enabled, vendor, tier, systemPrompt, resources: passedOn(resources) }));
487
+ // What the writer says it dropped, in its order.
488
+ const said = (resources) => (resources || []).flatMap((resource) => consumer.kinds.includes(resource.kind)
489
+ ? Object.keys(resource)
490
+ .filter((key) => !consumer.resourceFields.includes(key))
491
+ .map((key) => `ai: dropped ${key} on ${resource.name} — not ${passed}`)
492
+ : [`ai: dropped resource ${resource.name} — kind ${resource.kind} is not ${passed}`]);
493
+ const dropLines = (log) => log.split('\n').filter((line) => line.includes('ai: dropped ')).map((line) => line.slice(line.indexOf('ai: dropped ')).trimEnd());
494
+ const problems = [];
495
+ const read = (label, rel) => fs.readFileSync(path.join(work, label, 'demo', rel), 'utf8');
496
+ // Each app: the block it was sent, and the clean block it must come out as.
497
+ for (const [label, sent, clean] of [['on', geminiUi, geminiUi], ['onanthropic', anthropicUi, anthropicUi], ['extras', extrasUi, extrasCleanUi], ['breakers', breakersUi, breakersUi]]) {
498
+ const raw = read(label, 'client/src/ai/generated/ai-config.json');
499
+ if (raw.includes('{{')) problems.push(`${label}: ai-config.json still holds {{ for Handlebars to expand`);
500
+ let written;
501
+ try { written = JSON.parse(raw); } catch (e) { problems.push(`${label}: ai-config.json is not JSON: ${e.message}`); continue; }
502
+ if (!isDeepStrictEqual(written, declared(JSON.parse(clean).ai))) problems.push(`${label}: ai-config.json is not the declared fields of the clean input`);
503
+ const lines = dropLines(fs.readFileSync(`${work}/${label}.log`, 'utf8'));
504
+ const want = said(JSON.parse(sent).ai.resources);
505
+ if (!isDeepStrictEqual(lines, want)) problems.push(`${label}: the log says ${JSON.stringify(lines)}, not ${JSON.stringify(want)}`);
506
+
507
+ const on = JSON.parse(read(label, 'client/package.json'));
508
+ const off = JSON.parse(read('default', 'client/package.json'));
509
+ // The assistant package is every React app's, so chat adds no dependency.
510
+ if (!isDeepStrictEqual(on.dependencies, off.dependencies)) problems.push(`${label}: dependencies differ from an AI-off app's`);
511
+ for (const script of new Set([...Object.keys(on.scripts), ...Object.keys(off.scripts)])) {
512
+ const want = ['build', 'dev'].includes(script) ? `${off.scripts[script]} -e GENX_ENABLE_AI=true` : off.scripts[script];
513
+ if (on.scripts[script] !== want) problems.push(`${label}: script "${script}" is ${JSON.stringify(on.scripts[script])}`);
514
+ }
515
+ const rest = (pkg) => ({ ...pkg, dependencies: undefined, scripts: undefined });
516
+ if (!isDeepStrictEqual(rest(on), rest(off))) problems.push(`${label}: package.json differs outside dependencies and scripts`);
517
+ }
518
+ // The declaration passes customCode on. Every expectation above derives from it, so it is pinned here.
519
+ const insert = JSON.parse(read('on', 'client/src/ai/generated/ai-config.json')).resources.find((r) => r.name === 'EVENT_TRADE_INSERT');
520
+ if (!isDeepStrictEqual(insert?.customCode, { alsoWrites: ['POSITION'], listComplete: false })) {
521
+ problems.push(`on: EVENT_TRADE_INSERT's customCode is ${JSON.stringify(insert?.customCode)}, not the fixture's`);
522
+ }
523
+ // The declaration passes references on, and the fixture carries them.
524
+ const references = [{ resource: 'REQ_COUNTERPARTY', fields: [{ field: 'COUNTERPARTY_ID', targetField: 'COUNTERPARTY_ID' }] }];
525
+ if (!isDeepStrictEqual(insert?.references, references)) {
526
+ problems.push(`on: EVENT_TRADE_INSERT's references are ${JSON.stringify(insert?.references)}, not the fixture's`);
527
+ }
528
+ // Anti-vacuity: 'extras' did send what must be dropped, and was told so.
529
+ if (!said(JSON.parse(extrasUi).ai.resources).length) problems.push('extras: the fixture smuggles nothing the writer must say');
530
+ problems.forEach((p) => console.log(` ${p}`));
531
+ process.exit(problems.length ? 1 : 0);
532
+ NODE
533
+
534
+ # The seed seeds no rights: the project's rights files belong to the generator that sends them.
535
+ echo "=== no rights written"
536
+ for label in on onanthropic; do
537
+ grep -rqs 'AI_CHAT' "$WORK_DIR/$label/demo/$MODULE/data/" && fail "$label: the seed wrote an AI_CHAT row; rights belong to the generator"
538
+ done
539
+
540
+ echo "=== non-react"
541
+ [ "$(ai_artifacts_present nonreact)" = "0" ] || fail "non-react: AI files emitted with no panel to use them"
542
+
543
+ # Create's own resolver cases, as the blocks it actually sends: each must reach the app verbatim, top level
544
+ # included, minus the kinds and keys the declaration withholds, with each drop said and nothing else; a
545
+ # case that resolves to no block must emit no AI file at all.
546
+ echo "=== C-8 cases through the seed"
547
+ CASES="$SEED_DIR/.genx/tests/contracts/ai/ai-resolver-cases.json"
548
+ case_count="$(node -e 'console.log(require(process.argv[1]).cases.length)' "$CASES")"
549
+ case_labels=()
550
+ for i in $(seq 0 $((case_count - 1))); do
551
+ generate "case$i" --framework react \
552
+ --ui "$(node -e 'console.log(JSON.stringify({ ai: require(process.argv[1]).cases[+process.argv[2]].expected.ai }))' "$CASES" "$i")" \
553
+ && case_labels+=("case$i")
554
+ done
555
+ node - "$CASES" "$WORK_DIR" "$SEED_DIR/.genx/ai-consumer.json" "$SEED_DIR/.genx/versions.json" <<'NODE' \
556
+ || fail "C-8: a resolved block did not reach the app as what the declaration passes on (see above)"
557
+ const fs = require('fs');
558
+ const path = require('path');
559
+ const { isDeepStrictEqual } = require('util');
560
+ const [casesFile, work, consumerFile, versionsFile] = process.argv.slice(2);
561
+ const consumer = JSON.parse(fs.readFileSync(consumerFile, 'utf8'));
562
+ const passed = `passed to @genesislcap/ai-assistant ${JSON.parse(fs.readFileSync(versionsFile, 'utf8')).UI}`;
563
+ const projected = ({ op, customCode: code }) => {
564
+ if (!code || !['insert', 'modify', 'delete'].includes(op)) return undefined;
565
+ // Complete only when it says so and nothing had to be cut (v2.83).
566
+ const whole = Array.isArray(code.alsoWrites) && code.alsoWrites.every((name) => typeof name === 'string');
567
+ return {
568
+ alsoWrites: Array.isArray(code.alsoWrites) ? code.alsoWrites.filter((name) => typeof name === 'string') : [],
569
+ listComplete: whole && code.listComplete === true,
570
+ };
571
+ };
572
+ // The top level is compared as sent, so a key Create adds there cannot be dropped with this check green.
573
+ const declared = (ai) => JSON.parse(JSON.stringify({
574
+ ...ai,
575
+ resources: (ai.resources || [])
576
+ .filter((resource) => consumer.kinds.includes(resource.kind))
577
+ .map((resource) => {
578
+ const kept = Object.fromEntries(Object.entries(resource).filter(([key]) => consumer.resourceFields.includes(key)));
579
+ if ('customCode' in kept) kept.customCode = projected(resource);
580
+ return kept;
581
+ }),
582
+ }));
583
+ const said = (resources) => (resources || []).flatMap((resource) => consumer.kinds.includes(resource.kind)
584
+ ? Object.keys(resource)
585
+ .filter((key) => !consumer.resourceFields.includes(key))
586
+ .map((key) => `ai: dropped ${key} on ${resource.name} — not ${passed}`)
587
+ : [`ai: dropped resource ${resource.name} — kind ${resource.kind} is not ${passed}`]);
588
+ const dropLines = (log) => log.split('\n').filter((line) => line.includes('ai: dropped ')).map((line) => line.slice(line.indexOf('ai: dropped ')).trimEnd());
589
+ const problems = [];
590
+ JSON.parse(fs.readFileSync(casesFile, 'utf8')).cases.forEach(({ name, expected }, i) => {
591
+ const client = path.join(work, `case${i}`, 'demo', 'client');
592
+ const file = path.join(client, 'src/ai/generated/ai-config.json');
593
+ if (!expected.ai?.enabled) {
594
+ if (fs.existsSync(path.join(client, 'src/ai'))) problems.push(`case ${i} (${name}): AI files for a project with no chat`);
595
+ return;
596
+ }
597
+ if (!fs.existsSync(file)) return problems.push(`case ${i} (${name}): no ai-config.json`);
598
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
599
+ if (!isDeepStrictEqual(written, declared(expected.ai))) problems.push(`case ${i} (${name}): ai-config.json is not its declared kinds and fields`);
600
+ // A case the declaration withholds nothing from reaches the app exactly as Create resolved it.
601
+ // The declaration reads everything the copies carry, so every case reaches the app as Create
602
+ // resolved it, with nothing withheld.
603
+ if (!isDeepStrictEqual(written, JSON.parse(JSON.stringify(expected.ai)))) problems.push(`case ${i} (${name}): ai-config.json is not Create's block verbatim`);
604
+ // Every resource of a kind the declaration withholds, and every key it withholds, is said, in order,
605
+ // and nothing else is.
606
+ const lines = dropLines(fs.readFileSync(path.join(work, `case${i}.log`), 'utf8'));
607
+ const want = said(expected.ai.resources);
608
+ if (!isDeepStrictEqual(lines, want)) problems.push(`case ${i} (${name}): the log says ${JSON.stringify(lines)}, not ${JSON.stringify(want)}`);
609
+ });
610
+ problems.forEach((p) => console.log(` ${p}`));
611
+ process.exit(problems.length ? 1 : 0);
612
+ NODE
613
+
614
+ # The row and key cases, from the apps the C-8 loop generated: each keyed resource reaches the app byte
615
+ # for byte, and the writer's projection drops what 'rows' smuggles inside the lists (C-18.8, C-19).
616
+ echo "=== row actions and keys through the writer"
617
+ row_cases="$(node -e '
618
+ const { cases } = require(process.argv[1]);
619
+ // A row action, or a modify or delete that names its row by a key (C-19).
620
+ const keyed = (r) => r.shape || (["modify", "delete"].includes(r.op) && r.key);
621
+ console.log(cases.flatMap(({ expected }, i) => ((expected.ai?.resources || []).some(keyed) ? [i] : [])).join(" "));' "$CASES")"
622
+ row_labels=(rows)
623
+ node - "$CASES" "$WORK_DIR" "$AI_UI_ROWS_CLEAN" "$row_cases" <<'NODE' \
624
+ || fail "rows: a row action did not reach the app as Create resolved it (see above)"
625
+ const fs = require('fs');
626
+ const path = require('path');
627
+ const { isDeepStrictEqual } = require('util');
628
+ const [casesFile, work, rowsClean, indexes] = process.argv.slice(2);
629
+ // Verbatim: the file is the block after one JSON round trip, nothing added, dropped or rebuilt.
630
+ const verbatim = (written, sent) => isDeepStrictEqual(written, JSON.parse(JSON.stringify(sent)));
631
+ const read = (label) =>
632
+ JSON.parse(fs.readFileSync(path.join(work, label, 'demo/client/src/ai/generated/ai-config.json'), 'utf8'));
633
+ const drops = (label) =>
634
+ fs.readFileSync(`${work}/${label}.log`, 'utf8').split('\n').filter((line) => line.includes('ai: dropped '));
635
+ const problems = [];
636
+ // The compare itself: it must refuse a copy that lost a row field, or carries one more nested key.
637
+ const sample = JSON.parse(rowsClean).ai;
638
+ const swap = (change) => ({ ...sample, resources: sample.resources.map((r) => (r.shape ? change(r) : r)) });
639
+ const lost = swap((r) => ({ ...r, effects: undefined }));
640
+ const extra = swap((r) => ({ ...r, inputs: [{ ...r.inputs[0], url: 'x' }] }));
641
+ if (verbatim(lost, sample) || verbatim(extra, sample)) problems.push('the compare passes a copy that is not verbatim');
642
+ // 'rows': every key is declared, so nothing is said, and the smuggles inside the lists are gone.
643
+ if (!verbatim(read('rows'), sample)) {
644
+ const wrote = read('rows').resources;
645
+ const first = sample.resources.findIndex((r, i) => !verbatim(wrote[i], r));
646
+ problems.push(`rows: ${sample.resources[first]?.name ?? 'the list'} came out as ${JSON.stringify(wrote[first])}`);
647
+ }
648
+ if (drops('rows').length) problems.push(`rows: the log says ${JSON.stringify(drops('rows'))}`);
649
+ const cases = JSON.parse(fs.readFileSync(casesFile, 'utf8')).cases;
650
+ const picked = indexes.split(/\s+/).filter(Boolean).map(Number);
651
+ if (!picked.some((i) => cases[i].name.startsWith('C-18 GC-C1'))) problems.push('GC-C1 is not among the row cases');
652
+ // And at least one modify or delete named by its key (C-19), or the key half of this is untested.
653
+ const crudKey = (r) => ['modify', 'delete'].includes(r.op) && r.key !== undefined;
654
+ if (!picked.some((i) => cases[i].expected.ai.resources.some(crudKey))) problems.push('no case names a modify or delete by its key');
655
+ for (const i of picked) {
656
+ const { name, expected } = cases[i];
657
+ const written = read(`case${i}`);
658
+ if (!verbatim(written, expected.ai)) problems.push(`case ${i} (${name}): ai-config.json is not Create's block verbatim`);
659
+ // Each resource that carries a key as Create wrote it, byte for byte, key order included.
660
+ for (const resource of expected.ai.resources.filter((r) => r.key !== undefined)) {
661
+ const got = JSON.stringify(written.resources.find((r) => r.name === resource.name));
662
+ if (got !== JSON.stringify(resource)) problems.push(`case ${i}: ${resource.name} came out as ${got}`);
663
+ }
664
+ }
665
+ problems.forEach((p) => console.log(` ${p}`));
666
+ process.exit(problems.length ? 1 : 0);
667
+ NODE
668
+
669
+ # The declaration check runs on the React AI build (generate-test-apps.sh), against whatever assistant
670
+ # that app installed. These are the installs it never meets there: an assistant from before the export,
671
+ # none at all or a broken one, an export with nothing usable in it, one that throws, imports what it may
672
+ # not, ends the process, prints, or keeps the process alive, and one for import or for require only.
673
+ # Each is a stand-in package.
674
+ echo "=== the declaration check, against assistants the build does not install"
675
+ node - "$WORK_DIR/declaration" "$SEED_DIR/.genx/ai-consumer.json" "$SEED_DIR/.genx/scripts/check-ai-declaration.mjs" <<'NODE' \
676
+ || fail "declaration: the check does not hold the floor (see above)"
677
+ const fs = require('fs');
678
+ const path = require('path');
679
+ const { spawnSync } = require('child_process');
680
+ const [work, consumerFile, check] = process.argv.slice(2);
681
+ const seed = JSON.parse(fs.readFileSync(consumerFile, 'utf8'));
682
+ const baseline = { version: 1, kinds: ['request', 'event'], resourceFields: ['name', 'kind', 'op', 'context', 'maxRows'] };
683
+ const app = (name, exports, files = {}) => {
684
+ const pkg = path.join(work, name, 'node_modules/@genesislcap/ai-assistant');
685
+ fs.mkdirSync(pkg, { recursive: true });
686
+ fs.writeFileSync(path.join(work, name, 'package.json'), '{"name":"app"}');
687
+ if (exports) fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: '@genesislcap/ai-assistant', exports }));
688
+ for (const [file, text] of Object.entries(files)) fs.writeFileSync(path.join(pkg, file), text);
689
+ return path.join(work, name);
690
+ };
691
+ const reads = app('reads', { '.': './index.js', './genesis/consumes': './consumes.cjs' }, {
692
+ 'consumes.cjs': `exports.GENESIS_AI_CONSUMES = ${JSON.stringify({ kinds: seed.kinds, resourceFields: seed.resourceFields })};`,
693
+ });
694
+ const before = app('before-the-export', { '.': './index.js' });
695
+ const noManifest = app('no-package-json', null);
696
+ const none = path.join(work, 'no-assistant');
697
+ fs.mkdirSync(none, { recursive: true });
698
+ fs.writeFileSync(path.join(none, 'package.json'), '{"name":"app"}');
699
+ const exporting = (name, text) => app(name, { './genesis/consumes': './consumes.cjs' }, { 'consumes.cjs': text });
700
+ const seedSet = JSON.stringify({ kinds: seed.kinds, resourceFields: seed.resourceFields });
701
+ const falsy = exporting('exports-false', 'exports.GENESIS_AI_CONSUMES = false;');
702
+ const aFunction = exporting('exports-a-function', 'exports.GENESIS_AI_CONSUMES = () => ({});');
703
+ // Its kinds are text that happens to hold the names: only a list counts.
704
+ const notLists = exporting('exports-no-lists', `exports.GENESIS_AI_CONSUMES = ${JSON.stringify({ kinds: 'request,event', resourceFields: baseline.resourceFields })};`);
705
+ const exits = exporting('exits', 'process.exit(0);');
706
+ // It prints a line that looks like the probe's answer, and a wider one, before its own export.
707
+ const wider = JSON.stringify({ type: 'object', consumes: { kinds: [...seed.kinds, 'unread'], resourceFields: seed.resourceFields } });
708
+ const pretends = exporting('prints-a-wider-answer', `console.log(${JSON.stringify(wider)}); exports.GENESIS_AI_CONSUMES = ${seedSet};`);
709
+ const prints = exporting('prints', `console.log("loading"); exports.GENESIS_AI_CONSUMES = ${seedSet};`);
710
+ const stays = exporting('keeps-the-process-alive', `setInterval(() => {}, 1000); exports.GENESIS_AI_CONSUMES = ${seedSet};`);
711
+ const requireOnly = app('require-only', { './genesis/consumes': { require: './consumes.cjs' } }, {
712
+ 'consumes.cjs': `exports.GENESIS_AI_CONSUMES = ${seedSet};`,
713
+ });
714
+ const empty = app('empty-export', { './genesis/consumes': './consumes.cjs' }, { 'consumes.cjs': 'exports.OTHER = 1;' });
715
+ const throws = app('throws', { './genesis/consumes': './consumes.cjs' }, { 'consumes.cjs': 'throw new Error("no");' });
716
+ // Its module needs a subpath the package does not export: that is a broken install, not an old one.
717
+ const unexported = app('imports-unexported', { './genesis/consumes': './consumes.cjs' }, {
718
+ 'consumes.cjs': 'require("@genesislcap/ai-assistant/hidden");',
719
+ });
720
+ const importOnly = app('import-only', { './genesis/consumes': { import: './consumes.mjs' } }, {
721
+ 'consumes.mjs': `export const GENESIS_AI_CONSUMES = ${JSON.stringify({ kinds: seed.kinds, resourceFields: seed.resourceFields })};`,
722
+ });
723
+ const declaring = (name, declaration) => {
724
+ const file = path.join(work, `${name}.json`);
725
+ fs.writeFileSync(file, JSON.stringify(declaration));
726
+ return file;
727
+ };
728
+ const cases = [
729
+ ['the seed, on an assistant that reads what it declares', consumerFile, reads, 0],
730
+ ['a kind the assistant does not read', declaring('kind', { ...seed, kinds: [...seed.kinds, 'unread'] }), reads, 1],
731
+ ['a field the assistant does not read', declaring('field', { ...seed, resourceFields: [...seed.resourceFields, 'unread'] }), reads, 1],
732
+ ['a baseline field left out', declaring('no-op', { ...seed, resourceFields: seed.resourceFields.filter((f) => f !== 'op') }), reads, 1],
733
+ ['the baseline, before the export', declaring('baseline', baseline), before, 0],
734
+ ['more than the baseline, before the export', declaring('more', { ...baseline, resourceFields: [...baseline.resourceFields, 'customCode'] }), before, 1],
735
+ ['the baseline, with an assistant that has no package.json', declaring('baseline', baseline), noManifest, 1],
736
+ ['the baseline, with no assistant installed', declaring('baseline', baseline), none, 1],
737
+ ['the baseline, with an export that names nothing', declaring('baseline', baseline), empty, 1],
738
+ ['a declaration of another version', declaring('v2', { ...seed, version: 2 }), reads, 1],
739
+ ['a baseline kind left out', declaring('no-event', { ...seed, kinds: ['request'] }), reads, 1],
740
+ ['query, before the export', declaring('query', { ...baseline, kinds: [...baseline.kinds, 'query'] }), before, 1],
741
+ ['the baseline, with an export that throws', declaring('baseline', baseline), throws, 1],
742
+ ['the baseline, with an export that imports what it may not', declaring('baseline', baseline), unexported, 1],
743
+ ['the seed, on an assistant that exports it for import only', consumerFile, importOnly, 0],
744
+ ['the baseline, with a constant that is false', declaring('baseline', baseline), falsy, 1],
745
+ ['the baseline, with a constant that is a function', declaring('baseline', baseline), aFunction, 1],
746
+ ['the baseline, with a constant whose kinds are not a list', declaring('baseline', baseline), notLists, 1],
747
+ ['the baseline, with a module that ends the process', declaring('baseline', baseline), exits, 1],
748
+ ['a kind it does not read, on a module that prints a wider answer first', declaring('kind', { ...seed, kinds: [...seed.kinds, 'unread'] }), pretends, 1],
749
+ ['the seed, on a module that prints', consumerFile, prints, 0],
750
+ ['the seed, on a module that keeps the process alive', consumerFile, stays, 0],
751
+ // Required only, it reads as absent: fail-safe, as that assistant is held to the baseline.
752
+ ['more than the baseline, on an assistant that exports it for require only', declaring('more', { ...baseline, resourceFields: [...baseline.resourceFields, 'customCode'] }), requireOnly, 1],
753
+ ];
754
+ let wrong = 0;
755
+ for (const [name, declaration, client, want] of cases) {
756
+ const run = spawnSync(process.execPath, [check, declaration, client], { encoding: 'utf8' });
757
+ if (run.status !== want) {
758
+ wrong++;
759
+ console.log(` ${name}: exit ${run.status}, not ${want}${run.stdout ? `\n${run.stdout}` : ''}${run.stderr}`);
760
+ }
761
+ }
762
+ process.exit(wrong ? 1 : 0);
763
+ NODE
764
+
765
+ # Every React app depends on the assistant package, AI on or off, so a prebuilt base already has it: at
766
+ # exactly the UI version, the pin the generated app gets, never a range. A non-React app does not.
767
+ echo "=== package: every React app depends on the assistant"
768
+ node - "$WORK_DIR" "$SEED_DIR/.genx/versions.json" default off on onanthropic breakers extras ${case_labels[@]+"${case_labels[@]}"} <<'NODE' \
769
+ || fail "package: an app does not depend on the assistant as every React app must (see above)"
770
+ const fs = require('fs');
771
+ const path = require('path');
772
+ const [work, versionsFile, ...labels] = process.argv.slice(2);
773
+ const version = JSON.parse(fs.readFileSync(versionsFile, 'utf8')).UI;
774
+ const dependencies = (label) => JSON.parse(fs.readFileSync(path.join(work, label, 'demo/client/package.json'), 'utf8')).dependencies;
775
+ const problems = [];
776
+ for (const label of labels) {
777
+ const pin = dependencies(label)['@genesislcap/ai-assistant'];
778
+ if (pin !== version) problems.push(`${label}: @genesislcap/ai-assistant is ${JSON.stringify(pin)}, not ${version}`);
779
+ }
780
+ if ('@genesislcap/ai-assistant' in dependencies('nonreact')) problems.push('nonreact: a non-React app depends on the assistant');
781
+ problems.forEach((p) => console.log(` ${p}`));
782
+ process.exit(problems.length ? 1 : 0);
783
+ NODE
784
+
785
+ # Create refuses an export carrying any of these (server/archive-generation-service/export-leak-guard.js,
786
+ # LEAK_RULES, copied): catching one here is cheaper than a 422 on a customer's export.
787
+ echo "=== nothing Create's export guard refuses"
788
+ node - "$WORK_DIR" on onanthropic ${case_labels[@]+"${case_labels[@]}"} ${row_labels[@]+"${row_labels[@]}"} <<'NODE' || fail "leaks: a generated file carries something Create's export guard refuses (see above)"
789
+ const fs = require('fs');
790
+ const path = require('path');
791
+ const [work, ...labels] = process.argv.slice(2);
792
+ const RULES = [
793
+ ['create-ai-route', /\/ai\/api\/[a-z-]+|\/api\/preview-app\/[a-z-]+/],
794
+ ['create-ai-host', /ai-service:\d+|localhost:3001\/api|:3001\/api\//],
795
+ ['anthropic-key', /sk-ant-[A-Za-z0-9_-]{8,}/],
796
+ ['google-key', /AIza[0-9A-Za-z_-]{35}/],
797
+ ['vendor-key-with-value', /(?<![A-Za-z0-9_])(ANTHROPIC_API_KEY|GENX_GEMINI_API_KEY|OPENAI_API_KEY|GOOGLE_API_KEY)["']?\s*[=:]\s*["']?(?!["']?\s*(?:[,}\n]|$))([^"'\s,}]+)/],
798
+ ['preview-build-global', /GENX_AI_CHAT_BASE/],
799
+ ];
800
+ const SKIP = new Set(['node_modules', '.git', '.gradle', 'build', 'dist', '.idea']);
801
+ const BINARY = /\.(png|jpe?g|gif|ico|svg|webp|woff2?|ttf|eot|zip|jar|gz|tgz|pdf|mp4|class|keystore|p12)$/i;
802
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
803
+ const full = path.join(dir, e.name);
804
+ if (e.isDirectory()) return SKIP.has(e.name) ? [] : walk(full);
805
+ return BINARY.test(e.name) || fs.statSync(full).size > 2 * 1024 * 1024 ? [] : [full];
806
+ });
807
+ const problems = [];
808
+ for (const label of labels) {
809
+ const app = path.join(work, label, 'demo');
810
+ for (const file of walk(app)) {
811
+ const text = fs.readFileSync(file, 'utf8');
812
+ for (const [id, pattern] of RULES) if (pattern.test(text)) problems.push(`${label}: ${path.relative(app, file)}: ${id}`);
813
+ }
814
+ }
815
+ problems.forEach((p) => console.log(` ${p}`));
816
+ process.exit(problems.length ? 1 : 0);
817
+ NODE
818
+
819
+ # Genesis Start's REST API has no authentication, so only the two headless scripts may switch it on.
820
+ # The rules are broad on purpose. In package.json, every line that mentions Genesis Start, gradlew or a
821
+ # headless/REST flag must be one of the three scripts, once each (so no npm hook, template branch or
822
+ # abbreviated task name can run it). No live Gradle line may mention the launcher, apart from its plugin
823
+ # id, nor a key it also reads bare. And Google's repository stays last and androidx-only.
824
+ echo "=== genesis start: desktop unless a headless script asks"
825
+ node - "$SEED_DIR" "$WORK_DIR" default off on onanthropic nonreact <<'NODE' \
826
+ || fail "genesis start: the launcher is configured where it must not be (see above)"
827
+ const fs = require('fs');
828
+ const path = require('path');
829
+ const [seed, work, ...labels] = process.argv.slice(2);
830
+ const flags = '-Pgenesis.start.headless=true -Pgenesis.start.restEnabled=true -Pgenesis.start.restPort=18080';
831
+ const scripts = [
832
+ '"genesis-start": "cd ../server && ./gradlew genesisStart",',
833
+ `"genesis-start:headless": "cd ../server && ./gradlew genesisStart ${flags}",`,
834
+ `"genesis-start:write-script": "cd ../server && ./gradlew writeStartScript ${flags}",`,
835
+ ];
836
+ const scriptMention = /genesis-?start|genesis\.start|gradlew|-P\S*(headless|rest)/i;
837
+ const gradleMention = /genesis-?start|genesis\.?launcher|genesis\.start|\b(headless|restEnabled|restPort|restBasePath|restMaxRequestBodyBytes)\b/i;
838
+ const pluginId = /^\s*id\("global\.genesis\.genesis-start-gui"\)( version startVersion)?\s*$/;
839
+ const googleBlock = [
840
+ ' password = properties["genesisArtifactoryPassword"].toString()',
841
+ ' }',
842
+ ' }',
843
+ ' // The Genesis Start launcher (0.1.12+) needs androidx.* artifacts, which none of the repositories',
844
+ ' // above have. Last, and for those groups only, so nothing else is ever looked up at Google.',
845
+ ' google {',
846
+ ' content {',
847
+ ' includeGroupByRegex("androidx\\\\..*")',
848
+ ' }',
849
+ ' }',
850
+ ' }',
851
+ ].join('\n');
852
+ const problems = [];
853
+ const checkPackageJson = (where, text) => {
854
+ const found = text.split('\n').filter((l) => scriptMention.test(l)).map((l) => l.trim());
855
+ const extra = [...found];
856
+ for (const line of scripts) {
857
+ const i = extra.indexOf(line);
858
+ if (i < 0) problems.push(`${where}: missing ${line}`);
859
+ else extra.splice(i, 1);
860
+ }
861
+ extra.forEach((l) => problems.push(`${where}: ${l}`));
862
+ };
863
+ const live = (file) => {
864
+ const text = fs.readFileSync(file, 'utf8');
865
+ return file.endsWith('.properties')
866
+ ? text.split('\n').filter((l) => !/^\s*[#!]/.test(l))
867
+ : text.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((l) => !/^\s*\/\//.test(l));
868
+ };
869
+ const gradleFiles = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
870
+ const full = path.join(dir, e.name);
871
+ if (e.isDirectory()) return ['node_modules', 'build', '.gradle', '.git'].includes(e.name) ? [] : gradleFiles(full);
872
+ return /^gradle\.properties$|\.gradle(\.kts)?$/.test(e.name) ? [full] : [];
873
+ });
874
+ // Every framework's template, as text: a Handlebars branch is just more matching lines.
875
+ for (const fw of fs.readdirSync(path.join(seed, 'client-tmp'))) {
876
+ const file = path.join(seed, 'client-tmp', fw, 'package.json');
877
+ if (fs.existsSync(file)) checkPackageJson(`client-tmp/${fw}/package.json`, fs.readFileSync(file, 'utf8'));
878
+ }
879
+ for (const label of labels) {
880
+ const app = path.join(work, label, 'demo');
881
+ checkPackageJson(`${label}: client/package.json`, fs.readFileSync(path.join(app, 'client/package.json'), 'utf8'));
882
+ for (const file of gradleFiles(app)) {
883
+ live(file).filter((l) => gradleMention.test(l) && !pluginId.test(l))
884
+ .forEach((l) => problems.push(`${label}: ${path.relative(app, file)}: ${JSON.stringify(l.trim())}`));
885
+ }
886
+ const build = fs.readFileSync(path.join(app, 'server/build.gradle.kts'), 'utf8');
887
+ if (!build.includes(googleBlock)) problems.push(`${label}: server/build.gradle.kts: the scoped google {} block is not last, whole, after the Genesis repository`);
888
+ const googles = live(path.join(app, 'server/build.gradle.kts')).filter((l) => /\bgoogle\b/.test(l)).length;
889
+ if (googles !== 1) problems.push(`${label}: server/build.gradle.kts: ${googles} live lines mention google, expected 1`);
890
+ }
891
+ problems.forEach((p) => console.log(` ${p}`));
892
+ process.exit(problems.length ? 1 : 0);
893
+ NODE
894
+
895
+ if [ "${GRADLE:-0}" = "1" ]; then
896
+ # A CI runner's temporary files are gone once the job ends, so a failing step prints its own log.
897
+ gradle_in_on_app() { # log file, gradle arguments...
898
+ local log="$1"; shift
899
+ # Unquoted on purpose: GRADLE_PARAMS may hold several arguments, or none.
900
+ (cd "$WORK_DIR/on/demo" && ./gradlew --no-daemon ${GRADLE_PARAMS:-} "$@" > "$log" 2>&1) && return 0
901
+ show_log "$log"; return 1
902
+ }
903
+ # The compiler's own errors can sit hundreds of lines above the end, so they come first.
904
+ show_log() {
905
+ echo "--- errors in $1"; grep -E ' ERROR |^e: |What went wrong' "$1" | head -n 40
906
+ echo "--- last 150 lines of $1"; tail -n 150 "$1"; echo "---"
907
+ }
908
+
909
+ echo "=== gradle: build + checkAuthPermissions on the AI app"
910
+ gradle_in_on_app "$WORK_DIR/gradle.log" :server:demo-app:build \
911
+ && gradle_in_on_app "$WORK_DIR/scan.log" :server:demo-app:checkAuthPermissions --rerun \
912
+ || fail "gradle: build or scan failed (see the log above)"
913
+ # The build passes even with insecure endpoints unless a project opts into failing it, so the
914
+ # scan's own summary is the assertion — a green build alone proves nothing about permissioning.
915
+ # `--rerun` because a scan restored from the build cache prints no summary at all, and the endpoint
916
+ # count because a scan that found nothing would also report nothing insecure. Whole lines only:
917
+ # "Total endpoints: 2" is inside "Total endpoints: 20", and every per-type line ends "Insecure: N".
918
+ if ! grep -qx " Total endpoints: 2" "$WORK_DIR/scan.log" || ! grep -qx " Insecure: 0" "$WORK_DIR/scan.log"; then
919
+ show_log "$WORK_DIR/scan.log"
920
+ fail "gradle: the security scan did not report the two chat endpoints, both secure (see the log above)"
921
+ fi
922
+
923
+ # Scripts compile only when the router starts, so neither the build nor the scan above notices a
924
+ # proxy that no longer compiles against this Genesis version. The platform's own preCompileScripts
925
+ # compiles the router's web handlers with the real script host. The Anthropic proxy goes in beside
926
+ # the Gemini one under a second name, after the scan so the scan never counts it.
927
+ echo "=== gradle: compile both proxies with the platform's preCompileScripts"
928
+ cp "$WORK_DIR/onanthropic/demo/$MODULE/scripts/ai-service-web-handler.kts" \
929
+ "$WORK_DIR/on/demo/$MODULE/scripts/ai-service-anthropic-web-handler.kts"
930
+ gradle_in_on_app "$WORK_DIR/compile.log" :server:demo-app:preCompileScripts --rerun -PfailPreCompileScriptOnErrors=true \
931
+ || fail "gradle: a proxy does not compile against this Genesis version (see the log above)"
932
+ # And both were really compiled: a file the task never picked up would pass by never being built.
933
+ for name in ai-service-web-handler.kts ai-service-anthropic-web-handler.kts; do
934
+ grep -q "GENESIS_ROUTER: Compiled script $name " "$WORK_DIR/compile.log" \
935
+ || fail "gradle: preCompileScripts never compiled $name"
936
+ done
937
+
938
+ # A key must never reach a file the build writes. Every GENESIS_SYSDEF_ variable becomes a
939
+ # system-definition item, and genesisConfigJar renders them all in plain text into
940
+ # build/genesis/rendered-templates/generated-system-definition.json. So the render runs with CANARY
941
+ # values, never a real key, under the names the proxy reads and under the old names it refuses. The
942
+ # old ones MUST land: that proves the render ran, so the new ones' absence means something.
943
+ # --rerun so it renders even when Gradle would call it up to date.
944
+ echo "=== gradle: no key the proxy reads lands in the build's files"
945
+ canary="canary-$$-not-a-key"
946
+ ( export AI_GEMINI_API_KEY="$canary-gemini" AI_ANTHROPIC_API_KEY="$canary-anthropic" \
947
+ GENESIS_SYSDEF_AI_GEMINI_API_KEY="$canary-old-gemini" GENESIS_SYSDEF_AI_ANTHROPIC_API_KEY="$canary-old-anthropic"
948
+ gradle_in_on_app "$WORK_DIR/keys.log" :server:demo-app:genesisConfigJar --rerun ) \
949
+ || fail "gradle: the render with canary keys failed (see the log above)"
950
+ # The files under the AI app that hold a value, with every jar and zip looked into as well.
951
+ holding() {
952
+ { grep -rlF "$1" "$WORK_DIR/on/demo" --exclude-dir=node_modules 2>/dev/null
953
+ find "$WORK_DIR/on/demo" -name node_modules -prune -o \( -name '*.jar' -o -name '*.zip' \) -print \
954
+ | while read -r archive; do unzip -p "$archive" 2>/dev/null | grep -qF "$1" && echo "$archive"; done
955
+ } | sed "s#^$WORK_DIR/on/demo/##" | sort -u | tr '\n' ' '
956
+ }
957
+ for vendor in GEMINI ANTHROPIC; do
958
+ lower="$(echo "$vendor" | tr 'A-Z' 'a-z')"
959
+ found="$(holding "$canary-$lower")"
960
+ [ -z "$found" ] || fail "gradle: AI_${vendor}_API_KEY reached the build's files: $found"
961
+ [ -n "$(holding "$canary-old-$lower")" ] \
962
+ || fail "gradle: GENESIS_SYSDEF_AI_${vendor}_API_KEY reached no file, so the render never ran and this proves nothing"
963
+ done
964
+ echo " the old names, which the proxy refuses, still land in: $(holding "$canary-old")"
965
+ fi
966
+
967
+ echo
968
+ if [ ${#FAILURES[@]} -gt 0 ]; then
969
+ echo "FAILED:"; printf ' - %s\n' "${FAILURES[@]}"
970
+ echo "Generated apps kept in $WORK_DIR"
971
+ exit 1
972
+ fi
973
+ echo "AI emission checks passed${GRADLE:+ (with gradle)}"
974
+ [ "${KEEP:-0}" = "1" ] && echo "Generated apps kept in $WORK_DIR" || rm -rf "$WORK_DIR"