Skip to main content

fallow_output/
inspect_envelopes.rs

1//! Explain and inspect output envelopes.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5
6/// Envelope emitted by `fallow explain <issue-type> --format json`.
7///
8/// Standalone rule explanation. This command does not run project analysis
9/// and intentionally returns a compact object without `schema_version` /
10/// `version` metadata; consumers that need those should call any other
11/// fallow JSON-producing command.
12#[derive(Debug, Clone, Serialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[cfg_attr(
15    feature = "schema",
16    schemars(title = "fallow explain <issue-type> --format json")
17)]
18pub struct ExplainOutput {
19    /// Issue-type identifier, e.g. `unused-export`.
20    pub id: String,
21    /// Human-readable issue-type name.
22    pub name: String,
23    /// One-line description of what the issue type reports.
24    pub summary: String,
25    /// Why the issue matters.
26    pub rationale: String,
27    /// Illustrative code example of the issue.
28    pub example: String,
29    /// How to resolve findings of this type.
30    pub how_to_fix: String,
31    /// Public documentation URL for the issue type.
32    pub docs: String,
33}
34
35/// Serialize the `fallow explain --format json` envelope.
36///
37/// # Errors
38///
39/// Returns a serde error when the explain output cannot be converted to JSON.
40pub fn serialize_explain_json_output(
41    output: ExplainOutput,
42    mode: RootEnvelopeMode,
43    analysis_run_id: Option<&str>,
44) -> Result<serde_json::Value, serde_json::Error> {
45    let mut value = serialize_named_json_output(output, "explain", mode)?;
46    attach_telemetry_meta(&mut value, analysis_run_id);
47    Ok(value)
48}
49
50/// Envelope emitted by `fallow inspect --format json`.
51#[derive(Debug, Clone, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[cfg_attr(feature = "schema", schemars(title = "fallow inspect --format json"))]
54pub struct InspectOutput {
55    /// What was inspected: a file or a specific exported symbol.
56    pub target: InspectTargetDescriptor,
57    /// Graph identity facts about the target.
58    pub identity: InspectIdentity,
59    /// Per-analysis evidence sections for the target.
60    pub evidence: InspectEvidence,
61    /// Non-fatal problems encountered while gathering evidence.
62    pub warnings: Vec<String>,
63    /// `_meta` block with type-aware backend info, when applicable.
64    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
65    pub meta: Option<fallow_types::envelope::Meta>,
66}
67
68/// `target` block of [`InspectOutput`], tagged by `type`.
69#[derive(Debug, Clone, Serialize)]
70#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum InspectTargetDescriptor {
73    /// A whole file was inspected.
74    File {
75        /// File path relative to the analysed root.
76        file: String,
77    },
78    /// One exported symbol was inspected.
79    Symbol {
80        /// File path relative to the analysed root.
81        file: String,
82        /// Name of the inspected export.
83        export_name: String,
84    },
85}
86
87/// `identity` block of [`InspectOutput`]; shape follows the target type.
88#[derive(Debug, Clone, Serialize)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[serde(untagged)]
91pub enum InspectIdentity {
92    /// Identity facts for a file target.
93    File(InspectFileIdentity),
94    /// Identity facts for a symbol target.
95    Symbol(InspectSymbolIdentity),
96}
97
98/// Graph identity facts for a file target. `Value`-typed fields carry the
99/// graph's verdict when analysis ran and are `null` when it did not.
100#[derive(Debug, Clone, Serialize)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102pub struct InspectFileIdentity {
103    /// File path relative to the analysed root.
104    pub file: String,
105    /// Whether the module graph can reach the file from an entry point.
106    #[cfg_attr(feature = "schema", schemars(with = "Option<bool>"))]
107    pub is_reachable: Option<serde_json::Value>,
108    /// Whether the file is itself an entry point.
109    #[cfg_attr(feature = "schema", schemars(with = "Option<bool>"))]
110    pub is_entry_point: Option<serde_json::Value>,
111    /// Number of exports the file declares.
112    pub export_count: Option<usize>,
113    /// Number of modules the file imports.
114    pub import_count: Option<usize>,
115    /// Number of modules that import the file.
116    pub imported_by_count: Option<usize>,
117}
118
119/// Graph identity facts for a symbol target. `Value`-typed fields carry the
120/// graph's verdict when analysis ran and are `null` when it did not.
121#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct InspectSymbolIdentity {
124    /// File path relative to the analysed root.
125    pub file: String,
126    /// Name of the inspected export.
127    pub export_name: String,
128    /// Whether the containing file is reachable from an entry point.
129    #[cfg_attr(feature = "schema", schemars(with = "Option<bool>"))]
130    pub file_reachable: Option<serde_json::Value>,
131    /// Whether the containing file is itself an entry point.
132    #[cfg_attr(feature = "schema", schemars(with = "Option<bool>"))]
133    pub is_entry_point: Option<serde_json::Value>,
134    /// Whether the export has any recorded consumer.
135    #[cfg_attr(feature = "schema", schemars(with = "Option<bool>"))]
136    pub is_used: Option<serde_json::Value>,
137    /// Explanation of the usage verdict, when the graph recorded one.
138    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
139    pub reason: Option<serde_json::Value>,
140}
141
142/// `evidence` block of [`InspectOutput`]: one section per analysis.
143#[derive(Debug, Clone, Serialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145pub struct InspectEvidence {
146    /// File-level reachability trace.
147    pub trace_file: InspectEvidenceSection,
148    /// Export-level usage trace; present only for symbol targets.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub trace_export: Option<InspectEvidenceSection>,
151    /// Dead-code findings for the target.
152    pub dead_code: InspectEvidenceSection,
153    /// Duplication findings involving the target file.
154    pub duplication: InspectEvidenceSection,
155    /// Complexity findings for the target file.
156    pub complexity: InspectEvidenceSection,
157    /// Security findings for the target file.
158    pub security: InspectEvidenceSection,
159    /// Impact closure scoped to the inspected file as the seed: the transitive
160    /// affected-but-not-in-diff set + coordination gap.
161    pub impact_closure: InspectEvidenceSection,
162    /// OPT-IN target-level git churn. Omitted unless historical evidence was
163    /// explicitly requested by the caller.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub churn: Option<InspectEvidenceSection>,
166    /// OPT-IN symbol-level call chain. Present only when `--symbol-chain` was
167    /// requested AND the target is a SYMBOL (best-effort, syntactic, OFF the
168    /// ranked path). `None` (omitted) by default: symbol-level chains are
169    /// best-effort and not part of the trusted ranked evidence.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub symbol_chain: Option<InspectEvidenceSection>,
172    /// Checker-backed declaration, alias, re-export, and use-site evidence.
173    /// Present only for a symbol target in type-aware mode.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub semantic_trace: Option<InspectEvidenceSection>,
176    /// Package-public TypeScript surface and private-type reachability.
177    /// Present only for a symbol target in type-aware mode.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub api_surface: Option<InspectEvidenceSection>,
180    /// Exact-symbol consumers and transitive affected files.
181    /// Present only for a symbol target in type-aware mode.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub symbol_impact: Option<InspectEvidenceSection>,
184    /// Test entry points reachable from the exact symbol, with provenance.
185    /// Present only for a symbol target in type-aware mode.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub targeted_tests: Option<InspectEvidenceSection>,
188}
189
190/// One evidence section: status, the scope the evidence covers, and either a
191/// data payload or a message explaining its absence.
192#[derive(Debug, Clone, Serialize)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194pub struct InspectEvidenceSection {
195    /// Whether the section's analysis produced usable evidence.
196    pub status: InspectSectionStatus,
197    /// Granularity the evidence covers.
198    pub scope: InspectEvidenceScope,
199    /// Explanation for `error` / `unavailable` sections.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub message: Option<String>,
202    /// Section payload; present when the analysis ran.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub data: Option<serde_json::Value>,
205}
206
207impl InspectEvidenceSection {
208    /// Successful section carrying `data`.
209    #[must_use]
210    pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
211        Self {
212            status: InspectSectionStatus::Ok,
213            scope,
214            message: None,
215            data: Some(data),
216        }
217    }
218
219    /// Section whose status mirrors the semantic backend's completeness
220    /// verdict while still carrying `data`.
221    #[must_use]
222    pub fn semantic(
223        scope: InspectEvidenceScope,
224        status: fallow_types::semantic::SemanticCompleteness,
225        data: serde_json::Value,
226    ) -> Self {
227        let status = match status {
228            fallow_types::semantic::SemanticCompleteness::Complete => InspectSectionStatus::Ok,
229            fallow_types::semantic::SemanticCompleteness::Partial => InspectSectionStatus::Partial,
230            fallow_types::semantic::SemanticCompleteness::Unavailable => {
231                InspectSectionStatus::Unavailable
232            }
233        };
234        Self {
235            status,
236            scope,
237            message: None,
238            data: Some(data),
239        }
240    }
241
242    /// Failed section carrying only an explanation.
243    #[must_use]
244    pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
245        Self {
246            status: InspectSectionStatus::Error,
247            scope,
248            message: Some(message),
249            data: None,
250        }
251    }
252
253    /// Section whose analysis could not run in this environment.
254    #[must_use]
255    pub fn unavailable(scope: InspectEvidenceScope, message: String) -> Self {
256        Self {
257            status: InspectSectionStatus::Unavailable,
258            scope,
259            message: Some(message),
260            data: None,
261        }
262    }
263}
264
265/// Serialize the `fallow inspect --format json` envelope.
266///
267/// # Errors
268///
269/// Returns a serde error when the inspect output cannot be converted to JSON.
270pub fn serialize_inspect_json_output(
271    output: InspectOutput,
272    mode: RootEnvelopeMode,
273    analysis_run_id: Option<&str>,
274) -> Result<serde_json::Value, serde_json::Error> {
275    let mut value = serialize_named_json_output(output, "inspect_target", mode)?;
276    attach_telemetry_meta(&mut value, analysis_run_id);
277    Ok(value)
278}
279
280/// Status of an [`InspectEvidenceSection`].
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(rename_all = "snake_case")]
284pub enum InspectSectionStatus {
285    /// Analysis ran and the payload is complete.
286    Ok,
287    /// Analysis ran but the semantic backend reported incomplete coverage.
288    Partial,
289    /// Analysis could not run in this environment.
290    Unavailable,
291    /// Analysis failed; see `message`.
292    Error,
293}
294
295/// Granularity an [`InspectEvidenceSection`] payload covers.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[serde(rename_all = "snake_case")]
299pub enum InspectEvidenceScope {
300    /// Evidence about the exact inspected symbol.
301    Symbol,
302    /// Evidence about the whole target file.
303    File,
304    /// Project-wide analysis filtered down to entries touching the file.
305    ProjectFilteredToFile,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn explain_json_output_uses_output_owned_root_contract() {
314        let output = ExplainOutput {
315            id: "unused-export".to_string(),
316            name: "Unused export".to_string(),
317            summary: "summary".to_string(),
318            rationale: "rationale".to_string(),
319            example: "example".to_string(),
320            how_to_fix: "fix".to_string(),
321            docs: "https://example.test".to_string(),
322        };
323
324        let value =
325            serialize_explain_json_output(output, RootEnvelopeMode::Tagged, Some("run-explain"))
326                .expect("explain output should serialize");
327
328        assert_eq!(value["kind"], "explain");
329        assert_eq!(
330            value["_meta"]["telemetry"]["analysis_run_id"],
331            "run-explain"
332        );
333    }
334
335    #[test]
336    fn inspect_json_output_uses_output_owned_root_contract() {
337        let output = InspectOutput {
338            target: InspectTargetDescriptor::File {
339                file: "src/app.ts".to_string(),
340            },
341            identity: InspectIdentity::File(InspectFileIdentity {
342                file: "src/app.ts".to_string(),
343                is_reachable: None,
344                is_entry_point: None,
345                export_count: Some(0),
346                import_count: Some(0),
347                imported_by_count: Some(0),
348            }),
349            evidence: InspectEvidence {
350                trace_file: InspectEvidenceSection::ok(
351                    InspectEvidenceScope::File,
352                    serde_json::json!({}),
353                ),
354                trace_export: None,
355                dead_code: InspectEvidenceSection::error(
356                    InspectEvidenceScope::File,
357                    "not run".to_string(),
358                ),
359                duplication: InspectEvidenceSection::error(
360                    InspectEvidenceScope::ProjectFilteredToFile,
361                    "not run".to_string(),
362                ),
363                complexity: InspectEvidenceSection::error(
364                    InspectEvidenceScope::ProjectFilteredToFile,
365                    "not run".to_string(),
366                ),
367                security: InspectEvidenceSection::error(
368                    InspectEvidenceScope::File,
369                    "not run".to_string(),
370                ),
371                impact_closure: InspectEvidenceSection::error(
372                    InspectEvidenceScope::ProjectFilteredToFile,
373                    "not run".to_string(),
374                ),
375                churn: None,
376                symbol_chain: None,
377                semantic_trace: None,
378                api_surface: None,
379                symbol_impact: None,
380                targeted_tests: None,
381            },
382            warnings: Vec::new(),
383            meta: Some(fallow_types::envelope::Meta {
384                type_aware: Some(fallow_types::envelope::TypeAwareMeta {
385                    protocol_version: 7,
386                    backend: "typescript-go".to_string(),
387                    ..fallow_types::envelope::TypeAwareMeta::default()
388                }),
389                ..fallow_types::envelope::Meta::default()
390            }),
391        };
392
393        let value =
394            serialize_inspect_json_output(output, RootEnvelopeMode::Tagged, Some("run-inspect"))
395                .expect("inspect output should serialize");
396
397        assert_eq!(value["kind"], "inspect_target");
398        assert_eq!(
399            value["_meta"]["telemetry"]["analysis_run_id"],
400            "run-inspect"
401        );
402        assert_eq!(value["_meta"]["type_aware"]["protocol_version"], 7);
403        assert_eq!(value["_meta"]["type_aware"]["backend"], "typescript-go");
404        assert!(value["evidence"].get("churn").is_none());
405    }
406
407    #[test]
408    fn inspect_churn_section_serializes_success_unavailable_and_error_states() {
409        let ok = InspectEvidenceSection::ok(
410            InspectEvidenceScope::ProjectFilteredToFile,
411            serde_json::json!({"file": "src/app.ts", "commits": 4}),
412        );
413        let unavailable = InspectEvidenceSection::unavailable(
414            InspectEvidenceScope::ProjectFilteredToFile,
415            "git repository unavailable".to_string(),
416        );
417        let error = InspectEvidenceSection::error(
418            InspectEvidenceScope::ProjectFilteredToFile,
419            "git log failed".to_string(),
420        );
421
422        assert_eq!(
423            serde_json::to_value(ok).expect("ok section should serialize")["status"],
424            "ok"
425        );
426        assert_eq!(
427            serde_json::to_value(unavailable).expect("unavailable section should serialize")["status"],
428            "unavailable"
429        );
430        assert_eq!(
431            serde_json::to_value(error).expect("error section should serialize")["status"],
432            "error"
433        );
434    }
435}