fix(template-engine): resolve TemplateEngine-001/003/004/005, re-triage 002 — recursive composed flattening, fixed-field guard, alarm script refs, dead collision query

This commit is contained in:
Joseph Doherty
2026-05-16 19:57:28 -04:00
parent 71c0564ec0
commit 74aae53500
5 changed files with 506 additions and 130 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | `9c60592` |
| Open findings | 14 |
| Open findings | 10 |
## Summary
@@ -52,7 +52,7 @@ of attributes vs. alarms vs. scripts throughout the resolve/flatten/derive paths
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:211`, `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:535`, `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:609` |
**Description**
@@ -77,7 +77,13 @@ the recursion already in `TemplateResolver.AddComposedMembers` and
**Resolution**
_Unresolved._
Resolved 2026-05-16 (commit `<pending>`): replaced the hand-unrolled
one/two-level composition loops in `ResolveComposedAttributes`,
`ResolveComposedAlarms`, and `ResolveComposedScripts` with single recursive
walks (`*Recursive` helpers) carrying the accumulated path prefix and a
`visited` set, so composed members at arbitrary nesting depth are resolved.
Regression tests: `Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved`,
`Flatten_NestedComposedAlarm_TriggerAttributePrefixed`.
### TemplateEngine-002 — Derived templates omit all base alarms; composed alarms cannot be overridden per slot
@@ -110,7 +116,22 @@ already do.
**Resolution**
_Unresolved._
_Unresolved (re-triaged 2026-05-16)._ Partially mis-stated and out of the
current fix scope. Correction to the description: composed/inherited alarms
are **not** dropped from the flattened deployment output — `FlatteningService`
resolves alarms from the entire inheritance chain (`ResolveInheritedAlarms`
walks `templateChain`, which includes the base of a derived template), so an
instance of a derived template still receives the base template's alarms. The
real, valid gap is narrower: there is no per-slot **alarm override**
mechanism. The fix genuinely requires adding `IsInherited` / `LockedInDerived`
fields to the `TemplateAlarm` entity, which lives in `ScadaLink.Commons`
(a different module). Adding an alarm copy loop to `BuildDerivedTemplate`
without those fields would be actively harmful: copied alarm rows on the
derived template would shadow the live base alarm with stale data during
flattening (`ResolveInheritedAlarms` has no `IsInherited` skip for alarms,
unlike attributes/scripts). Resolving this safely is a cross-module change
(`Commons` + `TemplateEngine`) and must be scheduled as a coordinated edit;
left **Open** pending that.
### TemplateEngine-003 — `UpdateAttributeAsync` lets a non-locked attribute change its fixed DataType / DataSourceReference
@@ -118,7 +139,7 @@ _Unresolved._
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:285` |
**Description**
@@ -147,7 +168,12 @@ apply block.
**Resolution**
_Unresolved._
Resolved 2026-05-16 (commit `<pending>`): removed the `&& existing.IsLocked`
guard in `UpdateAttributeAsync` so the fixed-field granularity error is always
honoured, and removed the unconditional `existing.DataType` /
`existing.DataSourceReference` assignments from the apply block. Regression
tests: `UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected`,
`UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected`.
### TemplateEngine-004 — Alarm on-trigger script references are never resolved (empty placeholder)
@@ -155,7 +181,7 @@ _Unresolved._
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:695` |
**Description**
@@ -179,7 +205,15 @@ and implement that consistently.
**Resolution**
_Unresolved._
Resolved 2026-05-16 (commit `<pending>`): implemented `ResolveAlarmScriptReferences`.
Alarm resolution now records each resolved alarm's `OnTriggerScriptId` keyed by
canonical name, and script resolution records each resolved `TemplateScript.Id`
keyed by its canonical name (both honour composition path prefixes). Step 7
joins the two maps to set `ResolvedAlarm.OnTriggerScriptCanonicalName`, so the
revision hash, diff, and `SemanticValidator` on-trigger-script-exists check now
all see the reference. Regression tests:
`Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName`,
`Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix`.
### TemplateEngine-005 — Collision validation is skipped when creating a child template
@@ -187,7 +221,7 @@ _Unresolved._
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:56` |
**Description**
@@ -210,7 +244,15 @@ that explicitly instead of leaving a no-op.
**Resolution**
_Unresolved._
Resolved 2026-05-16 (commit `<pending>`): deleted the dead `if
(parentTemplateId.HasValue)` block and its unused `GetAllTemplatesAsync`
read in `CreateTemplateAsync`. A create-time collision check on a child is a
guaranteed no-op — a freshly created template has no members of its own, the
parent's members were already collision-validated on every member-mutating
call, and a new child cannot be an ancestor of its parent. Replaced the no-op
with an explanatory comment documenting that collision detection is enforced
on `AddAttribute`/`AddAlarm`/`AddScript`/`AddComposition` and on rename.
Regression test: `CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery`.
### TemplateEngine-006 — Forbidden-API enforcement is a naive substring scan (bypassable and false-positive prone)