@@ -24,11 +24,11 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
2424 let mut current_list: Vec < String > = Vec :: new ( ) ;
2525
2626 for line in yaml_block. lines ( ) {
27- // List item: " - value"
27+ // List item: " - value" (supports spaces or tabs for indentation)
2828 if let Some ( stripped) = line. trim_start ( ) . strip_prefix ( "- " )
2929 && current_key. is_some ( )
3030 {
31- current_list. push ( stripped. trim ( ) . to_string ( ) ) ;
31+ current_list. push ( strip_yaml_comment ( stripped. trim ( ) ) ) ;
3232 continue ;
3333 }
3434
@@ -45,13 +45,13 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
4545 current_list. clear ( ) ;
4646 }
4747
48- let value = line[ colon_pos + 1 ..] . trim ( ) ;
48+ let value = strip_yaml_comment ( line[ colon_pos + 1 ..] . trim ( ) ) ;
4949
5050 if value. is_empty ( ) || value == "[]" {
5151 current_key = Some ( key. to_string ( ) ) ;
5252 current_list. clear ( ) ;
5353 } else {
54- set_scalar ( & mut fm, key, value) ;
54+ set_scalar ( & mut fm, key, & value) ;
5555 }
5656 continue ;
5757 }
@@ -77,6 +77,25 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
7777 } )
7878}
7979
80+ /// Strip inline YAML comments from a value.
81+ /// Handles: `value # comment` → `value`
82+ /// Preserves: `value` (no comment), quoted strings with `#` inside.
83+ fn strip_yaml_comment ( value : & str ) -> String {
84+ // Don't strip from quoted strings or bracket arrays
85+ if value. starts_with ( '"' ) || value. starts_with ( '\'' ) || value. starts_with ( '[' ) {
86+ return value. to_string ( ) ;
87+ }
88+ // Find ` # ` pattern (space-hash-space) which is a YAML comment
89+ if let Some ( pos) = value. find ( " #" ) {
90+ // Verify the # is followed by a space or is at end of string (YAML comment convention)
91+ let after = & value[ pos + 2 ..] ;
92+ if after. is_empty ( ) || after. starts_with ( ' ' ) {
93+ return value[ ..pos] . trim_end ( ) . to_string ( ) ;
94+ }
95+ }
96+ value. to_string ( )
97+ }
98+
8099fn set_scalar ( fm : & mut Frontmatter , key : & str , value : & str ) {
81100 match key {
82101 "module" => fm. module = Some ( value. to_string ( ) ) ,
@@ -123,6 +142,22 @@ fn set_field(fm: &mut Frontmatter, key: &str, values: &[String]) {
123142 }
124143}
125144
145+ /// Check if a ### header describes exported symbols (case-insensitive).
146+ /// Matches headers containing "Exported", "Exports", "Export", or "Public" as keywords.
147+ /// Examples that match:
148+ /// "### Exported Functions", "### TypeScript Exports", "### Exports",
149+ /// "### Public Types", "### Export Functions", "### Exported Symbols"
150+ /// Examples that do NOT match:
151+ /// "### API Endpoints", "### Component API", "### Configuration",
152+ /// "### Internal Functions", "### Route Handlers"
153+ pub fn is_export_header ( header : & str ) -> bool {
154+ let lower = header. to_ascii_lowercase ( ) ;
155+ lower. contains ( "exported" )
156+ || lower. contains ( "exports" )
157+ || lower. contains ( "export " )
158+ || lower. contains ( "public " )
159+ }
160+
126161static TABLE_ROW_RE : LazyLock < Regex > = LazyLock :: new ( || Regex :: new ( r"^\|\s*`(\w+)`" ) . unwrap ( ) ) ;
127162
128163static METHOD_HEADER_RE : LazyLock < Regex > =
@@ -179,12 +214,16 @@ pub fn get_spec_symbols(body: &str) -> Vec<String> {
179214 . find ( |l| !l. is_empty ( ) )
180215 . unwrap_or ( "" ) ;
181216
182- // Allowlist: only validate tables under ### headers containing "Exported"
183- // (e.g., "### Exported Functions", "### Exported Types").
217+ // Allowlist: only validate tables under ### headers that describe exports.
218+ // Accepted patterns (case-insensitive):
219+ // - "### Exported Functions", "### Exported Types" (contains "Exported")
220+ // - "### TypeScript Exports", "### Exports" (contains "Exports")
221+ // - "### Public Functions", "### Public Types" (contains "Public")
222+ // - "### Exported Symbols", "### Export Types" (contains "Export")
184223 // Tables directly under ## Public API (no ### header) are also validated.
185224 // Everything else (### API Endpoints, ### Component API, ### Route Handlers,
186225 // ### Configuration, ### Internal Functions, etc.) is informational only.
187- if header. starts_with ( "### " ) && !header . contains ( "Exported" ) {
226+ if header. starts_with ( "### " ) && !is_export_header ( header ) {
188227 continue ;
189228 }
190229
@@ -246,6 +285,42 @@ mod tests {
246285 assert ! ( parsed. frontmatter. db_tables. is_empty( ) ) ;
247286 }
248287
288+ #[ test]
289+ fn test_strip_yaml_comment ( ) {
290+ assert_eq ! ( strip_yaml_comment( "active" ) , "active" ) ;
291+ assert_eq ! ( strip_yaml_comment( "active # this is the status" ) , "active" ) ;
292+ assert_eq ! ( strip_yaml_comment( "value #no-space-means-not-comment" ) , "value #no-space-means-not-comment" ) ;
293+ assert_eq ! ( strip_yaml_comment( "[42, 57] # issue list" ) , "[42, 57] # issue list" ) ; // brackets preserved
294+ assert_eq ! ( strip_yaml_comment( "\" quoted # value\" " ) , "\" quoted # value\" " ) ; // quotes preserved
295+ assert_eq ! ( strip_yaml_comment( "value #" ) , "value" ) ;
296+ }
297+
298+ #[ test]
299+ fn test_parse_frontmatter_inline_comments ( ) {
300+ let content = "---\n module: auth # the auth module\n version: 1 # initial\n status: active # current status\n files:\n - src/auth.ts # main file\n ---\n \n # Auth\n " ;
301+ let parsed = parse_frontmatter ( content) . unwrap ( ) ;
302+ assert_eq ! ( parsed. frontmatter. module. as_deref( ) , Some ( "auth" ) ) ;
303+ assert_eq ! ( parsed. frontmatter. version. as_deref( ) , Some ( "1" ) ) ;
304+ assert_eq ! ( parsed. frontmatter. status. as_deref( ) , Some ( "active" ) ) ;
305+ assert_eq ! ( parsed. frontmatter. files, vec![ "src/auth.ts" ] ) ;
306+ }
307+
308+ #[ test]
309+ fn test_parse_frontmatter_tabs_and_whitespace ( ) {
310+ // Tabs used for indentation instead of spaces
311+ let content = "---\n module: auth\n version: 1\n status: active\n files:\n \t - src/auth.ts\n \t - src/auth.utils.ts\n ---\n \n # Auth\n " ;
312+ let parsed = parse_frontmatter ( content) . unwrap ( ) ;
313+ assert_eq ! ( parsed. frontmatter. files, vec![ "src/auth.ts" , "src/auth.utils.ts" ] ) ;
314+ }
315+
316+ #[ test]
317+ fn test_parse_frontmatter_trailing_spaces ( ) {
318+ let content = "---\n module: auth \n version: 1 \n status: active \n files:\n - src/auth.ts \n ---\n \n # Auth\n " ;
319+ let parsed = parse_frontmatter ( content) . unwrap ( ) ;
320+ assert_eq ! ( parsed. frontmatter. module. as_deref( ) , Some ( "auth" ) ) ;
321+ assert_eq ! ( parsed. frontmatter. files, vec![ "src/auth.ts" ] ) ;
322+ }
323+
249324 #[ test]
250325 fn test_parse_frontmatter_missing ( ) {
251326 let content = "# No frontmatter here\n \n Just markdown." ;
@@ -368,6 +443,57 @@ Something
368443 assert ! ( parsed. frontmatter. tracks. is_empty( ) ) ;
369444 }
370445
446+ #[ test]
447+ fn test_is_export_header ( ) {
448+ // Should match
449+ assert ! ( is_export_header( "### Exported Functions" ) ) ;
450+ assert ! ( is_export_header( "### Exported Types" ) ) ;
451+ assert ! ( is_export_header( "### TypeScript Exports" ) ) ;
452+ assert ! ( is_export_header( "### Exports" ) ) ;
453+ assert ! ( is_export_header( "### Public Functions" ) ) ;
454+ assert ! ( is_export_header( "### Public Types" ) ) ;
455+ assert ! ( is_export_header( "### Export Types" ) ) ;
456+ assert ! ( is_export_header( "### Exported Symbols" ) ) ;
457+ assert ! ( is_export_header( "### exported functions" ) ) ; // case-insensitive
458+
459+ // Should NOT match
460+ assert ! ( !is_export_header( "### API Endpoints" ) ) ;
461+ assert ! ( !is_export_header( "### Component API" ) ) ;
462+ assert ! ( !is_export_header( "### Route Handlers" ) ) ;
463+ assert ! ( !is_export_header( "### Configuration" ) ) ;
464+ assert ! ( !is_export_header( "### Internal Functions" ) ) ;
465+ }
466+
467+ #[ test]
468+ fn test_get_spec_symbols_accepts_header_variations ( ) {
469+ let body = r#"## Public API
470+
471+ ### TypeScript Exports
472+
473+ | Function | Description |
474+ |----------|-------------|
475+ | `createAuth` | Creates auth |
476+ | `validateToken` | Validates |
477+
478+ ### Public Types
479+
480+ | Type | Description |
481+ |------|-------------|
482+ | `AuthConfig` | Config type |
483+
484+ ### API Endpoints
485+
486+ | Endpoint | Method |
487+ |----------|--------|
488+ | `/login` | POST |
489+
490+ ## Invariants
491+ "# ;
492+ let symbols = get_spec_symbols ( body) ;
493+ // Should extract from "TypeScript Exports" and "Public Types" but not "API Endpoints"
494+ assert_eq ! ( symbols, vec![ "createAuth" , "validateToken" , "AuthConfig" ] ) ;
495+ }
496+
371497 #[ test]
372498 fn test_get_spec_symbols_top_level_table ( ) {
373499 // Tables directly under ## Public API (no ### header) should be validated
0 commit comments