@owasp-aghast/aghast 0.8.3 → 0.9.0-rc.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.
Files changed (113) hide show
  1. package/config/pricing.json +39 -9
  2. package/dist/build-config.d.ts.map +1 -1
  3. package/dist/build-config.js +101 -2
  4. package/dist/build-config.js.map +1 -1
  5. package/dist/check-library.d.ts +25 -0
  6. package/dist/check-library.d.ts.map +1 -1
  7. package/dist/check-library.js +172 -1
  8. package/dist/check-library.js.map +1 -1
  9. package/dist/ci-metadata.d.ts +42 -0
  10. package/dist/ci-metadata.d.ts.map +1 -0
  11. package/dist/ci-metadata.js +131 -0
  12. package/dist/ci-metadata.js.map +1 -0
  13. package/dist/claude-code-provider.d.ts +7 -0
  14. package/dist/claude-code-provider.d.ts.map +1 -1
  15. package/dist/claude-code-provider.js +65 -1
  16. package/dist/claude-code-provider.js.map +1 -1
  17. package/dist/cli.js +4 -1
  18. package/dist/cli.js.map +1 -1
  19. package/dist/concurrency.d.ts +18 -0
  20. package/dist/concurrency.d.ts.map +1 -0
  21. package/dist/concurrency.js +39 -0
  22. package/dist/concurrency.js.map +1 -0
  23. package/dist/cost-tracker.d.ts +45 -0
  24. package/dist/cost-tracker.d.ts.map +1 -0
  25. package/dist/cost-tracker.js +56 -0
  26. package/dist/cost-tracker.js.map +1 -0
  27. package/dist/discoveries/glob-discovery.d.ts +21 -0
  28. package/dist/discoveries/glob-discovery.d.ts.map +1 -0
  29. package/dist/discoveries/glob-discovery.js +215 -0
  30. package/dist/discoveries/glob-discovery.js.map +1 -0
  31. package/dist/discoveries/script-discovery.d.ts +117 -0
  32. package/dist/discoveries/script-discovery.d.ts.map +1 -0
  33. package/dist/discoveries/script-discovery.js +593 -0
  34. package/dist/discoveries/script-discovery.js.map +1 -0
  35. package/dist/docs-url.d.ts +14 -0
  36. package/dist/docs-url.d.ts.map +1 -0
  37. package/dist/docs-url.js +17 -0
  38. package/dist/docs-url.js.map +1 -0
  39. package/dist/error-codes.d.ts +12 -1
  40. package/dist/error-codes.d.ts.map +1 -1
  41. package/dist/error-codes.js +16 -2
  42. package/dist/error-codes.js.map +1 -1
  43. package/dist/formatters/csv-formatter.d.ts +45 -0
  44. package/dist/formatters/csv-formatter.d.ts.map +1 -0
  45. package/dist/formatters/csv-formatter.js +176 -0
  46. package/dist/formatters/csv-formatter.js.map +1 -0
  47. package/dist/formatters/html-formatter.d.ts +39 -0
  48. package/dist/formatters/html-formatter.d.ts.map +1 -0
  49. package/dist/formatters/html-formatter.js +432 -0
  50. package/dist/formatters/html-formatter.js.map +1 -0
  51. package/dist/formatters/index.d.ts.map +1 -1
  52. package/dist/formatters/index.js +6 -0
  53. package/dist/formatters/index.js.map +1 -1
  54. package/dist/formatters/markdown-formatter.d.ts +61 -0
  55. package/dist/formatters/markdown-formatter.d.ts.map +1 -0
  56. package/dist/formatters/markdown-formatter.js +557 -0
  57. package/dist/formatters/markdown-formatter.js.map +1 -0
  58. package/dist/formatters/sarif-formatter.d.ts +7 -0
  59. package/dist/formatters/sarif-formatter.d.ts.map +1 -1
  60. package/dist/formatters/sarif-formatter.js +80 -0
  61. package/dist/formatters/sarif-formatter.js.map +1 -1
  62. package/dist/formatters/types.d.ts +24 -0
  63. package/dist/formatters/types.d.ts.map +1 -1
  64. package/dist/index.d.ts +4 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +365 -10
  67. package/dist/index.js.map +1 -1
  68. package/dist/issue-file-writer.d.ts +21 -0
  69. package/dist/issue-file-writer.d.ts.map +1 -0
  70. package/dist/issue-file-writer.js +307 -0
  71. package/dist/issue-file-writer.js.map +1 -0
  72. package/dist/judge.d.ts +62 -0
  73. package/dist/judge.d.ts.map +1 -0
  74. package/dist/judge.js +305 -0
  75. package/dist/judge.js.map +1 -0
  76. package/dist/mock-agent-provider.d.ts +3 -0
  77. package/dist/mock-agent-provider.d.ts.map +1 -1
  78. package/dist/mock-agent-provider.js +13 -0
  79. package/dist/mock-agent-provider.js.map +1 -1
  80. package/dist/new-check.d.ts.map +1 -1
  81. package/dist/new-check.js +334 -22
  82. package/dist/new-check.js.map +1 -1
  83. package/dist/opencode-provider.d.ts.map +1 -1
  84. package/dist/opencode-provider.js +33 -0
  85. package/dist/opencode-provider.js.map +1 -1
  86. package/dist/repo-scan.d.ts +80 -0
  87. package/dist/repo-scan.d.ts.map +1 -0
  88. package/dist/repo-scan.js +250 -0
  89. package/dist/repo-scan.js.map +1 -0
  90. package/dist/result-handlers/pr-comment-handler.d.ts +136 -0
  91. package/dist/result-handlers/pr-comment-handler.d.ts.map +1 -0
  92. package/dist/result-handlers/pr-comment-handler.js +381 -0
  93. package/dist/result-handlers/pr-comment-handler.js.map +1 -0
  94. package/dist/retry.d.ts +128 -0
  95. package/dist/retry.d.ts.map +1 -0
  96. package/dist/retry.js +261 -0
  97. package/dist/retry.js.map +1 -0
  98. package/dist/runtime-config.d.ts.map +1 -1
  99. package/dist/runtime-config.js +62 -0
  100. package/dist/runtime-config.js.map +1 -1
  101. package/dist/scan-runner.d.ts +31 -22
  102. package/dist/scan-runner.d.ts.map +1 -1
  103. package/dist/scan-runner.js +145 -100
  104. package/dist/scan-runner.js.map +1 -1
  105. package/dist/semgrep-runner.js +6 -4
  106. package/dist/semgrep-runner.js.map +1 -1
  107. package/dist/stats.d.ts.map +1 -1
  108. package/dist/stats.js +4 -1
  109. package/dist/stats.js.map +1 -1
  110. package/dist/types.d.ts +149 -2
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/types.js.map +1 -1
  113. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,11 +8,13 @@ import { resolve, dirname } from 'node:path';
8
8
  import { runMultiScanWithCost } from './scan-runner.js';
9
9
  import { loadDefaultPricing, mergePricing, formatCostSourceLabel } from './cost-calculator.js';
10
10
  import { saveScanRecord, queryScanHistory } from './scan-history.js';
11
+ import { writeIndividualIssueFiles } from './issue-file-writer.js';
11
12
  import { createProviderByName, getProviderNames, DEFAULT_PROVIDER_NAME } from './provider-registry.js';
12
- import { loadCheckRegistry, discoverCheckFolders, resolveChecks, filterChecksForRepository, validateCheck, loadCheckDetails, } from './check-library.js';
13
+ import { loadCheckRegistry, discoverCheckFolders, resolveChecks, filterChecksForRepositoryAsync, sortChecksByPriority, validateCheck, loadCheckDetails, } from './check-library.js';
14
+ import { clearRepoSnapshotCache } from './repo-scan.js';
13
15
  import { analyzeRepository } from './repository-analyzer.js';
14
16
  import { loadRuntimeConfig } from './runtime-config.js';
15
- import { logProgress, logDebug, setLogLevel, createTimer, isValidLogLevel, initFileHandler, closeAllHandlers, getAvailableLogTypes } from './logging.js';
17
+ import { logProgress, logDebug, logWarn, setLogLevel, createTimer, isValidLogLevel, initFileHandler, closeAllHandlers, getAvailableLogTypes } from './logging.js';
16
18
  import { MOCK_MODEL_NAME, DEFAULT_MODEL } from './types.js';
17
19
  import { getFormatter } from './formatters/index.js';
18
20
  import { verifySemgrepInstalled } from './semgrep-runner.js';
@@ -21,11 +23,36 @@ import { verifyOpenAntInstalled } from './openant-runner.js';
21
23
  import { MockAgentProvider } from './mock-agent-provider.js';
22
24
  import { ERROR_CODES, formatError, formatFatalError } from './error-codes.js';
23
25
  import { DEFAULT_OUTPUT_FORMAT, DEFAULT_LOG_LEVEL, DEFAULT_LOG_TYPE } from './defaults.js';
26
+ import { docsFooter } from './docs-url.js';
24
27
  import { colorStatus } from './colors.js';
25
28
  import { getCheckType } from './check-types.js';
26
29
  import { getDiscovery, getRegisteredDiscoveries } from './discovery.js';
30
+ import { postPRComments } from './result-handlers/pr-comment-handler.js';
27
31
  import { createRequire } from 'node:module';
28
32
  const TAG = 'aghast';
33
+ async function createMockJudgeProvider(modelOverride) {
34
+ // AGHAST_MOCK_JUDGE='true' → default TP response; AGHAST_MOCK_JUDGE=<path> → read from that file
35
+ const mockJudgeValue = process.env.AGHAST_MOCK_JUDGE;
36
+ let rawResponse = '{"verdict":"true_positive","confidence":1.0,"rationale":"mock"}';
37
+ if (mockJudgeValue && mockJudgeValue !== 'true') {
38
+ try {
39
+ rawResponse = await readFile(resolve(mockJudgeValue), 'utf-8');
40
+ }
41
+ catch (err) {
42
+ throw new Error(formatError(ERROR_CODES.E8001, `path: ${mockJudgeValue}`), { cause: err });
43
+ }
44
+ }
45
+ // Test hook mirroring AGHAST_MOCK_FAIL_TIMES, but for the judge's own calls.
46
+ // Kept separate so a test can fail the judge without also failing the scan
47
+ // (and vice versa) — the two are retried independently.
48
+ const failTimesRaw = process.env.AGHAST_MOCK_JUDGE_FAIL_TIMES;
49
+ const parsedFailTimes = failTimesRaw ? Number(failTimesRaw) : 0;
50
+ const failTimes = Number.isInteger(parsedFailTimes) && parsedFailTimes > 0 ? parsedFailTimes : 0;
51
+ const provider = new MockAgentProvider({ rawResponse, failTimes });
52
+ await provider.initialize({});
53
+ provider.setModel?.(modelOverride ?? MOCK_MODEL_NAME);
54
+ return provider;
55
+ }
29
56
  async function createMockProvider() {
30
57
  // AGHAST_MOCK_AI='true' → default empty response; AGHAST_MOCK_AI=<path> → read from that file
31
58
  const mockAiValue = process.env.AGHAST_MOCK_AI;
@@ -62,7 +89,13 @@ async function createMockProvider() {
62
89
  };
63
90
  }
64
91
  }
65
- const provider = new MockAgentProvider({ rawResponse, tokenUsage });
92
+ // Test hook: AGHAST_MOCK_FAIL_TIMES=<n> makes the mock fail its first n calls
93
+ // with a retryable (503) error before succeeding, so retry behaviour can be
94
+ // exercised end-to-end through the real CLI.
95
+ const failTimesRaw = process.env.AGHAST_MOCK_FAIL_TIMES;
96
+ const parsedFailTimes = failTimesRaw ? Number(failTimesRaw) : 0;
97
+ const failTimes = Number.isInteger(parsedFailTimes) && parsedFailTimes > 0 ? parsedFailTimes : 0;
98
+ const provider = new MockAgentProvider({ rawResponse, tokenUsage, failTimes });
66
99
  await provider.initialize({});
67
100
  return provider;
68
101
  }
@@ -74,12 +107,13 @@ Arguments:
74
107
  <repo-path> Path to the repository to scan
75
108
 
76
109
  General options:
110
+ -h, --help Show this help message
77
111
  --config-dir <path> Config directory containing checks-config.json,
78
112
  checks/ folder, and optionally runtime-config.json.
79
113
  Required unless AGHAST_CONFIG_DIR is set.
80
114
  --output <path> Output file path for results
81
115
  (default: <repo-path>/security_checks_results.<ext>)
82
- --output-format json|sarif Output format (default: json)
116
+ --output-format <fmt> Output format: json, sarif, csv, html, markdown (default: json)
83
117
  --fail-on-check-failure Exit with code 1 if any check FAILs or ERRORs
84
118
  --debug Shorthand for --log-level debug
85
119
  --log-level <level> Console log level: error, warn, info, debug, trace
@@ -91,6 +125,7 @@ General options:
91
125
  --model <model> AI model override (e.g. claude-sonnet-4-20250514)
92
126
  --agent-provider <name> Agent provider name (default: claude-code)
93
127
  --generic-prompt <file> Generic prompt template filename in prompts/ dir
128
+ --runtime-config <path> Path to runtime config file
94
129
  --diff-ref <ref> Git ref to diff against (e.g. HEAD~1, main, SHA).
95
130
  Auto-activates diff filtering on every check whose
96
131
  discovery supports it, unless the check opts out
@@ -102,6 +137,24 @@ General options:
102
137
  --budget-limit-tokens <n> Abort the scan when accumulated tokens exceed n.
103
138
  Warns at 80%, aborts at 100%
104
139
 
140
+ PR comment options (post findings as inline GitHub PR review comments):
141
+ --pr <number> Pull request number to post comments on
142
+ --repo <owner/repo> GitHub repository in owner/repo form
143
+ (default: $GITHUB_REPOSITORY)
144
+ --base-sha <sha> Base commit SHA (default: $GITHUB_BASE_SHA)
145
+ --head-sha <sha> Head commit SHA
146
+ (default: $GITHUB_HEAD_SHA / $GITHUB_SHA)
147
+ Judge stage options (enables a post-scan LLM re-evaluation of findings):
148
+ --judge-model <model> Enable the judge stage using this model. Required
149
+ to activate the stage (no separate --judge flag).
150
+ --judge-provider <name> Agent provider for the judge (default: scan provider)
151
+ --judge-concurrency <n> Max concurrent judge calls (default: 5)
152
+ --judge-drop-false-positives Remove issues judged as false positives from output
153
+ --judge-min-confidence <f> Demote true_positive verdicts with confidence < this
154
+ value to uncertain (0.0–1.0)
155
+ --retry-max-attempts <n> Retry transient provider failures up to n attempts
156
+ per AI call (default: 1, i.e. no retry)
157
+
105
158
  Environment variables:
106
159
  ANTHROPIC_API_KEY API key for Claude. If unset, AI-based checks fall
107
160
  back to a logged-in local Claude session
@@ -112,16 +165,34 @@ Environment variables:
112
165
  AGHAST_LOG_LEVEL Console log level (CLI --log-level takes precedence)
113
166
  AGHAST_LOG_FILE Log file path (CLI --log-file takes precedence)
114
167
  AGHAST_LOG_TYPE Log file handler type (CLI --log-type takes precedence)
168
+ AGHAST_MOCK_SARIF Use a SARIF file instead of running Semgrep or Opengrep
169
+ (test/development use only)
170
+ AGHAST_OPENANT_DATASET Use a pre-generated OpenAnt dataset JSON file
115
171
  AGHAST_DIFF_REF Git ref to diff against (CLI --diff-ref takes precedence)
172
+ NO_COLOR Set to "1" to disable colored output
173
+ GITHUB_REPOSITORY Default for --repo (auto-set in GitHub Actions)
174
+ GITHUB_BASE_SHA Default for --base-sha
175
+ GITHUB_HEAD_SHA Default for --head-sha
176
+ GITHUB_SHA Fallback for --head-sha (auto-set in GitHub Actions)
177
+ GH_TOKEN / GITHUB_TOKEN Auth for the gh CLI when posting PR comments
178
+ AGHAST_JUDGE_MODEL Judge model (presence enables the stage; CLI --judge-model takes precedence)
179
+ AGHAST_JUDGE_PROVIDER Judge provider (CLI --judge-provider takes precedence)
180
+ AGHAST_RETRY_MAX_ATTEMPTS Retry attempts per AI call; >1 enables retry
181
+ (CLI --retry-max-attempts takes precedence)
182
+ AGHAST_MOCK_JUDGE Enables mock judge provider. Set to "true" for default
183
+ {"verdict":"true_positive","confidence":1.0,"rationale":"mock"},
184
+ or set to a file path for a custom fixture
116
185
 
117
186
  Examples:
118
187
  aghast scan ./my-repo --config-dir ./my-checks
119
188
  aghast scan ./my-repo --config-dir ./my-checks --output results.sarif --output-format sarif
120
189
  aghast scan ./my-repo --config-dir ./my-checks --fail-on-check-failure --debug
121
190
  aghast scan ./my-repo --config-dir ./my-checks --log-file scan.log --log-level warn
122
- aghast scan ./my-repo --config-dir ./my-checks --model claude-sonnet-4-20250514`;
191
+ aghast scan ./my-repo --config-dir ./my-checks --model claude-sonnet-4-20250514
192
+
193
+ ${docsFooter('scanning.md')}`;
123
194
  function parseArgs(args) {
124
- if (args.length < 1 || args[0] === '--help' || args[0] === '-h') {
195
+ if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
125
196
  console.log(SCAN_HELP);
126
197
  process.exit(0);
127
198
  }
@@ -145,6 +216,16 @@ function parseArgs(args) {
145
216
  let diffFile;
146
217
  let budgetLimitCost;
147
218
  let budgetLimitTokens;
219
+ let prNumber;
220
+ let prRepo;
221
+ let baseSha;
222
+ let headSha;
223
+ let judgeModel;
224
+ let judgeProvider;
225
+ let judgeConcurrency;
226
+ const judgeDropFalsePositives = args.includes('--judge-drop-false-positives');
227
+ let judgeMinConfidence;
228
+ let retryMaxAttempts;
148
229
  for (let i = startIdx; i < args.length; i++) {
149
230
  switch (args[i]) {
150
231
  case '--config-dir': {
@@ -290,7 +371,126 @@ function parseArgs(args) {
290
371
  i++;
291
372
  break;
292
373
  }
293
- // --fail-on-check-failure and --debug are handled above via includes()
374
+ case '--pr': {
375
+ const raw = args[i + 1];
376
+ if (!raw) {
377
+ console.error(formatError(ERROR_CODES.E1001, '--pr requires a pull request number'));
378
+ process.exit(1);
379
+ }
380
+ const parsedNum = Number.parseInt(raw, 10);
381
+ if (!Number.isInteger(parsedNum) || parsedNum <= 0) {
382
+ console.error(formatError(ERROR_CODES.E1001, `--pr requires a positive integer (got "${raw}")`));
383
+ process.exit(1);
384
+ }
385
+ prNumber = parsedNum;
386
+ i++;
387
+ break;
388
+ }
389
+ case '--repo': {
390
+ prRepo = args[i + 1];
391
+ if (!prRepo) {
392
+ console.error(formatError(ERROR_CODES.E1001, '--repo requires an owner/repo argument'));
393
+ process.exit(1);
394
+ }
395
+ i++;
396
+ break;
397
+ }
398
+ case '--base-sha': {
399
+ baseSha = args[i + 1];
400
+ if (!baseSha) {
401
+ console.error(formatError(ERROR_CODES.E1001, '--base-sha requires a SHA argument'));
402
+ process.exit(1);
403
+ }
404
+ i++;
405
+ break;
406
+ }
407
+ case '--head-sha': {
408
+ headSha = args[i + 1];
409
+ if (!headSha) {
410
+ console.error(formatError(ERROR_CODES.E1001, '--head-sha requires a SHA argument'));
411
+ process.exit(1);
412
+ }
413
+ i++;
414
+ break;
415
+ }
416
+ case '--judge-model': {
417
+ judgeModel = args[i + 1];
418
+ if (!judgeModel) {
419
+ console.error(formatError(ERROR_CODES.E1001, '--judge-model requires a model name argument'));
420
+ process.exit(1);
421
+ }
422
+ i++;
423
+ break;
424
+ }
425
+ case '--judge-provider': {
426
+ judgeProvider = args[i + 1];
427
+ if (!judgeProvider) {
428
+ console.error(formatError(ERROR_CODES.E1001, '--judge-provider requires a provider name argument'));
429
+ process.exit(1);
430
+ }
431
+ i++;
432
+ break;
433
+ }
434
+ case '--judge-concurrency': {
435
+ const raw = args[i + 1];
436
+ if (!raw) {
437
+ console.error(formatError(ERROR_CODES.E1001, '--judge-concurrency requires a number argument'));
438
+ process.exit(1);
439
+ }
440
+ const n = Number(raw);
441
+ if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
442
+ console.error(formatError(ERROR_CODES.E1001, `--judge-concurrency must be a positive integer (got "${raw}")`));
443
+ process.exit(1);
444
+ }
445
+ judgeConcurrency = n;
446
+ i++;
447
+ break;
448
+ }
449
+ case '--judge-min-confidence': {
450
+ const raw = args[i + 1];
451
+ if (!raw) {
452
+ console.error(formatError(ERROR_CODES.E1001, '--judge-min-confidence requires a number argument (0.0–1.0)'));
453
+ process.exit(1);
454
+ }
455
+ const n = Number(raw);
456
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
457
+ console.error(formatError(ERROR_CODES.E1001, `--judge-min-confidence must be between 0 and 1 (got "${raw}")`));
458
+ process.exit(1);
459
+ }
460
+ judgeMinConfidence = n;
461
+ i++;
462
+ break;
463
+ }
464
+ case '--retry-max-attempts': {
465
+ const raw = args[i + 1];
466
+ if (!raw) {
467
+ console.error(formatError(ERROR_CODES.E1001, '--retry-max-attempts requires a number argument'));
468
+ process.exit(1);
469
+ }
470
+ const n = Number(raw);
471
+ if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
472
+ console.error(formatError(ERROR_CODES.E1001, `--retry-max-attempts must be an integer >= 1 (got "${raw}")`));
473
+ process.exit(1);
474
+ }
475
+ retryMaxAttempts = n;
476
+ i++;
477
+ break;
478
+ }
479
+ // Boolean flags: their values are read via args.includes() above, but each
480
+ // still needs a case here — without one it reaches `default` and is
481
+ // rejected as an unknown option.
482
+ case '--fail-on-check-failure':
483
+ case '--debug':
484
+ case '--judge-drop-false-positives':
485
+ break;
486
+ default: {
487
+ const arg = args[i];
488
+ if (arg?.startsWith('--')) {
489
+ const suggestion = arg === '--provider' ? ' Did you mean --agent-provider?' : '';
490
+ console.error(formatError(ERROR_CODES.E1002, `Unknown option: ${arg}.${suggestion}`));
491
+ process.exit(1);
492
+ }
493
+ }
294
494
  }
295
495
  }
296
496
  return {
@@ -299,8 +499,36 @@ function parseArgs(args) {
299
499
  runtimeConfigPath, model, agentProvider, genericPrompt,
300
500
  diffRef, diffFile,
301
501
  budgetLimitCost, budgetLimitTokens,
502
+ prNumber, prRepo, baseSha, headSha,
503
+ judgeModel, judgeProvider, judgeConcurrency,
504
+ judgeDropFalsePositives: judgeDropFalsePositives || undefined,
505
+ judgeMinConfidence,
506
+ retryMaxAttempts,
302
507
  };
303
508
  }
509
+ /**
510
+ * Parse "owner/repo" into its parts. Returns undefined when the input is
511
+ * missing or malformed so callers can produce a CLI-friendly error.
512
+ *
513
+ * Validates each half against `^[A-Za-z0-9_.-]+$` — the character set GitHub
514
+ * actually permits in owner/repo names. This rejects whitespace and shell
515
+ * metacharacters defensively, even though the parsed value is only ever
516
+ * interpolated into a `gh api` URL path (no shell).
517
+ */
518
+ const REPO_SLUG_PART = /^[A-Za-z0-9_.-]+$/;
519
+ export function parseRepoSlug(slug) {
520
+ if (!slug)
521
+ return undefined;
522
+ const parts = slug.split('/');
523
+ if (parts.length !== 2)
524
+ return undefined;
525
+ const [owner, repo] = parts;
526
+ if (!owner || !repo)
527
+ return undefined;
528
+ if (!REPO_SLUG_PART.test(owner) || !REPO_SLUG_PART.test(repo))
529
+ return undefined;
530
+ return { owner, repo };
531
+ }
304
532
  async function createProvider(useMock, agentProviderName, modelOverride) {
305
533
  if (useMock) {
306
534
  logProgress(TAG, `Mock provider enabled via AGHAST_MOCK_AI=${process.env.AGHAST_MOCK_AI}`);
@@ -359,6 +587,16 @@ async function validateConfigDir(configDir) {
359
587
  export async function runScan(args) {
360
588
  const globalTimer = createTimer();
361
589
  const parsed = parseArgs(args);
590
+ // Reset the per-process repo-snapshot cache so back-to-back programmatic
591
+ // invocations of `runScan` don't reuse a stale filesystem snapshot from a
592
+ // previous run (e.g. when callers edit the target repo between scans).
593
+ //
594
+ // Caveat: the cache is module-scoped, so two `runScan` invocations running
595
+ // *concurrently* in the same Node process will clobber each other's cached
596
+ // snapshots — each scan still works correctly, it just won't share the
597
+ // filesystem walk across invocations. Sequential invocations are the
598
+ // primary supported pattern.
599
+ clearRepoSnapshotCache();
362
600
  // --config-dir is required (CLI flag > AGHAST_CONFIG_DIR env var)
363
601
  const rawConfigDir = parsed.configDir || process.env.AGHAST_CONFIG_DIR;
364
602
  if (!rawConfigDir) {
@@ -441,7 +679,9 @@ export async function runScan(args) {
441
679
  const effectiveRepoPath = parsed.repositoryPath;
442
680
  const repoAnalysis = await analyzeRepository(effectiveRepoPath);
443
681
  const repoUrl = repoAnalysis?.repository.remoteUrl ?? effectiveRepoPath;
444
- const matchingChecks = filterChecksForRepository(allChecks, repoUrl);
682
+ const filtered = await filterChecksForRepositoryAsync(allChecks, repoUrl, effectiveRepoPath);
683
+ // Run lower-priority checks first; checks without a priority sort to the end.
684
+ const matchingChecks = sortChecksByPriority(filtered);
445
685
  logProgress(TAG, `Found ${matchingChecks.length} matching checks (of ${allChecks.length} total)`);
446
686
  if (matchingChecks.length === 0) {
447
687
  logProgress(TAG, 'No matching checks found for this repository');
@@ -452,6 +692,11 @@ export async function runScan(args) {
452
692
  // instructionsFile is already absolute from resolveChecks — validate against ''
453
693
  const validation = await validateCheck(check, '');
454
694
  if (!validation.valid) {
695
+ const emptyInstructionsError = validation.errors.find((error) => error.includes('Instructions file') && error.includes('is empty'));
696
+ if (emptyInstructionsError) {
697
+ console.error(formatError(ERROR_CODES.E2004, `Invalid check "${check.id}": ${emptyInstructionsError}`));
698
+ process.exit(1);
699
+ }
455
700
  logProgress(TAG, `Skipping invalid check "${check.id}": ${validation.errors.join(', ')}`);
456
701
  continue;
457
702
  }
@@ -627,8 +872,67 @@ export async function runScan(args) {
627
872
  logDebug(TAG, `Could not load scan history for budget check: ${err instanceof Error ? err.message : String(err)}`);
628
873
  }
629
874
  }
875
+ // ─── Judge stage setup ───
876
+ // Stage is enabled iff a judge model is resolvable from any config source.
877
+ // Provider defaults to the scan provider if not explicitly set (decision #8).
878
+ const resolvedJudgeModel = parsed.judgeModel ?? process.env.AGHAST_JUDGE_MODEL ?? runtimeConfig.judge?.model;
879
+ const resolvedJudgeProvider = parsed.judgeProvider ?? process.env.AGHAST_JUDGE_PROVIDER ?? runtimeConfig.judge?.provider;
880
+ const resolvedJudgeConcurrency = parsed.judgeConcurrency ?? runtimeConfig.judge?.concurrency;
881
+ const resolvedJudgeDropFP = parsed.judgeDropFalsePositives ?? runtimeConfig.judge?.dropFalsePositives;
882
+ const resolvedJudgeMinConf = parsed.judgeMinConfidence ?? runtimeConfig.judge?.minConfidence;
883
+ const mockJudgeEnv = process.env.AGHAST_MOCK_JUDGE;
884
+ const useMockJudge = !!(mockJudgeEnv && mockJudgeEnv !== 'false');
885
+ let judgeOptions;
886
+ if (resolvedJudgeModel) {
887
+ const judgeProviderName = resolvedJudgeProvider ?? agentProviderName;
888
+ let judgeProvider;
889
+ if (useMockJudge) {
890
+ logProgress(TAG, `Mock judge provider enabled via AGHAST_MOCK_JUDGE=${mockJudgeEnv}`);
891
+ judgeProvider = await createMockJudgeProvider(resolvedJudgeModel);
892
+ }
893
+ else if (useMock) {
894
+ // If scan uses mock, use mock for judge too (tests that set AGHAST_MOCK_AI but not AGHAST_MOCK_JUDGE)
895
+ logWarn(TAG, 'AGHAST_MOCK_AI is set; judge provider will also use mock (set AGHAST_MOCK_JUDGE to control judge response)');
896
+ judgeProvider = await createMockJudgeProvider(resolvedJudgeModel);
897
+ }
898
+ else {
899
+ const jp = createProviderByName(judgeProviderName);
900
+ await jp.initialize({ apiKey: process.env.ANTHROPIC_API_KEY, model: resolvedJudgeModel });
901
+ judgeProvider = jp;
902
+ }
903
+ judgeOptions = {
904
+ provider: judgeProvider,
905
+ providerName: useMockJudge || useMock ? 'mock' : judgeProviderName,
906
+ model: resolvedJudgeModel,
907
+ concurrency: resolvedJudgeConcurrency,
908
+ dropFalsePositives: resolvedJudgeDropFP,
909
+ minConfidence: resolvedJudgeMinConf,
910
+ };
911
+ logProgress(TAG, `Judge stage enabled: model=${resolvedJudgeModel}, provider=${judgeOptions.providerName}`);
912
+ }
913
+ // Retry is opt-in. Precedence matches every other setting:
914
+ // CLI --retry-max-attempts > AGHAST_RETRY_MAX_ATTEMPTS > runtime config.
915
+ // When none is supplied this stays undefined and the scan runner's default
916
+ // (one attempt, no backoff, no circuit breaker) applies.
917
+ let resolvedRetry = runtimeConfig.retry;
918
+ const envRetryAttempts = process.env.AGHAST_RETRY_MAX_ATTEMPTS;
919
+ let retryAttemptsOverride = parsed.retryMaxAttempts;
920
+ if (retryAttemptsOverride === undefined && envRetryAttempts !== undefined) {
921
+ const n = Number(envRetryAttempts);
922
+ if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
923
+ console.error(formatError(ERROR_CODES.E1001, `AGHAST_RETRY_MAX_ATTEMPTS must be an integer >= 1 (got "${envRetryAttempts}")`));
924
+ process.exit(1);
925
+ }
926
+ retryAttemptsOverride = n;
927
+ }
928
+ if (retryAttemptsOverride !== undefined) {
929
+ resolvedRetry = { ...resolvedRetry, maxAttempts: retryAttemptsOverride };
930
+ }
931
+ if (resolvedRetry?.maxAttempts !== undefined && resolvedRetry.maxAttempts > 1) {
932
+ logProgress(TAG, `Retry enabled: up to ${resolvedRetry.maxAttempts} attempts per AI call`);
933
+ }
630
934
  try {
631
- const outcome = await runMultiScanWithCost({
935
+ const scanOptions = {
632
936
  repositoryPath: effectiveRepoPath,
633
937
  checks: checksWithDetails,
634
938
  agentProvider: provider,
@@ -646,7 +950,13 @@ export async function runScan(args) {
646
950
  // Prefer the provider's resolved auth mode (covers auto-detected local login);
647
951
  // fall back to the env var for providers without the concept (e.g. mock).
648
952
  isLocalClaude: provider?.isLocalMode?.() ?? (process.env.AGHAST_LOCAL_CLAUDE === 'true'),
649
- });
953
+ judge: judgeOptions,
954
+ // Undefined when absent, which leaves the scan runner on its defaults —
955
+ // and the default is retry OFF, so a scan with no retry configuration
956
+ // anywhere fails fast exactly as it did before retry existed.
957
+ retry: resolvedRetry,
958
+ };
959
+ const outcome = await runMultiScanWithCost(scanOptions);
650
960
  const results = outcome.results;
651
961
  // Resolve output path: --output flag > runtime config dir > default
652
962
  let resolvedOutputPath;
@@ -662,6 +972,17 @@ export async function runScan(args) {
662
972
  }
663
973
  await mkdir(dirname(resolvedOutputPath), { recursive: true });
664
974
  await writeFile(resolvedOutputPath, formatter.format(results), 'utf-8');
975
+ // Optional: write one file per issue alongside the main report (Spec E.3.2).
976
+ let individualIssueDir;
977
+ let individualIssueCount = 0;
978
+ if (runtimeConfig.reporting?.includeIndividualIssueFiles && results.issues.length > 0) {
979
+ const issueFormat = runtimeConfig.reporting.individualIssueFormat ?? 'markdown';
980
+ const issueOutputDir = dirname(resolvedOutputPath);
981
+ const written = await writeIndividualIssueFiles(results, issueOutputDir, issueFormat);
982
+ individualIssueDir = written.rootDir;
983
+ individualIssueCount = written.files.length;
984
+ logProgress(TAG, `Wrote ${individualIssueCount} individual issue file(s) to ${individualIssueDir}`);
985
+ }
665
986
  // Summary output
666
987
  const statusIcon = results.summary.failedChecks > 0
667
988
  ? 'ISSUES DETECTED'
@@ -695,8 +1016,16 @@ export async function runScan(args) {
695
1016
  const equiv = outcome.costCoveredBySubscription ? ' equivalent' : '';
696
1017
  console.log(` Cost: $${outcome.totalCostUsd.toFixed(4)}${equiv} ${label}`);
697
1018
  }
1019
+ if (outcome.judgeSummary) {
1020
+ const js = outcome.judgeSummary;
1021
+ const truePos = js.judgedIssues - js.falsePositives - js.uncertainJudgements;
1022
+ console.log(` Judged: ${js.judgedIssues} issues: ${truePos} true / ${js.falsePositives} false / ${js.uncertainJudgements} uncertain (judge: ${outcome.judgeModel})`);
1023
+ }
698
1024
  console.log(` Duration: ${globalTimer.elapsedStr()}`);
699
1025
  console.log(` Results: ${resolvedOutputPath}`);
1026
+ if (individualIssueDir) {
1027
+ console.log(` Issue files: ${individualIssueCount} in ${individualIssueDir}`);
1028
+ }
700
1029
  console.log('='.repeat(60));
701
1030
  // Persist the scan record to history (best-effort — never blocks exit).
702
1031
  // We DO save the record even when the scan was aborted by budget: per-period
@@ -741,6 +1070,32 @@ export async function runScan(args) {
741
1070
  console.error(formatError(ERROR_CODES.E7001, reason));
742
1071
  logProgress(TAG, `Scan aborted by budget: ${reason}`);
743
1072
  }
1073
+ // ─── Optional: post findings as PR comments ──────────────────────────────
1074
+ if (parsed.prNumber !== undefined) {
1075
+ const repoSlug = parsed.prRepo ?? process.env.GITHUB_REPOSITORY;
1076
+ const repoParts = parseRepoSlug(repoSlug);
1077
+ if (!repoParts) {
1078
+ console.error(formatError(ERROR_CODES.E1001, '--pr was supplied but --repo (or $GITHUB_REPOSITORY) is missing or not in owner/repo form'));
1079
+ await closeAllHandlers();
1080
+ process.exit(1);
1081
+ }
1082
+ try {
1083
+ const headSha = parsed.headSha ?? process.env.GITHUB_HEAD_SHA ?? process.env.GITHUB_SHA;
1084
+ const baseSha = parsed.baseSha ?? process.env.GITHUB_BASE_SHA;
1085
+ const summary = await postPRComments(results, {
1086
+ owner: repoParts.owner,
1087
+ repo: repoParts.repo,
1088
+ prNumber: parsed.prNumber,
1089
+ baseSha,
1090
+ headSha,
1091
+ });
1092
+ console.log(` PR comments: posted ${summary.posted}, skipped ${summary.skipped}`);
1093
+ }
1094
+ catch (err) {
1095
+ console.error(formatError(ERROR_CODES.E7201, `Failed to post PR comments: ${err instanceof Error ? err.message : String(err)}`));
1096
+ // Non-fatal: don't override scan exit code, but log a warning.
1097
+ }
1098
+ }
744
1099
  // Exit code based on --fail-on-check-failure flag or runtime config (spec Section 9.3),
745
1100
  // OR a budget abort.
746
1101
  const failOnCheckFailure = parsed.failOnCheckFailure || runtimeConfig.failOnCheckFailure === true;