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://fallow.tools/docs/cli/dupes/";
10
11/// Docs URL for the runtime coverage setup command's agent-readable JSON.
12pub const COVERAGE_SETUP_DOCS: &str = "https://fallow.tools/docs/cli/coverage/#agent-readable-json";
13
14/// Docs URL for `fallow coverage analyze --format json --explain`.
15pub const COVERAGE_ANALYZE_DOCS: &str = "https://fallow.tools/docs/cli/coverage/#analyze";
16
17/// Docs URL for the health command.
18pub const HEALTH_DOCS: &str = "https://fallow.tools/docs/cli/health/";
19
20/// Docs URL for the security command.
21pub const SECURITY_DOCS: &str = "https://fallow.tools/docs/cli/security/";
22
23/// Output-facing metadata for one security rule.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct SecurityRuleMeta<'a> {
26    /// Stable rule identifier used as the `_meta.rules` key.
27    pub id: &'a str,
28    /// Human-readable rule name.
29    pub name: &'a str,
30    /// One-line rule description.
31    pub description: &'a str,
32    /// Docs path relative to the docs site root, joined onto the base URL.
33    pub docs_path: &'a str,
34}
35
36/// Build the `_meta` object for `fallow health --format json --explain`.
37#[must_use]
38pub fn health_meta() -> Meta {
39    Meta {
40        docs: Some(HEALTH_DOCS.to_string()),
41        field_definitions: action_field_definitions(),
42        metrics: health_metrics(),
43        ..Meta::default()
44    }
45}
46
47/// Build the `_meta` object for `fallow security --format json --explain`.
48#[must_use]
49pub fn security_meta<'a>(rules: impl IntoIterator<Item = SecurityRuleMeta<'a>>) -> Meta {
50    Meta {
51        docs: Some(SECURITY_DOCS.to_string()),
52        field_definitions: security_field_definitions(),
53        metrics: BTreeMap::new(),
54        rules: rules
55            .into_iter()
56            .map(|rule| {
57                (
58                    rule.id.to_string(),
59                    MetaRule {
60                        name: Some(rule.name.to_string()),
61                        description: Some(rule.description.to_string()),
62                        docs: Some(crate::issue_contract::rule_docs_url(rule.docs_path)),
63                    },
64                )
65            })
66            .collect(),
67        ..Meta::default()
68    }
69}
70
71/// Build the `_meta` object for `fallow dupes --format json --explain`.
72#[must_use]
73pub fn dupes_meta() -> Meta {
74    Meta {
75        docs: Some(DUPES_DOCS.to_string()),
76        field_definitions: action_field_definitions(),
77        metrics: dupes_metrics(),
78        ..Meta::default()
79    }
80}
81
82fn dupes_metrics() -> BTreeMap<String, MetaMetric> {
83    dupes_size_metrics()
84        .into_iter()
85        .chain(dupes_triage_metrics())
86        .collect()
87}
88
89fn dupes_size_metrics() -> [(String, MetaMetric); 6] {
90    [
91        (
92            "duplication_percentage".to_string(),
93            metric(
94                "Duplication Percentage",
95                "Percentage of source lines that overlap at least one reported clone instance. Computed over the full analyzed file set.",
96                Some("[0, 100]"),
97                "lower is better",
98            ),
99        ),
100        (
101            "duplicated_tokens".to_string(),
102            metric(
103                "Duplicated Tokens",
104                "Number of tokens in redundant clone copies, excluding one retained copy per clone group.",
105                Some("[0, ∞)"),
106                "higher values indicate more code that can be removed by consolidating clones",
107            ),
108        ),
109        (
110            "token_count".to_string(),
111            metric(
112                "Token Count",
113                "Number of normalized source tokens in the clone group. Tokens are language-aware (keywords, identifiers, operators, punctuation). Higher token count = larger duplicate.",
114                Some("[1, ∞)"),
115                "larger clones have higher refactoring value",
116            ),
117        ),
118        (
119            "line_count".to_string(),
120            metric(
121                "Line Count",
122                "Number of source lines spanned by the clone instance. Approximation of clone size for human readability.",
123                Some("[1, ∞)"),
124                "larger clones are more impactful to deduplicate",
125            ),
126        ),
127        (
128            "spread".to_string(),
129            metric(
130                "Clone Spread",
131                "Maximum directory-tree distance between files in a clone group, or the same-file line gap measured in 250-line steps.",
132                Some("[0, ∞)"),
133                "higher values indicate clones that are harder to discover and coordinate",
134            ),
135        ),
136        (
137            "similarity".to_string(),
138            metric(
139                "Clone Similarity",
140                "Lowest all-pairs Jaccard similarity in a near-miss clone group. Omitted for exact clone groups.",
141                Some("[0, 1]"),
142                "values closer to 1 are more structurally alike",
143            ),
144        ),
145    ]
146}
147
148fn dupes_triage_metrics() -> [(String, MetaMetric); 5] {
149    [
150        (
151            "clone_groups".to_string(),
152            metric(
153                "Clone Groups",
154                "A set of code fragments with identical or near-identical normalized token sequences. Each group has 2+ instances across different locations.",
155                None,
156                "each group is a single refactoring opportunity",
157            ),
158        ),
159        (
160            "clone_groups_below_min_occurrences".to_string(),
161            metric(
162                "Clone Groups Below minOccurrences",
163                "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`.",
164                Some("[0, ∞)"),
165                "high values suggest noisy pair-only duplication; lower `minOccurrences` to inspect",
166            ),
167        ),
168        (
169            "clone_groups_ignored".to_string(),
170            metric(
171                "Ignored Clone Groups",
172                "Number of clone groups hidden by `duplicates.ignoredClones` in this run.",
173                Some("[0, ∞)"),
174                "nonzero values show reviewed clone groups omitted from the report",
175            ),
176        ),
177        (
178            "near_candidates_skipped".to_string(),
179            metric(
180                "Skipped Near Candidates",
181                "Number of near-miss candidate comparisons skipped by bounded-work limits.",
182                Some("[0, ∞)"),
183                "nonzero values mean near-miss detection intentionally limited candidate work",
184            ),
185        ),
186        (
187            "clone_families".to_string(),
188            metric(
189                "Clone Families",
190                "Groups of clone groups that share the same set of files. Indicates systematic duplication patterns (e.g., mirrored directory structures).",
191                None,
192                "families suggest extract-module refactoring opportunities",
193            ),
194        ),
195    ]
196}
197
198/// Build the `_meta` object for `fallow coverage setup --json --explain`.
199#[must_use]
200pub fn coverage_setup_meta() -> Value {
201    json!({
202        "docs_url": COVERAGE_SETUP_DOCS,
203        "field_definitions": {
204            "schema_version": "Coverage setup JSON contract version. Stays at \"1\" for additive opt-in fields such as _meta.",
205            "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.",
206            "package_manager": "Detected package manager used for install and run commands, or null when no package manager signal was found.",
207            "runtime_targets": "Union of runtime targets across emitted members.",
208            "members[]": "Per-runtime-workspace setup recipes. Pure aggregator roots and build-only libraries are omitted.",
209            "members[].name": "Workspace package name from package.json, or the root directory name when package.json has no name.",
210            "members[].path": "Workspace path relative to the command root. The root package is represented as \".\".",
211            "members[].framework_detected": "Runtime framework detected for that member.",
212            "members[].package_manager": "Package manager detected for that member, or inherited from the workspace root when no member-specific signal exists.",
213            "members[].runtime_targets": "Runtime targets produced by that member.",
214            "members[].files_to_edit": "Files in that member that should receive runtime beacon setup code.",
215            "members[].snippets": "Copy-paste setup snippets for that member, with paths relative to the command root.",
216            "members[].dockerfile_snippet": "Environment snippet for file-system capture in that member's containerized Node runtime, or null when not applicable.",
217            "members[].warnings": "Actionable setup caveats discovered for that member.",
218            "config_written": "Always null for --json because JSON setup is side-effect-free and never writes configuration.",
219            "files_to_edit": "Compatibility copy of the primary member's files, with workspace prefixes when the primary member is not the root.",
220            "snippets": "Compatibility copy of the primary member's snippets, with workspace prefixes when the primary member is not the root.",
221            "dockerfile_snippet": "Environment snippet for file-system capture in containerized Node runtimes, or null when not applicable.",
222            "commands": "Package-manager commands needed to install the runtime beacon and sidecar packages.",
223            "next_steps": "Ordered setup workflow after applying the emitted snippets.",
224            "warnings": "Actionable setup caveats discovered while building the recipe."
225        },
226        "enums": {
227            "framework_detected": ["nextjs", "nestjs", "nuxt", "sveltekit", "astro", "remix", "vite", "plain_node", "unknown"],
228            "runtime_targets": ["node", "browser"],
229            "package_manager": ["npm", "pnpm", "yarn", "bun", null]
230        },
231        "warnings": {
232            "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.",
233            "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.",
234            "Package manager was not detected": "No packageManager field or known lockfile was found. Commands fall back to npm.",
235            "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."
236        }
237    })
238}
239
240/// Build the `_meta` object for `fallow coverage analyze --format json --explain`.
241#[must_use]
242pub fn coverage_analyze_meta() -> Value {
243    json!({
244        "docs_url": COVERAGE_ANALYZE_DOCS,
245        "field_definitions": {
246            "schema_version": "Standalone coverage analyze envelope version. \"2\" for the current shape.",
247            "version": "fallow CLI version that produced this output.",
248            "elapsed_ms": "Wall-clock milliseconds spent producing the report.",
249            "runtime_coverage": "Same RuntimeCoverageReport block emitted by `fallow health --runtime-coverage`.",
250            "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.",
251            "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.",
252            "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.",
253            "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.",
254            "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.",
255            "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.",
256            "runtime_coverage.findings[].evidence.static_status": "used = the function is reachable in the AST module graph; unused = it is dead by static analysis.",
257            "runtime_coverage.findings[].evidence.test_coverage": "covered = the local test suite hits the function; not_covered otherwise.",
258            "runtime_coverage.findings[].evidence.test_only_reference": "true = the function is unreachable in the production module graph but still referenced from a file production mode excludes (test, spec, story, fixture, benchmark), so it is not dead code and never earns safe_to_delete; false = both graphs were compared and no such reference exists. Omitted from the JSON entirely when the run applied no production filter or the producing surface carries no second reachability answer.",
259            "runtime_coverage.findings[].evidence.v8_tracking": "tracked = V8 observed the function during the capture window; untracked otherwise.",
260            "runtime_coverage.findings[].actions[].type": "Suggested follow-up identifier. delete-cold-code is emitted on safe_to_delete; review-runtime on review_required.",
261            "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.",
262            "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. importance ranks the risk of a change; use hot_paths[].optimization_target to rank speed work.",
263            "runtime_coverage.hot_paths[].optimization_target": "Speed-work inputs for a hot function: cost_score (invocations multiplied by the per-call cost, uncapped), cost_basis (inner_iterations when V8 block counts exist, else cognitive), cognitive, cyclomatic, line_count, and inner_iterations_per_call (peak block executions per call, block-coverage dumps only). Omitted when the hot path has no stable_id or no static counterpart. cognitive is a static proxy for the work per call, not a measurement.",
264            "runtime_coverage.warnings[].code": "Stable warning identifier. cloud_functions_unmatched flags entries dropped because no AST/static counterpart was found locally. optimization_target_unmatched counts hot paths without an optimization_target: the hot path has no stable_id, or no static function matches its stable_id."
265        },
266        "enums": {
267            "data_source": ["local", "cloud"],
268            "report_verdict": ["clean", "hot-path-touched", "cold-code-detected", "license-expired-grace", "unknown"],
269            "finding_verdict": ["safe_to_delete", "review_required", "coverage_unavailable", "low_traffic", "active", "unknown"],
270            "static_status": ["used", "unused"],
271            "test_only_reference": [true, false],
272            "test_coverage": ["covered", "not_covered"],
273            "v8_tracking": ["tracked", "untracked"],
274            "action_type": ["delete-cold-code", "review-runtime"],
275            "cost_basis": ["inner_iterations", "cognitive"]
276        },
277        "warnings": {
278            "no_runtime_data": "Cloud returned an empty runtime window. Either the period is too narrow or no traces have been ingested yet.",
279            "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."
280        }
281    })
282}
283
284fn action_field_definitions() -> BTreeMap<String, String> {
285    BTreeMap::from([
286        (
287            "actions[]".to_string(),
288            ACTIONS_FIELD_DEFINITION.to_string(),
289        ),
290        (
291            "actions[].auto_fixable".to_string(),
292            ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION.to_string(),
293        ),
294    ])
295}
296
297fn security_field_definitions() -> BTreeMap<String, String> {
298    BTreeMap::from([
299        (
300            "version".to_string(),
301            "fallow CLI version that produced this output.".to_string(),
302        ),
303        (
304            "elapsed_ms".to_string(),
305            "Wall-clock milliseconds spent producing the security report.".to_string(),
306        ),
307        (
308            "config".to_string(),
309            "Privacy-safe config context relevant to security candidate generation.".to_string(),
310        ),
311        (
312            "config.rules.*.configured".to_string(),
313            "Severity from resolved config before the security command forced default-off rules on."
314                .to_string(),
315        ),
316        (
317            "config.rules.*.effective".to_string(),
318            "Severity used for this security command run.".to_string(),
319        ),
320        (
321            "config.categories_include".to_string(),
322            "Configured security category include list. null means unset, [] means explicitly empty."
323                .to_string(),
324        ),
325        (
326            "config.categories_exclude".to_string(),
327            "Configured security category exclude list. null means unset, [] means explicitly empty."
328                .to_string(),
329        ),
330        (
331            "security_findings[]".to_string(),
332            "Unverified security candidates for downstream human or agent verification.".to_string(),
333        ),
334        (
335            "summary.security_findings".to_string(),
336            "Number of security candidates after all filters, gates, and scopes.".to_string(),
337        ),
338        (
339            "summary.by_severity".to_string(),
340            "Fixed high, medium, and low severity counts for summary JSON.".to_string(),
341        ),
342        (
343            "summary.by_category".to_string(),
344            "Candidate counts by catalogue category, or by kind for uncategorized findings."
345                .to_string(),
346        ),
347        (
348            "summary.by_reachability".to_string(),
349            "Fixed reachability and source-backed ranking-signal counts for summary JSON."
350                .to_string(),
351        ),
352        (
353            "summary.by_runtime_state".to_string(),
354            "Fixed production-runtime coverage state counts for summary JSON.".to_string(),
355        ),
356        (
357            "unresolved_edge_files".to_string(),
358            "Number of client files whose import cone contains dynamic edges the graph could not follow."
359                .to_string(),
360        ),
361        (
362            "unresolved_callee_sites".to_string(),
363            "Number of sink-shaped nodes whose callee could not be flattened to a static path."
364                .to_string(),
365        ),
366    ])
367}
368
369fn health_metrics() -> BTreeMap<String, MetaMetric> {
370    let mut metrics = BTreeMap::new();
371    metrics.extend(health_complexity_metrics());
372    metrics.extend(health_population_metrics());
373    metrics.extend(health_churn_and_target_metrics());
374    metrics.extend(health_change_history_metrics());
375    metrics.extend(health_ownership_metrics());
376    metrics.extend(health_runtime_metrics());
377    metrics.extend(health_styling_metrics());
378    metrics
379}
380
381fn health_population_metrics() -> [(String, MetaMetric); 6] {
382    [
383        health_metric(
384            "avg_cyclomatic",
385            "Average Cyclomatic Complexity",
386            "Mean over authored function, module-scope, and template units before finding filters. Divide the sum of cyclomatic_population group sums by the sum of their counts, rounded to one decimal.",
387            Some("[0, infinity)"),
388            "zero for an empty population; lower is better",
389        ),
390        health_metric(
391            "p90_cyclomatic",
392            "P90 Cyclomatic Complexity",
393            "Nearest-rank 90th percentile over the same units as avg_cyclomatic, including module scopes and templates.",
394            Some("[0, infinity)"),
395            "zero for an empty population; lower is better",
396        ),
397        health_metric(
398            "critical_complexity_pct",
399            "Critical Cyclomatic Share",
400            "Percentage of the cyclomatic unit population at or above the critical threshold, including module scopes and templates.",
401            Some("[0, 100]"),
402            "absent for an empty population; lower is better",
403        ),
404        health_metric(
405            "cyclomatic_population.count",
406            "Cyclomatic Population Count",
407            "Unit count in each disjoint functions, modules, or templates group. Modules contribute to aggregate metrics without producing function findings.",
408            Some("[0, infinity)"),
409            "sum the three counts to obtain the distribution denominator",
410        ),
411        health_metric(
412            "cyclomatic_population.sum",
413            "Cyclomatic Population Sum",
414            "Sum of cyclomatic values in each functions, modules, or templates group before rounding and threshold filtering.",
415            Some("[0, infinity)"),
416            "sum the three groups to obtain the mean numerator",
417        ),
418        health_metric(
419            "cyclomatic_population.max",
420            "Cyclomatic Population Maximum",
421            "Highest cyclomatic value within each functions, modules, or templates group; null when that group has no units.",
422            Some("[1, infinity) or null"),
423            "identifies which kind of unit drives the tail; module units remain aggregate-only",
424        ),
425    ]
426}
427
428fn health_complexity_metrics() -> [(String, MetaMetric); 11] {
429    [
430        health_metric(
431            "cyclomatic",
432            "Cyclomatic Complexity",
433            "McCabe cyclomatic complexity: 1 + number of decision points.",
434            Some("[1, infinity)"),
435            "lower is better; default threshold: 20",
436        ),
437        health_metric(
438            "cognitive",
439            "Cognitive Complexity",
440            "Cognitive complexity penalizes nesting depth and non-linear control flow.",
441            Some("[0, infinity)"),
442            "lower is better; default threshold: 15",
443        ),
444        health_metric(
445            "line_count",
446            "Function Line Count",
447            "Number of lines in the function body.",
448            Some("[1, infinity)"),
449            "context-dependent; long functions may need splitting",
450        ),
451        health_metric(
452            "lines",
453            "File Line Count",
454            "Total lines of code in the file.",
455            Some("[1, infinity)"),
456            "context-dependent; large files may benefit from splitting",
457        ),
458        health_metric(
459            "maintainability_index",
460            "Maintainability Index",
461            "Composite file score combining complexity density, dead code ratio, and coupling.",
462            Some("[0, 100]"),
463            "higher is better",
464        ),
465        health_metric(
466            "complexity_density",
467            "Complexity Density",
468            "Total cyclomatic complexity divided by lines of code.",
469            Some("[0, infinity)"),
470            "lower is better; >1.0 indicates very dense complexity",
471        ),
472        health_metric(
473            "dead_code_ratio",
474            "Dead Code Ratio",
475            "Fraction of value exports with zero references across the project.",
476            Some("[0, 1]"),
477            "lower is better; 0 means all exports are used",
478        ),
479        health_metric(
480            "fan_in",
481            "Fan-in (Importers)",
482            "Number of files that import this file.",
483            Some("[0, infinity)"),
484            "context-dependent; high fan-in files need careful review",
485        ),
486        health_metric(
487            "fan_out",
488            "Fan-out (Imports)",
489            "Number of files this file directly imports.",
490            Some("[0, infinity)"),
491            "lower is better; high fan-out indicates coupling",
492        ),
493        health_metric(
494            "max_render_fan_in",
495            "Render Fan-in (Blast Radius)",
496            "Highest distinct-parent render count across React or Preact components.",
497            Some("[0, infinity)"),
498            "descriptive only; high values mean broad edit ripple",
499        ),
500        health_metric(
501            "crap_max",
502            "Untested Complexity Risk (CRAP)",
503            "Highest Change Risk Anti-Patterns score from complexity and coverage evidence.",
504            Some("[1, infinity)"),
505            "lower is better; high values indicate complex untested code",
506        ),
507    ]
508}
509
510fn health_churn_and_target_metrics() -> [(String, MetaMetric); 10] {
511    [
512        health_metric(
513            "score",
514            "Hotspot Score",
515            "Normalized churn multiplied by normalized complexity.",
516            Some("[0, 100]"),
517            "higher means riskier; prioritize refactoring high-score files",
518        ),
519        health_metric(
520            "weighted_commits",
521            "Weighted Commits",
522            "Recency-weighted commit count using exponential decay.",
523            Some("[0, infinity)"),
524            "higher means more recent churn activity",
525        ),
526        health_metric(
527            "trend",
528            "Churn Trend",
529            "Compares recent vs older commit frequency within the analysis window.",
530            None,
531            "accelerating files need attention; cooling files are stabilizing",
532        ),
533        health_metric(
534            "priority",
535            "Refactoring Priority",
536            "Weighted refactoring score using complexity, hotspots, dead code, fan-in, and fan-out.",
537            Some("[0, 100]"),
538            "higher means more urgent to refactor",
539        ),
540        health_metric(
541            "efficiency",
542            "Efficiency Score",
543            "Priority divided by effort estimate.",
544            Some("[0, 100]"),
545            "higher means better quick-win value",
546        ),
547        health_metric(
548            "effort",
549            "Effort Estimate",
550            "Heuristic effort estimate based on file size, function count, and fan-in.",
551            None,
552            "low means quick win, high needs planning and coordination",
553        ),
554        health_metric(
555            "confidence",
556            "Confidence Level",
557            "Reliability of the recommendation based on data source.",
558            None,
559            "high means act on it; medium or low means verify context",
560        ),
561        health_metric(
562            "health_score",
563            "Health Score",
564            "Project-level aggregate score computed from vital signs and issue signals.",
565            Some("[0, 100]"),
566            "higher is better; missing metrics are not penalized",
567        ),
568        health_metric(
569            "health_score.formula_version",
570            "Health Score Formula Version",
571            "Version of the health-scoring rubric used to produce the current score.",
572            Some("[1, infinity)"),
573            "compare score values only when both formula versions are known and equal",
574        ),
575        health_metric(
576            "health_trend.compared_to.score_formula_version",
577            "Baseline Health Score Formula Version",
578            "Formula version saved with the historical score; absent on legacy or unscored snapshots.",
579            Some("[1, infinity)"),
580            "the score delta is omitted when formula versions are unknown or different; available raw metric trends remain comparable",
581        ),
582    ]
583}
584
585fn health_change_history_metrics() -> [(String, MetaMetric); 3] {
586    [
587        health_metric(
588            "median_files_per_commit",
589            "Change Dispersion (Files)",
590            "Median number of analyzed files in the commits that changed this file. Bulk commits above the change coupling file cap are not counted.",
591            Some("[1, infinity)"),
592            "higher means a change to this file usually spreads over more files",
593        ),
594        health_metric(
595            "median_lines_per_commit",
596            "Change Dispersion (Lines)",
597            "Median number of added plus deleted lines, analyzed files only, in the commits that changed this file.",
598            Some("[0, infinity)"),
599            "higher means a change to this file is usually large",
600        ),
601        health_metric(
602            "degree",
603            "Change Coupling Degree",
604            "Shared commits of two files divided by the mean commit count of the two files.",
605            Some("[0, 1]"),
606            "higher means the two files change together more often; a hidden pair has no import between the files",
607        ),
608    ]
609}
610
611fn health_ownership_metrics() -> [(String, MetaMetric); 6] {
612    [
613        health_metric(
614            "bus_factor",
615            "Bus Factor",
616            "Minimum number of contributors who account for most recent weighted commits.",
617            Some("[1, infinity)"),
618            "lower is higher knowledge-loss risk",
619        ),
620        health_metric(
621            "contributor_count",
622            "Contributor Count",
623            "Number of distinct authors who touched this file in the analysis window.",
624            Some("[0, infinity)"),
625            "higher generally indicates broader knowledge spread",
626        ),
627        health_metric(
628            "share",
629            "Contributor Share",
630            "Recency-weighted share of total weighted commits attributed to a contributor.",
631            Some("[0, 1]"),
632            "share close to 1.0 indicates ownership concentration",
633        ),
634        health_metric(
635            "stale_days",
636            "Stale Days",
637            "Days since this contributor last touched the file.",
638            Some("[0, infinity)"),
639            "high stale days can indicate ownership drift",
640        ),
641        health_metric(
642            "drift",
643            "Ownership Drift",
644            "Whether original authorship and current contribution ownership have diverged.",
645            None,
646            "true means current review ownership may differ from original ownership",
647        ),
648        health_metric(
649            "unowned",
650            "Unowned (Tristate)",
651            "Whether CODEOWNERS exists but has no matching owner for this file.",
652            None,
653            "true on a hotspot is a review-bottleneck risk",
654        ),
655    ]
656}
657
658fn health_runtime_metrics() -> [(String, MetaMetric); 7] {
659    [
660        health_metric(
661            "runtime_coverage_verdict",
662            "Runtime Coverage Verdict",
663            "Overall verdict across runtime-coverage findings.",
664            None,
665            "cold-code-detected is the primary standalone cleanup signal",
666        ),
667        health_metric(
668            "runtime_coverage_state",
669            "Runtime Coverage State",
670            "Per-function runtime observation state.",
671            None,
672            "never-called with static unused is the highest-confidence delete signal",
673        ),
674        health_metric(
675            "runtime_coverage_confidence",
676            "Runtime Coverage Confidence",
677            "Confidence in a runtime-coverage finding.",
678            None,
679            "high means act on it; medium or low means verify context",
680        ),
681        health_metric(
682            "production_invocations",
683            "Production Invocations",
684            "Observed invocation count for the function over the collected coverage window.",
685            Some("[0, infinity)"),
686            "0 plus tracked means cold path; high means active path",
687        ),
688        health_metric(
689            "percent_dead_in_production",
690            "Percent Dead in Production",
691            "Fraction of tracked functions with zero observed invocations, multiplied by 100.",
692            Some("[0, 100]"),
693            "lower is better",
694        ),
695        health_metric(
696            "optimization_cost_score",
697            "Optimization Cost Score",
698            "Invocations multiplied by the per-call cost, on runtime_coverage.hot_paths[].optimization_target. The per-call cost is the peak block executions per call from V8 block coverage (cost_basis inner_iterations), or static cognitive complexity with a minimum of 1 when the function has no usable block counts (cost_basis cognitive).",
699            Some("[0, infinity)"),
700            "higher means a larger speed gain; compare it only between hot paths with the same cost_basis. importance ranks the risk of a change, not the speed gain",
701        ),
702        health_metric(
703            "inner_iterations_per_call",
704            "Inner Iterations per Call",
705            "Peak executions of one block inside a hot function per call, from V8 block coverage.",
706            Some("[1, infinity)"),
707            "1.0 means no block ran more than once per call; 3.0 means a loop body ran 3 times per call. Calls to other functions do not change the value",
708        ),
709    ]
710}
711
712fn health_styling_metrics() -> [(String, MetaMetric); 11] {
713    [
714        health_metric(
715            "styling_health.score",
716            "Styling Health Score",
717            "CSS/styling-axis aggregate score computed from the styling penalty rubric. Present only under --css.",
718            Some("[0, 100]"),
719            "higher is better; missing metrics are not penalized",
720        ),
721        health_metric(
722            "styling_health.formula_version",
723            "Styling Health Formula Version",
724            "Version of the styling-health scoring rubric used to produce the score. Present only under --css.",
725            Some("[1, infinity)"),
726            "bump signals a rubric change; compare scores only within the same version",
727        ),
728        health_metric(
729            "styling_health.penalties.duplication",
730            "Styling Duplication Penalty",
731            "Points deducted for copy-paste declaration blocks, scaled by the share of declarations removable via consolidation. Present only under --css.",
732            Some("[0, 20]"),
733            "lower is better; 0 means no removable duplicate blocks",
734        ),
735        health_metric(
736            "styling_health.penalties.dead_surface",
737            "Styling Dead-Surface Penalty",
738            "Points deducted for unreferenced classes, unused tokens, at-rules, and font-faces, normalized per stylesheet. Present only under --css.",
739            Some("[0, 20]"),
740            "lower is better; 0 means no dead styling surface",
741        ),
742        health_metric(
743            "styling_health.penalties.broken_references",
744            "Styling Broken-References Penalty",
745            "Points deducted for markup classes one edit from a defined class and animations referencing undefined keyframes. Present only under --css.",
746            Some("[0, 15]"),
747            "lower is better; 0 means no broken references",
748        ),
749        health_metric(
750            "styling_health.penalties.token_erosion",
751            "Styling Token-Erosion Penalty",
752            "Points deducted for mixing font-size units past a healthy baseline and Tailwind arbitrary-value bypasses. Present only under --css.",
753            Some("[0, 10]"),
754            "lower is better; 0 means a single source of truth for the scale",
755        ),
756        health_metric(
757            "styling_health.penalties.structural",
758            "Styling Structural Penalty",
759            "Points deducted for !important density above a healthy floor and deep style-rule nesting. Present only under --css.",
760            Some("[0, 10]"),
761            "lower is better; 0 means no structural smells",
762        ),
763        health_metric(
764            "css_analytics.summary.near_duplicate_theme_tokens",
765            "Near-Duplicate Theme Tokens",
766            "Count of Tailwind v4 theme tokens whose comparable values are close to another token in the same theme dictionary. Present only in deep CSS analysis.",
767            Some("[0, infinity)"),
768            "0 means no near-duplicate token candidates were found",
769        ),
770        health_metric(
771            "css_analytics.summary.near_duplicate_css_in_js_tokens",
772            "Near-Duplicate CSS-in-JS Tokens",
773            "Count of CSS-in-JS design tokens whose comparable values are close to another project token. Present only in deep CSS analysis.",
774            Some("[0, infinity)"),
775            "0 means no near-duplicate CSS-in-JS token candidates were found",
776        ),
777        health_metric(
778            "styling_findings[].blast_radius",
779            "Styling Finding Blast Radius",
780            "Static lower-bound count of known consumers affected by a styling finding. Omitted when the family has no reliable blast-radius model.",
781            Some("[0, infinity)"),
782            "0 means no static consumers were found; omitted means unknown",
783        ),
784        health_metric(
785            "styling_findings[].nearest_token.distance",
786            "Nearest Styling Token Distance",
787            "Distance between a token-drift finding and its nearest comparable token. Units depend on the token namespace.",
788            Some("(0, infinity)"),
789            "lower means closer; compare only within the same token namespace",
790        ),
791    ]
792}
793
794fn health_metric(
795    key: impl Into<String>,
796    name: impl Into<String>,
797    description: impl Into<String>,
798    range: Option<&str>,
799    interpretation: impl Into<String>,
800) -> (String, MetaMetric) {
801    (key.into(), metric(name, description, range, interpretation))
802}
803
804fn metric(
805    name: impl Into<String>,
806    description: impl Into<String>,
807    range: Option<&str>,
808    interpretation: impl Into<String>,
809) -> MetaMetric {
810    MetaMetric {
811        name: Some(name.into()),
812        description: Some(description.into()),
813        range: range.map(str::to_string),
814        interpretation: Some(interpretation.into()),
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn dupes_meta_uses_output_contract_shape() {
824        let meta = dupes_meta();
825        assert_eq!(meta.docs.as_deref(), Some(DUPES_DOCS));
826        assert!(meta.field_definitions.contains_key("actions[]"));
827        assert!(meta.metrics.contains_key("duplication_percentage"));
828        assert!(
829            meta.metrics
830                .contains_key("clone_groups_below_min_occurrences")
831        );
832        for key in [
833            "duplicated_tokens",
834            "spread",
835            "similarity",
836            "clone_groups_ignored",
837            "near_candidates_skipped",
838        ] {
839            let metric = meta.metrics.get(key).expect("duplication metric");
840            assert!(metric.range.is_some(), "{key} must document its range");
841            assert!(
842                metric.interpretation.is_some(),
843                "{key} must document its interpretation"
844            );
845        }
846    }
847
848    #[test]
849    fn health_meta_uses_output_contract_shape() {
850        let meta = health_meta();
851        assert_eq!(meta.docs.as_deref(), Some(HEALTH_DOCS));
852        assert!(meta.field_definitions.contains_key("actions[]"));
853        assert!(meta.metrics.contains_key("cyclomatic"));
854        assert!(meta.metrics.contains_key("health_score"));
855        assert!(meta.metrics.contains_key("health_score.formula_version"));
856        assert!(
857            meta.metrics
858                .contains_key("health_trend.compared_to.score_formula_version")
859        );
860        assert!(meta.metrics.contains_key("max_render_fan_in"));
861        assert!(meta.metrics.contains_key("percent_dead_in_production"));
862        assert!(meta.metrics.contains_key("styling_health.score"));
863        assert!(
864            meta.metrics
865                .contains_key("styling_health.penalties.duplication")
866        );
867        assert!(
868            meta.metrics
869                .contains_key("styling_health.penalties.structural")
870        );
871    }
872
873    #[test]
874    fn security_meta_uses_output_contract_shape() {
875        let meta = security_meta([SecurityRuleMeta {
876            id: "security/example",
877            name: "Example",
878            description: "Example security candidate.",
879            docs_path: "cli/security",
880        }]);
881        assert_eq!(meta.docs.as_deref(), Some(SECURITY_DOCS));
882        assert!(meta.field_definitions.contains_key("security_findings[]"));
883        assert!(meta.metrics.is_empty());
884        assert_eq!(
885            meta.rules["security/example"].docs.as_deref(),
886            Some("https://fallow.tools/docs/cli/security/")
887        );
888    }
889
890    #[test]
891    fn coverage_setup_meta_uses_output_contract_shape() {
892        let meta = coverage_setup_meta();
893        assert_eq!(meta["docs_url"], COVERAGE_SETUP_DOCS);
894        assert!(meta["field_definitions"]["members[]"].is_string());
895        assert!(meta["enums"]["runtime_targets"].is_array());
896        assert!(meta["warnings"]["Package manager was not detected"].is_string());
897    }
898
899    #[test]
900    fn coverage_analyze_meta_uses_output_contract_shape() {
901        let meta = coverage_analyze_meta();
902        assert_eq!(meta["docs_url"], COVERAGE_ANALYZE_DOCS);
903        assert!(meta["field_definitions"]["runtime_coverage.findings[].stable_id"].is_string());
904        assert!(meta["enums"]["action_type"].is_array());
905        assert!(meta["warnings"]["cloud_functions_unmatched"].is_string());
906    }
907}