solidity-argus 0.3.3 → 0.3.4

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.
@@ -1,9 +1,16 @@
1
+ import { existsSync } from "node:fs"
1
2
  import path from "node:path"
2
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
3
4
  import { loadArgusConfig } from "../config/loader"
4
5
  import type { ArgusConfig } from "../config/types"
6
+ import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
7
+ import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
5
8
  import { createLogger } from "../shared/logger"
6
9
  import { resolveProjectDir } from "../shared/project-utils"
10
+ import { resolveReportPath } from "../shared/report-path-resolver"
11
+ import { normalizeToCanonicalFinding } from "../state/adapters"
12
+ import { stableHash } from "../state/projectors"
13
+ import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
7
14
  import type { AuditState, Finding, FindingSeverity } from "../state/types"
8
15
 
9
16
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
@@ -13,7 +20,9 @@ type ReportGeneratorArgs = {
13
20
  scope: string[]
14
21
  include_executive_summary?: boolean
15
22
  severity_threshold?: SeverityThreshold
16
- audit_state: string
23
+ quality_gate_policy?: QualityGatePolicy
24
+ report_input?: string
25
+ audit_state?: string
17
26
  }
18
27
 
19
28
  type FindingsCount = {
@@ -28,13 +37,77 @@ export type ReportGenerationResult = {
28
37
  report: string
29
38
  findingsCount: FindingsCount
30
39
  filename: string
40
+ contentHash: string
41
+ qualityGates: ReportQualityValidation
42
+ contractDiagnostics: DropDiagnostic[]
31
43
  filePath?: string
44
+ error?: { code: string; message: string }
45
+ }
46
+
47
+ type QualityGatePolicy = "warn" | "strict-fail"
48
+
49
+ type ReportQualityViolation = {
50
+ findingId: string
51
+ code: string
52
+ message: string
53
+ }
54
+
55
+ type ReportQualityValidation = {
56
+ passed: boolean
57
+ violations: ReportQualityViolation[]
32
58
  }
33
59
 
34
60
  export type ReportGenerationDependencies = {
35
61
  loadConfig?: (projectDir: string) => ArgusConfig
36
62
  }
37
63
 
64
+ export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
65
+
66
+ const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
67
+
68
+ /**
69
+ * Extract the run_id from report metadata embedded as an HTML comment.
70
+ * Returns null if no metadata is found or run_id is missing.
71
+ */
72
+ export function extractReportRunId(content: string): string | null {
73
+ const match = content.match(REPORT_METADATA_REGEX)
74
+ if (!match?.[1]) return null
75
+ try {
76
+ const metadata = JSON.parse(match[1])
77
+ return typeof metadata.run_id === "string" ? metadata.run_id : null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ function buildReportMetadataComment(runId: string): string {
84
+ const metadata = {
85
+ run_id: runId,
86
+ policy_version: SINGLE_WRITER_POLICY_VERSION,
87
+ }
88
+ return `<!-- argus:report_metadata ${JSON.stringify(metadata)} -->`
89
+ }
90
+
91
+ async function checkDuplicateWrite(
92
+ filePath: string,
93
+ runId: string,
94
+ ): Promise<{ code: string; message: string } | null> {
95
+ if (!existsSync(filePath)) return null
96
+ try {
97
+ const existingContent = await Bun.file(filePath).text()
98
+ const existingRunId = extractReportRunId(existingContent)
99
+ if (existingRunId === runId) {
100
+ return {
101
+ code: "DUPLICATE_WRITE_ATTEMPT",
102
+ message: `Report for run_id "${runId}" already exists at ${filePath}. Single-writer policy (v${SINGLE_WRITER_POLICY_VERSION}) prevents duplicate writes for the same run.`,
103
+ }
104
+ }
105
+ } catch {
106
+ // Cannot read existing file; allow write
107
+ }
108
+ return null
109
+ }
110
+
38
111
  const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
39
112
 
40
113
  const SEVERITY_PREFIX: Record<FindingSeverity, string> = {
@@ -61,6 +134,24 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
61
134
  Informational: 1,
62
135
  }
63
136
 
137
+ const SEVERITY_RANK: Record<FindingSeverity, number> = {
138
+ Critical: 0,
139
+ High: 1,
140
+ Medium: 2,
141
+ Low: 3,
142
+ Informational: 4,
143
+ }
144
+
145
+ const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
146
+ const MISSING_RECOMMENDATION_TEXT =
147
+ "Recommendation details were not provided in the finding payload."
148
+
149
+ type ReportFindingFields = {
150
+ impact?: string
151
+ recommendation?: string
152
+ proofOfConcept?: string
153
+ }
154
+
64
155
  function emptyCounts(): FindingsCount {
65
156
  return {
66
157
  critical: 0,
@@ -252,21 +343,237 @@ function normalizeFinding(f: Record<string, unknown>): Finding {
252
343
  source,
253
344
  remediation: typeof f.remediation === "string" ? f.remediation : undefined,
254
345
  exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
346
+ ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
347
+ ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
348
+ ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
349
+ ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
350
+ } as Finding
351
+ }
352
+
353
+ export type ParseAuditStateOptions = {
354
+ dropPolicy?: DropPolicy
355
+ }
356
+
357
+ export type ParseAuditStateResult = {
358
+ state: AuditState
359
+ diagnostics: DropDiagnostic[]
360
+ }
361
+
362
+ type ParseReportInputResult = {
363
+ reportInput: ReportInput
364
+ diagnostics: DropDiagnostic[]
365
+ }
366
+
367
+ function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
368
+ return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
369
+ }
370
+
371
+ function throwContractMismatch(message: string, diagnostics: DropDiagnostic[]): never {
372
+ const details = diagnosticsSummary(diagnostics)
373
+ const fullMessage = details.length > 0 ? `${message}. Diagnostics: ${details}` : message
374
+ throw new Error(fullMessage)
375
+ }
376
+
377
+ function reportInputToAuditState(reportInput: ReportInput): AuditState {
378
+ return {
379
+ sessionId: reportInput.session_id,
380
+ projectDir: reportInput.projectDir,
381
+ contractsReviewed: Array.from(
382
+ new Set(reportInput.findings.map((finding) => finding.file)),
383
+ ).sort((a, b) => a.localeCompare(b)),
384
+ findings: reportInput.findings,
385
+ toolsExecuted: reportInput.toolsExecuted,
386
+ currentPhase: "complete",
387
+ scope: reportInput.scope,
388
+ startTime: 0,
389
+ soloditResults: reportInput.soloditResults,
390
+ fuzzCounterexamples: reportInput.fuzzCounterexamples,
391
+ coverageReport: reportInput.coverageReport,
392
+ gasHotspots: reportInput.gasHotspots,
393
+ proxyContracts: reportInput.proxyContracts,
394
+ patternVersion: reportInput.patternVersion,
395
+ skillsLoaded: reportInput.skillsLoaded,
396
+ }
397
+ }
398
+
399
+ function buildLegacyCompatibleReportInput(
400
+ state: AuditState,
401
+ context: ToolContext,
402
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
403
+ ): ReportInput {
404
+ diagnostics.warn(
405
+ "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
406
+ "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
407
+ "audit_state",
408
+ )
409
+
410
+ const runId = state.sessionId || context.sessionID || "legacy-run"
411
+ const sessionId = state.sessionId || context.sessionID || runId
412
+
413
+ if (!state.sessionId) {
414
+ diagnostics.warn(
415
+ "REPORT_INPUT_SYNTHESIZED_SESSION",
416
+ "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
417
+ "session_id",
418
+ )
419
+ }
420
+ if (!state.projectDir) {
421
+ diagnostics.warn(
422
+ "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
423
+ "Legacy payload missing projectDir; synthesized projectDir from tool context.",
424
+ "projectDir",
425
+ )
426
+ }
427
+
428
+ const canonicalFindings = state.findings
429
+ .map((finding, index) => {
430
+ const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
431
+ for (const diag of normalized.diagnostics) {
432
+ diagnostics.warn(
433
+ "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
434
+ `[index:${index}] ${diag.message}`,
435
+ diag.field,
436
+ )
437
+ }
438
+ return normalized.data
439
+ })
440
+ .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
441
+
442
+ return {
443
+ run_id: runId,
444
+ seq: state.toolsExecuted.length + canonicalFindings.length,
445
+ session_id: sessionId,
446
+ tool_call_id: "legacy-adapter",
447
+ source: "report-generator-legacy-adapter",
448
+ schema_version: SCHEMA_VERSION,
449
+ projectDir: state.projectDir || resolveProjectDir(context),
450
+ findings: canonicalFindings,
451
+ toolsExecuted: state.toolsExecuted.map((toolExec) => ({
452
+ ...toolExec,
453
+ run_id: runId,
454
+ schema_version: SCHEMA_VERSION,
455
+ })),
456
+ scope: state.scope,
457
+ soloditResults: state.soloditResults,
458
+ fuzzCounterexamples: state.fuzzCounterexamples,
459
+ coverageReport: state.coverageReport,
460
+ gasHotspots: state.gasHotspots,
461
+ proxyContracts: state.proxyContracts,
462
+ patternVersion: state.patternVersion,
463
+ skillsLoaded: state.skillsLoaded,
464
+ }
465
+ }
466
+
467
+ function parseReportInputPayload(
468
+ args: ReportGeneratorArgs,
469
+ context: ToolContext,
470
+ ): ParseReportInputResult {
471
+ const diagnostics = createDropDiagnosticsCollector(
472
+ "warn",
473
+ "report-generator",
474
+ "argus_generate_report",
475
+ )
476
+
477
+ if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
478
+ let parsed: unknown
479
+ try {
480
+ parsed = JSON.parse(args.report_input)
481
+ } catch {
482
+ diagnostics.error(
483
+ "REPORT_INPUT_MALFORMED_JSON",
484
+ "report_input is not valid JSON. Expected serialized ReportInput object.",
485
+ "report_input",
486
+ )
487
+ throwContractMismatch(
488
+ "ReportInput contract mismatch: malformed report_input JSON",
489
+ diagnostics.getDiagnostics(),
490
+ )
491
+ }
492
+
493
+ const validation = validateReportInput(parsed)
494
+ if (!validation.success) {
495
+ for (const error of validation.errors) {
496
+ diagnostics.error(
497
+ "REPORT_INPUT_CONTRACT_MISMATCH",
498
+ `${error.field}: ${error.message}`,
499
+ error.field,
500
+ )
501
+ }
502
+ throwContractMismatch(
503
+ "ReportInput contract mismatch: report_input failed schema validation",
504
+ diagnostics.getDiagnostics(),
505
+ )
506
+ }
507
+
508
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
509
+ diagnostics.warn(
510
+ "REPORT_INPUT_LEGACY_FIELD_IGNORED",
511
+ "Both report_input and audit_state were provided; audit_state is ignored.",
512
+ "audit_state",
513
+ )
514
+ }
515
+
516
+ return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
517
+ }
518
+
519
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
520
+ const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
521
+ for (const diagnostic of legacy.diagnostics) {
522
+ diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
523
+ }
524
+ const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
525
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
526
+ }
527
+
528
+ diagnostics.error(
529
+ "REPORT_INPUT_MISSING",
530
+ "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
531
+ "report_input",
532
+ )
533
+ throwContractMismatch(
534
+ "ReportInput contract mismatch: missing required payload",
535
+ diagnostics.getDiagnostics(),
536
+ )
537
+ }
538
+
539
+ function emitDropDiagnosticsForFindings(
540
+ rawItems: unknown[],
541
+ normalized: Record<string, unknown>[],
542
+ validFindings: Finding[],
543
+ diag: ReturnType<typeof createDropDiagnosticsCollector>,
544
+ ): void {
545
+ const droppedCount = rawItems.length - validFindings.length
546
+ if (droppedCount <= 0) return
547
+
548
+ for (const item of normalized) {
549
+ if (hasMinimumFindingFields(item)) continue
550
+ const missing: string[] = []
551
+ if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
552
+ if (typeof item.file !== "string") missing.push("file")
553
+ if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
554
+ diag.error(
555
+ "MISSING_REQUIRED_FIELD",
556
+ `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
557
+ missing[0],
558
+ )
255
559
  }
256
560
  }
257
561
 
258
- export function parseAuditState(auditState: string): AuditState {
562
+ export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
563
+ const policy = options?.dropPolicy ?? "warn"
564
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
565
+
259
566
  let parsed: unknown
260
567
  try {
261
568
  parsed = JSON.parse(auditState)
262
569
  } catch {
570
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
571
+ diag.throwIfStrict()
263
572
  throw new Error(
264
573
  "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
265
574
  )
266
575
  }
267
576
 
268
- const logger = createLogger()
269
-
270
577
  if (Array.isArray(parsed)) {
271
578
  const rawItems = parsed as unknown[]
272
579
  const normalized = rawItems
@@ -275,12 +582,8 @@ export function parseAuditState(auditState: string): AuditState {
275
582
  const validFindings = normalized
276
583
  .filter(hasMinimumFindingFields)
277
584
  .map((f) => normalizeFinding(f as Record<string, unknown>))
278
- const dropped = rawItems.length - validFindings.length
279
- if (dropped > 0) {
280
- logger.warn(
281
- `parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
282
- )
283
- }
585
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
586
+ diag.throwIfStrict()
284
587
  return emptyAuditState(validFindings)
285
588
  }
286
589
 
@@ -297,12 +600,8 @@ export function parseAuditState(auditState: string): AuditState {
297
600
  const validFindings = normalized
298
601
  .filter(hasMinimumFindingFields)
299
602
  .map((f) => normalizeFinding(f as Record<string, unknown>))
300
- const dropped = rawFindings.length - validFindings.length
301
- if (dropped > 0) {
302
- logger.warn(
303
- `parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
304
- )
305
- }
603
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
604
+ diag.throwIfStrict()
306
605
  return {
307
606
  ...emptyAuditState(),
308
607
  ...state,
@@ -313,6 +612,59 @@ export function parseAuditState(auditState: string): AuditState {
313
612
  return emptyAuditState()
314
613
  }
315
614
 
615
+ export function parseAuditStateWithDiagnostics(
616
+ auditState: string,
617
+ options?: ParseAuditStateOptions,
618
+ ): ParseAuditStateResult {
619
+ const policy = options?.dropPolicy ?? "warn"
620
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
621
+
622
+ let parsed: unknown
623
+ try {
624
+ parsed = JSON.parse(auditState)
625
+ } catch {
626
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
627
+ diag.throwIfStrict()
628
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
629
+ }
630
+
631
+ if (Array.isArray(parsed)) {
632
+ const rawItems = parsed as unknown[]
633
+ const normalized = rawItems
634
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
635
+ .map((item) => normalizeRawFinding(item))
636
+ const validFindings = normalized
637
+ .filter(hasMinimumFindingFields)
638
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
639
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
640
+ diag.throwIfStrict()
641
+ return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
642
+ }
643
+
644
+ if (
645
+ typeof parsed === "object" &&
646
+ parsed !== null &&
647
+ Array.isArray((parsed as AuditState).findings)
648
+ ) {
649
+ const auditStateObj = parsed as AuditState
650
+ const rawFindings = auditStateObj.findings as unknown[]
651
+ const normalized = rawFindings
652
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
653
+ .map((item) => normalizeRawFinding(item))
654
+ const validFindings = normalized
655
+ .filter(hasMinimumFindingFields)
656
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
657
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
658
+ diag.throwIfStrict()
659
+ return {
660
+ state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
661
+ diagnostics: diag.getDiagnostics(),
662
+ }
663
+ }
664
+
665
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
666
+ }
667
+
316
668
  function normalizeTitle(check: string): string {
317
669
  if (!check || typeof check !== "string") return "Unknown Check"
318
670
  return check
@@ -384,6 +736,146 @@ function genericRecommendation(severity: FindingSeverity): string {
384
736
  return "Track and resolve during routine code quality and documentation improvements."
385
737
  }
386
738
 
739
+ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
740
+ return finding as Finding & ReportFindingFields
741
+ }
742
+
743
+ function getFindingImpact(finding: Finding): string {
744
+ const extended = getExtendedFinding(finding)
745
+ if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
746
+ return extended.impact.trim()
747
+ }
748
+ return MISSING_IMPACT_TEXT
749
+ }
750
+
751
+ function getFindingRecommendation(finding: Finding): string {
752
+ const extended = getExtendedFinding(finding)
753
+ if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
754
+ return extended.recommendation.trim()
755
+ }
756
+ if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
757
+ return finding.remediation.trim()
758
+ }
759
+ return MISSING_RECOMMENDATION_TEXT
760
+ }
761
+
762
+ function getPocEvidence(finding: Finding): string | undefined {
763
+ const extended = getExtendedFinding(finding)
764
+ if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
765
+ return extended.proofOfConcept.trim()
766
+ }
767
+ if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
768
+ return finding.exploitReference.trim()
769
+ }
770
+ return undefined
771
+ }
772
+
773
+ function compareFindingsDeterministically(a: Finding, b: Finding): number {
774
+ const severityDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
775
+ if (severityDelta !== 0) return severityDelta
776
+
777
+ const fileDelta = a.file.localeCompare(b.file)
778
+ if (fileDelta !== 0) return fileDelta
779
+
780
+ const lineDelta = (a.lines[0] ?? 0) - (b.lines[0] ?? 0)
781
+ if (lineDelta !== 0) return lineDelta
782
+
783
+ return a.id.localeCompare(b.id)
784
+ }
785
+
786
+ function sortFindingsDeterministically(findings: Finding[]): Finding[] {
787
+ return [...findings].sort(compareFindingsDeterministically)
788
+ }
789
+
790
+ export function validateReportQuality(
791
+ findings: Finding[],
792
+ policy: QualityGatePolicy,
793
+ ): ReportQualityValidation {
794
+ const violations: ReportQualityViolation[] = []
795
+
796
+ for (const finding of findings) {
797
+ const findingId = finding.id
798
+ const impact = getFindingImpact(finding)
799
+ const recommendation = getFindingRecommendation(finding)
800
+ const severity = finding.severity
801
+
802
+ if (!finding.id || !finding.check || !finding.file || !Array.isArray(finding.lines)) {
803
+ violations.push({
804
+ findingId,
805
+ code: "schema.missing-required",
806
+ message: "Finding is missing required fields for deterministic report rendering.",
807
+ })
808
+ }
809
+
810
+ if (!finding.description || finding.description.trim().length === 0) {
811
+ violations.push({
812
+ findingId,
813
+ code: "completeness.missing-description",
814
+ message: "Finding description must be non-empty.",
815
+ })
816
+ }
817
+
818
+ if (!finding.source || finding.source.trim().length === 0) {
819
+ violations.push({
820
+ findingId,
821
+ code: "provenance.missing-source",
822
+ message: "Finding source is required for provenance traceability.",
823
+ })
824
+ }
825
+
826
+ if (severity !== "Critical" && severity !== "High") {
827
+ continue
828
+ }
829
+
830
+ if (
831
+ impact.length === 0 ||
832
+ impact === MISSING_IMPACT_TEXT ||
833
+ impact === genericImpact(severity)
834
+ ) {
835
+ violations.push({
836
+ findingId,
837
+ code: "severity-justification.missing-impact",
838
+ message: `${severity} findings must include specific non-generic impact details.`,
839
+ })
840
+ }
841
+
842
+ if (
843
+ recommendation.length === 0 ||
844
+ recommendation === MISSING_RECOMMENDATION_TEXT ||
845
+ recommendation === genericRecommendation(severity)
846
+ ) {
847
+ violations.push({
848
+ findingId,
849
+ code: "severity-justification.missing-recommendation",
850
+ message: `${severity} findings must include specific non-generic recommendations.`,
851
+ })
852
+ }
853
+
854
+ if (getPocEvidence(finding) == null) {
855
+ violations.push({
856
+ findingId,
857
+ code: "severity-justification.missing-poc",
858
+ message: `${severity} findings must satisfy PoC policy with exploitReference or proofOfConcept.`,
859
+ })
860
+ }
861
+ }
862
+
863
+ if (policy === "warn" && violations.length > 0) {
864
+ const logger = createLogger()
865
+ logger.warn(`[report-generator] quality gates failed with ${violations.length} violation(s)`)
866
+ for (const violation of violations) {
867
+ logger.warn(
868
+ `[report-generator] [${violation.code}] finding=${violation.findingId}: ${violation.message}`,
869
+ )
870
+ }
871
+ }
872
+
873
+ return {
874
+ passed: violations.length === 0,
875
+ violations,
876
+ }
877
+ }
878
+
387
879
  function buildRecommendations(counts: FindingsCount): string[] {
388
880
  const items: string[] = []
389
881
 
@@ -434,7 +926,8 @@ function buildFindingsSection(findings: Finding[]): string {
434
926
  const prefix = SEVERITY_PREFIX[severity]
435
927
  const findingId = `[${prefix}-${index + 1}]`
436
928
  const title = normalizeTitle(finding.check)
437
- const recommendation = finding.remediation ?? genericRecommendation(severity)
929
+ const recommendation = getFindingRecommendation(finding)
930
+ const impact = getFindingImpact(finding)
438
931
 
439
932
  lines.push(`### ${findingId} ${title}`)
440
933
  lines.push(`**Severity**: ${finding.severity}`)
@@ -443,9 +936,14 @@ function buildFindingsSection(findings: Finding[]): string {
443
936
  lines.push("")
444
937
  lines.push(`**Description**: ${finding.description}`)
445
938
  lines.push("")
446
- lines.push(`**Impact**: ${genericImpact(finding.severity)}`)
939
+ lines.push(`**Impact**: ${impact}`)
447
940
  lines.push("")
448
941
  lines.push(`**Recommendation**: ${recommendation}`)
942
+ const pocEvidence = getPocEvidence(finding)
943
+ if (pocEvidence) {
944
+ lines.push("")
945
+ lines.push(`**PoC / Evidence**: ${pocEvidence}`)
946
+ }
449
947
  lines.push("")
450
948
  })
451
949
  }
@@ -465,7 +963,7 @@ export function buildProvenanceAppendix(
465
963
  ): string {
466
964
  const lines: string[] = ["## Appendix: Data Provenance"]
467
965
 
468
- lines.push("- Data source: `audit_state` payload")
966
+ lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
469
967
  lines.push(`- Severity threshold applied: ${threshold}`)
470
968
  lines.push(`- Findings included in report: ${includedCount}`)
471
969
 
@@ -479,7 +977,11 @@ export function buildProvenanceAppendix(
479
977
  lines.push("")
480
978
  lines.push("| Source | Count |")
481
979
  lines.push("| --- | ---: |")
482
- for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => b[1] - a[1])) {
980
+ for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => {
981
+ const countDelta = b[1] - a[1]
982
+ if (countDelta !== 0) return countDelta
983
+ return a[0].localeCompare(b[0])
984
+ })) {
483
985
  lines.push(`| ${source} | ${count} |`)
484
986
  }
485
987
  }
@@ -562,8 +1064,19 @@ export async function executeReportGeneration(
562
1064
  ): Promise<ReportGenerationResult> {
563
1065
  const includeExecutiveSummary = args.include_executive_summary ?? true
564
1066
  const threshold = args.severity_threshold ?? "low"
565
- const state = parseAuditState(args.audit_state)
566
- const findings = state.findings.filter((finding) => shouldIncludeFinding(finding, threshold))
1067
+ const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1068
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1069
+ const state = reportInputToAuditState(reportInput)
1070
+ const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1071
+ const findings = sortFindingsDeterministically(
1072
+ state.findings.filter((finding) => shouldIncludeFinding(finding, threshold)),
1073
+ )
1074
+ const qualityGates = validateReportQuality(findings, qualityGatePolicy)
1075
+ if (!qualityGates.passed && qualityGatePolicy === "strict-fail") {
1076
+ throw new Error(
1077
+ `Report quality gates failed: ${JSON.stringify({ passed: false, violations: qualityGates.violations })}`,
1078
+ )
1079
+ }
567
1080
  const counts = calculateCounts(findings)
568
1081
  const auditDate = new Date().toISOString().slice(0, 10)
569
1082
 
@@ -590,10 +1103,10 @@ export async function executeReportGeneration(
590
1103
 
591
1104
  sections.push("## Scope")
592
1105
  sections.push("Contracts in scope:")
593
- if (args.scope.length === 0) {
1106
+ if (scope.length === 0) {
594
1107
  sections.push("- None provided")
595
1108
  } else {
596
- for (const contract of args.scope) {
1109
+ for (const contract of scope) {
597
1110
  sections.push(`- ${contract}`)
598
1111
  }
599
1112
  }
@@ -606,7 +1119,7 @@ export async function executeReportGeneration(
606
1119
  sections.push("- Pattern Analysis")
607
1120
  sections.push("- Solodit research cross-referencing")
608
1121
  sections.push(
609
- "Approach: Findings were normalized, deduplicated by detector signature and location, then prioritized by severity and confidence.",
1122
+ "Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
610
1123
  )
611
1124
 
612
1125
  sections.push(buildFindingsSection(findings))
@@ -618,14 +1131,28 @@ export async function executeReportGeneration(
618
1131
 
619
1132
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
620
1133
 
1134
+ // Embed report metadata for single-writer policy enforcement
1135
+ const runId = reportInput.run_id || state.sessionId || ""
1136
+ if (runId) {
1137
+ sections.push(buildReportMetadataComment(runId))
1138
+ }
1139
+
621
1140
  const reportMarkdown = sections.join("\n\n")
622
- const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
623
- const diskFilename = `${safeName}-${Date.now()}.md`
1141
+ const contentHash = stableHash(reportMarkdown)
1142
+ const { filename: canonicalFilename } = resolveReportPath({
1143
+ contractName: args.project_name,
1144
+ date: new Date(auditDate),
1145
+ outputDir: ".opencode/reports/",
1146
+ runId: runId || undefined,
1147
+ })
624
1148
 
625
1149
  const result: ReportGenerationResult = {
626
1150
  report: reportMarkdown,
627
1151
  findingsCount: counts,
628
- filename: `${args.project_name}-audit-report-${auditDate}.md`,
1152
+ filename: canonicalFilename,
1153
+ contentHash,
1154
+ qualityGates,
1155
+ contractDiagnostics: diagnostics,
629
1156
  }
630
1157
 
631
1158
  try {
@@ -633,7 +1160,17 @@ export async function executeReportGeneration(
633
1160
  const projectDir = resolveProjectDir(context)
634
1161
  const config = loadConfig(projectDir)
635
1162
  const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
636
- const fullPath = path.join(projectDir, outputDir, diskFilename)
1163
+ const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1164
+
1165
+ // Single-writer policy: check for duplicate writes with same run_id
1166
+ if (runId) {
1167
+ const duplicateError = await checkDuplicateWrite(fullPath, runId)
1168
+ if (duplicateError) {
1169
+ result.error = duplicateError
1170
+ return result
1171
+ }
1172
+ }
1173
+
637
1174
  await Bun.write(fullPath, reportMarkdown)
638
1175
  result.filePath = fullPath
639
1176
  } catch (err: unknown) {
@@ -647,7 +1184,7 @@ export async function executeReportGeneration(
647
1184
 
648
1185
  export const reportGeneratorTool = tool({
649
1186
  description:
650
- "Generate a professional markdown security audit report from serialized findings and audit context.",
1187
+ "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
651
1188
  args: {
652
1189
  project_name: tool.schema.string(),
653
1190
  scope: tool.schema.array(tool.schema.string()),
@@ -655,7 +1192,8 @@ export const reportGeneratorTool = tool({
655
1192
  severity_threshold: tool.schema
656
1193
  .enum(["critical", "high", "medium", "low", "informational"])
657
1194
  .default("low"),
658
- audit_state: tool.schema.string(),
1195
+ report_input: tool.schema.string().optional(),
1196
+ audit_state: tool.schema.string().optional(),
659
1197
  },
660
1198
  async execute(args, context) {
661
1199
  const result = await executeReportGeneration(args, context)