@neurcode/action 0.2.1

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/src/index.ts ADDED
@@ -0,0 +1,1677 @@
1
+ import * as core from '@actions/core';
2
+ import * as exec from '@actions/exec';
3
+ import * as github from '@actions/github';
4
+ import { parseCliVerifyJsonPayload } from '@neurcode-ai/contracts';
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+ import {
8
+ buildVerifyArgs,
9
+ getVerifyFallbackDecision,
10
+ isMissingPlanVerificationFailure,
11
+ } from './verify-mode';
12
+
13
+ interface Violation {
14
+ file: string;
15
+ rule: string;
16
+ severity: string;
17
+ message: string;
18
+ }
19
+
20
+ interface NeurcodeVerifyResult {
21
+ grade: string;
22
+ score: number;
23
+ verdict: 'PASS' | 'FAIL' | 'WARN' | 'INFO';
24
+ violations: Violation[];
25
+ message: string;
26
+ verificationSource?: 'api' | 'local_fallback' | 'policy_only' | string;
27
+ tier?: string;
28
+ scopeGuardPassed?: boolean;
29
+ bloatCount?: number;
30
+ blastRadius?: {
31
+ riskScore?: 'low' | 'medium' | 'high';
32
+ filesChanged?: number;
33
+ };
34
+ suspiciousChange?: {
35
+ flagged?: boolean;
36
+ confidence?: 'low' | 'medium' | 'high';
37
+ unexpectedFiles?: string[];
38
+ };
39
+ governanceDecision?: {
40
+ decision?: 'allow' | 'warn' | 'manual_approval' | 'block';
41
+ averageRelevanceScore?: number;
42
+ };
43
+ aiChangeLog?: {
44
+ integrity?: {
45
+ valid?: boolean;
46
+ signed?: boolean;
47
+ required?: boolean;
48
+ issues?: string[];
49
+ };
50
+ };
51
+ orgGovernance?: {
52
+ requireSignedAiLogs?: boolean;
53
+ requireManualApproval?: boolean;
54
+ minimumManualApprovals?: number;
55
+ } | null;
56
+ policyCompilation?: {
57
+ fingerprint?: string;
58
+ deterministicRuleCount?: number;
59
+ unmatchedStatements?: number;
60
+ sourcePath?: string;
61
+ policyLockFingerprint?: string | null;
62
+ };
63
+ changeContract?: {
64
+ path?: string;
65
+ exists?: boolean;
66
+ enforced?: boolean;
67
+ valid?: boolean | null;
68
+ planId?: string | null;
69
+ contractId?: string | null;
70
+ violations?: Array<{
71
+ code?: string;
72
+ message?: string;
73
+ }>;
74
+ };
75
+ }
76
+
77
+ interface ShipSummaryResult {
78
+ status: 'READY_TO_MERGE' | 'BLOCKED';
79
+ finalPlanId: string;
80
+ mergeConfidence: number;
81
+ riskScore: number;
82
+ verification: {
83
+ verdict: string;
84
+ grade: string;
85
+ score: number;
86
+ violations: number;
87
+ };
88
+ tests: {
89
+ skipped: boolean;
90
+ passed: boolean;
91
+ };
92
+ artifacts?: {
93
+ jsonPath?: string;
94
+ markdownPath?: string;
95
+ };
96
+ shareCard?: {
97
+ id: string;
98
+ shareToken: string;
99
+ shareUrl: string;
100
+ createdAt: string;
101
+ } | null;
102
+ }
103
+
104
+ interface CommandResult {
105
+ exitCode: number;
106
+ output: string;
107
+ }
108
+
109
+ interface CliInvocation {
110
+ cmd: string;
111
+ argsPrefix: string[];
112
+ description: string;
113
+ }
114
+
115
+ type CliInstallSource = 'npm' | 'workspace';
116
+
117
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
118
+ const NON_FAST_FORWARD_PATTERNS = [
119
+ 'non-fast-forward',
120
+ '[rejected]',
121
+ 'fetch first',
122
+ 'would be overwritten by push',
123
+ ];
124
+ const TRANSIENT_NETWORK_PATTERNS = [
125
+ 'fetch failed',
126
+ 'econnreset',
127
+ 'etimedout',
128
+ 'socket hang up',
129
+ 'gateway time-out',
130
+ 'gateway timeout',
131
+ '502',
132
+ '503',
133
+ '504',
134
+ ];
135
+ const TIER_LIMITED_VERIFY_PATTERNS = [
136
+ 'pro required for policy verification',
137
+ 'basic file change summary',
138
+ 'tier-limited',
139
+ ];
140
+ const GRADE_ORDER: Record<string, number> = {
141
+ F: 1,
142
+ D: 2,
143
+ C: 3,
144
+ B: 4,
145
+ A: 5,
146
+ };
147
+
148
+ function stripAnsi(value: string): string {
149
+ return value.replace(ANSI_PATTERN, '');
150
+ }
151
+
152
+ function sleep(ms: number): Promise<void> {
153
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
154
+ }
155
+
156
+ function parseBoolean(input: string | undefined, fallback: boolean): boolean {
157
+ if (input === undefined || input === null || input.trim() === '') {
158
+ return fallback;
159
+ }
160
+ const normalized = input.trim().toLowerCase();
161
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
162
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
163
+ return fallback;
164
+ }
165
+
166
+ function parsePositiveInt(input: string | undefined, fallback: number, min: number, max: number): number {
167
+ const parsed = Number(input);
168
+ if (!Number.isFinite(parsed)) return fallback;
169
+ const rounded = Math.round(parsed);
170
+ if (rounded < min) return min;
171
+ if (rounded > max) return max;
172
+ return rounded;
173
+ }
174
+
175
+ function normalizeGrade(input: string | undefined): keyof typeof GRADE_ORDER | undefined {
176
+ const normalized = (input || '').trim().toUpperCase();
177
+ if (normalized in GRADE_ORDER) {
178
+ return normalized as keyof typeof GRADE_ORDER;
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function isGradeBelowThreshold(
184
+ grade: string | undefined,
185
+ threshold: keyof typeof GRADE_ORDER
186
+ ): boolean | null {
187
+ const normalized = normalizeGrade(grade);
188
+ if (!normalized) {
189
+ return null;
190
+ }
191
+ return GRADE_ORDER[normalized] < GRADE_ORDER[threshold];
192
+ }
193
+
194
+ function parseCliInstallSource(input: string | undefined): CliInstallSource {
195
+ const normalized = (input || '').trim().toLowerCase();
196
+ if (normalized === 'workspace') {
197
+ return 'workspace';
198
+ }
199
+ return 'npm';
200
+ }
201
+
202
+ function withCommandTimeout(
203
+ cmd: string,
204
+ args: string[],
205
+ timeoutMinutes: number
206
+ ): {
207
+ cmd: string;
208
+ args: string[];
209
+ timeoutApplied: boolean;
210
+ } {
211
+ if (timeoutMinutes > 0 && process.platform !== 'win32') {
212
+ return {
213
+ cmd: 'timeout',
214
+ args: [`${timeoutMinutes}m`, cmd, ...args],
215
+ timeoutApplied: true,
216
+ };
217
+ }
218
+ return {
219
+ cmd,
220
+ args,
221
+ timeoutApplied: false,
222
+ };
223
+ }
224
+
225
+ function hasWorkingTreeChanges(output: string): boolean {
226
+ return output
227
+ .split('\n')
228
+ .map((line) => line.trim())
229
+ .filter(Boolean).length > 0;
230
+ }
231
+
232
+ function countWorkingTreeChanges(output: string): number {
233
+ return output
234
+ .split('\n')
235
+ .map((line) => line.trim())
236
+ .filter(Boolean).length;
237
+ }
238
+
239
+ function normalizeRepoPath(path: string): string {
240
+ return path
241
+ .trim()
242
+ .replace(/^\.\/+/, '')
243
+ .replace(/^a\//, '')
244
+ .replace(/^b\//, '')
245
+ .replace(/\\/g, '/');
246
+ }
247
+
248
+ function parseChangedFiles(output: string): Set<string> {
249
+ const files = output
250
+ .split('\n')
251
+ .map((line) => normalizeRepoPath(line))
252
+ .filter(Boolean);
253
+ return new Set(files);
254
+ }
255
+
256
+ function collectActionableViolations(violations: Violation[], changedFiles: Set<string>): Violation[] {
257
+ if (changedFiles.size === 0) {
258
+ return [];
259
+ }
260
+ const changedList = [...changedFiles];
261
+ return violations.filter((violation) => {
262
+ const violationPath = normalizeRepoPath(violation.file || '');
263
+ if (!violationPath || violationPath === 'unknown') {
264
+ return false;
265
+ }
266
+ if (changedFiles.has(violationPath)) {
267
+ return true;
268
+ }
269
+ return changedList.some((changed) => violationPath.endsWith(changed));
270
+ });
271
+ }
272
+
273
+ function isNonFastForwardPushFailure(output: string): boolean {
274
+ const normalized = stripAnsi(output).toLowerCase();
275
+ return NON_FAST_FORWARD_PATTERNS.some((pattern) => normalized.includes(pattern));
276
+ }
277
+
278
+ function isLikelyTransientNetworkFailure(output: string): boolean {
279
+ const normalized = stripAnsi(output).toLowerCase();
280
+ return TRANSIENT_NETWORK_PATTERNS.some((pattern) => normalized.includes(pattern));
281
+ }
282
+
283
+ function isTierLimitedInfoVerifyResult(result: NeurcodeVerifyResult | null): boolean {
284
+ if (!result || result.verdict !== 'INFO') {
285
+ return false;
286
+ }
287
+ const tier = (result.tier || '').trim().toUpperCase();
288
+ if (tier === 'FREE') {
289
+ return true;
290
+ }
291
+ const message = (result.message || '').toLowerCase();
292
+ return TIER_LIMITED_VERIFY_PATTERNS.some((pattern) => message.includes(pattern));
293
+ }
294
+
295
+ function readJsonFile(path: string): Record<string, unknown> | null {
296
+ if (!existsSync(path)) return null;
297
+ try {
298
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
299
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
300
+ return parsed as Record<string, unknown>;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ function detectProjectOrgId(cwd: string): string | undefined {
307
+ const statePath = join(cwd, '.neurcode', 'config.json');
308
+ const state = readJsonFile(statePath);
309
+ const value = state?.orgId;
310
+ if (typeof value === 'string' && value.trim()) {
311
+ return value.trim();
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ function seedCiAuthFile(cwd: string, apiKey: string, preferredOrgId?: string): void {
317
+ const home = process.env.HOME || process.env.USERPROFILE;
318
+ if (!home) {
319
+ core.warning('Unable to seed ~/.neurcoderc (home directory not found).');
320
+ return;
321
+ }
322
+
323
+ const authPath = join(home, '.neurcoderc');
324
+ const existing = readJsonFile(authPath) || {};
325
+
326
+ const next: Record<string, unknown> = {
327
+ ...existing,
328
+ version: 2,
329
+ apiKey,
330
+ };
331
+
332
+ const orgId = preferredOrgId || detectProjectOrgId(cwd);
333
+ const apiKeysByOrgRaw = existing.apiKeysByOrg;
334
+ const apiKeysByOrg =
335
+ apiKeysByOrgRaw && typeof apiKeysByOrgRaw === 'object' && !Array.isArray(apiKeysByOrgRaw)
336
+ ? { ...(apiKeysByOrgRaw as Record<string, string>) }
337
+ : {};
338
+
339
+ if (orgId) {
340
+ apiKeysByOrg[orgId] = apiKey;
341
+ next.defaultOrgId = orgId;
342
+ }
343
+ next.apiKeysByOrg = apiKeysByOrg;
344
+
345
+ const apiUrl = process.env.NEURCODE_API_URL;
346
+ if (apiUrl && typeof apiUrl === 'string' && apiUrl.trim()) {
347
+ next.apiUrl = apiUrl.trim();
348
+ }
349
+
350
+ writeFileSync(authPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
351
+ core.info(orgId
352
+ ? `Seeded CI auth key for org ${orgId}`
353
+ : 'Seeded CI auth key for default scope');
354
+ }
355
+
356
+ function canPushToPullRequestBranch(pr: any): boolean {
357
+ if (!pr) return false;
358
+ const repoFullName = `${github.context.repo.owner}/${github.context.repo.repo}`;
359
+ const prHeadFullName = pr?.head?.repo?.full_name;
360
+ if (!prHeadFullName || prHeadFullName !== repoFullName) {
361
+ return false;
362
+ }
363
+ return typeof pr?.head?.ref === 'string' && pr.head.ref.length > 0;
364
+ }
365
+
366
+ function extractLastJsonObject(output: string): unknown | null {
367
+ const clean = stripAnsi(output).trim();
368
+ const end = clean.lastIndexOf('}');
369
+ if (end < 0) return null;
370
+
371
+ let start = clean.lastIndexOf('{', end);
372
+ while (start >= 0) {
373
+ const candidate = clean.slice(start, end + 1).trim();
374
+ try {
375
+ return JSON.parse(candidate);
376
+ } catch {
377
+ start = clean.lastIndexOf('{', start - 1);
378
+ }
379
+ }
380
+
381
+ return null;
382
+ }
383
+
384
+ function parseVerifyResult(output: string): NeurcodeVerifyResult | null {
385
+ const parsed = extractLastJsonObject(output);
386
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
387
+
388
+ const value = parsed as Record<string, unknown>;
389
+ let validated: ReturnType<typeof parseCliVerifyJsonPayload>;
390
+ try {
391
+ validated = parseCliVerifyJsonPayload(value, 'action-verify-result');
392
+ } catch {
393
+ return null;
394
+ }
395
+
396
+ const rawViolations = Array.isArray(value.violations) ? value.violations : [];
397
+ const violations: Violation[] = rawViolations
398
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
399
+ .map((entry) => ({
400
+ file: typeof entry.file === 'string' ? entry.file : 'unknown',
401
+ rule: typeof entry.rule === 'string' ? entry.rule : 'unknown',
402
+ severity: typeof entry.severity === 'string' ? entry.severity : 'warn',
403
+ message: typeof entry.message === 'string' ? entry.message : '',
404
+ }));
405
+
406
+ return {
407
+ grade: validated.grade,
408
+ score: validated.score,
409
+ verdict: validated.verdict as NeurcodeVerifyResult['verdict'],
410
+ violations,
411
+ message: validated.message,
412
+ verificationSource:
413
+ typeof (validated as Record<string, unknown>).verificationSource === 'string'
414
+ ? ((validated as Record<string, unknown>).verificationSource as string)
415
+ : undefined,
416
+ tier: typeof value.tier === 'string' ? value.tier : undefined,
417
+ scopeGuardPassed: validated.scopeGuardPassed,
418
+ bloatCount: typeof value.bloatCount === 'number' ? value.bloatCount : undefined,
419
+ blastRadius:
420
+ value.blastRadius && typeof value.blastRadius === 'object' && !Array.isArray(value.blastRadius)
421
+ ? {
422
+ riskScore: typeof (value.blastRadius as Record<string, unknown>).riskScore === 'string'
423
+ ? ((value.blastRadius as Record<string, unknown>).riskScore as 'low' | 'medium' | 'high')
424
+ : undefined,
425
+ filesChanged: typeof (value.blastRadius as Record<string, unknown>).filesChanged === 'number'
426
+ ? (value.blastRadius as Record<string, unknown>).filesChanged as number
427
+ : undefined,
428
+ }
429
+ : undefined,
430
+ suspiciousChange:
431
+ value.suspiciousChange && typeof value.suspiciousChange === 'object' && !Array.isArray(value.suspiciousChange)
432
+ ? {
433
+ flagged: (value.suspiciousChange as Record<string, unknown>).flagged === true,
434
+ confidence: typeof (value.suspiciousChange as Record<string, unknown>).confidence === 'string'
435
+ ? ((value.suspiciousChange as Record<string, unknown>).confidence as 'low' | 'medium' | 'high')
436
+ : undefined,
437
+ unexpectedFiles: Array.isArray((value.suspiciousChange as Record<string, unknown>).unexpectedFiles)
438
+ ? ((value.suspiciousChange as Record<string, unknown>).unexpectedFiles as unknown[])
439
+ .filter((item): item is string => typeof item === 'string')
440
+ : undefined,
441
+ }
442
+ : undefined,
443
+ governanceDecision:
444
+ value.governanceDecision && typeof value.governanceDecision === 'object' && !Array.isArray(value.governanceDecision)
445
+ ? {
446
+ decision: typeof (value.governanceDecision as Record<string, unknown>).decision === 'string'
447
+ ? ((value.governanceDecision as Record<string, unknown>).decision as 'allow' | 'warn' | 'manual_approval' | 'block')
448
+ : undefined,
449
+ averageRelevanceScore: typeof (value.governanceDecision as Record<string, unknown>).averageRelevanceScore === 'number'
450
+ ? (value.governanceDecision as Record<string, unknown>).averageRelevanceScore as number
451
+ : undefined,
452
+ }
453
+ : undefined,
454
+ aiChangeLog:
455
+ value.aiChangeLog && typeof value.aiChangeLog === 'object' && !Array.isArray(value.aiChangeLog)
456
+ ? (() => {
457
+ const aiLogRaw = value.aiChangeLog as Record<string, unknown>;
458
+ const integrityRaw =
459
+ aiLogRaw.integrity && typeof aiLogRaw.integrity === 'object' && !Array.isArray(aiLogRaw.integrity)
460
+ ? (aiLogRaw.integrity as Record<string, unknown>)
461
+ : null;
462
+ return {
463
+ integrity: integrityRaw
464
+ ? {
465
+ valid: integrityRaw.valid === true,
466
+ signed: integrityRaw.signed === true,
467
+ required: integrityRaw.required === true,
468
+ issues: Array.isArray(integrityRaw.issues)
469
+ ? integrityRaw.issues.filter((item): item is string => typeof item === 'string')
470
+ : [],
471
+ }
472
+ : undefined,
473
+ };
474
+ })()
475
+ : undefined,
476
+ orgGovernance:
477
+ value.orgGovernance && typeof value.orgGovernance === 'object' && !Array.isArray(value.orgGovernance)
478
+ ? {
479
+ requireSignedAiLogs: (value.orgGovernance as Record<string, unknown>).requireSignedAiLogs === true,
480
+ requireManualApproval: (value.orgGovernance as Record<string, unknown>).requireManualApproval === true,
481
+ minimumManualApprovals:
482
+ typeof (value.orgGovernance as Record<string, unknown>).minimumManualApprovals === 'number'
483
+ ? Math.max(
484
+ 1,
485
+ Math.min(
486
+ 5,
487
+ Math.floor((value.orgGovernance as Record<string, unknown>).minimumManualApprovals as number)
488
+ )
489
+ )
490
+ : undefined,
491
+ }
492
+ : undefined,
493
+ policyCompilation:
494
+ value.policyCompilation && typeof value.policyCompilation === 'object' && !Array.isArray(value.policyCompilation)
495
+ ? {
496
+ fingerprint:
497
+ typeof (value.policyCompilation as Record<string, unknown>).fingerprint === 'string'
498
+ ? ((value.policyCompilation as Record<string, unknown>).fingerprint as string)
499
+ : undefined,
500
+ deterministicRuleCount:
501
+ typeof (value.policyCompilation as Record<string, unknown>).deterministicRuleCount === 'number'
502
+ ? ((value.policyCompilation as Record<string, unknown>).deterministicRuleCount as number)
503
+ : undefined,
504
+ unmatchedStatements:
505
+ typeof (value.policyCompilation as Record<string, unknown>).unmatchedStatements === 'number'
506
+ ? ((value.policyCompilation as Record<string, unknown>).unmatchedStatements as number)
507
+ : undefined,
508
+ sourcePath:
509
+ typeof (value.policyCompilation as Record<string, unknown>).sourcePath === 'string'
510
+ ? ((value.policyCompilation as Record<string, unknown>).sourcePath as string)
511
+ : undefined,
512
+ policyLockFingerprint:
513
+ typeof (value.policyCompilation as Record<string, unknown>).policyLockFingerprint === 'string'
514
+ ? ((value.policyCompilation as Record<string, unknown>).policyLockFingerprint as string)
515
+ : null,
516
+ }
517
+ : undefined,
518
+ changeContract:
519
+ value.changeContract && typeof value.changeContract === 'object' && !Array.isArray(value.changeContract)
520
+ ? {
521
+ path:
522
+ typeof (value.changeContract as Record<string, unknown>).path === 'string'
523
+ ? ((value.changeContract as Record<string, unknown>).path as string)
524
+ : undefined,
525
+ exists:
526
+ typeof (value.changeContract as Record<string, unknown>).exists === 'boolean'
527
+ ? ((value.changeContract as Record<string, unknown>).exists as boolean)
528
+ : undefined,
529
+ enforced:
530
+ typeof (value.changeContract as Record<string, unknown>).enforced === 'boolean'
531
+ ? ((value.changeContract as Record<string, unknown>).enforced as boolean)
532
+ : undefined,
533
+ valid:
534
+ typeof (value.changeContract as Record<string, unknown>).valid === 'boolean'
535
+ ? ((value.changeContract as Record<string, unknown>).valid as boolean)
536
+ : null,
537
+ planId:
538
+ typeof (value.changeContract as Record<string, unknown>).planId === 'string'
539
+ ? ((value.changeContract as Record<string, unknown>).planId as string)
540
+ : null,
541
+ contractId:
542
+ typeof (value.changeContract as Record<string, unknown>).contractId === 'string'
543
+ ? ((value.changeContract as Record<string, unknown>).contractId as string)
544
+ : null,
545
+ violations: Array.isArray((value.changeContract as Record<string, unknown>).violations)
546
+ ? ((value.changeContract as Record<string, unknown>).violations as unknown[])
547
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
548
+ .map((item) => ({
549
+ code: typeof item.code === 'string' ? item.code : undefined,
550
+ message: typeof item.message === 'string' ? item.message : undefined,
551
+ }))
552
+ : [],
553
+ }
554
+ : undefined,
555
+ };
556
+ }
557
+
558
+ function parseShipSummary(output: string): ShipSummaryResult | null {
559
+ const parsed = extractLastJsonObject(output);
560
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
561
+
562
+ const value = parsed as Record<string, unknown>;
563
+ if (typeof value.status !== 'string' || typeof value.finalPlanId !== 'string') {
564
+ return null;
565
+ }
566
+
567
+ const verificationRaw = value.verification;
568
+ const testsRaw = value.tests;
569
+
570
+ if (!verificationRaw || typeof verificationRaw !== 'object' || Array.isArray(verificationRaw)) {
571
+ return null;
572
+ }
573
+ if (!testsRaw || typeof testsRaw !== 'object' || Array.isArray(testsRaw)) {
574
+ return null;
575
+ }
576
+
577
+ const verification = verificationRaw as Record<string, unknown>;
578
+ const tests = testsRaw as Record<string, unknown>;
579
+
580
+ const artifactsRaw = value.artifacts;
581
+ const artifacts = artifactsRaw && typeof artifactsRaw === 'object' && !Array.isArray(artifactsRaw)
582
+ ? {
583
+ jsonPath: typeof (artifactsRaw as Record<string, unknown>).jsonPath === 'string'
584
+ ? (artifactsRaw as Record<string, unknown>).jsonPath as string
585
+ : undefined,
586
+ markdownPath: typeof (artifactsRaw as Record<string, unknown>).markdownPath === 'string'
587
+ ? (artifactsRaw as Record<string, unknown>).markdownPath as string
588
+ : undefined,
589
+ }
590
+ : undefined;
591
+
592
+ const shareCardRaw = value.shareCard;
593
+ const shareCard = shareCardRaw && typeof shareCardRaw === 'object' && !Array.isArray(shareCardRaw)
594
+ ? {
595
+ id: String((shareCardRaw as Record<string, unknown>).id || ''),
596
+ shareToken: String((shareCardRaw as Record<string, unknown>).shareToken || ''),
597
+ shareUrl: String((shareCardRaw as Record<string, unknown>).shareUrl || ''),
598
+ createdAt: String((shareCardRaw as Record<string, unknown>).createdAt || ''),
599
+ }
600
+ : null;
601
+
602
+ return {
603
+ status: value.status as ShipSummaryResult['status'],
604
+ finalPlanId: value.finalPlanId,
605
+ mergeConfidence: typeof value.mergeConfidence === 'number' ? value.mergeConfidence : 0,
606
+ riskScore: typeof value.riskScore === 'number' ? value.riskScore : 0,
607
+ verification: {
608
+ verdict: typeof verification.verdict === 'string' ? verification.verdict : 'UNKNOWN',
609
+ grade: typeof verification.grade === 'string' ? verification.grade : 'N/A',
610
+ score: typeof verification.score === 'number' ? verification.score : 0,
611
+ violations: typeof verification.violations === 'number' ? verification.violations : 0,
612
+ },
613
+ tests: {
614
+ skipped: tests.skipped === true,
615
+ passed: tests.passed === true,
616
+ },
617
+ artifacts,
618
+ shareCard,
619
+ };
620
+ }
621
+
622
+ async function runCommand(
623
+ cmd: string,
624
+ args: string[],
625
+ options: {
626
+ cwd: string;
627
+ env?: Record<string, string>;
628
+ }
629
+ ): Promise<CommandResult> {
630
+ let output = '';
631
+ const safeEnv: Record<string, string> = {};
632
+ for (const [key, value] of Object.entries(process.env)) {
633
+ if (typeof value === 'string') {
634
+ safeEnv[key] = value;
635
+ }
636
+ }
637
+ if (options.env) {
638
+ Object.assign(safeEnv, options.env);
639
+ }
640
+
641
+ const execOptions: exec.ExecOptions = {
642
+ cwd: options.cwd,
643
+ ignoreReturnCode: true,
644
+ env: safeEnv,
645
+ listeners: {
646
+ stdout: (data: Buffer) => {
647
+ output += data.toString();
648
+ },
649
+ stderr: (data: Buffer) => {
650
+ output += data.toString();
651
+ },
652
+ },
653
+ };
654
+
655
+ try {
656
+ const exitCode = await exec.exec(cmd, args, execOptions);
657
+ return { exitCode, output };
658
+ } catch (error) {
659
+ const message = error instanceof Error ? error.message : String(error);
660
+ return {
661
+ exitCode: 127,
662
+ output: `${output}${message ? `${message}\n` : ''}`,
663
+ };
664
+ }
665
+ }
666
+
667
+ async function maybeCommitAndPushRemediation(input: {
668
+ cwd: string;
669
+ pr: any;
670
+ baselineDirty: boolean;
671
+ enabled: boolean;
672
+ pushEnabled: boolean;
673
+ pushRetries: number;
674
+ pushRetryDelaySeconds: number;
675
+ commitMessage: string;
676
+ gitUserName: string;
677
+ gitUserEmail: string;
678
+ }): Promise<{
679
+ committed: boolean;
680
+ pushed: boolean;
681
+ commitSha?: string;
682
+ message: string;
683
+ }> {
684
+ if (!input.enabled) {
685
+ return { committed: false, pushed: false, message: 'Remediation commit disabled' };
686
+ }
687
+
688
+ if (!canPushToPullRequestBranch(input.pr)) {
689
+ return {
690
+ committed: false,
691
+ pushed: false,
692
+ message: 'Skipped remediation commit (not a same-repo pull request branch)',
693
+ };
694
+ }
695
+
696
+ const statusBefore = await runCommand('git', ['status', '--porcelain'], { cwd: input.cwd });
697
+ if (statusBefore.exitCode !== 0) {
698
+ return {
699
+ committed: false,
700
+ pushed: false,
701
+ message: 'Skipped remediation commit (failed to inspect git status)',
702
+ };
703
+ }
704
+ if (!hasWorkingTreeChanges(statusBefore.output)) {
705
+ return {
706
+ committed: false,
707
+ pushed: false,
708
+ message: 'No remediation changes to commit',
709
+ };
710
+ }
711
+ if (input.baselineDirty) {
712
+ return {
713
+ committed: false,
714
+ pushed: false,
715
+ message:
716
+ `Skipped remediation commit because working tree was already dirty before remediation ` +
717
+ `(${countWorkingTreeChanges(statusBefore.output)} path(s) currently changed).`,
718
+ };
719
+ }
720
+
721
+ await runCommand('git', ['config', 'user.name', input.gitUserName], { cwd: input.cwd });
722
+ await runCommand('git', ['config', 'user.email', input.gitUserEmail], { cwd: input.cwd });
723
+
724
+ const addResult = await runCommand('git', ['add', '-A'], { cwd: input.cwd });
725
+ if (addResult.exitCode !== 0) {
726
+ return {
727
+ committed: false,
728
+ pushed: false,
729
+ message: 'Failed to stage remediation changes',
730
+ };
731
+ }
732
+
733
+ const commitResult = await runCommand('git', ['commit', '-m', input.commitMessage], { cwd: input.cwd });
734
+ if (commitResult.exitCode !== 0) {
735
+ const statusAfter = await runCommand('git', ['status', '--porcelain'], { cwd: input.cwd });
736
+ if (!hasWorkingTreeChanges(statusAfter.output)) {
737
+ return {
738
+ committed: false,
739
+ pushed: false,
740
+ message: 'No commit created (nothing new to commit)',
741
+ };
742
+ }
743
+ return {
744
+ committed: false,
745
+ pushed: false,
746
+ message: 'Failed to create remediation commit',
747
+ };
748
+ }
749
+
750
+ const shaResult = await runCommand('git', ['rev-parse', 'HEAD'], { cwd: input.cwd });
751
+ const commitSha = shaResult.exitCode === 0 ? stripAnsi(shaResult.output).trim() : undefined;
752
+
753
+ if (!input.pushEnabled) {
754
+ return {
755
+ committed: true,
756
+ pushed: false,
757
+ commitSha,
758
+ message: 'Remediation commit created locally (push disabled)',
759
+ };
760
+ }
761
+
762
+ const targetRef = input.pr.head.ref;
763
+ const maxAttempts = Math.max(1, input.pushRetries + 1);
764
+ let pushOutput = '';
765
+ let rebaseFailed = false;
766
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
767
+ const pushResult = await runCommand('git', ['push', 'origin', `HEAD:${targetRef}`], { cwd: input.cwd });
768
+ pushOutput = pushResult.output;
769
+ if (pushResult.exitCode === 0) {
770
+ return {
771
+ committed: true,
772
+ pushed: true,
773
+ commitSha,
774
+ message: `Remediation commit pushed to ${targetRef}`,
775
+ };
776
+ }
777
+
778
+ const canRetry = isNonFastForwardPushFailure(pushResult.output) && attempt < maxAttempts;
779
+ if (!canRetry) {
780
+ break;
781
+ }
782
+
783
+ core.warning(
784
+ `Remediation push hit non-fast-forward (attempt ${attempt}/${maxAttempts}). Rebasing onto origin/${targetRef} and retrying...`
785
+ );
786
+ const fetchResult = await runCommand('git', ['fetch', 'origin', targetRef], { cwd: input.cwd });
787
+ if (fetchResult.exitCode !== 0) {
788
+ pushOutput += `\n${fetchResult.output}`;
789
+ break;
790
+ }
791
+
792
+ const rebaseResult = await runCommand('git', ['rebase', `origin/${targetRef}`], { cwd: input.cwd });
793
+ if (rebaseResult.exitCode !== 0) {
794
+ rebaseFailed = true;
795
+ pushOutput += `\n${rebaseResult.output}`;
796
+ await runCommand('git', ['rebase', '--abort'], { cwd: input.cwd });
797
+ break;
798
+ }
799
+
800
+ if (input.pushRetryDelaySeconds > 0) {
801
+ await sleep(input.pushRetryDelaySeconds * 1000);
802
+ }
803
+ }
804
+
805
+ const reason = rebaseFailed
806
+ ? 'rebase conflict while retrying non-fast-forward push'
807
+ : isNonFastForwardPushFailure(pushOutput)
808
+ ? `non-fast-forward after ${maxAttempts} attempt(s)`
809
+ : 'push failed';
810
+ const detail = stripAnsi(pushOutput).trim().split('\n').slice(-3).join(' | ');
811
+ return {
812
+ committed: true,
813
+ pushed: false,
814
+ commitSha,
815
+ message: detail
816
+ ? `Remediation commit created, but ${reason}: ${detail}`
817
+ : `Remediation commit created, but ${reason}`,
818
+ };
819
+ }
820
+
821
+ async function ensureCliInstalled(input: {
822
+ version: string;
823
+ cwd: string;
824
+ source: CliInstallSource;
825
+ workspacePath: string;
826
+ }): Promise<CliInvocation> {
827
+ const existing = await resolveCliInvocation(input.cwd);
828
+ if (existing) {
829
+ core.info(`Using existing neurcode CLI (${existing.description})`);
830
+ return existing;
831
+ }
832
+
833
+ let install: CommandResult;
834
+ if (input.source === 'workspace') {
835
+ const workspaceCliPath = resolve(process.cwd(), input.workspacePath || 'packages/cli');
836
+ if (!existsSync(join(workspaceCliPath, 'package.json'))) {
837
+ throw new Error(
838
+ `Workspace CLI source selected, but package.json not found at: ${workspaceCliPath}`
839
+ );
840
+ }
841
+ core.info(`Installing workspace CLI from ${workspaceCliPath}...`);
842
+ install = await runCommand('npm', ['install', '-g', workspaceCliPath], { cwd: input.cwd });
843
+ } else {
844
+ core.info(`Installing @neurcode-ai/cli@${input.version}...`);
845
+ install = await runCommand('npm', ['install', '-g', `@neurcode-ai/cli@${input.version}`], { cwd: input.cwd });
846
+ }
847
+ if (install.exitCode !== 0) {
848
+ throw new Error(
849
+ input.source === 'workspace'
850
+ ? 'Failed to install workspace Neurcode CLI'
851
+ : `Failed to install @neurcode-ai/cli@${input.version}`
852
+ );
853
+ }
854
+
855
+ let installed = await resolveCliInvocation(input.cwd);
856
+ if (installed) {
857
+ core.info(`Installed neurcode CLI (${installed.description})`);
858
+ return installed;
859
+ }
860
+
861
+ if (input.source === 'workspace') {
862
+ core.warning(
863
+ 'Workspace CLI install succeeded but executable was not discoverable. Falling back to npm registry install.'
864
+ );
865
+ const fallbackInstall = await runCommand('npm', ['install', '-g', `@neurcode-ai/cli@${input.version}`], {
866
+ cwd: input.cwd,
867
+ });
868
+ if (fallbackInstall.exitCode !== 0) {
869
+ throw new Error(
870
+ `Failed to install fallback @neurcode-ai/cli@${input.version}: ${stripAnsi(fallbackInstall.output).trim()}`
871
+ );
872
+ }
873
+ installed = await resolveCliInvocation(input.cwd);
874
+ if (installed) {
875
+ core.info(`Installed fallback neurcode CLI (${installed.description})`);
876
+ return installed;
877
+ }
878
+ }
879
+
880
+ const sourceLabel = input.source === 'workspace' ? 'workspace + npm fallback' : 'npm';
881
+ throw new Error(`Neurcode CLI is not available after install flow (${sourceLabel}).`);
882
+ }
883
+
884
+ function withCliCommandTimeout(
885
+ cli: CliInvocation,
886
+ args: string[],
887
+ timeoutMinutes: number
888
+ ): {
889
+ cmd: string;
890
+ args: string[];
891
+ timeoutApplied: boolean;
892
+ } {
893
+ return withCommandTimeout(cli.cmd, [...cli.argsPrefix, ...args], timeoutMinutes);
894
+ }
895
+
896
+ async function resolveCliInvocation(cwd: string): Promise<CliInvocation | null> {
897
+ const direct = await runCommand('neurcode', ['--version'], { cwd });
898
+ if (direct.exitCode === 0) {
899
+ return {
900
+ cmd: 'neurcode',
901
+ argsPrefix: [],
902
+ description: `neurcode (${stripAnsi(direct.output).trim()})`,
903
+ };
904
+ }
905
+
906
+ const prefixResult = await runCommand('npm', ['config', 'get', 'prefix'], { cwd });
907
+ if (prefixResult.exitCode === 0) {
908
+ const npmPrefix = stripAnsi(prefixResult.output).trim();
909
+ if (npmPrefix) {
910
+ const binDir = process.platform === 'win32' ? npmPrefix : resolve(npmPrefix, 'bin');
911
+ core.addPath(binDir);
912
+
913
+ const fromPath = await runCommand('neurcode', ['--version'], { cwd });
914
+ if (fromPath.exitCode === 0) {
915
+ return {
916
+ cmd: 'neurcode',
917
+ argsPrefix: [],
918
+ description: `neurcode via PATH (${stripAnsi(fromPath.output).trim()})`,
919
+ };
920
+ }
921
+
922
+ const binCandidates = process.platform === 'win32'
923
+ ? [join(npmPrefix, 'neurcode.cmd'), join(npmPrefix, 'neurcode')]
924
+ : [join(npmPrefix, 'bin', 'neurcode')];
925
+
926
+ for (const binPath of binCandidates) {
927
+ if (!existsSync(binPath)) continue;
928
+ const absoluteProbe = await runCommand(binPath, ['--version'], { cwd });
929
+ if (absoluteProbe.exitCode === 0) {
930
+ return {
931
+ cmd: binPath,
932
+ argsPrefix: [],
933
+ description: `${binPath} (${stripAnsi(absoluteProbe.output).trim()})`,
934
+ };
935
+ }
936
+ }
937
+ }
938
+ }
939
+
940
+ const globalRoot = await runCommand('npm', ['root', '-g'], { cwd });
941
+ if (globalRoot.exitCode === 0) {
942
+ const rootPath = stripAnsi(globalRoot.output).trim();
943
+ const distEntry = join(rootPath, '@neurcode-ai', 'cli', 'dist', 'index.js');
944
+ if (existsSync(distEntry)) {
945
+ const nodeProbe = await runCommand('node', [distEntry, '--version'], { cwd });
946
+ if (nodeProbe.exitCode === 0) {
947
+ return {
948
+ cmd: 'node',
949
+ argsPrefix: [distEntry],
950
+ description: `node ${distEntry} (${stripAnsi(nodeProbe.output).trim()})`,
951
+ };
952
+ }
953
+ }
954
+ }
955
+
956
+ return null;
957
+ }
958
+
959
+
960
+ function buildRemediationGoal(
961
+ explicitGoal: string,
962
+ verifyResult: NeurcodeVerifyResult | null,
963
+ prTitle?: string
964
+ ): string {
965
+ if (explicitGoal.trim()) {
966
+ return explicitGoal.trim();
967
+ }
968
+
969
+ const violations = verifyResult?.violations || [];
970
+ const topViolations = violations.slice(0, 5)
971
+ .map((v) => `${v.file} [${v.severity}] ${v.rule}${v.message ? `: ${v.message}` : ''}`)
972
+ .join('; ');
973
+
974
+ const titlePart = prTitle ? ` for PR: ${prTitle}` : '';
975
+ if (topViolations) {
976
+ return `Apply minimal safe fixes${titlePart} so neurcode verify passes. Focus on: ${topViolations}`;
977
+ }
978
+
979
+ return `Apply minimal safe fixes${titlePart} so neurcode verify passes and merge confidence is READY_TO_MERGE.`;
980
+ }
981
+
982
+ function buildShipArgs(input: {
983
+ goal: string;
984
+ projectId?: string;
985
+ maxAttempts: number;
986
+ skipTests: boolean;
987
+ allowDirty: boolean;
988
+ shipTestCommand?: string;
989
+ record: boolean;
990
+ publishMergeCard: boolean;
991
+ }): string[] {
992
+ const args = [
993
+ 'ship',
994
+ input.goal,
995
+ '--json',
996
+ '--max-fix-attempts',
997
+ String(input.maxAttempts),
998
+ ];
999
+
1000
+ if (input.projectId) args.push('--project-id', input.projectId);
1001
+ if (input.skipTests) args.push('--skip-tests');
1002
+ if (input.allowDirty) args.push('--allow-dirty');
1003
+ if (input.shipTestCommand && input.shipTestCommand.trim()) {
1004
+ args.push('--test-command', input.shipTestCommand.trim());
1005
+ }
1006
+ if (!input.record) args.push('--no-record');
1007
+ if (!input.publishMergeCard) args.push('--no-publish-card');
1008
+
1009
+ return args;
1010
+ }
1011
+
1012
+ function generateMarkdown(
1013
+ verifyResult: NeurcodeVerifyResult | null,
1014
+ remediation: ShipSummaryResult | null,
1015
+ remediationCommit?: {
1016
+ committed: boolean;
1017
+ pushed: boolean;
1018
+ commitSha?: string;
1019
+ message: string;
1020
+ } | null
1021
+ ): string {
1022
+ if (!verifyResult) {
1023
+ return [
1024
+ '### ⚠️ Neurcode Gatekeeper',
1025
+ '',
1026
+ 'Neurcode ran, but JSON output could not be parsed reliably.',
1027
+ 'Check workflow logs for full details.',
1028
+ ].join('\n');
1029
+ }
1030
+
1031
+ const isPass = verifyResult.verdict === 'PASS' || (
1032
+ verifyResult.verdict === 'INFO' && !isTierLimitedInfoVerifyResult(verifyResult)
1033
+ );
1034
+ const icon = isPass ? '✅' : '❌';
1035
+
1036
+ const lines: string[] = [
1037
+ `### ${icon} Neurcode Gatekeeper: ${verifyResult.verdict}`,
1038
+ '',
1039
+ `**Score:** \`${verifyResult.score}/100\` | **Grade:** \`${verifyResult.grade}\``,
1040
+ ];
1041
+
1042
+ if (verifyResult.message) {
1043
+ lines.push(`> ${verifyResult.message}`);
1044
+ }
1045
+ if (verifyResult.tier) {
1046
+ lines.push(`- Verification tier: \`${verifyResult.tier}\``);
1047
+ }
1048
+ if (isTierLimitedInfoVerifyResult(verifyResult)) {
1049
+ lines.push('- ⚠️ Tier-limited verification: governance policy checks were not fully evaluated.');
1050
+ }
1051
+ if (verifyResult.policyCompilation?.fingerprint) {
1052
+ lines.push(
1053
+ `- Policy compilation: \`${verifyResult.policyCompilation.fingerprint.slice(0, 12)}\` (${verifyResult.policyCompilation.deterministicRuleCount || 0} deterministic rules)`
1054
+ );
1055
+ }
1056
+ if (verifyResult.changeContract?.exists) {
1057
+ const contractState =
1058
+ verifyResult.changeContract.valid === true
1059
+ ? 'valid'
1060
+ : verifyResult.changeContract.valid === false
1061
+ ? 'drift_detected'
1062
+ : 'not_evaluated';
1063
+ lines.push(`- Change contract: **${contractState}**${verifyResult.changeContract.enforced ? ' (enforced)' : ''}`);
1064
+ if ((verifyResult.changeContract.violations || []).length > 0) {
1065
+ lines.push(`- Change contract violations: ${(verifyResult.changeContract.violations || []).length}`);
1066
+ }
1067
+ }
1068
+
1069
+ if (verifyResult.scopeGuardPassed === false) {
1070
+ lines.push('', '**⚠️ Scope Violation:** Changes detected outside the approved plan.');
1071
+ }
1072
+
1073
+ if (verifyResult.violations.length > 0) {
1074
+ lines.push('', '### 🚨 Policy Violations', '', '| Severity | File | Message |', '| :--- | :--- | :--- |');
1075
+ for (const v of verifyResult.violations.slice(0, 15)) {
1076
+ const sevIcon = v.severity === 'block' ? '🔴' : '🟡';
1077
+ lines.push(`| ${sevIcon} **${v.severity}** | \`${v.file}\` | ${v.message || v.rule} |`);
1078
+ }
1079
+ if (verifyResult.violations.length > 15) {
1080
+ lines.push(`| … | … | ${verifyResult.violations.length - 15} more violation(s) omitted |`);
1081
+ }
1082
+ }
1083
+
1084
+ if (remediation) {
1085
+ const remIcon = remediation.status === 'READY_TO_MERGE' ? '🛠️✅' : '🛠️⚠️';
1086
+ lines.push('', `### ${remIcon} Auto-Remediation`);
1087
+ lines.push(`- Result: **${remediation.status}**`);
1088
+ lines.push(`- Final plan: \`${remediation.finalPlanId}\``);
1089
+ lines.push(`- Merge confidence: **${remediation.mergeConfidence}/100**`);
1090
+ lines.push(`- Risk score: **${remediation.riskScore}/100**`);
1091
+ lines.push(`- Verification after remediation: **${remediation.verification.verdict}** (${remediation.verification.grade}, score ${remediation.verification.score})`);
1092
+ lines.push(`- Tests: **${remediation.tests.passed ? 'PASS' : 'FAIL'}**${remediation.tests.skipped ? ' (skipped)' : ''}`);
1093
+
1094
+ if (remediation.shareCard?.shareUrl) {
1095
+ lines.push(`- Share card: [Open merge confidence card](${remediation.shareCard.shareUrl})`);
1096
+ }
1097
+ }
1098
+
1099
+ if (remediationCommit) {
1100
+ lines.push('', '### 🧾 Remediation Commit');
1101
+ lines.push(`- ${remediationCommit.message}`);
1102
+ if (remediationCommit.commitSha) {
1103
+ lines.push(`- Commit: \`${remediationCommit.commitSha}\``);
1104
+ }
1105
+ }
1106
+
1107
+ lines.push('', '---', '[View Full Report in Dashboard](https://dashboard.neurcode.com)');
1108
+ return lines.join('\n');
1109
+ }
1110
+
1111
+ async function postComment(
1112
+ token: string,
1113
+ prNumber: number,
1114
+ verifyResult: NeurcodeVerifyResult | null,
1115
+ remediation: ShipSummaryResult | null,
1116
+ remediationCommit?: {
1117
+ committed: boolean;
1118
+ pushed: boolean;
1119
+ commitSha?: string;
1120
+ message: string;
1121
+ } | null
1122
+ ): Promise<void> {
1123
+ const octokit = github.getOctokit(token);
1124
+ const body = generateMarkdown(verifyResult, remediation, remediationCommit);
1125
+
1126
+ const { data: comments } = await octokit.rest.issues.listComments({
1127
+ ...github.context.repo,
1128
+ issue_number: prNumber,
1129
+ });
1130
+
1131
+ const botComment = comments.find((comment) => comment.body?.includes('Neurcode Gatekeeper'));
1132
+
1133
+ if (botComment) {
1134
+ await octokit.rest.issues.updateComment({
1135
+ ...github.context.repo,
1136
+ comment_id: botComment.id,
1137
+ body,
1138
+ });
1139
+ } else {
1140
+ await octokit.rest.issues.createComment({
1141
+ ...github.context.repo,
1142
+ issue_number: prNumber,
1143
+ body,
1144
+ });
1145
+ }
1146
+ }
1147
+
1148
+ async function run(): Promise<void> {
1149
+ try {
1150
+ const thresholdInput = core.getInput('threshold');
1151
+ const parsedThreshold = normalizeGrade(thresholdInput);
1152
+ const threshold = parsedThreshold || 'C';
1153
+ if (thresholdInput && !parsedThreshold) {
1154
+ core.warning(`Invalid threshold "${thresholdInput}" provided; defaulting to "C".`);
1155
+ }
1156
+ const apiKey = core.getInput('api_key') || core.getInput('api-key') || process.env.NEURCODE_API_KEY;
1157
+ const githubToken = core.getInput('github_token') || process.env.GITHUB_TOKEN;
1158
+ const failOnViolation = parseBoolean(core.getInput('fail_on_violation'), true);
1159
+ const planId = core.getInput('plan_id') || core.getInput('plan-id');
1160
+ const projectId = core.getInput('project_id') || core.getInput('project-id');
1161
+ const orgId = core.getInput('org_id') || core.getInput('org-id') || process.env.NEURCODE_ORG_ID || '';
1162
+ const baseRefInput = core.getInput('base_ref') || '';
1163
+ const workingDirectory = core.getInput('working_directory') || '.';
1164
+ const record = parseBoolean(core.getInput('record'), true);
1165
+ const cliVersion = core.getInput('neurcode_cli_version') || 'latest';
1166
+ const cliInstallSource = parseCliInstallSource(core.getInput('neurcode_cli_source'));
1167
+ const cliWorkspacePath = core.getInput('neurcode_cli_workspace_path') || 'packages/cli';
1168
+ const verifyTimeoutMinutes = parsePositiveInt(core.getInput('verify_timeout_minutes'), 8, 1, 120);
1169
+ const verifyPolicyOnly = parseBoolean(core.getInput('verify_policy_only'), false);
1170
+ const compiledPolicyPath = (core.getInput('compiled_policy_path') || 'neurcode.policy.compiled.json').trim();
1171
+ const changeContractPath = (core.getInput('change_contract_path') || '.neurcode/change-contract.json').trim();
1172
+ const enforceChangeContract = parseBoolean(core.getInput('enforce_change_contract'), false);
1173
+ const changedFilesOnly = parseBoolean(core.getInput('changed_files_only'), false);
1174
+
1175
+ const autoRemediate = parseBoolean(core.getInput('auto_remediate'), false);
1176
+ const remediationGoalInput = core.getInput('remediation_goal') || '';
1177
+ const remediationMaxAttempts = parsePositiveInt(core.getInput('remediation_max_attempts'), 2, 1, 5);
1178
+ const remediationSkipTests = parseBoolean(core.getInput('remediation_skip_tests'), true);
1179
+ const remediationAllowDirty = parseBoolean(core.getInput('remediation_allow_dirty'), true);
1180
+ const publishMergeCard = parseBoolean(core.getInput('publish_merge_card'), true);
1181
+ const shipTestCommand = core.getInput('ship_test_command') || '';
1182
+ const shipTimeoutMinutes = parsePositiveInt(core.getInput('ship_timeout_minutes'), 20, 1, 180);
1183
+ const shipNetworkRetries = parsePositiveInt(core.getInput('ship_network_retries'), 1, 0, 3);
1184
+ const shipNetworkRetryDelaySeconds = parsePositiveInt(
1185
+ core.getInput('ship_network_retry_delay_seconds'),
1186
+ 5,
1187
+ 0,
1188
+ 30
1189
+ );
1190
+ const remediationCommit = parseBoolean(core.getInput('remediation_commit'), false);
1191
+ const remediationPush = parseBoolean(core.getInput('remediation_push'), false);
1192
+ const enforceStrictVerification = parseBoolean(core.getInput('enforce_strict_verification'), false);
1193
+ const verifyAfterRemediation = parseBoolean(core.getInput('verify_after_remediation'), true);
1194
+ const verifyAfterRemediationTimeoutMinutes = parsePositiveInt(
1195
+ core.getInput('verify_after_remediation_timeout_minutes'),
1196
+ verifyTimeoutMinutes,
1197
+ 1,
1198
+ 120
1199
+ );
1200
+ const requireRemediationPushSuccess = parseBoolean(core.getInput('require_remediation_push_success'), false);
1201
+ const remediationPushRetries = parsePositiveInt(core.getInput('remediation_push_retries'), 2, 0, 5);
1202
+ const remediationPushRetryDelaySeconds = parsePositiveInt(
1203
+ core.getInput('remediation_push_retry_delay_seconds'),
1204
+ 2,
1205
+ 0,
1206
+ 30
1207
+ );
1208
+ const remediationCommitMessage =
1209
+ core.getInput('remediation_commit_message') || 'chore(neurcode): auto-remediate verify failures';
1210
+ const remediationGitUserName = core.getInput('remediation_git_user_name') || 'neurcode-bot';
1211
+ const remediationGitUserEmail =
1212
+ core.getInput('remediation_git_user_email') || 'neurcode-bot@users.noreply.github.com';
1213
+
1214
+ const cwd = resolve(process.cwd(), workingDirectory);
1215
+
1216
+ if (remediationPush && !remediationCommit) {
1217
+ throw new Error('Invalid action configuration: remediation_push=true requires remediation_commit=true.');
1218
+ }
1219
+ if (requireRemediationPushSuccess && !remediationPush) {
1220
+ throw new Error(
1221
+ 'Invalid action configuration: require_remediation_push_success=true requires remediation_push=true.'
1222
+ );
1223
+ }
1224
+
1225
+ if (!apiKey) {
1226
+ core.warning('No Neurcode API key provided. Neurcode may fail if auth is required.');
1227
+ } else {
1228
+ seedCiAuthFile(cwd, apiKey, orgId || undefined);
1229
+ }
1230
+
1231
+ const cliInvocation = await ensureCliInstalled({
1232
+ version: cliVersion,
1233
+ cwd,
1234
+ source: cliInstallSource,
1235
+ workspacePath: cliWorkspacePath,
1236
+ });
1237
+
1238
+ const pr = github.context.payload.pull_request;
1239
+ const defaultBaseRef = pr ? `origin/${pr.base.ref}` : 'HEAD~1';
1240
+ const baseRef = baseRefInput.trim() || defaultBaseRef;
1241
+ core.info(pr
1242
+ ? `Running verify in PR context against ${baseRef}`
1243
+ : `Running verify in push context against ${baseRef}`);
1244
+ core.info(`Verification mode: ${verifyPolicyOnly ? 'policy-only' : 'plan-aware'}`);
1245
+
1246
+ let changedFiles = new Set<string>();
1247
+ if (changedFilesOnly) {
1248
+ const changedFilesRun = await runCommand('git', ['diff', '--name-only', `${baseRef}...HEAD`], { cwd });
1249
+ if (changedFilesRun.exitCode === 0) {
1250
+ changedFiles = parseChangedFiles(changedFilesRun.output);
1251
+ core.info(`Changed-files-only mode: detected ${changedFiles.size} changed file(s).`);
1252
+ } else {
1253
+ core.warning('Changed-files-only mode enabled, but changed files could not be resolved. Falling back to full set.');
1254
+ }
1255
+ }
1256
+
1257
+ let effectiveVerifyPolicyOnly = verifyPolicyOnly;
1258
+ const hasExplicitPlanId = Boolean(planId && planId.trim());
1259
+ let policyOnlyFallbackUsed = false;
1260
+ let fallbackFailureHint: string | null = null;
1261
+ let verifyArgs = buildVerifyArgs({
1262
+ baseRef,
1263
+ planId: planId || undefined,
1264
+ projectId: projectId || undefined,
1265
+ policyOnly: effectiveVerifyPolicyOnly,
1266
+ record,
1267
+ compiledPolicyPath: compiledPolicyPath || undefined,
1268
+ changeContractPath: changeContractPath || undefined,
1269
+ enforceChangeContract,
1270
+ });
1271
+
1272
+ let verifyCommand = withCliCommandTimeout(cliInvocation, verifyArgs, verifyTimeoutMinutes);
1273
+ let verifyRun = await runCommand(verifyCommand.cmd, verifyCommand.args, {
1274
+ cwd,
1275
+ env: apiKey ? { NEURCODE_API_KEY: apiKey } : undefined,
1276
+ });
1277
+ if (verifyCommand.timeoutApplied && verifyRun.exitCode === 124) {
1278
+ core.setFailed(`Neurcode verify timed out after ${verifyTimeoutMinutes} minute(s).`);
1279
+ return;
1280
+ }
1281
+
1282
+ const fallbackDecision = getVerifyFallbackDecision({
1283
+ verifyExitCode: verifyRun.exitCode,
1284
+ policyOnlyRequested: effectiveVerifyPolicyOnly,
1285
+ hasExplicitPlanId,
1286
+ verifyOutput: verifyRun.output,
1287
+ });
1288
+
1289
+ // Safety fallback: if plan-aware verify fails only because plan context is missing,
1290
+ // rerun in policy-only mode to match expected enterprise fallback behavior.
1291
+ if (fallbackDecision.shouldRetryPolicyOnly) {
1292
+ core.warning(
1293
+ 'Plan-aware verification failed due to missing plan context. Retrying with --policy-only fallback.'
1294
+ );
1295
+ policyOnlyFallbackUsed = true;
1296
+ effectiveVerifyPolicyOnly = true;
1297
+ verifyArgs = buildVerifyArgs({
1298
+ baseRef,
1299
+ projectId: projectId || undefined,
1300
+ planId: undefined,
1301
+ policyOnly: true,
1302
+ record,
1303
+ compiledPolicyPath: compiledPolicyPath || undefined,
1304
+ changeContractPath: changeContractPath || undefined,
1305
+ enforceChangeContract,
1306
+ });
1307
+ verifyCommand = withCliCommandTimeout(cliInvocation, verifyArgs, verifyTimeoutMinutes);
1308
+ verifyRun = await runCommand(verifyCommand.cmd, verifyCommand.args, {
1309
+ cwd,
1310
+ env: apiKey ? { NEURCODE_API_KEY: apiKey } : undefined,
1311
+ });
1312
+ if (verifyCommand.timeoutApplied && verifyRun.exitCode === 124) {
1313
+ core.setFailed(`Neurcode verify timed out after ${verifyTimeoutMinutes} minute(s).`);
1314
+ return;
1315
+ }
1316
+ if (verifyRun.exitCode !== 0) {
1317
+ fallbackFailureHint =
1318
+ 'Plan context was unavailable, and policy-only fallback verification also failed. Review policy violations in the action logs.';
1319
+ }
1320
+ } else if (
1321
+ verifyRun.exitCode !== 0 &&
1322
+ fallbackDecision.reason === 'explicit_plan_id' &&
1323
+ isMissingPlanVerificationFailure(verifyRun.output)
1324
+ ) {
1325
+ fallbackFailureHint =
1326
+ 'Plan-aware verification failed because the provided plan_id could not be validated for this repository scope.';
1327
+ }
1328
+
1329
+ let remediation: ShipSummaryResult | null = null;
1330
+ let remediationCommitResult: {
1331
+ committed: boolean;
1332
+ pushed: boolean;
1333
+ commitSha?: string;
1334
+ message: string;
1335
+ } | null = null;
1336
+ let baselineDirty = false;
1337
+ let finalExitCode = verifyRun.exitCode;
1338
+ let failureReason: string | null = fallbackFailureHint;
1339
+ let actionableViolations: Violation[] = [];
1340
+ let actionableBlockViolations: Violation[] = [];
1341
+
1342
+ let verifyResult = parseVerifyResult(verifyRun.output);
1343
+ if (!verifyResult) {
1344
+ core.warning('Unable to parse JSON result from neurcode verify output.');
1345
+ }
1346
+ if (changedFilesOnly && verifyResult && changedFiles.size > 0) {
1347
+ actionableViolations = collectActionableViolations(verifyResult.violations, changedFiles);
1348
+ actionableBlockViolations = actionableViolations.filter(
1349
+ (violation) => violation.severity.toLowerCase() === 'block'
1350
+ );
1351
+ core.info(
1352
+ `Actionable violations in changed files: ${actionableViolations.length} total, ` +
1353
+ `${actionableBlockViolations.length} block.`
1354
+ );
1355
+ if (finalExitCode !== 0 && actionableBlockViolations.length === 0) {
1356
+ core.info(
1357
+ 'No BLOCK violations found in changed files; treating baseline-only violations as non-blocking.'
1358
+ );
1359
+ finalExitCode = 0;
1360
+ failureReason = null;
1361
+ }
1362
+ }
1363
+ if (enforceStrictVerification && isTierLimitedInfoVerifyResult(verifyResult)) {
1364
+ core.warning('Strict verification is enabled: tier-limited INFO verify result is treated as failure.');
1365
+ finalExitCode = verifyRun.exitCode === 0 ? 2 : verifyRun.exitCode;
1366
+ failureReason = 'Strict verification requires full governance evaluation; received tier-limited INFO result.';
1367
+ }
1368
+
1369
+ if (finalExitCode !== 0 && autoRemediate) {
1370
+ const baselineStatus = await runCommand('git', ['status', '--porcelain'], { cwd });
1371
+ if (baselineStatus.exitCode === 0) {
1372
+ baselineDirty = hasWorkingTreeChanges(baselineStatus.output);
1373
+ if (baselineDirty) {
1374
+ core.warning(
1375
+ `Working tree has ${countWorkingTreeChanges(baselineStatus.output)} pre-existing change(s) before remediation; ` +
1376
+ 'remediation commit push will be skipped for safety.'
1377
+ );
1378
+ }
1379
+ } else {
1380
+ core.warning('Unable to inspect baseline git status before remediation.');
1381
+ }
1382
+
1383
+ const remediationGoal = buildRemediationGoal(
1384
+ remediationGoalInput,
1385
+ verifyResult,
1386
+ pr?.title
1387
+ );
1388
+
1389
+ core.info(`Auto-remediation enabled. Running neurcode ship with goal: ${remediationGoal}`);
1390
+
1391
+ const shipArgs = buildShipArgs({
1392
+ goal: remediationGoal,
1393
+ projectId: projectId || undefined,
1394
+ maxAttempts: remediationMaxAttempts,
1395
+ skipTests: remediationSkipTests,
1396
+ allowDirty: remediationAllowDirty,
1397
+ shipTestCommand,
1398
+ record,
1399
+ publishMergeCard,
1400
+ });
1401
+
1402
+ const shipCommand = withCliCommandTimeout(cliInvocation, shipArgs, shipTimeoutMinutes);
1403
+ const maxShipAttempts = Math.max(1, shipNetworkRetries + 1);
1404
+ let shipRun: CommandResult = { exitCode: 1, output: '' };
1405
+ for (let attempt = 1; attempt <= maxShipAttempts; attempt += 1) {
1406
+ shipRun = await runCommand(shipCommand.cmd, shipCommand.args, {
1407
+ cwd,
1408
+ env: apiKey ? { NEURCODE_API_KEY: apiKey } : undefined,
1409
+ });
1410
+ if (shipCommand.timeoutApplied && shipRun.exitCode === 124) {
1411
+ core.warning(`Auto-remediation timed out after ${shipTimeoutMinutes} minute(s).`);
1412
+ }
1413
+ if (shipRun.exitCode === 0) {
1414
+ break;
1415
+ }
1416
+
1417
+ const canRetry =
1418
+ attempt < maxShipAttempts &&
1419
+ shipRun.exitCode !== 124 &&
1420
+ isLikelyTransientNetworkFailure(shipRun.output);
1421
+ if (!canRetry) {
1422
+ break;
1423
+ }
1424
+
1425
+ core.warning(
1426
+ `Auto-remediation failed with a transient network error (attempt ${attempt}/${maxShipAttempts}); retrying...`
1427
+ );
1428
+ if (shipNetworkRetryDelaySeconds > 0) {
1429
+ await sleep(shipNetworkRetryDelaySeconds * 1000);
1430
+ }
1431
+ }
1432
+
1433
+ remediation = parseShipSummary(shipRun.output);
1434
+ if (!remediation) {
1435
+ core.warning('Auto-remediation finished but ship JSON output could not be parsed.');
1436
+ } else {
1437
+ core.info(`Auto-remediation result: ${remediation.status} (confidence=${remediation.mergeConfidence})`);
1438
+ }
1439
+
1440
+ if (remediation?.status === 'READY_TO_MERGE') {
1441
+ finalExitCode = 0;
1442
+ } else {
1443
+ finalExitCode = shipRun.exitCode;
1444
+ }
1445
+
1446
+ if (remediation?.status === 'READY_TO_MERGE') {
1447
+ if (verifyAfterRemediation) {
1448
+ const postVerifyCommand = withCliCommandTimeout(
1449
+ cliInvocation,
1450
+ verifyArgs,
1451
+ verifyAfterRemediationTimeoutMinutes
1452
+ );
1453
+ const postVerifyRun = await runCommand(postVerifyCommand.cmd, postVerifyCommand.args, {
1454
+ cwd,
1455
+ env: apiKey ? { NEURCODE_API_KEY: apiKey } : undefined,
1456
+ });
1457
+ if (postVerifyCommand.timeoutApplied && postVerifyRun.exitCode === 124) {
1458
+ core.warning(
1459
+ `Post-remediation verify timed out after ${verifyAfterRemediationTimeoutMinutes} minute(s).`
1460
+ );
1461
+ finalExitCode = 124;
1462
+ } else {
1463
+ finalExitCode = postVerifyRun.exitCode;
1464
+ }
1465
+ const postVerifyResult = parseVerifyResult(postVerifyRun.output);
1466
+ if (!postVerifyResult) {
1467
+ core.warning('Post-remediation verify JSON output could not be parsed.');
1468
+ if (enforceStrictVerification) {
1469
+ finalExitCode = finalExitCode === 0 ? 2 : finalExitCode;
1470
+ failureReason = 'Post-remediation verify output was not parseable under strict verification mode.';
1471
+ }
1472
+ } else {
1473
+ verifyResult = postVerifyResult;
1474
+ }
1475
+ if (enforceStrictVerification && isTierLimitedInfoVerifyResult(verifyResult)) {
1476
+ core.warning(
1477
+ 'Strict verification is enabled: post-remediation verify is tier-limited INFO and is treated as failure.'
1478
+ );
1479
+ finalExitCode = finalExitCode === 0 ? 2 : finalExitCode;
1480
+ failureReason =
1481
+ 'Strict verification requires full governance evaluation; post-remediation verify is tier-limited INFO.';
1482
+ }
1483
+ }
1484
+
1485
+ const remediationVerificationPassed = finalExitCode === 0;
1486
+ if (remediationCommit && remediationVerificationPassed) {
1487
+ remediationCommitResult = await maybeCommitAndPushRemediation({
1488
+ cwd,
1489
+ pr,
1490
+ baselineDirty,
1491
+ enabled: remediationCommit,
1492
+ pushEnabled: remediationPush,
1493
+ pushRetries: remediationPushRetries,
1494
+ pushRetryDelaySeconds: remediationPushRetryDelaySeconds,
1495
+ commitMessage: remediationCommitMessage,
1496
+ gitUserName: remediationGitUserName,
1497
+ gitUserEmail: remediationGitUserEmail,
1498
+ });
1499
+ core.info(remediationCommitResult.message);
1500
+ if (requireRemediationPushSuccess && remediationPush && !remediationCommitResult.pushed) {
1501
+ core.warning('remediation_push is required to succeed, but remediation commit push did not complete.');
1502
+ finalExitCode = finalExitCode === 0 ? 3 : finalExitCode;
1503
+ failureReason = 'Auto-remediation produced changes but failed to push remediation commit to PR branch.';
1504
+ }
1505
+ } else if (remediationCommit && !remediationVerificationPassed) {
1506
+ remediationCommitResult = {
1507
+ committed: false,
1508
+ pushed: false,
1509
+ message: 'Skipped remediation commit because post-remediation verification did not pass.',
1510
+ };
1511
+ core.info(remediationCommitResult.message);
1512
+ }
1513
+ } else if (remediationCommit) {
1514
+ remediationCommitResult = {
1515
+ committed: false,
1516
+ pushed: false,
1517
+ message: 'Skipped remediation commit because remediation did not reach READY_TO_MERGE',
1518
+ };
1519
+ }
1520
+ }
1521
+
1522
+ if (githubToken && pr) {
1523
+ await postComment(githubToken, pr.number, verifyResult, remediation, remediationCommitResult);
1524
+ }
1525
+
1526
+ if (verifyResult) {
1527
+ core.setOutput('verdict', verifyResult.verdict);
1528
+ core.setOutput('grade', verifyResult.grade);
1529
+ core.setOutput('score', verifyResult.score);
1530
+ core.setOutput('violations', verifyResult.violations.length);
1531
+ if (verifyResult.verificationSource) {
1532
+ core.setOutput('verification_source', verifyResult.verificationSource);
1533
+ }
1534
+ if (verifyResult.tier) {
1535
+ core.setOutput('verification_tier', verifyResult.tier);
1536
+ }
1537
+ core.setOutput('tier_limited', isTierLimitedInfoVerifyResult(verifyResult) ? 'true' : 'false');
1538
+ if (verifyResult.blastRadius?.riskScore) {
1539
+ core.setOutput('risk_level', verifyResult.blastRadius.riskScore);
1540
+ }
1541
+ if (verifyResult.suspiciousChange) {
1542
+ core.setOutput('suspicious_change_flagged', verifyResult.suspiciousChange.flagged ? 'true' : 'false');
1543
+ core.setOutput(
1544
+ 'suspicious_change_confidence',
1545
+ verifyResult.suspiciousChange.confidence || 'unknown'
1546
+ );
1547
+ }
1548
+ if (verifyResult.governanceDecision?.decision) {
1549
+ core.setOutput('governance_decision', verifyResult.governanceDecision.decision);
1550
+ }
1551
+ if (typeof verifyResult.governanceDecision?.averageRelevanceScore === 'number') {
1552
+ core.setOutput(
1553
+ 'average_relevance_score',
1554
+ verifyResult.governanceDecision.averageRelevanceScore.toString()
1555
+ );
1556
+ }
1557
+ if (verifyResult.aiChangeLog?.integrity) {
1558
+ core.setOutput(
1559
+ 'ai_change_log_integrity_valid',
1560
+ verifyResult.aiChangeLog.integrity.valid === true ? 'true' : 'false'
1561
+ );
1562
+ core.setOutput(
1563
+ 'ai_change_log_signed',
1564
+ verifyResult.aiChangeLog.integrity.signed === true ? 'true' : 'false'
1565
+ );
1566
+ }
1567
+ if (verifyResult.orgGovernance) {
1568
+ core.setOutput(
1569
+ 'org_manual_approval_required',
1570
+ verifyResult.orgGovernance.requireManualApproval === true ? 'true' : 'false'
1571
+ );
1572
+ if (typeof verifyResult.orgGovernance.minimumManualApprovals === 'number') {
1573
+ core.setOutput(
1574
+ 'org_minimum_manual_approvals',
1575
+ verifyResult.orgGovernance.minimumManualApprovals.toString()
1576
+ );
1577
+ }
1578
+ }
1579
+ if (verifyResult.policyCompilation?.fingerprint) {
1580
+ core.setOutput('policy_compilation_fingerprint', verifyResult.policyCompilation.fingerprint);
1581
+ }
1582
+ if (typeof verifyResult.policyCompilation?.deterministicRuleCount === 'number') {
1583
+ core.setOutput(
1584
+ 'policy_compilation_rule_count',
1585
+ verifyResult.policyCompilation.deterministicRuleCount.toString()
1586
+ );
1587
+ }
1588
+ if (verifyResult.changeContract?.exists !== undefined) {
1589
+ core.setOutput(
1590
+ 'change_contract_exists',
1591
+ verifyResult.changeContract.exists === true ? 'true' : 'false'
1592
+ );
1593
+ }
1594
+ if (verifyResult.changeContract?.valid !== undefined && verifyResult.changeContract.valid !== null) {
1595
+ core.setOutput(
1596
+ 'change_contract_valid',
1597
+ verifyResult.changeContract.valid === true ? 'true' : 'false'
1598
+ );
1599
+ }
1600
+ if (verifyResult.changeContract?.enforced !== undefined) {
1601
+ core.setOutput(
1602
+ 'change_contract_enforced',
1603
+ verifyResult.changeContract.enforced === true ? 'true' : 'false'
1604
+ );
1605
+ }
1606
+ if (verifyResult.changeContract?.contractId) {
1607
+ core.setOutput('change_contract_id', verifyResult.changeContract.contractId);
1608
+ }
1609
+ const thresholdCheck = isGradeBelowThreshold(verifyResult.grade, threshold);
1610
+ core.setOutput('threshold', threshold);
1611
+ core.setOutput(
1612
+ 'threshold_passed',
1613
+ thresholdCheck === null ? 'unknown' : (thresholdCheck ? 'false' : 'true')
1614
+ );
1615
+ if (changedFilesOnly) {
1616
+ core.setOutput('changed_files_only', 'true');
1617
+ core.setOutput('changed_files_count', String(changedFiles.size));
1618
+ core.setOutput('actionable_violations', String(actionableViolations.length));
1619
+ core.setOutput('actionable_block_violations', String(actionableBlockViolations.length));
1620
+ }
1621
+ }
1622
+ const verifyModeOutput = effectiveVerifyPolicyOnly
1623
+ ? policyOnlyFallbackUsed
1624
+ ? 'policy_only_fallback'
1625
+ : 'policy_only'
1626
+ : hasExplicitPlanId
1627
+ ? 'plan_enforced_explicit'
1628
+ : 'plan_aware';
1629
+ core.setOutput('verify_mode', verifyModeOutput);
1630
+ core.setOutput('policy_only_fallback_used', policyOnlyFallbackUsed ? 'true' : 'false');
1631
+
1632
+ if (remediation) {
1633
+ core.setOutput('remediation_status', remediation.status);
1634
+ core.setOutput('merge_confidence', remediation.mergeConfidence);
1635
+ if (remediation.shareCard?.shareUrl) {
1636
+ core.setOutput('share_card_url', remediation.shareCard.shareUrl);
1637
+ }
1638
+ }
1639
+ if (remediationCommitResult) {
1640
+ core.setOutput('remediation_commit_created', remediationCommitResult.committed ? 'true' : 'false');
1641
+ core.setOutput('remediation_commit_pushed', remediationCommitResult.pushed ? 'true' : 'false');
1642
+ if (remediationCommitResult.commitSha) {
1643
+ core.setOutput('remediation_commit_sha', remediationCommitResult.commitSha);
1644
+ }
1645
+ }
1646
+
1647
+ if (verifyResult && failOnViolation && !changedFilesOnly) {
1648
+ const thresholdCheck = isGradeBelowThreshold(verifyResult.grade, threshold);
1649
+ if (thresholdCheck === true) {
1650
+ finalExitCode = finalExitCode === 0 ? 4 : finalExitCode;
1651
+ failureReason = failureReason || `Verification grade ${verifyResult.grade} is below threshold ${threshold}.`;
1652
+ } else if (thresholdCheck === null) {
1653
+ core.warning(
1654
+ `Verification grade "${verifyResult.grade}" is not comparable to threshold ${threshold}; ` +
1655
+ 'threshold check skipped.'
1656
+ );
1657
+ }
1658
+ }
1659
+
1660
+ if (finalExitCode !== 0 && failOnViolation) {
1661
+ if (failureReason) {
1662
+ core.setFailed(failureReason);
1663
+ } else {
1664
+ const verdict = verifyResult?.verdict || 'FAIL';
1665
+ core.setFailed(`Neurcode verification failed with verdict: ${verdict}`);
1666
+ }
1667
+ }
1668
+ } catch (error) {
1669
+ if (error instanceof Error) {
1670
+ core.setFailed(error.message);
1671
+ } else {
1672
+ core.setFailed('Unknown Neurcode Action failure');
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ run();