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
5 changes: 5 additions & 0 deletions docs/explanation/branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ Detection alone is half a design; this section is the other half.
merge gate always runs the dry-run overlay validation, even for
"clean" merges.

A dry run reports the same gate without landing anything: the landable
entries split into added, changed and deleted, and every finding the merged
world carries, errors and non-blocking ones alike. A reviewer reads one
result instead of a diff and a separate validation call.

**Resolution happens on the branch, never inside the merge.** Merge is
all-or-nothing: it either lands everything or reports conflicts and
changes nothing. Resolving is ordinary branch work, not a special mode:
Expand Down
7 changes: 7 additions & 0 deletions docs/how-to/repair-validation-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ clarification. A new rule or unclear result can require inspection of its
validator implementation; the catalog and tool contracts are the starting
point.

To survey a whole namespace instead, run `validate_project` with `namespace`
set to it and `json: true`: every EventModel in the namespace is validated in
one call. Filter its findings on a code to list what needs work, for example
`SLICE_NO_SCENARIOS` (Info) for the slices that still lack a given/when/then
scenario.

## Discover the full dependency context

Use existing model tools before searching exports manually:
Expand Down Expand Up @@ -147,6 +153,7 @@ errors and `isError` before reading that envelope.
| `get_entity_json` | `{ "etag": "...", "json": { ... } }` with an object-valued entity. |
| `validate_event_model` with `json: true` | `{ "json": "..." }` with a JSON-encoded response string. |
| Reference tools, `get_impact`, `batch_mutate_json` | `{ "binpb_base64": "..." }` with the protobuf response. |
| `put_entity`, `delete_entity`, `batch_mutate`, `batch_mutate_json` | `{ "scope": "branch" or "baseline", "branch": "..." or null, "binpb_base64": "..." }`. Use `scope` and `branch` to confirm which store took the write. |

The JSON response helper can fall back to `binpb_base64` if reflection
rendering fails. Decode binary responses using the matching RPC response
Expand Down
14 changes: 13 additions & 1 deletion proto/trogonatlas/api/eventmodel/v1alpha1/service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1033,11 +1033,23 @@ message MergeBranchResponse {
}
Status status = 1;
repeated trogonatlas.catalog.v1alpha1.BranchDiffEntry conflicts = 2;
// Post-merge validation findings. STATUS_INVALID carries the findings that
// blocked the merge. A dry run that reports STATUS_VALIDATED also carries
// its Warning and Info findings, so a reviewer sees what the merge leaves
// behind without a separate validation call.
repeated ValidationIssue validation = 3;
// Number of non-CONVERGED entries actually landed on baseline.
// Number of non-CONVERGED entries actually landed on baseline, or that a
// dry run would land.
uint32 applied_count = 4;
// Set only when the request carried an operation_id.
trogonatlas.catalog.v1alpha1.OperationReceipt operation_receipt = 5;
// The landed (or, on a dry run, landable) entries split by diff class.
// Together they sum to applied_count when the merge reached the landing
// step (STATUS_APPLIED, STATUS_VALIDATED, or a dry run's STATUS_INVALID);
// all zero for STATUS_CONFLICTS and for an operation_id replay.
uint32 added_count = 6;
uint32 changed_count = 7;
uint32 deleted_count = 8;
}

message UpdateBranchRequest {
Expand Down
11 changes: 6 additions & 5 deletions proto/trogonatlas/api/eventmodel/v1alpha1/validation.proto
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,19 @@ message ListValidationRulesResponse {
repeated ValidationRule rules = 1;
}

// Validate every EventModel in a project (or in the entire store) in one
// Validate every EventModel in a project, a domain or a namespace in one
// pass, instead of N round trips through ValidateEventModel. The
// validator already has the full entity store in memory per call;
// looping per-model is pure waste.
message ValidateProjectRequest {
// Scope by Project.id (if your store carries Projects) or by
// Domain.id (legacy). One of the two must be set. ENTITY_KIND_UNSPECIFIED
// for the full store works only on small dev stores; large stores
// should always scope.
// Scope by Project.id (if your store carries Projects), by Domain.id
// (legacy) or by namespace. Exactly one must be set.
oneof scope {
trogonatlas.type.v1alpha1.Id project_id = 1;
trogonatlas.type.v1alpha1.Id domain_id = 2;
// Every EventModel whose id carries this namespace, whether or not a
// Domain claims it.
string namespace = 3;
}
}

Expand Down
48 changes: 42 additions & 6 deletions rsworkspace/crates/trogon-atlas-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@ enum BranchCmd {
Merge {
name: String,
/// Run full conflict detection and post-merge validation without
/// persisting anything, even on success.
/// persisting anything, even on success. Prints how many entities
/// would be added, changed and deleted, and the post-merge findings.
#[arg(long)]
dry_run: bool,
/// Keep the branch (now empty of deltas) after a successful merge
Expand Down Expand Up @@ -970,19 +971,42 @@ async fn run(
if text {
println!("{name} merged ({} entities applied)", resp.applied_count);
}
let data =
json!({"status": "applied", "applied_count": resp.applied_count});
let counts = branch::MergeCounts::of(&resp);
let data = json!({
"status": "applied",
"applied_count": resp.applied_count,
"added_count": counts.added,
"changed_count": counts.changed,
"deleted_count": counts.deleted,
});
Ok((ExitCode::SUCCESS, Some(data)))
}
Status::Validated => {
let counts = branch::MergeCounts::of(&resp);
let severities = branch::SeverityCounts::of(&resp.validation);
if text {
println!(
"{name} would merge cleanly ({} entities, dry run)",
"{name} would merge cleanly (dry run): {} entities ({counts})",
resp.applied_count
);
println!("validation after merge: {severities}");
for issue in &resp.validation {
println!("{}", apply::format_issue(issue));
}
}
let data =
json!({"status": "validated", "applied_count": resp.applied_count});
let data = json!({
"status": "validated",
"applied_count": resp.applied_count,
"added_count": counts.added,
"changed_count": counts.changed,
"deleted_count": counts.deleted,
"validation_summary": {
"errors": severities.errors,
"warnings": severities.warnings,
"info": severities.infos,
},
"validation": resp.validation.iter().map(apply::issue_to_json).collect::<Vec<_>>(),
});
Ok((ExitCode::SUCCESS, Some(data)))
}
Status::Conflicts => {
Expand All @@ -995,14 +1019,26 @@ async fn run(
Ok((ExitCode::from(EXIT_FINDINGS), Some(data)))
}
Status::Invalid => {
let counts = branch::MergeCounts::of(&resp);
let severities = branch::SeverityCounts::of(&resp.validation);
if text {
eprintln!("{name} would leave baseline invalid, nothing merged:");
eprintln!("would have landed: {counts}");
eprintln!("validation after merge: {severities}");
for issue in &resp.validation {
eprintln!("{}", apply::format_issue(issue));
}
}
let data = json!({
"status": "invalid",
"added_count": counts.added,
"changed_count": counts.changed,
"deleted_count": counts.deleted,
"validation_summary": {
"errors": severities.errors,
"warnings": severities.warnings,
"info": severities.infos,
},
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
"validation": resp.validation.iter().map(apply::issue_to_json).collect::<Vec<_>>(),
});
Ok((ExitCode::from(EXIT_FINDINGS), Some(data)))
Expand Down
103 changes: 103 additions & 0 deletions rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ spec: { title: On branch }
"{:?}",
merged.stdout
);
assert!(
merged.stdout.contains("1 added, 0 changed, 0 deleted"),
"{:?}",
merged.stdout
);
assert!(
merged
.stdout
.contains("validation after merge: 0 errors, 0 warnings, 0 info"),
"{:?}",
merged.stdout
);

let as_json = trogon_atlas(
&endpoint,
&[
"--format",
"json",
"branch",
"merge",
"cli-dry",
"--dry-run",
],
);
let doc: serde_json::Value = serde_json::from_str(&as_json.stdout).unwrap();
assert_eq!(doc["data"]["added_count"], 1, "{doc}");
assert_eq!(doc["data"]["changed_count"], 0, "{doc}");
assert_eq!(doc["data"]["deleted_count"], 0, "{doc}");
assert_eq!(doc["data"]["validation_summary"]["errors"], 0, "{doc}");

// Dry run must not persist: baseline still lacks the entity.
let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30)
Expand All @@ -224,6 +253,80 @@ spec: { title: On branch }
assert!(missing.is_err(), "dry run must not persist");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trogon_atlas_branch_merge_dry_run_invalid_reports_counts_and_findings() {
let endpoint = start_server().await;

let created = trogon_atlas(&endpoint, &["branch", "create", "cli-inv"]);
assert!(created.status.success(), "{:?}", created.stderr);

let dir = tempfile_dir();
let manifest_path = dir.path().join("invalid.yaml");
std::fs::write(
&manifest_path,
r"
apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1
kind: Term
metadata: { namespace: cli-inv-ns, name: ghost-term }
spec:
title: Ghost
embodiedBy:
- kind: ENTITY_KIND_EVENT
id: { namespace: cli-inv-ns, slug: does-not-exist, version: '1' }
",
)
.unwrap();
let applied = trogon_atlas_branched(
&endpoint,
"cli-inv",
&["apply", "-f", manifest_path.to_str().unwrap()],
);
assert!(applied.status.success(), "{:?}", applied.stderr);

let merged = trogon_atlas(&endpoint, &["branch", "merge", "cli-inv", "--dry-run"]);
assert_eq!(merged.status.code(), Some(1), "{:?}", merged.stderr);
assert!(
merged
.stderr
.contains("would have landed: 1 added, 0 changed, 0 deleted"),
"{:?}",
merged.stderr
);
assert!(
merged.stderr.contains("validation after merge: 1 errors"),
"{:?}",
merged.stderr
);
assert!(
merged.stderr.contains("TERM_DANGLING_ENTITY"),
"{:?}",
merged.stderr
);

let as_json = trogon_atlas(
&endpoint,
&[
"--format",
"json",
"branch",
"merge",
"cli-inv",
"--dry-run",
],
);
let doc: serde_json::Value = serde_json::from_str(&as_json.stdout).unwrap();
assert_eq!(doc["data"]["status"], "invalid", "{doc}");
assert_eq!(doc["data"]["added_count"], 1, "{doc}");
assert_eq!(doc["data"]["validation_summary"]["errors"], 1, "{doc}");
let finding = doc["data"]["validation"]
.as_array()
.unwrap()
.iter()
.find(|i| i["code"] == "TERM_DANGLING_ENTITY")
.unwrap_or_else(|| panic!("{doc}"));
assert_eq!(finding["severity"], "error", "{doc}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trogon_atlas_branch_merge_clean_exits_0() {
let endpoint = start_server().await;
Expand Down
13 changes: 10 additions & 3 deletions rsworkspace/crates/trogon-atlas-client/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ pub fn issue_to_json(issue: &pb::ValidationIssue) -> Value {
Some(pb::canonical::id_string(kind, id))
});
json!({
"severity": severity_name(issue),
"code": issue.code,
"message": issue.message,
"subject": subject,
Expand Down Expand Up @@ -380,13 +381,17 @@ pub fn render_diff(plan: &[PlannedChange], types: &TranscodePool) -> Result<(Str
Ok((out, changed))
}

pub fn format_issue(issue: &pb::ValidationIssue) -> String {
let severity = match pb::validation_issue::Severity::try_from(issue.severity) {
fn severity_name(issue: &pb::ValidationIssue) -> &'static str {
match pb::validation_issue::Severity::try_from(issue.severity) {
Ok(pb::validation_issue::Severity::Error) => "error",
Ok(pb::validation_issue::Severity::Warning) => "warning",
Ok(pb::validation_issue::Severity::Info) => "info",
_ => "issue",
};
}
}

pub fn format_issue(issue: &pb::ValidationIssue) -> String {
let severity = severity_name(issue);
let subject = issue
.subject
.as_ref()
Expand Down Expand Up @@ -499,11 +504,13 @@ mod tests {
issues: vec![pb::ValidationIssue {
code: "ENTITY_REFERENCED".into(),
message: "entity is referenced".into(),
severity: pb::validation_issue::Severity::Error as i32,
..Default::default()
}],
};
let json = rejected.to_json();
assert_eq!(json["failed"], "event:shop/order.placed@1");
assert_eq!(json["issues"][0]["code"], "ENTITY_REFERENCED");
assert_eq!(json["issues"][0]["severity"], "error");
}
}
Loading
Loading