Skip to main content

presolve_compiler/
project_discovery.rs

1//! Deterministic ergonomic project discovery for Phase R.
2
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use crate::platform::Digest;
7use crate::{
8    parse_semantic_package_contract, SemanticPackageResolutionTable,
9    SemanticPackageRuntimeModuleKey, SemanticPackageRuntimeModuleTable,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DiscoveredProjectSourceV1 {
14    pub logical_path: PathBuf,
15    pub source: String,
16}
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DiscoveredProjectV1 {
19    pub root: PathBuf,
20    pub source_root: PathBuf,
21    pub fingerprint: String,
22    pub sources: Vec<DiscoveredProjectSourceV1>,
23}
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ProjectDiscoveryErrorV1 {
26    pub code: &'static str,
27    pub message: String,
28}
29
30/// Resolves published `presolve.contract.json` metadata from a declared npm
31/// package. Package source stays opaque; only verified contract/runtime paths
32/// become compiler inputs.
33pub fn discover_semantic_packages_v1(
34    root: &Path,
35    specifiers: &[String],
36) -> Result<
37    (
38        SemanticPackageResolutionTable,
39        SemanticPackageRuntimeModuleTable,
40    ),
41    ProjectDiscoveryErrorV1,
42> {
43    let mut contracts = SemanticPackageResolutionTable::default();
44    let mut modules = SemanticPackageRuntimeModuleTable::default();
45    for specifier in specifiers {
46        if !is_npm_package_specifier(specifier) {
47            continue;
48        }
49        let package_root = root.join("node_modules").join(specifier);
50        let contract_path = package_root.join("presolve.contract.json");
51        if !contract_path.is_file() {
52            continue;
53        }
54        let source =
55            fs::read_to_string(&contract_path).map_err(|error| ProjectDiscoveryErrorV1 {
56                code: "PSDISC1010_PACKAGE_CONTRACT_READ_FAILED",
57                message: error.to_string(),
58            })?;
59        let contract =
60            parse_semantic_package_contract(&source).map_err(|error| ProjectDiscoveryErrorV1 {
61                code: "PSDISC1011_PACKAGE_CONTRACT_INVALID",
62                message: format!("{error:?}"),
63            })?;
64        if contract.package != *specifier {
65            return Err(ProjectDiscoveryErrorV1 {
66                code: "PSDISC1014_PACKAGE_SPECIFIER_MISMATCH",
67                message: format!("{} declares package {}", specifier, contract.package),
68            });
69        }
70        for export in contract.exports.values() {
71            if !is_contained_runtime_module_path(&export.runtime_module) {
72                return Err(ProjectDiscoveryErrorV1 {
73                    code: "PSDISC1015_PACKAGE_RUNTIME_PATH_INVALID",
74                    message: format!("{}: {}", specifier, export.runtime_module),
75                });
76            }
77            let location = package_root.join(&export.runtime_module);
78            if !location.is_file() {
79                return Err(ProjectDiscoveryErrorV1 {
80                    code: "PSDISC1012_PACKAGE_RUNTIME_MISSING",
81                    message: location.display().to_string(),
82                });
83            }
84            modules
85                .insert(
86                    SemanticPackageRuntimeModuleKey {
87                        package: contract.package.clone(),
88                        version: contract.version.clone(),
89                        integrity: contract.integrity.clone(),
90                        runtime_module: export.runtime_module.clone(),
91                    },
92                    location.to_string_lossy().into_owned(),
93                )
94                .map_err(|error| ProjectDiscoveryErrorV1 {
95                    code: "PSDISC1013_PACKAGE_RUNTIME_INVALID",
96                    message: format!("{error:?}"),
97                })?;
98        }
99        contracts
100            .insert(specifier.clone(), contract)
101            .map_err(|error| ProjectDiscoveryErrorV1 {
102                code: "PSDISC1011_PACKAGE_CONTRACT_INVALID",
103                message: format!("{error:?}"),
104            })?;
105    }
106    Ok((contracts, modules))
107}
108
109fn is_npm_package_specifier(specifier: &str) -> bool {
110    let segments = specifier.split('/').collect::<Vec<_>>();
111    match segments.as_slice() {
112        [name] => valid_package_name_segment(name, false),
113        [scope, name] => {
114            valid_package_name_segment(scope, true) && valid_package_name_segment(name, false)
115        }
116        _ => false,
117    }
118}
119
120fn valid_package_name_segment(value: &str, scoped: bool) -> bool {
121    let value = if scoped {
122        let Some(value) = value.strip_prefix('@') else {
123            return false;
124        };
125        value
126    } else {
127        value
128    };
129    !value.is_empty()
130        && value.chars().all(|character| {
131            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
132        })
133}
134
135fn is_contained_runtime_module_path(value: &str) -> bool {
136    let path = Path::new(value);
137    !path.is_absolute()
138        && path
139            .components()
140            .all(|component| matches!(component, Component::Normal(_)))
141}
142
143/// Discovers the default `app/` root, falling back to `src/`, in deterministic path order.
144///
145/// # Errors
146/// Returns a stable error for unavailable roots or unreadable supported files.
147pub fn discover_project_v1(root: &Path) -> Result<DiscoveredProjectV1, ProjectDiscoveryErrorV1> {
148    let root = fs::canonicalize(root).map_err(|error| ProjectDiscoveryErrorV1 {
149        code: "PSDISC1001_PROJECT_ROOT_UNAVAILABLE",
150        message: error.to_string(),
151    })?;
152    let source_root = [root.join("app"), root.join("src")]
153        .into_iter()
154        .find(|path| path.is_dir())
155        .ok_or_else(|| ProjectDiscoveryErrorV1 {
156            code: "PSDISC1002_SOURCE_ROOT_MISSING",
157            message: "expected app/ or src/ below project root".into(),
158        })?;
159    let mut paths = Vec::new();
160    collect_sources(&source_root, &mut paths)?;
161    paths.sort();
162    if paths.is_empty() {
163        return Err(ProjectDiscoveryErrorV1 {
164            code: "PSDISC1003_SOURCE_SET_EMPTY",
165            message: "default source root contains no .ts or .tsx sources".into(),
166        });
167    }
168    let sources = paths
169        .into_iter()
170        .map(|path| {
171            let source = fs::read_to_string(&path).map_err(|error| ProjectDiscoveryErrorV1 {
172                code: "PSDISC1004_SOURCE_READ_FAILED",
173                message: format!("{}: {error}", path.display()),
174            })?;
175            let logical_path = path
176                .strip_prefix(&root)
177                .expect("discovered source is contained")
178                .to_path_buf();
179            Ok(DiscoveredProjectSourceV1 {
180                logical_path,
181                source,
182            })
183        })
184        .collect::<Result<Vec<_>, ProjectDiscoveryErrorV1>>()?;
185    let fingerprint = Digest::sha256(
186        sources
187            .iter()
188            .map(|source| format!("{}\n{}\n", source.logical_path.display(), source.source))
189            .collect::<String>(),
190    )
191    .to_string();
192    Ok(DiscoveredProjectV1 {
193        root,
194        source_root,
195        fingerprint,
196        sources,
197    })
198}
199
200fn collect_sources(
201    directory: &Path,
202    paths: &mut Vec<PathBuf>,
203) -> Result<(), ProjectDiscoveryErrorV1> {
204    for entry in fs::read_dir(directory).map_err(|error| ProjectDiscoveryErrorV1 {
205        code: "PSDISC1001_PROJECT_ROOT_UNAVAILABLE",
206        message: error.to_string(),
207    })? {
208        let path = entry
209            .map_err(|error| ProjectDiscoveryErrorV1 {
210                code: "PSDISC1001_PROJECT_ROOT_UNAVAILABLE",
211                message: error.to_string(),
212            })?
213            .path();
214        if path.is_dir() {
215            collect_sources(&path, paths)?;
216        } else if matches!(
217            path.extension().and_then(|value| value.to_str()),
218            Some("ts") | Some("tsx")
219        ) {
220            paths.push(path);
221        }
222    }
223    Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228    use std::fs;
229    use std::path::PathBuf;
230    use std::sync::atomic::{AtomicU64, Ordering};
231
232    use super::{discover_project_v1, discover_semantic_packages_v1};
233    use crate::SemanticPackageRuntimeModuleKey;
234
235    static NEXT_TEMPORARY_PROJECT: AtomicU64 = AtomicU64::new(1);
236
237    fn temporary_project_root(label: &str) -> PathBuf {
238        let sequence = NEXT_TEMPORARY_PROJECT.fetch_add(1, Ordering::Relaxed);
239        let root = std::env::temp_dir().join(format!(
240            "presolve-project-discovery-{label}-{}-{sequence}",
241            std::process::id()
242        ));
243        fs::create_dir_all(&root).unwrap();
244        root
245    }
246
247    #[test]
248    fn discovers_app_sources_in_deterministic_logical_path_order() {
249        let root = temporary_project_root("sources");
250        fs::create_dir_all(root.join("app/routes/blog")).unwrap();
251        fs::create_dir_all(root.join("src")).unwrap();
252        fs::write(root.join("app/routes/index.tsx"), "export class Home {}\n").unwrap();
253        fs::write(
254            root.join("app/routes/blog/post.tsx"),
255            "export class Post {}\n",
256        )
257        .unwrap();
258        fs::write(root.join("src/ignored.tsx"), "export class Ignored {}\n").unwrap();
259
260        let discovered = discover_project_v1(&root).unwrap();
261
262        assert_eq!(
263            discovered
264                .sources
265                .iter()
266                .map(|source| source.logical_path.to_string_lossy().into_owned())
267                .collect::<Vec<_>>(),
268            vec!["app/routes/blog/post.tsx", "app/routes/index.tsx"]
269        );
270        assert_eq!(
271            discovered.source_root,
272            std::fs::canonicalize(root.join("app")).unwrap()
273        );
274        assert!(!discovered.fingerprint.is_empty());
275        fs::remove_dir_all(root).unwrap();
276    }
277
278    #[test]
279    fn resolves_only_exactly_named_contained_package_runtime_modules() {
280        let root = temporary_project_root("packages");
281        let package_root = root.join("node_modules/@acme/analytics");
282        fs::create_dir_all(package_root.join("dist")).unwrap();
283        fs::write(
284            package_root.join("dist/track.js"),
285            "export function track() {}\n",
286        )
287        .unwrap();
288        fs::write(
289            package_root.join("presolve.contract.json"),
290            r#"{"schema_version":1,"package":"@acme/analytics","version":"1.0.0","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"track":{"kind":"opaque","type_signature":"() -> void","runtime_module":"dist/track.js","resume_policy":"cold_fallback","opaque_terminal":{"execution_boundary":"client","resume":"cold_fallback"}}}}"#,
291        )
292        .unwrap();
293
294        let specifier = "@acme/analytics".to_string();
295        let (contracts, modules) =
296            discover_semantic_packages_v1(&root, std::slice::from_ref(&specifier)).unwrap();
297
298        let contract = contracts.contract(&specifier).unwrap();
299        assert_eq!(contract.package, specifier);
300        assert_eq!(
301            modules.resolve(&SemanticPackageRuntimeModuleKey {
302                package: "@acme/analytics".into(),
303                version: "1.0.0".into(),
304                integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
305                runtime_module: "dist/track.js".into(),
306            }),
307            Some(package_root.join("dist/track.js").to_string_lossy().as_ref())
308        );
309        fs::remove_dir_all(root).unwrap();
310    }
311
312    #[test]
313    fn rejects_contract_runtime_paths_that_escape_the_declared_package() {
314        let root = temporary_project_root("escape");
315        let package_root = root.join("node_modules/date-kit");
316        fs::create_dir_all(&package_root).unwrap();
317        fs::write(
318            package_root.join("presolve.contract.json"),
319            r#"{"schema_version":1,"package":"date-kit","version":"1.0.0","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"format":{"kind":"pure","type_signature":"(Date) -> string","runtime_module":"../outside.js","resume_policy":"input_only"}}}"#,
320        )
321        .unwrap();
322
323        let error = discover_semantic_packages_v1(&root, &["date-kit".into()]).unwrap_err();
324
325        assert_eq!(error.code, "PSDISC1015_PACKAGE_RUNTIME_PATH_INVALID");
326        fs::remove_dir_all(root).unwrap();
327    }
328}