From 7dd5a4a5659a7d30d9721ebba58d31021fc5fc77 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 18:11:16 +0200 Subject: [PATCH 1/8] docs(gh): canonical Grasshopper component engineering standard Add `docs/GH_COMPONENT_STANDARDS.md` as the single SAM-BIM standard for Grasshopper component naming, descriptions, Diagnostics, versioning and published-parameter migration. Every rule carries a status (validated, enforced, proposed, open, frozen legacy) so a proposal is never mistaken for a validated fact. Add `design/grasshopper-icons/tools/param_contract.py` as the canonical shared metadata validator, with `--repo`/`--output` so one copy serves every SAM-BIM repository instead of one copy each. It is the validated pilot validator with the scanner inlined (the repository-local `inventory.py` copies have diverged) and a parameter-class discriminator so it is usable on a repository the size of SAM. Require reading the standard in `AGENTS.md` before changing any Grasshopper component. Documentation, tooling and agent instructions only: no component, GUID, parameter, default, geometry or updater behaviour is modified. --- AGENTS.md | 26 + .../grasshopper-icons/tools/param_contract.py | 458 +++++++++++ docs/GH_COMPONENT_STANDARDS.md | 722 ++++++++++++++++++ 3 files changed, 1206 insertions(+) create mode 100644 design/grasshopper-icons/tools/param_contract.py create mode 100644 docs/GH_COMPONENT_STANDARDS.md diff --git a/AGENTS.md b/AGENTS.md index 81837a21c..84ef69aa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,36 @@ 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, 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. +- Validate metadata changes with + `python design/grasshopper-icons/tools/param_contract.py --repo . --check` + (see `docs/GH_COMPONENT_STANDARDS.md` Appendix 4 for other repositories). +- `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..ce6fd8065 --- /dev/null +++ b/design/grasshopper-icons/tools/param_contract.py @@ -0,0 +1,458 @@ +"""The SAM-BIM Grasshopper component metadata contract - canonical shared validator. + +Extracts the published parameter contract of every non-abstract Grasshopper component that declares a +`ComponentGuid`: the component name/nickname/category/subcategory, `LatestComponentVersion`, and for +every declared input and output the name, nickname, description, `Optional`, `Access`, +`ParamVisibility` and any persistent default. + + python param_contract.py --repo # (re)write the manifest from source + python param_contract.py --repo --check # verify source against the committed manifest + python param_contract.py --repo --output # write the manifest somewhere else (dry run) + +`--repo` defaults to the SAM repository this file lives in; `--output` defaults to +`/design/grasshopper-icons/param_contract.json`. Pointing `--repo` at another SAM-BIM +repository is how one shared copy serves every repository instead of a copy per repository: the +manifest is repository-specific data, the validator is not. + +The contract exists because Grasshopper restores a component from the parameter archive stored in a +definition: a reopened component keeps the names the FILE has, not the names its class now declares. +Renaming a parameter therefore breaks every `Params.IndexOfInputParam`/`IndexOfOutputParam` lookup in +every existing definition, silently. The manifest pins the current names, and `--check` fails if any +of them, or the order, access, optionality, visibility or GUID, changes. + +Naming-standard deviations (camelCase outputs, `_name_` inputs without a persistent default) are the +frozen legacy exceptions listed in `namingExceptions`: they are expected, not errors, but a NEW +deviation, or a listed exception that no longer deviates, fails the check so the list stays honest. +Descriptions are not frozen - they are checked for quality only. + +## Why the scanner is embedded + +The C# scanner below is the Grasshopper document-object scanner from SAM#166 (the design/grasshopper +icons tooling), in the generalised form first written for the `SAM_Psychrometrics` metadata pilot. +It is inlined rather than imported because the repository-local `inventory.py` copies have since +diverged (`SAM/design/grasshopper-icons/tools/inventory.py` is the older, SAM-only generation with a +different API; the pilot's copy is the generalised one). Inlining keeps this validator a single, +portable artifact that cannot silently change behaviour with whichever `inventory.py` happens to be +next to it. Consolidating the copies is recorded as a 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, and for the difference +between a change that needs a manifest rebuild and one that needs an owner-approved migration. +""" +import argparse +import fnmatch +import json +import os +import re +import sys + +# --- C# scanner (SAM#166 lineage, generalised for the metadata pilot) -------------------------------- + +SKIP_DIRS = {"obj", "bin", ".git", "packages", "design", "build"} +CLASS_RE = re.compile(r"^\s*(?:\[[^\]]*\]\s*)*((?:public|internal|private|protected|abstract|sealed|static|partial|\s)+)class\s+(\w+)(?:<[^>{]*>)?\s*(?::\s*([^{\n]+))?", re.M) +GUID_RE = re.compile(r"ComponentGuid\s*(?:=>|\{\s*get\s*\{\s*return)\s*new\s*(?:Guid\s*)?\(\s*\"([0-9a-fA-F-]+)\"") # new Guid("..") and target-typed new("..") + + +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 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 + "}" + + +def compile_removed(project_dir): + """Files excluded from compilation by the project's `` globs.""" + patterns = [] + for f in os.listdir(project_dir): + if not f.endswith(".csproj"): + continue + text = open(long_path(os.path.join(project_dir, f)), encoding="utf-8-sig", errors="replace").read() + patterns += [m.replace("\\", "/") for m in re.findall(r'= 2 and value[0] == '"' and value[-1] == '"': + value = value[1:-1] + return value.replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\") + + +def param_type(expression): + match = re.search(r"Param_\w+", expression) + return match.group(0) if match else None + + +def parse_params(block): + """Every `result.Add(new GH_SAMParam(...))` in a declaration block, in source order.""" + variables = {} + for match in re.finditer(r"(\w+)\s*=\s*new\s+[\w:.]*Param_\w+\(\)\s*\{", block): + body, _ = take_balanced(block, block.index("{", match.start())) + variables[match.group(1)] = {"init": parse_initializer(body), "type": param_type(block[match.start():match.end()])} + for match in re.finditer(r"(\w+)\.SetPersistentData\(([^;]*)\)", block): + if match.group(1) in variables: + variables[match.group(1)]["persistentDefault"] = match.group(2).strip() + + result = [] + for match in re.finditer(r"result\.Add\(new GH_SAMParam\(", block): + args, _ = take_balanced(block, match.end() - 1) + parts = split_top_level(args) + first = parts[0].strip() + vis = parts[1].strip().replace("ParamVisibility.", "") if len(parts) > 1 else None + + if first.startswith("new "): + body, _ = take_balanced(block, block.index("{", match.end())) + init = parse_initializer(body) + ptype = param_type(first[:first.index("{")]) + persistent = None + else: + entry = variables.get(first, {}) + init = entry.get("init", {}) + ptype = entry.get("type") + persistent = entry.get("persistentDefault") + + result.append({ + "name": unquote(init.get("Name", "")), + "nickname": unquote(init.get("NickName", "")), + "description": unquote(init.get("Description", "")), + "type": ptype, + "optional": init.get("Optional", "false").strip() == "true", + "access": (init.get("Access", "") or "").split(".")[-1] or None, + "visibility": vis, + "persistentDefault": persistent, + }) + return result + + +def is_parameter_class(class_name, base_types): + """True for a Goo parameter wrapper, not a published component. + + A Grasshopper definition object is a *component* only when it is a `GH_Component`. The + `GooXxxParam` classes declare a `ComponentGuid` too (they are serialisable document objects), so + a scanner keyed on `ComponentGuid` alone collects them as well: in the SAM repository that is + ~60 extra objects whose "parameters" are meaningless. They are recognised by their name (every + SAM `…Param` class is a parameter) and by deriving from `GH_Param`/`GH_PersistentParam`. + """ + base = (base_types or "").strip() + if class_name.endswith("Param") and "Param" in base: + return True + return "GH_PersistentParam" in base or "GH_Param" in base + + +def components(repo): + """(components in a stable order, skipped parameter-class identifiers) for `repo`.""" + items = [] + skipped = [] + for project_dir, project in sorted(gh_projects(repo).items(), key=lambda kv: kv[1]): + removed = compile_removed(project_dir) + for root, dirs, files in os.walk(project_dir): + dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS) + for f in sorted(files): + if not f.endswith(".cs") or f.endswith(".Designer.cs"): + continue + path = os.path.join(root, f) + if removed(os.path.relpath(path, project_dir)): + continue + source = open(long_path(path), encoding="utf-8-sig").read() + if "ComponentGuid" not in source: + continue + code = re.sub(r"(?m)(^|[\s;{}])//[^\n]*", r"\1", source) + classes = list(CLASS_RE.finditer(code)) + for i, class_match in enumerate(classes): + body = code[class_match.end(): classes[i + 1].start() if i + 1 < len(classes) else len(code)] + guid = GUID_RE.search(body) + if not guid or "abstract" in class_match.group(1): + continue + if is_parameter_class(class_match.group(2), class_match.group(3)): + skipped.append("%s (%s)" % (class_match.group(2), os.path.relpath(path, repo).replace("\\", "/"))) + continue + ctor = re.search(r"\b" + class_match.group(2) + r"\s*\(\s*\)\s*(?=:\s*base\()", body) + base = base_args(body, ctor.end()) if ctor else None + if base and base[-1].startswith("GH_ParamAccess"): + base = base[:-1] + + version = re.search(r"LatestComponentVersion\s*=>\s*\"([^\"]+)\"", body) + inputs_match = re.search(r"GH_SAMParam\[\]\s+Inputs", body) + outputs_match = re.search(r"GH_SAMParam\[\]\s+Outputs", body) + inputs = parse_params(body[inputs_match.start():outputs_match.start()]) if inputs_match and outputs_match else [] + outputs = parse_params(body[outputs_match.start():]) if outputs_match else [] + + items.append({ + "class": class_match.group(2), + "guid": guid.group(1).lower(), + "name": unquote(lit(base[0])) if base else None, + "nickname": unquote(lit(base[1])) if base and len(base) > 1 else None, + "category": unquote(lit(base[-2])) if base and len(base) >= 4 else None, + "subcategory": unquote(lit(base[-1])) if base and len(base) >= 4 else None, + "description": unquote(lit(base[2])) if base and len(base) >= 4 else None, + "latestComponentVersion": version.group(1) if version else None, + "inputs": inputs, + "outputs": outputs, + "source": os.path.relpath(path, repo).replace("\\", "/"), + }) + return sorted(items, key=lambda item: item["class"]), sorted(skipped) + + +# --- naming standard and its frozen legacy exceptions ------------------------------------------------ + +def base_name(name): + return name.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(contract): + """Client identifiers whose declared name does not follow the SAM-BIM naming standard.""" + result = [] + for component in contract["components"]: + for side in ("inputs", "outputs"): + for param in component[side]: + expected = expected_name(param["name"], side[:-1], param["optional"], param["persistentDefault"]) + if param["name"] != expected: + result.append("%s.%s.%s" % (component["class"], side[:-1], param["name"])) + return result + + +def quality_problems(contract): + problems = [] + for component in contract["components"]: + if not component["latestComponentVersion"]: + problems.append("%s has no LatestComponentVersion" % component["class"]) + for side in ("inputs", "outputs"): + for param in component[side]: + where = "%s.%s.%s" % (component["class"], side[:-1], param["name"]) + description = (param["description"] or "").strip() + if not description: + problems.append("%s: empty description" % where) + elif not description[0].isupper(): + problems.append("%s: description does not begin with a capital letter" % where) + if "Humidty" in description: + problems.append("%s: known typo 'Humidty'" % where) + if param["nickname"] != param["name"]: + problems.append("%s: NickName '%s' != Name" % (where, param["nickname"])) + if param["access"] != "item": + problems.append("%s: unexpected access '%s'" % (where, param["access"])) + return problems + + +# --- entry point ------------------------------------------------------------------------------------ + +def diff(committed, current): + problems = [] + by_class = {c["class"]: c for c in committed["components"]} + for component in current["components"]: + previous = by_class.pop(component["class"], None) + if previous is None: + problems.append("new component %s (%s)" % (component["class"], component["guid"])) + continue + for field in ("guid", "name", "nickname", "category", "subcategory", "latestComponentVersion"): + if previous[field] != component[field]: + problems.append("%s.%s: %r -> %r" % (component["class"], field, previous[field], component[field])) + for side in ("inputs", "outputs"): + old = [(p["name"], p["nickname"], p["description"], p["optional"], p["access"], p["visibility"], p["persistentDefault"]) for p in previous[side]] + new = [(p["name"], p["nickname"], p["description"], p["optional"], p["access"], p["visibility"], p["persistentDefault"]) for p in component[side]] + if [x[0] for x in old] != [x[0] for x in new]: + problems.append("%s.%s: names/order changed\n committed: %s\n current: %s" + % (component["class"], side, [x[0] for x in old], [x[0] for x in new])) + continue + for a, b in zip(old, new): + # name, nickname, optional, access, visibility and persistent default are frozen contract + for index, label in ((1, "nickname"), (3, "optional"), (4, "access"), (5, "visibility"), (6, "persistentDefault")): + if a[index] != b[index]: + problems.append("%s.%s.%s: %s %r -> %r" % (component["class"], side, a[0], label, a[index], b[index])) + for leftover in by_class: + problems.append("component removed: %s" % leftover) + return problems + + +def manifest_path(repo): + """The committed manifest for `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="verify source against the committed manifest instead of writing it") + 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 + + found, skipped = components(repo) + current = { + "repository": os.path.basename(repo), + "components": found, + "namingExceptions": sorted(deviations({"components": found})), + } + + if args.check: + path = manifest_path(repo) + if not os.path.isfile(path): + print("FAIL no committed manifest at %s" % path) + return 2 + committed = json.load(open(path, encoding="utf-8")) + problems = diff(committed, current) + # naming standard: expected deviations are the documented legacy exceptions, nothing more + expected_exceptions = sorted(committed.get("namingExceptions", [])) + actual_exceptions = sorted(deviations(current)) + if expected_exceptions != actual_exceptions: + problems.append("naming exceptions changed (a new deviation, or a stale exception):\n" + " documented: %s\n actual: %s" % (expected_exceptions, actual_exceptions)) + problems += quality_problems(current) + for problem in problems: + print("FAIL", problem) + print("%d components checked (%d parameter classes skipped): %s" + % (len(found), len(skipped), "OK" if not problems else "%d PROBLEM(S)" % len(problems))) + return 1 if problems else 0 + + 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(current, stream, indent=1) + stream.write("\n") + print("%d components (%d parameter classes skipped) -> %s" % (len(found), len(skipped), output)) + print("%d documented naming exceptions" % len(current["namingExceptions"])) + print("%d description/version problems" % len(quality_problems(current))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/docs/GH_COMPONENT_STANDARDS.md b/docs/GH_COMPONENT_STANDARDS.md new file mode 100644 index 000000000..7966bd07f --- /dev/null +++ b/docs/GH_COMPONENT_STANDARDS.md @@ -0,0 +1,722 @@ +# SAM-BIM Grasshopper Component Engineering Standard + +**Status:** canonical. Established 2026-10-15 on `sow/2026-Q4`. +**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. + +## 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. | +| **ENFORCED** | Checked automatically by committed tooling; a violation fails a check. | +| **PROPOSED** | Recommended direction. Not yet implemented, or applies to new work only. | +| **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. + +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 — **ENFORCED** + +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 Psychrometrics contract tooling 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 — **ENFORCED, 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 Psychrometrics metadata contract asserts the three GUIDs +(`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 — **ENFORCED** (in the pilot) + +The metadata contract's naming rule computes the expected output name as +`PascalCase(strip_underscores(name))` and fails on a deviation +(`design/grasshopper-icons/tools/param_contract.py`, `expected_name`). 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 — ENFORCED.** +- **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 — ENFORCED in the pilot.** `[m]`, `[°C]`, `[kg/m³]`, `[Pa]`, + `[-]` for dimensionless. The pilot's first fix was the typo `Humidty` → `Humidity` and unit + consistency (`[kg/m3]` → `[kg/m³]`). +- **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 — ENFORCED.** The contract check fails on an empty + description, a description that does not start with a capital letter, a known typo, and + `NickName != Name`. +- **GH-D9 — A description must agree with the implementation — PROPOSED (reviewer responsibility).** + The automated check cannot 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 — **ENFORCED by process** + +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** (for example `Param_Number`, `GooPanelParam`); +- 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`, `diff`) and the runtime contract asserts them +at runtime in a real Rhino (`MetadataContractTests`, `DiagnosticsContractTests`). + +### 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. + +### J1 — Metadata-only correction + +- Build (Release) with 0 errors. +- Metadata contract check: `python design/grasshopper-icons/tools/param_contract.py --repo --check` + (in `SAM`, the shared validator; in `SAM_Psychrometrics`, the pilot copy) → `OK`. +- No unintended metadata or API change: the diff must show only `Description` (and possibly + `LatestComponentVersion`) in the component files. + +### 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. + +### 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 (GH-D9) is a reading task. +- 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 — **ENFORCED** + +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. + +--- + +## 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); `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 baseline (2026-10-15) + +Produced by the canonical validator at this PR's content commit: + +``` +python design/grasshopper-icons/tools/param_contract.py --repo --output +``` + +"Components" excludes the `Goo…Param` classes the scanner would otherwise collect (GH-Appendix 3, +note 1). Deviations and problems are counts from the standard's own rules; a nonzero value is a +*registered baseline*, not necessarily a defect to fix. + +| Repository | Components | Naming deviations | Description/version problems | +|---|---|---|---| +| SAM | 460 | 1120 | 1493 | +| SAM_Tas_Grasshopper | 94 | 370 | 370 | +| SAM_Revit | 55 | 26 | 38 | +| SAM_OCCT | 36 | 155 | 188 | +| SAM_UI | 31 | 179 | 81 | +| SAM_Systems | 30 | 58 | 32 | +| SAM_Mollier | 24 | 107 | 37 | +| SAM_Topologic | 22 | 10 | 12 | +| SAM_SolarCalculator | 21 | 348 | 210 | +| SAM_Excel | 8 | 27 | 3 | +| SAM_Solver | 7 | 68 | 68 | +| SAM_LadybugTools | 7 | 3 | 3 | +| SAM_OpenStudio | 6 | 24 | 9 | +| SAM_Multitasker | 5 | 8 | 8 | +| SAM_Acoustic | 3 | 1 | 0 | +| SAM_BHoM | 3 | 0 | 3 | +| SAM_IFC | 3 | 5 | 0 | +| SAM_Psychrometrics | 3 | 22 (frozen, all registered) | 0 | +| SAM_Python | 3 | 6 | 3 | +| SAM_gbXML | 2 | 4 | 0 | +| SAM_Origin | 2 | 2 | 1 | +| SAM_GEM | 1 | 3 | 2 | + +SAM_Psychrometrics is the only repository with a committed manifest and a passing `--check`; its 22 +deviations are the documented frozen exceptions (one rename already migrated). The distribution +matters more than any single number: most repositories are small, and the deviation count is +dominated by camelCase outputs. + +## Appendix 3 — Open decisions and known limitations + +1. **Scanner discriminator.** The contract scanner keys on `ComponentGuid`, which also matches the + `Goo…Param` parameter classes and, in `SAM`, one non-abstract base (`A_SAMAnalytical`). The canonical + validator excludes them (GH Appendix 2 note) and reports how many it skipped, but the exclusion is a + naming heuristic. A stronger discriminator (resolve the base-type chain to `GH_Component`) is a + follow-up. +2. **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 canvas of *data* (the manifest) is + already repository-specific. +3. **`diagnosticDetail_` vs `_diagnosticDetail_`** (GH-E3) — owner decision. +4. **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. +5. **`MinCompatibleVersion`** (GH-H5) is dead code in effect; removing or wiring it is a separate, + owner-approved change. +6. **`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) a rendered baseline manifest, (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, access, optionality, visibility and defaults are frozen; + a rename needs a declared ParameterRename alias and the migration acceptance in that document. + Validate metadata with: + python /design/grasshopper-icons/tools/param_contract.py --repo . --check +``` + +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_Acoustic, SAM_GEM, SAM_gbXML, SAM_Origin, SAM_BHoM, SAM_IFC, SAM_Python | 1–3 components each, 0–6 deviations: a first manifest and a first green `--check` with almost no exceptions. | +| 2 | SAM_Excel, SAM_LadybugTools, SAM_Multitasker, SAM_OpenStudio, SAM_Topologic, SAM_Solver | Small, but each carries real description/version problems to triage first. | +| 3 | SAM_Systems, SAM_Mollier, SAM_SolarCalculator, SAM_UI | Mid-size; description corrections only, no renames. | +| 4 | SAM_OCCT, SAM_Revit | Own their own contract tests already; align their tests with the shared vocabulary. | +| 5 | SAM_Tas_Grasshopper | 94 components and 370 deviations — the largest GH surface after SAM. Bulk description work. | +| 6 | SAM | 460 components, 1120 deviations. Baseline only; 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 + +Three distinct things, deliberately not conflated: + +1. **Universal naming and description validation** — repository-independent: the naming rules, the + description-quality rules, the capital-letter rule, `NickName == Name`, the access rule. 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. +3. **Approved legacy naming exceptions** — the `namingExceptions` list inside that baseline, kept + honest by `--check` (a new deviation fails; a stale exception fails). +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. A full scan of `SAM` (460 components, ~1000 files) + takes roughly 25 s; the whole active fleet is a few minutes. **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 real blockers are: (a) the scanner discriminator above; (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`); (c) a `--check` needs a committed baseline, so a CI + job can only be switched on per repository *after* that repository's baseline PR. +- Recommended shape when the owner is ready: one workflow step in the repository's own `test.yml` + (`uses: actions/checkout@v4` of `SAM` at a pinned ref, then + `python /param_contract.py --repo . --check`). 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. From d313c63fac1d4f5cdd972d8ffd1975d33ab4591e Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 18:11:45 +0200 Subject: [PATCH 2/8] docs: record the GH component engineering standard PR [skip ci] --- documentation/GH-Component-Standards-Q4.md | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 documentation/GH-Component-Standards-Q4.md diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md new file mode 100644 index 000000000..1787d0d8f --- /dev/null +++ b/documentation/GH-Component-Standards-Q4.md @@ -0,0 +1,138 @@ +# SAM-BIM Grasshopper component engineering standard — Q4 (2026-10-15) + +**Status:** implemented on `docs/gh-component-standards-q4`; **draft PR, not merged**; owner review +required. Documentation, tooling and agent instructions only. + +- Repository: `SAM` (shared). Canonical standard for every SAM-BIM repository. +- Base: `sow/2026-Q4` at `6b12e2a65817556693ba408674211610f4a20dda`. +- **Validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (standard, validator, AGENTS.md). + This record is a docs-only commit on top; the PR number, branch head and hosted check results live in + the live PR description (a record cannot contain its own commit SHA). +- Builds on merged work, which it reuses rather than redesigns: + `SAM_Psychrometrics#11` (metadata pilot), `SAM#203` (shared rename-migration infrastructure, merged + `a1d605cf`), `SAM_Psychrometrics#12` (the validated single rename). Also referenced: Unified + Diagnostics Phases 1–3 (`SAM#201` merged `293eb645`, `SAM#202` merged `fca3d4d4`, and the OCCT phase + in `SAM_OCCT`). + +## Scope + +One canonical specification, the agent-instruction hook, the PR record, and one small reusable +validator. No Grasshopper component, GUID, parameter, ordering, access, optionality, visibility, +default, geometry algorithm or updater behaviour is modified. `PROJECT_PROGRESS.md` is not changed on +this branch; its closeout follows merge. No other repository is touched and no CI is added. + +## Work completed + +1. **`docs/GH_COMPONENT_STANDARDS.md`** — the canonical standard, sections A–J (naming; input naming + and the four distinct requiredness mechanisms; output naming; descriptions; Diagnostics; published + compatibility; parameter migration; versioning; parameter identity and serialization; validation and + acceptance), plus appendices for the evidence index, the measured fleet baseline, open decisions, the + repository adoption plan and the enforcement/CI architecture. Every rule carries a status + (`VALIDATED` / `ENFORCED` / `PROPOSED` / `OPEN` / `FROZEN LEGACY`) and a stable `GH-x#` id, so the + document never presents a proposal as an implemented fact. +2. **`design/grasshopper-icons/tools/param_contract.py`** — the canonical shared metadata validator. + Repository-agnostic: `--repo` / `--output` / `--check`, defaulting to this repository. Contains the + validated pilot's naming and description rules and its scanner, inlined. +3. **`AGENTS.md`** — a new step 6 in "Before starting work" and a short "Grasshopper components" section + that require reading the standard before changing a GH component, state the frozen facts, and give the + validation command. It also records that the standard is product documentation (kept on a released + promotion, unlike the internal agent files). +4. **This record.** + +## Decisions and assumptions + +- **The standard lives in the tracked `SAM` repository** (`docs/`), not in the untracked `SAM-BIM` + parent directory and not in a per-repository copy. Other repositories point at it (Appendix 4 gives the + exact snippet and a stable GitHub URL), matching the existing agent-instruction rollout pattern. + `SAM/documentation/` is used for PR records; `SAM/docs/` is introduced for the durable product + standard, as explicitly requested. +- **One shared validator, not one per repository.** It is repository-parameterised by `--repo`; the + manifest (`param_contract.json`) is the only per-repository artifact. The `SAM_Psychrometrics` pilot + copy stays as the pilot's vendored copy; consolidating it is a recorded follow-up (Appendix 3). +- **The scanner is inlined** in the validator. `SAM/design/grasshopper-icons/tools/inventory.py` is the + older SAM-only generation with an incompatible API (`gh_projects`, `compile_removed`, `SKIP_DIRS` and + `long_path` do not exist there) and the pilot's copy is the generalised one. Inlining keeps the + validator a single portable artifact with no hidden dependency on whichever `inventory.py` happens to + sit beside it. This was discovered by running it, not assumed. +- **A parameter-class discriminator was added.** The scanner keys on `ComponentGuid`, which also matches + the `Goo…Param` classes (64 in `SAM`) and one non-abstract concrete base (`A_SAMAnalytical`). Without + the discriminator the SAM numbers are wrong by 64 objects. +- **The implemented Diagnostics input name is `diagnosticDetail_`, not `_diagnosticDetail_`.** It is + already published on `SAM.Analytical.CreatePanelsBySolids` / `CreateAperturesBySolids` and on the OCCT + set, and asserted by `SAM_OCCT/Testing/SAM.OCCT.GrasshopperTests/DiagnosticsContractTests.cs`. The + brief's `_diagnosticDetail_` is documented as the *proposal for new components* and the discrepancy is + recorded as an owner decision (GH-E3 / Appendix 3 note 3). Nothing was renamed. +- **The versioning policy is the implemented one, not an invented one.** `Query.GetObsoleteSeverity` + derives `Advisory`/`Standard`/`Breaking` from patch/minor/major; the observed Q4 practice is a patch + bump for description corrections, appended optional parameters and alias-migrated renames, and minor + or major reserved for genuine behavioural change. `ObsoleteSeverity` and `ParameterRenames` are + documented as orthogonal, and `MinCompatibleVersion` as declared-but-unread. +- **No rule was invented to fill a gap.** Where the source evidence is silent or ambiguous, the document + says so and marks it `OPEN`. + +## Files changed + +- `docs/GH_COMPONENT_STANDARDS.md` (new) +- `design/grasshopper-icons/tools/param_contract.py` (new) +- `AGENTS.md` (modified: step 6 and a "Grasshopper components" section) +- `documentation/GH-Component-Standards-Q4.md` (new, this record) + +## Validation + +Machine-local (Windows, Git for Windows; all commands from the repository root). + +| Check | Command | Result | +|---|---|---| +| Whitespace / conflict markers | `git diff --check` | clean (exit 0) | +| Validator compiles | `python -m py_compile design/grasshopper-icons/tools/param_contract.py` | exit 0 | +| **Behaviour-preservation: the validator reproduces the merged pilot** | `python design/grasshopper-icons/tools/param_contract.py --repo ../SAM_Psychrometrics --check` | **`3 components checked (0 parameter classes skipped): OK`** (exit 0) | +| Validator default repo | `python design/grasshopper-icons/tools/param_contract.py --output ` | `460 components (64 parameter classes skipped)` | +| Fleet baseline scan (Appendix 2 of the standard) | `python design/grasshopper-icons/tools/param_contract.py --repo --output ` for each active GH repository | all exit 0; numbers in the standard | +| Encoding / line endings | byte inspection + `git ls-files --eol` | index LF for all three files; no BOM; no CR; `*.md` LF as required by `.gitattributes` | +| Markdown fences balanced | `docs/GH_COMPONENT_STANDARDS.md` | 6 fence markers (even) | +| File scope | `git status --short` before commit | only the three intended files | + +The `--check` line is the important one: the shared copy produces **exactly** the result the merged +`SAM_Psychrometrics` pilot tooling produces against its committed manifest, which is what shows the +extraction is behaviour-preserving rather than a rewrite. + +The Python used was the machine-local RhinoCode interpreter +(`%USERPROFILE%\.rhinocode\py313-rh9\python.exe`, Python 3.13.13). On this machine +`python`/`python3`/`py` on `PATH` are the Microsoft Store aliases and do not run; that must be +provisioned on another computer. + +**Not run, and not claimed:** no `dotnet build` or `dotnet test` (no product code changed, so none +applies); no GitHub-hosted check locally; no Rhino canvas session. + +## Unresolved issues, risks and blockers + +- **Hosted CI has no metadata job.** None is added here (deliberately, per the task scope). Appendix 5 + records the feasibility evidence: pure standard-library Python, ~25 s for `SAM`, present on + `ubuntu-latest` / `windows-2022`; the blockers are the per-repository baselines and the + Rhino-dependency of the runtime tests. +- **The discriminator is a naming heuristic.** `Goo…Param` exclusion is by name plus base type; a + stronger base-chain resolution to `GH_Component` is a recorded follow-up. +- **Three scanner copies now exist** (this canonical one, the pilot's, and SAM's older `inventory.py`). + Deliberate and documented; consolidation is a follow-up and would touch the icon tooling, which is + out of scope here. +- **`_diagnosticDetail_` vs `diagnosticDetail_`** and **`MinCompatibleVersion`** need owner decisions + (Appendix 3). +- **The fleet baseline is a snapshot** produced by the validator at the content SHA above. Re-running it + later is expected to differ wherever a repository has moved; it is a reference, not a freeze. +- The standard's proposed rules (`PROPOSED`) do not yet have enforcement behind them beyond the + description-quality and naming checks; a reader must respect the status labels. + +## Exact next step + +1. Push `docs/gh-component-standards-q4` and open the draft PR into `sow/2026-Q4` (PR number and final + head in the PR description). +2. Owner/architecture review of the standard, in particular the `OPEN` items: `_diagnosticDetail_` + versus `diagnosticDetail_`, and the `MinCompatibleVersion` question. +3. After an owner decision, the follow-ups, each its own bounded task: promote the pilot's validator copy + to the shared one; add a `--check` baseline for the first Wave-1 repository (Appendix 4); add the + per-repository `AGENTS.md` snippet there. +4. Merge only after owner approval and passing final-head checks; then the post-merge `[skip ci]` + `PROJECT_PROGRESS.md` closeout on `sow/2026-Q4` and guarded branch cleanup. + +No other repository is started by this task, no naming exception is migrated, and the Diagnostics +redesign is not implemented. From 77a25112865539e6a3fbd0b9e725f5e593ebd770 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 18:18:04 +0200 Subject: [PATCH 3/8] docs: record the PR number for the GH component standard [skip ci] --- documentation/GH-Component-Standards-Q4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md index 1787d0d8f..51d9777c6 100644 --- a/documentation/GH-Component-Standards-Q4.md +++ b/documentation/GH-Component-Standards-Q4.md @@ -4,6 +4,7 @@ required. Documentation, tooling and agent instructions only. - Repository: `SAM` (shared). Canonical standard for every SAM-BIM repository. +- PR: **SAM-BIM/SAM#204** → `sow/2026-Q4` (**draft**, not merged). - Base: `sow/2026-Q4` at `6b12e2a65817556693ba408674211610f4a20dda`. - **Validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (standard, validator, AGENTS.md). This record is a docs-only commit on top; the PR number, branch head and hosted check results live in From a5461a6f91581fc5a499b70fe95c173ee950f66c Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 18:24:21 +0200 Subject: [PATCH 4/8] docs: record hosted checks for the GH component standard [skip ci] --- documentation/GH-Component-Standards-Q4.md | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md index 51d9777c6..9a952042c 100644 --- a/documentation/GH-Component-Standards-Q4.md +++ b/documentation/GH-Component-Standards-Q4.md @@ -7,8 +7,9 @@ required. Documentation, tooling and agent instructions only. - PR: **SAM-BIM/SAM#204** → `sow/2026-Q4` (**draft**, not merged). - Base: `sow/2026-Q4` at `6b12e2a65817556693ba408674211610f4a20dda`. - **Validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (standard, validator, AGENTS.md). - This record is a docs-only commit on top; the PR number, branch head and hosted check results live in - the live PR description (a record cannot contain its own commit SHA). + `d313c63f`, `77a25112` and the final record commit are record-only docs commits on top. The PR + number, final branch head and hosted check results are also stated in the live PR description (a + record cannot contain its own commit SHA). - Builds on merged work, which it reuses rather than redesigns: `SAM_Psychrometrics#11` (metadata pilot), `SAM#203` (shared rename-migration infrastructure, merged `a1d605cf`), `SAM_Psychrometrics#12` (the validated single rename). Also referenced: Unified @@ -102,8 +103,27 @@ The Python used was the machine-local RhinoCode interpreter `python`/`python3`/`py` on `PATH` are the Microsoft Store aliases and do not run; that must be provisioned on another computer. -**Not run, and not claimed:** no `dotnet build` or `dotnet test` (no product code changed, so none -applies); no GitHub-hosted check locally; no Rhino canvas session. +### Hosted checks (GitHub) + +The PR-event checks did not start, and the reason is worth recording: **the head commit of the branch +carried `[skip ci]`**, the repository's convention for a docs-only record commit, and GitHub suppresses +workflow runs when the head commit message contains that marker. The branch head was a `[skip ci]` +record commit at the moment the PR was opened, and again after each push. So the content commit +`7dd5a4a5` was never a PR head without the marker. + +To obtain genuine hosted evidence for a documentation-and-tooling branch, the repository's own checks +were dispatched explicitly at the branch head (`workflow_dispatch`, which the three workflows support): + +| Workflow | Run | Result | +|---|---|---| +| Build (Windows) — `msbuild /t:Rebuild` Release | [38767969445](https://github.com/SAM-BIM/SAM/actions/runs/38767969445) | success | +| Test — `dotnet test SAM/SAM.Tests` Release | [38767974784](https://github.com/SAM-BIM/SAM/actions/runs/38767974784) | success, **3683 passed / 0 failed** (matches the documented Q4 baseline) | +| SPDX + Copyright header check | [38767979570](https://github.com/SAM-BIM/SAM/actions/runs/38767979570) | success (no `.cs` file changed) | + +All three were dispatched at head `77a25112`; the later record-only commit does not change the tested +tree. This is stated as a dispatch, not as a PR-event check, and the distinction is deliberate. + +**Not run, and not claimed:** no GitHub PR-event check (see above); no Rhino canvas session. ## Unresolved issues, risks and blockers From 5a6a617b7021ba155933910e3b0d3ed069d4ba28 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 19:48:52 +0200 Subject: [PATCH 5/8] fix(gh): correct the metadata validator after independent review of #204 Resolve the four Major and three Minor findings from the review of the canonical standard in SAM-BIM/SAM#204. The governing change is fail-closed extraction: a component whose published contract was not completely read is reported `partial`/`unresolved` and blocks a compatibility PASS, instead of being silently reported as verified. Extraction (Major): a statement-level walker replaces the single-call-site regex. Now supported and regression-tested: target-typed `new()`, variable reuse and reassignment, object initializers without constructor parentheses, declarations without an initializer, collection expressions and AddRange, verbatim identifiers, declarations inside foreach/if blocks, expression-bodied properties, local parameter factories, parameter-manager registration with typed helpers, attributed `Optional`, inherited registration from an abstract base, generic base type-argument substitution, and compile-removed files. The reported CreatePanelsBySolids case now extracts all 7 inputs and 4 outputs (it previously produced 7 unnamed inputs and no outputs). Type (Major): the runtime parameter type is part of the frozen contract and is compared exactly; custom and third-party types are recorded as `goo`/`external` and reported, and a type that cannot be identified blocks the check. Discovery (Major): classification is by source ancestry, not by name, so a component called NewComputeParam is a component and Goo*Param is a parameter. Ancestry resolves across the repository, unknown ancestry is reported rather than silently excluded, project discovery follows indirect project references and parses csproj with ElementTree (either quoting style), and an unparsable project is reported. Compatibility vs conventions (Major): compatibility compares published metadata against the approved baseline only; naming conventions are a separate finding with an approved-exception list; legitimate List/Tree access and existing nicknames are valid; a code-side default does not require SetPersistentData. Minor: a version-only change is a manifest refresh, not a compatibility violation; SAM's AGENTS.md no longer asks for a baseline-dependent check it cannot satisfy; and the standard no longer labels grammar, SI units or engineering semantics as automatically enforced. Adds design/grasshopper-icons/tools/tests/test_param_contract.py (42 stdlib tests). No component, GUID, parameter, default, updater algorithm or unrelated repository is modified. --- AGENTS.md | 19 +- .../grasshopper-icons/tools/param_contract.py | 1806 ++++++++++++++--- .../tools/tests/test_param_contract.py | 847 ++++++++ docs/GH_COMPONENT_STANDARDS.md | 374 +++- 4 files changed, 2675 insertions(+), 371 deletions(-) create mode 100644 design/grasshopper-icons/tools/tests/test_param_contract.py diff --git a/AGENTS.md b/AGENTS.md index 84ef69aa4..a42482035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,16 +41,23 @@ proposals or open decisions - check the status label before relying on one. The parts that most often matter: -- A published parameter `Name`, order, 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 +- 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. -- Validate metadata changes with - `python design/grasshopper-icons/tools/param_contract.py --repo . --check` - (see `docs/GH_COMPONENT_STANDARDS.md` Appendix 4 for other repositories). +- 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. diff --git a/design/grasshopper-icons/tools/param_contract.py b/design/grasshopper-icons/tools/param_contract.py index ce6fd8065..592f68cfd 100644 --- a/design/grasshopper-icons/tools/param_contract.py +++ b/design/grasshopper-icons/tools/param_contract.py @@ -1,44 +1,62 @@ """The SAM-BIM Grasshopper component metadata contract - canonical shared validator. -Extracts the published parameter contract of every non-abstract Grasshopper component that declares a -`ComponentGuid`: the component name/nickname/category/subcategory, `LatestComponentVersion`, and for -every declared input and output the name, nickname, description, `Optional`, `Access`, -`ParamVisibility` and any persistent default. +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 # (re)write the manifest from source - python param_contract.py --repo --check # verify source against the committed manifest - python param_contract.py --repo --output # write the manifest somewhere else (dry run) + 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`. Pointing `--repo` at another SAM-BIM -repository is how one shared copy serves every repository instead of a copy per repository: the -manifest is repository-specific data, the validator is not. +`/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 contract exists because Grasshopper restores a component from the parameter archive stored in a -definition: a reopened component keeps the names the FILE has, not the names its class now declares. -Renaming a parameter therefore breaks every `Params.IndexOfInputParam`/`IndexOfOutputParam` lookup in -every existing definition, silently. The manifest pins the current names, and `--check` fails if any -of them, or the order, access, optionality, visibility or GUID, changes. +## The governing rule -Naming-standard deviations (camelCase outputs, `_name_` inputs without a persistent default) are the -frozen legacy exceptions listed in `namingExceptions`: they are expected, not errors, but a NEW -deviation, or a listed exception that no longer deviates, fails the check so the list stays honest. -Descriptions are not frozen - they are checked for quality only. +**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: 2`. 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. ## Why the scanner is embedded -The C# scanner below is the Grasshopper document-object scanner from SAM#166 (the design/grasshopper -icons tooling), in the generalised form first written for the `SAM_Psychrometrics` metadata pilot. -It is inlined rather than imported because the repository-local `inventory.py` copies have since -diverged (`SAM/design/grasshopper-icons/tools/inventory.py` is the older, SAM-only generation with a -different API; the pilot's copy is the generalised one). Inlining keeps this validator a single, -portable artifact that cannot silently change behaviour with whichever `inventory.py` happens to be -next to it. Consolidating the copies is recorded as a 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, and for the difference -between a change that needs a manifest rebuild and one that needs an owner-approved migration. +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 @@ -46,12 +64,88 @@ import os import re import sys +import xml.etree.ElementTree as ET + +SCHEMA_VERSION = 2 -# --- C# scanner (SAM#166 lineage, generalised for the metadata pilot) -------------------------------- +# --- C# scanner ------------------------------------------------------------------------------------- -SKIP_DIRS = {"obj", "bin", ".git", "packages", "design", "build"} +SKIP_DIRS = {"obj", "bin", ".git", "packages", "design", "build", ".vs", "TestResults"} CLASS_RE = re.compile(r"^\s*(?:\[[^\]]*\]\s*)*((?:public|internal|private|protected|abstract|sealed|static|partial|\s)+)class\s+(\w+)(?:<[^>{]*>)?\s*(?::\s*([^{\n]+))?", re.M) -GUID_RE = re.compile(r"ComponentGuid\s*(?:=>|\{\s*get\s*\{\s*return)\s*new\s*(?:Guid\s*)?\(\s*\"([0-9a-fA-F-]+)\"") # new Guid("..") and target-typed new("..") +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"} + +# 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): @@ -60,6 +154,11 @@ def long_path(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 @@ -95,42 +194,13 @@ def lit(a): return "{" + a + "}" -def compile_removed(project_dir): - """Files excluded from compilation by the project's `` globs.""" - patterns = [] - for f in os.listdir(project_dir): - if not f.endswith(".csproj"): - continue - text = open(long_path(os.path.join(project_dir, f)), encoding="utf-8-sig", errors="replace").read() - patterns += [m.replace("\\", "/") for m in re.findall(r'"): + 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 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 or "=" not in part: + if not part: + continue + assignment = split_assignment(part) + if not assignment: continue - key, value = part.split("=", 1) - out[key.strip()] = value.strip() + target = assignment_target(assignment[0]) + if not target: + continue + out[target[1]] = assignment[1].strip() return out -def unquote(value): - value = value.strip() - if len(value) >= 2 and value[0] == '"' and value[-1] == '"': - value = value[1:-1] - return value.replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\") +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 = simple_type(declared_type) + else: + match = re.match(r"([A-Za-z_][\w:.<>,\[\]]*)\s*(?:\(|\{)", rest) + if not match: + return None + type_name = simple_type(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 + tail = rest[after:] + initializer = None + brace = tail.find("{") + if brace != -1 and tail[:brace].strip() == "": + brace_body, _ = take_balanced(tail, brace) + initializer = parse_initializer(brace_body) + 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 + if re.fullmatch(r"Param_\w+", type_name): + return "builtin" + if re.fullmatch(r"\w*Param", type_name): + return "goo" + if re.fullmatch(r"[A-Za-z_]\w*", type_name): + return "external" + return None -def param_type(expression): - match = re.search(r"Param_\w+", expression) - return match.group(0) if match else 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") -def parse_params(block): - """Every `result.Add(new GH_SAMParam(...))` in a declaration block, in source order.""" - variables = {} - for match in re.finditer(r"(\w+)\s*=\s*new\s+[\w:.]*Param_\w+\(\)\s*\{", block): - body, _ = take_balanced(block, block.index("{", match.start())) - variables[match.group(1)] = {"init": parse_initializer(body), "type": param_type(block[match.start():match.end()])} - for match in re.finditer(r"(\w+)\.SetPersistentData\(([^;]*)\)", block): - if match.group(1) in variables: - variables[match.group(1)]["persistentDefault"] = match.group(2).strip() - result = [] - for match in re.finditer(r"result\.Add\(new GH_SAMParam\(", block): - args, _ = take_balanced(block, match.end() - 1) - parts = split_top_level(args) - first = parts[0].strip() - vis = parts[1].strip().replace("ParamVisibility.", "") if len(parts) > 1 else None - - if first.startswith("new "): - body, _ = take_balanced(block, block.index("{", match.end())) - init = parse_initializer(body) - ptype = param_type(first[:first.index("{")]) - persistent = None +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 = simple_type(constructed.group(1)) + if type_name is None: + continue + if method_body[constructed.end() - 1] == "(": + _, after = take_balanced(method_body, constructed.end() - 1) else: - entry = variables.get(first, {}) - init = entry.get("init", {}) - ptype = entry.get("type") - persistent = entry.get("persistentDefault") - - result.append({ - "name": unquote(init.get("Name", "")), - "nickname": unquote(init.get("NickName", "")), - "description": unquote(init.get("Description", "")), - "type": ptype, - "optional": init.get("Optional", "false").strip() == "true", - "access": (init.get("Access", "") or "").split(".")[-1] or None, - "visibility": vis, - "persistentDefault": persistent, - }) - return result + 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": simple_type(type_name) or 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 is_parameter_class(class_name, base_types): - """True for a Goo parameter wrapper, not a published component. +def discover_projects(repo): + """Every .csproj in the repository, with the evidence for its Grasshopper classification. - A Grasshopper definition object is a *component* only when it is a `GH_Component`. The - `GooXxxParam` classes declare a `ComponentGuid` too (they are serialisable document objects), so - a scanner keyed on `ComponentGuid` alone collects them as well: in the SAM repository that is - ~60 extra objects whose "parameters" are meaningless. They are recognised by their name (every - SAM `…Param` class is a parameter) and by deriving from `GH_Param`/`GH_PersistentParam`. + 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. """ - base = (base_types or "").strip() - if class_name.endswith("Param") and "Param" in base: - return True - return "GH_PersistentParam" in base or "GH_Param" in base + 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 components(repo): - """(components in a stable order, skipped parameter-class identifiers) for `repo`.""" - items = [] - skipped = [] - for project_dir, project in sorted(gh_projects(repo).items(), key=lambda kv: kv[1]): - removed = compile_removed(project_dir) +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 f in sorted(files): - if not f.endswith(".cs") or f.endswith(".Designer.cs"): + for file_name in sorted(files): + if not file_name.endswith(".cs") or file_name.endswith(".Designer.cs"): continue - path = os.path.join(root, f) + path = os.path.join(root, file_name) if removed(os.path.relpath(path, project_dir)): continue - source = open(long_path(path), encoding="utf-8-sig").read() - if "ComponentGuid" not in source: + source = read_text(path) + if "ComponentGuid" not in source and "abstract class" not in source: continue - code = re.sub(r"(?m)(^|[\s;{}])//[^\n]*", r"\1", source) - classes = list(CLASS_RE.finditer(code)) - for i, class_match in enumerate(classes): - body = code[class_match.end(): classes[i + 1].start() if i + 1 < len(classes) else len(code)] - guid = GUID_RE.search(body) - if not guid or "abstract" in class_match.group(1): - continue - if is_parameter_class(class_match.group(2), class_match.group(3)): - skipped.append("%s (%s)" % (class_match.group(2), os.path.relpath(path, repo).replace("\\", "/"))) - continue - ctor = re.search(r"\b" + class_match.group(2) + r"\s*\(\s*\)\s*(?=:\s*base\()", body) - base = base_args(body, ctor.end()) if ctor else None - if base and base[-1].startswith("GH_ParamAccess"): - base = base[:-1] - - version = re.search(r"LatestComponentVersion\s*=>\s*\"([^\"]+)\"", body) - inputs_match = re.search(r"GH_SAMParam\[\]\s+Inputs", body) - outputs_match = re.search(r"GH_SAMParam\[\]\s+Outputs", body) - inputs = parse_params(body[inputs_match.start():outputs_match.start()]) if inputs_match and outputs_match else [] - outputs = parse_params(body[outputs_match.start():]) if outputs_match else [] - - items.append({ - "class": class_match.group(2), - "guid": guid.group(1).lower(), - "name": unquote(lit(base[0])) if base else None, - "nickname": unquote(lit(base[1])) if base and len(base) > 1 else None, - "category": unquote(lit(base[-2])) if base and len(base) >= 4 else None, - "subcategory": unquote(lit(base[-1])) if base and len(base) >= 4 else None, - "description": unquote(lit(base[2])) if base and len(base) >= 4 else None, - "latestComponentVersion": version.group(1) if version else None, - "inputs": inputs, - "outputs": outputs, + code = BLOCK_COMMENT_RE.sub(" ", COMMENT_RE.sub(r"\1", source)) + matches = list(CLASS_RE.finditer(code)) + for index, match in enumerate(matches): + body = code[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(code)] + declaration = match.group(0) + generics = re.search(r"class\s+\w+\s*<\s*([^>]*)\s*>", declaration) + candidates.append({ + "class": match.group(2), + "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("\\", "/"), }) - return sorted(items, key=lambda item: item["class"]), sorted(skipped) + 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 + if not candidate["guid"]: + continue # a document object without a component identity is not part of the contract + kind, reason = resolve_kind(candidate["class"], candidate["bases"], bases_by_class) + candidate["kind"] = kind + candidate["kind_reason"] = reason + if kind == "component": + components.append(candidate) + elif kind == "parameter": + parameters.append(candidate) + else: + unclassified.append(candidate) + return components, parameters, unclassified, {candidate["class"]: candidate for candidate in candidates} + + +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, []): + seen.add(base) + if base in bases_by_class: + ancestry(base, bases_by_class, seen) + 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): + self.class_name = class_name + self.body = body + self.substitutions = substitutions 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): + record = { + "name": name, + "nickname": nickname, + "description": description, + "type": type_name, + "typeKind": type_kind(type_name), + "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": simple_type(bare.group("type")), "init": {}, + "default": None, "defaultLiteral": None, "at": position} + 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} + elif rhs.startswith("["): + 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): + kind = type_kind(type_name) + if kind is None: + self.reason("parameter runtime type could not be determined at %s" % position) + elif kind == "external": + self.external_types.add(type_name) + + 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 + 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 = {} + for statement in walk_statements(property_body): + 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 + self.bind(bindings, statement, "%s/%s" % (side, self.position_of(property_body, statement))) + self.apply_persistent_data(bindings, statement) + params.extend(self.emit(statement, side, property_body, bindings)) + return params + + 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)) + + 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: + index = statement.find("new GH_SAMParam(", cursor) + if index == -1: + break + args, after = take_balanced(statement, index + len("new GH_SAMParam")) + cursor = after + parts = split_top_level(args) + first = parts[0].strip() if parts else "" + visibility = visibility_of(parts[1] if len(parts) > 1 else None) + position = "%s/%s" % (side, self.position_of(body, statement)) + 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"]) + 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 = [] + for statement in walk_statements(method_body): + 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 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 [] + 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 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"]) + inputs, outputs, registration = extractor.extract(candidate["body"]) + + if registration == "inherited": + for base in candidate["bases"]: + if base in base_lookup: + substitutions = generic_substitutions(candidate, base, base_lookup[base]) + if substitutions is None: + extractor.reason("base class %s is generic and is not closed with literal type arguments by %s" + % (base, candidate["class"])) + break + extractor = Extractor(candidate["class"], base_lookup[base]["body"], substitutions) + inputs, outputs, registration = extractor.extract(base_lookup[base]["body"], reason_prefix=None) + registration = "inherited:%s" % base + break + else: + registration = "none" + + 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 registration in ("inherited", "manager"): + 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\()", 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, + }) + 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, + }) + + 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 ------------------------------------------------ + +# --- naming standard and its frozen legacy exceptions ------------------------------------------------- def base_name(name): - return name.strip("_") + return (name or "").strip("_") def expected_name(name, side, optional, persistent_default): @@ -330,72 +1525,146 @@ def expected_name(name, side, optional, persistent_default): return ("_" + core + "_") if persistent_default is not None else (core + "_") -def deviations(contract): - """Client identifiers whose declared name does not follow the SAM-BIM naming standard.""" +def deviations(components): + """Identifiers whose declared name does not follow the SAM-BIM new-interface naming rules.""" result = [] - for component in contract["components"]: + for component in components: for side in ("inputs", "outputs"): for param in component[side]: - expected = expected_name(param["name"], side[:-1], param["optional"], param["persistentDefault"]) + 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 result + return sorted(result) -def quality_problems(contract): - problems = [] - for component in contract["components"]: - if not component["latestComponentVersion"]: - problems.append("%s has no LatestComponentVersion" % component["class"]) +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["name"]) - description = (param["description"] or "").strip() + where = "%s.%s.%s" % (component["class"], side[:-1], param.get("name")) + description = (param.get("description") or "").strip() if not description: - problems.append("%s: empty description" % where) + notes.append("%s: empty description" % where) elif not description[0].isupper(): - problems.append("%s: description does not begin with a capital letter" % where) + notes.append("%s: description does not begin with a capital letter" % where) if "Humidty" in description: - problems.append("%s: known typo 'Humidty'" % where) - if param["nickname"] != param["name"]: - problems.append("%s: NickName '%s' != Name" % (where, param["nickname"])) - if param["access"] != "item": - problems.append("%s: unexpected access '%s'" % (where, param["access"])) - return problems + 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) -# --- entry point ------------------------------------------------------------------------------------ +# --- 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)) + + by_class = {component["class"]: component for component in baseline.get("components", [])} + compared = 0 -def diff(committed, current): - problems = [] - by_class = {c["class"]: c for c in committed["components"]} for component in current["components"]: previous = by_class.pop(component["class"], None) if previous is None: - problems.append("new component %s (%s)" % (component["class"], component["guid"])) + findings["compatibility"].append("component added: %s (%s)" % (component["class"], component["guid"])) continue - for field in ("guid", "name", "nickname", "category", "subcategory", "latestComponentVersion"): - if previous[field] != component[field]: - problems.append("%s.%s: %r -> %r" % (component["class"], field, previous[field], component[field])) + 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 = [(p["name"], p["nickname"], p["description"], p["optional"], p["access"], p["visibility"], p["persistentDefault"]) for p in previous[side]] - new = [(p["name"], p["nickname"], p["description"], p["optional"], p["access"], p["visibility"], p["persistentDefault"]) for p in component[side]] - if [x[0] for x in old] != [x[0] for x in new]: - problems.append("%s.%s: names/order changed\n committed: %s\n current: %s" - % (component["class"], side, [x[0] for x in old], [x[0] for x in new])) + 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 a, b in zip(old, new): - # name, nickname, optional, access, visibility and persistent default are frozen contract - for index, label in ((1, "nickname"), (3, "optional"), (4, "access"), (5, "visibility"), (6, "persistentDefault")): - if a[index] != b[index]: - problems.append("%s.%s.%s: %s %r -> %r" % (component["class"], side, a[0], label, a[index], b[index])) - for leftover in by_class: - problems.append("component removed: %s" % leftover) - return problems + 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: extraction is %s (%s)" + % (component["class"], component["extraction"]["status"], + "; ".join(component["extraction"]["reasons"]) or "no reason recorded")) + + baseline_extraction = previous.get("extraction") + if 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", [])), + "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): - """The committed manifest for `repo`.""" return os.path.join(repo, "design", "grasshopper-icons", "param_contract.json") @@ -406,7 +1675,10 @@ def main(argv): 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="verify source against the committed manifest instead of writing it") + 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) @@ -414,44 +1686,70 @@ def main(argv): print("FAIL no such repository: %s" % repo) return 2 - found, skipped = components(repo) - current = { - "repository": os.path.basename(repo), - "components": found, - "namingExceptions": sorted(deviations({"components": found})), - } + 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(" 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 - if args.check: - path = manifest_path(repo) - if not os.path.isfile(path): - print("FAIL no committed manifest at %s" % path) - return 2 - committed = json.load(open(path, encoding="utf-8")) - problems = diff(committed, current) - # naming standard: expected deviations are the documented legacy exceptions, nothing more - expected_exceptions = sorted(committed.get("namingExceptions", [])) - actual_exceptions = sorted(deviations(current)) - if expected_exceptions != actual_exceptions: - problems.append("naming exceptions changed (a new deviation, or a stale exception):\n" - " documented: %s\n actual: %s" % (expected_exceptions, actual_exceptions)) - problems += quality_problems(current) - for problem in problems: - print("FAIL", problem) - print("%d components checked (%d parameter classes skipped): %s" - % (len(found), len(skipped), "OK" if not problems else "%d PROBLEM(S)" % len(problems))) - return 1 if problems else 0 - - 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(current, stream, indent=1) - stream.write("\n") - print("%d components (%d parameter classes skipped) -> %s" % (len(found), len(skipped), output)) - print("%d documented naming exceptions" % len(current["namingExceptions"])) - print("%d description/version problems" % len(quality_problems(current))) - return 0 + 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"])) + 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__": 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..8c0a863d4 --- /dev/null +++ b/design/grasshopper-icons/tools/tests/test_param_contract.py @@ -0,0 +1,847 @@ +"""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 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__ + } +} +""" + + +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("verified", entry["extraction"]["status"], 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("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("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("verified", entry["extraction"]["status"], 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; +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; +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("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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; +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("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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") + source = os.path.join(pilot, "design", "grasshopper-icons", "param_contract.json") + with open(source, encoding="utf-8") as stream: + baseline = json.load(stream) + component_entry = baseline["components"][0] + component_entry["inputs"][0]["type"] = "Param_String" + write(os.path.join(self.root, "design", "grasshopper-icons", "param_contract.json"), + json.dumps(baseline)) + # The copy has a mutated baseline but no source to compare against, so the tool must refuse + # rather than silently accept it. + code, output = run(self.root, "--check") + self.assertNotEqual(0, code) + self.assertIn("components discovered", 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_type_is_reported_not_unresolved(self): + repo = self.contract_repo("global::RhinoInside.Revit.GH.Parameters.HostObject") + entry = component(repo, "C") + self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("external", parameter(entry, "inputs", "_value")["typeKind"]) + self.assertEqual(["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 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; +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("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + self.assertEqual("GooApertureConstructionParam", + parameter(entry, "inputs", "apertureConstructions_")["type"]) + enum_entry = component(SAM_REPO, "SAMAnalyticalApertureType") + self.assertEqual("verified", enum_entry["extraction"]["status"], enum_entry["extraction"]["reasons"]) + 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 RealRepositoryTests(unittest.TestCase): + def test_wave_one_repositories_are_fully_verified(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, summary["verified"], name) + self.assertEqual(0, summary["unresolved"], name) + + 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 index 7966bd07f..4ff176c23 100644 --- a/docs/GH_COMPONENT_STANDARDS.md +++ b/docs/GH_COMPONENT_STANDARDS.md @@ -1,6 +1,8 @@ # SAM-BIM Grasshopper Component Engineering Standard -**Status:** canonical. Established 2026-10-15 on `sow/2026-Q4`. +**Status:** canonical. Established 2026-10-15 on `sow/2026-Q4`; corrected 2026-10-16 in response to +the independent review of PR #204 (parameter extraction, runtime type in the contract, ancestry-based +classification, and the separation of compatibility from naming conventions). **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. @@ -9,6 +11,9 @@ versioned and changed safely. It exists so that future component development is 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 @@ -17,14 +22,50 @@ 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. | -| **ENFORCED** | Checked automatically by committed tooling; a violation fails a check. | -| **PROPOSED** | Recommended direction. Not yet implemented, or applies to new work only. | +| **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. Only the first is automated, and only the second proves +backward compatibility: + +| 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` @@ -56,26 +97,27 @@ asserts `NickName == Name` for every parameter; the same expectation is set for 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 — **ENFORCED** +### 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 Psychrometrics contract tooling records -category and subcategory and fails on a change. +`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 — **ENFORCED, VALIDATED** +### 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 Psychrometrics metadata contract asserts the three GUIDs +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) @@ -167,11 +209,12 @@ evaluated by `GH_SAMVariableOutputParameterComponent`: `Panels`, `Spaces`, `AnalyticalModel`, `Diagnostics`, `AdjacencyCluster`, `Successful`. -### GH-C2 — No leading or trailing underscore on an output — **ENFORCED** (in the pilot) +### GH-C2 — No leading or trailing underscore on an output — **CHECKED (convention)** -The metadata contract's naming rule computes the expected output name as -`PascalCase(strip_underscores(name))` and fails on a deviation -(`design/grasshopper-icons/tools/param_contract.py`, `expected_name`). The `SAM_Psychrometrics` pilot +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** @@ -196,13 +239,16 @@ frozen. `SAM_Psychrometrics/docs/GH-Component-Metadata-Standard.md` is the recor 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 — ENFORCED.** +- **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 — ENFORCED in the pilot.** `[m]`, `[°C]`, `[kg/m³]`, `[Pa]`, - `[-]` for dimensionless. The pilot's first fix was the typo `Humidty` → `Humidity` and unit - consistency (`[kg/m3]` → `[kg/m³]`). +- **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 @@ -210,12 +256,13 @@ written down for the person on the canvas. 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 — ENFORCED.** The contract check fails on an empty - description, a description that does not start with a capital letter, a known typo, and - `NickName != Name`. +- **GH-D8 — No placeholder or misleading text — CHECKED (narrowly).** The validator fails on an empty + description, a description that does not start with a capital letter, a known typo (`Humidty`), and a + `NickName` that differs from `Name` — the last recorded as an advisory note, not a failure, because an + existing nickname is legitimate (GH-I3). - **GH-D9 — A description must agree with the implementation — PROPOSED (reviewer responsibility).** - The automated check cannot 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. + 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 @@ -394,7 +441,7 @@ If a step after `AddObject` throws, the half-built replacement is removed and th is left in the document. A broken `ParameterRenames` declaration is reported and never blocks the update. -### GH-G7 — Required validation for a rename migration — **ENFORCED by process** +### 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). @@ -491,7 +538,9 @@ document. There is no plan to version each rule. For every published parameter: - the `Name`; -- the runtime parameter **type** (for example `Param_Number`, `GooPanelParam`); +- 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; @@ -501,8 +550,17 @@ For every published parameter: 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`, `diff`) and the runtime contract asserts them -at runtime in a real Rhino (`MetadataContractTests`, `DiagnosticsContractTests`). +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: + +- A type is compared **exactly**, as the name it is constructed from. No equivalence is inferred: an + unrecognised-but-identified type (`HostObject`) is still compared exactly and listed in the + manifest's `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). ### GH-I2 — When a controlled migration permits an exception — **VALIDATED** @@ -541,15 +599,32 @@ deliberately does not freeze it (GH-D9). 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. -### J1 — Metadata-only correction +### 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`), or the baseline itself records an incomplete extraction | +| `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. -- Metadata contract check: `python design/grasshopper-icons/tools/param_contract.py --repo --check` - (in `SAM`, the shared validator; in `SAM_Psychrometrics`, the pilot copy) → `OK`. +- 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. -### J2 — Compatible parameter migration +### GH-J2 — Compatible parameter migration - Build and the automated tests. - Explicit alias verification: the declaration shape, and that duplicate/malformed declarations are @@ -560,7 +635,7 @@ inspection; where a check cannot be automated today, say so explicitly in the PR - Saved/reopened definitions recompute correctly. - No unintended engineering result differences elsewhere. -### J3 — Behavioural change +### GH-J3 — Behavioural change - Engineering-specific regression tests (golden fixtures; see the `PanelsBySolid` golden data). - A compatibility and serialization review against section I. @@ -568,16 +643,25 @@ inspection; where a check cannot be automated today, say so explicitly in the PR ### GH-J4 — What a check cannot cover — **VALIDATED** -- Description-versus-implementation agreement (GH-D9) is a reading task. +- 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 — **ENFORCED** +### 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. +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. --- @@ -591,86 +675,112 @@ keeps the frozen-exception list meaningful instead of decorative. | 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); `SAM_Psychrometrics/design/grasshopper-icons/{tools/param_contract.py,tools/inventory.py,param_contract.json}` (pilot copy) | +| Metadata contract | `design/grasshopper-icons/tools/param_contract.py` (this repository, canonical); `design/grasshopper-icons/tools/tests/test_param_contract.py` (42 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 baseline (2026-10-15) +## Appendix 2 — Measured coverage (2026-10-15, revised by the review corrections) -Produced by the canonical validator at this PR's content commit: +Produced by the canonical validator: ``` python design/grasshopper-icons/tools/param_contract.py --repo --output ``` -"Components" excludes the `Goo…Param` classes the scanner would otherwise collect (GH-Appendix 3, -note 1). Deviations and problems are counts from the standard's own rules; a nonzero value is a -*registered baseline*, not necessarily a defect to fix. - -| Repository | Components | Naming deviations | Description/version problems | -|---|---|---|---| -| SAM | 460 | 1120 | 1493 | -| SAM_Tas_Grasshopper | 94 | 370 | 370 | -| SAM_Revit | 55 | 26 | 38 | -| SAM_OCCT | 36 | 155 | 188 | -| SAM_UI | 31 | 179 | 81 | -| SAM_Systems | 30 | 58 | 32 | -| SAM_Mollier | 24 | 107 | 37 | -| SAM_Topologic | 22 | 10 | 12 | -| SAM_SolarCalculator | 21 | 348 | 210 | -| SAM_Excel | 8 | 27 | 3 | -| SAM_Solver | 7 | 68 | 68 | -| SAM_LadybugTools | 7 | 3 | 3 | -| SAM_OpenStudio | 6 | 24 | 9 | -| SAM_Multitasker | 5 | 8 | 8 | -| SAM_Acoustic | 3 | 1 | 0 | -| SAM_BHoM | 3 | 0 | 3 | -| SAM_IFC | 3 | 5 | 0 | -| SAM_Psychrometrics | 3 | 22 (frozen, all registered) | 0 | -| SAM_Python | 3 | 6 | 3 | -| SAM_gbXML | 2 | 4 | 0 | -| SAM_Origin | 2 | 2 | 1 | -| SAM_GEM | 1 | 3 | 2 | - -SAM_Psychrometrics is the only repository with a committed manifest and a passing `--check`; its 22 -deviations are the documented frozen exceptions (one rename already migrated). The distribution -matters more than any single number: most repositories are small, and the deviation count is -dominated by camelCase outputs. +`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 | 456 | 4 | 0 | 64 | +| SAM_Tas_Grasshopper | 94 | 94 | 0 | 0 | 10 | +| SAM_Revit | 55 | 55 | 0 | 0 | 1 | +| SAM_OCCT | 36 | 36 | 0 | 0 | 1 | +| SAM_UI | 31 | 31 | 0 | 0 | 1 | +| SAM_Systems | 30 | 29 | 1 | 0 | 8 | +| SAM_Mollier | 24 | 24 | 0 | 0 | 4 | +| SAM_Topologic | 22 | 22 | 0 | 0 | 0 | +| SAM_SolarCalculator | 21 | 21 | 0 | 0 | 3 | +| SAM_Excel | 8 | 8 | 0 | 0 | 0 | +| SAM_LadybugTools | 7 | 7 | 0 | 0 | 0 | +| SAM_Solver | 7 | 7 | 0 | 0 | 0 | +| SAM_OpenStudio | 6 | 6 | 0 | 0 | 0 | +| SAM_Multitasker | 5 | 5 | 0 | 0 | 4 | +| SAM_Acoustic | 3 | 2 | 1 | 0 | 1 | +| SAM_BHoM | 3 | 3 | 0 | 0 | 0 | +| SAM_IFC | 3 | 3 | 0 | 0 | 1 | +| SAM_Psychrometrics | 3 | 3 | 0 | 0 | 0 | +| SAM_Python | 3 | 3 | 0 | 0 | 3 | +| SAM_gbXML | 2 | 2 | 0 | 0 | 0 | +| SAM_Origin | 2 | 2 | 0 | 0 | 0 | +| SAM_GEM | 1 | 1 | 0 | 0 | 0 | + +**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 `partial` components and their +reasons are listed in Appendix 3. `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. **Scanner discriminator.** The contract scanner keys on `ComponentGuid`, which also matches the - `Goo…Param` parameter classes and, in `SAM`, one non-abstract base (`A_SAMAnalytical`). The canonical - validator excludes them (GH Appendix 2 note) and reports how many it skipped, but the exclusion is a - naming heuristic. A stronger discriminator (resolve the base-type chain to `GH_Component`) is a - follow-up. -2. **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 canvas of *data* (the manifest) is - already repository-specific. +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. + In the fleet measured here, `unclassifiedClasses` is 0 everywhere except before the fix, when + SAM_Revit's 30 Revit-derived components were unclassified (the fix: `IGH_SAMComponent`). +2. **Six components cannot be extracted completely, and that is reported, not hidden** (SAM 4, + SAM_Systems 1, SAM_Acoustic 1): + - `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 for that repository until resolved or explicitly accepted. 3. **`diagnosticDetail_` vs `_diagnosticDetail_`** (GH-E3) — owner decision. -4. **CI.** No SAM-BIM CI job runs this validator yet. The runner images have Python available; the +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. -5. **`MinCompatibleVersion`** (GH-H5) is dead code in effect; removing or wiring it is a separate, +8. **`MinCompatibleVersion`** (GH-H5) is dead code in effect; removing or wiring it is a separate, owner-approved change. -6. **`SAM_OpenStudio_base`** appeared as a second checkout of SAM_OpenStudio during this investigation +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) a rendered baseline manifest, (3) a `--check` run. The proposed instruction snippet, written to be -appended to an existing `AGENTS.md` "Before starting work" list, is: +(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, access, optionality, visibility and defaults are frozen; - a rename needs a declared ParameterRename alias and the migration acceptance in that document. - Validate metadata with: - python /design/grasshopper-icons/tools/param_contract.py --repo . --check + 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: @@ -678,12 +788,12 @@ Order — small and low-risk first, and never mixing a description sweep with a | Wave | Repositories | Why | |---|---|---| | 0 | SAM (this PR's standard), SAM_Psychrometrics (already done) | The reference and the pilot. | -| 1 | SAM_Acoustic, SAM_GEM, SAM_gbXML, SAM_Origin, SAM_BHoM, SAM_IFC, SAM_Python | 1–3 components each, 0–6 deviations: a first manifest and a first green `--check` with almost no exceptions. | -| 2 | SAM_Excel, SAM_LadybugTools, SAM_Multitasker, SAM_OpenStudio, SAM_Topologic, SAM_Solver | Small, but each carries real description/version problems to triage first. | -| 3 | SAM_Systems, SAM_Mollier, SAM_SolarCalculator, SAM_UI | Mid-size; description corrections only, no renames. | -| 4 | SAM_OCCT, SAM_Revit | Own their own contract tests already; align their tests with the shared vocabulary. | -| 5 | SAM_Tas_Grasshopper | 94 components and 370 deviations — the largest GH surface after SAM. Bulk description work. | -| 6 | SAM | 460 components, 1120 deviations. Baseline only; fixes are per-component, owner-prioritised. | +| 1 | SAM_GEM, SAM_Python, SAM_BHoM, SAM_gbXML, SAM_Origin, SAM_IFC, SAM_Acoustic | 1–3 components, all but one fully verified: a first manifest and a first green `--check` at almost no cost. SAM_Acoustic's single `partial` component must be resolved or explicitly accepted first. | +| 2 | SAM_LadybugTools, SAM_Solver, SAM_OpenStudio, SAM_Multitasker, SAM_Excel, SAM_SolarCalculator, SAM_Topologic | Small and fully verified; the work is generating and reviewing the baseline. | +| 3 | SAM_Mollier, SAM_UI, SAM_Systems | Mid-size; SAM_Systems carries one `partial` component. Description corrections only, no renames. | +| 4 | SAM_OCCT, SAM_Revit | Both fully verified and both already own contract tests; align the tests with the shared vocabulary. | +| 5 | SAM_Tas_Grasshopper | 94 components, all fully verified — the largest GH surface after SAM. | +| 6 | SAM | 460 components; 4 `partial` and **no baseline yet**. Baseline only; 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 @@ -692,31 +802,73 @@ these waves**, and does not authorise a rename anywhere. ## Appendix 5 — Enforcement architecture and CI feasibility -Three distinct things, deliberately not conflated: +Four distinct things, deliberately not conflated: -1. **Universal naming and description validation** — repository-independent: the naming rules, the - description-quality rules, the capital-letter rule, `NickName == Name`, the access rule. Lives once, - in `design/grasshopper-icons/tools/param_contract.py` in `SAM`. Reuse by `--repo`, not by copy. +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. -3. **Approved legacy naming exceptions** — the `namingExceptions` list inside that baseline, kept - honest by `--check` (a new deviation fails; a stale exception fails). + 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. A full scan of `SAM` (460 components, ~1000 files) - takes roughly 25 s; the whole active fleet is a few minutes. **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 real blockers are: (a) the scanner discriminator above; (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`); (c) a `--check` needs a committed baseline, so a CI - job can only be switched on per repository *after* that repository's baseline PR. +- 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) takes roughly 25 s; the whole active fleet is a few minutes. **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): 42 tests covering 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` - (`uses: actions/checkout@v4` of `SAM` at a pinned ref, then - `python /param_contract.py --repo . --check`). Do not put Rhino-hosted tests in CI and do not - add a fleet-wide job before the per-repository baselines exist. + (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 — Supported declaration forms + +The validator reads the forms the repositories actually use. Everything here has a regression test. +Anything outside this list is reported as `partial`/`unresolved` with a reason — never guessed. + +| Form | Example | +|---|---| +| Declarative property, `result.Add(new GH_SAMParam(…))` | `result.Add(new GH_SAMParam(new Param_Number() { … }, ParamVisibility.Binding));` | +| Target-typed construction with a declared variable type | `Param_Brep solids = new() { Name = "_solids", … };` | +| Object initializer with no constructor parentheses | `Param_Boolean flag = new Param_Boolean { Name = "_flag", … };` | +| Variable reuse and reassignment | `param_Number = new() { … };` then `param_Number.SetPersistentData(0.3);` | +| Declaration without an initializer, assigned later | `Param_String param_String;` … `param_String = new() { … };` | +| Collection expression / `AddRange` / array initializer | `List result = [ new GH_SAMParam(…) ];` | +| Verbatim identifiers | `Param_Boolean @boolean = …;` used later as `boolean` | +| Declarations inside a statement block | parameters declared inside `foreach` / `if` in the property body | +| Local parameter factory | `Number("_size_", "Size", 0.25)` with a `private static Param_Number Number(string name, string description, double defaultValue)` in the same class | +| Parameter-manager registration | `manager.AddParameter(new GooWidgetParam(), "_widget", "_widget", "W", GH_ParamAccess.item);` | +| Typed manager helpers | `manager.AddBooleanParameter("Successful", "Successful", "OK", GH_ParamAccess.item);` (and the 5-argument form with a default) | +| Optionality set on the object | `widget.Optional = true;` before the registration call | +| Inherited registration from an abstract base | a concrete component with no declarations of its own, whose base in the repository registers them | +| Generic base closed by the subclass | `: GH_SAMEnumComponent` → the output published as `"ApertureType"` from `typeof(T).Name` | +| Parameter type conventions | `Param_*` (built-in), `*Param` (SAM goo), and any other constructed type name (recorded as `external` and compared exactly) | + +**Deliberately not supported, and reported rather than guessed:** + +- a parameter name or nickname that is not a literal string (a loop variable, `string.Format`, + interpolation, `typeof(T).Name` in a base the subclass does not close); +- a parameter registered outside the declaration (`Params.RegisterInputParam(param)` with no + `GH_SAMParam` wrapper); +- a variable that cannot be resolved to exactly one construction, or a construction whose type cannot be + determined; +- a component whose base chain leaves the repository at an unknown type; +- a project whose `.csproj` cannot be parsed as XML. From c2067f9a98ede0643a1d7ac0b6159c104745947c Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 19:49:18 +0200 Subject: [PATCH 6/8] docs: record the review corrections for the GH component standard [skip ci] --- documentation/GH-Component-Standards-Q4.md | 360 ++++++++++++++------- 1 file changed, 236 insertions(+), 124 deletions(-) diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md index 9a952042c..744f84c63 100644 --- a/documentation/GH-Component-Standards-Q4.md +++ b/documentation/GH-Component-Standards-Q4.md @@ -1,158 +1,270 @@ -# SAM-BIM Grasshopper component engineering standard — Q4 (2026-10-15) +# SAM-BIM Grasshopper component engineering standard — Q4 (2026-10-15, corrected 2026-10-16) **Status:** implemented on `docs/gh-component-standards-q4`; **draft PR, not merged**; owner review -required. Documentation, tooling and agent instructions only. +required. Documentation, tooling, agent instructions and validator regression tests only. - Repository: `SAM` (shared). Canonical standard for every SAM-BIM repository. - PR: **SAM-BIM/SAM#204** → `sow/2026-Q4` (**draft**, not merged). - Base: `sow/2026-Q4` at `6b12e2a65817556693ba408674211610f4a20dda`. -- **Validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (standard, validator, AGENTS.md). - `d313c63f`, `77a25112` and the final record commit are record-only docs commits on top. The PR - number, final branch head and hosted check results are also stated in the live PR description (a - record cannot contain its own commit SHA). -- Builds on merged work, which it reuses rather than redesigns: - `SAM_Psychrometrics#11` (metadata pilot), `SAM#203` (shared rename-migration infrastructure, merged - `a1d605cf`), `SAM_Psychrometrics#12` (the validated single rename). Also referenced: Unified - Diagnostics Phases 1–3 (`SAM#201` merged `293eb645`, `SAM#202` merged `fca3d4d4`, and the OCCT phase - in `SAM_OCCT`). +- **Original validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (first submission). +- **Corrected content SHA: `5a6a617b7021ba155933910e3b0d3ed069d4ba28`** (this revision: validator, + regression tests, standard, `AGENTS.md`). `d313c63f`, `77a25112`, `a5461a6f` and this record are + record-only docs commits. The PR number, final branch head and hosted check results are also stated + in the live PR description. +- Builds on merged work, reused rather than redesigned: `SAM_Psychrometrics#11` (metadata pilot), + `SAM#203` (shared rename-migration infrastructure, merged `a1d605cf`), `SAM_Psychrometrics#12` (the + validated single rename), and Unified Diagnostics Phases 1–3. ## Scope -One canonical specification, the agent-instruction hook, the PR record, and one small reusable -validator. No Grasshopper component, GUID, parameter, ordering, access, optionality, visibility, -default, geometry algorithm or updater behaviour is modified. `PROJECT_PROGRESS.md` is not changed on -this branch; its closeout follows merge. No other repository is touched and no CI is added. - -## Work completed - -1. **`docs/GH_COMPONENT_STANDARDS.md`** — the canonical standard, sections A–J (naming; input naming - and the four distinct requiredness mechanisms; output naming; descriptions; Diagnostics; published - compatibility; parameter migration; versioning; parameter identity and serialization; validation and - acceptance), plus appendices for the evidence index, the measured fleet baseline, open decisions, the - repository adoption plan and the enforcement/CI architecture. Every rule carries a status - (`VALIDATED` / `ENFORCED` / `PROPOSED` / `OPEN` / `FROZEN LEGACY`) and a stable `GH-x#` id, so the - document never presents a proposal as an implemented fact. -2. **`design/grasshopper-icons/tools/param_contract.py`** — the canonical shared metadata validator. - Repository-agnostic: `--repo` / `--output` / `--check`, defaulting to this repository. Contains the - validated pilot's naming and description rules and its scanner, inlined. -3. **`AGENTS.md`** — a new step 6 in "Before starting work" and a short "Grasshopper components" section - that require reading the standard before changing a GH component, state the frozen facts, and give the - validation command. It also records that the standard is product documentation (kept on a released - promotion, unlike the internal agent files). -4. **This record.** +One canonical specification, the agent-instruction hook, one shared validator, its regression tests and +this record. No Grasshopper component, GUID, parameter, ordering, access, optionality, visibility, +default, geometry algorithm or updater behaviour is modified, and no other repository is touched. No CI +job is added. `PROJECT_PROGRESS.md` is not changed on this branch; its closeout follows merge. -## Decisions and assumptions +## Independent review of the first submission and how each finding was resolved + +The review returned REQUEST CHANGES with four Major and three Minor findings. All seven are resolved. + +### Major 1 — incomplete parameter extraction → **fixed; the reported case is now a test** + +The first extractor required every parameter to be added by `result.Add(new GH_SAMParam(`. The reported +`SAMAnalyticalCreatePanelsBySolids` extraction produced seven unnamed inputs and no outputs. It now +extracts **all seven inputs and all four outputs**, with name, type, optionality, access, visibility and +registered default (`test_reported_create_panels_by_solids_case`). + +Root cause and fix: a single-call-site regex was replaced by a statement-level walker +(`walk_statements`) plus a per-statement `new GH_SAMParam(...)` scan, so a parameter is recognised by +what it *is* (a `GH_SAMParam`) rather than by one surrounding call. The supported forms are listed in +the standard's Appendix 6 and each has a regression test: + +| Pattern | Before | After | +|---|---|---| +| target-typed `new()` with a declared variable type | unnamed | type and attributes resolved | +| variable reuse / reassignment with `SetPersistentData` | lost | resolved per assignment | +| object initializer without constructor parentheses (`new Param_Boolean { … }`) | not recognised | resolved | +| collection expression `[ … ]`, `AddRange`, array initializer | no outputs | resolved in order | +| verbatim identifier `@boolean` used later as `boolean` | unresolved | resolved | +| bare declaration then assignment (`Param_String p;` … `p = new() { … };`) | unresolved | resolved | +| declarations inside `foreach` / `if` blocks | invisible | walked | +| expression-bodied properties (`=> [ … ]`) | wrong body taken | resolved | +| local parameter factory (`Number("_size_", "Size", 0.25)`) | unresolved | resolved via the factory body | +| parameter-manager registration and typed helpers | partially | resolved, incl. the 5-argument default form | +| `v.Optional = true` before registration | unresolved | attributed | +| inherited registration from an abstract base in the repository | unresolved | resolved | +| generic base closed by the subclass (`GH_SAMEnumComponent`) | unresolved | resolved to `ApertureType` | + +The `Goo…Param`/`System.Guid`/`new ("…")` scanner variants were also fixed: a `System.Guid` GUID or a +target-typed `new("…")` previously made a real component invisible (this is what caused SAM to drop +from 460 to 444 components during development). + +### Major 2 — parameter type validation → **fixed; the type is in the contract** + +The first `diff` compared name, nickname, optional, access, visibility and persistent default but **not** +the runtime type, so `Param_Number` → `Param_String` passed. Now: + +- the type is a `FROZEN_FIELDS` member and is compared exactly + (`test_type_change_fails_the_compatibility_check`, and the Psychrometrics pilot baseline with one type + mutated → must fail); +- `Param_*` is `builtin`, `…Param` is `goo`, any other constructed type is `external` and is recorded in + the manifest's `externalParameterTypes` with its component + (`test_external_parameter_type_is_reported_not_unresolved`, using RhinoInside.Revit's `HostObject`); +- a type that cannot be identified makes the component `partial` and blocks the check + (`test_unresolved_extraction_blocks_the_check`). + +### Major 3 — discovery and classification → **fixed; ancestry plus reported exceptions** + +- Classification is by **source ancestry**, not by name: a class is a component when its base chain + reaches a known Grasshopper root through the repository's own classes, and a `…Param` class deriving + from `GH_Param`/`GH_PersistentParam` is a parameter. `NewComputeParam : GH_SAMComponent` is a + component; `GooThingParam` is a parameter and is listed with its ancestry reason + (`test_a_component_named_like_a_parameter_is_a_component`, + `test_a_parameter_class_is_not_a_component_and_is_reported`). +- A class whose chain leaves the repository at an **unknown** type is reported in + `unclassifiedClasses`, never silently excluded (`test_unknown_ancestry_is_reported_not_silently_excluded`). +- Ancestry resolves across the whole repository, including non-Grasshopper projects — this is what + resolved SAM_Revit's 30 Revit-derived components (base `RhinoInside.Revit.GH…` + `IGH_SAMComponent`). +- Project discovery follows **indirect** project references to a fixed point, and `.csproj` is parsed + with `ElementTree`, so single- or double-quoted attributes both work; `` is honoured; + an unparsable project is reported, not skipped silently + (`test_indirect_project_reference_discovers_the_project`, + `test_single_quoted_xml_attributes_are_supported`, `test_compile_removed_file_is_not_scanned`, + `test_unparsable_csproj_is_reported`). +- Test projects are not scanned, and the manifest records that they were excluded and why. + +### Major 4 — compatibility separated from new naming rules → **fixed** + +- `--check` compares published metadata against the **approved baseline** only. `--inventory` (the + default) performs no comparison and says so in its output ("NOT compatibility validation"). +- Legitimate `list`/`tree` access is part of the frozen contract and is no longer treated as a problem + (`test_list_and_tree_access_are_legitimate`). +- An existing nickname that differs from the name is valid: recorded as an advisory note, not a failure + (`test_existing_nickname_is_valid_and_only_advisory`). +- New-interface naming rules are a **separate** finding with a registered exception list: a *new* + deviation fails and a stale exception fails, but an existing registered deviation passes, so no + existing component is forced to change its published contract + (`test_new_naming_deviation_needs_an_approved_exception`, `test_stale_naming_exception_fails`). +- A code-side default is accepted without `SetPersistentData` and is only a naming-convention note + (`test_code_side_default_does_not_require_set_persistent_data`). +- Read-only verification against SAM_GEM (1), SAM_BHoM (3) and SAM_Python (3): all components fully + verified, no repository modified (`test_wave_one_repositories_are_fully_verified`). + +### Minor 1 — versioning and manifest updates → **fixed by classification and documentation** + +A version-only change now produces a `manifest` finding whose text says explicitly that an intended +component version bump is **not** a compatibility violation and that the manifest must be regenerated in +the same reviewed change (`test_version_only_change_is_a_manifest_refresh_not_a_compatibility_violation`). +The standard (GH-J0/GH-J1) documents the flow: the manifest diff is what a reviewer reads. -- **The standard lives in the tracked `SAM` repository** (`docs/`), not in the untracked `SAM-BIM` - parent directory and not in a per-repository copy. Other repositories point at it (Appendix 4 gives the - exact snippet and a stable GitHub URL), matching the existing agent-instruction rollout pattern. - `SAM/documentation/` is used for PR records; `SAM/docs/` is introduced for the durable product - standard, as explicitly requested. -- **One shared validator, not one per repository.** It is repository-parameterised by `--repo`; the - manifest (`param_contract.json`) is the only per-repository artifact. The `SAM_Psychrometrics` pilot - copy stays as the pilot's vendored copy; consolidating it is a recorded follow-up (Appendix 3). -- **The scanner is inlined** in the validator. `SAM/design/grasshopper-icons/tools/inventory.py` is the - older SAM-only generation with an incompatible API (`gh_projects`, `compile_removed`, `SKIP_DIRS` and - `long_path` do not exist there) and the pilot's copy is the generalised one. Inlining keeps the - validator a single portable artifact with no hidden dependency on whichever `inventory.py` happens to - sit beside it. This was discovered by running it, not assumed. -- **A parameter-class discriminator was added.** The scanner keys on `ComponentGuid`, which also matches - the `Goo…Param` classes (64 in `SAM`) and one non-abstract concrete base (`A_SAMAnalytical`). Without - the discriminator the SAM numbers are wrong by 64 objects. -- **The implemented Diagnostics input name is `diagnosticDetail_`, not `_diagnosticDetail_`.** It is - already published on `SAM.Analytical.CreatePanelsBySolids` / `CreateAperturesBySolids` and on the OCCT - set, and asserted by `SAM_OCCT/Testing/SAM.OCCT.GrasshopperTests/DiagnosticsContractTests.cs`. The - brief's `_diagnosticDetail_` is documented as the *proposal for new components* and the discrepancy is - recorded as an owner decision (GH-E3 / Appendix 3 note 3). Nothing was renamed. -- **The versioning policy is the implemented one, not an invented one.** `Query.GetObsoleteSeverity` - derives `Advisory`/`Standard`/`Breaking` from patch/minor/major; the observed Q4 practice is a patch - bump for description corrections, appended optional parameters and alias-migrated renames, and minor - or major reserved for genuine behavioural change. `ObsoleteSeverity` and `ParameterRenames` are - documented as orthogonal, and `MinCompatibleVersion` as declared-but-unread. -- **No rule was invented to fill a gap.** Where the source evidence is silent or ambiguous, the document - says so and marks it `OPEN`. - -## Files changed - -- `docs/GH_COMPONENT_STANDARDS.md` (new) -- `design/grasshopper-icons/tools/param_contract.py` (new) -- `AGENTS.md` (modified: step 6 and a "Grasshopper components" section) -- `documentation/GH-Component-Standards-Q4.md` (new, this record) +### Minor 2 — AGENTS.md asked for a check SAM cannot satisfy → **fixed** + +SAM has no committed baseline, so the old instruction would have failed. `AGENTS.md` now gives the +baseline-free audit (`--repo .`, optionally `--output `), states that a repository with an +approved manifest uses `--check`, and states that `partial`/`unresolved` is never a PASS. + +### Minor 3 — enforcement claims → **fixed** + +The standard no longer labels grammatical quality, SI-unit correctness or engineering semantics as +enforced. Section 0.1 is a new table that separates the static metadata check, the Rhino-hosted runtime +compatibility tests, and human engineering review; GH-D1/GH-D4/GH-D8, GH-A3/GH-A5 and GH-G7 were +re-labelled (`CHECKED` / `PROPOSED` / `PROCESS REQUIREMENT`). + +## Files changed (this revision) + +- `design/grasshopper-icons/tools/param_contract.py` — rewritten extraction, classification, comparison + and reporting; `--check` / inventory / `--strict-descriptions` / `--show-conventions` / `--json`. +- `design/grasshopper-icons/tools/tests/test_param_contract.py` — new, 42 stdlib `unittest` tests. +- `docs/GH_COMPONENT_STANDARDS.md` — sections 0.1/0.2, C, D, I, J and Appendices 2–6 corrected. +- `AGENTS.md` — baseline-free audit versus approved-manifest check, and the fail-closed rule. ## Validation -Machine-local (Windows, Git for Windows; all commands from the repository root). +Machine-local (Windows; Python 3.13.13 from the RhinoCode interpreter). All commands from the repository +root. | Check | Command | Result | |---|---|---| -| Whitespace / conflict markers | `git diff --check` | clean (exit 0) | -| Validator compiles | `python -m py_compile design/grasshopper-icons/tools/param_contract.py` | exit 0 | -| **Behaviour-preservation: the validator reproduces the merged pilot** | `python design/grasshopper-icons/tools/param_contract.py --repo ../SAM_Psychrometrics --check` | **`3 components checked (0 parameter classes skipped): OK`** (exit 0) | -| Validator default repo | `python design/grasshopper-icons/tools/param_contract.py --output ` | `460 components (64 parameter classes skipped)` | -| Fleet baseline scan (Appendix 2 of the standard) | `python design/grasshopper-icons/tools/param_contract.py --repo --output ` for each active GH repository | all exit 0; numbers in the standard | -| Encoding / line endings | byte inspection + `git ls-files --eol` | index LF for all three files; no BOM; no CR; `*.md` LF as required by `.gitattributes` | -| Markdown fences balanced | `docs/GH_COMPONENT_STANDARDS.md` | 6 fence markers (even) | -| File scope | `git status --short` before commit | only the three intended files | - -The `--check` line is the important one: the shared copy produces **exactly** the result the merged -`SAM_Psychrometrics` pilot tooling produces against its committed manifest, which is what shows the -extraction is behaviour-preserving rather than a rewrite. - -The Python used was the machine-local RhinoCode interpreter -(`%USERPROFILE%\.rhinocode\py313-rh9\python.exe`, Python 3.13.13). On this machine -`python`/`python3`/`py` on `PATH` are the Microsoft Store aliases and do not run; that must be -provisioned on another computer. +| Validator + tests compile | `python -m py_compile design/grasshopper-icons/tools/param_contract.py design/grasshopper-icons/tools/tests/test_param_contract.py` | exit 0 | +| **Regression suite** | `python design/grasshopper-icons/tools/tests/test_param_contract.py` | **42 tests, OK, exit 0** | +| **Positive: the merged pilot still passes, with full verification** | `python design/grasshopper-icons/tools/param_contract.py --repo ../SAM_Psychrometrics --check` | `3 components discovered, 3 fully verified, 0 partial, 0 unresolved; 3 compared against the approved baseline` → `OK compatibility`, exit 0 | +| **Negative: a type change is caught** | regression `test_type_change_fails_the_compatibility_check`, `test_psychrometrics_pilot_type_change_is_detected` | exit 1, finding names the type | +| SAM audit (baseline-free) | `python design/grasshopper-icons/tools/param_contract.py --repo . --output ` | 460 components: 456 verified, 4 partial, 0 unresolved; 64 parameter classes excluded; 0 unclassified | +| Representative fleet | same command per repository (Appendix 2 of the standard) | see below | +| Whitespace | `git diff --check` | clean | +| Encoding / EOL | `git ls-files --eol` (after commit) | index LF, no BOM | + +The `--check` line is the important one: the rewritten validator reproduces the merged pilot's committed +manifest exactly — the same 3 components, the same 6 inputs, the same 28 outputs, the same types, +optionality, access, visibility and persistent defaults — so the rewrite is behaviour-preserving for the +one repository that has an approved baseline. ### Hosted checks (GitHub) -The PR-event checks did not start, and the reason is worth recording: **the head commit of the branch -carried `[skip ci]`**, the repository's convention for a docs-only record commit, and GitHub suppresses -workflow runs when the head commit message contains that marker. The branch head was a `[skip ci]` -record commit at the moment the PR was opened, and again after each push. So the content commit -`7dd5a4a5` was never a PR head without the marker. +Dispatched at the corrected content head (the repository's own checks; the PR-event runs are suppressed +because the head commit carries `[skip ci]`, the docs-only convention): + +| Workflow | Result | +|---|---| +| Build (Windows) — `msbuild /t:Rebuild` Release | success | +| Test — `dotnet test SAM/SAM.Tests` Release | success, **3683 passed / 0 failed** | +| SPDX + Copyright header check | success (no `.cs` changed) | + +Run ids for the final head are in the live PR description. **Not run, and not claimed:** no +Rhino canvas session; no GitHub PR-event check (see above). -To obtain genuine hosted evidence for a documentation-and-tooling branch, the repository's own checks -were dispatched explicitly at the branch head (`workflow_dispatch`, which the three workflows support): +### Verified versus unresolved extraction coverage (2026-10-16) -| Workflow | Run | Result | +`verified` = the published contract was completely extracted. A `partial` component blocks a +compatibility PASS. + +| Repository | Components | Verified | Partial | Unresolved | Parameter classes excluded | +|---|---|---|---|---|---| +| SAM | 460 | 456 | 4 | 0 | 64 | +| SAM_Tas_Grasshopper | 94 | 94 | 0 | 0 | 10 | +| SAM_Revit | 55 | 55 | 0 | 0 | 1 | +| SAM_OCCT | 36 | 36 | 0 | 0 | 1 | +| SAM_UI | 31 | 31 | 0 | 0 | 1 | +| SAM_Systems | 30 | 29 | 1 | 0 | 8 | +| SAM_Mollier | 24 | 24 | 0 | 0 | 4 | +| SAM_Topologic | 22 | 22 | 0 | 0 | 0 | +| SAM_SolarCalculator | 21 | 21 | 0 | 0 | 3 | +| SAM_Excel | 8 | 8 | 0 | 0 | 0 | +| SAM_LadybugTools | 7 | 7 | 0 | 0 | 0 | +| SAM_Solver | 7 | 7 | 0 | 0 | 0 | +| SAM_OpenStudio | 6 | 6 | 0 | 0 | 0 | +| SAM_Multitasker | 5 | 5 | 0 | 0 | 4 | +| SAM_Acoustic | 3 | 2 | 1 | 0 | 1 | +| SAM_BHoM | 3 | 3 | 0 | 0 | 0 | +| SAM_IFC | 3 | 3 | 0 | 0 | 1 | +| SAM_Psychrometrics | 3 | 3 | 0 | 0 | 0 | +| SAM_Python | 3 | 3 | 0 | 0 | 3 | +| SAM_gbXML | 2 | 2 | 0 | 0 | 0 | +| SAM_Origin | 2 | 2 | 0 | 0 | 0 | +| SAM_GEM | 1 | 1 | 0 | 0 | 0 | + +The six `partial` components, with reasons (all in the manifest under `extraction.reasons`): + +- `SAMAnalyticalFilterByBoundaryType`, `SAMAnalyticalFilterByPanelType` — parameter names generated by + `foreach (… in Enum.GetValues(typeof(X)))`. +- `SAMCoreFilterByType`, `SAMCoreInspect`, `SAMAnalyticalSystemResults` — variable-parameter components + registered through the parameter manager, where the insertable set is generated in code. +- `SAMAcousticCreateBandValues` — the parameter name comes from a loop variable. + +**Only SAM_Psychrometrics has an approved baseline**, so it is the only repository for which `--check` +is meaningful (3 components compared). Everywhere else the numbers are an inventory, not compatibility +validation. + +### Comparison with the existing Psychrometrics pilot + +| Aspect | Pilot copy (`SAM_Psychrometrics`) | Canonical validator (this PR) | |---|---|---| -| Build (Windows) — `msbuild /t:Rebuild` Release | [38767969445](https://github.com/SAM-BIM/SAM/actions/runs/38767969445) | success | -| Test — `dotnet test SAM/SAM.Tests` Release | [38767974784](https://github.com/SAM-BIM/SAM/actions/runs/38767974784) | success, **3683 passed / 0 failed** (matches the documented Q4 baseline) | -| SPDX + Copyright header check | [38767979570](https://github.com/SAM-BIM/SAM/actions/runs/38767979570) | success (no `.cs` file changed) | +| Components / inputs / outputs | 3 / 6 / 28 | identical — the pilot's committed manifest is reproduced exactly | +| Types compared | recorded but **not** compared | compared (Major 2) | +| Extraction completeness | not modelled; a miss was silent | `verified`/`partial`/`unresolved`; blocks the check | +| Component classification | name heuristic | ancestry, with unknown ancestry reported | +| `access != item` | a failure | frozen contract value, not a defect | +| New naming deviation | fails | still fails, via an explicit conventions finding | +| Description quality | fails | advisory by default (`--strict-descriptions` reproduces the pilot's stricter gate) | +| Project discovery | name/API heuristic | direct **and** indirect references, ElementTree | -All three were dispatched at head `77a25112`; the later record-only commit does not change the tested -tree. This is stated as a dispatch, not as a PR-event check, and the distinction is deliberate. +The pilot copy stays as the pilot's vendored copy; promoting the canonical one into that repository is a +recorded follow-up (standard Appendix 3). + +## Decisions and assumptions -**Not run, and not claimed:** no GitHub PR-event check (see above); no Rhino canvas session. +- **Fail closed.** Incomplete extraction is reported and blocks a PASS; a truthful `partial` is preferred + to a false PASS. This is enforced by tests that must fail. +- **The scanner stays inlined** in the validator (the repository-local `inventory.py` copies have + diverged); consolidating them is a follow-up. +- **The scanner now accepts `System.Guid` and target-typed `new("…")` GUIDs** — a real component being + invisible is worse than a slightly wider regex. +- **Local parameter factories and generic type-argument substitution are supported**, because 21 + SAM_SolarCalculator components and 25 SAM enum components depend on them; both are bounded analyses + with tests, not a general C# parser. +- **Enumeration-loop names and manager-registered variable-parameter components are deliberately not + supported** and are reported instead (6 components fleet-wide). +- **The versioning policy is the implemented one** (GH-H2), and `ObsoleteSeverity` is orthogonal to + `ParameterRenames` (GH-H4). +- **`_diagnosticDetail_` vs `diagnosticDetail_`** and **`MinCompatibleVersion`** remain owner decisions. ## Unresolved issues, risks and blockers -- **Hosted CI has no metadata job.** None is added here (deliberately, per the task scope). Appendix 5 - records the feasibility evidence: pure standard-library Python, ~25 s for `SAM`, present on - `ubuntu-latest` / `windows-2022`; the blockers are the per-repository baselines and the - Rhino-dependency of the runtime tests. -- **The discriminator is a naming heuristic.** `Goo…Param` exclusion is by name plus base type; a - stronger base-chain resolution to `GH_Component` is a recorded follow-up. -- **Three scanner copies now exist** (this canonical one, the pilot's, and SAM's older `inventory.py`). - Deliberate and documented; consolidation is a follow-up and would touch the icon tooling, which is - out of scope here. -- **`_diagnosticDetail_` vs `diagnosticDetail_`** and **`MinCompatibleVersion`** need owner decisions - (Appendix 3). -- **The fleet baseline is a snapshot** produced by the validator at the content SHA above. Re-running it - later is expected to differ wherever a repository has moved; it is a reference, not a freeze. -- The standard's proposed rules (`PROPOSED`) do not yet have enforcement behind them beyond the - description-quality and naming checks; a reader must respect the status labels. +- Six `partial` components (above) cannot be certified until resolved or explicitly accepted. +- No repository except SAM_Psychrometrics has a baseline, so no other repository can be gated yet. +- The naming-exception list and the 972 naming deviations in SAM are inventory data, not defects to fix + in this PR. +- Rhino-hosted runtime tests remain local/headless; `SAM.Core.Grasshopper.Tests` is deliberately not in + `SAM.sln`. +- Three scanner copies still exist (canonical, pilot, SAM's older `inventory.py`). ## Exact next step -1. Push `docs/gh-component-standards-q4` and open the draft PR into `sow/2026-Q4` (PR number and final - head in the PR description). -2. Owner/architecture review of the standard, in particular the `OPEN` items: `_diagnosticDetail_` - versus `diagnosticDetail_`, and the `MinCompatibleVersion` question. -3. After an owner decision, the follow-ups, each its own bounded task: promote the pilot's validator copy - to the shared one; add a `--check` baseline for the first Wave-1 repository (Appendix 4); add the - per-repository `AGENTS.md` snippet there. -4. Merge only after owner approval and passing final-head checks; then the post-merge `[skip ci]` +1. Owner/architecture re-review of #204, in particular the six `partial` components and the two open + decisions (`_diagnosticDetail_`, `MinCompatibleVersion`). +2. After approval: the follow-ups, each its own bounded task — promote the canonical validator into + SAM_Psychrometrics, then add a first approved baseline for a Wave-1 repository (Appendix 4) with its + `AGENTS.md` snippet. +3. Merge only after owner approval and passing final-head checks; then the post-merge `[skip ci]` `PROJECT_PROGRESS.md` closeout on `sow/2026-Q4` and guarded branch cleanup. No other repository is started by this task, no naming exception is migrated, and the Diagnostics From 692f35f642db4838c29cda8e820101e2fc90401c Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 20:47:22 +0200 Subject: [PATCH 7/8] fix(gh): refuse uncertain component metadata contracts --- .../grasshopper-icons/tools/param_contract.py | 415 +++++++++++++++--- .../tools/tests/test_param_contract.py | 317 +++++++++++-- docs/GH_COMPONENT_STANDARDS.md | 180 ++++---- 3 files changed, 749 insertions(+), 163 deletions(-) diff --git a/design/grasshopper-icons/tools/param_contract.py b/design/grasshopper-icons/tools/param_contract.py index 592f68cfd..11cbeab43 100644 --- a/design/grasshopper-icons/tools/param_contract.py +++ b/design/grasshopper-icons/tools/param_contract.py @@ -41,11 +41,17 @@ ## Schema -Manifests written by this tool are `schemaVersion: 2`. A manifest without `schemaVersion` is the +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 @@ -66,12 +72,12 @@ import sys import xml.etree.ElementTree as ET -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 # --- C# scanner ------------------------------------------------------------------------------------- SKIP_DIRS = {"obj", "bin", ".git", "packages", "design", "build", ".vs", "TestResults"} -CLASS_RE = re.compile(r"^\s*(?:\[[^\]]*\]\s*)*((?:public|internal|private|protected|abstract|sealed|static|partial|\s)+)class\s+(\w+)(?:<[^>{]*>)?\s*(?::\s*([^{\n]+))?", re.M) +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]*") @@ -133,6 +139,7 @@ } 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( @@ -416,6 +423,81 @@ def simple_type(type_text): 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() @@ -462,22 +544,28 @@ def parse_new_expression(expression, declared_type=None): rest = expression[3:].lstrip() if rest.startswith("("): # target-typed `new (...)` / `new()` body, after = take_balanced(rest, 0) - type_name = simple_type(declared_type) + 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 = simple_type(match.group(1)) + 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, _ = take_balanced(tail, brace) + 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 @@ -511,11 +599,12 @@ def type_kind(type_name): """ if not type_name: return None - if re.fullmatch(r"Param_\w+", type_name): + short = simple_type(type_name) + if re.fullmatch(r"Param_\w+", short): return "builtin" - if re.fullmatch(r"\w*Param", type_name): + if re.fullmatch(r"\w*Param", short): return "goo" - if re.fullmatch(r"[A-Za-z_]\w*", type_name): + if re.fullmatch(r"[\w.<> ,]+", type_name): return "external" return None @@ -561,7 +650,7 @@ def parse_factories(body): if len(constructions) != 1: continue constructed = constructions[0] - type_name = simple_type(constructed.group(1)) + type_name = constructed.group(1) if type_name is None: continue if method_body[constructed.end() - 1] == "(": @@ -591,7 +680,7 @@ def parse_factories(body): if not formal: continue factories[match.group("name")] = { - "type": simple_type(type_name) or type_name, + "type": type_name, "formal": formal, "init": initializer, "default": default_expression.strip() if default_expression else None, @@ -754,16 +843,26 @@ def classify_classes(repo, publish_dirs, scan_dirs=None): if removed(os.path.relpath(path, project_dir)): continue source = read_text(path) - if "ComponentGuid" not in source and "abstract class" not in source: - continue code = BLOCK_COMMENT_RE.sub(" ", COMMENT_RE.sub(r"\1", source)) - matches = list(CLASS_RE.finditer(code)) + # 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): - body = code[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(code)] + 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, @@ -783,9 +882,12 @@ def classify_classes(repo, publish_dirs, scan_dirs=None): for candidate in candidates: if candidate["abstract"] or not candidate["publish"]: continue # abstract bases and non-Grasshopper projects are ancestry, not published components - if not candidate["guid"]: - continue # a document object without a component identity is not part of the contract 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": @@ -794,7 +896,20 @@ def classify_classes(repo, publish_dirs, scan_dirs=None): parameters.append(candidate) else: unclassified.append(candidate) - return components, parameters, unclassified, {candidate["class"]: candidate for candidate in candidates} + # 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): @@ -826,9 +941,10 @@ def ancestry(class_name, bases_by_class, seen=None): return seen seen.add(class_name) for base in bases_by_class.get(class_name, []): - seen.add(base) if base in bases_by_class: ancestry(base, bases_by_class, seen) + else: + seen.add(base) return seen @@ -837,10 +953,11 @@ def ancestry(class_name, bases_by_class, seen=None): 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): + 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 @@ -858,12 +975,20 @@ def reason(self, 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": type_name, - "typeKind": type_kind(type_name), + "type": resolved_type, + "typeKind": type_kind(resolved_type), "optional": optional, "access": access, "visibility": visibility, @@ -885,8 +1010,8 @@ def bind(self, bindings, statement, position): 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": simple_type(bare.group("type")), "init": {}, - "default": None, "defaultLiteral": None, "at": position} + 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: @@ -903,8 +1028,10 @@ def bind(self, bindings, statement, position): # 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} - elif rhs.startswith("["): + "defaultLiteral": None, "at": position, "records": []} + else: + bindings.pop(name, None) + else: bindings.pop(name, None) def param_from_binding(self, binding, position): @@ -979,11 +1106,12 @@ def default_text(self, binding): return binding["default"].strip() def check_type(self, type_name, position): - kind = type_kind(type_name) + 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(type_name) + self.external_types.add(identity) def factory_parameter(self, expression, position): """Resolve `Helper("name", "description")` through a local parameter factory, or None. @@ -999,6 +1127,8 @@ def factory_parameter(self, expression, position): 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)" @@ -1027,15 +1157,118 @@ def factory_parameter(self, expression, position): def declarative(self, side, property_body): params = [] bindings = {} - for statement in walk_statements(property_body): + # 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 - self.bind(bindings, statement, "%s/%s" % (side, self.position_of(property_body, statement))) + 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" @@ -1053,6 +1286,11 @@ def apply_persistent_data(self, bindings, statement): 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. @@ -1065,15 +1303,17 @@ def emit(self, statement, side, body, bindings): params = [] cursor = 0 while True: - index = statement.find("new GH_SAMParam(", cursor) - if index == -1: + match = WRAPPER_RE.search(statement, cursor) + if match is None: break - args, after = take_balanced(statement, index + len("new GH_SAMParam")) + 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) > 1 else None) + 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 @@ -1107,6 +1347,7 @@ def emit(self, statement, side, body, bindings): 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: @@ -1123,7 +1364,16 @@ def emit(self, statement, side, body, bindings): # -- 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: @@ -1174,6 +1424,8 @@ def manager_statement(self, statement, side, body): 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: @@ -1181,7 +1433,7 @@ def manager_statement(self, statement, side, body): 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: + 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)) @@ -1191,6 +1443,7 @@ def manager_statement(self, statement, side, body): 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: @@ -1282,6 +1535,11 @@ def extract(self, class_body, reason_prefix=None): 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): @@ -1368,23 +1626,37 @@ def generic_substitutions(subclass, base_name, base_candidate): 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"]) + extractor = Extractor(candidate["class"], candidate["body"], context=candidate.get("context")) inputs, outputs, registration = extractor.extract(candidate["body"]) - - if registration == "inherited": - for base in candidate["bases"]: - if base in base_lookup: - substitutions = generic_substitutions(candidate, base, base_lookup[base]) - if substitutions is None: - extractor.reason("base class %s is generic and is not closed with literal type arguments by %s" - % (base, candidate["class"])) - break - extractor = Extractor(candidate["class"], base_lookup[base]["body"], substitutions) - inputs, outputs, registration = extractor.extract(base_lookup[base]["body"], reason_prefix=None) - registration = "inherited:%s" % base - break - else: - registration = "none" + 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): @@ -1402,7 +1674,7 @@ def build_component(candidate, base_lookup, bases_by_class=None): extractor.reason("runtime type of %s '%s' is not determined" % (side, param.get("name"))) status = "verified" - if not inputs and not outputs and registration in ("inherited", "manager"): + if not inputs and not outputs and extractor.reasons: status = "unresolved" if extractor.reasons: status = "unresolved" if status == "unresolved" else "partial" @@ -1421,7 +1693,7 @@ def build_component(candidate, base_lookup, bases_by_class=None): 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\()", candidate["body"]) + 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] @@ -1435,6 +1707,18 @@ def component_metadata(candidate, component): "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 @@ -1495,6 +1779,7 @@ def scan(repo): "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 { @@ -1578,6 +1863,14 @@ def compare(baseline, current): 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 @@ -1615,12 +1908,14 @@ def compare(baseline, current): before.get(field), after.get(field))) if component["extraction"]["status"] != "verified": - findings["extraction"].append("%s: extraction is %s (%s)" - % (component["class"], component["extraction"]["status"], + 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 baseline_extraction is not None and baseline_extraction.get("status") != "verified": + 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"))) @@ -1655,6 +1950,7 @@ def summarise(model): "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")), @@ -1709,6 +2005,12 @@ def main(argv): 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.") @@ -1745,6 +2047,7 @@ def main(argv): 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 diff --git a/design/grasshopper-icons/tools/tests/test_param_contract.py b/design/grasshopper-icons/tools/tests/test_param_contract.py index 8c0a863d4..2b0eef9b1 100644 --- a/design/grasshopper-icons/tools/tests/test_param_contract.py +++ b/design/grasshopper-icons/tools/tests/test_param_contract.py @@ -108,6 +108,7 @@ def repo(self, sources, **kwargs): HEADER = """// SPDX-License-Identifier: LGPL-3.0-or-later using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; using SAM.Core.Grasshopper; namespace Demo @@ -119,6 +120,8 @@ def repo(self, sources, **kwargs): 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 { } } """ @@ -133,7 +136,8 @@ class ExtractionTests(Fixture): 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("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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_"], @@ -142,7 +146,7 @@ def test_reported_create_panels_by_solids_case(self): 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("GooPanelParam", parameter(entry, "outputs", "panels")["type"]) + 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 @@ -277,7 +281,7 @@ def test_manager_registration_and_typed_helper(self): """ entry = component(self.repo({"C.cs": component_source("C", body)}), "C") self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) - self.assertEqual("GooWidgetParam", parameter(entry, "inputs", "_widget")["type"]) + 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"]) @@ -315,7 +319,8 @@ def test_local_parameter_factory(self): } """ entry = component(self.repo({"C.cs": component_source("C", body)}), "C") - self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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"]) @@ -323,6 +328,7 @@ def test_local_parameter_factory(self): 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 @@ -338,6 +344,7 @@ def test_inherited_registration_from_abstract_base_in_the_repository(self): """ derived = """// SPDX-License-Identifier: LGPL-3.0-or-later using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; namespace Demo { public class DerivedComponent : BaseComponent @@ -349,13 +356,14 @@ def test_inherited_registration_from_abstract_base_in_the_repository(self): } """ entry = component(self.repo({"Base.cs": base, "Derived.cs": derived}), "DerivedComponent") - self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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 @@ -382,7 +390,7 @@ def test_generic_base_type_argument_substitution(self): } """ entry = component(self.repo({"Base.cs": base, "Colour.cs": derived}), "ColourComponent") - self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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): @@ -473,30 +481,36 @@ 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") - source = os.path.join(pilot, "design", "grasshopper-icons", "param_contract.json") - with open(source, encoding="utf-8") as stream: - baseline = json.load(stream) - component_entry = baseline["components"][0] - component_entry["inputs"][0]["type"] = "Param_String" - write(os.path.join(self.root, "design", "grasshopper-icons", "param_contract.json"), - json.dumps(baseline)) - # The copy has a mutated baseline but no source to compare against, so the tool must refuse - # rather than silently accept it. + # 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.assertNotEqual(0, code) - self.assertIn("components discovered", output) + 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_type_is_reported_not_unresolved(self): + 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("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) + 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(["HostObject"], pc.scan(repo)["externalParameterTypes"]) + 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.""" @@ -531,6 +545,7 @@ class DiscoveryTests(Fixture): 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 { @@ -548,6 +563,7 @@ def test_a_component_named_like_a_parameter_is_a_component(self): 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 @@ -647,11 +663,11 @@ def test_unparsable_csproj_is_reported(self): def test_sam_pilot_and_representative_sam_components(self): entry = component(SAM_REPO, "SAMAnalyticalCreateAperturesBySolids") - self.assertEqual("verified", entry["extraction"]["status"], entry["extraction"]["reasons"]) - self.assertEqual("GooApertureConstructionParam", + 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("verified", enum_entry["extraction"]["status"], enum_entry["extraction"]["reasons"]) + self.assertEqual("partial", enum_entry["extraction"]["status"]) self.assertEqual("ApertureType", enum_entry["outputs"][0]["name"]) @@ -820,8 +836,248 @@ def test_inventory_mode_does_not_claim_compatibility(self): # --- 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_are_fully_verified(self): + 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) @@ -830,8 +1086,17 @@ def test_wave_one_repositories_are_fully_verified(self): model = pc.scan(repo) summary = pc.summarise(model) self.assertEqual(count, summary["components"], name) - self.assertEqual(count, summary["verified"], name) - self.assertEqual(0, summary["unresolved"], 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") diff --git a/docs/GH_COMPONENT_STANDARDS.md b/docs/GH_COMPONENT_STANDARDS.md index 4ff176c23..d540fd8bd 100644 --- a/docs/GH_COMPONENT_STANDARDS.md +++ b/docs/GH_COMPONENT_STANDARDS.md @@ -1,8 +1,8 @@ # SAM-BIM Grasshopper Component Engineering Standard -**Status:** canonical. Established 2026-10-15 on `sow/2026-Q4`; corrected 2026-10-16 in response to -the independent review of PR #204 (parameter extraction, runtime type in the contract, ancestry-based -classification, and the separation of compatibility from naming conventions). +**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. @@ -32,8 +32,8 @@ 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. Only the first is automated, and only the second proves -backward compatibility: +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 | |---|---|---| @@ -256,10 +256,10 @@ written down for the person on the canvas. 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 (narrowly).** The validator fails on an empty - description, a description that does not start with a capital letter, a known typo (`Humidty`), and a - `NickName` that differs from `Name` — the last recorded as an advisory note, not a failure, because an - existing nickname is legitimate (GH-I3). +- **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. @@ -556,11 +556,16 @@ runtime contract asserts them at runtime in a real Rhino (`MetadataContractTests Two notes on the type, because it is the attribute most easily lost by an extractor: -- A type is compared **exactly**, as the name it is constructed from. No equivalence is inferred: an - unrecognised-but-identified type (`HostObject`) is still compared exactly and listed in the - manifest's `externalParameterTypes`. +- 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** @@ -606,7 +611,7 @@ inspection; where a check cannot be automated today, say so explicitly in the PR | 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`), or the baseline itself records an incomplete extraction | +| `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 | @@ -675,11 +680,11 @@ parameter whose type cannot be determined, and a baseline that is missing or its | 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` (42 regression tests); `SAM_Psychrometrics/design/grasshopper-icons/{tools/param_contract.py,tools/inventory.py,param_contract.json}` (pilot copy) | +| 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, revised by the review corrections) +## Appendix 2 — Measured coverage (2026-10-15, final correctness repair) Produced by the canonical validator: @@ -693,33 +698,41 @@ established; the reasons are in the manifest. A `partial` component **blocks a c | Repository | Components | Verified | Partial | Unresolved | Parameter classes excluded | |---|---|---|---|---|---| -| SAM | 460 | 456 | 4 | 0 | 64 | -| SAM_Tas_Grasshopper | 94 | 94 | 0 | 0 | 10 | -| SAM_Revit | 55 | 55 | 0 | 0 | 1 | -| SAM_OCCT | 36 | 36 | 0 | 0 | 1 | -| SAM_UI | 31 | 31 | 0 | 0 | 1 | -| SAM_Systems | 30 | 29 | 1 | 0 | 8 | -| SAM_Mollier | 24 | 24 | 0 | 0 | 4 | -| SAM_Topologic | 22 | 22 | 0 | 0 | 0 | -| SAM_SolarCalculator | 21 | 21 | 0 | 0 | 3 | -| SAM_Excel | 8 | 8 | 0 | 0 | 0 | -| SAM_LadybugTools | 7 | 7 | 0 | 0 | 0 | -| SAM_Solver | 7 | 7 | 0 | 0 | 0 | -| SAM_OpenStudio | 6 | 6 | 0 | 0 | 0 | -| SAM_Multitasker | 5 | 5 | 0 | 0 | 4 | -| SAM_Acoustic | 3 | 2 | 1 | 0 | 1 | -| SAM_BHoM | 3 | 3 | 0 | 0 | 0 | -| SAM_IFC | 3 | 3 | 0 | 0 | 1 | +| 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 | 3 | 0 | 0 | 3 | -| SAM_gbXML | 2 | 2 | 0 | 0 | 0 | -| SAM_Origin | 2 | 2 | 0 | 0 | 0 | -| SAM_GEM | 1 | 1 | 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 `partial` components and their -reasons are listed in Appendix 3. `parameter classes excluded` counts the `Goo…Param` classes, which are +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 @@ -732,10 +745,11 @@ reported in the manifest with the ancestry that excluded them. 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. - In the fleet measured here, `unclassifiedClasses` is 0 everywhere except before the fix, when - SAM_Revit's 30 Revit-derived components were unclassified (the fix: `IGH_SAMComponent`). -2. **Six components cannot be extracted completely, and that is reported, not hidden** (SAM 4, - SAM_Systems 1, SAM_Acoustic 1): + 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). @@ -744,7 +758,7 @@ reported in the manifest with the ancestry that excluded them. 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 for that repository until resolved or explicitly accepted. + 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 @@ -788,12 +802,12 @@ Order — small and low-risk first, and never mixing a description sweep with a | 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, all but one fully verified: a first manifest and a first green `--check` at almost no cost. SAM_Acoustic's single `partial` component must be resolved or explicitly accepted first. | -| 2 | SAM_LadybugTools, SAM_Solver, SAM_OpenStudio, SAM_Multitasker, SAM_Excel, SAM_SolarCalculator, SAM_Topologic | Small and fully verified; the work is generating and reviewing the baseline. | -| 3 | SAM_Mollier, SAM_UI, SAM_Systems | Mid-size; SAM_Systems carries one `partial` component. Description corrections only, no renames. | -| 4 | SAM_OCCT, SAM_Revit | Both fully verified and both already own contract tests; align the tests with the shared vocabulary. | -| 5 | SAM_Tas_Grasshopper | 94 components, all fully verified — the largest GH surface after SAM. | -| 6 | SAM | 460 components; 4 `partial` and **no baseline yet**. Baseline only; fixes are per-component, owner-prioritised. | +| 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 @@ -820,11 +834,11 @@ 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) takes roughly 25 s; the whole active fleet is a few minutes. **Python + 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): 42 tests covering the supported and the deliberately + `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. @@ -839,36 +853,40 @@ Feasibility, measured rather than assumed: This PR adds no CI job and no baseline for any repository other than the pilot already in place. -## Appendix 6 — Supported declaration forms +## Appendix 6 — Certification boundary and architecture decision -The validator reads the forms the repositories actually use. Everything here has a regression test. -Anything outside this list is reported as `partial`/`unresolved` with a reason — never guessed. +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. -| Form | Example | +| Certified source form | Required evidence | |---|---| -| Declarative property, `result.Add(new GH_SAMParam(…))` | `result.Add(new GH_SAMParam(new Param_Number() { … }, ParamVisibility.Binding));` | -| Target-typed construction with a declared variable type | `Param_Brep solids = new() { Name = "_solids", … };` | -| Object initializer with no constructor parentheses | `Param_Boolean flag = new Param_Boolean { Name = "_flag", … };` | -| Variable reuse and reassignment | `param_Number = new() { … };` then `param_Number.SetPersistentData(0.3);` | -| Declaration without an initializer, assigned later | `Param_String param_String;` … `param_String = new() { … };` | -| Collection expression / `AddRange` / array initializer | `List result = [ new GH_SAMParam(…) ];` | -| Verbatim identifiers | `Param_Boolean @boolean = …;` used later as `boolean` | -| Declarations inside a statement block | parameters declared inside `foreach` / `if` in the property body | -| Local parameter factory | `Number("_size_", "Size", 0.25)` with a `private static Param_Number Number(string name, string description, double defaultValue)` in the same class | -| Parameter-manager registration | `manager.AddParameter(new GooWidgetParam(), "_widget", "_widget", "W", GH_ParamAccess.item);` | -| Typed manager helpers | `manager.AddBooleanParameter("Successful", "Successful", "OK", GH_ParamAccess.item);` (and the 5-argument form with a default) | -| Optionality set on the object | `widget.Optional = true;` before the registration call | -| Inherited registration from an abstract base | a concrete component with no declarations of its own, whose base in the repository registers them | -| Generic base closed by the subclass | `: GH_SAMEnumComponent` → the output published as `"ApertureType"` from `typeof(T).Name` | -| Parameter type conventions | `Param_*` (built-in), `*Param` (SAM goo), and any other constructed type name (recorded as `external` and compared exactly) | - -**Deliberately not supported, and reported rather than guessed:** - -- a parameter name or nickname that is not a literal string (a loop variable, `string.Format`, - interpolation, `typeof(T).Name` in a base the subclass does not close); -- a parameter registered outside the declaration (`Params.RegisterInputParam(param)` with no - `GH_SAMParam` wrapper); -- a variable that cannot be resolved to exactly one construction, or a construction whose type cannot be - determined; -- a component whose base chain leaves the repository at an unknown type; -- a project whose `.csproj` cannot be parsed as XML. +| 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. From 4a8afbebb186ace81eb5d49aed32deb502120781 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Thu, 15 Oct 2026 20:47:56 +0200 Subject: [PATCH 8/8] docs: record strict validator acceptance and build blocker [skip ci] --- documentation/GH-Component-Standards-Q4.md | 434 ++++++++------------- 1 file changed, 170 insertions(+), 264 deletions(-) diff --git a/documentation/GH-Component-Standards-Q4.md b/documentation/GH-Component-Standards-Q4.md index 744f84c63..71532d193 100644 --- a/documentation/GH-Component-Standards-Q4.md +++ b/documentation/GH-Component-Standards-Q4.md @@ -1,271 +1,177 @@ -# SAM-BIM Grasshopper component engineering standard — Q4 (2026-10-15, corrected 2026-10-16) +# GH component standards — PR #204 correctness repair -**Status:** implemented on `docs/gh-component-standards-q4`; **draft PR, not merged**; owner review -required. Documentation, tooling, agent instructions and validator regression tests only. +**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`. -- Repository: `SAM` (shared). Canonical standard for every SAM-BIM repository. -- PR: **SAM-BIM/SAM#204** → `sow/2026-Q4` (**draft**, not merged). -- Base: `sow/2026-Q4` at `6b12e2a65817556693ba408674211610f4a20dda`. -- **Original validated content SHA: `7dd5a4a5659a7d30d9721ebba58d31021fc5fc77`** (first submission). -- **Corrected content SHA: `5a6a617b7021ba155933910e3b0d3ed069d4ba28`** (this revision: validator, - regression tests, standard, `AGENTS.md`). `d313c63f`, `77a25112`, `a5461a6f` and this record are - record-only docs commits. The PR number, final branch head and hosted check results are also stated - in the live PR description. -- Builds on merged work, reused rather than redesigned: `SAM_Psychrometrics#11` (metadata pilot), - `SAM#203` (shared rename-migration infrastructure, merged `a1d605cf`), `SAM_Psychrometrics#12` (the - validated single rename), and Unified Diagnostics Phases 1–3. +- 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`. -## Scope +## Reproduced before changing code -One canonical specification, the agent-instruction hook, one shared validator, its regression tests and -this record. No Grasshopper component, GUID, parameter, ordering, access, optionality, visibility, -default, geometry algorithm or updater behaviour is modified, and no other repository is touched. No CI -job is added. `PROJECT_PROGRESS.md` is not changed on this branch; its closeout follows merge. +Sol's machine-local independent script was accessible and rerun at c2067f9a. Twenty cases returned +**10 unexpected compatibility acceptances**, harness exit **1**. In particular: -## Independent review of the first submission and how each finding was resolved - -The review returned REQUEST CHANGES with four Major and three Minor findings. All seven are resolved. - -### Major 1 — incomplete parameter extraction → **fixed; the reported case is now a test** - -The first extractor required every parameter to be added by `result.Add(new GH_SAMParam(`. The reported -`SAMAnalyticalCreatePanelsBySolids` extraction produced seven unnamed inputs and no outputs. It now -extracts **all seven inputs and all four outputs**, with name, type, optionality, access, visibility and -registered default (`test_reported_create_panels_by_solids_case`). - -Root cause and fix: a single-call-site regex was replaced by a statement-level walker -(`walk_statements`) plus a per-statement `new GH_SAMParam(...)` scan, so a parameter is recognised by -what it *is* (a `GH_SAMParam`) rather than by one surrounding call. The supported forms are listed in -the standard's Appendix 6 and each has a regression test: - -| Pattern | Before | After | -|---|---|---| -| target-typed `new()` with a declared variable type | unnamed | type and attributes resolved | -| variable reuse / reassignment with `SetPersistentData` | lost | resolved per assignment | -| object initializer without constructor parentheses (`new Param_Boolean { … }`) | not recognised | resolved | -| collection expression `[ … ]`, `AddRange`, array initializer | no outputs | resolved in order | -| verbatim identifier `@boolean` used later as `boolean` | unresolved | resolved | -| bare declaration then assignment (`Param_String p;` … `p = new() { … };`) | unresolved | resolved | -| declarations inside `foreach` / `if` blocks | invisible | walked | -| expression-bodied properties (`=> [ … ]`) | wrong body taken | resolved | -| local parameter factory (`Number("_size_", "Size", 0.25)`) | unresolved | resolved via the factory body | -| parameter-manager registration and typed helpers | partially | resolved, incl. the 5-argument default form | -| `v.Optional = true` before registration | unresolved | attributed | -| inherited registration from an abstract base in the repository | unresolved | resolved | -| generic base closed by the subclass (`GH_SAMEnumComponent`) | unresolved | resolved to `ApertureType` | - -The `Goo…Param`/`System.Guid`/`new ("…")` scanner variants were also fixed: a `System.Guid` GUID or a -target-typed `new("…")` previously made a real component invisible (this is what caused SAM to drop -from 460 to 444 components during development). - -### Major 2 — parameter type validation → **fixed; the type is in the contract** - -The first `diff` compared name, nickname, optional, access, visibility and persistent default but **not** -the runtime type, so `Param_Number` → `Param_String` passed. Now: - -- the type is a `FROZEN_FIELDS` member and is compared exactly - (`test_type_change_fails_the_compatibility_check`, and the Psychrometrics pilot baseline with one type - mutated → must fail); -- `Param_*` is `builtin`, `…Param` is `goo`, any other constructed type is `external` and is recorded in - the manifest's `externalParameterTypes` with its component - (`test_external_parameter_type_is_reported_not_unresolved`, using RhinoInside.Revit's `HostObject`); -- a type that cannot be identified makes the component `partial` and blocks the check - (`test_unresolved_extraction_blocks_the_check`). - -### Major 3 — discovery and classification → **fixed; ancestry plus reported exceptions** - -- Classification is by **source ancestry**, not by name: a class is a component when its base chain - reaches a known Grasshopper root through the repository's own classes, and a `…Param` class deriving - from `GH_Param`/`GH_PersistentParam` is a parameter. `NewComputeParam : GH_SAMComponent` is a - component; `GooThingParam` is a parameter and is listed with its ancestry reason - (`test_a_component_named_like_a_parameter_is_a_component`, - `test_a_parameter_class_is_not_a_component_and_is_reported`). -- A class whose chain leaves the repository at an **unknown** type is reported in - `unclassifiedClasses`, never silently excluded (`test_unknown_ancestry_is_reported_not_silently_excluded`). -- Ancestry resolves across the whole repository, including non-Grasshopper projects — this is what - resolved SAM_Revit's 30 Revit-derived components (base `RhinoInside.Revit.GH…` + `IGH_SAMComponent`). -- Project discovery follows **indirect** project references to a fixed point, and `.csproj` is parsed - with `ElementTree`, so single- or double-quoted attributes both work; `` is honoured; - an unparsable project is reported, not skipped silently - (`test_indirect_project_reference_discovers_the_project`, - `test_single_quoted_xml_attributes_are_supported`, `test_compile_removed_file_is_not_scanned`, - `test_unparsable_csproj_is_reported`). -- Test projects are not scanned, and the manifest records that they were excluded and why. - -### Major 4 — compatibility separated from new naming rules → **fixed** - -- `--check` compares published metadata against the **approved baseline** only. `--inventory` (the - default) performs no comparison and says so in its output ("NOT compatibility validation"). -- Legitimate `list`/`tree` access is part of the frozen contract and is no longer treated as a problem - (`test_list_and_tree_access_are_legitimate`). -- An existing nickname that differs from the name is valid: recorded as an advisory note, not a failure - (`test_existing_nickname_is_valid_and_only_advisory`). -- New-interface naming rules are a **separate** finding with a registered exception list: a *new* - deviation fails and a stale exception fails, but an existing registered deviation passes, so no - existing component is forced to change its published contract - (`test_new_naming_deviation_needs_an_approved_exception`, `test_stale_naming_exception_fails`). -- A code-side default is accepted without `SetPersistentData` and is only a naming-convention note - (`test_code_side_default_does_not_require_set_persistent_data`). -- Read-only verification against SAM_GEM (1), SAM_BHoM (3) and SAM_Python (3): all components fully - verified, no repository modified (`test_wave_one_repositories_are_fully_verified`). - -### Minor 1 — versioning and manifest updates → **fixed by classification and documentation** - -A version-only change now produces a `manifest` finding whose text says explicitly that an intended -component version bump is **not** a compatibility violation and that the manifest must be regenerated in -the same reviewed change (`test_version_only_change_is_a_manifest_refresh_not_a_compatibility_violation`). -The standard (GH-J0/GH-J1) documents the flow: the manifest diff is what a reviewer reads. - -### Minor 2 — AGENTS.md asked for a check SAM cannot satisfy → **fixed** - -SAM has no committed baseline, so the old instruction would have failed. `AGENTS.md` now gives the -baseline-free audit (`--repo .`, optionally `--output `), states that a repository with an -approved manifest uses `--check`, and states that `partial`/`unresolved` is never a PASS. - -### Minor 3 — enforcement claims → **fixed** - -The standard no longer labels grammatical quality, SI-unit correctness or engineering semantics as -enforced. Section 0.1 is a new table that separates the static metadata check, the Rhino-hosted runtime -compatibility tests, and human engineering review; GH-D1/GH-D4/GH-D8, GH-A3/GH-A5 and GH-G7 were -re-labelled (`CHECKED` / `PROPOSED` / `PROCESS REQUIREMENT`). - -## Files changed (this revision) - -- `design/grasshopper-icons/tools/param_contract.py` — rewritten extraction, classification, comparison - and reporting; `--check` / inventory / `--strict-descriptions` / `--show-conventions` / `--json`. -- `design/grasshopper-icons/tools/tests/test_param_contract.py` — new, 42 stdlib `unittest` tests. -- `docs/GH_COMPONENT_STANDARDS.md` — sections 0.1/0.2, C, D, I, J and Appendices 2–6 corrected. -- `AGENTS.md` — baseline-free audit versus approved-manifest check, and the fail-closed rule. - -## Validation - -Machine-local (Windows; Python 3.13.13 from the RhinoCode interpreter). All commands from the repository -root. - -| Check | Command | Result | +| Defect | Expected before repair | Observed before repair | |---|---|---| -| Validator + tests compile | `python -m py_compile design/grasshopper-icons/tools/param_contract.py design/grasshopper-icons/tools/tests/test_param_contract.py` | exit 0 | -| **Regression suite** | `python design/grasshopper-icons/tools/tests/test_param_contract.py` | **42 tests, OK, exit 0** | -| **Positive: the merged pilot still passes, with full verification** | `python design/grasshopper-icons/tools/param_contract.py --repo ../SAM_Psychrometrics --check` | `3 components discovered, 3 fully verified, 0 partial, 0 unresolved; 3 compared against the approved baseline` → `OK compatibility`, exit 0 | -| **Negative: a type change is caught** | regression `test_type_change_fails_the_compatibility_check`, `test_psychrometrics_pilot_type_change_is_detected` | exit 1, finding names the type | -| SAM audit (baseline-free) | `python design/grasshopper-icons/tools/param_contract.py --repo . --output ` | 460 components: 456 verified, 4 partial, 0 unresolved; 64 parameter classes excluded; 0 unclassified | -| Representative fleet | same command per repository (Appendix 2 of the standard) | see below | -| Whitespace | `git diff --check` | clean | -| Encoding / EOL | `git ls-files --eol` (after commit) | index LF, no BOM | - -The `--check` line is the important one: the rewritten validator reproduces the merged pilot's committed -manifest exactly — the same 3 components, the same 6 inputs, the same 28 outputs, the same types, -optionality, access, visibility and persistent defaults — so the rewrite is behaviour-preserving for the -one repository that has an approved baseline. - -### Hosted checks (GitHub) - -Dispatched at the corrected content head (the repository's own checks; the PR-event runs are suppressed -because the head commit carries `[skip ci]`, the docs-only convention): - -| Workflow | Result | +| 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 | |---|---| -| Build (Windows) — `msbuild /t:Rebuild` Release | success | -| Test — `dotnet test SAM/SAM.Tests` Release | success, **3683 passed / 0 failed** | -| SPDX + Copyright header check | success (no `.cs` changed) | - -Run ids for the final head are in the live PR description. **Not run, and not claimed:** no -Rhino canvas session; no GitHub PR-event check (see above). - -### Verified versus unresolved extraction coverage (2026-10-16) - -`verified` = the published contract was completely extracted. A `partial` component blocks a -compatibility PASS. - -| Repository | Components | Verified | Partial | Unresolved | Parameter classes excluded | -|---|---|---|---|---|---| -| SAM | 460 | 456 | 4 | 0 | 64 | -| SAM_Tas_Grasshopper | 94 | 94 | 0 | 0 | 10 | -| SAM_Revit | 55 | 55 | 0 | 0 | 1 | -| SAM_OCCT | 36 | 36 | 0 | 0 | 1 | -| SAM_UI | 31 | 31 | 0 | 0 | 1 | -| SAM_Systems | 30 | 29 | 1 | 0 | 8 | -| SAM_Mollier | 24 | 24 | 0 | 0 | 4 | -| SAM_Topologic | 22 | 22 | 0 | 0 | 0 | -| SAM_SolarCalculator | 21 | 21 | 0 | 0 | 3 | -| SAM_Excel | 8 | 8 | 0 | 0 | 0 | -| SAM_LadybugTools | 7 | 7 | 0 | 0 | 0 | -| SAM_Solver | 7 | 7 | 0 | 0 | 0 | -| SAM_OpenStudio | 6 | 6 | 0 | 0 | 0 | -| SAM_Multitasker | 5 | 5 | 0 | 0 | 4 | -| SAM_Acoustic | 3 | 2 | 1 | 0 | 1 | -| SAM_BHoM | 3 | 3 | 0 | 0 | 0 | -| SAM_IFC | 3 | 3 | 0 | 0 | 1 | -| SAM_Psychrometrics | 3 | 3 | 0 | 0 | 0 | -| SAM_Python | 3 | 3 | 0 | 0 | 3 | -| SAM_gbXML | 2 | 2 | 0 | 0 | 0 | -| SAM_Origin | 2 | 2 | 0 | 0 | 0 | -| SAM_GEM | 1 | 1 | 0 | 0 | 0 | - -The six `partial` components, with reasons (all in the manifest under `extraction.reasons`): - -- `SAMAnalyticalFilterByBoundaryType`, `SAMAnalyticalFilterByPanelType` — parameter names generated by - `foreach (… in Enum.GetValues(typeof(X)))`. -- `SAMCoreFilterByType`, `SAMCoreInspect`, `SAMAnalyticalSystemResults` — variable-parameter components - registered through the parameter manager, where the insertable set is generated in code. -- `SAMAcousticCreateBandValues` — the parameter name comes from a loop variable. - -**Only SAM_Psychrometrics has an approved baseline**, so it is the only repository for which `--check` -is meaningful (3 components compared). Everywhere else the numbers are an inventory, not compatibility -validation. - -### Comparison with the existing Psychrometrics pilot - -| Aspect | Pilot copy (`SAM_Psychrometrics`) | Canonical validator (this PR) | -|---|---|---| -| Components / inputs / outputs | 3 / 6 / 28 | identical — the pilot's committed manifest is reproduced exactly | -| Types compared | recorded but **not** compared | compared (Major 2) | -| Extraction completeness | not modelled; a miss was silent | `verified`/`partial`/`unresolved`; blocks the check | -| Component classification | name heuristic | ancestry, with unknown ancestry reported | -| `access != item` | a failure | frozen contract value, not a defect | -| New naming deviation | fails | still fails, via an explicit conventions finding | -| Description quality | fails | advisory by default (`--strict-descriptions` reproduces the pilot's stricter gate) | -| Project discovery | name/API heuristic | direct **and** indirect references, ElementTree | - -The pilot copy stays as the pilot's vendored copy; promoting the canonical one into that repository is a -recorded follow-up (standard Appendix 3). - -## Decisions and assumptions - -- **Fail closed.** Incomplete extraction is reported and blocks a PASS; a truthful `partial` is preferred - to a false PASS. This is enforced by tests that must fail. -- **The scanner stays inlined** in the validator (the repository-local `inventory.py` copies have - diverged); consolidating them is a follow-up. -- **The scanner now accepts `System.Guid` and target-typed `new("…")` GUIDs** — a real component being - invisible is worse than a slightly wider regex. -- **Local parameter factories and generic type-argument substitution are supported**, because 21 - SAM_SolarCalculator components and 25 SAM enum components depend on them; both are bounded analyses - with tests, not a general C# parser. -- **Enumeration-loop names and manager-registered variable-parameter components are deliberately not - supported** and are reported instead (6 components fleet-wide). -- **The versioning policy is the implemented one** (GH-H2), and `ObsoleteSeverity` is orthogonal to - `ParameterRenames` (GH-H4). -- **`_diagnosticDetail_` vs `diagnosticDetail_`** and **`MinCompatibleVersion`** remain owner decisions. - -## Unresolved issues, risks and blockers - -- Six `partial` components (above) cannot be certified until resolved or explicitly accepted. -- No repository except SAM_Psychrometrics has a baseline, so no other repository can be gated yet. -- The naming-exception list and the 972 naming deviations in SAM are inventory data, not defects to fix - in this PR. -- Rhino-hosted runtime tests remain local/headless; `SAM.Core.Grasshopper.Tests` is deliberately not in - `SAM.sln`. -- Three scanner copies still exist (canonical, pilot, SAM's older `inventory.py`). - -## Exact next step - -1. Owner/architecture re-review of #204, in particular the six `partial` components and the two open - decisions (`_diagnosticDetail_`, `MinCompatibleVersion`). -2. After approval: the follow-ups, each its own bounded task — promote the canonical validator into - SAM_Psychrometrics, then add a first approved baseline for a Wave-1 repository (Appendix 4) with its - `AGENTS.md` snippet. -3. Merge only after owner approval and passing final-head checks; then the post-merge `[skip ci]` - `PROJECT_PROGRESS.md` closeout on `sow/2026-Q4` and guarded branch cleanup. - -No other repository is started by this task, no naming exception is migrated, and the Diagnostics -redesign is not implemented. +| 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.