solidity-argus 0.3.3 → 0.3.5

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/agents/argus-prompt.ts +67 -8
  3. package/src/agents/scribe-prompt.ts +13 -5
  4. package/src/cli/commands/init.ts +1 -1
  5. package/src/cli/index.ts +0 -0
  6. package/src/config/schema.ts +7 -2
  7. package/src/create-hooks.ts +116 -27
  8. package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
  9. package/src/features/migration/index.ts +14 -0
  10. package/src/features/migration/migration-adapter.ts +151 -0
  11. package/src/features/migration/parity-telemetry.ts +133 -0
  12. package/src/features/persistent-state/audit-state-manager.ts +28 -6
  13. package/src/features/persistent-state/event-sink.ts +175 -0
  14. package/src/features/persistent-state/findings-materializer.ts +51 -0
  15. package/src/features/persistent-state/index.ts +2 -0
  16. package/src/features/persistent-state/run-finalizer.ts +192 -0
  17. package/src/features/persistent-state/run-journal.ts +15 -4
  18. package/src/hooks/agent-tracker.ts +15 -0
  19. package/src/hooks/event-hook.ts +93 -1
  20. package/src/hooks/system-prompt-hook.ts +20 -0
  21. package/src/hooks/tool-tracking-hook.ts +263 -33
  22. package/src/shared/audit-artifact-resolver.ts +75 -0
  23. package/src/shared/drop-diagnostics.ts +108 -0
  24. package/src/shared/file-utils.ts +7 -2
  25. package/src/shared/index.ts +14 -0
  26. package/src/shared/path-root-resolver.ts +34 -0
  27. package/src/shared/report-path-resolver.ts +70 -0
  28. package/src/solodit-lifecycle.ts +86 -7
  29. package/src/state/adapters.ts +262 -0
  30. package/src/state/index.ts +15 -0
  31. package/src/state/projectors.ts +437 -0
  32. package/src/state/schemas.ts +453 -0
  33. package/src/state/types.ts +6 -0
  34. package/src/tools/report-generator-tool.ts +647 -36
  35. package/src/tools/report-preflight.ts +79 -0
  36. package/src/tools/solodit-search-tool.ts +15 -24
  37. package/src/utils/solodit-health.ts +18 -0
@@ -1,10 +1,19 @@
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 { readEvents } from "../features/persistent-state/event-sink"
7
+ import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
8
+ import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
5
9
  import { createLogger } from "../shared/logger"
6
10
  import { resolveProjectDir } from "../shared/project-utils"
11
+ import { resolveReportPath } from "../shared/report-path-resolver"
12
+ import { normalizeToCanonicalFinding } from "../state/adapters"
13
+ import { stableHash } from "../state/projectors"
14
+ import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
7
15
  import type { AuditState, Finding, FindingSeverity } from "../state/types"
16
+ import { checkReportPreflight } from "./report-preflight"
8
17
 
9
18
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
10
19
 
@@ -13,7 +22,10 @@ type ReportGeneratorArgs = {
13
22
  scope: string[]
14
23
  include_executive_summary?: boolean
15
24
  severity_threshold?: SeverityThreshold
16
- audit_state: string
25
+ quality_gate_policy?: QualityGatePolicy
26
+ report_input?: string
27
+ audit_state?: string
28
+ preflight_policy?: PreflightPolicy
17
29
  }
18
30
 
19
31
  type FindingsCount = {
@@ -28,11 +40,81 @@ export type ReportGenerationResult = {
28
40
  report: string
29
41
  findingsCount: FindingsCount
30
42
  filename: string
43
+ contentHash: string
44
+ qualityGates: ReportQualityValidation
45
+ contractDiagnostics: DropDiagnostic[]
31
46
  filePath?: string
47
+ error?: { code: string; message: string }
48
+ }
49
+
50
+ type QualityGatePolicy = "warn" | "strict-fail"
51
+
52
+ type PreflightPolicy = "warn" | "strict-fail"
53
+
54
+ type ReportQualityViolation = {
55
+ findingId: string
56
+ code: string
57
+ message: string
58
+ }
59
+
60
+ type ReportQualityValidation = {
61
+ passed: boolean
62
+ violations: ReportQualityViolation[]
32
63
  }
33
64
 
34
65
  export type ReportGenerationDependencies = {
35
66
  loadConfig?: (projectDir: string) => ArgusConfig
67
+ readEvents?: (
68
+ runId: string,
69
+ projectDir: string,
70
+ ) => Promise<import("../state/schemas").AuditEvent[]>
71
+ }
72
+
73
+ export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
74
+
75
+ const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
76
+
77
+ /**
78
+ * Extract the run_id from report metadata embedded as an HTML comment.
79
+ * Returns null if no metadata is found or run_id is missing.
80
+ */
81
+ export function extractReportRunId(content: string): string | null {
82
+ const match = content.match(REPORT_METADATA_REGEX)
83
+ if (!match?.[1]) return null
84
+ try {
85
+ const metadata = JSON.parse(match[1])
86
+ return typeof metadata.run_id === "string" ? metadata.run_id : null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ function buildReportMetadataComment(runId: string): string {
93
+ const metadata = {
94
+ run_id: runId,
95
+ policy_version: SINGLE_WRITER_POLICY_VERSION,
96
+ }
97
+ return `<!-- argus:report_metadata ${JSON.stringify(metadata)} -->`
98
+ }
99
+
100
+ async function checkDuplicateWrite(
101
+ filePath: string,
102
+ runId: string,
103
+ ): Promise<{ code: string; message: string } | null> {
104
+ if (!existsSync(filePath)) return null
105
+ try {
106
+ const existingContent = await Bun.file(filePath).text()
107
+ const existingRunId = extractReportRunId(existingContent)
108
+ if (existingRunId === runId) {
109
+ return {
110
+ code: "DUPLICATE_WRITE_ATTEMPT",
111
+ 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.`,
112
+ }
113
+ }
114
+ } catch {
115
+ // Cannot read existing file; allow write
116
+ }
117
+ return null
36
118
  }
37
119
 
38
120
  const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
@@ -61,6 +143,24 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
61
143
  Informational: 1,
62
144
  }
63
145
 
146
+ const SEVERITY_RANK: Record<FindingSeverity, number> = {
147
+ Critical: 0,
148
+ High: 1,
149
+ Medium: 2,
150
+ Low: 3,
151
+ Informational: 4,
152
+ }
153
+
154
+ const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
155
+ const MISSING_RECOMMENDATION_TEXT =
156
+ "Recommendation details were not provided in the finding payload."
157
+
158
+ type ReportFindingFields = {
159
+ impact?: string
160
+ recommendation?: string
161
+ proofOfConcept?: string
162
+ }
163
+
64
164
  function emptyCounts(): FindingsCount {
65
165
  return {
66
166
  critical: 0,
@@ -252,21 +352,237 @@ function normalizeFinding(f: Record<string, unknown>): Finding {
252
352
  source,
253
353
  remediation: typeof f.remediation === "string" ? f.remediation : undefined,
254
354
  exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
355
+ ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
356
+ ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
357
+ ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
358
+ ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
359
+ } as Finding
360
+ }
361
+
362
+ export type ParseAuditStateOptions = {
363
+ dropPolicy?: DropPolicy
364
+ }
365
+
366
+ export type ParseAuditStateResult = {
367
+ state: AuditState
368
+ diagnostics: DropDiagnostic[]
369
+ }
370
+
371
+ type ParseReportInputResult = {
372
+ reportInput: ReportInput
373
+ diagnostics: DropDiagnostic[]
374
+ }
375
+
376
+ function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
377
+ return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
378
+ }
379
+
380
+ function throwContractMismatch(message: string, diagnostics: DropDiagnostic[]): never {
381
+ const details = diagnosticsSummary(diagnostics)
382
+ const fullMessage = details.length > 0 ? `${message}. Diagnostics: ${details}` : message
383
+ throw new Error(fullMessage)
384
+ }
385
+
386
+ function reportInputToAuditState(reportInput: ReportInput): AuditState {
387
+ return {
388
+ sessionId: reportInput.session_id,
389
+ projectDir: reportInput.projectDir,
390
+ contractsReviewed: Array.from(
391
+ new Set(reportInput.findings.map((finding) => finding.file)),
392
+ ).sort((a, b) => a.localeCompare(b)),
393
+ findings: reportInput.findings,
394
+ toolsExecuted: reportInput.toolsExecuted,
395
+ currentPhase: "complete",
396
+ scope: reportInput.scope,
397
+ startTime: 0,
398
+ soloditResults: reportInput.soloditResults,
399
+ fuzzCounterexamples: reportInput.fuzzCounterexamples,
400
+ coverageReport: reportInput.coverageReport,
401
+ gasHotspots: reportInput.gasHotspots,
402
+ proxyContracts: reportInput.proxyContracts,
403
+ patternVersion: reportInput.patternVersion,
404
+ skillsLoaded: reportInput.skillsLoaded,
405
+ }
406
+ }
407
+
408
+ function buildLegacyCompatibleReportInput(
409
+ state: AuditState,
410
+ context: ToolContext,
411
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
412
+ ): ReportInput {
413
+ diagnostics.warn(
414
+ "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
415
+ "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
416
+ "audit_state",
417
+ )
418
+
419
+ const runId = state.sessionId || context.sessionID || "legacy-run"
420
+ const sessionId = state.sessionId || context.sessionID || runId
421
+
422
+ if (!state.sessionId) {
423
+ diagnostics.warn(
424
+ "REPORT_INPUT_SYNTHESIZED_SESSION",
425
+ "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
426
+ "session_id",
427
+ )
428
+ }
429
+ if (!state.projectDir) {
430
+ diagnostics.warn(
431
+ "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
432
+ "Legacy payload missing projectDir; synthesized projectDir from tool context.",
433
+ "projectDir",
434
+ )
435
+ }
436
+
437
+ const canonicalFindings = state.findings
438
+ .map((finding, index) => {
439
+ const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
440
+ for (const diag of normalized.diagnostics) {
441
+ diagnostics.warn(
442
+ "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
443
+ `[index:${index}] ${diag.message}`,
444
+ diag.field,
445
+ )
446
+ }
447
+ return normalized.data
448
+ })
449
+ .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
450
+
451
+ return {
452
+ run_id: runId,
453
+ seq: state.toolsExecuted.length + canonicalFindings.length,
454
+ session_id: sessionId,
455
+ tool_call_id: "legacy-adapter",
456
+ source: "report-generator-legacy-adapter",
457
+ schema_version: SCHEMA_VERSION,
458
+ projectDir: state.projectDir || resolveProjectDir(context),
459
+ findings: canonicalFindings,
460
+ toolsExecuted: state.toolsExecuted.map((toolExec) => ({
461
+ ...toolExec,
462
+ run_id: runId,
463
+ schema_version: SCHEMA_VERSION,
464
+ })),
465
+ scope: state.scope,
466
+ soloditResults: state.soloditResults,
467
+ fuzzCounterexamples: state.fuzzCounterexamples,
468
+ coverageReport: state.coverageReport,
469
+ gasHotspots: state.gasHotspots,
470
+ proxyContracts: state.proxyContracts,
471
+ patternVersion: state.patternVersion,
472
+ skillsLoaded: state.skillsLoaded,
473
+ }
474
+ }
475
+
476
+ function parseReportInputPayload(
477
+ args: ReportGeneratorArgs,
478
+ context: ToolContext,
479
+ ): ParseReportInputResult {
480
+ const diagnostics = createDropDiagnosticsCollector(
481
+ "warn",
482
+ "report-generator",
483
+ "argus_generate_report",
484
+ )
485
+
486
+ if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
487
+ let parsed: unknown
488
+ try {
489
+ parsed = JSON.parse(args.report_input)
490
+ } catch {
491
+ diagnostics.error(
492
+ "REPORT_INPUT_MALFORMED_JSON",
493
+ "report_input is not valid JSON. Expected serialized ReportInput object.",
494
+ "report_input",
495
+ )
496
+ throwContractMismatch(
497
+ "ReportInput contract mismatch: malformed report_input JSON",
498
+ diagnostics.getDiagnostics(),
499
+ )
500
+ }
501
+
502
+ const validation = validateReportInput(parsed)
503
+ if (!validation.success) {
504
+ for (const error of validation.errors) {
505
+ diagnostics.error(
506
+ "REPORT_INPUT_CONTRACT_MISMATCH",
507
+ `${error.field}: ${error.message}`,
508
+ error.field,
509
+ )
510
+ }
511
+ throwContractMismatch(
512
+ "ReportInput contract mismatch: report_input failed schema validation",
513
+ diagnostics.getDiagnostics(),
514
+ )
515
+ }
516
+
517
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
518
+ diagnostics.warn(
519
+ "REPORT_INPUT_LEGACY_FIELD_IGNORED",
520
+ "Both report_input and audit_state were provided; audit_state is ignored.",
521
+ "audit_state",
522
+ )
523
+ }
524
+
525
+ return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
255
526
  }
527
+
528
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
529
+ const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
530
+ for (const diagnostic of legacy.diagnostics) {
531
+ diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
532
+ }
533
+ const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
534
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
535
+ }
536
+
537
+ diagnostics.error(
538
+ "REPORT_INPUT_MISSING",
539
+ "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
540
+ "report_input",
541
+ )
542
+ throwContractMismatch(
543
+ "ReportInput contract mismatch: missing required payload",
544
+ diagnostics.getDiagnostics(),
545
+ )
256
546
  }
257
547
 
258
- export function parseAuditState(auditState: string): AuditState {
548
+ function emitDropDiagnosticsForFindings(
549
+ rawItems: unknown[],
550
+ normalized: Record<string, unknown>[],
551
+ validFindings: Finding[],
552
+ diag: ReturnType<typeof createDropDiagnosticsCollector>,
553
+ ): void {
554
+ const droppedCount = rawItems.length - validFindings.length
555
+ if (droppedCount <= 0) return
556
+
557
+ for (const item of normalized) {
558
+ if (hasMinimumFindingFields(item)) continue
559
+ const missing: string[] = []
560
+ if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
561
+ if (typeof item.file !== "string") missing.push("file")
562
+ if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
563
+ diag.error(
564
+ "MISSING_REQUIRED_FIELD",
565
+ `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
566
+ missing[0],
567
+ )
568
+ }
569
+ }
570
+
571
+ export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
572
+ const policy = options?.dropPolicy ?? "warn"
573
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
574
+
259
575
  let parsed: unknown
260
576
  try {
261
577
  parsed = JSON.parse(auditState)
262
578
  } catch {
579
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
580
+ diag.throwIfStrict()
263
581
  throw new Error(
264
582
  "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
265
583
  )
266
584
  }
267
585
 
268
- const logger = createLogger()
269
-
270
586
  if (Array.isArray(parsed)) {
271
587
  const rawItems = parsed as unknown[]
272
588
  const normalized = rawItems
@@ -275,12 +591,8 @@ export function parseAuditState(auditState: string): AuditState {
275
591
  const validFindings = normalized
276
592
  .filter(hasMinimumFindingFields)
277
593
  .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
- }
594
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
595
+ diag.throwIfStrict()
284
596
  return emptyAuditState(validFindings)
285
597
  }
286
598
 
@@ -297,12 +609,8 @@ export function parseAuditState(auditState: string): AuditState {
297
609
  const validFindings = normalized
298
610
  .filter(hasMinimumFindingFields)
299
611
  .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
- }
612
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
613
+ diag.throwIfStrict()
306
614
  return {
307
615
  ...emptyAuditState(),
308
616
  ...state,
@@ -313,6 +621,59 @@ export function parseAuditState(auditState: string): AuditState {
313
621
  return emptyAuditState()
314
622
  }
315
623
 
624
+ export function parseAuditStateWithDiagnostics(
625
+ auditState: string,
626
+ options?: ParseAuditStateOptions,
627
+ ): ParseAuditStateResult {
628
+ const policy = options?.dropPolicy ?? "warn"
629
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
630
+
631
+ let parsed: unknown
632
+ try {
633
+ parsed = JSON.parse(auditState)
634
+ } catch {
635
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
636
+ diag.throwIfStrict()
637
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
638
+ }
639
+
640
+ if (Array.isArray(parsed)) {
641
+ const rawItems = parsed as unknown[]
642
+ const normalized = rawItems
643
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
644
+ .map((item) => normalizeRawFinding(item))
645
+ const validFindings = normalized
646
+ .filter(hasMinimumFindingFields)
647
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
648
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
649
+ diag.throwIfStrict()
650
+ return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
651
+ }
652
+
653
+ if (
654
+ typeof parsed === "object" &&
655
+ parsed !== null &&
656
+ Array.isArray((parsed as AuditState).findings)
657
+ ) {
658
+ const auditStateObj = parsed as AuditState
659
+ const rawFindings = auditStateObj.findings as unknown[]
660
+ const normalized = rawFindings
661
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
662
+ .map((item) => normalizeRawFinding(item))
663
+ const validFindings = normalized
664
+ .filter(hasMinimumFindingFields)
665
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
666
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
667
+ diag.throwIfStrict()
668
+ return {
669
+ state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
670
+ diagnostics: diag.getDiagnostics(),
671
+ }
672
+ }
673
+
674
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
675
+ }
676
+
316
677
  function normalizeTitle(check: string): string {
317
678
  if (!check || typeof check !== "string") return "Unknown Check"
318
679
  return check
@@ -384,6 +745,146 @@ function genericRecommendation(severity: FindingSeverity): string {
384
745
  return "Track and resolve during routine code quality and documentation improvements."
385
746
  }
386
747
 
748
+ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
749
+ return finding as Finding & ReportFindingFields
750
+ }
751
+
752
+ function getFindingImpact(finding: Finding): string {
753
+ const extended = getExtendedFinding(finding)
754
+ if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
755
+ return extended.impact.trim()
756
+ }
757
+ return MISSING_IMPACT_TEXT
758
+ }
759
+
760
+ function getFindingRecommendation(finding: Finding): string {
761
+ const extended = getExtendedFinding(finding)
762
+ if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
763
+ return extended.recommendation.trim()
764
+ }
765
+ if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
766
+ return finding.remediation.trim()
767
+ }
768
+ return MISSING_RECOMMENDATION_TEXT
769
+ }
770
+
771
+ function getPocEvidence(finding: Finding): string | undefined {
772
+ const extended = getExtendedFinding(finding)
773
+ if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
774
+ return extended.proofOfConcept.trim()
775
+ }
776
+ if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
777
+ return finding.exploitReference.trim()
778
+ }
779
+ return undefined
780
+ }
781
+
782
+ function compareFindingsDeterministically(a: Finding, b: Finding): number {
783
+ const severityDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
784
+ if (severityDelta !== 0) return severityDelta
785
+
786
+ const fileDelta = a.file.localeCompare(b.file)
787
+ if (fileDelta !== 0) return fileDelta
788
+
789
+ const lineDelta = (a.lines[0] ?? 0) - (b.lines[0] ?? 0)
790
+ if (lineDelta !== 0) return lineDelta
791
+
792
+ return a.id.localeCompare(b.id)
793
+ }
794
+
795
+ function sortFindingsDeterministically(findings: Finding[]): Finding[] {
796
+ return [...findings].sort(compareFindingsDeterministically)
797
+ }
798
+
799
+ export function validateReportQuality(
800
+ findings: Finding[],
801
+ policy: QualityGatePolicy,
802
+ ): ReportQualityValidation {
803
+ const violations: ReportQualityViolation[] = []
804
+
805
+ for (const finding of findings) {
806
+ const findingId = finding.id
807
+ const impact = getFindingImpact(finding)
808
+ const recommendation = getFindingRecommendation(finding)
809
+ const severity = finding.severity
810
+
811
+ if (!finding.id || !finding.check || !finding.file || !Array.isArray(finding.lines)) {
812
+ violations.push({
813
+ findingId,
814
+ code: "schema.missing-required",
815
+ message: "Finding is missing required fields for deterministic report rendering.",
816
+ })
817
+ }
818
+
819
+ if (!finding.description || finding.description.trim().length === 0) {
820
+ violations.push({
821
+ findingId,
822
+ code: "completeness.missing-description",
823
+ message: "Finding description must be non-empty.",
824
+ })
825
+ }
826
+
827
+ if (!finding.source || finding.source.trim().length === 0) {
828
+ violations.push({
829
+ findingId,
830
+ code: "provenance.missing-source",
831
+ message: "Finding source is required for provenance traceability.",
832
+ })
833
+ }
834
+
835
+ if (severity !== "Critical" && severity !== "High") {
836
+ continue
837
+ }
838
+
839
+ if (
840
+ impact.length === 0 ||
841
+ impact === MISSING_IMPACT_TEXT ||
842
+ impact === genericImpact(severity)
843
+ ) {
844
+ violations.push({
845
+ findingId,
846
+ code: "severity-justification.missing-impact",
847
+ message: `${severity} findings must include specific non-generic impact details.`,
848
+ })
849
+ }
850
+
851
+ if (
852
+ recommendation.length === 0 ||
853
+ recommendation === MISSING_RECOMMENDATION_TEXT ||
854
+ recommendation === genericRecommendation(severity)
855
+ ) {
856
+ violations.push({
857
+ findingId,
858
+ code: "severity-justification.missing-recommendation",
859
+ message: `${severity} findings must include specific non-generic recommendations.`,
860
+ })
861
+ }
862
+
863
+ if (getPocEvidence(finding) == null) {
864
+ violations.push({
865
+ findingId,
866
+ code: "severity-justification.missing-poc",
867
+ message: `${severity} findings must satisfy PoC policy with exploitReference or proofOfConcept.`,
868
+ })
869
+ }
870
+ }
871
+
872
+ if (policy === "warn" && violations.length > 0) {
873
+ const logger = createLogger()
874
+ logger.warn(`[report-generator] quality gates failed with ${violations.length} violation(s)`)
875
+ for (const violation of violations) {
876
+ logger.warn(
877
+ `[report-generator] [${violation.code}] finding=${violation.findingId}: ${violation.message}`,
878
+ )
879
+ }
880
+ }
881
+
882
+ return {
883
+ passed: violations.length === 0,
884
+ violations,
885
+ }
886
+ }
887
+
387
888
  function buildRecommendations(counts: FindingsCount): string[] {
388
889
  const items: string[] = []
389
890
 
@@ -434,7 +935,8 @@ function buildFindingsSection(findings: Finding[]): string {
434
935
  const prefix = SEVERITY_PREFIX[severity]
435
936
  const findingId = `[${prefix}-${index + 1}]`
436
937
  const title = normalizeTitle(finding.check)
437
- const recommendation = finding.remediation ?? genericRecommendation(severity)
938
+ const recommendation = getFindingRecommendation(finding)
939
+ const impact = getFindingImpact(finding)
438
940
 
439
941
  lines.push(`### ${findingId} ${title}`)
440
942
  lines.push(`**Severity**: ${finding.severity}`)
@@ -443,9 +945,14 @@ function buildFindingsSection(findings: Finding[]): string {
443
945
  lines.push("")
444
946
  lines.push(`**Description**: ${finding.description}`)
445
947
  lines.push("")
446
- lines.push(`**Impact**: ${genericImpact(finding.severity)}`)
948
+ lines.push(`**Impact**: ${impact}`)
447
949
  lines.push("")
448
950
  lines.push(`**Recommendation**: ${recommendation}`)
951
+ const pocEvidence = getPocEvidence(finding)
952
+ if (pocEvidence) {
953
+ lines.push("")
954
+ lines.push(`**PoC / Evidence**: ${pocEvidence}`)
955
+ }
449
956
  lines.push("")
450
957
  })
451
958
  }
@@ -465,7 +972,7 @@ export function buildProvenanceAppendix(
465
972
  ): string {
466
973
  const lines: string[] = ["## Appendix: Data Provenance"]
467
974
 
468
- lines.push("- Data source: `audit_state` payload")
975
+ lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
469
976
  lines.push(`- Severity threshold applied: ${threshold}`)
470
977
  lines.push(`- Findings included in report: ${includedCount}`)
471
978
 
@@ -479,7 +986,11 @@ export function buildProvenanceAppendix(
479
986
  lines.push("")
480
987
  lines.push("| Source | Count |")
481
988
  lines.push("| --- | ---: |")
482
- for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => b[1] - a[1])) {
989
+ for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => {
990
+ const countDelta = b[1] - a[1]
991
+ if (countDelta !== 0) return countDelta
992
+ return a[0].localeCompare(b[0])
993
+ })) {
483
994
  lines.push(`| ${source} | ${count} |`)
484
995
  }
485
996
  }
@@ -491,9 +1002,25 @@ export function buildProvenanceAppendix(
491
1002
  lines.push("| Tool | Duration | Status | Findings |")
492
1003
  lines.push("| --- | --- | --- | ---: |")
493
1004
  for (const exec of state.toolsExecuted) {
494
- const duration = exec.endTime != null ? formatDuration(exec.endTime - exec.startTime) : ""
495
- const status = exec.success ? "✅ success" : "❌ failure"
496
- lines.push(`| ${exec.tool} | ${duration} | ${status} | ${exec.findingsCount} |`)
1005
+ const toolName = typeof exec.tool === "string" && exec.tool ? exec.tool : "(unknown tool)"
1006
+ const hasTimes =
1007
+ typeof exec.startTime === "number" &&
1008
+ !Number.isNaN(exec.startTime) &&
1009
+ exec.endTime != null &&
1010
+ typeof exec.endTime === "number" &&
1011
+ !Number.isNaN(exec.endTime)
1012
+ const duration = hasTimes ? formatDuration(exec.endTime! - exec.startTime) : "N/A"
1013
+ const status =
1014
+ typeof exec.success === "boolean"
1015
+ ? exec.success
1016
+ ? "\u2705 success"
1017
+ : "\u274C failure"
1018
+ : "\u26A0 malformed"
1019
+ const findings =
1020
+ typeof exec.findingsCount === "number" && !Number.isNaN(exec.findingsCount)
1021
+ ? exec.findingsCount
1022
+ : "N/A"
1023
+ lines.push(`| ${toolName} | ${duration} | ${status} | ${findings} |`)
497
1024
  }
498
1025
  }
499
1026
 
@@ -506,7 +1033,8 @@ export function buildProvenanceAppendix(
506
1033
  lines.push(`- Pattern pack version: \`${state.patternVersion}\``)
507
1034
  }
508
1035
  if (syncExec) {
509
- lines.push(`- SCVD last synced: ${new Date(syncExec.startTime).toISOString()}`)
1036
+ const syncTime = typeof syncExec.startTime === "number" && !Number.isNaN(syncExec.startTime) ? new Date(syncExec.startTime).toISOString() : "N/A"
1037
+ lines.push(`- SCVD last synced: ${syncTime}`)
510
1038
  }
511
1039
  }
512
1040
 
@@ -562,8 +1090,61 @@ export async function executeReportGeneration(
562
1090
  ): Promise<ReportGenerationResult> {
563
1091
  const includeExecutiveSummary = args.include_executive_summary ?? true
564
1092
  const threshold = args.severity_threshold ?? "low"
565
- const state = parseAuditState(args.audit_state)
566
- const findings = state.findings.filter((finding) => shouldIncludeFinding(finding, threshold))
1093
+ const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1094
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1095
+ const preflightPolicy = args.preflight_policy ?? "warn"
1096
+ let preflightWarningSection: string | null = null
1097
+ try {
1098
+ const readEventsFn = deps.readEvents ?? readEvents
1099
+ const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
1100
+ const preflightResult = checkReportPreflight(events)
1101
+ if (!preflightResult.passed) {
1102
+ if (preflightPolicy === "strict-fail") {
1103
+ const parts: string[] = []
1104
+ if (preflightResult.orphanedTools.length > 0)
1105
+ parts.push(`orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
1106
+ if (preflightResult.missingLifecycle.length > 0)
1107
+ parts.push(`missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
1108
+ if (preflightResult.missingRequiredTools.length > 0)
1109
+ parts.push(`missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
1110
+ throw new Error(`Preflight failed (strict-fail): ${parts.join("; ")}`)
1111
+ }
1112
+ const lines: string[] = [
1113
+ "## \u26A0 Completeness Warning",
1114
+ "",
1115
+ "This report was generated with incomplete orchestration state.",
1116
+ "",
1117
+ ]
1118
+ if (preflightResult.orphanedTools.length > 0)
1119
+ lines.push(`- Orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
1120
+ if (preflightResult.missingLifecycle.length > 0)
1121
+ lines.push(`- Missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
1122
+ if (preflightResult.missingRequiredTools.length > 0)
1123
+ lines.push(`- Missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
1124
+ if (preflightResult.warnings.length > 0)
1125
+ lines.push(`- Warnings: ${preflightResult.warnings.join(", ")}`)
1126
+ preflightWarningSection = lines.join("\n")
1127
+ }
1128
+ } catch (err) {
1129
+ if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
1130
+ throw err
1131
+ }
1132
+ if (preflightPolicy === "strict-fail") {
1133
+ throw new Error("Preflight failed: unable to read event stream for completeness check")
1134
+ }
1135
+ // warn mode: skip preflight when events cannot be read
1136
+ }
1137
+ const state = reportInputToAuditState(reportInput)
1138
+ const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1139
+ const findings = sortFindingsDeterministically(
1140
+ state.findings.filter((finding) => shouldIncludeFinding(finding, threshold)),
1141
+ )
1142
+ const qualityGates = validateReportQuality(findings, qualityGatePolicy)
1143
+ if (!qualityGates.passed && qualityGatePolicy === "strict-fail") {
1144
+ throw new Error(
1145
+ `Report quality gates failed: ${JSON.stringify({ passed: false, violations: qualityGates.violations })}`,
1146
+ )
1147
+ }
567
1148
  const counts = calculateCounts(findings)
568
1149
  const auditDate = new Date().toISOString().slice(0, 10)
569
1150
 
@@ -590,10 +1171,10 @@ export async function executeReportGeneration(
590
1171
 
591
1172
  sections.push("## Scope")
592
1173
  sections.push("Contracts in scope:")
593
- if (args.scope.length === 0) {
1174
+ if (scope.length === 0) {
594
1175
  sections.push("- None provided")
595
1176
  } else {
596
- for (const contract of args.scope) {
1177
+ for (const contract of scope) {
597
1178
  sections.push(`- ${contract}`)
598
1179
  }
599
1180
  }
@@ -606,7 +1187,7 @@ export async function executeReportGeneration(
606
1187
  sections.push("- Pattern Analysis")
607
1188
  sections.push("- Solodit research cross-referencing")
608
1189
  sections.push(
609
- "Approach: Findings were normalized, deduplicated by detector signature and location, then prioritized by severity and confidence.",
1190
+ "Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
610
1191
  )
611
1192
 
612
1193
  sections.push(buildFindingsSection(findings))
@@ -616,24 +1197,52 @@ export async function executeReportGeneration(
616
1197
  sections.push(`- ${item}`)
617
1198
  }
618
1199
 
1200
+ if (preflightWarningSection) {
1201
+ sections.push(preflightWarningSection)
1202
+ }
1203
+
619
1204
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
620
1205
 
1206
+ // Embed report metadata for single-writer policy enforcement
1207
+ const runId = reportInput.run_id || state.sessionId || ""
1208
+ if (runId) {
1209
+ sections.push(buildReportMetadataComment(runId))
1210
+ }
1211
+
621
1212
  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`
1213
+ const contentHash = stableHash(reportMarkdown)
1214
+ const { filename: canonicalFilename } = resolveReportPath({
1215
+ contractName: args.project_name,
1216
+ date: new Date(auditDate),
1217
+ outputDir: ".opencode/reports/",
1218
+ runId: runId || undefined,
1219
+ })
624
1220
 
625
1221
  const result: ReportGenerationResult = {
626
1222
  report: reportMarkdown,
627
1223
  findingsCount: counts,
628
- filename: `${args.project_name}-audit-report-${auditDate}.md`,
1224
+ filename: canonicalFilename,
1225
+ contentHash,
1226
+ qualityGates,
1227
+ contractDiagnostics: diagnostics,
629
1228
  }
630
1229
 
631
1230
  try {
632
1231
  const loadConfig = deps.loadConfig ?? loadArgusConfig
633
1232
  const projectDir = resolveProjectDir(context)
634
1233
  const config = loadConfig(projectDir)
635
- const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
636
- const fullPath = path.join(projectDir, outputDir, diskFilename)
1234
+ const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
1235
+ const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1236
+
1237
+ // Single-writer policy: check for duplicate writes with same run_id
1238
+ if (runId) {
1239
+ const duplicateError = await checkDuplicateWrite(fullPath, runId)
1240
+ if (duplicateError) {
1241
+ result.error = duplicateError
1242
+ return result
1243
+ }
1244
+ }
1245
+
637
1246
  await Bun.write(fullPath, reportMarkdown)
638
1247
  result.filePath = fullPath
639
1248
  } catch (err: unknown) {
@@ -647,7 +1256,7 @@ export async function executeReportGeneration(
647
1256
 
648
1257
  export const reportGeneratorTool = tool({
649
1258
  description:
650
- "Generate a professional markdown security audit report from serialized findings and audit context.",
1259
+ "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
651
1260
  args: {
652
1261
  project_name: tool.schema.string(),
653
1262
  scope: tool.schema.array(tool.schema.string()),
@@ -655,7 +1264,9 @@ export const reportGeneratorTool = tool({
655
1264
  severity_threshold: tool.schema
656
1265
  .enum(["critical", "high", "medium", "low", "informational"])
657
1266
  .default("low"),
658
- audit_state: tool.schema.string(),
1267
+ report_input: tool.schema.string().optional(),
1268
+ audit_state: tool.schema.string().optional(),
1269
+ preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
659
1270
  },
660
1271
  async execute(args, context) {
661
1272
  const result = await executeReportGeneration(args, context)