120 lines
4.3 KiB
Text
120 lines
4.3 KiB
Text
# 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,
|
|
}
|