Skip to main content

mcp_execution_skill/
parser.rs

1//! Server metadata sidecar reader.
2//!
3//! Reads the structured `_meta.json` sidecar emitted by
4//! `mcp-execution-codegen` alongside a server's generated TypeScript tool
5//! files, and maps it into this crate's [`ParsedToolFile`] / [`ParsedParameter`]
6//! types for skill generation.
7//!
8//! Prior to this module, tool metadata was recovered by re-parsing the
9//! generated `.ts` files with regexes — a lossy, fragile round-trip that,
10//! among other issues, could never recover parameter descriptions. The
11//! sidecar is a structured, serde-derived contract shared with codegen via
12//! `mcp_execution_core::metadata`, so no re-parsing of generated source is
13//! needed at all.
14
15use mcp_execution_core::metadata::{METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ServerMetadata};
16use regex::Regex;
17use serde::Deserialize;
18use std::path::Path;
19use std::sync::LazyLock;
20use thiserror::Error;
21
22/// Maximum number of tools accepted from a single sidecar (denial-of-service protection).
23pub const MAX_TOOL_FILES: usize = 500;
24
25/// Maximum sidecar file size to read in bytes (1MB).
26pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
27
28/// Maximum size of a `SKILL.md`'s extracted YAML frontmatter block, in bytes.
29///
30/// `serde_norway` (like other libyaml-based parsers) is not linear-time on
31/// pathologically nested input (e.g. deeply nested flow sequences), so
32/// bounding only the overall `SKILL.md` content size does not bound parse
33/// latency. A real `name`/`description` frontmatter is a few hundred bytes at
34/// most, so 8KB is already generous while keeping [`extract_skill_metadata`]
35/// cheap enough to run synchronously on `save_skill`'s request-handling task.
36///
37/// This cap is the project-wide contract for any YAML entry point, not a local
38/// detail of this one — see the project constitution's security section.
39pub const MAX_FRONTMATTER_SIZE: usize = 8 * 1024;
40
41// Locates the raw YAML block between a SKILL.md's `---` delimiters. The
42// block's contents are handed to `serde_norway` for actual parsing, so this
43// regex never inspects individual field values (see `extract_skill_metadata`).
44static FRONTMATTER_REGEX: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
46
47// `sanitize_path_for_error` lives in `mcp-execution-core`, the workspace's
48// security-validation foundation crate.
49use mcp_execution_core::sanitize_path_for_error;
50
51/// Errors that can occur while scanning a server directory for its `_meta.json` sidecar.
52#[derive(Debug, Error)]
53pub enum ScanError {
54    /// I/O error reading the directory or sidecar file.
55    #[error("I/O error: {0}")]
56    Io(#[from] std::io::Error),
57
58    /// Directory does not exist.
59    #[error("directory does not exist: {path}")]
60    DirectoryNotFound {
61        /// Sanitized path of the missing directory.
62        path: String,
63    },
64
65    /// The `_meta.json` sidecar is missing from the server directory.
66    #[error("metadata sidecar not found: {path} (was the server directory regenerated?)")]
67    MissingMetadata {
68        /// Sanitized path of the expected sidecar file.
69        path: String,
70    },
71
72    /// The `_meta.json` sidecar could not be parsed as valid `ServerMetadata` JSON.
73    #[error("failed to parse metadata sidecar {path}: {source}")]
74    MetadataParse {
75        /// Sanitized path of the sidecar file that failed to parse.
76        path: String,
77        /// Underlying JSON deserialization error.
78        #[source]
79        source: serde_json::Error,
80    },
81
82    /// The sidecar's `schema_version` does not match the version this crate understands.
83    #[error("unsupported metadata schema version: found {found}, expected {expected}")]
84    UnsupportedSchema {
85        /// Schema version read from the sidecar.
86        found: u32,
87        /// Schema version this crate supports (`METADATA_SCHEMA_VERSION`).
88        expected: u32,
89    },
90
91    /// Too many tools in the sidecar (denial-of-service protection).
92    #[error("too many tools: {count} exceeds limit of {limit}")]
93    TooManyFiles {
94        /// Number of tools listed in the sidecar.
95        count: usize,
96        /// Maximum allowed number of tools (`MAX_TOOL_FILES`).
97        limit: usize,
98    },
99
100    /// Sidecar file too large to process.
101    #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
102    FileTooLarge {
103        /// Sanitized path of the oversized sidecar file.
104        path: String,
105        /// Actual size of the file, in bytes.
106        size: u64,
107        /// Maximum allowed size, in bytes (`MAX_FILE_SIZE`).
108        limit: u64,
109    },
110
111    /// A tool listed in the `_meta.json` sidecar has no corresponding `.ts`
112    /// file on disk.
113    ///
114    /// This indicates the sidecar and the generated TypeScript files have
115    /// drifted apart — e.g. the file was deleted manually, or a `generate`
116    /// run was interrupted before writing it.
117    #[error(
118        "stale metadata: tool '{tool}' is listed in {sidecar_path} but its file '{expected_file}' \
119         is missing (re-run 'generate' to regenerate this server)"
120    )]
121    StaleMetadata {
122        /// MCP tool name listed in the sidecar.
123        tool: String,
124        /// `.ts` file name expected on disk for `tool`.
125        expected_file: String,
126        /// Sanitized path of the sidecar that references `tool`.
127        sidecar_path: String,
128    },
129}
130
131/// Result of [`scan_tools_directory`]: the parsed tools plus any non-fatal
132/// drift warnings.
133///
134/// A warning does not fail the scan — it flags a `.ts` file on disk that was
135/// excluded from `tools` because the sidecar has no matching entry for it.
136/// Callers that only inspect `tools` would otherwise have no way to detect
137/// this drift short of tailing server-side `tracing` output.
138#[derive(Debug, Clone, Default)]
139pub struct ScanResult {
140    /// Parsed tools, sorted by name.
141    pub tools: Vec<ParsedToolFile>,
142
143    /// Non-fatal warnings, e.g. `.ts` files excluded for lacking a sidecar entry.
144    pub warnings: Vec<String>,
145}
146
147/// Parsed metadata from a server's generated tool set.
148#[derive(Debug, Clone)]
149pub struct ParsedToolFile {
150    /// Original MCP tool name.
151    pub name: String,
152
153    /// TypeScript function name (`PascalCase` filename).
154    pub typescript_name: String,
155
156    /// Server identifier.
157    pub server_id: String,
158
159    /// Category for grouping.
160    pub category: Option<String>,
161
162    /// Keywords for discovery.
163    pub keywords: Vec<String>,
164
165    /// Tool description.
166    pub description: Option<String>,
167
168    /// Parsed parameters for the tool.
169    pub parameters: Vec<ParsedParameter>,
170}
171
172/// A parsed parameter from a tool's metadata.
173#[derive(Debug, Clone)]
174pub struct ParsedParameter {
175    /// Parameter name.
176    pub name: String,
177
178    /// TypeScript type (e.g., "string", "number", "boolean").
179    pub typescript_type: String,
180
181    /// Whether the parameter is required.
182    pub required: bool,
183
184    /// Parameter description.
185    pub description: Option<String>,
186}
187
188impl From<mcp_execution_core::metadata::ParameterMetadata> for ParsedParameter {
189    fn from(meta: mcp_execution_core::metadata::ParameterMetadata) -> Self {
190        Self {
191            name: meta.name,
192            typescript_type: meta.typescript_type,
193            required: meta.required,
194            description: meta.description,
195        }
196    }
197}
198
199/// Builds a [`ParsedToolFile`] from a sidecar tool entry and the server ID it belongs to.
200///
201/// A plain function rather than a `From<ToolMetadata>` impl: `ToolMetadata` carries no
202/// `server_id` of its own (it lives once on the enclosing [`ServerMetadata`]), so a `From` impl
203/// could only ever produce a `ParsedToolFile` with a placeholder `server_id` that every caller
204/// then had to patch in after construction — a representable-but-wrong intermediate state.
205/// Taking `server_id` as a parameter removes that sentinel from this construction path;
206/// `ParsedToolFile`'s fields remain public, so callers that build one directly (e.g. test
207/// fixtures) are unaffected and can still set an arbitrary `server_id` themselves.
208fn parsed_tool_file_from_metadata(
209    meta: mcp_execution_core::metadata::ToolMetadata,
210    server_id: &str,
211) -> ParsedToolFile {
212    ParsedToolFile {
213        name: meta.name.into_inner(),
214        typescript_name: meta.typescript_name,
215        server_id: server_id.to_string(),
216        category: meta.category,
217        keywords: meta.keywords,
218        description: meta.description,
219        parameters: meta.parameters.into_iter().map(Into::into).collect(),
220    }
221}
222
223/// Scan a server directory and read its `_meta.json` sidecar.
224///
225/// Reads the structured metadata sidecar written by `mcp-execution-codegen`
226/// and maps each tool entry into a [`ParsedToolFile`]. Unlike the former
227/// regex-based `.ts` scanner, tool metadata (name, category, keywords,
228/// parameters) is never re-parsed from generated TypeScript source — the
229/// sidecar remains the single source of truth for that. However, each
230/// sidecar entry's `.ts` file is cross-checked for existence on disk to
231/// detect drift between the sidecar and the generated files (see issues
232/// #154, #155): a missing file is a hard error, while an unreferenced `.ts`
233/// file on disk is logged via `tracing::warn!`, omitted from the result, and
234/// named in the returned [`ScanResult::warnings`] (see issue #161).
235///
236/// # Arguments
237///
238/// * `dir` - Path to server directory (e.g., `~/.claude/servers/github`)
239///
240/// # Returns
241///
242/// [`ScanResult`] with one `ParsedToolFile` per tool in the sidecar (sorted
243/// by name) plus any non-fatal drift warnings.
244///
245/// # Errors
246///
247/// Returns `ScanError` if the directory doesn't exist, the sidecar is
248/// missing or malformed, the sidecar's tool count exceeds
249/// [`MAX_TOOL_FILES`], or a sidecar entry's `.ts` file is missing from disk
250/// ([`ScanError::StaleMetadata`]).
251///
252/// # Examples
253///
254/// ```no_run
255/// use mcp_execution_skill::scan_tools_directory;
256/// use std::path::Path;
257///
258/// # async fn example() -> Result<(), mcp_execution_skill::ScanError> {
259/// let result = scan_tools_directory(Path::new("/home/user/.claude/servers/github")).await?;
260/// println!("Found {} tools", result.tools.len());
261/// # Ok(())
262/// # }
263/// ```
264pub async fn scan_tools_directory(dir: &Path) -> Result<ScanResult, ScanError> {
265    // Canonicalize the base directory to resolve symlinks and get absolute path
266    let canonical_base = tokio::fs::canonicalize(dir).await.map_err(|err| {
267        if err.kind() == std::io::ErrorKind::NotFound {
268            ScanError::DirectoryNotFound {
269                path: sanitize_path_for_error(dir),
270            }
271        } else {
272            ScanError::Io(err)
273        }
274    })?;
275
276    let meta_path = canonical_base.join(METADATA_FILE_NAME);
277
278    // SECURITY: Canonicalize the sidecar path and validate it stays within the base
279    // directory, preventing path traversal via a symlinked `_meta.json`.
280    let canonical_meta = match tokio::fs::canonicalize(&meta_path).await {
281        Ok(path) if path.starts_with(&canonical_base) => path,
282        Ok(_) => {
283            return Err(ScanError::MissingMetadata {
284                path: sanitize_path_for_error(&meta_path),
285            });
286        }
287        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
288            return Err(ScanError::MissingMetadata {
289                path: sanitize_path_for_error(&meta_path),
290            });
291        }
292        Err(err) => return Err(ScanError::Io(err)),
293    };
294
295    let file_metadata = tokio::fs::metadata(&canonical_meta).await?;
296    if file_metadata.len() > MAX_FILE_SIZE {
297        return Err(ScanError::FileTooLarge {
298            path: sanitize_path_for_error(&meta_path),
299            size: file_metadata.len(),
300            limit: MAX_FILE_SIZE,
301        });
302    }
303
304    let content = tokio::fs::read_to_string(&canonical_meta).await?;
305
306    let meta: ServerMetadata =
307        serde_json::from_str(&content).map_err(|source| ScanError::MetadataParse {
308            path: sanitize_path_for_error(&meta_path),
309            source,
310        })?;
311
312    if meta.schema_version != METADATA_SCHEMA_VERSION {
313        return Err(ScanError::UnsupportedSchema {
314            found: meta.schema_version,
315            expected: METADATA_SCHEMA_VERSION,
316        });
317    }
318
319    if meta.tools.len() > MAX_TOOL_FILES {
320        return Err(ScanError::TooManyFiles {
321            count: meta.tools.len(),
322            limit: MAX_TOOL_FILES,
323        });
324    }
325
326    let warnings = verify_tool_files_on_disk(&canonical_base, &meta.tools, &meta_path).await?;
327
328    let server_id = meta.server_id.into_inner();
329    let mut tools: Vec<ParsedToolFile> = meta
330        .tools
331        .into_iter()
332        .map(|tool| parsed_tool_file_from_metadata(tool, &server_id))
333        .collect();
334
335    // Sort by name for consistent ordering
336    tools.sort_by(|a, b| a.name.cmp(&b.name));
337
338    Ok(ScanResult { tools, warnings })
339}
340
341/// Cross-checks sidecar tool entries against the `.ts` files actually
342/// present in `dir`, guarding against drift between `_meta.json` and the
343/// generated TypeScript output (see issues #154, #155).
344///
345/// Every sidecar entry must have a matching `{typescript_name}.ts` file, or
346/// this returns [`ScanError::StaleMetadata`]. `.ts` files present on disk
347/// but not referenced by the sidecar are not fatal — regenerating tool
348/// files is a normal part of `generate` — but are logged via
349/// `tracing::warn!` and returned as human-readable warning strings so the
350/// drift isn't silently dropped from `SKILL.md` or invisible to structured
351/// callers (issue #161).
352///
353/// # Errors
354///
355/// Returns `ScanError::Io` if the directory cannot be read, or
356/// `ScanError::StaleMetadata` if a sidecar entry's `.ts` file is missing.
357async fn verify_tool_files_on_disk(
358    dir: &Path,
359    tools: &[mcp_execution_core::metadata::ToolMetadata],
360    meta_path: &Path,
361) -> Result<Vec<String>, ScanError> {
362    // Generated aggregator file, not a per-tool file — never expected in the sidecar.
363    const INDEX_FILE_NAME: &str = "index.ts";
364
365    let mut expected_files: std::collections::HashSet<String> =
366        std::collections::HashSet::with_capacity(tools.len());
367
368    for tool in tools {
369        let file_name = format!("{}.ts", tool.typescript_name);
370        if !dir.join(&file_name).is_file() {
371            return Err(ScanError::StaleMetadata {
372                tool: tool.name.to_string(),
373                expected_file: file_name,
374                sidecar_path: sanitize_path_for_error(meta_path),
375            });
376        }
377        expected_files.insert(file_name);
378    }
379
380    let mut warnings = Vec::new();
381    let mut entries = tokio::fs::read_dir(dir).await?;
382    while let Some(entry) = entries.next_entry().await? {
383        let path = entry.path();
384        if path.extension().and_then(std::ffi::OsStr::to_str) != Some("ts") {
385            continue;
386        }
387        let Some(file_name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
388            continue;
389        };
390        if file_name == INDEX_FILE_NAME || expected_files.contains(file_name) {
391            continue;
392        }
393        tracing::warn!(
394            file = %file_name,
395            "found .ts tool file not referenced by _meta.json; it will be omitted from SKILL.md \
396             (re-run 'generate' to refresh the sidecar)"
397        );
398        warnings.push(format!(
399            "'{file_name}' is not referenced by _meta.json and was excluded from SKILL.md \
400             (re-run 'generate' to refresh the sidecar)"
401        ));
402    }
403
404    Ok(warnings)
405}
406
407/// Errors that can occur while extracting [`SkillMetadata`](crate::types::SkillMetadata)
408/// from a `SKILL.md`'s YAML frontmatter.
409#[derive(Debug, Error)]
410pub enum SkillMetadataError {
411    /// The content did not start with a `---`-delimited YAML frontmatter block.
412    #[error("YAML frontmatter not found")]
413    MissingFrontmatter,
414
415    /// The extracted frontmatter block exceeded `MAX_FRONTMATTER_SIZE`.
416    #[error("YAML frontmatter too large: {size} bytes exceeds {limit} limit")]
417    FrontmatterTooLarge {
418        /// Actual size of the rejected frontmatter block, in bytes.
419        size: usize,
420        /// Maximum allowed size (`MAX_FRONTMATTER_SIZE`).
421        limit: usize,
422    },
423
424    /// The frontmatter block was not valid YAML.
425    ///
426    /// The message is a rendering of the underlying `serde_norway` error
427    /// (captured eagerly rather than storing the error type itself, so this
428    /// crate's public API is not pinned to a specific `serde_norway`
429    /// version) with its line number corrected to be relative to the whole
430    /// `SKILL.md` file rather than the extracted frontmatter block — the
431    /// block starts one line after the file's opening `---` delimiter.
432    #[error("failed to parse YAML frontmatter: {0}")]
433    InvalidYaml(String),
434
435    /// A required field was absent, or present but empty, in an otherwise
436    /// valid frontmatter block.
437    #[error("'{field}' field is missing or empty in frontmatter")]
438    MissingField {
439        /// Name of the missing/empty field (e.g. `"name"`, `"description"`).
440        field: &'static str,
441    },
442}
443
444/// Renders a `serde_norway` deserialization error, correcting its line number
445/// to be relative to the whole `SKILL.md` file rather than the frontmatter
446/// block passed to `serde_norway::from_str` (see
447/// [`SkillMetadataError::InvalidYaml`]).
448fn describe_yaml_error(err: &serde_norway::Error) -> String {
449    let rendered = err.to_string();
450    let Some(location) = err.location() else {
451        return rendered;
452    };
453    // The block starts one line after the file's opening `---`, so the
454    // block-relative line number under-counts the file line by exactly one.
455    let block_relative = format!("line {} column {}", location.line(), location.column());
456    let file_relative = format!("line {} column {}", location.line() + 1, location.column());
457    rendered.replacen(&block_relative, &file_relative, 1)
458}
459
460/// Raw shape of a `SKILL.md`'s YAML frontmatter block.
461///
462/// Both fields are optional at the YAML level so that a missing `name` and a
463/// missing `description` are reported as distinct
464/// [`SkillMetadataError::MissingField`] variants rather than being folded
465/// into one generic deserialization failure.
466///
467/// # Regression coverage (issue #359)
468///
469/// Both fields being plain `Option<String>` (no `#[serde(flatten)]`, no buffering
470/// `deserialize_with`, no untagged/`Value` retype) is why an alias bomb placed under an
471/// undeclared key or under `description` itself is discarded today without expanding nested
472/// aliases (`tests::test_extract_skill_metadata_alias_bomb_under_unknown_key_stays_ok`,
473/// `tests::test_extract_skill_metadata_alias_bomb_under_declared_field_short_circuits`). If
474/// `description` (or `name`) is ever changed to a buffering type — `serde_norway::Value`, an
475/// untagged enum, or a `#[serde(deserialize_with)]` that internally buffers through one of
476/// those while keeping the field declared as `Option<String>` — that change reopens the
477/// amplification path for a bomb placed directly under the affected key, and the
478/// declared-field test above flips loudly (its error stops being a plain type mismatch and
479/// becomes `serde_norway`'s "repetition limit exceeded"). A `Value`/untagged retype is also
480/// compile-blocked at `require_field`'s `Option<String>` parameter in the call below; a
481/// buffering `deserialize_with` is not, since the declared type is unchanged. Both underlying
482/// assumptions are additionally pinned in isolation, against local test-only structs shaped
483/// like each hypothetical retype, by
484/// `tests::test_serde_norway_buffers_alias_bomb_when_declared_field_is_value_typed` and
485/// `tests::test_serde_norway_buffers_alias_bomb_when_declared_field_uses_deserialize_with`. If
486/// this struct's field shape is ever changed in either direction, all three tests above need
487/// re-checking against the new shape.
488#[derive(Debug, Deserialize)]
489struct RawFrontmatter {
490    name: Option<String>,
491    description: Option<String>,
492}
493
494/// Returns `value` if present and non-blank, otherwise a
495/// [`SkillMetadataError::MissingField`] naming `field`.
496///
497/// Treats an absent key, a null/empty scalar (`name:`, `name: null`,
498/// `name: ~`), and a blank string (`name: ""`, `name: "   "`) as equally
499/// invalid — a skill with an empty name or description is not usable.
500fn require_field(value: Option<String>, field: &'static str) -> Result<String, SkillMetadataError> {
501    match value {
502        Some(v) if !v.trim().is_empty() => Ok(v),
503        _ => Err(SkillMetadataError::MissingField { field }),
504    }
505}
506
507/// Extract skill metadata from SKILL.md content.
508///
509/// Parses YAML frontmatter to extract name and description, and counts
510/// sections (H2 headers) and words. Frontmatter is parsed with a real YAML
511/// parser (`serde_norway`), so block scalars (`description: |` / `>`) and
512/// quoted scalars (`name: "my-name"`) are handled per the YAML spec rather
513/// than by a single-line regex capture.
514///
515/// # Arguments
516///
517/// * `content` - SKILL.md content with YAML frontmatter
518///
519/// # Returns
520///
521/// `SkillMetadata` with extracted information.
522///
523/// # Errors
524///
525/// Returns [`SkillMetadataError`] if the YAML frontmatter is missing, too
526/// large (`MAX_FRONTMATTER_SIZE`), malformed, or a required field (`name`,
527/// `description`) is absent or empty.
528///
529/// # Examples
530///
531/// ```
532/// use mcp_execution_skill::extract_skill_metadata;
533///
534/// let content = r"---
535/// name: github-progressive
536/// description: GitHub MCP server operations
537/// ---
538///
539/// # GitHub Progressive
540///
541/// ## Quick Start
542///
543/// Content here.
544/// ";
545///
546/// let metadata = extract_skill_metadata(content).unwrap();
547/// assert_eq!(metadata.name, "github-progressive");
548/// assert_eq!(metadata.description, "GitHub MCP server operations");
549/// ```
550pub fn extract_skill_metadata(
551    content: &str,
552) -> Result<crate::types::SkillMetadata, SkillMetadataError> {
553    use crate::types::SkillMetadata;
554
555    // Locate the raw YAML block between the `---` delimiters (using pre-compiled regex).
556    let frontmatter_block = FRONTMATTER_REGEX
557        .captures(content)
558        .and_then(|c| c.get(1))
559        .map(|m| m.as_str())
560        .ok_or(SkillMetadataError::MissingFrontmatter)?;
561
562    // Bound parse cost before handing the block to `serde_norway`: YAML parsing is not
563    // linear-time on pathologically nested input, so the overall SKILL.md size limit
564    // (`MAX_SKILL_CONTENT_SIZE` in mcp-execution-server) does not bound parse latency.
565    if frontmatter_block.len() > MAX_FRONTMATTER_SIZE {
566        return Err(SkillMetadataError::FrontmatterTooLarge {
567            size: frontmatter_block.len(),
568            limit: MAX_FRONTMATTER_SIZE,
569        });
570    }
571
572    let frontmatter: RawFrontmatter = serde_norway::from_str(frontmatter_block)
573        .map_err(|e| SkillMetadataError::InvalidYaml(describe_yaml_error(&e)))?;
574
575    let name = require_field(frontmatter.name, "name")?;
576    let description = require_field(frontmatter.description, "description")?;
577
578    // Count sections (H2 headers)
579    let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
580
581    // Count words (approximate)
582    let word_count = content.split_whitespace().count();
583
584    Ok(SkillMetadata {
585        name,
586        description,
587        section_count,
588        word_count,
589    })
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use mcp_execution_core::metadata::{ParameterMetadata, ToolMetadata};
596    use mcp_execution_core::{ServerId, ToolName};
597    use tempfile::TempDir;
598
599    fn sample_metadata(tool_count: usize) -> ServerMetadata {
600        ServerMetadata {
601            schema_version: METADATA_SCHEMA_VERSION,
602            server_id: ServerId::new("github").unwrap(),
603            server_name: "GitHub".to_string(),
604            server_version: "1.0.0".to_string(),
605            tools: (0..tool_count)
606                .map(|i| ToolMetadata {
607                    name: ToolName::new(format!("tool_{i}")).unwrap(),
608                    typescript_name: format!("tool{i}"),
609                    category: Some("test".to_string()),
610                    keywords: vec!["test".to_string()],
611                    description: Some(format!("Tool {i}")),
612                    parameters: vec![ParameterMetadata {
613                        name: "param".to_string(),
614                        typescript_type: "string".to_string(),
615                        required: true,
616                        description: Some("A parameter".to_string()),
617                    }],
618                })
619                .collect(),
620        }
621    }
622
623    /// Writes `_meta.json` plus a matching stub `.ts` file for each tool, since
624    /// `scan_tools_directory` cross-checks the sidecar against files on disk.
625    async fn write_metadata(dir: &Path, meta: &ServerMetadata) {
626        let content = serde_json::to_string_pretty(meta).unwrap();
627        tokio::fs::write(dir.join(METADATA_FILE_NAME), content)
628            .await
629            .unwrap();
630
631        for tool in &meta.tools {
632            tokio::fs::write(
633                dir.join(format!("{}.ts", tool.typescript_name)),
634                "export {}",
635            )
636            .await
637            .unwrap();
638        }
639    }
640
641    #[tokio::test]
642    async fn test_scan_tools_directory_round_trip_preserves_parameter_descriptions() {
643        // Issue #141 regression: the old regex-based parser hard-coded parameter
644        // descriptions to `None`. The sidecar-backed scanner must preserve them.
645        let temp_dir = TempDir::new().unwrap();
646        let meta = sample_metadata(2);
647        write_metadata(temp_dir.path(), &meta).await;
648
649        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
650        let tools = result.tools;
651
652        assert_eq!(tools.len(), 2);
653        assert_eq!(tools[0].name, "tool_0");
654        assert_eq!(tools[0].server_id, "github");
655        assert_eq!(tools[0].parameters.len(), 1);
656        assert_eq!(
657            tools[0].parameters[0].description,
658            Some("A parameter".to_string()),
659            "parameter descriptions must survive the sidecar round-trip"
660        );
661        assert!(result.warnings.is_empty());
662    }
663
664    #[tokio::test]
665    async fn test_scan_tools_directory_sorts_by_name() {
666        let temp_dir = TempDir::new().unwrap();
667        let mut meta = sample_metadata(0);
668        meta.tools = vec![
669            ToolMetadata {
670                name: ToolName::new("zebra").unwrap(),
671                typescript_name: "zebra".to_string(),
672                category: None,
673                keywords: vec![],
674                description: None,
675                parameters: vec![],
676            },
677            ToolMetadata {
678                name: ToolName::new("alpha").unwrap(),
679                typescript_name: "alpha".to_string(),
680                category: None,
681                keywords: vec![],
682                description: None,
683                parameters: vec![],
684            },
685        ];
686        write_metadata(temp_dir.path(), &meta).await;
687
688        let tools = scan_tools_directory(temp_dir.path()).await.unwrap().tools;
689
690        assert_eq!(tools[0].name, "alpha");
691        assert_eq!(tools[1].name, "zebra");
692    }
693
694    #[tokio::test]
695    async fn test_scan_tools_directory_stale_metadata_missing_ts_file() {
696        // Issue #154/#155 regression: a sidecar entry whose `.ts` file was
697        // deleted (or never written, e.g. an interrupted `generate`) must be
698        // reported instead of silently vanishing from `SKILL.md`.
699        let temp_dir = TempDir::new().unwrap();
700        let meta = sample_metadata(1);
701        // Write only the sidecar, not the tool's `.ts` file.
702        let content = serde_json::to_string_pretty(&meta).unwrap();
703        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
704            .await
705            .unwrap();
706
707        let result = scan_tools_directory(temp_dir.path()).await;
708
709        match result {
710            Err(ScanError::StaleMetadata {
711                tool,
712                expected_file,
713                ..
714            }) => {
715                assert_eq!(tool, "tool_0");
716                assert_eq!(expected_file, "tool0.ts");
717            }
718            other => panic!("expected StaleMetadata, got: {other:?}"),
719        }
720    }
721
722    #[tokio::test]
723    async fn test_scan_tools_directory_stale_metadata_reports_first_missing_in_sidecar_order() {
724        // With multiple tools in the sidecar, only some of which have a missing
725        // `.ts` file, the check short-circuits on the first missing entry in
726        // sidecar order rather than scanning every tool up front.
727        let temp_dir = TempDir::new().unwrap();
728        let meta = sample_metadata(3);
729        let content = serde_json::to_string_pretty(&meta).unwrap();
730        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
731            .await
732            .unwrap();
733
734        // Only write the `.ts` file for the middle tool; `tool_0` and `tool_2`
735        // are both missing, but `tool_0` is first in sidecar order.
736        tokio::fs::write(temp_dir.path().join("tool1.ts"), "export {}")
737            .await
738            .unwrap();
739
740        let result = scan_tools_directory(temp_dir.path()).await;
741
742        match result {
743            Err(ScanError::StaleMetadata {
744                tool,
745                expected_file,
746                ..
747            }) => {
748                assert_eq!(tool, "tool_0");
749                assert_eq!(expected_file, "tool0.ts");
750            }
751            other => panic!("expected StaleMetadata for tool_0, got: {other:?}"),
752        }
753    }
754
755    #[tokio::test]
756    async fn test_scan_tools_directory_extra_ts_file_excluded_from_result() {
757        // Issue #154/#155 regression: a `.ts` file on disk that the sidecar
758        // does not reference (e.g. left over from a renamed/removed tool) must
759        // not be fatal and must not leak into the scan result — it is logged
760        // via `tracing::warn!` instead.
761        //
762        // Issue #161: the drift must also be visible in the returned
763        // `ScanResult::warnings`, not just in the tracing log line.
764        let temp_dir = TempDir::new().unwrap();
765        let meta = sample_metadata(1);
766        write_metadata(temp_dir.path(), &meta).await;
767
768        tokio::fs::write(temp_dir.path().join("orphan.ts"), "export {}")
769            .await
770            .unwrap();
771
772        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
773
774        assert_eq!(
775            result.tools.len(),
776            1,
777            "the orphaned .ts file must not be reported as a tool"
778        );
779        assert_eq!(result.tools[0].name, "tool_0");
780        assert_eq!(
781            result.warnings.len(),
782            1,
783            "the orphaned .ts file must be surfaced as a warning"
784        );
785        assert!(
786            result.warnings[0].contains("orphan.ts"),
787            "warning must name the excluded file: {:?}",
788            result.warnings[0]
789        );
790    }
791
792    #[tokio::test]
793    async fn test_scan_tools_directory_index_ts_not_treated_as_extra() {
794        // `index.ts` is the generated aggregator file and is never listed in
795        // the sidecar; its presence alone must not affect the scan result.
796        let temp_dir = TempDir::new().unwrap();
797        let meta = sample_metadata(1);
798        write_metadata(temp_dir.path(), &meta).await;
799
800        tokio::fs::write(temp_dir.path().join("index.ts"), "export * from './tool0';")
801            .await
802            .unwrap();
803
804        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
805
806        assert_eq!(result.tools.len(), 1);
807        assert_eq!(result.tools[0].name, "tool_0");
808        assert!(
809            result.warnings.is_empty(),
810            "index.ts must not be reported as a warning"
811        );
812    }
813
814    #[test]
815    fn test_stale_metadata_error_message_tells_user_to_regenerate() {
816        let err = ScanError::StaleMetadata {
817            tool: "create_issue".to_string(),
818            expected_file: "createIssue.ts".to_string(),
819            sidecar_path: "~/.claude/servers/github/_meta.json".to_string(),
820        };
821
822        let message = err.to_string();
823        assert!(
824            message.contains("create_issue"),
825            "message must name the affected tool"
826        );
827        assert!(
828            message.contains("createIssue.ts"),
829            "message must name the missing file"
830        );
831        assert!(
832            message.contains("re-run 'generate'"),
833            "message must tell the user how to fix it: {message}"
834        );
835    }
836
837    #[tokio::test]
838    async fn test_scan_tools_directory_missing_metadata() {
839        let temp_dir = TempDir::new().unwrap();
840
841        let result = scan_tools_directory(temp_dir.path()).await;
842
843        assert!(matches!(result, Err(ScanError::MissingMetadata { .. })));
844    }
845
846    #[tokio::test]
847    async fn test_scan_tools_directory_corrupt_json() {
848        let temp_dir = TempDir::new().unwrap();
849        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), "{not valid json")
850            .await
851            .unwrap();
852
853        let result = scan_tools_directory(temp_dir.path()).await;
854
855        assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
856    }
857
858    /// Regression test for issue #317: `ServerMetadata.server_id`/`ToolMetadata.name` now go
859    /// through `ServerId`/`ToolName`'s `#[serde(try_from = "String")]`, so a sidecar that is
860    /// syntactically valid JSON but carries a semantically-invalid `server_id` (here, one
861    /// containing a path separator) now fails to deserialize at all — surfacing as
862    /// `ScanError::MetadataParse` — where before this change it would have deserialized as a
863    /// plain, unvalidated `String`.
864    #[tokio::test]
865    async fn test_scan_tools_directory_rejects_invalid_server_id_in_valid_json() {
866        let temp_dir = TempDir::new().unwrap();
867        let json = r#"{
868            "schema_version": 1,
869            "server_id": "not/a/valid/id",
870            "server_name": "GitHub",
871            "server_version": "1.0.0",
872            "tools": []
873        }"#;
874        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), json)
875            .await
876            .unwrap();
877
878        let result = scan_tools_directory(temp_dir.path()).await;
879
880        assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
881    }
882
883    /// Same regression as above, for `ToolMetadata.name`.
884    #[tokio::test]
885    async fn test_scan_tools_directory_rejects_invalid_tool_name_in_valid_json() {
886        let temp_dir = TempDir::new().unwrap();
887        let json = r#"{
888            "schema_version": 1,
889            "server_id": "github",
890            "server_name": "GitHub",
891            "server_version": "1.0.0",
892            "tools": [{
893                "name": "../escape",
894                "typescript_name": "escape",
895                "category": null,
896                "keywords": [],
897                "description": null,
898                "parameters": []
899            }]
900        }"#;
901        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), json)
902            .await
903            .unwrap();
904
905        let result = scan_tools_directory(temp_dir.path()).await;
906
907        assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
908    }
909
910    #[tokio::test]
911    async fn test_scan_tools_directory_unsupported_schema() {
912        let temp_dir = TempDir::new().unwrap();
913        let mut meta = sample_metadata(1);
914        meta.schema_version = METADATA_SCHEMA_VERSION + 1;
915        write_metadata(temp_dir.path(), &meta).await;
916
917        let result = scan_tools_directory(temp_dir.path()).await;
918
919        match result {
920            Err(ScanError::UnsupportedSchema { found, expected }) => {
921                assert_eq!(found, METADATA_SCHEMA_VERSION + 1);
922                assert_eq!(expected, METADATA_SCHEMA_VERSION);
923            }
924            other => panic!("expected UnsupportedSchema, got: {other:?}"),
925        }
926    }
927
928    #[tokio::test]
929    async fn test_scan_tools_directory_too_many_tools() {
930        let temp_dir = TempDir::new().unwrap();
931        let meta = sample_metadata(MAX_TOOL_FILES + 1);
932        write_metadata(temp_dir.path(), &meta).await;
933
934        let result = scan_tools_directory(temp_dir.path()).await;
935
936        match result {
937            Err(ScanError::TooManyFiles { count, limit }) => {
938                assert_eq!(count, MAX_TOOL_FILES + 1);
939                assert_eq!(limit, MAX_TOOL_FILES);
940            }
941            other => panic!("expected TooManyFiles, got: {other:?}"),
942        }
943    }
944
945    #[tokio::test]
946    async fn test_scan_tools_directory_file_too_large() {
947        let temp_dir = TempDir::new().unwrap();
948        let mut meta = sample_metadata(1);
949        // MAX_FILE_SIZE (1MB) always fits in usize; the cast cannot truncate.
950        #[allow(clippy::cast_possible_truncation)]
951        let padding = "a".repeat((MAX_FILE_SIZE as usize) + 1);
952        meta.tools[0].description = Some(padding);
953        write_metadata(temp_dir.path(), &meta).await;
954
955        let result = scan_tools_directory(temp_dir.path()).await;
956
957        match result {
958            Err(ScanError::FileTooLarge { size, limit, .. }) => {
959                assert!(size > MAX_FILE_SIZE);
960                assert_eq!(limit, MAX_FILE_SIZE);
961            }
962            other => panic!("expected FileTooLarge, got: {other:?}"),
963        }
964    }
965
966    #[tokio::test]
967    async fn test_scan_tools_directory_nonexistent() {
968        let result = scan_tools_directory(Path::new("/nonexistent/path/for/testing")).await;
969
970        assert!(matches!(result, Err(ScanError::DirectoryNotFound { .. })));
971    }
972
973    #[tokio::test]
974    #[cfg(unix)]
975    async fn test_scan_tools_directory_canonicalize_non_not_found_error_propagates_as_io() {
976        // Issue #302 regression: a symlink loop makes `canonicalize` fail with
977        // `ErrorKind::FilesystemLoop`/`Other` (never `NotFound`). Before the fix,
978        // every canonicalize failure — regardless of kind — collapsed into
979        // `DirectoryNotFound`, silently discarding the real error.
980        let temp_dir = TempDir::new().unwrap();
981        let loop_path = temp_dir.path().join("loop");
982        std::os::unix::fs::symlink(&loop_path, &loop_path).unwrap();
983
984        let result = scan_tools_directory(&loop_path).await;
985
986        match result {
987            Err(ScanError::Io(err)) => {
988                assert_ne!(err.kind(), std::io::ErrorKind::NotFound);
989            }
990            other => panic!("expected ScanError::Io, got: {other:?}"),
991        }
992    }
993
994    // ========================================================================
995    // extract_skill_metadata Tests
996    // ========================================================================
997
998    #[test]
999    fn test_extract_skill_metadata_valid() {
1000        let content = r"---
1001name: github-progressive
1002description: GitHub MCP server operations
1003---
1004
1005# GitHub Progressive
1006
1007## Quick Start
1008
1009Content here.
1010
1011## Common Tasks
1012
1013More content.
1014";
1015
1016        let result = extract_skill_metadata(content);
1017        assert!(result.is_ok());
1018
1019        let metadata = result.unwrap();
1020        assert_eq!(metadata.name, "github-progressive");
1021        assert_eq!(metadata.description, "GitHub MCP server operations");
1022        assert_eq!(metadata.section_count, 2);
1023        assert!(metadata.word_count > 0);
1024    }
1025
1026    #[test]
1027    fn test_extract_skill_metadata_no_frontmatter() {
1028        let content = "# Test\n\nNo frontmatter";
1029
1030        let result = extract_skill_metadata(content);
1031        assert!(matches!(
1032            result,
1033            Err(SkillMetadataError::MissingFrontmatter)
1034        ));
1035    }
1036
1037    #[test]
1038    fn test_extract_skill_metadata_missing_name() {
1039        let content = "---\ndescription: test\n---\n# Test";
1040
1041        let result = extract_skill_metadata(content);
1042        assert!(matches!(
1043            result,
1044            Err(SkillMetadataError::MissingField { field: "name" })
1045        ));
1046    }
1047
1048    #[test]
1049    fn test_extract_skill_metadata_missing_description() {
1050        let content = "---\nname: test\n---\n# Test";
1051
1052        let result = extract_skill_metadata(content);
1053        assert!(matches!(
1054            result,
1055            Err(SkillMetadataError::MissingField {
1056                field: "description"
1057            })
1058        ));
1059    }
1060
1061    #[test]
1062    fn test_extract_skill_metadata_invalid_yaml() {
1063        // Syntactically invalid YAML (an unterminated flow sequence) must surface
1064        // as `SkillMetadataError::InvalidYaml`, wrapping the underlying serde_norway error.
1065        let content = "---\nname: [unterminated\ndescription: test\n---\n# Test";
1066
1067        let result = extract_skill_metadata(content);
1068        let Err(SkillMetadataError::InvalidYaml(message)) = &result else {
1069            panic!("expected InvalidYaml, got: {result:?}");
1070        };
1071        // Issue #203 follow-up (M3): the error's line number must be file-relative, not
1072        // relative to the extracted frontmatter block. `name: [unterminated` is file line 2
1073        // (after the opening `---` on line 1); the block-relative location.line() the
1074        // underlying serde_norway error reports for this input is 1.
1075        assert!(
1076            message.contains("line 2"),
1077            "expected file-relative 'line 2', got: {message:?}"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_extract_skill_metadata_frontmatter_too_large() {
1083        // Issue #203 follow-up (S2): a pathologically large frontmatter block must be
1084        // rejected before it reaches `serde_norway::from_str`, since YAML parsing is not
1085        // linear-time on deeply nested input.
1086        let padding = "a".repeat(MAX_FRONTMATTER_SIZE + 1);
1087        let content = format!("---\nname: test\ndescription: {padding}\n---\n# Test");
1088
1089        let result = extract_skill_metadata(&content);
1090
1091        match result {
1092            Err(SkillMetadataError::FrontmatterTooLarge { size, limit }) => {
1093                assert!(size > MAX_FRONTMATTER_SIZE);
1094                assert_eq!(limit, MAX_FRONTMATTER_SIZE);
1095            }
1096            other => panic!("expected FrontmatterTooLarge, got: {other:?}"),
1097        }
1098    }
1099
1100    #[test]
1101    fn test_extract_skill_metadata_null_name_rejected() {
1102        // Issue #203 follow-up (M4): `name:`/`name: null`/`name: ~` all deserialize to
1103        // `None`, same as an absent key, and must be rejected the same way.
1104        let content = "---\nname: ~\ndescription: test\n---\n# Test";
1105
1106        let result = extract_skill_metadata(content);
1107
1108        assert!(matches!(
1109            result,
1110            Err(SkillMetadataError::MissingField { field: "name" })
1111        ));
1112    }
1113
1114    #[test]
1115    fn test_extract_skill_metadata_empty_string_name_rejected() {
1116        // Issue #203 follow-up (M4): an empty-string name/description is present but
1117        // useless, and must not silently reach `SkillMetadata`.
1118        let content = "---\nname: \"\"\ndescription: test\n---\n# Test";
1119
1120        let result = extract_skill_metadata(content);
1121
1122        assert!(matches!(
1123            result,
1124            Err(SkillMetadataError::MissingField { field: "name" })
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_extract_skill_metadata_with_extra_fields() {
1130        let content = r"---
1131name: test-skill
1132description: Test description
1133version: 1.0.0
1134author: Test Author
1135---
1136
1137# Test
1138";
1139
1140        let result = extract_skill_metadata(content);
1141        assert!(result.is_ok());
1142
1143        let metadata = result.unwrap();
1144        assert_eq!(metadata.name, "test-skill");
1145        assert_eq!(metadata.description, "Test description");
1146    }
1147
1148    #[test]
1149    fn test_extract_skill_metadata_block_literal_scalar() {
1150        // Issue #203 regression: a `description: |` block literal scalar must have its
1151        // real multi-line body captured, not just the `|` marker character.
1152        let content = r"---
1153name: test-skill
1154description: |
1155  This is a block literal description
1156  spanning multiple lines.
1157---
1158
1159# Test
1160";
1161
1162        let metadata = extract_skill_metadata(content).unwrap();
1163        assert_ne!(metadata.description, "|");
1164        assert!(metadata.description.contains("block literal description"));
1165        assert!(metadata.description.contains("spanning multiple lines."));
1166    }
1167
1168    #[test]
1169    fn test_extract_skill_metadata_folded_block_scalar() {
1170        // Issue #203 regression: a `description: >` folded block scalar must have its
1171        // real multi-line body captured, not just the `>` marker character.
1172        let content = r"---
1173name: test-skill
1174description: >
1175  This is a folded description
1176  spanning multiple lines.
1177---
1178
1179# Test
1180";
1181
1182        let metadata = extract_skill_metadata(content).unwrap();
1183        assert_ne!(metadata.description, ">");
1184        assert!(metadata.description.contains("folded description"));
1185        assert!(metadata.description.contains("spanning multiple lines."));
1186    }
1187
1188    #[test]
1189    fn test_extract_skill_metadata_quoted_scalars() {
1190        // Issue #203 regression: quote characters must be stripped by the YAML
1191        // parser, not captured verbatim into the field value.
1192        let content = r#"---
1193name: "quoted-name"
1194description: 'quoted text'
1195---
1196
1197# Test
1198"#;
1199
1200        let metadata = extract_skill_metadata(content).unwrap();
1201        assert_eq!(metadata.name, "quoted-name");
1202        assert_eq!(metadata.description, "quoted text");
1203    }
1204
1205    /// Builds the flow-sequence body of an 8-anchor alias bomb shared by every alias-bomb test
1206    /// below: 8 anchors (`a0..a7`), each referencing the previous 8 times — 8^8 ~= 16.7M leaves
1207    /// if ever fully expanded. `preamble` is prepended verbatim; it decides which YAML key
1208    /// (declared or not) the bomb ends up under. At a few hundred bytes the result sits well
1209    /// under `MAX_FRONTMATTER_SIZE` (8 KiB); shrinking the branching factor, the anchor count,
1210    /// or the frontmatter cap changes that margin and must be re-checked against callers' size
1211    /// assertions.
1212    fn alias_bomb_fixture(preamble: &str) -> String {
1213        use std::fmt::Write as _;
1214
1215        let mut frontmatter = String::from(preamble);
1216        writeln!(frontmatter, "  - &a0 [x, x, x, x, x, x, x, x]").unwrap();
1217        for level in 1..=7 {
1218            let prev = level - 1;
1219            let refs = (0..8)
1220                .map(|_| format!("*a{prev}"))
1221                .collect::<Vec<_>>()
1222                .join(", ");
1223            writeln!(frontmatter, "  - &a{level} [{refs}]").unwrap();
1224        }
1225        writeln!(frontmatter, "  - *a7").unwrap();
1226        frontmatter
1227    }
1228
1229    #[test]
1230    fn test_extract_skill_metadata_alias_bomb_under_unknown_key_stays_ok() {
1231        // ADR-341 §3.3/§5, corrected per review: `RawFrontmatter` has no
1232        // `#[serde(deny_unknown_fields)]`, so a key it does not declare is discarded via a
1233        // lazy visitor that never expands nested aliases — the bomb below sits under such a
1234        // key (`unknown_key`), with `name`/`description` left valid, so parsing succeeds
1235        // fast today. A `#[serde(flatten)]`-style buffering field would force every unknown
1236        // key to be materialized into a `Content`-like structure before per-field routing,
1237        // which *does* expand aliases and trips serde_norway's own repetition-limit guard —
1238        // flipping this exact fixture's outcome from `Ok` to `Err`. That deterministic
1239        // outcome flip, not wall-clock timing, is the primary thing this test pins: a single
1240        // cold-process call's timing noise (measured up to ~1.6ms) is not a safe
1241        // discriminator against the ADR's ~4-7ms regression floor. An earlier version of
1242        // this test put the bomb on the *declared* `description` field instead, where
1243        // serde's derive reads it directly regardless of `flatten` — that fixture could
1244        // never detect the regression it claimed to guard, at any budget.
1245        //
1246        // Fixture shape: see `alias_bomb_fixture`'s doc comment.
1247        //
1248        // Deliberately out of scope here: retyping `description` itself to a buffering type
1249        // (`serde_norway::Value`, an untagged enum, a buffering `deserialize_with`) is a
1250        // distinct regression vector this fixture does not cover — see
1251        // `test_serde_norway_buffers_alias_bomb_when_declared_field_is_value_typed` and
1252        // `test_serde_norway_buffers_alias_bomb_when_declared_field_uses_deserialize_with`
1253        // below (issue #359).
1254        use std::time::{Duration, Instant};
1255
1256        let frontmatter =
1257            alias_bomb_fixture("name: test-skill\ndescription: valid description\nunknown_key:\n");
1258
1259        let content = format!("---\n{frontmatter}---\n# Test\n");
1260        assert!(
1261            content.len() <= MAX_FRONTMATTER_SIZE,
1262            "fixture must stay under the frontmatter cap to exercise the parser, not the size guard"
1263        );
1264
1265        let start = Instant::now();
1266        let result = extract_skill_metadata(&content);
1267        let elapsed = start.elapsed();
1268
1269        assert!(
1270            result.is_ok(),
1271            "expected Ok: an alias bomb under a key RawFrontmatter does not declare must be \
1272             ignored today without expansion; if this now errors, RawFrontmatter's field shape \
1273             likely changed (e.g. a #[serde(flatten)] field) and reopened the amplification path \
1274             serde_norway's own repetition-limit guard then catches at ms-scale cost instead of \
1275             being ignored at us-scale cost. got: {result:?}"
1276        );
1277        // Sanity bound only, not the detection mechanism (see comment above): guards against an
1278        // outright hang rather than the ms-scale regression, which the Ok/Err flip already
1279        // catches deterministically regardless of timing noise.
1280        assert!(
1281            elapsed < Duration::from_secs(1),
1282            "parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_serde_norway_buffers_alias_bomb_when_declared_field_is_value_typed() {
1288        // Issue #359 ("vector 2", sub-case A). The vector-1 test above pins the *unknown-key*
1289        // amplification path, which only opens up if `RawFrontmatter` grows a
1290        // `#[serde(flatten)]`-style field; it cannot detect a second, distinct regression: a
1291        // *declared* field (e.g. `description`) retyped from `Option<String>` to a buffering
1292        // type such as `serde_norway::Value` or an untagged enum. Buffering a declared field
1293        // forces serde to materialize its value before matching it against the target type,
1294        // which expands nested aliases and trips serde_norway's own repetition-limit guard.
1295        // Unlike vector 1, there is no `Ok` baseline here: deserializing a YAML sequence into
1296        // `Option<String>` always errs, buffering or not. The regression this pins is not
1297        // presence-vs-absence of an error but *which* error: a cheap, immediate type-mismatch
1298        // without buffering, versus this expensive repetition-limit trip once buffered.
1299        //
1300        // This is a canary on `serde_norway`'s own buffering behavior, not a regression test
1301        // against production `RawFrontmatter` (see its doc comment): that struct's
1302        // `description` is `Option<String>` today, so this exact retype is compile-blocked at
1303        // `require_field`'s `Option<String>` parameter, its `extract_skill_metadata` call site
1304        // — if attempted, the build fails before this test could ever run. It is kept to pin
1305        // the assumption the sibling `deserialize_with` test below relies on, since that one is
1306        // NOT compile-blocked.
1307        //
1308        // As in vector 1, the primary assertion is the deterministic outcome flip, not
1309        // wall-clock timing. The ~5.3ms release / ~61.7ms debug degradation figures quoted when
1310        // this vector was raised are from issue #359's own reproduction, not independently
1311        // re-verified against vendored sources the way ADR-341 §3.1-§3.6 were — illustrative
1312        // only, not a re-confirmed Evidence Ledger figure.
1313        use std::time::{Duration, Instant};
1314
1315        #[derive(Debug, Deserialize)]
1316        #[allow(
1317            dead_code,
1318            reason = "fields exist only to mirror RawFrontmatter's shape"
1319        )]
1320        struct RawFrontmatterBufferedDescription {
1321            name: Option<String>,
1322            description: serde_norway::Value,
1323        }
1324
1325        let frontmatter = alias_bomb_fixture("name: test-skill\ndescription:\n");
1326        // Unlike vector 1, this path never reaches `extract_skill_metadata`, so
1327        // `MAX_FRONTMATTER_SIZE` is not enforced here; this only records that the fixture has
1328        // headroom under that constant, for comparability with vector 1's fixture.
1329        assert!(
1330            frontmatter.len() <= MAX_FRONTMATTER_SIZE,
1331            "fixture unexpectedly exceeds MAX_FRONTMATTER_SIZE; re-check alias_bomb_fixture's margin"
1332        );
1333
1334        let start = Instant::now();
1335        let result = serde_norway::from_str::<RawFrontmatterBufferedDescription>(&frontmatter);
1336        let elapsed = start.elapsed();
1337
1338        let err = result.expect_err(
1339            "expected Err: buffering a declared field into serde_norway::Value forces alias \
1340             expansion before per-field routing, which should trip serde_norway's own \
1341             repetition-limit guard",
1342        );
1343        assert!(
1344            err.to_string().contains("repetition limit exceeded"),
1345            "expected serde_norway's own repetition-limit guard specifically, not some \
1346             unrelated error a future serde_norway version might introduce instead; got: {err}"
1347        );
1348        // Sanity bound only: `from_str` has already returned by the time `elapsed` is measured,
1349        // so this cannot catch a genuine hang (this workspace's nextest config sets no
1350        // slow-timeout/terminate-after). It only flags a completed-but-pathologically-slow
1351        // parse that the deterministic `Err` assertion above would not otherwise surface.
1352        assert!(
1353            elapsed < Duration::from_secs(1),
1354            "parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_serde_norway_buffers_alias_bomb_when_declared_field_uses_deserialize_with() {
1360        // Issue #359 ("vector 2", sub-case B — the genuinely silent one, per review). The
1361        // sibling test above pins the same amplification path for a field whose *type* changes
1362        // to `serde_norway::Value`, which is compile-blocked at `extract_skill_metadata`'s
1363        // `require_field` call site. This test pins the sub-case that is NOT compile-blocked: a
1364        // `#[serde(deserialize_with = ...)]` that buffers through `serde_norway::Value`
1365        // internally while keeping the field's declared Rust type as `Option<String>` — exactly
1366        // the shape `require_field` expects, so this variant would compile and could land
1367        // without any type-level signal.
1368        //
1369        // See `RawFrontmatter`'s doc comment for which future edits this canary (and its
1370        // sibling above) are meant to catch, and why both must be extended or replaced if
1371        // `RawFrontmatter` itself is ever changed in either direction.
1372        use std::time::{Duration, Instant};
1373
1374        fn buffer_via_value<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1375        where
1376            D: serde::Deserializer<'de>,
1377        {
1378            let value = serde_norway::Value::deserialize(deserializer)?;
1379            Ok(value.as_str().map(str::to_string))
1380        }
1381
1382        #[derive(Debug, Deserialize)]
1383        #[allow(
1384            dead_code,
1385            reason = "fields exist only to mirror RawFrontmatter's shape"
1386        )]
1387        struct RawFrontmatterDeserializeWithDescription {
1388            name: Option<String>,
1389            #[serde(deserialize_with = "buffer_via_value")]
1390            description: Option<String>,
1391        }
1392
1393        let frontmatter = alias_bomb_fixture("name: test-skill\ndescription:\n");
1394        // See the sibling test above: this path also bypasses `extract_skill_metadata`, so
1395        // this only records headroom under `MAX_FRONTMATTER_SIZE`, not a production size guard.
1396        assert!(
1397            frontmatter.len() <= MAX_FRONTMATTER_SIZE,
1398            "fixture unexpectedly exceeds MAX_FRONTMATTER_SIZE; re-check alias_bomb_fixture's margin"
1399        );
1400
1401        let start = Instant::now();
1402        let result =
1403            serde_norway::from_str::<RawFrontmatterDeserializeWithDescription>(&frontmatter);
1404        let elapsed = start.elapsed();
1405
1406        let err = result.expect_err(
1407            "expected Err: a buffering deserialize_with should force alias expansion before \
1408             per-field routing just like an outright Value-typed field, even though the \
1409             declared Rust type here stays Option<String>",
1410        );
1411        assert!(
1412            err.to_string().contains("repetition limit exceeded"),
1413            "expected serde_norway's own repetition-limit guard specifically, not some \
1414             unrelated error a future serde_norway version might introduce instead; got: {err}"
1415        );
1416        // Sanity bound only (see the sibling test above for why this cannot catch a hang).
1417        assert!(
1418            elapsed < Duration::from_secs(1),
1419            "parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_extract_skill_metadata_alias_bomb_under_declared_field_short_circuits() {
1425        // Issue #359 ("vector 2", production-coupled canary). The two tests above pin the
1426        // buffering assumption against local, decoupled types; this one closes that gap for
1427        // real against production `RawFrontmatter` + `extract_skill_metadata`, the same way
1428        // vector 1 does for the unknown-key path. `describe_yaml_error` (used by
1429        // `extract_skill_metadata`) passes the underlying `serde_norway` error's `Display`
1430        // through into `SkillMetadataError::InvalidYaml(String)`, only rewriting its embedded
1431        // line/column offset — see `test_extract_skill_metadata_invalid_yaml` for existing
1432        // precedent asserting on that message — so the error text (including the
1433        // "repetition limit exceeded" substring this test checks for, untouched by that
1434        // rewrite) is directly inspectable here without a local test-only struct.
1435        //
1436        // Today `RawFrontmatter::description` is `Option<String>`, so deserializing the bomb
1437        // sequence into it short-circuits on an immediate type mismatch before any buffering
1438        // can happen — the error is NOT the repetition-limit guard. If `description` is ever
1439        // retyped to a buffering shape (see `RawFrontmatter`'s doc comment), this assertion
1440        // flips loudly: the error becomes "repetition limit exceeded", the same string the two
1441        // sibling tests above pin directly.
1442        let content = format!(
1443            "---\n{}---\n# Test\n",
1444            alias_bomb_fixture("name: test-skill\ndescription:\n")
1445        );
1446        assert!(
1447            content.len() <= MAX_FRONTMATTER_SIZE,
1448            "fixture must stay under the frontmatter cap to exercise the parser, not the size guard"
1449        );
1450
1451        let result = extract_skill_metadata(&content);
1452        let Err(SkillMetadataError::InvalidYaml(message)) = &result else {
1453            panic!("expected InvalidYaml, got: {result:?}");
1454        };
1455        assert!(
1456            !message.contains("repetition limit exceeded"),
1457            "RawFrontmatter::description now trips serde_norway's repetition-limit guard on \
1458             this fixture, meaning it was retyped to a buffering shape (serde_norway::Value, an \
1459             untagged enum, or a buffering deserialize_with) without extending this test — see \
1460             RawFrontmatter's doc comment. got: {message:?}"
1461        );
1462    }
1463}