From ff3106f4f8dc25acc3d8f349c380e9a799f06d1b Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 16 Sep 2026 00:03:32 +0200 Subject: [PATCH 1/5] fix: improve Mermaid and needs template rendering --- .../docs/generate_metamodel_rst.py | 39 ++++++-- .../tests/test_rules_file_based.py | 7 +- .../score_sphinx_bundle/__init__.py | 4 +- .../score_sphinx_needs_templates/README.md | 5 +- .../score_sphinx_needs_templates/__init__.py | 96 ++++++++++++++++--- 5 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/extensions/score_metamodel/docs/generate_metamodel_rst.py b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py index 76b7b7c91..cd3b12ca5 100644 --- a/src/extensions/score_metamodel/docs/generate_metamodel_rst.py +++ b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py @@ -78,6 +78,18 @@ def _split_targets(targets: str) -> list[str]: return [target.strip() for target in targets.split(",") if target.strip()] +def _mermaid_identifier(name: str) -> str: + """Return a Mermaid-safe identifier for a metamodel type name. + + Metamodel type names are also directive names and may contain hyphens. + Mermaid class declarations accept those names, but Mermaid's ``style`` + statements do not accept a hyphenated target. Underscores preserve a + readable, deterministic identifier while the original name remains the + displayed class label. + """ + return name.replace("-", "_") + + @dataclass(frozen=True) class NeedLink: """A link definition belonging to one need type.""" @@ -209,6 +221,7 @@ class MermaidNode: """A generic class-diagram node.""" name: str + identifier: str attributes: tuple[str, ...] = () optional_attributes: tuple[str, ...] = () color: str | None = None @@ -284,8 +297,13 @@ def render(self, diagram: MermaidDiagram) -> str: def _class_declarations(self, diagram: MermaidDiagram) -> list[str]: lines: list[str] = [] for node in sorted(diagram.nodes, key=lambda item: item.name): + label = ( + f'["{self._escape_label(node.name)}"]' + if node.identifier != node.name + else "" + ) if node.attributes or node.optional_attributes: - lines.append(f"class {node.name} {{") + lines.append(f"class {node.identifier}{label} {{") lines.extend(f" +{attribute}" for attribute in sorted(node.attributes)) # Mermaid's trailing '*' member classifier renders the complete # attribute line in italics. Keep the textual marker as part @@ -298,29 +316,32 @@ def _class_declarations(self, diagram: MermaidDiagram) -> list[str]: ) lines.append("}") else: - lines.append(f"class {node.name}") + lines.append(f"class {node.identifier}{label}") return lines def _edge_declarations(self, diagram: MermaidDiagram) -> list[str]: lines: list[str] = [] seen: set[tuple[str, str, str]] = set() + identifiers = {node.name: node.identifier for node in diagram.nodes} for edge in diagram.edges: key = (edge.source, edge.target, edge.label) if key in seen: continue seen.add(key) arrow = "..>" if edge.optional else "-->" - lines.append(f"{edge.source} {arrow} {edge.target} : {edge.label}") + source = identifiers.get(edge.source, edge.source) + target = identifiers.get(edge.target, edge.target) + lines.append(f"{source} {arrow} {target} : {edge.label}") return lines def _style_declarations(self, diagram: MermaidDiagram) -> list[str]: lines = [ - f"style {node.name} fill:{node.color},stroke:#666,color:#000" + f"style {node.identifier} fill:{node.color},stroke:#666,color:#000" for node in sorted(diagram.nodes, key=lambda item: item.name) if node.color and node.style_override is None ] lines.extend( - f"style {node.name} {node.style_override}" + f"style {node.identifier} {node.style_override}" for node in sorted(diagram.nodes, key=lambda item: item.name) if node.style_override ) @@ -331,12 +352,17 @@ def _link_declarations(self, diagram: MermaidDiagram) -> list[str]: for node in sorted(diagram.nodes, key=lambda item: item.name): if node.href is None: continue - line = f'click {node.name} href "{node.href}"' + line = f'click {node.identifier} href "{node.href}"' if node.tooltip: line += f' "{node.tooltip}"' lines.append(line) return lines + @staticmethod + def _escape_label(value: str) -> str: + """Escape a type name used inside a Mermaid quoted class label.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + @dataclass(frozen=True) class NeedDiagramBuilder: @@ -375,6 +401,7 @@ def build(self, focal_name: str | None = None) -> MermaidDiagram: def _node(need_type: NeedType) -> MermaidNode: return MermaidNode( name=need_type.name, + identifier=_mermaid_identifier(need_type.name), attributes=tuple(need_type.mandatory_options), optional_attributes=tuple(need_type.optional_options), color=need_type.color, diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index 4d47fe90d..e493d58e8 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -98,9 +98,10 @@ def count_need_objects(rst_file: Path) -> RstData: rst_data = RstData(filename=str(rst_file.relative_to(RST_DIR))) with open(rst_file) as f: for no, line in enumerate(f, start=1): - # Beginning of new need - # We filter for '::' as well so we ONLY get directives not comments - if line.startswith(".. ") and "::" in line: + # Beginning of a new need. Nested needs are indented below their + # containing need, so inspect the directive after leading whitespace. + # We filter for '::' as well so we ONLY get directives, not comments. + if line.lstrip().startswith(".. ") and "::" in line: rst_data.found_objects.append(no) return rst_data diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index 523649be0..6d7deb045 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -53,8 +53,8 @@ def setup(app: Sphinx) -> dict[str, object]: # Global settings # Note: the "sub-extensions" also set their own config values - # Same as current VS Code extension - config_setdefault(app.config, "mermaid_version", "11.6.0") + # Match the current GitHub and ubCode Mermaid renderers. + config_setdefault(app.config, "mermaid_version", "11.17.2") config_setdefault(app.config, "mermaid_d3_zoom", True) config_setdefault(app.config, "mermaid_include_elk", True) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index 56e540156..75d9ef33b 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -24,6 +24,7 @@ The extension provides: * the shared `src/needs_templates` directory as the Sphinx-Needs template directory; * the `linked_needs(need_id, link_name)` helper for traversing Need links; +* the `needs_of_type(need_type)` helper for selecting top-level graph roots; * support for graph-driven `post_template`s that are rendered after parallel Need collection has been merged; * ordinary Sphinx page navigation for sections generated by those @@ -51,7 +52,9 @@ with Sphinx-Needs' `:post_template:` option. The extension then purges and rereads the affected report page once after parallel Need collection has been merged, so `linked_needs` can see the complete model. -The generated content should use normal reStructuredText sections instead of +Both outgoing links and backlink fields ending in ``_back`` can be traversed, +which is useful for generated testcase evidence and nested Need children. The +generated content should use normal reStructuredText sections instead of rubrics. Sphinx-Needs parses `post_template` output after the Need and with section matching enabled, so section IDs and the local page ToC are collected by Sphinx itself. Ordinary `:template:` use keeps its normal Sphinx-Needs diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 3863046af..e0ae280c9 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -23,6 +23,10 @@ # Post-templates containing this marker need a second read after parallel Need # collection has been merged. _RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection" +# During that second read, the document being reread is temporarily absent +# from Sphinx's Need collection. Keep its already-collected Needs available so +# a post-template can traverse nested children of its own report Need. +_template_rerender_needs: dict[str, NeedItem] = {} def _base_need_id(need_id: str) -> str: @@ -46,6 +50,18 @@ def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: return None +def _template_needs() -> dict[str, NeedItem]: + """Return the current Needs, including the temporary reread snapshot.""" + if _template_environment is None: + return {} + + needs = SphinxNeedsData(_template_environment).get_needs_mutable() + if _template_rerender_needs: + needs = dict(needs) + needs.update(_template_rerender_needs) + return needs + + def _needs_template_folder() -> Path: """Locate the shared ``.need`` template directory for Sphinx-Needs.""" template_folder = Path(__file__).parents[2] / "needs_templates" @@ -60,8 +76,9 @@ class _LinkedNeeds: """Provide link traversal to Need templates as a pickleable callable. Calling the object with a Need ID and a link field returns the target - ``NeedItem`` objects in the order declared by the source Need. This lets a - template derive sections from the Need graph instead of embedding IDs. + ``NeedItem`` objects in the order declared by the source Need. Backlink + fields ending in ``_back`` are also supported. This lets a template derive + sections from the Need graph instead of embedding IDs. The object is deliberately a top-level class instance because Sphinx puts the render context into its parallel-reader configuration. A plain @@ -70,16 +87,44 @@ class _LinkedNeeds: """ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: - if _template_environment is None: + needs = _template_needs() + if not needs: return [] - needs = SphinxNeedsData(_template_environment).get_needs_mutable() source = _find_need(needs, need_id) - if source is None: - return [] + if link_name.endswith("_back"): + link_type = link_name.removesuffix("_back") + if source is not None: + links = source.get_backlinks(link_type, as_str=False) + if links: + return [ + target + for link in links + if (target := _find_need(needs, link.to_link_string())) + is not None + ] + + # During a post-template reread, Sphinx-Needs may not have rebuilt + # backlink caches yet. The current Need is also not registered in + # the environment while its own post-template is being rendered. + # Derive the reverse relation from outgoing links so both cases + # remain usable by graph-driven templates. + source_id = _base_need_id(need_id) + return [ + candidate + for candidate in needs.values() + if any( + _base_need_id(link.to_link_string()) == source_id + for link in candidate.get_links(link_type, as_str=False) + ) + ] + else: + if source is None: + return [] + links = source.get_links(link_name, as_str=False) linked: list[NeedItem] = [] - for link in source.get_links(link_name, as_str=False): + for link in links: target = _find_need(needs, link.to_link_string()) if target is not None: linked.append(target) @@ -89,6 +134,18 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: _linked_needs_callable = _LinkedNeeds() +class _NeedsOfType: + """Provide all Needs of a given type to graph-driven templates.""" + + def __call__(self, need_type: str) -> list[NeedItem]: + return [ + need for need in _template_needs().values() if need["type"] == need_type + ] + + +_needs_of_type_callable = _NeedsOfType() + + def _complex_post_template_names(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" template_folder = _needs_template_folder() @@ -136,10 +193,19 @@ def _rerender_pages_with_complex_post_templates( complex_post_template_docs.add(docname) pages_to_rerender = sorted(complex_post_template_docs) - for docname in pages_to_rerender: - app.emit("env-purge-doc", env, docname) - env.clear_doc(docname) - app.builder.read_doc(docname) + global _template_rerender_needs + _template_rerender_needs = { + need["id"]: need + for need in needs_data.get_needs_mutable().values() + if need.get("docname") in pages_to_rerender + } + try: + for docname in pages_to_rerender: + app.emit("env-purge-doc", env, docname) + env.clear_doc(docname) + app.builder.read_doc(docname) + finally: + _template_rerender_needs = {} return pages_to_rerender @@ -163,8 +229,14 @@ def setup(app: Sphinx) -> dict[str, object]: app.config, "needs_template_folder", str(_needs_template_folder()) ) app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) + app.config.needs_render_context.setdefault("needs_of_type", _needs_of_type_callable) app.connect("builder-inited", _capture_template_environment) - app.connect("env-updated", _rerender_pages_with_complex_post_templates) + # Run after the source-code linker has injected generated testcase Needs and + # their verification backlinks (priority 525), so report templates can + # include those testcases in their traceability tables. + app.connect( + "env-updated", _rerender_pages_with_complex_post_templates, priority=600 + ) return { "version": "1.0.0", From c1777e732394271bcb22eb7e1b9e47e930ec905e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 16 Sep 2026 00:59:03 +0200 Subject: [PATCH 2/5] refactor: clarify needs template state names --- .../score_sphinx_needs_templates/__init__.py | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index e0ae280c9..a09c8e931 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -19,14 +19,15 @@ from src.helper_lib import config_setdefault -_template_environment: BuildEnvironment | None = None +_build_environment: BuildEnvironment | None = None # Post-templates containing this marker need a second read after parallel Need # collection has been merged. _RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection" -# During that second read, the document being reread is temporarily absent -# from Sphinx's Need collection. Keep its already-collected Needs available so -# a post-template can traverse nested children of its own report Need. -_template_rerender_needs: dict[str, NeedItem] = {} +# During that second read, ``env.clear_doc()`` temporarily removes all Needs +# belonging to the document from Sphinx's collection. Keep those temporarily +# removed Needs available so templates can resolve links while the document is +# reread. +_temporarily_removed_needs: dict[str, NeedItem] = {} def _base_need_id(need_id: str) -> str: @@ -50,15 +51,15 @@ def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: return None -def _template_needs() -> dict[str, NeedItem]: - """Return the current Needs, including the temporary reread snapshot.""" - if _template_environment is None: +def _get_available_needs() -> dict[str, NeedItem]: + """Return the current Needs, including temporarily removed Needs.""" + if _build_environment is None: return {} - needs = SphinxNeedsData(_template_environment).get_needs_mutable() - if _template_rerender_needs: + needs = SphinxNeedsData(_build_environment).get_needs_mutable() + if _temporarily_removed_needs: needs = dict(needs) - needs.update(_template_rerender_needs) + needs.update(_temporarily_removed_needs) return needs @@ -87,7 +88,7 @@ class _LinkedNeeds: """ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: - needs = _template_needs() + needs = _get_available_needs() if not needs: return [] @@ -139,14 +140,16 @@ class _NeedsOfType: def __call__(self, need_type: str) -> list[NeedItem]: return [ - need for need in _template_needs().values() if need["type"] == need_type + need + for need in _get_available_needs().values() + if need["type"] == need_type ] _needs_of_type_callable = _NeedsOfType() -def _complex_post_template_names(app: Sphinx) -> set[str]: +def _post_templates_requiring_reread(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" template_folder = _needs_template_folder() return { @@ -159,9 +162,7 @@ def _complex_post_template_names(app: Sphinx) -> set[str]: } -def _rerender_pages_with_complex_post_templates( - app: Sphinx, env: BuildEnvironment -) -> list[str]: +def _reread_post_template_pages(app: Sphinx, env: BuildEnvironment) -> list[str]: """Re-read marked post-template pages after Need environments are merged. Post-templates are expanded while source documents are read. A parallel @@ -172,53 +173,53 @@ def _rerender_pages_with_complex_post_templates( if app.builder.name != "html": return [] - complex_post_templates = _complex_post_template_names(app) - if not complex_post_templates: + post_templates_requiring_reread = _post_templates_requiring_reread(app) + if not post_templates_requiring_reread: return [] needs_data = SphinxNeedsData(env) if needs_data.needs_is_post_processed: return [] - complex_post_template_docs: set[str] = set() + post_template_docs: set[str] = set() for need in needs_data.get_needs_mutable().values(): post_template = need.get("post_template") if ( not isinstance(post_template, str) - or post_template not in complex_post_templates + or post_template not in post_templates_requiring_reread ): continue docname = need["docname"] if isinstance(docname, str) and docname: - complex_post_template_docs.add(docname) + post_template_docs.add(docname) - pages_to_rerender = sorted(complex_post_template_docs) - global _template_rerender_needs - _template_rerender_needs = { + pages_to_reread = sorted(post_template_docs) + global _temporarily_removed_needs + _temporarily_removed_needs = { need["id"]: need for need in needs_data.get_needs_mutable().values() - if need.get("docname") in pages_to_rerender + if need.get("docname") in pages_to_reread } try: - for docname in pages_to_rerender: + for docname in pages_to_reread: app.emit("env-purge-doc", env, docname) env.clear_doc(docname) app.builder.read_doc(docname) finally: - _template_rerender_needs = {} + _temporarily_removed_needs = {} - return pages_to_rerender + return pages_to_reread -def _capture_template_environment(app: Sphinx) -> None: +def _capture_build_environment(app: Sphinx) -> None: """Give the link helper the environment in which it should resolve Needs. The helper is registered during ``setup()``, but Sphinx creates ``app.env`` only after extension setup has completed. ``builder-inited`` is the first lifecycle event at which the final build environment is available. """ - global _template_environment - _template_environment = app.env + global _build_environment + _build_environment = app.env def setup(app: Sphinx) -> dict[str, object]: @@ -230,13 +231,11 @@ def setup(app: Sphinx) -> dict[str, object]: ) app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) app.config.needs_render_context.setdefault("needs_of_type", _needs_of_type_callable) - app.connect("builder-inited", _capture_template_environment) + app.connect("builder-inited", _capture_build_environment) # Run after the source-code linker has injected generated testcase Needs and # their verification backlinks (priority 525), so report templates can # include those testcases in their traceability tables. - app.connect( - "env-updated", _rerender_pages_with_complex_post_templates, priority=600 - ) + app.connect("env-updated", _reread_post_template_pages, priority=600) return { "version": "1.0.0", From 081db7b9ffd3df065d8291dbbb174a4ee5ca6542 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 16 Sep 2026 01:33:30 +0200 Subject: [PATCH 3/5] fix: address review findings for rendering fixes --- .../docs/generate_metamodel_rst.py | 13 ++++++--- .../score_sphinx_bundle/__init__.py | 2 +- .../score_sphinx_needs_templates/README.md | 2 +- .../score_sphinx_needs_templates/__init__.py | 28 ++++++++++++++++--- .../_expected/docs/data_test/index.html | 2 +- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/extensions/score_metamodel/docs/generate_metamodel_rst.py b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py index cd3b12ca5..0a0e959d5 100644 --- a/src/extensions/score_metamodel/docs/generate_metamodel_rst.py +++ b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py @@ -46,6 +46,7 @@ import sys from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass, replace +from hashlib import sha256 from pathlib import Path from typing import Any, cast @@ -83,11 +84,15 @@ def _mermaid_identifier(name: str) -> str: Metamodel type names are also directive names and may contain hyphens. Mermaid class declarations accept those names, but Mermaid's ``style`` - statements do not accept a hyphenated target. Underscores preserve a - readable, deterministic identifier while the original name remains the - displayed class label. + statements do not accept a hyphenated target. A short stable digest + distinguishes names that normalize to the same identifier, while the + original name remains the displayed class label. """ - return name.replace("-", "_") + normalized = name.replace("-", "_") + if normalized == name: + return name + suffix = sha256(name.encode("utf-8")).hexdigest()[:8] + return f"{normalized}_{suffix}" @dataclass(frozen=True) diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index 6d7deb045..1628647fc 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -53,7 +53,7 @@ def setup(app: Sphinx) -> dict[str, object]: # Global settings # Note: the "sub-extensions" also set their own config values - # Match the current GitHub and ubCode Mermaid renderers. + # Match the current GitHub and VS Code Mermaid renderers. config_setdefault(app.config, "mermaid_version", "11.17.2") config_setdefault(app.config, "mermaid_d3_zoom", True) config_setdefault(app.config, "mermaid_include_elk", True) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index 75d9ef33b..fa76b5a7a 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -24,7 +24,7 @@ The extension provides: * the shared `src/needs_templates` directory as the Sphinx-Needs template directory; * the `linked_needs(need_id, link_name)` helper for traversing Need links; -* the `needs_of_type(need_type)` helper for selecting top-level graph roots; +* the `needs_of_type(need_type)` helper for selecting Needs by type; * support for graph-driven `post_template`s that are rendered after parallel Need collection has been merged; * ordinary Sphinx page navigation for sections generated by those diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index a09c8e931..b9619a4a0 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -20,9 +20,11 @@ from src.helper_lib import config_setdefault _build_environment: BuildEnvironment | None = None + # Post-templates containing this marker need a second read after parallel Need # collection has been merged. _RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection" + # During that second read, ``env.clear_doc()`` temporarily removes all Needs # belonging to the document from Sphinx's collection. Keep those temporarily # removed Needs available so templates can resolve links while the document is @@ -54,10 +56,16 @@ def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: def _get_available_needs() -> dict[str, NeedItem]: """Return the current Needs, including temporarily removed Needs.""" if _build_environment is None: + # The render helpers are registered before ``builder-inited`` captures + # the environment. There is no Need collection to query before then. return {} needs = SphinxNeedsData(_build_environment).get_needs_mutable() if _temporarily_removed_needs: + # ``env.clear_doc()`` removes the Needs belonging to the page being + # reread. Preserve the live collection and overlay the saved entries + # so templates can resolve links during that reread without mutating + # Sphinx's collection while it is being rebuilt. needs = dict(needs) needs.update(_temporarily_removed_needs) return needs @@ -94,8 +102,14 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: source = _find_need(needs, need_id) if link_name.endswith("_back"): + # A ``*_back`` name asks for the reverse of an ordinary outgoing + # link. Strip the suffix because Sphinx-Needs stores backlinks + # under the original link type. link_type = link_name.removesuffix("_back") if source is not None: + # Prefer Sphinx-Needs' backlink index when the source Need is + # present. Resolving each backlink through ``needs`` also + # handles version-qualified or imported Need IDs uniformly. links = source.get_backlinks(link_type, as_str=False) if links: return [ @@ -106,10 +120,10 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: ] # During a post-template reread, Sphinx-Needs may not have rebuilt - # backlink caches yet. The current Need is also not registered in - # the environment while its own post-template is being rendered. - # Derive the reverse relation from outgoing links so both cases - # remain usable by graph-driven templates. + # backlink caches yet. The current Need may also be temporarily + # absent from the live environment while its document is reread. + # Derive the reverse relation from outgoing links so templates can + # still find all Needs that point to the requested Need. source_id = _base_need_id(need_id) return [ candidate @@ -122,6 +136,9 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: else: if source is None: return [] + # For an ordinary link name, Sphinx-Needs already stores the + # outgoing links on the source Need. Resolve those links against + # the combined live-and-snapshot collection below. links = source.get_links(link_name, as_str=False) linked: list[NeedItem] = [] @@ -206,6 +223,9 @@ def _reread_post_template_pages(app: Sphinx, env: BuildEnvironment) -> list[str] env.clear_doc(docname) app.builder.read_doc(docname) finally: + # The exception, if any, must still propagate. Clear the process-local + # snapshot first so stale Needs cannot affect later rereads or builds + # that continue in the same Python process. _temporarily_removed_needs = {} return pages_to_reread diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/docs/data_test/index.html b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/docs/data_test/index.html index b551a3006..17090d47f 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/docs/data_test/index.html +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/docs/data_test/index.html @@ -63,7 +63,7 @@ -