projectatlas/runtime/
module_resolution.rs

1//! Bounded repository compiler-configuration loading for semantic graph projection.
2
3use super::{
4    CliError, IndexWorkControl, IndexWorkFailure, IndexWorkResource, IndexWorkStage, Node,
5    NodeKind, SourceReadFailure, read_source_bytes_controlled, repo_path_to_native,
6    source_changed_during_derivation,
7};
8use jsonc_parser::{ParseOptions as JsoncParseOptions, parse_to_serde_value};
9use projectatlas_symbols::{
10    ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
11    EcmaScriptPathMapping, MAX_CONFIGURED_MODULE_CONFIGS, MAX_CONFIGURED_MODULE_MAPPINGS,
12    MAX_CONFIGURED_MODULE_TARGETS,
13};
14use serde_json::Value;
15use std::path::Path;
16
17/// Maximum complete bytes admitted from one compiler configuration.
18const MAX_MODULE_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
19/// Maximum aggregate compiler-configuration bytes retained by one graph stage.
20const MAX_MODULE_CONFIG_TOTAL_BYTES: u64 = 16 * 1024 * 1024;
21/// Optional byte-order mark accepted at the start of UTF-8 compiler configuration.
22const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
23/// Work interval between cancellation/deadline checks while decoding mappings.
24const MODULE_CONFIG_CONTROL_INTERVAL: usize = 64;
25
26/// Load one deterministic compiler-configuration snapshot from indexed nodes.
27pub(super) fn load_configured_module_resolution(
28    root: &Path,
29    nodes: &[Node],
30    control: &IndexWorkControl,
31) -> Result<ConfiguredModuleResolution, CliError> {
32    let mut config_nodes = nodes
33        .iter()
34        .filter(|node| node.kind == NodeKind::File)
35        .filter_map(|node| config_kind(&node.path).map(|kind| (node, kind)))
36        .collect::<Vec<_>>();
37    config_nodes.sort_by(|(left, _), (right, _)| left.path.cmp(&right.path));
38    if config_nodes.len() > MAX_CONFIGURED_MODULE_CONFIGS {
39        return Err(module_config_resource_limit(
40            IndexWorkResource::Entries,
41            MAX_CONFIGURED_MODULE_CONFIGS,
42            config_nodes.len(),
43        ));
44    }
45
46    let mut total_bytes = 0_u64;
47    let mut total_mappings = 0_u64;
48    let mut configs = Vec::with_capacity(config_nodes.len());
49    for (index, (node, kind)) in config_nodes.into_iter().enumerate() {
50        check_config_work(control, index)?;
51        let size = node.size_bytes.unwrap_or_default();
52        if size > MAX_MODULE_CONFIG_FILE_BYTES {
53            return Err(module_config_resource_limit(
54                IndexWorkResource::SourceBytes,
55                MAX_MODULE_CONFIG_FILE_BYTES,
56                size,
57            ));
58        }
59        claim_module_config_total(
60            &mut total_bytes,
61            size,
62            MAX_MODULE_CONFIG_TOTAL_BYTES,
63            IndexWorkResource::SourceBytes,
64        )?;
65        let native_path = root.join(repo_path_to_native(&node.path));
66        let bytes = match read_source_bytes_controlled(
67            &native_path,
68            MAX_MODULE_CONFIG_FILE_BYTES,
69            IndexWorkStage::SymbolParsing,
70            control,
71        ) {
72            Ok(bytes) => bytes,
73            Err(SourceReadFailure::Io(source)) => {
74                return Err(CliError::Io {
75                    path: native_path,
76                    source,
77                });
78            }
79            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
80            Err(SourceReadFailure::LimitExceeded { observed }) => {
81                return Err(module_config_resource_limit(
82                    IndexWorkResource::SourceBytes,
83                    MAX_MODULE_CONFIG_FILE_BYTES,
84                    observed,
85                ));
86            }
87        };
88        let current_hash = blake3::hash(&bytes).to_hex().to_string();
89        if node.content_hash.as_deref() != Some(current_hash.as_str()) {
90            return Err(source_changed_during_derivation(root, &node.path));
91        }
92        let content = std::str::from_utf8(bytes.strip_prefix(UTF8_BOM).unwrap_or(&bytes)).map_err(
93            |_utf8_error| {
94                CliError::InvalidInput(format!(
95                    "compiler configuration '{}' is not valid UTF-8",
96                    node.path
97                ))
98            },
99        )?;
100        let value: Value =
101            parse_to_serde_value(content, &JsoncParseOptions::default()).map_err(|error| {
102                CliError::InvalidInput(format!(
103                    "failed to parse compiler configuration '{}': {error}",
104                    node.path
105                ))
106            })?;
107        let config = decode_config(node.path.as_str(), kind, &value, control)?;
108        claim_module_config_total(
109            &mut total_mappings,
110            u64::try_from(config.1).unwrap_or(u64::MAX),
111            u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX),
112            IndexWorkResource::RelationRows,
113        )?;
114        configs.push(config.0);
115    }
116    ConfiguredModuleResolution::new(configs)
117        .map_err(|error| CliError::InvalidInput(error.to_string()))
118}
119
120/// Decode direct `compilerOptions.baseUrl` and `compilerOptions.paths`.
121fn decode_config(
122    config_path: &str,
123    kind: EcmaScriptConfigKind,
124    value: &Value,
125    control: &IndexWorkControl,
126) -> Result<(EcmaScriptModuleConfig, usize), CliError> {
127    let root = value.as_object().ok_or_else(|| {
128        CliError::InvalidInput(format!(
129            "compiler configuration '{config_path}' must contain a JSON object"
130        ))
131    })?;
132    let Some(compiler_options) = root.get("compilerOptions") else {
133        return EcmaScriptModuleConfig::new(config_path, kind, None, Vec::new())
134            .map(|config| (config, 0))
135            .map_err(|error| CliError::InvalidInput(error.to_string()));
136    };
137    let compiler_options = compiler_options.as_object().ok_or_else(|| {
138        CliError::InvalidInput(format!(
139            "compiler configuration '{config_path}' field 'compilerOptions' must be an object"
140        ))
141    })?;
142    let config_directory = config_path
143        .rsplit_once('/')
144        .map_or("", |(directory, _)| directory);
145    let base_url = compiler_options
146        .get("baseUrl")
147        .map(|value| {
148            value.as_str().ok_or_else(|| {
149                CliError::InvalidInput(format!(
150                    "compiler configuration '{config_path}' field 'baseUrl' must be a string"
151                ))
152            })
153        })
154        .transpose()?
155        .map(|base_url| normalize_repository_target(config_directory, base_url, false))
156        .transpose()?;
157    let mapping_base = base_url.as_deref().unwrap_or(config_directory);
158    let mut mappings = Vec::new();
159    if let Some(paths) = compiler_options.get("paths") {
160        let paths = paths.as_object().ok_or_else(|| {
161            CliError::InvalidInput(format!(
162                "compiler configuration '{config_path}' field 'paths' must be an object"
163            ))
164        })?;
165        for (index, (pattern, targets)) in paths.iter().enumerate() {
166            check_config_work(control, index)?;
167            let targets = targets.as_array().ok_or_else(|| {
168                CliError::InvalidInput(format!(
169                    "compiler configuration '{config_path}' path mapping '{pattern}' must be an array"
170                ))
171            })?;
172            if targets.is_empty() || targets.len() > MAX_CONFIGURED_MODULE_TARGETS {
173                return Err(module_config_resource_limit(
174                    IndexWorkResource::RelationRows,
175                    MAX_CONFIGURED_MODULE_TARGETS,
176                    targets.len(),
177                ));
178            }
179            let targets = targets
180                .iter()
181                .map(|target| {
182                    let target = target.as_str().ok_or_else(|| {
183                        CliError::InvalidInput(format!(
184                            "compiler configuration '{config_path}' path mapping '{pattern}' contains a non-string target"
185                        ))
186                    })?;
187                    normalize_repository_target(mapping_base, target, true)
188                })
189                .collect::<Result<Vec<_>, _>>()?;
190            mappings.push(
191                EcmaScriptPathMapping::new(pattern, targets)
192                    .map_err(|error| CliError::InvalidInput(error.to_string()))?,
193            );
194        }
195    }
196    let mapping_count = mappings.len();
197    EcmaScriptModuleConfig::new(config_path, kind, base_url, mappings)
198        .map(|config| (config, mapping_count))
199        .map_err(|error| CliError::InvalidInput(error.to_string()))
200}
201
202/// Normalize one config-relative target into a repository-contained lexical path.
203fn normalize_repository_target(
204    base: &str,
205    target: &str,
206    allow_wildcard: bool,
207) -> Result<String, CliError> {
208    let target = target.replace('\\', "/");
209    if target.starts_with('/') || target.contains(':') || target.contains('\0') {
210        return Err(CliError::InvalidInput(format!(
211            "configured module target {target:?} is absolute or invalid"
212        )));
213    }
214    if !allow_wildcard && target.contains('*') || target.matches('*').count() > 1 {
215        return Err(CliError::InvalidInput(format!(
216            "configured module target {target:?} contains an invalid wildcard"
217        )));
218    }
219    let mut components = base
220        .split('/')
221        .filter(|component| !component.is_empty())
222        .map(ToString::to_string)
223        .collect::<Vec<_>>();
224    for component in target.split('/') {
225        match component {
226            "" | "." => {}
227            ".." => {
228                if components.pop().is_none() {
229                    return Err(CliError::InvalidInput(format!(
230                        "configured module target {target:?} escapes the repository"
231                    )));
232                }
233            }
234            component => components.push(component.to_string()),
235        }
236    }
237    Ok(components.join("/"))
238}
239
240/// Classify exact compiler-configuration basenames.
241fn config_kind(path: &str) -> Option<EcmaScriptConfigKind> {
242    match path.rsplit('/').next() {
243        Some("tsconfig.json") => Some(EcmaScriptConfigKind::TypeScript),
244        Some("jsconfig.json") => Some(EcmaScriptConfigKind::JavaScript),
245        _ => None,
246    }
247}
248
249/// Observe cancellation and deadline ownership at a bounded work interval.
250fn check_config_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
251    if index.is_multiple_of(MODULE_CONFIG_CONTROL_INTERVAL) {
252        control.check(IndexWorkStage::SymbolParsing)?;
253    }
254    Ok(())
255}
256
257/// Admit aggregate compiler-configuration work without saturating past its bound.
258fn claim_module_config_total(
259    total: &mut u64,
260    added: u64,
261    limit: u64,
262    resource: IndexWorkResource,
263) -> Result<(), CliError> {
264    let observed = total.saturating_add(added);
265    if observed > limit {
266        return Err(module_config_resource_limit(resource, limit, observed));
267    }
268    *total = observed;
269    Ok(())
270}
271
272/// Translate configured-module bounds into the runtime's typed limit failure.
273fn module_config_resource_limit(
274    resource: IndexWorkResource,
275    limit: impl TryInto<u64>,
276    observed: impl TryInto<u64>,
277) -> CliError {
278    IndexWorkFailure::resource_limit(
279        IndexWorkStage::SymbolParsing,
280        resource,
281        limit.try_into().unwrap_or(u64::MAX),
282        observed.try_into().unwrap_or(u64::MAX),
283    )
284    .into()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::{
290        CliError, MAX_MODULE_CONFIG_FILE_BYTES, MAX_MODULE_CONFIG_TOTAL_BYTES, UTF8_BOM,
291        claim_module_config_total, decode_config, load_configured_module_resolution,
292        normalize_repository_target,
293    };
294    use projectatlas_core::{
295        IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkResource, IndexWorkStage,
296        Node, NodeKind,
297    };
298    use projectatlas_symbols::{
299        EcmaScriptConfigKind, MAX_CONFIGURED_MODULE_CONFIGS, MAX_CONFIGURED_MODULE_MAPPINGS,
300        MAX_CONFIGURED_MODULE_TARGETS,
301    };
302    use serde_json::json;
303    use std::error::Error;
304    use std::fs;
305    use std::path::Path;
306    use std::time::Instant;
307
308    fn config_node(path: &str, content: &[u8]) -> Node {
309        Node {
310            path: path.to_string(),
311            kind: NodeKind::File,
312            parent_path: Path::new(path)
313                .parent()
314                .and_then(Path::to_str)
315                .filter(|parent| !parent.is_empty())
316                .map(ToString::to_string),
317            extension: Some(".json".to_string()),
318            language: Some("json".to_string()),
319            size_bytes: Some(u64::try_from(content.len()).unwrap_or(u64::MAX)),
320            mtime_ns: Some(1),
321            content_hash: Some(blake3::hash(content).to_hex().to_string()),
322        }
323    }
324
325    #[test]
326    fn direct_json_config_is_normalized_without_leaving_the_repository()
327    -> Result<(), Box<dyn Error>> {
328        let control = IndexWorkControl::new(IndexCancellation::new(), None);
329        let (config, mappings) = decode_config(
330            "packages/app/tsconfig.json",
331            EcmaScriptConfigKind::TypeScript,
332            &json!({
333                "compilerOptions": {
334                    "baseUrl": "src",
335                    "paths": {
336                        "@/*": ["*"],
337                        "@shared/*": ["../../shared/*"]
338                    }
339                }
340            }),
341            &control,
342        )?;
343        if mappings != 2 {
344            return Err(std::io::Error::other("expected both configured path mappings").into());
345        }
346        let _configured = projectatlas_symbols::ConfiguredModuleResolution::new(vec![config])?;
347        Ok(())
348    }
349
350    #[test]
351    fn compiler_config_loader_accepts_utf8_bom_for_both_config_kinds() -> Result<(), Box<dyn Error>>
352    {
353        let temp = tempfile::tempdir()?;
354        let content = br#"{"compilerOptions":{"baseUrl":"src"}}"#;
355        let control = IndexWorkControl::new(IndexCancellation::new(), None);
356
357        for config_path in ["tsconfig.json", "jsconfig.json"] {
358            fs::write(temp.path().join(config_path), content)?;
359            let plain = load_configured_module_resolution(
360                temp.path(),
361                &[config_node(config_path, content)],
362                &control,
363            )?;
364
365            let bom_content = [UTF8_BOM, content].concat();
366            fs::write(temp.path().join(config_path), &bom_content)?;
367            let with_bom = load_configured_module_resolution(
368                temp.path(),
369                &[config_node(config_path, &bom_content)],
370                &control,
371            )?;
372            if with_bom != plain {
373                return Err(std::io::Error::other(format!(
374                    "{config_path} decoded differently with a UTF-8 BOM"
375                ))
376                .into());
377            }
378        }
379        Ok(())
380    }
381
382    #[test]
383    fn compiler_config_loader_rejects_malformed_and_non_utf8_after_bom()
384    -> Result<(), Box<dyn Error>> {
385        let temp = tempfile::tempdir()?;
386        let control = IndexWorkControl::new(IndexCancellation::new(), None);
387
388        for config_path in ["tsconfig.json", "jsconfig.json"] {
389            for (case, malformed) in [
390                ("after a leading BOM", [UTF8_BOM, b"{"].concat()),
391                (
392                    "with a complete BOM after byte zero",
393                    [b"{".as_slice(), UTF8_BOM, b"}".as_slice()].concat(),
394                ),
395            ] {
396                fs::write(temp.path().join(config_path), &malformed)?;
397                match load_configured_module_resolution(
398                    temp.path(),
399                    &[config_node(config_path, &malformed)],
400                    &control,
401                ) {
402                    Err(CliError::InvalidInput(message))
403                        if message.contains("failed to parse compiler configuration") => {}
404                    result => {
405                        return Err(std::io::Error::other(format!(
406                            "{config_path} malformed input {case} returned {result:?}"
407                        ))
408                        .into());
409                    }
410                }
411            }
412
413            for (case, non_utf8) in [
414                ("after a BOM", [UTF8_BOM, &[0xFF]].concat()),
415                ("with a partial BOM", b"\xEF\xBB{}".to_vec()),
416            ] {
417                fs::write(temp.path().join(config_path), &non_utf8)?;
418                match load_configured_module_resolution(
419                    temp.path(),
420                    &[config_node(config_path, &non_utf8)],
421                    &control,
422                ) {
423                    Err(CliError::InvalidInput(message))
424                        if message.contains("is not valid UTF-8") => {}
425                    result => {
426                        return Err(std::io::Error::other(format!(
427                            "{config_path} non-UTF-8 input {case} returned {result:?}"
428                        ))
429                        .into());
430                    }
431                }
432            }
433        }
434        Ok(())
435    }
436
437    #[test]
438    fn lexical_target_normalization_rejects_root_escape() {
439        let contained = normalize_repository_target("packages/app", "../shared/*", true);
440        assert!(contained.is_ok());
441        assert_eq!(contained.ok().as_deref(), Some("packages/shared/*"));
442        assert_eq!(
443            normalize_repository_target("src", "shared folder/*", true)
444                .ok()
445                .as_deref(),
446            Some("src/shared folder/*")
447        );
448        assert!(normalize_repository_target("", "../outside/*", true).is_err());
449        assert!(normalize_repository_target("src", "C:/outside/*", true).is_err());
450    }
451
452    #[test]
453    fn config_decode_observes_typed_cancellation_before_mapping_work() {
454        let control = IndexWorkControl::new(IndexCancellation::new(), None);
455        control.cancel();
456        let result = decode_config(
457            "tsconfig.json",
458            EcmaScriptConfigKind::TypeScript,
459            &json!({
460                "compilerOptions": {
461                    "paths": {
462                        "@/*": ["src/*"]
463                    }
464                }
465            }),
466            &control,
467        );
468        assert!(matches!(
469            result,
470            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
471                stage: IndexWorkStage::SymbolParsing
472            }))
473        ));
474    }
475
476    #[test]
477    #[allow(clippy::panic_in_result_fn)]
478    fn configured_module_loader_enforces_deadline_currentness_and_bounds()
479    -> Result<(), Box<dyn Error>> {
480        let temp = tempfile::tempdir()?;
481        let content = br#"{"compilerOptions":{"paths":{"@/*":["src/*"]}}}"#;
482        fs::write(temp.path().join("tsconfig.json"), content)?;
483        let node = config_node("tsconfig.json", content);
484
485        let expired = IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
486        assert!(matches!(
487            load_configured_module_resolution(temp.path(), std::slice::from_ref(&node), &expired),
488            Err(CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
489                stage: IndexWorkStage::SymbolParsing
490            }))
491        ));
492
493        let mut changed = node.clone();
494        changed.content_hash = Some(blake3::hash(b"other").to_hex().to_string());
495        assert!(
496            load_configured_module_resolution(
497                temp.path(),
498                &[changed],
499                &IndexWorkControl::new(IndexCancellation::new(), None),
500            )
501            .is_err()
502        );
503
504        let mut oversized = node;
505        oversized.size_bytes = Some(MAX_MODULE_CONFIG_FILE_BYTES + 1);
506        assert!(matches!(
507            load_configured_module_resolution(
508                temp.path(),
509                &[oversized],
510                &IndexWorkControl::new(IndexCancellation::new(), None),
511            ),
512            Err(CliError::IndexWork(
513                IndexWorkFailure::ResourceLimitExceeded {
514                    resource: IndexWorkResource::SourceBytes,
515                    ..
516                }
517            ))
518        ));
519
520        let excessive_configs = (0..=MAX_CONFIGURED_MODULE_CONFIGS)
521            .map(|index| config_node(&format!("config-{index}/tsconfig.json"), content))
522            .collect::<Vec<_>>();
523        assert!(matches!(
524            load_configured_module_resolution(
525                temp.path(),
526                &excessive_configs,
527                &IndexWorkControl::new(IndexCancellation::new(), None),
528            ),
529            Err(CliError::IndexWork(
530                IndexWorkFailure::ResourceLimitExceeded {
531                    resource: IndexWorkResource::Entries,
532                    ..
533                }
534            ))
535        ));
536
537        let mut total = MAX_MODULE_CONFIG_TOTAL_BYTES;
538        assert!(matches!(
539            claim_module_config_total(
540                &mut total,
541                1,
542                MAX_MODULE_CONFIG_TOTAL_BYTES,
543                IndexWorkResource::SourceBytes,
544            ),
545            Err(CliError::IndexWork(
546                IndexWorkFailure::ResourceLimitExceeded {
547                    resource: IndexWorkResource::SourceBytes,
548                    ..
549                }
550            ))
551        ));
552        let mut mappings = u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX);
553        assert!(matches!(
554            claim_module_config_total(
555                &mut mappings,
556                1,
557                u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX),
558                IndexWorkResource::RelationRows,
559            ),
560            Err(CliError::IndexWork(
561                IndexWorkFailure::ResourceLimitExceeded {
562                    resource: IndexWorkResource::RelationRows,
563                    ..
564                }
565            ))
566        ));
567
568        let excessive_targets = (0..=MAX_CONFIGURED_MODULE_TARGETS)
569            .map(|index| format!("src/{index}/*"))
570            .collect::<Vec<_>>();
571        assert!(matches!(
572            decode_config(
573                "tsconfig.json",
574                EcmaScriptConfigKind::TypeScript,
575                &json!({"compilerOptions":{"paths":{"@/*": excessive_targets}}}),
576                &IndexWorkControl::new(IndexCancellation::new(), None),
577            ),
578            Err(CliError::IndexWork(
579                IndexWorkFailure::ResourceLimitExceeded {
580                    resource: IndexWorkResource::RelationRows,
581                    ..
582                }
583            ))
584        ));
585
586        Ok(())
587    }
588}