1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
use async_trait::async_trait;
use code_system_graph_model::{
ArtifactFingerprint, CheckoutId, NativePath, RepoId, StoredExtractorBatch
};
use semver::Version;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Maximum source size accepted by focused boundary extractors.
pub const MAX_EXTRACTOR_INPUT_BYTES: usize = 8 * 1024 * 1024;
/// Repository file metadata available during extractor discovery.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDescriptor {
/// Repository containing the file.
pub repo_id: RepoId,
/// Concrete checkout containing the file.
pub checkout_id: CheckoutId,
/// Lossless repository-relative path.
pub path: NativePath,
/// Exact source size in bytes.
pub size_bytes: u64,
}
/// Bounded repository inventory supplied to an extractor.
#[derive(Debug, Clone)]
pub struct DiscoverContext<'a> {
/// Candidate files in deterministic path order.
pub files: &'a [FileDescriptor],
}
/// One file selected by an extractor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredInput {
/// Selected file metadata.
pub file: FileDescriptor,
}
/// Immutable source input supplied to a focused extractor.
#[derive(Debug, Clone)]
pub struct ExtractInput<'a> {
/// Selected file metadata.
pub file: &'a FileDescriptor,
/// Bounded source content.
pub content: &'a [u8],
}
/// Content identity returned independently from extraction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentFingerprint {
/// BLAKE3 content hash.
pub content_hash: String,
/// Exact source size in bytes.
pub size_bytes: u64,
}
/// Explicit completeness of one focused extraction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionCompleteness {
/// Every supported construct in the input was parsed.
Complete,
/// Unsupported or dynamic constructs were observed and reported.
Partial,
}
/// Audit metrics and diagnostics for one source-owned batch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtractionReport {
/// Number of files selected by this batch.
pub discovered_files: u64,
/// Number of files parsed successfully.
pub parsed_files: u64,
/// Number of files skipped.
pub skipped_files: u64,
/// Explicit result completeness.
pub completeness: ExtractionCompleteness,
/// Bounded warnings; these must not contain source contents or secrets.
pub warnings: Vec<String>,
/// Number of evidence records emitted into the payload.
pub evidence_count: u64,
/// Extractor semantic version.
pub extractor_version: Version,
/// Bounded elapsed wall-clock time.
pub elapsed_ms: u64,
}
/// Versioned, source-owned output produced by a boundary extractor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionBatch {
/// Source fingerprint that owns and invalidates this output.
pub source: ArtifactFingerprint,
/// Deterministic UTF-8 JSON observations without source text.
pub payload: Vec<u8>,
/// Number of observations encoded in the payload.
pub output_count: u64,
/// Extraction diagnostics.
pub report: ExtractionReport,
}
impl ExtractionBatch {
/// Converts the batch to its persistence representation.
#[must_use]
pub fn into_stored(
self,
budget_fingerprint: String,
source_was_lossy: bool,
) -> StoredExtractorBatch {
StoredExtractorBatch {
source: self.source,
extractor_version: self.report.extractor_version.to_string(),
budget_fingerprint,
source_was_lossy,
output_count: self.output_count,
payload: self.payload,
}
}
}
/// Failure returned by focused boundary extractors.
#[derive(Debug, Error)]
pub enum ExtractorError {
/// Extraction exceeded one configured invocation resource.
#[error(transparent)]
LimitExceeded(#[from] crate::ExtractionLimitExceeded),
/// Input exceeds the documented extraction budget.
#[error("extractor input is {actual} bytes; maximum is {maximum}")]
InputTooLarge {
/// Observed byte count.
actual: usize,
/// Configured maximum.
maximum: usize,
},
/// Source bytes are not valid for a text extractor.
#[error("extractor input is not valid UTF-8")]
InvalidUtf8(#[from] std::str::Utf8Error),
/// Structured output could not be encoded.
#[error("extractor output could not be encoded: {0}")]
InvalidOutput(#[from] serde_json::Error),
/// Extractor-specific failure with a bounded, non-sensitive explanation.
#[error("{0}")]
InvalidInput(String),
}
/// Focused, deterministic contract extractor.
#[async_trait]
pub trait BoundaryExtractor: Send + Sync {
/// Stable extractor identity.
fn id(&self) -> &'static str;
/// Extractor and payload-schema semantic version.
fn version(&self) -> Version;
/// Returns whether this extractor can consume a candidate file.
fn supports(&self, file: &FileDescriptor) -> bool;
/// Selects supported files from a bounded deterministic inventory.
async fn discover(
&self,
context: &DiscoverContext<'_>,
) -> Result<Vec<DiscoveredInput>, ExtractorError>;
/// Produces a complete replacement batch for one source file.
async fn extract(&self, input: &ExtractInput<'_>) -> Result<ExtractionBatch, ExtractorError>;
/// Computes the source fingerprint used for incremental reuse.
async fn fingerprint(
&self,
input: &ExtractInput<'_>,
) -> Result<ContentFingerprint, ExtractorError>;
}
/// Computes a bounded source fingerprint shared by focused extractors.
///
/// # Errors
///
/// Returns [`ExtractorError::InputTooLarge`] when the input exceeds the extraction budget.
pub fn fingerprint_content(content: &[u8]) -> Result<ContentFingerprint, ExtractorError> {
if content.len() > MAX_EXTRACTOR_INPUT_BYTES {
return Err(ExtractorError::InputTooLarge {
actual: content.len(),
maximum: MAX_EXTRACTOR_INPUT_BYTES,
});
}
Ok(ContentFingerprint {
content_hash: blake3::hash(content).to_hex().to_string(),
size_bytes: u64::try_from(content.len()).map_err(|_| {
ExtractorError::InvalidInput("source size exceeds the supported range".to_owned())
})?,
})
}
#[cfg(test)]
mod tests {
use super::{ExtractorError, MAX_EXTRACTOR_INPUT_BYTES, fingerprint_content};
#[test]
fn fingerprint_should_be_deterministic_and_bounded() {
let first = fingerprint_content(b"GET /orders");
let second = fingerprint_content(b"GET /orders");
assert!(matches!(
(first, second),
(Ok(left), Ok(right)) if left == right
));
assert!(matches!(
fingerprint_content(&vec![0; MAX_EXTRACTOR_INPUT_BYTES + 1]),
Err(ExtractorError::InputTooLarge { .. })
));
}
}