dev/pbs #8
1
crates/prometeu-compiler/src/deps/mod.rs
Normal file
1
crates/prometeu-compiler/src/deps/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod resolver;
|
||||
371
crates/prometeu-compiler/src/deps/resolver.rs
Normal file
371
crates/prometeu-compiler/src/deps/resolver.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user