From 3fb1367d64eca81762d4ead4d949fdbaf3a4a55e Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 24 Jul 2026 10:07:04 +0000 Subject: [PATCH 1/2] feat(needs): needs.json filter wrappers + requirement/architecture checklists Add to docs.bzl: - filtered_needs_json and the convenience wrappers component_requirements, feature_requirements, assumptions_of_use, feature_architecture, component_architecture (extract a typed/named subset of a needs.json). - requirements_checklist and architecture_checklist (SHA256 inspection-record validation over the transitive sphinx-needs link graph). The filter wrappers support: - strict: fail the build (no output) on any dangling link reference. - extra_needs: additional needs.json inputs that count as resolvable link targets (e.g. upstream repositories). - link_fields: restrict which link fields are checked (default: all, auto-detected via the sphinx-needs '_back' convention). Add supporting py_binary tools scripts_bazel/filter_needs_json.py and scripts_bazel/validate_checklist.py. --- docs.bzl | 478 ++++++++++++++++++++++++++++ scripts_bazel/BUILD | 16 + scripts_bazel/filter_needs_json.py | 315 ++++++++++++++++++ scripts_bazel/validate_checklist.py | 348 ++++++++++++++++++++ 4 files changed, 1157 insertions(+) create mode 100644 scripts_bazel/filter_needs_json.py create mode 100644 scripts_bazel/validate_checklist.py diff --git a/docs.bzl b/docs.bzl index d89a1ac4c..2f1922dfb 100644 --- a/docs.bzl +++ b/docs.bzl @@ -361,3 +361,481 @@ def _sourcelinks_json(name, srcs): tools = [generate_sourcelinks_tool], visibility = ["//visibility:public"], ) + +def filtered_needs_json( + name, + src, + types = [], + names = [], + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract a subset of sphinx-needs elements from a needs.json file. + + Produces a `.json` file containing only the needs that match all of + the given filters. This is useful to hand a downstream consumer just the + elements (e.g. `feat_req`) of one or more particular features/components. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output (a directory containing + `needs.json`), e.g. `":needs_json"` or `"@score_process//:needs_json"`. + types: Optional list of sphinx-needs element types to keep + (e.g. `["feat_req", "comp_req"]`). If empty, all types are kept. + names: Optional list of feature/component names to keep, matched against + the second `__`-separated segment of each need ID (the + `____...` naming convention). If empty, all + features/components are kept. + strict: When True the build fails (and no `.json` is written) if any + kept need has a dangling link, i.e. a link target that is present + neither in `src` nor in any `extra_needs` input. Off by default. + extra_needs: Optional list of additional `needs_json` build outputs whose + needs count as resolvable link targets in `strict` mode (e.g. + requirements imported from an upstream repository). Typically the same + upstream `needs_json` targets passed as `data` to `docs(...)` (e.g. + `["@score_platform//:needs_json"]`). + link_fields: Optional list of link fields checked for dangling references + in `strict` mode (e.g. `["derived_from", "satisfies"]`). If empty, + every link field is checked (auto-detected from the data). + visibility: Standard Bazel visibility for the generated target. + """ + filter_tool = Label("//scripts_bazel:filter_needs_json") + + type_args = " ".join(["--type '%s'" % t for t in types]) + name_args = " ".join(["--name '%s'" % n for n in names]) + strict_arg = "--strict" if strict else "" + link_args = " ".join(["--link-field '%s'" % f for f in link_fields]) + extra_args = " ".join(["--extra-needs-json $(location %s)" % e for e in extra_needs]) + + native.genrule( + name = name, + srcs = [src] + extra_needs, + outs = [name + ".json"], + cmd = """ + SRC="$(location {src})" + if [ -d "$$SRC" ]; then SRC="$$SRC/needs.json"; fi + $(location {filter_tool}) \ + --output $@ \ + {type_args} \ + {name_args} \ + {strict_arg} \ + {link_args} \ + {extra_args} \ + "$$SRC" + """.format( + filter_tool = filter_tool, + type_args = type_args, + name_args = name_args, + strict_arg = strict_arg, + link_args = link_args, + extra_args = extra_args, + src = src, + ), + tools = [filter_tool], + visibility = visibility, + ) + +def component_requirements( + name, + src = "//:needs_json", + component = None, + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract the component requirements (`comp_req`) from a needs.json file. + + Convenience wrapper around `filtered_needs_json`. Produces a `.json` + file containing only `comp_req` elements. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output. Defaults to the calling + package's `//:needs_json`. + component: Optional component name. If given, only component requirements + named with that component (per the `____...` + convention) are kept; if omitted, all component requirements are + kept. + strict: When True the build fails (no output) on any dangling link in the + kept needs. See `filtered_needs_json`. + extra_needs: Additional `needs_json` build outputs whose needs count as + resolvable link targets in `strict` mode. See `filtered_needs_json`. + link_fields: Link fields checked for dangling references in `strict` + mode. If empty, every link field is checked. + visibility: Standard Bazel visibility for the generated target. + """ + filtered_needs_json( + name = name, + src = src, + types = ["comp_req"], + names = [component] if component else [], + strict = strict, + extra_needs = extra_needs, + link_fields = link_fields, + visibility = visibility, + ) + +def feature_requirements( + name, + src = "//:needs_json", + feature = None, + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract the feature requirements (`feat_req`) from a needs.json file. + + Convenience wrapper around `filtered_needs_json`. Produces a `.json` + file containing only `feat_req` elements. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output. Defaults to the calling + package's `//:needs_json`. + feature: Optional feature name. If given, only feature requirements + named with that feature (per the `____...` convention) + are kept; if omitted, all feature requirements are kept. + strict: When True the build fails (no output) on any dangling link in the + kept needs. See `filtered_needs_json`. + extra_needs: Additional `needs_json` build outputs whose needs count as + resolvable link targets in `strict` mode. See `filtered_needs_json`. + link_fields: Link fields checked for dangling references in `strict` + mode. If empty, every link field is checked. + visibility: Standard Bazel visibility for the generated target. + """ + filtered_needs_json( + name = name, + src = src, + types = ["feat_req"], + names = [feature] if feature else [], + strict = strict, + extra_needs = extra_needs, + link_fields = link_fields, + visibility = visibility, + ) + +def assumptions_of_use( + name, + src = "//:needs_json", + component = None, + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract the assumptions of use (`aou_req`) from a needs.json file. + + Convenience wrapper around `filtered_needs_json`. Produces a `.json` + file containing only `aou_req` elements. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output. Defaults to the calling + package's `//:needs_json`. + component: Optional component name. If given, only assumptions of use + named with that component (per the `____...` + convention) are kept; if omitted, all assumptions of use are kept. + strict: When True the build fails (no output) on any dangling link in the + kept needs. See `filtered_needs_json`. + extra_needs: Additional `needs_json` build outputs whose needs count as + resolvable link targets in `strict` mode. See `filtered_needs_json`. + link_fields: Link fields checked for dangling references in `strict` + mode. If empty, every link field is checked. + visibility: Standard Bazel visibility for the generated target. + """ + filtered_needs_json( + name = name, + src = src, + types = ["aou_req"], + names = [component] if component else [], + strict = strict, + extra_needs = extra_needs, + link_fields = link_fields, + visibility = visibility, + ) + +def feature_architecture( + name, + src = "//:needs_json", + feature = None, + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract the feature architecture from a needs.json file. + + Convenience wrapper around `filtered_needs_json`. Produces a `.json` + file containing the feature architecture elements. Static (`feat_arc_sta`) + and dynamic (`feat_arc_dyn`) architecture are not differentiated; both are + kept. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output. Defaults to the calling + package's `//:needs_json`. + feature: Optional feature name. If given, only feature architecture + elements named with that feature (per the `____...` + convention) are kept; if omitted, all feature architecture elements + are kept. + strict: When True the build fails (no output) on any dangling link in the + kept needs. See `filtered_needs_json`. + extra_needs: Additional `needs_json` build outputs whose needs count as + resolvable link targets in `strict` mode. See `filtered_needs_json`. + link_fields: Link fields checked for dangling references in `strict` + mode. If empty, every link field is checked. + visibility: Standard Bazel visibility for the generated target. + """ + filtered_needs_json( + name = name, + src = src, + types = ["feat_arc_sta", "feat_arc_dyn"], + names = [feature] if feature else [], + strict = strict, + extra_needs = extra_needs, + link_fields = link_fields, + visibility = visibility, + ) + +def component_architecture( + name, + src = "//:needs_json", + component = None, + strict = False, + extra_needs = [], + link_fields = [], + visibility = None): + """Extract the component architecture from a needs.json file. + + Convenience wrapper around `filtered_needs_json`. Produces a `.json` + file containing the component architecture elements. Static (`comp_arc_sta`) + and dynamic (`comp_arc_dyn`) architecture are not differentiated; both are + kept. + + Args: + name: Name of the generated target. The output file is `.json`. + src: Label of a `needs_json` build output. Defaults to the calling + package's `//:needs_json`. + component: Optional component name. If given, only component architecture + elements named with that component are kept; component architecture + IDs follow the `____` convention (e.g. + `comp_arc_sta__baselibs__bit_manipulation`) and any underscores in + the component segment are removed for matching (so `bit_manipulation` + matches the component name `bitmanipulation`). If omitted, all + component architecture elements are kept. + strict: When True the build fails (no output) on any dangling link in the + kept needs. See `filtered_needs_json`. + extra_needs: Additional `needs_json` build outputs whose needs count as + resolvable link targets in `strict` mode. See `filtered_needs_json`. + link_fields: Link fields checked for dangling references in `strict` + mode. If empty, every link field is checked. + visibility: Standard Bazel visibility for the generated target. + """ + filtered_needs_json( + name = name, + src = src, + types = ["comp_arc_sta", "comp_arc_dyn"], + names = [component] if component else [], + strict = strict, + extra_needs = extra_needs, + link_fields = link_fields, + visibility = visibility, + ) + +def requirements_checklist( + name, + deps, + mod_insp_id, + src = "//:needs_json", + link_fields = ["derived_from", "satisfies", "covers"], + extra_needs = [], + visibility = None): + """Validate a requirement inspection record against its build output. + + Building this target recomputes the SHA256 over the requirements in `deps` + **and**, by default, over everything they depend on transitively, and + compares it to the `sha256` attribute of the `mod_insp` inspection record + `mod_insp_id` (looked up in `src`'s `needs.json`). The build **fails** when + the hashes differ, i.e. when a validated requirement *or one of its + (recursive) dependencies* has changed since the inspection was last reviewed. + + The dependency graph is the sphinx-needs link graph: starting from the + requirements in `deps` (the *roots*), the `link_fields` (by default + `derived_from`, `satisfies` and `covers`) are followed recursively through + `src`'s `needs.json`. For feature requirements this means the linked + stakeholder requirements (and their parents in turn) are part of the hash, so + changing a relevant stakeholder requirement makes this checklist go out of + date. Pass `link_fields = []` to restore the old behaviour of hashing only + the requirements in `deps`. + + Typical usage validates the extracted requirements of a component against the + inspection record that reviewed them: + + component_requirements( + name = "bitmanipulation_comp_reqs", + component = "bitmanipulation", + ) + + requirements_checklist( + name = "bitmanipulation_req_checklist", + mod_insp_id = "mod_insp__bitmanipulation__comp_req", + deps = [":bitmanipulation_comp_reqs"], + ) + + Run with `bazel build //:bitmanipulation_req_checklist`. On the first run (or + after the requirements change) the build fails and prints the actual SHA256; + copy it into the `sha256` attribute of the inspection record once it has + been (re-)reviewed. + + Args: + name: Name of the generated target. The output file is `.sha256`. + deps: List of labels whose outputs define the root requirements that are + hashed and validated. Usually a single + `component_requirements`/`feature_requirements`/`filtered_needs_json` + target. + mod_insp_id: Id of the `mod_insp` inspection record to validate + (e.g. `"mod_insp__bitmanipulation__comp_req"`). + src: Label of a `needs_json` build output containing the inspection record + and the full link graph. Defaults to the calling package's + `//:needs_json`. + link_fields: Sphinx-needs link fields followed recursively from the root + requirements to include their (transitive) dependencies in the hash. + Defaults to `["derived_from", "satisfies", "covers"]`. Set to `[]` to + hash only the requirements in `deps`. + extra_needs: Optional list of additional `needs_json` build outputs that + provide the full content of needs referenced from the validated + requirements but not contained in `src` (e.g. stakeholder + requirements imported from an upstream repository). Without them such + external needs are hashed as `` and changes to them are not + detected. Typically the same upstream `needs_json` targets passed as + `data` to `docs(...)` (e.g. `["@score_platform//:needs_json"]`). + visibility: Standard Bazel visibility for the generated target. + """ + validate_tool = Label("//scripts_bazel:validate_checklist") + + dep_args = " ".join(["$(locations %s)" % d for d in deps]) + link_args = " ".join(["--link-field '%s'" % f for f in link_fields]) + extra_args = " ".join(["--extra-needs-json $(location %s)/needs.json" % e for e in extra_needs]) + + native.genrule( + name = name, + srcs = [src] + extra_needs + deps, + outs = [name + ".sha256"], + cmd = """ + $(location {validate_tool}) \ + --needs-json $(location {src})/needs.json \ + --checklist-id '{mod_insp_id}' \ + --output $@ \ + {link_args} \ + {extra_args} \ + {dep_args} + """.format( + validate_tool = validate_tool, + mod_insp_id = mod_insp_id, + src = src, + link_args = link_args, + extra_args = extra_args, + dep_args = dep_args, + ), + tools = [validate_tool], + visibility = visibility, + ) + +def architecture_checklist( + name, + deps, + mod_insp_id, + src = "//:needs_json", + link_fields = ["fulfils", "includes", "uses", "provides", "derived_from", "satisfies", "covers"], + extra_needs = [], + visibility = None): + """Validate an architecture inspection record against its build output. + + Building this target recomputes the SHA256 over the architecture in `deps` + **and**, by default, over everything they depend on transitively, and + compares it to the `sha256` attribute of the `mod_insp` inspection record + `mod_insp_id` (looked up in `src`'s `needs.json`). The build **fails** when + the hashes differ, i.e. when a validated architecture element *or one of its + (recursive) dependencies* has changed since the inspection was last reviewed. + + The dependency graph is the sphinx-needs link graph: starting from the + architecture elements in `deps` (the *roots*), the `link_fields` are followed + recursively through `src`'s `needs.json`. The defaults follow the structural + architecture links (`includes`, `uses`, `provides`) as well as `fulfils` and + the requirement links (`derived_from`, `satisfies`, `covers`), so the closure + reaches the fulfilled requirements and their parents. Changing a fulfilled + requirement (or a stakeholder requirement it derives from) therefore makes + this checklist go out of date. Pass `link_fields = []` to restore the old + behaviour of hashing only the elements in `deps`. + + Typical usage validates the extracted architecture of a component against the + inspection record that reviewed it: + + component_architecture( + name = "bitmanipulation_comp_arch", + component = "bitmanipulation", + ) + + architecture_checklist( + name = "bitmanipulation_arch_checklist", + mod_insp_id = "mod_insp__bitmanipulation__comp_arc", + deps = [":bitmanipulation_comp_arch"], + ) + + Run with `bazel build //:bitmanipulation_arch_checklist`. On the first run (or + after the architecture changes) the build fails and prints the actual SHA256; + copy it into the `sha256` attribute of the inspection record once it has + been (re-)reviewed. + + Args: + name: Name of the generated target. The output file is `.sha256`. + deps: List of labels whose outputs define the root architecture elements + that are hashed and validated. Usually a single + `feature_architecture`/`component_architecture`/`filtered_needs_json` + target. + mod_insp_id: Id of the `mod_insp` inspection record to validate + (e.g. `"mod_insp__baselibs__feat_arc"`). + src: Label of a `needs_json` build output containing the inspection record + and the full link graph. Defaults to the calling package's + `//:needs_json`. + link_fields: Sphinx-needs link fields followed recursively from the root + architecture elements to include their (transitive) dependencies in + the hash. Defaults to the structural architecture links plus the + requirement links. Set to `[]` to hash only the elements in `deps`. + extra_needs: Optional list of additional `needs_json` build outputs that + provide the full content of needs referenced from the validated + architecture elements but not contained in `src` (e.g. feature + requirements imported from an upstream repository). Without them such + external needs are hashed as `` and changes to them are not + detected. Typically the same upstream `needs_json` targets passed as + `data` to `docs(...)` (e.g. `["@score_platform//:needs_json"]`). + visibility: Standard Bazel visibility for the generated target. + """ + validate_tool = Label("//scripts_bazel:validate_checklist") + + dep_args = " ".join(["$(locations %s)" % d for d in deps]) + link_args = " ".join(["--link-field '%s'" % f for f in link_fields]) + extra_args = " ".join(["--extra-needs-json $(location %s)/needs.json" % e for e in extra_needs]) + + native.genrule( + name = name, + srcs = [src] + extra_needs + deps, + outs = [name + ".sha256"], + cmd = """ + $(location {validate_tool}) \ + --needs-json $(location {src})/needs.json \ + --checklist-id '{mod_insp_id}' \ + --output $@ \ + {link_args} \ + {extra_args} \ + {dep_args} + """.format( + validate_tool = validate_tool, + mod_insp_id = mod_insp_id, + src = src, + link_args = link_args, + extra_args = extra_args, + dep_args = dep_args, + ), + tools = [validate_tool], + visibility = visibility, + ) diff --git a/scripts_bazel/BUILD b/scripts_bazel/BUILD index e2d0402d2..0cc517060 100644 --- a/scripts_bazel/BUILD +++ b/scripts_bazel/BUILD @@ -45,3 +45,19 @@ py_binary( visibility = ["//visibility:public"], deps = [], ) + +py_binary( + name = "filter_needs_json", + srcs = ["filter_needs_json.py"], + main = "filter_needs_json.py", + visibility = ["//visibility:public"], + deps = [], +) + +py_binary( + name = "validate_checklist", + srcs = ["validate_checklist.py"], + main = "validate_checklist.py", + visibility = ["//visibility:public"], + deps = [], +) diff --git a/scripts_bazel/filter_needs_json.py b/scripts_bazel/filter_needs_json.py new file mode 100644 index 000000000..9363af140 --- /dev/null +++ b/scripts_bazel/filter_needs_json.py @@ -0,0 +1,315 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +""" +Extract a subset of sphinx-needs elements from a needs.json file. + +A need is kept when it matches *all* of the active filters: + +* ``--type``: the value of the need's ``type`` attribute is in the requested + list of element types (e.g. ``feat_req``). If no ``--type`` is given, needs + of any type are kept. +* ``--name``: the feature/component name encoded in the need's ID matches one + of the requested names. Need IDs follow the convention + ``____`` (e.g. ``feat_req__baselibs__core_utilities``), so + the second ``__``-separated segment is the feature/component name. The + ``comp_arc_sta`` and ``comp_arc_dyn`` types are an exception: their IDs follow + ``____`` (e.g. + ``comp_arc_sta__baselibs__filesystem``), so the *third* segment holds the + component name used for matching. Any underscores within that component + segment are removed before matching, so ``comp_arc_sta__baselibs__bit_manipulation`` + matches the component name ``bitmanipulation``. If no ``--name`` is given, + needs of any feature/component are kept. + +The top-level structure of the needs.json file is preserved; only the per-need +entries are filtered. +""" + +import argparse +import json +import logging +import sys +from pathlib import Path +from typing import Any + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +# Element types whose IDs follow ``____``, +# i.e. the component name used for matching is the *third* ``__`` segment. +_COMPONENT_NAME_THIRD_SEGMENT_TYPES = frozenset({"comp_arc_sta", "comp_arc_dyn"}) + + +def _id_name_segment(need_id: str, need_type: str | None = None) -> str | None: + """Return the feature/component name encoded in a need ID. + + Need IDs follow the convention ``____`` (e.g. + ``feat_req__baselibs__core_utilities``); the second ``__``-separated segment + is the feature/component name. The ``comp_arc_sta`` and ``comp_arc_dyn`` + types are an exception: their IDs follow + ``____`` (e.g. + ``comp_arc_sta__baselibs__filesystem``), so the *third* segment holds the + component name. Any underscores within that component segment are removed, + so ``comp_arc_sta__baselibs__bit_manipulation`` yields ``bitmanipulation``. + Returns ``None`` when the ID does not follow the convention. + """ + parts = need_id.split("__") + if need_type in _COMPONENT_NAME_THIRD_SEGMENT_TYPES: + if len(parts) < 3 or not parts[2]: + return None + return parts[2].replace("_", "") + if len(parts) < 2 or not parts[1]: + return None + return parts[1] + + +def _keep_need( + need_id: str, + need: dict[str, Any], + types: set[str], + names: set[str], +) -> bool: + if types and need.get("type") not in types: + return False + if names: + segment = _id_name_segment(need_id, need.get("type")) + if segment is None or segment not in names: + return False + return True + + +def filter_needs( + data: dict[str, Any], + types: set[str], + names: set[str], +) -> dict[str, Any]: + """Return a copy of ``data`` keeping only the needs that match the filters.""" + for version in data.get("versions", {}).values(): + needs = version.get("needs", {}) + version["needs"] = { + need_id: need + for need_id, need in needs.items() + if _keep_need(need_id, need, types, names) + } + return data + + +def collect_needs(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return a flat ``{id: need}`` mapping of every need in a needs.json structure.""" + all_needs: dict[str, dict[str, Any]] = {} + for version in data.get("versions", {}).values(): + for need_id, need in version.get("needs", {}).items(): + all_needs[need_id] = need + return all_needs + + +def _resolve_needs_json(path: Path) -> Path: + """Return ``path`` or ``path/needs.json`` when ``path`` is a directory.""" + return path / "needs.json" if path.is_dir() else path + + +def _normalize_id(need_id: str) -> str: + """Strip a trailing ``[...]`` version constraint and whitespace from ``need_id``.""" + return need_id.split("[", 1)[0].strip() + + +def _link_targets(need: dict[str, Any], field: str) -> list[str]: + """Return the normalized link targets stored under ``field`` of ``need``.""" + value = need.get(field) + if value is None: + return [] + raw = value if isinstance(value, list) else [value] + return [_normalize_id(str(v)) for v in raw if str(v).strip()] # pyright: ignore[reportUnknownArgumentType] + + +def detect_link_fields(needs: dict[str, dict[str, Any]]) -> set[str]: + """Return the set of forward link field names used in ``needs``. + + sphinx-needs creates a companion ``_back`` reverse-link field for + every configured link field, so a field ``F`` is a forward link field iff + ``F + "_back"`` occurs as a key on some need. This detects the link fields + directly from the data, independent of the metamodel definition. + """ + suffix = "_back" + fields: set[str] = set() + for need in needs.values(): + for key in need: + if key.endswith(suffix): + fields.add(key[: -len(suffix)]) + return fields + + +def find_dangling_links( + kept: dict[str, dict[str, Any]], + universe: set[str], + link_fields: set[str], +) -> list[tuple[str, str, str]]: + """Return ``(need_id, field, target)`` for every link target missing from ``universe``. + + A link target is dangling when the referenced need id is present neither in + the source needs.json nor in any external needs.json input (together forming + ``universe``). + """ + dangling: list[tuple[str, str, str]] = [] + for need_id, need in sorted(kept.items()): + for field in sorted(link_fields): + for target in _link_targets(need, field): + if target not in universe: + dangling.append((need_id, field, target)) + return dangling + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Extract a subset of sphinx-needs elements from a needs.json file." + ) + ) + _ = parser.add_argument( + "--output", + required=True, + type=Path, + help="Path of the filtered needs.json file to write.", + ) + _ = parser.add_argument( + "--type", + dest="types", + action="append", + default=[], + metavar="ELEMENT_TYPE", + help=( + "Sphinx-needs element type to keep (e.g. 'feat_req'). " + "May be given multiple times. If omitted, all types are kept." + ), + ) + _ = parser.add_argument( + "--name", + dest="names", + action="append", + default=[], + metavar="NAME", + help=( + "Feature/component name to keep, matched against the second " + "'__'-separated segment of each need ID (the '____...' " + "naming convention). May be given multiple times. If omitted, all " + "features/components are kept." + ), + ) + _ = parser.add_argument( + "--extra-needs-json", + dest="extra_needs_json", + action="append", + default=[], + type=Path, + metavar="PATH", + help=( + "Additional needs.json file (or a directory containing one) that " + "provides the full content of needs referenced from the kept " + "elements but not contained in the input (e.g. requirements " + "imported from an upstream repository). In --strict mode such " + "external needs count as resolved link targets. May be given " + "multiple times." + ), + ) + _ = parser.add_argument( + "--strict", + action="store_true", + help=( + "Fail (exit 1, no output written) when any kept need has a dangling " + "link, i.e. a link target present neither in the input needs.json " + "nor in any --extra-needs-json input." + ), + ) + _ = parser.add_argument( + "--link-field", + dest="link_fields", + action="append", + default=[], + metavar="FIELD", + help=( + "Link field to check for dangling references in --strict mode " + "(e.g. 'derived_from'). May be given multiple times. If omitted, " + "every link field is checked (auto-detected from the data via the " + "sphinx-needs '_back' convention)." + ), + ) + _ = parser.add_argument( + "input", + type=Path, + help="Input needs.json file to filter.", + ) + + args = parser.parse_args() + + with open(args.input) as f: + data = json.load(f) + + # Full set of needs available locally (before filtering) plus any external + # needs.json inputs, used to resolve link targets for the strict check. + source_needs = collect_needs(data) + extra_needs: dict[str, dict[str, Any]] = {} + for extra in args.extra_needs_json: + with open(_resolve_needs_json(extra)) as f: + extra_needs.update(collect_needs(json.load(f))) + + filtered = filter_needs( + data, + types=set(args.types), + names=set(args.names), + ) + + kept_needs = collect_needs(filtered) + + if args.strict: + universe = set(source_needs) | set(extra_needs) + if args.link_fields: + link_fields = set(args.link_fields) + else: + link_fields = detect_link_fields({**extra_needs, **source_needs}) + dangling = find_dangling_links(kept_needs, universe, link_fields) + if dangling: + logger.error( + "Refusing to export '%s': %d dangling link reference(s) found. " + "Each target below is present neither in the source needs.json " + "nor in any --extra-needs-json input:", + args.output, + len(dangling), + ) + for need_id, field, target in dangling: + logger.error(" %s --%s--> %s (target not found)", need_id, field, target) + logger.error( + "Provide the missing needs via --extra-needs-json (e.g. an " + "upstream repository's needs_json), or fix the broken links." + ) + return 1 + + logger.info( + "Filtered '%s' -> '%s' (%d needs kept, types=%s, names=%s%s)", + args.input, + args.output, + len(kept_needs), + sorted(args.types) or "ALL", + sorted(args.names) or "ALL", + ", strict" if args.strict else "", + ) + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w") as f: + json.dump(filtered, f, indent=2, sort_keys=True) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts_bazel/validate_checklist.py b/scripts_bazel/validate_checklist.py new file mode 100644 index 000000000..3f3b44427 --- /dev/null +++ b/scripts_bazel/validate_checklist.py @@ -0,0 +1,348 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +""" +Validate an inspection record against the build output it was reviewed against. + +A ``mod_insp`` sphinx-needs element pins the state of one or more build +outputs (e.g. the extracted component requirements) via a ``sha256`` attribute. +This script: + +1. Reads ``needs.json`` and looks up the inspection record by its id. +2. Computes the SHA256 over the validated build output. +3. Compares the computed hash with the ``sha256`` attribute of the record. + +There are two hashing modes: + +* **Flat** (no ``--link-field`` given): the SHA256 is computed over the + concatenated input files (sorted by path, so the result is independent of the + order in which Bazel passes them). This pins exactly the elements contained in + the input files. +* **Transitive** (one or more ``--link-field`` given): the input files only + define the *root* elements (e.g. the extracted feature requirements). Starting + from those roots, the given link fields (e.g. ``derived_from``, ``satisfies``) + are followed recursively through the full ``needs.json``, collecting every + reachable element (e.g. the stakeholder requirements a feature requirement is + derived from, and their parents in turn). The SHA256 is computed over the + canonical serialization of the whole closure. As a result the checklist also + goes out of date when an *upstream* dependency (such as a linked stakeholder + requirement) changes, not just when a root element changes. + + Reachable elements whose full content does not live in ``--needs-json`` (e.g. + requirements or architecture imported from another repository) must be + supplied via ``--extra-needs-json``; otherwise they are hashed as ```` + and changes to them are not detected. + +On match it writes the verified hash to ``--output`` and exits ``0``. On mismatch +(or when the need / attribute is missing) it logs the expected and actual hashes +and exits ``1``, which fails the Bazel build. +""" + +import argparse +import hashlib +import json +import logging +import sys +from pathlib import Path +from typing import Any + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +def find_need(data: dict[str, Any], need_id: str) -> dict[str, Any] | None: + """Return the need with id ``need_id`` from a needs.json structure.""" + for version in data.get("versions", {}).values(): + needs = version.get("needs", {}) + if need_id in needs: + return needs[need_id] + return None + + +def collect_needs(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return a flat ``{id: need}`` mapping of every need in a needs.json structure.""" + all_needs: dict[str, dict[str, Any]] = {} + for version in data.get("versions", {}).values(): + for need_id, need in version.get("needs", {}).items(): + all_needs[need_id] = need + return all_needs + + +def _link_targets(need: dict[str, Any], field: str) -> list[str]: + """Return the link targets stored under ``field`` of ``need`` as a list. + + sphinx-needs may store link targets with a version constraint suffix such as + ``stkh_req__foo[version==1]``. The constraint is stripped so the target + matches the plain need id used as the key in ``needs.json``. + """ + value = need.get(field) + if value is None: + return [] + raw = value if isinstance(value, list) else [value] + return [_normalize_id(str(v)) for v in raw if str(v).strip()] # pyright: ignore[reportUnknownArgumentType] + + +def _normalize_id(need_id: str) -> str: + """Strip a trailing ``[...]`` version constraint and whitespace from ``need_id``.""" + return need_id.split("[", 1)[0].strip() + + +def compute_closure( + all_needs: dict[str, dict[str, Any]], + roots: set[str], + link_fields: list[str], +) -> set[str]: + """Return the transitive closure of ``roots`` following ``link_fields``. + + Starting from ``roots`` every link target reachable via one of the + ``link_fields`` is collected recursively. Missing link targets (ids that are + not present in ``all_needs``) are kept in the result so that a dangling link + still influences the hash deterministically. + """ + seen: set[str] = set() + stack: list[str] = list(roots) + while stack: + need_id = stack.pop() + if need_id in seen: + continue + seen.add(need_id) + need = all_needs.get(need_id) + if need is None: + continue + for field in link_fields: + for target in _link_targets(need, field): + if target not in seen: + stack.append(target) + return seen + + +def compute_sha256(paths: list[Path]) -> str: + """Return the SHA256 over the concatenated contents of ``paths`` (sorted).""" + digest = hashlib.sha256() + for path in sorted(paths, key=lambda p: p.name): + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _canonicalize_need(need: dict[str, Any]) -> dict[str, Any]: + """Return a copy of ``need`` with sphinx-needs back-link lists order-normalized. + + sphinx-needs auto-populates the reverse-link fields (every link field has a + corresponding ``_back``) in document-processing order, which is *not* + stable with respect to unrelated changes: editing an unrelated ``.rst`` file + can reorder the entries of a ``*_back`` list without changing its contents. + Because the full need dict is hashed, that spurious reordering would + invalidate the checklist even though nothing semantically relevant changed. + + Sorting the (string) entries of every ``*_back`` list makes the serialization + order-independent so only genuine changes to the set of back-links affect the + hash. Forward link fields are author-specified and deterministic, so they are + left untouched. + """ + normalized = dict(need) + for key, value in normalized.items(): + if key.endswith("_back") and isinstance(value, list): + normalized[key] = sorted( # pyright: ignore[reportUnknownArgumentType] + value, + key=lambda item: str(item), # pyright: ignore[reportUnknownLambdaType] + ) + return normalized + + +def compute_closure_sha256( + all_needs: dict[str, dict[str, Any]], + ids: set[str], +) -> str: + """Return the SHA256 over the canonical serialization of ``ids`` in ``all_needs``. + + Needs are serialized sorted by id, each as a deterministic JSON object + (``sort_keys=True``). The full need dict is hashed, so any change to a + reachable element - including its content, attributes or links - changes the + result. The auto-generated ``*_back`` link lists are order-normalized first + (see ``_canonicalize_need``) so that unrelated edits which only reshuffle + those reverse links do not invalidate the hash. Ids without a matching need + are still mixed into the digest so that a dangling link is detected too. + """ + digest = hashlib.sha256() + for need_id in sorted(ids): + digest.update(need_id.encode("utf-8")) + digest.update(b"\x00") + need = all_needs.get(need_id) + if need is None: + digest.update(b"") + else: + payload = json.dumps( + _canonicalize_need(need), sort_keys=True, ensure_ascii=False + ) + digest.update(payload.encode("utf-8")) + digest.update(b"\x00") + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Validate an inspection record (mod_insp) against the SHA256 of " + "the build output it was reviewed against." + ) + ) + _ = parser.add_argument( + "--needs-json", + required=True, + type=Path, + help="Path of the needs.json file containing the checklist need.", + ) + _ = parser.add_argument( + "--checklist-id", + required=True, + help="Id of the mod_insp need to validate (e.g. 'mod_insp__foo').", + ) + _ = parser.add_argument( + "--output", + required=True, + type=Path, + help="Path of the stamp file to write with the verified hash on success.", + ) + _ = parser.add_argument( + "--link-field", + dest="link_fields", + action="append", + default=[], + metavar="FIELD", + help=( + "Link field to follow recursively from the input (root) elements when " + "computing the hash (e.g. 'derived_from'). May be given multiple " + "times. If omitted, only the input files themselves are hashed " + "(no transitive dependencies)." + ), + ) + _ = parser.add_argument( + "--extra-needs-json", + dest="extra_needs_json", + action="append", + default=[], + type=Path, + metavar="PATH", + help=( + "Additional needs.json file providing the full content of needs that " + "are referenced from the validated elements but are not contained in " + "the main --needs-json (e.g. requirements/architecture imported from " + "an upstream repository). Used in transitive mode to resolve and hash " + "such external needs; without it they are hashed as and " + "changes to them go undetected. May be given multiple times." + ), + ) + _ = parser.add_argument( + "inputs", + nargs="+", + type=Path, + help="Build output files whose combined SHA256 is validated.", + ) + + args = parser.parse_args() + + with open(args.needs_json) as f: + data = json.load(f) + + need = find_need(data, args.checklist_id) + if need is None: + logger.error( + "Checklist need '%s' not found in '%s'.", + args.checklist_id, + args.needs_json, + ) + return 1 + + expected = need.get("sha256") + + if args.link_fields: + # Transitive mode: the input files define the root elements; follow the + # given link fields recursively through the full needs.json and hash the + # whole closure (roots + all reachable dependencies). + root_needs: dict[str, dict[str, Any]] = {} + for path in args.inputs: + with open(path) as f: + root_needs.update(collect_needs(json.load(f))) + roots = set(root_needs.keys()) + + # Build the authoritative content/link graph. Extra needs.json sources + # (e.g. an upstream repository) only fill in needs that are missing + # locally, so the main --needs-json keeps precedence for local content, + # and the extracted root elements keep precedence over both. Without the + # extra sources, external needs referenced by the validated elements are + # absent from the graph and get hashed as , so changes to them + # are invisible to the checklist. + all_needs: dict[str, dict[str, Any]] = {} + for extra_path in args.extra_needs_json: + with open(extra_path) as f: + all_needs.update(collect_needs(json.load(f))) + all_needs.update(collect_needs(data)) + all_needs.update(root_needs) + + closure = compute_closure(all_needs, roots, args.link_fields) + dependencies = closure - roots + logger.info( + "Checklist '%s': hashing %d element(s) (%d root(s) + %d " + "transitive dependency/ies via %s).", + args.checklist_id, + len(closure), + len(roots), + len(dependencies), + ", ".join(args.link_fields), + ) + if dependencies: + logger.info( + " transitive dependencies: %s", + ", ".join(sorted(dependencies)), + ) + actual = compute_closure_sha256(all_needs, closure) + else: + actual = compute_sha256(args.inputs) + + if not expected: + logger.error( + "Checklist '%s' has an EMPTY 'sha256' attribute.\n" + "Review the target output and, if correct, pin it by setting the " + "checklist's 'sha256' attribute to:\n" + "\n" + " %s\n", + args.checklist_id, + actual, + ) + return 1 + + if expected != actual: + logger.error( + "Checklist '%s' is OUT OF DATE.\n" + " expected (sha256 in need): %s\n" + " actual (build output): %s\n" + "The validated target output has changed since the checklist was " + "last reviewed. Re-review the checklist and update its 'sha256' " + "attribute to '%s'.", + args.checklist_id, + expected, + actual, + actual, + ) + return 1 + + logger.info("Checklist '%s' is up to date (sha256=%s).", args.checklist_id, actual) + + args.output.parent.mkdir(parents=True, exist_ok=True) + _ = args.output.write_text(actual + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5693221d89b891a7dbfbdc7ba23915514e4f2360 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 24 Jul 2026 10:29:24 +0000 Subject: [PATCH 2/2] feat(needs): resolve --name via linked feature/component title Match the filter --name against the title of the feat/comp element a need links to, instead of parsing the need ID: - feat_req/comp_req resolve the owning feature/component via satisfied_by (with a legacy belongs_to fallback). - feat_arc_*/comp_arc_* resolve it via belongs_to. Link targets are looked up in the input needs.json plus any --extra-needs-json inputs. Other element types fall back to the second '__' ID segment. --- docs.bzl | 32 ++++---- scripts_bazel/filter_needs_json.py | 113 +++++++++++++++++++---------- 2 files changed, 92 insertions(+), 53 deletions(-) diff --git a/docs.bzl b/docs.bzl index 2f1922dfb..26328adaa 100644 --- a/docs.bzl +++ b/docs.bzl @@ -384,8 +384,10 @@ def filtered_needs_json( types: Optional list of sphinx-needs element types to keep (e.g. `["feat_req", "comp_req"]`). If empty, all types are kept. names: Optional list of feature/component names to keep, matched against - the second `__`-separated segment of each need ID (the - `____...` naming convention). If empty, all + the `title` of the `feat`/`comp` element the need links to: + `feat_req`/`comp_req` via `satisfied_by` (legacy `belongs_to` + fallback), `feat_arc_*`/`comp_arc_*` via `belongs_to`. Other types + fall back to the second `__`-separated ID segment. If empty, all features/components are kept. strict: When True the build fails (and no `.json` is written) if any kept need has a dangling link, i.e. a link target that is present @@ -454,8 +456,9 @@ def component_requirements( src: Label of a `needs_json` build output. Defaults to the calling package's `//:needs_json`. component: Optional component name. If given, only component requirements - named with that component (per the `____...` - convention) are kept; if omitted, all component requirements are + linked to a component whose `title` matches are kept; the component + is resolved via the requirement's `satisfied_by` link (legacy + `belongs_to` fallback). If omitted, all component requirements are kept. strict: When True the build fails (no output) on any dangling link in the kept needs. See `filtered_needs_json`. @@ -494,8 +497,10 @@ def feature_requirements( src: Label of a `needs_json` build output. Defaults to the calling package's `//:needs_json`. feature: Optional feature name. If given, only feature requirements - named with that feature (per the `____...` convention) - are kept; if omitted, all feature requirements are kept. + linked to a feature whose `title` matches are kept; the feature is + resolved via the requirement's `satisfied_by` link (legacy + `belongs_to` fallback). If omitted, all feature requirements are + kept. strict: When True the build fails (no output) on any dangling link in the kept needs. See `filtered_needs_json`. extra_needs: Additional `needs_json` build outputs whose needs count as @@ -574,9 +579,9 @@ def feature_architecture( src: Label of a `needs_json` build output. Defaults to the calling package's `//:needs_json`. feature: Optional feature name. If given, only feature architecture - elements named with that feature (per the `____...` - convention) are kept; if omitted, all feature architecture elements - are kept. + elements linked to a feature whose `title` matches are kept; the + feature is resolved via the element's `belongs_to` link. If omitted, + all feature architecture elements are kept. strict: When True the build fails (no output) on any dangling link in the kept needs. See `filtered_needs_json`. extra_needs: Additional `needs_json` build outputs whose needs count as @@ -616,12 +621,9 @@ def component_architecture( src: Label of a `needs_json` build output. Defaults to the calling package's `//:needs_json`. component: Optional component name. If given, only component architecture - elements named with that component are kept; component architecture - IDs follow the `____` convention (e.g. - `comp_arc_sta__baselibs__bit_manipulation`) and any underscores in - the component segment are removed for matching (so `bit_manipulation` - matches the component name `bitmanipulation`). If omitted, all - component architecture elements are kept. + elements linked to a component whose `title` matches are kept; the + component is resolved via the element's `belongs_to` link. If + omitted, all component architecture elements are kept. strict: When True the build fails (no output) on any dangling link in the kept needs. See `filtered_needs_json`. extra_needs: Additional `needs_json` build outputs whose needs count as diff --git a/scripts_bazel/filter_needs_json.py b/scripts_bazel/filter_needs_json.py index 9363af140..7919c3a9c 100644 --- a/scripts_bazel/filter_needs_json.py +++ b/scripts_bazel/filter_needs_json.py @@ -19,17 +19,20 @@ * ``--type``: the value of the need's ``type`` attribute is in the requested list of element types (e.g. ``feat_req``). If no ``--type`` is given, needs of any type are kept. -* ``--name``: the feature/component name encoded in the need's ID matches one - of the requested names. Need IDs follow the convention - ``____`` (e.g. ``feat_req__baselibs__core_utilities``), so - the second ``__``-separated segment is the feature/component name. The - ``comp_arc_sta`` and ``comp_arc_dyn`` types are an exception: their IDs follow - ``____`` (e.g. - ``comp_arc_sta__baselibs__filesystem``), so the *third* segment holds the - component name used for matching. Any underscores within that component - segment are removed before matching, so ``comp_arc_sta__baselibs__bit_manipulation`` - matches the component name ``bitmanipulation``. If no ``--name`` is given, - needs of any feature/component are kept. +* ``--name``: the need belongs to one of the requested features/components. The + owning feature/component is resolved by following a link and reading the + linked element's ``title``: + + * ``feat_req`` / ``comp_req`` follow ``satisfied_by`` (falling back to the + legacy ``belongs_to`` link) to their ``feat`` / ``comp`` element. + * ``feat_arc_*`` / ``comp_arc_*`` follow ``belongs_to`` to their ``feat`` / + ``comp`` element. + + The link target is looked up in the input needs.json (plus any + ``--extra-needs-json`` inputs) and its ``title`` is matched against the + requested names. For any other element type the name falls back to the second + ``__``-separated segment of the need ID (``____...``). If no + ``--name`` is given, needs of any feature/component are kept. The top-level structure of the needs.json file is preserved; only the per-need entries are filtered. @@ -46,46 +49,74 @@ logger = logging.getLogger(__name__) -# Element types whose IDs follow ``____``, -# i.e. the component name used for matching is the *third* ``__`` segment. -_COMPONENT_NAME_THIRD_SEGMENT_TYPES = frozenset({"comp_arc_sta", "comp_arc_dyn"}) +# For each element type, the ordered link field(s) whose target ``feat`` / +# ``comp`` element carries the owning feature/component ``title`` used for +# --name matching. Requirements prefer the new ``satisfied_by`` link and fall +# back to the legacy ``belongs_to`` link; architecture views use ``belongs_to``. +_NAME_LINK_FIELDS: dict[str, tuple[str, ...]] = { + "feat_req": ("satisfied_by", "belongs_to"), + "comp_req": ("satisfied_by", "belongs_to"), + "feat_arc_sta": ("belongs_to",), + "feat_arc_dyn": ("belongs_to",), + "comp_arc_sta": ("belongs_to",), + "comp_arc_dyn": ("belongs_to",), +} -def _id_name_segment(need_id: str, need_type: str | None = None) -> str | None: - """Return the feature/component name encoded in a need ID. +def _id_name_segment(need_id: str) -> str | None: + """Return the feature/component name in the second ``__`` segment of a need ID. - Need IDs follow the convention ``____`` (e.g. - ``feat_req__baselibs__core_utilities``); the second ``__``-separated segment - is the feature/component name. The ``comp_arc_sta`` and ``comp_arc_dyn`` - types are an exception: their IDs follow - ``____`` (e.g. - ``comp_arc_sta__baselibs__filesystem``), so the *third* segment holds the - component name. Any underscores within that component segment are removed, - so ``comp_arc_sta__baselibs__bit_manipulation`` yields ``bitmanipulation``. - Returns ``None`` when the ID does not follow the convention. + Fallback for element types without a configured owning link (see + ``_NAME_LINK_FIELDS``). Need IDs follow ``____`` (e.g. + ``aou_req__baselibs__foo``), so the second ``__``-separated segment is the + name. Returns ``None`` when the ID does not follow the convention. """ parts = need_id.split("__") - if need_type in _COMPONENT_NAME_THIRD_SEGMENT_TYPES: - if len(parts) < 3 or not parts[2]: - return None - return parts[2].replace("_", "") if len(parts) < 2 or not parts[1]: return None return parts[1] +def _need_names( + need_id: str, + need: dict[str, Any], + universe: dict[str, dict[str, Any]], +) -> set[str]: + """Return the feature/component names a need belongs to. + + For requirement and architecture element types the owning feature/component + is resolved by following the configured link (``satisfied_by`` / + ``belongs_to``, see ``_NAME_LINK_FIELDS``) and reading the linked element's + ``title`` from ``universe``. For any other type the name falls back to the + second ``__`` segment of the need ID. + """ + fields = _NAME_LINK_FIELDS.get(str(need.get("type"))) + if fields is None: + segment = _id_name_segment(need_id) + return {segment} if segment is not None else set() + names: set[str] = set() + for field in fields: + for target in _link_targets(need, field): + target_need = universe.get(target) + if target_need is None: + continue + title = target_need.get("title") + if title: + names.add(str(title).strip()) + return names + + def _keep_need( need_id: str, need: dict[str, Any], types: set[str], names: set[str], + universe: dict[str, dict[str, Any]], ) -> bool: if types and need.get("type") not in types: return False - if names: - segment = _id_name_segment(need_id, need.get("type")) - if segment is None or segment not in names: - return False + if names and names.isdisjoint(_need_names(need_id, need, universe)): + return False return True @@ -93,6 +124,7 @@ def filter_needs( data: dict[str, Any], types: set[str], names: set[str], + universe: dict[str, dict[str, Any]], ) -> dict[str, Any]: """Return a copy of ``data`` keeping only the needs that match the filters.""" for version in data.get("versions", {}).values(): @@ -100,7 +132,7 @@ def filter_needs( version["needs"] = { need_id: need for need_id, need in needs.items() - if _keep_need(need_id, need, types, names) + if _keep_need(need_id, need, types, names, universe) } return data @@ -200,10 +232,10 @@ def main() -> int: default=[], metavar="NAME", help=( - "Feature/component name to keep, matched against the second " - "'__'-separated segment of each need ID (the '____...' " - "naming convention). May be given multiple times. If omitted, all " - "features/components are kept." + "Feature/component name to keep, matched against the 'title' of the " + "feat/comp element linked via 'satisfied_by' (requirements) or " + "'belongs_to' (architecture). May be given multiple times. If " + "omitted, all features/components are kept." ), ) _ = parser.add_argument( @@ -263,10 +295,15 @@ def main() -> int: with open(_resolve_needs_json(extra)) as f: extra_needs.update(collect_needs(json.load(f))) + # Universe used to resolve a need's owning feature/component title for --name + # filtering (link targets may live in the input or an external needs.json). + name_universe = {**source_needs, **extra_needs} + filtered = filter_needs( data, types=set(args.types), names=set(args.names), + universe=name_universe, ) kept_needs = collect_needs(filtered)