Reusable pre-commit / prek hooks for JetBrains MPS projects.
MPS keeps a lot of its project structure in files that are easy to leave in an inconsistent state by hand or by a bad
merge: a module added on disk but never registered in .mps/modules.xml, a model dropped outside every source root, a
descriptor renamed but not its folder. MPS silently ignores most of these, so the mistake only surfaces much later.
These hooks catch them at commit time.
Add the repo to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/specificlanguages/mps-pre-commit-hooks
rev: v0.4.0
hooks:
- id: mps-check-orphan-modules
- id: mps-check-unbuilt-modules
- id: mps-check-missing-modules
- id: mps-check-orphan-models
- id: mps-check-orphan-mpsr-files
- id: mps-check-well-formed-xml
- id: mps-check-language-versions
- id: mps-check-no-test-info
- id: mps-check-banned-model-names
- id: mps-check-module-naming
- id: mps-check-model-naming
- id: mps-check-path-variablesThen pre-commit install (or just prek install). The hooks work the same under both runners.
Each hook is independent — enable only the ones you want. They all scan the whole repository (not just the staged files), since most of what they check are cross-file relationships. The structural checks (orphans, and missing/dangling references) run on every commit, because the problem they catch is often introduced by a commit that only deletes a file — which pre-commit would otherwise skip them for. The per-file content checks (well-formed-xml, naming, path variables) run only when a relevant file is added or changed.
Reports orphan modules — *.msd / *.mpl / *.devkit / *.mpst files present on disk but not registered in any
.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.
Reports unbuilt modules — modules that are not mentioned in any MPS build script.
Exclude demo and sandbox modules with --exclude:
- id: mps-check-unbuilt-modules
args: [--exclude=/code/applications, --exclude=_spreferences, --exclude=*.sandbox.msd]The reverse of the orphan-modules check: reports .mps/modules.xml entries whose modulePath points to a file that no
longer exists on disk (typically left behind when a module is moved or deleted). Entries addressed through a path
variable are ignored.
mps-check-missing-modules is read-only — it reports and fails. mps-fix-missing-modules removes the dangling
<modulePath> entries from their modules.xml; use it when you want the hook to repair them for you:
- id: mps-fix-missing-modules
args: [--exclude=examples/]Both hooks accept repeatable --exclude options. Each is a .gitignore-style glob matched against the
repository-relative .mps/modules.xml path. Use it when a project contains modulePath entries for modules that are
not present on disk but should be ignored; MPS silently ignores those entries when loading the project. A trailing /
excludes a whole subtree; for example, --exclude=examples/ skips every modules.xml below examples.
These are defined as separate hooks because the check hook may run in parallel with other read-only hooks whereas the
fix hook requires serial execution (require_serial: true).
Reports model files (*.mps / *.mpsr / .model) living outside every source root of every declared default model
root. A model that falls under none of them is invisible to MPS. Such models may appear in the repository during merge
conflict resolution.
Reports *.mpsr files whose directory has no .model header file alongside them. The header describes the model;
without it MPS cannot load the roots, so the model is effectively lost.
Reports MPS XML files that do not parse as well-formed XML — model files (*.mps / *.mpsr / .model), module
descriptors (*.msd / *.mpl / *.devkit / *.mpst), and the per-project .mps/modules.xml / .mps/libraries.xml.
Besides the zero-byte file left by a botched save, merge, or checkout, this catches a truncated file or one still
carrying Git conflict markers — none of which MPS can load.
This is a thin wrapper over the check-xml hook from
pre-commit/pre-commit-hooks, pre-pointed at MPS's XML file extensions so it needs no configuration. If you would
rather wire up check-xml yourself — or already depend on that repo — enable it directly instead and give it the same
file pattern:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-xml
files: '(\.(msd|mpl|devkit|mps|mpsr|model)$)|((^|/)\.mps/(modules|libraries)\.xml$)'
types: [file] # override the hook's default types: [xml], which excludes MPS's extensionsWiring it up this way also lets prek substitute its built-in Rust check-xml, which it
does automatically for the pre-commit/pre-commit-hooks repo — roughly twice as fast on a large repository. It does
not do this for the wrapper above, whose fast path prek can't recognize. Under pre-commit the same config just runs
the Python check-xml, so the snippet works on both runners.
Reports two problems in a model file's (*.mps / .model) <languages> header:
- A language used with
version="-1". MPS writes-1when it saves a model while the used language's version is unknown — typically because the language module was not on the path at save time. The model still loads, but the missing version is a latent inconsistency that resurfaces as spurious diffs or migration problems once the language is available again. - A language whose version disagrees with the version the owning module records for that language in its descriptor's
<languageVersions>. MPS reports this at load time ("Migration assistant detected inconsistency in language versions"). It is reported rather than fixed: MPS changes a model's language version only by running the actual migration, which migrates the node content along with the number.
Because the second check reads both sides, the hook also runs on module files (*.msd / *.mpl / *.devkit /
*.mpst): a passed module descriptor is expanded to the models it owns, so a version bump in a descriptor is checked
against its models even when no model file changed in the commit.
Reports model files (*.mps / *.mpsr) whose registry instantiates the jetbrains.mps.lang.test TestInfo concept.
The concept is matched by its language and concept id, not its name.
Reports model files (*.mps / .model) whose qualified name is one you forbid with --ban, a repeatable exact match
against the full model name. The name is read from the model's ref header, so a model is reported through its .mps
or .model file regardless of persistence format.
The motivating case is a generator model left with MPS' default unqualified name, main@generator: banning it catches
that mistake while leaving a properly namespaced foo.bar.main@generator alone.
- id: mps-check-banned-model-names
args: [--ban=main@generator]Checks that every module descriptor (*.msd / *.mpl / *.devkit / *.mpst) agrees with its layout on disk. The
directory and the file must be named after the full module name: com.example.foo must be located in
com.example.foo/com.example.foo.mpl (likewise for other module types).
For language modules, nested runtime and sandbox modules can optionally use the layout
foo.bar/runtime/foo.bar.runtime.msd and foo.bar/sandbox/foo.bar.sandbox.msd for the module names foo.bar.runtime
and foo.bar.sandbox. Enable these exceptions explicitly; the language directory must contain a .mpl descriptor:
- id: mps-check-module-naming
args: [--allow-nested-runtime, --allow-nested-sandbox]Modules can be excluded with --exclude, a repeatable glob written like a .gitignore pattern:
- id: mps-check-module-naming
args: [--exclude=_spreferences, --exclude=some.lang/sandbox]Checks that model files (*.mps / .model) are named consistently with the model's name. What it checks depends on
where the model lives.
For a model in a solution's or language's own model root, the file name must match the model name. Relative to the
source root it lives under, a model named foo.bar.baz.quux in a module foo.bar may be stored as:
foo.bar.baz.quux.mps the full name
baz.quux.mps the full name with the module name truncated away
foo/bar/baz/quux.mps the full name, each segment its own directory
baz/quux.mps truncated, each segment its own directory
and any mix of dot- and directory-separated segments. Truncation drops the whole owning module name and nothing
less, so bar.baz.quux.mps (only part of the module name removed) and an unrelated somethingElse.mps are both
reported. A model in the per-root format is a directory of the same name holding a .model header, so the rules apply
to the directory the .model sits in. A @stereotype (e.g. @tests) is part of the name and so of the file name —
foo and foo@tests are different models, kept in different files.
The truncated short name is accepted by default. To require solution models to carry the full name, pass
--no-short-names. Language models keep the short form regardless — the terse per-aspect file name (behavior.mps for
com.example.lang.behavior) is the idiomatic language layout.
- id: mps-check-model-naming
args: [--no-short-names]For a model in a language's embedded generator, only the name is checked: it must be namespaced under the owning
language (a model of language foo.bar must be named foo.bar…). This catches a template model left over from another
language, or one left with a non-unique name like main@generator. The file layout of generator models is not checked —
the generator/template folder makes it unpredictable, and the generator's own declared namespace is unreliable (legacy
#id forms), so the language namespace is what a generator model is measured against.
A model that lies outside every declared source root is left to mps-check-orphan-models.
Reports path variables in .mps/libraries.xml and .mps/modules.xml. Replacing these paths with project-relative paths
makes it easier to check out and open the project.
mps-check-path-variables is check-only, it fails if a path variable is found. mps-fix-path-variables rewrites the
offending paths in place; use it when you want the hook to repair them for you:
- id: mps-fix-path-variablesThese are defined as separate hooks because the check hook may run in parallel with other read-only hooks whereas the
fix hook requires serial execution (require_serial: true).
The fix assumes the macro's value is the Git repository root and re-expresses the whole path relative to
$PROJECT_DIR$:
${mbeddr.github.core.home}/code/platform/com.mbeddr.doc
→ $PROJECT_DIR$/../../platform/com.mbeddr.doc
Caveat. This assumption holds for the common case where a variable points at the project's own checkout root. In other cases the rewritten path will be incorrect. Like other pre-commit fixers, the hook exits non-zero when it changes files, so you re-stage them deliberately.
MIT. See LICENSE.