feat: implement core architecture modules

Phase 1: Wiki-Link Graph Indexing
- WikiLinkParser: extract [[links]] from markdown
- WikiLinkGraph: BFS traversal, reachable docs, backlinks
- Support relative path resolution (../../../)

Phase 2: ScoringPipeline trait (SOLID design)
- DocumentScorer trait: single interface for all scorers
- GlobalTfIdfScorer, ProjectTfIdfScorer, SemanticScorer
- MetadataBoostingScorer (decorator pattern)
- ScoringPipeline: orchestrate multiple scorers with RRF fusion
- Benefits: add new scorers without modifying existing code

Phase 7: RBAC + PolicyProvider trait
- PolicyProvider trait: pluggable backends (Vault, Postgres, Redis)
- VaultPolicyProvider: load YAML from vault/projects/* and vault/shared/skills/*
- MockPolicyProvider: for testing (no I/O)
- AccessChecker trait: single-purpose RBAC checks
- AccessLevelChecker, RoleChecker, PermissionChecker
- AccessDecisionEngine: orchestrate checkers with short-circuit eval
- AuditLogger trait: pluggable audit backends

Test Fixtures (DRY principle)
- OidcClaimsBuilder: fluent API for test data
- AccessPolicyBuilder: fluent API for policies
- MockPolicyProvider, MockAuditLogger: testing mocks

All modules compile and unit tests pass.
This commit is contained in:
2026-08-30 20:40:43 -07:00
parent 2850907167
commit 985f65d1f4
14 changed files with 1423 additions and 2 deletions
+1
View File
@@ -18,6 +18,7 @@ time = { workspace = true }
chrono = { workspace = true }
walkdir = "2.5"
sha2 = { workspace = true }
regex = { workspace = true }
[dev-dependencies]
time = { workspace = true }
+2
View File
@@ -7,6 +7,7 @@ pub mod reference_cycle_guard;
pub mod optimizer_sink;
pub mod optimizer_metrics;
pub mod query_metrics;
pub mod wiki_link;
pub use pi_session::PiSessionSource;
pub use claude_transcript::ClaudeTranscriptSource;
@@ -18,3 +19,4 @@ pub use query_metrics::{
QueryMetrics, QueryMetricsRepository, ProgressSnapshot, MetricsSummary,
OptimizationStatus, CompressorMetrics, ContentTypeMetrics,
};
pub use wiki_link::{WikiLink, WikiLinkParser, WikiLinkGraph, LinkType};
+216
View File
@@ -0,0 +1,216 @@
/// Wiki-Link Graph: Extract and manage [[links]] between documents
///
/// Phase 1: Wiki-Link Graph Indexing
///
/// Used to scope queries to project namespaces and enable graph traversal.
/// For example: poimen/tools/kubectl.md [[debugging.md]] creates an edge
/// from tools/kubectl to debugging (within same project).
use anyhow::{anyhow, Result};
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum LinkType {
Memory, // [[debugging.md]]
Skill, // [[SKILL-kubernetes-debugging]]
Shared, // [[../../shared/concepts/design-patterns.md]]
Unknown,
}
#[derive(Debug, Clone)]
pub struct WikiLink {
pub project: String,
pub source_path: String, // e.g., "tools/kubectl.md"
pub target_path: String, // e.g., "debugging.md"
pub link_type: LinkType,
pub resolved_path: Option<String>, // fully qualified
}
/// Parser: Extract [[links]] from markdown
pub struct WikiLinkParser;
impl WikiLinkParser {
/// Extract all wiki-links from markdown content
pub fn parse_links(content: &str) -> Result<Vec<String>> {
let regex = Regex::new(r"\[\[([^\]]+)\]\]")?;
let links = regex
.captures_iter(content)
.map(|cap| cap[1].trim().to_string())
.collect();
Ok(links)
}
/// Infer link type from target path
pub fn infer_link_type(target: &str) -> LinkType {
if target.contains("SKILL-") {
LinkType::Skill
} else if target.contains("shared:") || target.starts_with("../../") {
LinkType::Shared
} else if target.ends_with(".md") || !target.contains("/") {
LinkType::Memory
} else {
LinkType::Unknown
}
}
/// Resolve relative path to fully qualified path
///
/// Examples:
/// - "debugging.md" from "tools/kubectl.md" -> "tools/debugging.md"
/// - "../concepts/design.md" from "tools/kubectl.md" -> "concepts/design.md"
/// - "../../shared/skills/SKILL-*" from "tools/kubectl.md" -> "shared/skills/SKILL-*"
pub fn resolve_path(target: &str, source_dir: &Path) -> Result<PathBuf> {
// If target starts with shared: prefix, it's absolute
if target.starts_with("shared:") {
return Ok(PathBuf::from(target.replace("shared:", "shared/")));
}
// If target is just a filename, it's in the same dir as source
if !target.contains("/") && !target.contains("..") {
return Ok(source_dir.join(target));
}
// Otherwise resolve relative path
let resolved = source_dir.parent().unwrap_or_else(|| Path::new("")).join(target);
Ok(resolved)
}
}
/// Graph Index: Stores and queries wiki-link relationships
pub struct WikiLinkGraph {
/// Forward links: source -> [targets]
forward_links: HashMap<String, Vec<String>>,
/// Reverse links: target -> [sources] (for backlinks)
backward_links: HashMap<String, Vec<String>>,
/// Project scoping
project: String,
}
impl WikiLinkGraph {
pub fn new(project: &str) -> Self {
Self {
forward_links: HashMap::new(),
backward_links: HashMap::new(),
project: project.to_string(),
}
}
/// Add a wiki-link edge
pub fn add_link(&mut self, source: &str, target: &str) {
self.forward_links.entry(source.to_string())
.or_insert_with(Vec::new)
.push(target.to_string());
self.backward_links.entry(target.to_string())
.or_insert_with(Vec::new)
.push(source.to_string());
}
/// Get all reachable documents from a starting point (BFS)
pub fn reachable_docs(&self, start: &str) -> HashSet<String> {
let mut visited = HashSet::new();
let mut queue = vec![start.to_string()];
while let Some(current) = queue.pop() {
if visited.contains(&current) {
continue;
}
visited.insert(current.clone());
if let Some(targets) = self.forward_links.get(&current) {
for target in targets {
if !visited.contains(target) {
queue.push(target.clone());
}
}
}
}
visited
}
/// Get backlinks (documents that link to this one)
pub fn backlinks(&self, doc: &str) -> Vec<String> {
self.backward_links
.get(doc)
.cloned()
.unwrap_or_default()
}
/// Get forward links (documents this one links to)
pub fn forward_links(&self, doc: &str) -> Vec<String> {
self.forward_links
.get(doc)
.cloned()
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_wiki_links() {
let content = r#"
## Borrowing
See [[lifetimes.md]] for more.
Also check [[../../shared/skills/SKILL-ownership]]
"#;
let links = WikiLinkParser::parse_links(content).unwrap();
assert_eq!(links.len(), 2);
assert!(links.contains(&"lifetimes.md".to_string()));
assert!(links.contains(&"../../shared/skills/SKILL-ownership".to_string()));
}
#[test]
fn test_infer_link_type() {
assert_eq!(WikiLinkParser::infer_link_type("debugging.md"), LinkType::Memory);
assert_eq!(
WikiLinkParser::infer_link_type("SKILL-kubernetes-debug"),
LinkType::Skill
);
assert_eq!(
WikiLinkParser::infer_link_type("../../shared/concepts/design.md"),
LinkType::Shared
);
}
#[test]
fn test_resolve_path_simple() {
let source_dir = Path::new("poimen/tools");
let resolved = WikiLinkParser::resolve_path("debugging.md", source_dir).unwrap();
assert_eq!(resolved.file_name().unwrap(), "debugging.md");
}
#[test]
fn test_graph_reachable_docs() {
let mut graph = WikiLinkGraph::new("poimen");
graph.add_link("index.md", "tools/kubectl.md");
graph.add_link("tools/kubectl.md", "debugging/pod-crashes.md");
graph.add_link("debugging/pod-crashes.md", "../memories/ownership.md");
let reachable = graph.reachable_docs("index.md");
assert!(reachable.contains("index.md"));
assert!(reachable.contains("tools/kubectl.md"));
assert!(reachable.contains("debugging/pod-crashes.md"));
assert!(reachable.contains("../memories/ownership.md"));
}
#[test]
fn test_graph_backlinks() {
let mut graph = WikiLinkGraph::new("poimen");
graph.add_link("tools/kubectl.md", "debugging.md");
graph.add_link("tools/docker.md", "debugging.md");
let backlinks = graph.backlinks("debugging.md");
assert_eq!(backlinks.len(), 2);
assert!(backlinks.contains(&"tools/kubectl.md".to_string()));
assert!(backlinks.contains(&"tools/docker.md".to_string()));
}
}