graccesscli: enumerate ScriptExtension via canonical text-attribute suffixes
`object scripts list` previously walked obj.Attributes through a
substring heuristic (`IsScriptAttribute`) that matched any name
containing "script", "expression", "execute", "startup", "shutdown",
or "scan". Two failure modes:
- "deSCRIPTion" contains "script", so every `MoveIn*.Description` /
`MoveOut*.Description` attribute on `$MESReceiver` (and any other
object with `.Description` UDAs) was mis-emitted as a script.
- `ScanState` / `ScanStateCmd` (ordinary attribute UDAs) leaked in via
the "scan" keyword.
Net result on $MESReceiver: 25 entries, all false positives, zero
true ScriptExtensions.
The GRAccess COM API does not expose an `IScriptExtension` enumerable
(verified against ArchestrA.GRAccess.dll string table — only
`AddExtensionPrimitive` / `DeleteExtensionPrimitive` /
`RenameExtensionPrimitive` mutators exist). The only attribute-side
signal that an object carries a real ScriptExtension is the canonical
text-attribute suffix list documented in docs/script-parsing.md:
ExecuteText, DeclarationsText, StartupText, ShutdownText, OnScanText,
OffScanText, Expression.
Replace `IsScriptAttribute` with `ScriptExtensionPrefix(name)` that
detects exactly those suffixes, then group attributes by prefix:
- Each ScriptExtension yields one logical entry named by its prefix.
- The Fields[] list reports which text attributes are present.
- Sibling `<prefix>.TriggerType` / `<prefix>.TriggerPeriod` attributes
are surfaced as TriggerType / TriggerPeriod when present.
- ExtensionType is always "ScriptExtension".
Outer `ObjectScripts(IGalaxy, IgObject, PackageSnapshot)` updated to:
- Thread ExtensionType / Fields / TriggerType / TriggerPeriod through.
- Look up body content by prefix first, then fall back to
`<prefix>.ExecuteText` (the package's serialized bodies are keyed
by full attribute name).
- Mark every `<prefix>.<field>` as emitted so the package-fallback
branch doesn't re-emit each text field as its own entry.
Drop dead `IsScriptAttribute` (no other callers).
Validation against live ZB galaxy:
- $MESReceiver: 25 → 0 (correct: no ScriptExtensions on this template)
- $TestMachine: 1 entry — UpdateTestChangingInt with body
"Me.TestChangingInt = System.Random().Next(1,1000);" + all 7 fields
+ trigger metadata.
- $DelmiaReceiver: 2 entries — ProcessRecipe + Reset, both with bodies
("Me.RecipeDownloadFlag = false;") + trigger metadata.
61/61 existing tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1417,23 +1417,86 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
|
||||
return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = "No supported scalar accessor succeeded for this MxValue." };
|
||||
}
|
||||
|
||||
// Canonical script-text attribute suffixes a `ScriptExtension` primitive
|
||||
// projects onto its parent object — see `docs/script-parsing.md` and
|
||||
// `docs/usage.md:250`. The presence of any of these as `<prefix>.<suffix>`
|
||||
// is the only attribute-side signal that the object carries a real
|
||||
// ScriptExtension primitive (the GRAccess COM API does not expose an
|
||||
// enumerable for primitives — only `AddExtensionPrimitive` /
|
||||
// `DeleteExtensionPrimitive` / `RenameExtensionPrimitive` mutators).
|
||||
private static readonly string[] ScriptTextSuffixes =
|
||||
{
|
||||
"ExecuteText",
|
||||
"DeclarationsText",
|
||||
"StartupText",
|
||||
"ShutdownText",
|
||||
"OnScanText",
|
||||
"OffScanText",
|
||||
"Expression",
|
||||
};
|
||||
|
||||
// Returns the ScriptExtension prefix when `name` ends in one of the
|
||||
// canonical script-text suffixes (e.g. `Foo.ExecuteText` -> `Foo`),
|
||||
// else null.
|
||||
private static string ScriptExtensionPrefix(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
var dot = name.LastIndexOf('.');
|
||||
if (dot <= 0 || dot == name.Length - 1) return null;
|
||||
var suffix = name.Substring(dot + 1);
|
||||
foreach (var s in ScriptTextSuffixes)
|
||||
{
|
||||
if (string.Equals(suffix, s, StringComparison.OrdinalIgnoreCase))
|
||||
return name.Substring(0, dot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> ObjectScripts(IgObject obj)
|
||||
{
|
||||
var attributes = AttributeInfos(obj.Attributes)
|
||||
.Where(IsScriptAttribute)
|
||||
.GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First());
|
||||
var attrs = AttributeInfos(obj.Attributes).ToList();
|
||||
|
||||
foreach (var attr in attributes)
|
||||
// Group attributes by their ScriptExtension prefix. Each
|
||||
// ScriptExtension projects 1..N script-text attributes; one logical
|
||||
// script entity per distinct prefix. Drops the previous
|
||||
// `IsScriptAttribute` substring heuristic which false-matched
|
||||
// `*.Description` (contains "script" in "deSCRIPTion") and
|
||||
// `Scan*` attribute UDAs.
|
||||
var byPrefix = attrs
|
||||
.Select(a => new { Attr = a, Prefix = ScriptExtensionPrefix(a.Name) })
|
||||
.Where(x => x.Prefix != null)
|
||||
.GroupBy(x => x.Prefix, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var grp in byPrefix)
|
||||
{
|
||||
var prefix = grp.Key;
|
||||
var fields = grp
|
||||
.Select(x => x.Attr.Name.Substring(prefix.Length + 1))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
// Sibling trigger metadata (the ScriptExtension also projects
|
||||
// `<prefix>.TriggerType` / `<prefix>.TriggerPeriod` when set).
|
||||
var triggerType = attrs.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, prefix + ".TriggerType", StringComparison.OrdinalIgnoreCase))?.DataType;
|
||||
var triggerPeriod = attrs.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, prefix + ".TriggerPeriod", StringComparison.OrdinalIgnoreCase))?.DataType;
|
||||
|
||||
yield return new
|
||||
{
|
||||
attr.Name,
|
||||
attr.DataType,
|
||||
attr.Category,
|
||||
Name = prefix,
|
||||
ExtensionType = "ScriptExtension",
|
||||
Fields = fields,
|
||||
TriggerType = triggerType,
|
||||
TriggerPeriod = triggerPeriod,
|
||||
// Carry empty DataType/Category so the outer ReadString-based
|
||||
// wrapper still works without nulling out the JSON shape.
|
||||
DataType = string.Empty,
|
||||
Category = string.Empty,
|
||||
BodyAvailable = false,
|
||||
Source = "attribute",
|
||||
Unavailable = "Object script body access is version-specific and was not exposed through the generic GRAccess attribute metadata path."
|
||||
Source = "attribute-prefix",
|
||||
Unavailable = "Script body access requires the package fallback path."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1448,16 +1511,34 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
|
||||
foreach (var script in direct)
|
||||
{
|
||||
var name = ReadString(script, "Name");
|
||||
var fields = ReadProperty(script, "Fields") as IReadOnlyList<string>
|
||||
?? (IReadOnlyList<string>)((ReadProperty(script, "Fields") as IEnumerable<string>)?.ToList());
|
||||
emitted.Add(name);
|
||||
var body = FindPackageScriptBody(package, name);
|
||||
// Mark every `<prefix>.<field>` as emitted too so the
|
||||
// package-only branch below doesn't double-yield each script
|
||||
// text field as its own entry.
|
||||
if (fields != null)
|
||||
{
|
||||
foreach (var f in fields)
|
||||
emitted.Add(name + "." + f);
|
||||
}
|
||||
// Body lookup: try the prefix first, then the canonical body
|
||||
// field `<prefix>.ExecuteText` (the package's serialized
|
||||
// script bodies are keyed by full attribute name).
|
||||
var body = FindPackageScriptBody(package, name)
|
||||
?? FindPackageScriptBody(package, name + ".ExecuteText");
|
||||
yield return new
|
||||
{
|
||||
Name = name,
|
||||
DataType = ReadString(script, "DataType"),
|
||||
Category = ReadString(script, "Category"),
|
||||
ExtensionType = ReadString(script, "ExtensionType"),
|
||||
Fields = fields,
|
||||
TriggerType = ReadString(script, "TriggerType"),
|
||||
TriggerPeriod = ReadString(script, "TriggerPeriod"),
|
||||
BodyAvailable = body != null,
|
||||
Body = body?.Body,
|
||||
Source = body == null ? "attribute" : body.Source,
|
||||
Source = body == null ? ReadString(script, "Source") : body.Source,
|
||||
PackageFallbackUsed = package.PackageFallbackUsed,
|
||||
Unavailable = body == null ? ReadString(script, "Unavailable") : string.Empty
|
||||
};
|
||||
@@ -1470,6 +1551,10 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
|
||||
body.Name,
|
||||
DataType = string.Empty,
|
||||
Category = string.Empty,
|
||||
ExtensionType = "ScriptExtension",
|
||||
Fields = (List<string>)null,
|
||||
TriggerType = (string)null,
|
||||
TriggerPeriod = (string)null,
|
||||
BodyAvailable = true,
|
||||
Body = body.Body,
|
||||
Source = body.Source,
|
||||
@@ -2028,17 +2113,6 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsScriptAttribute(GRAccessAttributeInfo attribute)
|
||||
{
|
||||
var name = attribute?.Name ?? string.Empty;
|
||||
return name.IndexOf("script", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| name.IndexOf("expression", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| name.IndexOf("execute", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| name.IndexOf("startup", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| name.IndexOf("shutdown", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| name.IndexOf("scan", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static MxValue CreateMxValue(string value, string dataType)
|
||||
{
|
||||
var mxValue = new MxValueClass();
|
||||
|
||||
Reference in New Issue
Block a user