#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "schema-emit binary prints the regenerated schema to stdout and errors to stderr"
)]
#[cfg(not(test))]
use std::path::PathBuf;
use std::process::ExitCode;
use schemars::generate::SchemaSettings;
use serde_json::{Map, Value};
use fallow_cli::health_types::{
ComplexityViolation, ContributorEntry, ContributorIdentifierFormat, CoverageGapSummary,
CoverageGaps, CoverageModel, CoverageTier, ExceededThreshold, FileHealthScore, FindingSeverity,
HealthActionsMeta, HealthScore, HealthScorePenalties, HealthSummary, HealthTrend, HotspotEntry,
HotspotFinding, HotspotSummary, LargeFunctionEntry, OwnershipMetrics, RecommendationCategory,
RefactoringTarget, RefactoringTargetFinding, RiskProfile, RuntimeCoverageReport,
TargetThresholds, TrendCount, UntestedExport, UntestedExportFinding, UntestedFile,
UntestedFileFinding, VitalSigns, VitalSignsCounts,
};
use fallow_cli::output_dupes::{
AttributedCloneGroupFinding, CloneFamilyAction, CloneFamilyActionType, CloneFamilyFinding,
CloneGroupAction, CloneGroupActionType, CloneGroupFinding, DupesReportPayload,
};
use fallow_cli::output_envelope::{
AuditCommand, AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
BoundariesListing, CheckGroupedEntry, CheckGroupedOutput, CheckOutput, CodeClimateIssue,
CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateOutput,
CodeClimateSeverity, CombinedOutput, CoverageAnalyzeOutput, CoverageAnalyzeSchemaVersion,
CoverageSetupFileToEdit, CoverageSetupFramework, CoverageSetupMember, CoverageSetupOutput,
CoverageSetupPackageManager, CoverageSetupRuntimeTarget, CoverageSetupSchemaVersion,
CoverageSetupSnippet, DupesOutput, ExplainOutput, FallowOutput, GitHubReviewComment,
GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType,
GroupByMode, HealthOutput, ListBoundariesOutput, ReviewCheckConclusion, ReviewComment,
ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema,
ReviewEnvelopeSummary, ReviewProvider, ReviewReconcileOutput, ReviewReconcileSchema,
};
use fallow_cli::report::dupes_grouping::{
AttributedCloneGroup, AttributedInstance, DuplicationGroup,
};
use fallow_config::{AuthoredRule, LogicalGroup, LogicalGroupStatus};
use fallow_core::duplicates::{
CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, MirroredDirectory,
RefactoringKind, RefactoringSuggestion,
};
use fallow_types::envelope::{
AuditIntroduced, BaselineCategoryDelta, BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs,
EntryPoints, Meta, MetaMetric, MetaRule, RegressionResult, RegressionStatus,
RegressionToleranceKind, SchemaVersion, ToolVersion,
};
use fallow_types::extract::MemberKind;
use fallow_types::output::{
AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
SuppressLineKind, SuppressLineScope,
};
use fallow_types::output_dead_code::{
BoundaryViolationFinding, CircularDependencyFinding, PrivateTypeLeakFinding,
TestOnlyDependencyFinding, TypeOnlyDependencyFinding, UnlistedDependencyFinding,
UnresolvedImportFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
UnusedOptionalDependencyFinding, UnusedTypeFinding,
};
use fallow_types::output_health::{
HealthFindingAction, HealthFindingActionType, HotspotAction, HotspotActionHeuristic,
HotspotActionType, RefactoringTargetAction, RefactoringTargetActionType, UntestedExportAction,
UntestedExportActionType, UntestedFileAction, UntestedFileActionType,
};
use fallow_types::results::{
AnalysisResults, BoundaryViolation, CircularDependency, DependencyLocation,
DependencyOverrideMisconfigReason, DependencyOverrideSource, DuplicateExport,
DuplicateLocation, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
FlagConfidence, FlagKind, ImportSite, MisconfiguredDependencyOverride, PrivateTypeLeak,
ReferenceLocation, StaleSuppression, SuppressionOrigin, TestOnlyDependency, TypeOnlyDependency,
UnlistedDependency, UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry,
UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedMember,
};
#[cfg(not(test))]
const COMMITTED_SCHEMA_REL_PATH: &str = "docs/output-schema.json";
#[cfg(test)]
const COMMITTED_SCHEMA: &str = include_str!("../../../../docs/output-schema.json");
#[cfg(not(test))]
fn read_committed_schema() -> Result<String, String> {
let start = std::env::var_os("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| "unable to determine starting directory".to_string())?;
for dir in start.ancestors() {
let candidate = dir.join(COMMITTED_SCHEMA_REL_PATH);
if candidate.is_file() {
return std::fs::read_to_string(&candidate)
.map_err(|err| format!("failed to read {}: {err}", candidate.display()));
}
}
Err(format!(
"could not find {COMMITTED_SCHEMA_REL_PATH} by walking up from {}; run the binary from the workspace root",
start.display()
))
}
#[cfg(test)]
#[expect(
clippy::unnecessary_wraps,
reason = "signature must match the non-test variant's `Result<String, String>` return"
)]
fn committed_schema_source() -> Result<String, String> {
Ok(COMMITTED_SCHEMA.to_string())
}
#[cfg(not(test))]
fn committed_schema_source() -> Result<String, String> {
read_committed_schema()
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("fallow-schema-emit: {err}");
ExitCode::from(2)
}
}
}
fn run() -> Result<(), String> {
let derived = derived_definitions();
let merged = merge_with_committed(&derived)?;
let pretty = serde_json::to_string_pretty(&merged)
.map_err(|err| format!("failed to serialize merged schema: {err}"))?;
println!("{pretty}");
Ok(())
}
pub(crate) fn derived_definition_names() -> &'static [&'static str] {
&[
"BoundaryViolation",
"CircularDependency",
"DuplicateExport",
"DuplicateLocation",
"EmptyCatalogGroup",
"ImportSite",
"MisconfiguredDependencyOverride",
"PrivateTypeLeak",
"StaleSuppression",
"TestOnlyDependency",
"TypeOnlyDependency",
"UnlistedDependency",
"UnresolvedCatalogReference",
"UnresolvedImport",
"UnusedCatalogEntry",
"UnusedDependency",
"UnusedDependencyOverride",
"UnusedExport",
"UnusedFile",
"UnusedMember",
"CloneFamily",
"CloneGroup",
"CloneInstance",
"MirroredDirectory",
"AddToConfigAction",
"FixAction",
"IssueAction",
"SuppressFileAction",
"SuppressLineAction",
"ComplexityViolation",
"ContributorEntry",
"CoverageGapSummary",
"CoverageGaps",
"FileHealthScore",
"HealthActionsMeta",
"HealthFinding",
"HealthScore",
"HealthScorePenalties",
"HealthSummary",
"HealthTrend",
"HotspotEntry",
"HotspotFinding",
"HotspotSummary",
"LargeFunctionEntry",
"OwnershipMetrics",
"RefactoringTarget",
"RefactoringTargetFinding",
"RiskProfile",
"RuntimeCoverageReport",
"TargetThresholds",
"TrendCount",
"UntestedExport",
"UntestedExportFinding",
"UntestedFile",
"UntestedFileFinding",
"VitalSigns",
"VitalSignsCounts",
"HealthFindingAction",
"HotspotAction",
"RefactoringTargetAction",
"UntestedExportAction",
"UntestedFileAction",
"AuditIntroduced",
"BaselineDeltas",
"BaselineMatch",
"CheckSummary",
"ElapsedMs",
"EntryPoints",
"Meta",
"RegressionResult",
"SchemaVersion",
"ToolVersion",
"RuntimeCoverageAction",
"RuntimeCoverageBlastRadiusEntry",
"RuntimeCoverageCaptureQuality",
"RuntimeCoverageConfidence",
"RuntimeCoverageEvidence",
"RuntimeCoverageFinding",
"RuntimeCoverageHotPath",
"RuntimeCoverageImportanceEntry",
"RuntimeCoverageMessage",
"RuntimeCoverageReportVerdict",
"RuntimeCoverageRiskBand",
"RuntimeCoverageSignal",
"RuntimeCoverageSummary",
"RuntimeCoverageVerdict",
"RuntimeCoverageWatermark",
"DuplicationReport",
"HealthReport",
"AuditOutput",
"CheckGroupedEntry",
"CheckGroupedOutput",
"CheckOutput",
"CodeClimateIssue",
"CodeClimateOutput",
"CombinedOutput",
"CoverageSetupFileToEdit",
"CoverageSetupMember",
"CoverageSetupOutput",
"CoverageSetupSnippet",
"DupesOutput",
"ExplainOutput",
"GitHubReviewComment",
"GitLabReviewComment",
"GitLabReviewPosition",
"HealthGroup",
"HealthOutput",
"ReviewEnvelopeOutput",
"ReviewEnvelopeSummary",
"ReviewReconcileOutput",
"FallowOutput",
"BoundariesListLogicalGroup",
"BoundariesListRule",
"BoundariesListZone",
"BoundariesListing",
"ListBoundariesOutput",
"AuthoredRule",
"LogicalGroup",
"LogicalGroupStatus",
"AttributedCloneGroup",
"AttributedInstance",
"DuplicationGroup",
"AttributedCloneGroupFinding",
"CloneFamilyAction",
"CloneFamilyActionType",
"CloneFamilyFinding",
"CloneGroupAction",
"CloneGroupActionType",
"CloneGroupFinding",
"DupesReportPayload",
"CoverageAnalyzeOutput",
"CoverageAnalyzeSchemaVersion",
]
}
fn finding_definition_names() -> &'static [&'static str] {
&[]
}
#[derive(Debug, Clone, Copy)]
struct FindingAugmentation {
actions_item_ref: &'static str,
include_introduced: bool,
}
const DEFAULT_FINDING_AUGMENTATION: FindingAugmentation = FindingAugmentation {
actions_item_ref: "#/definitions/IssueAction",
include_introduced: true,
};
fn finding_augmentation(_name: &str) -> FindingAugmentation {
DEFAULT_FINDING_AUGMENTATION
}
#[allow(
clippy::too_many_lines,
reason = "this function is fundamentally a registration list: one `subschema_for::<T>()` call per type in the public output contract. Splitting by module obscures the registration set; the linear list is the cleanest representation."
)]
fn derived_definitions() -> Map<String, Value> {
let mut generator = SchemaSettings::draft07().into_generator();
let _ = generator.subschema_for::<AnalysisResults>();
let _ = generator.subschema_for::<DuplicationReport>();
let _ = generator.subschema_for::<UnusedFile>();
let _ = generator.subschema_for::<UnusedExport>();
let _ = generator.subschema_for::<PrivateTypeLeak>();
let _ = generator.subschema_for::<UnusedDependency>();
let _ = generator.subschema_for::<DependencyLocation>();
let _ = generator.subschema_for::<UnusedMember>();
let _ = generator.subschema_for::<UnresolvedImport>();
let _ = generator.subschema_for::<UnlistedDependency>();
let _ = generator.subschema_for::<ImportSite>();
let _ = generator.subschema_for::<DuplicateExport>();
let _ = generator.subschema_for::<DuplicateLocation>();
let _ = generator.subschema_for::<TypeOnlyDependency>();
let _ = generator.subschema_for::<UnusedCatalogEntry>();
let _ = generator.subschema_for::<EmptyCatalogGroup>();
let _ = generator.subschema_for::<UnresolvedCatalogReference>();
let _ = generator.subschema_for::<DependencyOverrideSource>();
let _ = generator.subschema_for::<UnusedDependencyOverride>();
let _ = generator.subschema_for::<DependencyOverrideMisconfigReason>();
let _ = generator.subschema_for::<MisconfiguredDependencyOverride>();
let _ = generator.subschema_for::<TestOnlyDependency>();
let _ = generator.subschema_for::<CircularDependency>();
let _ = generator.subschema_for::<BoundaryViolation>();
let _ = generator.subschema_for::<SuppressionOrigin>();
let _ = generator.subschema_for::<StaleSuppression>();
let _ = generator.subschema_for::<FlagKind>();
let _ = generator.subschema_for::<FlagConfidence>();
let _ = generator.subschema_for::<FeatureFlag>();
let _ = generator.subschema_for::<ExportUsage>();
let _ = generator.subschema_for::<ReferenceLocation>();
let _ = generator.subschema_for::<EntryPointSummary>();
let _ = generator.subschema_for::<MemberKind>();
let _ = generator.subschema_for::<CloneInstance>();
let _ = generator.subschema_for::<CloneGroup>();
let _ = generator.subschema_for::<RefactoringKind>();
let _ = generator.subschema_for::<RefactoringSuggestion>();
let _ = generator.subschema_for::<CloneFamily>();
let _ = generator.subschema_for::<MirroredDirectory>();
let _ = generator.subschema_for::<AttributedInstance>();
let _ = generator.subschema_for::<AttributedCloneGroup>();
let _ = generator.subschema_for::<DuplicationGroup>();
let _ = generator.subschema_for::<DuplicationStats>();
let _ = generator.subschema_for::<CloneGroupFinding>();
let _ = generator.subschema_for::<CloneFamilyFinding>();
let _ = generator.subschema_for::<AttributedCloneGroupFinding>();
let _ = generator.subschema_for::<CloneGroupAction>();
let _ = generator.subschema_for::<CloneGroupActionType>();
let _ = generator.subschema_for::<CloneFamilyAction>();
let _ = generator.subschema_for::<CloneFamilyActionType>();
let _ = generator.subschema_for::<DupesReportPayload>();
let _ = generator.subschema_for::<IssueAction>();
let _ = generator.subschema_for::<FixAction>();
let _ = generator.subschema_for::<FixActionType>();
let _ = generator.subschema_for::<SuppressLineAction>();
let _ = generator.subschema_for::<SuppressLineKind>();
let _ = generator.subschema_for::<SuppressLineScope>();
let _ = generator.subschema_for::<SuppressFileAction>();
let _ = generator.subschema_for::<SuppressFileKind>();
let _ = generator.subschema_for::<AddToConfigAction>();
let _ = generator.subschema_for::<AddToConfigKind>();
let _ = generator.subschema_for::<AddToConfigValue>();
let _ = generator.subschema_for::<IgnoreExportsRule>();
let _ = generator.subschema_for::<UnusedFileFinding>();
let _ = generator.subschema_for::<PrivateTypeLeakFinding>();
let _ = generator.subschema_for::<UnresolvedImportFinding>();
let _ = generator.subschema_for::<CircularDependencyFinding>();
let _ = generator.subschema_for::<BoundaryViolationFinding>();
let _ = generator.subschema_for::<UnusedExportFinding>();
let _ = generator.subschema_for::<UnusedTypeFinding>();
let _ = generator.subschema_for::<UnusedEnumMemberFinding>();
let _ = generator.subschema_for::<UnusedClassMemberFinding>();
let _ = generator.subschema_for::<UnusedDependencyFinding>();
let _ = generator.subschema_for::<UnusedDevDependencyFinding>();
let _ = generator.subschema_for::<UnusedOptionalDependencyFinding>();
let _ = generator.subschema_for::<UnlistedDependencyFinding>();
let _ = generator.subschema_for::<TypeOnlyDependencyFinding>();
let _ = generator.subschema_for::<TestOnlyDependencyFinding>();
let _ = generator.subschema_for::<HealthSummary>();
let _ = generator.subschema_for::<ComplexityViolation>();
let _ = generator.subschema_for::<ExceededThreshold>();
let _ = generator.subschema_for::<FindingSeverity>();
let _ = generator.subschema_for::<CoverageTier>();
let _ = generator.subschema_for::<CoverageModel>();
let _ = generator.subschema_for::<LargeFunctionEntry>();
let _ = generator.subschema_for::<FileHealthScore>();
let _ = generator.subschema_for::<HotspotEntry>();
let _ = generator.subschema_for::<HotspotFinding>();
let _ = generator.subschema_for::<HotspotSummary>();
let _ = generator.subschema_for::<OwnershipMetrics>();
let _ = generator.subschema_for::<ContributorEntry>();
let _ = generator.subschema_for::<ContributorIdentifierFormat>();
let _ = generator.subschema_for::<RefactoringTarget>();
let _ = generator.subschema_for::<RefactoringTargetFinding>();
let _ = generator.subschema_for::<RecommendationCategory>();
let _ = generator.subschema_for::<TargetThresholds>();
let _ = generator.subschema_for::<HealthTrend>();
let _ = generator.subschema_for::<TrendCount>();
let _ = generator.subschema_for::<CoverageGaps>();
let _ = generator.subschema_for::<CoverageGapSummary>();
let _ = generator.subschema_for::<UntestedFile>();
let _ = generator.subschema_for::<UntestedFileFinding>();
let _ = generator.subschema_for::<UntestedExport>();
let _ = generator.subschema_for::<UntestedExportFinding>();
let _ = generator.subschema_for::<HealthScore>();
let _ = generator.subschema_for::<HealthScorePenalties>();
let _ = generator.subschema_for::<VitalSigns>();
let _ = generator.subschema_for::<VitalSignsCounts>();
let _ = generator.subschema_for::<RiskProfile>();
let _ = generator.subschema_for::<RuntimeCoverageReport>();
let _ = generator.subschema_for::<HealthActionsMeta>();
let _ = generator.subschema_for::<SchemaVersion>();
let _ = generator.subschema_for::<ToolVersion>();
let _ = generator.subschema_for::<ElapsedMs>();
let _ = generator.subschema_for::<AuditIntroduced>();
let _ = generator.subschema_for::<EntryPoints>();
let _ = generator.subschema_for::<CheckSummary>();
let _ = generator.subschema_for::<BaselineDeltas>();
let _ = generator.subschema_for::<BaselineCategoryDelta>();
let _ = generator.subschema_for::<BaselineMatch>();
let _ = generator.subschema_for::<RegressionResult>();
let _ = generator.subschema_for::<RegressionStatus>();
let _ = generator.subschema_for::<RegressionToleranceKind>();
let _ = generator.subschema_for::<Meta>();
let _ = generator.subschema_for::<MetaMetric>();
let _ = generator.subschema_for::<MetaRule>();
register_per_command_envelope_definitions(&mut generator);
let _ = generator.subschema_for::<FallowOutput>();
register_list_boundaries_definitions(&mut generator);
let _ = generator.subschema_for::<HealthFindingAction>();
let _ = generator.subschema_for::<HealthFindingActionType>();
let _ = generator.subschema_for::<HotspotAction>();
let _ = generator.subschema_for::<HotspotActionType>();
let _ = generator.subschema_for::<HotspotActionHeuristic>();
let _ = generator.subschema_for::<RefactoringTargetAction>();
let _ = generator.subschema_for::<RefactoringTargetActionType>();
let _ = generator.subschema_for::<UntestedFileAction>();
let _ = generator.subschema_for::<UntestedFileActionType>();
let _ = generator.subschema_for::<UntestedExportAction>();
let _ = generator.subschema_for::<UntestedExportActionType>();
generator.take_definitions(true)
}
fn register_per_command_envelope_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<AuditOutput>();
let _ = generator.subschema_for::<AuditCommand>();
let _ = generator.subschema_for::<CoverageSetupOutput>();
let _ = generator.subschema_for::<CoverageSetupMember>();
let _ = generator.subschema_for::<CoverageSetupFileToEdit>();
let _ = generator.subschema_for::<CoverageSetupSnippet>();
let _ = generator.subschema_for::<CoverageSetupSchemaVersion>();
let _ = generator.subschema_for::<CoverageSetupFramework>();
let _ = generator.subschema_for::<CoverageSetupPackageManager>();
let _ = generator.subschema_for::<CoverageSetupRuntimeTarget>();
let _ = generator.subschema_for::<CoverageAnalyzeOutput>();
let _ = generator.subschema_for::<CoverageAnalyzeSchemaVersion>();
let _ = generator.subschema_for::<CombinedOutput>();
let _ = generator.subschema_for::<CheckOutput>();
let _ = generator.subschema_for::<CheckGroupedOutput>();
let _ = generator.subschema_for::<CheckGroupedEntry>();
let _ = generator.subschema_for::<DupesOutput>();
let _ = generator.subschema_for::<HealthOutput>();
let _ = generator.subschema_for::<fallow_cli::health_types::HealthGroup>();
let _ = generator.subschema_for::<fallow_cli::health_types::HealthReport>();
let _ = generator.subschema_for::<GroupByMode>();
let _ = generator.subschema_for::<ExplainOutput>();
let _ = generator.subschema_for::<CodeClimateOutput>();
let _ = generator.subschema_for::<CodeClimateIssue>();
let _ = generator.subschema_for::<CodeClimateIssueKind>();
let _ = generator.subschema_for::<CodeClimateSeverity>();
let _ = generator.subschema_for::<CodeClimateLocation>();
let _ = generator.subschema_for::<CodeClimateLines>();
let _ = generator.subschema_for::<ReviewEnvelopeOutput>();
let _ = generator.subschema_for::<ReviewEnvelopeSummary>();
let _ = generator.subschema_for::<ReviewEnvelopeEvent>();
let _ = generator.subschema_for::<ReviewComment>();
let _ = generator.subschema_for::<GitHubReviewComment>();
let _ = generator.subschema_for::<GitHubReviewSide>();
let _ = generator.subschema_for::<GitLabReviewComment>();
let _ = generator.subschema_for::<GitLabReviewPosition>();
let _ = generator.subschema_for::<GitLabReviewPositionType>();
let _ = generator.subschema_for::<ReviewEnvelopeMeta>();
let _ = generator.subschema_for::<ReviewEnvelopeSchema>();
let _ = generator.subschema_for::<ReviewProvider>();
let _ = generator.subschema_for::<ReviewCheckConclusion>();
let _ = generator.subschema_for::<ReviewReconcileOutput>();
let _ = generator.subschema_for::<ReviewReconcileSchema>();
}
fn register_list_boundaries_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<ListBoundariesOutput>();
let _ = generator.subschema_for::<BoundariesListing>();
let _ = generator.subschema_for::<BoundariesListZone>();
let _ = generator.subschema_for::<BoundariesListRule>();
let _ = generator.subschema_for::<BoundariesListLogicalGroup>();
let _ = generator.subschema_for::<LogicalGroup>();
let _ = generator.subschema_for::<LogicalGroupStatus>();
let _ = generator.subschema_for::<AuthoredRule>();
}
fn merge_with_committed(derived: &Map<String, Value>) -> Result<Value, String> {
let source = committed_schema_source()?;
let mut document: Value = serde_json::from_str(&source)
.map_err(|err| format!("failed to parse committed docs/output-schema.json: {err}"))?;
let definitions = document
.get_mut("definitions")
.and_then(Value::as_object_mut)
.ok_or_else(|| {
"committed docs/output-schema.json has no top-level `definitions` object".to_string()
})?;
let finding_names: rustc_hash::FxHashSet<&'static str> =
finding_definition_names().iter().copied().collect();
for name in derived_definition_names() {
let derived_schema = derived.get(*name).ok_or_else(|| {
format!(
"derived schema missing for '{name}'; check that the type carries `#[cfg_attr(feature = \"schema\", derive(schemars::JsonSchema))]` and is registered in derived_definitions"
)
})?;
let mut value = derived_schema.clone();
normalize_schema(&mut value);
if finding_names.contains(name) {
augment_finding_definition(&mut value, finding_augmentation(name))?;
}
definitions.insert((*name).to_string(), value);
}
let in_scope: rustc_hash::FxHashSet<&'static str> =
derived_definition_names().iter().copied().collect();
for (name, value) in derived {
if in_scope.contains(name.as_str()) {
continue;
}
let mut value = value.clone();
normalize_schema(&mut value);
definitions.insert(name.clone(), value);
}
rewrite_document_root_one_of(&mut document)?;
Ok(document)
}
const HAND_MAINTAINED_ROOT_ENVELOPES: &[&str] = &[];
fn rewrite_document_root_one_of(document: &mut Value) -> Result<(), String> {
let root = document
.as_object_mut()
.ok_or_else(|| "schema document root is not a JSON object".to_string())?;
let mut one_of: Vec<Value> = Vec::with_capacity(2 + HAND_MAINTAINED_ROOT_ENVELOPES.len());
one_of.push(serde_json::json!({ "$ref": "#/definitions/FallowOutput" }));
one_of.push(serde_json::json!({ "$ref": "#/definitions/CodeClimateOutput" }));
for name in HAND_MAINTAINED_ROOT_ENVELOPES {
one_of.push(serde_json::json!({ "$ref": format!("#/definitions/{name}") }));
}
root.insert("oneOf".to_string(), Value::Array(one_of));
root.insert(
"description".to_string(),
Value::String(
"Schemas for the JSON output of fallow commands. To identify which \
envelope you have, check for the unique top-level field: \
`summary.total_issues` (check), `health_score` (health), \
`clone_groups` (dupes), `runtime_coverage` (coverage analyze), \
`boundaries` (list --boundaries), `command: \"audit\"` (audit), \
`body` plus `comments` (review-github / review-gitlab), \
`schema: \"fallow-review-reconcile/v1\"` (ci reconcile-review), \
`framework_detected` plus `members` (coverage setup), `id` plus \
`how_to_fix` (explain), `check`+`dupes`+`health` keys together \
(bare combined invocation). `HealthOutput` and `DupesOutput` \
flatten their body (`HealthReport` / `DupesReportPayload`) into \
top-level fields, so the discriminator field is from the body \
shape itself, not a wrapper key. Every object-shaped envelope \
is a variant of `FallowOutput`; `CodeClimateOutput` is a bare \
JSON array (per the Code Climate / GitLab Code Quality spec) \
and stays a sibling root branch."
.to_string(),
),
);
Ok(())
}
fn augment_finding_definition(
value: &mut Value,
augmentation: FindingAugmentation,
) -> Result<(), String> {
let object = value
.as_object_mut()
.ok_or_else(|| "finding definition is not a JSON object".to_string())?;
let properties = object
.entry("properties")
.or_insert_with(|| Value::Object(Map::new()));
let properties = properties
.as_object_mut()
.ok_or_else(|| "finding definition `properties` is not a JSON object".to_string())?;
if !properties.contains_key("actions") {
properties.insert(
"actions".to_string(),
serde_json::json!({
"type": "array",
"items": { "$ref": augmentation.actions_item_ref },
"description": "Suggested actions to resolve this issue."
}),
);
}
if augmentation.include_introduced && !properties.contains_key("introduced") {
properties.insert(
"introduced".to_string(),
serde_json::json!({ "$ref": "#/definitions/AuditIntroduced" }),
);
}
let required = object
.entry("required")
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(arr) = required
&& !arr.iter().any(|v| v.as_str() == Some("actions"))
{
arr.push(Value::String("actions".to_string()));
}
Ok(())
}
fn normalize_schema(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("$schema");
map.remove("default");
map.remove("examples");
map.remove("format");
map.remove("minimum");
map.remove("maximum");
map.remove("exclusiveMinimum");
map.remove("exclusiveMaximum");
if let Some(Value::String(reference)) = map.get_mut("$ref")
&& let Some(rest) = reference.strip_prefix("#/$defs/")
{
*reference = format!("#/definitions/{rest}");
}
if let Some(Value::Array(all_of)) = map.get("allOf")
&& all_of.len() == 1
&& let Some(Value::Object(only)) = all_of.first()
&& only.len() == 1
&& only.contains_key("$ref")
{
let reference = only.get("$ref").cloned().unwrap_or(Value::Null);
map.remove("allOf");
map.insert("$ref".to_string(), reference);
}
for (key, child) in map.iter_mut() {
if matches!(
key.as_str(),
"properties" | "definitions" | "$defs" | "patternProperties"
) && let Value::Object(inner) = child
{
for inner_value in inner.values_mut() {
normalize_schema(inner_value);
}
continue;
}
normalize_schema(child);
}
}
Value::Array(items) => {
for item in items {
normalize_schema(item);
}
}
_ => {}
}
}
#[cfg(test)]
mod drift_tests {
use super::*;
fn canonicalize(mut value: Value) -> Value {
normalize_one(&mut value);
value
}
fn normalize_one(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("description");
map.remove("format");
map.remove("minimum");
map.remove("maximum");
map.remove("exclusiveMinimum");
map.remove("exclusiveMaximum");
if let Some(Value::Array(arr)) = map.get_mut("type") {
arr.retain(|v| v.as_str() != Some("null"));
if arr.len() == 1 {
let only = arr.remove(0);
map.insert("type".to_string(), only);
}
}
if let Some(Value::Array(all_of)) = map.get("allOf")
&& all_of.len() == 1
&& let Some(Value::Object(only)) = all_of.first()
&& only.len() == 1
&& only.contains_key("$ref")
{
let reference = only.get("$ref").cloned().unwrap_or(Value::Null);
map.remove("allOf");
map.insert("$ref".to_string(), reference);
}
if let Some(any_of) = map.remove("anyOf") {
map.insert("oneOf".to_string(), any_of);
}
if let Some(Value::Array(items)) = map.get_mut("required") {
items.sort_by(|a, b| {
a.as_str()
.unwrap_or_default()
.cmp(b.as_str().unwrap_or_default())
});
}
if let Some(Value::Array(items)) = map.get_mut("enum") {
items.sort_by(|a, b| {
a.as_str()
.unwrap_or_default()
.cmp(b.as_str().unwrap_or_default())
});
}
for (key, child) in map.iter_mut() {
if matches!(
key.as_str(),
"properties" | "definitions" | "$defs" | "patternProperties"
) && let Value::Object(inner) = child
{
for inner_value in inner.values_mut() {
normalize_one(inner_value);
}
continue;
}
normalize_one(child);
}
}
Value::Array(items) => {
for item in items {
normalize_one(item);
}
}
_ => {}
}
}
fn committed_definitions() -> Map<String, Value> {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
document
.get("definitions")
.and_then(Value::as_object)
.cloned()
.expect("committed docs/output-schema.json must carry `definitions`")
}
fn derived_definitions_for_drift() -> Map<String, Value> {
let raw = derived_definitions();
let mut out = Map::new();
let finding_names: rustc_hash::FxHashSet<&'static str> =
finding_definition_names().iter().copied().collect();
let in_scope: rustc_hash::FxHashSet<&'static str> =
derived_definition_names().iter().copied().collect();
for (name, raw_value) in &raw {
let mut value = raw_value.clone();
normalize_schema(&mut value);
if in_scope.contains(name.as_str()) && finding_names.contains(name.as_str()) {
augment_finding_definition(&mut value, finding_augmentation(name))
.expect("augment_finding_definition must not fail");
}
out.insert(name.clone(), value);
}
out
}
#[test]
fn every_registered_name_resolves_to_a_derived_schema() {
let derived = derived_definitions();
for name in derived_definition_names() {
assert!(
derived.contains_key(*name),
"no derived schema for `{name}`: either the type lacks `#[cfg_attr(feature = \"schema\", derive(schemars::JsonSchema))]`, or the call to `generator.subschema_for::<{name}>()` is missing in `derived_definitions()`."
);
}
}
#[test]
fn every_fallow_output_variant_is_registered_in_derived_definitions() {
const VARIANTS: &[(&str, &str)] = &[
("Audit", "AuditOutput"),
("Explain", "ExplainOutput"),
("ReviewEnvelope", "ReviewEnvelopeOutput"),
("ReviewReconcile", "ReviewReconcileOutput"),
("CoverageSetup", "CoverageSetupOutput"),
("CoverageAnalyze", "CoverageAnalyzeOutput"),
("ListBoundaries", "ListBoundariesOutput"),
("Health", "HealthOutput"),
("Dupes", "DupesOutput"),
("CheckGrouped", "CheckGroupedOutput"),
("Check", "CheckOutput"),
("Combined", "CombinedOutput"),
];
#[expect(
dead_code,
reason = "compile-time exhaustiveness guard for the VARIANTS list above; never called at runtime"
)]
fn variant_count_is_locked(value: &FallowOutput) -> &'static str {
match value {
FallowOutput::Audit(_) => "Audit",
FallowOutput::Explain(_) => "Explain",
FallowOutput::ReviewEnvelope(_) => "ReviewEnvelope",
FallowOutput::ReviewReconcile(_) => "ReviewReconcile",
FallowOutput::CoverageSetup(_) => "CoverageSetup",
FallowOutput::CoverageAnalyze(_) => "CoverageAnalyze",
FallowOutput::ListBoundaries(_) => "ListBoundaries",
FallowOutput::Health(_) => "Health",
FallowOutput::Dupes(_) => "Dupes",
FallowOutput::CheckGrouped(_) => "CheckGrouped",
FallowOutput::Check(_) => "Check",
FallowOutput::Combined(_) => "Combined",
}
}
let derived = derived_definitions();
let mut missing: Vec<String> = Vec::new();
for (variant, inner) in VARIANTS {
if !derived.contains_key(*inner) {
missing.push(format!(
"variant `FallowOutput::{variant}({inner})` produces an inline schema in the root `oneOf` because `{inner}` is not registered in `derived_definitions()`. Add `let _ = generator.subschema_for::<{inner}>();` (or include it via `register_per_command_envelope_definitions` / `register_list_boundaries_definitions`)."
));
}
}
assert!(
missing.is_empty(),
"{} `FallowOutput` variant(s) missing registration:\n\n{}",
missing.len(),
missing.join("\n\n"),
);
}
#[test]
fn finding_names_are_subset_of_registered_names() {
let registered: rustc_hash::FxHashSet<&'static str> =
derived_definition_names().iter().copied().collect();
for name in finding_definition_names() {
assert!(
registered.contains(name),
"finding type `{name}` is augmented with `actions`/`introduced` but never registered as a derived definition. Add it to `derived_definition_names()` (and the corresponding `subschema_for::<{name}>()` call) before listing it as a finding."
);
}
}
#[test]
fn augmentation_attaches_actions_and_introduced_to_each_finding() {
let derived = derived_definitions_for_drift();
for name in finding_definition_names() {
let entry = derived
.get(*name)
.unwrap_or_else(|| panic!("finding `{name}` missing from derived"));
let properties = entry
.get("properties")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("finding `{name}` missing properties"));
assert!(
properties.contains_key("actions"),
"finding `{name}` was not augmented with `actions`",
);
let aug = finding_augmentation(name);
if aug.include_introduced {
assert!(
properties.contains_key("introduced"),
"finding `{name}` was not augmented with `introduced` (audit-aware finding)",
);
} else {
assert!(
!properties.contains_key("introduced"),
"finding `{name}` carries `introduced` but `finding_augmentation` opted out",
);
}
}
}
#[test]
fn committed_definitions_match_derived_property_keys() {
let committed = committed_definitions();
let derived = derived_definitions_for_drift();
const AUGMENTATION_KEYS: &[&str] = &["actions", "introduced"];
let mut failures: Vec<String> = Vec::new();
for name in derived.keys() {
let Some(committed_entry) = committed.get(name) else {
failures.push(format!(
"definition `{name}` is missing from `docs/output-schema.json`. Add a stub entry to `definitions` (the drift test only compares; it does not insert)."
));
continue;
};
let derived_entry = derived
.get(name)
.expect("iterating derived's own keys; entry must exist");
let committed_props = committed_entry.get("properties").and_then(Value::as_object);
let derived_props = derived_entry.get("properties").and_then(Value::as_object);
if let (Some(committed_props), Some(derived_props)) = (committed_props, derived_props) {
for key in derived_props.keys() {
if !committed_props.contains_key(key) {
failures.push(format!(
"drift on `{name}`: property `{key}` is in the Rust struct (derived schema) but missing from `docs/output-schema.json`"
));
}
}
for key in committed_props.keys() {
if !derived_props.contains_key(key)
&& !AUGMENTATION_KEYS.contains(&key.as_str())
{
failures.push(format!(
"drift on `{name}`: property `{key}` is in `docs/output-schema.json` but missing from the Rust struct (derived schema)"
));
}
}
}
let committed_required: rustc_hash::FxHashSet<String> = committed_entry
.get("required")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let derived_required: rustc_hash::FxHashSet<String> = derived_entry
.get("required")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
for key in &derived_required {
if !committed_required.contains(key) {
failures.push(format!(
"drift on `{name}`: property `{key}` is required by the Rust struct but optional in `docs/output-schema.json`"
));
}
}
for key in &committed_required {
if !derived_required.contains(key) && !AUGMENTATION_KEYS.contains(&key.as_str()) {
failures.push(format!(
"drift on `{name}`: property `{key}` is required by `docs/output-schema.json` but optional in the Rust struct"
));
}
}
}
assert!(
failures.is_empty(),
"schema drift detected ({} issue{}):\n\n - {}\n\nRegenerate the in-scope `definitions` blocks with:\n cargo run -p fallow-cli --features schema-emit --bin fallow-schema-emit > /tmp/emitted-schema.json\nthen reconcile the relevant entries in `docs/output-schema.json` against the derived shape, or update the Rust source if the schema change was the intended source of truth.",
failures.len(),
if failures.len() == 1 { "" } else { "s" },
failures.join("\n - "),
);
}
#[test]
fn committed_property_refs_match_derived_property_refs() {
let committed = committed_definitions();
let derived = derived_definitions_for_drift();
let mut failures: Vec<String> = Vec::new();
for name in derived.keys() {
let Some(committed_entry) = committed.get(name) else {
continue;
};
let Some(derived_entry) = derived.get(name) else {
continue;
};
let committed_props = committed_entry.get("properties").and_then(Value::as_object);
let derived_props = derived_entry.get("properties").and_then(Value::as_object);
if let (Some(committed_props), Some(derived_props)) = (committed_props, derived_props) {
for (key, derived_value) in derived_props {
let Some(committed_value) = committed_props.get(key) else {
continue;
};
let derived_ref = canonical_ref(derived_value);
let committed_ref = canonical_ref(committed_value);
if let (Some(dref), Some(cref)) = (&derived_ref, &committed_ref)
&& dref != cref
{
failures.push(format!(
"drift on `{name}.{key}`: derived schema points at `{dref}` but committed schema points at `{cref}`"
));
}
}
}
}
assert!(
failures.is_empty(),
"schema `$ref` drift detected ({} issue{}):\n\n - {}\n\nThe wire format produced by the Rust source disagrees with the type the committed schema documents. Either update `docs/output-schema.json` to point at the type the wire actually emits, or change the runtime to produce the documented shape.",
failures.len(),
if failures.len() == 1 { "" } else { "s" },
failures.join("\n - "),
);
}
fn canonical_ref(value: &Value) -> Option<String> {
let mut canonical = value.clone();
normalize_one(&mut canonical);
if let Some(Value::String(s)) = canonical.get("$ref") {
return Some(s.clone());
}
if let Some(Value::Array(arr)) = canonical.get("oneOf") {
for variant in arr {
if let Some(Value::String(s)) = variant.get("$ref") {
return Some(s.clone());
}
}
}
None
}
#[test]
fn emitted_schema_has_no_dangling_refs() {
let derived = derived_definitions();
let document =
merge_with_committed(&derived).expect("merge must succeed on committed schema");
let mut defined: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
if let Some(map) = document.get("definitions").and_then(Value::as_object) {
for key in map.keys() {
defined.insert(key.clone());
}
}
let mut refs: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
fn collect_refs(node: &Value, out: &mut rustc_hash::FxHashSet<String>) {
match node {
Value::Object(map) => {
if let Some(Value::String(reference)) = map.get("$ref")
&& let Some(name) = reference.strip_prefix("#/definitions/")
{
out.insert(name.to_string());
}
for child in map.values() {
collect_refs(child, out);
}
}
Value::Array(items) => {
for child in items {
collect_refs(child, out);
}
}
_ => {}
}
}
collect_refs(&document, &mut refs);
let mut missing: Vec<String> = refs.difference(&defined).cloned().collect();
missing.sort();
assert!(
missing.is_empty(),
"emitted schema has {} dangling `$ref` target{}: {}\n\n\
A regenerated `docs/output-schema.json` with dangling refs is invalid; \
every referenced name must appear under `definitions`. If schemars \
produced a transitive helper definition, ensure `merge_with_committed` \
inserts every entry from the derived map (not just names in \
`derived_definition_names()`).",
missing.len(),
if missing.len() == 1 { "" } else { "s" },
missing.join(", "),
);
}
#[test]
fn committed_definitions_match_derived_structurally() {
let committed = committed_definitions();
let derived = derived_definitions_for_drift();
let mut failures: Vec<String> = Vec::new();
for (name, derived_value) in &derived {
let Some(committed_value) = committed.get(name) else {
failures.push(format!(
"definition `{name}` is missing from `docs/output-schema.json`."
));
continue;
};
let derived_entry = canonicalize(derived_value.clone());
let committed_entry = canonicalize(committed_value.clone());
if committed_entry != derived_entry {
let committed_pretty = serde_json::to_string_pretty(&committed_entry)
.unwrap_or_else(|_| "<unprintable>".to_string());
let derived_pretty = serde_json::to_string_pretty(&derived_entry)
.unwrap_or_else(|_| "<unprintable>".to_string());
failures.push(format!(
"drift on `{name}`:\n--- committed (canonicalized) ---\n{committed_pretty}\n--- derived (canonicalized) ---\n{derived_pretty}"
));
}
}
const HAND_MAINTAINED_ALLOW_LIST: &[(&str, &str)] = &[];
let allow_list: rustc_hash::FxHashSet<&'static str> = HAND_MAINTAINED_ALLOW_LIST
.iter()
.map(|(name, _)| *name)
.collect();
for name in committed.keys() {
if !derived.contains_key(name) && !allow_list.contains(name.as_str()) {
failures.push(format!(
"orphan in `docs/output-schema.json`: definition `{name}` is not produced by `derived_definitions()`. Either register the type via `subschema_for::<{name}>()` in `derived_definitions`, or delete the stale entry. (If the entry is hand-maintained pending another #384 item, add it to `HAND_MAINTAINED_ALLOW_LIST` with a reason linking the issue.)"
));
}
}
assert!(
failures.is_empty(),
"{} structural drift issue{}:\n\n{}",
failures.len(),
if failures.len() == 1 { "" } else { "s" },
failures.join("\n\n"),
);
}
#[test]
fn normalize_schema_preserves_property_named_format() {
let mut value = serde_json::json!({
"type": "object",
"properties": {
"format": { "$ref": "#/definitions/SomeEnum" },
"minimum": { "type": "integer" },
"default": { "type": "string" },
"regular": { "type": "string", "format": "uri" }
},
"required": ["format", "minimum", "default", "regular"]
});
super::normalize_schema(&mut value);
let properties = value
.get("properties")
.and_then(Value::as_object)
.expect("properties stays an object");
assert!(
properties.contains_key("format"),
"property `format` must survive normalize_schema (issue #394)"
);
assert!(
properties.contains_key("minimum"),
"property `minimum` must survive normalize_schema"
);
assert!(
properties.contains_key("default"),
"property `default` must survive normalize_schema"
);
let regular = properties
.get("regular")
.and_then(Value::as_object)
.expect("`regular` stays an object");
assert!(
!regular.contains_key("format"),
"schemars `format` keyword inside a property's schema is still stripped"
);
}
#[test]
fn normalize_one_preserves_property_named_format() {
let mut value = serde_json::json!({
"type": "object",
"properties": {
"format": { "$ref": "#/definitions/SomeEnum" },
"minimum": { "type": "integer" },
"regular": { "type": "string", "format": "uri" }
},
"required": ["format", "minimum", "regular"]
});
normalize_one(&mut value);
let properties = value
.get("properties")
.and_then(Value::as_object)
.expect("properties stays an object");
assert!(
properties.contains_key("format"),
"property `format` must survive normalize_one"
);
assert!(
properties.contains_key("minimum"),
"property `minimum` must survive normalize_one"
);
let regular = properties
.get("regular")
.and_then(Value::as_object)
.expect("`regular` stays an object");
assert!(
!regular.contains_key("format"),
"schemars `format` keyword inside a property's schema is still stripped"
);
}
#[test]
fn hand_maintained_root_envelopes_appear_in_root_one_of() {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
let one_of = document
.get("oneOf")
.and_then(Value::as_array)
.expect("committed schema must carry a root-level `oneOf`");
let mut refs: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for entry in one_of {
if let Some(reference) = entry.get("$ref").and_then(Value::as_str)
&& let Some(name) = reference.strip_prefix("#/definitions/")
{
refs.insert(name.to_string());
}
}
for name in HAND_MAINTAINED_ROOT_ENVELOPES {
assert!(
refs.contains(*name),
"hand-maintained root envelope `{name}` is registered in \
`HAND_MAINTAINED_ROOT_ENVELOPES` but is not referenced from \
the document-root `oneOf`. Either (a) re-add the entry to \
the rewritten `oneOf` in `rewrite_document_root_one_of`, \
or (b) remove it from `HAND_MAINTAINED_ROOT_ENVELOPES` \
because the migration to a typed `FallowOutput` variant \
has landed. Root `oneOf` refs today: {:?}",
refs.iter().collect::<Vec<_>>(),
);
}
}
#[test]
fn root_one_of_carries_fallow_output_and_codeclimate() {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
let one_of = document
.get("oneOf")
.and_then(Value::as_array)
.expect("committed schema must carry a root-level `oneOf`");
let mut refs: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for entry in one_of {
if let Some(reference) = entry.get("$ref").and_then(Value::as_str)
&& let Some(name) = reference.strip_prefix("#/definitions/")
{
refs.insert(name.to_string());
}
}
assert!(
refs.contains("FallowOutput"),
"document-root `oneOf` must reference `#/definitions/FallowOutput`; \
found refs: {:?}",
refs.iter().collect::<Vec<_>>(),
);
assert!(
refs.contains("CodeClimateOutput"),
"document-root `oneOf` must reference `#/definitions/CodeClimateOutput` \
as a sibling root branch (the bare-array spec form); found refs: {:?}",
refs.iter().collect::<Vec<_>>(),
);
}
}