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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
What changed in PACT, newest first. Each entry says what an author can now write, or what now
holds that did not; the commits are listed under it for whoever wants the detail.

## 2026-11 — `pact about`: the binary says which commit it was built from (13 November)

A runtime pins PACT by commit and then runs whatever `pact` binary it finds. The binary could not
say what it was, so one built from an edited checkout, or left over from another commit, passed
every check a pin makes of the source beside it. Bud Agent Flow's pin guard asked for this.

- **`pact about`** prints three lines, each `<name>: <value>`: `version:`, `commit: <40 hex, or unknown>` and
`uncommitted: no | yes | unknown` — whether anything the binary is built from (`crates`, `spec`,
`models`, `Cargo.toml`, `Cargo.lock`) differed from that commit when it was built. Both are fixed
at compile time (`crates/pact-cli/build.rs`). Built with no repository around it, both are
`unknown`.
- A pin check compares `commit:` with its pin and refuses anything but `uncommitted: no`.

## 2026-11 — a decision as the specification says it: who is asked, what a model is shown, what is recorded (13 November)

Writing a shared case for a classifier found that PACT's reference runtime read neither
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -998,11 +998,11 @@ framework is an extra.
|---|---|
| **Design** | Thesis, 28 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
| **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4912 tests (1639 Rust + 3273 adapter), clippy clean, TypeScript type-checked** |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4915 tests (1642 Rust + 3273 adapter), clippy clean, TypeScript type-checked** |
| **Adapters** | **All 7 named targets**, proven against one shared conformance suite |

```bash
./scripts/test-all.sh # 4912 tests, Rust + 7 adapters, fully offline
./scripts/test-all.sh # 4915 tests, Rust + 7 adapters, fully offline
```

**What is still owed** is not hidden — it lives in
Expand Down
73 changes: 73 additions & 0 deletions crates/pact-cli/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! What `pact about` reports: the commit this binary was built from, and
//! whether anything it is built from was changed and not committed.
//!
//! A runtime pins PACT by commit and then runs whatever `pact` binary it
//! finds. Until this, the binary could not say what it was: one built from an
//! edited checkout, or at another commit, passed every check a pin makes. Bud
//! Agent Flow found it when an uncommitted rule of PACT's refused one of its
//! trees through a binary nothing had vouched for.
//!
//! Both are read from `git` when the crate is built and compiled in. Built
//! from a source archive with no repository around it, both say `unknown` —
//! which is the truth, and what a pin check should refuse.

use std::path::Path;
use std::process::Command;

/// What the binary is built from, as paths from the repository's root: the
/// crates, the specification and the catalogue compiled into it, and the
/// manifests that choose its dependencies.
const BUILT_FROM: [&str; 5] = ["crates", "spec", "models", "Cargo.toml", "Cargo.lock"];

fn git(root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}

fn main() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
// Only a repository whose top is this workspace counts: a source archive
// unpacked inside somebody else's checkout is not built from THEIR commit.
let here = git(&root, &["rev-parse", "--show-toplevel"])
.and_then(|top| Some(Path::new(&top).canonicalize().ok()? == root.canonicalize().ok()?))
.unwrap_or(false);
let commit = here
.then(|| git(&root, &["rev-parse", "HEAD"]))
.flatten()
.filter(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()));
let uncommitted = match &commit {
None => "unknown",
Some(_) => {
let mut args = vec!["status", "--porcelain", "--"];
args.extend(BUILT_FROM);
match git(&root, &args) {
Some(changed) if changed.is_empty() => "no",
Some(_) => "yes",
None => "unknown",
}
}
};
println!(
"cargo:rustc-env=PACT_BUILT_FROM_COMMIT={}",
commit.as_deref().unwrap_or("unknown")
);
println!("cargo:rustc-env=PACT_BUILT_UNCOMMITTED={uncommitted}");

// Asked again whenever the answer can have changed: a file the binary is
// built from, or what is checked out and committed.
for path in BUILT_FROM {
println!("cargo:rerun-if-changed={}", root.join(path).display());
}
if let Some(dir) = git(&root, &["rev-parse", "--absolute-git-dir"]) {
for file in ["HEAD", "index", "logs/HEAD"] {
println!("cargo:rerun-if-changed={}", Path::new(&dir).join(file).display());
}
}
}
32 changes: 31 additions & 1 deletion crates/pact-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ USAGE:
pact build <FILE> --to <FOLDER>
Write the folder of YAML and Markdown that a
PACT-language file stands for
pact about Say which commit this `pact` was built from, and
whether it was built with uncommitted changes
pact help Show this message (also written -h or --help)

PATH defaults to the current directory.
Expand Down Expand Up @@ -136,6 +138,33 @@ fn main() {
}
}

/// `pact about`: what this binary is, in three lines a script can read.
///
/// ```text
/// version: 0.1.0
/// commit: 63a048bbbc5279feefcac453d38a8582090b29db
/// uncommitted: no
/// ```
///
/// `version:` is the release the crate says it is. `commit:` is the commit the binary was built from, or `unknown` when it was
/// built with no repository around it. `uncommitted:` is `no`, `yes` or
/// `unknown`: whether anything the binary is built from (the crates, the
/// specification, the model catalogue, the manifests) differed from that
/// commit when it was built. Both are fixed when it is compiled (`build.rs`),
/// so they describe the binary and not the folder it is run from.
///
/// A runtime that pins PACT by commit compares the first with its pin and
/// refuses anything but `no` for the second.
fn about() -> anyhow::Result<i32> {
out_raw!(
"version: {}\ncommit: {}\nuncommitted: {}\n",
env!("CARGO_PKG_VERSION"),
env!("PACT_BUILT_FROM_COMMIT"),
env!("PACT_BUILT_UNCOMMITTED"),
);
Ok(0)
}

/// Whether this is somebody asking what the commands are — answered BEFORE the
/// positional filter below, which is the whole point of it living here.
///
Expand Down Expand Up @@ -362,6 +391,7 @@ fn run() -> Result<i32> {
};

match cmd {
"about" => about(),
"check" => check(&path, quiet, unsafe_spec, deny_warnings),
"show" => show(&path, unsafe_spec),
"waits" => waits_cmd(&path, unsafe_spec),
Expand Down Expand Up @@ -390,7 +420,7 @@ fn run() -> Result<i32> {
// typo suggestion is the same `suggest::closest` the option refusal
// two functions up already uses.
let verbs = [
"check", "show", "waits", "discover", "card", "write", "build", "help",
"check", "show", "waits", "discover", "card", "write", "build", "about", "help",
];
let fix = match pact_schema::suggest::closest(other, &verbs) {
Some(near) => format!("Did you mean `pact {near}`?"),
Expand Down
108 changes: 108 additions & 0 deletions crates/pact-cli/tests/the_binary_says_what_it_was_built_from.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! `pact about` says which commit the binary was built from, and whether it
//! was built with uncommitted changes.
//!
//! A runtime pins PACT by commit and then runs whatever `pact` it finds on the
//! machine. The binary could not say what it was, so one built from an edited
//! checkout, or left over from another commit, passed every check a pin makes
//! of the source beside it. Bud Agent Flow's pin guard compared the checkout's
//! `HEAD` and its clean tree and had nothing to compare the binary with.
//!
//! What is held: the three lines are there and a script can read them; the
//! commit is this checkout's `HEAD`; and `uncommitted:` is what `git status`
//! says of the things the binary is built from, as they were when it was
//! built — which is now, since `cargo test` has just built it.

use std::path::PathBuf;
use std::process::Command;

fn repo() -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}

fn about() -> Vec<(String, String)> {
let out = Command::new(env!("CARGO_BIN_EXE_pact"))
.arg("about")
.output()
.expect("runs");
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout).into_owned();
text.lines()
.map(|l| {
let (key, value) = l.split_once(": ").expect("`<name>: <value>`");
(key.to_string(), value.to_string())
})
.collect()
}

fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo())
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}

#[test]
fn it_says_its_version_its_commit_and_whether_anything_was_uncommitted() {
let said = about();
let names: Vec<&str> = said.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(names, ["version", "commit", "uncommitted"]);
assert_eq!(said[0].1, env!("CARGO_PKG_VERSION"));
let (commit, uncommitted) = (&said[1].1, &said[2].1);
assert!(
commit == "unknown" || (commit.len() == 40 && commit.chars().all(|c| c.is_ascii_hexdigit())),
"{commit}"
);
assert!(["no", "yes", "unknown"].contains(&uncommitted.as_str()), "{uncommitted}");
// No commit, no claim about what was changed since it.
if commit == "unknown" {
assert_eq!(uncommitted, "unknown");
}
}

#[test]
fn the_commit_is_the_one_this_checkout_is_at() {
// A source archive has no repository around it; there the binary says
// `unknown` and there is nothing to compare.
let Some(head) = git(&["rev-parse", "HEAD"]) else {
return;
};
let said = about();
assert_eq!(said[1].1, head);
let changed = git(&[
"status",
"--porcelain",
"--",
"crates",
"spec",
"models",
"Cargo.toml",
"Cargo.lock",
])
.expect("git status");
assert_eq!(said[2].1, if changed.is_empty() { "no" } else { "yes" });
}

#[test]
fn it_is_a_command_the_usage_names_and_a_misspelling_is_answered_with_it() {
let help = Command::new(env!("CARGO_BIN_EXE_pact"))
.arg("help")
.output()
.expect("runs");
assert!(String::from_utf8_lossy(&help.stdout).contains("pact about"));
let typo = Command::new(env!("CARGO_BIN_EXE_pact"))
.arg("abuot")
.output()
.expect("runs");
assert!(!typo.status.success());
let said = format!(
"{}{}",
String::from_utf8_lossy(&typo.stdout),
String::from_utf8_lossy(&typo.stderr)
);
assert!(said.contains("Did you mean `pact about`?"), "{said}");
}
2 changes: 1 addition & 1 deletion docs/90-REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ them and changes nothing.
| | |
|---|---|
| Schema | **43 groups, 262 field entries, 207 distinct field names** (core 123, expert 69, both 15), 40 closed enums, 27 cross-reference constraints, **0 fields missing `tier:` or `surface:`** |
| Rust | 5 crates, **1639 tests**, `unsafe_code = forbid`, clippy-deny, edition 2024 |
| Rust | 5 crates, **1642 tests**, `unsafe_code = forbid`, clippy-deny, edition 2024 |
| Python | **47 modules** (`find adapters/python/src -name "*.py" \| wc -l`), **3273 tests**, **nine transports** — seven model-bound (`anthropic`, `autogen`, `langchain`, `langgraph`, `ollama`, `openai_agents`, `pydantic_ai`), one bound to a remote *agent* (`a2a`), one deterministic mock; `_metering.py`, `_summarise.py` and `_tool_choice.py` are shared code the transports import, which is what the `_` says — offline by default |
| TypeScript | 5 modules, 2,305 lines, a second port of **the stepping, the ceilings and the stage path** (`harness.ts:15-21`: *"AC-5.3's bar is the stage path"*) — **not** of the loop entire: no interceptor chain, no durable suspension record, no ledger (`notDoneHere`; a park returns `halted: "suspended"` and writes nothing down). This row is load-bearing for the Phase 3 pricing below (E4) |
| CLI | **6 verbs**: `check`, `show`, `waits`, `discover`, `card`, `help` |
Expand Down
2 changes: 1 addition & 1 deletion site-docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ so plainly rather than leaving it to be discovered.

| | |
|---|---|
| Tests | **4,912** — 1639 Rust, 3273 adapter |
| Tests | **4,915** — 1642 Rust, 3273 adapter |
| Schema kinds | **68** |
| Framework targets | **7**, over 2 runtimes |
| Eve capabilities accounted for | **101 of 101** |
Expand Down
29 changes: 29 additions & 0 deletions site-docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pact card <agent> [PATH]
pact write [PATH] Print the workspace as one PACT-language file
pact build <FILE> --to <FOLDER>
Write the folder a PACT-language file stands for
pact about Say which commit this `pact` was built from
pact help Show this message
```

Expand Down Expand Up @@ -150,3 +151,31 @@ and left where they are.
The folder of YAML and Markdown that a `.pact` file stands for, written into
`--to <folder>`, which must not exist yet or must be empty. What was written is
read back and compared with the file before the command says it is done.

---

## `pact about`

Says what this binary is, in three lines a script can read.

```console
$ pact about
version: 0.1.0
commit: 63a048bbbc5279feefcac453d38a8582090b29db
uncommitted: no
```

| Line | Means |
|---|---|
| `version:` | the release this `pact` says it is |
| `commit:` | the commit the binary was built from, or `unknown` when it was built with no repository around it (a source archive) |
| `uncommitted:` | `no`, `yes` or `unknown`: whether anything the binary is built from — the crates, the specification, the model catalogue, the manifests — differed from that commit when it was built |

Both are fixed when the binary is compiled. They describe the binary, not the
folder you run it in.

**What it is for.** A runtime that pins PACT by commit runs whatever `pact` it
finds on the machine. Checking the source beside it does not check the binary:
one built from an edited checkout, or left over from another commit, looks the
same from outside. A pin check compares `commit:` with its pin and refuses
anything but `uncommitted: no`.
4 changes: 2 additions & 2 deletions site-docs/status/verified.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ from an earlier document.

| Claim | Value | How to check |
|---|---|---|
| Rust tests | **1639** | `cargo test --workspace` |
| Rust tests | **1642** | `cargo test --workspace` |
| Adapter tests | **3273** | `cd adapters/python && uv run pytest tests/ -q` |
| Total | **4,912** | both of the above |
| Total | **4,915** | both of the above |
| TypeScript type-checks | clean | `cd adapters/typescript && npx tsc --noEmit` |
| Lints | clean | `cargo clippy --all-targets` |
| Worked example loads | 498 settings | `cargo run -p pact-cli -- check examples/refund-desk` |
Expand Down
Loading