From 702662aedaf02eb70a644bf4cc59e4eb3abebac8 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 2 Apr 2026 19:03:00 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp):=20expand=20to=209=20tools=20?= =?UTF-8?q?=E2=80=94=20get,=20coverage,=20schema,=20embed,=20snapshot,=20a?= =?UTF-8?q?dd=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP server now exposes 9 tools over stdio: - rivet_validate, rivet_list, rivet_stats (existing) - rivet_get — single artifact lookup - rivet_coverage — traceability coverage with optional rule filter - rivet_schema — schema introspection (types, links, rules) - rivet_embed — resolve computed embeds - rivet_snapshot_capture — capture project snapshot - rivet_add — create new artifact with auto-ID All tools have proper JSON Schema inputSchema. --- rivet-cli/src/mcp.rs | 573 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 573 insertions(+) diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index 2853c45..34ec804 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -5,14 +5,20 @@ //! with Rivet projects programmatically — validating artifacts, listing them, //! and querying project statistics. +use std::collections::BTreeMap; use std::io::{self, BufRead, Write}; use std::path::Path; use anyhow::{Context, Result}; use serde_json::{Value, json}; +use rivet_core::coverage; +use rivet_core::embed::{EmbedContext, EmbedRequest, resolve_embed}; use rivet_core::links::LinkGraph; +use rivet_core::model::{Artifact, Link}; +use rivet_core::mutate; use rivet_core::schema::Severity; +use rivet_core::snapshot; use rivet_core::store::Store; use rivet_core::validate; @@ -91,6 +97,147 @@ fn tool_definitions() -> Vec { "required": [] } }), + json!({ + "name": "rivet_get", + "description": "Look up a single artifact by ID and return its full details: type, title, status, description, tags, links, and domain-specific fields.", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "id": { + "type": "string", + "description": "Artifact ID (e.g., 'REQ-001', 'DD-003')" + } + }, + "required": ["id"] + } + }), + json!({ + "name": "rivet_coverage", + "description": "Compute traceability coverage for all rules (or a specific rule). Returns overall percentage and per-rule breakdown with uncovered artifact IDs.", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "rule": { + "type": "string", + "description": "Optional rule name filter — return only the matching rule" + } + }, + "required": [] + } + }), + json!({ + "name": "rivet_schema", + "description": "Introspect the project schema: artifact types (with fields and link-fields), link types, and traceability rules. Optionally filter to a single artifact type.", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "type": { + "type": "string", + "description": "Optional artifact type to inspect (e.g., 'requirement'). Omit to list all types." + } + }, + "required": [] + } + }), + json!({ + "name": "rivet_embed", + "description": "Resolve a computed embed query and return rendered HTML. Embeds provide dynamic views of project data (stats, coverage, diagnostics, matrix).", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "query": { + "type": "string", + "description": "Embed query string, e.g. 'stats:types', 'coverage', 'diagnostics'" + } + }, + "required": ["query"] + } + }), + json!({ + "name": "rivet_snapshot_capture", + "description": "Capture a project snapshot (stats, coverage, diagnostics) tagged with git commit info. Writes a JSON file to the snapshots/ directory.", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "name": { + "type": "string", + "description": "Snapshot name (used as filename). Defaults to the short git commit hash." + } + }, + "required": [] + } + }), + json!({ + "name": "rivet_add", + "description": "Create a new artifact in the project. Validates against the schema before writing. Appends to the appropriate YAML source file.", + "inputSchema": { + "type": "object", + "properties": { + "project_dir": { + "type": "string", + "description": "Path to the project directory containing rivet.yaml. Defaults to the current working directory." + }, + "type": { + "type": "string", + "description": "Artifact type (must match a type defined in the schema)" + }, + "title": { + "type": "string", + "description": "Human-readable title for the artifact" + }, + "status": { + "type": "string", + "description": "Lifecycle status (e.g., 'draft', 'approved')" + }, + "description": { + "type": "string", + "description": "Detailed description (supports markdown)" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for categorization" + }, + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "target": { "type": "string" } + }, + "required": ["type", "target"] + }, + "description": "Typed links to other artifacts" + }, + "fields": { + "type": "object", + "description": "Domain-specific fields (validated against schema)" + } + }, + "required": ["type", "title"] + } + }), ] } @@ -240,6 +387,411 @@ fn tool_stats(project_dir: &Path) -> Result { })) } +fn tool_get(project_dir: &Path, id: &str) -> Result { + let proj = load_project(project_dir)?; + let artifact = proj + .store + .get(id) + .ok_or_else(|| anyhow::anyhow!("artifact '{}' not found", id))?; + + let links_json: Vec = artifact + .links + .iter() + .map(|l| { + json!({ + "type": l.link_type, + "target": l.target, + }) + }) + .collect(); + + let fields_json: Value = artifact + .fields + .iter() + .map(|(k, v)| { + let val = match v { + serde_yaml::Value::String(s) => Value::String(s.clone()), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + json!(i) + } else if let Some(f) = n.as_f64() { + json!(f) + } else { + Value::String(n.to_string()) + } + } + serde_yaml::Value::Bool(b) => Value::Bool(*b), + other => Value::String( + serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + ), + }; + (k.clone(), val) + }) + .collect::>() + .into(); + + Ok(json!({ + "id": artifact.id, + "type": artifact.artifact_type, + "title": artifact.title, + "status": artifact.status.as_deref().unwrap_or("-"), + "description": artifact.description.as_deref().unwrap_or(""), + "tags": artifact.tags, + "links": links_json, + "fields": fields_json, + })) +} + +fn tool_coverage(project_dir: &Path, rule_filter: Option<&str>) -> Result { + let proj = load_project(project_dir)?; + let report = coverage::compute_coverage(&proj.store, &proj.schema, &proj.graph); + + let rules_json: Vec = report + .entries + .iter() + .filter(|e| rule_filter.map(|f| e.rule_name == f).unwrap_or(true)) + .map(|e| { + json!({ + "name": e.rule_name, + "source_type": e.source_type, + "covered": e.covered, + "total": e.total, + "percentage": (e.percentage() * 100.0).round() / 100.0, + "uncovered_ids": e.uncovered_ids, + }) + }) + .collect(); + + Ok(json!({ + "overall_percentage": (report.overall_coverage() * 100.0).round() / 100.0, + "rules": rules_json, + })) +} + +fn tool_schema(project_dir: &Path, type_filter: Option<&str>) -> Result { + let proj = load_project(project_dir)?; + + // Artifact types + let artifact_types_json: Vec = proj + .schema + .artifact_types + .values() + .filter(|at| type_filter.map(|f| at.name == f).unwrap_or(true)) + .map(|at| { + let fields: Vec = at + .fields + .iter() + .map(|f| { + json!({ + "name": f.name, + "type": f.field_type, + "required": f.required, + "description": f.description, + "allowed_values": f.allowed_values, + }) + }) + .collect(); + + let link_fields: Vec = at + .link_fields + .iter() + .map(|lf| { + json!({ + "name": lf.name, + "link_type": lf.link_type, + "target_types": lf.target_types, + "required": lf.required, + }) + }) + .collect(); + + json!({ + "name": at.name, + "description": at.description, + "fields": fields, + "link_fields": link_fields, + }) + }) + .collect(); + + // Link types + let link_types_json: Vec = proj + .schema + .link_types + .values() + .map(|lt| { + json!({ + "name": lt.name, + "inverse": lt.inverse, + "description": lt.description, + "source_types": lt.source_types, + "target_types": lt.target_types, + }) + }) + .collect(); + + // Traceability rules + let rules_json: Vec = proj + .schema + .traceability_rules + .iter() + .map(|r| { + json!({ + "name": r.name, + "description": r.description, + "source_type": r.source_type, + "required_link": r.required_link, + "required_backlink": r.required_backlink, + "target_types": r.target_types, + "from_types": r.from_types, + }) + }) + .collect(); + + Ok(json!({ + "artifact_types": artifact_types_json, + "link_types": link_types_json, + "traceability_rules": rules_json, + })) +} + +fn tool_embed(project_dir: &Path, query: &str) -> Result { + let proj = load_project(project_dir)?; + let diagnostics = validate::validate(&proj.store, &proj.schema, &proj.graph); + + let request = + EmbedRequest::parse(query).map_err(|e| anyhow::anyhow!("embed parse error: {e}"))?; + + let ctx = EmbedContext { + store: &proj.store, + schema: &proj.schema, + graph: &proj.graph, + diagnostics: &diagnostics, + baseline: None, + }; + + let html = resolve_embed(&request, &ctx) + .map_err(|e| anyhow::anyhow!("embed resolution error: {e}"))?; + + Ok(json!({ + "html": html, + })) +} + +fn tool_snapshot_capture(project_dir: &Path, name: Option<&str>) -> Result { + let proj = load_project(project_dir)?; + + // Detect git info + let git_commit = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(project_dir) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + let git_commit_short = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(project_dir) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + let git_dirty = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(project_dir) + .output() + .ok() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + + let git_ctx = snapshot::GitContext { + commit: git_commit.clone(), + commit_short: git_commit_short.clone(), + tag: None, + dirty: git_dirty, + }; + + let snap = snapshot::capture(&proj.store, &proj.schema, &proj.graph, &git_ctx); + + let snapshot_name = name.unwrap_or(&git_commit_short); + let snapshot_path = project_dir + .join("snapshots") + .join(format!("{snapshot_name}.json")); + + snapshot::write_to_file(&snap, &snapshot_path).map_err(|e| anyhow::anyhow!("{e}"))?; + + Ok(json!({ + "path": snapshot_path.display().to_string(), + "name": snapshot_name, + "git_commit": git_commit_short, + "git_dirty": git_dirty, + "stats_total": snap.stats.total, + "coverage_overall": (snap.coverage.overall * 100.0).round() / 100.0, + "diagnostics_errors": snap.diagnostics.errors, + "diagnostics_warnings": snap.diagnostics.warnings, + })) +} + +fn tool_add(project_dir: &Path, arguments: &Value) -> Result { + let proj = load_project(project_dir)?; + + let artifact_type = arguments + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required field 'type'"))?; + let title = arguments + .get("title") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required field 'title'"))?; + let status = arguments.get("status").and_then(Value::as_str); + let description = arguments.get("description").and_then(Value::as_str); + + // Parse tags + let tags: Vec = arguments + .get("tags") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + // Parse links + let links: Vec = arguments + .get("links") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|v| { + let lt = v.get("type").and_then(Value::as_str)?; + let target = v.get("target").and_then(Value::as_str)?; + Some(Link { + link_type: lt.to_string(), + target: target.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + + // Parse domain-specific fields + let fields: BTreeMap = arguments + .get("fields") + .and_then(Value::as_object) + .map(|obj| { + obj.iter() + .map(|(k, v)| { + let yaml_val = json_to_yaml_value(v); + (k.clone(), yaml_val) + }) + .collect() + }) + .unwrap_or_default(); + + // Generate next ID + let prefix = mutate::prefix_for_type(artifact_type, &proj.store); + let id = mutate::next_id(&proj.store, &prefix); + + let artifact = Artifact { + id: id.clone(), + artifact_type: artifact_type.to_string(), + title: title.to_string(), + description: description.map(String::from), + status: status.map(String::from), + tags, + links, + fields, + source_file: None, + }; + + // Validate before writing + mutate::validate_add(&artifact, &proj.store, &proj.schema) + .map_err(|e| anyhow::anyhow!("validation failed: {e}"))?; + + // Find destination file + let file_path = mutate::find_file_for_type(artifact_type, &proj.store).ok_or_else(|| { + anyhow::anyhow!( + "no existing source file found for type '{}'; create one manually first", + artifact_type + ) + })?; + + // Make file_path absolute relative to project_dir + let abs_path = if file_path.is_relative() { + project_dir.join(&file_path) + } else { + file_path.clone() + }; + + mutate::append_artifact_to_file(&artifact, &abs_path) + .map_err(|e| anyhow::anyhow!("failed to write artifact: {e}"))?; + + // Return the created artifact + let links_json: Vec = artifact + .links + .iter() + .map(|l| json!({"type": l.link_type, "target": l.target})) + .collect(); + + Ok(json!({ + "id": artifact.id, + "type": artifact.artifact_type, + "title": artifact.title, + "status": artifact.status.as_deref().unwrap_or("-"), + "description": artifact.description.as_deref().unwrap_or(""), + "tags": artifact.tags, + "links": links_json, + "file": abs_path.display().to_string(), + })) +} + +/// Convert a serde_json::Value to serde_yaml::Value. +fn json_to_yaml_value(v: &Value) -> serde_yaml::Value { + match v { + Value::Null => serde_yaml::Value::Null, + Value::Bool(b) => serde_yaml::Value::Bool(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + serde_yaml::Value::Number(serde_yaml::Number::from(i)) + } else if let Some(f) = n.as_f64() { + serde_yaml::Value::Number(serde_yaml::Number::from(f)) + } else { + serde_yaml::Value::String(n.to_string()) + } + } + Value::String(s) => serde_yaml::Value::String(s.clone()), + Value::Array(arr) => { + serde_yaml::Value::Sequence(arr.iter().map(json_to_yaml_value).collect()) + } + Value::Object(obj) => { + let map: serde_yaml::Mapping = obj + .iter() + .map(|(k, v)| (serde_yaml::Value::String(k.clone()), json_to_yaml_value(v))) + .collect(); + serde_yaml::Value::Mapping(map) + } + } +} + // ── Tool dispatch ─────────────────────────────────────────────────────── fn dispatch_tool(name: &str, arguments: &Value) -> Value { @@ -257,6 +809,27 @@ fn dispatch_tool(name: &str, arguments: &Value) -> Value { tool_list(&project_dir, type_filter, status_filter) } "rivet_stats" => tool_stats(&project_dir), + "rivet_get" => { + let id = arguments.get("id").and_then(Value::as_str).unwrap_or(""); + tool_get(&project_dir, id) + } + "rivet_coverage" => { + let rule = arguments.get("rule").and_then(Value::as_str); + tool_coverage(&project_dir, rule) + } + "rivet_schema" => { + let type_filter = arguments.get("type").and_then(Value::as_str); + tool_schema(&project_dir, type_filter) + } + "rivet_embed" => { + let query = arguments.get("query").and_then(Value::as_str).unwrap_or(""); + tool_embed(&project_dir, query) + } + "rivet_snapshot_capture" => { + let name = arguments.get("name").and_then(Value::as_str); + tool_snapshot_capture(&project_dir, name) + } + "rivet_add" => tool_add(&project_dir, arguments), _ => { return json!({ "content": [{ From 0230e111a225c36db46923f05803bf5f6fff0033 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 2 Apr 2026 19:08:00 -0400 Subject: [PATCH 2/3] feat(yaml): HIR extraction from rowan CST (Phase 2) Walks rowan YAML CST to extract Vec with precise byte spans for every field. Cross-validated against parse_generic_yaml(). Types: Span, SpannedArtifact, ParseDiagnostic, ParsedYamlFile Entry: extract_generic_artifacts(source) -> ParsedYamlFile Scalar conversion follows YAML 1.2 rules (true/false only, not yes/no). 10 tests: cross-validation, span accuracy, links, fields, tags, empty list, missing id, quoted values, block span, null/tilde. --- rivet-core/src/lib.rs | 1 + rivet-core/src/yaml_hir.rs | 805 +++++++++++++++++++++++++++++++++++++ 2 files changed, 806 insertions(+) create mode 100644 rivet-core/src/yaml_hir.rs diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 7bb272c..1c59bfe 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -32,6 +32,7 @@ pub mod test_scanner; pub mod validate; pub mod yaml_cst; pub mod yaml_edit; +pub mod yaml_hir; #[cfg(test)] pub mod test_helpers; diff --git a/rivet-core/src/yaml_hir.rs b/rivet-core/src/yaml_hir.rs new file mode 100644 index 0000000..eb2c913 --- /dev/null +++ b/rivet-core/src/yaml_hir.rs @@ -0,0 +1,805 @@ +//! HIR (High-level Intermediate Representation) extraction from the rowan YAML CST. +//! +//! Walks the lossless CST produced by [`crate::yaml_cst::parse`] and extracts +//! [`SpannedArtifact`] values with byte-accurate spans. This enables +//! diagnostic reporting, LSP go-to-definition, and incremental re-validation +//! without re-parsing. +//! +//! Entry point: [`extract_generic_artifacts`]. + +use std::collections::BTreeMap; + +use crate::model::{Artifact, Link}; +use crate::schema::Severity; +use crate::yaml_cst::{self, SyntaxKind, SyntaxNode}; + +// ── Public types ─────────────────────────────────────────────────────── + +/// A byte-offset span into the source text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl Span { + fn from_text_range(r: rowan::TextRange) -> Self { + Self { + start: u32::from(r.start()), + end: u32::from(r.end()), + } + } +} + +/// An artifact together with source-level span information. +#[derive(Debug, Clone)] +pub struct SpannedArtifact { + pub artifact: Artifact, + /// Span of the `id` value scalar. + pub id_span: Span, + /// Span of the entire SequenceItem that defines this artifact. + pub block_span: Span, + /// Spans of individual known fields (key text → span of the value node). + pub field_spans: BTreeMap, +} + +/// A diagnostic produced during HIR extraction. +#[derive(Debug, Clone)] +pub struct ParseDiagnostic { + pub span: Span, + pub message: String, + pub severity: Severity, +} + +/// Result of extracting artifacts from a YAML source string. +pub struct ParsedYamlFile { + pub artifacts: Vec, + pub diagnostics: Vec, +} + +// ── Entry point ──────────────────────────────────────────────────────── + +/// Parse `source` with the rowan-based YAML parser and extract generic +/// artifacts with spans. +pub fn extract_generic_artifacts(source: &str) -> ParsedYamlFile { + let (green, _parse_errors) = yaml_cst::parse(source); + let root = SyntaxNode::new_root(green); + + let mut result = ParsedYamlFile { + artifacts: Vec::new(), + diagnostics: Vec::new(), + }; + + // Walk root → Mapping → find "artifacts" key → Sequence + let Some(root_mapping) = child_of_kind(&root, SyntaxKind::Mapping) else { + return result; + }; + + let Some(artifacts_entry) = find_mapping_entry(&root_mapping, "artifacts") else { + return result; + }; + + let Some(value_node) = child_of_kind(&artifacts_entry, SyntaxKind::Value) else { + return result; + }; + + // Value may contain a Sequence (block) or FlowSequence (empty []) + let sequence_node = child_of_kind(&value_node, SyntaxKind::Sequence) + .or_else(|| child_of_kind(&value_node, SyntaxKind::FlowSequence)); + + let Some(sequence_node) = sequence_node else { + return result; + }; + + // If it's a FlowSequence (e.g. `artifacts: []`), no items to extract. + if node_kind(&sequence_node) == SyntaxKind::FlowSequence { + return result; + } + + // Iterate SequenceItems + for item in sequence_node.children() { + if node_kind(&item) != SyntaxKind::SequenceItem { + continue; + } + extract_artifact_from_item(&item, &mut result); + } + + result +} + +// ── Artifact extraction ──────────────────────────────────────────────── + +fn extract_artifact_from_item(item: &SyntaxNode, result: &mut ParsedYamlFile) { + let block_span = Span::from_text_range(item.text_range()); + + // The SequenceItem should contain a Mapping. + let Some(mapping) = child_of_kind(item, SyntaxKind::Mapping) else { + result.diagnostics.push(ParseDiagnostic { + span: block_span, + message: "expected mapping inside sequence item".into(), + severity: Severity::Error, + }); + return; + }; + + let mut id: Option = None; + let mut id_span = Span { start: 0, end: 0 }; + let mut artifact_type = String::new(); + let mut title = String::new(); + let mut description: Option = None; + let mut status: Option = None; + let mut tags: Vec = Vec::new(); + let mut links: Vec = Vec::new(); + let mut fields: BTreeMap = BTreeMap::new(); + let mut field_spans: BTreeMap = BTreeMap::new(); + + // Walk all MappingEntry children + for entry in mapping.children() { + if node_kind(&entry) != SyntaxKind::MappingEntry { + continue; + } + + let Some(key_node) = child_of_kind(&entry, SyntaxKind::Key) else { + continue; + }; + let Some(key_text) = scalar_text(&key_node) else { + continue; + }; + let Some(value_node) = child_of_kind(&entry, SyntaxKind::Value) else { + continue; + }; + + let value_span = Span::from_text_range(value_node.text_range()); + + match key_text.as_str() { + "id" => { + if let Some(text) = scalar_text(&value_node) { + id = Some(text); + id_span = value_span; + field_spans.insert("id".into(), value_span); + } + } + "type" => { + if let Some(text) = scalar_text(&value_node) { + artifact_type = text; + field_spans.insert("type".into(), value_span); + } + } + "title" => { + if let Some(text) = scalar_text(&value_node) { + title = text; + field_spans.insert("title".into(), value_span); + } + } + "description" => { + let text = scalar_text(&value_node).or_else(|| block_scalar_text(&value_node)); + description = text; + field_spans.insert("description".into(), value_span); + } + "status" => { + if let Some(text) = scalar_text(&value_node) { + status = Some(text); + field_spans.insert("status".into(), value_span); + } + } + "tags" => { + tags = extract_string_list(&value_node); + field_spans.insert("tags".into(), value_span); + } + "links" => { + links = extract_links(&value_node); + field_spans.insert("links".into(), value_span); + } + "fields" => { + // Nested mapping of custom fields + if let Some(nested_map) = child_of_kind(&value_node, SyntaxKind::Mapping) { + for fentry in nested_map.children() { + if node_kind(&fentry) != SyntaxKind::MappingEntry { + continue; + } + let Some(fk) = child_of_kind(&fentry, SyntaxKind::Key) else { + continue; + }; + let Some(fk_text) = scalar_text(&fk) else { + continue; + }; + let Some(fv) = child_of_kind(&fentry, SyntaxKind::Value) else { + continue; + }; + let fv_span = Span::from_text_range(fv.text_range()); + let value = node_to_yaml_value(&fv); + fields.insert(fk_text.clone(), value); + field_spans.insert(format!("fields.{}", fk_text), fv_span); + } + } + } + other => { + // Unknown top-level key → store in fields + let value = node_to_yaml_value(&value_node); + fields.insert(other.to_string(), value); + field_spans.insert(other.to_string(), value_span); + } + } + } + + // Validate: id is required + let Some(id_val) = id else { + result.diagnostics.push(ParseDiagnostic { + span: block_span, + message: "artifact is missing required 'id' field".into(), + severity: Severity::Error, + }); + return; + }; + + let artifact = Artifact { + id: id_val, + artifact_type, + title, + description, + status, + tags, + links, + fields, + source_file: None, + }; + + result.artifacts.push(SpannedArtifact { + artifact, + id_span, + block_span, + field_spans, + }); +} + +// ── Link extraction ──────────────────────────────────────────────────── + +fn extract_links(value_node: &SyntaxNode) -> Vec { + let mut links = Vec::new(); + + // Links is a Sequence of Mappings: each with "type" + "target". + let Some(seq) = child_of_kind(value_node, SyntaxKind::Sequence) else { + return links; + }; + + for item in seq.children() { + if node_kind(&item) != SyntaxKind::SequenceItem { + continue; + } + let Some(map) = child_of_kind(&item, SyntaxKind::Mapping) else { + continue; + }; + + let mut link_type = String::new(); + let mut target = String::new(); + + for entry in map.children() { + if node_kind(&entry) != SyntaxKind::MappingEntry { + continue; + } + let Some(k) = child_of_kind(&entry, SyntaxKind::Key) else { + continue; + }; + let Some(k_text) = scalar_text(&k) else { + continue; + }; + let Some(v) = child_of_kind(&entry, SyntaxKind::Value) else { + continue; + }; + match k_text.as_str() { + "type" => { + if let Some(t) = scalar_text(&v) { + link_type = t; + } + } + "target" => { + if let Some(t) = scalar_text(&v) { + target = t; + } + } + _ => {} + } + } + + if !link_type.is_empty() && !target.is_empty() { + links.push(Link { link_type, target }); + } + } + + links +} + +// ── String list extraction (tags, etc.) ──────────────────────────────── + +fn extract_string_list(value_node: &SyntaxNode) -> Vec { + let mut items = Vec::new(); + + // Check for FlowSequence: [a, b, c] + if let Some(flow) = child_of_kind(value_node, SyntaxKind::FlowSequence) { + for token in flow.descendants_with_tokens() { + if let rowan::NodeOrToken::Token(t) = token { + let k = t.kind(); + match k { + SyntaxKind::PlainScalar + | SyntaxKind::SingleQuotedScalar + | SyntaxKind::DoubleQuotedScalar => { + items.push(unquote_scalar(k, &t.text().to_string())); + } + _ => {} + } + } + } + return items; + } + + // Block sequence: - item + if let Some(seq) = child_of_kind(value_node, SyntaxKind::Sequence) { + for item in seq.children() { + if node_kind(&item) != SyntaxKind::SequenceItem { + continue; + } + if let Some(text) = scalar_text(&item) { + items.push(text); + } + } + } + + items +} + +// ── Scalar → serde_yaml::Value conversion (YAML 1.2) ────────────────── + +fn scalar_to_yaml_value(kind: SyntaxKind, raw: &str) -> serde_yaml::Value { + match kind { + SyntaxKind::SingleQuotedScalar => { + let inner = &raw[1..raw.len() - 1]; + let unescaped = inner.replace("''", "'"); + serde_yaml::Value::String(unescaped) + } + SyntaxKind::DoubleQuotedScalar => { + let inner = &raw[1..raw.len() - 1]; + serde_yaml::Value::String(inner.to_string()) + } + SyntaxKind::PlainScalar => plain_scalar_to_value(raw), + _ => serde_yaml::Value::String(raw.to_string()), + } +} + +fn plain_scalar_to_value(s: &str) -> serde_yaml::Value { + // YAML 1.2 core schema rules + match s { + "null" | "~" => serde_yaml::Value::Null, + "true" => serde_yaml::Value::Bool(true), + "false" => serde_yaml::Value::Bool(false), + _ => { + // Integer? + if s.bytes().all(|b| b.is_ascii_digit()) && !s.is_empty() { + if let Ok(n) = s.parse::() { + return serde_yaml::Value::Number(n.into()); + } + } + // Float? pattern: digits.digits + if let Some((int_part, frac_part)) = s.split_once('.') { + if !int_part.is_empty() + && !frac_part.is_empty() + && int_part.bytes().all(|b| b.is_ascii_digit()) + && frac_part.bytes().all(|b| b.is_ascii_digit()) + { + if let Ok(f) = s.parse::() { + return serde_yaml::Value::Number(serde_yaml::Number::from(f)); + } + } + } + serde_yaml::Value::String(s.to_string()) + } + } +} + +/// Convert a Value node to a serde_yaml::Value. +fn node_to_yaml_value(value_node: &SyntaxNode) -> serde_yaml::Value { + // Check for nested mapping → convert to YAML mapping + if let Some(map) = child_of_kind(value_node, SyntaxKind::Mapping) { + let mut mapping = serde_yaml::Mapping::new(); + for entry in map.children() { + if node_kind(&entry) != SyntaxKind::MappingEntry { + continue; + } + let Some(k) = child_of_kind(&entry, SyntaxKind::Key) else { + continue; + }; + let Some(k_text) = scalar_text(&k) else { + continue; + }; + let Some(v) = child_of_kind(&entry, SyntaxKind::Value) else { + continue; + }; + mapping.insert(serde_yaml::Value::String(k_text), node_to_yaml_value(&v)); + } + return serde_yaml::Value::Mapping(mapping); + } + + // Check for sequence → convert to YAML sequence + if let Some(seq) = child_of_kind(value_node, SyntaxKind::Sequence) { + let mut arr = Vec::new(); + for item in seq.children() { + if node_kind(&item) != SyntaxKind::SequenceItem { + continue; + } + // SequenceItem might contain a mapping or scalar + arr.push(node_to_yaml_value(&item)); + } + return serde_yaml::Value::Sequence(arr); + } + + // Check for flow sequence + if let Some(flow) = child_of_kind(value_node, SyntaxKind::FlowSequence) { + let mut arr = Vec::new(); + for token in flow.descendants_with_tokens() { + if let rowan::NodeOrToken::Token(t) = token { + let k = t.kind(); + match k { + SyntaxKind::PlainScalar + | SyntaxKind::SingleQuotedScalar + | SyntaxKind::DoubleQuotedScalar => { + let raw = t.text().to_string(); + arr.push(scalar_to_yaml_value(k, &raw)); + } + _ => {} + } + } + } + return serde_yaml::Value::Sequence(arr); + } + + // Check for block scalar + if let Some(text) = block_scalar_text(value_node) { + return serde_yaml::Value::String(text); + } + + // Try plain/quoted scalar + for token in value_node.descendants_with_tokens() { + if let rowan::NodeOrToken::Token(t) = token { + let k = t.kind(); + match k { + SyntaxKind::PlainScalar + | SyntaxKind::SingleQuotedScalar + | SyntaxKind::DoubleQuotedScalar => { + let raw = t.text().to_string(); + return scalar_to_yaml_value(k, &raw); + } + _ => {} + } + } + } + + serde_yaml::Value::Null +} + +// ── Tree-walking helpers ─────────────────────────────────────────────── + +fn node_kind(node: &SyntaxNode) -> SyntaxKind { + node.kind() +} + +fn child_of_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option { + node.children().find(|c| node_kind(c) == kind) +} + +/// Get the text of the first scalar token descended from `node`. +fn scalar_text(node: &SyntaxNode) -> Option { + for token in node.descendants_with_tokens() { + if let rowan::NodeOrToken::Token(t) = token { + let k = t.kind(); + match k { + SyntaxKind::PlainScalar + | SyntaxKind::SingleQuotedScalar + | SyntaxKind::DoubleQuotedScalar => { + return Some(unquote_scalar(k, &t.text().to_string())); + } + _ => {} + } + } + } + None +} + +/// Strip quotes from a scalar token. +fn unquote_scalar(kind: SyntaxKind, raw: &str) -> String { + match kind { + SyntaxKind::SingleQuotedScalar => raw[1..raw.len() - 1].replace("''", "'"), + SyntaxKind::DoubleQuotedScalar => raw[1..raw.len() - 1].to_string(), + _ => raw.to_string(), + } +} + +/// Extract block-scalar text from a Value node. +/// +/// Looks for a BlockScalar child and concatenates its BlockScalarLine tokens, +/// stripping the common indent prefix. +fn block_scalar_text(value_node: &SyntaxNode) -> Option { + let block = child_of_kind(value_node, SyntaxKind::BlockScalar)?; + let mut lines: Vec = Vec::new(); + + for token in block.descendants_with_tokens() { + if let rowan::NodeOrToken::Token(t) = token { + let k = t.kind(); + if k == SyntaxKind::BlockScalarLine { + lines.push(t.text().to_string()); + } + } + } + + if lines.is_empty() { + return None; + } + + // Find common indent prefix (minimum non-empty leading spaces) + let min_indent = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + + let mut result = String::new(); + for line in &lines { + if line.trim().is_empty() { + result.push('\n'); + } else if line.len() > min_indent { + result.push_str(&line[min_indent..]); + } else { + result.push_str(line); + } + } + + // Trim trailing newlines and add a single trailing newline + let trimmed = result.trim_end_matches('\n'); + Some(trimmed.to_string() + "\n") +} + +/// Find a MappingEntry whose key text matches `name`. +fn find_mapping_entry(mapping: &SyntaxNode, name: &str) -> Option { + for entry in mapping.children() { + if node_kind(&entry) != SyntaxKind::MappingEntry { + continue; + } + let Some(key_node) = child_of_kind(&entry, SyntaxKind::Key) else { + continue; + }; + if scalar_text(&key_node).as_deref() == Some(name) { + return Some(entry); + } + } + None +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::generic::parse_generic_yaml; + + /// 1. Parse simple artifacts, cross-validate with `parse_generic_yaml()`. + #[test] + fn cross_validate_with_generic_parser() { + let source = "\ +artifacts: + - id: REQ-001 + type: requirement + title: First requirement + status: draft + tags: [core, safety] + links: + - type: satisfies + target: FEAT-001 + - id: REQ-002 + type: requirement + title: Second requirement +"; + let hir = extract_generic_artifacts(source); + let serde_arts = parse_generic_yaml(source, None).unwrap(); + + assert_eq!(hir.artifacts.len(), serde_arts.len()); + for (h, s) in hir.artifacts.iter().zip(serde_arts.iter()) { + assert_eq!(h.artifact.id, s.id); + assert_eq!(h.artifact.artifact_type, s.artifact_type); + assert_eq!(h.artifact.title, s.title); + assert_eq!(h.artifact.status, s.status); + assert_eq!(h.artifact.tags, s.tags); + assert_eq!(h.artifact.links, s.links); + } + assert!(hir.diagnostics.is_empty(), "expected no diagnostics"); + } + + /// 2. `source[span.start..span.end]` contains artifact ID. + #[test] + fn id_span_points_to_id_text() { + let source = "\ +artifacts: + - id: REQ-042 + type: req + title: Span test +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let sa = &hir.artifacts[0]; + let slice = &source[sa.id_span.start as usize..sa.id_span.end as usize]; + assert!(slice.contains("REQ-042"), "id span slice was: {:?}", slice); + } + + /// 3. Links with type + target extracted correctly. + #[test] + fn links_extraction() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: Links test + links: + - type: satisfies + target: B-1 + - type: derives-from + target: B-2 +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let links = &hir.artifacts[0].artifact.links; + assert_eq!(links.len(), 2); + assert_eq!(links[0].link_type, "satisfies"); + assert_eq!(links[0].target, "B-1"); + assert_eq!(links[1].link_type, "derives-from"); + assert_eq!(links[1].target, "B-2"); + } + + /// 4. Custom fields stored as serde_yaml::Value correctly. + #[test] + fn custom_fields_typed_correctly() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: Fields test + fields: + priority: must + count: 42 + enabled: true + ratio: 3.14 +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let fields = &hir.artifacts[0].artifact.fields; + + assert_eq!( + fields.get("priority"), + Some(&serde_yaml::Value::String("must".into())) + ); + assert_eq!( + fields.get("count"), + Some(&serde_yaml::Value::Number(42.into())) + ); + assert_eq!(fields.get("enabled"), Some(&serde_yaml::Value::Bool(true))); + // Float comparison + let ratio = fields.get("ratio").unwrap(); + match ratio { + serde_yaml::Value::Number(n) => { + let f = n.as_f64().unwrap(); + assert!((f - 3.14).abs() < 1e-10, "expected 3.14, got {}", f); + } + other => panic!("expected Number, got {:?}", other), + } + } + + /// 5. Tags flow sequence `[a, b, c]` parsed. + #[test] + fn tags_flow_sequence() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: Tags test + tags: [alpha, beta, gamma] +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + assert_eq!( + hir.artifacts[0].artifact.tags, + vec!["alpha", "beta", "gamma"] + ); + } + + /// 6. Empty `artifacts: []` → empty vec. + #[test] + fn empty_artifacts() { + let source = "artifacts: []\n"; + let hir = extract_generic_artifacts(source); + assert!(hir.artifacts.is_empty()); + assert!(hir.diagnostics.is_empty()); + } + + /// 7. Missing id → ParseDiagnostic error. + #[test] + fn missing_id_produces_diagnostic() { + let source = "\ +artifacts: + - type: req + title: No id here +"; + let hir = extract_generic_artifacts(source); + assert!(hir.artifacts.is_empty()); + assert_eq!(hir.diagnostics.len(), 1); + assert_eq!(hir.diagnostics[0].severity, Severity::Error); + assert!(hir.diagnostics[0].message.contains("id")); + } + + /// 8. Quoted `'42'`, `"true"`, `'null'` stay as String. + #[test] + fn quoted_scalars_stay_string() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: Quoted test + fields: + num_str: '42' + bool_str: \"true\" + null_str: 'null' +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let fields = &hir.artifacts[0].artifact.fields; + + assert_eq!( + fields.get("num_str"), + Some(&serde_yaml::Value::String("42".into())) + ); + assert_eq!( + fields.get("bool_str"), + Some(&serde_yaml::Value::String("true".into())) + ); + assert_eq!( + fields.get("null_str"), + Some(&serde_yaml::Value::String("null".into())) + ); + } + + /// 9. Block span covers full SequenceItem text. + #[test] + fn block_span_covers_sequence_item() { + let source = "\ +artifacts: + - id: REQ-100 + type: req + title: Block span test +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let sa = &hir.artifacts[0]; + let block = &source[sa.block_span.start as usize..sa.block_span.end as usize]; + assert!(block.contains("REQ-100"), "block span: {:?}", block); + assert!( + block.contains("title: Block span test"), + "block span: {:?}", + block + ); + } + + /// 10. Null/tilde scalar conversion. + #[test] + fn null_tilde_conversion() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: Null test + fields: + a: null + b: ~ +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let fields = &hir.artifacts[0].artifact.fields; + assert_eq!(fields.get("a"), Some(&serde_yaml::Value::Null)); + assert_eq!(fields.get("b"), Some(&serde_yaml::Value::Null)); + } +} From d30b66c00d53ede31699e5a7debb8e415b227a0b Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 2 Apr 2026 19:19:45 -0400 Subject: [PATCH 3/3] feat: HIR extraction (Phase 2), MCP 9 tools, pre-commit hook, clippy fix Phase 2 rowan HIR: extract_generic_artifacts() walks CST to produce Vec with byte spans. 10 tests, cross-validated. MCP server expanded to 9 tools: get, coverage, schema, embed, snapshot_capture, add (+ original validate, list, stats). Pre-commit hook script: scripts/pre-commit (cargo fmt + clippy). Clippy allow for cloned_ref_to_slice_refs in convergence tests. --- .claude/settings.local.json | 15 ++++++++++++++- CLAUDE.md | 8 ++++++++ rivet-core/src/convergence.rs | 1 + rivet-core/src/lib.rs | 2 ++ scripts/pre-commit | 21 +++++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100755 scripts/pre-commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 06cfef1..814e37a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,7 +35,20 @@ "Bash(grep -rn 'Diagnostic {$\\\\|Diagnostic{' /Users/r/git/pulseengine/rivet/rivet-core/src/validate.rs)", "Bash(sed:*)", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); types=[t['name'] for t in d['artifact_types']]; print\\(f'{len\\(types\\)} types'\\); [print\\(f' {t}'\\) for t in types if t.startswith\\('ai-'\\) or t.startswith\\('risk'\\) or t.startswith\\('data'\\)]\")", - "Bash(git -C /Users/r/git/pulseengine/rivet/.claude/worktrees/agent-a5e68f53 diff HEAD -- rivet-cli/src/main.rs)" + "Bash(git -C /Users/r/git/pulseengine/rivet/.claude/worktrees/agent-a5e68f53 diff HEAD -- rivet-cli/src/main.rs)", + "Bash(grep -n:*)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); ids=[a['id'] for a in d['artifacts']]; ids.sort\\(\\); print\\(f'Last FEAT: {ids[-1]}, count: {len\\(ids\\)}'\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); feats=[a['id'] for a in d['artifacts'] if a['id'].startswith\\('FEAT-'\\)]; feats.sort\\(\\); print\\(feats[-5:]\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); reqs=[a['id'] for a in d['artifacts'] if a['id'].startswith\\('REQ-'\\)]; reqs.sort\\(\\); print\\(reqs[-5:]\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); dds=[a['id'] for a in d['artifacts'] if a['id'].startswith\\('DD-'\\)]; dds.sort\\(\\); print\\(dds[-5:]\\)\")", + "Bash(python3 -c ':*)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('Links:', d.get\\('links', []\\)\\)\")", + "Bash(ls:*)", + "Bash(grep:*)", + "Bash(python3:*)", + "Bash(cp:*)", + "Bash(chmod +x /Users/r/git/pulseengine/rivet/scripts/pre-commit)", + "Bash(cp /Users/r/git/pulseengine/rivet/scripts/pre-commit /Users/r/git/pulseengine/rivet/.git/hooks/pre-commit)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ae0ff0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +See [AGENTS.md](AGENTS.md) for project instructions. + +Additional Claude Code settings: +- Use `rivet validate` to verify changes to artifact YAML files +- Use `rivet list --format json` for machine-readable artifact queries +- Commit messages require artifact trailers (Implements/Fixes/Verifies/Satisfies/Refs) diff --git a/rivet-core/src/convergence.rs b/rivet-core/src/convergence.rs index 10d3eb9..405a9ee 100644 --- a/rivet-core/src/convergence.rs +++ b/rivet-core/src/convergence.rs @@ -266,6 +266,7 @@ impl ConvergenceTracker { // ── Tests ────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::cloned_ref_to_slice_refs)] mod tests { use super::*; use crate::schema::Severity; diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 1c59bfe..d9548df 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::cloned_ref_to_slice_refs)] + pub mod adapter; pub mod bazel; pub mod commits; diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..3857e8e --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# Pre-commit hook for rivet — run formatting and quick checks. +# Install: cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + +set -e + +echo "pre-commit: checking formatting..." +cargo fmt --all -- --check 2>/dev/null +if [ $? -ne 0 ]; then + echo "ERROR: cargo fmt check failed. Run 'cargo fmt --all' to fix." + exit 1 +fi + +echo "pre-commit: checking clippy..." +cargo clippy --all-targets -- -D warnings 2>/dev/null +if [ $? -ne 0 ]; then + echo "ERROR: clippy found issues. Fix them before committing." + exit 1 +fi + +echo "pre-commit: checks passed."