1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#![cfg(feature = "slow-tests")]
//! Contract: enrich — dry-run envelope and the required --mode guard.
//!
//! Part of the JSON-contract suite split by GAP-SG-208: the single file held
//! 1393 lines and 41 tests, past the 800-line ceiling this project sets for
//! itself. The shared harness lives in `tests/contract_support/`.
//!
//! Ground truth: `docs/schemas/*.schema.json`. Each test checks the expected
//! exit code, valid JSON, and the presence of the required keys.
#[path = "contract_support/mod.rs"]
mod support;
use serial_test::serial;
use support::{assert_has_keys, Env};
// ---------------------------------------------------------------------------
// 39 — enrich (dry-run, no LLM spawned)
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn contract_39_enrich() {
let env = Env::new();
env.init();
// Seed one memory without entity bindings so the scan finds it.
env.remember(
"mem-enrich-contract",
"auth uses JWT with short expiry and refresh tokens",
);
// dry-run mode: emits phase events + preview item events + summary without calling LLM.
let out = env
.cmd()
.args([
"enrich",
"--operation",
"memory-bindings",
"--mode",
"openrouter",
"--dry-run",
])
.output()
.unwrap();
assert!(
out.status.success(),
"enrich dry-run failed: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
// Output is NDJSON: one line per event.
let stdout_str = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout_str
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
assert!(
!lines.is_empty(),
"enrich must emit at least one NDJSON line"
);
// Parse each line and find the summary (last non-empty line typically).
let mut summary_found = false;
let mut phase_validate_found = false;
let mut phase_scan_found = false;
// GAP-CLI-DRY-01 (v1.1.8): dry-run never resolves a provider binary, so the
// `validate` phase — which carries `binary_path` and `version` — must NOT be
// emitted. Announcing a validation that never ran would make the envelope
// lie. This test used to REQUIRE `validate` and had been red since v1.1.8.
for line in &lines {
let val: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid NDJSON line: {e}\n{line}"));
if val["summary"] == true {
// Summary line
assert_has_keys(
"enrich-summary",
&val,
&[
"summary",
"operation",
"items_total",
"completed",
"failed",
"skipped",
"cost_usd",
"elapsed_ms",
],
);
assert_eq!(val["summary"], true);
summary_found = true;
} else if val.get("phase").is_some() {
// Phase event
let phase = val["phase"].as_str().unwrap_or("");
match phase {
"validate" => {
assert_has_keys("enrich-phase(validate)", &val, &["phase"]);
phase_validate_found = true;
}
"scan" => {
assert_has_keys("enrich-phase(scan)", &val, &["phase"]);
phase_scan_found = true;
}
_ => panic!("unexpected phase value: {phase}"),
}
} else if val.get("item").is_some() {
// Item event (preview in dry-run)
assert_has_keys("enrich-item", &val, &["item", "status", "index", "total"]);
let status = val["status"].as_str().unwrap_or("");
assert_eq!(
status, "preview",
"dry-run items must have status='preview'"
);
}
}
assert!(
!phase_validate_found,
"dry-run must NOT emit a 'validate' phase event: no provider binary is \
resolved, so there is nothing validated to report (GAP-CLI-DRY-01)"
);
assert!(phase_scan_found, "enrich must emit a 'scan' phase event");
assert!(summary_found, "enrich must emit a summary line");
}
// ---------------------------------------------------------------------------
// 39b — enrich requires --mode (GAP-HEADLESS-DEFAULT: no default provider)
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn contract_39b_enrich_requires_mode() {
let env = Env::new();
env.init();
// GAP-HEADLESS-DEFAULT: a WRITE run that omits --mode must be rejected by
// clap, never silently defaulting to a provider that spawns a headless CLI.
// This is still enforced and is the case that matters — a write run is the
// one that would actually spawn something.
let out = env
.cmd()
.args(["enrich", "--operation", "memory-bindings"])
.output()
.unwrap();
assert!(
!out.status.success(),
"enrich without --mode must fail; stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.status.code(),
Some(2),
"enrich without --mode must exit 2 (clap parsing error); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
// GAP-CLI-DRY-01 (v1.1.8) carved out --dry-run: it is read-only and spawns
// nothing, so clap accepts the omission there. This test used to run WITH
// --dry-run and had been red since that change.
//
// Note the asymmetry is what makes the invariant hold: under --dry-run the
// binary resolution is skipped before the mode is even inspected, so no
// provider can be reached regardless of the fallback value.
let dry = env
.cmd()
.args(["enrich", "--operation", "memory-bindings", "--dry-run"])
.output()
.unwrap();
assert!(
dry.status.success(),
"enrich --dry-run without --mode must succeed (GAP-CLI-DRY-01); stderr: {}",
String::from_utf8_lossy(&dry.stderr)
);
let dry_out = String::from_utf8_lossy(&dry.stdout);
for line in dry_out.lines().filter(|l| !l.trim().is_empty()) {
let val: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid NDJSON line: {e}\n{line}"));
assert!(
val.get("binary_path").is_none(),
"dry-run must never resolve a provider binary; offending line: {line}"
);
}
}