Skip to main content

fallow_output/
inspect_envelopes.rs

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