Skip to main content

semantic/
index_assembly.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Object-source semantic index assembly, independent of repository capture.
3
4use crate::{
5    parser::Language,
6    semantic_index::{
7        extract_semantic_file, grammar_version, grammar_version_by_name, language_name,
8    },
9};
10use objects::{
11    error::HeddleError,
12    object::{
13        ContentHash, SemanticEntryKind, SemanticFileFacts, SemanticFileNode, SemanticIndexRoot,
14        SemanticTreeEntry, SemanticTreeNode, Tree, TreeEntryTarget,
15    },
16    store::{ObjectSource, ObjectStore},
17};
18use std::collections::{BTreeMap, HashMap};
19
20const MAX_SEMANTIC_TREE_DEPTH: usize = 1024;
21
22/// Source files above this size are recorded as `Opaque` rather than parsed —
23/// generated/vendored blobs dominate parse cost and rarely carry review-worthy
24/// symbols.
25const SEMANTIC_FILE_BUDGET_BYTES: usize = 1 << 20;
26
27type PendingSemanticBlobs = Vec<(ContentHash, Vec<u8>)>;
28pub type DeferredSemanticRoot = (SemanticIndexRoot, ContentHash, PendingSemanticBlobs);
29
30/// Failures while assembling semantic nodes. Callers can map these to their
31/// own transport status without inspecting error text.
32#[derive(Debug, thiserror::Error)]
33pub enum SemanticAssemblyError {
34    #[error("semantic analysis cancelled")]
35    Cancelled,
36    #[error("semantic analysis deadline exceeded")]
37    Deadline,
38    #[error("semantic analysis source work budget exceeded")]
39    SourceLimit,
40    #[error("semantic analysis output budget exceeded")]
41    OutputLimit,
42    #[error("malformed semantic object: {0}")]
43    Malformed(String),
44    #[error(transparent)]
45    Storage(#[from] HeddleError),
46}
47
48pub type SemanticAssemblyResult<T> = std::result::Result<T, SemanticAssemblyError>;
49
50impl From<SemanticAssemblyError> for HeddleError {
51    fn from(error: SemanticAssemblyError) -> Self {
52        match error {
53            SemanticAssemblyError::Storage(error) => error,
54            other => HeddleError::InvalidObject(other.to_string()),
55        }
56    }
57}
58
59/// What a built subtree resolved to: the storage hash of the node blob (or the
60/// raw source blob, for opaque entries) plus its reformat-stable digest.
61#[derive(Clone, Copy)]
62struct BuiltEntry {
63    kind: SemanticEntryKind,
64    node: ContentHash,
65    semantic_digest: ContentHash,
66}
67
68/// Builds a semantic index over a source tree, reusing a parent index where the
69/// source is unchanged and memoizing per source-blob so each unique blob is
70/// parsed at most once. `parse_count` is exposed for tests that assert the
71/// prune-without-reparse invariant.
72///
73/// Work and output allowances apply to the lifetime of this builder, including
74/// repeated root builds. Flushing pending bytes does not reset accounting or
75/// memoized allocations; create a fresh builder for an independent analysis.
76pub struct SemanticIndexBuilder<'store, S: ObjectSource> {
77    store: &'store S,
78    budget: Option<crate::parser::ParseBudget>,
79    work_entries: usize,
80    work_bytes: usize,
81    output_limit: Option<usize>,
82    pending_bytes: usize,
83    source_blobs: Option<&'store HashMap<ContentHash, &'store [u8]>>,
84    source_trees: Option<&'store HashMap<ContentHash, &'store Tree>>,
85    extractor_version: u32,
86    /// Per-build memo keyed by `(source blob hash, language)` — a blob that
87    /// appears at several paths is parsed once, but byte-identical blobs at
88    /// `a.js` and `b.py` get distinct nodes (a node is a pure function of
89    /// `(bytes, ext, grammar, extractor)`).
90    file_memo: HashMap<(ContentHash, Language), BuiltEntry>,
91    /// Languages encountered while building, seeded from the parent root so
92    /// pruned subtrees' grammars are not lost.
93    grammars: BTreeMap<String, String>,
94    /// Node blobs written this build, flushed as one pack at the end so a
95    /// snapshot never regresses to N loose per-node fsyncs.
96    pending: Vec<(ContentHash, Vec<u8>)>,
97    /// Number of source blobs actually parsed this build.
98    pub parse_count: usize,
99}
100
101impl<'store, S: ObjectSource> SemanticIndexBuilder<'store, S> {
102    pub fn new(store: &'store S, extractor_version: u32) -> Self {
103        Self {
104            store,
105            budget: None,
106            work_entries: 0,
107            work_bytes: 0,
108            output_limit: None,
109            pending_bytes: 0,
110            source_blobs: None,
111            source_trees: None,
112            extractor_version,
113            file_memo: HashMap::new(),
114            grammars: BTreeMap::new(),
115            pending: Vec::new(),
116            parse_count: 0,
117        }
118    }
119
120    /// Bound RPC analysis independently of historical capture/backfill work.
121    pub fn with_budget(mut self, budget: crate::parser::ParseBudget) -> Self {
122        self.budget = Some(budget);
123        self.output_limit.get_or_insert(15 * 1024 * 1024);
124        self
125    }
126    /// Bound lifetime canonical output independently of input size. The encoder
127    /// checks the remaining allowance before growing each node's buffer;
128    /// flushing a completed root does not replenish this allowance.
129    pub fn with_output_limit(mut self, bytes: usize) -> Self {
130        self.output_limit = Some(bytes);
131        self
132    }
133
134    fn check_work(&self) -> SemanticAssemblyResult<()> {
135        if let Some(budget) = &self.budget {
136            if budget.cancelled.load(std::sync::atomic::Ordering::Acquire) {
137                return Err(SemanticAssemblyError::Cancelled);
138            }
139            if std::time::Instant::now() >= budget.deadline {
140                return Err(SemanticAssemblyError::Deadline);
141            }
142        }
143        if self.budget.is_some() && (self.work_entries > 4096 || self.work_bytes > 32 * 1024 * 1024)
144        {
145            return Err(SemanticAssemblyError::SourceLimit);
146        }
147        Ok(())
148    }
149
150    fn interruption_error(&self) -> SemanticAssemblyError {
151        if self
152            .budget
153            .as_ref()
154            .is_some_and(|budget| budget.cancelled.load(std::sync::atomic::Ordering::Acquire))
155        {
156            SemanticAssemblyError::Cancelled
157        } else {
158            SemanticAssemblyError::Deadline
159        }
160    }
161
162    pub fn with_source_objects(
163        store: &'store S,
164        extractor_version: u32,
165        source_blobs: &'store HashMap<ContentHash, &'store [u8]>,
166        source_trees: &'store HashMap<ContentHash, &'store Tree>,
167    ) -> Self {
168        Self {
169            source_blobs: Some(source_blobs),
170            source_trees: Some(source_trees),
171            ..Self::new(store, extractor_version)
172        }
173    }
174
175    /// Build the semantic closure without crossing a durability barrier.
176    /// Worktree snapshots fold these blobs into their authoritative commit
177    /// pack; other callers use [`Self::build_root`] for immediate persistence.
178    pub fn build_root_deferred(
179        &mut self,
180        tree: &Tree,
181        parent: Option<&ParentIndex>,
182    ) -> SemanticAssemblyResult<DeferredSemanticRoot> {
183        // Refuse node reuse across an extractor or grammar bump: reusing stale
184        // nodes would mix v1+v2 fingerprints in one index. A non-current parent
185        // is dropped entirely, forcing a clean full rebuild.
186        let parent = parent.filter(|p| self.parent_is_reusable(p));
187        if let Some(parent) = parent {
188            self.grammars = parent.root.grammars.clone();
189        }
190        let parent_ctx = parent.map(|p| (&p.source_tree, &p.semantic_tree));
191        let (node_hash, digest) = self.build_tree(tree, parent_ctx, 0)?;
192        let root = SemanticIndexRoot::new(
193            self.extractor_version,
194            std::mem::take(&mut self.grammars),
195            node_hash,
196            digest,
197        );
198        let root_hash = self.put_node(&root)?;
199        Ok((root, root_hash, std::mem::take(&mut self.pending)))
200    }
201
202    /// Whether a parent index may be reused: its extractor version and every
203    /// grammar version must match the builder's current ones.
204    fn parent_is_reusable(&self, parent: &ParentIndex) -> bool {
205        parent.root.extractor_version == self.extractor_version
206            && parent
207                .root
208                .grammars
209                .iter()
210                .all(|(name, version)| grammar_version_by_name(name) == Some(version.as_str()))
211    }
212
213    fn build_tree(
214        &mut self,
215        tree: &Tree,
216        parent: Option<(&Tree, &SemanticTreeNode)>,
217        depth: usize,
218    ) -> SemanticAssemblyResult<(ContentHash, ContentHash)> {
219        if depth > MAX_SEMANTIC_TREE_DEPTH {
220            return Err(SemanticAssemblyError::Malformed(format!(
221                "semantic index tree exceeds max depth {MAX_SEMANTIC_TREE_DEPTH}"
222            )));
223        }
224        self.work_entries = self.work_entries.saturating_add(tree.len());
225        self.check_work()?;
226        let mut entries = Vec::with_capacity(tree.len());
227        for entry in tree.entries() {
228            self.check_work()?;
229            let name = entry.name();
230            let built = match entry.target() {
231                TreeEntryTarget::Tree { hash } => self.build_dir(name, *hash, parent, depth)?,
232                TreeEntryTarget::Blob { hash, .. } => self.build_file(name, *hash, parent)?,
233                TreeEntryTarget::Symlink { hash } => BuiltEntry {
234                    kind: SemanticEntryKind::Opaque,
235                    node: *hash,
236                    semantic_digest: *hash,
237                },
238                // Git submodule / native child-spool edges have no source blob
239                // in this store; fingerprint them by their stable target bytes.
240                TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => {
241                    let digest = opaque_edge_digest(entry.target());
242                    BuiltEntry {
243                        kind: SemanticEntryKind::Opaque,
244                        node: digest,
245                        semantic_digest: digest,
246                    }
247                }
248            };
249            entries.push(SemanticTreeEntry {
250                name: name.to_string(),
251                kind: built.kind,
252                node: built.node,
253                semantic_digest: built.semantic_digest,
254            });
255        }
256        let (node, digest) = SemanticTreeNode::new(entries);
257        let node_hash = self.put_node(&node)?;
258        Ok((node_hash, digest))
259    }
260
261    fn build_dir(
262        &mut self,
263        name: &str,
264        source_hash: ContentHash,
265        parent: Option<(&Tree, &SemanticTreeNode)>,
266        depth: usize,
267    ) -> SemanticAssemblyResult<BuiltEntry> {
268        // Unchanged-subtree prune: same-named source dir with the same hash and
269        // a matching parent semantic entry ⇒ reuse wholesale, no recurse, no
270        // parse.
271        if let Some((parent_source, parent_sem)) = parent
272            && let Some(parent_entry) = parent_source.get(name)
273            && parent_entry.tree_hash() == Some(source_hash)
274            && let Some(sem_entry) = parent_sem.get(name)
275            && sem_entry.kind == SemanticEntryKind::Dir
276        {
277            return Ok(BuiltEntry {
278                kind: SemanticEntryKind::Dir,
279                node: sem_entry.node,
280                semantic_digest: sem_entry.semantic_digest,
281            });
282        }
283
284        let source_tree = match self
285            .source_trees
286            .and_then(|trees| trees.get(&source_hash).copied())
287        {
288            Some(tree) => tree.clone(),
289            None => self
290                .store
291                .get_tree(&source_hash)?
292                .ok_or_else(|| HeddleError::NotFound(format!("tree {source_hash}")))?,
293        };
294
295        // Descend with the matching parent subtree as the reuse basis, if any.
296        let child_parent = self.child_parent_ctx(name, parent)?;
297        let child_parent_ref = child_parent.as_ref().map(|(t, n)| (t, n));
298        let (node, digest) = self.build_tree(&source_tree, child_parent_ref, depth + 1)?;
299        Ok(BuiltEntry {
300            kind: SemanticEntryKind::Dir,
301            node,
302            semantic_digest: digest,
303        })
304    }
305
306    /// Load the parent source subtree + parent semantic subtree for `name`, to
307    /// serve as the reuse basis when recursing into a changed directory.
308    fn child_parent_ctx(
309        &self,
310        name: &str,
311        parent: Option<(&Tree, &SemanticTreeNode)>,
312    ) -> SemanticAssemblyResult<Option<(Tree, SemanticTreeNode)>> {
313        let Some((parent_source, parent_sem)) = parent else {
314            return Ok(None);
315        };
316        let Some(source_entry) = parent_source.get(name) else {
317            return Ok(None);
318        };
319        let Some(source_hash) = source_entry.tree_hash() else {
320            return Ok(None);
321        };
322        let Some(sem_entry) = parent_sem.get(name) else {
323            return Ok(None);
324        };
325        if sem_entry.kind != SemanticEntryKind::Dir {
326            return Ok(None);
327        }
328        let Some(source_tree) = self.store.get_tree(&source_hash)? else {
329            return Ok(None);
330        };
331        let Some(blob) = self.store.get_blob(&sem_entry.node)? else {
332            return Ok(None);
333        };
334        match SemanticTreeNode::decode(blob.content()) {
335            Ok(sem_tree) => Ok(Some((source_tree, sem_tree))),
336            Err(_) => Ok(None),
337        }
338    }
339
340    fn build_file(
341        &mut self,
342        name: &str,
343        source_hash: ContentHash,
344        parent: Option<(&Tree, &SemanticTreeNode)>,
345    ) -> SemanticAssemblyResult<BuiltEntry> {
346        // A file node is a pure function of (bytes, ext/language, grammar,
347        // extractor), so memoize per `(source_hash, language)` — NOT bytes
348        // alone (byte-identical `a.js`/`b.py` must not share a node).
349        let language = Language::from_path(std::path::Path::new(name));
350        let memo_key = (source_hash, language);
351        if let Some(built) = self.file_memo.get(&memo_key) {
352            return Ok(*built);
353        }
354
355        // Unchanged-file reuse: same-named source blob with the same hash and a
356        // matching parent semantic entry ⇒ reuse, no parse.
357        if let Some((parent_source, parent_sem)) = parent
358            && let Some(parent_entry) = parent_source.get(name)
359            && parent_entry.blob_hash() == Some(source_hash)
360            && let Some(sem_entry) = parent_sem.get(name)
361        {
362            let built = BuiltEntry {
363                kind: sem_entry.kind,
364                node: sem_entry.node,
365                semantic_digest: sem_entry.semantic_digest,
366            };
367            self.file_memo.insert(memo_key, built);
368            return Ok(built);
369        }
370
371        let built = self.parse_file(language, source_hash)?;
372        self.file_memo.insert(memo_key, built);
373        Ok(built)
374    }
375
376    fn parse_file(
377        &mut self,
378        language: Language,
379        source_hash: ContentHash,
380    ) -> SemanticAssemblyResult<BuiltEntry> {
381        let opaque = BuiltEntry {
382            kind: SemanticEntryKind::Opaque,
383            node: source_hash,
384            semantic_digest: source_hash,
385        };
386
387        if language.parser_handle().is_none() {
388            return Ok(opaque);
389        }
390        let blob = match self
391            .source_blobs
392            .and_then(|blobs| blobs.get(&source_hash).copied())
393        {
394            Some(bytes) => Some(objects::object::Blob::from(bytes.to_vec())),
395            None => self.store.get_blob(&source_hash)?,
396        };
397        let Some(blob) = blob else {
398            return Ok(opaque);
399        };
400        self.work_bytes = self.work_bytes.saturating_add(blob.size());
401        self.check_work()?;
402        if blob.size() > SEMANTIC_FILE_BUDGET_BYTES {
403            return Ok(opaque);
404        }
405        let extracted = match &self.budget {
406            Some(budget) => crate::semantic_index::extract_semantic_file_bounded(
407                blob.content(),
408                language,
409                budget,
410            )
411            .map_err(|error| match error {
412                crate::semantic_index::ExtractionBudgetError::Interrupted => {
413                    self.interruption_error()
414                }
415                crate::semantic_index::ExtractionBudgetError::Exceeded(_) => {
416                    SemanticAssemblyError::SourceLimit
417                }
418            })?,
419            None => extract_semantic_file(blob.content(), language),
420        };
421        self.check_work()?;
422        let Some(extracted) = extracted else {
423            // Unsupported/parse-fail → opaque.
424            return Ok(opaque);
425        };
426        self.parse_count += 1;
427
428        let lang = language_name(extracted.language).to_string();
429        let gv = grammar_version(extracted.language).to_string();
430        // Freshly-parsed grammar version wins in the root metadata (overwrite,
431        // not or_insert) so a stale seed from a current parent can't linger.
432        self.grammars.insert(lang.clone(), gv.clone());
433
434        let node = SemanticFileNode::new(
435            lang,
436            gv,
437            self.extractor_version,
438            source_hash,
439            extracted.scaffold_hash,
440            SemanticFileFacts {
441                symbols: extracted.symbols,
442                scopes: extracted.scopes,
443                imports: extracted.imports,
444                occurrences: extracted.occurrences,
445            },
446        );
447        let digest = node.semantic_digest;
448        let node_hash = self.put_node(&node)?;
449        Ok(BuiltEntry {
450            kind: SemanticEntryKind::File,
451            node: node_hash,
452            semantic_digest: digest,
453        })
454    }
455
456    /// Queue an encoded node blob for the end-of-build pack flush, returning its
457    /// content hash (identical to what `put_blob` would assign).
458    fn put_node(&mut self, node: &impl serde::Serialize) -> SemanticAssemblyResult<ContentHash> {
459        self.check_work()?;
460        let remaining = self
461            .output_limit
462            .unwrap_or(usize::MAX)
463            .saturating_sub(self.pending_bytes);
464        let mut writer = SemanticNodeWriter {
465            bytes: Vec::new(),
466            remaining,
467            exceeded: false,
468        };
469        let encoded =
470            node.serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map());
471        if writer.exceeded {
472            return Err(SemanticAssemblyError::OutputLimit);
473        }
474        encoded.map_err(|error| SemanticAssemblyError::Malformed(error.to_string()))?;
475        let bytes = writer.bytes;
476        let hash = ContentHash::compute_typed("blob", &bytes);
477        self.pending_bytes = self.pending_bytes.saturating_add(bytes.len());
478        self.pending.push((hash, bytes));
479        Ok(hash)
480    }
481}
482
483impl<S: ObjectStore + ObjectSource> SemanticIndexBuilder<'_, S> {
484    /// Persist an assembled root and its generated node blobs.
485    pub fn build_root(
486        &mut self,
487        tree: &Tree,
488        parent: Option<&ParentIndex>,
489    ) -> SemanticAssemblyResult<(SemanticIndexRoot, ContentHash)> {
490        let (root, root_hash, pending) = self.build_root_deferred(tree, parent)?;
491        self.check_work()?;
492        self.store.put_blobs_packed(pending)?;
493        Ok((root, root_hash))
494    }
495}
496
497/// A canonical node is encoded directly into its remaining output allowance.
498/// Reject before extending the byte buffer, not after materializing a node.
499struct SemanticNodeWriter {
500    bytes: Vec<u8>,
501    remaining: usize,
502    exceeded: bool,
503}
504impl std::io::Write for SemanticNodeWriter {
505    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
506        if bytes.len() > self.remaining {
507            self.exceeded = true;
508            return Err(std::io::Error::other(
509                "semantic analysis output budget exceeded",
510            ));
511        }
512        self.bytes.extend_from_slice(bytes);
513        self.remaining = self.remaining.saturating_sub(bytes.len());
514        Ok(bytes.len())
515    }
516    fn flush(&mut self) -> std::io::Result<()> {
517        Ok(())
518    }
519}
520
521/// Digest for a git submodule / spool edge — hashed over its stable target
522/// bytes so a submodule pointer bump perturbs the digest chain.
523fn opaque_edge_digest(target: &TreeEntryTarget) -> ContentHash {
524    match target {
525        TreeEntryTarget::Gitlink { target } => {
526            ContentHash::compute_typed("hd-sem-opaque-gitlink", target.as_bytes())
527        }
528        TreeEntryTarget::Spoollink { spool_id, state_id } => {
529            let mut buf = Vec::new();
530            buf.extend_from_slice(spool_id.as_str().as_bytes());
531            buf.push(0);
532            buf.extend_from_slice(state_id.as_bytes());
533            ContentHash::compute_typed("hd-sem-opaque-spoollink", &buf)
534        }
535        // Only edge targets reach here.
536        _ => ContentHash::compute_typed("hd-sem-opaque", &[]),
537    }
538}
539
540/// A parent state's index, materialized for reuse during an incremental build.
541pub struct ParentIndex {
542    pub source_tree: Tree,
543    pub semantic_tree: SemanticTreeNode,
544    pub root: SemanticIndexRoot,
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::parser::ParseBudget;
551    use crate::semantic_index::EXTRACTOR_VERSION;
552    use objects::object::{Blob, SemanticEntryKind, SemanticTreeEntry, State, StateId, TreeEntry};
553
554    struct ReadOnly<'a>(&'a objects::store::InMemoryStore);
555    impl ObjectSource for ReadOnly<'_> {
556        fn get_tree(&self, hash: &ContentHash) -> objects::store::Result<Option<Tree>> {
557            ObjectSource::get_tree(self.0, hash)
558        }
559        fn get_state(&self, id: &StateId) -> objects::store::Result<Option<State>> {
560            ObjectSource::get_state(self.0, id)
561        }
562        fn get_blob(&self, hash: &ContentHash) -> objects::store::Result<Option<Blob>> {
563            ObjectSource::get_blob(self.0, hash)
564        }
565    }
566
567    struct FailingSource;
568    impl ObjectSource for FailingSource {
569        fn get_tree(&self, _: &ContentHash) -> objects::store::Result<Option<Tree>> {
570            Ok(None)
571        }
572        fn get_state(&self, _: &StateId) -> objects::store::Result<Option<State>> {
573            Ok(None)
574        }
575        fn get_blob(&self, _: &ContentHash) -> objects::store::Result<Option<Blob>> {
576            Err(HeddleError::InvalidObject("source read failed".into()))
577        }
578    }
579
580    #[test]
581    fn read_only_deferred_assembly_matches_persisted_output() {
582        let store = objects::store::InMemoryStore::new();
583        let source_hash = ObjectStore::put_blob(&store, &Blob::from_slice(b"fn main() {}\n"))
584            .expect("source blob");
585        let tree = Tree::from_entries(vec![
586            TreeEntry::file("main.rs", source_hash, false).expect("entry"),
587        ]);
588        let read_only = ReadOnly(&store);
589        let (deferred_root, deferred_hash, blobs) =
590            SemanticIndexBuilder::new(&read_only, EXTRACTOR_VERSION)
591                .build_root_deferred(&tree, None)
592                .expect("read-only assembly");
593        assert!(
594            ObjectStore::get_blob(&store, &deferred_hash)
595                .expect("lookup")
596                .is_none()
597        );
598        let (persisted_root, persisted_hash) = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION)
599            .build_root(&tree, None)
600            .expect("persisted assembly");
601        assert_eq!(deferred_root, persisted_root);
602        assert_eq!(deferred_hash, persisted_hash);
603        for (hash, bytes) in blobs {
604            assert_eq!(
605                ObjectStore::get_blob(&store, &hash)
606                    .expect("lookup")
607                    .expect("persisted blob")
608                    .content(),
609                bytes
610            );
611        }
612        let semantic_tree = SemanticTreeNode::decode(
613            ObjectStore::get_blob(&store, &persisted_root.tree)
614                .expect("lookup")
615                .expect("semantic tree")
616                .content(),
617        )
618        .expect("decode semantic tree");
619        let parent = ParentIndex {
620            source_tree: tree.clone(),
621            semantic_tree,
622            root: persisted_root,
623        };
624        let mut reused = SemanticIndexBuilder::new(&read_only, EXTRACTOR_VERSION);
625        let (reused_root, reused_hash, _) = reused
626            .build_root_deferred(&tree, Some(&parent))
627            .expect("reuse parent");
628        assert_eq!(reused_hash, persisted_hash);
629        assert_eq!(reused_root, parent.root);
630        assert_eq!(reused.parse_count, 0);
631    }
632
633    #[test]
634    fn semantic_failures_keep_cancellation_deadline_and_limits_distinct() {
635        use std::sync::{Arc, atomic::AtomicBool};
636        let store = objects::store::InMemoryStore::new();
637        let cancelled = ParseBudget {
638            cancelled: Arc::new(AtomicBool::new(true)),
639            deadline: std::time::Instant::now() + std::time::Duration::from_secs(30),
640        };
641        let mut builder =
642            SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(cancelled);
643        assert!(matches!(
644            builder.build_root_deferred(&Tree::new(), None),
645            Err(SemanticAssemblyError::Cancelled)
646        ));
647
648        let deadline = ParseBudget {
649            cancelled: Arc::new(AtomicBool::new(false)),
650            deadline: std::time::Instant::now(),
651        };
652        let mut builder =
653            SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(deadline);
654        assert!(matches!(
655            builder.build_root_deferred(&Tree::new(), None),
656            Err(SemanticAssemblyError::Deadline)
657        ));
658
659        let budget = ParseBudget {
660            cancelled: Arc::new(AtomicBool::new(false)),
661            deadline: std::time::Instant::now() + std::time::Duration::from_secs(30),
662        };
663        let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(budget);
664        builder.work_entries = 4097;
665        assert!(matches!(
666            builder.build_root_deferred(&Tree::new(), None),
667            Err(SemanticAssemblyError::SourceLimit)
668        ));
669
670        let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_output_limit(0);
671        assert!(matches!(
672            builder.build_root_deferred(&Tree::new(), None),
673            Err(SemanticAssemblyError::OutputLimit)
674        ));
675
676        let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION);
677        assert!(matches!(
678            builder.build_tree(&Tree::new(), None, MAX_SEMANTIC_TREE_DEPTH + 1),
679            Err(SemanticAssemblyError::Malformed(_))
680        ));
681
682        let tree = Tree::from_entries(vec![
683            TreeEntry::file("main.rs", ContentHash::from_bytes([7; 32]), false).expect("entry"),
684        ]);
685        let mut builder = SemanticIndexBuilder::new(&FailingSource, EXTRACTOR_VERSION);
686        assert!(matches!(
687            builder.build_root_deferred(&tree, None),
688            Err(SemanticAssemblyError::Storage(_))
689        ));
690    }
691    #[test]
692    fn bounded_analysis_encodes_canonical_nodes_with_cumulative_output_limit() {
693        let store = objects::store::InMemoryStore::new();
694        let (node, _) = SemanticTreeNode::new(vec![SemanticTreeEntry {
695            name: "a.rs".into(),
696            kind: SemanticEntryKind::Opaque,
697            node: ContentHash::from_bytes([23; 32]),
698            semantic_digest: ContentHash::from_bytes([23; 32]),
699        }]);
700        let canonical = node.encode().expect("canonical bytes");
701        let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION)
702            .with_output_limit(canonical.len() * 2 - 1);
703        let hash = builder.put_node(&node).expect("first node fits");
704        assert_eq!(hash, ContentHash::compute_typed("blob", &canonical));
705        assert_eq!(
706            builder.pending[0].1, canonical,
707            "encoder retains exact canonical representation"
708        );
709        let error = builder
710            .put_node(&node)
711            .expect_err("second node exceeds total output allowance");
712        assert!(
713            error.to_string().contains("output budget exceeded"),
714            "{error}"
715        );
716        assert_eq!(builder.pending.len(), 1, "failed node is never queued");
717        assert_eq!(builder.pending_bytes, canonical.len());
718        assert!(
719            ObjectStore::get_blob(&store, &hash)
720                .expect("lookup")
721                .is_none(),
722            "no node is published during bounded assembly"
723        );
724    }
725
726    #[test]
727    fn bounded_analysis_writer_rejects_before_buffer_growth() {
728        use std::io::Write;
729        let mut writer = SemanticNodeWriter {
730            bytes: Vec::new(),
731            remaining: 7,
732            exceeded: false,
733        };
734        writer.write_all(b"small").expect("within allowance");
735        let capacity = writer.bytes.capacity();
736        let expanded = vec![0; 1 << 20];
737        let error = writer
738            .write_all(&expanded)
739            .expect_err("reject before growing buffer");
740        assert!(error.to_string().contains("output budget exceeded"));
741        assert_eq!(writer.bytes, b"small");
742        assert_eq!(writer.bytes.capacity(), capacity);
743    }
744}