diff --git a/.architecture-rules/frontend.yaml b/.architecture-rules/frontend.yaml index a31b3a7a..4a4ff87f 100644 --- a/.architecture-rules/frontend.yaml +++ b/.architecture-rules/frontend.yaml @@ -644,6 +644,37 @@ template_rules: exceptions: - "shared/macros/headers.html" + - id: "TPL-016" + name: "Persona templates >75 LOC must include a shared/ partial" + severity: "warning" + description: | + Any persona template (under app/modules//templates//{admin,merchant,store}/*.html) + that exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial is likely + duplicating body content that should live in a shared partial used by all three + personas. See docs/architecture/persona-template-consolidation.md for the pattern. + + RIGHT (thin wrapper + shared body): + {% extends "store/base.html" %} + ...page-header + loading/error... + {% set cards_api_prefix = '/store/loyalty' %} + {% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %} + {% include 'loyalty/shared/cards-list.html' %} + + WRONG (inlined table + filters that already exist in shared/): + {% extends "store/base.html" %} + ...200 lines of inline /filters/pagination identical to merchant's... + + Suppress for legit exceptions (admin multi-merchant aggregator, store-only + hardware UI, persona-unique tabbed dashboard) with `{# noqa: TPL-016 #}` + anywhere in the file. + pattern: + file_pattern: "app/modules/*/templates/*/{admin,merchant,store}/*.html" + threshold_loc: 75 + required_pattern: "{% include .*/shared/.*" + exceptions: + - "base.html" + - "partials/" + - id: "TPL-014" name: "Use new modal_simple macro API with call block" severity: "error" diff --git a/scripts/validate/validate_architecture.py b/scripts/validate/validate_architecture.py index 5203a763..44ecbe3a 100755 --- a/scripts/validate/validate_architecture.py +++ b/scripts/validate/validate_architecture.py @@ -871,6 +871,10 @@ class ArchitectureValidator: if not is_macro: self._check_static_v_usage(file_path, content, lines) + # TPL-016: Persona templates >75 LOC should include a shared/ partial + if not is_macro: + self._check_persona_template_shared_include(file_path, content, lines) + # TPL-004: Check x-text usage for dynamic content self._check_xtext_usage(file_path, content, lines) @@ -1832,6 +1836,55 @@ class ArchitectureValidator: suggestion="Replace url_for(...) with static_v(request, ...) so the URL carries ?v=", ) + _TPL_016_THRESHOLD = 75 + _TPL_016_PERSONA_RE = re.compile( + r"app/modules/[^/]+/templates/[^/]+/(?:admin|merchant|store)/[^/]+\.html$" + ) + _TPL_016_SHARED_INCLUDE_RE = re.compile( + r"""\{%\s*include\s+['"][^'"]*/shared/[^'"]+['"]""" + ) + + def _check_persona_template_shared_include( + self, file_path: Path, content: str, lines: list[str] + ): + """TPL-016: Persona templates >75 LOC should include a shared/ partial. + + Catches new persona-specific templates that inline body content already + living (or worth living) in `/templates//shared/*.html`. + See docs/architecture/persona-template-consolidation.md. + + Skip on file-level `noqa: TPL-016` comment. + """ + if not self._TPL_016_PERSONA_RE.search(str(file_path).replace("\\", "/")): + return + if len(lines) <= self._TPL_016_THRESHOLD: + return + if any( + "noqa: tpl-016" in line.lower() or "noqa: tpl016" in line.lower() + for line in lines + ): + return + if self._TPL_016_SHARED_INCLUDE_RE.search(content): + return + + self._add_violation( + rule_id="TPL-016", + rule_name="Persona templates >75 LOC must include a shared/ partial", + severity=Severity.WARNING, + file_path=file_path, + line_number=1, + message=( + f"Persona template is {len(lines)} LOC and doesn't include a shared/ partial — " + "likely duplicates body content already in a sibling persona" + ), + context=file_path.name, + suggestion=( + "Extract the body into app/modules//templates//shared/-(list|form|view).html " + "and {% include %} it from this wrapper. Or add {# noqa: TPL-016 #} if intentionally standalone " + "(see docs/architecture/persona-template-consolidation.md for the heuristic)." + ), + ) + def _validate_api_endpoints(self, target_path: Path): """Validate API endpoint rules (API-001, API-002, API-003, API-004)""" print("📡 Validating API endpoints...") @@ -3551,6 +3604,10 @@ class ArchitectureValidator: if not is_base_or_partial and not is_macro: self._check_static_v_usage(file_path, content, lines) + # TPL-016: Persona templates >75 LOC should include a shared/ partial + if not is_base_or_partial and not is_macro: + self._check_persona_template_shared_include(file_path, content, lines) + # TPL-008: Check for call table_header() pattern self._check_table_header_call_pattern(file_path, content, lines)