diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index d1544755..45749f04 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -86,7 +86,7 @@ jobs: pyright: permissions: contents: read - name: Pyright data-flow checks + name: Pyright data-flow + typed storage slice runs-on: ubuntu-latest timeout-minutes: 8 @@ -101,9 +101,14 @@ jobs: with: node-version: "24" - - name: Run Pyright + # Narrow diagnostics across prks_app.py + backend (typeCheckingMode: off). + - name: Run Pyright data-flow checks run: npm exec --yes --ignore-scripts --package="pyright@${PYRIGHT_VERSION}" -- pyright --project pyrightconfig.json + # Genuine basic type checking for the first #69 slice: backend/storage. + - name: Run Pyright typed slice (backend/storage, basic) + run: npm exec --yes --ignore-scripts --package="pyright@${PYRIGHT_VERSION}" -- pyright --project pyrightconfig.typed-slice.json + eslint: permissions: contents: read diff --git a/pyrightconfig.typed-slice.json b/pyrightconfig.typed-slice.json new file mode 100644 index 00000000..f96b6275 --- /dev/null +++ b/pyrightconfig.typed-slice.json @@ -0,0 +1,13 @@ +{ + "include": [ + "backend/storage" + ], + "exclude": [ + "**/__pycache__" + ], + "pythonVersion": "3.12", + "typeCheckingMode": "basic", + "reportUndefinedVariable": "error", + "reportUnboundVariable": "error", + "reportUnusedExcept": "error" +} diff --git a/scripts/check_invariants.py b/scripts/check_invariants.py index d4b6027d..087abcad 100644 --- a/scripts/check_invariants.py +++ b/scripts/check_invariants.py @@ -12,12 +12,45 @@ import argparse import ast +import fnmatch +import json +import re from dataclasses import dataclass from pathlib import Path from typing import Iterable REPO_ROOT = Path(__file__).resolve().parents[1] +# First #69 Pyright slice: genuine basic type checking for backend/storage. +# Kept next to the AST invariants so Fast Static Analysis fails if the typed +# slice silently reverts to effectively-off mode. +PYRIGHT_DATAFLOW_CONFIG = "pyrightconfig.json" +PYRIGHT_DATAFLOW_REQUIRED_INCLUDE = "backend" +PYRIGHT_TYPED_SLICE_CONFIG = "pyrightconfig.typed-slice.json" +PYRIGHT_TYPED_SLICE_INCLUDE = ("backend/storage",) +PYRIGHT_TYPED_SLICE_ROOT = "backend/storage" +PYRIGHT_TYPED_SLICE_MODES = frozenset({"basic", "standard", "strict"}) +# Only __pycache__ exclusions are permitted on the typed slice; anything else +# that covers backend/storage could silently suppress the checked scope. +PYRIGHT_TYPED_SLICE_ALLOWED_EXCLUDES = frozenset( + { + "**/__pycache__", + "**/__pycache__/**", + "__pycache__", + "__pycache__/", + } +) +PYRIGHT_REQUIRED_DIAGNOSTICS = ( + "reportUndefinedVariable", + "reportUnboundVariable", + "reportUnusedExcept", +) +STATIC_ANALYSIS_WORKFLOW = ".github/workflows/static-analysis.yml" +_PYRIGHT_PROJECT_ARG_RE = re.compile( + r"--project(?:\s+|=)(?P[\"']?)(?P[^\"'\s]+)(?P=q)" +) +_WORKFLOW_RUN_KEY_RE = re.compile(r"^(\s*)(?:-\s+)?run:\s*(.*)$") + # Calls that must not appear anywhere under backend/. New managed-file copies # must go through the durable storage capability instead of ad-hoc copy calls. BANNED_SHUTIL_COPY_CALLS = {"copy", "copy2", "copyfile"} @@ -238,6 +271,499 @@ def check_repo(root: Path = REPO_ROOT) -> list[Finding]: return findings +def _load_json_object(path: Path) -> dict | None: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + return raw if isinstance(raw, dict) else None + + +def _require_diagnostic_errors(cfg: dict, relpath: str) -> list[Finding]: + findings: list[Finding] = [] + for key in PYRIGHT_REQUIRED_DIAGNOSTICS: + if cfg.get(key) != "error": + findings.append( + Finding( + "INV-PYRIGHT-001", + relpath, + 1, + f"{key} must remain \"error\" (found {cfg.get(key)!r})", + ) + ) + return findings + + +def _normalize_pyright_path(entry: object) -> str: + return str(entry).replace("\\", "/").rstrip("/") + + +def _normalize_pyright_glob_pattern(pattern: str) -> str | None: + """Drop empty/``.`` segments and resolve ``..`` against a preceding literal. + + Returns ``None`` when ``..`` cannot be resolved (no preceding literal, or + the preceding segment is a glob such as ``**``). Callers treat ``None`` as + fail-closed: the entry covers the protected root. + """ + text = pattern.replace("\\", "/").strip() + out: list[str] = [] + for segment in text.split("/"): + if segment in {"", "."}: + continue + if segment == "..": + if not out: + return None + prev = out[-1] + if prev == "**" or any(ch in prev for ch in "*?["): + return None + out.pop() + continue + out.append(segment) + return "/".join(out) + + +def _is_allowed_cache_exclude(entry: object) -> bool: + raw = str(entry).replace("\\", "/").strip() + if raw in PYRIGHT_TYPED_SLICE_ALLOWED_EXCLUDES: + return True + normalized = _normalize_pyright_glob_pattern(raw) + if normalized is None: + return False + allowed = { + _normalize_pyright_path(item) for item in PYRIGHT_TYPED_SLICE_ALLOWED_EXCLUDES + } + return _normalize_pyright_path(normalized) in allowed + + +def _is_allowed_typed_slice_exclude(entry: object) -> bool: + return _is_allowed_cache_exclude(entry) + + +def _pyright_glob_match(path: str, pattern: str) -> bool: + """Match ``path`` against a Pyright/gitignore-style glob. + + ``**`` matches zero or more directories (unlike stdlib ``fnmatch``, where + mid-path ``**`` does not consume an empty directory span). + ``*`` and ``?`` match within a single path segment. + """ + path = path.replace("\\", "/").strip("/") + pattern = pattern.replace("\\", "/").strip("/") + if pattern in {"", "**"}: + return True + path_parts = path.split("/") if path else [] + pat_parts = pattern.split("/") if pattern else [] + + def match_from(pi: int, pti: int) -> bool: + while pti < len(pat_parts): + token = pat_parts[pti] + if token == "**": + # Zero-or-more directories: try consuming nothing, then 1..N parts. + if pti == len(pat_parts) - 1: + return True + for skip in range(pi, len(path_parts) + 1): + if match_from(skip, pti + 1): + return True + return False + if pi >= len(path_parts): + return False + if not fnmatch.fnmatchcase(path_parts[pi], token): + return False + pi += 1 + pti += 1 + return pi == len(path_parts) + + return match_from(0, 0) + + +def _literal_prefix_before_glob(pattern: str) -> str: + """Path segments before the first glob token (``*``, ``?``, ``**``, ``[…]``).""" + parts: list[str] = [] + for segment in pattern.replace("\\", "/").strip("/").split("/"): + if not segment: + continue + if segment == "**" or "*" in segment or "?" in segment or "[" in segment: + break + parts.append(segment) + return "/".join(parts) + + +def _glob_overlaps_typed_root(pattern: str, root: str) -> bool: + """True if ``pattern`` can match ``root``, an ancestor, or any path under it.""" + root_parts = root.replace("\\", "/").strip("/").split("/") + pat_parts = [p for p in pattern.replace("\\", "/").strip("/").split("/") if p] + if not pat_parts: + return True + + def dfs(pti: int, ri: int) -> bool: + if pti == len(pat_parts): + # Pattern exhausted on an ancestor or the root itself. + return ri <= len(root_parts) + + token = pat_parts[pti] + if token == "**": + if pti == len(pat_parts) - 1: + return True + for skip in range(ri, len(root_parts) + 1): + if dfs(pti + 1, skip): + return True + # Remaining tokens can match invented descendants under root. + return True + + if ri < len(root_parts): + if fnmatch.fnmatchcase(root_parts[ri], token): + return dfs(pti + 1, ri + 1) + return False + + # Past the root: any further pattern segments match some descendant. + return True + + return dfs(0, 0) + + +def _path_covers_root(entry: object, root: str) -> bool: + """True when ignore/exclude can match ``root`` or anything under it. + + Dot-segments are normalized first (``.`` dropped; ``..`` resolved against a + preceding literal). Unresolvable ``..`` (``../x``, ``**/..``) fails closed. + Cache-only ``__pycache__`` excludes are allowlisted. + """ + if _is_allowed_cache_exclude(entry): + return False + raw = str(entry).replace("\\", "/").strip() + normalized = _normalize_pyright_glob_pattern(raw) + if normalized is None: + return True + pattern = normalized.rstrip("/") + if pattern in {"", ".", "*", "**", "**/*", "**/**"}: + return True + + lit = _literal_prefix_before_glob(pattern) + # Clearly rooted at or under the protected root (globs may follow). + if lit == root or lit.startswith(root + "/"): + return True + # Literal-only parent of the root (no glob metacharacters anywhere). + if lit and not any(ch in pattern for ch in "*?["): + if root.startswith(lit + "/"): + return True + + return _glob_overlaps_typed_root(pattern, root) + + +def _path_covers_typed_slice(entry: object) -> bool: + """True when ignore/exclude can match ``backend/storage`` or anything under it.""" + return _path_covers_root(entry, PYRIGHT_TYPED_SLICE_ROOT) + + +def _reject_ignore_exclude_covering( + cfg: dict, + *, + protected_root: str, + config_name: str, + code: str, +) -> list[Finding]: + """Reject ignore/exclude entries that would silence ``protected_root``.""" + findings: list[Finding] = [] + for key in ("ignore", "exclude"): + value = cfg.get(key) + if value is None: + continue + if not isinstance(value, list): + findings.append( + Finding( + code, + config_name, + 1, + f"{config_name} {key} must be a list when present (found {type(value).__name__})", + ) + ) + continue + for entry in value: + if _path_covers_root(entry, protected_root): + findings.append( + Finding( + code, + config_name, + 1, + ( + f"{config_name} {key} entry {entry!r} would suppress " + f"{protected_root}; only __pycache__ exclusions are allowed" + ), + ) + ) + return findings + + +def _reject_typed_slice_suppression(cfg: dict) -> list[Finding]: + """Reject ignore/exclude entries that would silence the typed slice.""" + return _reject_ignore_exclude_covering( + cfg, + protected_root=PYRIGHT_TYPED_SLICE_ROOT, + config_name=PYRIGHT_TYPED_SLICE_CONFIG, + code="INV-PYRIGHT-002", + ) + + +def _workflow_run_scripts(workflow_text: str) -> list[str]: + """Collect executable ``run:`` script bodies (not YAML ``#`` comments). + + Dependency-free: Fast Static Analysis runs this checker without PyYAML. + Handles single-line ``run:`` and block scalars (``|`` / ``>``). + """ + scripts: list[str] = [] + lines = workflow_text.splitlines() + i = 0 + while i < len(lines): + raw = lines[i] + if raw.lstrip().startswith("#"): + i += 1 + continue + match = _WORKFLOW_RUN_KEY_RE.match(raw) + if match is None: + i += 1 + continue + indent = len(match.group(1)) + rest = match.group(2).rstrip() + block = rest in {"", "|", ">", "|-", ">-", "|+", ">+"} or rest.startswith(("|", ">")) + if not block: + scripts.append(rest) + i += 1 + continue + body: list[str] = [] + i += 1 + while i < len(lines): + nxt = lines[i] + if nxt.strip() == "": + body.append("") + i += 1 + continue + content_indent = len(nxt) - len(nxt.lstrip(" ")) + if content_indent <= indent: + break + body.append(nxt) + i += 1 + scripts.append("\n".join(body)) + return scripts + + +def _strip_shell_comment_lines(script: str) -> str: + kept: list[str] = [] + for line in script.splitlines(): + if line.lstrip().startswith("#"): + continue + kept.append(line) + return "\n".join(kept) + + +def _shell_chunk_invokes_pyright(chunk: str) -> bool: + """True when ``pyright`` is an invoked command, not text inside ``echo``.""" + # Drop quoted strings so ``echo "pyright --project X"`` does not count. + unquoted = re.sub(r'"[^"]*"', ' "" ', chunk) + unquoted = re.sub(r"'[^']*'", " '' ", unquoted) + tokens = unquoted.split() + if not tokens: + return False + head = tokens[0].rsplit("/", 1)[-1] + if head in {"echo", "printf", "cat"}: + return False + for index, token in enumerate(tokens): + name = token.rsplit("/", 1)[-1] + if name != "pyright": + continue + if index == 0: + return True + # npm/npx exec … -- pyright (or similar package runners) + if head in {"npm", "npx", "yarn", "pnpm"} and "--" in tokens[:index]: + return True + if tokens[index - 1] in {"--", "time", "command", "exec", "env"}: + return True + return False + + +def _executable_pyright_projects(workflow_text: str) -> set[str]: + """Project paths from real ``pyright --project`` invocations in ``run`` steps.""" + projects: set[str] = set() + for script in _workflow_run_scripts(workflow_text): + cleaned = _strip_shell_comment_lines(script) + for chunk in re.split(r"[;\n|&]+", cleaned): + chunk = chunk.strip() + if not chunk or not _shell_chunk_invokes_pyright(chunk): + continue + for match in _PYRIGHT_PROJECT_ARG_RE.finditer(chunk): + projects.add(match.group("path")) + return projects + + +def check_pyright_configs(root: Path = REPO_ROOT) -> list[Finding]: + """Keep the #69 typed slice from silently becoming effectively-off. + + The data-flow config may stay on ``typeCheckingMode: off`` (narrow + diagnostics only) but must still include ``backend``. The typed-slice + config must enable genuine analysis for ``backend/storage`` without + ignore/exclude suppression, and CI must execute ``pyright --project`` + for both configs (filename mentions in comments do not count). + """ + findings: list[Finding] = [] + + dataflow_path = root / PYRIGHT_DATAFLOW_CONFIG + if not dataflow_path.is_file(): + findings.append( + Finding( + "INV-PYRIGHT-001", + PYRIGHT_DATAFLOW_CONFIG, + 1, + "missing Pyright data-flow config", + ) + ) + else: + dataflow = _load_json_object(dataflow_path) + if dataflow is None: + findings.append( + Finding( + "INV-PYRIGHT-001", + PYRIGHT_DATAFLOW_CONFIG, + 1, + "Pyright data-flow config is not a JSON object", + ) + ) + else: + findings.extend(_require_diagnostic_errors(dataflow, PYRIGHT_DATAFLOW_CONFIG)) + include = dataflow.get("include") + include_paths = ( + {_normalize_pyright_path(item) for item in include} + if isinstance(include, list) + else set() + ) + if PYRIGHT_DATAFLOW_REQUIRED_INCLUDE not in include_paths: + findings.append( + Finding( + "INV-PYRIGHT-001", + PYRIGHT_DATAFLOW_CONFIG, + 1, + ( + "data-flow include must contain " + f"{PYRIGHT_DATAFLOW_REQUIRED_INCLUDE!r} " + f"(found {sorted(include_paths)!r})" + ), + ) + ) + findings.extend( + _reject_ignore_exclude_covering( + dataflow, + protected_root=PYRIGHT_DATAFLOW_REQUIRED_INCLUDE, + config_name=PYRIGHT_DATAFLOW_CONFIG, + code="INV-PYRIGHT-001", + ) + ) + + typed_path = root / PYRIGHT_TYPED_SLICE_CONFIG + if not typed_path.is_file(): + findings.append( + Finding( + "INV-PYRIGHT-002", + PYRIGHT_TYPED_SLICE_CONFIG, + 1, + "missing Pyright typed-slice config (first #69 scope)", + ) + ) + return findings + + typed = _load_json_object(typed_path) + if typed is None: + findings.append( + Finding( + "INV-PYRIGHT-002", + PYRIGHT_TYPED_SLICE_CONFIG, + 1, + "Pyright typed-slice config is not a JSON object", + ) + ) + return findings + + findings.extend(_require_diagnostic_errors(typed, PYRIGHT_TYPED_SLICE_CONFIG)) + + mode = typed.get("typeCheckingMode") + if mode not in PYRIGHT_TYPED_SLICE_MODES: + findings.append( + Finding( + "INV-PYRIGHT-002", + PYRIGHT_TYPED_SLICE_CONFIG, + 1, + ( + "typed-slice typeCheckingMode must be one of " + f"{sorted(PYRIGHT_TYPED_SLICE_MODES)} " + f"(found {mode!r}); off would silently disable real type analysis" + ), + ) + ) + + include = typed.get("include") + if not isinstance(include, list) or not include: + findings.append( + Finding( + "INV-PYRIGHT-002", + PYRIGHT_TYPED_SLICE_CONFIG, + 1, + "typed-slice include must be a non-empty list", + ) + ) + else: + normalized = tuple(_normalize_pyright_path(item) for item in include) + if normalized != PYRIGHT_TYPED_SLICE_INCLUDE: + findings.append( + Finding( + "INV-PYRIGHT-002", + PYRIGHT_TYPED_SLICE_CONFIG, + 1, + ( + "typed-slice include must be exactly " + f"{list(PYRIGHT_TYPED_SLICE_INCLUDE)} " + f"(found {list(normalized)!r}); expand only in a focused follow-up PR" + ), + ) + ) + + findings.extend(_reject_typed_slice_suppression(typed)) + + workflow_path = root / STATIC_ANALYSIS_WORKFLOW + if not workflow_path.is_file(): + findings.append( + Finding( + "INV-PYRIGHT-003", + STATIC_ANALYSIS_WORKFLOW, + 1, + "missing Fast Static Analysis workflow", + ) + ) + else: + projects = _executable_pyright_projects(workflow_path.read_text(encoding="utf-8")) + if PYRIGHT_TYPED_SLICE_CONFIG not in projects: + findings.append( + Finding( + "INV-PYRIGHT-003", + STATIC_ANALYSIS_WORKFLOW, + 1, + ( + f"workflow must execute pyright --project {PYRIGHT_TYPED_SLICE_CONFIG} " + "in a run step (comments / filename mentions do not count)" + ), + ) + ) + if PYRIGHT_DATAFLOW_CONFIG not in projects: + findings.append( + Finding( + "INV-PYRIGHT-003", + STATIC_ANALYSIS_WORKFLOW, + 1, + ( + f"workflow must execute pyright --project {PYRIGHT_DATAFLOW_CONFIG} " + "in a run step (comments / filename mentions do not count)" + ), + ) + ) + + return findings + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -249,6 +775,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) findings = check_repo(args.root.resolve()) + findings.extend(check_pyright_configs(args.root.resolve())) if findings: for finding in findings: print(finding.render()) @@ -259,5 +786,6 @@ def main(argv: list[str] | None = None) -> int: return 0 + if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/test_engineering_invariants.py b/tests/test_engineering_invariants.py index 02e809a9..6461c9be 100644 --- a/tests/test_engineering_invariants.py +++ b/tests/test_engineering_invariants.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +import json import sys import tempfile import unittest @@ -159,6 +160,256 @@ def test_repo_scan_covers_prks_app_entry(self): ] self.assertIn("prks_app.py", scanned) + def _write_valid_pyright_tree(self, root: Path) -> None: + (root / "backend" / "storage").mkdir(parents=True) + (root / ".github" / "workflows").mkdir(parents=True) + (root / "pyrightconfig.json").write_text( + json.dumps( + { + "include": ["prks_app.py", "backend"], + "typeCheckingMode": "off", + "reportUndefinedVariable": "error", + "reportUnboundVariable": "error", + "reportUnusedExcept": "error", + } + ), + encoding="utf-8", + ) + (root / "pyrightconfig.typed-slice.json").write_text( + json.dumps( + { + "include": ["backend/storage"], + "exclude": ["**/__pycache__"], + "typeCheckingMode": "basic", + "reportUndefinedVariable": "error", + "reportUnboundVariable": "error", + "reportUnusedExcept": "error", + } + ), + encoding="utf-8", + ) + (root / ".github" / "workflows" / "static-analysis.yml").write_text( + ( + "jobs:\n" + " pyright:\n" + " steps:\n" + " - run: pyright --project pyrightconfig.json\n" + " - run: pyright --project pyrightconfig.typed-slice.json\n" + ), + encoding="utf-8", + ) + + def test_pyright_typed_slice_config_passes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + self.assertEqual(checker.check_pyright_configs(root), []) + + def test_pyright_typed_slice_rejects_off_mode(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + typed = root / "pyrightconfig.typed-slice.json" + typed.write_text( + json.dumps( + { + "include": ["backend/storage"], + "typeCheckingMode": "off", + "reportUndefinedVariable": "error", + "reportUnboundVariable": "error", + "reportUnusedExcept": "error", + } + ), + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-002"]) + self.assertIn("typeCheckingMode", findings[0].message) + + def test_pyright_dataflow_requires_backend_include(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + (root / "pyrightconfig.json").write_text( + json.dumps( + { + "include": ["prks_app.py"], + "typeCheckingMode": "off", + "reportUndefinedVariable": "error", + "reportUnboundVariable": "error", + "reportUnusedExcept": "error", + } + ), + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-001"]) + self.assertIn("backend", findings[0].message) + + def test_pyright_typed_slice_rejects_ignore_of_scope(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + typed = json.loads((root / "pyrightconfig.typed-slice.json").read_text()) + typed["ignore"] = ["backend/storage"] + (root / "pyrightconfig.typed-slice.json").write_text( + json.dumps(typed), encoding="utf-8" + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-002"]) + self.assertIn("ignore", findings[0].message) + + def test_pyright_typed_slice_rejects_exclude_of_scope(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + typed = json.loads((root / "pyrightconfig.typed-slice.json").read_text()) + typed["exclude"] = ["**/__pycache__", "backend/storage"] + (root / "pyrightconfig.typed-slice.json").write_text( + json.dumps(typed), encoding="utf-8" + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-002"]) + self.assertIn("exclude", findings[0].message) + + def test_pyright_typed_slice_rejects_parent_globs(self): + """Globs that can match backend/storage or anything under it must fail.""" + cases = ( + "backend/**/storage/**", + "backend/**/storage", + "**/storage/**/*", + "**/backend/**/storage/**", + "backend/**/*", + "**", + "backend/**", + "**/backend/**", + "backend/*", + "**/storage/**", + "backend/storage/services/**", + "backend/storage/services/*", + "backend/storage/config.*", + "./backend/storage", + "backend/./storage", + "./backend/**", + "backend/../backend/storage", + "backend/./storage/**", + "../backend/storage", + "**/..", + ) + for pattern in cases: + with self.subTest(pattern=pattern): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + typed = json.loads((root / "pyrightconfig.typed-slice.json").read_text()) + typed["exclude"] = ["**/__pycache__", pattern] + (root / "pyrightconfig.typed-slice.json").write_text( + json.dumps(typed), encoding="utf-8" + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-002"]) + self.assertIn(pattern, findings[0].message) + + def test_pyright_dataflow_rejects_ignore_of_backend(self): + """Data-flow ignore/exclude must not silence the backend include root.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + dataflow = json.loads((root / "pyrightconfig.json").read_text()) + dataflow["ignore"] = ["backend"] + (root / "pyrightconfig.json").write_text( + json.dumps(dataflow), encoding="utf-8" + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-001"]) + self.assertIn("ignore", findings[0].message) + self.assertIn("backend", findings[0].message) + + def test_pyright_typed_slice_allows_pycache_excludes(self): + """Genuine cache-only excludes must not trip INV-PYRIGHT-002.""" + for pattern in ("**/__pycache__", "**/__pycache__/**", "__pycache__"): + with self.subTest(pattern=pattern): + self.assertFalse(checker._path_covers_typed_slice(pattern)) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + typed = json.loads((root / "pyrightconfig.typed-slice.json").read_text()) + typed["exclude"] = [pattern] + (root / "pyrightconfig.typed-slice.json").write_text( + json.dumps(typed), encoding="utf-8" + ) + findings = checker.check_pyright_configs(root) + self.assertEqual(findings, []) + + def test_pyright_typed_slice_rejects_missing_ci_reference(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + (root / ".github" / "workflows" / "static-analysis.yml").write_text( + "jobs:\n pyright:\n steps:\n - run: pyright --project pyrightconfig.json\n", + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-003"]) + + def test_pyright_ci_rejects_comment_only_filename(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + (root / ".github" / "workflows" / "static-analysis.yml").write_text( + ( + "jobs:\n" + " pyright:\n" + " steps:\n" + " - run: pyright --project pyrightconfig.json\n" + " # - run: pyright --project pyrightconfig.typed-slice.json\n" + " - run: echo pyrightconfig.typed-slice.json\n" + ), + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-003"]) + self.assertIn("typed-slice", findings[0].message) + + def test_pyright_ci_rejects_echo_of_pyright_command(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + (root / ".github" / "workflows" / "static-analysis.yml").write_text( + ( + "jobs:\n" + " pyright:\n" + " steps:\n" + " - run: pyright --project pyrightconfig.json\n" + ' - run: echo "pyright --project pyrightconfig.typed-slice.json"\n' + ), + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-003"]) + self.assertIn("typed-slice", findings[0].message) + + def test_pyright_ci_rejects_commented_out_command(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_valid_pyright_tree(root) + (root / ".github" / "workflows" / "static-analysis.yml").write_text( + ( + "jobs:\n" + " pyright:\n" + " steps:\n" + " - run: pyright --project pyrightconfig.json\n" + " # kept for docs: pyright --project pyrightconfig.typed-slice.json\n" + ), + encoding="utf-8", + ) + findings = checker.check_pyright_configs(root) + self.assertEqual([f.code for f in findings], ["INV-PYRIGHT-003"]) + + def test_current_repo_pyright_configs_pass(self): + findings = checker.check_pyright_configs(_ROOT) + self.assertEqual(findings, [], "\n".join(f.render() for f in findings)) + if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file