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}
562
563#[derive(Debug, Clone, Serialize)]
564#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
565pub struct InspectEvidenceSection {
566 pub status: InspectSectionStatus,
567 pub scope: InspectEvidenceScope,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub message: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub data: Option<serde_json::Value>,
572}
573
574impl InspectEvidenceSection {
575 #[must_use]
576 pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
577 Self {
578 status: InspectSectionStatus::Ok,
579 scope,
580 message: None,
581 data: Some(data),
582 }
583 }
584
585 #[must_use]
586 pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
587 Self {
588 status: InspectSectionStatus::Error,
589 scope,
590 message: Some(message),
591 data: None,
592 }
593 }
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
598#[serde(rename_all = "snake_case")]
599pub enum InspectSectionStatus {
600 Ok,
601 Error,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
605#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
606#[serde(rename_all = "snake_case")]
607pub enum InspectEvidenceScope {
608 Symbol,
609 File,
610 ProjectFilteredToFile,
611}
612
613#[derive(Debug, Clone, Serialize)]
617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
618#[cfg_attr(
619 feature = "schema",
620 schemars(title = "fallow --format codeclimate / gitlab-codequality")
621)]
622#[serde(transparent)]
623#[allow(
624 dead_code,
625 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."
626)]
627pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
628
629#[derive(Debug, Clone, Serialize)]
631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
632pub struct CodeClimateIssue {
633 #[serde(rename = "type")]
634 pub kind: CodeClimateIssueKind,
635 pub check_name: String,
636 pub description: String,
637 pub categories: Vec<String>,
638 pub severity: CodeClimateSeverity,
639 pub fingerprint: String,
640 pub location: CodeClimateLocation,
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646#[serde(rename_all = "lowercase")]
647pub enum CodeClimateIssueKind {
648 Issue,
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 CodeClimateSeverity {
657 #[allow(
662 dead_code,
663 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."
664 )]
665 Info,
666 Minor,
668 Major,
670 Critical,
672 #[allow(
675 dead_code,
676 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."
677 )]
678 Blocker,
679}
680
681#[derive(Debug, Clone, Serialize)]
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684pub struct CodeClimateLocation {
685 pub path: String,
687 pub lines: CodeClimateLines,
690}
691
692#[derive(Debug, Clone, Copy, Serialize)]
694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
695pub struct CodeClimateLines {
696 pub begin: u32,
698}
699
700#[derive(Debug, Clone, Serialize)]
702#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
703#[cfg_attr(
704 feature = "schema",
705 schemars(title = "fallow --format review-github / review-gitlab")
706)]
707pub struct ReviewEnvelopeOutput {
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub event: Option<ReviewEnvelopeEvent>,
710 pub body: String,
711 #[serde(default = "ReviewEnvelopeSummary::empty_default")]
712 pub summary: ReviewEnvelopeSummary,
713 pub comments: Vec<ReviewComment>,
714 #[serde(default = "default_marker_regex")]
715 pub marker_regex: String,
716 #[serde(default = "default_marker_regex_flags")]
717 pub marker_regex_flags: String,
718 pub meta: ReviewEnvelopeMeta,
719}
720
721#[must_use]
723pub fn default_marker_regex() -> String {
724 MARKER_REGEX_V2.to_owned()
725}
726
727#[must_use]
729pub fn default_marker_regex_flags() -> String {
730 MARKER_REGEX_FLAGS_V2.to_owned()
731}
732
733pub const MARKER_REGEX_V2: &str =
735 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
736
737pub const MARKER_REGEX_FLAGS_V2: &str = "m";
739
740#[derive(Debug, Clone, Serialize, Default)]
742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
743pub struct ReviewEnvelopeSummary {
744 pub body: String,
745 pub fingerprint: String,
746}
747
748impl ReviewEnvelopeSummary {
749 #[must_use]
751 #[allow(
752 dead_code,
753 reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
754 )]
755 pub fn empty_default() -> Self {
756 Self::default()
757 }
758}
759
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763pub enum ReviewEnvelopeEvent {
764 #[serde(rename = "COMMENT")]
765 Comment,
766}
767
768#[derive(Debug, Clone, Serialize)]
773#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
774#[serde(untagged)]
775pub enum ReviewComment {
776 GitHub(GitHubReviewComment),
777 GitLab(GitLabReviewComment),
778}
779
780#[derive(Debug, Clone, Serialize)]
782#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
783pub struct GitHubReviewComment {
784 pub path: String,
785 pub line: u32,
786 pub side: GitHubReviewSide,
787 pub body: String,
788 pub fingerprint: String,
789 #[serde(default, skip_serializing_if = "is_false")]
790 pub truncated: bool,
791}
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
795#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
796pub enum GitHubReviewSide {
797 #[serde(rename = "RIGHT")]
798 Right,
799}
800
801#[derive(Debug, Clone, Serialize)]
803#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
804pub struct GitLabReviewComment {
805 pub body: String,
806 pub position: GitLabReviewPosition,
807 pub fingerprint: String,
808 #[serde(default, skip_serializing_if = "is_false")]
809 pub truncated: bool,
810}
811
812#[must_use]
820#[allow(
821 clippy::trivially_copy_pass_by_ref,
822 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
823)]
824pub fn is_false(value: &bool) -> bool {
825 !*value
826}
827
828#[derive(Debug, Clone, Serialize)]
831#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
832pub struct GitLabReviewPosition {
833 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub base_sha: Option<String>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub start_sha: Option<String>,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub head_sha: Option<String>,
839 pub position_type: GitLabReviewPositionType,
840 pub old_path: String,
841 pub new_path: String,
842 pub new_line: u32,
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
848#[serde(rename_all = "lowercase")]
849pub enum GitLabReviewPositionType {
850 Text,
851}
852
853#[derive(Debug, Clone, Serialize)]
855#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
856pub struct ReviewEnvelopeMeta {
857 pub schema: ReviewEnvelopeSchema,
858 pub provider: ReviewProvider,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub check_conclusion: Option<ReviewCheckConclusion>,
861}
862
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
865#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
866pub enum ReviewEnvelopeSchema {
867 #[serde(rename = "fallow-review-envelope/v1")]
873 #[allow(
874 dead_code,
875 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
876 )]
877 V1,
878 #[serde(rename = "fallow-review-envelope/v2")]
892 V2,
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
897#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
898#[serde(rename_all = "lowercase")]
899pub enum ReviewProvider {
900 Github,
902 Gitlab,
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910#[serde(rename_all = "lowercase")]
911pub enum ReviewCheckConclusion {
912 Success,
914 Neutral,
916 Failure,
918}
919
920#[derive(Debug, Clone, Serialize)]
924#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
925#[cfg_attr(
926 feature = "schema",
927 schemars(title = "fallow ci reconcile-review --format json")
928)]
929pub struct ReviewReconcileOutput {
930 pub schema: ReviewReconcileSchema,
931 pub provider: ReviewProvider,
932 pub target: Option<String>,
933 pub dry_run: bool,
934 pub comments: u32,
935 pub current_fingerprints: u32,
936 pub existing_fingerprints: u32,
937 pub new_fingerprints: u32,
938 pub stale_fingerprints: u32,
939 pub new: Vec<String>,
940 pub stale: Vec<String>,
941 pub provider_warning: Option<String>,
942 pub resolution_comments_posted: u32,
943 pub threads_resolved: u32,
944 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub apply_hint: Option<String>,
946 pub apply_errors: Vec<String>,
947 #[serde(default, skip_serializing_if = "Vec::is_empty")]
948 pub failed_fingerprints: Vec<String>,
949 #[serde(default, skip_serializing_if = "Vec::is_empty")]
950 pub unapplied_fingerprints: Vec<String>,
951}
952
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
955#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
956pub enum ReviewReconcileSchema {
957 #[serde(rename = "fallow-review-reconcile/v1")]
959 V1,
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
968#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
969#[serde(rename_all = "lowercase")]
970pub enum GroupByMode {
971 Owner,
972 Directory,
973 Package,
974 Section,
975}
976#[derive(Debug, Clone, Serialize)]
982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
983#[cfg_attr(
984 feature = "schema",
985 schemars(title = "fallow list --boundaries --format json")
986)]
987#[allow(
988 dead_code,
989 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."
990)]
991pub struct ListBoundariesOutput {
992 pub boundaries: BoundariesListing,
993}
994
995#[derive(Debug, Clone, Serialize)]
997#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
998#[cfg_attr(
999 feature = "schema",
1000 schemars(title = "fallow workspaces --format json")
1001)]
1002pub struct WorkspacesOutput {
1003 pub workspace_count: usize,
1005 pub workspaces: Vec<WorkspaceInfo>,
1008 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
1012}
1013
1014#[derive(Debug, Clone, Serialize)]
1016#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1017pub struct WorkspaceInfo {
1018 pub name: String,
1021 pub path: String,
1024 pub is_internal_dependency: bool,
1027}
1028
1029#[derive(Debug, Clone, Serialize)]
1031#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1032#[allow(
1033 dead_code,
1034 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1035)]
1036pub struct BoundariesListing {
1037 pub configured: bool,
1038 pub zone_count: usize,
1039 pub zones: Vec<BoundariesListZone>,
1040 pub rule_count: usize,
1041 pub rules: Vec<BoundariesListRule>,
1042 pub logical_group_count: usize,
1043 pub logical_groups: Vec<BoundariesListLogicalGroup>,
1044}
1045
1046#[derive(Debug, Clone, Serialize)]
1049#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1050#[allow(
1051 dead_code,
1052 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1053)]
1054pub struct BoundariesListZone {
1055 pub name: String,
1056 pub patterns: Vec<String>,
1057 pub file_count: usize,
1058}
1059
1060#[derive(Debug, Clone, Serialize)]
1065#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1066#[allow(
1067 dead_code,
1068 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1069)]
1070pub struct BoundariesListRule {
1071 pub from: String,
1072 pub allow: Vec<String>,
1073}
1074
1075#[derive(Debug, Clone, Serialize)]
1080#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1081#[allow(
1082 dead_code,
1083 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1084)]
1085pub struct BoundariesListLogicalGroup {
1086 pub name: String,
1087 pub children: Vec<String>,
1088 pub auto_discover: Vec<String>,
1089 pub status: fallow_config::LogicalGroupStatus,
1090 pub source_zone_index: usize,
1091 pub file_count: usize,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub authored_rule: Option<fallow_config::AuthoredRule>,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub fallback_zone: Option<String>,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub merged_from: Option<Vec<usize>>,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub original_zone_root: Option<String>,
1100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1101 pub child_source_indices: Vec<usize>,
1102}
1103
1104#[derive(Debug, Clone, Serialize)]
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123#[cfg_attr(
1124 feature = "schema",
1125 schemars(title = "fallow --format json (typed root)")
1126)]
1127#[serde(tag = "kind")]
1128#[allow(
1129 dead_code,
1130 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
1131)]
1132pub enum FallowOutput {
1133 #[serde(rename = "audit")]
1136 Audit(AuditOutput),
1137 #[serde(rename = "explain")]
1140 Explain(ExplainOutput),
1141 #[serde(rename = "inspect_target")]
1144 Inspect(InspectOutput),
1145 #[serde(rename = "review-envelope")]
1148 ReviewEnvelope(ReviewEnvelopeOutput),
1149 #[serde(rename = "review-reconcile")]
1153 ReviewReconcile(ReviewReconcileOutput),
1154 #[serde(rename = "coverage-setup")]
1157 CoverageSetup(CoverageSetupOutput),
1158 #[serde(rename = "coverage-analyze")]
1162 CoverageAnalyze(CoverageAnalyzeOutput),
1163 #[serde(rename = "list-boundaries")]
1166 ListBoundaries(ListBoundariesOutput),
1167 #[serde(rename = "list-workspaces")]
1170 Workspaces(WorkspacesOutput),
1171 #[serde(rename = "health")]
1173 Health(HealthOutput),
1174 #[serde(rename = "dupes")]
1178 Dupes(DupesOutput),
1179 #[serde(rename = "dead-code-grouped")]
1182 CheckGrouped(CheckGroupedOutput),
1183 #[serde(rename = "impact")]
1187 Impact(crate::impact::ImpactReport),
1188 #[serde(rename = "impact-cross-repo")]
1193 ImpactCrossRepo(crate::impact::CrossRepoImpactReport),
1194 #[serde(rename = "security")]
1197 SecuritySummary(crate::security::SecuritySummaryOutput),
1198 #[serde(rename = "security")]
1203 Security(crate::security::SecurityOutput),
1204 #[serde(rename = "security-survivors")]
1207 SecuritySurvivors(crate::security::SecuritySurvivorsOutput),
1208 #[serde(rename = "security-blind-spots")]
1211 SecurityBlindSpots(crate::security::SecurityBlindSpotsOutput),
1212 #[serde(rename = "dead-code")]
1215 Check(CheckOutput),
1216 #[serde(rename = "combined")]
1220 Combined(CombinedOutput),
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1226
1227 use super::*;
1228
1229 static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1230
1231 struct TelemetryRunIdGuard {
1232 _lock: std::sync::MutexGuard<'static, ()>,
1233 }
1234
1235 impl TelemetryRunIdGuard {
1236 fn set(run_id: Option<&str>) -> Self {
1237 let lock = TEST_TELEMETRY_RUN_ID_LOCK
1238 .lock()
1239 .unwrap_or_else(|poisoned| poisoned.into_inner());
1240 set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1241 Self { _lock: lock }
1242 }
1243 }
1244
1245 impl Drop for TelemetryRunIdGuard {
1246 fn drop(&mut self) {
1247 set_telemetry_analysis_run_id(None);
1248 }
1249 }
1250
1251 fn combined_output() -> CombinedOutput {
1252 CombinedOutput {
1253 schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1254 version: ToolVersion("test".to_string()),
1255 elapsed_ms: ElapsedMs(0),
1256 meta: None,
1257 check: None,
1258 dupes: None,
1259 health: None,
1260 next_steps: Vec::new(),
1261 }
1262 }
1263
1264 #[test]
1265 fn root_output_serializes_kind_by_default() {
1266 let _guard = TelemetryRunIdGuard::set(None);
1267 let value = serialize_root_output_with_mode(
1268 FallowOutput::Combined(combined_output()),
1269 EnvelopeMode::Tagged,
1270 )
1271 .expect("combined root should serialize");
1272
1273 assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1274 assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1275 }
1276
1277 #[test]
1278 fn legacy_mode_removes_only_root_kind() {
1279 let _guard = TelemetryRunIdGuard::set(None);
1280 let value = serialize_root_output_with_mode(
1281 FallowOutput::Combined(combined_output()),
1282 EnvelopeMode::Legacy,
1283 )
1284 .expect("combined root should serialize");
1285
1286 assert!(value.get("kind").is_none());
1287
1288 let mut nested = serde_json::json!({
1289 "kind": "root",
1290 "action": {
1291 "kind": "suppress"
1292 }
1293 });
1294 remove_root_kind(&mut nested);
1295 assert!(nested.get("kind").is_none());
1296 assert_eq!(nested["action"]["kind"], "suppress");
1297 }
1298
1299 #[test]
1300 fn root_output_attaches_telemetry_meta() {
1301 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1302 let value = serialize_root_output_with_mode(
1303 FallowOutput::Combined(combined_output()),
1304 EnvelopeMode::Tagged,
1305 )
1306 .expect("combined root should serialize");
1307
1308 assert_eq!(
1309 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1310 Some("run_test123")
1311 );
1312 }
1313
1314 #[test]
1315 fn telemetry_meta_preserves_existing_meta_sections() {
1316 let mut output = combined_output();
1317 output.meta = Some(CombinedMeta {
1318 check: Some(Meta {
1319 docs: Some("https://example.com/check".to_string()),
1320 ..Meta::default()
1321 }),
1322 dupes: None,
1323 health: None,
1324 telemetry: None,
1325 });
1326
1327 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1328 let value =
1329 serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1330 .expect("combined root should serialize");
1331
1332 assert_eq!(
1333 value["_meta"]["check"]["docs"].as_str(),
1334 Some("https://example.com/check")
1335 );
1336 assert_eq!(
1337 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1338 Some("run_test123")
1339 );
1340 }
1341}