Skip to main content

faf_fafb/
compile.rs

1//! Compile/decompile API for .faf ↔ .fafb conversion — FAFb v2, closed canonical.
2//!
3//! The writer emits exactly the canonical chunk set (see `canon`), in canonical
4//! order. Non-canonical top-level YAML keys are folded into the `context`
5//! chunk — nothing is lost, and the string table stays fixed. Identical
6//! content therefore produces identical bytes regardless of input key order:
7//! the brick is content-addressable.
8
9use std::io::Write;
10
11use super::canon::{CANONICAL_CHUNKS, CanonicalChunk, ChunkClassification, canonical_chunk};
12use super::error::{FafbError, FafbResult};
13use super::header::{FafbHeader, HEADER_SIZE, MAX_FILE_SIZE, MAX_SECTIONS};
14use super::priority::Priority;
15use super::section::{SECTION_ENTRY_SIZE, SectionEntry, SectionTable};
16use super::string_table::StringTable;
17
18/// Options for compilation
19#[derive(Debug, Clone)]
20pub struct CompileOptions {
21    /// Whether to include a timestamp (set to false for deterministic output in tests)
22    pub use_timestamp: bool,
23}
24
25impl Default for CompileOptions {
26    fn default() -> Self {
27        Self {
28            use_timestamp: true,
29        }
30    }
31}
32
33/// A decompiled .fafb file with header, section table, string table, and raw data
34#[derive(Debug, Clone)]
35pub struct DecompiledFafb {
36    /// The 32-byte header
37    pub header: FafbHeader,
38    /// Section table with all entries
39    pub section_table: SectionTable,
40    /// Raw file data (for extracting section content)
41    pub data: Vec<u8>,
42    /// String table — maps section_name_index to name strings
43    string_table: StringTable,
44}
45
46impl DecompiledFafb {
47    /// Extract the raw bytes for a section entry
48    pub fn section_data(&self, entry: &SectionEntry) -> Option<&[u8]> {
49        let start = entry.offset as usize;
50        let end = start + entry.length as usize;
51        if end <= self.data.len() {
52            Some(&self.data[start..end])
53        } else {
54            None
55        }
56    }
57
58    /// Extract section data as a UTF-8 string
59    pub fn section_string(&self, entry: &SectionEntry) -> Option<String> {
60        self.section_data(entry)
61            .and_then(|bytes| std::str::from_utf8(bytes).ok())
62            .map(|s| s.to_string())
63    }
64
65    /// Get the string table
66    pub fn string_table(&self) -> &StringTable {
67        &self.string_table
68    }
69
70    /// Get section name by entry — looks up in string table.
71    /// Unknown indices read as "UNKNOWN" (the IFF rule: skip, don't fail).
72    pub fn section_name(&self, entry: &SectionEntry) -> String {
73        self.string_table
74            .get(entry.name_index)
75            .unwrap_or("UNKNOWN")
76            .to_string()
77    }
78
79    /// Get section data by name
80    pub fn get_section_by_name(&self, name: &str) -> Option<&[u8]> {
81        let idx = self.string_table.index_of(name)?;
82        self.section_table
83            .entries()
84            .iter()
85            .find(|e| e.name_index == idx)
86            .and_then(|entry| self.section_data(entry))
87    }
88
89    /// Get section data by name as string
90    pub fn get_section_string_by_name(&self, name: &str) -> Option<String> {
91        self.get_section_by_name(name)
92            .and_then(|bytes| std::str::from_utf8(bytes).ok())
93            .map(|s| s.to_string())
94    }
95
96    /// Get all DNA sections
97    pub fn dna_sections(&self) -> Vec<&SectionEntry> {
98        self.section_table
99            .entries()
100            .iter()
101            .filter(|e| e.classification() == ChunkClassification::Dna)
102            .collect()
103    }
104
105    /// Get all Context sections
106    pub fn context_sections(&self) -> Vec<&SectionEntry> {
107        self.section_table
108            .entries()
109            .iter()
110            .filter(|e| e.classification() == ChunkClassification::Context)
111            .collect()
112    }
113
114    /// Get the Pointer section (docs)
115    pub fn pointer_section(&self) -> Option<&SectionEntry> {
116        self.section_table
117            .entries()
118            .iter()
119            .find(|e| e.classification() == ChunkClassification::Pointer)
120    }
121}
122
123/// Compile a .faf YAML source string into .fafb v2 binary bytes.
124///
125/// FAFb v2 is closed canonical: only the canonical chunk set produces
126/// sections, serialized in canonical order. Non-canonical top-level keys are
127/// folded into the `context` chunk (sorted alphabetically, after any authored
128/// `context` content) — preserved, never named.
129///
130/// # Example
131///
132/// ```rust
133/// use faf_fafb::{compile, CompileOptions};
134///
135/// let yaml = r#"
136/// faf_version: 2.5.0
137/// project:
138///   name: my-project
139///   goal: Build something great
140/// custom_data:
141///   key: value
142/// "#;
143///
144/// let opts = CompileOptions { use_timestamp: false };
145/// let fafb_bytes = compile(yaml, &opts).unwrap();
146/// assert_eq!(&fafb_bytes[0..4], b"FAFB");
147/// assert_eq!(fafb_bytes[4], 2); // FAFb v2
148/// ```
149pub fn compile(yaml_source: &str, options: &CompileOptions) -> Result<Vec<u8>, String> {
150    let source_bytes = yaml_source.as_bytes();
151    if source_bytes.is_empty() {
152        return Err("Source content is empty".to_string());
153    }
154
155    let yaml: serde_yaml_ng::Value =
156        serde_yaml_ng::from_str(yaml_source).map_err(|e| format!("Invalid YAML: {}", e))?;
157
158    let mapping = yaml
159        .as_mapping()
160        .ok_or_else(|| "YAML root must be a mapping".to_string())?;
161
162    // Partition top-level keys: canonical chunks vs keys to fold into context.
163    let mut canonical_values: Vec<(&'static CanonicalChunk, serde_yaml_ng::Value)> = Vec::new();
164    let mut folded: Vec<(String, serde_yaml_ng::Value)> = Vec::new();
165
166    for (key, value) in mapping {
167        let key_str = key
168            .as_str()
169            .ok_or_else(|| "YAML key must be a string".to_string())?;
170        match canonical_chunk(key_str) {
171            Some(chunk) => canonical_values.push((chunk, value.clone())),
172            None => folded.push((key_str.to_string(), value.clone())),
173        }
174    }
175
176    // Fold non-canonical keys into the context chunk: authored context entries
177    // first, folded keys after, sorted alphabetically.
178    if !folded.is_empty() {
179        folded.sort_by(|a, b| a.0.cmp(&b.0));
180
181        let context_chunk = canonical_chunk("context").expect("context is canonical");
182        let existing = canonical_values
183            .iter_mut()
184            .find(|(chunk, _)| chunk.name == "context");
185
186        let mut context_map = match existing.as_ref().map(|(_, v)| v) {
187            None => serde_yaml_ng::Mapping::new(),
188            Some(serde_yaml_ng::Value::Mapping(m)) => m.clone(),
189            Some(_) => {
190                return Err("context must be a mapping to fold non-canonical keys into".to_string());
191            }
192        };
193
194        for (key, value) in folded {
195            let yaml_key = serde_yaml_ng::Value::String(key.clone());
196            if context_map.contains_key(&yaml_key) {
197                return Err(format!(
198                    "Cannot fold non-canonical key '{}' into context: key already exists there",
199                    key
200                ));
201            }
202            context_map.insert(yaml_key, value);
203        }
204
205        let merged = serde_yaml_ng::Value::Mapping(context_map);
206        match existing {
207            Some((_, v)) => *v = merged,
208            None => canonical_values.push((context_chunk, merged)),
209        }
210    }
211
212    if canonical_values.is_empty() {
213        return Err("No sections found in YAML".to_string());
214    }
215
216    // Canonical serialization order — same content, same bytes, any key order.
217    canonical_values.sort_by_key(|(chunk, _)| {
218        CANONICAL_CHUNKS
219            .iter()
220            .position(|c| c.name == chunk.name)
221            .expect("chunk is canonical")
222    });
223
224    // Build string table and sections in canonical order.
225    let mut string_table = StringTable::new();
226    let mut sections: Vec<(u8, ChunkClassification, Priority, Vec<u8>)> = Vec::new();
227
228    for (chunk, value) in &canonical_values {
229        let name_idx = string_table
230            .add(chunk.name)
231            .map_err(|e| format!("String table error: {}", e))?;
232
233        let content = serde_yaml_ng::to_string(value)
234            .map_err(|e| format!("Failed to serialize '{}': {}", chunk.name, e))?;
235        let data = format!("{}:\n{}", chunk.name, content).into_bytes();
236
237        sections.push((
238            name_idx,
239            chunk.classification,
240            Priority::new(chunk.priority),
241            data,
242        ));
243    }
244
245    if sections.len() > MAX_SECTIONS as usize {
246        return Err(format!(
247            "Too many sections: {} exceeds maximum {}",
248            sections.len(),
249            MAX_SECTIONS
250        ));
251    }
252
253    // Add __string_table__ name to string table before serializing
254    let st_name_idx = string_table
255        .add("__string_table__")
256        .map_err(|e| format!("String table error: {}", e))?;
257
258    let string_table_bytes = string_table
259        .to_bytes()
260        .map_err(|e| format!("String table serialization error: {}", e))?;
261
262    // Layout: [HEADER 32B] [section data...] [string table data] [section table entries...]
263    let mut data_offset: u32 = HEADER_SIZE as u32;
264    let mut section_data: Vec<u8> = Vec::new();
265    let mut section_table = SectionTable::new();
266
267    for (name_idx, classification, priority, data) in &sections {
268        let entry = SectionEntry::new(*name_idx, data_offset, data.len() as u32)
269            .with_priority(*priority)
270            .with_classification(*classification);
271
272        section_table.push(entry);
273        section_data.extend_from_slice(data);
274        data_offset = data_offset
275            .checked_add(data.len() as u32)
276            .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
277    }
278
279    // String table section (last content section)
280    let st_section_index = section_table.len() as u16;
281    let st_entry = SectionEntry::new(st_name_idx, data_offset, string_table_bytes.len() as u32)
282        .with_priority(Priority::critical());
283
284    section_table.push(st_entry);
285    section_data.extend_from_slice(&string_table_bytes);
286    data_offset = data_offset
287        .checked_add(string_table_bytes.len() as u32)
288        .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
289
290    let section_count = section_table.len();
291    let section_table_size = section_count * SECTION_ENTRY_SIZE;
292    let section_table_offset = data_offset;
293    let total_size = section_table_offset
294        .checked_add(section_table_size as u32)
295        .ok_or_else(|| "Total file size exceeds u32::MAX bytes".to_string())?;
296
297    if total_size > MAX_FILE_SIZE {
298        return Err(format!(
299            "Output size {} bytes exceeds maximum {} bytes (10MB)",
300            total_size, MAX_FILE_SIZE
301        ));
302    }
303
304    // Build header
305    let mut header = if options.use_timestamp {
306        FafbHeader::with_timestamp()
307    } else {
308        FafbHeader::new()
309    };
310    header.set_source_checksum(source_bytes);
311    header.section_count = section_count as u16;
312    header.section_table_offset = section_table_offset;
313    header.total_size = total_size;
314    header.string_table_index = st_section_index;
315
316    // Assemble binary
317    let mut output: Vec<u8> = Vec::with_capacity(total_size as usize);
318    header.write(&mut output).map_err(|e| e.to_string())?;
319    output.write_all(&section_data).map_err(|e| e.to_string())?;
320    section_table
321        .write(&mut output)
322        .map_err(|e| e.to_string())?;
323
324    if output.len() != total_size as usize {
325        return Err(format!(
326            "Internal error: size mismatch (expected {} bytes, got {} bytes)",
327            total_size,
328            output.len()
329        ));
330    }
331
332    Ok(output)
333}
334
335/// Decompile .fafb v2 binary bytes into a structured representation.
336///
337/// Parses header, section table, and string table. FAFb v1 binaries are
338/// rejected (`IncompatibleVersion`) — re-compile from the `.faf` source.
339///
340/// # Example
341///
342/// ```rust
343/// use faf_fafb::{compile, decompile, CompileOptions};
344///
345/// let yaml = "faf_version: 2.5.0\nproject:\n  name: test\n";
346/// let opts = CompileOptions { use_timestamp: false };
347/// let fafb_bytes = compile(yaml, &opts).unwrap();
348///
349/// let result = decompile(&fafb_bytes).unwrap();
350/// assert_eq!(result.header.version_major, 2);
351///
352/// let project = result.get_section_string_by_name("project").unwrap();
353/// assert!(project.contains("test"));
354/// ```
355pub fn decompile(fafb_bytes: &[u8]) -> FafbResult<DecompiledFafb> {
356    let header = FafbHeader::from_bytes(fafb_bytes)?;
357    header.validate(fafb_bytes)?;
358
359    // Read section table
360    let table_start = header.section_table_offset as usize;
361    let table_data = &fafb_bytes[table_start..];
362    let section_table = SectionTable::from_bytes(table_data, header.section_count as usize)?;
363    section_table.validate_bounds(header.total_size)?;
364
365    // Extract string table (required)
366    let st_index = header.string_table_index as usize;
367    if st_index >= section_table.len() {
368        return Err(FafbError::MissingStringTable);
369    }
370    let st_entry = section_table.get(st_index).unwrap();
371    let st_start = st_entry.offset as usize;
372    let st_end = st_start + st_entry.length as usize;
373    if st_end > fafb_bytes.len() {
374        return Err(FafbError::MissingStringTable);
375    }
376    let string_table = StringTable::from_bytes(&fafb_bytes[st_start..st_end])?;
377
378    Ok(DecompiledFafb {
379        header,
380        section_table,
381        data: fafb_bytes.to_vec(),
382        string_table,
383    })
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn opts() -> CompileOptions {
391        CompileOptions {
392            use_timestamp: false,
393        }
394    }
395
396    fn minimal_yaml() -> &'static str {
397        "faf_version: 2.5.0\nproject:\n  name: test-project\n"
398    }
399
400    fn full_yaml() -> &'static str {
401        r#"faf_version: 2.5.0
402project:
403  name: full-project
404  goal: Test the compiler
405tech_stack:
406  languages:
407    - Rust
408    - TypeScript
409commands:
410  build: cargo build
411  test: cargo test
412architecture:
413  style: microservices
414context:
415  notes: some context
416docs:
417  readme: README.md
418custom_field:
419  key: value
420another_custom:
421  deep:
422    nested: data
423"#
424    }
425
426    // ─── Core compile/decompile ───
427
428    #[test]
429    fn test_compile_produces_valid_v2_header() {
430        let bytes = compile(minimal_yaml(), &opts()).unwrap();
431        assert_eq!(&bytes[0..4], b"FAFB");
432        assert_eq!(bytes[4], 2); // FAFb v2
433        assert!(bytes.len() >= HEADER_SIZE);
434    }
435
436    #[test]
437    fn test_compile_empty_fails() {
438        assert!(compile("", &opts()).is_err());
439    }
440
441    #[test]
442    fn test_compile_options_default() {
443        let o = CompileOptions::default();
444        assert!(o.use_timestamp);
445    }
446
447    #[test]
448    fn test_roundtrip_minimal() {
449        let bytes = compile(minimal_yaml(), &opts()).unwrap();
450        let result = decompile(&bytes).unwrap();
451
452        assert_eq!(result.header.version_major, 2);
453        assert!(result.header.flags.has_string_table());
454
455        // faf_version + project + __string_table__
456        assert!(result.section_table.len() >= 3);
457
458        let project = result.get_section_string_by_name("project").unwrap();
459        assert!(project.contains("test-project"));
460    }
461
462    #[test]
463    fn test_v1_binaries_rejected() {
464        let mut bytes = compile(minimal_yaml(), &opts()).unwrap();
465        bytes[4] = 1; // forge a v1 header
466        let err = decompile(&bytes).unwrap_err();
467        assert!(matches!(
468            err,
469            FafbError::IncompatibleVersion { actual: 1, .. }
470        ));
471    }
472
473    #[test]
474    fn test_roundtrip_full() {
475        let bytes = compile(full_yaml(), &opts()).unwrap();
476        let result = decompile(&bytes).unwrap();
477
478        let st = result.string_table();
479        assert!(st.index_of("faf_version").is_some());
480        assert!(st.index_of("project").is_some());
481        assert!(st.index_of("tech_stack").is_some());
482        assert!(st.index_of("commands").is_some());
483
484        // Closed canonical: non-canonical keys never become section names.
485        // `docs` is NOT in faf-cli FafData → it folds, same as custom keys.
486        assert!(st.index_of("docs").is_none());
487        assert!(st.index_of("custom_field").is_none());
488        assert!(st.index_of("another_custom").is_none());
489    }
490
491    #[test]
492    fn test_decompile_invalid_magic() {
493        let bytes = vec![0u8; 32];
494        assert!(decompile(&bytes).is_err());
495    }
496
497    #[test]
498    fn test_decompile_too_small() {
499        let bytes = vec![0u8; 16];
500        assert!(decompile(&bytes).is_err());
501    }
502
503    #[test]
504    fn test_source_checksum() {
505        let yaml = full_yaml();
506        let bytes = compile(yaml, &opts()).unwrap();
507        let result = decompile(&bytes).unwrap();
508
509        let expected = FafbHeader::compute_checksum(yaml.as_bytes());
510        assert_eq!(result.header.source_checksum, expected);
511    }
512
513    #[test]
514    fn test_deterministic_without_timestamp() {
515        let yaml = minimal_yaml();
516        let bytes1 = compile(yaml, &opts()).unwrap();
517        let bytes2 = compile(yaml, &opts()).unwrap();
518        assert_eq!(bytes1, bytes2);
519    }
520
521    #[test]
522    fn test_canonical_order_key_order_independent() {
523        // Same content, shuffled top-level key order → byte-identical output.
524        // (The checksum seals the SOURCE, which differs — compare structure by
525        // zeroing the source_checksum field, bytes 8..12.)
526        let a = "faf_version: 2.5.0\nproject:\n  name: x\ncommands:\n  build: make\n";
527        let b = "commands:\n  build: make\nfaf_version: 2.5.0\nproject:\n  name: x\n";
528
529        let mut bytes_a = compile(a, &opts()).unwrap();
530        let mut bytes_b = compile(b, &opts()).unwrap();
531        for buf in [&mut bytes_a, &mut bytes_b] {
532            for byte in &mut buf[8..12] {
533                *byte = 0;
534            }
535        }
536        assert_eq!(bytes_a, bytes_b);
537    }
538
539    // ─── Folding (closed canonical) ───
540
541    #[test]
542    fn test_non_canonical_keys_folded_into_context() {
543        let yaml =
544            "faf_version: 2.5.0\nproject:\n  name: test\nmy_exotic_field:\n  data: preserved\n";
545        let bytes = compile(yaml, &opts()).unwrap();
546        let result = decompile(&bytes).unwrap();
547
548        // Not a section of its own…
549        assert!(result.string_table().index_of("my_exotic_field").is_none());
550        // …but fully preserved inside context.
551        let context = result.get_section_string_by_name("context").unwrap();
552        assert!(context.contains("my_exotic_field"));
553        assert!(context.contains("preserved"));
554    }
555
556    #[test]
557    fn test_folding_preserves_authored_context() {
558        let bytes = compile(full_yaml(), &opts()).unwrap();
559        let result = decompile(&bytes).unwrap();
560
561        let context = result.get_section_string_by_name("context").unwrap();
562        assert!(context.contains("notes")); // authored content kept
563        assert!(context.contains("custom_field")); // folded
564        assert!(context.contains("another_custom")); // folded
565    }
566
567    #[test]
568    fn test_folding_collision_is_an_error() {
569        let yaml = "project:\n  name: x\ncontext:\n  dupe: authored\ndupe: folded\n";
570        // 'dupe' is non-canonical and collides with context.dupe
571        assert!(compile(yaml, &opts()).is_err());
572    }
573
574    #[test]
575    fn test_folding_into_scalar_context_is_an_error() {
576        let yaml = "project:\n  name: x\ncontext: just a string\nweird_key: value\n";
577        assert!(compile(yaml, &opts()).is_err());
578    }
579
580    // ─── Section names ───
581
582    #[test]
583    fn test_section_names() {
584        let bytes = compile(full_yaml(), &opts()).unwrap();
585        let result = decompile(&bytes).unwrap();
586
587        for entry in result.section_table.entries() {
588            let name = result.section_name(entry);
589            assert!(!name.is_empty());
590            assert_ne!(name, "UNKNOWN");
591        }
592    }
593
594    #[test]
595    fn test_get_section_by_name() {
596        let bytes = compile(full_yaml(), &opts()).unwrap();
597        let result = decompile(&bytes).unwrap();
598
599        let project = result.get_section_string_by_name("project");
600        assert!(project.is_some());
601        assert!(project.unwrap().contains("full-project"));
602
603        // `docs` is non-canonical → folded into context, not its own section.
604        assert!(result.get_section_string_by_name("docs").is_none());
605        let context = result.get_section_string_by_name("context").unwrap();
606        assert!(context.contains("docs"));
607        assert!(context.contains("README.md"));
608    }
609
610    // ─── Classification ───
611
612    #[test]
613    fn test_classification_dna() {
614        let bytes = compile(full_yaml(), &opts()).unwrap();
615        let result = decompile(&bytes).unwrap();
616
617        let dna = result.dna_sections();
618        let dna_names: Vec<String> = dna.iter().map(|e| result.section_name(e)).collect();
619
620        assert!(dna_names.contains(&"faf_version".to_string()));
621        assert!(dna_names.contains(&"project".to_string()));
622        assert!(dna_names.contains(&"tech_stack".to_string()));
623        assert!(dna_names.contains(&"commands".to_string()));
624        assert!(dna_names.contains(&"architecture".to_string()));
625
626        // `context` is Context-class (the fold target), not DNA.
627        let ctx_names: Vec<String> = result
628            .context_sections()
629            .iter()
630            .map(|e| result.section_name(e))
631            .collect();
632        assert!(ctx_names.contains(&"context".to_string()));
633    }
634
635    #[test]
636    fn test_no_pointer_section_in_fafdata_truth() {
637        // faf-cli FafData has no `docs` (or any Pointer-class) top-level key,
638        // so a compiled brick has no Pointer section.
639        let bytes = compile(full_yaml(), &opts()).unwrap();
640        let result = decompile(&bytes).unwrap();
641        assert!(result.pointer_section().is_none());
642    }
643
644    // ─── String table ───
645
646    #[test]
647    fn test_string_table_flag_set() {
648        let bytes = compile(minimal_yaml(), &opts()).unwrap();
649        let result = decompile(&bytes).unwrap();
650        assert!(result.header.flags.has_string_table());
651    }
652
653    #[test]
654    fn test_string_table_index_valid() {
655        let bytes = compile(minimal_yaml(), &opts()).unwrap();
656        let result = decompile(&bytes).unwrap();
657
658        let st_idx = result.header.string_table_index as usize;
659        assert!(st_idx < result.section_table.len());
660    }
661
662    // ─── Priority ───
663
664    #[test]
665    fn test_priority_from_canon_table() {
666        let bytes = compile(full_yaml(), &opts()).unwrap();
667        let result = decompile(&bytes).unwrap();
668
669        for entry in result.section_table.entries() {
670            let name = result.section_name(entry);
671            match name.as_str() {
672                "faf_version" | "project" => assert!(entry.priority.is_critical()),
673                "commands" => assert_eq!(entry.priority.value(), 180),
674                "architecture" => assert_eq!(entry.priority.value(), 128),
675                "context" | "scores" => assert_eq!(entry.priority.value(), 64),
676                _ => {}
677            }
678        }
679    }
680
681    // ─── Canonical chunk coverage ───
682
683    #[test]
684    fn test_all_canonical_chunks_compile() {
685        // All 13 faf-cli FafData top-level keys, each becomes a section.
686        let yaml = r#"faf_version: 2.5.0
687project:
688  name: all-types
689app_type: cli
690about:
691  represents: Wolfe-Jam/source
692stack:
693  build: cargo
694human_context:
695  who: devs
696monorepo:
697  packages_count: 3
698tech_stack:
699  - Rust
700key_files:
701  - main.rs
702commands:
703  build: make
704architecture:
705  style: monolith
706scores:
707  total: 100
708context:
709  note: x
710"#;
711        let bytes = compile(yaml, &opts()).unwrap();
712        let result = decompile(&bytes).unwrap();
713
714        let st = result.string_table();
715        for key in &[
716            "faf_version",
717            "project",
718            "app_type",
719            "about",
720            "stack",
721            "human_context",
722            "monorepo",
723            "tech_stack",
724            "key_files",
725            "commands",
726            "architecture",
727            "scores",
728            "context",
729        ] {
730            assert!(
731                st.index_of(key).is_some(),
732                "Expected canonical chunk '{}' in string table",
733                key
734            );
735        }
736    }
737}