# Story 5.31: Explicit Plugin Name Normalization

**Status:** Ready for Code Review
**Priority:** High
**Depends on:** None

---

## Context / Background

Plugin names in fapilog are normalized via `_normalize_plugin_name()` in `src/fapilog/plugins/loader.py:21-23`:

```python
def _normalize_plugin_name(name: str) -> str:
    """Normalize plugin names to a canonical underscore/lower format."""
    return name.replace("-", "_").lower()
```

This means:
- `"http-sink"` becomes `"http_sink"`
- `"CloudWatch"` becomes `"cloudwatch"`
- `"Loki_Sink"` becomes `"loki_sink"`

**The problem:** This normalization happens silently. When a user gets an error like:

```
PluginNotFoundError: Plugin 'http-sink' not found in group 'fapilog.sinks'
```

They don't know that the system looked for `"http_sink"` internally. This creates confusion:
- "Why isn't my plugin loading? I registered it as 'http-sink'!"
- "The error says 'http-sink' but the registry shows 'http_sink'"

Additionally, the validation mode (`_validation_mode`) is global module state set via `set_validation_mode()`, creating "spooky action at a distance."

The Human Understandability Audit identified this as a **P1 issue**.

---

## Scope (In / Out)

### In Scope

- Document the normalization behavior in docstrings with examples
- Improve error messages to show both original and normalized names
- Add validation that registered plugin names are already normalized
- Consider making validation mode a parameter instead of global state

### Out of Scope

- Changing the normalization algorithm itself
- Breaking backward compatibility for existing plugins
- Adding new normalization rules

---

## Acceptance Criteria

### AC1: Documented Normalization

**Description:** The `_normalize_plugin_name()` function has comprehensive documentation.

**Validation:**
```python
def _normalize_plugin_name(name: str) -> str:
    """Normalize plugin names to a canonical underscore/lowercase format.

    Normalization Rules:
    - Hyphens (-) are replaced with underscores (_)
    - All characters are lowercased

    Examples:
        >>> _normalize_plugin_name("http-sink")
        'http_sink'
        >>> _normalize_plugin_name("CloudWatch")
        'cloudwatch'
        >>> _normalize_plugin_name("My_Custom_Plugin")
        'my_custom_plugin'

    This ensures consistent lookup regardless of how users specify names.
    Both 'http-sink' and 'http_sink' will find the same plugin.
    """
    return name.replace("-", "_").lower()
```

### AC2: Improved Error Messages

**Description:** Error messages show both user-provided and normalized names.

**Validation:**
```python
# Before:
raise PluginNotFoundError(f"Plugin '{name}' not found in group '{group}'")

# After:
canonical = _normalize_plugin_name(name)
if canonical != name:
    raise PluginNotFoundError(
        f"Plugin '{name}' (normalized: '{canonical}') not found in group '{group}'"
    )
else:
    raise PluginNotFoundError(f"Plugin '{name}' not found in group '{group}'")
```

### AC3: Registration Validation

**Description:** Warn when registering plugins with non-normalized names.

**Validation:**
```python
def register_builtin(group: str, name: str, cls: type, ...):
    canonical = _normalize_plugin_name(name)
    if name != canonical:
        diagnostics.warn(
            "plugins",
            "non-canonical plugin name",
            registered=name,
            canonical=canonical,
        )
    registry[canonical] = cls
```

### AC4: Validation Mode Refactored

**Description:** Validation mode is passed as parameter rather than global state.

- [ ] `load_plugin()` accepts `validation_mode` parameter (already exists)
- [ ] Document that the global `set_validation_mode()` is for default only
- [ ] Consider deprecating global state in favor of explicit parameter

---

## Implementation Notes

### File Changes

```
src/fapilog/plugins/loader.py (MODIFIED - docstrings, error messages, validation)
tests/unit/test_plugin_loader.py (MODIFIED - test improved errors)
docs/plugins/naming.md (NEW - plugin naming guide)
```

### Key Changes

**Improved error message:**
```python
def load_plugin(group: str, name: str, config: dict | None = None, ...) -> Any:
    config = config or {}
    canonical = _normalize_plugin_name(name)

    # ... existing lookup logic ...

    # Improved error with both names
    if canonical != name:
        raise PluginNotFoundError(
            f"Plugin '{name}' (normalized to '{canonical}') not found in group '{group}'. "
            f"Available plugins: {', '.join(list_available_plugins(group))}"
        )
    raise PluginNotFoundError(
        f"Plugin '{name}' not found in group '{group}'. "
        f"Available plugins: {', '.join(list_available_plugins(group))}"
    )
```

**Registration validation:**
```python
def register_builtin(group: str, name: str, cls: type, ...) -> None:
    """Register a built-in plugin class and optional aliases.

    Args:
        group: Plugin group (e.g., 'fapilog.sinks')
        name: Plugin name (should be lowercase with underscores)
        cls: Plugin class
        aliases: Optional alternative names

    Note:
        Names are normalized internally. For consistency, use canonical
        names (lowercase, underscores) when registering.
    """
    canonical = _normalize_plugin_name(name)
    if name != canonical:
        try:
            diagnostics.warn(
                "plugins",
                "non-canonical plugin name",
                registered=name,
                canonical=canonical,
            )
        except Exception:
            pass

    registry = _registry_for_group(group)
    if registry is not None:
        registry[canonical] = cls
    # ... rest of function
```

---

## Tasks

### Phase 1: Documentation

- [ ] Add comprehensive docstring to `_normalize_plugin_name()` with examples
- [ ] Add docstring to `load_plugin()` explaining name normalization
- [ ] Add docstring to `register_builtin()` recommending canonical names

### Phase 2: Error Messages

- [ ] Update `PluginNotFoundError` to show both names when different
- [ ] Include list of available plugins in error message
- [ ] Add hint about name normalization to error

### Phase 3: Validation

- [ ] Add diagnostic warning for non-canonical registration names
- [ ] Document validation mode parameter vs global state
- [ ] Add tests for new warning behavior

### Phase 4: Documentation

- [ ] Create `docs/plugins/naming.md` plugin naming guide
- [ ] Add examples of correct vs incorrect names
- [ ] Link from loader docstrings to naming guide

---

## Tests

### Unit Tests

- `tests/unit/test_plugin_loader.py`
  - Test error message includes normalized name
  - Test error message lists available plugins
  - Test warning emitted for non-canonical registration
  - Test normalization examples from docstring

### Integration Tests

- Load plugin with hyphenated name, verify it finds underscore version
- Register plugin with uppercase, verify warning emitted

---

## Definition of Done

### Code Complete

- [ ] Docstrings added with examples
- [ ] Error messages improved
- [ ] Registration validation added
- [ ] No linting errors

### Quality Assurance

- [ ] New tests pass
- [ ] Existing tests pass
- [ ] `ruff check` passes
- [ ] `mypy` passes

### Documentation

- [ ] Plugin naming guide created
- [ ] Docstrings complete with examples

---

## Risks / Rollback

### Risks

1. **Risk:** Registration warnings spam logs for existing plugins
   - **Mitigation:** Only warn, don't error; warnings are rate-limited

2. **Risk:** Error message change breaks log parsing
   - **Mitigation:** Existing message text preserved, just extended

### Rollback Plan

1. Revert commit (no data migration)

---

## Related Stories

- **Related:** Story 5.1 - Plugin metadata consistency
- **Related:** Story 4.23 - Plugin metadata completeness

---

## Change Log

| Date | Change | Author |
|------|--------|--------|
| 2026-01-14 | Initial draft | Claude |
