1use std::time::Instant;
2
3use async_trait::async_trait;
4use code_system_graph_model::ArtifactFingerprint;
5use semver::Version;
6use serde::Serialize;
7
8use crate::{
9 BoundaryExtractor, ContentFingerprint, DiscoverContext, DiscoveredInput, ExtractInput, ExtractionBatch, ExtractionBudgets, ExtractionCompleteness, ExtractionReport, ExtractionTracker, ExtractorError, FileDescriptor, SourceEpistemicStatus, SourceObservation, SourceSyntaxLanguage, extract_generated_client_metadata, extract_package_manifest_with_tracker, inspect_source_syntax, parse_go_source_with_tracker, parse_java_source_with_tracker, parse_javascript_source_at_path_with_tracker, parse_python_source_with_tracker, parse_rust_source_with_tracker, parse_typescript_source_at_path_with_tracker
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FocusedSourceLanguage {
15 JavaScript,
17 TypeScript,
19 Rust,
21 Python,
23 Go,
25 Java,
27}
28
29pub struct FocusedSourceExtractor {
31 language: FocusedSourceLanguage,
32 budgets: ExtractionBudgets,
33}
34
35impl FocusedSourceExtractor {
36 #[must_use]
38 pub fn new(language: FocusedSourceLanguage) -> Self {
39 Self::with_budgets(language, ExtractionBudgets::default())
40 }
41
42 #[must_use]
44 pub fn with_budgets(language: FocusedSourceLanguage, budgets: ExtractionBudgets) -> Self {
45 Self { language, budgets }
46 }
47}
48
49#[async_trait]
50impl BoundaryExtractor for FocusedSourceExtractor {
51 fn id(&self) -> &'static str {
52 match self.language {
53 FocusedSourceLanguage::JavaScript => "code-system-graph.source.javascript",
54 FocusedSourceLanguage::TypeScript => "code-system-graph.source.typescript",
55 FocusedSourceLanguage::Rust => "code-system-graph.source.rust",
56 FocusedSourceLanguage::Python => "code-system-graph.source.python",
57 FocusedSourceLanguage::Go => "code-system-graph.source.go",
58 FocusedSourceLanguage::Java => "code-system-graph.source.java",
59 }
60 }
61
62 fn version(&self) -> Version {
63 Version::new(1, 0, 0)
64 }
65
66 fn supports(&self, file: &FileDescriptor) -> bool {
67 let extension = file
68 .path
69 .display
70 .rsplit_once('.')
71 .map(|(_, extension)| extension);
72 matches!(
73 (self.language, extension),
74 (FocusedSourceLanguage::JavaScript, Some("js" | "jsx"))
75 | (FocusedSourceLanguage::TypeScript, Some("ts" | "tsx"))
76 | (FocusedSourceLanguage::Rust, Some("rs"))
77 | (FocusedSourceLanguage::Python, Some("py"))
78 | (FocusedSourceLanguage::Go, Some("go"))
79 | (FocusedSourceLanguage::Java, Some("java"))
80 )
81 }
82
83 async fn discover(
84 &self,
85 context: &DiscoverContext<'_>,
86 ) -> Result<Vec<DiscoveredInput>, ExtractorError> {
87 Ok(context
88 .files
89 .iter()
90 .filter(|file| self.supports(file))
91 .cloned()
92 .map(|file| DiscoveredInput { file })
93 .collect())
94 }
95
96 async fn extract(&self, input: &ExtractInput<'_>) -> Result<ExtractionBatch, ExtractorError> {
97 if !self.supports(input.file) {
98 return Err(ExtractorError::InvalidInput(format!(
99 "{} does not support `{}`",
100 self.id(),
101 input.file.path.display
102 )));
103 }
104 let started = Instant::now();
105 let mut tracker =
106 crate::ExtractionTracker::new(&input.file.path.display, self.id(), &self.budgets);
107 tracker.check_input_bytes(u64::try_from(input.content.len()).unwrap_or(u64::MAX))?;
108 let fingerprint = fingerprint_with_budgets(input, self.id(), &self.budgets)?;
109 let source = std::str::from_utf8(input.content)?;
110 let syntax = inspect_source_syntax(
111 syntax_language(self.language),
112 &input.file.path.display,
113 source,
114 &mut tracker,
115 )
116 .map_err(extractor_source_syntax_error)?;
117 let reserved_observations = u64::try_from(syntax.boundary_candidate_count)
118 .map_err(|_| ExtractorError::InvalidInput("too many syntax candidates".to_owned()))?;
119 tracker.charge_work(reserved_observations)?;
120 precheck_focused_source_values(source, &mut tracker)?;
121 let observations = match self.language {
122 FocusedSourceLanguage::JavaScript => parse_javascript_source_at_path_with_tracker(
123 &input.file.path.display,
124 source,
125 &mut tracker,
126 )?,
127 FocusedSourceLanguage::TypeScript => parse_typescript_source_at_path_with_tracker(
128 &input.file.path.display,
129 source,
130 &mut tracker,
131 )?,
132 FocusedSourceLanguage::Rust => parse_rust_source_with_tracker(source, &mut tracker)?,
133 FocusedSourceLanguage::Python => {
134 parse_python_source_with_tracker(source, &mut tracker)?
135 }
136 FocusedSourceLanguage::Go => parse_go_source_with_tracker(source, &mut tracker)?,
137 FocusedSourceLanguage::Java => parse_java_source_with_tracker(source, &mut tracker)?,
138 };
139 if !observations.is_empty() && syntax.boundary_candidate_count == 0 {
140 return Err(ExtractorError::InvalidInput(
141 "framework observation lacked a Tree-sitter boundary candidate".to_owned(),
142 ));
143 }
144 let completeness = if !syntax.has_error
145 && observations
146 .iter()
147 .all(|observation| observation.status == SourceEpistemicStatus::Confirmed)
148 {
149 ExtractionCompleteness::Complete
150 } else {
151 ExtractionCompleteness::Partial
152 };
153 let mut warnings = observations
154 .iter()
155 .flat_map(|observation| &observation.warnings)
156 .map(|warning| format!("{warning:?}"))
157 .collect::<Vec<_>>();
158 if syntax.has_error {
159 warnings.push("Tree-sitter recovered from source syntax errors".to_owned());
160 }
161 let output_count = u64::try_from(observations.len())
162 .map_err(|_| ExtractorError::InvalidInput("too many observations".to_owned()))?;
163 let payload = serialize_bounded(&observations, &tracker)?;
164 tracker.check_structured_time()?;
165 Ok(ExtractionBatch {
166 source: ArtifactFingerprint {
167 repo_id: input.file.repo_id.clone(),
168 checkout_id: input.file.checkout_id.clone(),
169 path: input.file.path.clone(),
170 extractor: self.id().to_owned(),
171 content_hash: fingerprint.content_hash,
172 size_bytes: fingerprint.size_bytes,
173 },
174 payload,
175 output_count,
176 report: ExtractionReport {
177 discovered_files: 1,
178 parsed_files: 1,
179 skipped_files: 0,
180 completeness,
181 warnings,
182 evidence_count: output_count,
183 extractor_version: self.version(),
184 elapsed_ms: elapsed_ms(started),
185 },
186 })
187 }
188
189 async fn fingerprint(
190 &self,
191 input: &ExtractInput<'_>,
192 ) -> Result<crate::ContentFingerprint, ExtractorError> {
193 fingerprint_with_budgets(input, self.id(), &self.budgets)
194 }
195}
196
197fn syntax_language(language: FocusedSourceLanguage) -> SourceSyntaxLanguage {
198 match language {
199 FocusedSourceLanguage::JavaScript => SourceSyntaxLanguage::JavaScript,
200 FocusedSourceLanguage::TypeScript => SourceSyntaxLanguage::TypeScript,
201 FocusedSourceLanguage::Rust => SourceSyntaxLanguage::Rust,
202 FocusedSourceLanguage::Python => SourceSyntaxLanguage::Python,
203 FocusedSourceLanguage::Go => SourceSyntaxLanguage::Go,
204 FocusedSourceLanguage::Java => SourceSyntaxLanguage::Java,
205 }
206}
207
208#[doc(hidden)]
215pub fn precheck_focused_source_values(
216 source: &str,
217 tracker: &mut ExtractionTracker,
218) -> Result<(), crate::ExtractionLimitExceeded> {
219 let bytes = source.as_bytes();
220 let mut cursor = 0_usize;
221 let mut accumulated = 0_u64;
222 while cursor < bytes.len() {
223 if cursor.is_multiple_of(1_024) {
224 tracker.check_structured_time()?;
225 }
226 let byte = bytes[cursor];
227 if matches!(byte, b'"' | b'\'' | b'`') {
228 tracker.charge_work(1)?;
229 let delimiter = byte;
230 cursor = cursor.saturating_add(1);
231 let start = cursor;
232 let mut escaped = false;
233 while cursor < bytes.len() {
234 let byte = bytes[cursor];
235 if escaped {
236 escaped = false;
237 } else if byte == b'\\' {
238 escaped = true;
239 } else if byte == delimiter {
240 break;
241 }
242 cursor = cursor.saturating_add(1);
243 if cursor.is_multiple_of(1_024) {
244 tracker.check_structured_time()?;
245 }
246 }
247 let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
248 tracker.check_string_bytes(observed)?;
249 accumulated = accumulated.saturating_add(observed);
250 tracker.check_accumulated_string_bytes(accumulated)?;
251 } else if byte == b'_' || byte.is_ascii_alphabetic() {
252 tracker.charge_work(1)?;
253 let start = cursor;
254 cursor = cursor.saturating_add(1);
255 while cursor < bytes.len()
256 && (bytes[cursor] == b'_' || bytes[cursor].is_ascii_alphanumeric())
257 {
258 cursor = cursor.saturating_add(1);
259 }
260 let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
261 tracker.check_identifier_bytes(observed)?;
262 accumulated = accumulated.saturating_add(observed);
263 tracker.check_accumulated_string_bytes(accumulated)?;
264 continue;
265 }
266 cursor = cursor.saturating_add(1);
267 }
268 Ok(())
269}
270
271pub struct GeneratedClientMetadataExtractor {
273 budgets: ExtractionBudgets,
274}
275
276impl GeneratedClientMetadataExtractor {
277 #[must_use]
279 pub fn new() -> Self {
280 Self::with_budgets(ExtractionBudgets::default())
281 }
282
283 #[must_use]
285 pub const fn with_budgets(budgets: ExtractionBudgets) -> Self {
286 Self { budgets }
287 }
288}
289
290impl Default for GeneratedClientMetadataExtractor {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296#[async_trait]
297impl BoundaryExtractor for GeneratedClientMetadataExtractor {
298 fn id(&self) -> &'static str {
299 "code-system-graph.http.generated-client"
300 }
301
302 fn version(&self) -> Version {
303 Version::new(1, 0, 0)
304 }
305
306 fn supports(&self, file: &FileDescriptor) -> bool {
307 generated_client_path_supported(&file.path.display)
308 }
309
310 async fn discover(
311 &self,
312 context: &DiscoverContext<'_>,
313 ) -> Result<Vec<DiscoveredInput>, ExtractorError> {
314 Ok(context
315 .files
316 .iter()
317 .filter(|file| self.supports(file))
318 .cloned()
319 .map(|file| DiscoveredInput { file })
320 .collect())
321 }
322
323 async fn extract(&self, input: &ExtractInput<'_>) -> Result<ExtractionBatch, ExtractorError> {
324 if !self.supports(input.file) {
325 return Err(ExtractorError::InvalidInput(format!(
326 "{} does not support `{}`",
327 self.id(),
328 input.file.path.display
329 )));
330 }
331 let started = Instant::now();
332 let mut tracker =
333 crate::ExtractionTracker::new(&input.file.path.display, self.id(), &self.budgets);
334 tracker.check_input_bytes(u64::try_from(input.content.len()).unwrap_or(u64::MAX))?;
335 let fingerprint = fingerprint_with_budgets(input, self.id(), &self.budgets)?;
336 let source = std::str::from_utf8(input.content)?;
337 let metadata =
338 extract_generated_client_metadata(&input.file.path.display, source, &mut tracker)
339 .map_err(|error| match error {
340 crate::GeneratedClientError::LimitExceeded(limit) => {
341 ExtractorError::LimitExceeded(limit)
342 }
343 error => ExtractorError::InvalidInput(error.to_string()),
344 })?;
345 let output_count = u64::try_from(metadata.len())
346 .map_err(|_| ExtractorError::InvalidInput("too many metadata facts".to_owned()))?;
347 let payload = serialize_bounded(&metadata, &tracker)?;
348 tracker.check_structured_time()?;
349 Ok(ExtractionBatch {
350 source: ArtifactFingerprint {
351 repo_id: input.file.repo_id.clone(),
352 checkout_id: input.file.checkout_id.clone(),
353 path: input.file.path.clone(),
354 extractor: self.id().to_owned(),
355 content_hash: fingerprint.content_hash,
356 size_bytes: fingerprint.size_bytes,
357 },
358 payload,
359 output_count,
360 report: ExtractionReport {
361 discovered_files: 1,
362 parsed_files: 1,
363 skipped_files: 0,
364 completeness: ExtractionCompleteness::Complete,
365 warnings: Vec::new(),
366 evidence_count: output_count,
367 extractor_version: self.version(),
368 elapsed_ms: elapsed_ms(started),
369 },
370 })
371 }
372
373 async fn fingerprint(
374 &self,
375 input: &ExtractInput<'_>,
376 ) -> Result<crate::ContentFingerprint, ExtractorError> {
377 fingerprint_with_budgets(input, self.id(), &self.budgets)
378 }
379}
380
381pub struct PackageManifestExtractor {
383 budgets: ExtractionBudgets,
384}
385
386impl PackageManifestExtractor {
387 #[must_use]
389 pub fn new() -> Self {
390 Self::with_budgets(ExtractionBudgets::default())
391 }
392
393 #[must_use]
395 pub const fn with_budgets(budgets: ExtractionBudgets) -> Self {
396 Self { budgets }
397 }
398}
399
400impl Default for PackageManifestExtractor {
401 fn default() -> Self {
402 Self::new()
403 }
404}
405
406#[async_trait]
407impl BoundaryExtractor for PackageManifestExtractor {
408 fn id(&self) -> &'static str {
409 "code-system-graph.packages"
410 }
411
412 fn version(&self) -> Version {
413 Version::new(1, 0, 0)
414 }
415
416 fn supports(&self, file: &FileDescriptor) -> bool {
417 package_path_supported(&file.path.display)
418 }
419
420 async fn discover(
421 &self,
422 context: &DiscoverContext<'_>,
423 ) -> Result<Vec<DiscoveredInput>, ExtractorError> {
424 Ok(context
425 .files
426 .iter()
427 .filter(|file| self.supports(file))
428 .cloned()
429 .map(|file| DiscoveredInput { file })
430 .collect())
431 }
432
433 async fn extract(&self, input: &ExtractInput<'_>) -> Result<ExtractionBatch, ExtractorError> {
434 if !self.supports(input.file) {
435 return Err(ExtractorError::InvalidInput(format!(
436 "{} does not support `{}`",
437 self.id(),
438 input.file.path.display
439 )));
440 }
441 let started = Instant::now();
442 let mut tracker =
443 crate::ExtractionTracker::new(&input.file.path.display, self.id(), &self.budgets);
444 tracker.check_input_bytes(u64::try_from(input.content.len()).unwrap_or(u64::MAX))?;
445 let fingerprint = fingerprint_with_budgets(input, self.id(), &self.budgets)?;
446 let source = std::str::from_utf8(input.content)?;
447 let manifest =
448 extract_package_manifest_with_tracker(&input.file.path.display, source, &mut tracker)
449 .map_err(|error| match error {
450 crate::PackageManifestError::LimitExceeded(limit) => {
451 ExtractorError::LimitExceeded(limit)
452 }
453 error => ExtractorError::InvalidInput(error.to_string()),
454 })?;
455 let output_count = [
456 manifest.packages.len(),
457 manifest.dependencies.len(),
458 manifest.workspace_members.len(),
459 manifest.exports.len(),
460 manifest.features.len(),
461 manifest.lockfiles.len(),
462 ]
463 .into_iter()
464 .try_fold(0_u64, |total, count| {
465 u64::try_from(count)
466 .ok()
467 .and_then(|count| total.checked_add(count))
468 })
469 .ok_or_else(|| ExtractorError::InvalidInput("too many package facts".to_owned()))?;
470 let payload = serialize_bounded(&manifest, &tracker)?;
471 tracker.check_structured_time()?;
472 Ok(ExtractionBatch {
473 source: ArtifactFingerprint {
474 repo_id: input.file.repo_id.clone(),
475 checkout_id: input.file.checkout_id.clone(),
476 path: input.file.path.clone(),
477 extractor: self.id().to_owned(),
478 content_hash: fingerprint.content_hash,
479 size_bytes: fingerprint.size_bytes,
480 },
481 payload,
482 output_count,
483 report: ExtractionReport {
484 discovered_files: 1,
485 parsed_files: 1,
486 skipped_files: 0,
487 completeness: ExtractionCompleteness::Complete,
488 warnings: Vec::new(),
489 evidence_count: output_count,
490 extractor_version: self.version(),
491 elapsed_ms: elapsed_ms(started),
492 },
493 })
494 }
495
496 async fn fingerprint(
497 &self,
498 input: &ExtractInput<'_>,
499 ) -> Result<crate::ContentFingerprint, ExtractorError> {
500 fingerprint_with_budgets(input, self.id(), &self.budgets)
501 }
502}
503
504fn extractor_source_syntax_error(error: crate::SourceSyntaxError) -> ExtractorError {
505 match error {
506 crate::SourceSyntaxError::LimitExceeded(limit) => ExtractorError::LimitExceeded(limit),
507 error => ExtractorError::InvalidInput(error.to_string()),
508 }
509}
510
511fn fingerprint_with_budgets(
512 input: &ExtractInput<'_>,
513 extractor: &str,
514 budgets: &ExtractionBudgets,
515) -> Result<ContentFingerprint, ExtractorError> {
516 let observed = u64::try_from(input.content.len()).unwrap_or(u64::MAX);
517 ExtractionTracker::new(&input.file.path.display, extractor, budgets)
518 .check_input_bytes(observed)?;
519 Ok(ContentFingerprint {
520 content_hash: blake3::hash(input.content).to_hex().to_string(),
521 size_bytes: observed,
522 })
523}
524
525#[doc(hidden)]
531pub fn charge_source_observation(
532 observation: &SourceObservation,
533 tracker: &mut ExtractionTracker,
534) -> Result<(), crate::ExtractionLimitExceeded> {
535 crate::source_http::charge_source_observation_values(observation, tracker)
536}
537
538fn serialize_bounded<T: Serialize>(
539 value: &T,
540 tracker: &ExtractionTracker,
541) -> Result<Vec<u8>, ExtractorError> {
542 let mut writer = tracker.bounded_json_writer();
543 if let Err(error) = serde_json::to_writer(&mut writer, value) {
544 if let Some(limit) = tracker.output_limit_error(&writer) {
545 return Err(ExtractorError::LimitExceeded(limit));
546 }
547 return Err(ExtractorError::InvalidOutput(error));
548 }
549 Ok(writer.into_inner())
550}
551
552fn package_path_supported(path: &str) -> bool {
553 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
554 matches!(
555 name,
556 "package.json"
557 | "package-lock.json"
558 | "npm-shrinkwrap.json"
559 | "pnpm-lock.yaml"
560 | "yarn.lock"
561 | "pyproject.toml"
562 | "Cargo.toml"
563 | "go.mod"
564 | "go.work"
565 | "pom.xml"
566 | "build.gradle"
567 | "build.gradle.kts"
568 | "packages.config"
569 ) || (name.starts_with("requirements")
570 && std::path::Path::new(name)
571 .extension()
572 .is_some_and(|extension| extension.eq_ignore_ascii_case("txt")))
573 || name.to_ascii_lowercase().ends_with(".csproj")
574}
575
576fn generated_client_path_supported(path: &str) -> bool {
577 let path = std::path::Path::new(path);
578 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
579 return false;
580 };
581 name == "openapitools.json"
582 || (matches!(name, "FILES" | "VERSION")
583 && path
584 .parent()
585 .and_then(std::path::Path::file_name)
586 .is_some_and(|parent| parent == ".openapi-generator"))
587}
588
589fn elapsed_ms(started: Instant) -> u64 {
590 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
591}
592
593#[cfg(test)]
594mod tests {
595 use code_system_graph_model::{CheckoutId, NativePath, NativePathEncoding, RepoId};
596
597 use super::{
598 BoundaryExtractor, ExtractInput, ExtractionBudgets, ExtractorError, FileDescriptor, FocusedSourceExtractor, FocusedSourceLanguage, GeneratedClientMetadataExtractor, PackageManifestExtractor
599 };
600
601 fn file(path: &str) -> FileDescriptor {
602 FileDescriptor {
603 repo_id: RepoId::new("repo:test"),
604 checkout_id: CheckoutId::new("checkout:test"),
605 path: NativePath {
606 encoding: NativePathEncoding::Utf8,
607 bytes: path.as_bytes().to_vec(),
608 display: path.to_owned(),
609 },
610 size_bytes: 1,
611 }
612 }
613
614 #[tokio::test]
615 async fn source_extractor_should_emit_versioned_json_without_source_text() {
616 let extractor = FocusedSourceExtractor::new(FocusedSourceLanguage::Rust);
617 let file = file("src/routes.rs");
618 let source =
619 b"use axum::{Router, routing::get}; Router::new().route(\"/health\", get(health));";
620 let result = extractor
621 .extract(&ExtractInput {
622 file: &file,
623 content: source,
624 })
625 .await;
626
627 assert!(matches!(
628 result,
629 Ok(batch)
630 if batch.output_count == 1
631 && !String::from_utf8_lossy(&batch.payload).contains("Router::new")
632 ));
633 }
634
635 #[tokio::test]
636 async fn source_extractor_should_preflight_work_and_values() {
637 let file = file("src/routes.rs");
638 let source =
639 b"use axum::{Router, routing::get}; Router::new().route(\"/health\", get(health));";
640 let cases = [
641 (
642 ExtractionBudgets {
643 max_work_units_per_artifact: 1,
644 ..ExtractionBudgets::default()
645 },
646 crate::ExtractionResource::WorkUnits,
647 ),
648 (
649 ExtractionBudgets {
650 max_string_bytes_per_value: 3,
651 ..ExtractionBudgets::default()
652 },
653 crate::ExtractionResource::StringBytesPerValue,
654 ),
655 ];
656
657 for (budgets, resource) in cases {
658 let extractor =
659 FocusedSourceExtractor::with_budgets(FocusedSourceLanguage::Rust, budgets);
660 let result = extractor
661 .extract(&ExtractInput {
662 file: &file,
663 content: source,
664 })
665 .await;
666 assert!(matches!(
667 result,
668 Err(ExtractorError::LimitExceeded(error)) if error.resource == resource
669 ));
670 }
671 }
672
673 #[tokio::test]
674 async fn source_extractor_should_charge_each_observation_before_retention() {
675 let file = file("tests/test_api.py");
676 let source = b"import requests\ndef test_create_order():\n requests.post(\"https://api.test/v1/orders\")\n";
677 let one = ExtractionBudgets {
678 max_observations_per_artifact: 1,
679 ..ExtractionBudgets::default()
680 };
681 let rejected = FocusedSourceExtractor::with_budgets(FocusedSourceLanguage::Python, one)
682 .extract(&ExtractInput {
683 file: &file,
684 content: source,
685 })
686 .await;
687 assert!(matches!(
688 rejected,
689 Err(ExtractorError::LimitExceeded(error))
690 if error.resource == crate::ExtractionResource::Observations
691 && error.observed == 2
692 && error.maximum == 1
693 ));
694
695 let exact = ExtractionBudgets {
696 max_observations_per_artifact: 2,
697 ..ExtractionBudgets::default()
698 };
699 let accepted = FocusedSourceExtractor::with_budgets(FocusedSourceLanguage::Python, exact)
700 .extract(&ExtractInput {
701 file: &file,
702 content: source,
703 })
704 .await;
705 assert!(matches!(accepted, Ok(batch) if batch.output_count == 2));
706 }
707
708 #[tokio::test]
709 async fn javascript_extractor_should_preserve_next_file_route_context() {
710 let extractor = FocusedSourceExtractor::new(FocusedSourceLanguage::JavaScript);
711 let file = file("frontend/src/app/api/logout/route.js");
712 let source = b"export async function GET() { return new Response(); }\n";
713 let batch = extractor
714 .extract(&ExtractInput {
715 file: &file,
716 content: source,
717 })
718 .await
719 .expect("Next.js route should extract");
720 let observations: Vec<crate::SourceObservation> =
721 serde_json::from_slice(&batch.payload).expect("valid observation payload");
722
723 assert!(observations.iter().any(|observation| {
724 observation.framework == crate::SourceFramework::NextJs
725 && observation.method.as_deref() == Some("GET")
726 && observation.path.as_deref() == Some("/api/logout")
727 }));
728 }
729
730 #[tokio::test]
731 async fn generated_client_extractor_should_emit_explicit_config_facts() {
732 let extractor = GeneratedClientMetadataExtractor::default();
733 let file = file("openapitools.json");
734 let result = extractor
735 .extract(&ExtractInput {
736 file: &file,
737 content: br#"{"generator-cli":{"generators":{"client":{"generatorName":"rust","inputSpec":"openapi.yaml"}}}}"#,
738 })
739 .await;
740
741 assert!(matches!(result, Ok(batch) if batch.output_count == 1));
742 }
743
744 #[tokio::test]
745 async fn package_extractor_should_omit_evidence_source_lines_from_payload() {
746 let extractor = PackageManifestExtractor::default();
747 let file = file("Cargo.toml");
748 let source = b"[package]\nname = \"api\"\nversion = \"1.0.0\"\n";
749 let result = extractor
750 .extract(&ExtractInput {
751 file: &file,
752 content: source,
753 })
754 .await;
755
756 assert!(matches!(
757 result,
758 Ok(batch)
759 if batch.output_count == 1
760 && !String::from_utf8_lossy(&batch.payload).contains("name =")
761 ));
762 }
763
764 #[tokio::test]
765 async fn built_in_extractors_should_honor_explicit_effective_budgets() {
766 let budgets = ExtractionBudgets {
767 max_serialized_output_bytes_per_artifact: 1,
768 ..ExtractionBudgets::default()
769 };
770 let extractor = GeneratedClientMetadataExtractor::with_budgets(budgets);
771 let file = file("openapitools.json");
772 let result = extractor
773 .extract(&ExtractInput {
774 file: &file,
775 content: br#"{"generator-cli":{"generators":{"client":{"generatorName":"rust"}}}}"#,
776 })
777 .await;
778
779 assert!(matches!(
780 result,
781 Err(ExtractorError::LimitExceeded(error))
782 if error.resource
783 == crate::ExtractionResource::SerializedOutputBytes
784 && error.maximum == 1
785 ));
786 }
787}