feat(sealed): sealed visibility + ineffective-suppress warning (§11 tail M4)
- `pic triggers ls --group` / `routes ls --group` gain a `sealed` column
(threaded through TriggerTemplateInfo/RouteTemplateInfo + the two DTOs).
- dangling_suppress_warnings now also warns when a suppress reference matches
only SEALED inherited templates ("... is sealed — the suppression has no
effect") via `bool_or(NOT sealed)` per handler/path — making the mandatory
guarantee observable at plan/apply time, alongside the existing typo guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -530,6 +530,8 @@ pub struct TriggerTemplateInfo {
|
|||||||
pub target: String,
|
pub target: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
|
pub sealed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the read-only §11 tail route-template report (`pic routes ls
|
/// One row of the read-only §11 tail route-template report (`pic routes ls
|
||||||
@@ -545,6 +547,8 @@ pub struct RouteTemplateInfo {
|
|||||||
pub script: String,
|
pub script: String,
|
||||||
pub dispatch: String,
|
pub dispatch: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
|
pub sealed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -2081,6 +2085,7 @@ impl ApplyService {
|
|||||||
target,
|
target,
|
||||||
script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
|
script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
|
||||||
enabled: t.enabled,
|
enabled: t.enabled,
|
||||||
|
sealed: t.sealed,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -2122,6 +2127,7 @@ impl ApplyService {
|
|||||||
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
|
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
|
||||||
dispatch: r.dispatch_mode.as_str().to_string(),
|
dispatch: r.dispatch_mode.as_str().to_string(),
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
|
sealed: r.sealed,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -2153,47 +2159,63 @@ impl ApplyService {
|
|||||||
if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
|
if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
// Inherited (ancestor-group) trigger handler names + route paths.
|
// Inherited (ancestor-group) trigger handler names + route paths, each
|
||||||
let trig_names: Vec<(String,)> = sqlx::query_as(&format!(
|
// tagged with whether ANY matching template is un-sealed (i.e. actually
|
||||||
|
// suppressible). `bool_or(NOT sealed)` = true iff at least one match can
|
||||||
|
// be declined; false = every match is `sealed` (§11 tail), so the
|
||||||
|
// suppression is inert; absent = no inherited template at all (a typo).
|
||||||
|
let trig_names: Vec<(String, bool)> = sqlx::query_as(&format!(
|
||||||
"{CHAIN_LEVELS_CTE} \
|
"{CHAIN_LEVELS_CTE} \
|
||||||
SELECT DISTINCT LOWER(s.name) FROM triggers t \
|
SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \
|
||||||
JOIN scripts s ON s.id = t.script_id \
|
JOIN scripts s ON s.id = t.script_id \
|
||||||
JOIN chain c ON t.group_id = c.group_owner \
|
JOIN chain c ON t.group_id = c.group_owner \
|
||||||
WHERE t.group_id IS NOT NULL",
|
WHERE t.group_id IS NOT NULL \
|
||||||
|
GROUP BY LOWER(s.name)",
|
||||||
))
|
))
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
let inherited_handlers: HashSet<String> = trig_names.into_iter().map(|(n,)| n).collect();
|
let inherited_handlers: HashMap<String, bool> = trig_names.into_iter().collect();
|
||||||
|
|
||||||
let route_paths: Vec<(String,)> = sqlx::query_as(&format!(
|
let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!(
|
||||||
"{CHAIN_LEVELS_CTE} \
|
"{CHAIN_LEVELS_CTE} \
|
||||||
SELECT DISTINCT r.path FROM routes r \
|
SELECT r.path, bool_or(NOT r.sealed) FROM routes r \
|
||||||
JOIN chain c ON r.group_id = c.group_owner \
|
JOIN chain c ON r.group_id = c.group_owner \
|
||||||
WHERE r.group_id IS NOT NULL",
|
WHERE r.group_id IS NOT NULL \
|
||||||
|
GROUP BY r.path",
|
||||||
))
|
))
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
let inherited_paths: HashSet<String> = route_paths.into_iter().map(|(p,)| p).collect();
|
let inherited_paths: HashMap<String, bool> = route_paths.into_iter().collect();
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in &bundle.suppress_triggers {
|
for r in &bundle.suppress_triggers {
|
||||||
if !inherited_handlers.contains(&r.to_lowercase()) {
|
match inherited_handlers.get(&r.to_lowercase()) {
|
||||||
out.push(format!(
|
None => out.push(format!(
|
||||||
"suppress: no inherited trigger bound to script `{r}` — the \
|
"suppress: no inherited trigger bound to script `{r}` — the \
|
||||||
suppression has no effect"
|
suppression has no effect"
|
||||||
));
|
)),
|
||||||
|
Some(false) => out.push(format!(
|
||||||
|
"suppress: the inherited trigger bound to script `{r}` is \
|
||||||
|
sealed — the suppression has no effect"
|
||||||
|
)),
|
||||||
|
Some(true) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for r in &bundle.suppress_routes {
|
for r in &bundle.suppress_routes {
|
||||||
if !inherited_paths.contains(r) {
|
match inherited_paths.get(r) {
|
||||||
out.push(format!(
|
None => out.push(format!(
|
||||||
"suppress: no inherited route at path `{r}` — the \
|
"suppress: no inherited route at path `{r}` — the \
|
||||||
suppression has no effect"
|
suppression has no effect"
|
||||||
));
|
)),
|
||||||
|
Some(false) => out.push(format!(
|
||||||
|
"suppress: the inherited route at path `{r}` is sealed — the \
|
||||||
|
suppression has no effect"
|
||||||
|
)),
|
||||||
|
Some(true) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -1405,6 +1405,9 @@ pub struct TriggerTemplateDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sealed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the §11 tail route-template report.
|
/// One row of the §11 tail route-template report.
|
||||||
@@ -1423,6 +1426,9 @@ pub struct RouteTemplateDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dispatch: String,
|
pub dispatch: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sealed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the §11.6 shared-collection report.
|
/// One row of the §11.6 shared-collection report.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
|||||||
"script",
|
"script",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"sealed",
|
||||||
]);
|
]);
|
||||||
for r in rows {
|
for r in rows {
|
||||||
table.row([
|
table.row([
|
||||||
@@ -60,6 +61,7 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
|||||||
r.script,
|
r.script,
|
||||||
r.dispatch,
|
r.dispatch,
|
||||||
r.enabled.to_string(),
|
r.enabled.to_string(),
|
||||||
|
r.sealed.to_string(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
|||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let rows = client.group_triggers_list(group).await?;
|
let rows = client.group_triggers_list(group).await?;
|
||||||
let mut table = Table::new(["kind", "target", "script", "enabled"]);
|
let mut table = Table::new(["kind", "target", "script", "enabled", "sealed"]);
|
||||||
for t in rows {
|
for t in rows {
|
||||||
table.row([t.kind, t.target, t.script, t.enabled.to_string()]);
|
table.row([
|
||||||
|
t.kind,
|
||||||
|
t.target,
|
||||||
|
t.script,
|
||||||
|
t.enabled.to_string(),
|
||||||
|
t.sealed.to_string(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user