scripts/check_changes.py
Rama main · réplica de lectura del repositorio original.
"""Share conservative affected-surface selection between local checks and CI."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
from collections.abc import Sequence
from pathlib import Path, PurePosixPath
REPO_ROOT = Path(__file__).resolve().parents[1]
SURFACES = ("code", "python", "terminal", "desktop", "rust", "site", "docs", "dependencies")
_INERT = {".gitignore", ".gitattributes", ".editorconfig", ".github/workflows/status-report.yml"}
_DEPENDENCY_NAMES = {
"pyproject.toml", "package.json", "Cargo.toml", "Cargo.lock", "uv.lock", "pnpm-lock.yaml",
"pnpm-workspace.yaml", "requirements.lock", "requirements.txt", "brokers.lock",
".npmrc", ".pnpmfile.cjs", "pnpmfile.cjs", "uv.toml", "rust-toolchain", "rust-toolchain.toml",
".python-version", ".node-version", ".nvmrc",
}
_PYTHON_PACKAGES = {
"core/core", "core/data", "core/historical", "core/indicators", "core/ticks",
"services/ai", "services/automation", "services/backtest", "services/ditto", "services/engine",
"services/journal", "services/screener", "integrations/gateway", "integrations/webhooks",
}
_DOCUMENTATION_PACKAGES = _PYTHON_PACKAGES | {"apps/terminal", "apps/desktop", "apps/site", "core/design-system"}
_TERMINAL_CONTRACT_INPUTS = {
"docs/INVENTORY.md", "packages/apps/site/src/app/globals.css",
}
def classify_paths(paths: Sequence[str], *, full: bool = False) -> dict[str, bool]:
"""Select the union of checks, expanding unknown or shared changes to all.
``python`` selects the package suites; repository invariants run separately
even for documentation and frontend changes. This is deliberately a surface
map, not a claim of a complete dependency graph between Python packages.
"""
result = dict.fromkeys(SURFACES, full)
if full:
return result
def select(*surfaces: str) -> None:
for surface in surfaces:
result[surface] = True
for path in paths:
parts = PurePosixPath(path).parts
if not parts or path.startswith(("/", "\\")) or ".." in parts or "\\" in path:
return dict.fromkeys(SURFACES, True)
if path in _TERMINAL_CONTRACT_INPUTS:
# Terminal source contracts also inspect the shared catalogue and
# the site's design-system imports.
select("python", "terminal", "site")
if path in _INERT or path.startswith((".local/", ".github/ISSUE_TEMPLATE/")):
if path.startswith(".github/workflows/"):
select("dependencies")
continue
if path in {"notice", "notice.generated", "LICENSE"}:
select("dependencies", "site")
continue
if parts[-1] in _DEPENDENCY_NAMES or ".cargo" in parts:
return dict.fromkeys(SURFACES, True)
# Package Markdown can be runtime input (AI skills, prompts and test
# fixtures). Only a known package's root README is documentation.
package_readme = (
len(parts) == 4 and parts[-1] == "README.md"
and "/".join(parts[1:3]) in _DOCUMENTATION_PACKAGES
)
if path.startswith(("docs/", "flinttrade-design/")) or (
path.endswith(".md") and (parts[0] != "packages" or package_readme)
):
select("docs", "site")
continue
if path.startswith("packages/apps/site/"):
select("docs", "site")
continue
if path.startswith("packages/apps/terminal/"):
# Python contracts inspect terminal API clients and fixtures.
select("code", "python", "terminal", "site")
continue
if path.startswith("packages/apps/desktop/"):
# Desktop bootstrap helpers and their subprocess tests are Python.
select("code", "python", "desktop", "site")
# Packaged install resources participate in frozen-install and
# provenance assurance, even when the lockfiles do not change.
if path.startswith(("packages/apps/desktop/resources/", "packages/apps/desktop/scripts/")):
select("dependencies")
continue
if path.startswith("packages/core/design-system/"):
select("code", "python", "terminal", "site")
continue
if len(parts) >= 4 and parts[0] == "packages" and "/".join(parts[1:3]) in _PYTHON_PACKAGES:
package = "/".join(parts[1:3])
select("code", "python")
if package == "core/ticks":
select("rust")
if package in {"core/core", "integrations/gateway", "services/engine"}:
select("terminal", "site")
if package == "services/ai":
select("site")
continue
# Root tests, configuration, CI, install scripts and new packages can
# affect contracts across languages; a guess here could hide a failure.
return dict.fromkeys(SURFACES, True)
return result
def changed_paths(
base: str = "origin/main", head: str = "HEAD", *, include_worktree: bool = False, root: Path = REPO_ROOT,
) -> list[str] | None:
"""Read changed paths, including both sides of renames; None selects all.
The PR merge-base range includes every change on the branch. Local checks
optionally include staged, unstaged and non-ignored untracked files too.
Git arguments are never interpreted by a shell.
"""
if not base or not head or base.startswith("-") or head.startswith("-"):
return None
def git(*args: str) -> bytes:
return subprocess.run(
["git", *args], cwd=root, check=True, capture_output=True, timeout=30,
).stdout
try:
base_sha = git("rev-parse", "--verify", f"{base}^{{commit}}").decode().strip()
head_sha = git("rev-parse", "--verify", f"{head}^{{commit}}").decode().strip()
names = git("diff", "--no-renames", "--name-only", "-z", f"{base_sha}...{head_sha}")
if include_worktree:
# Read index and working-copy changes independently: an unstaged
# restoration can otherwise hide a change waiting in the index.
names += git("diff", "--cached", "--no-renames", "--name-only", "-z", head_sha)
names += git("diff", "--no-renames", "--name-only", "-z")
names += git("ls-files", "--others", "--exclude-standard", "-z")
return sorted({name.decode("utf-8", errors="surrogateescape") for name in names.split(b"\0") if name})
except (OSError, subprocess.SubprocessError, UnicodeError):
return None
def main(argv: Sequence[str] | None = None) -> int:
"""Print the check plan and optionally expose booleans to GitHub Actions."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default="origin/main")
parser.add_argument("--head", default="HEAD")
parser.add_argument("--full", action="store_true")
parser.add_argument("--github-output", action="store_true")
args = parser.parse_args(argv)
paths = [] if args.full else changed_paths(args.base, args.head)
surfaces = classify_paths(paths or [], full=args.full or paths is None)
print(json.dumps(surfaces, sort_keys=True))
if args.github_output:
output = os.environ.get("GITHUB_OUTPUT")
if not output:
parser.error("--github-output requires GITHUB_OUTPUT")
with Path(output).open("a", encoding="utf-8") as stream:
stream.writelines(f"{key}={str(value).lower()}\n" for key, value in surfaces.items())
return 0
if __name__ == "__main__":
raise SystemExit(main())