diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 1db94a7..2a4c798 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -15,7 +15,8 @@ - id: mps-check-orphan-modules name: Check for orphan MPS modules description: >- - Report MPS modules (*.msd / *.mpl / *.devkit) present on disk but not registered in any .mps/modules.xml. + Report MPS modules (*.msd / *.mpl / *.devkit) present on disk but not registered in any .mps/modules.xml. Exclude + module descriptors that are known to be unregistered with args: [--exclude=GLOB]. entry: python -m mps_pre_commit_hooks.check_orphan_modules language: python language_version: python3.14 @@ -60,7 +61,8 @@ - id: mps-check-orphan-models name: Check for orphan MPS models description: >- - Report MPS model files (*.mps / *.mpsr / .model) that live outside any module's declared default model root. + Report MPS model files (*.mps / *.mpsr / .model) that live outside any module's declared default model root. Exclude + model files that are known to be orphans with args: [--exclude=GLOB]. entry: python -m mps_pre_commit_hooks.check_orphan_models language: python language_version: python3.14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cde6e2..801b852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Repeatable `--exclude` support for `mps-check-orphan-modules` and `mps-check-orphan-models`. Each is a + `.gitignore`-style glob matched against the repository-relative path of the module descriptor or model file, so a + project with existing orphans can enable these hooks and still catch new ones. + ## [0.5.0] - 2026-09-18 ### Added diff --git a/README.md b/README.md index d853b90..b1a925e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,16 @@ Reports **orphan modules** — `*.msd` / `*.mpl` / `*.devkit` / `*.mpst` files p `.mps/modules.xml`. MPS opens a project from its `modules.xml`, so it silently ignores an unregistered module, and the mistake otherwise surfaces only much later. +The hook supports the repeatable `--exclude` option. It accepts `.gitignore`-style globs matched against +repository-relative module descriptor paths. A trailing `/` excludes a whole subtree. Use it to skip modules that are +already unregistered, so that only new orphans are reported: + +```yaml +- id: mps-check-orphan-modules + args: + - --exclude=code/extensions/languages/de.itemis.model.merge.test/ +``` + ### `mps-check-unbuilt-modules` Reports **unbuilt modules** — modules that are not mentioned in any MPS build script. @@ -87,6 +97,16 @@ Reports model files (`*.mps` / `*.mpsr` / `.model`) living outside every source root. A model that falls under none of them is invisible to MPS. Such models may appear in the repository during merge conflict resolution. +The hook supports the repeatable `--exclude` option. It accepts `.gitignore`-style globs matched against +repository-relative model file paths. A trailing `/` excludes a whole subtree. Use it to skip model files that are +already orphans, so that only new orphans are reported: + +```yaml +- id: mps-check-orphan-models + args: + - --exclude=code/extensions/celllayout/languages/de.itemis.mps.celllayout/generator/template/main@generator.mps +``` + ### `mps-check-empty-model-roots` Reports each declared default model root that contains no tracked model files (`*.mps` / `*.mpsr` / `.model`) under any diff --git a/src/mps_pre_commit_hooks/check_orphan_models.py b/src/mps_pre_commit_hooks/check_orphan_models.py index 420e56b..4b4bba8 100644 --- a/src/mps_pre_commit_hooks/check_orphan_models.py +++ b/src/mps_pre_commit_hooks/check_orphan_models.py @@ -4,17 +4,23 @@ # Every model file (*.mps / *.mpsr / .model) must live under a source root of some # default model root; one that does not is invisible to MPS -- present on disk but # never loaded -- usually a move that didn't update the owning module, or a stray copy. +# +# Model files that are known to be orphans can be skipped with --exclude. from __future__ import annotations +import argparse import sys from pathlib import Path from ._common import ( MODEL_GLOBS, MODULE_GLOBS, + FloatingGlob, + anchor, default_model_root_dirs, git_ls_files, + matches, nearest_ancestor_in, parse_xml, repo_root, @@ -37,16 +43,40 @@ def model_roots(root: Path) -> set[Path]: return roots -def main() -> int: +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="mps-check-orphan-models", + description="Check that every MPS model file lives under a default model root.", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + metavar="GLOB", + type=FloatingGlob, + help="Glob matching model file paths (repo-relative) that are not " + "checked. Written like a .gitignore pattern ('*' stays within a path " + "segment, '**' spans directories, a trailing '/' excludes a subtree). " + "Repeatable. E.g. --exclude='some.lang/generator/template/main@generator.mps'.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + root = repo_root() roots = model_roots(root) + excludes = [anchor(g) for g in args.exclude] # .mps/ holds project configuration, not models; skip it as the other checks do. failed = False for model in git_ls_files(*MODEL_GLOBS): if ".mps" in model.parts: continue + if matches(model.relative_to(root).as_posix(), *excludes, subtree=True): + continue if nearest_ancestor_in(model, roots) is None: print(f"{model.relative_to(root).as_posix()}: model file outside any default model root's source roots") failed = True diff --git a/src/mps_pre_commit_hooks/check_orphan_modules.py b/src/mps_pre_commit_hooks/check_orphan_modules.py index 65d057b..6453f87 100644 --- a/src/mps_pre_commit_hooks/check_orphan_modules.py +++ b/src/mps_pre_commit_hooks/check_orphan_modules.py @@ -12,9 +12,12 @@ # outside a model root, mps-check-orphan-models; *.mpsr without a .model header, # mps-check-orphan-roots; malformed XML files, mps-check-well-formed-xml; module # naming, mps-check-module-naming; path variables, mps-check-path-variables. +# +# Modules that are known to be unregistered can be skipped with --exclude. from __future__ import annotations +import argparse import os import sys from pathlib import Path @@ -22,7 +25,10 @@ from ._common import ( MODULE_GLOBS, MODULES_XML_GLOBS, + FloatingGlob, + anchor, git_ls_files, + matches, module_name, parse_xml, repo_root, @@ -53,15 +59,39 @@ def referenced_modules() -> set[Path]: return referenced -def main() -> int: +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="mps-check-orphan-modules", + description="Check that every MPS module is registered in a .mps/modules.xml.", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + metavar="GLOB", + type=FloatingGlob, + help="Glob matching module descriptor paths (repo-relative) that are not " + "checked. Written like a .gitignore pattern ('*' stays within a path " + "segment, '**' spans directories, a trailing '/' excludes a subtree). " + "Repeatable. E.g. --exclude='code/some.lang/' --exclude='*.sandbox.msd'.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + root = repo_root() referenced = referenced_modules() + excludes = [anchor(g) for g in args.exclude] failed = False for module in git_ls_files(*MODULE_GLOBS): if module in referenced: continue + if matches(module.relative_to(root).as_posix(), *excludes, subtree=True): + continue descriptor = parse_xml(module) if descriptor is None: continue diff --git a/tests/test_check_orphan_models.py b/tests/test_check_orphan_models.py index 4a683d8..5ea4c11 100644 --- a/tests/test_check_orphan_models.py +++ b/tests/test_check_orphan_models.py @@ -54,3 +54,29 @@ def test_model_outside_root_is_reported(repo): assert result.returncode == 1 assert "outside any default model root's source roots" in result.stdout assert "stray.mps" in result.stdout + + +def test_excluded_model_is_not_reported(repo): + write(os.path.join(repo, "com.example.foo/stray.mps"), MODEL_MPS) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + result = run_check(repo, "--exclude=com.example.foo/stray.mps") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "" + + +def test_excluded_subtree_is_not_reported(repo): + write(os.path.join(repo, "com.example.foo/generator/template/main@generator.mps"), MODEL_MPS) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + result = run_check(repo, "--exclude", "generator/") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "" + + +def test_exclude_only_skips_matching_models(repo): + write(os.path.join(repo, "com.example.foo/stray.mps"), MODEL_MPS) + write(os.path.join(repo, "com.example.foo/other.mps"), MODEL_MPS) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + result = run_check(repo, "--exclude=stray.mps") + assert result.returncode == 1 + assert "stray.mps" not in result.stdout + assert "other.mps" in result.stdout diff --git a/tests/test_check_orphan_modules.py b/tests/test_check_orphan_modules.py index da617fc..5fdff58 100644 --- a/tests/test_check_orphan_modules.py +++ b/tests/test_check_orphan_modules.py @@ -92,3 +92,35 @@ def test_root_level_project_is_scanned(tmp_path): reported = run_check(root) assert reported.returncode == 1 assert "not registered in any modules.xml" in reported.stdout + + +def unregister(repo): + write( + proj_path(repo, ".mps/modules.xml"), + MODULES_XML.replace( + '', + "", + ), + ) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + + +def test_excluded_descriptor_is_not_reported(consistent_repo): + unregister(consistent_repo) + result = run_check(consistent_repo, "--exclude", "proj/com.example.foo/com.example.foo.msd") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "" + + +def test_excluded_subtree_is_not_reported(consistent_repo): + unregister(consistent_repo) + result = run_check(consistent_repo, "--exclude=com.example.foo/") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "" + + +def test_non_matching_exclude_still_reports(consistent_repo): + unregister(consistent_repo) + result = run_check(consistent_repo, "--exclude=com.example.bar/", "--exclude=*.mpl") + assert result.returncode == 1 + assert "com.example.foo.msd" in result.stdout