From e9cb7c92fef2e0ee92d0b01cd87620e7c0d99e56 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:12:41 +0200 Subject: [PATCH 1/4] test: specify that only module-level settings activate the request processor A TEMPLATES bound in a function, called or not, or in a class configures nothing; a function reading, mutating or rebinding the module's TEMPLATES leaves the activation uncertain; a local TEMPLATES beside module settings changes nothing. --- tests/test_settings_scope.py | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_settings_scope.py diff --git a/tests/test_settings_scope.py b/tests/test_settings_scope.py new file mode 100644 index 0000000..0a1ec5b --- /dev/null +++ b/tests/test_settings_scope.py @@ -0,0 +1,109 @@ +"""Only module-level settings configure Django. + +The request context processor is taken as active only from the settings a project +assigns at module level: a ``TEMPLATES`` assigned inside a function, called or not, +binds a local name and configures nothing, and so does a class attribute. A function +that reads or rebinds the module's ``TEMPLATES`` (through ``global``, or by mutating +it) may change the effective settings, so it leaves the activation uncertain, as any +other mention does. Uncertain activation leaves the filter reachable, neither +exploitable through the processor nor declared absent. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories +from coretrace_python.findings import Severity +from coretrace_python.semantic.symbols import SymbolId + +STRIPTAGS = "python.django.template.defaultfilters.striptags" +ADVISORY = Advisory( + "CVE-2099-5601", + "django", + "<4.2.17", + "striptags is quadratic in its input", + Severity.MEDIUM, + entry_points=(AdvisoryEntryPoint(SymbolId(STRIPTAGS), "a template filter"),), +) +LOCK = """version = 1 + +[[package]] +name = "app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [{ name = "django" }] + +[[package]] +name = "django" +version = "4.2.16" +source = { registry = "https://pypi.org/simple" } +""" +ENGINE = ( + "{'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, " + "'OPTIONS': {'context_processors': ['django.template.context_processors.request']}}" +) +VIEW = ( + "from django.http import HttpRequest\n" + "from django.shortcuts import render\n\n" + "def search(request: HttpRequest):\n" + " return render(request, 'app/search.html')\n" +) +PAGE = "app/templates/app/search.html" + + +def levels(root: Path, settings: str) -> list[str]: + files = { + "uv.lock": LOCK, + "app/__init__.py": "", + "app/settings.py": settings, + "app/views.py": VIEW, + PAGE: "{{ request.GET.q|striptags }}\n", + } + for relative, text in files.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS]).findings + reached = ("reachable-vulnerability", "exploitable-vulnerability") + return sorted(f.metadata["level"] for f in findings if f.rule_id in reached and f.metadata["advisory"] == ADVISORY.id) + + +def test_module_level_settings_activate_the_processor(tmp_path: Path) -> None: + assert levels(tmp_path, f"TEMPLATES = [{ENGINE}]\n") == ["exploitable", "reachable"] + + +@pytest.mark.parametrize( + "settings", + [ + f"def never_called():\n TEMPLATES = [{ENGINE}]\n", + f"def configure():\n TEMPLATES = [{ENGINE}]\n return TEMPLATES\n\nconfigure()\n", + f"class Settings:\n TEMPLATES = [{ENGINE}]\n", + f"def build():\n templates = [{ENGINE}]\n return templates\n", + ], + ids=["uncalled-function", "called-function", "class-attribute", "other-local-name"], +) +def test_settings_bound_below_the_module_do_not_configure_django(tmp_path: Path, settings: str) -> None: + assert levels(tmp_path, settings) == ["reachable"] + + +@pytest.mark.parametrize( + "settings", + [ + f"TEMPLATES = [{ENGINE}]\n\ndef tweak():\n TEMPLATES[0]['OPTIONS']['context_processors'].pop(0)\n", + f"TEMPLATES = [{ENGINE}]\n\ndef reset():\n global TEMPLATES\n TEMPLATES = []\n", + f"TEMPLATES = [{ENGINE}]\n\ndef read():\n return TEMPLATES\n", + ], + ids=["mutated-in-a-function", "rebound-through-global", "read-in-a-function"], +) +def test_a_function_touching_the_module_settings_leaves_activation_uncertain(tmp_path: Path, settings: str) -> None: + assert levels(tmp_path, settings) == ["reachable"] + + +def test_a_local_name_beside_module_settings_changes_nothing(tmp_path: Path) -> None: + settings = f"TEMPLATES = [{ENGINE}]\n\ndef helper():\n TEMPLATES = []\n return TEMPLATES\n" + + assert levels(tmp_path, settings) == ["exploitable", "reachable"] From b2e1f231fe5b50dc5891b6a8e59279a80c52ba35 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:12:41 +0200 Subject: [PATCH 2/4] fix: take only module-level TEMPLATES as Django settings request_processor walked every node of every module and counted any assignment to TEMPLATES, so a function-local or class-level assignment activated the request context processor. It now walks the modules with their scope tables: an assignment counts only at module scope, and a mention counts only when the name resolves to the module's binding, or to nothing, as a star import gives. ScopeTable.scope_for accepts a lambda, whose scope the builder already records. --- CHANGELOG.md | 6 ++++ docs/usage.md | 9 ++++-- src/coretrace_python/engine.py | 3 +- src/coretrace_python/semantic/scopes.py | 4 +-- src/coretrace_python/taint/templates.py | 39 ++++++++++++++++--------- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f04f2..3adce12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Precision + +- A `TEMPLATES` assigned inside a function or a class no longer counts as Django settings: only a module-level assignment can activate the `request` context processor, and only a mention of the module's `TEMPLATES` elsewhere leaves that activation uncertain. A function-local `TEMPLATES`, called or not, used to enable the implicit request source, upgrading a template filter finding to exploitable (#184). + ## 0.18.0 (2026-09-28) ### Precision diff --git a/docs/usage.md b/docs/usage.md index 35c8e90..fce946f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -399,9 +399,12 @@ With the `django.template.context_processors.request` context processor, a templ rendered with the request reads it as `request`: `{{ request.GET.q|striptags }}` is exploitable at the render call too. The analyzer counts it only where all of these hold: -- The project's settings assign `TEMPLATES` a literal list, every `DjangoTemplates` - engine of it lists the processor in a literal `context_processors`, and no other code - of the project names `TEMPLATES`. A mere mention of the processor proves nothing. +- The project's settings assign `TEMPLATES` a literal list at module level, every + `DjangoTemplates` engine of it lists the processor in a literal `context_processors`, + and no other code of the project names the module's `TEMPLATES`, or a `TEMPLATES` it + does not bind, as a star import gives. A `TEMPLATES` bound in a function or a class + is another name: it configures nothing and changes nothing. + A mere mention of the processor proves nothing. - The render call passes the request: `render` and `TemplateResponse` always do, `render_to_string` when given `request`, `SimpleTemplateResponse` never. - The render's context is certain, absent or a dict literal as above, so it cannot hide diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index b877096..2d70a21 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -447,7 +447,8 @@ def analyze_project( templates = project_templates(root) clearing = models.clearing(escaped) template_calls = models.template_calls( - templates.filters, request_processor(modules[name] for name in sorted(analysable)) + templates.filters, + request_processor((modules[name], analysable[name].get(ScopeAnalysis)) for name in sorted(analysable)), ) for manager in analysable.values(): manager.provide(RegisteredRoutes, routes) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 9f4a3b2..4120871 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -113,8 +113,8 @@ def scope(self, scope_id: ScopeId) -> Scope: def children(self, scope_id: ScopeId) -> tuple[Scope, ...]: return self._children[scope_id] - def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope: - """Return the scope introduced by a definition or comprehension node.""" + def scope_for(self, node: nodes.Function | nodes.Class | nodes.Lambda | nodes.Comprehension) -> Scope: + """Return the scope introduced by a definition, lambda or comprehension node.""" return self._scopes[self._spans[node.span]] diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index a41ed57..1c56df1 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -44,6 +44,7 @@ TemplateFilter, discover_files, ) +from coretrace_python.semantic.scopes import ResolutionKind, ScopeId, ScopeTable from coretrace_python.semantic.symbols import SymbolId from coretrace_python.source import SourceId, SourceSpan from coretrace_python.taint.models import ModelTable @@ -578,29 +579,41 @@ def _through(bound: frozenset[str], name: str) -> frozenset[str]: return frozenset(".".join(f"{b}.{attribute}".split(".")[:2]) if attribute else b for b in bound) -def request_processor(modules: Iterable[nodes.Module]) -> bool: +def request_processor(modules: Iterable[tuple[nodes.Module, ScopeTable]]) -> bool: """Whether every Django template engine the project's settings configure runs the - ``request`` context processor, for certain: each assignment to ``TEMPLATES`` is a - literal list of literal engines, at least one of them ``DjangoTemplates``, and each - of those lists the processor in a literal ``context_processors``; no other code - names ``TEMPLATES``, which could change it. A mere mention of the processor proves - nothing.""" + ``request`` context processor, for certain: each module-level assignment to + ``TEMPLATES`` is a literal list of literal engines, at least one of them + ``DjangoTemplates``, and each of those lists the processor in a literal + ``context_processors``; no other code names the module's ``TEMPLATES``, or a + ``TEMPLATES`` it does not bind (a star import), which could change it. A + ``TEMPLATES`` bound in a function or a class is another name, which configures + nothing and changes nothing. A mere mention of the processor proves nothing.""" assignments = mentions = 0 - for module in modules: - stack: list[Node] = list(module.body) - while stack: - node = stack.pop() - stack.extend(children(node)) + for module, scopes in modules: + for node, scope in _scoped(module, scopes): if isinstance(node, nodes.Name) and node.identifier == _ENGINES_SETTING: - mentions += 1 - elif isinstance(node, nodes.Assign) and _names_engines(node.target): + if scopes.resolve(scope, node.identifier).kind in (ResolutionKind.GLOBAL, ResolutionKind.UNBOUND): + mentions += 1 + elif isinstance(node, nodes.Assign) and _names_engines(node.target) and scope == scopes.module_scope.id: if not _runs_request_processor(node.value): return False assignments += 1 return assignments > 0 and mentions == assignments +def _scoped(module: nodes.Module, scopes: ScopeTable) -> Iterator[tuple[Node, ScopeId]]: + """Every node of ``module`` with the scope it stands in.""" + + stack: list[tuple[Node, ScopeId]] = [(node, scopes.module_scope.id) for node in module.body] + while stack: + node, scope = stack.pop() + yield node, scope + if isinstance(node, nodes.Function | nodes.Lambda | nodes.Class | nodes.Comprehension): + scope = scopes.scope_for(node).id + stack.extend((child, scope) for child in children(node)) + + def _names_engines(target: nodes.Expression) -> bool: return isinstance(target, nodes.Name) and target.identifier == _ENGINES_SETTING From 5b504720c7ec065a6592cd9e8b6502b9e4fcd3e3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:16:34 +0200 Subject: [PATCH 3/4] test: specify that block.super keeps the parent block's filters in the flow A block rendering {{ block.super }} extends the parent's block: its filters stay fed by the render context, with the parent template as the place of the call, however block.super is rendered; a block without it replaces the parent's whole block; other overridden blocks stay excluded; a chain of templates keeps exactly the parents each level renders, once. --- tests/test_block_super.py | 155 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/test_block_super.py diff --git a/tests/test_block_super.py b/tests/test_block_super.py new file mode 100644 index 0000000..b5a4e63 --- /dev/null +++ b/tests/test_block_super.py @@ -0,0 +1,155 @@ +"""``{{ block.super }}`` keeps the parent block's filters in the render context flow. + +A template extending another renders only its blocks, which replace the blocks of that +name in the parent. A block that renders ``{{ block.super }}`` extends the parent's +block instead of replacing it: Django executes the parent's content, so the filters it +applies stay fed by the render context, with the parent template as the place of the +call. A block without ``block.super`` still replaces the parent's whole block, other +overridden blocks stay excluded, and a chain of templates keeps exactly the parents each +level renders, without duplicate findings. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories +from coretrace_python.findings import Severity +from coretrace_python.semantic.symbols import SymbolId + +STRIPTAGS = "python.django.template.defaultfilters.striptags" +ADVISORY = Advisory( + "CVE-2099-5701", + "django", + "<4.2.17", + "striptags is quadratic in its input", + Severity.MEDIUM, + entry_points=(AdvisoryEntryPoint(SymbolId(STRIPTAGS), "a template filter"),), +) +LOCK = """version = 1 + +[[package]] +name = "app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [{ name = "django" }] + +[[package]] +name = "django" +version = "4.2.16" +source = { registry = "https://pypi.org/simple" } +""" +VIEW = ( + "from django.http import HttpRequest\n" + "from django.shortcuts import render\n\n" + "def profile(request: HttpRequest):\n" + " return render(request, 'app/profile.html', {'bio': request.POST['bio']})\n" +) +LINE = 5 +BASE = "app/templates/app/base.html" +MIDDLE = "app/templates/app/middle.html" +PAGE = "app/templates/app/profile.html" + + +def exploitable(root: Path, templates: dict[str, str]) -> list[tuple[int, str, str]]: + """The exploitable striptags calls the render makes: the render line, the template + holding the filter, and the line of the filter there.""" + + files = {"uv.lock": LOCK, "app/__init__.py": "", "app/views.py": VIEW, **templates} + for relative, text in files.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS]).findings + return sorted( + (f.span.start_line, f.metadata["through"], f.metadata["sink_line"]) + for f in findings + if f.rule_id == "exploitable-vulnerability" and f.metadata["advisory"] == ADVISORY.id + ) + + +def test_a_block_rendering_block_super_keeps_the_parent_flow(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] + + +def test_a_block_may_add_its_own_filters_beside_block_super(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}

{{ bio|striptags }}

\n{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/profile.html", "2")] + + +def test_a_replacement_without_block_super_still_excludes_the_parent(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ bio }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [] + + +def test_another_block_does_not_come_back_through_one_block_super(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n{% block aside %}{{ bio|striptags }}{% endblock %}\n", + PAGE: ( + "{% extends 'app/base.html' %}\n" + "{% block body %}{{ block.super }}{% endblock %}\n" + "{% block aside %}{{ bio }}{% endblock %}\n" + ), + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] + + +def test_block_super_at_each_level_keeps_the_whole_chain_once(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/middle.html", "2")] + + +def test_a_replacement_in_the_middle_cuts_the_chain_above_it(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/middle.html", "2")] + + +def test_a_replacement_at_the_end_cuts_the_whole_chain(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ bio }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [] + + +@pytest.mark.parametrize( + "child_block", + ["{{ block.super|lower }}", "{% if bio %}{{ block.super }}{% endif %}", "{{ block.super }}{{ block.super }}"], + ids=["filtered", "conditional", "twice"], +) +def test_block_super_counts_however_it_is_rendered(tmp_path: Path, child_block: str) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}" + child_block + "{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] From 9e5beee44eea18f7d0956f55cee594085374d021 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:16:34 +0200 Subject: [PATCH 4/4] fix: keep the parent block's filters when a child renders block.super A template extending another replaced every block it defined in the parent, so the parent's filter applications dropped out of the render context flow even when the child rendered {{ block.super }}, which Django executes. A template now records the blocks that render block.super, and only the blocks defined without it are passed up as replaced, level by level. --- CHANGELOG.md | 6 ++++++ docs/usage.md | 3 ++- src/coretrace_python/taint/templates.py | 20 +++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f04f2..e2bbbec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Precision + +- A template block that renders `{{ block.super }}` keeps the parent block's content, so the filters the parent applies stay fed by the render context, with the parent template as the place of the call; a block without it still replaces the parent's whole block, and a chain of templates keeps exactly the parents each level renders. The parent's filters used to drop out of the exploitable flow as soon as a child defined the block (#185). + ## 0.18.0 (2026-09-28) ### Precision diff --git a/docs/usage.md b/docs/usage.md index 35c8e90..157821e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -386,7 +386,8 @@ certain of it: arguments of the filters before it, or the output of a `{% filter %}` block. A name a tag binds shadows the context, and `{% include ... only %}` passes none of it. - The filter renders: a template extending another renders only its blocks, which - replace the blocks of that name in what it extends. + replace the blocks of that name in what it extends, unless the block renders + `{{ block.super }}`, which keeps the parent's content and the filters it applies. - One file has the template's name; of two files of the same name, which one renders depends on the loaders. diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index a41ed57..e166b30 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -131,6 +131,8 @@ # the request context processor adds gives (``request.GET``). _VARIABLE = re.compile(r"([A-Za-z_]\w*)(?:\.(\w+))?") _LITERALS = frozenset({"True", "False", "None"}) +# The variable a block renders to keep the parent's content, as ``_chain`` reads it. +_BLOCK_SUPER = "block.super" REQUEST_PROCESSOR = "django.template.context_processors.request" _DJANGO_ENGINE = "django.template.backends.django.DjangoTemplates" @@ -365,11 +367,13 @@ def maps(self, names: frozenset[str]) -> frozenset[str]: @dataclass(frozen=True) class _Template: - """What one template file applies and references, and the ``blocks`` it defines.""" + """What one template file applies and references, the ``blocks`` it defines, and + among them the ``extended`` ones, which render ``{{ block.super }}``.""" applications: tuple[_Application, ...] references: tuple[_Reference, ...] blocks: frozenset[str] + extended: frozenset[str] = frozenset() def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool: """Whether what stands in ``blocks`` renders when a template extending this one @@ -379,6 +383,13 @@ def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool: extends = any(reference.extends for reference in self.references) return not overridden.intersection(blocks) and (bool(blocks) or not extends) + @property + def replaced(self) -> frozenset[str]: + """The blocks this template replaces in what it extends: those it defines + without rendering ``{{ block.super }}``, which keeps the parent's content.""" + + return self.blocks - self.extended + def project_templates(root: Path) -> ProjectTemplates: """What the templates under ``root`` call, and the templates they include or extend @@ -444,7 +455,7 @@ def _reaching( if reference.name is None: continue if reference.extends: - found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.blocks) + found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.replaced) elif template.rendered(reference.blocks, overridden): found = _reaching(reference.name, files, read, visiting | {name}) else: @@ -463,6 +474,7 @@ def _read_scopes(text: str, source: SourceId) -> _Template: references: list[_Reference] = [] opened: list[str] = [] defined: set[str] = set() + extended: set[str] = set() scopes: list[dict[str, frozenset[str]]] = [{}] blocks: list[tuple[list[tuple[str, int, frozenset[str]]], set[str]]] = [] @@ -491,6 +503,8 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> for offset, expression, tag in _expressions(text): if not tag: head, chain = _chain(expression, offset) + if opened and _BLOCK_SUPER in head: + extended.add(opened[-1]) printed = apply(free(head), chain) for _, collected in blocks: collected |= printed @@ -546,7 +560,7 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> elif "as" in words[1:-1]: target = words[words.index("as", 1) + 1] scopes[-1][target] = values.get(words[1], frozenset()) if name == "regroup" else frozenset() - return _Template(tuple(applications), tuple(references), frozenset(defined)) + return _Template(tuple(applications), tuple(references), frozenset(defined), frozenset(extended)) def _chain(expression: str, offset: int) -> tuple[frozenset[str], list[tuple[str, int, frozenset[str]]]]: