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.
- package/README.md +384 -1100
- package/glama.json +9 -1
- package/package.json +4 -3
- package/src/adapters/browser.js +1 -0
- package/src/cli/init.js +8 -4
- package/src/config/targets.js +10 -0
- package/src/mcp-server.js +115 -2
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/env-comparison.js +0 -1
- package/src/orchestration/orchestrator.js +10 -5
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +1 -1
- package/src/orchestration/watch-mode.js +0 -4
- package/src/server/index.js +24 -2
- package/src/server/slash-command-handler.js +0 -1
- package/src/utils/a11y-deep-analyzer.js +294 -0
- package/src/utils/baseline-manager.js +3 -3
- package/src/utils/codebase-analyzer.js +3 -3
- package/src/utils/content-analyzer.js +1 -1
- package/src/utils/flow-runner.js +4 -4
- package/src/utils/font-analyzer.js +213 -0
- package/src/utils/form-analyzer.js +247 -0
- package/src/utils/github-reporter.js +221 -18
- package/src/utils/har-recorder.js +197 -0
- package/src/utils/motion-analyzer.js +243 -0
- package/src/utils/pr-diff-analyzer.js +121 -0
- package/src/utils/route-discoverer.js +1 -1
- package/src/utils/security-analyzer.js +1 -1
- package/src/utils/seo-analyzer.js +1 -1
- package/src/utils/session-persistence.js +1 -1
- package/src/utils/visual-diff-analyzer.js +9 -4
|
@@ -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)
|
|
5
|
-
* C2.2 buildStatusPayload(report, diff)
|
|
6
|
-
* C2.3 postPrComment(report, diff)
|
|
7
|
-
* C2.4 setCommitStatus(report, diff)
|
|
8
|
-
* C2.5 isGitHubConfigured()
|
|
9
|
-
* C2.6 reportToGitHub(report, diff)
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:**`, ``);
|
|
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
|
-
|
|
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:
|
|
168
|
-
description:
|
|
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:
|
|
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) {
|