1use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7pub const AUDIT_SCHEMA_VERSION: u32 = 10;
9
10pub const COMBINED_SCHEMA_VERSION: u32 = 11;
15
16#[cfg(feature = "schema")]
18#[allow(dead_code, reason = "schema-only type used by the field projection")]
19#[derive(schemars::JsonSchema)]
20#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
21struct AuditSchemaVersion(u32);
22
23#[cfg(feature = "schema")]
25#[allow(dead_code, reason = "schema-only type used by the field projection")]
26#[derive(schemars::JsonSchema)]
27#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
28struct CombinedSchemaVersion(u32);
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RootEnvelopeMode {
33 Tagged,
35}
36
37pub fn serialize_json_root_output<T: Serialize>(
45 output: T,
46 mode: RootEnvelopeMode,
47) -> Result<serde_json::Value, serde_json::Error> {
48 let _ = mode;
49 serde_json::to_value(output)
50}
51
52pub fn serialize_named_json_output<T: Serialize>(
63 output: T,
64 kind: &'static str,
65 mode: RootEnvelopeMode,
66) -> Result<serde_json::Value, serde_json::Error> {
67 let mut value = serde_json::to_value(output)?;
68 apply_root_kind(&mut value, kind, mode);
69 Ok(value)
70}
71
72pub fn serialize_audit_json_output<
80 Verdict,
81 Summary,
82 Attribution,
83 DeadCode,
84 Duplication,
85 Complexity,
86>(
87 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
88 mode: RootEnvelopeMode,
89 analysis_run_id: Option<&str>,
90) -> Result<serde_json::Value, serde_json::Error>
91where
92 Verdict: Serialize,
93 Summary: Serialize,
94 Attribution: Serialize,
95 DeadCode: Serialize,
96 Duplication: Serialize,
97 Complexity: Serialize,
98{
99 let mut value = serde_json::to_value(output)?;
100 apply_root_kind(&mut value, "audit", mode);
101 attach_telemetry_meta(&mut value, analysis_run_id);
102 Ok(value)
103}
104
105pub fn serialize_combined_json_output<Check, Dupes, Health>(
113 output: CombinedOutput<Check, Dupes, Health>,
114 mode: RootEnvelopeMode,
115 analysis_run_id: Option<&str>,
116) -> Result<serde_json::Value, serde_json::Error>
117where
118 Check: Serialize,
119 Dupes: Serialize,
120 Health: Serialize,
121{
122 let mut value = serde_json::to_value(output)?;
123 apply_root_kind(&mut value, "combined", mode);
124 attach_telemetry_meta(&mut value, analysis_run_id);
125 Ok(value)
126}
127
128pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
130 let _ = mode;
131 if let serde_json::Value::Object(map) = value {
132 let previous = map.shift_insert(
133 0,
134 "kind".to_string(),
135 serde_json::Value::String(kind.to_string()),
136 );
137 if let Some(previous) = previous
138 && let Some(current) = map.get_mut("kind")
139 {
140 *current = previous;
141 }
142 }
143}
144
145pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
147 let Some(analysis_run_id) = analysis_run_id else {
148 return;
149 };
150 let serde_json::Value::Object(map) = value else {
151 return;
152 };
153 let meta = map
154 .entry("_meta".to_string())
155 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
156 if !meta.is_object() {
157 *meta = serde_json::Value::Object(serde_json::Map::new());
158 }
159 if let serde_json::Value::Object(meta_map) = meta {
160 meta_map.insert(
161 "telemetry".to_string(),
162 serde_json::json!({ "analysis_run_id": analysis_run_id }),
163 );
164 }
165}
166
167#[derive(Debug, Clone, Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
171pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
172 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
174 pub schema_version: SchemaVersion,
175 pub version: ToolVersion,
177 pub command: AuditCommand,
179 pub verdict: Verdict,
181 pub changed_files_count: u32,
183 pub base_ref: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub base_description: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub head_sha: Option<String>,
195 pub elapsed_ms: ElapsedMs,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub base_snapshot_skipped: Option<bool>,
201 pub summary: Summary,
203 pub attribution: Attribution,
205 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
208 pub meta: Option<Meta>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub dead_code: Option<DeadCode>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub duplication: Option<Duplication>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub complexity: Option<Complexity>,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub next_steps: Vec<NextStep>,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227#[serde(rename_all = "lowercase")]
228pub enum AuditCommand {
229 Audit,
231}
232
233#[derive(Debug, Clone, Serialize)]
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236#[cfg_attr(
237 feature = "schema",
238 schemars(title = "fallow --format json (bare, combined)")
239)]
240pub struct CombinedOutput<Check, Dupes, Health> {
241 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
243 pub schema_version: SchemaVersion,
244 pub version: ToolVersion,
246 pub elapsed_ms: ElapsedMs,
248 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
250 pub meta: Option<CombinedMeta>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub check: Option<Check>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub dupes: Option<Dupes>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub health: Option<Health>,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub next_steps: Vec<NextStep>,
264}
265
266#[derive(Debug, Clone, Serialize)]
268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269pub struct CombinedMeta {
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub check: Option<Meta>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub dupes: Option<Meta>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub health: Option<Meta>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub telemetry: Option<TelemetryMeta>,
282}
283
284#[derive(Debug, Clone, Serialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[cfg_attr(
302 feature = "schema",
303 schemars(title = "fallow --format json (typed root)")
304)]
305#[serde(tag = "kind")]
306#[allow(
307 dead_code,
308 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
309)]
310pub enum FallowOutput<
311 Audit,
312 Explain,
313 Inspect,
314 Trace,
315 ReviewEnvelope,
316 ReviewReconcile,
317 CoverageSetup,
318 CoverageAnalyze,
319 ListBoundaries,
320 Workspaces,
321 Health,
322 Dupes,
323 CheckGrouped,
324 Impact,
325 ImpactCrossRepo,
326 SecuritySummary,
327 Security,
328 SecuritySurvivors,
329 SecurityBlindSpots,
330 Check,
331 Combined,
332 FeatureFlags,
333 AuditBrief,
334 DecisionSurface,
335 WalkthroughGuide,
336 WalkthroughValidation,
337 SuppressionInventory,
338 TypeAwareStatus,
339> {
340 #[serde(rename = "audit")]
342 Audit(Audit),
343 #[serde(rename = "explain")]
345 Explain(Explain),
346 #[serde(rename = "inspect_target")]
348 Inspect(Inspect),
349 #[serde(rename = "trace")]
351 Trace(Trace),
352 #[serde(rename = "review-envelope")]
354 ReviewEnvelope(ReviewEnvelope),
355 #[serde(rename = "review-reconcile")]
357 ReviewReconcile(ReviewReconcile),
358 #[serde(rename = "coverage-setup")]
360 CoverageSetup(CoverageSetup),
361 #[serde(rename = "coverage-analyze")]
363 CoverageAnalyze(CoverageAnalyze),
364 #[serde(rename = "list-boundaries")]
366 ListBoundaries(ListBoundaries),
367 #[serde(rename = "list-workspaces")]
369 Workspaces(Workspaces),
370 #[serde(rename = "health")]
372 Health(Health),
373 #[serde(rename = "dupes")]
375 Dupes(Dupes),
376 #[serde(rename = "dead-code-grouped")]
378 CheckGrouped(CheckGrouped),
379 #[serde(rename = "impact")]
381 Impact(Impact),
382 #[serde(rename = "impact-cross-repo")]
384 ImpactCrossRepo(ImpactCrossRepo),
385 #[serde(rename = "security")]
387 SecuritySummary(SecuritySummary),
388 #[serde(rename = "security")]
390 Security(Security),
391 #[serde(rename = "security-survivors")]
393 SecuritySurvivors(SecuritySurvivors),
394 #[serde(rename = "security-blind-spots")]
396 SecurityBlindSpots(SecurityBlindSpots),
397 #[serde(rename = "dead-code")]
399 Check(Check),
400 #[serde(rename = "combined")]
402 Combined(Combined),
403 #[serde(rename = "feature-flags")]
405 FeatureFlags(FeatureFlags),
406 #[serde(rename = "audit-brief")]
408 AuditBrief(AuditBrief),
409 #[serde(rename = "decision-surface")]
411 DecisionSurface(DecisionSurface),
412 #[serde(rename = "review-walkthrough-guide")]
414 WalkthroughGuide(WalkthroughGuide),
415 #[serde(rename = "review-walkthrough-validation")]
417 WalkthroughValidation(WalkthroughValidation),
418 #[serde(rename = "suppression-inventory")]
420 SuppressionInventory(SuppressionInventory),
421 #[serde(rename = "type-aware-status")]
423 TypeAwareStatus(TypeAwareStatus),
424}
425
426#[cfg(test)]
427mod tests {
428 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
429 use serde_json::json;
430
431 use super::*;
432
433 #[test]
434 fn apply_root_kind_sets_tagged_mode() {
435 let mut value = json!({});
436
437 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
438
439 assert_eq!(value["kind"], "dead_code");
440 }
441
442 #[test]
443 fn apply_root_kind_prepends_without_reordering_existing_fields() {
444 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
445
446 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
447
448 assert_eq!(
449 serde_json::to_string(&value).expect("root output should serialize"),
450 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
451 );
452 }
453
454 #[test]
455 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
456 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
457
458 apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
459
460 assert_eq!(
461 serde_json::to_string(&value).expect("root output should serialize"),
462 r#"{"kind":"custom","before":1,"after":2}"#
463 );
464 }
465
466 #[test]
467 fn apply_root_kind_preserves_non_object_roots() {
468 let mut value = json!(["not", "an", "object"]);
469
470 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
471
472 assert_eq!(value, json!(["not", "an", "object"]));
473 }
474
475 #[test]
476 fn attach_telemetry_meta_sets_analysis_run_id() {
477 let mut value = json!({});
478
479 attach_telemetry_meta(&mut value, Some("run-123"));
480
481 assert_eq!(
482 value["_meta"]["telemetry"]["analysis_run_id"],
483 json!("run-123")
484 );
485 }
486
487 #[test]
488 fn attach_telemetry_meta_preserves_non_object_roots() {
489 let mut value = json!(["not", "an", "object"]);
490
491 attach_telemetry_meta(&mut value, Some("run-123"));
492
493 assert_eq!(value, json!(["not", "an", "object"]));
494 }
495
496 #[test]
497 fn serialize_named_json_output_applies_explicit_kind() {
498 let value = serialize_named_json_output(
499 json!({
500 "schema_version": 1,
501 "summary": { "total": 0 }
502 }),
503 "example",
504 RootEnvelopeMode::Tagged,
505 )
506 .expect("named output should serialize");
507
508 assert_eq!(value["kind"], "example");
509 assert_eq!(value["summary"]["total"], 0);
510 }
511
512 #[test]
513 fn serialize_audit_json_output_applies_audit_kind() {
514 let value = serialize_audit_json_output(
515 AuditOutput {
516 schema_version: SchemaVersion(7),
517 version: ToolVersion("1.2.3".to_string()),
518 command: AuditCommand::Audit,
519 verdict: "pass",
520 changed_files_count: 2,
521 base_ref: "origin/main".to_string(),
522 base_description: Some("merge-base with origin/main".to_string()),
523 head_sha: Some("abc123".to_string()),
524 elapsed_ms: ElapsedMs(42),
525 base_snapshot_skipped: Some(false),
526 summary: json!({ "dead_code_issues": 0 }),
527 attribution: json!({ "gate": "new_only" }),
528 meta: None,
529 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
530 duplication: None::<serde_json::Value>,
531 complexity: None::<serde_json::Value>,
532 next_steps: Vec::new(),
533 },
534 RootEnvelopeMode::Tagged,
535 Some("run-audit"),
536 )
537 .expect("audit output should serialize");
538
539 assert_eq!(value["kind"], "audit");
540 assert_eq!(value["command"], "audit");
541 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
542 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
543 }
544
545 #[test]
546 fn serialize_combined_json_output_applies_combined_kind() {
547 let value = serialize_combined_json_output(
548 CombinedOutput {
549 schema_version: SchemaVersion(7),
550 version: ToolVersion("1.2.3".to_string()),
551 elapsed_ms: ElapsedMs(42),
552 meta: None,
553 check: Some(json!({ "summary": { "total_issues": 0 } })),
554 dupes: None::<serde_json::Value>,
555 health: None::<serde_json::Value>,
556 next_steps: Vec::new(),
557 },
558 RootEnvelopeMode::Tagged,
559 Some("run-combined"),
560 )
561 .expect("combined output should serialize");
562
563 assert_eq!(value["kind"], "combined");
564 assert_eq!(value["check"]["summary"]["total_issues"], 0);
565 assert_eq!(
566 value["_meta"]["telemetry"]["analysis_run_id"],
567 "run-combined"
568 );
569 }
570}