Skip to main content

faf_fafb/
canon.rs

1//! The canonical chunk table — FAFb v2 is CLOSED CANONICAL.
2//!
3//! The writer emits exactly the chunks in this table, in this order.
4//! Non-canonical top-level YAML keys are folded into the `context` chunk —
5//! nothing is lost, and nothing grows a new section name. The reader keeps
6//! the IFF rule (skip unknown names gracefully) so future minor versions can
7//! add a chunk without breaking deployed readers: writer closed, reader
8//! graceful.
9//!
10//! Closed canonical is what makes the brick addressable: canonical order is
11//! fixed, so Content ID is well-defined. Same `.faf` → same Content ID is a
12//! property of one compiler build, not of YAML-the-idea.
13
14/// Chunk classification, stored in bits 0–1 of `SectionEntry.flags`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ChunkClassification {
17    /// Core project identity (project, stack, human_context, …).
18    Dna = 0b00,
19    /// Runtime/supplementary context.
20    Context = 0b01,
21    /// Documentation references (docs).
22    Pointer = 0b10,
23    /// Reserved for future use.
24    Reserved = 0b11,
25}
26
27impl ChunkClassification {
28    /// The 2-bit value for encoding into flags.
29    pub const fn bits(&self) -> u32 {
30        *self as u32
31    }
32
33    /// Decode from the low 2 bits of a flags value.
34    pub fn from_bits(bits: u32) -> Self {
35        match bits & 0b11 {
36            0b00 => Self::Dna,
37            0b01 => Self::Context,
38            0b10 => Self::Pointer,
39            _ => Self::Reserved,
40        }
41    }
42
43    /// Human-readable name.
44    pub const fn name(&self) -> &'static str {
45        match self {
46            Self::Dna => "DNA",
47            Self::Context => "Context",
48            Self::Pointer => "Pointer",
49            Self::Reserved => "Reserved",
50        }
51    }
52}
53
54/// Classification mask for the low 2 bits of section flags.
55pub const CLASSIFICATION_MASK: u32 = 0b11;
56
57/// One canonical chunk: name, classification, default priority.
58#[derive(Debug, Clone, Copy)]
59pub struct CanonicalChunk {
60    pub name: &'static str,
61    pub classification: ChunkClassification,
62    pub priority: u8,
63}
64
65/// The canonical chunk table — the complete, closed set of FAFb v2 section
66/// names, in serialization order. **This IS the format: there is no chunk 14.**
67///
68/// The set mirrors `faf-cli`'s `FafData` (the single source of truth for the
69/// `.faf` structure — `src/core/types.ts`): 11 DNA chunks + 2 Context. The
70/// metastamp is NOT a chunk — it's the FAFb header (`created_timestamp`),
71/// which is why `.faf`'s `generated:` key lives there, not here. Non-canonical
72/// top-level keys (anything tools add) fold losslessly into `context`.
73pub const CANONICAL_CHUNKS: &[CanonicalChunk] = &[
74    // ── DNA — core identity (11) ──
75    CanonicalChunk {
76        name: "faf_version",
77        classification: ChunkClassification::Dna,
78        priority: 255,
79    },
80    CanonicalChunk {
81        name: "project",
82        classification: ChunkClassification::Dna,
83        priority: 255,
84    },
85    CanonicalChunk {
86        name: "app_type",
87        classification: ChunkClassification::Dna,
88        priority: 200,
89    },
90    CanonicalChunk {
91        name: "about",
92        classification: ChunkClassification::Dna,
93        priority: 150,
94    },
95    CanonicalChunk {
96        name: "stack",
97        classification: ChunkClassification::Dna,
98        priority: 200,
99    },
100    CanonicalChunk {
101        name: "human_context",
102        classification: ChunkClassification::Dna,
103        priority: 200,
104    },
105    CanonicalChunk {
106        name: "tech_stack",
107        classification: ChunkClassification::Dna,
108        priority: 200,
109    },
110    CanonicalChunk {
111        name: "key_files",
112        classification: ChunkClassification::Dna,
113        priority: 200,
114    },
115    CanonicalChunk {
116        name: "commands",
117        classification: ChunkClassification::Dna,
118        priority: 180,
119    },
120    CanonicalChunk {
121        name: "monorepo",
122        classification: ChunkClassification::Dna,
123        priority: 150,
124    },
125    CanonicalChunk {
126        name: "architecture",
127        classification: ChunkClassification::Dna,
128        priority: 128,
129    },
130    // ── Context (2) — derived output + the fold target ──
131    CanonicalChunk {
132        name: "scores",
133        classification: ChunkClassification::Context,
134        priority: 64,
135    },
136    CanonicalChunk {
137        name: "context",
138        classification: ChunkClassification::Context,
139        priority: 64,
140    },
141];
142
143/// Look up a canonical chunk by name.
144pub fn canonical_chunk(name: &str) -> Option<&'static CanonicalChunk> {
145    CANONICAL_CHUNKS.iter().find(|c| c.name == name)
146}
147
148/// Is this top-level key part of the canonical chunk set?
149pub fn is_canonical(name: &str) -> bool {
150    canonical_chunk(name).is_some()
151}
152
153/// Structural (`__`-prefixed) chunk names closed for spec 2.0.
154///
155/// `__score__` is deliberately absent. Unknown `__` names are a writer error
156/// and a reader pass-through.
157pub const STRUCTURAL_CHUNKS: &[&str] = &[
158    "__string_table__",
159    "__tokens__",
160    "__provenance__",
161    "__members__",
162];
163
164/// True when `name` is structural (leading `__`), whether or not it is on the
165/// closed list. Structural chunks are excluded from the Content ID.
166pub fn is_structural_name(name: &str) -> bool {
167    name.starts_with("__")
168}
169
170/// True when `name` is on the closed structural list.
171pub fn is_known_structural(name: &str) -> bool {
172    STRUCTURAL_CHUNKS.contains(&name)
173}
174
175/// `__members__` paths are POSIX, relative to the root brick, and MUST NOT
176/// contain `..`.
177pub fn is_valid_member_path(path: &str) -> bool {
178    !path.is_empty()
179        && !path.starts_with('/')
180        && !path.split('/').any(|seg| seg == ".." || seg.is_empty())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn table_is_closed_at_13() {
189        // Mirrors faf-cli FafData: 11 DNA + 2 Context. The one-way door.
190        assert_eq!(CANONICAL_CHUNKS.len(), 13);
191        let dna = CANONICAL_CHUNKS
192            .iter()
193            .filter(|c| c.classification == ChunkClassification::Dna)
194            .count();
195        let ctx = CANONICAL_CHUNKS
196            .iter()
197            .filter(|c| c.classification == ChunkClassification::Context)
198            .count();
199        assert_eq!(dna, 11);
200        assert_eq!(ctx, 2);
201    }
202
203    #[test]
204    fn mirrors_faf_cli_fafdata() {
205        // The canonical set IS faf-cli's FafData top-level keys (the truth).
206        for key in [
207            "faf_version",
208            "project",
209            "app_type",
210            "about",
211            "stack",
212            "human_context",
213            "monorepo",
214            "scores",
215            "tech_stack",
216            "key_files",
217            "commands",
218            "architecture",
219            "context",
220        ] {
221            assert!(is_canonical(key), "FafData key '{}' must be canonical", key);
222        }
223        // Keys that diverged from the old faf-rust-sdk model are NOT canonical.
224        for key in [
225            "instant_context",
226            "ai_score",
227            "ai_confidence",
228            "ai_tldr",
229            "context_quality",
230            "preferences",
231            "state",
232            "tags",
233            "meta",
234            "bi_sync",
235            "docs",
236            "generated",
237        ] {
238            assert!(!is_canonical(key), "'{}' must fold, not be a chunk", key);
239        }
240    }
241
242    #[test]
243    fn names_are_unique() {
244        for (i, a) in CANONICAL_CHUNKS.iter().enumerate() {
245            for b in &CANONICAL_CHUNKS[i + 1..] {
246                assert_ne!(a.name, b.name);
247            }
248        }
249    }
250
251    #[test]
252    fn identity_chunks_are_critical() {
253        assert_eq!(canonical_chunk("faf_version").unwrap().priority, 255);
254        assert_eq!(canonical_chunk("project").unwrap().priority, 255);
255    }
256
257    #[test]
258    fn context_is_the_fold_target() {
259        // `context` is canonical (Context class) and is where non-canonical
260        // keys fold. No Pointer-class chunk exists in the FafData truth.
261        assert_eq!(
262            canonical_chunk("context").unwrap().classification,
263            ChunkClassification::Context
264        );
265        assert!(
266            !CANONICAL_CHUNKS
267                .iter()
268                .any(|c| c.classification == ChunkClassification::Pointer)
269        );
270    }
271
272    #[test]
273    fn unknown_keys_are_not_canonical() {
274        assert!(!is_canonical("custom_field"));
275        assert!(!is_canonical("my_exotic_field"));
276        // Case-sensitive (YAML convention)
277        assert!(!is_canonical("Project"));
278        assert!(is_canonical("project"));
279    }
280
281    #[test]
282    fn structural_list_is_closed_without_score() {
283        assert_eq!(STRUCTURAL_CHUNKS.len(), 4);
284        assert!(is_known_structural("__string_table__"));
285        assert!(is_known_structural("__tokens__"));
286        assert!(is_known_structural("__provenance__"));
287        assert!(is_known_structural("__members__"));
288        assert!(!is_known_structural("__score__"));
289        assert!(is_structural_name("__score__"));
290        assert!(is_structural_name("__mystery__"));
291        assert!(!is_structural_name("project"));
292    }
293
294    #[test]
295    fn member_paths_reject_dotdot() {
296        assert!(is_valid_member_path("packages/billing"));
297        assert!(!is_valid_member_path("../etc/passwd"));
298        assert!(!is_valid_member_path("packages/../secret"));
299        assert!(!is_valid_member_path("/abs"));
300        assert!(!is_valid_member_path(""));
301    }
302
303    #[test]
304    fn classification_bits_roundtrip() {
305        for class in &[
306            ChunkClassification::Dna,
307            ChunkClassification::Context,
308            ChunkClassification::Pointer,
309            ChunkClassification::Reserved,
310        ] {
311            assert_eq!(ChunkClassification::from_bits(class.bits()), *class);
312        }
313        // Higher bits ignored
314        assert_eq!(
315            ChunkClassification::from_bits(0xFF00_0001),
316            ChunkClassification::Context
317        );
318    }
319}