Skip to main content

sinter_core/
node.rs

1use serde::{Deserialize, Serialize};
2
3/// Repository role of a source file and every graph node declared in it.
4///
5/// Scope is persisted per file by the store rather than repeated in every
6/// node blob. The path classifier is deliberately conservative: repositories
7/// can override an exceptional path, while an uncertain path remains
8/// production instead of disappearing from the default agent corpus.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CorpusScope {
12    Production,
13    Test,
14    Fixture,
15    Example,
16    Generated,
17    Vendor,
18    Docs,
19}
20
21impl CorpusScope {
22    pub const ALL: [Self; 7] = [
23        Self::Production,
24        Self::Test,
25        Self::Fixture,
26        Self::Example,
27        Self::Generated,
28        Self::Vendor,
29        Self::Docs,
30    ];
31
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Production => "production",
35            Self::Test => "test",
36            Self::Fixture => "fixture",
37            Self::Example => "example",
38            Self::Generated => "generated",
39            Self::Vendor => "vendor",
40            Self::Docs => "docs",
41        }
42    }
43
44    pub fn from_str_opt(value: &str) -> Option<Self> {
45        Some(match value {
46            "production" | "prod" => Self::Production,
47            "test" | "tests" => Self::Test,
48            "fixture" | "fixtures" => Self::Fixture,
49            "example" | "examples" => Self::Example,
50            "generated" => Self::Generated,
51            "vendor" | "vendored" => Self::Vendor,
52            "docs" | "documentation" => Self::Docs,
53            _ => return None,
54        })
55    }
56
57    /// Conservative path-only classification used when a repository has no
58    /// explicit override. Rules operate on complete path components and
59    /// well-known generated suffixes to avoid hiding ordinary source whose
60    /// name merely contains words such as `test` or `example`.
61    ///
62    /// Test-infrastructure directories (`harness`, `bench`, `benches`,
63    /// `benchmark`, `benchmarks`, `eval`, `evals`, `e2e`) count only as the
64    /// top-level component: a crate or module literally named `eval` under
65    /// `crates/` or `src/` stays production. Nested `fixtures`, `golden`,
66    /// `testdata`, `examples`, and `tests` components still match anywhere.
67    ///
68    /// Rust convention: `tests.rs`, `*_tests.rs`, and `test_*.rs` basenames
69    /// are test files wherever they sit under `src/`.
70    pub fn classify_path(file: &str) -> Self {
71        if file.starts_with("dep:") {
72            return Self::Vendor;
73        }
74        let lower = file.replace('\\', "/").to_ascii_lowercase();
75        let components = lower.split('/').collect::<Vec<_>>();
76        let basename = components.last().copied().unwrap_or(&lower);
77
78        if components.iter().any(|component| {
79            matches!(
80                *component,
81                "vendor" | "vendored" | "third_party" | "third-party" | "node_modules"
82            )
83        }) {
84            return Self::Vendor;
85        }
86        if components.iter().any(|component| {
87            matches!(
88                *component,
89                "generated" | "autogen" | "auto-generated" | "generated-src"
90            )
91        }) || basename.contains(".generated.")
92            || basename.contains("_generated.")
93            || basename.ends_with(".g.rs")
94            || basename.ends_with(".pb.go")
95            || basename.ends_with(".designer.cs")
96        {
97            return Self::Generated;
98        }
99        if components.iter().any(|component| {
100            matches!(
101                *component,
102                "fixture"
103                    | "fixtures"
104                    | "golden"
105                    | "testdata"
106                    | "test-data"
107                    | "expected"
108                    | "worked"
109                    | "snapshot"
110                    | "snapshots"
111                    | "__snapshots__"
112            )
113        }) {
114            return Self::Fixture;
115        }
116        if components.iter().any(|component| {
117            matches!(
118                *component,
119                "example" | "examples" | "sample" | "samples" | "demo" | "demos"
120            )
121        }) {
122            return Self::Example;
123        }
124        if components
125            .iter()
126            .any(|component| matches!(*component, "test" | "tests" | "spec" | "specs"))
127            || components.first().is_some_and(|component| {
128                matches!(
129                    *component,
130                    "harness"
131                        | "bench"
132                        | "benches"
133                        | "benchmark"
134                        | "benchmarks"
135                        | "eval"
136                        | "evals"
137                        | "e2e"
138                )
139            })
140            || basename.starts_with("test_")
141            || basename == "tests.rs"
142            || basename.ends_with("_tests.rs")
143            || basename.contains("_test.")
144            || basename.contains(".test.")
145            || basename.contains("_spec.")
146            || basename.contains(".spec.")
147        {
148            return Self::Test;
149        }
150        if components
151            .first()
152            .is_some_and(|component| matches!(*component, "docs" | "doc" | "documentation"))
153            || matches!(
154                basename,
155                "readme"
156                    | "readme.md"
157                    | "readme.mdx"
158                    | "changelog.md"
159                    | "contributing.md"
160                    | "architecture.md"
161            )
162            || [".md", ".mdx", ".rst", ".adoc", ".asciidoc"]
163                .iter()
164                .any(|extension| basename.ends_with(extension))
165        {
166            return Self::Docs;
167        }
168        Self::Production
169    }
170}
171
172impl std::fmt::Display for CorpusScope {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.write_str(self.as_str())
175    }
176}
177
178impl std::str::FromStr for CorpusScope {
179    type Err = String;
180
181    fn from_str(value: &str) -> Result<Self, Self::Err> {
182        Self::from_str_opt(value).ok_or_else(|| {
183            format!(
184                "unknown scope `{value}` (expected production, test, fixture, example, generated, vendor, or docs)"
185            )
186        })
187    }
188}
189
190/// Identifier of a graph node.
191///
192/// Comparison is byte-exact and case-sensitive: `Config` and `config` are
193/// distinct ids. A collision on insert is an error, never a merge.
194#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
195pub struct NodeId(String);
196
197impl NodeId {
198    pub fn new(id: impl Into<String>) -> Self {
199        Self(id.into())
200    }
201
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205
206    /// The qualified declaration path encoded in this snapshot-local id.
207    /// File-node ids contain no `#` and therefore qualify as their path.
208    pub fn qualified(&self) -> &str {
209        match self.0.split_once('#') {
210            Some((_, rest)) => rest
211                .rsplit_once('@')
212                .map_or(rest, |(qualified, _)| qualified),
213            None => &self.0,
214        }
215    }
216}
217
218impl std::fmt::Display for NodeId {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.write_str(&self.0)
221    }
222}
223
224/// A declaration handle that is independent of byte offsets.
225///
226/// The encoded form is `symbol:<kind>:<file-byte-length>:<file>#<qualified>`.
227/// The length prefix keeps parsing unambiguous even when a path contains `#`.
228/// A key is deliberately not a unique id: overloads or duplicate declarations
229/// with the same kind and qualified path share it, and callers must handle the
230/// resulting candidate set rather than guessing a binding.
231#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
232pub struct SymbolKey(String);
233
234impl SymbolKey {
235    pub const PREFIX: &'static str = "symbol:";
236
237    pub fn new(kind: SymbolKind, file: &str, qualified: &str) -> Self {
238        Self(format!(
239            "{}{}:{}:{file}#{qualified}",
240            Self::PREFIX,
241            kind.as_str(),
242            file.len()
243        ))
244    }
245
246    pub fn parse(encoded: impl Into<String>) -> Option<Self> {
247        let key = Self(encoded.into());
248        key.parts()?;
249        Some(key)
250    }
251
252    pub fn as_str(&self) -> &str {
253        &self.0
254    }
255
256    pub fn parts(&self) -> Option<(SymbolKind, &str, &str)> {
257        let rest = self.0.strip_prefix(Self::PREFIX)?;
258        let (kind, rest) = rest.split_once(':')?;
259        let (file_len, rest) = rest.split_once(':')?;
260        let file_len: usize = file_len.parse().ok()?;
261        let file = rest.get(..file_len)?;
262        let qualified = rest.get(file_len..)?.strip_prefix('#')?;
263        if file.is_empty() || qualified.is_empty() {
264            return None;
265        }
266        Some((SymbolKind::from_str_opt(kind)?, file, qualified))
267    }
268}
269
270impl std::fmt::Display for SymbolKey {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.write_str(&self.0)
273    }
274}
275
276/// Byte range of a symbol in its source file. `end` is exclusive; a valid
277/// span has `end > start`.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
279pub struct Span {
280    pub start: u64,
281    pub end: u64,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
285pub enum SymbolKind {
286    Function,
287    Method,
288    Struct,
289    Class,
290    Enum,
291    Variant,
292    Trait,
293    Interface,
294    TypeAlias,
295    Constant,
296    Static,
297    Variable,
298    Field,
299    Module,
300    Macro,
301    File,
302    /// A prose document section (markdown heading); appended for postcard
303    /// wire compatibility — never reorder.
304    Section,
305    /// A relational database table. Appended for postcard wire compatibility.
306    Table,
307    /// A named relational view, including materialized views.
308    View,
309    /// A column declared inside a table.
310    Column,
311    /// A named database index.
312    Index,
313}
314
315impl SymbolKind {
316    pub fn as_str(self) -> &'static str {
317        match self {
318            Self::Function => "function",
319            Self::Method => "method",
320            Self::Struct => "struct",
321            Self::Class => "class",
322            Self::Enum => "enum",
323            Self::Variant => "variant",
324            Self::Trait => "trait",
325            Self::Interface => "interface",
326            Self::TypeAlias => "typealias",
327            Self::Constant => "constant",
328            Self::Static => "static",
329            Self::Variable => "variable",
330            Self::Field => "field",
331            Self::Module => "module",
332            Self::Macro => "macro",
333            Self::File => "file",
334            Self::Section => "section",
335            Self::Table => "table",
336            Self::View => "view",
337            Self::Column => "column",
338            Self::Index => "index",
339        }
340    }
341
342    /// Inverse of [`SymbolKind::as_str`]; the mapping extraction query
343    /// captures (`@def.<kind>`) resolve through.
344    pub fn from_str_opt(s: &str) -> Option<Self> {
345        Some(match s {
346            "function" => Self::Function,
347            "method" => Self::Method,
348            "struct" => Self::Struct,
349            "class" => Self::Class,
350            "enum" => Self::Enum,
351            "variant" => Self::Variant,
352            "trait" => Self::Trait,
353            "interface" => Self::Interface,
354            "typealias" => Self::TypeAlias,
355            "constant" => Self::Constant,
356            "static" => Self::Static,
357            "variable" => Self::Variable,
358            "field" => Self::Field,
359            "module" => Self::Module,
360            "macro" => Self::Macro,
361            "file" => Self::File,
362            "section" => Self::Section,
363            "table" => Self::Table,
364            "view" => Self::View,
365            "column" => Self::Column,
366            "index" => Self::Index,
367            _ => return None,
368        })
369    }
370}
371
372/// A symbol with enough content that a query result saves the consumer a
373/// file read: signature, doc comment, and exact byte span.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct Node {
376    pub id: NodeId,
377    pub kind: SymbolKind,
378    pub name: String,
379    /// Repo-relative source file path.
380    pub file: String,
381    pub span: Span,
382    /// Declaration text; may be empty for kinds with no meaningful signature.
383    pub signature: String,
384    pub doc: Option<String>,
385}
386
387impl Node {
388    /// Stable semantic handle for this declaration. It survives unrelated
389    /// text inserted before the declaration; unlike [`NodeId`], it does not
390    /// claim uniqueness among overloads or duplicate declarations.
391    pub fn symbol_key(&self) -> SymbolKey {
392        SymbolKey::new(self.kind, &self.file, self.id.qualified())
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::{CorpusScope, Node, NodeId, Span, SymbolKey, SymbolKind};
399
400    fn node(id: &str, signature: &str) -> Node {
401        Node {
402            id: NodeId::new(id),
403            kind: SymbolKind::Function,
404            name: "run".to_string(),
405            file: "src/lib.rs".to_string(),
406            span: Span { start: 10, end: 20 },
407            signature: signature.to_string(),
408            doc: None,
409        }
410    }
411
412    #[test]
413    fn symbol_key_survives_offset_changes() {
414        let before = node("src/lib.rs#Runner::run@10", "fn run()");
415        let after = node("src/lib.rs#Runner::run@200", "fn run()");
416        assert_ne!(before.id, after.id);
417        assert_eq!(before.symbol_key(), after.symbol_key());
418        assert_eq!(
419            before.symbol_key().parts(),
420            Some((SymbolKind::Function, "src/lib.rs", "Runner::run"))
421        );
422        assert_eq!(
423            SymbolKey::parse(before.symbol_key().to_string()),
424            Some(before.symbol_key())
425        );
426    }
427
428    #[test]
429    fn overloads_share_a_key_without_claiming_uniqueness() {
430        let one = node("src/lib.rs#run@10", "fn run(u8)");
431        let two = node("src/lib.rs#run@30", "fn run(u16)");
432        assert_eq!(one.symbol_key(), two.symbol_key());
433    }
434
435    #[test]
436    fn sql_symbol_kinds_round_trip_through_capture_names() {
437        for kind in [
438            SymbolKind::Table,
439            SymbolKind::View,
440            SymbolKind::Column,
441            SymbolKind::Index,
442        ] {
443            assert_eq!(SymbolKind::from_str_opt(kind.as_str()), Some(kind));
444        }
445    }
446
447    #[test]
448    fn corpus_scope_uses_conservative_path_roles() {
449        let cases = [
450            ("src/lib.rs", CorpusScope::Production),
451            ("tests/integration.rs", CorpusScope::Test),
452            ("harness/golden/example/main.rs", CorpusScope::Fixture),
453            ("examples/client.rs", CorpusScope::Example),
454            ("src/generated/schema.pb.go", CorpusScope::Generated),
455            ("third_party/parser.c", CorpusScope::Vendor),
456            ("docs/architecture.md", CorpusScope::Docs),
457            ("src/contest.rs", CorpusScope::Production),
458            ("harness/eval/runner/scoring.rs", CorpusScope::Test),
459            (
460                "harness/eval/fixtures/agent-flow/main.rs",
461                CorpusScope::Fixture,
462            ),
463            (
464                "harness/golden/fixtures/go-basic/main.go",
465                CorpusScope::Fixture,
466            ),
467            ("benches/ask.rs", CorpusScope::Test),
468            ("tools/skillgen/expected/card.md", CorpusScope::Fixture),
469            ("worked/mixed-corpus/raw/cluster.py", CorpusScope::Fixture),
470            ("samples/demo.py", CorpusScope::Example),
471            ("e2e/smoke.ts", CorpusScope::Test),
472            ("crates/sinter-cli/tests/cli.rs", CorpusScope::Test),
473            ("crates/eval/src/lib.rs", CorpusScope::Production),
474            ("src/eval/mod.rs", CorpusScope::Production),
475            ("src/tests.rs", CorpusScope::Test),
476            ("crates/foo/src/bar/tests.rs", CorpusScope::Test),
477            ("src/bar_tests.rs", CorpusScope::Test),
478            ("src/test_bar.rs", CorpusScope::Test),
479            ("crates/foo/src/bar/tests/cases.rs", CorpusScope::Test),
480            ("src/contests.rs", CorpusScope::Production),
481        ];
482        for (path, expected) in cases {
483            assert_eq!(CorpusScope::classify_path(path), expected, "{path}");
484        }
485    }
486}