argusqa-os 9.5.5 → 9.6.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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * ARGUS Form Validation Analyzer (Sprint 5d — A11)
3
+ *
4
+ * Detects accessibility and security gaps in HTML forms — one of the most
5
+ * commonly broken areas in web apps.
6
+ *
7
+ * Findings emitted:
8
+ * form_missing_required — <input> inside a form with no required/aria-required
9
+ * form_no_autocomplete — personal data field (name/email/address/CC) missing autocomplete
10
+ * form_inaccessible_error — error element not linked via aria-describedby to its input
11
+ * form_unmasked_password — <input type="text"> with password-adjacent label
12
+ * form_no_validation — form with required fields and no novalidate + no JS validation
13
+ * form_summary — info, always emitted
14
+ */
15
+
16
+ import { registerExpensive } from '../registry.js';
17
+ import { unwrapEval } from './mcp-client.js';
18
+ import { childLogger } from './logger.js';
19
+
20
+ const logger = childLogger('form-analyzer');
21
+
22
+ const FORM_SCRIPT = `() => {
23
+ var result = {
24
+ missingRequired: [],
25
+ missingAutocomplete: [],
26
+ inaccessibleErrors: [],
27
+ unmaskedPasswords: [],
28
+ noValidationForms: [],
29
+ };
30
+
31
+ // Personal data fields that should have autocomplete (WCAG 1.3.5)
32
+ var PERSONAL_PATTERNS = /name|email|phone|tel|address|postcode|zip|city|country|credit|card|cc-|bday|birthday/i;
33
+ var AUTOCOMPLETE_TYPES = /name|email|tel|address|on|off|username|current-password|new-password/i;
34
+
35
+ var forms = Array.from(document.querySelectorAll('form'));
36
+
37
+ for (var fi = 0; fi < forms.length; fi++) {
38
+ var form = forms[fi];
39
+ var inputs = Array.from(form.querySelectorAll('input,select,textarea'));
40
+ var hasRequiredField = false;
41
+ var hasJsValidation = false;
42
+
43
+ // Check for JS validation: submit event listener heuristic
44
+ // We can't detect event listeners directly; check for novalidate + required combo
45
+ var hasNovalidate = form.hasAttribute('novalidate');
46
+
47
+ for (var ii = 0; ii < inputs.length; ii++) {
48
+ var inp = inputs[ii];
49
+ var type = (inp.type || 'text').toLowerCase();
50
+ if (type === 'hidden' || type === 'submit' || type === 'button' || type === 'reset') continue;
51
+
52
+ var name = (inp.name || inp.id || inp.getAttribute('aria-label') || '').toLowerCase();
53
+ var label = '';
54
+ if (inp.id) {
55
+ var lbl = document.querySelector('label[for="' + inp.id + '"]');
56
+ if (lbl) label = lbl.textContent.trim().toLowerCase();
57
+ }
58
+ var combined = name + ' ' + label;
59
+
60
+ // Missing required
61
+ var hasRequired = inp.required || inp.getAttribute('aria-required') === 'true';
62
+ var isContentField = type !== 'checkbox' && type !== 'radio';
63
+ if (!hasRequired && isContentField && combined.length > 0) {
64
+ result.missingRequired.push({
65
+ type: type,
66
+ name: name.slice(0, 60),
67
+ id: (inp.id || '').slice(0, 60),
68
+ });
69
+ }
70
+
71
+ if (inp.required) hasRequiredField = true;
72
+
73
+ // Missing autocomplete on personal data fields
74
+ var isPersonal = PERSONAL_PATTERNS.test(combined) && type !== 'checkbox' && type !== 'radio';
75
+ if (isPersonal && !inp.getAttribute('autocomplete')) {
76
+ result.missingAutocomplete.push({
77
+ type: type,
78
+ name: name.slice(0, 60),
79
+ id: (inp.id || '').slice(0, 60),
80
+ });
81
+ }
82
+
83
+ // Unmasked password: type=text with password-adjacent label
84
+ if (type === 'text' && /password|passwd|pwd/i.test(combined)) {
85
+ result.unmaskedPasswords.push({
86
+ name: name.slice(0, 60),
87
+ id: (inp.id || '').slice(0, 60),
88
+ });
89
+ }
90
+ }
91
+
92
+ // No validation: required fields but no novalidate and no submit handler evidence
93
+ if (hasRequiredField && !hasNovalidate) {
94
+ // Check for pattern attributes or min/max as proxy for validation intent
95
+ var hasAttrValidation = Array.from(inputs).some(function(i) {
96
+ return i.pattern || i.minLength > 0 || i.type === 'email' || i.type === 'url';
97
+ });
98
+ if (!hasAttrValidation) {
99
+ result.noValidationForms.push({
100
+ action: (form.action || '').slice(0, 100),
101
+ id: (form.id || '').slice(0, 60),
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ // Inaccessible error messages: elements with error-like classes/roles not linked to input
108
+ var errorEls = Array.from(document.querySelectorAll(
109
+ '[role="alert"],[aria-live="assertive"],[aria-live="polite"],.error,.field-error,.form-error,.validation-error,.invalid-feedback'
110
+ ));
111
+ for (var ei = 0; ei < errorEls.length; ei++) {
112
+ var el = errorEls[ei];
113
+ var eid = el.id;
114
+ if (!eid) {
115
+ result.inaccessibleErrors.push({
116
+ tag: el.tagName.toLowerCase(),
117
+ text: el.textContent.trim().slice(0, 80),
118
+ issue: 'no id — cannot be referenced by aria-describedby',
119
+ });
120
+ continue;
121
+ }
122
+ // Check if any input references this error via aria-describedby / aria-errormessage
123
+ var linked = document.querySelector(
124
+ '[aria-describedby~="' + eid + '"],[aria-errormessage="' + eid + '"]'
125
+ );
126
+ if (!linked) {
127
+ result.inaccessibleErrors.push({
128
+ tag: el.tagName.toLowerCase(),
129
+ id: eid,
130
+ text: el.textContent.trim().slice(0, 80),
131
+ issue: 'has id but no input links to it via aria-describedby/aria-errormessage',
132
+ });
133
+ }
134
+ }
135
+
136
+ return JSON.stringify(result);
137
+ }`;
138
+
139
+ export async function analyzeForm(browser, url) {
140
+ const findings = [];
141
+
142
+ try {
143
+ await browser.navigate(url);
144
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
145
+ await new Promise(r => setTimeout(r, 400));
146
+ } catch {
147
+ return findings;
148
+ }
149
+
150
+ let data = null;
151
+ try {
152
+ const raw = await browser.evaluate(FORM_SCRIPT);
153
+ const s = unwrapEval(raw);
154
+ data = typeof s === 'object' ? s : JSON.parse(s);
155
+ } catch (err) {
156
+ logger.warn(`[ARGUS] form-analyzer: failed for ${url}: ${err.message}`);
157
+ data = { missingRequired: [], missingAutocomplete: [], inaccessibleErrors: [], unmaskedPasswords: [], noValidationForms: [] };
158
+ }
159
+
160
+ const missingRequired = data.missingRequired ?? [];
161
+ const missingAutocomplete = data.missingAutocomplete ?? [];
162
+ const inaccessibleErrors = data.inaccessibleErrors ?? [];
163
+ const unmaskedPasswords = data.unmaskedPasswords ?? [];
164
+ const noValidationForms = data.noValidationForms ?? [];
165
+
166
+ // form_missing_required
167
+ for (const inp of missingRequired.slice(0, 20)) {
168
+ findings.push({
169
+ type: 'form_missing_required',
170
+ message: `Form input '${inp.name || inp.id || inp.type}' has no required or aria-required attribute`,
171
+ inputName: inp.name,
172
+ inputType: inp.type,
173
+ severity: 'warning',
174
+ url,
175
+ });
176
+ }
177
+
178
+ // form_no_autocomplete
179
+ for (const inp of missingAutocomplete.slice(0, 20)) {
180
+ findings.push({
181
+ type: 'form_no_autocomplete',
182
+ message: `Personal data field '${inp.name || inp.id}' (type: ${inp.type}) is missing autocomplete attribute (WCAG 1.3.5)`,
183
+ inputName: inp.name,
184
+ inputType: inp.type,
185
+ severity: 'warning',
186
+ url,
187
+ });
188
+ }
189
+
190
+ // form_inaccessible_error
191
+ for (const err of inaccessibleErrors.slice(0, 10)) {
192
+ findings.push({
193
+ type: 'form_inaccessible_error',
194
+ message: `Error element not linked to its input via aria-describedby: ${err.issue}`,
195
+ errorId: err.id,
196
+ errorText: err.text,
197
+ severity: 'warning',
198
+ url,
199
+ });
200
+ }
201
+
202
+ // form_unmasked_password
203
+ for (const inp of unmaskedPasswords.slice(0, 5)) {
204
+ findings.push({
205
+ type: 'form_unmasked_password',
206
+ message: `Input '${inp.name || inp.id}' has type="text" but is labelled as a password field — use type="password"`,
207
+ inputName: inp.name,
208
+ severity: 'critical',
209
+ url,
210
+ });
211
+ }
212
+
213
+ // form_no_validation
214
+ for (const form of noValidationForms.slice(0, 5)) {
215
+ findings.push({
216
+ type: 'form_no_validation',
217
+ message: `Form (${form.id || form.action || 'unknown'}) has required fields but no HTML5 pattern/type or novalidate attribute — client-side validation may be absent`,
218
+ formId: form.id,
219
+ severity: 'info',
220
+ url,
221
+ });
222
+ }
223
+
224
+ // Summary — always emitted
225
+ const total = missingRequired.length + missingAutocomplete.length +
226
+ inaccessibleErrors.length + unmaskedPasswords.length + noValidationForms.length;
227
+
228
+ findings.push({
229
+ type: 'form_summary',
230
+ missingRequired: missingRequired.length,
231
+ missingAutocomplete: missingAutocomplete.length,
232
+ inaccessibleErrors: inaccessibleErrors.length,
233
+ unmaskedPasswords: unmaskedPasswords.length,
234
+ noValidation: noValidationForms.length,
235
+ totalIssues: total,
236
+ message: `Form: ${missingRequired.length} missing-required, ${missingAutocomplete.length} no-autocomplete, ${inaccessibleErrors.length} inaccessible-error, ${unmaskedPasswords.length} unmasked-pw, ${noValidationForms.length} no-validation`,
237
+ severity: 'info',
238
+ url,
239
+ });
240
+
241
+ return findings;
242
+ }
243
+
244
+ registerExpensive({
245
+ name: 'form',
246
+ analyze: (browser, url) => analyzeForm(browser, url),
247
+ });
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Argus Phase C2: GitHub PR comment + commit status integration.
2
+ * Argus Phase C2: GitHub PR comment + commit status + Check Runs integration.
3
3
  *
4
- * C2.1 formatPrComment(report, diff) — build Markdown PR comment body (pure)
5
- * C2.2 buildStatusPayload(report, diff) — build GitHub commit status payload (pure)
6
- * C2.3 postPrComment(report, diff) — create/update PR comment via GitHub API
7
- * C2.4 setCommitStatus(report, diff) — set commit status (blocks merge on new criticals)
8
- * C2.5 isGitHubConfigured() — guard: true when GITHUB_TOKEN + GITHUB_REPOSITORY set
9
- * C2.6 reportToGitHub(report, diff) — orchestrates C2.3 + C2.4
4
+ * C2.1 formatPrComment(report, diff) — build Markdown PR comment body (pure)
5
+ * C2.2 buildStatusPayload(report, diff) — build GitHub commit status payload (pure)
6
+ * C2.3 postPrComment(report, diff) — create/update PR comment via GitHub API
7
+ * C2.4 setCommitStatus(report, diff) — set commit status (blocks merge on new criticals)
8
+ * C2.5 isGitHubConfigured() — guard: true when GITHUB_TOKEN + GITHUB_REPOSITORY set
9
+ * C2.6 reportToGitHub(report, diff) — orchestrates C2.3 + C2.4 + C2.7
10
+ * C2.7 createCheckRun(name, sha) — create a GitHub Checks API run
11
+ * C2.8 completeCheckRun(id, report, diff) — update Check Run with conclusion + rich output
12
+ * C2.9 generateReleaseNotes(cur, prev, opts) — pure: markdown changelog between two runs
10
13
  *
11
14
  * Required env vars:
12
15
  * GITHUB_TOKEN — personal access token or Actions GITHUB_TOKEN (required)
@@ -17,7 +20,10 @@
17
20
  * GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
18
21
  *
19
22
  * Optional env vars:
20
- * ARGUS_REPORT_URL — URL to the full HTML report; linked in the commit status check
23
+ * ARGUS_REPORT_URL — URL to the full HTML report; linked in the commit status check
24
+ * ARGUS_CRITICAL_THRESHOLD — number of new criticals before blocking merge (default: 1, set 0 to never block)
25
+ * ARGUS_DIFF_IMAGE_URL — URL of visual diff image to embed in PR comment (set after uploading CI artifact)
26
+ * GITHUB_CHECK_NAME — name of the Check Run (default: 'argus-qa')
21
27
  */
22
28
 
23
29
  import { childLogger } from './logger.js';
@@ -35,7 +41,7 @@ function sevIcon(sev) { return SEV_ICON[sev] ?? '⚪'; }
35
41
 
36
42
  /** Escape pipe characters so they don't break Markdown tables. */
37
43
  function mdCell(text, maxLen = 100) {
38
- return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' ');
44
+ return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' '); // lgtm[js/incomplete-string-escaping] — escaping pipe and newline is correct and sufficient for GitHub Markdown table cells
39
45
  }
40
46
 
41
47
  // ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
@@ -97,17 +103,46 @@ export function formatPrComment(report, diff) {
97
103
 
98
104
  // ── New findings table — skipped on first run (all findings would be "new") ──
99
105
  if (allNewFindings.length > 0 && !isFirst) {
106
+ // Check if any finding has a selector (DOM-linked findings) for the extra column
107
+ const hasSelectors = allNewFindings.some(f => f.selector);
100
108
  lines.push('', `### 🆕 New Findings (${allNewFindings.length})`);
101
- lines.push('| Severity | Source | Type | Details |');
102
- lines.push('|---|---|---|---|');
103
- for (const f of allNewFindings.slice(0, MAX_TABLE_ROWS)) {
104
- lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${mdCell(f.message)} |`);
109
+ if (hasSelectors) {
110
+ lines.push('| Severity | Source | Type | Selector | Details |');
111
+ lines.push('|---|---|---|---|---|');
112
+ for (const f of allNewFindings.slice(0, MAX_TABLE_ROWS)) {
113
+ const sel = f.selector ? `\`${mdCell(f.selector, 60)}\`` : '—';
114
+ lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${sel} | ${mdCell(f.message)} |`);
115
+ }
116
+ } else {
117
+ lines.push('| Severity | Source | Type | Details |');
118
+ lines.push('|---|---|---|---|');
119
+ for (const f of allNewFindings.slice(0, MAX_TABLE_ROWS)) {
120
+ lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${mdCell(f.message)} |`);
121
+ }
105
122
  }
106
123
  if (allNewFindings.length > MAX_TABLE_ROWS) {
107
124
  lines.push(`| … | … | … | _${allNewFindings.length - MAX_TABLE_ROWS} more — see full report_ |`);
108
125
  }
109
126
  }
110
127
 
128
+ // ── Visual regression section ──────────────────────────────────────────────
129
+ const visualRegressions = routes.flatMap(r =>
130
+ (r.errors ?? []).filter(e => e.type === 'visual_regression')
131
+ );
132
+ if (visualRegressions.length > 0) {
133
+ lines.push('', '### 🖼️ Visual Regressions');
134
+ lines.push('| Route | Diff % | Severity |');
135
+ lines.push('|---|---|---|');
136
+ for (const f of visualRegressions.slice(0, 10)) {
137
+ const pct = typeof f.diffPercent === 'number' ? `${f.diffPercent.toFixed(2)}%` : '—';
138
+ lines.push(`| ${f.url ?? '—'} | ${pct} | ${sevIcon(f.severity)} ${f.severity} |`);
139
+ }
140
+ const diffImageUrl = process.env.ARGUS_DIFF_IMAGE_URL;
141
+ if (diffImageUrl) {
142
+ lines.push('', `**Pixel diff:**`, `![Visual diff](${diffImageUrl})`);
143
+ }
144
+ }
145
+
111
146
  // ── Resolved note ──
112
147
  if (!isFirst && resolvedCount > 0) {
113
148
  lines.push('', `### ✅ Resolved (${resolvedCount})`);
@@ -162,13 +197,21 @@ export function buildStatusPayload(report, diff) {
162
197
  ),
163
198
  ].length;
164
199
 
165
- const passing = newCriticals === 0;
200
+ // ARGUS_CRITICAL_THRESHOLD: number of new criticals before blocking (default: 1).
201
+ // Set to 0 to never block merge; set to N to block only when N+ criticals found.
202
+ const threshold = parseInt(process.env.ARGUS_CRITICAL_THRESHOLD ?? '1', 10);
203
+ const passing = Number.isFinite(threshold) && threshold > 0
204
+ ? newCriticals < threshold
205
+ : true; // threshold=0 → never block
206
+
166
207
  return {
167
- state: passing ? 'success' : 'failure',
168
- description: passing
208
+ state: passing ? 'success' : 'failure',
209
+ description: passing
169
210
  ? `Argus: All checks passed (${report.summary.total} total finding(s))`
170
- : `Argus: ${newCriticals} new critical issue(s) — merge blocked`,
171
- context: 'argus-qa',
211
+ : `Argus: ${newCriticals} new critical issue(s) — merge blocked (threshold: ${threshold})`,
212
+ context: process.env.GITHUB_CHECK_NAME ?? 'argus-qa',
213
+ newCriticalCount: newCriticals,
214
+ threshold,
172
215
  };
173
216
  }
174
217
 
@@ -270,6 +313,156 @@ export async function setCommitStatus(report, diff) {
270
313
  logger.info(`[ARGUS] C2: Commit status → ${payload.state} (${payload.description})`);
271
314
  }
272
315
 
316
+ // ── C2.7: Create GitHub Check Run ────────────────────────────────────────────
317
+
318
+ /**
319
+ * Create a new GitHub Check Run in 'in_progress' state.
320
+ * Returns the check run id used by completeCheckRun().
321
+ * Requires GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_SHA.
322
+ *
323
+ * @param {string} [name] - Check run name (default: GITHUB_CHECK_NAME ?? 'argus-qa')
324
+ * @param {string} [sha] - Commit SHA (default: GITHUB_SHA env var)
325
+ * @returns {Promise<number>} check run id
326
+ */
327
+ export async function createCheckRun(name, sha) {
328
+ const repo = process.env.GITHUB_REPOSITORY;
329
+ const headSha = sha ?? process.env.GITHUB_SHA;
330
+ if (!repo || !headSha) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY or GITHUB_SHA not set');
331
+
332
+ const checkName = name ?? process.env.GITHUB_CHECK_NAME ?? 'argus-qa';
333
+ const data = await ghFetch(`/repos/${repo}/check-runs`, 'POST', {
334
+ name: checkName,
335
+ head_sha: headSha,
336
+ status: 'in_progress',
337
+ started_at: new Date().toISOString(),
338
+ });
339
+ logger.info(`[ARGUS] C2: Check run created (id: ${data.id}, name: ${checkName})`);
340
+ return data.id;
341
+ }
342
+
343
+ // ── C2.8: Complete GitHub Check Run with rich output ─────────────────────────
344
+
345
+ /**
346
+ * Update an existing Check Run to 'completed' with a conclusion and rich output.
347
+ *
348
+ * Output includes:
349
+ * - summary: one-line result (pass/fail + finding counts)
350
+ * - text: full findings table in Markdown (same data as PR comment, without COMMENT_MARKER)
351
+ *
352
+ * @param {number} checkRunId - id from createCheckRun()
353
+ * @param {object} report - runCrawl() report
354
+ * @param {object|null} diff - baseline diff (null = first run)
355
+ */
356
+ export async function completeCheckRun(checkRunId, report, diff) {
357
+ const repo = process.env.GITHUB_REPOSITORY;
358
+ if (!repo) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY not set');
359
+
360
+ const status = buildStatusPayload(report, diff);
361
+ const conclusion = status.state === 'success' ? 'success' : 'failure';
362
+
363
+ // Build rich text output (full findings table, without the COMMENT_MARKER sentinel)
364
+ const fullBody = formatPrComment(report, diff);
365
+ const richText = fullBody
366
+ .replace(COMMENT_MARKER + '\n', '') // strip the HTML sentinel
367
+ .slice(0, 65000); // GitHub Check output text limit
368
+
369
+ await ghFetch(`/repos/${repo}/check-runs/${checkRunId}`, 'PATCH', {
370
+ status: 'completed',
371
+ conclusion,
372
+ completed_at: new Date().toISOString(),
373
+ output: {
374
+ title: status.description,
375
+ summary: status.description,
376
+ text: richText,
377
+ },
378
+ });
379
+ logger.info(`[ARGUS] C2: Check run ${checkRunId} completed (${conclusion})`);
380
+ }
381
+
382
+ // ── C2.9: Release notes generator (pure) ─────────────────────────────────────
383
+
384
+ /**
385
+ * Generate a Markdown release notes / changelog from two Argus reports.
386
+ * Pure function — no I/O.
387
+ *
388
+ * @param {object} currentReport - report from the current run
389
+ * @param {object} prevReport - report from the previous/baseline run
390
+ * @param {object} [opts]
391
+ * @param {string} [opts.fromTag] - git tag for the previous run (e.g. 'v1.2.0')
392
+ * @param {string} [opts.toTag] - git tag for the current run (e.g. 'v1.3.0')
393
+ * @returns {string} Markdown release notes
394
+ */
395
+ export function generateReleaseNotes(currentReport, prevReport, opts = {}) {
396
+ const { fromTag, toTag } = opts;
397
+ const heading = toTag
398
+ ? `## 🚀 Argus Release Notes — ${toTag}` + (fromTag ? ` _(since ${fromTag})_` : '')
399
+ : '## 🚀 Argus Release Notes';
400
+
401
+ // Collect all findings from both reports as flat arrays
402
+ function allFindings(report) {
403
+ return [
404
+ ...(report.routes ?? []).flatMap(r => (r.errors ?? []).map(e => ({ ...e, _source: r.route }))),
405
+ ...(report.codebase ?? []).map(f => ({ ...f, _source: 'codebase' })),
406
+ ...(report.flows ?? []).flatMap(f => (f.findings ?? []).map(e => ({ ...e, _source: `flow:${f.flowName}` }))),
407
+ ];
408
+ }
409
+
410
+ const curFindings = allFindings(currentReport);
411
+ const prevFindings = allFindings(prevReport);
412
+
413
+ // Key each finding for comparison: type + source + message prefix
414
+ function findingKey(f) { return `${f.type}::${f._source}::${String(f.message ?? '').slice(0, 80)}`; }
415
+ const prevKeys = new Set(prevFindings.map(findingKey));
416
+ const curKeys = new Set(curFindings.map(findingKey));
417
+
418
+ const fixed = prevFindings.filter(f => !curKeys.has(findingKey(f)));
419
+ const newOnes = curFindings.filter(f => !prevKeys.has(findingKey(f)));
420
+
421
+ const lines = [heading, ''];
422
+
423
+ if (newOnes.length === 0 && fixed.length === 0) {
424
+ lines.push('_No changes detected since last run._');
425
+ } else {
426
+ lines.push(`**Run date**: ${new Date(currentReport.generatedAt ?? Date.now()).toUTCString()} `);
427
+ lines.push(`**Total findings**: ${currentReport.summary?.total ?? 0} (was ${prevReport.summary?.total ?? 0}) `);
428
+ lines.push('');
429
+
430
+ if (newOnes.length > 0) {
431
+ const crits = newOnes.filter(f => f.severity === 'critical').length;
432
+ lines.push(`### 🆕 New Issues (${newOnes.length})`);
433
+ if (crits > 0) lines.push(`> ⚠️ ${crits} new critical issue(s) require attention`);
434
+ lines.push('');
435
+ lines.push('| Severity | Source | Type | Details |');
436
+ lines.push('|---|---|---|---|');
437
+ for (const f of newOnes.slice(0, MAX_TABLE_ROWS)) {
438
+ lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${mdCell(f.message)} |`);
439
+ }
440
+ if (newOnes.length > MAX_TABLE_ROWS) {
441
+ lines.push(`| … | … | … | _${newOnes.length - MAX_TABLE_ROWS} more_ |`);
442
+ }
443
+ lines.push('');
444
+ }
445
+
446
+ if (fixed.length > 0) {
447
+ lines.push(`### ✅ Resolved Issues (${fixed.length})`);
448
+ lines.push('');
449
+ lines.push('| Severity | Source | Type | Details |');
450
+ lines.push('|---|---|---|---|');
451
+ for (const f of fixed.slice(0, MAX_TABLE_ROWS)) {
452
+ lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${mdCell(f.message)} |`);
453
+ }
454
+ if (fixed.length > MAX_TABLE_ROWS) {
455
+ lines.push(`| … | … | … | _${fixed.length - MAX_TABLE_ROWS} more_ |`);
456
+ }
457
+ lines.push('');
458
+ }
459
+ }
460
+
461
+ lines.push('---');
462
+ lines.push(`_Generated by [Argus](https://github.com/ironclawdevs27/Argus)_`);
463
+ return lines.join('\n');
464
+ }
465
+
273
466
  // ── C2.5: Configuration guard ─────────────────────────────────────────────────
274
467
 
275
468
  export function isGitHubConfigured() {
@@ -294,11 +487,21 @@ export async function reportToGitHub(report, diff) {
294
487
  }
295
488
 
296
489
  if (process.env.GITHUB_SHA) {
490
+ // Commit status (fast, minimal)
297
491
  tasks.push(
298
492
  setCommitStatus(report, diff).catch(err =>
299
493
  logger.warn(`[ARGUS] C2: Commit status failed — ${err.message}`)
300
494
  )
301
495
  );
496
+
497
+ // Check Run (rich output — created and completed in sequence)
498
+ tasks.push(
499
+ createCheckRun(undefined, process.env.GITHUB_SHA)
500
+ .then(id => completeCheckRun(id, report, diff))
501
+ .catch(err =>
502
+ logger.warn(`[ARGUS] C2: Check run failed — ${err.message}`)
503
+ )
504
+ );
302
505
  }
303
506
 
304
507
  if (tasks.length === 0) {