1use std::sync::Mutex;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use fallow_core::results::AnalysisResults;
10use fallow_types::envelope::{
11 BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
12 SchemaVersion, TelemetryMeta, ToolVersion,
13};
14use fallow_types::output::NextStep;
15use serde::Serialize;
16
17use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
18use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
19use crate::output_dupes::DupesReportPayload;
20use crate::report::dupes_grouping::DuplicationGroup;
21
22static LEGACY_ENVELOPE: AtomicBool = AtomicBool::new(false);
23static TELEMETRY_ANALYSIS_RUN_ID: Mutex<Option<String>> = Mutex::new(None);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EnvelopeMode {
27 Tagged,
28 Legacy,
29}
30
31impl EnvelopeMode {
32 #[must_use]
33 pub fn current() -> Self {
34 if LEGACY_ENVELOPE.load(Ordering::Relaxed) {
35 Self::Legacy
36 } else {
37 Self::Tagged
38 }
39 }
40}
41
42pub fn set_legacy_envelope(enabled: bool) {
43 LEGACY_ENVELOPE.store(enabled, Ordering::Relaxed);
44}
45
46pub fn set_telemetry_analysis_run_id(run_id: Option<String>) {
47 if let Ok(mut current) = TELEMETRY_ANALYSIS_RUN_ID.lock() {
48 *current = run_id;
49 }
50}
51
52fn telemetry_analysis_run_id() -> Option<String> {
53 TELEMETRY_ANALYSIS_RUN_ID
54 .lock()
55 .ok()
56 .and_then(|id| id.clone())
57}
58
59pub fn serialize_root_output(output: FallowOutput) -> Result<serde_json::Value, serde_json::Error> {
60 serialize_root_output_with_mode(output, EnvelopeMode::current())
61}
62
63pub fn serialize_root_output_without_telemetry(
64 output: FallowOutput,
65) -> Result<serde_json::Value, serde_json::Error> {
66 let mut value = serde_json::to_value(output)?;
67 if EnvelopeMode::current() == EnvelopeMode::Legacy {
68 remove_root_kind(&mut value);
69 }
70 Ok(value)
71}
72
73pub fn serialize_root_output_with_mode(
74 output: FallowOutput,
75 mode: EnvelopeMode,
76) -> Result<serde_json::Value, serde_json::Error> {
77 let mut value = serde_json::to_value(output)?;
78 if mode == EnvelopeMode::Legacy {
79 remove_root_kind(&mut value);
80 }
81 attach_telemetry_meta(&mut value);
82 Ok(value)
83}
84
85pub fn attach_telemetry_meta(value: &mut serde_json::Value) {
86 let Some(run_id) = telemetry_analysis_run_id() else {
87 return;
88 };
89 let serde_json::Value::Object(map) = value else {
90 return;
91 };
92 let meta = map
93 .entry("_meta".to_string())
94 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
95 if !meta.is_object() {
96 *meta = serde_json::Value::Object(serde_json::Map::new());
97 }
98 if let serde_json::Value::Object(meta_map) = meta {
99 meta_map.insert(
100 "telemetry".to_string(),
101 serde_json::json!({ "analysis_run_id": run_id }),
102 );
103 }
104}
105
106pub fn remove_root_kind(value: &mut serde_json::Value) {
110 if let serde_json::Value::Object(map) = value {
111 map.remove("kind");
112 }
113}
114
115pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
116 if EnvelopeMode::current() == EnvelopeMode::Tagged
117 && let serde_json::Value::Object(map) = value
118 {
119 map.insert(
120 "kind".to_string(),
121 serde_json::Value::String(kind.to_string()),
122 );
123 }
124}
125#[derive(Debug, Clone, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
129pub struct CoverageSetupOutput {
130 pub schema_version: CoverageSetupSchemaVersion,
131 pub framework_detected: CoverageSetupFramework,
132 pub package_manager: Option<CoverageSetupPackageManager>,
133 pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
134 pub members: Vec<CoverageSetupMember>,
135 pub config_written: Option<serde_json::Value>,
136 pub commands: Vec<String>,
137 pub files_to_edit: Vec<CoverageSetupFileToEdit>,
138 pub snippets: Vec<CoverageSetupSnippet>,
139 pub dockerfile_snippet: Option<String>,
140 pub next_steps: Vec<String>,
141 pub warnings: Vec<String>,
142 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
143 pub meta: Option<serde_json::Value>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub enum CoverageSetupSchemaVersion {
149 #[serde(rename = "1")]
150 V1,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(rename_all = "snake_case")]
156pub enum CoverageSetupFramework {
157 #[serde(rename = "nextjs")]
158 NextJs,
159 #[serde(rename = "nestjs")]
160 NestJs,
161 Nuxt,
162 #[serde(rename = "sveltekit")]
163 SvelteKit,
164 Astro,
165 Remix,
166 Vite,
167 PlainNode,
168 Unknown,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[serde(rename_all = "lowercase")]
174pub enum CoverageSetupPackageManager {
175 Npm,
176 Pnpm,
177 Yarn,
178 Bun,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183#[serde(rename_all = "lowercase")]
184pub enum CoverageSetupRuntimeTarget {
185 Node,
186 Browser,
187}
188
189#[derive(Debug, Clone, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct CoverageSetupMember {
192 pub name: String,
193 pub path: String,
194 pub framework_detected: CoverageSetupFramework,
195 pub package_manager: Option<CoverageSetupPackageManager>,
196 pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
197 pub files_to_edit: Vec<CoverageSetupFileToEdit>,
198 pub snippets: Vec<CoverageSetupSnippet>,
199 pub dockerfile_snippet: Option<String>,
200 pub warnings: Vec<String>,
201}
202
203#[derive(Debug, Clone, Serialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub struct CoverageSetupFileToEdit {
206 pub path: String,
207 pub reason: String,
208}
209
210#[derive(Debug, Clone, Serialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct CoverageSetupSnippet {
213 pub label: String,
214 pub path: String,
215 pub content: String,
216}
217
218#[derive(Debug, Clone, Serialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
222#[allow(
223 dead_code,
224 reason = "schema-source-of-truth: audit.rs still builds the wire via serde_json::json!; this struct locks the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
225)]
226pub struct AuditOutput {
227 pub schema_version: SchemaVersion,
228 pub version: ToolVersion,
229 pub command: AuditCommand,
230 pub verdict: AuditVerdict,
231 pub changed_files_count: u32,
232 pub base_ref: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub base_description: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub head_sha: Option<String>,
242 pub elapsed_ms: ElapsedMs,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub base_snapshot_skipped: Option<bool>,
245 pub summary: AuditSummary,
246 pub attribution: AuditAttribution,
247 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
248 pub meta: Option<Meta>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub dead_code: Option<CheckOutput>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub duplication: Option<DupesReportPayload>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub complexity: Option<HealthReport>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub next_steps: Vec<NextStep>,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
263#[serde(rename_all = "lowercase")]
264#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
265pub enum AuditCommand {
266 Audit,
267}
268
269#[derive(Debug, Clone, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[cfg_attr(
273 feature = "schema",
274 schemars(title = "fallow --format json (bare, combined)")
275)]
276pub struct CombinedOutput {
277 pub schema_version: SchemaVersion,
278 pub version: ToolVersion,
279 pub elapsed_ms: ElapsedMs,
280 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
281 pub meta: Option<CombinedMeta>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub check: Option<CheckOutput>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub dupes: Option<DupesReportPayload>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub health: Option<HealthReport>,
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub next_steps: Vec<NextStep>,
292}
293
294#[derive(Debug, Clone, Serialize)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296pub struct CombinedMeta {
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub check: Option<Meta>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub dupes: Option<Meta>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub health: Option<Meta>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub telemetry: Option<TelemetryMeta>,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
309pub enum CoverageAnalyzeSchemaVersion {
310 #[serde(rename = "1")]
311 V1,
312}
313
314#[derive(Debug, Clone, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[cfg_attr(
317 feature = "schema",
318 schemars(title = "fallow coverage analyze --format json")
319)]
320pub struct CoverageAnalyzeOutput {
321 pub schema_version: CoverageAnalyzeSchemaVersion,
322 pub version: ToolVersion,
323 pub elapsed_ms: ElapsedMs,
324 pub runtime_coverage: RuntimeCoverageReport,
325 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
326 pub meta: Option<Meta>,
327}
328
329#[derive(Debug, Clone, Serialize)]
330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
331#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
332pub struct DupesOutput {
333 pub schema_version: SchemaVersion,
334 pub version: ToolVersion,
335 pub elapsed_ms: ElapsedMs,
336 #[serde(flatten)]
337 pub report: DupesReportPayload,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub grouped_by: Option<GroupByMode>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub total_issues: Option<usize>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub groups: Option<Vec<DuplicationGroup>>,
344 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
347 pub meta: Option<Meta>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
355 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub next_steps: Vec<NextStep>,
359}
360
361#[derive(Debug, Clone, Serialize)]
371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
372#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
373pub struct CheckOutput {
374 pub schema_version: SchemaVersion,
375 pub version: ToolVersion,
376 pub elapsed_ms: ElapsedMs,
377 pub total_issues: usize,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub entry_points: Option<EntryPoints>,
380 pub summary: CheckSummary,
381 #[serde(flatten)]
382 pub results: AnalysisResults,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub baseline_deltas: Option<BaselineDeltas>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub baseline: Option<BaselineMatch>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub regression: Option<RegressionResult>,
389 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
390 pub meta: Option<Meta>,
391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub next_steps: Vec<NextStep>,
401}
402
403#[derive(Debug, Clone, Serialize)]
410#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
411#[cfg_attr(
412 feature = "schema",
413 schemars(
414 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
415 )
416)]
417pub struct CheckGroupedOutput {
418 pub schema_version: SchemaVersion,
419 pub version: ToolVersion,
420 pub elapsed_ms: ElapsedMs,
421 pub grouped_by: GroupByMode,
422 pub total_issues: usize,
423 pub groups: Vec<CheckGroupedEntry>,
424 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
425 pub meta: Option<Meta>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
429 pub next_steps: Vec<NextStep>,
430}
431
432#[derive(Debug, Clone, Serialize)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437pub struct CheckGroupedEntry {
438 pub key: String,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub owners: Option<Vec<String>>,
441 pub total_issues: usize,
442 #[serde(flatten)]
443 pub results: AnalysisResults,
444}
445
446#[derive(Debug, Clone, Serialize)]
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
460pub struct HealthOutput {
461 pub schema_version: SchemaVersion,
462 pub version: ToolVersion,
463 pub elapsed_ms: ElapsedMs,
464 #[serde(flatten)]
465 pub report: HealthReport,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub grouped_by: Option<GroupByMode>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub groups: Option<Vec<HealthGroup>>,
470 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
471 pub meta: Option<Meta>,
472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
473 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
477 pub next_steps: Vec<NextStep>,
478}
479
480#[derive(Debug, Clone, Serialize)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[cfg_attr(
489 feature = "schema",
490 schemars(title = "fallow explain <issue-type> --format json")
491)]
492pub struct ExplainOutput {
493 pub id: String,
494 pub name: String,
495 pub summary: String,
496 pub rationale: String,
497 pub example: String,
498 pub how_to_fix: String,
499 pub docs: String,
500}
501
502#[derive(Debug, Clone, Serialize)]
504#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
505#[cfg_attr(feature = "schema", schemars(title = "fallow inspect --format json"))]
506pub struct InspectOutput {
507 pub target: InspectTargetDescriptor,
508 pub identity: InspectIdentity,
509 pub evidence: InspectEvidence,
510 pub warnings: Vec<String>,
511}
512
513#[derive(Debug, Clone, Serialize)]
514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
515#[serde(tag = "type", rename_all = "snake_case")]
516pub enum InspectTargetDescriptor {
517 File { file: String },
518 Symbol { file: String, export_name: String },
519}
520
521#[derive(Debug, Clone, Serialize)]
522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
523#[serde(untagged)]
524pub enum InspectIdentity {
525 File(InspectFileIdentity),
526 Symbol(InspectSymbolIdentity),
527}
528
529#[derive(Debug, Clone, Serialize)]
530#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
531pub struct InspectFileIdentity {
532 pub file: String,
533 pub is_reachable: Option<serde_json::Value>,
534 pub is_entry_point: Option<serde_json::Value>,
535 pub export_count: Option<usize>,
536 pub import_count: Option<usize>,
537 pub imported_by_count: Option<usize>,
538}
539
540#[derive(Debug, Clone, Serialize)]
541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
542pub struct InspectSymbolIdentity {
543 pub file: String,
544 pub export_name: String,
545 pub file_reachable: Option<serde_json::Value>,
546 pub is_entry_point: Option<serde_json::Value>,
547 pub is_used: Option<serde_json::Value>,
548 pub reason: Option<serde_json::Value>,
549}
550
551#[derive(Debug, Clone, Serialize)]
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
553pub struct InspectEvidence {
554 pub trace_file: InspectEvidenceSection,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub trace_export: Option<InspectEvidenceSection>,
557 pub dead_code: InspectEvidenceSection,
558 pub duplication: InspectEvidenceSection,
559 pub complexity: InspectEvidenceSection,
560 pub security: InspectEvidenceSection,
561 pub impact_closure: InspectEvidenceSection,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub symbol_chain: Option<InspectEvidenceSection>,
570}
571
572#[derive(Debug, Clone, Serialize)]
573#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
574pub struct InspectEvidenceSection {
575 pub status: InspectSectionStatus,
576 pub scope: InspectEvidenceScope,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub message: Option<String>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub data: Option<serde_json::Value>,
581}
582
583impl InspectEvidenceSection {
584 #[must_use]
585 pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
586 Self {
587 status: InspectSectionStatus::Ok,
588 scope,
589 message: None,
590 data: Some(data),
591 }
592 }
593
594 #[must_use]
595 pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
596 Self {
597 status: InspectSectionStatus::Error,
598 scope,
599 message: Some(message),
600 data: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
606#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
607#[serde(rename_all = "snake_case")]
608pub enum InspectSectionStatus {
609 Ok,
610 Error,
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615#[serde(rename_all = "snake_case")]
616pub enum InspectEvidenceScope {
617 Symbol,
618 File,
619 ProjectFilteredToFile,
620}
621
622#[derive(Debug, Clone, Serialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627#[cfg_attr(
628 feature = "schema",
629 schemars(title = "fallow --format codeclimate / gitlab-codequality")
630)]
631#[serde(transparent)]
632#[allow(
633 dead_code,
634 reason = "schema-source-of-truth wrapper: runtime emits a `Vec<CodeClimateIssue>` directly via `codeclimate::issues_to_value`; this newtype exists so `schemars` can title and document the bare-array shape for the drift gate."
635)]
636pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
637
638#[derive(Debug, Clone, Serialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641pub struct CodeClimateIssue {
642 #[serde(rename = "type")]
643 pub kind: CodeClimateIssueKind,
644 pub check_name: String,
645 pub description: String,
646 pub categories: Vec<String>,
647 pub severity: CodeClimateSeverity,
648 pub fingerprint: String,
649 pub location: CodeClimateLocation,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
655#[serde(rename_all = "lowercase")]
656pub enum CodeClimateIssueKind {
657 Issue,
659}
660
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664#[serde(rename_all = "lowercase")]
665pub enum CodeClimateSeverity {
666 #[allow(
671 dead_code,
672 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
673 )]
674 Info,
675 Minor,
677 Major,
679 Critical,
681 #[allow(
684 dead_code,
685 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
686 )]
687 Blocker,
688}
689
690#[derive(Debug, Clone, Serialize)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693pub struct CodeClimateLocation {
694 pub path: String,
696 pub lines: CodeClimateLines,
699}
700
701#[derive(Debug, Clone, Copy, Serialize)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704pub struct CodeClimateLines {
705 pub begin: u32,
707}
708
709#[derive(Debug, Clone, Serialize)]
711#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
712#[cfg_attr(
713 feature = "schema",
714 schemars(title = "fallow --format review-github / review-gitlab")
715)]
716pub struct ReviewEnvelopeOutput {
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub event: Option<ReviewEnvelopeEvent>,
719 pub body: String,
720 #[serde(default = "ReviewEnvelopeSummary::empty_default")]
721 pub summary: ReviewEnvelopeSummary,
722 pub comments: Vec<ReviewComment>,
723 #[serde(default = "default_marker_regex")]
724 pub marker_regex: String,
725 #[serde(default = "default_marker_regex_flags")]
726 pub marker_regex_flags: String,
727 pub meta: ReviewEnvelopeMeta,
728}
729
730#[must_use]
732pub fn default_marker_regex() -> String {
733 MARKER_REGEX_V2.to_owned()
734}
735
736#[must_use]
738pub fn default_marker_regex_flags() -> String {
739 MARKER_REGEX_FLAGS_V2.to_owned()
740}
741
742pub const MARKER_REGEX_V2: &str =
744 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
745
746pub const MARKER_REGEX_FLAGS_V2: &str = "m";
748
749#[derive(Debug, Clone, Serialize, Default)]
751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
752pub struct ReviewEnvelopeSummary {
753 pub body: String,
754 pub fingerprint: String,
755}
756
757impl ReviewEnvelopeSummary {
758 #[must_use]
760 #[allow(
761 dead_code,
762 reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
763 )]
764 pub fn empty_default() -> Self {
765 Self::default()
766 }
767}
768
769#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
772pub enum ReviewEnvelopeEvent {
773 #[serde(rename = "COMMENT")]
774 Comment,
775}
776
777#[derive(Debug, Clone, Serialize)]
782#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
783#[serde(untagged)]
784pub enum ReviewComment {
785 GitHub(GitHubReviewComment),
786 GitLab(GitLabReviewComment),
787}
788
789#[derive(Debug, Clone, Serialize)]
791#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
792pub struct GitHubReviewComment {
793 pub path: String,
794 pub line: u32,
795 pub side: GitHubReviewSide,
796 pub body: String,
797 pub fingerprint: String,
798 #[serde(default, skip_serializing_if = "is_false")]
799 pub truncated: bool,
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805pub enum GitHubReviewSide {
806 #[serde(rename = "RIGHT")]
807 Right,
808}
809
810#[derive(Debug, Clone, Serialize)]
812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
813pub struct GitLabReviewComment {
814 pub body: String,
815 pub position: GitLabReviewPosition,
816 pub fingerprint: String,
817 #[serde(default, skip_serializing_if = "is_false")]
818 pub truncated: bool,
819}
820
821#[must_use]
829#[allow(
830 clippy::trivially_copy_pass_by_ref,
831 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
832)]
833pub fn is_false(value: &bool) -> bool {
834 !*value
835}
836
837#[derive(Debug, Clone, Serialize)]
840#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
841pub struct GitLabReviewPosition {
842 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub base_sha: Option<String>,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub start_sha: Option<String>,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub head_sha: Option<String>,
848 pub position_type: GitLabReviewPositionType,
849 pub old_path: String,
850 pub new_path: String,
851 pub new_line: u32,
852}
853
854#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
856#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
857#[serde(rename_all = "lowercase")]
858pub enum GitLabReviewPositionType {
859 Text,
860}
861
862#[derive(Debug, Clone, Serialize)]
864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
865pub struct ReviewEnvelopeMeta {
866 pub schema: ReviewEnvelopeSchema,
867 pub provider: ReviewProvider,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub check_conclusion: Option<ReviewCheckConclusion>,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
874#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
875pub enum ReviewEnvelopeSchema {
876 #[serde(rename = "fallow-review-envelope/v1")]
882 #[allow(
883 dead_code,
884 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
885 )]
886 V1,
887 #[serde(rename = "fallow-review-envelope/v2")]
901 V2,
902}
903
904#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
906#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
907#[serde(rename_all = "lowercase")]
908pub enum ReviewProvider {
909 Github,
911 Gitlab,
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
919#[serde(rename_all = "lowercase")]
920pub enum ReviewCheckConclusion {
921 Success,
923 Neutral,
925 Failure,
927}
928
929#[derive(Debug, Clone, Serialize)]
933#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
934#[cfg_attr(
935 feature = "schema",
936 schemars(title = "fallow ci reconcile-review --format json")
937)]
938pub struct ReviewReconcileOutput {
939 pub schema: ReviewReconcileSchema,
940 pub provider: ReviewProvider,
941 pub target: Option<String>,
942 pub dry_run: bool,
943 pub comments: u32,
944 pub current_fingerprints: u32,
945 pub existing_fingerprints: u32,
946 pub new_fingerprints: u32,
947 pub stale_fingerprints: u32,
948 pub new: Vec<String>,
949 pub stale: Vec<String>,
950 pub provider_warning: Option<String>,
951 pub resolution_comments_posted: u32,
952 pub threads_resolved: u32,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub apply_hint: Option<String>,
955 pub apply_errors: Vec<String>,
956 #[serde(default, skip_serializing_if = "Vec::is_empty")]
957 pub failed_fingerprints: Vec<String>,
958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
959 pub unapplied_fingerprints: Vec<String>,
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
964#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
965pub enum ReviewReconcileSchema {
966 #[serde(rename = "fallow-review-reconcile/v1")]
968 V1,
969}
970
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
977#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
978#[serde(rename_all = "lowercase")]
979pub enum GroupByMode {
980 Owner,
981 Directory,
982 Package,
983 Section,
984}
985#[derive(Debug, Clone, Serialize)]
991#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
992#[cfg_attr(
993 feature = "schema",
994 schemars(title = "fallow list --boundaries --format json")
995)]
996#[allow(
997 dead_code,
998 reason = "schema-source-of-truth: list.rs still builds the wire via serde_json::json!; this struct and its sub-types lock the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
999)]
1000pub struct ListBoundariesOutput {
1001 pub boundaries: BoundariesListing,
1002}
1003
1004#[derive(Debug, Clone, Serialize)]
1006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1007#[cfg_attr(
1008 feature = "schema",
1009 schemars(title = "fallow workspaces --format json")
1010)]
1011pub struct WorkspacesOutput {
1012 pub workspace_count: usize,
1014 pub workspaces: Vec<WorkspaceInfo>,
1017 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
1021}
1022
1023#[derive(Debug, Clone, Serialize)]
1025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1026pub struct WorkspaceInfo {
1027 pub name: String,
1030 pub path: String,
1033 pub is_internal_dependency: bool,
1036}
1037
1038#[derive(Debug, Clone, Serialize)]
1040#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1041#[allow(
1042 dead_code,
1043 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1044)]
1045pub struct BoundariesListing {
1046 pub configured: bool,
1047 pub zone_count: usize,
1048 pub zones: Vec<BoundariesListZone>,
1049 pub rule_count: usize,
1050 pub rules: Vec<BoundariesListRule>,
1051 pub logical_group_count: usize,
1052 pub logical_groups: Vec<BoundariesListLogicalGroup>,
1053}
1054
1055#[derive(Debug, Clone, Serialize)]
1058#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1059#[allow(
1060 dead_code,
1061 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1062)]
1063pub struct BoundariesListZone {
1064 pub name: String,
1065 pub patterns: Vec<String>,
1066 pub file_count: usize,
1067}
1068
1069#[derive(Debug, Clone, Serialize)]
1074#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1075#[allow(
1076 dead_code,
1077 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1078)]
1079pub struct BoundariesListRule {
1080 pub from: String,
1081 pub allow: Vec<String>,
1082}
1083
1084#[derive(Debug, Clone, Serialize)]
1089#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1090#[allow(
1091 dead_code,
1092 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1093)]
1094pub struct BoundariesListLogicalGroup {
1095 pub name: String,
1096 pub children: Vec<String>,
1097 pub auto_discover: Vec<String>,
1098 pub status: fallow_config::LogicalGroupStatus,
1099 pub source_zone_index: usize,
1100 pub file_count: usize,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1102 pub authored_rule: Option<fallow_config::AuthoredRule>,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1104 pub fallback_zone: Option<String>,
1105 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub merged_from: Option<Vec<usize>>,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub original_zone_root: Option<String>,
1109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1110 pub child_source_indices: Vec<usize>,
1111}
1112
1113#[derive(Debug, Clone, Serialize)]
1131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1132#[cfg_attr(
1133 feature = "schema",
1134 schemars(title = "fallow --format json (typed root)")
1135)]
1136#[serde(tag = "kind")]
1137#[allow(
1138 dead_code,
1139 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
1140)]
1141pub enum FallowOutput {
1142 #[serde(rename = "audit")]
1145 Audit(AuditOutput),
1146 #[serde(rename = "explain")]
1149 Explain(ExplainOutput),
1150 #[serde(rename = "inspect_target")]
1153 Inspect(InspectOutput),
1154 #[serde(rename = "trace")]
1160 Trace(fallow_core::trace_chain::SymbolChainTrace),
1161 #[serde(rename = "review-envelope")]
1164 ReviewEnvelope(ReviewEnvelopeOutput),
1165 #[serde(rename = "review-reconcile")]
1169 ReviewReconcile(ReviewReconcileOutput),
1170 #[serde(rename = "coverage-setup")]
1173 CoverageSetup(CoverageSetupOutput),
1174 #[serde(rename = "coverage-analyze")]
1178 CoverageAnalyze(CoverageAnalyzeOutput),
1179 #[serde(rename = "list-boundaries")]
1182 ListBoundaries(ListBoundariesOutput),
1183 #[serde(rename = "list-workspaces")]
1186 Workspaces(WorkspacesOutput),
1187 #[serde(rename = "health")]
1189 Health(HealthOutput),
1190 #[serde(rename = "dupes")]
1194 Dupes(DupesOutput),
1195 #[serde(rename = "dead-code-grouped")]
1198 CheckGrouped(CheckGroupedOutput),
1199 #[serde(rename = "impact")]
1203 Impact(crate::impact::ImpactReport),
1204 #[serde(rename = "impact-cross-repo")]
1209 ImpactCrossRepo(crate::impact::CrossRepoImpactReport),
1210 #[serde(rename = "security")]
1213 SecuritySummary(crate::security::SecuritySummaryOutput),
1214 #[serde(rename = "security")]
1219 Security(crate::security::SecurityOutput),
1220 #[serde(rename = "security-survivors")]
1223 SecuritySurvivors(crate::security::SecuritySurvivorsOutput),
1224 #[serde(rename = "security-blind-spots")]
1227 SecurityBlindSpots(crate::security::SecurityBlindSpotsOutput),
1228 #[serde(rename = "dead-code")]
1231 Check(CheckOutput),
1232 #[serde(rename = "combined")]
1236 Combined(CombinedOutput),
1237 #[serde(rename = "audit-brief")]
1242 AuditBrief(crate::audit_brief::ReviewBriefOutput),
1243 #[serde(rename = "decision-surface")]
1248 DecisionSurface(crate::audit_decision_surface::DecisionSurfaceOutput),
1249 #[serde(rename = "review-walkthrough-guide")]
1254 WalkthroughGuide(crate::audit_walkthrough::WalkthroughGuide),
1255 #[serde(rename = "review-walkthrough-validation")]
1260 WalkthroughValidation(crate::audit_walkthrough::WalkthroughValidation),
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1266
1267 use super::*;
1268
1269 static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1270
1271 struct TelemetryRunIdGuard {
1272 _lock: std::sync::MutexGuard<'static, ()>,
1273 }
1274
1275 impl TelemetryRunIdGuard {
1276 fn set(run_id: Option<&str>) -> Self {
1277 let lock = TEST_TELEMETRY_RUN_ID_LOCK
1278 .lock()
1279 .unwrap_or_else(|poisoned| poisoned.into_inner());
1280 set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1281 Self { _lock: lock }
1282 }
1283 }
1284
1285 impl Drop for TelemetryRunIdGuard {
1286 fn drop(&mut self) {
1287 set_telemetry_analysis_run_id(None);
1288 }
1289 }
1290
1291 fn combined_output() -> CombinedOutput {
1292 CombinedOutput {
1293 schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1294 version: ToolVersion("test".to_string()),
1295 elapsed_ms: ElapsedMs(0),
1296 meta: None,
1297 check: None,
1298 dupes: None,
1299 health: None,
1300 next_steps: Vec::new(),
1301 }
1302 }
1303
1304 #[test]
1305 fn root_output_serializes_kind_by_default() {
1306 let _guard = TelemetryRunIdGuard::set(None);
1307 let value = serialize_root_output_with_mode(
1308 FallowOutput::Combined(combined_output()),
1309 EnvelopeMode::Tagged,
1310 )
1311 .expect("combined root should serialize");
1312
1313 assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1314 assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1315 }
1316
1317 #[test]
1318 fn legacy_mode_removes_only_root_kind() {
1319 let _guard = TelemetryRunIdGuard::set(None);
1320 let value = serialize_root_output_with_mode(
1321 FallowOutput::Combined(combined_output()),
1322 EnvelopeMode::Legacy,
1323 )
1324 .expect("combined root should serialize");
1325
1326 assert!(value.get("kind").is_none());
1327
1328 let mut nested = serde_json::json!({
1329 "kind": "root",
1330 "action": {
1331 "kind": "suppress"
1332 }
1333 });
1334 remove_root_kind(&mut nested);
1335 assert!(nested.get("kind").is_none());
1336 assert_eq!(nested["action"]["kind"], "suppress");
1337 }
1338
1339 #[test]
1340 fn root_output_attaches_telemetry_meta() {
1341 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1342 let value = serialize_root_output_with_mode(
1343 FallowOutput::Combined(combined_output()),
1344 EnvelopeMode::Tagged,
1345 )
1346 .expect("combined root should serialize");
1347
1348 assert_eq!(
1349 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1350 Some("run_test123")
1351 );
1352 }
1353
1354 #[test]
1355 fn telemetry_meta_preserves_existing_meta_sections() {
1356 let mut output = combined_output();
1357 output.meta = Some(CombinedMeta {
1358 check: Some(Meta {
1359 docs: Some("https://example.com/check".to_string()),
1360 ..Meta::default()
1361 }),
1362 dupes: None,
1363 health: None,
1364 telemetry: None,
1365 });
1366
1367 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1368 let value =
1369 serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1370 .expect("combined root should serialize");
1371
1372 assert_eq!(
1373 value["_meta"]["check"]["docs"].as_str(),
1374 Some("https://example.com/check")
1375 );
1376 assert_eq!(
1377 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1378 Some("run_test123")
1379 );
1380 }
1381}