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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,32 @@ release notes.

### Tools

- `deed review --before <path> --after <path>` gives a patch an evidence
receipt. It checks both module sets through the same import and shipped-module
path as `deed check`, then reports effect-row entries added to each stable
`module/declaration` identity and obligations whose tier regressed, such as
`proven -> guarded`. A newly added effectful function or handler counts as new authority,
imported effects retain the module that declared them in their identity, and
a newly introduced Guarded obligation is a third, separate kind of evidence.

Human output is for review; `--format json` writes one `review_receipt` object
for an agent or CI job. Findings remain informational by default. Three
independent policies turn them into an exit-one gate:
`--deny-new-authority`, `--deny-weaker-promises` and `--deny-new-guarded`.
The receipt is still written, with a policy verdict in human or JSON form.
Compiler or manifest errors on either side also exit one but produce no
receipt, because comparing evidence the compiler could not establish would
make the report look stronger than it is.

`deed mcp` exposes the same evidence as a seventh tool, `deed_review`. Its
`before` and `after` arguments are arrays of module source texts, so imports
resolve within each side without giving the server a filesystem. Its optional
policy object maps the three CLI gates to `denyNewAuthority`,
`denyWeakerPromises` and `denyNewGuarded`. A failed policy is a successful
tool call carrying `policy.passed: false`; malformed arguments remain
protocol errors, and a side that does not check returns `review_refused`
instead of a partial receipt.

- A list of numbers crosses a component boundary. `deed build --component`
refused any export carrying a list; `List<Int>` now goes both ways, and a
component runtime reads it as `list<s64>`:
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,35 @@ an editor does rather than scraping them out of terminal output. It holds no
capability: a program arrives as text, the answer leaves as text, and a program
whose row reaches a file is refused before it runs.
[how-to/let-an-agent-use-the-compiler.md](how-to/let-an-agent-use-the-compiler.md)
has the six tools and the one line worth reading.
has the tool reference and the one line worth reading.

## Every agent patch comes with a receipt

`deed review` compares the checked module set before a change with the one
after it. The first receipt names newly declared authority and obligations
that fell to a weaker contract tier:

```
$ deed review --before old/src --after src
receipt: review required
authority added (1)
+ billing/transfer/settle: Audit.note
obligation tier regressions (1)
! billing/transfer/settle: Positive proven -> guarded
guarded obligations added (0)
```

The paths may name files or directories, and imports are resolved independently
on each side. `--format json` emits the same receipt as one stable object for an
agent or CI job. Findings are informational unless a policy turns them into a
gate: `--deny-new-authority`, `--deny-weaker-promises` and
`--deny-new-guarded` each exit one when its own evidence is present, while still
printing the receipt and a policy verdict. A side that does not check is refused
rather than compared.

The same evidence is available to an agent as `deed_review`. It takes the
before and after module sets as arrays of source text, resolves imports within
each array, and applies the same three policies without opening a file.

## Demo

Expand Down
136 changes: 135 additions & 1 deletion crates/deed-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ deed, a contract-first language
Usage:
deed new <name>
deed check [options] <path>...
deed review --before <path>... --after <path>... [options]
deed test [options] <path>...
deed run [options] <path>... [-- <argument>...]
deed build [options] <path>...
Expand All @@ -25,6 +26,11 @@ Usage:

Options:
--format <human|json> How to print diagnostics. Default: human.
--before <path> A file or directory before the change. Repeatable.
--after <path> A file or directory after the change. Repeatable.
--deny-new-authority With `review`, fail if authority was added.
--deny-weaker-promises With `review`, fail if an obligation's tier regressed.
--deny-new-guarded With `review`, fail if a new Guarded obligation appeared.
--obligations Report which tier each refinement obligation landed in.
--timings Report how long each pass took.
--profile-runtime With `run`, report where runtime went.
Expand Down Expand Up @@ -75,6 +81,10 @@ and a new project has none.
`deed test --compiled` runs test blocks and the properties contracts generate
through the compiled backend. Blocks the backend cannot compile are skipped and
named, so the summary says both how much ran and what did not.
`deed review` compares two checked module sets. Its receipt names authority
added to effect rows and refinement obligations that moved to a weaker tier.
Findings are informational unless a `--deny-*` policy is enabled. Code that
does not check is refused before the two sets are compared.
`deed run` calls `main`, handing it the one `System` capability there is.
Everything after `--` goes to the program, which reads it with `Io.args`.
Standard input is read when, and only when, `main`'s row says `Io.line`. A
Expand Down Expand Up @@ -198,9 +208,21 @@ pub struct CheckArgs {
pub locked: Option<PathBuf>,
}

#[derive(Debug)]
pub struct ReviewArgs {
pub before: Vec<PathBuf>,
pub after: Vec<PathBuf>,
pub format: Format,
pub deny_new_authority: bool,
pub deny_weaker_promises: bool,
pub deny_new_guarded: bool,
}

#[derive(Debug)]
pub enum Command {
Check(CheckArgs),
/// Compare the authority and contract evidence in two module sets.
Review(ReviewArgs),
/// Write a new project into a directory of this name.
New(String),
/// Print the page for one diagnostic code.
Expand Down Expand Up @@ -284,6 +306,7 @@ pub fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Command, String>
(Some(name), None) => Ok(Command::New(name)),
};
}
"review" => return parse_review(args),
"check" => Mode::Check,
"test" => Mode::Test,
"run" => Mode::Run,
Expand All @@ -293,7 +316,7 @@ pub fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Command, String>
"doc" => Mode::Doc,
other => {
return Err(format!(
"unknown command `{other}`, the choices are `new`, `check`, `test`, `run`, `build`, `doc`, `fmt`, `fix`, `explain`, `lsp`, `debug` and `mcp`"
"unknown command `{other}`, the choices are `new`, `check`, `review`, `test`, `run`, `build`, `doc`, `fmt`, `fix`, `explain`, `lsp`, `debug` and `mcp`"
));
}
};
Expand Down Expand Up @@ -459,6 +482,70 @@ pub fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Command, String>
}))
}

fn parse_review<I: Iterator<Item = String>>(mut args: I) -> Result<Command, String> {
let mut before = Vec::new();
let mut after = Vec::new();
let mut format = Format::Human;
let mut deny_new_authority = false;
let mut deny_weaker_promises = false;
let mut deny_new_guarded = false;

while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(Command::Help),
"--deny-new-authority" => deny_new_authority = true,
"--deny-weaker-promises" => deny_weaker_promises = true,
"--deny-new-guarded" => deny_new_guarded = true,
"--before" => before.push(PathBuf::from(
args.next()
.ok_or_else(|| "`--before` needs a path".to_string())?,
)),
other if other.starts_with("--before=") => {
before.push(PathBuf::from(&other["--before=".len()..]));
}
"--after" => after.push(PathBuf::from(
args.next()
.ok_or_else(|| "`--after` needs a path".to_string())?,
)),
other if other.starts_with("--after=") => {
after.push(PathBuf::from(&other["--after=".len()..]));
}
"--format" => {
let value = args
.next()
.ok_or_else(|| "`--format` needs a value, `human` or `json`".to_string())?;
format = parse_format(&value)?;
}
other if other.starts_with("--format=") => {
format = parse_format(&other["--format=".len()..])?;
}
other if other.starts_with('-') => {
return Err(format!("unknown option `{other}`"));
}
path => {
return Err(format!(
"review path `{path}` must follow `--before` or `--after`"
));
}
}
}

if before.is_empty() || after.is_empty() {
return Err(
"`deed review` needs at least one `--before` and one `--after` path".to_string(),
);
}

Ok(Command::Review(ReviewArgs {
before,
after,
format,
deny_new_authority,
deny_weaker_promises,
deny_new_guarded,
}))
}

fn parse_format(value: &str) -> Result<Format, String> {
match value {
"human" => Ok(Format::Human),
Expand Down Expand Up @@ -495,6 +582,53 @@ mod tests {
assert_eq!(check.mode, Mode::Test);
}

#[test]
fn review_has_explicit_repeatable_sides_a_format_and_policies() {
let Ok(Command::Review(review)) = parse(args(&[
"review",
"--before",
"old/a.deed",
"--before=old/b.deed",
"--after=new",
"--format=json",
"--deny-new-authority",
"--deny-weaker-promises",
"--deny-new-guarded",
])) else {
panic!("should parse");
};
assert_eq!(review.before.len(), 2);
assert_eq!(review.after, vec![std::path::PathBuf::from("new")]);
assert_eq!(review.format, Format::Json);
assert!(review.deny_new_authority);
assert!(review.deny_weaker_promises);
assert!(review.deny_new_guarded);
}

#[test]
fn review_needs_both_sides_and_labels_every_path() {
let missing = parse(args(&["review", "--before", "old"])).unwrap_err();
assert!(missing.contains("one `--after`"), "{missing}");

let unlabelled = parse(args(&["review", "old", "--after", "new"])).unwrap_err();
assert!(unlabelled.contains("must follow"), "{unlabelled}");
}

#[test]
fn review_distinguishes_an_unknown_option_from_an_unlabelled_path() {
let unknown = parse(args(&[
"review", "--before", "old", "--after", "new", "--strict",
]))
.unwrap_err();
assert_eq!(unknown, "unknown option `--strict`");

let unlabelled = parse(args(&[
"review", "--before", "old", "--after", "new", "extra",
]))
.unwrap_err();
assert!(unlabelled.contains("must follow"), "{unlabelled}");
}

#[test]
fn lsp_is_a_command_and_takes_nothing_else() {
assert!(matches!(parse(args(&["lsp"])), Ok(Command::Lsp)));
Expand Down
Loading