mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +02:00
* test: e2e coverage for @description/@instruction surfaces Add end-to-end tests pinning the two annotation surfaces as they exist today, at their real boundaries: - engine (lifecycle.rs): schema-level @description (node/edge/property) and @instruction (node/edge) persist verbatim into the on-disk _schema.ir.json through Omnigraph::init; property-level @instruction aborts init and writes no schema IR. - server (stored_queries.rs): query-level @description/@instruction on a stored query surface as typed QueryCatalogEntry fields over GET /queries, and a query declaring neither omits both fields. No behavior change — these document the current contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): surface stored-query @description/@instruction in `queries list` A stored query's @description/@instruction are its catalog metadata — what it does and how to invoke it. The HTTP GET /queries catalog already carries them, but `omnigraph queries list` dropped both fields in human and --json output even though they were available on the registry entry. Carry description/instruction on QueriesListItem (Option, skipped when None) and copy them from the query decl. Human output prints an indented `description:` / `instruction:` line per query when present; --json includes the fields when present and omits them otherwise — matching the HTTP catalog shape documented in docs/user/operations/server.md. Tests (cli_queries.rs): a query with both annotations surfaces them in human + --json; a query with neither prints no annotation lines and omits both JSON fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(cli): document `queries list` output incl. description/instruction Per AGENTS.md maintenance Rule 1, document the user-visible `queries list` output alongside the field addition. The `queries` command family had no row in the CLI reference top-level table; add one covering `list` (human + --json shapes, with description/instruction shown only when declared, matching the HTTP GET /queries catalog) and `validate`. Addresses the Greptile P2 review finding on PR #280. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): indent multiline stored-query annotations in `queries list` A `@description`/`@instruction` value can be multiline (GQ string literals admit newlines), which made the human `queries list` output break back to the left margin on continuation lines. Indent continuation lines to align under the first via a `print_query_annotation` helper. Addresses review feedback from @martin-g on PR #280. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ragnor Comerford <ragnor.comerford@gmail.com>
384 lines
13 KiB
Rust
384 lines
13 KiB
Rust
//! Stored-query commands and alias resolution.
|
|
//! Moved verbatim from tests/cli.rs in the modularization.
|
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
mod support;
|
|
|
|
use support::*;
|
|
|
|
|
|
#[test]
|
|
fn query_check_alias_matches_lint_output() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Person {
|
|
name: String
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let lint_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let check_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("check")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
|
|
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
|
|
}
|
|
|
|
// Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were
|
|
// removed in RFC-011 D4 — operator aliases now live under `omnigraph alias
|
|
// <name>` (the happy path is covered by system_local's operator-alias e2e).
|
|
// The legacy file-alias path has no CLI entry point.
|
|
|
|
#[test]
|
|
fn alias_flag_is_removed_from_query() {
|
|
// RFC-011 D4: `--alias` no longer exists on query/mutate; use `alias <name>`.
|
|
let output = output_failure(cli().arg("query").arg("--alias").arg("who"));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("unexpected argument") && stderr.contains("--alias"),
|
|
"expected clap to reject --alias on query; got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alias_unknown_name_errors_listing_defined() {
|
|
// Hermetic: an unknown alias fails before any network, listing defined ones.
|
|
let home = tempdir().unwrap();
|
|
std::fs::write(
|
|
home.path().join("config.yaml"),
|
|
"servers:\n dev:\n url: https://x\naliases:\n who:\n server: dev\n query: find_person\n",
|
|
)
|
|
.unwrap();
|
|
let output = output_failure(
|
|
cli()
|
|
.env("OMNIGRAPH_HOME", home.path())
|
|
.arg("alias")
|
|
.arg("nope"),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("unknown alias 'nope'") && stderr.contains("who"),
|
|
"expected an unknown-alias error listing defined aliases; got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
|
|
for (flag, value) in [
|
|
("--server", "dev"),
|
|
("--graph", "local"),
|
|
("--store", "file:///tmp/graph.omni"),
|
|
("--cluster", "."),
|
|
("--profile", "prod"),
|
|
("--as", "act-op"),
|
|
] {
|
|
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("`alias` uses the server, graph, and stored query")
|
|
&& stderr.contains(flag),
|
|
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
|
|
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
|
"queries should point at --cluster, not --config; got: {stderr}"
|
|
);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("--server")
|
|
.arg("prod")
|
|
.arg("policy")
|
|
.arg("validate"),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
|
"policy should point at --cluster, not --config; got: {stderr}"
|
|
);
|
|
}
|
|
|
|
// RFC-011: `queries validate`/`list` source the registry + schemas from a
|
|
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
|
|
|
|
/// Build a converged single-graph cluster (id `knowledge`) with one stored
|
|
/// query. `query_block` is the YAML under the graph's `queries:` key.
|
|
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
|
|
let temp = tempdir().unwrap();
|
|
let dir = temp.path();
|
|
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
|
write_query_file(&dir.join(query_file), query_src);
|
|
std::fs::write(
|
|
dir.join("cluster.yaml"),
|
|
format!(
|
|
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
|
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
|
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
|
temp
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_exits_zero_on_clean_registry() {
|
|
let cluster = converged_cluster_with_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
" find_person:\n file: ./find_person.gq\n",
|
|
);
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg("--cluster")
|
|
.arg(cluster.path()),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn cluster_import_rejects_a_type_broken_query() {
|
|
// In the cluster model a stored query is type-checked at the cluster
|
|
// boundary (import/apply), so a broken query can never reach the applied
|
|
// state `queries validate` reads — the gate is upstream. `Widget` is not in
|
|
// the fixture schema, so import must reject it, naming the query.
|
|
let temp = tempdir().unwrap();
|
|
let dir = temp.path();
|
|
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
|
write_query_file(
|
|
&dir.join("ghost.gq"),
|
|
"query ghost() { match { $w: Widget } return { $w.name } }",
|
|
);
|
|
std::fs::write(
|
|
dir.join("cluster.yaml"),
|
|
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
|
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
|
|
)
|
|
.unwrap();
|
|
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
|
let combined = format!(
|
|
"{}{}",
|
|
stdout_string(&output),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
combined.contains("ghost"),
|
|
"cluster import must reject the broken query, naming it; got:\n{combined}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_prints_registered_query() {
|
|
let cluster = converged_cluster_with_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
" find_person:\n file: ./find_person.gq\n",
|
|
);
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--cluster")
|
|
.arg(cluster.path()),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
|
|
assert!(
|
|
stdout.contains("$name: String"),
|
|
"list should show typed params; stdout:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_surfaces_description_and_instruction() {
|
|
// `@description`/`@instruction` are the whole point of a stored query in a
|
|
// catalog — they tell an agent/operator what it does and how to invoke it.
|
|
// The CLI catalog must surface them in both human and --json output, to
|
|
// match the HTTP `GET /queries` surface.
|
|
let cluster = converged_cluster_with_query(
|
|
"described.gq",
|
|
"query described($name: String) \
|
|
@description(\"Find a person by exact name.\") \
|
|
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
|
|
{ match { $p: Person { name: $name } } return { $p.age } }",
|
|
" described:\n file: ./described.gq\n",
|
|
);
|
|
|
|
// Human output.
|
|
let output = output_success(
|
|
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(
|
|
stdout.contains("description: Find a person by exact name."),
|
|
"human list must show @description; stdout:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("instruction: Use for exact lookups; prefer search for fuzzy matches."),
|
|
"human list must show @instruction; stdout:\n{stdout}"
|
|
);
|
|
|
|
// --json output.
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--cluster")
|
|
.arg(cluster.path())
|
|
.arg("--json"),
|
|
);
|
|
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
let entry = body["queries"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|q| q["name"] == "described")
|
|
.unwrap();
|
|
assert_eq!(entry["description"], "Find a person by exact name.");
|
|
assert_eq!(
|
|
entry["instruction"],
|
|
"Use for exact lookups; prefer search for fuzzy matches."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_indents_multiline_annotation_continuation() {
|
|
// GQ string literals admit newlines, so a `@description`/`@instruction`
|
|
// can be multiline. Human output must indent continuation lines to align
|
|
// under the first rather than breaking back to the left margin.
|
|
let cluster = converged_cluster_with_query(
|
|
"multi.gq",
|
|
"query multi($name: String) \
|
|
@description(\"line one\\nline two\") \
|
|
{ match { $p: Person { name: $name } } return { $p.age } }",
|
|
" multi:\n file: ./multi.gq\n",
|
|
);
|
|
let output = output_success(
|
|
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
// " description: " is 17 chars wide; the continuation aligns under it.
|
|
assert!(
|
|
stdout.contains(" description: line one\n line two"),
|
|
"multiline annotation must indent the continuation; stdout:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_omits_annotations_when_absent() {
|
|
// The other half of the contract: a query that declares neither annotation
|
|
// prints no extra lines and omits both JSON fields entirely. This keeps the
|
|
// catalog clean rather than echoing empty `description:`/`instruction:`.
|
|
let cluster = converged_cluster_with_query(
|
|
"bare.gq",
|
|
"query bare() { match { $p: Person } return { $p.name } }",
|
|
" bare:\n file: ./bare.gq\n",
|
|
);
|
|
|
|
// Human output: the query is listed, but no annotation lines.
|
|
let output = output_success(
|
|
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("bare()"), "stdout:\n{stdout}");
|
|
assert!(
|
|
!stdout.contains("description:") && !stdout.contains("instruction:"),
|
|
"a query without annotations prints no annotation lines; stdout:\n{stdout}"
|
|
);
|
|
|
|
// --json output: both fields omitted (not present as null).
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--cluster")
|
|
.arg(cluster.path())
|
|
.arg("--json"),
|
|
);
|
|
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
let entry = body["queries"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|q| q["name"] == "bare")
|
|
.unwrap();
|
|
assert!(
|
|
entry.get("description").is_none() && entry.get("instruction").is_none(),
|
|
"a query without annotations omits both JSON fields: {entry}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_requires_a_cluster() {
|
|
// RFC-011: with no --cluster (and no cluster profile), the command errors
|
|
// loudly rather than reading any omnigraph.yaml.
|
|
let output = output_failure(cli().arg("queries").arg("validate"));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
|
|
"queries validate must require a cluster; stderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_graph_filter_selects_one_graph() {
|
|
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
|
|
// that graph's registry, ignoring `engineering`'s.
|
|
let temp = tempdir().unwrap();
|
|
let dir = temp.path();
|
|
write_multi_graph_cluster_fixture(dir);
|
|
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
|
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg("--cluster")
|
|
.arg(dir)
|
|
.arg("--graph")
|
|
.arg("knowledge"),
|
|
);
|
|
assert!(stdout_string(&output).contains("OK"));
|
|
}
|