use crate::diagnostics::{LintDiagnostic, RototoRuleId, SemanticEntity};
use super::super::engine::LintContext;
use super::super::index::*;
use super::super::stages::push_project_diagnostic;
const OPERATIONS: &[&str] = &["add", "update", "delete"];
pub(super) fn lint_governance_shape(ctx: &mut LintContext) {
let Some(governance) = &ctx.index.governance else {
return;
};
let mut diagnostics = Vec::new();
let target = || SemanticEntity::Governance;
for kind in &governance.unknown_kinds {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceShape,
target(),
kind.location.clone(),
format!(
"governance blocks are keyed [<kind>.<id>] with kind one of catalog, list, \
variable, evaluation_context, or layer, or the top-level [defaults] block: {}",
kind.value
),
);
}
for block in &governance.blocks {
let label = if block.kind == "defaults" {
"defaults".to_owned()
} else {
format!("{}.{}", block.kind, block.id)
};
for key in &block.unknown_keys {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceShape,
target(),
key.location.clone(),
format!(
"governance block {label} declares an unknown key: {}",
key.value
),
);
}
if block.kind != "defaults" && !governed_target_exists(ctx, &block.kind, &block.id) {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceUnknownTarget,
target(),
block.location.clone(),
format!("governance block names an unknown target: {label}"),
);
}
let mut allowed = Vec::new();
for (key, list) in [
("allowed_operations", &block.allowed_operations),
("denied_operations", &block.denied_operations),
] {
match list {
None => {}
Some(ProjectField::Present(operations)) => {
for operation in &operations.value {
if !OPERATIONS.contains(&operation.value.as_str()) {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceShape,
target(),
operation.location.clone(),
format!(
"governance operations are add, update, and delete: {}",
operation.value
),
);
} else if key == "allowed_operations" {
allowed.push(operation.value.as_str());
}
}
}
Some(field) => push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceShape,
target(),
field.location(),
format!("governance {key} must be an array of operation names"),
),
}
}
for (operation, policy) in [
("update", &block.update_policy),
("delete", &block.delete_policy),
] {
let Some(policy) = policy else {
if operation == "update" && allowed.contains(&"update") && block.kind == "catalog" {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceUnscopedUpdate,
target(),
block.location.clone(),
format!("governance block {label} grants update without allowed_fields"),
);
}
continue;
};
if !allowed.contains(&operation) {
push_project_diagnostic(
&mut diagnostics,
RototoRuleId::GovernanceShape,
target(),
policy.location.clone(),
format!(
"governance block {label} declares a {operation}_policy but does not \
allow {operation}; the policy is dead"
),
);
}
lint_policy(&mut diagnostics, ctx, block, &label, operation, policy);
}
}
ctx.diagnostics.extend(diagnostics);
}
fn lint_policy(
diagnostics: &mut Vec<LintDiagnostic>,
ctx: &LintContext,
block: &GovernanceBlockNode,
label: &str,
operation: &str,
policy: &GovernancePolicyNode,
) {
let target = SemanticEntity::Governance;
for (key, list) in [
("allowed_entries", &policy.allowed_entries),
("denied_entries", &policy.denied_entries),
("allowed_fields", &policy.allowed_fields),
("denied_fields", &policy.denied_fields),
] {
match list {
None => {}
Some(ProjectField::Present(items)) => {
if items.value.is_empty() && key.starts_with("allowed") {
push_project_diagnostic(
diagnostics,
RototoRuleId::GovernanceShape,
target.clone(),
items.location.clone(),
format!(
"governance {operation}_policy {key} is empty; \"listed nothing\" \
reads two ways, so name targets or drop the list"
),
);
}
if key.ends_with("_fields") && operation == "delete" {
push_project_diagnostic(
diagnostics,
RototoRuleId::GovernanceShape,
target.clone(),
items.location.clone(),
format!(
"governance delete_policy has no field scope; {key} does not apply"
),
);
}
if key.ends_with("_fields") && block.kind == "catalog" && !block.id.is_empty() {
for item in &items.value {
if !field_pattern_matches_schema(ctx, &block.id, &item.value) {
push_project_diagnostic(
diagnostics,
RototoRuleId::GovernanceShape,
target.clone(),
item.location.clone(),
format!(
"governance {operation}_policy names a field the {} schema \
does not declare: {}",
label, item.value
),
);
}
}
}
}
Some(field) => push_project_diagnostic(
diagnostics,
RototoRuleId::GovernanceShape,
target.clone(),
field.location(),
format!("governance {operation}_policy {key} must be an array of strings"),
),
}
}
}
fn governed_target_exists(ctx: &LintContext, kind: &str, id: &str) -> bool {
match kind {
"catalog" => ctx.index.catalogs.contains_key(id),
"list" => ctx.index.lists.contains_key(id),
"variable" => ctx.index.variables.contains_key(id),
"evaluation_context" => ctx.index.evaluation_contexts.contains_key(id),
"layer" => ctx.index.layers.contains_key(id),
_ => true,
}
}
fn field_pattern_matches_schema(ctx: &LintContext, catalog: &str, pattern: &str) -> bool {
let Some(properties) = ctx
.index
.catalogs
.get(catalog)
.and_then(|catalog| catalog.json.as_ref())
.and_then(|schema| schema.get("properties"))
.and_then(|properties| properties.as_object())
else {
return true;
};
properties.keys().any(|field| glob_match(pattern, field))
}
pub(crate) fn glob_match(pattern: &str, value: &str) -> bool {
let mut parts = pattern.split('*');
let Some(first) = parts.next() else {
return pattern == value;
};
if !value.starts_with(first) {
return false;
}
let mut position = first.len();
let mut last: Option<&str> = None;
for part in parts {
last = Some(part);
if part.is_empty() {
continue;
}
match value[position..].find(part) {
Some(found) => position = position + found + part.len(),
None => return false,
}
}
match last {
None => pattern == value,
Some(last) => last.is_empty() || value.ends_with(last),
}
}
#[cfg(test)]
mod tests {
use super::glob_match;
#[test]
fn glob_matches_literals_stars_and_mixed_patterns() {
assert!(glob_match("welcome_hero", "welcome_hero"));
assert!(!glob_match("welcome_hero", "welcome"));
assert!(glob_match("*", "anything"));
assert!(glob_match("acme_*", "acme_enterprise"));
assert!(!glob_match("acme_*", "base_plan"));
assert!(glob_match("*_hero", "welcome_hero"));
assert!(!glob_match("*_hero", "welcome_banner"));
assert!(glob_match("a*c", "abc"));
assert!(!glob_match("a*c", "abd"));
}
}