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}