website-htmx-rustelo/.ontoref/ontology/purpose-constraints.ncl
Jesús Pérez 3ccd15a14b init repo
2026-07-10 03:33:46 +01:00

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,
}