mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
Files where inline tests crowded out production code (test/prod ratio ≥ 0.8) move to sibling files via `#[path]`. Files where production dominates (query_input.rs, schema_plan.rs) stay inline — extracting would add noise, not reduce it. - ir/lower.rs: 1239 → 577 lines (ratio 1.15) - catalog/mod.rs: 594 → 326 lines (ratio 0.83) - query/lint.rs: 562 → 314 lines (ratio 0.80) catalog/tests.rs uses the shorter name since it's inside a module directory (no ambiguity with filename). All 229 compiler tests green, identical count to before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314 lines
9.6 KiB
Rust
314 lines
9.6 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::catalog::Catalog;
|
|
use crate::query::ast::{Mutation, QueryDecl};
|
|
use crate::query::parser::parse_query;
|
|
use crate::query::typecheck::typecheck_query_decl;
|
|
|
|
const PARSE_ERROR_CODE: &str = "Q000";
|
|
const L201_CODE: &str = "L201";
|
|
const HARDCODED_MUTATION_WARNING: &str =
|
|
"mutation declares no params; hardcoded mutations are easy to miss";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum QueryLintStatus {
|
|
Ok,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum QueryLintSeverity {
|
|
Error,
|
|
Warning,
|
|
Info,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum QueryLintQueryKind {
|
|
Read,
|
|
Mutation,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum QueryLintSchemaSourceKind {
|
|
File,
|
|
Repo,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct QueryLintSchemaSource {
|
|
pub kind: QueryLintSchemaSourceKind,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub uri: Option<String>,
|
|
}
|
|
|
|
impl QueryLintSchemaSource {
|
|
pub fn file(path: impl Into<String>) -> Self {
|
|
Self {
|
|
kind: QueryLintSchemaSourceKind::File,
|
|
path: Some(path.into()),
|
|
uri: None,
|
|
}
|
|
}
|
|
|
|
pub fn repo(uri: impl Into<String>) -> Self {
|
|
Self {
|
|
kind: QueryLintSchemaSourceKind::Repo,
|
|
path: None,
|
|
uri: Some(uri.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct QueryLintQueryResult {
|
|
pub name: String,
|
|
pub kind: QueryLintQueryKind,
|
|
pub status: QueryLintStatus,
|
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
pub warnings: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct QueryLintFinding {
|
|
pub severity: QueryLintSeverity,
|
|
pub code: String,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub type_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub property: Option<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
pub query_names: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct QueryLintOutput {
|
|
pub status: QueryLintStatus,
|
|
pub schema_source: QueryLintSchemaSource,
|
|
pub query_path: String,
|
|
pub queries_processed: usize,
|
|
pub errors: usize,
|
|
pub warnings: usize,
|
|
pub infos: usize,
|
|
pub results: Vec<QueryLintQueryResult>,
|
|
pub findings: Vec<QueryLintFinding>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct UpdateCoverage {
|
|
query_names: BTreeSet<String>,
|
|
assigned_properties: BTreeSet<String>,
|
|
}
|
|
|
|
pub fn lint_query_file(
|
|
catalog: &Catalog,
|
|
query_source: &str,
|
|
query_path: impl Into<String>,
|
|
schema_source: QueryLintSchemaSource,
|
|
) -> QueryLintOutput {
|
|
let query_path = query_path.into();
|
|
match parse_query(query_source) {
|
|
Ok(parsed) => {
|
|
let queries_processed = parsed.queries.len();
|
|
let mut results = Vec::with_capacity(queries_processed);
|
|
let mut coverage = BTreeMap::<String, UpdateCoverage>::new();
|
|
|
|
for query in &parsed.queries {
|
|
let kind = query_kind(query);
|
|
let warnings = per_query_warnings(query);
|
|
match typecheck_query_decl(catalog, query) {
|
|
Ok(_) => {
|
|
collect_update_coverage(query, &mut coverage);
|
|
results.push(QueryLintQueryResult {
|
|
name: query.name.clone(),
|
|
kind,
|
|
status: QueryLintStatus::Ok,
|
|
warnings,
|
|
error: None,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
results.push(QueryLintQueryResult {
|
|
name: query.name.clone(),
|
|
kind,
|
|
status: QueryLintStatus::Error,
|
|
warnings,
|
|
error: Some(err.to_string()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut findings = lint_update_coverage(catalog, &coverage);
|
|
findings.sort_by(findings_cmp);
|
|
|
|
let errors = results
|
|
.iter()
|
|
.filter(|result| result.status == QueryLintStatus::Error)
|
|
.count()
|
|
+ findings
|
|
.iter()
|
|
.filter(|finding| finding.severity == QueryLintSeverity::Error)
|
|
.count();
|
|
let warnings = results
|
|
.iter()
|
|
.map(|result| result.warnings.len())
|
|
.sum::<usize>()
|
|
+ findings
|
|
.iter()
|
|
.filter(|finding| finding.severity == QueryLintSeverity::Warning)
|
|
.count();
|
|
let infos = findings
|
|
.iter()
|
|
.filter(|finding| finding.severity == QueryLintSeverity::Info)
|
|
.count();
|
|
|
|
QueryLintOutput {
|
|
status: if errors == 0 {
|
|
QueryLintStatus::Ok
|
|
} else {
|
|
QueryLintStatus::Error
|
|
},
|
|
schema_source,
|
|
query_path,
|
|
queries_processed,
|
|
errors,
|
|
warnings,
|
|
infos,
|
|
results,
|
|
findings,
|
|
}
|
|
}
|
|
Err(err) => QueryLintOutput {
|
|
status: QueryLintStatus::Error,
|
|
schema_source,
|
|
query_path,
|
|
queries_processed: 0,
|
|
errors: 1,
|
|
warnings: 0,
|
|
infos: 0,
|
|
results: Vec::new(),
|
|
findings: vec![QueryLintFinding {
|
|
severity: QueryLintSeverity::Error,
|
|
code: PARSE_ERROR_CODE.to_string(),
|
|
message: err.to_string(),
|
|
type_name: None,
|
|
property: None,
|
|
query_names: Vec::new(),
|
|
}],
|
|
},
|
|
}
|
|
}
|
|
|
|
fn query_kind(query: &QueryDecl) -> QueryLintQueryKind {
|
|
if query.mutations.is_empty() {
|
|
QueryLintQueryKind::Read
|
|
} else {
|
|
QueryLintQueryKind::Mutation
|
|
}
|
|
}
|
|
|
|
fn per_query_warnings(query: &QueryDecl) -> Vec<String> {
|
|
if query.mutations.is_empty() || !query.params.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
vec![HARDCODED_MUTATION_WARNING.to_string()]
|
|
}
|
|
|
|
fn collect_update_coverage(query: &QueryDecl, coverage: &mut BTreeMap<String, UpdateCoverage>) {
|
|
for mutation in &query.mutations {
|
|
if let Mutation::Update(update) = mutation {
|
|
let entry = coverage.entry(update.type_name.clone()).or_default();
|
|
entry.query_names.insert(query.name.clone());
|
|
for assignment in &update.assignments {
|
|
entry
|
|
.assigned_properties
|
|
.insert(assignment.property.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn lint_update_coverage(
|
|
catalog: &Catalog,
|
|
coverage: &BTreeMap<String, UpdateCoverage>,
|
|
) -> Vec<QueryLintFinding> {
|
|
let mut type_names = catalog.node_types.keys().cloned().collect::<Vec<_>>();
|
|
type_names.sort();
|
|
|
|
let mut findings = Vec::new();
|
|
for type_name in type_names {
|
|
let Some(type_coverage) = coverage.get(&type_name) else {
|
|
continue;
|
|
};
|
|
if type_coverage.query_names.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let node_type = &catalog.node_types[&type_name];
|
|
let key_properties = node_type.key.clone().unwrap_or_default();
|
|
|
|
let mut property_names = node_type.properties.keys().cloned().collect::<Vec<_>>();
|
|
property_names.sort();
|
|
|
|
for property_name in property_names {
|
|
let property = &node_type.properties[&property_name];
|
|
if !property.nullable {
|
|
continue;
|
|
}
|
|
if key_properties.iter().any(|key| key == &property_name) {
|
|
continue;
|
|
}
|
|
if node_type.embed_sources.contains_key(&property_name) {
|
|
continue;
|
|
}
|
|
if type_coverage.assigned_properties.contains(&property_name) {
|
|
continue;
|
|
}
|
|
|
|
findings.push(QueryLintFinding {
|
|
severity: QueryLintSeverity::Warning,
|
|
code: L201_CODE.to_string(),
|
|
message: format!(
|
|
"{}.{} exists in schema but no update query sets it",
|
|
type_name, property_name
|
|
),
|
|
type_name: Some(type_name.clone()),
|
|
property: Some(property_name),
|
|
query_names: type_coverage.query_names.iter().cloned().collect(),
|
|
});
|
|
}
|
|
}
|
|
findings
|
|
}
|
|
|
|
fn findings_cmp(left: &QueryLintFinding, right: &QueryLintFinding) -> std::cmp::Ordering {
|
|
severity_rank(left.severity)
|
|
.cmp(&severity_rank(right.severity))
|
|
.then_with(|| left.type_name.cmp(&right.type_name))
|
|
.then_with(|| left.property.cmp(&right.property))
|
|
.then_with(|| left.message.cmp(&right.message))
|
|
}
|
|
|
|
fn severity_rank(severity: QueryLintSeverity) -> u8 {
|
|
match severity {
|
|
QueryLintSeverity::Error => 0,
|
|
QueryLintSeverity::Warning => 1,
|
|
QueryLintSeverity::Info => 2,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "lint_tests.rs"]
|
|
mod tests;
|