--- a/.local/text-navigation-probe-20260911/source_symbols.rs +++ b/.local/text-navigation-probe-20260911/source_symbols.rs @@ -7,9 +7,9 @@ //! Its matches are authored lexical uses, not typed semantic dependency edges. use oa_core::language::{Span, TokenClass, TokenSpan}; use oa_core::Id; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::Range; /// Fresh editor/workspace session identity plus a revision changed for EVERY /// indexed input change, including membership, outline and validation evidence. @@ -357,16 +357,19 @@ || files.iter().any(|file| file.membership == Membership::Unverified)) { return Err(input_error(0, None, InputProblem::InconsistentCompleteScope)); } + // Scratch storage is allocated only after aggregate file/path admission. + // Each admitted path is folded once, retaining core's Unicode policy. + let mut folded_paths = BTreeSet::new(); for (file_index, file) in files.iter().copied().enumerate() { if file.syntax != SyntaxCoverage::FullFile { return Err(input_error(file_index, None, InputProblem::IncompleteSyntax)); } if oa_core::source_project::resolve_include("", file.path).as_deref() != Ok(file.path) { return Err(input_error(file_index, None, InputProblem::NonCanonicalPath)); } - if files[..file_index].iter().any(|other| other.path.eq_ignore_ascii_case(file.path)) { + if !folded_paths.insert(file.path.to_lowercase()) { return Err(input_error(file_index, None, InputProblem::DuplicateOrCaseCollidingPath)); } let mut end = 0; for (token_index, token) in file.tokens.iter().enumerate() { @@ -417,8 +420,9 @@ } let literal = &file.text[identity.literal_span.start..identity.literal_span.end]; let decoded = serde_json::from_str::(literal) .ok() + .filter(|value| oa_core::language::has_uuid_header_shape(value)) .and_then(|value| Id::parse_str(&value).ok()); if decoded != Some(identity.id) { return Err(problem(InputProblem::IdentityLiteral)); } @@ -1005,8 +1009,84 @@ } } #[test] + fn unicode_path_collisions_match_core_lowercase_policy() { + for (first_path, second_path) in [("Ä.arcadia", "ä.arcadia"), ("ΟΣ/model.arcadia", "ος/model.arcadia")] { + let first = Fixture::new(first_path, "source @a;".into(), &[]); + let second = Fixture::new(second_path, "source @b;".into(), &[]); + assert_eq!(first_path.to_lowercase(), second_path.to_lowercase()); + assert!(!first_path.eq_ignore_ascii_case(second_path)); + assert!(matches!(Index::build(snapshot(1), &[first.included(), second.included()], Scope::CompleteProject, limits()), Err(BuildError::Input { problem: InputProblem::DuplicateOrCaseCollidingPath, .. }))); + } + } + + #[test] + fn noncanonical_header_identity_cannot_enter_through_query_metadata() { + for raw in [ + "00000000000000000000000000000001", + "{00000000-0000-0000-0000-000000000001}", + "urn:uuid:00000000-0000-0000-0000-000000000001", + ] { + let literal = format!("\"{raw}\""); + let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;"); + let mut fixture = Fixture::new("project.arcadia", source.clone(), &[("oa_actor", "a", None)]); + let start = source.find(&literal).unwrap(); + // This fixture has one explicit empty body. A brace inside the + // quoted UUID must not become the test's declaration end. + let body_start = source.find("{}\n").unwrap(); + fixture.declarations[0].span.end = body_start + 2; + assert!(start + literal.len() <= body_start); + // An adapter passes None from the corrected outline; lexical + // definition/reference navigation survives without an ID anchor. + { + let admitted = index(&[fixture.included()]); + let d = definition(&admitted, "a"); + assert_eq!(admitted.identity(d), Ok(Identity::Missing)); + assert_eq!(admitted.references_to(d, Domain::Included).unwrap().count(), 1); + assert_eq!(admitted.source(0).unwrap().text, source); + } + // A forged/stale caller cannot put the UUID crate's permissive + // interpretation back into an authored anchor via the raw API. + fixture.declarations[0].identity = Some(AuthoredIdentity { + id: A, + literal_span: Span { start, end: start + literal.len() }, + }); + assert!(matches!(Index::build(snapshot(1), &[fixture.included()], Scope::CompleteProject, limits()), Err(BuildError::Input { problem: InputProblem::IdentityLiteral, .. }))); + } + } + + #[test] + fn query_header_admission_preserves_uppercase_and_json_escape_bytes() { + let expected = Id::from_u128(0xabcdef); + let uppercase = expected.to_string().to_uppercase(); + for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] { + let literal = format!("\"{raw}\""); + let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;"); + let mut fixture = Fixture::new("project.arcadia", source.clone(), &[("oa_actor", "a", None)]); + let start = source.find(&literal).unwrap(); + fixture.declarations[0].identity = Some(AuthoredIdentity { + id: expected, + literal_span: Span { start, end: start + literal.len() }, + }); + let admitted = index(&[fixture.included()]); + assert_eq!(anchor(&admitted, definition(&admitted, "a")).id(), expected); + assert_eq!(admitted.source(0).unwrap().text.as_bytes(), source.as_bytes()); + } + } + + #[test] + fn admitted_unicode_path_budget_keeps_original_policy_after_single_fold() { + let fixture = Fixture::new("İ.arcadia", String::new(), &[]); + assert!(fixture.path.to_lowercase().len() > fixture.path.len()); + let exact = Limits { files: 1, source_bytes: 0, path_bytes: fixture.path.len(), tokens: 0, records: 0 }; + let admitted = Index::build(snapshot(1), &[fixture.included()], Scope::CompleteProject, exact).unwrap(); + assert_eq!(admitted.source(0).unwrap().path, "İ.arcadia"); + // PathBytes continues to count authored input. One lowercase key's + // Unicode expansion is bounded scratch, not a new path rejection rule. + } + + #[test] fn every_budget_admits_boundary_and_returns_no_index_one_below() { let fixture = Fixture::new("project.arcadia", "oa_actor a \"Text\" {}\nsource @a; target @a;".into(), &[("oa_actor", "a", None)]); let exact = Limits { files: 1, source_bytes: fixture.text.len(), path_bytes: fixture.path.len(), tokens: fixture.tokens.len(), records: 3 }; assert!(Index::build(snapshot(1), &[fixture.included()], Scope::CompleteProject, exact).is_ok());