graccesscli: scripts-get now uses direct read; bypass anon-type reflection

Two follow-on fixes after the MxValueDetails reader correction:

1. AttributeValueDetails was falling through to the package-binary
   fallback even when MxValueDetails returned Supported=true. Cause:
   `ReadString(direct, "Supported")` reflects on an anonymous type via
   InvokeMember, which returned an empty string under
   `BindingFlags.IgnoreCase`, making the StringEquals check fail.
   Replace with a typed `Type.GetProperty("Supported").GetValue(direct)`
   bool check via the new IsDirectSupported helper.

2. ObjectScript (the `scripts get` body retrieval) skipped the live
   attribute path entirely and went straight to the package-binary
   parser. The parser truncates strings at certain delimiters (e.g.
   marker text containing `\n` or `=` came back as just the prefix).
   Now ObjectScript tries `obj.ConfigurableAttributes[<script>.<field>]
   .value.GetString()` first and only falls back to the package body
   when the direct read returns null. Matches the pattern in
   AttributeValueDetails. The package fallback survives for cases
   where direct GRAccess doesn't surface the attribute.

End-to-end live round-trip on $DelmiaReceiver.ProcessRecipe.ExecuteText
with marker `// scripts-get round-trip marker\nMe.RecipeDownloadFlag = false;`:

  scripts get      -> Source: direct-graccess
                      Body: <marker>
  attribute value get -> Value: <marker>

Both readers return the same content the writer wrote. Field then
restored to the original `Me.RecipeDownloadFlag = false;`.

Tests still 66/66.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 01:34:04 -04:00
parent 842b94fb39
commit 6cde4d9fe4
@@ -982,7 +982,14 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
if (string.IsNullOrWhiteSpace(attributeName))
throw new ArgumentException("Attribute name is required.");
return TryFindAttribute(obj.Attributes, attributeName) ?? TryFindAttribute(obj.ConfigurableAttributes, attributeName)
// Prefer ConfigurableAttributes for reads too. The runtime
// `obj.Attributes` collection sometimes returns an IAttribute proxy
// for dotted/extension names that resolves `.value` to a synthesized
// MxValue containing only the parent attribute's prefix (e.g. reading
// `ProcessRecipe.ExecuteText` returned `"Me.RecipeDownloadFlag"`,
// truncated at the first `=`). ConfigurableAttributes returns the
// proper IAttribute backed by the actual ScriptExtension projection.
return TryFindAttribute(obj.ConfigurableAttributes, attributeName) ?? TryFindAttribute(obj.Attributes, attributeName)
?? throw new InvalidOperationException($"Attribute '{attributeName}' was not found.");
}
@@ -1002,6 +1009,20 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
catch { return null; }
}
// Anonymous-type property check that doesn't go through the reflection
// path used by `ReadProperty` / `ReadString`. The reflection path
// intermittently returns null/empty on anonymous types from this
// assembly, which made AttributeValueDetails fall through to the
// package-binary fallback even when the direct read had succeeded.
private static bool IsDirectSupported(object direct)
{
if (direct == null) return false;
var prop = direct.GetType().GetProperty("Supported");
if (prop == null) return false;
var v = prop.GetValue(direct);
return v is bool b && b;
}
private static string SafeAttributeName(IAttribute attr)
{
if (attr == null) return string.Empty;
@@ -1354,13 +1375,6 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
private static object AttributeValueDetails(IGalaxy galaxy, IgObject obj, IAttribute attr)
{
var unavailable = new List<LlmUnavailableField>();
// Use the typed `IAttribute.value` accessor. For ScriptExtension /
// PackageOnly fields the IAttribute proxy must come from
// `ConfigurableAttributes`, not the runtime `Attributes` snapshot —
// the runtime view often returns a stub whose `.value` is null even
// when the persisted value is set. Re-look-up from
// ConfigurableAttributes by name when needed; fall back to whatever
// `attr` was passed in if the configurable lookup fails.
object rawMxValue = TryReadAttributeValue(attr);
if (rawMxValue == null)
{
@@ -1376,7 +1390,12 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
var package = new PackageSnapshot();
var packageValue = (PackageAttributeValue)null;
if (!StringEquals(ReadString(direct, "Supported"), "True"))
// Use direct property access to check Supported. Reflecting on the
// anonymous type via ReadString/ReadProperty was unreliable enough
// to trigger the package fallback even when MxValueDetails returned
// Supported=true — the package binary parser then returned a
// truncated old value, masking the live attribute read.
if (!IsDirectSupported(direct))
{
package = TryReadPackageSnapshot(galaxy, obj, ObjectDetails(obj), unavailable);
packageValue = FindPackageAttributeValue(package, attr.Name);
@@ -1629,12 +1648,36 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
private static object ObjectScript(IGalaxy galaxy, IgObject obj, string scriptName)
{
var unavailable = new List<LlmUnavailableField>();
var package = TryReadPackageSnapshot(galaxy, obj, ObjectDetails(obj), unavailable);
var attributeName = ScriptAttributeName(scriptName);
// Try the live attribute path FIRST. The package-binary parser
// truncates strings at certain delimiters (e.g. `=`), so when a
// marker like `// foo` is present in ExecuteText the parser
// returns only the prefix. The direct GRAccess attribute read via
// ConfigurableAttributes returns the full string.
string directBody = null;
try
{
var attr = TryFindAttribute(obj.ConfigurableAttributes, attributeName)
?? TryFindAttribute(obj.Attributes, attributeName);
if (attr != null)
{
var raw = TryReadAttributeValue(attr);
if (raw is IMxValue mx) directBody = mx.GetString();
}
}
catch { directBody = null; }
var package = TryReadPackageSnapshot(galaxy, obj, ObjectDetails(obj), unavailable);
var match = ObjectScripts(galaxy, obj, package)
.FirstOrDefault(s => string.Equals(ReadString(s, "Name"), scriptName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(ReadString(s, "Name"), attributeName, StringComparison.OrdinalIgnoreCase));
var body = FindPackageScriptBody(package, attributeName) ?? FindPackageScriptBody(package, scriptName);
var packageBody = FindPackageScriptBody(package, attributeName) ?? FindPackageScriptBody(package, scriptName);
string body = directBody ?? packageBody?.Body;
string source = directBody != null
? "direct-graccess"
: (packageBody != null ? packageBody.Source : null);
if (match == null && body == null)
throw new InvalidOperationException($"Script '{scriptName}' was not found in script-like object attributes.");
@@ -1642,10 +1685,10 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
return new
{
Metadata = match,
Body = body?.Body,
Body = body,
BodyAvailable = body != null,
PackageFallbackUsed = package.PackageFallbackUsed,
Source = body == null ? "direct-graccess" : body.Source,
Source = source ?? "direct-graccess",
Unavailable = body == null
? unavailable.Concat(new[] { new LlmUnavailableField { Field = "scriptBody", Reason = "Script body was not exposed through direct GRAccess and was not found in the exported package." } }).ToList()
: unavailable