Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion src/mps_pre_commit_hooks/check_orphan_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
32 changes: 31 additions & 1 deletion src/mps_pre_commit_hooks/check_orphan_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@
# 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

from ._common import (
MODULE_GLOBS,
MODULES_XML_GLOBS,
FloatingGlob,
anchor,
git_ls_files,
matches,
module_name,
parse_xml,
repo_root,
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_check_orphan_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions tests/test_check_orphan_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<modulePath path="$PROJECT_DIR$/com.example.foo/com.example.foo.msd" folder="" />',
"",
),
)
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
Loading