dev/pbs #8

Merged
bquarkz merged 74 commits from dev/pbs into master 2026-02-03 15:28:31 +00:00
4 changed files with 373 additions and 49 deletions
Showing only changes of commit ba61458a78 - Show all commits

View File

@ -0,0 +1 @@
pub mod resolver;

View File

@ -0,0 +1,371 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::manifest::{Manifest, DependencySpec, load_manifest};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProjectId {
pub name: String,
pub version: String,
}
#[derive(Debug)]
pub struct ResolvedNode {
pub id: ProjectId,
pub path: PathBuf,
pub manifest: Manifest,
}
#[derive(Debug)]
pub struct ResolvedEdge {
pub alias: String,
pub to: ProjectId,
}
#[derive(Debug, Default)]
pub struct ResolvedGraph {
pub nodes: HashMap<ProjectId, ResolvedNode>,
pub edges: HashMap<ProjectId, Vec<ResolvedEdge>>,
pub root_id: Option<ProjectId>,
}
#[derive(Debug)]
pub enum ResolveError {
CycleDetected(Vec<String>),
MissingDependency(PathBuf),
VersionConflict {
name: String,
v1: String,
v2: String,
},
NameCollision {
name: String,
p1: PathBuf,
p2: PathBuf,
},
ManifestError(crate::manifest::ManifestError),
IoError {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResolveError::CycleDetected(chain) => write!(f, "Cycle detected: {}", chain.join(" -> ")),
ResolveError::MissingDependency(path) => write!(f, "Missing dependency at: {}", path.display()),
ResolveError::VersionConflict { name, v1, v2 } => {
write!(f, "Version conflict for project '{}': {} vs {}", name, v1, v2)
}
ResolveError::NameCollision { name, p1, p2 } => {
write!(f, "Name collision: two distinct projects claiming same name '{}' at {} and {}", name, p1.display(), p2.display())
}
ResolveError::ManifestError(e) => write!(f, "Manifest error: {}", e),
ResolveError::IoError { path, source } => write!(f, "IO error at {}: {}", path.display(), source),
}
}
}
impl std::error::Error for ResolveError {}
impl From<crate::manifest::ManifestError> for ResolveError {
fn from(e: crate::manifest::ManifestError) -> Self {
ResolveError::ManifestError(e)
}
}
pub fn resolve_graph(root_dir: &Path) -> Result<ResolvedGraph, ResolveError> {
let mut graph = ResolvedGraph::default();
let mut visited = HashSet::new();
let mut stack = Vec::new();
let root_path = root_dir.canonicalize().map_err(|e| ResolveError::IoError {
path: root_dir.to_path_buf(),
source: e,
})?;
let root_id = resolve_recursive(&root_path, &mut graph, &mut visited, &mut stack)?;
graph.root_id = Some(root_id);
Ok(graph)
}
fn resolve_recursive(
project_path: &Path,
graph: &mut ResolvedGraph,
visited: &mut HashSet<ProjectId>,
stack: &mut Vec<ProjectId>,
) -> Result<ProjectId, ResolveError> {
let manifest = load_manifest(project_path)?;
let project_id = ProjectId {
name: manifest.name.clone(),
version: manifest.version.clone(),
};
// Cycle detection
if let Some(pos) = stack.iter().position(|id| id == &project_id) {
let mut chain: Vec<String> = stack[pos..].iter().map(|id| id.name.clone()).collect();
chain.push(project_id.name.clone());
return Err(ResolveError::CycleDetected(chain));
}
// Collision handling: Name collision
// If we find a project with the same name but different path/version, we might have a collision or version conflict.
for node in graph.nodes.values() {
if node.id.name == project_id.name {
if node.id.version != project_id.version {
return Err(ResolveError::VersionConflict {
name: project_id.name.clone(),
v1: node.id.version.clone(),
v2: project_id.version.clone(),
});
}
// Same name, same version, but different path?
if node.path != project_path {
return Err(ResolveError::NameCollision {
name: project_id.name.clone(),
p1: node.path.clone(),
p2: project_path.to_path_buf(),
});
}
}
}
// If already fully visited, return the ID
if visited.contains(&project_id) {
return Ok(project_id);
}
stack.push(project_id.clone());
let mut edges = Vec::new();
for (alias, spec) in &manifest.dependencies {
let dep_path = match spec {
DependencySpec::Path(p) => project_path.join(p),
DependencySpec::Full(full) => {
if let Some(p) = &full.path {
project_path.join(p)
} else {
// Git dependencies not supported in v0 (PR-11 will add fetching)
return Err(ResolveError::MissingDependency(PathBuf::from("git-dependency-unsupported-in-v0")));
}
}
};
let dep_path = dep_path.canonicalize().map_err(|_| ResolveError::MissingDependency(dep_path))?;
let dep_id = resolve_recursive(&dep_path, graph, visited, stack)?;
edges.push(ResolvedEdge {
alias: alias.clone(),
to: dep_id,
});
}
stack.pop();
visited.insert(project_id.clone());
graph.nodes.insert(project_id.clone(), ResolvedNode {
id: project_id.clone(),
path: project_path.to_path_buf(),
manifest,
});
graph.edges.insert(project_id.clone(), edges);
Ok(project_id)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_simple_graph() {
let dir = tempdir().unwrap();
let root = dir.path().join("root");
let dep = dir.path().join("dep");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&dep).unwrap();
fs::write(root.join("prometeu.json"), r#"{
"name": "root",
"version": "0.1.0",
"dependencies": { "d": "../dep" }
}"#).unwrap();
fs::write(dep.join("prometeu.json"), r#"{
"name": "dep",
"version": "1.0.0"
}"#).unwrap();
let graph = resolve_graph(&root).unwrap();
assert_eq!(graph.nodes.len(), 2);
let root_id = graph.root_id.as_ref().unwrap();
assert_eq!(root_id.name, "root");
let edges = graph.edges.get(root_id).unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].alias, "d");
assert_eq!(edges[0].to.name, "dep");
}
#[test]
fn test_cycle_detection() {
let dir = tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
fs::write(a.join("prometeu.json"), r#"{
"name": "a",
"version": "0.1.0",
"dependencies": { "b": "../b" }
}"#).unwrap();
fs::write(b.join("prometeu.json"), r#"{
"name": "b",
"version": "0.1.0",
"dependencies": { "a": "../a" }
}"#).unwrap();
let err = resolve_graph(&a).unwrap_err();
match err {
ResolveError::CycleDetected(chain) => {
assert_eq!(chain, vec!["a", "b", "a"]);
}
_ => panic!("Expected CycleDetected error, got {:?}", err),
}
}
#[test]
fn test_alias_does_not_change_identity() {
let dir = tempdir().unwrap();
let root = dir.path().join("root");
let dep = dir.path().join("dep");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&dep).unwrap();
fs::write(root.join("prometeu.json"), r#"{
"name": "root",
"version": "0.1.0",
"dependencies": { "my_alias": "../dep" }
}"#).unwrap();
fs::write(dep.join("prometeu.json"), r#"{
"name": "actual_name",
"version": "1.0.0"
}"#).unwrap();
let graph = resolve_graph(&root).unwrap();
let root_id = graph.root_id.as_ref().unwrap();
let edges = graph.edges.get(root_id).unwrap();
assert_eq!(edges[0].alias, "my_alias");
assert_eq!(edges[0].to.name, "actual_name");
assert!(graph.nodes.contains_key(&edges[0].to));
}
#[test]
fn test_version_conflict() {
let dir = tempdir().unwrap();
let root = dir.path().join("root");
let dep1 = dir.path().join("dep1");
let dep2 = dir.path().join("dep2");
let shared = dir.path().join("shared1");
let shared2 = dir.path().join("shared2");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&dep1).unwrap();
fs::create_dir_all(&dep2).unwrap();
fs::create_dir_all(&shared).unwrap();
fs::create_dir_all(&shared2).unwrap();
fs::write(root.join("prometeu.json"), r#"{
"name": "root",
"version": "0.1.0",
"dependencies": { "d1": "../dep1", "d2": "../dep2" }
}"#).unwrap();
fs::write(dep1.join("prometeu.json"), r#"{
"name": "dep1",
"version": "0.1.0",
"dependencies": { "s": "../shared1" }
}"#).unwrap();
fs::write(dep2.join("prometeu.json"), r#"{
"name": "dep2",
"version": "0.1.0",
"dependencies": { "s": "../shared2" }
}"#).unwrap();
fs::write(shared.join("prometeu.json"), r#"{
"name": "shared",
"version": "1.0.0"
}"#).unwrap();
fs::write(shared2.join("prometeu.json"), r#"{
"name": "shared",
"version": "2.0.0"
}"#).unwrap();
let err = resolve_graph(&root).unwrap_err();
match err {
ResolveError::VersionConflict { name, .. } => {
assert_eq!(name, "shared");
}
_ => panic!("Expected VersionConflict error, got {:?}", err),
}
}
#[test]
fn test_name_collision() {
let dir = tempdir().unwrap();
let root = dir.path().join("root");
let dep1 = dir.path().join("dep1");
let dep2 = dir.path().join("dep2");
let p1 = dir.path().join("p1");
let p2 = dir.path().join("p2");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&dep1).unwrap();
fs::create_dir_all(&dep2).unwrap();
fs::create_dir_all(&p1).unwrap();
fs::create_dir_all(&p2).unwrap();
fs::write(root.join("prometeu.json"), r#"{
"name": "root",
"version": "0.1.0",
"dependencies": { "d1": "../dep1", "d2": "../dep2" }
}"#).unwrap();
fs::write(dep1.join("prometeu.json"), r#"{
"name": "dep1",
"version": "0.1.0",
"dependencies": { "p": "../p1" }
}"#).unwrap();
fs::write(dep2.join("prometeu.json"), r#"{
"name": "dep2",
"version": "0.1.0",
"dependencies": { "p": "../p2" }
}"#).unwrap();
// Both p1 and p2 claim to be "collision" version 1.0.0
fs::write(p1.join("prometeu.json"), r#"{
"name": "collision",
"version": "1.0.0"
}"#).unwrap();
fs::write(p2.join("prometeu.json"), r#"{
"name": "collision",
"version": "1.0.0"
}"#).unwrap();
let err = resolve_graph(&root).unwrap_err();
match err {
ResolveError::NameCollision { name, .. } => {
assert_eq!(name, "collision");
}
_ => panic!("Expected NameCollision error, got {:?}", err),
}
}
}

View File

@ -45,6 +45,7 @@ pub mod backend;
pub mod frontends;
pub mod compiler;
pub mod manifest;
pub mod deps;
use anyhow::Result;
use clap::{Parser, Subcommand};

View File

@ -1,52 +1,3 @@
## PR-10 — Dependency Resolver v0: build a resolved project graph
**Why:** We need a deterministic **module graph** from manifest(s) before compiling.
### Scope
* Implement `prometeu_compiler::deps::resolver`:
* Input: root project dir
* Output: `ResolvedGraph`
* Graph nodes:
* project identity: `{name, version}`
* local alias name (the key used by the parent)
* root path in filesystem (after fetch/resolve)
* manifest loaded for each node
* Resolution rules (v0):
* DFS/stack walk from root
* cycle detection
* collision handling:
* If the same (project name) appears with incompatible versions, error
* aliasing:
* alias is local to the edge, but graph also stores the underlying project identity
### Deliverables
* `resolve_graph(root_dir) -> Result<ResolvedGraph, ResolveError>`
* `ResolveError` variants:
* cycle detected (show chain)
* missing dependency (path not found / git not fetchable)
* version conflict (same project name, incompatible constraints)
* name collision (two distinct projects claiming same name)
### Tests
* simple root -> dep path graph
* cycle detection
* alias rename does not change project identity
### Acceptance
* Compiler can produce a stable, deterministic dependency graph.
---
## PR-11 — Dependency Fetching v0: local cache layout + git/path fetch
**Why:** Graph resolution needs a concrete directory for each dependency.