Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ the Anthropic community plugin catalog. Not a Claude-for-OSS eligibility claim.
> [star it](https://github.com/daichunghy/contribkit/stargazers). That is the
> only growth signal this repo tracks.

## First-use experience on the development branch

For a checked-out copy of this branch, run:

```sh
node dist/src/cli.js check --repo .
node dist/src/cli.js check --repo . --base origin/main --body-file pr.md
```

The read-only default groups findings into blockers, human review and suggestions.
The new `check` exit codes are 0 (pass), 1 (blocked), 3 (needs-human) and 2 (usage error). Legacy `preflight` retains existing semantics.
It prefers upstream Git references and explicitly warns when it must fall back
to HEAD. Add `--run-tests` only when you trust the target repository code.
The existing `preflight` command, receipt schema and MCP tools are unchanged.
**The `check` command is not part of the published npm alpha.7 release.**

The fastest first result on the current repository is:

```sh
Expand Down Expand Up @@ -96,10 +112,9 @@ handshake (2024-11-05 and 2025-11-25), not the newer stateless MCP variant.

### Development branch versus npm

Fixes under review in [PR #58](https://github.com/daichunghy/contribkit/pull/58)
are **not part of the published `0.1.0-alpha.7` npm package**. Install
and test this source branch only if you need those unreleased fixes; do not
describe them as published functionality.
The changes from [PR #58](https://github.com/daichunghy/contribkit/pull/58)
are merged on `main`, but are **not included in the published `0.1.0-alpha.7` npm package**.
The new `check` command in PR #66 is also unreleased.

## What is shipped

Expand Down
55 changes: 55 additions & 0 deletions docs/PRODUCT_READINESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Product readiness review

Status: development alpha. Successful CI does not prove product-market fit or production readiness.

## Product boundary

ContribKit checks explicit repository contribution rules before opening a PR. It does not replace linters, Git hooks, CI, maintainer approvals, or merge protection.

## Comparable developer experiences

- [pre-commit](https://pre-commit.com/) offers install and run commands for local hooks.
- [Lefthook](https://lefthook.dev/usage/) offers a small set of install, run, and validate workflows.
- [reviewdog](https://github.com/reviewdog/reviewdog) focuses on linters and diagnostic annotations.
- [GitHub CLI](https://cli.github.com/manual/gh_pr_checks) shows checks for an existing PR.

ContribKit should differentiate on policy provenance and before-PR readiness, not duplicate these products.

## Personas and progressive disclosure

| Audience | Default task | Advanced capability |
| --- | --- | --- |
| Contributor | `contribkit check` | PR body, exact base, opt-in tests |
| Maintainer | Policy validation | Blocking adapter selection, overrides, provenance |
| Agent / CI | Machine-readable receipt | Deterministic contract, MCP, evidence digests |

## Shipping gaps and release gates

### P0: First run and trust

- A check needs a clear base ref, an honest empty-diff state and actionable blocker/human/suggestion grouping.
- No test execution without an explicit choice; never fetch remotes silently.
- `check` distinguishes human review with nonzero exit code 3, while legacy preflight remains backward compatible.
- Verify first-use behavior with and without remote tracking refs.

### P1: Deep inspection

- Add versioned, structured remediation fields for affected paths, evidence, rule applicability and next actions. Do not mutate old receipt hashes silently.
- Provide a read-only `doctor` command for policy quality and unsupported constructs.
- Build maintainer policy setup with explicit preview and confirmation before writing files.
- Offer clear summaries by default while keeping full rule evidence and JSON for advanced workflows.

### P1: Real toolchains and platforms

- Test macOS, Linux, Windows; test Node, Python, Rust and Maven using real projects and explicit opt-in.
- Exercise MCP with actual client SDKs; track compatibility and failures with reproducible fixtures.

### P2: User evidence and integration

- Run five consented first-use sessions including first-time contributors and maintainers.
- Proposed readiness criteria: at least four of five users complete their first check without assistance; all identify human-review status accurately; no unintended target command execution.
- Only then prioritize editor or hosted integrations, guided setup and stable release.

## Explicit non-claims

CI tests are developer evidence, not external user validation. Adapter manifests are not production toolchain compatibility evidence. Do not claim published features until a new npm version is verified.
54 changes: 52 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { canonicalJson } from "./canonical.js";
import { compile } from "./compile.js";
import { explainReceipt, formatReceipt, statusExitCode } from "./explain.js";
import { preflight } from "./preflight.js";
import { RepoError } from "./repo.js";
import { git, RepoError } from "./repo.js";
import { assertReceipt } from "./schema.js";
import { VERSION } from "./version.js";
import type { PreflightReceipt } from "./types.js";
Expand All @@ -29,14 +29,18 @@ function help(): string {
`contribkit ${VERSION}`,
"Contribution preflight — do not open the PR until the repo contract is satisfied.",
"",
"Quick start: contribkit check [--repo .] [--base origin/main]",
"",
"Usage:",
" contribkit check [--repo <path>] [--base <ref>] [--body-file <file>] [--json] [--out <file>] [--run-tests]",
" contribkit compile --repo <path> [--ref <git-ref>] [--out <file>] [--json]",
" contribkit preflight --repo <path> --base <git-ref> [--body <text> | --body-file <file>]",
" [--out <file>] [--json] [--run-tests] [--allow] [--ref <trusted-policy-ref>]",
" contribkit explain <receipt.json> [--json]",
" contribkit mcp",
"",
"Exit codes (preflight): blocked → 1; pass and needs-human → 0.",
"check exit codes: pass 0; blocked 1; needs-human 3; invalid arguments 2.",
"Legacy preflight: blocked 1; pass and needs-human 0.",
"CONTRIBKIT_ALLOW=1 or --allow sets receipt.overridden = true; it does not rewrite argv.",
"--base selects the trusted policy revision; --ref overrides it only by explicit choice.",
"--run-tests is opt-in: up to 16 distinct allowlisted argv, never arbitrary shell strings.",
Expand All @@ -61,6 +65,30 @@ async function writeOut(path: string | undefined, text: string): Promise<void> {
await writeFile(path, text.endsWith("\n") ? text : `${text}\n`, "utf8");
}

async function autoBase(repo: string): Promise<{ ref: string; warning?: string }> {
for (const candidate of ["origin/main", "origin/master", "origin/HEAD"]) {
const result = await git(repo, ["rev-parse", "--verify", "--end-of-options", candidate + "^{commit}"]);
if (result.code === 0) return { ref: candidate };
}
return { ref: "HEAD", warning: "No upstream base found. HEAD only checks uncommitted changes. Pass --base <trusted-ref> to check commits on your branch." };
}

function formatCheck(receipt: PreflightReceipt, base: string, count: number, warning?: string): string {
const missing = receipt.findings.filter((f) => !f.passed);
const lines = ["ContribKit check: " + receipt.status, "Base: " + base, "Changed files: " + count];
if (warning) lines.push("Warning: " + warning);
if (count === 0) lines.push("No differences found; this is not evidence that a future PR is ready.");
for (const [severity, title] of [["block", "Fix before PR"], ["needs-human", "Human review"], ["advisory", "Suggestions"]]) {
const items = missing.filter((f) => f.severity === severity);
if (items.length === 0) continue;
lines.push("", title + " (" + items.length + ")");
for (const item of items) lines.push(" - " + item.message + " [" + item.origin + "]");
}
if (missing.length === 0 && count > 0) lines.push("No unmet rules found. CI and maintainer approval remain separate.");
lines.push("", "Next: --body-file pr.md supplies PR details; --run-tests executes trusted project tests only.");
return lines.join("\n") + "\n";
}

export async function runCli(argv: string[], io: CliIo = defaultIo): Promise<number> {
const command = argv[0];
if (command === undefined || command === "-h" || command === "--help") {
Expand Down Expand Up @@ -96,6 +124,28 @@ export async function runCli(argv: string[], io: CliIo = defaultIo): Promise<num
return 0;
}

if (command === "check") {
const repo = flagValue(argv, "--repo") ?? ".";
const explicitBase = flagValue(argv, "--base");
const selected = explicitBase === undefined ? await autoBase(repo) : { ref: explicitBase };
const body = flagValue(argv, "--body");
const bodyFile = flagValue(argv, "--body-file");
if (body !== undefined && bodyFile !== undefined) {
io.stderr.write("Use --body or --body-file, not both.\n");
return 2;
}
const prBodyDraft = bodyFile !== undefined ? await readFile(bodyFile, "utf8") : body;
const result = await preflight({
repoPath: repo, baseRef: selected.ref, runTests: hasFlag(argv, "--run-tests"),
...(prBodyDraft !== undefined ? { prBodyDraft } : {}),
});
const receiptText = canonicalJson(result.receipt) + "\n";
await writeOut(out, receiptText);
if (json) io.stdout.write(receiptText);
else io.stdout.write(formatCheck(result.receipt, selected.ref, result.snapshot.changedPaths.length, "warning" in selected ? selected.warning : undefined));
return result.receipt.status === "needs-human" ? 3 : statusExitCode(result.receipt.status);
}

if (command === "preflight") {
const repo = flagValue(argv, "--repo") ?? ".";
const base = flagValue(argv, "--base") ?? "HEAD";
Expand Down
26 changes: 26 additions & 0 deletions test/first-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,30 @@ describe("first-use CLI path", () => {
await rm(repo, { recursive: true, force: true });
}
});
it("check presents useful repair steps for missing PR evidence", async () => {
const repo = await stageFixture("missing-issue");
const result = await captureCli(["check", "--repo", repo, "--base", "HEAD"]);
expect(result.code).toBe(1);
expect(result.stdout).toContain("Fix before PR");
expect(result.stdout).toContain("Link an issue");
expect(result.stdout).toContain("Changed files:");
});

it("check warns on HEAD fallback and supports receipt JSON", async () => {
const clean = await stageFixture("pass-clean");
const fallback = await captureCli(["check", "--repo", clean]);
expect(fallback.stdout).toContain("No upstream base found");
const missing = await stageFixture("missing-issue");
const report = await captureCli(["check", "--repo", missing, "--base", "HEAD", "--json"]);
const receipt: unknown = JSON.parse(report.stdout);
assertReceipt(receipt);
});

it("check returns distinct nonzero code when human review is required", async () => {
const repo = await stageFixture("codeowners-path");
const result = await captureCli(["check", "--repo", repo, "--base", "HEAD"]);
expect(result.code).toBe(3);
expect(result.stdout).toContain("Human review");
});

});
Loading