#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "schema-emit binary prints the regenerated schema to stdout and errors to stderr"
)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "schema drift tests use unwrap and expect to keep invariant checks concise"
)
)]
//! Regenerate `docs/output-schema.json` from the Rust source of truth.
//!
//! When the `schema-emit` feature is enabled, this binary derives the Rust-owned
//! definitions, merges them with the committed schema, and prints draft-07 JSON
//! Schema to stdout.
#[cfg(not(test))]
use std::path::PathBuf;
use std::process::ExitCode;
use schemars::generate::SchemaSettings;
use serde_json::{Map, Value};
use fallow_api::{
AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, AuditOutput,
BoundariesListLogicalGroup, BoundariesListing, CloneFamilyFinding, CloneGroupFinding,
CombinedOutput, DupesReportPayload, DuplicationGroup, FallowOutput, ImpactOutput,
ListBoundariesOutput, ReviewBriefWireOutput, SecurityGate, SecurityGateMode, SecurityOutput,
SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
};
use fallow_config::{AuthoredRule, LogicalGroup, LogicalGroupStatus};
use fallow_output::{
AcceptedJudgment, AgentSchema, ArchitectureSchemaVersion, AuditCommand, BoundariesListRule,
BoundariesListZone, ChangeAnchor, CheckGroupedEntry, CheckGroupedOutput, CheckOutput,
ComplexityViolation, ConfidenceFlag, ContainmentEvent, ContributorEntry,
ContributorIdentifierFormat, CoverageAnalyzeOutput, CoverageAnalyzeSchemaVersion,
CoverageGapSummary, CoverageGaps, CoverageModel, CoverageSetupFileToEdit,
CoverageSetupFramework, CoverageSetupMember, CoverageSetupOutput, CoverageSetupPackageManager,
CoverageSetupRuntimeTarget, CoverageSetupSchemaVersion, CoverageSetupSnippet, CoverageTier,
CrossRepoImpactReport, CrossRepoImpactSchemaVersion, CrossRepoProjectEntry, CrossRepoTotals,
Decision, DecisionAction, DecisionActionType, DecisionCategory, DecisionSurface,
DecisionSurfaceOutput, DecisionSurfaceSchemaVersion, DecisionWithActions, DiffTriage,
DirectionUnit, DoctorCheck, DoctorCheckCategory, DoctorCheckId, DoctorCheckStatus,
DoctorOutput, DoctorRemediation, DoctorStatus, DoctorSummary, DupesOutput, EnabledSource,
ExceededThreshold, ExplainOutput, FileHealthScore, FindingSeverity, FocusLabel, FocusMap,
FocusScore, FocusUnit, GateName, GateOutcome, GateStatus, GitHubReviewComment,
GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType,
GraphFacts, GroupByMode, HealthActionsMeta, HealthGroup, HealthOutput, HealthReport,
HealthScore, HealthScorePenalties, HealthSummary, HealthTrend, HotspotEntry, HotspotFinding,
HotspotSummary, ImpactCounts, ImpactReport, ImpactReportSchemaVersion, ImpactTrendDirection,
InspectEvidence, InspectEvidenceScope, InspectEvidenceSection, InspectFileIdentity,
InspectIdentity, InspectOutput, InspectSectionStatus, InspectSymbolIdentity,
InspectTargetDescriptor, LargeFunctionEntry, MutationInspectOutput, MutationPlanOutput,
MutationReport, MutationVerdictInput, OwnershipMetrics, RecommendationCategory,
RefactoringTarget, RefactoringTargetFinding, RejectedJudgment, RequestName, ResolutionEvent,
ReviewBriefSchemaVersion, ReviewCheckConclusion, ReviewComment, ReviewDirection, ReviewEffort,
ReviewEnvelopeEvent, ReviewEnvelopeSchema, ReviewEnvelopeSummary, ReviewEnvelopeWireOutput,
ReviewProvider, ReviewReconcileOutput, ReviewReconcileSchema, RiskClass, RiskProfile,
RuntimeCoverageReport, SecurityBlindSpotFile, SecurityBlindSpotGroup, SecurityBlindSpotsOutput,
SecurityBlindSpotsSchemaVersion, SecurityBlindSpotsSummary, SecurityGateVerdict,
SecurityReachabilityCounts, SecurityRuntimeStateCounts, SecuritySchemaVersion,
SecuritySeverityCounts, SecuritySummary, SecuritySurvivor, SecuritySurvivorsOutput,
SecuritySurvivorsSchemaVersion, SecuritySurvivorsSummary, SecurityUnresolvedCalleeDiagnostics,
SecurityUnresolvedCalleeReasonCount, SecurityUnresolvedCalleeSample,
SecurityUnresolvedCalleeTopFile, SecurityVerifierVerdict, SecurityVerifierVerdictStatus,
SimilarCodeCacheClearOutput, SimilarCodeCacheClearSchemaVersion, SimilarCodeInspectOutput,
SimilarCodeOutput, SimilarCodeReviewOutput, SimilarCodeStatusOutput,
SimilarCodeStatusSchemaVersion, SimilarCodeVerdictInput,
StandardReviewBriefOutput as ReviewBriefOutput, StandardWalkthroughGuide as WalkthroughGuide,
SuppressionInventoryEntry, SuppressionInventoryFile, SuppressionInventoryLevel,
SuppressionInventoryOrigin, SuppressionInventoryOutput, SuppressionInventorySchemaVersion,
SuppressionInventorySummary, SuppressionKindCount, TargetThresholds, TrendCount, TrendSummary,
TruncationNote, TypeAwareStatusOutput, UntestedExport, UntestedExportFinding, UntestedFile,
UntestedFileFinding, VitalSigns, VitalSignsCounts, WalkthroughValidation, WorkspaceInfo,
};
use fallow_output::{
CloneFamilyAction, CloneFamilyActionType, CloneGroupAction, CloneGroupActionType,
CodeClimateIssue, CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation,
CodeClimateOutput, CodeClimateSeverity,
};
use fallow_types::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, TelemetryMeta, ToolVersion,
};
use fallow_types::extract::{
MemberKind, SecurityControlKind, SkippedSecurityCalleeExpressionKind,
SkippedSecurityCalleeReason,
};
use fallow_types::output::{
AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
IgnoreExportsRule, IssueAction, NextStep, 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, SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
SecuritySeverity, SecurityTaintFlow, SecurityZoneCrossing, StaleSuppression, SuppressionOrigin,
TaintEndpoint, TaintPath, TestOnlyDependency, TraceHop, TraceHopRole, TypeOnlyDependency,
UnlistedDependency, UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry,
UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedMember,
};
use fallow_types::semantic::{SemanticSymbolImpact, SemanticSymbolTrace};
/// Workspace-relative path to the committed schema.
#[cfg(not(test))]
const COMMITTED_SCHEMA_REL_PATH: &str = "docs/output-schema.json";
/// Embedded copy used by tests.
#[cfg(test)]
const COMMITTED_SCHEMA: &str = include_str!("../../../../docs/output-schema.json");
/// Locate `docs/output-schema.json` by walking up from `CARGO_MANIFEST_DIR`.
#[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()
))
}
/// Test-only helper that uses the embedded schema.
#[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)
}
}
}
/// Argument that prints the similar-code snapshot schema instead of the output
/// schema. `scripts/generate-all.mjs` writes the result to
/// `crates/api/similar-code-snapshot-schema.json`.
const SIMILAR_CODE_SNAPSHOT_FLAG: &str = "--similar-code-snapshot";
fn run() -> Result<(), String> {
if std::env::args().nth(1).as_deref() == Some(SIMILAR_CODE_SNAPSHOT_FLAG) {
let pretty = serde_json::to_string_pretty(
&fallow_output::SimilarCodeCandidateSnapshot::json_schema(),
)
.map_err(|err| format!("failed to serialize snapshot schema: {err}"))?;
println!("{pretty}");
return Ok(());
}
let derived = derived_definitions();
validate_graph_facts_descriptions(&derived)?;
validate_review_brief_wire_contract(&derived)?;
let mut merged = merge_with_committed(&derived)?;
normalize_output_punctuation(&mut merged);
let pretty = serde_json::to_string_pretty(&merged)
.map_err(|err| format!("failed to serialize merged schema: {err}"))?;
println!("{pretty}");
Ok(())
}
fn validate_review_brief_wire_contract(derived: &Map<String, Value>) -> Result<(), String> {
let properties = derived
.get("ReviewBriefWireOutput")
.and_then(Value::as_object)
.and_then(|schema| schema.get("properties"))
.and_then(Value::as_object)
.ok_or_else(|| "derived ReviewBriefWireOutput properties are missing".to_string())?;
for field in [
"verdict",
"changed_files_count",
"base_ref",
"summary",
"attribution",
"_meta",
"dead_code",
"duplication",
"complexity",
] {
if !properties.contains_key(field) {
return Err(format!(
"derived ReviewBriefWireOutput is missing `{field}`"
));
}
}
Ok(())
}
fn validate_graph_facts_descriptions(derived: &Map<String, Value>) -> Result<(), String> {
const RETIRED_PHRASE: &str = "Stubbed to";
let graph_facts = derived
.get("GraphFacts")
.and_then(Value::as_object)
.ok_or_else(|| "derived GraphFacts schema is missing".to_string())?;
let properties = graph_facts
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| "derived GraphFacts properties are missing".to_string())?;
for field in ["exports_added", "api_width_delta"] {
let description = properties
.get(field)
.and_then(|value| value.get("description"))
.and_then(Value::as_str)
.ok_or_else(|| format!("derived GraphFacts.{field} description is missing"))?;
if description.contains(RETIRED_PHRASE) {
return Err(format!(
"derived GraphFacts.{field} description still contains retired phrase `{RETIRED_PHRASE}`"
));
}
}
Ok(())
}
fn normalize_output_punctuation(value: &mut Value) {
match value {
Value::String(text) => {
*text = text.replace('\u{2014}', ",");
}
Value::Array(items) => {
for item in items {
normalize_output_punctuation(item);
}
}
Value::Object(map) => {
for child in map.values_mut() {
normalize_output_punctuation(child);
}
}
_ => {}
}
}
/// Definitions owned by this binary; everything else is copied from the committed schema.
const DERIVED_DEFINITION_NAMES: &[&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",
"NextStep",
"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",
"AuditSchemaVersion",
"CheckSchemaVersion",
"ArchitectureSchemaVersion",
"CombinedSchemaVersion",
"DupesSchemaVersion",
"FeatureFlagsSchemaVersion",
"HealthSchemaVersion",
"DoctorSchemaVersion",
"TypeAwareStatusSchemaVersion",
"ToolVersion",
"RuntimeCoverageAction",
"RuntimeCoverageBlastRadiusEntry",
"RuntimeCoverageCaptureQuality",
"RuntimeCoverageConfidence",
"RuntimeCoverageCostBasis",
"RuntimeCoverageEvidence",
"RuntimeCoverageFinding",
"RuntimeCoverageHotPath",
"RuntimeCoverageImportanceEntry",
"RuntimeCoverageMessage",
"RuntimeCoverageOptimizationTarget",
"RuntimeCoverageReportVerdict",
"RuntimeCoverageRiskBand",
"RuntimeCoverageSignal",
"RuntimeCoverageSummary",
"RuntimeCoverageVerdict",
"RuntimeCoverageWatermark",
"DuplicationReport",
"HealthReport",
"AuditOutput",
"CheckGroupedEntry",
"CheckGroupedOutput",
"CheckOutput",
"ArchitectureGroupedOutput",
"ArchitectureOutput",
"CodeClimateIssue",
"CodeClimateOutput",
"CombinedOutput",
"ReviewBriefOutput",
"ReviewBriefWireOutput",
"ReviewBriefSchemaVersion",
"RiskClass",
"ReviewEffort",
"DiffTriage",
"GraphFacts",
"FocusLabel",
"ConfidenceFlag",
"FocusScore",
"FocusUnit",
"FocusMap",
"Decision",
"DecisionAction",
"DecisionActionType",
"DecisionCategory",
"DecisionSurface",
"DecisionSurfaceOutput",
"DecisionSurfaceSchemaVersion",
"DecisionWithActions",
"TruncationNote",
"AcceptedJudgment",
"AgentSchema",
"ChangeAnchor",
"DirectionUnit",
"RejectedJudgment",
"ReviewDirection",
"WalkthroughGuide",
"WalkthroughValidation",
"CoverageSetupFileToEdit",
"CoverageSetupMember",
"CoverageSetupOutput",
"CoverageSetupSnippet",
"DupesOutput",
"ExplainOutput",
"InspectEvidence",
"InspectEvidenceSection",
"InspectFileIdentity",
"InspectIdentity",
"InspectOutput",
"InspectSymbolIdentity",
"InspectTargetDescriptor",
"GitHubReviewComment",
"GitLabReviewComment",
"GitLabReviewPosition",
"HealthGroup",
"HealthOutput",
"ReviewEnvelopeOutput",
"ReviewEnvelopeSummary",
"ReviewReconcileOutput",
"InspectEvidenceScope",
"InspectSectionStatus",
"TraceOutput",
"ImpactOutput",
"SemanticSymbolTrace",
"SemanticSymbolImpact",
"DoctorOutput",
"DoctorCheck",
"DoctorCheckCategory",
"DoctorCheckId",
"DoctorCheckStatus",
"DoctorRemediation",
"DoctorStatus",
"DoctorSummary",
"TypeAwareStatusOutput",
"FallowOutput",
"BoundariesListLogicalGroup",
"BoundariesListRule",
"BoundariesListZone",
"BoundariesListing",
"ListBoundariesOutput",
"WorkspaceInfo",
"WorkspacesOutput",
"AuthoredRule",
"LogicalGroup",
"LogicalGroupStatus",
"AttributedCloneGroup",
"AttributedInstance",
"DuplicationGroup",
"AttributedCloneGroupFinding",
"CloneFamilyAction",
"CloneFamilyActionType",
"CloneFamilyFinding",
"CloneGroupAction",
"CloneGroupActionType",
"CloneGroupFinding",
"DupesReportPayload",
"CoverageAnalyzeOutput",
"CoverageAnalyzeSchemaVersion",
"ContainmentEvent",
"CrossRepoImpactReport",
"CrossRepoImpactSchemaVersion",
"CrossRepoProjectEntry",
"CrossRepoTotals",
"EnabledSource",
"ImpactCounts",
"ImpactReport",
"ImpactReportSchemaVersion",
"ImpactTrendDirection",
"ResolutionEvent",
"TrendSummary",
"SecurityOutput",
"SecurityUnresolvedCalleeDiagnostics",
"SecurityUnresolvedCalleeReasonCount",
"SecurityUnresolvedCalleeSample",
"SecurityUnresolvedCalleeTopFile",
"SkippedSecurityCalleeExpressionKind",
"SkippedSecurityCalleeReason",
"SecuritySummaryOutput",
"SecuritySummary",
"SecuritySeverityCounts",
"SecurityReachabilityCounts",
"SecurityRuntimeStateCounts",
"SecuritySurvivorsOutput",
"SecuritySurvivorsSchemaVersion",
"SecuritySurvivorsSummary",
"SecuritySurvivor",
"SecurityVerifierVerdict",
"SecurityVerifierVerdictStatus",
"SecurityBlindSpotsOutput",
"SecurityBlindSpotsSchemaVersion",
"SecurityBlindSpotsSummary",
"SecurityBlindSpotGroup",
"SecurityBlindSpotFile",
"SecuritySchemaVersion",
"SecurityGate",
"SecurityGateMode",
"SecurityGateVerdict",
"SecurityFinding",
"SecurityFindingKind",
"SecuritySeverity",
"SecurityDeadCodeContext",
"SecurityDeadCodeKind",
"SecurityReachability",
"SecurityCandidate",
"SecurityCandidateSink",
"SecurityCandidateBoundary",
"SecurityZoneCrossing",
"SecurityNetworkContext",
"SecurityTaintFlow",
"SecurityControlKind",
"SecurityDefensiveControl",
"SecurityDefensiveBoundary",
"SecurityAttackSurfaceEntry",
"TaintEndpoint",
"TaintPath",
"TraceHop",
"TraceHopRole",
"SuppressionInventoryOutput",
"SuppressionInventorySchemaVersion",
"SuppressionInventorySummary",
"SuppressionKindCount",
"SuppressionInventoryFile",
"SuppressionInventoryEntry",
"SuppressionInventoryLevel",
"SuppressionInventoryOrigin",
"SimilarCodeOutput",
"SimilarCodeInspectOutput",
"SimilarCodeReviewOutput",
"SimilarCodeStatusOutput",
"SimilarCodeCacheClearOutput",
"SimilarCodeVerdictInput",
"SimilarCodeSchemaVersion",
"SimilarCodeInspectSchemaVersion",
"SimilarCodeReviewSchemaVersion",
"SimilarCodeVerdictSchemaVersion",
"SimilarCodeGeneration",
"SimilarCodeProviderProvenance",
"SimilarCodeProvider",
"SimilarCodeModelProvenance",
"SimilarCodeGenerationParameters",
"SimilarCodeCandidate",
"SimilarCodeLocation",
"SimilarCodeSimilarityBand",
"SimilarCodeVerificationStatus",
"SimilarCodeEnrichmentAvailability",
"SimilarCodeEnrichmentState",
"SimilarCodeAction",
"SimilarCodeActionType",
"SimilarCodeCompletion",
"SimilarCodeCompletionStatus",
"SimilarCodePhaseCompletion",
"SimilarCodePhase",
"SimilarCodePhaseStatus",
"SimilarCodeLimits",
"SimilarCodeSkip",
"SimilarCodeSkipReason",
"SimilarCodeCacheSummary",
"SimilarCodeCacheStatus",
"SimilarCodeDiagnostic",
"SimilarCodeDiagnosticDomain",
"SimilarCodeInspectPacket",
"SimilarCodeSideEvidence",
"SimilarCodeSideEffectHint",
"SimilarCodeNamedReference",
"SimilarCodeVerdict",
"SimilarCodeDomainOutcome",
"SimilarCodeVerdictMatch",
"SimilarCodeReviewedCandidate",
"SimilarCodeReviewProvenance",
"MutationReport",
"MutationPlanOutput",
"MutationInspectOutput",
"MutationVerdictInput",
"MutationSchemaVersion",
"MutationPlanSchemaVersion",
"MutationInspectSchemaVersion",
"MutationVerdictSchemaVersion",
"MutantStatus",
"MutationStatusCounts",
"MutationCompletion",
"MutationCompletionStatus",
"MutationIncompleteReason",
"MutationScopeKind",
"MutationScopeOutput",
"MutationRunnerOutput",
"MutationRuntimeOutput",
"MutantResult",
"MutationFunctionResult",
"MutationSummary",
"MutationBaselineFailure",
"MutationSkippedFile",
"MutationWorktreeCheck",
"MutationRunIssue",
"MutationTimings",
"MutationPlanFile",
"MutationVerdictJoin",
"MutationVerdict",
"MutationVerdictKind",
"MutationPlanDisposition",
"MutationPlanMutant",
"MutationPlanCountsOutput",
"MutationInspectStatus",
"MutationStaleReason",
"MutationInspectCode",
];
pub(crate) fn derived_definition_names() -> &'static [&'static str] {
DERIVED_DEFINITION_NAMES
}
/// Finding definitions that get `actions` and optional `introduced` grafts.
fn finding_definition_names() -> &'static [&'static str] {
&[]
}
/// Per-finding override for `augment_finding_definition`.
///
/// Default augmentation for dead-code findings.
#[derive(Debug, Clone, Copy)]
struct FindingAugmentation {
/// Schema `$ref` for the items in the `actions` array.
actions_item_ref: &'static str,
/// Whether to attach the optional `introduced` audit breadcrumb.
include_introduced: bool,
}
/// Augmentation applied to dead-code findings: actions ref `IssueAction`,
/// `introduced` flag attached.
const DEFAULT_FINDING_AUGMENTATION: FindingAugmentation = FindingAugmentation {
actions_item_ref: "#/definitions/IssueAction",
include_introduced: true,
};
/// Pick the augmentation for a specific finding.
fn finding_augmentation(_name: &str) -> FindingAugmentation {
DEFAULT_FINDING_AUGMENTATION
}
/// Build derived schemas for every in-scope type using one shared generator.
#[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();
register_analysis_definitions(&mut generator);
register_duplication_definitions(&mut generator);
register_dead_code_action_definitions(&mut generator);
register_dead_code_finding_definitions(&mut generator);
register_health_definitions(&mut generator);
register_meta_definitions(&mut generator);
register_per_command_envelope_definitions(&mut generator);
register_audit_brief_definitions(&mut generator);
register_walkthrough_definitions(&mut generator);
register_impact_definitions(&mut generator);
register_security_definitions(&mut generator);
register_suppression_inventory_definitions(&mut generator);
register_similar_code_definitions(&mut generator);
register_mutation_definitions(&mut generator);
let _ = generator.subschema_for::<FallowOutput>();
register_list_boundaries_definitions(&mut generator);
register_health_action_definitions(&mut generator);
let mut definitions = generator.take_definitions(true);
derive_architecture_envelope_definitions(&mut definitions);
definitions
}
/// The architecture envelopes that share the dead-code body:
/// `(architecture definition, dead-code definition, title, description)`.
const ARCHITECTURE_ENVELOPE_DEFINITIONS: &[(&str, &str, &str, &str)] = &[
(
"ArchitectureOutput",
"CheckOutput",
"fallow architecture --format json",
"Envelope emitted by `fallow architecture --format json`.\n\nThe body is the `CheckOutput` body of `fallow dead-code`: the same issue arrays, `summary`, `entry_points`, actions and `gate_outcomes`. The architecture arrays (`circular_dependencies`, `re_export_cycles`, `package_cycles`, `boundary_violations`, `boundary_coverage_violations`, `boundary_call_violations`, `policy_violations`) carry the findings. The combined and audit envelopes do not use this envelope; their `check` block stays `CheckOutput`.",
),
(
"ArchitectureGroupedOutput",
"CheckGroupedOutput",
"fallow architecture --group-by <owner|directory|package|section> --format json",
"Envelope emitted by `fallow architecture --group-by ... --format json`.\n\nThe body is the `CheckGroupedOutput` body of `fallow dead-code`: issues are partitioned into resolver buckets (CODEOWNERS team, directory prefix, workspace package, or GitLab CODEOWNERS section), and each bucket carries the same issue-array shape as `ArchitectureOutput`, plus per-group `key` / `owners` / `total_issues`.",
),
];
/// Derive the `fallow architecture` envelope definitions from the dead-code
/// ones.
///
/// `fallow architecture` serializes the same `CheckOutput` and
/// `CheckGroupedOutput` structs, so a clone of the derived schema keeps the
/// body equal by construction. Only the title, the description and the
/// `schema_version` reference change. Runs inside [`derived_definitions`], so the emitter and
/// every drift test see the same definitions.
fn derive_architecture_envelope_definitions(definitions: &mut Map<String, Value>) {
for (architecture, dead_code, title, description) in ARCHITECTURE_ENVELOPE_DEFINITIONS {
let Some(mut schema) = definitions.get(*dead_code).cloned() else {
continue;
};
if let Some(object) = schema.as_object_mut() {
object.insert("title".to_string(), Value::String((*title).to_string()));
object.insert(
"description".to_string(),
Value::String((*description).to_string()),
);
}
if let Some(version) = schema
.pointer_mut("/properties/schema_version")
.and_then(Value::as_object_mut)
{
retarget_schema_version_refs(version);
version.insert(
"description".to_string(),
Value::String(
"Architecture output schema version; currently [`ARCHITECTURE_SCHEMA_VERSION`]. A change to the shared dead-code body bumps this version and the dead-code version together."
.to_string(),
),
);
}
definitions.insert((*architecture).to_string(), schema);
}
}
/// Point every `CheckSchemaVersion` reference in a cloned `schema_version`
/// property at `ArchitectureSchemaVersion`. The raw generator output can put
/// the reference in a one-item `allOf`, so the walk covers the whole subtree.
fn retarget_schema_version_refs(value: &mut Map<String, Value>) {
for (key, child) in value.iter_mut() {
match child {
Value::String(reference) if key == "$ref" => {
if let Some(prefix) = reference.strip_suffix("CheckSchemaVersion") {
*reference = format!("{prefix}ArchitectureSchemaVersion");
}
}
Value::Object(map) => retarget_schema_version_refs(map),
Value::Array(items) => {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
retarget_schema_version_refs(item);
}
}
_ => {}
}
}
}
fn register_analysis_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
}
fn register_duplication_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
}
fn register_dead_code_action_definitions(generator: &mut schemars::SchemaGenerator) {
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::<NextStep>();
}
fn register_dead_code_finding_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
}
fn register_health_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
}
fn register_meta_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
let _ = generator.subschema_for::<TelemetryMeta>();
// Registered explicitly: the shared gate type is reachable from every
// analysis envelope, and the orphan check rejects a committed definition
// `derived_definitions` does not produce.
let _ = generator.subschema_for::<GateOutcome>();
let _ = generator.subschema_for::<GateName>();
let _ = generator.subschema_for::<GateStatus>();
// Same reason for the request catalogue: `RequestOutcomes` is a keyed map,
// so the name enum is reachable from nothing and would otherwise be the one
// part of the object the mirror does not publish.
let _ = generator.subschema_for::<RequestName>();
}
/// Register the `fallow audit --brief --format json` envelope.
fn register_audit_brief_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<ReviewBriefSchemaVersion>();
let _ = generator.subschema_for::<RiskClass>();
let _ = generator.subschema_for::<ReviewEffort>();
let _ = generator.subschema_for::<DiffTriage>();
let _ = generator.subschema_for::<GraphFacts>();
let _ = generator.subschema_for::<FocusLabel>();
let _ = generator.subschema_for::<ConfidenceFlag>();
let _ = generator.subschema_for::<FocusScore>();
let _ = generator.subschema_for::<FocusUnit>();
let _ = generator.subschema_for::<FocusMap>();
let _ = generator.subschema_for::<ReviewBriefOutput>();
let _ = generator.subschema_for::<ReviewBriefWireOutput>();
let _ = generator.subschema_for::<DecisionCategory>();
let _ = generator.subschema_for::<Decision>();
let _ = generator.subschema_for::<DecisionAction>();
let _ = generator.subschema_for::<DecisionActionType>();
let _ = generator.subschema_for::<DecisionWithActions>();
let _ = generator.subschema_for::<TruncationNote>();
let _ = generator.subschema_for::<DecisionSurface>();
let _ = generator.subschema_for::<DecisionSurfaceSchemaVersion>();
let _ = generator.subschema_for::<DecisionSurfaceOutput>();
}
/// Register the `fallow review --walkthrough-guide` /
/// `--walkthrough-file` envelopes (the agent-contract loop).
fn register_walkthrough_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<DirectionUnit>();
let _ = generator.subschema_for::<ReviewDirection>();
let _ = generator.subschema_for::<ChangeAnchor>();
let _ = generator.subschema_for::<AgentSchema>();
let _ = generator.subschema_for::<WalkthroughGuide>();
let _ = generator.subschema_for::<AcceptedJudgment>();
let _ = generator.subschema_for::<RejectedJudgment>();
let _ = generator.subschema_for::<WalkthroughValidation>();
}
fn register_impact_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<ImpactCounts>();
let _ = generator.subschema_for::<EnabledSource>();
let _ = generator.subschema_for::<ImpactReportSchemaVersion>();
let _ = generator.subschema_for::<ImpactTrendDirection>();
let _ = generator.subschema_for::<ResolutionEvent>();
let _ = generator.subschema_for::<TrendSummary>();
let _ = generator.subschema_for::<ContainmentEvent>();
let _ = generator.subschema_for::<ImpactReport>();
let _ = generator.subschema_for::<CrossRepoImpactSchemaVersion>();
let _ = generator.subschema_for::<CrossRepoTotals>();
let _ = generator.subschema_for::<CrossRepoProjectEntry>();
let _ = generator.subschema_for::<CrossRepoImpactReport>();
}
fn register_security_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<SecurityFindingKind>();
let _ = generator.subschema_for::<SecuritySeverity>();
let _ = generator.subschema_for::<SecurityDeadCodeKind>();
let _ = generator.subschema_for::<SecurityDeadCodeContext>();
let _ = generator.subschema_for::<TraceHopRole>();
let _ = generator.subschema_for::<TraceHop>();
let _ = generator.subschema_for::<SecurityReachability>();
let _ = generator.subschema_for::<SecurityCandidateSink>();
let _ = generator.subschema_for::<SecurityZoneCrossing>();
let _ = generator.subschema_for::<SecurityCandidateBoundary>();
let _ = generator.subschema_for::<SecurityNetworkContext>();
let _ = generator.subschema_for::<SecurityCandidate>();
let _ = generator.subschema_for::<TaintEndpoint>();
let _ = generator.subschema_for::<TaintPath>();
let _ = generator.subschema_for::<SecurityTaintFlow>();
let _ = generator.subschema_for::<SecurityRuntimeState>();
let _ = generator.subschema_for::<SecurityRuntimeContext>();
let _ = generator.subschema_for::<SecurityControlKind>();
let _ = generator.subschema_for::<SecurityDefensiveControl>();
let _ = generator.subschema_for::<SecurityDefensiveBoundary>();
let _ = generator.subschema_for::<SecurityAttackSurfaceEntry>();
let _ = generator.subschema_for::<SecurityFinding>();
let _ = generator.subschema_for::<SecurityGateMode>();
let _ = generator.subschema_for::<SecurityGateVerdict>();
let _ = generator.subschema_for::<SecurityGate>();
let _ = generator.subschema_for::<SecuritySchemaVersion>();
let _ = generator.subschema_for::<SecurityOutput>();
let _ = generator.subschema_for::<SecurityUnresolvedCalleeDiagnostics>();
let _ = generator.subschema_for::<SecurityUnresolvedCalleeReasonCount>();
let _ = generator.subschema_for::<SecurityUnresolvedCalleeSample>();
let _ = generator.subschema_for::<SecurityUnresolvedCalleeTopFile>();
let _ = generator.subschema_for::<SkippedSecurityCalleeExpressionKind>();
let _ = generator.subschema_for::<SkippedSecurityCalleeReason>();
let _ = generator.subschema_for::<SecuritySummaryOutput>();
let _ = generator.subschema_for::<SecuritySummary>();
let _ = generator.subschema_for::<SecuritySeverityCounts>();
let _ = generator.subschema_for::<SecurityReachabilityCounts>();
let _ = generator.subschema_for::<SecurityRuntimeStateCounts>();
let _ = generator.subschema_for::<SecuritySurvivorsOutput>();
let _ = generator.subschema_for::<SecuritySurvivorsSchemaVersion>();
let _ = generator.subschema_for::<SecuritySurvivorsSummary>();
let _ = generator.subschema_for::<SecuritySurvivor>();
let _ = generator.subschema_for::<SecurityVerifierVerdict>();
let _ = generator.subschema_for::<SecurityVerifierVerdictStatus>();
let _ = generator.subschema_for::<SecurityBlindSpotsOutput>();
let _ = generator.subschema_for::<SecurityBlindSpotsSchemaVersion>();
let _ = generator.subschema_for::<SecurityBlindSpotsSummary>();
let _ = generator.subschema_for::<SecurityBlindSpotGroup>();
let _ = generator.subschema_for::<SecurityBlindSpotFile>();
}
/// Register the `fallow suppressions --format json` envelope.
fn register_suppression_inventory_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<SuppressionInventorySchemaVersion>();
let _ = generator.subschema_for::<SuppressionInventoryLevel>();
let _ = generator.subschema_for::<SuppressionInventoryOrigin>();
let _ = generator.subschema_for::<SuppressionInventoryEntry>();
let _ = generator.subschema_for::<SuppressionInventoryFile>();
let _ = generator.subschema_for::<SuppressionKindCount>();
let _ = generator.subschema_for::<SuppressionInventorySummary>();
let _ = generator.subschema_for::<SuppressionInventoryOutput>();
}
/// Register the independent mutation report, plan, inspect, and verdict contracts.
fn register_mutation_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<MutationReport>();
let _ = generator.subschema_for::<MutationPlanOutput>();
let _ = generator.subschema_for::<MutationInspectOutput>();
let _ = generator.subschema_for::<MutationVerdictInput>();
}
/// Register the independent similar-code candidate, inspect, and review contracts.
fn register_similar_code_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<SimilarCodeOutput>();
let _ = generator.subschema_for::<SimilarCodeInspectOutput>();
let _ = generator.subschema_for::<SimilarCodeReviewOutput>();
let _ = generator.subschema_for::<SimilarCodeVerdictInput>();
let _ = generator.subschema_for::<SimilarCodeStatusOutput>();
let _ = generator.subschema_for::<SimilarCodeStatusSchemaVersion>();
let _ = generator.subschema_for::<SimilarCodeCacheClearOutput>();
let _ = generator.subschema_for::<SimilarCodeCacheClearSchemaVersion>();
}
fn register_health_action_definitions(generator: &mut schemars::SchemaGenerator) {
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>();
}
/// Register per-command envelope structs.
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::<ArchitectureSchemaVersion>();
let _ = generator.subschema_for::<DupesOutput<DupesReportPayload, DuplicationGroup>>();
let _ = generator.subschema_for::<HealthOutput<HealthReport, HealthGroup>>();
let _ = generator.subschema_for::<HealthGroup>();
let _ = generator.subschema_for::<HealthReport>();
let _ = generator.subschema_for::<GroupByMode>();
let _ = generator.subschema_for::<ExplainOutput>();
let _ = generator.subschema_for::<InspectOutput>();
let _ = generator.subschema_for::<InspectTargetDescriptor>();
let _ = generator.subschema_for::<InspectIdentity>();
let _ = generator.subschema_for::<InspectFileIdentity>();
let _ = generator.subschema_for::<InspectSymbolIdentity>();
let _ = generator.subschema_for::<InspectEvidence>();
let _ = generator.subschema_for::<InspectEvidenceSection>();
let _ = generator.subschema_for::<InspectSectionStatus>();
let _ = generator.subschema_for::<InspectEvidenceScope>();
// Symbol-level call chain (`fallow trace`, `FallowOutput::Trace`).
let _ = generator.subschema_for::<fallow_types::trace_chain::SymbolChainTrace>();
// Shortest import path (`fallow trace --path`, `FallowOutput::Trace`).
let _ = generator.subschema_for::<fallow_types::trace::ImportPathTrace>();
// Stack-trace frame resolution (`fallow trace-error`,
// `FallowOutput::TraceError`).
let _ = generator.subschema_for::<fallow_types::trace_error::ErrorTrace>();
let _ = generator.subschema_for::<fallow_types::trace_error::ErrorTraceFrame>();
let _ = generator.subschema_for::<fallow_types::trace_error::ErrorTraceCandidate>();
let _ = generator.subschema_for::<fallow_types::trace_error::ErrorTraceCounts>();
let _ = generator.subschema_for::<fallow_types::trace_error::ErrorTraceSchemaVersion>();
let _ = generator.subschema_for::<fallow_types::trace_error::FrameOrigin>();
let _ = generator.subschema_for::<fallow_types::trace_error::FrameResolution>();
let _ = generator.subschema_for::<TraceOutput>();
let _ = generator.subschema_for::<ImpactOutput>();
let _ = generator.subschema_for::<SemanticSymbolTrace>();
let _ = generator.subschema_for::<SemanticSymbolImpact>();
let _ = generator.subschema_for::<DoctorOutput>();
let _ = generator.subschema_for::<DoctorCheck>();
let _ = generator.subschema_for::<DoctorCheckCategory>();
let _ = generator.subschema_for::<DoctorCheckId>();
let _ = generator.subschema_for::<DoctorCheckStatus>();
let _ = generator.subschema_for::<DoctorRemediation>();
let _ = generator.subschema_for::<DoctorStatus>();
let _ = generator.subschema_for::<DoctorSummary>();
let _ = generator.subschema_for::<TypeAwareStatusOutput>();
let _ = generator.subschema_for::<fallow_types::trace_chain::ChainHop>();
let _ = generator.subschema_for::<fallow_types::trace_chain::UnresolvedCallee>();
let _ = generator.subschema_for::<fallow_types::trace_chain::UnresolvedReason>();
// Structured error envelope (`--format json` failure path, no `kind`).
let _ = generator.subschema_for::<fallow_output::ErrorOutput>();
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::<ReviewEnvelopeWireOutput<'static>>();
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::<ReviewEnvelopeSchema>();
let _ = generator.subschema_for::<ReviewProvider>();
let _ = generator.subschema_for::<ReviewCheckConclusion>();
let _ = generator.subschema_for::<ReviewReconcileOutput>();
let _ = generator.subschema_for::<ReviewReconcileSchema>();
}
/// Register the `fallow list --boundaries --format json` envelope.
fn register_list_boundaries_definitions(generator: &mut schemars::SchemaGenerator) {
let _ = generator.subschema_for::<ListBoundariesOutput>();
let _ = generator.subschema_for::<WorkspacesOutput>();
let _ = generator.subschema_for::<WorkspaceInfo>();
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>();
}
/// Merge derived definitions back into the hand-written schema document.
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);
}
augment_audit_wire_definitions(definitions)?;
rewrite_fallow_output_definition(definitions)?;
rewrite_document_root_one_of(&mut document)?;
Ok(document)
}
fn augment_audit_wire_definitions(definitions: &mut Map<String, Value>) -> Result<(), String> {
let attribution = definitions
.get_mut("AuditAttribution")
.and_then(Value::as_object_mut)
.ok_or_else(|| "derived schema missing AuditAttribution object".to_string())?;
let properties = attribution
.get_mut("properties")
.and_then(Value::as_object_mut)
.ok_or_else(|| "AuditAttribution schema missing properties".to_string())?;
for name in [
"styling_introduced",
"styling_inherited",
"duplication_demoted",
"diff_risk_introduced",
] {
properties.insert(name.to_string(), serde_json::json!({"type": "integer"}));
}
let required = attribution
.get_mut("required")
.and_then(Value::as_array_mut)
.ok_or_else(|| "AuditAttribution schema missing required list".to_string())?;
for name in [
"styling_introduced",
"styling_inherited",
"duplication_demoted",
"diff_risk_introduced",
] {
if !required.iter().any(|item| item.as_str() == Some(name)) {
required.push(Value::String(name.to_string()));
}
}
let styling = definitions
.get_mut("StylingFinding")
.and_then(Value::as_object_mut)
.ok_or_else(|| "derived schema missing StylingFinding object".to_string())?;
let properties = styling
.get_mut("properties")
.and_then(Value::as_object_mut)
.ok_or_else(|| "StylingFinding schema missing properties".to_string())?;
properties.insert(
"introduced".to_string(),
serde_json::json!({
"type": ["boolean", "null"],
"description": "Audit-mode flag indicating whether the finding is new versus the base snapshot."
}),
);
Ok(())
}
/// Hand-maintained root envelopes that still need top-level `oneOf` entries.
/// `ErrorOutput` is the `--format json` failure document: it carries no `kind`,
/// so it is a document-root branch discriminated by the `error: true` field.
const HAND_MAINTAINED_ROOT_ENVELOPES: &[&str] = &["ErrorOutput"];
const FALLOW_OUTPUT_VARIANTS: &[(&str, &[&str], &str)] = &[
(
"audit",
&["AuditOutput"],
"`fallow audit --format json`. Required `command: \"audit\"` singleton\nplus `verdict` and `summary`.",
),
(
"explain",
&["ExplainOutput"],
"`fallow explain <issue-type> --format json`. Required `id`, `name`,\n`rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.",
),
(
"inspect_target",
&["InspectOutput"],
"`fallow inspect --format json`. Required `target`, `identity`,\n`evidence`, and `warnings`; no `schema_version`.",
),
(
"trace",
&[
"ExportTrace",
"ClassMemberTrace",
"FileTrace",
"DependencyTrace",
"CloneTrace",
"ImpactClosureTrace",
"ImportPathTrace",
"SymbolChainTrace",
"SemanticSymbolTrace",
],
"Export, member, file, dependency, clone, impact-closure, import-path, or\nsymbol-level trace, including exact TypeScript checker evidence when\nrequested.",
),
(
"trace-error",
&["ErrorTrace"],
"`fallow trace-error [FILE|-] --format json`. Required `schema_version`\nsingleton plus `source`, `frames`, `counts`, and `reason`.",
),
(
"review-envelope",
&["ReviewEnvelopeOutput"],
"`fallow --format review-github` / `--format review-gitlab`. Required\n`body`, `comments`, `meta`; no `schema_version`.",
),
(
"review-reconcile",
&["ReviewReconcileOutput"],
"`fallow ci reconcile-review --format json`. Required `schema`\nsingleton plus `provider`, `comments`, and the various\n`*_fingerprints` arrays.",
),
(
"coverage-setup",
&["CoverageSetupOutput"],
"`fallow coverage setup --json`. Required `schema_version` singleton\nplus `framework_detected`, `members`, `commands`, `snippets`.",
),
(
"coverage-analyze",
&["CoverageAnalyzeOutput"],
"`fallow coverage analyze --format json`. Required current\n`schema_version: \"2\"` plus `version`, `elapsed_ms`, `runtime_coverage`.",
),
(
"list-boundaries",
&["ListBoundariesOutput"],
"`fallow list --boundaries --format json`. Required `boundaries`\nsub-object; no `schema_version`.",
),
(
"list-workspaces",
&["WorkspacesOutput"],
"`fallow workspaces --format json`. Required `workspace_count`,\n`workspaces`, and `workspace_diagnostics`; no `schema_version`.",
),
(
"health",
&["HealthOutput"],
"`fallow health --format json`.",
),
("dupes", &["DupesOutput"], "`fallow dupes --format json`."),
(
"dead-code-grouped",
&["CheckGroupedOutput"],
"`fallow dead-code --format json --group-by <mode>`. Required `grouped_by`\nplus a `groups` array.",
),
(
"architecture-grouped",
&["ArchitectureGroupedOutput"],
"`fallow architecture --format json --group-by <mode>`. The grouped\ndead-code body with its own `schema_version`.",
),
(
"impact",
&["ImpactReport", "SemanticSymbolImpact"],
"Project-wide change impact or exact TypeScript symbol impact.",
),
(
"impact-cross-repo",
&["CrossRepoImpactReport"],
"`fallow impact --all --format json`. Required `project_count`,\n`tracked_count`, `unreadable_count`, `totals`, `projects`; each `projects[]`\nentry embeds a per-project `report`. Independently versioned via\n`CrossRepoImpactSchemaVersion`.",
),
(
"security",
&["SecurityOutput", "SecuritySummaryOutput"],
"`fallow security --format json`. Full mode requires `security_findings`,\n`unresolved_edge_files`, and `unresolved_callee_sites`; summary mode requires\n`summary` and omits per-finding arrays.",
),
(
"security-survivors",
&["SecuritySurvivorsOutput"],
"`fallow security survivors --format json`. Required `survivors` and\n`needs_human_review`, keyed by `finding_id`.",
),
(
"security-blind-spots",
&["SecurityBlindSpotsOutput"],
"`fallow security blind-spots --format json`. Required `summary` and\ngrouped unresolved-callee diagnostics.",
),
(
"dead-code",
&["CheckOutput"],
"`fallow dead-code --format json`.\nRequired `total_issues` plus `summary: CheckSummary`.",
),
(
"architecture",
&["ArchitectureOutput"],
"`fallow architecture --format json`. The dead-code body with its own\n`schema_version`: import cycles, boundary violations and rule-pack policy\nviolations.",
),
(
"combined",
&["CombinedOutput"],
"Bare `fallow --format json` (combined dead-code + dupes + health).\nRequired `schema_version`, `version`, and `elapsed_ms`, with optional\n`check`, `dupes`, and `health` subreports.",
),
(
"feature-flags",
&["FeatureFlagsOutput"],
"`fallow flags --format json`. Required `findings`, `summary`, and\n`meta` for feature-flag discovery.",
),
(
"audit-brief",
&["ReviewBriefWireOutput"],
"`fallow audit --brief --format json` (alias `fallow review`). Required\n`schema_version`, `version`, `command: \"audit-brief\"`, audit metadata,\n`triage`, and `graph_facts`, with optional analysis subreports. Independently\nversioned via `ReviewBriefSchemaVersion`; always emitted with exit 0.",
),
(
"decision-surface",
&["DecisionSurfaceOutput"],
"`fallow decision-surface --format json`. Required `schema_version`\nand `decisions`, derived from deterministic review signals.",
),
(
"review-walkthrough-guide",
&["WalkthroughGuide"],
"`fallow review --walkthrough-guide --format json`. The digest +\nschema the agent fetches: brief + decision surface, review direction, the\ngraph-snapshot pin, and the embedded agent schema. Graph-derived only\n(injection-resistant). Always exit 0.",
),
(
"review-walkthrough-validation",
&["WalkthroughValidation"],
"`fallow review --walkthrough-file --format json`. Post-validation\nof an agent's judgment JSON against the live graph: `accepted` (anchored,\nframing fenced), `rejected` (unanchored), and a `stale` flag. The verifier\nis the graph, not a second model. Always exit 0.",
),
(
"suppression-inventory",
&["SuppressionInventoryOutput"],
"`fallow suppressions --format json`. Required `schema_version: \"1\"`\nsingleton plus `summary` and per-file `files` listings. Read-only\nsuppression inventory; always emitted with exit 0.",
),
(
"doctor",
&["DoctorOutput"],
"`fallow doctor --format json`. Deterministic, read-only project readiness checks.",
),
(
"type-aware-status",
&["TypeAwareStatusOutput"],
"`fallow type-aware status --format json`. Companion discovery and\nprotocol compatibility without exposing an absolute host path.",
),
(
"similar-code",
&["SimilarCodeOutput"],
"`fallow similar-code --format json`. Independently versioned, local-only\nunverified semantic candidates with reproducible generation provenance and\ntyped completion accounting.",
),
(
"similar-code-inspect",
&["SimilarCodeInspectOutput"],
"`fallow similar-code inspect --format json`. Bounded source-grounded\npacket for one immutable candidate. Read-only and independently versioned.",
),
(
"similar-code-review",
&["SimilarCodeReviewOutput"],
"`fallow similar-code review --format json`. Deterministically joins raw\ncandidates with a separate verdict document without mutating candidate truth.",
),
(
"similar-code-status",
&["SimilarCodeStatusOutput"],
"`fallow similar-code status --format json` and successful setup output.\nLocal-provider and pinned-model readiness.",
),
(
"similar-code-cache-clear",
&["SimilarCodeCacheClearOutput"],
"`fallow similar-code cache clear --format json`. Project-local vector-cache\nclear result.",
),
(
"mutation",
&["MutationReport"],
"`fallow mutation --format json`. Independently versioned mutation testing\nresults: one status per mutant, the score, and the completion status. No\nsource text: the `change` of a mutant uses a fixed token vocabulary.",
),
(
"mutation-plan",
&["MutationPlanOutput"],
"`fallow mutation --plan --format json`. The static mutation plan: ranked\nmutants, prune reasons and file digests. Runs no project code.",
),
(
"mutation-inspect",
&["MutationInspectOutput"],
"`fallow mutation inspect --format json`. One mutant of a saved report against\nthe current file, with a fresh or stale status. Runs no project code.",
),
];
/// Schemars emits internally tagged newtype variants as `$ref` plus sibling
/// constraints. The schema declares draft-07, where `$ref` siblings are ignored
/// by many validators and code generators. Rewrite the root union into explicit
/// intersections so the `kind` discriminator is part of the public contract.
fn rewrite_fallow_output_definition(definitions: &mut Map<String, Value>) -> Result<(), String> {
let output = definitions
.get_mut("FallowOutput")
.and_then(Value::as_object_mut)
.ok_or_else(|| "derived schema has no object definition for `FallowOutput`".to_string())?;
let one_of = FALLOW_OUTPUT_VARIANTS
.iter()
.map(|(kind, definitions, description)| {
fallow_output_variant_schema(kind, definitions, description)
})
.collect();
output.remove("anyOf");
output.insert("oneOf".to_string(), Value::Array(one_of));
Ok(())
}
fn fallow_output_variant_schema(kind: &str, definitions: &[&str], description: &str) -> Value {
serde_json::json!({
"description": description,
"allOf": [
fallow_output_payload_schema(definitions),
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": kind
}
},
"required": ["kind"]
}
]
})
}
fn fallow_output_payload_schema(definitions: &[&str]) -> Value {
if definitions.len() == 1 {
serde_json::json!({ "$ref": format!("#/definitions/{}", definitions[0]) })
} else {
serde_json::json!({
"oneOf": definitions
.iter()
.map(|definition| serde_json::json!({ "$ref": format!("#/definitions/{definition}") }))
.collect::<Vec<_>>()
})
}
}
fn fallow_output_kind_list() -> String {
FALLOW_OUTPUT_VARIANTS
.iter()
.map(|(kind, _, _)| format!("`{kind}`"))
.collect::<Vec<_>>()
.join(", ")
}
/// Drive the document-root `oneOf` from the typed `FallowOutput` enum.
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(format!(
"Schemas for the JSON output of fallow commands. Object-shaped \
envelopes covered by the `FallowOutput` contract carry a top-level \
`kind` discriminator. Current kind values: {}. Consumers should \
branch on `kind` instead of probing for unique field presence. \
`CodeClimateOutput` is a bare JSON array (per the Code Climate / \
GitLab Code Quality spec) and stays a sibling root branch \
discriminated by checking whether the document root is an array. \
`ErrorOutput` is the `--format json` failure document, emitted on \
stdout with a non-zero exit; it carries no `kind` and is \
discriminated by the `error: true` field.",
fallow_output_kind_list()
)),
);
Ok(())
}
/// Add the `actions` array and optional `introduced` flag to a finding schema.
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(())
}
/// Normalize derived schemas to match the committed schema layout.
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 {
//! Drift gate for the Rust → `docs/output-schema.json` chain.
//!
//! The drift gate compares normalized derived schemas to the committed file.
use super::*;
const BACKWARDS_COMPATIBILITY_DOC: &str =
include_str!("../../../../docs/backwards-compatibility.md");
/// The MCP server serves the committed snapshot schema without the
/// `schema` feature, so the committed copy must equal the derive. The
/// serialized forms are compared, because `Value` equality ignores key order.
#[test]
fn committed_similar_code_snapshot_schema_matches_the_derive() {
assert_eq!(
fallow_api::schemas::similar_code_snapshot_schema().to_string(),
fallow_output::SimilarCodeCandidateSnapshot::json_schema().to_string(),
"crates/api/similar-code-snapshot-schema.json is stale. Regenerate it with \
`npm run generate:contracts`."
);
}
/// Recursively normalize a JSON value for drift comparison.
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`")
}
/// Build the normalized derived schema map used by the drift checks.
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);
}
augment_audit_wire_definitions(&mut out)
.expect("audit wire augmentation must succeed in drift checks");
rewrite_fallow_output_definition(&mut out)
.expect("FallowOutput postprocess must succeed in drift checks");
out
}
/// Ensure every registered name resolves to a derived schema.
#[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 envelope_schema_versions_are_exact_and_independent() {
let derived = derived_definitions_for_drift();
let expected = [
("AuditSchemaVersion", fallow_output::AUDIT_SCHEMA_VERSION),
("CheckSchemaVersion", fallow_output::CHECK_SCHEMA_VERSION),
(
"ArchitectureSchemaVersion",
fallow_output::ARCHITECTURE_SCHEMA_VERSION,
),
(
"CombinedSchemaVersion",
fallow_output::COMBINED_SCHEMA_VERSION,
),
(
"FeatureFlagsSchemaVersion",
fallow_output::FEATURE_FLAGS_SCHEMA_VERSION,
),
("HealthSchemaVersion", fallow_output::HEALTH_SCHEMA_VERSION),
("DoctorSchemaVersion", fallow_output::DOCTOR_SCHEMA_VERSION),
(
"ReviewBriefSchemaVersion",
fallow_output::REVIEW_BRIEF_SCHEMA_VERSION,
),
(
"TypeAwareStatusSchemaVersion",
fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
),
];
for (name, version) in expected {
assert_eq!(
derived.get(name).and_then(|schema| schema.get("const")),
Some(&Value::from(version)),
"{name} must pin its owning envelope version"
);
}
assert_eq!(
derived
.get("DupesSchemaVersion")
.and_then(|schema| schema.get("enum")),
Some(&serde_json::json!([
fallow_output::DUPES_PROGRAMMATIC_SCHEMA_VERSION,
fallow_output::DUPES_SCHEMA_VERSION,
])),
"DupesSchemaVersion must cover its CLI and programmatic lineages"
);
let current_string_versions = [
("CoverageAnalyzeSchemaVersion", "2"),
("CrossRepoImpactSchemaVersion", "2"),
("ImpactReportSchemaVersion", "2"),
("SecuritySchemaVersion", "8"),
];
for (name, version) in current_string_versions {
assert!(
derived
.get(name)
.and_then(|schema| schema.get("oneOf"))
.and_then(Value::as_array)
.is_some_and(|variants| variants.iter().any(|variant| {
variant.get("const").and_then(Value::as_str) == Some(version)
})),
"{name} must include current version {version}"
);
}
let envelope_refs = [
("AuditOutput", "AuditSchemaVersion"),
("CheckGroupedOutput", "CheckSchemaVersion"),
("CheckOutput", "CheckSchemaVersion"),
("ArchitectureGroupedOutput", "ArchitectureSchemaVersion"),
("ArchitectureOutput", "ArchitectureSchemaVersion"),
("CombinedOutput", "CombinedSchemaVersion"),
("CoverageAnalyzeOutput", "CoverageAnalyzeSchemaVersion"),
("CrossRepoImpactReport", "CrossRepoImpactSchemaVersion"),
("DupesOutput", "DupesSchemaVersion"),
("FeatureFlagsOutput", "FeatureFlagsSchemaVersion"),
("HealthOutput", "HealthSchemaVersion"),
("ImpactReport", "ImpactReportSchemaVersion"),
("ReviewBriefWireOutput", "ReviewBriefSchemaVersion"),
("SecurityOutput", "SecuritySchemaVersion"),
("SecuritySummaryOutput", "SecuritySchemaVersion"),
("DoctorOutput", "DoctorSchemaVersion"),
("TypeAwareStatusOutput", "TypeAwareStatusSchemaVersion"),
];
for (envelope, version_type) in envelope_refs {
let expected_ref = format!("#/definitions/{version_type}");
assert_eq!(
derived
.get(envelope)
.and_then(|schema| schema.pointer("/properties/schema_version/$ref"))
.and_then(Value::as_str),
Some(expected_ref.as_str()),
"{envelope} must reference its owning version type"
);
}
assert!(
derived
.get("SchemaVersion")
.and_then(|schema| schema.get("const"))
.is_none(),
"the shared SchemaVersion type spans independently versioned envelopes"
);
}
/// Each `FallowOutput` variant and the type that it wraps.
const REGISTERED_VARIANTS: &[(&str, &str)] = &[
("Audit", "AuditOutput"),
("Explain", "ExplainOutput"),
("Inspect", "InspectOutput"),
("Trace", "TraceOutput"),
("TraceError", "ErrorTrace"),
("ReviewEnvelope", "ReviewEnvelopeOutput"),
("ReviewReconcile", "ReviewReconcileOutput"),
("CoverageSetup", "CoverageSetupOutput"),
("CoverageAnalyze", "CoverageAnalyzeOutput"),
("ListBoundaries", "ListBoundariesOutput"),
("Workspaces", "WorkspacesOutput"),
("Health", "HealthOutput"),
("Dupes", "DupesOutput"),
("CheckGrouped", "CheckGroupedOutput"),
("ArchitectureGrouped", "ArchitectureGroupedOutput"),
("Impact", "ImpactOutput"),
("ImpactCrossRepo", "CrossRepoImpactReport"),
("Security", "SecurityOutput"),
("SecuritySummary", "SecuritySummaryOutput"),
("SecuritySurvivors", "SecuritySurvivorsOutput"),
("SecurityBlindSpots", "SecurityBlindSpotsOutput"),
("Check", "CheckOutput"),
("Architecture", "ArchitectureOutput"),
("Combined", "CombinedOutput"),
("FeatureFlags", "FeatureFlagsOutput"),
("AuditBrief", "ReviewBriefWireOutput"),
("DecisionSurface", "DecisionSurfaceOutput"),
("WalkthroughGuide", "WalkthroughGuide"),
("WalkthroughValidation", "WalkthroughValidation"),
("SuppressionInventory", "SuppressionInventoryOutput"),
("Doctor", "DoctorOutput"),
("TypeAwareStatus", "TypeAwareStatusOutput"),
("SimilarCode", "SimilarCodeOutput"),
("SimilarCodeInspect", "SimilarCodeInspectOutput"),
("SimilarCodeReview", "SimilarCodeReviewOutput"),
("SimilarCodeStatus", "SimilarCodeStatusOutput"),
("SimilarCodeCacheClear", "SimilarCodeCacheClearOutput"),
("Mutation", "MutationReport"),
("MutationPlan", "MutationPlanOutput"),
("MutationInspect", "MutationInspectOutput"),
];
#[expect(
dead_code,
reason = "compile-time exhaustiveness guard for the REGISTERED_VARIANTS list; never called at runtime"
)]
fn variant_count_is_locked(value: &FallowOutput) -> &'static str {
match value {
FallowOutput::Audit(_) => "Audit",
FallowOutput::Explain(_) => "Explain",
FallowOutput::Inspect(_) => "Inspect",
FallowOutput::Trace(_) => "Trace",
FallowOutput::TraceError(_) => "TraceError",
FallowOutput::ReviewEnvelope(_) => "ReviewEnvelope",
FallowOutput::ReviewReconcile(_) => "ReviewReconcile",
FallowOutput::CoverageSetup(_) => "CoverageSetup",
FallowOutput::CoverageAnalyze(_) => "CoverageAnalyze",
FallowOutput::ListBoundaries(_) => "ListBoundaries",
FallowOutput::Workspaces(_) => "Workspaces",
FallowOutput::Health(_) => "Health",
FallowOutput::Dupes(_) => "Dupes",
FallowOutput::CheckGrouped(_) => "CheckGrouped",
FallowOutput::ArchitectureGrouped(_) => "ArchitectureGrouped",
FallowOutput::Impact(_) => "Impact",
FallowOutput::ImpactCrossRepo(_) => "ImpactCrossRepo",
FallowOutput::SecuritySummary(_) => "SecuritySummary",
FallowOutput::Security(_) => "Security",
FallowOutput::SecuritySurvivors(_) => "SecuritySurvivors",
FallowOutput::SecurityBlindSpots(_) => "SecurityBlindSpots",
FallowOutput::Check(_) => "Check",
FallowOutput::Architecture(_) => "Architecture",
FallowOutput::Combined(_) => "Combined",
FallowOutput::FeatureFlags(_) => "FeatureFlags",
FallowOutput::AuditBrief(_) => "AuditBrief",
FallowOutput::DecisionSurface(_) => "DecisionSurface",
FallowOutput::WalkthroughGuide(_) => "WalkthroughGuide",
FallowOutput::WalkthroughValidation(_) => "WalkthroughValidation",
FallowOutput::SuppressionInventory(_) => "SuppressionInventory",
FallowOutput::Doctor(_) => "Doctor",
FallowOutput::TypeAwareStatus(_) => "TypeAwareStatus",
FallowOutput::SimilarCode(_) => "SimilarCode",
FallowOutput::SimilarCodeInspect(_) => "SimilarCodeInspect",
FallowOutput::SimilarCodeReview(_) => "SimilarCodeReview",
FallowOutput::SimilarCodeStatus(_) => "SimilarCodeStatus",
FallowOutput::SimilarCodeCacheClear(_) => "SimilarCodeCacheClear",
FallowOutput::Mutation(_) => "Mutation",
FallowOutput::MutationPlan(_) => "MutationPlan",
FallowOutput::MutationInspect(_) => "MutationInspect",
}
}
/// Ensure every `FallowOutput` variant stays registered.
#[test]
fn every_fallow_output_variant_is_registered_in_derived_definitions() {
let derived = derived_definitions();
let mut missing: Vec<String> = Vec::new();
for (variant, inner) in REGISTERED_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"),
);
}
/// Ensure every finding type is registered before augmentation runs.
#[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."
);
}
}
/// Collect every `kind` constant that the derived `FallowOutput` enum
/// schema declares, before the `oneOf` rewrite replaces it with the
/// `FALLOW_OUTPUT_VARIANTS` table.
fn derived_fallow_output_kinds() -> std::collections::BTreeSet<String> {
fn collect(value: &Value, out: &mut std::collections::BTreeSet<String>) {
match value {
Value::Object(map) => {
if let Some(kind) = map
.get("properties")
.and_then(|properties| properties.get("kind"))
{
if let Some(name) = kind.get("const").and_then(Value::as_str) {
out.insert(name.to_string());
}
for name in kind
.get("enum")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
out.insert(name.to_string());
}
}
for child in map.values() {
collect(child, out);
}
}
Value::Array(items) => {
for item in items {
collect(item, out);
}
}
_ => {}
}
}
let derived = derived_definitions();
let output = derived
.get("FallowOutput")
.expect("derived FallowOutput schema");
let mut kinds = std::collections::BTreeSet::new();
collect(output, &mut kinds);
kinds
}
/// The typed root enum and the schema kind table must name the same set of
/// kinds. A variant without a table row emits a kind that the published
/// schema rejects; a table row without a variant documents a kind that no
/// typed root can produce.
#[test]
fn fallow_output_tags_match_the_kind_table() {
let enum_kinds = derived_fallow_output_kinds();
let table_kinds: std::collections::BTreeSet<String> = FALLOW_OUTPUT_VARIANTS
.iter()
.map(|(kind, _, _)| (*kind).to_string())
.collect();
assert!(
!enum_kinds.is_empty(),
"the derived FallowOutput schema declares no kind constants"
);
let only_enum: Vec<_> = enum_kinds.difference(&table_kinds).collect();
let only_table: Vec<_> = table_kinds.difference(&enum_kinds).collect();
assert!(
only_enum.is_empty() && only_table.is_empty(),
"FallowOutput variants and FALLOW_OUTPUT_VARIANTS disagree.\n \
only in the enum: {only_enum:?}\n only in the table: {only_table:?}"
);
}
/// Every analysis command kind, flat and grouped, is a branch of the
/// published root schema.
#[test]
fn every_command_envelope_kind_is_a_schema_kind() {
let table: std::collections::BTreeSet<&str> = FALLOW_OUTPUT_VARIANTS
.iter()
.map(|(kind, _, _)| *kind)
.collect();
for row in fallow_types::command_surfaces::COMMAND_ENVELOPES {
for kind in std::iter::once(row.kind).chain(row.grouped_kind) {
assert!(
table.contains(kind),
"`fallow {}` writes kind `{kind}`, which FALLOW_OUTPUT_VARIANTS lacks",
row.command
);
}
}
}
/// `fallow architecture` serializes the dead-code structs, so its schema
/// definitions must keep the dead-code body: the same properties, the same
/// required list, and the same property schemas except `schema_version`.
#[test]
fn architecture_envelopes_share_the_dead_code_body() {
let derived = derived_definitions_for_drift();
for (architecture, dead_code, _, _) in ARCHITECTURE_ENVELOPE_DEFINITIONS {
let architecture_schema = derived
.get(*architecture)
.unwrap_or_else(|| panic!("missing derived {architecture}"));
let description = architecture_schema
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
description.contains("`fallow architecture")
&& !description.contains("dead-code --"),
"{architecture} must describe the architecture command, not dead-code: {description}"
);
let dead_code_schema = derived
.get(*dead_code)
.unwrap_or_else(|| panic!("missing derived {dead_code}"));
assert_eq!(
architecture_schema.get("required"),
dead_code_schema.get("required"),
"{architecture} and {dead_code} must require the same fields"
);
let architecture_properties = architecture_schema
.get("properties")
.and_then(Value::as_object)
.expect("architecture properties");
let dead_code_properties = dead_code_schema
.get("properties")
.and_then(Value::as_object)
.expect("dead-code properties");
let architecture_keys: Vec<&String> = architecture_properties.keys().collect();
let dead_code_keys: Vec<&String> = dead_code_properties.keys().collect();
assert_eq!(
architecture_keys, dead_code_keys,
"{architecture} and {dead_code} must have the same properties"
);
for (name, schema) in dead_code_properties {
if name == "schema_version" {
assert_eq!(
architecture_properties[name]
.get("$ref")
.and_then(Value::as_str),
Some("#/definitions/ArchitectureSchemaVersion"),
"{architecture}.schema_version must reference its own version"
);
continue;
}
assert_eq!(
&architecture_properties[name], schema,
"{architecture}.{name} must equal {dead_code}.{name}"
);
}
}
}
#[test]
fn backwards_compatibility_kind_list_matches_output_schema() {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
let schema_kinds = fallow_output_kind_values_from_schema(&document);
let docs_kinds = documented_fallow_output_kind_values(BACKWARDS_COMPATIBILITY_DOC);
assert_eq!(
docs_kinds, schema_kinds,
"docs/backwards-compatibility.md factual `kind` list must match docs/output-schema.json. Update the marked list from the schema manifest instead of editing protocol prose by hand."
);
}
/// Verify augmentation adds the expected `actions` / `introduced` fields.
#[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",
);
}
}
}
/// Verify derived and committed property/required sets stay in sync.
#[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 - "),
);
}
/// Verify property-level `$ref` targets stay aligned.
#[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 - "),
);
}
/// Extract the canonical `$ref` target from a property value.
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
}
/// Verify every emitted `$ref` resolves in the merged schema.
#[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(", "),
);
}
/// Verify the canonicalized committed schema matches the derived schema.
#[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"),
);
}
/// Regression for issue #394: property names like `format` must survive normalization.
#[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"
);
}
/// Mirror the `format`-property regression on the drift-test side.
#[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"
);
}
/// Ensure hand-maintained root envelopes remain referenced from the root union.
#[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<_>>(),
);
}
}
/// Ensure the root union keeps `FallowOutput` and `CodeClimateOutput`.
#[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<_>>(),
);
}
fn fallow_output_kind_values_from_schema(document: &Value) -> Vec<String> {
document
.pointer("/definitions/FallowOutput/oneOf")
.and_then(Value::as_array)
.expect("FallowOutput must expose oneOf variants")
.iter()
.map(|variant| {
variant
.pointer("/allOf/1/properties/kind/const")
.and_then(Value::as_str)
.expect("FallowOutput variant must carry a kind const")
.to_string()
})
.collect()
}
fn documented_fallow_output_kind_values(doc: &str) -> Vec<String> {
let start = "<!-- fallow-output-kind-list:start -->";
let end = "<!-- fallow-output-kind-list:end -->";
let section = doc
.split_once(start)
.and_then(|(_, rest)| rest.split_once(end).map(|(section, _)| section))
.expect("backwards compatibility doc must contain fallow output kind markers");
section
.split('`')
.enumerate()
.filter_map(|(index, value)| {
if index % 2 == 1 {
Some(value.to_string())
} else {
None
}
})
.collect()
}
#[test]
fn fallow_output_kind_variants_use_draft07_all_of_refs() {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
let variants = document
.pointer("/definitions/FallowOutput/oneOf")
.and_then(Value::as_array)
.expect("FallowOutput must expose oneOf variants");
for variant in variants {
assert!(
variant.get("$ref").is_none(),
"FallowOutput variant must not use `$ref` siblings; draft-07 validators ignore sibling constraints: {variant}"
);
let all_of = variant
.get("allOf")
.and_then(Value::as_array)
.expect("FallowOutput variant must use allOf to combine the payload ref with the root kind discriminator");
assert_eq!(
all_of.len(),
2,
"FallowOutput variant allOf should contain exactly payload ref + kind discriminator"
);
let payload_branch = &all_of[0];
let has_payload_ref = payload_branch.get("$ref").is_some()
|| payload_branch
.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|branches| {
!branches.is_empty()
&& branches.iter().all(|branch| branch.get("$ref").is_some())
});
assert!(
has_payload_ref,
"first allOf branch should be a payload ref or a oneOf of payload refs: {variant}"
);
assert!(
all_of[1]
.pointer("/properties/kind/const")
.and_then(Value::as_str)
.is_some(),
"second allOf branch should require a literal kind discriminator: {variant}"
);
}
}
#[test]
fn security_kind_accepts_full_and_summary_payloads() {
let document: Value = serde_json::from_str(COMMITTED_SCHEMA)
.expect("committed docs/output-schema.json must parse");
let variants = document
.pointer("/definitions/FallowOutput/oneOf")
.and_then(Value::as_array)
.expect("FallowOutput must expose oneOf variants");
let security_variant = variants
.iter()
.find(|variant| {
variant
.pointer("/allOf/1/properties/kind/const")
.and_then(Value::as_str)
== Some("security")
})
.expect("FallowOutput must include the security kind variant");
let payload_refs: Vec<&str> = security_variant
.pointer("/allOf/0/oneOf")
.and_then(Value::as_array)
.expect("security kind payload must be a oneOf")
.iter()
.map(|branch| {
branch
.get("$ref")
.and_then(Value::as_str)
.expect("security payload branch must be a ref")
})
.collect();
assert!(
payload_refs.contains(&"#/definitions/SecurityOutput"),
"security kind must accept full security output"
);
assert!(
payload_refs.contains(&"#/definitions/SecuritySummaryOutput"),
"security kind must accept summary security output"
);
}
}