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 frontends;
|
||||||
pub mod compiler;
|
pub mod compiler;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
|
pub mod deps;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
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
|
## PR-11 — Dependency Fetching v0: local cache layout + git/path fetch
|
||||||
|
|
||||||
**Why:** Graph resolution needs a concrete directory for each dependency.
|
**Why:** Graph resolution needs a concrete directory for each dependency.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user