diff --git a/AGENTS.md b/AGENTS.md index 81837a21c..a42482035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,43 @@ Before making significant changes: 3. Understand the relevant existing code, architecture, tests, and conventions. 4. Review the current Git branch and working tree; preserve existing local changes. 5. Continue from the documented current state. +6. Before changing, adding or removing a Grasshopper component, or any of its parameters, + descriptions, visibility, defaults or version, read and follow + `docs/GH_COMPONENT_STANDARDS.md`. Prefer small, safe, targeted changes consistent with existing conventions. Avoid unrelated refactoring unless it is required for the task. +## Grasshopper components + +`docs/GH_COMPONENT_STANDARDS.md` is the canonical SAM-BIM standard for Grasshopper component +naming, descriptions, Diagnostics, versioning and migration. It is product documentation: it +belongs on a released promotion, unlike the internal agent files. Its rules are frozen facts, +proposals or open decisions - check the status label before relying on one. + +The parts that most often matter: + +- A published parameter `Name`, order, runtime type, access, optionality, visibility and registered + default, and the component `GUID`, are frozen. Never change one to satisfy a naming convention, and + never rename a published parameter without a declared `ParameterRename` alias and that document's + migration acceptance. +- Which inputs are required, optional, defaulted or hidden is decided by `Optional`, + `ParamVisibility`, registered persistent data and the code-side fallback - not by the + `_name` / `_name_` / `name_` marker, which only records intent. +- Metadata validation depends on whether this repository has an approved baseline + (`design/grasshopper-icons/param_contract.json`). **SAM does not have one yet, so do not run + `--check` here.** Use the audit, which reports coverage and does not claim compatibility: + `python design/grasshopper-icons/tools/param_contract.py --repo .` + Write it to a temporary file instead of the repository with `--output `. + A repository that *does* have an approved manifest validates with + `python /design/grasshopper-icons/tools/param_contract.py --repo . --check`. + A component reported `partial` or `unresolved` is never a PASS: its contract was not fully + extracted, and the manifest records why. +- Validator regression tests: `python design/grasshopper-icons/tools/tests/test_param_contract.py`. +- `SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` and + `documentation/GH-Parameter-Rename-Migration-Q4.md` are the pilot records; the shared + implementation they describe is reused, not redesigned. + ## Repository operations and context - Validate fetch and push URLs before network operations; target only diff --git a/design/grasshopper-icons/tools/param_contract.py b/design/grasshopper-icons/tools/param_contract.py new file mode 100644 index 000000000..11cbeab43 --- /dev/null +++ b/design/grasshopper-icons/tools/param_contract.py @@ -0,0 +1,2059 @@ +"""The SAM-BIM Grasshopper component metadata contract - canonical shared validator. + +Extracts the published parameter contract of every Grasshopper component that declares a +`ComponentGuid`, and freezes it in a manifest (`param_contract.json`) that a later `--check` compares +against the source. + + python param_contract.py --repo # refresh the manifest (inventory) + python param_contract.py --repo --check # compatibility gate vs the committed manifest + python param_contract.py --repo --output # write elsewhere (dry run, no baseline) + python param_contract.py --repo --check --json # machine-readable summary + +`--repo` defaults to the SAM repository this file lives in; `--output` defaults to +`/design/grasshopper-icons/param_contract.json`. One shared copy serves every SAM-BIM +repository: the manifest is repository-specific data, the validator is not. + +## The governing rule + +**Never report a component or repository as verified when the published contract was not completely +and reliably extracted.** Every component in the manifest carries an `extraction.status`: + +| status | meaning | +|---|---| +| `verified` | every parameter was extracted: name, runtime type, access, optionality, visibility, registered default | +| `partial` | extracted, but at least one attribute could not be established (recorded in `extraction.reasons`) | +| `unresolved` | the parameter set itself could not be established | + +`--check` fails (nonzero) if any component is not `verified`, and never prints `OK` for an incomplete +contract. A baseline-free run is an **inventory**, labelled as such; it is not compatibility +validation and does not claim to be. + +The manifest is a *different thing* from the standard's naming conventions: + +* **compatibility** (fatal under `--check`) - component identity and the published parameter contract: + GUID, name, nickname, category, subcategory, and per parameter name, **runtime type**, order, access, + optionality, visibility and registered default. These are frozen. +* **conventions** (fatal under `--check`) - a naming-rule deviation that is not in the baseline's + approved `namingExceptions` list, and a listed exception that is no longer a deviation. Existing + published names are never forced to change; a *new* deviation needs an approved exception. +* **advisory** (non-fatal) - description quality, and other observations that do not change the + contract. Not labelled as enforced anywhere. + +## Schema + +Manifests written by this tool are `schemaVersion: 3`. Version 2 used lossy type names and must be +regenerated in a reviewed change; it is not silently certified. A manifest without `schemaVersion` is the +earlier v1 form (produced by the SAM_Psychrometrics pilot): it is still read, as a baseline whose +parameters carry a runtime type, but it has no `extraction` section - so it is treated as a legacy +baseline and only a fully extracted *current* component may be compared against it. + +Certification is intentionally narrower than inventory: straight-line, proven returned collections +or supported manager registrations, known parameter constructor defaults, literal registered values +and resolved type identities. Helper/conditional returns, nontrivial custom constructors and external +registration are recorded as partial/unresolved. This is not general C# or MSBuild semantic analysis. + +## Why the scanner is embedded + +The C# scanner below is the Grasshopper document-object scanner from SAM#166, in the generalised form +first written for the `SAM_Psychrometrics` metadata pilot, extended here with source-based ancestry +classification and a statement-level parameter extractor. It is inlined rather than imported because +the repository-local `inventory.py` copies have diverged (`SAM/design/grasshopper-icons/tools/ +inventory.py` is the older, SAM-only generation with a different API). Consolidating the copies is a +recorded follow-up in `docs/GH_COMPONENT_STANDARDS.md`; this file is the canonical copy and must not be +modified per repository. + +See `docs/GH_COMPONENT_STANDARDS.md` for the standard this file enforces. +""" +import argparse +import fnmatch +import json +import os +import re +import sys +import xml.etree.ElementTree as ET + +SCHEMA_VERSION = 3 + +# --- C# scanner ------------------------------------------------------------------------------------- + +SKIP_DIRS = {"obj", "bin", ".git", "packages", "design", "build", ".vs", "TestResults"} +CLASS_RE = re.compile(r"(?{]*>)?\s*(?::\s*([^{\n]+))?", re.M) +GUID_RE = re.compile(r"ComponentGuid\s*(?:=>|\{\s*get\s*\{\s*return)\s*new\s*(?:[\w.]*\.)?(?:Guid\s*)?\(\s*\"([0-9a-fA-F-]+)\"") +VERSION_RE = re.compile(r"LatestComponentVersion\s*=>\s*\"([^\"]+)\"") +COMMENT_RE = re.compile(r"(?m)(^|[\s;{}])//[^\n]*") +BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S) + +# External base types whose ancestry resolves to "a published component". +COMPONENT_ROOTS = { + "GH_Component", + "GH_SAMComponent", + "GH_SAMVariableOutputParameterComponent", + "GH_SAMAsyncComponent", + "GH_SAMEnumComponent", + "IGH_Component", + # SAM's own component contract. A component whose base lives in another assembly (for example + # SAM_Revit's `SAMTransactionalChainComponent : RhinoInside.Revit.GH.Components. + # TransactionalChainComponent, IGH_SAMComponent`) is still a published component. + "IGH_SAMComponent", +} + +# External base types whose ancestry resolves to "a Grasshopper parameter", not a component. +PARAMETER_ROOTS = { + "GH_Param", + "GH_PersistentParam", + "IGH_Param", +} + +# `GH_OutputParamManager.AddParameter(...)` helper -> declared runtime parameter type. +MANAGER_HELPER_TYPES = { + "Number": "Param_Number", + "Integer": "Param_Integer", + "Boolean": "Param_Boolean", + "Text": "Param_String", + "String": "Param_String", + "Point": "Param_Point", + "Vector": "Param_Vector", + "Plane": "Param_Plane", + "Line": "Param_Line", + "Circle": "Param_Circle", + "Arc": "Param_Arc", + "Curve": "Param_Curve", + "Surface": "Param_Surface", + "Brep": "Param_Brep", + "Mesh": "Param_Mesh", + "Geometry": "Param_Geometry", + "Generic": "Param_GenericObject", + "Interval": "Param_Interval", + "Colour": "Param_Colour", + "Color": "Param_Colour", + "Time": "Param_Time", + "Date": "Param_Time", + "FilePath": "Param_FilePath", + "Transform": "Param_Transform", + "SubD": "Param_SubD", + "Field": "Param_Field", + "Group": "Param_Group", + "ComplexNumber": "Param_Complex", + "Culture": "Param_Culture", + "Guid": "Param_Guid", +} + +ACCESS_VALUES = {"item", "list", "tree"} +WRAPPER_RE = re.compile(r"\bnew\s+(?:global::SAM\.Core\.Grasshopper\.|SAM\.Core\.Grasshopper\.)?GH_SAMParam\s*\(") + +# A parameter object assigned to a variable: `Type v = new ...` or `v = new ...`. +ASSIGNMENT_RE = re.compile( + r"(?:^|[;{}()\s])(?:(?P[A-Za-z_][\w:.<>,\[\]]*)\s+)?(?P" + r"@?[A-Za-z_]\w*" + r")\s*=\s*(?Pnew\b[^;]*)") + +# A C# identifier, including a verbatim identifier (`@string`), which SAM components use for +# parameter variables whose name collides with a keyword. +IDENT = r"@?[A-Za-z_]\w*" + + +def identifier(name): + """`@string` and `string` are the same C# identifier; the `@` is not part of the name.""" + return name[1:] if name.startswith("@") else name + + +def long_path(path): + """A \\\\?\\ path on Windows, so a path over MAX_PATH can still be read.""" + path = os.path.abspath(path) + return "\\\\?\\" + path if os.name == "nt" and not path.startswith("\\\\") else path + + +def read_text(path): + with open(long_path(path), encoding="utf-8-sig", errors="replace") as stream: + return stream.read() + + +def base_args(text, pos): + """Split the argument list of the `base(` call starting at/after pos (string- and paren-aware).""" + i = text.index("(", pos) + 1 + args, depth, cur, instr = [], 0, "", False + while i < len(text): + ch = text[i] + if instr: + cur += ch + if ch == "\\": + cur += text[i + 1]; i += 1 + elif ch == '"': + instr = False + elif ch == '"': + instr = True; cur += ch + elif ch in "([{": + depth += 1; cur += ch + elif ch in ")]}": + if depth == 0: + args.append(cur.strip()); return args + depth -= 1; cur += ch + elif ch == "," and depth == 0: + args.append(cur.strip()); cur = "" + else: + cur += ch + i += 1 + return args + + +def lit(a): + a = a.strip() + if re.fullmatch(r'"(?:[^"\\]|\\.)*"', a): + return a[1:-1] + return "{" + a + "}" + + +# --- C# lexing helpers ------------------------------------------------------------------------------- + +def take_balanced(text, index): + """Text between text[index] ('(' / '{' / '[') and its matching closer, and the index after it.""" + pairs = {"(": ")", "{": "}", "[": "]"} + opener = text[index] + closer = pairs[opener] + depth, i, instr, esc = 0, index, False, False + while i < len(text): + ch = text[i] + if instr: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + instr = False + elif ch == '"': + instr = True + elif ch == opener: + depth += 1 + elif ch == closer: + depth -= 1 + if depth == 0: + return text[index + 1:i], i + 1 + i += 1 + raise ValueError("unbalanced %s at %d" % (opener, index)) + + +def split_top_level(text, separator=","): + """Split on `separator` at depth 0, respecting strings and nesting.""" + parts, depth, cur, instr, esc = [], 0, "", False, False + for ch in text: + if instr: + cur += ch + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + instr = False + elif ch == '"': + instr = True + cur += ch + elif ch in "([{": + depth += 1 + cur += ch + elif ch in ")]}": + depth -= 1 + cur += ch + elif ch == separator and depth == 0: + parts.append(cur) + cur = "" + else: + cur += ch + parts.append(cur) + return parts + + +def split_statements(body): + """Top-level `;`-separated statements of a C# block, strings and nesting respected.""" + statements, cur, depth, instr, esc = [], "", 0, False, False + for ch in body: + if instr: + cur += ch + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + instr = False + continue + if ch == '"': + instr = True + cur += ch + continue + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == ";" and depth == 0: + statements.append(cur) + cur = "" + continue + cur += ch + if cur.strip(): + statements.append(cur) + return statements + + +# Statement-block headers whose `{ ... }` body is a nested statement sequence rather than an object or +# collection initializer. Parameter declarations inside such a block (a `foreach` over enum values, an +# `if` guarding a voluntary parameter) are real published parameters and must be read. +BLOCK_HEADERS = ("foreach", "for", "if", "else", "while", "do", "switch", "using", "try", "catch", + "finally", "lock", "unsafe", "fixed", "checked", "unchecked") + + +def is_block_header(text): + """Whether the text before a `{` is a statement-block header rather than an initializer. + + The parentheses must be balanced at the `{`: in + `foreach (string name in new string[] { "_a" }) { ... }` the first `{` belongs to the array + initializer inside the loop's own parentheses, not to the loop body, and descending into it would + hide the loop body's statements. + """ + header = text.strip() + depth = 0 + for ch in header: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if depth != 0: + return False + if header.endswith("=>"): + return True + for keyword in BLOCK_HEADERS: + if header == keyword or header.startswith(keyword + " ") or header.startswith(keyword + "("): + return True + return False + + +def walk_statements(body): + """Statements in source order, descending into nested statement blocks. + + `split_statements` stops at a nested `{ ... }`, which hides every parameter declared inside a + `foreach` or an `if` in the property body. Object and collection initializers are *not* descended + into - they are part of the statement they belong to - so the distinction is the block header. + """ + result, current, i = [], "", 0 + while i < len(body): + ch = body[i] + if ch == '"': + current += ch + i += 1 + while i < len(body): + current += body[i] + if body[i] == "\\": + i += 1 + if i < len(body): + current += body[i] + elif body[i] == '"': + break + i += 1 + i += 1 + continue + if ch == "{": + inner, after = take_balanced(body, i) + if is_block_header(current): + result.extend(walk_statements(inner)) + current = "" + else: + current += "{" + inner + "}" + i = after + continue + if ch == ";": + if current.strip(): + result.append(current) + current = "" + i += 1 + continue + current += ch + i += 1 + if current.strip(): + result.append(current) + return result + + +def split_assignment(statement): + """(lhs, rhs) for a single top-level assignment, else None. Ignores `==`, `=>`, `+=`, ...""" + depth, instr, esc, i = 0, False, False, 0 + while i < len(statement): + ch = statement[i] + if instr: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + instr = False + i += 1 + continue + if ch == '"': + instr = True + i += 1 + continue + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "=" and depth == 0: + nxt = statement[i + 1:i + 2] + prv = statement[i - 1:i] + if nxt != "=" and nxt != ">" and prv not in ("=", "!", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"): + return statement[:i], statement[i + 1:] + i += 1 + return None + + +def assignment_target(lhs): + """(declared type text or None, variable name) for an assignment target, else None. + + `a.b = x` is not a variable assignment and returns None; `Type v = x` and `v = x` do. + """ + lhs = lhs.strip() + match = re.fullmatch(r"(?:(?P[A-Za-z_][\w:.<>,\[\]\s]*?)\s+)?(?P" + IDENT + r")", lhs) + if not match: + return None + return match.group("type"), identifier(match.group("name")) + + +def simple_type(type_text): + """`global::A.B.Param_Brep` -> `Param_Brep`; `GooX` -> `GooX`.""" + if not type_text: + return None + text = type_text.strip() + text = text.split("<")[0] + text = text.split("::")[-1] + text = text.split(".")[-1].strip() + return text or None + + +def type_identity(type_text, context=None): + """Resolve only evidence available in the source; never erase qualification or type arguments. + + Built-in GH spellings retain the pilot's short names. Other types retain a qualified identity. + An unknown short name is not proof of a runtime type (the caller records an extraction reason). + This deliberately is not a substitute for a C# compiler's semantic model. + """ + if not type_text: + return None + context = context or {} + absolute = type_text.strip().startswith("global::") + text = re.sub(r"\s+", "", type_text).replace("global::", "") + aliases = context.get("aliases", {}) + first = re.split(r"[.<]", text)[0] + if not absolute and first in aliases: + text = aliases[first].replace("global::", "") + text[len(first):] + absolute = True + if "<" in text: + if not text.endswith(">"): + return None + outer, arguments = text.split("<", 1) + resolved = [type_identity(arg, context) for arg in split_top_level(arguments[:-1])] + root = type_identity(outer, context) + return root + "<" + ",".join(resolved) + ">" if root and all(resolved) else None + primitives = {"int": "System.Int32", "string": "System.String", "double": "System.Double", + "bool": "System.Boolean", "float": "System.Single", "object": "System.Object"} + if text in primitives: + return primitives[text] + known = context.get("types", {}) + if text.startswith("Grasshopper.Kernel.Parameters.Param_") and text not in known: + return text[len("Grasshopper.Kernel.Parameters."):] + namespace = context.get("namespace", "") + local = namespace + "." + text if namespace else text + if not absolute and local in known: + return local + if text in known: + return text + imported = {ns + "." + text for ns in context.get("usings", []) if ns + "." + text in known} + if len(imported) == 1: + return imported.pop() + if len(imported) > 1: + return None + if "." in text and re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+", text): + # An explicitly qualified external identity is preserved, not simplified. + return text + if re.fullmatch(r"Param_\w+", text) and "Grasshopper.Kernel.Parameters" in context.get("usings", []): + return text + return None + + +def parameter_defaults_proven(identity, context, seen=None): + """The GH built-ins have known defaults; an arbitrary constructor does not. + + The only source subclass certified here is an empty class inheriting those defaults. Reading + arbitrary Goo/third-party constructor effects belongs in a semantic/runtime follow-up. + """ + if not identity: + return False + if identity in set(MANAGER_HELPER_TYPES.values()): + return True + name = identity.split("<", 1)[0] + seen = set(seen or ()) + if name in seen: + return False + seen.add(name) + declarations = context.get("typeDeclarations", {}).get(name, []) + if len(declarations) != 1: + return False + declaration = declarations[0] + if declaration["body"].strip() or len(declaration["bases"]) != 1: + return False + base = type_identity(declaration["base_text"].strip(), declaration["context"]) + return parameter_defaults_proven(base, declaration["context"], seen) + + +def literal_string(expression): + """(text, True) for a string literal or a `+`-concatenation of literals, else (raw, False).""" + expression = expression.strip() + if not expression: + return None, False + parts = split_top_level(expression, "+") + pieces = [] + for part in parts: + part = part.strip() + if re.fullmatch(r'"(?:[^"\\]|\\.)*"', part): + pieces.append(part[1:-1].replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\")) + else: + return expression, False + return "".join(pieces), True + + +def parse_initializer(body): + """`{ Name = "x", Access = GH_ParamAccess.item }` -> {Name: 'x', ...} (raw right-hand sides).""" + out = {} + for part in split_top_level(body): + part = part.strip() + if not part: + continue + assignment = split_assignment(part) + if not assignment: + continue + target = assignment_target(assignment[0]) + if not target: + continue + out[target[1]] = assignment[1].strip() + return out + + +def parse_new_expression(expression, declared_type=None): + """(type name or None, initializer dict or None) for a `new` expression, else None. + + Handles `new T()`, `new T() { ... }`, `new T { ... }` (object initializer with no constructor + parentheses - a very common SAM form), `new A.B.T() { ... }`, and target-typed `new ()`/`new()` + whose type comes from the declared type. + """ + expression = expression.strip() + if not expression.startswith("new"): + return None + rest = expression[3:].lstrip() + if rest.startswith("("): # target-typed `new (...)` / `new()` + body, after = take_balanced(rest, 0) + type_name = declared_type if declared_type != "var" else None + else: + match = re.match(r"([A-Za-z_][\w:.<>,\[\]]*)\s*(?:\(|\{)", rest) + if not match: + return None + type_name = match.group(1) + if rest[match.end() - 1] == "(": + body, after = take_balanced(rest, match.end() - 1) + else: + body, after = "", match.end() - 1 # `new T { ... }`: initializer only + if body.strip(): + return None # constructor effects/overloads are outside the supported parameter subset + tail = rest[after:] + initializer = None + brace = tail.find("{") + if brace != -1 and tail[:brace].strip() == "": + brace_body, end = take_balanced(tail, brace) + if tail[end:].strip(): + return None + initializer = parse_initializer(brace_body) + elif tail.strip(): + return None + return type_name, initializer + + +def access_of(raw): + """`GH_ParamAccess.item` -> `item`.""" + if raw is None: + return None + value = raw.strip() + if not value: + return None + return value.split(".")[-1].strip() + + +def visibility_of(raw): + if raw is None: + return None + value = raw.strip() + if value.startswith("ParamVisibility."): + value = value[len("ParamVisibility."):] + return value or None + + +def create_diagnostic_record(code, message, severity="warning"): + return {"code": code, "message": message, "severity": severity} + + +def type_kind(type_name): + """`builtin` (Grasshopper `Param_*`), `goo` (a SAM `...Param` class), `external` (any other + constructed parameter type, e.g. RhinoInside.Revit's `HostObject`), or None when the type text is + not a usable type name at all. + """ + if not type_name: + return None + short = simple_type(type_name) + if re.fullmatch(r"Param_\w+", short): + return "builtin" + if re.fullmatch(r"\w*Param", short): + return "goo" + if re.fullmatch(r"[\w.<> ,]+", type_name): + return "external" + return None + + +def is_param_type_name(type_name): + """A recognisable Grasshopper parameter type name: built-in `Param_X` or SAM `XParam`.""" + return type_kind(type_name) in ("builtin", "goo") + + +FACTORY_RE = re.compile( + r"(?:public|internal|private|protected)?\s*static\s+" + r"(?:global::[\w.]*\.)?(?PParam_\w+|\w+Param)\s+(?P[A-Za-z_]\w*)\s*\((?P[^)]*)\)\s*\{", re.S) + + +def substitute_identifiers(text, mapping): + """Replace whole identifiers in `text` with the mapped expression.""" + for name, value in mapping.items(): + text = re.sub(r"(?,\[\]]*)\s*(?:\(|\{)", method_body)) + if len(constructions) != 1: + continue + constructed = constructions[0] + type_name = constructed.group(1) + if type_name is None: + continue + if method_body[constructed.end() - 1] == "(": + _, after = take_balanced(method_body, constructed.end() - 1) + else: + after = constructed.end() - 1 + tail = method_body[after:] + brace_at = tail.find("{") + if brace_at == -1 or tail[:brace_at].strip() != "": + continue + initializer_body, _ = take_balanced(tail, brace_at) + initializer = parse_initializer(initializer_body) + default = re.search(r"\.\s*SetPersistentData\s*\(", method_body) + default_expression = None + if default: + default_expression, _ = take_balanced(method_body, default.end() - 1) + formal = [] + for part in split_top_level(match.group("args")): + part = part.strip() + if not part: + continue + tokens = part.split() + if len(tokens) < 2: + formal = [] + break + formal.append(identifier(tokens[-1])) + if not formal: + continue + factories[match.group("name")] = { + "type": type_name, + "formal": formal, + "init": initializer, + "default": default_expression.strip() if default_expression else None, + } + return factories + + +# --- project discovery -------------------------------------------------------------------------------- + +def xml_local(tag): + return tag.rsplit("}", 1)[-1] if "}" in tag else tag + + +def parse_project(path): + """(root Element, error message). ElementTree handles both `"` and `'` attribute quoting.""" + try: + return ET.parse(long_path(path)).getroot(), None + except ET.ParseError as error: + return None, "csproj could not be parsed as XML: %s" % error + except OSError as error: + return None, "csproj could not be read: %s" % error + + +def project_references(root): + """(direct evidence strings, referenced assembly simple names, project reference paths).""" + direct, assemblies, projects = [], [], [] + for element in root.iter(): + name = xml_local(element.tag) + if name == "PackageReference" or name == "Reference": + include = element.get("Include") or element.get("Update") or "" + if include: + simple = include.split(",")[0].strip() + assemblies.append(simple) + if "grasshopper" in simple.lower() or simple.lower() in ("gh_io", "gh_util"): + direct.append("%s Include=%s" % (name, simple)) + elif name == "HintPath": + value = (element.text or "").strip() + if value: + simple = os.path.basename(value) + assemblies.append(simple) + if "grasshopper" in value.lower(): + direct.append("HintPath=%s" % value) + elif name == "ProjectReference": + include = element.get("Include") or "" + if include: + projects.append(include.replace("\\", "/")) + return direct, assemblies, projects + + +def compile_removed(root): + """Predicate for files excluded from compilation by the project's `` globs.""" + patterns = [] + for element in root.iter(): + if xml_local(element.tag) == "Compile": + pattern = element.get("Remove") + if pattern: + patterns.append(pattern.replace("\\", "/")) + + def removed(relative): + relative = relative.replace("\\", "/") + return any(fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(relative, pattern.replace("**/", "")) + for pattern in patterns) + + return removed + + +def discover_projects(repo): + """Every .csproj in the repository, with the evidence for its Grasshopper classification. + + Direct: the project itself references Grasshopper (package, assembly or hint path). + Indirect: it references a project in the repository that is itself a Grasshopper project - resolved + to a fixed point, so a chain of project references is followed. + + ElementTree parses the XML, so both `"` and `'` attribute quoting work and a project that cannot be + parsed is reported instead of being silently dropped. + """ + candidates = {} + for root, dirs, files in os.walk(repo): + dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS) + for file_name in sorted(files): + if not file_name.endswith(".csproj"): + continue + path = os.path.join(root, file_name) + element, error = parse_project(path) + entry = {"name": file_name[:-7], "path": path, "root": element, "error": error, + "direct": [], "assemblies": [], "projects": [], + "is_test": "Test" in file_name} + if element is not None: + entry["direct"], entry["assemblies"], entry["projects"] = project_references(element) + candidates[path] = entry + + by_path = {os.path.normcase(os.path.normpath(p)): p for p in candidates} + state = {} + for path, info in candidates.items(): + if info["error"]: + continue + if info["direct"] or any("grasshopper" in assembly.lower() for assembly in info["assemblies"]): + state[path] = "direct" + + changed = True + while changed: + changed = False + for path, info in candidates.items(): + if path in state or info["error"]: + continue + for reference in info["projects"]: + target = os.path.normcase(os.path.normpath(os.path.join(os.path.dirname(path), reference))) + if target in by_path and by_path[target] in state: + state[path] = "indirect:%s" % candidates[by_path[target]]["name"] + changed = True + break + + return candidates, state + + +# --- class classification ----------------------------------------------------------------------------- + +def declared_bases(base_text): + """Simple base type names of a class declaration, interfaces included.""" + if not base_text: + return [] + result = [] + for part in split_top_level(base_text): + part = part.strip() + if not part: + continue + name = simple_type(part.split("(")[0]) + if name: + result.append(name) + return result + + +def classify_classes(repo, publish_dirs, scan_dirs=None): + """Every class that declares a ComponentGuid, with its repository ancestry. + + `publish_dirs` are the scanned Grasshopper projects whose classes may become published components; + `scan_dirs` (default: the same) additionally supplies classes used **only** to resolve ancestry, so + a base class that lives in a non-Grasshopper project of the same repository still resolves instead + of leaving its subclasses unclassified. Test projects are never scanned. + + Returns (components, parameter_classes, unclassified, all_classes). Classification is by + **ancestry**, resolved through the repository's own classes and then against the known Grasshopper + root types - never by the class name, so a real component called `NewComputeParam` is a component + and `GooSpaceParam` is a parameter. Abstract classes are kept as resolvable ancestry but are never + published components. Files excluded by `` are not compiled and are skipped. + """ + scan_dirs = scan_dirs if scan_dirs is not None else publish_dirs + publish = {os.path.normcase(os.path.normpath(directory)) for directory, _root in publish_dirs} + + candidates = [] + for project_dir, root_element in scan_dirs: + removed = compile_removed(root_element) if root_element is not None else (lambda relative: False) + is_publishable = os.path.normcase(os.path.normpath(project_dir)) in publish + for root, dirs, files in os.walk(project_dir): + dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS) + for file_name in sorted(files): + if not file_name.endswith(".cs") or file_name.endswith(".Designer.cs"): + continue + path = os.path.join(root, file_name) + if removed(os.path.relpath(path, project_dir)): + continue + source = read_text(path) + code = BLOCK_COMMENT_RE.sub(" ", COMMENT_RE.sub(r"\1", source)) + # Match declarations anywhere on a line, but never inside description strings. + declaration_code = re.sub(r'"(?:[^"\\]|\\.)*"', lambda m: " " * len(m.group(0)), code) + matches = list(CLASS_RE.finditer(declaration_code)) + for index, match in enumerate(matches): + brace = code.find("{", match.end()) + try: + body, _ = take_balanced(code, brace) + except ValueError: + body = code[match.end():] + declaration = match.group(0) + generics = re.search(r"class\s+\w+\s*<\s*([^>]*)\s*>", declaration) + namespaces = list(re.finditer(r"\bnamespace\s+([\w.]+)\s*[;{]", code[:match.start()])) + namespace = namespaces[-1].group(1) if namespaces else "" + aliases = dict(re.findall(r"\busing\s+(\w+)\s*=\s*([\w:.<> ,]+)\s*;", code[:match.start()])) + candidates.append({ + "class": match.group(2), + "qualifiedClass": namespace + "." + match.group(2) if namespace else match.group(2), + "context": {"namespace": namespace, "aliases": aliases, + "usings": re.findall(r"\busing\s+([\w.]+)\s*;", code[:match.start()])}, + "modifiers": match.group(1).strip(), + "abstract": "abstract" in match.group(1), + "publish": is_publishable, + "bases": declared_bases(match.group(3)), + "base_text": match.group(3) or "", + "generics": [identifier(item.strip()) for item in split_top_level(generics.group(1))] if generics else [], + "guid": (GUID_RE.search(body).group(1).lower() if GUID_RE.search(body) else None), + "body": body, + "source": os.path.relpath(path, repo).replace("\\", "/"), + }) + + bases_by_class = {} + for candidate in candidates: + bases_by_class.setdefault(candidate["class"], []).extend(candidate["bases"]) + + components, parameters, unclassified = [], [], [] + for candidate in candidates: + if candidate["abstract"] or not candidate["publish"]: + continue # abstract bases and non-Grasshopper projects are ancestry, not published components + kind, reason = resolve_kind(candidate["class"], candidate["bases"], bases_by_class) + if not candidate["guid"]: + if kind == "component" or re.search(r"\b(?:System\.)?Guid\s+ComponentGuid\s*(?:=>|\{)", candidate["body"]): + candidate["kind_reason"] = "ComponentGuid could not be established: " + reason + unclassified.append(candidate) + continue + candidate["kind"] = kind + candidate["kind_reason"] = reason + if kind == "component": + components.append(candidate) + elif kind == "parameter": + parameters.append(candidate) + else: + unclassified.append(candidate) + # A simple-name collision must not silently select the last class in traversal order. + by_name = {} + for candidate in candidates: + by_name.setdefault(candidate["class"], []).append(candidate) + known_types = {item["qualifiedClass"] for item in candidates} + declarations = {} + for candidate in candidates: + declarations.setdefault(candidate["qualifiedClass"], []).append(candidate) + for candidate in candidates: + candidate["context"]["types"] = known_types + candidate["context"]["typeDeclarations"] = declarations + for candidate in components: + candidate["ambiguousBases"] = [base for base in candidate["bases"] if len(by_name.get(base, [])) > 1] + return components, parameters, unclassified, {name: items[0] for name, items in by_name.items() if len(items) == 1} + + +def resolve_kind(class_name, bases, bases_by_class, seen=None): + """('component'|'parameter'|'unresolved', reason) from the base chain, source-based.""" + seen = set(seen or ()) + if class_name in seen: + return "unresolved", "circular base chain" + seen.add(class_name) + if not bases: + return "unresolved", "no base type declared" + + if any(base in PARAMETER_ROOTS for base in bases): + return "parameter", "derives from %s" % next(base for base in bases if base in PARAMETER_ROOTS) + if any(base in COMPONENT_ROOTS for base in bases): + return "component", "derives from %s" % next(base for base in bases if base in COMPONENT_ROOTS) + + for base in bases: + if base in bases_by_class: + kind, reason = resolve_kind(base, bases_by_class[base], bases_by_class, seen) + if kind != "unresolved": + return kind, "%s -> %s" % (base, reason) + return "unresolved", "base chain leaves the repository at unknown type(s): %s" % ", ".join(bases) + + +def ancestry(class_name, bases_by_class, seen=None): + """Every base type name reachable from a class, through the repository's own classes.""" + seen = seen if seen is not None else set() + if class_name in seen: + return seen + seen.add(class_name) + for base in bases_by_class.get(class_name, []): + if base in bases_by_class: + ancestry(base, bases_by_class, seen) + else: + seen.add(base) + return seen + + +# --- parameter extraction ------------------------------------------------------------------------------ + +class Extractor: + """Extracts a component's declared parameter set from C# source, or explains why it cannot.""" + + def __init__(self, class_name, body, substitutions=None, context=None): + self.class_name = class_name + self.body = body + self.substitutions = substitutions or {} + self.context = context or {} + self.reasons = [] + self.external_types = set() + self._factories = None + + @property + def factories(self): + if self._factories is None: + self._factories = parse_factories(self.body) + return self._factories + + def reason(self, message): + if message not in self.reasons: + self.reasons.append(message) + + # -- helpers ------------------------------------------------------------------------------- + def param(self, name, nickname, description, type_name, optional, access, visibility, + persistent_default, default_literal, registration, extra=None, position=None): + resolved_type = type_identity(type_name, self.context) + if resolved_type is None: + self.reason("parameter runtime type identity is unresolved or ambiguous: %s at %s" % (type_name, position)) + elif not parameter_defaults_proven(resolved_type, self.context): + self.reason("constructor defaults of parameter type %s are not proven by the supported source subset at %s" + % (resolved_type, position)) + if persistent_default is not None and not default_literal: + self.reason("registered default is not a proven literal at %s (%s)" % (position, persistent_default)) + record = { + "name": name, + "nickname": nickname, + "description": description, + "type": resolved_type, + "typeKind": type_kind(resolved_type), + "optional": optional, + "access": access, + "visibility": visibility, + "persistentDefault": persistent_default, + "defaultLiteral": default_literal, + "registration": registration, + } + if position is not None: + record["declaration"] = position + if extra: + record.update(extra) + return record + + def bind(self, bindings, statement, position): + """Record `Type v = `, `v = ` and `Type v;` bindings.""" + assignment = split_assignment(statement) + if not assignment: + bare = re.fullmatch(r"\s*(?P[A-Za-z_][\w:.<>,\[\]]*)\s+(?P" + IDENT + r")\s*", statement) + if bare: + # `Param_String param_String;` followed by `param_String = new() { ... }`: the type is + # declared here and the initializer comes later, so record the declaration's type. + bindings[identifier(bare.group("name"))] = {"type": bare.group("type"), "init": {}, + "default": None, "defaultLiteral": None, "at": position, "records": []} + return + target = assignment_target(assignment[0]) + if not target: + return + declared_type, name = target + rhs = assignment[1].strip() + if rhs.startswith("new"): + parsed = parse_new_expression(rhs, declared_type) + if parsed: + type_name, initializer = parsed + previous = bindings.get(name) + if type_name is None and previous is not None: + # `v = new() { ... }` re-uses the variable's declared type; without this the type of + # every reassigned parameter variable (a very common SAM pattern) is lost. + type_name = previous.get("type") + bindings[name] = {"type": type_name, "init": initializer or {}, "default": None, + "defaultLiteral": None, "at": position, "records": []} + else: + bindings.pop(name, None) + else: + bindings.pop(name, None) + + def param_from_binding(self, binding, position): + if binding is None: + return None + initializer = binding["init"] + return self.param( + name=self.literal(initializer.get("Name"), position, "Name"), + nickname=self.literal(initializer.get("NickName"), position, "NickName"), + description=self.literal(initializer.get("Description"), position, "Description", required=False), + type_name=binding["type"], + optional=self.optional(initializer, position), + access=self.access(initializer.get("Access"), position), + visibility=None, + persistent_default=self.default_text(binding), + default_literal=bool(binding["defaultLiteral"]) if binding["default"] is not None else None, + registration="declarative", + position=position, + ) + + def literal(self, raw, position, field, required=True): + if raw is None: + if required: + self.reason("parameter %s not declared at %s" % (field, position)) + return None + for parameter, argument in self.substitutions.items(): + # `typeof(T).Name` in a generic base resolves to the closed type argument the subclass + # supplies (`class X : GH_SAMEnumComponent` publishes the output + # "ApertureType"). Only a type parameter the subclass actually closes is substituted. + raw = raw.replace("typeof(%s).Name" % parameter, '"%s"' % argument) + text, is_literal = literal_string(raw) + if not is_literal: + if required: + self.reason("parameter %s is not a string literal at %s (%s)" % (field, position, raw.strip()[:60])) + return text + return text + + def boolean(self, raw, position, field): + if raw is None: + return None + value = raw.strip().lower() + if value == "true": + return True + if value == "false": + return False + self.reason("parameter %s is not a literal boolean at %s (%s)" % (field, position, raw.strip()[:40])) + return None + + def optional(self, initializer, position): + """Grasshopper's `Optional` is false unless the declaration sets it; an unreadable value is not. + + This is a real, determinable default, so an absent `Optional` must not be reported as + unresolved - only an explicitly non-literal one is. + """ + if "Optional" not in (initializer or {}): + return False + return self.boolean(initializer.get("Optional"), position, "Optional") + + def access(self, raw, position): + """Grasshopper's parameter access is `item` unless the declaration sets it.""" + if raw is None or not str(raw).strip(): + return "item" + value = access_of(raw) + if value not in ACCESS_VALUES: + self.reason("unrecognised parameter access '%s' at %s" % (value, position)) + return None + return value + + def default_text(self, binding): + if binding["default"] is None: + return None + return binding["default"].strip() + + def check_type(self, type_name, position): + identity = type_identity(type_name, self.context) + kind = type_kind(identity) + if kind is None: + self.reason("parameter runtime type could not be determined at %s" % position) + elif kind == "external": + self.external_types.add(identity) + + def factory_parameter(self, expression, position): + """Resolve `Helper("name", "description")` through a local parameter factory, or None. + + The factory's formal parameters are substituted with the call arguments, so the resolved + parameter carries the same name, nickname, description, access, optionality and registered + default that a runtime instance would - or, if the substitution does not produce literals, it + is reported rather than guessed. + """ + match = re.fullmatch(r"(?P[A-Za-z_]\w*)\s*\((?P.*)\)\s*", expression.strip(), re.S) + if not match: + return None + factory = self.factories.get(match.group("name")) + if factory is None: + return None + self.reason("local parameter factory %s is inventory-only: return and mutation effects are not proven at %s" + % (match.group("name"), position)) + arguments = [part.strip() for part in split_top_level(match.group("args"))] + if len(arguments) != len(factory["formal"]): + self.reason("parameter factory %s is called with %d argument(s) at %s, not its %d parameter(s)" + % (match.group("name"), len(arguments), position, len(factory["formal"]))) + return None + mapping = dict(zip(factory["formal"], arguments)) + initializer = {key: substitute_identifiers(value, mapping) for key, value in factory["init"].items()} + default = substitute_identifiers(factory["default"], mapping) if factory["default"] else None + self.check_type(factory["type"], position) + return self.param( + name=self.literal(initializer.get("Name"), position, "Name"), + nickname=self.literal(initializer.get("NickName"), position, "NickName"), + description=self.literal(initializer.get("Description"), position, "Description", required=False), + type_name=factory["type"], + optional=self.optional(initializer, position), + access=self.access(initializer.get("Access"), position), + visibility=None, + persistent_default=default, + default_literal=(bool(re.fullmatch(r'-?\d+(\.\d+)?[fFdDmM]?|true|false|"(?:[^"\\]|\\.)*"', default)) + if default else None), + registration="factory:%s" % match.group("name"), + position=position, + ) + + # -- declarative `GH_SAMParam[] Inputs / Outputs` ------------------------------------------- + def declarative(self, side, property_body): + params = [] + bindings = {} + # Certification is a bounded straight-line language, not every C# expression we can scan. + # The old walker remains useful for inventory, but descending into branches/loops loses + # cardinality and execution order and therefore cannot establish a complete contract. + masked = re.sub(r'"(?:[^"\\]|\\.)*"', '""', property_body) + if re.search(r"\b(?:if|else|for|foreach|while|switch|try|catch|throw|yield)\b|#|=>", masked): + self.reason("control flow or unsupported expression in %s declaration; returned parameter set is not proven" % side) + statements = walk_statements(property_body) + if len(statements) == 1 and not statements[0].strip().startswith("return"): + # Expression-bodied property: its expression is the actual return value. + statements = ["return " + statements[0].strip()] + collection = None + returned = False + for statement in statements: + statement = statement.strip() + position = "%s/%s" % (side, self.position_of(property_body, statement)) + skeleton = self.wrapper_skeleton(statement) + if returned: + self.reason("statements after the return in %s declaration are not supported" % side) + if re.search(r"Params\s*\.\s*Register(Input|Output)Param\s*\(", statement): + self.reason("a parameter is registered directly through Params.Register*Param inside the %s declaration and cannot be attributed" % side) + continue + if statement.startswith("return "): + returned = True + expression = skeleton[7:].strip() + same_collection = collection and (re.fullmatch(re.escape(collection) + r"\s*\.\s*ToArray\s*\(\s*\)", expression) + or re.fullmatch(r"\[\s*\.\.\s*" + re.escape(collection) + r"\s*\]", expression)) + if (collection and not same_collection) or not self.collection_expression(expression, collection): + self.reason("unsupported %s return expression: the returned parameter set cannot be established (%s)" + % (side, skeleton[7:87])) + else: + assignment = split_assignment(skeleton) + target = assignment_target(assignment[0]) if assignment else None + rhs = assignment[1].strip() if assignment else "" + if target and self.collection_expression(rhs): + if collection is not None: + self.reason("multiple parameter collections in %s declaration are not supported" % side) + collection = target[1] + elif collection and re.fullmatch(re.escape(collection) + r"\s*\.\s*Add\s*\(\s*PARAM\s*\)", skeleton): + pass + elif collection and re.match(re.escape(collection) + r"\s*\.\s*AddRange\s*\(", skeleton): + args, end = take_balanced(skeleton, skeleton.index("(")) + if skeleton[end:].strip() or not self.collection_expression(args): + self.reason("unsupported AddRange expression at %s" % position) + elif self.metadata_assignment(bindings, statement, position): + continue + elif re.fullmatch(IDENT + r"\s*\.\s*SetPersistentData\s*\(.*\)", statement, re.S): + pass + elif re.fullmatch(IDENT + r"\s*\.\s*DataMapping\s*=\s*GH_DataMapping\.\w+", statement): + pass # data mapping is outside this metadata contract + elif target and parse_new_expression(rhs, target[0]) is not None: + pass + elif re.fullmatch(r"[\w:.<>,\[\]]+\s+" + IDENT, statement): + pass # bare declaration; its construction is required at use + else: + self.reason("unsupported statement in %s declaration at %s (%s)" % (side, position, skeleton[:80])) + self.bind(bindings, statement, position) + self.apply_persistent_data(bindings, statement) + params.extend(self.emit(statement, side, property_body, bindings)) + if not returned: + self.reason("no proven return value for %s declaration" % side) + return params + + def wrapper_skeleton(self, statement): + """Replace complete wrapper constructions, leaving their surrounding collection syntax.""" + cursor, result = 0, "" + for match in WRAPPER_RE.finditer(statement): + if match.start() < cursor: + continue + _, end = take_balanced(statement, match.end() - 1) + result += statement[cursor:match.start()] + "PARAM" + cursor = end + return result + statement[cursor:] + + def collection_expression(self, expression, collection=None): + expression = expression.strip() + if collection and (re.fullmatch(re.escape(collection) + r"\s*\.\s*ToArray\s*\(\s*\)", expression) + or re.fullmatch(r"\[\s*\.\.\s*" + re.escape(collection) + r"\s*\]", expression)): + return True + if re.fullmatch(r"new\s+GH_SAMParam\s*\[\s*0\s*\]", expression): + return True + if re.fullmatch(r"(?:System\.)?Array\.Empty\s*\(\s*\)", expression): + return True + list_type = r"(?:System\.Collections\.Generic\.)?List\s*<\s*GH_SAMParam\s*>" + if re.fullmatch(r"new\s+" + list_type + r"\s*\(\s*\)", expression): + return True + expression = re.sub(r"^new\s+(?:GH_SAMParam\s*\[\s*\]|" + list_type + r"\s*(?:\(\s*\))?)\s*", "", expression) + if not ((expression.startswith("[") and expression.endswith("]")) + or (expression.startswith("{") and expression.endswith("}"))): + return False + return all(not part.strip() or part.strip() == "PARAM" for part in split_top_level(expression[1:-1])) + + def metadata_assignment(self, bindings, statement, position): + match = re.fullmatch(r"(" + IDENT + r")\s*\.\s*(Name|NickName|Description|Optional|Access)\s*=\s*(.*)", statement, re.S) + if not match: + return False + binding = bindings.get(identifier(match.group(1))) + if binding is None: + self.reason("metadata assignment on unknown parameter at %s" % position) + return True + field, raw = match.group(2), match.group(3) + binding["init"][field] = raw + if field == "Optional": + value, key = self.boolean(raw, position, field), "optional" + elif field == "Access": + value, key = self.access(raw, position), "access" + else: + value = self.literal(raw, position, field, required=field != "Description") + key = {"Name": "name", "NickName": "nickname", "Description": "description"}[field] + for record in binding["records"]: + record[key] = value + return True + + def position_of(self, body, statement): + index = body.find(statement) + return "offset %d" % index if index >= 0 else "unknown offset" + + def apply_persistent_data(self, bindings, statement): + match = re.match(r"^\s*(" + IDENT + r")\s*\.\s*SetPersistentData\s*\(", statement.strip()) + if not match: + return + name = identifier(match.group(1)) + if name not in bindings: + self.reason("SetPersistentData on unknown variable '%s'" % name) + return + args, _ = take_balanced(statement.strip(), match.end() - 1) + value = args.strip() + binding = bindings[name] + binding["default"] = value + binding["defaultLiteral"] = bool(re.fullmatch(r'-?\d+(\.\d+)?[fFdDmM]?|true|false|"(?:[^"\\]|\\.)*"', value)) + if not binding["defaultLiteral"]: + self.reason("registered default is not a proven literal (%s)" % value) + for record in binding["records"]: + record["persistentDefault"] = value + record["defaultLiteral"] = binding["defaultLiteral"] + + def emit(self, statement, side, body, bindings): + """Every `new GH_SAMParam(...)` in one statement, in order. + + This is the identity of a published parameter by construction: a parameter is published only + through a GH_SAMParam, whether it is added to a list, put in a collection expression, returned + directly or passed to AddRange. Requiring a particular surrounding call is what made the + earlier extractor miss whole components. + """ + params = [] + cursor = 0 + while True: + match = WRAPPER_RE.search(statement, cursor) + if match is None: + break + args, after = take_balanced(statement, match.end() - 1) + cursor = after + parts = split_top_level(args) + first = parts[0].strip() if parts else "" + visibility = visibility_of(parts[1]) if len(parts) == 2 else "Binding" + position = "%s/%s" % (side, self.position_of(body, statement)) + if len(parts) not in (1, 2) or visibility not in ("Binding", "Default", "Voluntary", "Mandatory"): + self.reason("wrapper visibility/overload is not proven at %s" % position) + if not first: + self.reason("GH_SAMParam with no arguments at %s" % position) + continue + if first.startswith("new"): + parsed = parse_new_expression(first) + if parsed is None: + self.reason("GH_SAMParam argument is not a recognisable parameter expression at %s" % position) + continue + type_name, initializer = parsed + self.check_type(type_name, position) + record = self.param( + name=self.literal((initializer or {}).get("Name"), position, "Name"), + nickname=self.literal((initializer or {}).get("NickName"), position, "NickName"), + description=self.literal((initializer or {}).get("Description"), position, "Description", required=False), + type_name=type_name, + optional=self.optional(initializer or {}, position), + access=self.access((initializer or {}).get("Access"), position), + visibility=visibility, + persistent_default=None, + default_literal=None, + registration="declarative", + position=position, + ) + else: + name = identifier(simple_type(first.split(".")[-1]) or first) + binding = bindings.get(name) + if binding is not None: + self.check_type(binding["type"], position) + record = self.param_from_binding(binding, position) + record["visibility"] = visibility + record["persistentDefault"] = self.default_text(binding) + if binding["default"] is not None: + record["defaultLiteral"] = bool(binding["defaultLiteral"]) + binding["records"].append(record) + else: + record = self.factory_parameter(first, position) + if record is None: + if re.fullmatch(IDENT, name): + self.reason("GH_SAMParam uses parameter variable '%s' that was not declared at %s" % (name, position)) + else: + self.reason("GH_SAMParam argument at %s is not a parameter construction, a declared variable " + "or a known parameter factory (%s)" % (position, first.strip()[:70])) + continue + record["visibility"] = visibility + params.append(record) + return params + + # -- manager registration `RegisterInputParams` / `RegisterOutputParams` --------------------- + def manager(self, side, method_body): + params = [] + masked = re.sub(r'"(?:[^"\\]|\\.)*"', '""', method_body) + if re.search(r"\b(?:if|else|for|foreach|while|switch|try|catch|throw|yield)\b|#", masked): + self.reason("control flow in %s manager registration is not proven" % side) + for statement in walk_statements(method_body): + statement = statement.strip() + if not (re.fullmatch(r"\w+\s*\.\s*Add\w*Parameter\s*\(.*\)", statement, re.S) + or (split_assignment(statement) and assignment_target(split_assignment(statement)[0]) + and parse_new_expression(split_assignment(statement)[1], assignment_target(split_assignment(statement)[0])[0])) + or re.fullmatch(IDENT + r"\s*\.\s*Optional\s*=\s*(?:true|false)", statement)): + self.reason("unsupported %s manager statement (%s)" % (side, statement[:80])) + if re.search(r"Params\s*\.\s*(Input|Output)\s*\[", statement): + self.reason("optionality, access or visibility is set through Params.Input/Output and cannot be attributed (%s)" % side) + if "Params.RegisterInputParam" in statement or "Params.RegisterOutputParam" in statement: + self.reason("a parameter is registered directly through Params.Register*Param and cannot be attributed (%s)" % side) + continue + params.extend(self.manager_statement(statement, side, method_body)) + return params + + def manager_statement(self, statement, side, body): + match = re.search(r"\.\s*Add([A-Za-z_]*?)Parameter\s*\(", statement) + if not match: + if re.search(r"\.\s*Add\(.*GH_SAMParam", statement): + self.reason("GH_SAMParam is passed to a manager call that is not AddParameter (%s)" % side) + return [] + helper = match.group(1) + args, _ = take_balanced(statement, match.end() - 1) + parts = [part.strip() for part in split_top_level(args)] + position = "%s/%s" % (side, self.position_of(body, statement)) + variable_optional = None + + if helper == "": + if not parts: + self.reason("AddParameter with no arguments at %s" % position) + return [] + first = parts[0] + if first.startswith("new"): + parsed = parse_new_expression(first) + if parsed is None: + self.reason("AddParameter argument is not a recognisable parameter expression at %s" % position) + return [] + type_name, initializer = parsed + initializer = initializer or {} + variable_optional = self.optional(initializer, position) + elif not re.fullmatch(IDENT, identifier(simple_type(first.split(".")[-1]) or first)): + record = self.factory_parameter(first, position) + if record is None: + self.reason("AddParameter argument at %s is not a parameter construction, a declared variable " + "or a known parameter factory (%s)" % (position, first.strip()[:70])) + return [] + return [record] + else: + name = simple_type(first.split(".")[-1]) or first + variable = identifier(name) + found = self.find_variable(" ".join([first, variable])) + if found is None: + self.reason("AddParameter uses parameter variable '%s' that cannot be resolved unambiguously at %s" % (variable, position)) + return [] + type_name, initializer = found + initializer = initializer or {} + variable_optional = self.variable_optional(variable, position) + if variable_optional is None: + variable_optional = self.optional(initializer, position) + if re.search(r"\b" + re.escape(variable) + r"\s*\.\s*(Name|NickName|Access|Description)\s*=", self.body): + self.reason("parameter identity of variable '%s' is set outside the registration call and cannot be attributed (%s)" % (variable, position)) + if len(parts) == 1: + name_raw, nick_raw, desc_raw, access_raw = (initializer.get("Name"), initializer.get("NickName"), + initializer.get("Description"), initializer.get("Access")) + elif len(parts) == 4: + name_raw, nick_raw, desc_raw, access_raw = parts[1], parts[2], parts[3], initializer.get("Access") + elif len(parts) == 5: + name_raw, nick_raw, desc_raw, access_raw = parts[1], parts[2], parts[3], parts[4] + else: + self.reason("unrecognised AddParameter overload with %d arguments at %s" % (len(parts), position)) + return [] + else: + type_name = MANAGER_HELPER_TYPES.get(helper) + if type_name is None: + self.reason("unrecognised parameter manager helper Add%sParameter at %s" % (helper, position)) + return [] + type_name = "Grasshopper.Kernel.Parameters." + type_name + if len(parts) == 3: + name_raw, nick_raw, desc_raw, access_raw = parts[0], parts[1], parts[2], None + elif len(parts) == 4: + name_raw, nick_raw, desc_raw, access_raw = parts[0], parts[1], parts[2], parts[3] + elif len(parts) == 5: + name_raw, nick_raw, desc_raw, access_raw = parts[0], parts[1], parts[2], parts[3] + else: + self.reason("unrecognised Add%sParameter overload with %d arguments at %s" % (helper, len(parts), position)) + return [] + initializer = {} + + self.check_type(type_name, position) + persistent = parts[4] if helper != "" and len(parts) >= 5 else None + if variable_optional is None: + # Grasshopper's `Optional` is false unless something sets it; an explicit + # `v.Optional = ...` is read from the parameter object, so an absent one is false. + variable_optional = False + record = self.param( + name=self.literal(name_raw, position, "Name"), + nickname=self.literal(nick_raw, position, "NickName"), + description=self.literal(desc_raw, position, "Description", required=False), + type_name=type_name, + optional=variable_optional, + access=self.access(access_raw, position) or "item", + visibility=None, + persistent_default=persistent.strip() if persistent else None, + default_literal=(bool(re.fullmatch(r'-?\d+(\.\d+)?[fFdDmM]?|true|false|"(?:[^"\\]|\\.)*"', persistent.strip())) + if persistent else None), + registration="manager" if helper == "" else "manager-helper", + position=position, + ) + record["accessExplicit"] = access_raw is not None + return [record] + + def find_variable(self, expression): + """(type, initializer) for a parameter object held in a variable, or None if ambiguous. + + The variable may be declared anywhere in the class body, including inside a method (statement + splitting is depth-limited, so this scans the source directly). An object that is reassigned to + different values cannot be attributed to one registration, so it is refused rather than guessed. + """ + name = re.search(r"(" + IDENT + r")\s*$", expression.strip()) + if not name: + return None + variable = identifier(name.group(1)) + found = [] + for match in ASSIGNMENT_RE.finditer(self.body): + if identifier(match.group("name")) != variable: + continue + parsed = parse_new_expression(match.group("rhs").strip(), match.group("type")) + if parsed is not None: + found.append(parsed) + if not found: + return None + if any(item != found[0] for item in found[1:]): + return None + return found[0] + + def variable_optional(self, variable, position): + """`v.Optional = true|false` set on a parameter object outside the registration call.""" + values = set() + for match in re.finditer(r"\b" + re.escape(variable) + r"\s*\.\s*Optional\s*=\s*(true|false)\b", self.body): + values.add(match.group(1) == "true") + if len(values) > 1: + self.reason("optionality of parameter variable '%s' is set to conflicting values (%s)" % (variable, position)) + return None + return values.pop() if values else None + + # -- entry point --------------------------------------------------------------------------- + def extract(self, class_body, reason_prefix=None): + inputs_property = re.search(r"GH_SAMParam\s*\[\s*\]\s+Inputs\b", class_body) + outputs_property = re.search(r"GH_SAMParam\s*\[\s*\]\s+Outputs\b", class_body) + register_inputs = re.search(r"override\s+void\s+RegisterInputParams\s*\(", class_body) + register_outputs = re.search(r"override\s+void\s+RegisterOutputParams\s*\(", class_body) + + if inputs_property and outputs_property: + inputs_body = self.member_body(class_body, inputs_property.end(), accessor=True) + outputs_body = self.member_body(class_body, outputs_property.end(), accessor=True) + return self.declarative("input", inputs_body), self.declarative("output", outputs_body), "declarative" + + if register_inputs or register_outputs: + if re.search(r"IGH_VariableParameterComponent", class_body): + self.reason("variable-parameter component registered through the parameter manager: the insertable parameter set cannot be enumerated") + inputs = self.manager("input", self.member_body(class_body, register_inputs.end(), accessor=False)) if register_inputs else [] + outputs = self.manager("output", self.member_body(class_body, register_outputs.end(), accessor=False)) if register_outputs else [] + if not register_inputs: + self.reason("no RegisterInputParams override: the input set may be inherited") + if not register_outputs: + self.reason("no RegisterOutputParams override: the output set may be inherited") + return inputs, outputs, "manager" + + if re.search(r"\b(?:Inputs|Outputs)\s*(?:=>|\{)", class_body): + self.reason("unsupported or incomplete Inputs/Outputs declaration (for example ParamDefinition[]); " + "the published parameter set cannot be established") + return [], [], "unsupported" + + if reason_prefix: + self.reason(reason_prefix) + if re.search(r"GH_SAMParam|AddParameter\s*\(|Add[A-Za-z_]+Parameter\s*\(|Params\s*\.\s*Register", class_body): + self.reason("parameter declarations are present in the class but no declaration form was recognised: " + "the published parameter set cannot be established") + return [], [], "inherited" + + def member_body(self, text, search_from, accessor): + """The statement block of a member. + + A declared property is `{ get { } }`, so the outer brace has to be unwrapped + before statements can be split - otherwise the whole body stays at depth 1 and no statement + boundary is ever seen. A method body is already the statement block. An expression-bodied + member (`=> [ new GH_SAMParam(...) ]`) has no brace at all and is taken up to its `;`. + """ + rest = text[search_from:] + arrow = re.match(r"\s*=>", rest) + if accessor and arrow: + body, _ = self.expression_body(rest, arrow.end()) + return body + brace = text.find("{", search_from) + if brace == -1: + self.reason("a parameter declaration member has no body") + return "" + body, _ = take_balanced(text, brace) + if not accessor: + return body + inner = re.match(r"\s*get\s*\{", body) + if inner: + inner_body, _ = take_balanced(body, body.index("{", inner.start())) + return inner_body + if re.match(r"\s*get\s*=>", body): + arrow_rest = body[body.index("=>") + 2:] + expression, _ = self.expression_body(arrow_rest, 0) + return expression + return body + + def expression_body(self, text, start): + """The text of an expression-bodied member, up to the `;` that ends it (depth-aware).""" + depth, i, instr, esc = 0, start, False, False + while i < len(text): + ch = text[i] + if instr: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + instr = False + elif ch == '"': + instr = True + elif ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == ";" and depth == 0: + return text[start:i], i + 1 + i += 1 + return text[start:], len(text) + + +# --- component model ------------------------------------------------------------------------------------ + +def generic_substitutions(subclass, base_name, base_candidate): + """{type parameter: closed type argument} for `Base` in a subclass's base list. + + Returns None when the base is generic but the subclass does not supply a closed argument (for + example the subclass is itself generic), so the inherited registration stays unresolved. + """ + for part in split_top_level(subclass.get("base_text", "")): + match = re.match(r"([\w.]+)\s*<\s*(.*)\s*>$", part.strip()) + if not match or simple_type(match.group(1)) != base_name: + continue + arguments = [simple_type(item.strip()) or item.strip() for item in split_top_level(match.group(2))] + parameters = base_candidate.get("generics") or [] + if len(arguments) != len(parameters): + return None + own = set(subclass.get("generics") or []) + if any(argument in own for argument in arguments): + return None + return dict(zip(parameters, arguments)) + return {} + + +def build_component(candidate, base_lookup, bases_by_class=None): + """Extract one component, resolving inheritance from repository classes where it is unambiguous.""" + extractor = Extractor(candidate["class"], candidate["body"], context=candidate.get("context")) + inputs, outputs, registration = extractor.extract(candidate["body"]) + current, visited, path = candidate, set(), [] + while registration == "inherited" and not extractor.reasons: + name = current["class"] + if name in visited: + extractor.reason("circular inherited registration: %s" % " -> ".join(path + [name])) + break + visited.add(name) + if current.get("ambiguousBases"): + extractor.reason("ambiguous source base type(s): %s" % current["ambiguousBases"]) + break + bases = [base for base in current["bases"] if base in base_lookup] + if len(bases) != 1: + extractor.reason("inherited registration could not be read from external or ambiguous base(s): %s" + % ", ".join(current["bases"])) + break + base = bases[0] + substitutions = generic_substitutions(current, base, base_lookup[base]) + if substitutions is None: + extractor.reason("base class %s is generic and is not closed with literal type arguments" % base) + break + substitutions = {key: extractor.substitutions.get(value, value) for key, value in substitutions.items()} + current = base_lookup[base] + path.append(base) + inherited = Extractor(candidate["class"], current["body"], substitutions, context=current.get("context")) + inputs, outputs, registration = inherited.extract(current["body"]) + inherited.reasons = extractor.reasons + inherited.reasons + extractor = inherited + if path: + registration = "inherited:" + " -> ".join(path) + + if bases_by_class is not None and registration != "declarative": + if "IGH_VariableParameterComponent" in ancestry(candidate["class"], bases_by_class): + extractor.reason("variable-parameter component whose parameter-manager registration does not enumerate the insertable parameter set") + + for side, params in (("input", inputs), ("output", outputs)): + for param in params: + if param.get("optional") is None: + extractor.reason("optionality of %s '%s' is not determined" % (side, param.get("name"))) + if param.get("access") is None: + extractor.reason("access of %s '%s' is not determined" % (side, param.get("name"))) + if param.get("name") in (None, ""): + extractor.reason("a %s parameter has no literal name" % side) + if param.get("type") is None: + extractor.reason("runtime type of %s '%s' is not determined" % (side, param.get("name"))) + + status = "verified" + if not inputs and not outputs and extractor.reasons: + status = "unresolved" + if extractor.reasons: + status = "unresolved" if status == "unresolved" else "partial" + + return { + "class": candidate["class"], + "guid": candidate["guid"], + "source": candidate["source"], + "registration": registration, + "inputs": inputs, + "outputs": outputs, + "extraction": {"status": status, "reasons": sorted(extractor.reasons), + "externalTypes": sorted(extractor.external_types)}, + } + + +def component_metadata(candidate, component): + """Name/nickname/category/subcategory/description/version read from the constructor.""" + constructor = re.search(r"\b" + candidate["class"] + r"\s*\(\s*\)\s*(?=:\s*base\s*\()", candidate["body"]) + base = base_args(candidate["body"], constructor.end()) if constructor else None + if base and base[-1].startswith("GH_ParamAccess"): + base = base[:-1] + version = VERSION_RE.search(candidate["body"]) + entry = dict(component) + entry.update({ + "name": lit(base[0]) if base else None, + "nickname": lit(base[1]) if base and len(base) > 1 else None, + "category": lit(base[-2]) if base and len(base) >= 4 else None, + "subcategory": lit(base[-1]) if base and len(base) >= 4 else None, + "description": lit(base[2]) if base and len(base) >= 4 else None, + "latestComponentVersion": version.group(1) if version else None, + }) + expressions = dict(zip(("name", "nickname", "category", "subcategory"), + (base[0], base[1], base[-2], base[-1]))) if base and len(base) >= 4 else {} + for field in ("name", "nickname", "category", "subcategory"): + value, proven = literal_string(expressions.get(field, "")) + if not proven: + entry["extraction"]["reasons"].append("component %s is not a proven constructor literal" % field) + else: + entry[field] = value + if "LatestComponentVersion" in candidate["body"] and version is None: + entry["extraction"]["reasons"].append("LatestComponentVersion is not a proven string literal") + if entry["extraction"]["reasons"] and entry["extraction"]["status"] == "verified": + entry["extraction"]["status"] = "partial" + return entry + + +def scan(repo): + """The full model for a repository: components, exclusions, unclassified classes and projects.""" + candidates, gh_state = discover_projects(repo) + + scanned = [] + ancestry = [] + for path, info in sorted(candidates.items()): + if info["error"] or info["is_test"]: + continue + ancestry.append((os.path.dirname(path), info["root"])) + if path in gh_state: + scanned.append((os.path.dirname(path), info["root"])) + scanned.sort(key=lambda item: item[0]) + ancestry.sort(key=lambda item: item[0]) + + components, parameter_classes, unclassified, all_classes = classify_classes(repo, scanned, ancestry) + base_lookup = dict(all_classes) + classification = {} + for entry in all_classes.values(): + classification.setdefault(entry["class"], []).extend(entry["bases"]) + + entries = [] + for candidate in components: + try: + entries.append(component_metadata(candidate, build_component(candidate, base_lookup, classification))) + except Exception as error: # a parser failure must be reported, never crash the run or pass silently + entries.append({ + "class": candidate["class"], + "guid": candidate["guid"], + "source": candidate["source"], + "registration": "unknown", + "inputs": [], + "outputs": [], + "name": None, + "nickname": None, + "category": None, + "subcategory": None, + "description": None, + "latestComponentVersion": None, + "extraction": {"status": "unresolved", + "reasons": ["extraction failed: %s: %s" % (type(error).__name__, error)], + "externalTypes": []}, + }) + entries.sort(key=lambda item: item["class"]) + + projects = [] + for path, info in sorted(candidates.items()): + state = gh_state.get(path) + evidence = list(info["direct"][:1]) or ([state] if state else []) or ([info["error"]] if info["error"] else []) + if info["is_test"]: + evidence = evidence + ["test project: not scanned"] + projects.append({ + "name": info["name"], + "source": os.path.relpath(path, repo).replace("\\", "/"), + "grasshopper": "direct" if state == "direct" else "indirect" if state else "none", + "scanned": bool(state) and not info["is_test"] and not info["error"], + "evidence": evidence, + "error": info["error"], + }) + + return { + "repository": os.path.basename(repo), + "components": entries, + "externalParameterTypes": sorted({item for entry in entries + for item in entry["extraction"].get("externalTypes", [])}), + "parameterClasses": [{"class": item["class"], "source": item["source"], + "reason": item["kind_reason"]} for item in sorted(parameter_classes, key=lambda i: i["class"])], + "unclassifiedClasses": [{"class": item["class"], "source": item["source"], + "reason": item["kind_reason"]} for item in sorted(unclassified, key=lambda i: i["class"])], + "projects": projects, + } + + +# --- naming standard and its frozen legacy exceptions ------------------------------------------------- + +def base_name(name): + return (name or "").strip("_") + + +def expected_name(name, side, optional, persistent_default): + core = base_name(name) + if side == "output": + return core[:1].upper() + core[1:] + if not optional: + return "_" + core + return ("_" + core + "_") if persistent_default is not None else (core + "_") + + +def deviations(components): + """Identifiers whose declared name does not follow the SAM-BIM new-interface naming rules.""" + result = [] + for component in components: + for side in ("inputs", "outputs"): + for param in component[side]: + if not param.get("name"): + continue + expected = expected_name(param["name"], side[:-1], bool(param.get("optional")), param.get("persistentDefault")) + if param["name"] != expected: + result.append("%s.%s.%s" % (component["class"], side[:-1], param["name"])) + return sorted(result) + + +def description_notes(components): + """Advisory description observations. These are not compatibility and are not enforced by default.""" + notes = [] + for component in components: + for side in ("inputs", "outputs"): + for param in component[side]: + where = "%s.%s.%s" % (component["class"], side[:-1], param.get("name")) + description = (param.get("description") or "").strip() + if not description: + notes.append("%s: empty description" % where) + elif not description[0].isupper(): + notes.append("%s: description does not begin with a capital letter" % where) + if "Humidty" in description: + notes.append("%s: known typo 'Humidty'" % where) + if param.get("nickname") is not None and param.get("nickname") != param.get("name"): + notes.append("%s: NickName '%s' differs from Name (allowed; recorded, not enforced)" + % (where, param.get("nickname"))) + return sorted(notes) + + +# --- comparison ----------------------------------------------------------------------------------------- + +FROZEN_FIELDS = ("name", "type", "nickname", "optional", "access", "visibility", "persistentDefault") +IDENTITY_FIELDS = ("guid", "name", "nickname", "category", "subcategory") + + +def compare(baseline, current): + """Structured findings: compatibility (fatal), extraction (fatal), conventions (fatal), advisory. + + A baseline without `schemaVersion` is the v1 manifest: it is not rejected, but it cannot certify + extraction, so an unresolved current component still fails. + """ + findings = {"compatibility": [], "extraction": [], "conventions": [], "advisory": [], "manifest": []} + legacy_baseline = baseline.get("schemaVersion") is None + + if not legacy_baseline and baseline.get("schemaVersion") != SCHEMA_VERSION: + findings["manifest"].append("baseline schemaVersion %s is not supported (expected %s); regenerate it in a reviewed update" + % (baseline.get("schemaVersion"), SCHEMA_VERSION)) + + for item in current.get("unclassifiedClasses", []): + findings["extraction"].append("unclassified class %s at %s: %s" % (item["class"], item["source"], item["reason"])) + for project in current.get("projects", []): + if project.get("error"): + findings["extraction"].append("unparseable project %s: %s" % (project["source"], project["error"])) + if not any(project.get("scanned") for project in current.get("projects", [])): + findings["extraction"].append("no Grasshopper project was scanned; repository discovery is not established") + + by_class = {component["class"]: component for component in baseline.get("components", [])} + compared = 0 + + for component in current["components"]: + previous = by_class.pop(component["class"], None) + if previous is None: + findings["compatibility"].append("component added: %s (%s)" % (component["class"], component["guid"])) + continue + compared += 1 + + for field in IDENTITY_FIELDS: + if previous.get(field) != component.get(field): + findings["compatibility"].append("%s.%s: %r -> %r" % (component["class"], field, previous.get(field), component.get(field))) + + if previous.get("latestComponentVersion") != component.get("latestComponentVersion"): + findings["manifest"].append( + "%s.latestComponentVersion: %r -> %r - regenerate the manifest in this reviewed change " + "(an intended component version bump is not a compatibility violation)" + % (component["class"], previous.get("latestComponentVersion"), component.get("latestComponentVersion"))) + + for side in ("inputs", "outputs"): + old = previous.get(side) or [] + new = component.get(side) or [] + old_names = [param.get("name") for param in old] + new_names = [param.get("name") for param in new] + if old_names != new_names: + findings["compatibility"].append("%s.%s: names/order changed\n baseline: %s\n current: %s" + % (component["class"], side, old_names, new_names)) + continue + for before, after in zip(old, new): + for field in FROZEN_FIELDS: + if before.get(field) != after.get(field): + findings["compatibility"].append("%s.%s.%s: %s %r -> %r" + % (component["class"], side, after.get("name"), field, + before.get(field), after.get(field))) + + if component["extraction"]["status"] != "verified": + findings["extraction"].append("%s (%s): extraction is %s (%s)" + % (component["class"], component.get("source", "unknown source"), component["extraction"]["status"], + "; ".join(component["extraction"]["reasons"]) or "no reason recorded")) + + baseline_extraction = previous.get("extraction") + if not legacy_baseline and baseline_extraction is None: + findings["extraction"].append("%s: baseline has no extraction evidence" % component["class"]) + elif baseline_extraction is not None and baseline_extraction.get("status") != "verified": + findings["extraction"].append("%s: the approved baseline itself records an incomplete extraction (%s); " + "regenerate it in a reviewed change" + % (component["class"], baseline_extraction.get("status"))) + + for leftover in sorted(by_class): + findings["compatibility"].append("component removed: %s" % leftover) + + expected = sorted(baseline.get("namingExceptions", [])) + actual = deviations(current["components"]) + if expected != actual: + added = [item for item in actual if item not in expected] + stale = [item for item in expected if item not in actual] + if added: + findings["conventions"].append("new naming-standard deviation(s) not in the approved exception list: %s" % added) + if stale: + findings["conventions"].append("approved naming exception(s) that are no longer deviations: %s" % stale) + + findings["advisory"].extend(description_notes(current["components"])) + findings["_compared"] = compared + return findings + + +def summarise(model): + counts = {"verified": 0, "partial": 0, "unresolved": 0} + for component in model["components"]: + counts[component["extraction"]["status"]] += 1 + projects = model.get("projects", []) + return { + "components": len(model["components"]), + "verified": counts["verified"], + "partial": counts["partial"], + "unresolved": counts["unresolved"], + "parameterClassesExcluded": len(model.get("parameterClasses", [])), + "unclassifiedClasses": len(model.get("unclassifiedClasses", [])), + "unparseableProjects": sum(1 for project in projects if project.get("error")), + "projectsScanned": sum(1 for project in projects if project.get("scanned")), + "projectsDirect": sum(1 for project in projects if project["grasshopper"] == "direct" and project.get("scanned")), + "projectsIndirect": sum(1 for project in projects if project["grasshopper"] == "indirect" and project.get("scanned")), + "projectsTotal": len(projects), + } + + +# --- entry point ---------------------------------------------------------------------------------------- + +def manifest_path(repo): + return os.path.join(repo, "design", "grasshopper-icons", "param_contract.json") + + +def main(argv): + here = os.path.dirname(os.path.abspath(__file__)) + default_repo = os.path.dirname(os.path.dirname(os.path.dirname(here))) + + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", default=default_repo, help="repository to scan (default: the SAM repository this file lives in)") + parser.add_argument("--output", default=None, help="manifest to write (default: /design/grasshopper-icons/param_contract.json)") + parser.add_argument("--check", action="store_true", help="compatibility gate: compare the source against the committed manifest") + parser.add_argument("--strict-descriptions", action="store_true", help="treat description-quality observations as failures") + parser.add_argument("--show-conventions", action="store_true", help="list every naming-rule deviation found") + parser.add_argument("--json", action="store_true", help="print a machine-readable summary") + args = parser.parse_args(argv[1:]) + + repo = os.path.abspath(args.repo) + if not os.path.isdir(repo): + print("FAIL no such repository: %s" % repo) + return 2 + + model = scan(repo) + model["schemaVersion"] = SCHEMA_VERSION + model["namingExceptions"] = deviations(model["components"]) + summary = summarise(model) + + if args.json: + print(json.dumps({"repository": model["repository"], "summary": summary, + "components": [{"class": c["class"], "status": c["extraction"]["status"], + "reasons": c["extraction"]["reasons"], "inputs": len(c["inputs"]), + "outputs": len(c["outputs"]), "source": c["source"]} + for c in model["components"]]}, indent=1)) + + if not args.check: + output = os.path.abspath(args.output) if args.output else manifest_path(repo) + directory = os.path.dirname(output) + if directory and not os.path.isdir(directory): + os.makedirs(directory) + with open(output, "w", encoding="utf-8", newline="\n") as stream: + json.dump(model, stream, indent=1, ensure_ascii=False) + stream.write("\n") + print("inventory %s -> %s" % (model["repository"], output)) + print(" components %(components)d (verified %(verified)d, partial %(partial)d, unresolved %(unresolved)d)" % summary) + print(" parameter classes excluded %(parameterClassesExcluded)d, unclassified classes %(unclassifiedClasses)d" % summary) + print(" unparseable projects %(unparseableProjects)d" % summary) + for item in model["unclassifiedClasses"]: + print(" UNCLASSIFIED %s (%s): %s" % (item["class"], item["source"], item["reason"])) + for project in model["projects"]: + if project.get("error"): + print(" UNPARSEABLE %s: %s" % (project["source"], project["error"])) + print(" projects %(projectsTotal)d (direct %(projectsDirect)d, indirect %(projectsIndirect)d)" % summary) + print(" naming deviations %d, description notes %d" % (len(model["namingExceptions"]), len(description_notes(model["components"])))) + print(" NOT compatibility validation: no approved baseline was read.") + if args.show_conventions: + for item in model["namingExceptions"]: + print(" naming deviation %s" % item) + if summary["unresolved"] or summary["partial"]: + print(" unresolved/partial components are listed in the manifest under extraction.reasons") + return 0 + + path = manifest_path(repo) + if not os.path.isfile(path): + print("FAIL no approved baseline at %s" % path) + print(" Run an inventory to review a proposed baseline, then commit it in a reviewed change:") + print(" python %s --repo %s" % (os.path.relpath(__file__), repo)) + return 2 + + with open(path, encoding="utf-8") as stream: + baseline = json.load(stream) + + findings = compare(baseline, model) + fatal = [] + for category in ("compatibility", "extraction", "conventions", "manifest"): + for message in findings[category]: + fatal.append((category, message)) + for message in findings["advisory"]: + print("NOTE advisory: %s" % message) + if args.strict_descriptions and findings["advisory"]: + fatal.extend(("advisory", message) for message in findings["advisory"]) + + for category, message in fatal: + print("FAIL %s: %s" % (category, message)) + + print("%s: %d components discovered, %d fully verified, %d partial, %d unresolved; %d compared against the approved baseline; %d parameter classes excluded" + % (model["repository"], summary["components"], summary["verified"], summary["partial"], + summary["unresolved"], findings["_compared"], summary["parameterClassesExcluded"])) + print("discovery: %(unclassifiedClasses)d unclassified classes, %(unparseableProjects)d unparseable projects" % summary) + if not fatal: + print("OK compatibility (baseline %s)" % os.path.relpath(path, repo).replace("\\", "/")) + return 0 + print("%d PROBLEM(S)" % len(fatal)) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/design/grasshopper-icons/tools/tests/test_param_contract.py b/design/grasshopper-icons/tools/tests/test_param_contract.py new file mode 100644 index 000000000..2b0eef9b1 --- /dev/null +++ b/design/grasshopper-icons/tools/tests/test_param_contract.py @@ -0,0 +1,1112 @@ +"""Regression tests for the canonical SAM-BIM Grasshopper metadata contract validator. + +Run from the SAM repository root: + + python design/grasshopper-icons/tools/tests/test_param_contract.py + python -m unittest discover -s design/grasshopper-icons/tools/tests -t . + +Each test states the finding it protects. Synthetic repositories are written to a temporary directory, +so the tests never modify a repository, and the "reported case" tests read the real SAM component. +""" +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +import importlib.util + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.dirname(HERE) +SAM_REPO = os.path.dirname(os.path.dirname(os.path.dirname(TOOLS))) +WORKSPACE = os.path.dirname(SAM_REPO) +spec = importlib.util.spec_from_file_location("param_contract", os.path.join(TOOLS, "param_contract.py")) +pc = importlib.util.module_from_spec(spec) +spec.loader.exec_module(pc) + + +# --- harness ----------------------------------------------------------------------------------------- + +CSPROJ = """ + + net8.0-windows + + + + + +""" + +CSPROJ_SINGLE_QUOTED = """ + + net8.0-windows + + + + + + +""" + + +def write(path, text): + directory = os.path.dirname(path) + if directory and not os.path.isdir(directory): + os.makedirs(directory) + with open(path, "w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + + +def make_repo(root, sources, project="Demo.Grasshopper", csproj=CSPROJ, extra_projects=None): + """A synthetic single-project repository: {relative path: C# source}.""" + write(os.path.join(root, project, project + ".csproj"), csproj) + for name, source in sources.items(): + write(os.path.join(root, project, name), source) + for project_name, (project_csproj, project_sources) in (extra_projects or {}).items(): + write(os.path.join(root, project_name, project_name + ".csproj"), project_csproj) + for name, source in project_sources.items(): + write(os.path.join(root, project_name, name), source) + return root + + +def run(repo, *arguments): + """Run the validator CLI and return (exit code, stdout).""" + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = pc.main(["param_contract.py", "--repo", repo] + list(arguments)) + return code, buffer.getvalue() + + +def component(repo, name): + for item in pc.scan(repo)["components"]: + if item["class"] == name: + return item + raise AssertionError("component not found: %s" % name) + + +def parameter(component_entry, side, name): + for item in component_entry[side]: + if item["name"] == name: + return item + raise AssertionError("parameter not found: %s %s" % (side, name)) + + +class Fixture(unittest.TestCase): + def setUp(self): + self._temporary = tempfile.TemporaryDirectory(prefix="gh-contract-test-") + self.root = self._temporary.name + + def tearDown(self): + self._temporary.cleanup() + + def repo(self, sources, **kwargs): + return make_repo(self.root, sources, **kwargs) + + +# --- Major 1: parameter extraction --------------------------------------------------------------------- + +HEADER = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +using SAM.Core.Grasshopper; + +namespace Demo +{ + public class __NAME__ : GH_SAMVariableOutputParameterComponent + { + public override System.Guid ComponentGuid => new System.Guid("__GUID__"); + public override string LatestComponentVersion => "1.0.0"; + public __NAME__() : base("__NAME__", "__NAME__", "Demo", "SAM", "Demo") { } +__BODY__ + } + public class GooPanelParam : Grasshopper.Kernel.Parameters.Param_Number { } + public class GooWidgetParam : Grasshopper.Kernel.Parameters.Param_Number { } +} +""" + + +def component_source(name, body, guid="11111111-1111-1111-1111-111111111111"): + return (HEADER.replace("__NAME__", name).replace("__GUID__", guid).replace("__BODY__", body)) + + +class ExtractionTests(Fixture): + """Major finding 1 - incomplete parameter extraction.""" + + def test_reported_create_panels_by_solids_case(self): + """The reported case: target-typed `new()`, a reused variable and a collection initializer.""" + entry = component(SAM_REPO, "SAMAnalyticalCreatePanelsBySolids") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertTrue(all("constructor defaults" in reason for reason in entry["extraction"]["reasons"])) + self.assertEqual( + ["_solids", "apertureGeometries_", "minThickness_", "snap_", "maxThicknessRatio_", + "airForOpenings_", "diagnosticDetail_"], + [item["name"] for item in entry["inputs"]]) + self.assertEqual(["panels", "airPanels", "excluded", "log"], [item["name"] for item in entry["outputs"]]) + self.assertEqual("Param_Number", parameter(entry, "inputs", "maxThicknessRatio_")["type"]) + self.assertEqual("0.2", parameter(entry, "inputs", "maxThicknessRatio_")["persistentDefault"]) + self.assertEqual("Voluntary", parameter(entry, "inputs", "maxThicknessRatio_")["visibility"]) + self.assertEqual("SAM.Analytical.Grasshopper.GooPanelParam", parameter(entry, "outputs", "panels")["type"]) + + def test_variable_reuse_and_target_typed_new(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new() { Name = "_a", NickName = "_a", Description = "A", Access = GH_ParamAccess.item }; + number.SetPersistentData(1.0); + result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + number = new() { Name = "_b", NickName = "_b", Description = "B", Access = GH_ParamAccess.item }; + number.SetPersistentData(2.0); + result.Add(new GH_SAMParam(number, ParamVisibility.Voluntary)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("Param_Number", parameter(entry, "inputs", "_b")["type"]) + self.assertEqual("2.0", parameter(entry, "inputs", "_b")["persistentDefault"]) + + def test_collection_initializer_outputs(self): + body = """ protected override GH_SAMParam[] Inputs => new GH_SAMParam[0]; + protected override GH_SAMParam[] Outputs + { + get + { + System.Collections.Generic.List result = + [ + new GH_SAMParam(new Param_String() { Name = "Panels", NickName = "Panels", Description = "P", Access = GH_ParamAccess.list }, ParamVisibility.Binding), + new GH_SAMParam(new Param_Integer() { Name = "Count", NickName = "Count", Description = "C", Access = GH_ParamAccess.item }, ParamVisibility.Voluntary), + ]; + return [.. result]; + } + } +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual(["Panels", "Count"], [item["name"] for item in entry["outputs"]]) + + def test_object_initializer_without_constructor_parentheses(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Boolean flag = new Param_Boolean { Name = "_flag", NickName = "_flag", Description = "F", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(flag, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("Param_Boolean", parameter(entry, "inputs", "_flag")["type"]) + + def test_verbatim_identifier_is_the_same_variable(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Boolean @boolean = new Param_Boolean { Name = "_simplify_", NickName = "_simplify_", Description = "S", Access = GH_ParamAccess.item }; + boolean.SetPersistentData(true); + result.Add(new GH_SAMParam(boolean, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("true", parameter(entry, "inputs", "_simplify_")["persistentDefault"]) + + def test_bare_declaration_then_target_typed_assignment(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_String param_String; + param_String = new() { Name = "_name_", NickName = "_name_", Description = "N", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(param_String, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("Param_String", parameter(entry, "inputs", "_name_")["type"]) + + def test_parameter_declared_inside_a_foreach_block(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + foreach (string name in new string[] { "_a", "_b" }) + { + Param_Number param_Number = new Param_Number() { Name = name, NickName = name, Description = "N", Access = GH_ParamAccess.item }; + param_Number.SetPersistentData(0); + result.Add(new GH_SAMParam(param_Number, ParamVisibility.Binding)); + } + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + # The loop variable is not a literal, so the name cannot be established - and the reason must + # say so rather than the parameter silently disappearing. + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertTrue(any("Name is not a string literal" in reason for reason in entry["extraction"]["reasons"]), + entry["extraction"]["reasons"]) + self.assertEqual(1, len(entry["inputs"])) + self.assertEqual("Param_Number", entry["inputs"][0]["type"]) + + def test_manager_registration_and_typed_helper(self): + body = """ protected override void RegisterInputParams(GH_InputParamManager manager) + { + manager.AddParameter(new GooWidgetParam(), "_widget", "_widget", "W", GH_ParamAccess.item); + manager.AddNumberParameter("_size_", "_size_", "Size", GH_ParamAccess.list, 0.5); + } + protected override void RegisterOutputParams(GH_OutputParamManager manager) + { + manager.AddBooleanParameter("Successful", "Successful", "OK", GH_ParamAccess.item); + } +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("Demo.GooWidgetParam", parameter(entry, "inputs", "_widget")["type"]) + self.assertEqual("list", parameter(entry, "inputs", "_size_")["access"]) + self.assertEqual("0.5", parameter(entry, "inputs", "_size_")["persistentDefault"]) + self.assertEqual("Param_Boolean", parameter(entry, "outputs", "Successful")["type"]) + + def test_manager_optional_assignment_is_attributed(self): + body = """ protected override void RegisterInputParams(GH_InputParamManager manager) + { + GooWidgetParam widget = new GooWidgetParam(); + widget.Optional = true; + manager.AddParameter(widget, "_widget_", "_widget_", "W", GH_ParamAccess.item); + } + protected override void RegisterOutputParams(GH_OutputParamManager manager) { } +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertTrue(parameter(entry, "inputs", "_widget_")["optional"]) + + def test_local_parameter_factory(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + result.Add(new GH_SAMParam(Number("_size_", "Size", 0.25), ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; + + private static Param_Number Number(string name, string description, double defaultValue) + { + Param_Number result = new global::Grasshopper.Kernel.Parameters.Param_Number() { Name = name, NickName = name, Description = description, Access = GH_ParamAccess.item }; + result.SetPersistentData(defaultValue); + return result; + } +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertTrue(any("factory" in reason for reason in entry["extraction"]["reasons"])) + self.assertEqual("Param_Number", parameter(entry, "inputs", "_size_")["type"]) + self.assertEqual("0.25", parameter(entry, "inputs", "_size_")["persistentDefault"]) + self.assertEqual("Size", parameter(entry, "inputs", "_size_")["description"]) + + def test_inherited_registration_from_abstract_base_in_the_repository(self): + base = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +namespace Demo +{ + public abstract class BaseComponent : GH_Component + { + protected BaseComponent(string name) : base(name, name, "d", "SAM", "Demo") { } + protected override void RegisterInputParams(GH_InputParamManager manager) + { + manager.AddParameter(new Param_String(), "_shared", "_shared", "S", GH_ParamAccess.item); + } + protected override void RegisterOutputParams(GH_OutputParamManager manager) { } + } +} +""" + derived = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +namespace Demo +{ + public class DerivedComponent : BaseComponent + { + public override System.Guid ComponentGuid => new System.Guid("22222222-2222-2222-2222-222222222222"); + public override string LatestComponentVersion => "1.0.0"; + public DerivedComponent() : base("Demo.Derived") { } + } +} +""" + entry = component(self.repo({"Base.cs": base, "Derived.cs": derived}), "DerivedComponent") + self.assertEqual("partial", entry["extraction"]["status"]) # constructor forwards an unresolved one-argument metadata contract + self.assertEqual("inherited:BaseComponent", entry["registration"]) + self.assertEqual("_shared", entry["inputs"][0]["name"]) + + def test_generic_base_type_argument_substitution(self): + base = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +namespace Demo +{ + public abstract class EnumComponent : GH_Component + { + protected EnumComponent(string name) : base(name, name, "d", "SAM", "Demo") { } + protected override void RegisterInputParams(GH_InputParamManager manager) { } + protected override void RegisterOutputParams(GH_OutputParamManager manager) + { + manager.AddParameter(new Param_String() { Name = typeof(T).Name, NickName = typeof(T).Name, Description = typeof(T).Name, Access = GH_ParamAccess.item }); + } + } +} +""" + derived = """// SPDX-License-Identifier: LGPL-3.0-or-later +namespace Demo +{ + public class ColourComponent : EnumComponent + { + public override System.Guid ComponentGuid => new System.Guid("33333333-3333-3333-3333-333333333333"); + public override string LatestComponentVersion => "1.0.0"; + public ColourComponent() : base("Demo.Colour") { } + } + public enum ColourKind { Red, Blue } +} +""" + entry = component(self.repo({"Base.cs": base, "Colour.cs": derived}), "ColourComponent") + self.assertEqual("partial", entry["extraction"]["status"]) # registration is known; inherited display metadata is not + self.assertEqual("ColourKind", entry["outputs"][0]["name"]) + + def test_missing_outputs_is_not_verified(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + result.Add(new GH_SAMParam(new Param_String() { Name = "_in", NickName = "_in", Description = "I", Access = GH_ParamAccess.item }, ParamVisibility.Binding)); + return result.ToArray(); + } + } +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + def test_unrecognised_declaration_is_reported_not_ignored(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_String param = new Param_String() { Name = "_a", NickName = "_a", Description = "A", Access = GH_ParamAccess.item }; + Params.RegisterInputParam(param); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + def test_dynamic_name_is_not_a_literal(self): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + result.Add(new GH_SAMParam(new Param_String() { Name = string.Format("_{0}", "x"), NickName = "n", Description = "D", Access = GH_ParamAccess.item }, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" + entry = component(self.repo({"C.cs": component_source("C", body)}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + +# --- Major 2: parameter runtime type ------------------------------------------------------------------- + +class TypeTests(Fixture): + """Major finding 2 - the runtime type is part of the frozen contract.""" + + def contract_repo(self, type_name="Param_Number"): + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + result.Add(new GH_SAMParam(new %s() { Name = "_value", NickName = "_value", Description = "V", Access = GH_ParamAccess.item }, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" % type_name + return self.repo({"C.cs": component_source("C", body)}) + + def test_type_change_fails_the_compatibility_check(self): + repo = self.contract_repo("Param_Number") + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), component_source("C", """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + result.Add(new GH_SAMParam(new Param_String() { Name = "_value", NickName = "_value", Description = "V", Access = GH_ParamAccess.item }, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("type", output) + self.assertIn("Param_Number", output) + + def test_psychrometrics_pilot_type_change_is_detected(self): + pilot = os.path.join(WORKSPACE, "SAM_Psychrometrics") + if not os.path.isdir(pilot): + self.skipTest("SAM_Psychrometrics is not checked out") + # Copy actual component sources into a synthetic project, never mutate the pilot checkout. + source_dir = os.path.join(pilot, "Grasshopper", "SAM.Core.Grasshopper.Psychrometrics", "Component") + sources = {name: pc.read_text(os.path.join(source_dir, name)) + for name in os.listdir(source_dir) if name.endswith(".cs")} + make_repo(self.root, sources) + self.assertEqual(0, run(self.root)[0]) + self.assertEqual(0, run(self.root, "--check")[0]) + path = os.path.join(self.root, "Demo.Grasshopper", "SAMPsychrometricsDewPointTemperature.cs") + source = pc.read_text(path) + self.assertIn("new global::Grasshopper.Kernel.Parameters.Param_Number()", source) + write(path, source.replace("new global::Grasshopper.Kernel.Parameters.Param_Number()", + "new global::Grasshopper.Kernel.Parameters.Param_String()", 1)) + code, output = run(self.root, "--check") + self.assertEqual(1, code, output) + self.assertIn("SAMPsychrometricsDewPointTemperature.inputs._dryBulbTemperature: type 'Param_Number' -> 'Param_String'", output) + self.assertNotIn("component removed", output) + self.assertNotIn("FAIL extraction", output) + + def test_custom_goo_type_is_supported(self): + entry = component(self.contract_repo("GooPanelParam"), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("goo", parameter(entry, "inputs", "_value")["typeKind"]) + + def test_external_parameter_identity_is_recorded_but_unknown_defaults_block_certification(self): + repo = self.contract_repo("global::RhinoInside.Revit.GH.Parameters.HostObject") + entry = component(repo, "C") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertTrue(any("constructor defaults" in reason for reason in entry["extraction"]["reasons"])) + self.assertEqual("external", parameter(entry, "inputs", "_value")["typeKind"]) + self.assertEqual(["RhinoInside.Revit.GH.Parameters.HostObject"], pc.scan(repo)["externalParameterTypes"]) + + def test_unresolved_extraction_blocks_the_check(self): + """A parameter whose identity cannot be established must never be reported as verified.""" + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + var value = Create.Param("_value"); + result.Add(new GH_SAMParam(value, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; + + private static Param_Number Create_Param(string name) { return null; } +""" + repo = self.repo({"C.cs": component_source("C", body)}) + entry = component(repo, "C") + self.assertNotEqual("verified", entry["extraction"]["status"], entry["extraction"]) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("extraction", output) + + +# --- Major 3: discovery and classification -------------------------------------------------------------- + +class DiscoveryTests(Fixture): + """Major finding 3 - verifiable classification and project discovery.""" + + def test_a_component_named_like_a_parameter_is_a_component(self): + source = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +using SAM.Core.Grasshopper; +namespace Demo +{ + public class NewComputeParam : GH_SAMComponent + { + public override System.Guid ComponentGuid => new System.Guid("44444444-4444-4444-4444-444444444444"); + public override string LatestComponentVersion => "1.0.0"; + public NewComputeParam() : base("Demo.NewComputeParam", "Demo.NewComputeParam", "d", "SAM", "Demo") { } + } +} +""" + model = pc.scan(self.repo({"C.cs": source})) + self.assertIn("NewComputeParam", [item["class"] for item in model["components"]]) + + def test_a_parameter_class_is_not_a_component_and_is_reported(self): + source = """// SPDX-License-Identifier: LGPL-3.0-or-later +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +namespace Demo +{ + public class GooThingParam : GH_PersistentParam + { + public override System.Guid ComponentGuid => new System.Guid("55555555-5555-5555-5555-555555555555"); + } + public class GooThing { } +} +""" + model = pc.scan(self.repo({"C.cs": source})) + self.assertNotIn("GooThingParam", [item["class"] for item in model["components"]]) + excluded = {item["class"]: item for item in model["parameterClasses"]} + self.assertIn("GooThingParam", excluded) + self.assertIn("GH_PersistentParam", excluded["GooThingParam"]["reason"]) + + def test_unknown_ancestry_is_reported_not_silently_excluded(self): + source = """// SPDX-License-Identifier: LGPL-3.0-or-later +namespace Demo +{ + public class MysteryComponent : SomethingFromAnotherAssembly + { + public override System.Guid ComponentGuid => new System.Guid("66666666-6666-6666-6666-666666666666"); + } +} +""" + model = pc.scan(self.repo({"C.cs": source})) + self.assertNotIn("MysteryComponent", [item["class"] for item in model["components"]]) + unclassified = {item["class"]: item for item in model["unclassifiedClasses"]} + self.assertIn("MysteryComponent", unclassified) + self.assertIn("SomethingFromAnotherAssembly", unclassified["MysteryComponent"]["reason"]) + + def test_indirect_project_reference_discovers_the_project(self): + consumer = """ + + + + +""" + source = component_source("C", """ protected override GH_SAMParam[] Inputs => new GH_SAMParam[0]; + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""") + repo = self.repo( + {"Base.cs": component_source("Base", " protected override GH_SAMParam[] Inputs => new GH_SAMParam[0];\n protected override GH_SAMParam[] Outputs => new GH_SAMParam[0];\n")}, + extra_projects={"Demo.Consumer": (consumer, {"C.cs": source})}) + model = pc.scan(repo) + projects = {item["name"]: item for item in model["projects"]} + self.assertEqual("indirect", projects["Demo.Consumer"]["grasshopper"]) + self.assertTrue(projects["Demo.Consumer"]["scanned"]) + self.assertIn("Demo.Grasshopper", " ".join(projects["Demo.Consumer"]["evidence"])) + self.assertIn("C", [item["class"] for item in model["components"]]) + + def test_startup_repo_discovers_grasshopper_via_project_reference(self): + """Placeholder-free check that the evidence string names the referenced project.""" + consumer = """ + + + + +""" + repo = self.repo( + {"Base.cs": component_source("Base", " protected override GH_SAMParam[] Inputs => new GH_SAMParam[0];\n protected override GH_SAMParam[] Outputs => new GH_SAMParam[0];\n")}, + extra_projects={"Demo.Consumer": (consumer, {})}) + projects = {item["name"]: item for item in pc.scan(repo)["projects"]} + self.assertEqual(["indirect:Demo.Grasshopper"], projects["Demo.Consumer"]["evidence"]) + + def test_single_quoted_xml_attributes_are_supported(self): + repo = self.repo({"C.cs": component_source("C", """ protected override GH_SAMParam[] Inputs => new GH_SAMParam[0]; + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")}, csproj=CSPROJ_SINGLE_QUOTED) + model = pc.scan(repo) + projects = {item["name"]: item for item in model["projects"]} + self.assertEqual("direct", projects["Demo.Grasshopper"]["grasshopper"]) + self.assertEqual(1, len(model["components"])) + + def test_compile_removed_file_is_not_scanned(self): + repo = self.repo({"C.cs": component_source("C", """ protected override GH_SAMParam[] Inputs => new GH_SAMParam[0]; + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")}, csproj=CSPROJ) + write(os.path.join(repo, "Demo.Grasshopper", "Legacy", "Excluded.cs"), + component_source("Excluded", " protected override GH_SAMParam[] Inputs => new GH_SAMParam[0];\n protected override GH_SAMParam[] Outputs => new GH_SAMParam[0];\n")) + self.assertIn("Excluded", [item["class"] for item in pc.scan(repo)["components"]]) + # with a that names it, it is not compiled and is no longer a document object + write(os.path.join(repo, "Demo.Grasshopper", "Demo.Grasshopper.csproj"), CSPROJ_SINGLE_QUOTED) + self.assertNotIn("Excluded", [item["class"] for item in pc.scan(repo)["components"]]) + self.assertIn("C", [item["class"] for item in pc.scan(repo)["components"]]) + + def test_unparsable_csproj_is_reported(self): + repo = self.repo({"C.cs": component_source("C", """ protected override GH_SAMParam[] Inputs => new GH_SAMParam[0]; + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")}) + write(os.path.join(repo, "Broken", "Broken.csproj"), "") + model = pc.scan(repo) + broken = [item for item in model["projects"] if item["name"] == "Broken"] + self.assertEqual(1, len(broken)) + self.assertFalse(broken[0]["scanned"]) + self.assertTrue(broken[0]["evidence"]) + + def test_sam_pilot_and_representative_sam_components(self): + entry = component(SAM_REPO, "SAMAnalyticalCreateAperturesBySolids") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertEqual("SAM.Analytical.Grasshopper.GooApertureConstructionParam", + parameter(entry, "inputs", "apertureConstructions_")["type"]) + enum_entry = component(SAM_REPO, "SAMAnalyticalApertureType") + self.assertEqual("partial", enum_entry["extraction"]["status"]) + self.assertEqual("ApertureType", enum_entry["outputs"][0]["name"]) + + +# --- Major 4: compatibility versus naming conventions --------------------------------------------------- + +class CompatibilityTests(Fixture): + """Major finding 4 - compatibility is against the baseline, conventions are separate.""" + + def acme(self, access="item", nickname=None, name="_value", optional=False, default=None, comment=""): + nickname = nickname or name + optional_text = ", Optional = true" if optional else "" + body = """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new Param_Number() { Name = "%s", NickName = "%s", Description = "V%s", Access = GH_ParamAccess.%s%s }; +%s result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""" % (name, nickname, comment, access, optional_text, + (" number.SetPersistentData(%s);\n" % default) if default is not None else "") + return self.repo({"C.cs": component_source("C", body)}) + + def test_list_and_tree_access_are_legitimate(self): + for access in ("list", "tree"): + repo = self.acme(access=access) + entry = component(repo, "C") + self.assertEqual(access, entry["inputs"][0]["access"]) + code, output = run(repo) + self.assertEqual(0, code, output) + self.assertEqual(0, run(repo, "--check")[0], output) + self.assertNotIn("unexpected access", pc.description_notes(pc.scan(repo)["components"])) + + def test_existing_nickname_is_valid_and_only_advisory(self): + repo = self.acme(nickname="_v") + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(0, code, output) + self.assertIn("NOTE advisory", output) + + def test_code_side_default_does_not_require_set_persistent_data(self): + repo = self.acme(name="offset_", optional=True, access="item") + entry = component(repo, "C") + self.assertIsNone(parameter(entry, "inputs", "offset_")["persistentDefault"]) + self.assertEqual(0, run(repo)[0]) + self.assertEqual(0, run(repo, "--check")[0]) + + def test_new_naming_deviation_needs_an_approved_exception(self): + repo = self.acme(name="_value") + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), component_source("C", """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new Param_Number() { Name = "value", NickName = "value", Description = "V", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("conventions", output) + + def test_stale_naming_exception_fails(self): + repo = self.acme(name="_value") + self.assertEqual(0, run(repo)[0]) + manifest = os.path.join(repo, "design", "grasshopper-icons", "param_contract.json") + with open(manifest, encoding="utf-8") as stream: + baseline = json.load(stream) + baseline["namingExceptions"].append("C.input._gone_") + write(manifest, json.dumps(baseline)) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("no longer deviations", output) + + def test_description_change_is_not_a_compatibility_problem(self): + repo = self.acme() + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), component_source("C", """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new Param_Number() { Name = "_value", NickName = "_value", Description = "Better text", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")) + code, output = run(repo, "--check") + self.assertEqual(0, code, output) + self.assertNotIn("FAIL compatibility", output) + + def test_description_quality_is_advisory_unless_strict(self): + repo = self.acme(name="_value", comment="") + # empty description + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), component_source("C", """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new Param_Number() { Name = "_value", NickName = "_value", Description = "", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""")) + self.assertEqual(0, run(repo)[0]) + self.assertEqual(0, run(repo, "--check")[0]) + code, _ = run(repo, "--check", "--strict-descriptions") + self.assertEqual(1, code) + + def test_version_only_change_is_a_manifest_refresh_not_a_compatibility_violation(self): + repo = self.acme() + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), + component_source("C", """ protected override GH_SAMParam[] Inputs + { + get + { + System.Collections.Generic.List result = new System.Collections.Generic.List(); + Param_Number number = new Param_Number() { Name = "_value", NickName = "_value", Description = "V", Access = GH_ParamAccess.item }; + result.Add(new GH_SAMParam(number, ParamVisibility.Binding)); + return result.ToArray(); + } + } + protected override GH_SAMParam[] Outputs => new GH_SAMParam[0]; +""").replace('"1.0.0"', '"1.0.1"')) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("manifest:", output) + self.assertIn("not a compatibility violation", output) + + def test_missing_baseline_is_refused(self): + repo = self.acme() + code, output = run(repo, "--check") + self.assertEqual(2, code, output) + self.assertIn("no approved baseline", output) + + def test_incomplete_baseline_blocks_the_check(self): + repo = self.acme() + self.assertEqual(0, run(repo)[0]) + manifest = os.path.join(repo, "design", "grasshopper-icons", "param_contract.json") + with open(manifest, encoding="utf-8") as stream: + baseline = json.load(stream) + baseline["components"][0]["extraction"] = {"status": "partial", "reasons": ["recorded as partial"]} + write(manifest, json.dumps(baseline)) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("extraction", output) + + def test_inventory_mode_does_not_claim_compatibility(self): + repo = self.acme() + code, output = run(repo) + self.assertEqual(0, code, output) + self.assertIn("NOT compatibility validation", output) + + +# --- end-to-end: the real fleet, read-only ----------------------------------------------------------------- + +class FinalRepairTests(Fixture): + """Second independent review: reproduce each false PASS and assert its actual failure category.""" + + def declared(self, parameter_type="Param_Number", mutation="", wrapper="new GH_SAMParam("): + return component_source("C", ''' + protected override GH_SAMParam[] Inputs { get { + var result = new List(); + TYPE p = new TYPE() { Name = "_value", NickName = "_value", Description = "Value", Access = GH_ParamAccess.item }; + result.Add(WRAPPERp, ParamVisibility.Binding)); + MUTATION + return result.ToArray(); + } } + protected override GH_SAMParam[] Outputs => []; +'''.replace("TYPE", parameter_type).replace("WRAPPER", wrapper).replace("MUTATION", mutation)) + + def assert_mutation_fails(self, before, after, message, category="compatibility", extra=None): + repo = self.repo(dict({"C.cs": before}, **(extra or {}))) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(0, code, output) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), after) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("FAIL " + category + ":", output) + self.assertIn(message, output) + self.assertNotIn("component removed", output) + self.assertNotIn("OK compatibility", output) + + def test_qualified_external_types_are_distinct(self): + types = '''namespace VendorA { +public class HostObject : Grasshopper.Kernel.Parameters.Param_Number { } +} +namespace VendorB { +public class HostObject : Grasshopper.Kernel.Parameters.Param_Number { } +}''' + self.assert_mutation_fails(self.declared("global::VendorA.HostObject"), + self.declared("global::VendorB.HostObject"), + "type 'VendorA.HostObject' -> 'VendorB.HostObject'", extra={"Types.cs": types}) + + def test_closed_generic_type_arguments_are_distinct(self): + types = "namespace Demo { public class GooValueParam : Grasshopper.Kernel.Parameters.Param_Number { } }" + self.assert_mutation_fails(self.declared("GooValueParam"), self.declared("GooValueParam"), + "Demo.GooValueParam", extra={"Types.cs": types}) + + def test_equivalent_global_and_builtin_spellings_pass(self): + repo = self.repo({"C.cs": self.declared("Param_Number")}) + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), self.declared("global::Grasshopper.Kernel.Parameters.Param_Number")) + self.assertEqual(0, run(repo, "--check")[0]) + + def test_type_alias_resolves_and_alias_target_change_fails(self): + types = '''namespace VendorA { +public class HostObject : Grasshopper.Kernel.Parameters.Param_Number { } +} +namespace VendorB { +public class HostObject : Grasshopper.Kernel.Parameters.Param_Number { } +}''' + before = "using Alias = global::VendorA.HostObject;\n" + self.declared("Alias") + after = before.replace("VendorA", "VendorB") + self.assert_mutation_fails(before, after, "type 'VendorA.HostObject' -> 'VendorB.HostObject'", extra={"Types.cs": types}) + + def test_ambiguous_imported_type_blocks_verification(self): + source = "using VendorA; using VendorB;\n" + self.declared("HostObject") + types = "namespace VendorA { public class HostObject { } }\nnamespace VendorB { public class HostObject { } }" + repo = self.repo({"C.cs": source, "Types.cs": types}) + entry = component(repo, "C") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("identity is unresolved or ambiguous", output) + + def test_constructor_whitespace_preserves_contract(self): + repo = self.repo({"C.cs": self.declared()}) + self.assertEqual(0, run(repo)[0]) + spaced = self.declared(wrapper="new GH_SAMParam ( ") + write(os.path.join(repo, "Demo.Grasshopper", "C.cs"), spaced) + self.assertEqual(0, run(repo, "--check")[0]) + self.assert_mutation_fails(spaced, spaced.replace('Name = "_value"', 'Name = "_renamed"'), "names/order changed") + + def test_declarative_optional_assignment_after_wrapping_is_compared(self): + self.assert_mutation_fails(self.declared(mutation="p.Optional = false;"), + self.declared(mutation="p.Optional = true;"), "optional False -> True") + + def test_declarative_optional_assignment_before_wrapping_is_compared(self): + before = self.declared().replace("result.Add(", "p.Optional = false; result.Add(") + self.assert_mutation_fails(before, before.replace("p.Optional = false", "p.Optional = true"), "optional False -> True") + + def test_persistent_data_after_wrapping_is_compared(self): + self.assert_mutation_fails(self.declared(mutation="p.SetPersistentData(1);"), + self.declared(mutation="p.SetPersistentData(2);"), "persistentDefault '1' -> '2'") + + def test_reassigned_parameter_does_not_mutate_preceding_wrapper(self): + source = self.declared(mutation='p = new Param_Number() { Name = "_other", NickName = "_other", Access = GH_ParamAccess.item }; p.Optional = true;') + entry = component(self.repo({"C.cs": source}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]) + self.assertFalse(entry["inputs"][0]["optional"]) + + def test_unknown_reassignment_invalidates_the_old_binding(self): + source = self.declared().replace("result.Add(", "p = UnknownFactory(); result.Add(") + entry = component(self.repo({"C.cs": source}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + def test_helper_returned_outputs_cannot_be_certified_empty(self): + source = self.declared().replace("Outputs => []", "Outputs => BuildOutputs()") + repo = self.repo({"C.cs": source}) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("unsupported output return expression", output) + self.assertNotIn("OK compatibility", output) + + def test_loop_with_literal_name_cannot_be_certified_as_one_parameter(self): + source = self.declared().replace("result.Add(new", "foreach (var unused in new [] { 1, 2 }) { result.Add(new").replace("p, ParamVisibility.Binding));", "p, ParamVisibility.Binding)); }") + entry = component(self.repo({"C.cs": source}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + self.assertTrue(any("control flow" in r for r in entry["extraction"]["reasons"])) + + def test_filtering_a_return_collection_is_not_certified(self): + source = self.declared().replace("result.ToArray()", "result.Where(p => false).ToArray()") + entry = component(self.repo({"C.cs": source}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + def test_discarding_the_populated_collection_is_not_certified(self): + source = self.declared().replace("return result.ToArray()", "return []") + entry = component(self.repo({"C.cs": source}), "C") + self.assertNotEqual("verified", entry["extraction"]["status"]) + + def test_global_type_does_not_bind_to_a_relative_shadow(self): + context = {"namespace": "Demo", "types": {"Demo.VendorA.HostObject", "VendorA.HostObject"}} + self.assertEqual("VendorA.HostObject", pc.type_identity("global::VendorA.HostObject", context)) + self.assertEqual("Demo.VendorA.HostObject", pc.type_identity("VendorA.HostObject", context)) + + def test_genuinely_empty_declarations_are_verified(self): + source = component_source("C", "protected override GH_SAMParam[] Inputs => [];\nprotected override GH_SAMParam[] Outputs => new GH_SAMParam[0];") + entry = component(self.repo({"C.cs": source}), "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]) + + def test_no_registration_evidence_is_unresolved(self): + entry = component(self.repo({"C.cs": component_source("C", "")}), "C") + self.assertEqual("unresolved", entry["extraction"]["status"]) + self.assertTrue(entry["extraction"]["reasons"]) + + def test_revit_paramdefinition_registration_is_explicitly_unresolved(self): + source = component_source("C", ''' +protected override ParamDefinition[] Inputs => [new ParamDefinition(new Param_Number() { Name = "_value" }, ParamRelevance.Binding)]; +protected override ParamDefinition[] Outputs => []; +''') + repo = self.repo({"C.cs": source}) + entry = component(repo, "C") + self.assertEqual("unresolved", entry["extraction"]["status"]) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("ParamDefinition[]", output) + + def test_multilevel_inheritance_reads_the_declaring_base(self): + base = '''using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +namespace Demo { +public abstract class Base : GH_Component { +protected override void RegisterInputParams(GH_InputParamManager m) { m.AddNumberParameter("_shared", "_shared", "Shared", GH_ParamAccess.item); } +protected override void RegisterOutputParams(GH_OutputParamManager m) { } +} +public abstract class Middle : Base { } +}''' + derived = component_source("C", "").replace(": GH_SAMVariableOutputParameterComponent", ": Middle") + repo = self.repo({"C.cs": derived, "Base.cs": base}) + entry = component(repo, "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]) + self.assertEqual("_shared", entry["inputs"][0]["name"]) + self.assertEqual(0, run(repo)[0]) + self.assertEqual(0, run(repo, "--check")[0]) + write(os.path.join(repo, "Demo.Grasshopper", "Base.cs"), base.replace("AddNumberParameter", "AddTextParameter")) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("type 'Param_Number' -> 'Param_String'", output) + + def test_unknown_ancestry_is_a_fatal_discovery_finding(self): + repo = self.repo({"C.cs": self.declared()}) + self.assertEqual(0, run(repo)[0]) + self.assertEqual(0, run(repo, "--check")[0]) + write(os.path.join(repo, "Demo.Grasshopper", "Unknown.cs"), component_source("Mystery", "").replace(": GH_SAMVariableOutputParameterComponent", ": UnknownExternalBase")) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("unclassified class Mystery at Demo.Grasshopper/Unknown.cs", output) + self.assertNotIn("component removed", output) + + def test_malformed_project_is_a_fatal_discovery_finding(self): + repo = self.repo({"C.cs": self.declared()}) + self.assertEqual(0, run(repo)[0]) + write(os.path.join(repo, "Broken", "Broken.csproj"), "") + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("unparseable project Broken/Broken.csproj", output) + self.assertNotIn("component removed", output) + + def test_non_component_guid_reference_is_not_an_unclassified_object(self): + repo = self.repo({"C.cs": self.declared(), "Query.cs": '''namespace Demo { +public static class Query { public static object Copy(GH_Component c) { return c.ComponentGuid; } } +}'''}) + self.assertEqual([], pc.scan(repo)["unclassifiedClasses"]) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(0, code, output) + + def test_v3_baseline_without_extraction_evidence_fails(self): + repo = self.repo({"C.cs": self.declared()}) + self.assertEqual(0, run(repo)[0]) + path = os.path.join(repo, "design", "grasshopper-icons", "param_contract.json") + baseline = json.loads(pc.read_text(path)) + del baseline["components"][0]["extraction"] + write(path, json.dumps(baseline)) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("baseline has no extraction evidence", output) + + def test_custom_constructor_defaults_are_not_assumed_to_be_grasshopper_defaults(self): + types = '''namespace Demo { +public class CustomParam : Grasshopper.Kernel.Parameters.Param_Number { + public CustomParam() { Optional = true; SetPersistentData(123); } +} +}''' + repo = self.repo({"C.cs": self.declared("CustomParam"), "Types.cs": types}) + entry = component(repo, "C") + self.assertEqual("partial", entry["extraction"]["status"]) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("constructor defaults of parameter type Demo.CustomParam", output) + + def test_unknown_component_metadata_cannot_pass_as_matching_nulls(self): + source = self.declared().replace('base("C", "C", "Demo", "SAM", "Demo")', 'base(GetName(), "C", "Demo", "SAM", "Demo")') + repo = self.repo({"C.cs": source}) + self.assertEqual(0, run(repo)[0]) + code, output = run(repo, "--check") + self.assertEqual(1, code, output) + self.assertIn("component name is not a proven constructor literal", output) + + +class RealRepositoryTests(unittest.TestCase): + def test_wave_one_repositories_report_honest_coverage(self): + expected = {"SAM_GEM": 1, "SAM_BHoM": 3, "SAM_Python": 3} + for name, count in expected.items(): + repo = os.path.join(WORKSPACE, name) + if not os.path.isdir(repo): + self.skipTest("%s is not checked out" % name) + model = pc.scan(repo) + summary = pc.summarise(model) + self.assertEqual(count, summary["components"], name) + self.assertEqual(count, sum(summary[key] for key in ("verified", "partial", "unresolved")), name) + self.assertTrue(all(c["extraction"]["reasons"] for c in model["components"] + if c["extraction"]["status"] != "verified"), name) + + def test_real_revit_align_walls_is_not_certified_empty(self): + repo = os.path.join(WORKSPACE, "SAM_Revit") + if not os.path.isdir(repo): + self.skipTest("SAM_Revit is not checked out") + entry = component(repo, "RevitAlignWalls") + self.assertEqual("unresolved", entry["extraction"]["status"], entry) + self.assertTrue(any("ParamDefinition" in r for r in entry["extraction"]["reasons"])) + + def test_psychrometrics_baseline_passes_and_is_still_the_pilot(self): + pilot = os.path.join(WORKSPACE, "SAM_Psychrometrics") + if not os.path.isdir(pilot): + self.skipTest("SAM_Psychrometrics is not checked out") + code, output = run(pilot, "--check") + self.assertEqual(0, code, output) + self.assertIn("3 components discovered, 3 fully verified", output) + self.assertIn("OK compatibility", output) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/docs/GH_COMPONENT_STANDARDS.md b/docs/GH_COMPONENT_STANDARDS.md new file mode 100644 index 000000000..d540fd8bd --- /dev/null +++ b/docs/GH_COMPONENT_STANDARDS.md @@ -0,0 +1,892 @@ +# SAM-BIM Grasshopper Component Engineering Standard + +**Status:** proposed canonical specification in draft PR #204 into `sow/2026-Q4`, not merged. +Revised 2026-10-15 after two independent reviews. The validator certifies a deliberately bounded +source subset; incomplete extraction or discovery blocks compatibility acceptance. +**Owner:** SAM-BIM. Changes to this document go through a reviewed PR in `SAM-BIM/SAM`. +**Applies to:** every SAM-BIM repository that publishes Grasshopper components. + +This is the single specification for how a SAM-BIM Grasshopper component is named, described, +versioned and changed safely. It exists so that future component development is consistent, safe and +independently verifiable, and so a coding agent or a new maintainer can decide what a change is +allowed to do without re-deriving it from the source. + +Read section 0.1 and 0.2 first: they state exactly what is checked automatically, what a green result +does and does not prove, and what the validator refuses to certify. + +## 0. How to read this document + +Every rule carries a status, because the pilot work proved some rules and left others open. Do not +treat a proposal as a fact. + +| Status | Meaning | +|---|---| +| **VALIDATED** | Proven by committed code, a committed test, or a measured runtime result. Safe to rely on. | +| **CHECKED** | A static check in the metadata validator performs it, and a committed regression test covers it. Not a runtime or engineering guarantee. | +| **PROPOSED** | Recommended direction. Not checked by anything; a reviewer must judge it. | +| **OPEN** | A decision the owner has not made. Do not resolve it by guessing; ask. | +| **FROZEN LEGACY** | A published fact that must not change without an owner-approved migration. | + +Rule identifiers (`GH-A1`, `GH-B2`, …) are stable and may be cited from a PR, a test name or a +review comment. + +### 0.1 What is checked automatically, and what is not — read this before trusting a green result + +Three different things are often conflated. Static checks and Rhino-hosted NUnit tests are automated; +only the runtime tests exercise saved-definition compatibility. Engineering review remains separate: + +| Layer | What it establishes | Where | +|---|---|---| +| **Static metadata check** | The published parameter contract in the source still matches the approved baseline: GUID, component name/nickname/category/subcategory, and per parameter the name, runtime **type**, order, access, optionality, visibility and registered default; plus that extraction of that contract was complete. | `design/grasshopper-icons/tools/param_contract.py` and its regression tests | +| **Runtime compatibility test** | A saved definition opens, keeps its wires, typed-in values and groups, and recomputes. Rhino/Grasshopper-hosted; repository-local; not run by the static checker. | e.g. `Grasshopper/SAM.Core.Grasshopper.Tests/`, `SAM_Psychrometrics/Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/` | +| **Engineering review** | Description grammar and engineering meaning, SI-unit correctness, whether a description agrees with `SolveInstance`, mutually exclusive inputs, limitation wording, whether a rename is acceptable at all. | A human or agent reading the code. **Not automated.** | + +So: **grammatical quality, SI-unit correctness and engineering semantics are NOT automatically +enforced.** The static checker detects an empty description, a description that does not start with a +capital letter, and one known typo; everything else in section D is a review obligation. + +### 0.2 What the validator can be trusted for — three distinct outcomes + +The validator refuses to answer a question it cannot answer. Every component carries an +`extraction.status`: + +| Outcome | Meaning | Effect | +|---|---|---| +| `verified` | every parameter was extracted with name, runtime type, access, optionality, visibility and registered default | may be compared against the baseline | +| `partial` | extracted, but at least one attribute could not be established from the source | **blocks a compatibility PASS** | +| `unresolved` | the parameter set itself could not be established | **blocks a compatibility PASS** | + +- **Inventory mode** (`python param_contract.py --repo `) writes or refreshes the manifest and + reports coverage. It is explicitly labelled *not* compatibility validation: there is no approved + baseline yet, so nothing has been compared. It still reports how many components are verified. +- **Compatibility mode** (`--check`) compares against the committed manifest and fails if any + component is `partial`/`unresolved`, if the baseline is missing, or if any frozen attribute changed. +- A successful `--check` prints `OK compatibility`. Anything else is not a pass. + +The governing rule, which the tests enforce: **never report a component or repository as verified when +the validator has not completely and reliably extracted its published contract.** + +The mechanism this standard depends on lives in the shared `SAM` repository: + +- `Grasshopper/SAM.Core.Grasshopper/Classes/GH_SAMComponent.cs` +- `Grasshopper/SAM.Core.Grasshopper/Classes/ParameterRename.cs` +- `Grasshopper/SAM.Core.Grasshopper/Classes/ParameterRenameMap.cs` +- `Grasshopper/SAM.Core.Grasshopper/Modify/` (`CaptureConnections`, `RestoreConnections`, + `CopyPersistentData`, `CopyPersistentDataFromComponent`, `UpdateComponent`, `UpdateComponents`, + `ReadLegacyParameterData`, `ExpandVariableParameters`) +- `Grasshopper/SAM.Core.Grasshopper/Query/` (`Obsolete`, `HasComponentVersion`, `MissingConnections`) +- `Grasshopper/SAM.Core.Grasshopper/Enums/` (`ObsoleteSeverity`, `ParamVisibility`) + +--- + +## A. Component naming + +### GH-A1 — Component Name: PascalCase operation under a dotted prefix — **FROZEN LEGACY** for published names + +The published `Name` is the component's public identity. Existing instances follow +`.`, for example `SAMAnalytical.CreatePanelsBySolids`, +`SAMPsychrometrics.Psychrometrics`, `SAMOCCT.Solve3D`, `SAMCore.Update`. New components follow the +same shape: PascalCase after the dot, no underscores, no abbreviations that lose meaning. + +An existing `Name` is **frozen**. It is not renamed to tidy it up; see section F. + +### GH-A2 — Component NickName: short, recognisable, and equal to `Name` for new components — **PROPOSED** + +For a new component, set `NickName = Name`. The `SAM_Psychrometrics` pilot changed no nickname and +asserts `NickName == Name` for every parameter; the same expectation is set for the component itself. +An existing nickname (for example `SAMGeometrySplit`'s short `Geo` parameter nicknames) is never +overwritten blindly — it is a published display value with the same compatibility questions as a name. + +### GH-A3 — Category / Subcategory: `SAM` plus the domain area — **CHECKED** + +Use `Category = "SAM"` and `Subcategory` = the engineering domain: `Core`, `Analytical`, `Geometry`, +`Psychrometrics`, `OCCT`, `Weather`, `Architectural`. Historical subcategories such as +`Analytical01`…`Analytical04` exist and stay as they are. The metadata validator records category and +subcategory and fails on a change. + +### GH-A4 — Component Description: the engineering purpose, not the component name — **PROPOSED** + +The component description states what it computes, from what, and the units of the significant +quantities. It is not a restatement of the name and not a list of parameter names. See section D. + +### GH-A5 — Component GUID: stable identity, never regenerated for cosmetic changes — **CHECKED, VALIDATED** + +`ComponentGuid` is the component's identity in every saved definition. It is regenerated **only** when +a genuinely different component is created (for example a legacy component kept alongside a new one, +as with `SAMOCCT.AutoTune3D` vs `SAMOCCT.AutoTune3DDiscover`). + +Changing a GUID for a cosmetic, naming or description change is forbidden: it would orphan every +existing instance. The validator compares GUIDs against the baseline, and the Psychrometrics metadata +contract asserts the three GUIDs at runtime +(`Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/MetadataContractTests.cs`). + +### Approved legacy exceptions (component naming) + +- `SAMAnalytical.CreatePanelsBySolids`, `SAMAnalytical.CreateAperturesBySolids` name the operation in + the plural (`BySolids`). Kept: published names. +- Component names and nicknames that predate this standard are kept as they are. No repository-wide + renaming is authorised by this document. + +--- + +## B. Input naming — Ladybug-inspired convention + +SAM-BIM uses the Ladybug Tools / Honeybee input-marking convention. It is a **naming** convention. +It is not a mechanism, and it is not evidence of how Grasshopper behaves. + +| Pattern | Meaning | +|---|---| +| `_name` | mandatory input | +| `_name_` | optional input with a defined default | +| `name_` | optional input without a defined default | + +- **GH-B1 — New input names use camelCase with the marker that matches the design — PROPOSED.** + The marker must match what the component actually does, per GH-B4. +- **GH-B2 — Do not change published input names to fit the convention — FROZEN LEGACY.** + See section F. +- **GH-B3 — The marker is documentation, not enforcement — VALIDATED.** + Nothing in Grasshopper or in SAM reads the underscore. The `Optional` property, the registered + persistent data and any code-side fallback are independent declarations; the marker only records + the intent for a reader. In + `Grasshopper/SAM.Core.Grasshopper.Psychrometrics/Component/SAMPsychrometricsPsychrometrics.cs` the + name `"_relativeHumidity_"` and `Optional = true` are set on the same initializer but neither + derives from the other. + +### GH-B4 — Requiredness and defaults: four different things that are often confused — **VALIDATED** + +A reviewer or agent must keep these distinct. Verified in `GH_SAMVariableOutputParameterComponent` +registration and in the components themselves: + +1. **Grasshopper `Optional`.** A property of the parameter object. An `Optional = true` input does not + raise a "no data" error when it is unwired. It says nothing about a value existing afterwards. +2. **Registered persistent default (`IGH_Param.SetPersistentData`).** A value baked into the freshly + created parameter (`_pressure_` in `SAMPsychrometrics.Psychrometrics` and + `SAMPsychrometricsDryBulbTemperature` carries `101325`). A new instance arrives with that value in + its persistent data tree; the parameter archive in a saved definition overrides it on reopen. +3. **Code-side fallback default.** Applied inside `SolveInstance` when the input is unwired or NaN, for + example `pressure = 101325` in `SAMPsychrometricsPsychrometrics`. This is a real and legitimate + design. It is invisible in the parameter metadata, so it *must* be stated in the description and + covered by a test. +4. **Runtime validation / mandatory requirement.** Enforced in `SolveInstance`: either an + `AddRuntimeMessage(GH_RuntimeMessageLevel.Error, …)` + `return`, or a guard on a mutually exclusive + set (the four humidity indications above, exactly one of which must be supplied). + +**GH-B5 — Code-side defaults are valid when they are documented and tested — VALIDATED.** +Do not require `SetPersistentData` merely to satisfy the naming convention, and do not add +`SetPersistentData` as a substitute for documenting the fallback. `CreatePanelsBySolids` documents +`minThickness_` (0.08 m), `snap_` (0.3 m), `maxThicknessRatio_` (0.2) and `airForOpenings_` (true) in +the parameter description while applying the fallback in code. + +**GH-B6 — Never change `Optional`, `ParamVisibility` or a default to satisfy a naming convention — +VALIDATED (forbidden).** Those three are published behaviour. Changing one is a behavioural change +(section H) and is not a formatting fix. + +### `ParamVisibility` semantics — VALIDATED + +`ParamVisibility` is a `[Flags]` enum (`Grasshopper/SAM.Core.Grasshopper/Enums/ParamVisibility.cs`) +evaluated by `GH_SAMVariableOutputParameterComponent`: + +| Value | Registered on a fresh component | Removable on the canvas | +|---|---|---| +| `Voluntary` (0) | no | yes | +| `Mandatory` (1) | no | no | +| `Default` (2) | yes | yes | +| `Binding` (3) = `Mandatory \| Default` | yes | no | + +- Registration tests `HasFlag(Default)` (`RegisterInputParams` / `RegisterOutputParams`, + `Modify/ReadLegacyParameterData`); removal tests `HasFlag(Mandatory)` (`CanRemoveParameter`). +- `Voluntary` is the mechanism behind an input that is *hidden until inserted* — the "zoom in and + click +" parameter. `SAMAnalytical.CreatePanelsBySolids`' `maxThicknessRatio_` and `diagnosticDetail_` + are described that way on the canvas. +- `Mandatory` alone is never used anywhere in SAM and means "not registered and not removable"; treat + it as a legacy combination, not a supported option. + +--- + +## C. Output naming + +### GH-C1 — New outputs are PascalCase, capitalised, with no underscore markers — **PROPOSED** + +`Panels`, `Spaces`, `AnalyticalModel`, `Diagnostics`, `AdjacencyCluster`, `Successful`. + +### GH-C2 — No leading or trailing underscore on an output — **CHECKED (convention)** + +The metadata validator computes the expected output name as +`PascalCase(strip_underscores(name))` and reports a deviation. That is a **convention** finding, not a +compatibility failure: an existing deviation is legitimate and stays until it is registered as an +approved exception, and only a *new* deviation fails (section J). The `SAM_Psychrometrics` pilot +applied this to one output: `enthalpy` → `Enthalpy`, through the migration in section G. + +### GH-C3 — Existing camelCase outputs stay until an approved migration — **FROZEN LEGACY, VALIDATED** + +This is not a style question. Measured in Rhino 8: a reopened definition keeps the names the **file** +has, so renaming an output breaks every `Params.IndexOfOutputParam` lookup in every existing +definition, silently, and the SAM updater loses the wire and any typed-in value. Evidence: +`Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/SavedDefinitionCompatibilityTests.cs` and +`SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` §2. + +The `SAM_Psychrometrics` pilot froze 22 naming deviations and migrated exactly one. Those 22 stay +frozen. `SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` is the record. + +### GH-C4 — Units and engineering detail belong in the description, not the name — **PROPOSED** + +`Panels`, not `Panels_m2`. + +--- + +## D. Descriptions and tooltips + +Every component, input and output has a description. It is the only place the engineering meaning is +written down for the person on the canvas. + +- **GH-D1 — Begin with a capital letter; be grammatically and technically correct — CHECKED (for the + first letter only).** The validator detects an empty description and one that does not begin with a + capital letter. Grammar and technical correctness are a review obligation, not a check. +- **GH-D2 — Explain the engineering meaning, not the data type — PROPOSED.** +- **GH-D3 — Name the expected data when it matters — PROPOSED.** e.g. "Closed solids (Breps or + extrusions) of planar building elements: walls, floors, slabs, ceilings, roofs, gables." +- **GH-D4 — Document SI units explicitly — PROPOSED, NOT CHECKED.** Nothing verifies units + automatically. The validator reports the description text only; a unit error is invisible to it. The + pilot's metadata review fixed `[kg/m3]` → `[kg/m³]` by hand, and the pilot's *runtime* test asserts a + few description strings — that is all. +- **GH-D5 — Document defaults and fallback behaviour accurately — PROPOSED.** If a code-side fallback + applies (GH-B4.3), the description says so: "Atmospheric pressure [Pa]. Default: 101325". +- **GH-D6 — Explain important input relationships and mutually exclusive inputs — PROPOSED.** The + Psychrometrics component's humidity inputs each state which set they belong to and that only one may + be connected, matching the exclusivity `SolveInstance` actually enforces. +- **GH-D7 — Explain the limitation or failure condition where it is useful — PROPOSED.** e.g. "Any + other value is reported and Summary is used." +- **GH-D8 — No placeholder or misleading text — CHECKED (advisory, narrowly).** Empty descriptions, + descriptions that do not start with a capital letter, the known typo (`Humidty`), and differences + between `NickName` and `Name` produce advisory notes. They fail only with `--strict-descriptions`. + Existing nicknames remain legitimate (GH-I3); misleading engineering text requires review. +- **GH-D9 — A description must agree with the implementation — PROPOSED (reviewer responsibility).** + No check can verify this. It is the one quality rule that needs a human or an agent reading + `SolveInstance` against the text. Treat a mismatch as a defect. + +Descriptions are **not** frozen by the contract, deliberately: improving one does not require +regenerating the manifest. A description change is still a metadata change and gets a patch version +bump (section H) so the *Update component* offer becomes visible. + +--- + +## E. Engineering Diagnostics + +### E1 — What is implemented today — **VALIDATED** + +Three layers exist and are committed: + +1. **`SAM.Core`** (Unified Diagnostics Phase 1, SAM#201): `DiagnosticSeverity` (Info 0, Warning 1, + Error 2), `DiagnosticDetail` (Summary 0, Standard 1, Detailed 2), immutable `Diagnostic` and the + `Diagnostics` collection. `Add` drops Info above the collection's `Detail`; **Warnings and Errors + are never dropped**. `Filter(level)` can only narrow. Runtime values, not serialised, independent + of `Log`. Files: `SAM/SAM.Core/Enums/DiagnosticSeverity.cs`, `DiagnosticDetail.cs`, + `SAM/SAM.Core/Classes/Diagnostics/Diagnostic.cs`, `Diagnostics.cs`. +2. **`SAM.Core.OCCT`** (Phase 3): `DiagnosticRecord` / `DiagnosticMessage` adapters and + `SAM.Core.Query.TryGetDiagnosticDetail`. +3. **Grasshopper**: `SAM_OCCT` components expose a `Diagnostics` string-list output and a hidden + optional `diagnosticDetail_` integer input; `SAM.Analytical.CreatePanelsBySolids` / + `CreateAperturesBySolids` accept `diagnosticDetail_` and report in their existing `log` output. + Severity maps to the Grasshopper message level (Info → log only, Warning → GH Warning, Error → GH + Error). + +### E2 — The preferred convention for new SAM-BIM Diagnostics — **PROPOSED** + +- Detail enum: **0 = Summary (default), 1 = Standard, 2 = Detailed**, matching the implemented + `SAM.Core.DiagnosticDetail`. A higher level includes every lower level. +- Detail controls **information granularity only**, never severity. Important warnings and errors + remain visible at every level. +- Preferred new input: `_diagnosticDetail_`. +- Preferred output: `Diagnostics`. + +The enum values are implemented and must not be renumbered; the Preferred new input/output names are +the *proposal for new components*, and they conflict with what is already published — see GH-E3. + +### GH-E3 — `diagnosticDetail_` is already published; `_diagnosticDetail_` is a proposal — **OPEN** + +| Where | Published input name | +|---|---| +| `SAM.Analytical.CreatePanelsBySolids` 1.0.3, `CreateAperturesBySolids` 1.0.2 | `diagnosticDetail_` | +| `SAMOCCT.Solve3D` 0.7.1, `SAMOCCT.CreateAdjacencyCluster` 0.2.1 (and the rest of the OCCT set) | `diagnosticDetail_` | + +The implemented name (`diagnosticDetail_`) reads as "optional, no default" under section B, while the +input does have a code-side default of 0 — the convention's spelling for that is `_diagnosticDetail_`. +The owner must choose: keep `diagnosticDetail_` as the SAM-BIM convention, or adopt `_diagnosticDetail_` +for new components and accept that the existing inputs are a frozen legacy exception. **No existing +input is renamed by this document.** The OCCT components already assert the published name in +`SAM_OCCT/Testing/SAM.OCCT.GrasshopperTests/DiagnosticsContractTests.cs`. + +### GH-E4 — Do not infer severity from message text — **VALIDATED** + +`SAM.Core.OCCT` maps an explicit severity; `OcctDiagnosticReporter.ToRuntimeMessageLevel` switches on +the enum. Text matching was removed in the OCCT work. + +### GH-E5 — Default Summary changes the default output content — **VALIDATED, owner-accepted** + +After the Diagnostics work, the default `log` / `Diagnostics` text is Summary, so per-item Info lines +that used to appear by default now need `diagnosticDetail_ = 1` or `2`. Warnings and Errors are +unaffected. This is an accepted behaviour change; it is recorded so it is not reported as a +regression. + +### GH-E6 — Scope limit — **OPEN** + +The unified Diagnostics architecture is under active development (Phase 4, OpenStudio/TAS adapters, is +not started). This document describes what exists and the direction for new work. It does not +authorise a Diagnostics redesign, and nothing here should be read as describing a capability that is +not in the code above. + +--- + +## F. Published component compatibility + +### GH-F1 — The verified Rhino 8 serialization behaviour — **VALIDATED** + +Measured in a real Rhino 8 / Grasshopper run, not inferred: + +- `GH_DocumentIO` writes a `ParameterData` chunk holding, per parameter, its own `InstanceGuid`, + `Name`, `NickName`, `Description`, `Optional`, `Access` and persistent data. +- On reopen **the parameter names in the file win over the names the component class declares.** A + renamed parameter keeps carrying the old name, so `Params.IndexOfInputParam("")` returns + `-1` for every existing definition. +- Wires reference the parameter `InstanceGuid`, so a wire survives a reread but is lost when the + component is *replaced* by the updater and the replacement's parameter is matched by name. +- Consequences: a rename produces *Invalid data* and no output, with **no compile error and no failed + build**. Compilation cannot prove backward compatibility. + +Evidence: `SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` §2 ("Measured evidence"), +`SAM_Psychrometrics/Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/SavedDefinitionCompatibilityTests.cs`, +and the fixture `Fixtures/Psychrometrics-Legacy.ghx` saved by the pre-pilot build. + +### GH-F2 — Safe metadata correction vs compatibility migration vs behavioural change — **VALIDATED** + +| Class | Examples | Published parameter set | Version bump | +|---|---|---|---| +| **Safe metadata correction** | description text, typo, unit wording, nickname spelling fix that is verified harmless | unchanged: no `Name`, order, `Access`, `Optional`, visibility, persistent default or GUID touched | patch | +| **Compatibility migration** | parameter `Name` change with a declared alias | `Name` changes; every other attribute and the GUID stay | patch, with a `ParameterRename` declaration (section G) | +| **Behavioural / contract change** | computed result, default value, `Optional`, visibility, `Access`, order, a removed or added parameter, a GUID change | anything | minor or major, per section H | + +A nickname change and a description change are *metadata*; a nickname is still a published display +value, so verify before changing one (GH-I3). + +### GH-F3 — Existing parameter names stay unchanged unless an explicit, tested migration is provided — +**FROZEN LEGACY** + +This is the single most important rule in this document. It follows from GH-F1: the file wins, so an +unmigrated rename is a silent break for every existing definition. + +--- + +## G. Parameter migration + +The mechanism is implemented in the shared `SAM` repository and validated end to end on one controlled +rename. It is reused, not redesigned. The implementation record is +`documentation/GH-Parameter-Rename-Migration-Q4.md`; the consumer record is +`SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` §5. + +### GH-G1 — A rename is declared, never inferred — **VALIDATED** + +The component declares its published renames: + +```csharp +public override IReadOnlyList ParameterRenames => parameterRenames; + +private static readonly ParameterRename[] parameterRenames = +{ + new ParameterRename(GH_ParameterSide.Output, "enthalpy", "Enthalpy"), +}; +``` + +`GH_SAMComponent.ParameterRenames` defaults to `null`: a component that declares nothing behaves +exactly as before, and nothing renames on file open. + +### GH-G2 — Alias resolution is conservative and refuses ambiguity — **VALIDATED** + +`ParameterRenameMap.Create` validates the declaration and resolves a legacy name to exactly one +current name. Refused and reported rather than guessed: duplicate legacy names (`X→A` and `X→B`), +chained renames (a legacy name that is another rename's target), empty names, and a rename to the same +name. A declaration that throws is caught, reported, and never stops an otherwise-fine update. +A name no rename mentions resolves to itself. + +### GH-G3 — Alias-aware behaviour — **VALIDATED** + +| Concern | Member | +|---|---| +| Wire reconnection | `Modify.RestoreConnections(component, connections, map, out log)` — redirects a legacy name, and **refuses** a redirect whose parameter runtime type changed (`ParamConnection.ParamTypeName` vs the new parameter's type) | +| Persistent-data transfer | `Modify.CopyPersistentDataFromComponent(from, to, map, out log)` — same type guard; a value that cannot be migrated is reported, never discarded silently | +| `SolveInstance` compatibility | `GH_SAMComponent.IndexOfInputParameter` / `IndexOfOutputParameter` fall back to the declared legacy name, so an *un-updated* definition still computes | +| Missing-wire verification | `Query.MissingConnections(component, snapshots, map)` resolves the alias, so a restored wire is not reported to the person as missing | +| Group membership | `Modify.ReconcileGroupMembership(document, old, new, out log)` moves `GH_Group.ObjectIDs` entries from the replaced object to the replacement | +| Updater entry point | `Modify.DuplicateComponent` reads and validates the replacement's renames, and is transactional after `AddObject` | + +The original (pre-rename) public signatures were kept and delegate with an empty map: the change is +source-compatible for callers. + +### GH-G4 — Version gating: never migrate on open — **VALIDATED** + +A rename only takes effect through the explicit *Update component* offer, which becomes visible +because `LatestComponentVersion` was raised (see section H). Opening a file never changes it. + +### GH-G5 — Identity is reconciled, not adopted — **VALIDATED** + +A replacement is a newly constructed instance with a new `InstanceGuid`; `InstanceGuid` is get-only +and the only way to force a specific value is `NewInstanceGuid(Guid)`, whose purpose is minting a +fresh identity. `GH_Document` keeps an identity-keyed object cache populated at `AddObject`, so +adopting an old identity is unsafe. A reflection sweep of Rhino 8 Grasshopper +(`8.35.26251.13001`) found `GH_Group.ObjectIDs` as the only persisted `Guid` collection naming +document objects, so explicit group reconciliation is necessary and sufficient. + +### GH-G6 — Controlled failure — **VALIDATED** + +If a step after `AddObject` throws, the half-built replacement is removed and the original component +is left in the document. A broken `ParameterRenames` declaration is reported and never blocks the +update. + +### GH-G7 — Required validation for a rename migration — **PROCESS REQUIREMENT (not automated)** + +1. Build and the automated test suites. +2. An explicit alias-verification test (declaration shape, and duplicate/malformed declarations refused). +3. A committed legacy `.gh`/`.ghx` fixture saved by the *pre-change* build, driven through the updater + in a real headless Rhino 8 (Rhino.Testing), asserting preserved wires, preserved persistent values, + preserved groups. +4. Save/reopen/recompute: the upgraded definition recomputes identical engineering results. +5. No unintended engineering difference for anything not renamed. + +Reference implementation of exactly this: `SAM_Psychrometrics/Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/` +(`ParameterRenameMigrationTests.cs`, `MetadataContractTests.cs`, `SavedDefinitionCompatibilityTests.cs`, +`Fixtures/Psychrometrics-Legacy.ghx`) and, on the shared side, +`Grasshopper/SAM.Core.Grasshopper.Tests/ParameterRenameMigrationTests.cs` (13 tests) with the fixture +components in `Grasshopper/SAM.Core.Grasshopper.Tests.Fixtures/TestComponents.cs`. + +### GH-G8 — Residual limitation — **VALIDATED, unavoidable** + +A rename is still breaking for any consumer that looks the old name up **outside** the SAM updater — +for example a C# script component or an external tool that matched on the historical string. The +`SolveInstance` fallback only covers the component's own lookups. There is no runtime enforcement in +this state and none is possible; this is documented in the consumer PR and it is the reason each +rename needs its own approval. + +--- + +## H. Component versioning + +### GH-H1 — The version difference drives the severity label — **VALIDATED** + +`GH_SAMComponent.Obsolete` / `Query.GetObsoleteSeverity` compare the version recorded in the saved +definition (`SAM_ComponentVersion`, written from `LatestComponentVersion` at construction) with the +class's current `LatestComponentVersion`: + +| Difference | `ObsoleteSeverity` | +|---|---| +| equal | `None` | +| patch differs only | `Advisory` (logged as `[patch]`) | +| minor differs | `Standard` | +| major differs | `Breaking` (logged as `[breaking]`) | +| either version unparseable | `Standard` | + +`Obsolete` (severity ≠ `None`) is what makes the *Update component* / *Get the latest component* menu +items appear (`Modify/AppendNewComponentAdditionalMenuItem.cs`, `Modify.UpdateComponents`). + +### GH-H2 — The de-facto policy, as implemented and practised — **VALIDATED** + +Raise the **patch** component for every metadata-only or additive-compatible change; reserve minor and +major for genuine behavioural or contract-breaking changes. Observed in the merged work: + +| Change | Examples | Bump | +|---|---|---| +| Description-only correction | `SAMPsychrometrics.Psychrometrics` 1.0.5→1.0.6; `DryBulbTemperature` / `DewPointTemperature` 1.0.1→1.0.2 | patch | +| Additive optional input appended last | `SAMAnalytical.CreatePanelsBySolids` 1.0.2→1.0.3; `CreateAperturesBySolids` 1.0.1→1.0.2; `SAMOCCT.Solve3D` 0.7.0→0.7.1; `CreateAdjacencyCluster` 0.2.0→0.2.1; `CreateShells` 0.3.0→0.3.1 | patch | +| Compatible parameter rename with a declared alias | `SAMPsychrometrics.Psychrometrics` 1.0.6→1.0.7 (`enthalpy`→`Enthalpy`) | patch | +| Genuine behavioural / contract-breaking change | not yet exemplified in Q4; e.g. a changed default, a removed parameter, a different geometry result | minor or major, chosen by the impact, and the reviewing owner | + +### GH-H3 — A patch bump is not a claim that the change is harmless — **VALIDATED** + +The bump exists to make the *Update component* offer visible. Safety comes from the declared alias +(GH-G1) and the type guard (GH-G3), not from the number. A patch-level change may therefore carry the +most consequential work in a release (the `enthalpy` rename did); this is intentional and is not a +reason to inflate the version. + +### GH-H4 — `ObsoleteSeverity` and migration compatibility are orthogonal — **VALIDATED** + +| | `ObsoleteSeverity` | `ParameterRenames` | +|---|---|---| +| Answers | how big is the difference between the saved component and the current class? | which published names changed, so the updater can carry wires and values? | +| Derived from | the version strings | an explicit declaration | +| Effect | labels the update (`[patch]` / `[breaking]`) and makes the menu item appear | changes what the updater does: alias redirection, type-guarded reconnection, group reconciliation | +| Set by | raising `LatestComponentVersion` | adding a `ParameterRename` | + +A "breaking" label alone migrates nothing. A declared rename alone does nothing until the version is +raised. **Never use `ObsoleteSeverity` as evidence that a migration is safe, and never rely on a patch +bump to protect an unmigrated rename.** + +### GH-H5 — `MinCompatibleVersion` is declared but not consulted — **VALIDATED (do not rely on it)** + +`IGH_SAMComponent.MinCompatibleVersion` exists and every implementation returns +`LatestComponentVersion`, but nothing in `SAM.Core.Grasshopper` reads it. It is not an enforcement +mechanism today. Do not use it as one, and do not change its behaviour in a documentation PR. + +### GH-H6 — No versioning of the standard's rules by component — **PROPOSED** + +A component's `LatestComponentVersion` versions the *component*, not its compliance with this +document. There is no plan to version each rule. + +--- + +## I. Parameter identity and serialization + +### GH-I1 — What may never change without a separately approved migration — **FROZEN LEGACY** + +For every published parameter: + +- the `Name`; +- the runtime parameter **type** — the class the parameter is constructed from, for example + `Param_Number` becoming `Param_String`, `GooPanelParam`, or a third-party parameter such as + RhinoInside.Revit's `HostObject`; +- the `Access` (`item` / `list` / `tree`); +- the **order** within its side; +- the `Optional` flag; +- the visibility (`ParamVisibility`); +- any registered persistent default. + +And for the component: the `ComponentGuid`. + +Changing any of these changes what an existing definition means. The metadata contract freezes all of +them (`design/grasshopper-icons/tools/param_contract.py`, `compare` and `FROZEN_FIELDS`) and the +runtime contract asserts them at runtime in a real Rhino (`MetadataContractTests`, +`DiagnosticsContractTests`). + +Two notes on the type, because it is the attribute most easily lost by an extractor: + +- Type identity preserves namespaces and closed generic arguments: `VendorA.HostObject` differs from + `VendorB.HostObject`, and `GooValueParam` differs from `GooValueParam`. Literal aliases, + `global::`, primitive type aliases, local declarations and unambiguous source imports are resolved. + Known Grasshopper built-ins retain their pilot-compatible short names; arbitrary external types + retain their qualified identity in `externalParameterTypes`. +- A type that cannot be identified at all is **not** treated as equal to anything. It makes the + component `partial` and blocks the check (GH-J6). +- Type identity alone does not prove constructor defaults. Nontrivial custom Goo/third-party + constructors are inventory-only until their effects can be verified. Known built-ins and empty + source subclasses inheriting those defaults are the currently certified subset. + +### GH-I2 — When a controlled migration permits an exception — **VALIDATED** + +Exactly one thing is exemptable, and only with the section G mechanism plus section J acceptance: a +parameter `Name`, redirected by a declared `ParameterRename`, with an unchanged type. Everything else +in GH-I1 remains frozen even under a rename. + +### GH-I3 — Nickname and display metadata: verify first — **OPEN** + +`NickName` is serialised per parameter and restored from the file, like `Name`. The pilot treats +`NickName == Name` as the new-interface expectation and changes no existing nickname. Whether a +nickname change is *harmless* for an existing definition has not been independently measured the way +the name behaviour has, so: + +- changing a nickname is a metadata correction (GH-F2), subject to review; +- **do not** change a nickname as a way to change what a person sees while claiming the name is + untouched, without stating the compatibility reasoning in the PR. + +### GH-I4 — Order is identity-adjacent — **VALIDATED** + +Order is asserted by the contract and by the runtime tests. Appending a new voluntary parameter at the +**end** is the only order-safe addition (observed in `CreatePanelsBySolids` and the OCCT set, where +`diagnosticDetail_` is appended last and asserted as such). Inserting or reordering is a +contract-breaking change. + +### GH-I5 — Descriptions are serialised but not frozen — **VALIDATED** + +`Description` is stored per definition, so a new description reaches a person on a fresh placement or +after *Update component*; it cannot change how an existing definition computes. The contract +deliberately does not freeze it (GH-D9). + +--- + +## J. Validation and acceptance + +The minimum verification for a change depends on its class (GH-F2). Prefer automated verification over +inspection; where a check cannot be automated today, say so explicitly in the PR. + +### GH-J0 — The three outcomes of a compatibility check — **CHECKED** + +`--check` compares the source against the committed manifest and reports findings in categories: + +| Category | Fatal? | What it means | +|---|---|---| +| `compatibility` | yes | a frozen attribute (GH-I1) changed: identity, parameter name, **type**, order, access, optionality, visibility, registered default; or a component was added/removed | +| `extraction` | yes | a component could not be fully extracted (`partial`/`unresolved`), the baseline lacks required extraction evidence, an object is unclassified, or a project cannot be parsed | +| `conventions` | yes | a naming-rule deviation that is not in the approved exception list, or an exception that is no longer a deviation | +| `manifest` | yes | the component version changed, so the manifest must be regenerated in this reviewed change — explicitly **not** a compatibility violation | +| `advisory` | no (unless `--strict-descriptions`) | description quality and nickname differences | + +**Never weaken a check to obtain a green result.** If a repository cannot be validated reliably, report +its coverage limitation (a `partial` count with reasons) instead of lowering the bar. + +### GH-J1 — Metadata-only correction + +- Build (Release) with 0 errors. +- Compatibility check: `python design/grasshopper-icons/tools/param_contract.py --repo --check` + → `OK compatibility`. +- If the version was bumped (as it should be, section H), the manifest is regenerated in the same + reviewed change with `--repo `; the manifest diff is what a reviewer reads. +- No unintended metadata or API change: the diff must show only `Description` (and possibly + `LatestComponentVersion`) in the component files. + +### GH-J2 — Compatible parameter migration + +- Build and the automated tests. +- Explicit alias verification: the declaration shape, and that duplicate/malformed declarations are + refused. +- Legacy `.gh` / `.ghx` fixture testing in real Rhino 8 (headless `Rhino.Testing` is the accepted + substitute for a canvas session; state which was used). +- Preserved wires, persistent values and group membership. +- Saved/reopened definitions recompute correctly. +- No unintended engineering result differences elsewhere. + +### GH-J3 — Behavioural change + +- Engineering-specific regression tests (golden fixtures; see the `PanelsBySolid` golden data). +- A compatibility and serialization review against section I. +- Appropriate integration tests. + +### GH-J4 — What a check cannot cover — **VALIDATED** + +- Description-versus-implementation agreement, grammar and SI-unit correctness (section 0.1). +- Renames for consumers outside the SAM updater (GH-G8) are unenforceable. +- Interactive canvas behaviour (the update mark, the "+" insert UI, a bake) is a manual acceptance. + The merged Q4 work records headless equivalents and labels them as such; do not claim a canvas check + unless it was run. + +### GH-J5 — The metadata contract is a gate with an honest exception list — **CHECKED** + +A new naming deviation fails; a listed exception that is no longer a deviation also fails. That is what +keeps the frozen-exception list meaningful instead of decorative. An existing deviation that *is* +registered passes, so no existing component is forced to change its published contract. + +### GH-J6 — The extraction gate — **CHECKED, fail-closed** + +A component whose contract was not completely extracted makes `--check` fail with an `extraction` +finding. This is deliberate: it is better to report `partial` than to certify a contract the validator +did not actually read. The regression suite proves this with cases that must fail — a missing output +set, a parameter name that is not a literal, a parameter registered outside the declaration, a +parameter whose type cannot be determined, and a baseline that is missing or itself incomplete. + +--- + +## Appendix 1 — Evidence index + +| Rule area | Source of truth | +|---|---| +| Input/output markers and defaults | `Grasshopper/SAM.Core.Grasshopper.Psychrometrics/Component/SAMPsychrometricsPsychrometrics.cs`; `SAMPsychrometricsDryBulbTemperature.cs`; `SAMPsychrometricsDewPointTemperature.cs` | +| Visibility semantics | `Grasshopper/SAM.Core.Grasshopper/Classes/GH_SAMVariableOutputParameterComponent.cs`; `Enums/ParamVisibility.cs`; `Modify/ReadLegacyParameterData.cs` | +| Versioning and obsolete labels | `Classes/GH_SAMComponent.cs`; `Query/Obsolete.cs`; `Enums/ObsoleteSeverity.cs`; `Modify/AppendNewComponentAdditionalMenuItem.cs` | +| Rename mechanism | `Classes/ParameterRename.cs`; `Classes/ParameterRenameMap.cs`; `Modify/CaptureConnections.cs`; `Modify/RestoreConnections.cs`; `Modify/CopyPersistentData.cs`; `Modify/CopyPersistentDataFromComponent.cs`; `Modify/UpdateComponent.cs`; `Modify/UpdateComponents.cs`; `Query/MissingConnections.cs`; `Classes/ParamConnection.cs` | +| Legacy parameter archive | `Grasshopper/SAM.Core.Grasshopper/Modify/ReadLegacyParameterData.cs` | +| Diagnostics | `SAM/SAM.Core/Enums/DiagnosticDetail.cs`, `DiagnosticSeverity.cs`; `SAM/SAM.Core/Classes/Diagnostics/Diagnostics.cs`, `Diagnostic.cs`; `SAM_OCCT/Grasshopper/Shared/OcctDiagnosticReporter.cs`; `SAM_OCCT/Grasshopper/…/SAMOCCTCreateAdjacencyCluster.cs`; `Grasshopper/SAM.Analytical.Grasshopper/Component/SAMAnalyticalCreatePanelsBySolids.cs` | +| Metadata contract | `design/grasshopper-icons/tools/param_contract.py` (this repository, canonical); `design/grasshopper-icons/tools/tests/test_param_contract.py` (69 regression tests); `SAM_Psychrometrics/design/grasshopper-icons/{tools/param_contract.py,tools/inventory.py,param_contract.json}` (pilot copy) | +| Runtime contract | `Grasshopper/SAM.Core.Grasshopper.Tests/` (Rhino.Testing); `SAM_Psychrometrics/Grasshopper/SAM.Core.Grasshopper.Psychrometrics.Tests/`; `SAM_OCCT/Testing/SAM.OCCT.GrasshopperTests/` | +| Measured compatibility | `SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md`; `documentation/GH-Parameter-Rename-Migration-Q4.md`; `documentation/Diagnostics-Phase1-PR.md`; `documentation/Diagnostics-Phase2-BySolids-PR.md` | + +## Appendix 2 — Measured coverage (2026-10-15, final correctness repair) + +Produced by the canonical validator: + +``` +python design/grasshopper-icons/tools/param_contract.py --repo --output +``` + +`verified` means the published contract of that component was completely extracted and may be compared +against a baseline. `partial` means it was extracted but at least one attribute could not be +established; the reasons are in the manifest. A `partial` component **blocks a compatibility PASS**. + +| Repository | Components | Verified | Partial | Unresolved | Parameter classes excluded | +|---|---|---|---|---|---| +| SAM | 460 | 37 | 423 | 0 | 64 | +| SAM_Tas_Grasshopper | 94 | 5 | 85 | 4 | 10 | +| SAM_Revit | 55 | 9 | 13 | 33 | 1 | +| SAM_OCCT | 36 | 0 | 36 | 0 | 1 | +| SAM_UI | 31 | 0 | 31 | 0 | 1 | +| SAM_Systems | 30 | 0 | 27 | 3 | 8 | +| SAM_Mollier | 24 | 7 | 15 | 2 | 4 | +| SAM_Topologic | 22 | 7 | 15 | 0 | 0 | +| SAM_SolarCalculator | 21 | 0 | 21 | 0 | 3 | +| SAM_Excel | 8 | 1 | 6 | 1 | 0 | +| SAM_LadybugTools | 7 | 2 | 5 | 0 | 0 | +| SAM_Solver | 7 | 0 | 7 | 0 | 0 | +| SAM_OpenStudio | 6 | 0 | 6 | 0 | 0 | +| SAM_Multitasker | 5 | 0 | 4 | 1 | 4 | +| SAM_Acoustic | 3 | 0 | 3 | 0 | 1 | +| SAM_BHoM | 3 | 0 | 3 | 0 | 0 | +| SAM_IFC | 3 | 0 | 3 | 0 | 1 | +| SAM_Psychrometrics | 3 | 3 | 0 | 0 | 0 | +| SAM_Python | 3 | 0 | 3 | 0 | 3 | +| SAM_gbXML | 2 | 1 | 1 | 0 | 0 | +| SAM_Origin | 2 | 1 | 1 | 0 | 0 | +| SAM_GEM | 1 | 0 | 1 | 0 | 0 | + +**Corrected totals:** 22 repositories, **826 discovered / 73 verified / 709 partial / 44 unresolved**; +0 unclassified objects and 0 unparseable projects in these checkouts. The earlier 823/817 claim omitted +Psychrometrics' three components; the earlier table summed to 826/820. That 820 label was unsound: +unsupported declarations and constructor effects were treated as complete. These totals use the final +strict subset, rather than treating an empty scan as proof. Revit's 33 empty uncertain contracts are +now unresolved. The source metadata for CreatePanelsBySolids still yields 7 inputs/4 outputs, but its +Goo constructor defaults require additional evidence and therefore it is partial. + +**Coverage limitations, stated explicitly.** SAM_Psychrometrics is the only repository with an approved +baseline, so it is the only repository for which `--check` is meaningful; for every other repository +these numbers are an **inventory**, not compatibility validation. The six originally identified partial components remain included in the extraction gaps listed in +Appendix 3; they are not the complete coverage limitation. `parameter classes excluded` counts the `Goo…Param` classes, which are +reported in the manifest with the ancestry that excluded them. + +## Appendix 3 — Open decisions and known limitations + +1. **Classification is by ancestry, and unresolved ancestry is reported.** A class is a published + component when its base chain reaches a known Grasshopper root (`GH_Component`, `GH_SAMComponent`, + `GH_SAMVariableOutputParameterComponent`, `GH_SAMAsyncComponent`, `GH_SAMEnumComponent`, + `IGH_Component`, `IGH_SAMComponent`) through the repository's own classes; a `Goo…Param` class is a + parameter. A class whose chain leaves the repository at an **unknown** type is neither silently + included nor silently excluded — it is listed in `unclassifiedClasses` with its base chain, and it is + not counted as a component. Ancestry is resolved across the whole repository (including + non-Grasshopper projects), so a base type in another project of the same repository still resolves. + Unclassified objects and unparseable projects are fatal extraction findings with source paths. + Recognizing a Revit component through `IGH_SAMComponent` does not establish its parameter contract; + `ParamDefinition[]` declarations remain unresolved by this tool. +2. **The six originally reported partial components remain extraction gaps.** They are a subset + of the larger, corrected coverage limitations in Appendix 2: + - `SAMAnalyticalFilterByBoundaryType`, `SAMAnalyticalFilterByPanelType` — parameter names generated by + `foreach (… in Enum.GetValues(typeof(X)))`; resolving them means enumerating an enum's members, + which is deliberately out of scope (see note 4). + - `SAMCoreFilterByType`, `SAMCoreInspect`, `SAMAnalyticalSystemResults` — variable-parameter + components registered through the parameter manager, where the insertable set is generated in code + and cannot be enumerated from the declaration. + - `SAMAcousticCreateBandValues` — the parameter name comes from a loop variable, so it is not a + literal. + Each blocks a compatibility PASS. There is no CLI switch to waive incomplete extraction. +3. **`diagnosticDetail_` vs `_diagnosticDetail_`** (GH-E3) — owner decision. +4. **Supported declaration forms** (see Appendix 6) are the ones the repositories actually use. Anything + outside them is reported as `partial`/`unresolved` with a reason; the tool never invents a + contract. Extending the supported set is a bounded follow-up with its own regression test. +5. **Scanner copies.** The scanner exists in the canonical validator (inline) and in the + `SAM_Psychrometrics` pilot copy and `SAM/design/grasshopper-icons/tools/inventory.py` (older, + API-incompatible generation). Consolidating them is a follow-up; the *data* (the manifest) is already + repository-specific. +6. **No repository except SAM_Psychrometrics has an approved baseline**, so `--check` is meaningful only + there. Everywhere else the numbers in Appendix 2 are an inventory. +7. **CI.** No SAM-BIM CI job runs this validator yet. The runner images have Python available; the + blocker is the per-repository baseline workflow, not the Python. See Appendix 5. +8. **`MinCompatibleVersion`** (GH-H5) is dead code in effect; removing or wiring it is a separate, + owner-approved change. +9. **`SAM_OpenStudio_base`** appeared as a second checkout of SAM_OpenStudio during this investigation + and is not a SAM-BIM repository; it is excluded from all counts above. + +## Appendix 4 — Repository adoption plan + +The canonical specification lives in `SAM-BIM/SAM`. Another repository does not copy it; it points at +it. Adoption per repository is: (1) a one-sentence instruction in that repository's `AGENTS.md`, +(2) an inventory whose result is reviewed and committed as the baseline, (3) a `--check` run. The +proposed instruction snippet, written to be appended to an existing `AGENTS.md` "Before starting work" +list, is: + +```markdown +- Before changing, adding or removing a Grasshopper component or any of its parameters, + descriptions, defaults, visibility or version, read the SAM-BIM standard: + https://github.com/SAM-BIM/SAM/blob/sow/2026-Q4/docs/GH_COMPONENT_STANDARDS.md + Published parameter names, order, runtime type, access, optionality, visibility and defaults are + frozen; a rename needs a declared ParameterRename alias and the migration acceptance in that document. +- If this repository has an approved manifest (design/grasshopper-icons/param_contract.json), validate: + python /design/grasshopper-icons/tools/param_contract.py --repo . --check + Otherwise it has none yet: run the inventory, review the coverage, and propose the baseline in a PR: + python /design/grasshopper-icons/tools/param_contract.py --repo . + A `partial`/`unresolved` component is never a PASS; the manifest records why. +``` + +Order — small and low-risk first, and never mixing a description sweep with a rename: + +| Wave | Repositories | Why | +|---|---|---| +| 0 | SAM (this PR's standard), SAM_Psychrometrics (already done) | The reference and the pilot. | +| 1 | SAM_GEM, SAM_Python, SAM_BHoM, SAM_gbXML, SAM_Origin, SAM_IFC, SAM_Acoustic | 1–3 components each. Review extraction reasons and constructor/type dependencies before proposing a baseline; small does not mean fully verified. | +| 2 | SAM_LadybugTools, SAM_Solver, SAM_OpenStudio, SAM_Multitasker, SAM_Excel, SAM_SolarCalculator, SAM_Topologic | Small inventories; resolve coverage gaps before baseline acceptance. | +| 3 | SAM_Mollier, SAM_UI, SAM_Systems | Mid-size inventories with extraction gaps. Description corrections only, no renames. | +| 4 | SAM_OCCT, SAM_Revit | Both own runtime contract tests; static coverage is incomplete, especially Revit ParamDefinition and external bases. | +| 5 | SAM_Tas_Grasshopper | 94 components with incomplete static coverage; inspect dependency and inheritance gaps first. | +| 6 | SAM | 460 components and **no baseline yet**. Review the corrected coverage before proposing a baseline; fixes are per-component, owner-prioritised. | + +Per repository, separate the three change classes (GH-F2) into separate PRs: description corrections +first (safe, no metadata freeze change), then additive optional parameters, then — only with an owner +decision and the full GH-G7 acceptance — any naming migration. **This document does not start any of +these waves**, and does not authorise a rename anywhere. + +## Appendix 5 — Enforcement architecture and CI feasibility + +Four distinct things, deliberately not conflated: + +1. **Universal naming and description rules** — repository-independent: the naming rules, the + description-quality rules (empty, capital letter, known typo), the nickname note. Lives once, in + `design/grasshopper-icons/tools/param_contract.py` in `SAM`. Reuse by `--repo`, not by copy. +2. **Repository-specific published metadata baseline** — the rendered `param_contract.json`, committed + per repository. Data, not code. It carries the `namingExceptions` list of approved legacy deviations + and the per-component `extraction` status. +3. **Compatibility comparison** — the static `--check` against that baseline, which fails on a frozen + attribute change, on incomplete extraction, on a new/stale naming deviation, and on an unrefreshed + version bump (GH-J0). +4. **Runtime migration compatibility tests** — Rhino-hosted, repository-local, per migration; not + shareable, because each one pins that repository's real components and a real saved definition. + +Feasibility, measured rather than assumed: + +- The validator is pure standard-library Python 3 (`json`, `os`, `re`, `sys`, `argparse`, `fnmatch`, + `xml.etree.ElementTree`). A full scan of `SAM` (460 components, ~1000 files over 6 Grasshopper + projects among 25 project files) is machine-dependent; current coverage is recorded in Appendix 2. **Python + availability is not the blocker** — `ubuntu-latest` and `windows-2022` both ship Python 3, and the + local runs here used the RhinoCode Python 3.13. +- The regression suite is `design/grasshopper-icons/tools/tests/test_param_contract.py` (stdlib + `unittest`, no dependencies, no network): 69 tests cover the supported and the deliberately + unsupported declaration forms, type changes, custom and external parameter types, indirect + inheritance, unusual component names, collection-based registration, legitimate List/Tree access, + existing nicknames, code-side defaults, and missing or incomplete baselines. +- The real blockers are: (a) a `--check` needs a committed baseline, so a CI job can only be switched on + per repository *after* that repository's baseline PR; (b) Rhino-dependent runtime tests cannot run on + the hosted runners, so they stay local/headless, exactly as `SAM.Core.Grasshopper.Tests` does today + (it is deliberately **not** in `SAM.sln`). +- Recommended shape when the owner is ready: one workflow step in the repository's own `test.yml` + (checkout `SAM` at a pinned ref, then `python /param_contract.py --repo . --check`), plus the + regression suite in `SAM`. Do not put Rhino-hosted tests in CI and do not add a fleet-wide job before + the per-repository baselines exist. + +This PR adds no CI job and no baseline for any repository other than the pilot already in place. + +## Appendix 6 — Certification boundary and architecture decision + +The scanner may inventory more metadata than it can certify. `verified` requires evidence from the +supported declaration subset; successfully finding some `new` expressions is insufficient. + +| Certified source form | Required evidence | +|---|---| +| Straight-line `GH_SAMParam[]` properties | One proven returned collection, with wrappers added in order; direct array/collection expressions, a single list with `Add`/`AddRange`, `ToArray()` or `[.. list]` | +| Empty side | Explicit empty array/collection return or an empty manager override; absence of registration is not evidence of emptiness | +| Parameter construction | Declared or target-typed `new`, initializer with or without parentheses, whitespace variations, bare declaration followed by assignment; constructor arguments/effects must be supported | +| Parameter reuse/mutation | Assignment order is tracked; `Name`, `NickName`, `Optional`, `Access` and persistent data assignments update the correct object even after wrapping; reassignment does not mutate earlier objects | +| Parameter manager | Straight-line supported `AddParameter` or typed `Add*Parameter`; unsupported calls and control flow block certification | +| Inheritance | Follow repository bases to the actual registration, including multiple levels; missing external registration or ambiguous ancestry blocks certification | +| Type identity/defaults | Qualified and generic identity retained; aliases/imports resolved only with evidence; known built-in defaults or an empty source subclass inheriting them | + +**Inventory-only / partial or unresolved:** helper-returned output sets; local parameter factories; +branches/loops and filtered/discarded returned collections; symbolic defaults; unknown parameter types; +nontrivial Goo/third-party constructors; Revit `ParamDefinition[]`; unread external bases. Each has a +specific extraction reason and cannot obtain compatibility PASS. The original six partial components +remain among these limitations. No component is changed to fit the extractor. + +Schema 3 records qualified type identities and extraction evidence. Schema 2 baselines need a reviewed +refresh because their type identity was lossy. The approved schema-less Psychrometrics pilot remains +readable: its built-in types resolve reliably and its current source must still be fully extracted. + +The bounded choice for PR #204 was: + +| Approach | Decision | +|---|---| +| Strict Python subset | Chosen: existing standard-library tool, small explicit certification boundary, reject uncertainty; supports the approved pilot without requiring host applications | +| Roslyn semantic analysis | Separate reviewed follow-up for broader C#, symbols, constructors and MSBuild inclusion; adds a .NET tool/dependencies and project-resolution policy | +| Grasshopper runtime inspection | Complementary evidence for actual constructed parameters and saved definitions; requires each relevant host/dependency environment, including Revit for its components | + +Do not grow this into a general C# parser to improve the count. Broader certification requires a new +reviewed semantic/runtime approach. A passing static check applies to the supported declarations and +approved baseline; it is not a substitute for compilation, runtime compatibility tests or engineering +review. diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md new file mode 100644 index 000000000..71532d193 --- /dev/null +++ b/documentation/GH-Component-Standards-Q4.md @@ -0,0 +1,177 @@ +# GH component standards — PR #204 correctness repair + +**Status:** repaired on `docs/gh-component-standards-q4`; PR **SAM-BIM/SAM#204** remains draft, +unmerged, awaiting independent re-review and owner approval. Base `sow/2026-Q4` at +`6b12e2a65817556693ba408674211610f4a20dda`. + +- Prior reviewed head: `c2067f9a98ede0643a1d7ac0b6159c104745947c`. +- Validated repair content SHA: `692f35f642db4838c29cda8e820101e2fc90401c` (validator, tests and canonical standard). +- The following record-only commit preserves that tested content SHA. The final PR head and hosted + checks are identified in the live PR description. +- No production component, migration/updater implementation, GUID, parameter, geometry algorithm, + workflow or other repository source is changed. `PROJECT_PROGRESS.md` is unchanged: closeout only + after an authorized merge. Four changed files: this record, `docs/GH_COMPONENT_STANDARDS.md`, + `design/grasshopper-icons/tools/param_contract.py`, and its `tests/test_param_contract.py`. + +## Reproduced before changing code + +Sol's machine-local independent script was accessible and rerun at c2067f9a. Twenty cases returned +**10 unexpected compatibility acceptances**, harness exit **1**. In particular: + +| Defect | Expected before repair | Observed before repair | +|---|---|---| +| RevitAlignWalls, four inputs/one output | unsupported extraction must refuse certification | zero/zero, verified; copied-source input rename returned OK/0 | +| VendorA.HostObject vs VendorB.HostObject | distinct type contract | both reduced to HostObject; OK/0 | +| GooValueParam vs GooValueParam | distinct closed types | both reduced to GooValueParam; OK/0 | +| Unknown class ancestry added | discovery failure | unclassified record ignored; OK/0 | +| Malformed project XML added | discovery failure | project error ignored; OK/0 | +| Pilot type-negative test | source present; exact type mutation rejected | wrote only a manifest; passed on removed components, not type comparison | + +The other false passes concerned helper-returned outputs, whitespace around wrapper constructors, +declarative Optional mutation, persistent data after wrapping and multi-level registration inheritance. +The pre-repair JSON and original independent report remain machine-local; no such external file is +needed to run the committed regression suite. + +## Architecture checkpoint and final boundary + +Chosen: **strict, narrower Python certification**. Keep the existing stdlib inventory extractor, but +require a proven returned collection or supported straight-line manager registration before certifying +it. Reject unknown effects instead of expanding a general C# parser to raise coverage. + +- Roslyn would provide syntax/symbol/project analysis for wider certification, but adds a .NET tool, + dependencies and project-resolution policy. That is a separately reviewed subsystem. +- Runtime inspection is complementary and can see constructor effects and saved-definition behavior; + it requires the relevant Grasshopper/Rhino/Revit hosts and dependencies. It is not a portable source gate. +- Existing alias migration from SAM#203 / Psychrometrics#12 is reused unchanged. + +Important limits: this is a bounded source check, not arbitrary C#/MSBuild semantic verification. +Nontrivial Goo/third-party constructor defaults, factories, symbolic defaults, control flow, unsupported +returned expressions, unread external bases and ParamDefinition registrations block acceptance. Known +Grasshopper built-ins and empty local subclasses inheriting their defaults are the constructor subset. +Unresolved display identity/version metadata also cannot pass as matching null values. + +## Corrections + +1. **Extraction:** wrapper whitespace is supported; parameter object assignments are tracked in order, + including Optional and persistent data after wrapping. Reassignment does not alter an earlier + wrapped object. Unknown reassignment invalidates the binding. Returned collection shape is checked; + helper, filtered and discarded collections cannot be certified. Inheritance follows multiple source + bases to the declaring registration. Explicit empty sides are supported; absence of evidence is + unresolved. RevitAlignWalls is deliberately **unresolved**, with a ParamDefinition reason, rather + than implementing a new Revit extractor here. CreatePanelsBySolids still extracts 7 inputs/4 outputs + but is **partial** because its custom Goo constructor effects are not certified. +2. **Type identity:** preserve qualification and generic arguments; resolve supported aliases, + primitives, local declarations and unambiguous imports. Unknown/ambiguous identity blocks PASS. + Type changes still produce their precise compatibility findings. Schema **3** replaces lossy v2; + v2 baselines require reviewed refresh. The approved schema-less pilot remains readable because its + built-in types are reliably resolved. +3. **Discovery:** unknown objects and malformed XML are fatal extraction findings with paths and + explicit counts. Ordinary helper classes referencing ComponentGuid are not mistaken for objects. + Concrete base classes and same-line class declarations are no longer omitted by the old source filter. +4. **Regression evidence:** the pilot negative now copies actual component source, proves an unchanged + baseline passes, changes only one Param_Number to Param_String, then asserts the exact parameter/type + finding and absence of removed-component or extraction failures. New tests cover every independent + case plus alias equivalence, constructor effects, discarded returns and uncertain component metadata. +5. **Documentation:** GH-D8 now describes advisory behavior and --strict-descriptions accurately; + local automated Rhino tests are distinguished from static checks and human review. Adoption and + coverage claims now reflect actual extraction limits. + +## Reproducible validation (local Windows, Python 3.13.13) + +Run from the SAM repository. The WindowsApps python alias is not the interpreter used. Set TEMP/TMP to +an existing writable directory outside tracked source if the sandbox's default temporary directory +cannot create/delete fixtures. `-B` prevents repository bytecode files. + +```powershell +$pythonExe = 'C:\Users\michal\.rhinocode\py313-rh9\python.exe' +& $pythonExe -B design/grasshopper-icons/tools/tests/test_param_contract.py +& $pythonExe -B design/grasshopper-icons/tools/param_contract.py --repo ../SAM_Psychrometrics --check +# Inventory only; choose an output outside the target repository: +& $pythonExe -B design/grasshopper-icons/tools/param_contract.py --repo . --output "$env:TEMP\sam-pr204-inventory.json" +git diff --check +``` + +| Check | Result / exit code | +|---|---| +| Full committed validator regression suite | **69 tests passed**, no skips, exit **0** | +| Independent Sol script, unchanged file | **20 cases, zero unexpected acceptances**, exit **0** | +| Exact namespace and closed-generic source mutations | unchanged fixture passes **0**; changed type rejected **1**, type finding asserted | +| Optional and persistent-default changes | rejected **1** with the changed frozen field identified | +| Unknown ancestry / malformed XML | rejected **1**, discovery cause and path identified | +| Unsupported helper / Revit declaration | rejected **1**, extraction cause identified; no component-removal excuse | +| Missing / incomplete baseline | rejected **2 / 1** | +| Unchanged List/Tree, nickname and supported type spelling | pass **0** | +| Approved Psychrometrics baseline | **3 discovered / 3 verified / 3 compared; OK compatibility**, exit **0** | +| Whitespace and Python source syntax | exit **0** | + +Committed tests are self-contained except clearly skipped read-only real-repository tests when sibling +checkouts are absent. The pilot mutation test uses actual copied source and a generated valid baseline; +the separate pilot comparison uses its already approved committed baseline. Synthetic static fixtures +are not claimed as compiled or hosted runtime tests. + +Machine-local extra evidence directory (copy/provision it if needed elsewhere): +`C:\Users\michal\.codex\visualizations\2026\10\15\01a140c3-07d7-7db2-83db-a3f98397ac92\pr204-review\`. +It contains `before-repair-results.json`, `independent_review.py`, `independent-after-repair.txt`, +`results.json`, `repair-suite-final.txt`, `fleet-repair.txt`, `fleet/` and the BuildAll log. All critical +failure cases are also reproduced by the committed tests, so this directory is supplementary. + +## Corrected coverage + +22 named checkouts from the standard's Appendix 2: **826 components discovered, 73 verified, 709 +partial, 44 unresolved**; **0 unclassified objects, 0 unparseable projects**. The complete per-repository +table is in `docs/GH_COMPONENT_STANDARDS.md`; its inventory command is rerunnable per checkout. + +| Repository | Components | Verified | Partial | Unresolved | +|---|---|---|---|---| +| SAM | 460 | 37 | 423 | 0 | +| SAM_Revit | 55 | 9 | 13 | 33 | +| SAM_Psychrometrics | 3 | 3 | 0 | 0 | +| SAM_GEM | 1 | 0 | 1 | 0 | +| SAM_BHoM | 3 | 0 | 3 | 0 | +| SAM_Python | 3 | 0 | 3 | 0 | + +The earlier **823/817** omitted Psychrometrics' three entries; the old table actually summed to +**826/820**. The old verified count was unsound because unknown contracts/effects were accepted. +The lower final count is intentional; running a scanner is not compatibility evidence. + +The six originally documented gaps remain partial: SAMAnalyticalFilterByBoundaryType, +SAMAnalyticalFilterByPanelType, SAMCoreFilterByType, SAMCoreInspect, SAMAnalyticalSystemResults, +SAMAcousticCreateBandValues. They are now explicitly a subset of the unresolved coverage, not six +successful checks and not the complete limitation. Only the pilot currently has an approved baseline. + +## Requested BuildAlls_v5 acceptance + +Owner requested running the existing workspace driver after the repairs: + +```powershell +& 'C:\Users\michal\Documents\GitHub\SAM-BIM\BuildAlls_v5.bat' nopause +``` + +Executed current branches, normal Debug Restore/Clean/Rebuild, no pull or branch switching. +**FAILED, exit 1**, duration **00:01:44.7517270**, stopped at SAM_Multitasker. Error: + +```text +SAM_Multitasker/SAM_Multitasker/SAM.Core.Multitasker/Classes/Multitasker.cs(55,57): error CS0029: +Cannot implicitly convert ImmutableArray +to ImmutableArray +``` + +Source confirms `ImmutableArray immutableArray = script.Compile();` inside namespace +SAM.Core.Multitasker resolves Diagnostic to SAM.Core rather than the imported Roslyn type. This C# +source is untouched by #204. It needs a separately scoped SAM_Multitasker repair; no other repository +source was edited here. Consequently the full fleet build did not complete and is not green. +Normal build/deployment outputs were written by the owner-requested driver. No fresh Rhino canvas or +saved-definition compatibility test is claimed. BuildAll does not execute the Python validator. + +## Hosted checks and remaining next step + +Historical Build/Test/SPDX dispatches at c2067f9a passed (38780639246 / 38780644121 / 38780649528), +including 3683 C# tests. They do **not** verify this repair and do **not** run Python or Rhino migration +tests. Final repair-head check links/results belong in the live PR description; distinguish them from +local validator evidence above. + +Next: independent re-review of this bounded certification approach and the committed negative tests, +then owner merge decision after final-head checks. Keep the draft PR unmerged. No new product decision +is required for these corrections; Diagnostics naming, MinCompatibleVersion, broader Roslyn/runtime +coverage and baseline adoption remain separate decisions. The workspace-wide build remains blocked by +the specific SAM_Multitasker C# error above. Do not broaden #204 to fix it.