Skip to main content

fallow_output/
report_contract.rs

1use std::collections::BTreeMap;
2
3use fallow_types::envelope::{Meta, MetaMetric, MetaRule};
4use serde_json::{Value, json};
5
6use crate::{ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION, ACTIONS_FIELD_DEFINITION};
7
8/// Docs URL for the duplication command.
9pub const DUPES_DOCS: &str = "https://docs.fallow.tools/cli/dupes";
10
11/// Docs URL for the runtime coverage setup command's agent-readable JSON.
12pub const COVERAGE_SETUP_DOCS: &str = "https://docs.fallow.tools/cli/coverage#agent-readable-json";
13
14/// Docs URL for `fallow coverage analyze --format json --explain`.
15pub const COVERAGE_ANALYZE_DOCS: &str = "https://docs.fallow.tools/cli/coverage#analyze";
16
17/// Docs URL for the health command.
18pub const HEALTH_DOCS: &str = "https://docs.fallow.tools/cli/health";
19
20/// Docs URL for the security command.
21pub const SECURITY_DOCS: &str = "https://docs.fallow.tools/cli/security";
22
23/// Output-facing metadata for one security rule.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct SecurityRuleMeta<'a> {
26    pub id: &'a str,
27    pub name: &'a str,
28    pub description: &'a str,
29    pub docs_path: &'a str,
30}
31
32/// Build the `_meta` object for `fallow health --format json --explain`.
33#[must_use]
34pub fn health_meta() -> Meta {
35    Meta {
36        docs: Some(HEALTH_DOCS.to_string()),
37        field_definitions: action_field_definitions(),
38        metrics: health_metrics(),
39        ..Meta::default()
40    }
41}
42
43/// Build the `_meta` object for `fallow security --format json --explain`.
44#[must_use]
45pub fn security_meta<'a>(rules: impl IntoIterator<Item = SecurityRuleMeta<'a>>) -> Meta {
46    Meta {
47        docs: Some(SECURITY_DOCS.to_string()),
48        field_definitions: security_field_definitions(),
49        metrics: BTreeMap::new(),
50        rules: rules
51            .into_iter()
52            .map(|rule| {
53                (
54                    rule.id.to_string(),
55                    MetaRule {
56                        name: Some(rule.name.to_string()),
57                        description: Some(rule.description.to_string()),
58                        docs: Some(report_rule_docs_url(rule.docs_path)),
59                    },
60                )
61            })
62            .collect(),
63        ..Meta::default()
64    }
65}
66
67/// Build the `_meta` object for `fallow dupes --format json --explain`.
68#[must_use]
69pub fn dupes_meta() -> Meta {
70    Meta {
71        docs: Some(DUPES_DOCS.to_string()),
72        field_definitions: action_field_definitions(),
73        metrics: BTreeMap::from([
74            (
75                "duplication_percentage".to_string(),
76                metric(
77                    "Duplication Percentage",
78                    "Fraction of total source tokens that appear in at least one clone group. Computed over the full analyzed file set.",
79                    Some("[0, 100]"),
80                    "lower is better",
81                ),
82            ),
83            (
84                "token_count".to_string(),
85                metric(
86                    "Token Count",
87                    "Number of normalized source tokens in the clone group. Tokens are language-aware (keywords, identifiers, operators, punctuation). Higher token count = larger duplicate.",
88                    Some("[1, ∞)"),
89                    "larger clones have higher refactoring value",
90                ),
91            ),
92            (
93                "line_count".to_string(),
94                metric(
95                    "Line Count",
96                    "Number of source lines spanned by the clone instance. Approximation of clone size for human readability.",
97                    Some("[1, ∞)"),
98                    "larger clones are more impactful to deduplicate",
99                ),
100            ),
101            (
102                "clone_groups".to_string(),
103                metric(
104                    "Clone Groups",
105                    "A set of code fragments with identical or near-identical normalized token sequences. Each group has 2+ instances across different locations.",
106                    None,
107                    "each group is a single refactoring opportunity",
108                ),
109            ),
110            (
111                "clone_groups_below_min_occurrences".to_string(),
112                metric(
113                    "Clone Groups Below minOccurrences",
114                    "Number of clone groups detected but hidden by the `duplicates.minOccurrences` filter. Always 0 (or absent) when the filter is at its default of 2. Pre-filter group count = `clone_groups + clone_groups_below_min_occurrences`.",
115                    Some("[0, ∞)"),
116                    "high values suggest noisy pair-only duplication; lower `minOccurrences` to inspect",
117                ),
118            ),
119            (
120                "clone_families".to_string(),
121                metric(
122                    "Clone Families",
123                    "Groups of clone groups that share the same set of files. Indicates systematic duplication patterns (e.g., mirrored directory structures).",
124                    None,
125                    "families suggest extract-module refactoring opportunities",
126                ),
127            ),
128        ]),
129        ..Meta::default()
130    }
131}
132
133/// Build the `_meta` object for `fallow coverage setup --json --explain`.
134#[must_use]
135pub fn coverage_setup_meta() -> Value {
136    json!({
137        "docs_url": COVERAGE_SETUP_DOCS,
138        "field_definitions": {
139            "schema_version": "Coverage setup JSON contract version. Stays at \"1\" for additive opt-in fields such as _meta.",
140            "framework_detected": "Primary detected runtime framework for compatibility with single-app consumers. In workspaces this mirrors the first emitted runtime member; unknown means no runtime member was detected.",
141            "package_manager": "Detected package manager used for install and run commands, or null when no package manager signal was found.",
142            "runtime_targets": "Union of runtime targets across emitted members.",
143            "members[]": "Per-runtime-workspace setup recipes. Pure aggregator roots and build-only libraries are omitted.",
144            "members[].name": "Workspace package name from package.json, or the root directory name when package.json has no name.",
145            "members[].path": "Workspace path relative to the command root. The root package is represented as \".\".",
146            "members[].framework_detected": "Runtime framework detected for that member.",
147            "members[].package_manager": "Package manager detected for that member, or inherited from the workspace root when no member-specific signal exists.",
148            "members[].runtime_targets": "Runtime targets produced by that member.",
149            "members[].files_to_edit": "Files in that member that should receive runtime beacon setup code.",
150            "members[].snippets": "Copy-paste setup snippets for that member, with paths relative to the command root.",
151            "members[].dockerfile_snippet": "Environment snippet for file-system capture in that member's containerized Node runtime, or null when not applicable.",
152            "members[].warnings": "Actionable setup caveats discovered for that member.",
153            "config_written": "Always null for --json because JSON setup is side-effect-free and never writes configuration.",
154            "files_to_edit": "Compatibility copy of the primary member's files, with workspace prefixes when the primary member is not the root.",
155            "snippets": "Compatibility copy of the primary member's snippets, with workspace prefixes when the primary member is not the root.",
156            "dockerfile_snippet": "Environment snippet for file-system capture in containerized Node runtimes, or null when not applicable.",
157            "commands": "Package-manager commands needed to install the runtime beacon and sidecar packages.",
158            "next_steps": "Ordered setup workflow after applying the emitted snippets.",
159            "warnings": "Actionable setup caveats discovered while building the recipe."
160        },
161        "enums": {
162            "framework_detected": ["nextjs", "nestjs", "nuxt", "sveltekit", "astro", "remix", "vite", "plain_node", "unknown"],
163            "runtime_targets": ["node", "browser"],
164            "package_manager": ["npm", "pnpm", "yarn", "bun", null]
165        },
166        "warnings": {
167            "No runtime workspace members were detected": "The root appears to be a workspace, but no runtime-bearing package was found. The payload emits install commands only.",
168            "No local coverage artifact was detected yet": "Run the application with runtime coverage collection enabled, then re-run setup or health with the produced capture path.",
169            "Package manager was not detected": "No packageManager field or known lockfile was found. Commands fall back to npm.",
170            "Framework was not detected": "No known framework dependency or runtime script was found. Treat the recipe as a generic Node setup and adjust the entry path as needed."
171        }
172    })
173}
174
175/// Build the `_meta` object for `fallow coverage analyze --format json --explain`.
176#[must_use]
177pub fn coverage_analyze_meta() -> Value {
178    json!({
179        "docs_url": COVERAGE_ANALYZE_DOCS,
180        "field_definitions": {
181            "schema_version": "Standalone coverage analyze envelope version. \"1\" for the current shape.",
182            "version": "fallow CLI version that produced this output.",
183            "elapsed_ms": "Wall-clock milliseconds spent producing the report.",
184            "runtime_coverage": "Same RuntimeCoverageReport block emitted by `fallow health --runtime-coverage`.",
185            "runtime_coverage.summary.data_source": "Which evidence source produced the report. local = on-disk artifact via --runtime-coverage <path>; cloud = explicit pull via --cloud / --runtime-coverage-cloud / FALLOW_RUNTIME_COVERAGE_SOURCE=cloud.",
186            "runtime_coverage.summary.last_received_at": "ISO-8601 timestamp of the newest runtime payload included in the report. Null for local artifacts that do not carry receipt metadata.",
187            "runtime_coverage.summary.capture_quality": "Capture-window telemetry derived from the runtime evidence. lazy_parse_warning trips when more than 30% of tracked functions are V8-untracked, which usually indicates a short observation window.",
188            "runtime_coverage.findings[].id": "Per-finding SUPPRESSION key (fallow:prod:<hash>). Hashes file + function + the current line, so it changes when the function moves. Use it to suppress one finding at its current location.",
189            "runtime_coverage.findings[].stable_id": "Cross-surface JOIN key (fallow:fn:<hash>) from fallow_cov_protocol::function_identity_id, hashing file + name + start_line. The same function shares ONE value across findings, hot paths, blast-radius, and importance entries (the per-finding id uses a per-surface salt and differs), and across V8/Istanbul/oxc producers (columns are excluded from the hash). Like id, it changes when the function's file, name, or start line changes: it is a cross-surface/cross-producer join key, NOT a line-move-immune one. Omitted from the JSON entirely (not emitted as null) when the producing surface or an un-migrated cloud supplied no FunctionIdentity. New baselines key on this when present to align with the cross-surface join key; the grace-window reader accepts the legacy id too.",
190            "runtime_coverage._matching": "Function-identity fallback order when joining runtime evidence to local static analysis: (1) exact stable_id match (fallow:fn:<hash>) when both sides carry one; (2) exact (path, name, start_line); (3) fuzzy nearest candidate within a line tolerance. Baseline suppression accepts BOTH the stable_id and the legacy fallow:prod: id during the grace window, so baselines written before this version keep suppressing.",
191            "runtime_coverage.findings[].evidence.static_status": "used = the function is reachable in the AST module graph; unused = it is dead by static analysis.",
192            "runtime_coverage.findings[].evidence.test_coverage": "covered = the local test suite hits the function; not_covered otherwise.",
193            "runtime_coverage.findings[].evidence.v8_tracking": "tracked = V8 observed the function during the capture window; untracked otherwise.",
194            "runtime_coverage.findings[].actions[].type": "Suggested follow-up identifier. delete-cold-code is emitted on safe_to_delete; review-runtime on review_required.",
195            "runtime_coverage.blast_radius[]": "First-class blast-radius entries with stable fallow:blast IDs, static caller count, traffic-weighted caller reach, optional cloud deploy touch count, and low/medium/high risk band.",
196            "runtime_coverage.importance[]": "First-class production-importance entries with stable fallow:importance IDs, invocations, cyclomatic complexity, owner count, 0-100 importance score, and templated reason.",
197            "runtime_coverage.warnings[].code": "Stable warning identifier. cloud_functions_unmatched flags entries dropped because no AST/static counterpart was found locally."
198        },
199        "enums": {
200            "data_source": ["local", "cloud"],
201            "report_verdict": ["clean", "hot-path-touched", "cold-code-detected", "license-expired-grace", "unknown"],
202            "finding_verdict": ["safe_to_delete", "review_required", "coverage_unavailable", "low_traffic", "active", "unknown"],
203            "static_status": ["used", "unused"],
204            "test_coverage": ["covered", "not_covered"],
205            "v8_tracking": ["tracked", "untracked"],
206            "action_type": ["delete-cold-code", "review-runtime"]
207        },
208        "warnings": {
209            "no_runtime_data": "Cloud returned an empty runtime window. Either the period is too narrow or no traces have been ingested yet.",
210            "cloud_functions_unmatched": "One or more cloud-side functions could not be matched against the local AST/static index and were dropped from findings. Common causes: stale runtime data after a rename/move, file path mismatch between deploy and repo, or analysis run on the wrong commit."
211        }
212    })
213}
214
215fn action_field_definitions() -> BTreeMap<String, String> {
216    BTreeMap::from([
217        (
218            "actions[]".to_string(),
219            ACTIONS_FIELD_DEFINITION.to_string(),
220        ),
221        (
222            "actions[].auto_fixable".to_string(),
223            ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION.to_string(),
224        ),
225    ])
226}
227
228fn security_field_definitions() -> BTreeMap<String, String> {
229    BTreeMap::from([
230        (
231            "version".to_string(),
232            "fallow CLI version that produced this output.".to_string(),
233        ),
234        (
235            "elapsed_ms".to_string(),
236            "Wall-clock milliseconds spent producing the security report.".to_string(),
237        ),
238        (
239            "config".to_string(),
240            "Privacy-safe config context relevant to security candidate generation.".to_string(),
241        ),
242        (
243            "config.rules.*.configured".to_string(),
244            "Severity from resolved config before the security command forced default-off rules on."
245                .to_string(),
246        ),
247        (
248            "config.rules.*.effective".to_string(),
249            "Severity used for this security command run.".to_string(),
250        ),
251        (
252            "config.categories_include".to_string(),
253            "Configured security category include list. null means unset, [] means explicitly empty."
254                .to_string(),
255        ),
256        (
257            "config.categories_exclude".to_string(),
258            "Configured security category exclude list. null means unset, [] means explicitly empty."
259                .to_string(),
260        ),
261        (
262            "security_findings[]".to_string(),
263            "Unverified security candidates for downstream human or agent verification.".to_string(),
264        ),
265        (
266            "summary.security_findings".to_string(),
267            "Number of security candidates after all filters, gates, and scopes.".to_string(),
268        ),
269        (
270            "summary.by_severity".to_string(),
271            "Fixed high, medium, and low severity counts for summary JSON.".to_string(),
272        ),
273        (
274            "summary.by_category".to_string(),
275            "Candidate counts by catalogue category, or by kind for uncategorized findings."
276                .to_string(),
277        ),
278        (
279            "summary.by_reachability".to_string(),
280            "Fixed reachability and source-backed ranking-signal counts for summary JSON."
281                .to_string(),
282        ),
283        (
284            "summary.by_runtime_state".to_string(),
285            "Fixed production-runtime coverage state counts for summary JSON.".to_string(),
286        ),
287        (
288            "unresolved_edge_files".to_string(),
289            "Number of client files whose import cone contains dynamic edges the graph could not follow."
290                .to_string(),
291        ),
292        (
293            "unresolved_callee_sites".to_string(),
294            "Number of sink-shaped nodes whose callee could not be flattened to a static path."
295                .to_string(),
296        ),
297    ])
298}
299
300fn health_metrics() -> BTreeMap<String, MetaMetric> {
301    let mut metrics = BTreeMap::new();
302    metrics.extend(health_complexity_metrics());
303    metrics.extend(health_churn_and_target_metrics());
304    metrics.extend(health_ownership_metrics());
305    metrics.extend(health_runtime_metrics());
306    metrics
307}
308
309fn health_complexity_metrics() -> [(String, MetaMetric); 11] {
310    [
311        health_metric(
312            "cyclomatic",
313            "Cyclomatic Complexity",
314            "McCabe cyclomatic complexity: 1 + number of decision points.",
315            Some("[1, infinity)"),
316            "lower is better; default threshold: 20",
317        ),
318        health_metric(
319            "cognitive",
320            "Cognitive Complexity",
321            "Cognitive complexity penalizes nesting depth and non-linear control flow.",
322            Some("[0, infinity)"),
323            "lower is better; default threshold: 15",
324        ),
325        health_metric(
326            "line_count",
327            "Function Line Count",
328            "Number of lines in the function body.",
329            Some("[1, infinity)"),
330            "context-dependent; long functions may need splitting",
331        ),
332        health_metric(
333            "lines",
334            "File Line Count",
335            "Total lines of code in the file.",
336            Some("[1, infinity)"),
337            "context-dependent; large files may benefit from splitting",
338        ),
339        health_metric(
340            "maintainability_index",
341            "Maintainability Index",
342            "Composite file score combining complexity density, dead code ratio, and coupling.",
343            Some("[0, 100]"),
344            "higher is better",
345        ),
346        health_metric(
347            "complexity_density",
348            "Complexity Density",
349            "Total cyclomatic complexity divided by lines of code.",
350            Some("[0, infinity)"),
351            "lower is better; >1.0 indicates very dense complexity",
352        ),
353        health_metric(
354            "dead_code_ratio",
355            "Dead Code Ratio",
356            "Fraction of value exports with zero references across the project.",
357            Some("[0, 1]"),
358            "lower is better; 0 means all exports are used",
359        ),
360        health_metric(
361            "fan_in",
362            "Fan-in (Importers)",
363            "Number of files that import this file.",
364            Some("[0, infinity)"),
365            "context-dependent; high fan-in files need careful review",
366        ),
367        health_metric(
368            "fan_out",
369            "Fan-out (Imports)",
370            "Number of files this file directly imports.",
371            Some("[0, infinity)"),
372            "lower is better; high fan-out indicates coupling",
373        ),
374        health_metric(
375            "max_render_fan_in",
376            "Render Fan-in (Blast Radius)",
377            "Highest distinct-parent render count across React or Preact components.",
378            Some("[0, infinity)"),
379            "descriptive only; high values mean broad edit ripple",
380        ),
381        health_metric(
382            "crap_max",
383            "Untested Complexity Risk (CRAP)",
384            "Highest Change Risk Anti-Patterns score from complexity and coverage evidence.",
385            Some("[1, infinity)"),
386            "lower is better; high values indicate complex untested code",
387        ),
388    ]
389}
390
391fn health_churn_and_target_metrics() -> [(String, MetaMetric); 8] {
392    [
393        health_metric(
394            "score",
395            "Hotspot Score",
396            "Normalized churn multiplied by normalized complexity.",
397            Some("[0, 100]"),
398            "higher means riskier; prioritize refactoring high-score files",
399        ),
400        health_metric(
401            "weighted_commits",
402            "Weighted Commits",
403            "Recency-weighted commit count using exponential decay.",
404            Some("[0, infinity)"),
405            "higher means more recent churn activity",
406        ),
407        health_metric(
408            "trend",
409            "Churn Trend",
410            "Compares recent vs older commit frequency within the analysis window.",
411            None,
412            "accelerating files need attention; cooling files are stabilizing",
413        ),
414        health_metric(
415            "priority",
416            "Refactoring Priority",
417            "Weighted refactoring score using complexity, hotspots, dead code, fan-in, and fan-out.",
418            Some("[0, 100]"),
419            "higher means more urgent to refactor",
420        ),
421        health_metric(
422            "efficiency",
423            "Efficiency Score",
424            "Priority divided by effort estimate.",
425            Some("[0, 100]"),
426            "higher means better quick-win value",
427        ),
428        health_metric(
429            "effort",
430            "Effort Estimate",
431            "Heuristic effort estimate based on file size, function count, and fan-in.",
432            None,
433            "low means quick win, high needs planning and coordination",
434        ),
435        health_metric(
436            "confidence",
437            "Confidence Level",
438            "Reliability of the recommendation based on data source.",
439            None,
440            "high means act on it; medium or low means verify context",
441        ),
442        health_metric(
443            "health_score",
444            "Health Score",
445            "Project-level aggregate score computed from vital signs and issue signals.",
446            Some("[0, 100]"),
447            "higher is better; missing metrics are not penalized",
448        ),
449    ]
450}
451
452fn health_ownership_metrics() -> [(String, MetaMetric); 6] {
453    [
454        health_metric(
455            "bus_factor",
456            "Bus Factor",
457            "Minimum number of contributors who account for most recent weighted commits.",
458            Some("[1, infinity)"),
459            "lower is higher knowledge-loss risk",
460        ),
461        health_metric(
462            "contributor_count",
463            "Contributor Count",
464            "Number of distinct authors who touched this file in the analysis window.",
465            Some("[0, infinity)"),
466            "higher generally indicates broader knowledge spread",
467        ),
468        health_metric(
469            "share",
470            "Contributor Share",
471            "Recency-weighted share of total weighted commits attributed to a contributor.",
472            Some("[0, 1]"),
473            "share close to 1.0 indicates ownership concentration",
474        ),
475        health_metric(
476            "stale_days",
477            "Stale Days",
478            "Days since this contributor last touched the file.",
479            Some("[0, infinity)"),
480            "high stale days can indicate ownership drift",
481        ),
482        health_metric(
483            "drift",
484            "Ownership Drift",
485            "Whether original authorship and current contribution ownership have diverged.",
486            None,
487            "true means current review ownership may differ from original ownership",
488        ),
489        health_metric(
490            "unowned",
491            "Unowned (Tristate)",
492            "Whether CODEOWNERS exists but has no matching owner for this file.",
493            None,
494            "true on a hotspot is a review-bottleneck risk",
495        ),
496    ]
497}
498
499fn health_runtime_metrics() -> [(String, MetaMetric); 5] {
500    [
501        health_metric(
502            "runtime_coverage_verdict",
503            "Runtime Coverage Verdict",
504            "Overall verdict across runtime-coverage findings.",
505            None,
506            "cold-code-detected is the primary standalone cleanup signal",
507        ),
508        health_metric(
509            "runtime_coverage_state",
510            "Runtime Coverage State",
511            "Per-function runtime observation state.",
512            None,
513            "never-called with static unused is the highest-confidence delete signal",
514        ),
515        health_metric(
516            "runtime_coverage_confidence",
517            "Runtime Coverage Confidence",
518            "Confidence in a runtime-coverage finding.",
519            None,
520            "high means act on it; medium or low means verify context",
521        ),
522        health_metric(
523            "production_invocations",
524            "Production Invocations",
525            "Observed invocation count for the function over the collected coverage window.",
526            Some("[0, infinity)"),
527            "0 plus tracked means cold path; high means active path",
528        ),
529        health_metric(
530            "percent_dead_in_production",
531            "Percent Dead in Production",
532            "Fraction of tracked functions with zero observed invocations, multiplied by 100.",
533            Some("[0, 100]"),
534            "lower is better",
535        ),
536    ]
537}
538
539fn health_metric(
540    key: impl Into<String>,
541    name: impl Into<String>,
542    description: impl Into<String>,
543    range: Option<&str>,
544    interpretation: impl Into<String>,
545) -> (String, MetaMetric) {
546    (key.into(), metric(name, description, range, interpretation))
547}
548
549fn metric(
550    name: impl Into<String>,
551    description: impl Into<String>,
552    range: Option<&str>,
553    interpretation: impl Into<String>,
554) -> MetaMetric {
555    MetaMetric {
556        name: Some(name.into()),
557        description: Some(description.into()),
558        range: range.map(str::to_string),
559        interpretation: Some(interpretation.into()),
560    }
561}
562
563fn report_rule_docs_url(docs_path: &str) -> String {
564    format!("https://docs.fallow.tools/{docs_path}")
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn dupes_meta_uses_output_contract_shape() {
573        let meta = dupes_meta();
574        assert_eq!(meta.docs.as_deref(), Some(DUPES_DOCS));
575        assert!(meta.field_definitions.contains_key("actions[]"));
576        assert!(meta.metrics.contains_key("duplication_percentage"));
577        assert!(
578            meta.metrics
579                .contains_key("clone_groups_below_min_occurrences")
580        );
581    }
582
583    #[test]
584    fn health_meta_uses_output_contract_shape() {
585        let meta = health_meta();
586        assert_eq!(meta.docs.as_deref(), Some(HEALTH_DOCS));
587        assert!(meta.field_definitions.contains_key("actions[]"));
588        assert!(meta.metrics.contains_key("cyclomatic"));
589        assert!(meta.metrics.contains_key("health_score"));
590        assert!(meta.metrics.contains_key("max_render_fan_in"));
591        assert!(meta.metrics.contains_key("percent_dead_in_production"));
592    }
593
594    #[test]
595    fn security_meta_uses_output_contract_shape() {
596        let meta = security_meta([SecurityRuleMeta {
597            id: "security/example",
598            name: "Example",
599            description: "Example security candidate.",
600            docs_path: "cli/security",
601        }]);
602        assert_eq!(meta.docs.as_deref(), Some(SECURITY_DOCS));
603        assert!(meta.field_definitions.contains_key("security_findings[]"));
604        assert!(meta.metrics.is_empty());
605        assert_eq!(
606            meta.rules["security/example"].docs.as_deref(),
607            Some("https://docs.fallow.tools/cli/security")
608        );
609    }
610
611    #[test]
612    fn coverage_setup_meta_uses_output_contract_shape() {
613        let meta = coverage_setup_meta();
614        assert_eq!(meta["docs_url"], COVERAGE_SETUP_DOCS);
615        assert!(meta["field_definitions"]["members[]"].is_string());
616        assert!(meta["enums"]["runtime_targets"].is_array());
617        assert!(meta["warnings"]["Package manager was not detected"].is_string());
618    }
619
620    #[test]
621    fn coverage_analyze_meta_uses_output_contract_shape() {
622        let meta = coverage_analyze_meta();
623        assert_eq!(meta["docs_url"], COVERAGE_ANALYZE_DOCS);
624        assert!(meta["field_definitions"]["runtime_coverage.findings[].stable_id"].is_string());
625        assert!(meta["enums"]["action_type"].is_array());
626        assert!(meta["warnings"]["cloud_functions_unmatched"].is_string());
627    }
628}