jaku.sh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. package/src/utils/logger.js +50 -0
@@ -0,0 +1,398 @@
1
+ import { BrowserManager } from './browser-manager.js';
2
+ import { createFinding } from '../utils/finding.js';
3
+ import { CSRWaiter } from './csr-waiter.js';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ /**
8
+ * Test Runner — Executes generated test cases headlessly via Playwright.
9
+ * Captures screenshots on failure, records pass/fail, and generates findings.
10
+ */
11
+ export class TestRunner {
12
+ constructor(config, logger) {
13
+ this.config = config;
14
+ this.logger = logger;
15
+ this.results = [];
16
+ this.findings = [];
17
+ this._pendingFailures = []; // collected before grouping
18
+ this.screenshotDir = path.join(config.output_dir || 'jaku-reports', 'screenshots');
19
+ }
20
+
21
+ /**
22
+ * Execute all generated test cases.
23
+ */
24
+ async run(testCases) {
25
+ if (!fs.existsSync(this.screenshotDir)) {
26
+ fs.mkdirSync(this.screenshotDir, { recursive: true });
27
+ }
28
+
29
+ const browser = await BrowserManager.launch({ headless: true });
30
+ const context = await browser.newContext({
31
+ viewport: { width: 1440, height: 900 },
32
+ ignoreHTTPSErrors: true,
33
+ });
34
+
35
+ let passed = 0;
36
+ let failed = 0;
37
+ let errors = 0;
38
+
39
+ for (const testCase of testCases) {
40
+ try {
41
+ const result = await this._executeTest(context, testCase);
42
+ this.results.push(result);
43
+
44
+ if (result.status === 'pass') {
45
+ passed++;
46
+ } else {
47
+ failed++;
48
+ this._collectFailure(result);
49
+ }
50
+ } catch (err) {
51
+ errors++;
52
+ const errorResult = {
53
+ testCase,
54
+ status: 'error',
55
+ error: err.message,
56
+ duration: 0,
57
+ };
58
+ this.results.push(errorResult);
59
+ this._collectFailure(errorResult);
60
+ }
61
+ }
62
+
63
+ await browser.close();
64
+
65
+ // Emit grouped findings (one per root-cause pattern, not one per URL)
66
+ this._emitGroupedFindings();
67
+
68
+ const summary = { total: testCases.length, passed, failed, errors };
69
+ this.logger?.info?.(`Tests complete: ${passed} passed, ${failed} failed, ${errors} errors`);
70
+ return { results: this.results, findings: this.findings, summary };
71
+ }
72
+
73
+ /**
74
+ * Execute a single test case.
75
+ */
76
+ async _executeTest(context, testCase) {
77
+ const page = await context.newPage();
78
+ const startTime = Date.now();
79
+ const csrWaiter = new CSRWaiter(this.logger);
80
+ let status = 'pass';
81
+ let failureReason = '';
82
+
83
+ // Use CSRWaiter's filtered console listener — suppresses Supabase auth
84
+ // loading noise so it doesn't false-positive on smoke tests
85
+ const consoleErrors = CSRWaiter.installConsoleFilter(page);
86
+
87
+ page.on('pageerror', error => {
88
+ // Also filter page-level errors through the noise filter
89
+ if (CSRWaiter.isRealError(error.message)) {
90
+ consoleErrors.push({ type: 'exception', text: error.message, timestamp: Date.now(), url: page.url() });
91
+ }
92
+ });
93
+
94
+ try {
95
+ for (let i = 0; i < testCase.steps.length; i++) {
96
+ const step = testCase.steps[i];
97
+ await this._executeStep(page, step, testCase);
98
+
99
+ // After the first navigation step, wait for CSR content to settle
100
+ // This is the key fix for Supabase/Clerk/CSR app false positives
101
+ if (i === 0 && (step.action === 'navigate' || step.action === 'goto')) {
102
+ await csrWaiter.waitForContent(page, { timeout: 12000 });
103
+ }
104
+ }
105
+
106
+ // Validate expected outcomes
107
+ if (testCase.type === 'smoke') {
108
+ const realErrorCount = consoleErrors.filter(e => e.type === 'error' || e.type === 'exception').length;
109
+ if (realErrorCount > 0 && testCase.expected.noConsoleErrors) {
110
+ status = 'fail';
111
+ failureReason = `Console errors detected: ${consoleErrors.map(e => e.text).join('; ')}`;
112
+ }
113
+ }
114
+ } catch (err) {
115
+ status = 'fail';
116
+ failureReason = err.message;
117
+
118
+ // Capture screenshot on failure
119
+ try {
120
+ const screenshotPath = path.join(
121
+ this.screenshotDir,
122
+ `${testCase.id}-failure.png`
123
+ );
124
+ await page.screenshot({ path: screenshotPath, fullPage: true });
125
+ } catch {
126
+ // Screenshot capture is best-effort
127
+ }
128
+ }
129
+
130
+ const duration = Date.now() - startTime;
131
+ await page.close();
132
+
133
+ return {
134
+ testCase,
135
+ status,
136
+ failureReason,
137
+ consoleErrors,
138
+ duration,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Execute a single step of a test case.
144
+ */
145
+ async _executeStep(page, step, testCase) {
146
+ switch (step.action) {
147
+ case 'navigate':
148
+ const response = await page.goto(step.url, {
149
+ waitUntil: 'networkidle',
150
+ timeout: 15000,
151
+ });
152
+ if (step.expected === undefined) break;
153
+ break;
154
+
155
+ case 'assert_status':
156
+ const currentUrl = page.url();
157
+ const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 10000 });
158
+ if (resp) {
159
+ const expectedStatuses = Array.isArray(step.expected) ? step.expected : [step.expected];
160
+ if (!expectedStatuses.includes(resp.status())) {
161
+ throw new Error(`Expected HTTP ${step.expected}, got ${resp.status()}`);
162
+ }
163
+ }
164
+ break;
165
+
166
+ case 'assert_no_console_errors':
167
+ // Checked after all steps in _executeTest
168
+ break;
169
+
170
+ case 'assert_has_content':
171
+ const bodyText = await page.evaluate(() => document.body?.innerText?.trim() || '');
172
+ if (!bodyText) {
173
+ throw new Error('Page has no visible content (empty body)');
174
+ }
175
+ break;
176
+
177
+ case 'click_link':
178
+ try {
179
+ const linkSelector = `a[href="${step.url}"]`;
180
+ const link = await page.$(linkSelector);
181
+ if (link) {
182
+ await link.click({ timeout: 5000 });
183
+ await page.waitForLoadState('networkidle', { timeout: 10000 });
184
+ } else {
185
+ await page.goto(step.url, { waitUntil: 'networkidle', timeout: 10000 });
186
+ }
187
+ } catch {
188
+ await page.goto(step.url, { waitUntil: 'networkidle', timeout: 10000 });
189
+ }
190
+ break;
191
+
192
+ case 'locate_form':
193
+ const form = await page.$(`form#${step.selector}`) || await page.$('form');
194
+ if (!form) {
195
+ throw new Error(`Form "${step.selector}" not found on page`);
196
+ }
197
+ break;
198
+
199
+ case 'submit_empty':
200
+ const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
201
+ if (submitBtn) {
202
+ await submitBtn.click();
203
+ await page.waitForTimeout(1000);
204
+ }
205
+ break;
206
+
207
+ case 'fill_form':
208
+ if (step.data) {
209
+ for (const [name, value] of Object.entries(step.data)) {
210
+ try {
211
+ const input = await page.$(`[name="${name}"]`) || await page.$(`#${name}`);
212
+ if (input) {
213
+ if (typeof value === 'boolean') {
214
+ if (value) await input.check();
215
+ } else if (value === '__first_option__') {
216
+ await input.selectOption({ index: 1 });
217
+ } else {
218
+ await input.fill(String(value));
219
+ }
220
+ }
221
+ } catch {
222
+ // Best-effort field fill
223
+ }
224
+ }
225
+ }
226
+ break;
227
+
228
+ case 'fill_field':
229
+ try {
230
+ const field = await page.$(`[name="${step.field}"]`) || await page.$(`#${step.field}`);
231
+ if (field) {
232
+ await field.fill(String(step.value));
233
+ }
234
+ } catch {
235
+ // Best-effort field fill
236
+ }
237
+ break;
238
+
239
+ case 'submit':
240
+ const btn = await page.$('button[type="submit"], input[type="submit"]');
241
+ if (btn) {
242
+ await btn.click();
243
+ await page.waitForTimeout(1000);
244
+ }
245
+ break;
246
+
247
+ case 'assert_validation_error':
248
+ case 'assert_submission_feedback':
249
+ case 'assert_no_crash':
250
+ case 'assert_input_sanitized':
251
+ case 'assert_valid_json':
252
+ case 'assert_response_time':
253
+ // These are checked post-execution or are informational
254
+ break;
255
+
256
+ case 'http_request':
257
+ try {
258
+ const fetchResp = await page.evaluate(async (opts) => {
259
+ const resp = await fetch(opts.url, { method: opts.method });
260
+ return { status: resp.status, ok: resp.ok };
261
+ }, step);
262
+ if (!fetchResp.ok && step.expected) {
263
+ const expected = Array.isArray(step.expected) ? step.expected : [step.expected];
264
+ if (!expected.includes(fetchResp.status)) {
265
+ throw new Error(`API returned ${fetchResp.status}`);
266
+ }
267
+ }
268
+ } catch (err) {
269
+ throw new Error(`API request failed: ${err.message}`);
270
+ }
271
+ break;
272
+
273
+ default:
274
+ this.logger?.debug?.(`Unknown step action: ${step.action}`);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Collect a failure for later grouping (do not emit immediately).
280
+ */
281
+ _collectFailure(result) {
282
+ this._pendingFailures.push(result);
283
+ }
284
+
285
+ /**
286
+ * Group collected failures by (type + normalized root cause) and emit one finding per group.
287
+ *
288
+ * Example: 49 smoke failures with "Page has no visible content (empty body)" on /trips/:uuid
289
+ * becomes ONE high finding with all 49 URLs listed in the description.
290
+ */
291
+ _emitGroupedFindings() {
292
+ // Group key: type + root-cause message (first ~80 chars to normalize minor variance)
293
+ const groups = new Map();
294
+
295
+ for (const result of this._pendingFailures) {
296
+ const { testCase, failureReason, error } = result;
297
+ const message = (failureReason || error || 'Test failed').substring(0, 80);
298
+ const key = `${testCase.type}::${message}`;
299
+
300
+ if (!groups.has(key)) {
301
+ groups.set(key, {
302
+ type: testCase.type,
303
+ message,
304
+ results: [],
305
+ firstTestCase: testCase,
306
+ });
307
+ }
308
+ groups.get(key).results.push(result);
309
+ }
310
+
311
+ for (const [, group] of groups) {
312
+ const { type, message, results, firstTestCase } = group;
313
+ const count = results.length;
314
+ const urls = results.map(r => r.testCase.surface).filter(Boolean);
315
+
316
+ const severityMap = {
317
+ 'smoke': 'high',
318
+ 'navigation': 'medium',
319
+ 'form': 'medium',
320
+ 'api': 'high',
321
+ 'edge-case': 'low',
322
+ };
323
+
324
+ // For a group of 1, preserve the original specific title.
325
+ // For groups of 2+, produce a single grouped finding.
326
+ const title = count === 1
327
+ ? `Test Failed: ${firstTestCase.title}`
328
+ : `Test Failed (${count}x): ${message}`;
329
+
330
+ const description = count === 1
331
+ ? `Test "${firstTestCase.title}" (${type}) failed: ${message}`
332
+ : `${count} pages share the same root cause: "${message}"\n\nAffected URLs:\n${urls.map(u => ` - ${u}`).join('\n')}`;
333
+
334
+ this.findings.push(
335
+ createFinding({
336
+ module: 'qa',
337
+ title,
338
+ severity: severityMap[type] || 'medium',
339
+ affected_surface: count === 1 ? firstTestCase.surface : urls[0],
340
+ description,
341
+ reproduction: count === 1
342
+ ? firstTestCase.steps.map((s, i) =>
343
+ `${i + 1}. ${s.action}${s.url ? ` → ${s.url}` : ''}${s.value ? ` with value "${String(s.value).substring(0, 50)}"` : ''}`
344
+ )
345
+ : [
346
+ `1. navigate → any of the ${count} affected URLs`,
347
+ `2. assert_status (expect 200)`,
348
+ `3. assert_has_content`,
349
+ `4. Fails with: ${message}`,
350
+ ],
351
+ evidence: JSON.stringify({
352
+ groupedCount: count,
353
+ rootCause: message,
354
+ testType: type,
355
+ affectedUrls: urls,
356
+ firstTestId: firstTestCase.id,
357
+ }, null, 2),
358
+ remediation: this._getRemediation(firstTestCase, message),
359
+ })
360
+ );
361
+ }
362
+
363
+ if (this._pendingFailures.length > 0) {
364
+ this.logger?.info?.(`Test findings: ${this._pendingFailures.length} failures → ${this.findings.length} grouped findings`);
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Create a finding from a failed test (legacy single-emit path — kept for direct calls).
370
+ */
371
+ _createFindingFromResult(result) {
372
+ // Redirect to the grouping path
373
+ this._collectFailure(result);
374
+ this._emitGroupedFindings();
375
+ this._pendingFailures = [];
376
+ }
377
+
378
+ _getRemediation(testCase, message) {
379
+ switch (testCase.type) {
380
+ case 'smoke':
381
+ return 'Ensure the page loads correctly with HTTP 200, has visible content, and produces no JavaScript errors.';
382
+ case 'navigation':
383
+ return 'Fix the broken link or ensure the target URL is accessible. Update or remove any stale links.';
384
+ case 'form':
385
+ if (testCase.subtype === 'empty_submit') return 'Add proper form validation to prevent empty submissions.';
386
+ if (testCase.subtype === 'invalid_input') return `Add input validation for field "${testCase.fieldName}" to reject invalid data.`;
387
+ return 'Ensure form submission works correctly with proper validation and user feedback.';
388
+ case 'api':
389
+ return 'Verify the API endpoint is accessible, returns valid JSON, and responds within acceptable time limits.';
390
+ case 'edge-case':
391
+ return 'Ensure the application handles edge cases gracefully without crashing - long inputs, special characters, etc.';
392
+ default:
393
+ return 'Investigate and fix the test failure.';
394
+ }
395
+ }
396
+ }
397
+
398
+ export default TestRunner;
@@ -0,0 +1,172 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * DiffReporter — Compares findings between scan runs for regression detection.
6
+ *
7
+ * Generates a diff report showing:
8
+ * - New findings (appeared in current run)
9
+ * - Resolved findings (was in previous, not in current)
10
+ * - Persistent findings (in both runs)
11
+ * - Severity changes
12
+ */
13
+ export class DiffReporter {
14
+ constructor(logger) {
15
+ this.logger = logger;
16
+ }
17
+
18
+ /**
19
+ * Generate diff between current findings and the most recent previous run.
20
+ */
21
+ generateDiff(currentFindings, outputDir) {
22
+ const previousFindings = this._loadPreviousFindings(outputDir);
23
+
24
+ if (!previousFindings) {
25
+ this.logger?.info?.('Diff Report: no previous scan found — skipping regression diff');
26
+ return null;
27
+ }
28
+
29
+ const diff = this._computeDiff(previousFindings, currentFindings);
30
+
31
+ // Write diff report
32
+ const diffPath = path.join(outputDir, 'diff-report.json');
33
+ fs.writeFileSync(diffPath, JSON.stringify(diff, null, 2), 'utf-8');
34
+
35
+ // Write markdown diff
36
+ const mdPath = path.join(outputDir, 'diff-report.md');
37
+ fs.writeFileSync(mdPath, this._generateMarkdown(diff), 'utf-8');
38
+
39
+ this.logger?.info?.(`Diff Report: ${diff.new.length} new, ${diff.resolved.length} resolved, ${diff.persistent.length} persistent`);
40
+
41
+ return diff;
42
+ }
43
+
44
+ /**
45
+ * Load findings from the most recent previous scan run.
46
+ */
47
+ _loadPreviousFindings(currentOutputDir) {
48
+ try {
49
+ const reportsRoot = path.dirname(currentOutputDir);
50
+ if (!fs.existsSync(reportsRoot)) return null;
51
+
52
+ const runs = fs.readdirSync(reportsRoot)
53
+ .filter(d => fs.statSync(path.join(reportsRoot, d)).isDirectory())
54
+ .sort()
55
+ .reverse();
56
+
57
+ // Find the most recent run that isn't the current one
58
+ const currentName = path.basename(currentOutputDir);
59
+ for (const run of runs) {
60
+ if (run === currentName) continue;
61
+ const reportPath = path.join(reportsRoot, run, 'report.json');
62
+ if (fs.existsSync(reportPath)) {
63
+ const data = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
64
+ return data.findings || [];
65
+ }
66
+ }
67
+ } catch (err) {
68
+ this.logger?.debug?.(`Diff: could not load previous findings: ${err.message}`);
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Compute the diff between two finding sets.
76
+ * Matching is done by title + affected_surface (not ID, since IDs change per run).
77
+ */
78
+ _computeDiff(previousFindings, currentFindings) {
79
+ const key = f => `${f.title}::${f.affected_surface}`;
80
+
81
+ const prevMap = new Map(previousFindings.map(f => [key(f), f]));
82
+ const currMap = new Map(currentFindings.map(f => [key(f), f]));
83
+
84
+ const newFindings = [];
85
+ const resolved = [];
86
+ const persistent = [];
87
+ const severityChanges = [];
88
+
89
+ // Find new and persistent
90
+ for (const [k, curr] of currMap) {
91
+ if (prevMap.has(k)) {
92
+ persistent.push(curr);
93
+ const prev = prevMap.get(k);
94
+ if (prev.severity !== curr.severity) {
95
+ severityChanges.push({
96
+ title: curr.title,
97
+ affected_surface: curr.affected_surface,
98
+ previousSeverity: prev.severity,
99
+ currentSeverity: curr.severity,
100
+ });
101
+ }
102
+ } else {
103
+ newFindings.push(curr);
104
+ }
105
+ }
106
+
107
+ // Find resolved
108
+ for (const [k, prev] of prevMap) {
109
+ if (!currMap.has(k)) {
110
+ resolved.push(prev);
111
+ }
112
+ }
113
+
114
+ return {
115
+ timestamp: new Date().toISOString(),
116
+ previousRunFindings: previousFindings.length,
117
+ currentRunFindings: currentFindings.length,
118
+ new: newFindings,
119
+ resolved,
120
+ persistent,
121
+ severityChanges,
122
+ regressionDetected: newFindings.length > 0,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Generate markdown diff report.
128
+ */
129
+ _generateMarkdown(diff) {
130
+ let md = `# 呪 JAKU — Regression Diff Report\n\n`;
131
+ md += `**Generated:** ${diff.timestamp}\n`;
132
+ md += `**Previous Run:** ${diff.previousRunFindings} findings\n`;
133
+ md += `**Current Run:** ${diff.currentRunFindings} findings\n\n`;
134
+
135
+ md += `## Summary\n\n`;
136
+ md += `| Category | Count |\n`;
137
+ md += `|----------|-------|\n`;
138
+ md += `| 🆕 New Findings | ${diff.new.length} |\n`;
139
+ md += `| ✅ Resolved | ${diff.resolved.length} |\n`;
140
+ md += `| ⏳ Persistent | ${diff.persistent.length} |\n`;
141
+ md += `| 🔄 Severity Changes | ${diff.severityChanges.length} |\n\n`;
142
+
143
+ if (diff.new.length > 0) {
144
+ md += `## 🆕 New Findings (Regressions)\n\n`;
145
+ for (const f of diff.new) {
146
+ md += `- **[${f.severity.toUpperCase()}]** ${f.title} — ${f.affected_surface}\n`;
147
+ }
148
+ md += '\n';
149
+ }
150
+
151
+ if (diff.resolved.length > 0) {
152
+ md += `## ✅ Resolved Findings\n\n`;
153
+ for (const f of diff.resolved) {
154
+ md += `- ~~[${f.severity.toUpperCase()}] ${f.title} — ${f.affected_surface}~~\n`;
155
+ }
156
+ md += '\n';
157
+ }
158
+
159
+ if (diff.severityChanges.length > 0) {
160
+ md += `## 🔄 Severity Changes\n\n`;
161
+ for (const c of diff.severityChanges) {
162
+ md += `- ${c.title}: ${c.previousSeverity} → **${c.currentSeverity}**\n`;
163
+ }
164
+ md += '\n';
165
+ }
166
+
167
+ md += `\n*Report generated by JAKU 呪 Diff Engine*\n`;
168
+ return md;
169
+ }
170
+ }
171
+
172
+ export default DiffReporter;