diff --git a/roboco/conventions/custom.py b/roboco/conventions/custom.py index 45af16c3..9e438a4f 100644 --- a/roboco/conventions/custom.py +++ b/roboco/conventions/custom.py @@ -18,6 +18,26 @@ if TYPE_CHECKING: CustomRule, ) +# Dialect relations: the validator tags a ``.tsx`` file as language ``tsx`` +# (the JSX grammar needs that tag, distinct from plain ``typescript``), but a +# custom rule scoped to ``typescript`` — the language the scan *reports* for a +# React+TS repo — must still apply to ``.tsx`` files. The relation is +# one-directional: ``tsx`` is a TypeScript dialect, so a ``typescript``-scoped +# rule fires on ``.tsx``, but a ``tsx``-scoped (JSX-only) rule does not fire on +# plain ``.ts``. Map each dialect tag to the language family it belongs to. +_DIALECT_OF: dict[str, str] = {"tsx": "typescript"} + + +def _rule_applies(rule_languages: list[str], file_language: str) -> bool: + """Whether a custom rule scoped to ``rule_languages`` applies to a file of + ``file_language``. An unscoped rule (empty list) applies to everything.""" + if not rule_languages: + return True + if file_language in rule_languages: + return True + family = _DIALECT_OF.get(file_language) + return family is not None and family in rule_languages + def check_custom( rel_path: str, source: bytes, language: str, standard: ConventionsStandard @@ -26,7 +46,7 @@ def check_custom( text = source.decode(errors="replace") findings: list[Finding] = [] for rule in standard.custom: - if rule.languages and language not in rule.languages: + if not _rule_applies(rule.languages, language): continue findings.extend(_matches(rel_path, text, rule)) return findings diff --git a/tests/unit/conventions/test_custom.py b/tests/unit/conventions/test_custom.py index f15973cc..b29e5a7e 100644 --- a/tests/unit/conventions/test_custom.py +++ b/tests/unit/conventions/test_custom.py @@ -47,3 +47,60 @@ def test_bad_regex_abstains_without_crashing() -> None: rule = CustomRule(id="bad", pattern=r"(unclosed", message="m", level="block") std = ConventionsStandard(custom=[rule]) assert check_custom("a.py", b"anything\n", "python", std) == [] + + +# --------------------------------------------------------------------------- +# .tsx is a TypeScript dialect: a rule scoped to ``typescript`` must fire on a +# ``.tsx`` file. The validator tags a .tsx file as language ``tsx`` (the JSX +# grammar needs that tag), so without dialect-awareness in the scoping check a +# ``languages: [typescript]`` rule — the language the scan *reports* for a +# React+TS repo — silently skipped every .tsx file. +# --------------------------------------------------------------------------- + +_NO_CONSOLE = CustomRule( + id="no-console", + pattern=r"console\.", + message="no console in app code", + level="warn", + languages=["typescript"], +) + +_JSX_ONLY = CustomRule( + id="jsx-only", + pattern=r"jsx", + message="jsx-specific", + level="warn", + languages=["tsx"], +) + + +def test_typescript_scoped_rule_fires_on_tsx_file() -> None: + """A ``typescript``-scoped rule fires on a ``.tsx`` (language ``tsx``) + file — tsx is a typescript dialect, and the scan reports the project as + ``typescript`` so operators scope rules to that.""" + std = ConventionsStandard(custom=[_NO_CONSOLE]) + findings = check_custom("a.tsx", b"console.log(1)\n", "tsx", std) + assert len(findings) == 1 + assert findings[0].rule == "no-console" + + +def test_typescript_scoped_rule_still_fires_on_ts_file() -> None: + """Sanity: the typescript-scoped rule still fires on a plain ``.ts`` + (language ``typescript``) file — the dialect fix must not regress the + direct-match case.""" + std = ConventionsStandard(custom=[_NO_CONSOLE]) + assert len(check_custom("a.ts", b"console.log(1)\n", "typescript", std)) == 1 + + +def test_tsx_scoped_rule_does_not_fire_on_plain_typescript() -> None: + """A ``tsx``-scoped rule (JSX-only) must NOT fire on a plain ``.ts`` + file — the dialect relation is one-directional (tsx ⊂ typescript), so a + JSX-specific rule does not apply to non-JSX TypeScript.""" + std = ConventionsStandard(custom=[_JSX_ONLY]) + assert check_custom("a.ts", b"var jsx = 1\n", "typescript", std) == [] + + +def test_tsx_scoped_rule_fires_on_tsx_file() -> None: + """Sanity: a ``tsx``-scoped rule still fires on a ``.tsx`` file directly.""" + std = ConventionsStandard(custom=[_JSX_ONLY]) + assert len(check_custom("a.tsx", b"var jsx = 1\n", "tsx", std)) == 1