fix(templateengine+centralui): resolve follow-ups #3 (derived-template collisions) and #7 (sandbox batch/wait surface)

#3 — CollisionDetector counted a derived template's IsInherited placeholder
rows as a distinct origin from the parent members the inheritance walk
re-adds, reporting a spurious "Naming collision" for every inherited row and
blocking any attribute/composition add to a derived template. CollectDirectMembers
now skips IsInherited rows on the direct-template and inherited-parent walks;
it keeps them for the composed-module walk, where placeholders are the sole
representation of a derived module's inherited members (that walk does not
climb the composed template's parent chain).

#7 — SandboxAttributeAccessor (Central UI Test-Run host) omitted
WriteBatchAndWaitAsync / WaitAsync / WaitForAsync, so the editor false-flagged
valid instance scripts with CS1061 even though `template validate` and the
deploy gate accept them. Added the five overloads mirroring the runtime
AttributeAccessor; they throw a labelled ScriptSandboxException if run in
Test Run (the central sandbox has no device-batch / event-waiter transport).

Tests: +3 CollisionDetector unit + 1 end-to-end TemplateService (derived add
now succeeds); +2 ScriptAnalysisService diagnose-clean. Each new test verified
to fail without its fix with the exact user-facing symptom. Full suites green
(TemplateEngine.Tests 438, CentralUI.Tests 866).

Docs: Component-TemplateEngine.md (inherited-placeholder collision rule),
Component-ScriptAnalysis.md (third sandbox surface + its compile-clean guard),
known-issues tracker #3/#7 marked resolved and the minor note promoted to #8.
This commit is contained in:
Joseph Doherty
2026-06-24 15:03:27 -04:00
parent 1a647cf1c4
commit b3f6833b36
8 changed files with 254 additions and 12 deletions
@@ -313,6 +313,59 @@ public class SandboxAttributeAccessor
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
return Task.CompletedTask;
}
// Batch-write/wait helpers. These mirror the runtime AttributeAccessor
// (SiteRuntime/Scripts/ScopeAccessors.cs) and the deploy-gate
// ScriptCompileSurface member-for-member so instance scripts using them COMPILE
// in the editor and pass Test Run analysis (follow-up #7) — previously the
// sandbox omitted them and the editor false-flagged valid scripts with CS1061.
// Execution needs the site's DCL batch path + event-driven attribute waiter,
// for which the central Test Run sandbox has no transport, so each throws a
// clearly-labelled ScriptSandboxException; the same code validates/deploys/runs
// unchanged at a site.
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WriteBatchAndWaitAsync</c>: present
/// for editor/compile parity, throws <see cref="ScriptSandboxException"/> when run
/// in Test Run (no device batch-write transport here).
/// </summary>
public Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout)
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (value-equality form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// </summary>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (predicate form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// </summary>
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (value-equality form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// </summary>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitForAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (predicate form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// </summary>
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitForAsync));
private static ScriptSandboxException NotInSandbox(string member) =>
new($"{member}(...) drives live device tags and the site's event-driven " +
"attribute waiter, which aren't available in the central Test Run sandbox — " +
"deploy to a site to exercise batch-write/wait handshakes.");
}
/// <summary>
@@ -33,8 +33,9 @@ public static class CollisionDetector
var lookup = CycleDetector.BuildLookup(allTemplates);
var allMembers = new List<ResolvedMember>();
// Collect direct (top-level) members
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers);
// Collect direct (top-level) members. Inherited placeholder rows are skipped:
// the inheritance walk below re-adds them under the parent's origin.
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers, skipInherited: true);
// Collect members from composed modules recursively
foreach (var composition in template.Compositions)
@@ -75,26 +76,41 @@ public static class CollisionDetector
return collisions;
}
/// <param name="skipInherited">
/// When <c>true</c>, <c>IsInherited</c> placeholder rows are skipped. Those rows
/// are materialized copies of members the template inherits from its parent
/// chain, and they are ALSO re-added — under the parent's name — by
/// <see cref="CollectInheritedMembers"/>. Counting both yielded two distinct
/// origins for the same canonical name and a spurious collision, which blocked
/// every attempt to add an attribute/composition to a derived template
/// (follow-up #3). Pass <c>false</c> only for the composed-module walk, where
/// placeholder rows are the sole representation of a derived module's inherited
/// members (that walk does not climb the composed template's parent chain).
/// </param>
private static void CollectDirectMembers(
Template template,
string? prefix,
string originPrefix,
List<ResolvedMember> members)
List<ResolvedMember> members,
bool skipInherited)
{
foreach (var attr in template.Attributes)
{
if (skipInherited && attr.IsInherited) continue;
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
}
foreach (var alarm in template.Alarms)
{
if (skipInherited && alarm.IsInherited) continue;
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
}
foreach (var script in template.Scripts)
{
if (skipInherited && script.IsInherited) continue;
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
members.Add(new ResolvedMember(canonicalName, "Script", originPrefix));
}
@@ -110,8 +126,11 @@ public static class CollisionDetector
if (!visited.Add(template.Id))
return;
// Add direct members of this composed template with the prefix
CollectDirectMembers(template, prefix, $"module '{prefix}'", members);
// Add direct members of this composed template with the prefix. Inherited
// placeholder rows are KEPT here: the composed-module walk does not climb the
// composed template's parent chain, so the placeholders are the only
// representation of a derived module's inherited members.
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
// Recurse into nested compositions
foreach (var composition in template.Compositions)
@@ -139,8 +158,10 @@ public static class CollisionDetector
if (!visited.Add(parent.Id))
return;
// Inherited direct members (no prefix)
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members);
// Inherited direct members (no prefix). Skip the parent's OWN inherited
// placeholders too: the next recursion adds the grandparent's real rows, so
// counting the parent's copies would re-introduce the collision one level up.
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members, skipInherited: true);
// Inherited composed modules
foreach (var composition in parent.Compositions)