@ryuenn3123/agentic-senior-core 3.0.8 → 3.0.10

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.
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ /**
5
+ * ui-design-judge.mjs
6
+ *
7
+ * Advisory-first UI design contract judge.
8
+ *
9
+ * Compares changed UI diffs against docs/design-intent.json and docs/DESIGN.md.
10
+ * Default mode is advisory: findings never block release. Strict mode is opt-in.
11
+ *
12
+ * Usage:
13
+ * node scripts/ui-design-judge.mjs
14
+ * node scripts/ui-design-judge.mjs --dry-run
15
+ * node scripts/ui-design-judge.mjs --strict
16
+ *
17
+ * Environment variables:
18
+ * OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY
19
+ * UI_DESIGN_JUDGE_MODEL Override model for this script
20
+ * LLM_JUDGE_MODEL Shared fallback model override
21
+ * UI_DESIGN_JUDGE_MAX_DIFF_CHARS Max diff chars to send (default: 12000)
22
+ * UI_DESIGN_JUDGE_OUTPUT_PATH Machine-readable report output path
23
+ * UI_DESIGN_JUDGE_EMIT_JSON false disables report file emission
24
+ * UI_DESIGN_JUDGE_MOCK_RESPONSE Test-only raw LLM response body
25
+ * UI_DESIGN_JUDGE_CHANGED_FILES Optional comma/newline-separated changed file override
26
+ * PR_DIFF Inject diff directly
27
+ * GITHUB_BASE_SHA / GITHUB_HEAD_SHA
28
+ * CI_MERGE_REQUEST_DIFF_BASE_SHA / CI_COMMIT_SHA
29
+ */
30
+
31
+ import { execSync } from 'node:child_process';
32
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
33
+ import { resolve, dirname, extname } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = dirname(__filename);
38
+ const REPOSITORY_ROOT = resolve(__dirname, '..');
39
+
40
+ const DESIGN_INTENT_PATH = resolve(REPOSITORY_ROOT, 'docs', 'design-intent.json');
41
+ const DESIGN_GUIDE_PATH = resolve(REPOSITORY_ROOT, 'docs', 'DESIGN.md');
42
+ const DEFAULT_MACHINE_REPORT_PATH = resolve(REPOSITORY_ROOT, '.agent-context', 'state', 'ui-design-judge-report.json');
43
+ const MACHINE_REPORT_PATH = process.env.UI_DESIGN_JUDGE_OUTPUT_PATH || DEFAULT_MACHINE_REPORT_PATH;
44
+ const SHOULD_EMIT_MACHINE_REPORT = process.env.UI_DESIGN_JUDGE_EMIT_JSON !== 'false';
45
+ const MAX_DIFF_CHARS = parseInt(process.env.UI_DESIGN_JUDGE_MAX_DIFF_CHARS ?? '12000', 10);
46
+ const IS_DRY_RUN = process.argv.includes('--dry-run');
47
+ const IS_STRICT_MODE = process.argv.includes('--strict');
48
+ const IS_ADVISORY_MODE = !IS_STRICT_MODE;
49
+ const UI_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.vue', '.css', '.scss', '.sass']);
50
+
51
+ /**
52
+ * @typedef {{
53
+ * area: string,
54
+ * severity: string,
55
+ * problem: string,
56
+ * evidence: string,
57
+ * recommendation: string,
58
+ * blockingRecommended: boolean,
59
+ * }} DriftFinding
60
+ */
61
+
62
+ /**
63
+ * @typedef {{
64
+ * generatedAt: string,
65
+ * auditName: string,
66
+ * schemaVersion: string,
67
+ * mode: 'advisory' | 'strict',
68
+ * advisoryOnly: boolean,
69
+ * passed: boolean,
70
+ * skipped: boolean,
71
+ * skipReason: string | null,
72
+ * provider: string,
73
+ * ciProvider: string,
74
+ * contractPresent: boolean,
75
+ * summary: {
76
+ * changedUiFileCount: number,
77
+ * alignmentScore: number | null,
78
+ * driftCount: number,
79
+ * blockingCandidateCount: number,
80
+ * },
81
+ * malformedVerdict: boolean,
82
+ * providerError: boolean,
83
+ * findings: DriftFinding[],
84
+ * notes: string[],
85
+ * }} UiDesignJudgeReport
86
+ */
87
+
88
+ function detectCiProvider() {
89
+ if (process.env.GITHUB_ACTIONS === 'true') {
90
+ return 'github';
91
+ }
92
+
93
+ if (process.env.GITLAB_CI === 'true') {
94
+ return 'gitlab';
95
+ }
96
+
97
+ return 'local';
98
+ }
99
+
100
+ function normalizeSeverity(rawSeverityValue) {
101
+ const normalizedSeverityValue = String(rawSeverityValue || '').trim().toLowerCase();
102
+
103
+ if (['critical', 'high', 'medium', 'low'].includes(normalizedSeverityValue)) {
104
+ return normalizedSeverityValue;
105
+ }
106
+
107
+ if (normalizedSeverityValue === 'major') {
108
+ return 'high';
109
+ }
110
+
111
+ if (normalizedSeverityValue === 'minor' || normalizedSeverityValue === 'info') {
112
+ return 'low';
113
+ }
114
+
115
+ return 'low';
116
+ }
117
+
118
+ function collectGitDiff(baseSha, headSha) {
119
+ const execOptions = {
120
+ cwd: REPOSITORY_ROOT,
121
+ encoding: /** @type {'utf-8'} */ ('utf-8'),
122
+ maxBuffer: 1024 * 1024 * 8,
123
+ };
124
+
125
+ return execSync(`git diff "${baseSha}...${headSha}"`, execOptions);
126
+ }
127
+
128
+ function collectGitChangedFiles(baseSha, headSha) {
129
+ const execOptions = {
130
+ cwd: REPOSITORY_ROOT,
131
+ encoding: /** @type {'utf-8'} */ ('utf-8'),
132
+ maxBuffer: 1024 * 1024 * 2,
133
+ };
134
+
135
+ const output = execSync(`git diff --name-only "${baseSha}...${headSha}"`, execOptions);
136
+ return output
137
+ .split(/\r?\n/u)
138
+ .map((filePath) => filePath.trim())
139
+ .filter(Boolean);
140
+ }
141
+
142
+ function collectPullRequestDiff() {
143
+ if (process.env.PR_DIFF) {
144
+ return process.env.PR_DIFF;
145
+ }
146
+
147
+ const githubBaseSha = process.env.GITHUB_BASE_SHA;
148
+ const githubHeadSha = process.env.GITHUB_HEAD_SHA ?? 'HEAD';
149
+ if (githubBaseSha) {
150
+ return collectGitDiff(githubBaseSha, githubHeadSha);
151
+ }
152
+
153
+ const gitlabBaseSha = process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
154
+ const gitlabHeadSha = process.env.CI_COMMIT_SHA ?? 'HEAD';
155
+ if (gitlabBaseSha) {
156
+ return collectGitDiff(gitlabBaseSha, gitlabHeadSha);
157
+ }
158
+
159
+ try {
160
+ return execSync('git diff HEAD~1 HEAD', {
161
+ cwd: REPOSITORY_ROOT,
162
+ encoding: /** @type {'utf-8'} */ ('utf-8'),
163
+ maxBuffer: 1024 * 1024 * 8,
164
+ });
165
+ } catch {
166
+ try {
167
+ const emptyTreeSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
168
+ return execSync(`git diff "${emptyTreeSha}" HEAD`, {
169
+ cwd: REPOSITORY_ROOT,
170
+ encoding: /** @type {'utf-8'} */ ('utf-8'),
171
+ maxBuffer: 1024 * 1024 * 8,
172
+ });
173
+ } catch {
174
+ return '';
175
+ }
176
+ }
177
+ }
178
+
179
+ function collectChangedFiles() {
180
+ if (process.env.UI_DESIGN_JUDGE_CHANGED_FILES) {
181
+ return process.env.UI_DESIGN_JUDGE_CHANGED_FILES
182
+ .split(/[\r\n,]+/u)
183
+ .map((filePath) => filePath.trim())
184
+ .filter(Boolean);
185
+ }
186
+
187
+ if (process.env.PR_DIFF) {
188
+ const filePathSet = new Set();
189
+ for (const diffHeaderMatch of process.env.PR_DIFF.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)) {
190
+ filePathSet.add(diffHeaderMatch[2]);
191
+ }
192
+ return Array.from(filePathSet);
193
+ }
194
+
195
+ const githubBaseSha = process.env.GITHUB_BASE_SHA;
196
+ const githubHeadSha = process.env.GITHUB_HEAD_SHA ?? 'HEAD';
197
+ if (githubBaseSha) {
198
+ return collectGitChangedFiles(githubBaseSha, githubHeadSha);
199
+ }
200
+
201
+ const gitlabBaseSha = process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
202
+ const gitlabHeadSha = process.env.CI_COMMIT_SHA ?? 'HEAD';
203
+ if (gitlabBaseSha) {
204
+ return collectGitChangedFiles(gitlabBaseSha, gitlabHeadSha);
205
+ }
206
+
207
+ try {
208
+ const output = execSync('git diff --name-only HEAD~1 HEAD', {
209
+ cwd: REPOSITORY_ROOT,
210
+ encoding: /** @type {'utf-8'} */ ('utf-8'),
211
+ maxBuffer: 1024 * 1024 * 2,
212
+ });
213
+ return output.split(/\r?\n/u).map((filePath) => filePath.trim()).filter(Boolean);
214
+ } catch {
215
+ return [];
216
+ }
217
+ }
218
+
219
+ function isUiRelevantFilePath(filePath) {
220
+ const normalizedFilePath = String(filePath || '').replace(/\\/g, '/').toLowerCase();
221
+ const fileExtension = extname(normalizedFilePath);
222
+
223
+ if (!UI_FILE_EXTENSIONS.has(fileExtension)) {
224
+ return false;
225
+ }
226
+
227
+ return (
228
+ normalizedFilePath.startsWith('src/')
229
+ || normalizedFilePath.startsWith('app/')
230
+ || normalizedFilePath.startsWith('pages/')
231
+ || normalizedFilePath.startsWith('components/')
232
+ || normalizedFilePath.startsWith('styles/')
233
+ || normalizedFilePath.includes('/components/')
234
+ || normalizedFilePath.includes('/screens/')
235
+ || normalizedFilePath.includes('/layouts/')
236
+ );
237
+ }
238
+
239
+ function loadDesignIntent() {
240
+ if (!existsSync(DESIGN_INTENT_PATH)) {
241
+ return null;
242
+ }
243
+
244
+ try {
245
+ return JSON.parse(readFileSync(DESIGN_INTENT_PATH, 'utf8'));
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function loadDesignGuide() {
252
+ if (!existsSync(DESIGN_GUIDE_PATH)) {
253
+ return '';
254
+ }
255
+
256
+ return readFileSync(DESIGN_GUIDE_PATH, 'utf8');
257
+ }
258
+
259
+ function buildSystemPrompt(modeLabel) {
260
+ return [
261
+ 'You are a Principal UI/UX Design Reviewer.',
262
+ 'Compare the changed UI code against the provided design contract.',
263
+ 'Treat docs/design-intent.json as the machine-readable source of truth.',
264
+ 'Treat docs/DESIGN.md as explanatory context, not a generic style guide.',
265
+ 'Do not reward generic SaaS defaults or popular template patterns.',
266
+ 'Do not penalize originality when the implementation still aligns with the contract.',
267
+ 'Only flag drift when there is a clear mismatch with the contract, accessibility non-negotiables, or cross-viewport adaptation rules.',
268
+ `Current mode: ${modeLabel}. In advisory mode, findings are recommendations and should not be framed as release blockers unless blockingRecommended is clearly true.`,
269
+ 'Focus on color intent, typographic hierarchy, responsive re-layout, interaction behavior, and genericity drift.',
270
+ 'Return ONLY one JSON object on a single line prefixed with JSON_VERDICT:.',
271
+ 'Schema:',
272
+ '{"alignmentScore": number|null, "notes": string[], "findings": [{"area": string, "severity": "high|medium|low", "problem": string, "evidence": string, "recommendation": string, "blockingRecommended": boolean}]}',
273
+ ].join('\n');
274
+ }
275
+
276
+ function buildUserMessage(designIntentContent, designGuideContent, diffContent, changedUiFiles) {
277
+ const truncatedDiff = diffContent.length > MAX_DIFF_CHARS
278
+ ? `${diffContent.slice(0, MAX_DIFF_CHARS)}\n\n[DIFF TRUNCATED - ${diffContent.length - MAX_DIFF_CHARS} additional characters omitted]`
279
+ : diffContent;
280
+
281
+ return [
282
+ '## Changed UI Files',
283
+ changedUiFiles.length > 0 ? changedUiFiles.map((filePath) => `- ${filePath}`).join('\n') : '- none',
284
+ '',
285
+ '## design-intent.json',
286
+ '```json',
287
+ JSON.stringify(designIntentContent, null, 2),
288
+ '```',
289
+ '',
290
+ '## DESIGN.md',
291
+ '```md',
292
+ designGuideContent.trim() || '(missing DESIGN.md)',
293
+ '```',
294
+ '',
295
+ '## UI Diff',
296
+ '```diff',
297
+ truncatedDiff.trim() || '(no UI diff)',
298
+ '```',
299
+ '',
300
+ 'Judge alignment to the contract. Avoid aesthetic bias toward generic web trends.',
301
+ ].join('\n');
302
+ }
303
+
304
+ async function callOpenAiProvider(systemPrompt, userMessage) {
305
+ const selectedModel = process.env.UI_DESIGN_JUDGE_MODEL ?? process.env.LLM_JUDGE_MODEL ?? 'gpt-4o-mini';
306
+ const apiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
307
+ method: 'POST',
308
+ headers: {
309
+ 'Content-Type': 'application/json',
310
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
311
+ },
312
+ body: JSON.stringify({
313
+ model: selectedModel,
314
+ max_tokens: 2048,
315
+ temperature: 0,
316
+ messages: [
317
+ { role: 'system', content: systemPrompt },
318
+ { role: 'user', content: userMessage },
319
+ ],
320
+ }),
321
+ });
322
+
323
+ if (!apiResponse.ok) {
324
+ const errorBody = await apiResponse.text();
325
+ throw new Error(`OpenAI API returned ${apiResponse.status}: ${errorBody}`);
326
+ }
327
+
328
+ const responsePayload = await apiResponse.json();
329
+ return responsePayload.choices[0].message.content;
330
+ }
331
+
332
+ async function callAnthropicProvider(systemPrompt, userMessage) {
333
+ const selectedModel = process.env.UI_DESIGN_JUDGE_MODEL ?? process.env.LLM_JUDGE_MODEL ?? 'claude-3-5-haiku-latest';
334
+ const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
335
+ method: 'POST',
336
+ headers: {
337
+ 'Content-Type': 'application/json',
338
+ 'x-api-key': process.env.ANTHROPIC_API_KEY ?? '',
339
+ 'anthropic-version': '2023-06-01',
340
+ },
341
+ body: JSON.stringify({
342
+ model: selectedModel,
343
+ max_tokens: 2048,
344
+ system: systemPrompt,
345
+ messages: [{ role: 'user', content: userMessage }],
346
+ }),
347
+ });
348
+
349
+ if (!apiResponse.ok) {
350
+ const errorBody = await apiResponse.text();
351
+ throw new Error(`Anthropic API returned ${apiResponse.status}: ${errorBody}`);
352
+ }
353
+
354
+ const responsePayload = await apiResponse.json();
355
+ return responsePayload.content[0].text;
356
+ }
357
+
358
+ async function callGeminiProvider(systemPrompt, userMessage) {
359
+ const selectedModel = process.env.UI_DESIGN_JUDGE_MODEL ?? process.env.LLM_JUDGE_MODEL ?? 'gemini-2.0-flash';
360
+ const apiKey = process.env.GEMINI_API_KEY ?? '';
361
+ const endpointUrl = `https://generativelanguage.googleapis.com/v1beta/models/${selectedModel}:generateContent?key=${apiKey}`;
362
+
363
+ const apiResponse = await fetch(endpointUrl, {
364
+ method: 'POST',
365
+ headers: { 'Content-Type': 'application/json' },
366
+ body: JSON.stringify({
367
+ system_instruction: { parts: [{ text: systemPrompt }] },
368
+ contents: [{ role: 'user', parts: [{ text: userMessage }] }],
369
+ generationConfig: { temperature: 0, maxOutputTokens: 2048 },
370
+ }),
371
+ });
372
+
373
+ if (!apiResponse.ok) {
374
+ const errorBody = await apiResponse.text();
375
+ throw new Error(`Gemini API returned ${apiResponse.status}: ${errorBody}`);
376
+ }
377
+
378
+ const responsePayload = await apiResponse.json();
379
+ return responsePayload.candidates[0].content.parts[0].text;
380
+ }
381
+
382
+ function selectAvailableProvider() {
383
+ if (process.env.UI_DESIGN_JUDGE_MOCK_RESPONSE) {
384
+ return {
385
+ providerName: 'mock',
386
+ invokeProvider: async () => process.env.UI_DESIGN_JUDGE_MOCK_RESPONSE,
387
+ };
388
+ }
389
+
390
+ if (process.env.OPENAI_API_KEY) {
391
+ return { providerName: 'openai', invokeProvider: callOpenAiProvider };
392
+ }
393
+
394
+ if (process.env.ANTHROPIC_API_KEY) {
395
+ return { providerName: 'anthropic', invokeProvider: callAnthropicProvider };
396
+ }
397
+
398
+ if (process.env.GEMINI_API_KEY) {
399
+ return { providerName: 'gemini', invokeProvider: callGeminiProvider };
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ function extractVerdictObject(rawResponseText) {
406
+ const verdictMatch = rawResponseText.match(/JSON_VERDICT:\s*(\{[\s\S]*\})/i);
407
+ if (!verdictMatch) {
408
+ return { verdict: null, malformed: true };
409
+ }
410
+
411
+ try {
412
+ return {
413
+ verdict: JSON.parse(verdictMatch[1]),
414
+ malformed: false,
415
+ };
416
+ } catch {
417
+ return {
418
+ verdict: null,
419
+ malformed: true,
420
+ };
421
+ }
422
+ }
423
+
424
+ function normalizeFindings(rawFindings) {
425
+ if (!Array.isArray(rawFindings)) {
426
+ return [];
427
+ }
428
+
429
+ return rawFindings.map((rawFinding) => ({
430
+ area: String(rawFinding?.area || 'general'),
431
+ severity: normalizeSeverity(rawFinding?.severity),
432
+ problem: String(rawFinding?.problem || 'No problem description provided.'),
433
+ evidence: String(rawFinding?.evidence || 'No evidence provided.'),
434
+ recommendation: String(rawFinding?.recommendation || 'No recommendation provided.'),
435
+ blockingRecommended: rawFinding?.blockingRecommended === true,
436
+ }));
437
+ }
438
+
439
+ /**
440
+ * @param {Partial<UiDesignJudgeReport>} partialReport
441
+ * @returns {UiDesignJudgeReport}
442
+ */
443
+ function buildReport(partialReport) {
444
+ return {
445
+ generatedAt: new Date().toISOString(),
446
+ auditName: 'ui-design-judge',
447
+ schemaVersion: '1.0',
448
+ mode: IS_STRICT_MODE ? 'strict' : 'advisory',
449
+ advisoryOnly: IS_ADVISORY_MODE,
450
+ passed: true,
451
+ skipped: false,
452
+ skipReason: null,
453
+ provider: 'none',
454
+ ciProvider: detectCiProvider(),
455
+ contractPresent: false,
456
+ summary: {
457
+ changedUiFileCount: 0,
458
+ alignmentScore: null,
459
+ driftCount: 0,
460
+ blockingCandidateCount: 0,
461
+ },
462
+ malformedVerdict: false,
463
+ providerError: false,
464
+ findings: [],
465
+ notes: [],
466
+ ...partialReport,
467
+ };
468
+ }
469
+
470
+ function emitMachineReadableReport(machineReportPayload) {
471
+ if (SHOULD_EMIT_MACHINE_REPORT) {
472
+ writeFileSync(MACHINE_REPORT_PATH, `${JSON.stringify(machineReportPayload, null, 2)}\n`, 'utf8');
473
+ }
474
+
475
+ console.log(JSON.stringify(machineReportPayload, null, 2));
476
+ }
477
+
478
+ async function main() {
479
+ const changedFiles = collectChangedFiles();
480
+ const changedUiFiles = changedFiles.filter(isUiRelevantFilePath);
481
+ const rawDiff = collectPullRequestDiff();
482
+ const designIntentContent = loadDesignIntent();
483
+ const designGuideContent = loadDesignGuide();
484
+
485
+ if (!designIntentContent) {
486
+ emitMachineReadableReport(buildReport({
487
+ skipped: true,
488
+ skipReason: 'Design contract is missing or unreadable. Skipping UI design judge.',
489
+ contractPresent: false,
490
+ notes: ['docs/design-intent.json is required for contract-aware UI judging.'],
491
+ }));
492
+ return;
493
+ }
494
+
495
+ if (changedUiFiles.length === 0) {
496
+ emitMachineReadableReport(buildReport({
497
+ skipped: true,
498
+ skipReason: 'No UI-relevant changed files detected.',
499
+ contractPresent: true,
500
+ summary: {
501
+ changedUiFileCount: 0,
502
+ alignmentScore: null,
503
+ driftCount: 0,
504
+ blockingCandidateCount: 0,
505
+ },
506
+ notes: ['UI design judge only evaluates changed UI surfaces.'],
507
+ }));
508
+ return;
509
+ }
510
+
511
+ const systemPrompt = buildSystemPrompt(IS_STRICT_MODE ? 'strict' : 'advisory');
512
+ const userMessage = buildUserMessage(designIntentContent, designGuideContent, rawDiff, changedUiFiles);
513
+
514
+ if (IS_DRY_RUN) {
515
+ emitMachineReadableReport(buildReport({
516
+ provider: 'dry-run',
517
+ contractPresent: true,
518
+ summary: {
519
+ changedUiFileCount: changedUiFiles.length,
520
+ alignmentScore: null,
521
+ driftCount: 0,
522
+ blockingCandidateCount: 0,
523
+ },
524
+ notes: [
525
+ 'Dry run enabled. No LLM provider call was made.',
526
+ `System prompt chars: ${systemPrompt.length}`,
527
+ `User message chars: ${userMessage.length}`,
528
+ ],
529
+ }));
530
+ return;
531
+ }
532
+
533
+ const selectedProvider = selectAvailableProvider();
534
+ if (!selectedProvider) {
535
+ emitMachineReadableReport(buildReport({
536
+ provider: 'none',
537
+ contractPresent: true,
538
+ summary: {
539
+ changedUiFileCount: changedUiFiles.length,
540
+ alignmentScore: null,
541
+ driftCount: 0,
542
+ blockingCandidateCount: 0,
543
+ },
544
+ notes: ['No LLM provider configured. UI design judge skipped provider review and remained advisory.'],
545
+ }));
546
+ return;
547
+ }
548
+
549
+ let rawJudgeResponse;
550
+ try {
551
+ rawJudgeResponse = await selectedProvider.invokeProvider(systemPrompt, userMessage);
552
+ } catch (providerError) {
553
+ const providerErrorMessage = providerError instanceof Error
554
+ ? providerError.message
555
+ : 'Unknown provider error';
556
+
557
+ emitMachineReadableReport(buildReport({
558
+ provider: selectedProvider.providerName,
559
+ contractPresent: true,
560
+ providerError: true,
561
+ summary: {
562
+ changedUiFileCount: changedUiFiles.length,
563
+ alignmentScore: null,
564
+ driftCount: 0,
565
+ blockingCandidateCount: 0,
566
+ },
567
+ notes: [`Provider call failed: ${providerErrorMessage}`],
568
+ passed: IS_ADVISORY_MODE,
569
+ }));
570
+
571
+ if (IS_STRICT_MODE) {
572
+ process.exit(1);
573
+ }
574
+
575
+ return;
576
+ }
577
+
578
+ const { verdict, malformed } = extractVerdictObject(rawJudgeResponse);
579
+ const findings = normalizeFindings(verdict?.findings);
580
+ const blockingCandidateCount = findings.filter((finding) => finding.blockingRecommended || finding.severity === 'high').length;
581
+ const alignmentScore = typeof verdict?.alignmentScore === 'number' ? verdict.alignmentScore : null;
582
+ const notes = Array.isArray(verdict?.notes)
583
+ ? verdict.notes.map((note) => String(note))
584
+ : [];
585
+ const shouldFailInStrictMode = IS_STRICT_MODE && blockingCandidateCount > 0;
586
+
587
+ const reportPayload = buildReport({
588
+ provider: selectedProvider.providerName,
589
+ contractPresent: true,
590
+ passed: malformed ? IS_ADVISORY_MODE : !shouldFailInStrictMode,
591
+ malformedVerdict: malformed,
592
+ summary: {
593
+ changedUiFileCount: changedUiFiles.length,
594
+ alignmentScore,
595
+ driftCount: findings.length,
596
+ blockingCandidateCount,
597
+ },
598
+ findings,
599
+ notes: malformed
600
+ ? ['LLM response was malformed. Advisory mode kept the audit non-blocking.']
601
+ : notes,
602
+ });
603
+
604
+ emitMachineReadableReport(reportPayload);
605
+
606
+ if (IS_STRICT_MODE && (malformed || shouldFailInStrictMode)) {
607
+ process.exit(1);
608
+ }
609
+ }
610
+
611
+ main().catch((unexpectedError) => {
612
+ const errorMessage = unexpectedError instanceof Error
613
+ ? unexpectedError.message
614
+ : 'Unknown unexpected error';
615
+
616
+ emitMachineReadableReport(buildReport({
617
+ provider: 'none',
618
+ providerError: true,
619
+ passed: IS_ADVISORY_MODE,
620
+ notes: [`Unexpected ui-design-judge failure: ${errorMessage}`],
621
+ }));
622
+
623
+ if (IS_STRICT_MODE) {
624
+ process.exit(1);
625
+ }
626
+ });