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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ path = "tests/e2e/cases/E2E-046-a-root-waiting-on-a-person.rs"
name = "e2e_047_an_issue_nobody_holds_is_work"
path = "tests/e2e/cases/E2E-047-an-issue-nobody-holds-is-work.rs"

[[test]]
name = "e2e_048_a_registry_refused_whole"
path = "tests/e2e/cases/E2E-048-a-registry-refused-whole.rs"

[[test]]
name = "agents_test"
path = "tests/integration/agents_test.rs"
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog/unreleased/registry-refused-whole.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- **A registry the schema refuses names every violation at once.** `ephor
validate`, `ephor refresh` and every other command that loads the registry
used to name only the first place it failed the schema, so a hand-written
registry missing five fields took five runs to fix. The refusal now lists
each violation on its own line under a header that counts them, and closes
by naming `config/workspaces.example.json` as a complete registry to start
from; under `--json` the list rides in `says`
([§FS-006-project-interface.11.1](../../functional-spec/FS-006-project-interface.md#111-a-registry-the-schema-refuses-is-refused-whole)). (PR #177)
21 changes: 21 additions & 0 deletions docs/functional-spec/FS-006-project-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,27 @@ scalar values, so a list or a nested map written where a value belongs is
refused offline and by name rather than becoming a selector that silently never
matches.

### 11.1 A registry the schema refuses is refused whole

A registry that does not match its schema is answered with **every violation
one schema pass sees**, not the first of them: one line per violation, each
naming the instance path it sits at and what that place owes, under a header
that counts them. A registry written by hand from the schema owes its missing
fields all at once, and an answer naming one of them turns N missing fields
into N edit-and-rerun rounds, each one another command and another read of the
file ([§GOAL-001-fewest-moves](../goals.md#goal-001-fewest-moves-the-most-frequent-response-is-the-cheapest-one)). The answer closes by naming
`config/workspaces.example.json` as a complete registry to start from, because
copying the shipped example and cutting it down answers every one of those
lines at once.

It is the same refusal, only whole: the error, its exit code and the shape of
`--json` stay what they were, and under `--json` the list is carried inside the
answer's existing `says` sentence rather than in a field of its own. Every
command that loads the registry — `validate`, `validate --schema-only`,
`refresh` and the rest — gives this one answer, because they all load it the
same way. What a registry owes *after* the schema passes — unique ids, a
project's type, its derived ids — is not part of this pass.

## 12. What the toolchain keeps in a checkout has a home, and a deprecated one

`.agents/` is, by convention, where an agent's *instructions* live, and agent
Expand Down
4 changes: 4 additions & 0 deletions docs/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ describes what the concepts *mean* rather than restating the schema.

Your own registry lives at `~/.config/ephor/workspaces.json`;
`config/workspaces.example.json` is a worked example of everything below.
A registry the schema refuses is answered with every violation one pass sees,
each at its path, and the answer names that example as a complete registry to
start from
([§FS-006-project-interface.11.1](functional-spec/FS-006-project-interface.md#111-a-registry-the-schema-refuses-is-refused-whole)).

## Four top-level sections

Expand Down
21 changes: 18 additions & 3 deletions src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,32 @@ pub fn parse_registry(text: &str, registry_path: &Path, schema: &Value) -> Resul
Ok(registry)
}

/// Validates a parsed registry against its schema, then checks what the schema
/// cannot say. A registry the schema refuses is refused whole: every violation
/// one schema pass sees, one line each, under a header that counts them and
/// above a pointer at the shipped example (§FS-006-project-interface.11.1).
pub fn validate_registry(registry: &Value, schema: &Value) -> Result<()> {
if schema.get("type").and_then(Value::as_str) != Some("object") {
return Err(registry_error("Schema root must be an object."));
}

let validator = jsonschema::validator_for(schema)
.map_err(|err| registry_error(format!("Invalid registry schema: {err}")))?;
if let Some(error) = validator.iter_errors(registry).next() {
let violations: Vec<String> = validator
.iter_errors(registry)
.map(|error| format!(" {}: {}", error.instance_path, error))
.collect();
if !violations.is_empty() {
let noun = if violations.len() == 1 {
"violation"
} else {
"violations"
};
return Err(registry_error(format!(
"Registry does not match schema at '{}': {}",
error.instance_path, error
"Registry does not match schema ({} {noun}):\n{}\n\
config/workspaces.example.json is a complete registry to start from.",
violations.len(),
violations.join("\n")
)));
}

Expand Down
133 changes: 133 additions & 0 deletions tests/e2e/cases/E2E-048-a-registry-refused-whole.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! E2E-048-a-registry-refused-whole: a registry written by hand from the schema
//! learns everything it still owes from one run, not one field per run.
//!
//! The scenario is §FS-006-project-interface.11.1 end to end, on the registry
//! agent-grounds/ephor#158 reports. A person building the smallest site they
//! can writes every top-level section and leaves out five required fields: the
//! organization's `name`, the project type's `layout` and its agents'
//! `template` and `structure_intro`, and the project's `display_name`. One
//! schema pass sees all five. Ephor named the first and dropped the rest, so
//! reaching a registry it accepts took five failing runs.
//!
//! What this case holds ephor to, on every surface that loads the registry —
//! `validate`, `validate --schema-only`, `validate --json` and `refresh`: the
//! one refusal names all five violations at their instance paths, counts them,
//! and points at `config/workspaces.example.json`; it is still a refusal, with
//! the exit code it had, and under `--json` the list rides in `says`.

#[path = "../support.rs"]
mod support;

use serde_json::{json, Value};

use support::*;

/// The issue's hand-written registry, rooted in this world rather than in a
/// scratch directory of the machine it was first written on.
fn hand_written(world: &World) -> Value {
let root = world.path().join("site");
json!({
"organizations": [{"id": "demo", "root": root.to_string_lossy()}],
"hook_sets": [],
"project_types": [{"id": "repo",
"repos": [{"id": "repo", "path": ".", "role": "the project",
"required": true, "update_mode": "skip"}],
"agents": {"summary_template": "A demo project."}}],
"projects": [{"id": "proj", "organization": "demo", "type": "repo",
"root": root.join("proj").to_string_lossy(), "main_branch": "main",
"clone_mode": "worktree", "branch_root_template": "{project_root}/{branch}"}]
})
}

/// Every violation one schema pass sees: the place, and the field it owes.
const OWED: [(&str, &str); 5] = [
("/organizations/0", "name"),
("/project_types/0", "layout"),
("/project_types/0/agents", "template"),
("/project_types/0/agents", "structure_intro"),
("/projects/0", "display_name"),
];

/// The refusal names every owed field at its path, says how many there are,
/// and points at the shipped example.
fn assert_refused_whole(surface: &str, says: &str) {
let missing: Vec<String> = OWED
.iter()
.filter(|(path, field)| {
!says.lines().any(|line| {
line.contains(path) && line.contains(&format!("\"{field}\" is a required property"))
})
})
.map(|(path, field)| format!("{path}: {field}"))
.collect();
assert!(
missing.is_empty(),
"{surface}: one refusal should name all {} violations, each on a line with its path; \
it does not name {missing:?}:\n{says}",
OWED.len()
);
assert!(
says.contains("5 violations"),
"{surface}: the refusal should count what it names (\"5 violations\"):\n{says}"
);
assert!(
says.contains("config/workspaces.example.json"),
"{surface}: the refusal should name config/workspaces.example.json as a registry \
to start from:\n{says}"
);
}

fn stderr_of(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}

#[test]
fn validate_names_every_violation_in_one_run() {
let world = World::new();
write_json(&world.registry_path(), &hand_written(&world));

for args in [&["validate"][..], &["validate", "--schema-only"][..]] {
let output = world.ephor().args(args).output().expect("ephor runs");
assert!(
!output.status.success(),
"{args:?} must still refuse the registry"
);
assert_refused_whole(&format!("ephor {}", args.join(" ")), &stderr_of(&output));
}
}

#[test]
fn validate_json_carries_the_list_in_says() {
let world = World::new();
write_json(&world.registry_path(), &hand_written(&world));

let output = world
.ephor()
.args(["validate", "--json"])
.output()
.expect("ephor runs");
assert!(
!output.status.success(),
"--json must still refuse the registry"
);
let answer = json_of(&output);
assert_eq!(answer["ok"], json!(false), "{answer}");
let says = answer["says"]
.as_str()
.unwrap_or_else(|| panic!("a `says` string: {answer}"));
assert_refused_whole("ephor validate --json", says);
}

#[test]
fn refresh_names_every_violation_in_one_run() {
let world = World::new();
write_json(&world.registry_path(), &hand_written(&world));
world.configure(json!({ "projects": {} }));

let output = world.ephor().arg("refresh").output().expect("ephor runs");
assert!(
!output.status.success(),
"refresh must still refuse the registry"
);
assert_refused_whole("ephor refresh", &stderr_of(&output));
}
Loading