# Validation contracts for purpose.ncl files. # Applied AFTER the schema contract — enforces semantic invariants beyond structural typing. # Pattern mirrors adrs/adr-constraints.ncl: separate file, pure contracts, named exports. let _non_empty_objectives = std.contract.custom ( fun label => fun value => if std.array.length value.content_objectives == 0 then 'Error { message = "purpose.ncl: content_objectives must not be empty — a purpose with no objectives is passive aspiration, not an executable intent" } else 'Ok value ) in let _at_least_one_hard_criterion = std.contract.custom ( fun label => fun value => let hard = value.validation_criteria |> std.array.filter (fun vc => vc.severity == 'Hard) in if std.array.length hard == 0 then 'Error { message = "purpose.ncl: at least one ValidationCriteria must have severity='Hard — a purpose with only Soft criteria has no real publication gate" } else 'Ok value ) in let _at_least_one_hard_seo_criterion = std.contract.custom ( fun label => fun value => let hard = value.seo_criteria |> std.array.filter (fun sc => sc.severity == 'Hard) in if std.array.length hard == 0 then 'Error { message = "purpose.ncl: at least one SeoCriterion must have severity='Hard — SEO without a hard criterion is aspirational, not enforceable" } else 'Ok value ) in let _objective_audience_coverage = std.contract.custom ( fun label => fun value => let declared = value.declaration.primary_audience in let covered = value.content_objectives |> std.array.flat_map (fun obj => obj.target_audience) in let uncovered = declared |> std.array.filter (fun aud => !(covered |> std.array.any (fun cov => cov == aud)) ) |> std.array.map (fun aud => std.to_string aud) in if std.array.length uncovered > 0 then 'Error { message = "purpose.ncl: audiences declared in declaration.primary_audience but not covered by any ContentObjective.target_audience: %{std.string.join ", " uncovered}" } else 'Ok value ) in # Cross-field: every ValidationCriteria.applies_to id must reference an existing ContentObjective.id let _valid_applies_to = std.contract.custom ( fun label => fun value => let obj_ids = value.content_objectives |> std.array.map (fun o => o.id) in let bad_refs = value.validation_criteria |> std.array.flat_map (fun vc => vc.applies_to |> std.array.filter (fun ref_id => !(obj_ids |> std.array.any (fun oid => oid == ref_id)) ) |> std.array.map (fun bad_id => "criterion '%{vc.id}' applies_to unknown objective '%{bad_id}'" ) ) in if std.array.length bad_refs > 0 then std.contract.blame_with_message "purpose.ncl validation_criteria has invalid applies_to: %{std.string.join ", " bad_refs}" label else 'Ok value ) in # Cross-field: every SeoCriterion.applies_to id must reference an existing ContentObjective.id let _seo_applies_to = std.contract.custom ( fun label => fun value => let obj_ids = value.content_objectives |> std.array.map (fun o => o.id) in let bad_refs = value.seo_criteria |> std.array.flat_map (fun sc => sc.applies_to |> std.array.filter (fun ref_id => !(obj_ids |> std.array.any (fun oid => oid == ref_id)) ) |> std.array.map (fun bad_id => "seo criterion '%{sc.id}' applies_to unknown objective '%{bad_id}'" ) ) in if std.array.length bad_refs > 0 then std.contract.blame_with_message "purpose.ncl seo_criteria has invalid applies_to: %{std.string.join ", " bad_refs}" label else 'Ok value ) in { NonEmptyObjectives = _non_empty_objectives, AtLeastOneHardCriterion = _at_least_one_hard_criterion, AtLeastOneHardSeoCriterion = _at_least_one_hard_seo_criterion, ObjectiveAudienceCoverage = _objective_audience_coverage, ValidAppliesTo = _valid_applies_to, SeoAppliesTo = _seo_applies_to, }