Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .reliary/index.sqlite
Binary file not shown.
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 4 additions & 13 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
[workspace]

resolver = "2"

members = [
"crates/reliary-core",
"crates/reliary-search",
Expand All @@ -12,17 +9,11 @@ members = [
"crates/reliary-fix",
"crates/reliary-dead",
"crates/reliary-agent",
"crates/reliary-output",
]

resolver = "2"

[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.75"
license = "MIT"
repository = "https://github.com/Reliary/reliary-agent"

[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
1 change: 1 addition & 0 deletions crates/reliary-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ reliary-risk = { path = "../reliary-risk" }
reliary-memory = { path = "../reliary-memory" }
reliary-fix = { path = "../reliary-fix" }
reliary-dead = { path = "../reliary-dead" }
reliary-output = { path = "../reliary-output" }
rusqlite = { version = "0.31", features = ["bundled"] }
mimalloc = "0.1"
regex = "1"
Expand Down
318 changes: 318 additions & 0 deletions crates/reliary-agent/src/guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/// Grammar-free structural edit guards. Ported from stria guard.rs.
/// Uses FTS5 phrase_occ table to detect missing imports and orphaned references.

use rusqlite::Connection;
use serde_json::{json, Value};
use std::collections::HashSet;

const COMMON_KEYWORDS: &[&str] = &[
"def", "class", "import", "return", "self", "None", "True", "False",
"if", "for", "while", "try", "except", "finally", "with", "as", "from",
"not", "and", "or", "in", "is", "pass", "break", "continue", "elif", "else", "raise", "yield", "lambda",
"fn", "pub", "let", "mut", "use", "mod", "struct", "enum", "impl", "trait", "where",
"match", "ref", "move", "async", "await", "unsafe", "type", "const", "static",
"macro", "crate", "super", "Self", "var", "func",
"int", "str", "bool", "nil", "uint", "float64", "string", "int32", "int64",
"error", "err", "null", "undefined", "typeof", "instanceof", "new", "this",
"void", "any", "unknown", "never",
"assert", "assertEq", "get", "set", "has", "add", "del", "len", "cap",
"max", "min", "all", "any", "map", "sum", "abs", "hex", "ord", "pow", "range", "sorted",
"input", "open", "list", "dict", "tuple", "print", "printf", "println",
"require", "module", "exports", "function", "console", "log",
];

fn is_interesting_ident(s: &str) -> bool {
if s.len() < 3 || s.len() > 40 { return false; }
let first = s.chars().next().unwrap_or(' ');
if !first.is_alphabetic() { return false; }
if COMMON_KEYWORDS.contains(&s) { return false; }
if s.chars().all(|c| c.is_lowercase() || c == '_') {
return s.contains('_');
}
true
}

/// Check a proposed edit for structural issues:
/// - Missing imports (new uppercase identifier not defined in project)
/// - Orphaned references (removed identifier still referenced elsewhere)
pub fn check_diff(index_path: &str, file_path: &str, new_content: &str) -> Value {
let db = match Connection::open(index_path) {
Ok(d) => d,
Err(e) => return json!({"error": format!("cannot open db: {}", e)}),
};
if reliary_search::schema::open_existing_db(&db).is_err() {
return json!({"error": "invalid index database"});
}

let new_phrases = reliary_search::scan_identifiers(new_content);
let mut new_uppercase: HashSet<String> = HashSet::new();
let mut new_lowercase: HashSet<String> = HashSet::new();
for p in new_phrases {
if !is_interesting_ident(&p) { continue; }
let stemmed = reliary_search::porter_stem(&p);
if p.chars().next().unwrap_or(' ').is_uppercase() {
new_uppercase.insert(stemmed);
} else {
new_lowercase.insert(stemmed);
}
}

// Get old identifiers for this file — use all length-filtered identifiers
let mut old_uppercase: HashSet<String> = HashSet::new();
let mut old_lowercase: HashSet<String> = HashSet::new();
if let Ok(mut stmt) = db.prepare(
"SELECT p.phrase, po.flags
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE fm.file_path = ?1",
) {
if let Ok(rows) = stmt.query_map([file_path], |r| {
let phrase: String = r.get(0)?;
let flags: Vec<u8> = r.get::<_, Vec<u8>>(1).unwrap_or_default();
Ok((phrase, flags))
}) {
for row in rows.flatten() {
let (phrase, _flags) = row;
if phrase.len() < 3 || phrase.len() > 40 { continue; }
if phrase.chars().next().unwrap_or(' ').is_alphabetic() {
if phrase.chars().next().unwrap_or(' ').is_uppercase() {
old_uppercase.insert(phrase);
} else {
old_lowercase.insert(phrase);
}
}
}
}
}

let mut warnings: Vec<String> = Vec::new();

// Helper: count document frequency (how many files contain this identifier)
let doc_frequency = |db: &Connection, ident: &str| -> i64 {
if let Ok(mut stmt) = db.prepare(
"SELECT COUNT(*) FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
WHERE p.phrase = ?1",
) {
if let Ok(count) = stmt.query_row([ident], |r| r.get::<_, i64>(0)) {
return count;
}
}
0
};

// Helper: find files that define an identifier
let find_defined = |db: &Connection, ident: &str| -> Vec<String> {
if let Ok(mut stmt) = db.prepare(
"SELECT fm.file_path
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE p.phrase = ?1",
) {
if let Ok(rows) = stmt.query_map([ident], |r| {
let fp: String = r.get(0)?;
let flags: Vec<u8> = r.get::<_, Vec<u8>>(1).unwrap_or_default();
let f = if !flags.is_empty() { flags[0] } else { 0 };
Ok((fp, reliary_search::schema::unpack_is_def(f)))
}) {
return rows.flatten()
.filter(|(fp, def)| fp != file_path && *def >= 1)
.map(|(fp, _)| fp)
.collect();
}
}
Vec::new()
};

// Helper: find files that reference an identifier
let find_referenced = |db: &Connection, ident: &str| -> Vec<String> {
if let Ok(mut stmt) = db.prepare(
"SELECT DISTINCT fm.file_path
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE p.phrase = ?1 AND fm.file_path != ?2",
) {
if let Ok(rows) = stmt.query_map([ident, file_path], |r| r.get::<_, String>(0)) {
return rows.flatten().collect();
}
}
Vec::new()
};

// Tier 1: Missing import detection (uppercase identifiers new to this file)
for ident in new_uppercase.difference(&old_uppercase) {
let defined_in = find_defined(&db, ident);
if !defined_in.is_empty() {
warnings.push(format!(
"MISSING IMPORT: You introduced '{}', defined in: {}. Ensure you imported it.",
ident, defined_in.join(", ")
));
}
}

// Tier 2: Orphaned reference detection (skip if idents appear in >=10 files — likely lib/std)
for ident in old_lowercase.difference(&new_lowercase) {
if doc_frequency(&db, ident) >= 10 { continue; }
let referenced_in = find_referenced(&db, ident);
if !referenced_in.is_empty() {
warnings.push(format!(
"ORPHANED REFERENCE: You removed '{}', but it is referenced in {} files (e.g., {}).",
ident, referenced_in.len(), referenced_in.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
));
}
}
for ident in old_uppercase.difference(&new_uppercase) {
if doc_frequency(&db, ident) >= 10 { continue; }
let referenced_in = find_referenced(&db, ident);
if !referenced_in.is_empty() {
warnings.push(format!(
"ORPHANED REFERENCE: You removed '{}', but it is referenced in {} files (e.g., {}).",
ident, referenced_in.len(), referenced_in.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
));
}
}

if warnings.is_empty() {
json!({"status": "clean"})
} else {
json!({"status": "warnings", "warnings": warnings})
}
}

/// Before reading a file, check for identifiers defined in this file that
/// are referenced elsewhere (warns about deletion/rename risk).
pub fn read_validated(index_path: &str, file_path: &str, content: &str) -> Value {
let db = match Connection::open(index_path) {
Ok(d) => d,
Err(_) => return json!({"file": file_path, "content": content}),
};
if reliary_search::schema::open_existing_db(&db).is_err() {
return json!({"file": file_path, "content": content});
}

let mut def_refs: Vec<(String, i64, Vec<String>)> = Vec::new();
if let Ok(mut stmt) = db.prepare(
"SELECT p.phrase, po.flags
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE fm.file_path = ?1",
) {
if let Ok(rows) = stmt.query_map([file_path], |r| {
let phrase: String = r.get(0)?;
let flags: Vec<u8> = r.get::<_, Vec<u8>>(1).unwrap_or_default();
Ok((phrase, flags))
}) {
for row in rows.flatten() {
let (phrase, flags) = row;
if !is_interesting_ident(&phrase) { continue; }
let f = if !flags.is_empty() { flags[0] } else { 0 };
let is_def = reliary_search::schema::unpack_is_def(f);
if is_def < 1 { continue; }
// Skip all-uppercase identifiers (likely constants)
if phrase.chars().all(|c| c.is_uppercase() || c == '_') { continue; }
// Count references in other files
if let Ok(mut ref_stmt) = db.prepare(
"SELECT COUNT(*)
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE p.phrase = ?1 AND fm.file_path != ?2",
) {
if let Ok(count) = ref_stmt.query_row([&phrase, file_path], |r| r.get::<_, i64>(0)) {
if count > 0 {
if let Ok(mut name_stmt) = db.prepare(
"SELECT DISTINCT fm.file_path
FROM phrase_occ po
JOIN phrases p ON p.id = po.phrase_id
JOIN file_map fm ON fm.id = po.file_id
WHERE p.phrase = ?1 AND fm.file_path != ?2
LIMIT 5",
) {
let refs: Vec<String> = name_stmt
.query_map([&phrase, file_path], |r| r.get::<_, String>(0))
.into_iter()
.flatten()
.flatten()
.collect();
def_refs.push((phrase, count, refs));
}
}
}
}
}
}
}

let mut warnings: Vec<String> = Vec::new();
def_refs.sort_by_key(|b| std::cmp::Reverse(b.1));
def_refs.truncate(5);
for (phrase, count, refs) in &def_refs {
let preview = refs.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
warnings.push(format!(
"ORPHAN RISK: '{}' referenced by {} file(s) (e.g., {}). Do not delete or rename without updating callers.",
phrase, count, preview
));
}

let mut result = json!({"file": file_path, "content": content});
if !warnings.is_empty() {
result["warnings"] = json!(warnings);
result["status"] = json!("warnings_detected");
} else {
result["status"] = json!("clean");
}
result
}

#[cfg(test)]
mod tests {
use super::*;

fn test_index_path() -> String {
// Find working index
for dir in &["crates/.reliary/index.sqlite", ".reliary/index.sqlite"] {
let p = format!("/home/john/src/reliary-agent/{}", dir);
if std::path::Path::new(&p).exists() {
let count: i64 = rusqlite::Connection::open(&p)
.and_then(|db| db.query_row("SELECT COUNT(*) FROM file_map", [], |r| r.get(0)))
.unwrap_or(0);
if count > 0 {
return p;
}
}
}
panic!("no working index found")
}

#[test]
fn test_check_diff_orphan_detected() {
let path = test_index_path();
// Use dummy content — all old identifiers will be "removed", triggering orphan
let content = "fn z_no_identif() {}";
let result = check_diff(&path, "crates/reliary-agent/src/proxy.rs", content);
let warnings = result["warnings"].as_array().map(|a| a.len()).unwrap_or(0);
let status = result["status"].as_str().unwrap_or("error");
let warn_texts: Vec<String> = result["warnings"].as_array()
.map(|a| a.iter().take(5).map(|w| w.as_str().unwrap_or("").to_string()).collect())
.unwrap_or_default();
println!("ORPHAN CHECK: status={}, warnings={}", status, warnings);
for w in &warn_texts {
println!(" {}", w);
}
assert!(warnings > 0, "Should detect orphaned references, got 0 warnings");
}

#[test]
fn test_check_diff_clean_edit() {
let path = test_index_path();
let content = "// same identifiers — no changes";
let result = check_diff(&path, "crates/reliary-agent/src/proxy.rs", content);
let status = result["status"].as_str().unwrap_or("error");
println!("CLEAN CHECK: status={}", status);
assert!(true);
}
}
1 change: 1 addition & 0 deletions crates/reliary-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod init;
mod ux;
mod proxy;
mod routes;
mod guard;

use clap::{Parser, Subcommand};
use std::io::Read;
Expand Down
Loading
Loading