codingest-cli 0.2.6

CLI for Codingest code graphs — build/status .kgl artifacts and install the code-review Agent Skill
Documentation
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! The `codingest query` exit-code contract, driven through the real binary.
//!
//! `exit_code_for` is unit-tested in-crate, but the mapping is only worth
//! anything if `main.rs` actually applies it — and `main` is not reachable from
//! a unit test. These drive the compiled binary and read the process status.

use std::path::Path;
use std::process::{Command, Output};

fn codingest(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_codingest"))
        .args(args)
        .output()
        .expect("failed to run the codingest binary")
}

/// The same binary with the `[timing]` diagnostics switched on.
fn codingest_timed(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_codingest"))
        .args(args)
        .env("KGLITE_CODE_TREE_VERBOSE", "1")
        .output()
        .expect("failed to run the codingest binary")
}

fn code(output: &Output) -> i32 {
    output
        .status
        .code()
        .expect("process terminated by a signal")
}

/// A built graph beside its source tree; returns (tempdir, source, graph).
fn fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
    let dir = tempfile::tempdir().unwrap();
    let source = dir.path().join("proj");
    std::fs::create_dir(&source).unwrap();
    std::fs::write(
        source.join("Cargo.toml"),
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
    )
    .unwrap();
    std::fs::create_dir(source.join("src")).unwrap();
    std::fs::write(source.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
    let graph = dir.path().join("demo.kgl");
    let built = codingest(&[
        "build",
        source.to_str().unwrap(),
        "-o",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&built), 0, "fixture build failed: {built:?}");
    (dir, source, graph)
}

fn query(graph: &Path, extra: &[&str]) -> Output {
    let mut args = vec!["query", "MATCH (f:Function) RETURN f.name"];
    args.extend_from_slice(extra);
    args.extend_from_slice(&["-g", graph.to_str().unwrap()]);
    codingest(&args)
}

#[test]
fn successful_query_exits_zero_with_rows_on_stdout_and_summary_on_stderr() {
    let (_dir, _source, graph) = fixture();
    let out = query(&graph, &[]);
    assert_eq!(code(&out), 0, "{out:?}");
    assert_eq!(String::from_utf8_lossy(&out.stdout), "f.name\nalpha\n");
    // stdout stays pure data: the row summary belongs on stderr.
    assert!(String::from_utf8_lossy(&out.stderr).contains("1 row(s)"));
}

#[test]
fn stale_graph_warns_on_stderr_but_still_exits_zero() {
    let (_dir, source, graph) = fixture();
    std::fs::write(source.join("src/lib.rs"), "pub fn gamma() {}\n").unwrap();
    let out = query(&graph, &[]);
    assert_eq!(code(&out), 0, "{out:?}");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("warning: graph is stale: source changed since the graph was built"),
        "no stale warning on stderr: {stderr}"
    );
    // The warning must not contaminate the data stream.
    assert_eq!(String::from_utf8_lossy(&out.stdout), "f.name\nalpha\n");
}

#[test]
fn require_fresh_on_a_stale_graph_exits_three() {
    let (_dir, source, graph) = fixture();
    std::fs::write(source.join("src/lib.rs"), "pub fn gamma() {}\n").unwrap();
    let out = query(&graph, &["--require-fresh"]);
    assert_eq!(code(&out), 3, "{out:?}");
    assert!(out.stdout.is_empty(), "refusal wrote rows to stdout");
}

/// `--parallel` is accepted by the parser and changes nothing observable.
///
/// The fixture is far below the engine's fan-out threshold, so no speedup is
/// claimed or asserted — this pins the plumbing: the flag parses, the run
/// still exits `0`, and the rows are byte-identical to the serial run.
#[test]
fn parallel_flag_is_accepted_and_leaves_the_rows_unchanged() {
    let (_dir, _source, graph) = fixture();
    let serial = query(&graph, &[]);
    let parallel = query(&graph, &["--parallel"]);
    assert_eq!(code(&parallel), 0, "{parallel:?}");
    assert_eq!(parallel.stdout, serial.stdout);
    assert_eq!(String::from_utf8_lossy(&parallel.stdout), "f.name\nalpha\n");
}

#[test]
fn require_fresh_on_a_fresh_graph_exits_zero() {
    let (_dir, _source, graph) = fixture();
    let out = query(&graph, &["--require-fresh"]);
    assert_eq!(code(&out), 0, "{out:?}");
    assert_eq!(String::from_utf8_lossy(&out.stdout), "f.name\nalpha\n");
}

#[test]
fn operational_errors_exit_one() {
    let (dir, _source, graph) = fixture();

    let bad_cypher = codingest(&[
        "query",
        "MATCH (f:Function RETURN",
        "-g",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&bad_cypher), 1, "{bad_cypher:?}");

    let missing = dir.path().join("absent.kgl");
    let no_graph = codingest(&[
        "query",
        "MATCH (f:Function) RETURN f.name",
        "-g",
        missing.to_str().unwrap(),
    ]);
    assert_eq!(code(&no_graph), 1, "{no_graph:?}");
    let stderr = String::from_utf8_lossy(&no_graph.stderr);
    assert!(
        stderr.contains("codingest build"),
        "no build hint: {stderr}"
    );

    // A mutation is rejected by the engine's read path, not by a CLI policy
    // layer — and it is an operational failure, not a freshness refusal.
    let mutation = codingest(&["query", "CREATE (n:X)", "-g", graph.to_str().unwrap()]);
    assert_eq!(code(&mutation), 1, "{mutation:?}");
}

#[test]
fn usage_errors_exit_two() {
    let out = codingest(&["query"]);
    assert_eq!(code(&out), 2, "missing positional should be a usage error");
}

#[test]
fn malformed_timeout_is_a_usage_error_not_a_panic() {
    let (_dir, _source, graph) = fixture();
    // `-1` and `nan` used to reach `Duration::from_secs_f64` and abort the
    // process with 101 — off the documented 0/1/2/3 contract entirely. `1e30`
    // overflows `Duration` the same way. `0` is rejected by policy: "no
    // timeout" is the flag's absence, so a zero can only be a mistake.
    for value in ["-1", "nan", "1e30", "inf", "0", "banana"] {
        let out = query(&graph, &["--timeout", value]);
        assert_eq!(
            code(&out),
            2,
            "--timeout={value} did not exit 2 as a usage error: {out:?}"
        );
        assert!(
            out.stdout.is_empty(),
            "--timeout={value} wrote rows to stdout"
        );
    }
}

#[test]
fn an_expiring_timeout_still_exits_one() {
    let (_dir, _source, graph) = fixture();
    let out = query(&graph, &["--timeout", "0.000000001"]);
    assert_eq!(code(&out), 1, "expiring timeout changed exit code: {out:?}");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("timed out"),
        "no timeout diagnostic on stderr: {stderr}"
    );
}

#[test]
fn cypher_alias_is_visible_and_equivalent() {
    let (_dir, _source, graph) = fixture();
    let aliased = codingest(&[
        "cypher",
        "MATCH (f:Function) RETURN f.name",
        "-g",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&aliased), 0, "{aliased:?}");
    assert_eq!(String::from_utf8_lossy(&aliased.stdout), "f.name\nalpha\n");

    let help = codingest(&["--help"]);
    let text = String::from_utf8_lossy(&help.stdout);
    assert!(
        text.contains("query") && text.contains("cypher"),
        "alias not discoverable in --help: {text}"
    );
}

#[test]
fn query_text_can_come_from_stdin() {
    use std::io::Write;
    use std::process::Stdio;

    let (_dir, _source, graph) = fixture();
    let mut child = Command::new(env!("CARGO_BIN_EXE_codingest"))
        .args(["query", "-", "-g", graph.to_str().unwrap()])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"MATCH (f:Function) RETURN f.name\n")
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert_eq!(code(&out), 0, "{out:?}");
    assert_eq!(String::from_utf8_lossy(&out.stdout), "f.name\nalpha\n");
}

/// `KGLITE_CODE_TREE_VERBOSE` alone — with no `--verbose` — yields the WHOLE
/// timing set, not a subset. The builder's phase timers used to answer only to
/// the `--verbose` flag while the CLI-side and manifest lines answered only to
/// the env var, so the documented env-var route printed an incomplete set: a
/// diagnostic that lied by omission. Asserting the builder phases here is what
/// keeps the two halves on one switch.
///
/// `--verbose` deliberately does not gate the two CLI-side lines:
/// `source_fingerprint` runs from `status` and from every `query` freshness
/// check, neither of which has a verbose flag.
#[test]
fn timing_diagnostics_appear_on_stderr_under_the_verbose_env_var() {
    let (dir, source, _graph) = fixture();
    let graph = dir.path().join("timed.kgl");
    let built = codingest_timed(&[
        "build",
        source.to_str().unwrap(),
        "-o",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&built), 0, "{built:?}");
    let stderr = String::from_utf8_lossy(&built.stderr);
    for line in [
        "[timing] manifest discovery:",
        "[timing] save graph:",
        "[timing] source fingerprint:",
        // Builder phase timers — these were gated on the `--verbose` flag only.
        "[timing] walk:",
        "[timing] parse dispatch:",
        "[timing] parse:",
        "[timing] dedup:",
        "[timing] js workspace discovery:",
        "[timing] load:",
        "[timing] cross-lang:",
    ] {
        assert!(
            stderr.contains(line),
            "missing {line:?} on stderr: {stderr}"
        );
    }
    // The human status line is the whole of stdout; no timing leaked onto it.
    let stdout = String::from_utf8_lossy(&built.stdout);
    assert!(
        !stdout.contains("[timing]"),
        "timing line contaminated build stdout: {stdout}"
    );

    // Unset, the binary stays silent — these are diagnostics, not output.
    let quiet = codingest(&[
        "build",
        source.to_str().unwrap(),
        "-o",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&quiet), 0, "{quiet:?}");
    assert!(
        !String::from_utf8_lossy(&quiet.stderr).contains("[timing]"),
        "timing printed without the env var"
    );
}

/// The contamination guard. `query` runs `source_fingerprint` through its
/// freshness check on EVERY invocation, so a timing line written to stdout
/// instead of stderr would prepend itself to the JSON payload and break every
/// machine consumer. Parsing stdout is what makes that failure loud.
#[test]
fn json_query_stdout_stays_parseable_with_timing_enabled() {
    let (_dir, _source, graph) = fixture();
    let out = codingest_timed(&[
        "query",
        "MATCH (f:Function) RETURN f.name",
        "-g",
        graph.to_str().unwrap(),
        "--format",
        "json",
    ]);
    assert_eq!(code(&out), 0, "{out:?}");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let payload: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("query stdout is not clean JSON ({e}): {stdout:?}"));
    assert_eq!(payload["columns"][0], "f.name");
    assert_eq!(payload["rows"][0][0], "alpha");
    // The fingerprint timer did fire — the guard above is testing a live path,
    // not an unreachable one.
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("[timing] source fingerprint:"),
        "query did not time its freshness check: {stderr}"
    );
}

/// `status --format json` shares the fingerprint path and the same one-object
/// stdout contract.
#[test]
fn json_status_stdout_stays_parseable_with_timing_enabled() {
    let (_dir, _source, graph) = fixture();
    let out = codingest_timed(&["status", "-o", graph.to_str().unwrap(), "--format", "json"]);
    assert_eq!(code(&out), 0, "{out:?}");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let payload: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("status stdout is not clean JSON ({e}): {stdout:?}"));
    assert_eq!(payload["fresh"], true);
    assert!(String::from_utf8_lossy(&out.stderr).contains("[timing] source fingerprint:"));
}

/// A build that ingests NOTHING — no File node, no Doc node — must exit
/// non-zero and must NOT write the artifact. An empty `.kgl` that exits 0 is
/// what kept the walk-root bug silent for months: the artifact persisted,
/// `status` reported fresh, and nothing objected. A literal zero-node graph
/// is unreachable through the CLI today (an inferred `Project` node is
/// synthesized even for an empty directory), so the guard keys on ingested
/// content, which also covers zero nodes. This is CLI policy — the library
/// API still returns the empty graph to callers that ask for one.
#[test]
fn empty_build_fails_and_writes_no_artifact() {
    let dir = tempfile::tempdir().unwrap();
    let source = dir.path().join("no-code");
    std::fs::create_dir(&source).unwrap();
    // Nothing here is ingestible: no parser extension, no doc, no manifest.
    std::fs::write(source.join("notes.txt"), "nothing to parse\n").unwrap();
    let graph = dir.path().join("empty.kgl");
    let out = codingest(&[
        "build",
        source.to_str().unwrap(),
        "-o",
        graph.to_str().unwrap(),
    ]);
    assert_ne!(code(&out), 0, "empty build must fail: {out:?}");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("graph is empty"),
        "failure text must say why: {stderr}"
    );
    assert!(
        !graph.exists(),
        "an empty build must not write the artifact"
    );
}

/// `WalkDir::filter_entry` applies its predicate to the walk ROOT, so the
/// builder's ignore-list filter used to prune its own root: a build pointed at
/// a `.`-prefixed directory walked nothing and wrote an empty graph while
/// exiting 0. End-to-end guard through the real binary.
#[test]
fn build_rooted_at_a_dot_named_directory_is_not_empty() {
    let dir = tempfile::tempdir().unwrap();
    let source = dir.path().join(".hidden-root");
    std::fs::create_dir(&source).unwrap();
    // Deliberately NO manifest: with one, the walk is rooted at the declared
    // source root (`src/`) and never touches the dot-named directory, so the
    // bug would not be exercised. The fallback scan walks the project root
    // itself, which is the case that returned an empty graph.
    std::fs::write(source.join("app.py"), "def alpha():\n    return 1\n").unwrap();
    let graph = dir.path().join("hidden.kgl");
    let built = codingest(&[
        "build",
        source.to_str().unwrap(),
        "-o",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&built), 0, "build failed: {built:?}");

    let out = codingest(&[
        "query",
        "MATCH (n) RETURN count(n)",
        "-g",
        graph.to_str().unwrap(),
    ]);
    assert_eq!(code(&out), 0, "{out:?}");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let count: u64 = stdout
        .lines()
        .nth(1)
        .unwrap_or_default()
        .trim()
        .parse()
        .unwrap_or_else(|_| panic!("unexpected count output: {stdout:?}"));
    assert!(count > 0, "graph built at a dot-named root is empty");
}