1use 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
30pub 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
143pub 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}