# v1.2 Graph Linker Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Extract explicit `## Related Skills` relationships from indexed Markdown files, store them as a directed graph, and expose a `corpus graph <slug>` CLI command for upstream/downstream impact queries.

**Architecture:** A new `graph/` module owns slug extraction, path resolution, and SQLite adjacency storage. The ingest pipeline calls the extractor after chunking each file and writes edges into `<DATA_DIR>/graph.sqlite`. The `corpus graph` CLI command reads this table and prints a directed tree.

**Tech Stack:** Python stdlib `sqlite3`, `re`, `pathlib`; Typer (existing CLI); `pytest` + `tmp_path` (tests). No new dependencies.

---

## Design Decisions (locked)

- **Registry collisions** (two directories named `auth`): warn at index time, store all candidates, pick the shortest path as canonical. Edge gets `resolved = True/False` flag.
- **Directed edges only**: A→B is stored as written. The `graph` command traverses forward (downstream) and reverse (upstream) against the same directed table. No synthetic back-edges.
- **Storage**: `graph.sqlite` in `DATA_DIR` (same directory as the LanceDB index), separate from `corpus.sqlite`. Keeps concerns isolated.

---

## Task 1: `graph/extractor.py` — slug extraction from Markdown

**Files:**
- Create: `src/corpus_analyzer/graph/__init__.py`
- Create: `src/corpus_analyzer/graph/extractor.py`
- Create: `tests/graph/__init__.py`
- Create: `tests/graph/test_extractor.py`

### Step 1: Write the failing tests

```python
# tests/graph/test_extractor.py
"""Tests for ## Related Skills / ## Related Files slug extraction."""
from __future__ import annotations

from corpus_analyzer.graph.extractor import extract_related_slugs


def test_extracts_backtick_slugs() -> None:
    md = "## Related Skills\n\n- `feature-planning`, `dev-spec`\n"
    assert extract_related_slugs(md) == ["feature-planning", "dev-spec"]


def test_extracts_bold_slugs() -> None:
    md = "## Related Skills\n\n- **feature-planning**: for planning\n- **dev-spec**: for specs\n"
    assert extract_related_slugs(md) == ["feature-planning", "dev-spec"]


def test_extracts_plain_list_slugs() -> None:
    md = "## Related Skills\n\n- writing-clearly-and-concisely\n"
    assert extract_related_slugs(md) == ["writing-clearly-and-concisely"]


def test_mixed_formats_in_one_section() -> None:
    md = (
        "## Related Skills\n\n"
        "- `micro-saas-launcher`, `copywriting`\n"
        "- **landing-page-design**: For landing pages\n"
        "- seo\n"
    )
    result = extract_related_slugs(md)
    assert set(result) == {"micro-saas-launcher", "copywriting", "landing-page-design", "seo"}


def test_also_extracts_related_files_section() -> None:
    md = "## Related Files\n\n- `SKILL.md` - Main skill guide\n- `ADVANCED.md`\n"
    # .md extension is stripped: SKILL, ADVANCED
    assert "SKILL" in extract_related_slugs(md) or "SKILL.md" in extract_related_slugs(md)


def test_returns_empty_when_no_related_section() -> None:
    md = "# My Skill\n\nSome content here.\n"
    assert extract_related_slugs(md) == []


def test_stops_at_next_heading() -> None:
    md = (
        "## Related Skills\n\n"
        "- `feature-planning`\n\n"
        "## Usage\n\n"
        "- `something-else`\n"
    )
    assert extract_related_slugs(md) == ["feature-planning"]


def test_deduplicates_slugs() -> None:
    md = "## Related Skills\n\n- `auth`, `auth`\n"
    assert extract_related_slugs(md) == ["auth"]
```

### Step 2: Run tests to verify they fail

```bash
uv run pytest tests/graph/test_extractor.py -v
```
Expected: `ModuleNotFoundError` or `ImportError` — `graph` module does not exist yet.

### Step 3: Implement `graph/extractor.py`

```python
# src/corpus_analyzer/graph/__init__.py
"""Relationship graph extraction and storage for corpus-analyzer."""
```

```python
# src/corpus_analyzer/graph/extractor.py
"""Extract relationship slugs from ## Related Skills / ## Related Files sections.

Parses the first matching section (## Related Skills or ## Related Files) and
returns a deduplicated list of slug strings.  Supports three list item formats:

* Backtick names: ``- `feature-planning`, `dev-spec` ``
* Bold names:     ``- **feature-planning**: description``
* Plain names:    ``- writing-clearly-and-concisely``
"""
from __future__ import annotations

import re

# Matches one of the two recognised section headings.
_SECTION_RE = re.compile(
    r"^## Related (?:Skills|Files)\s*\n(.*?)(?=^##|\Z)",
    re.MULTILINE | re.DOTALL,
)
# Backtick slugs: `slug-name`
_BACKTICK_RE = re.compile(r"`([a-zA-Z0-9][a-zA-Z0-9_\-\.]*)`")
# Bold slugs: **slug-name** (optionally followed by colon + description)
_BOLD_RE = re.compile(r"\*\*([a-zA-Z0-9][a-zA-Z0-9_\-\.]*)\*\*")
# Plain list item slugs: "- slug-name" with no markup, optional trailing text
_PLAIN_RE = re.compile(r"^[-*]\s+([a-zA-Z0-9][a-zA-Z0-9_\-\.]+)\s*(?:[-–].*)?$", re.MULTILINE)


def extract_related_slugs(text: str) -> list[str]:
    """Return deduplicated slugs from the first ## Related Skills/Files section.

    Args:
        text: Full Markdown file content.

    Returns:
        Ordered list of unique slug strings.  Empty list if no section found.
    """
    match = _SECTION_RE.search(text)
    if not match:
        return []

    section = match.group(1)
    seen: dict[str, None] = {}  # ordered set

    for slug in _BACKTICK_RE.findall(section):
        seen.setdefault(slug, None)
    for slug in _BOLD_RE.findall(section):
        seen.setdefault(slug, None)

    # Plain items only if line has NO backtick or bold markup
    for line in section.splitlines():
        plain = _PLAIN_RE.match(line.strip())
        if plain and "`" not in line and "**" not in line:
            seen.setdefault(plain.group(1), None)

    return list(seen)
```

### Step 4: Run tests to verify they pass

```bash
uv run pytest tests/graph/test_extractor.py -v
```
Expected: all 8 tests pass.

### Step 5: Commit

```bash
git add src/corpus_analyzer/graph/ tests/graph/
git commit -m "feat(graph): add related-slug extractor for ## Related Skills sections"
```

---

## Task 2: `graph/registry.py` — slug-to-path name resolver

**Files:**
- Create: `src/corpus_analyzer/graph/registry.py`
- Create: `tests/graph/test_registry.py`

### Step 1: Write the failing tests

```python
# tests/graph/test_registry.py
"""Tests for the slug-to-path component registry."""
from __future__ import annotations

from pathlib import Path

import pytest

from corpus_analyzer.graph.registry import SlugRegistry


def test_resolves_known_slug(tmp_path: Path) -> None:
    (tmp_path / "skills" / "feature-planning").mkdir(parents=True)
    (tmp_path / "skills" / "feature-planning" / "SKILL.md").write_text("content")
    reg = SlugRegistry.build([tmp_path])
    assert reg.resolve("feature-planning") == tmp_path / "skills" / "feature-planning" / "SKILL.md"


def test_returns_none_for_unknown_slug(tmp_path: Path) -> None:
    reg = SlugRegistry.build([tmp_path])
    assert reg.resolve("no-such-skill") is None


def test_warns_on_ambiguous_slug(tmp_path: Path, caplog) -> None:
    (tmp_path / "a" / "auth").mkdir(parents=True)
    (tmp_path / "a" / "auth" / "SKILL.md").write_text("a")
    (tmp_path / "b" / "auth").mkdir(parents=True)
    (tmp_path / "b" / "auth" / "SKILL.md").write_text("b")
    import logging
    with caplog.at_level(logging.WARNING, logger="corpus_analyzer.graph.registry"):
        reg = SlugRegistry.build([tmp_path])
    assert "ambiguous" in caplog.text.lower()
    # Canonical is the shortest path (fewer parts)
    resolved = reg.resolve("auth")
    assert resolved is not None


def test_prefers_skill_md_over_readme(tmp_path: Path) -> None:
    skill_dir = tmp_path / "skills" / "my-skill"
    skill_dir.mkdir(parents=True)
    (skill_dir / "SKILL.md").write_text("skill")
    (skill_dir / "README.md").write_text("readme")
    reg = SlugRegistry.build([tmp_path])
    assert reg.resolve("my-skill").name == "SKILL.md"


def test_fallback_to_readme_when_no_skill_md(tmp_path: Path) -> None:
    skill_dir = tmp_path / "skills" / "my-skill"
    skill_dir.mkdir(parents=True)
    (skill_dir / "README.md").write_text("readme")
    reg = SlugRegistry.build([tmp_path])
    assert reg.resolve("my-skill").name == "README.md"
```

### Step 2: Run tests to verify they fail

```bash
uv run pytest tests/graph/test_registry.py -v
```
Expected: `ImportError` — `registry` module does not exist.

### Step 3: Implement `graph/registry.py`

```python
# src/corpus_analyzer/graph/registry.py
"""Slug-to-path registry built from a directory scan.

A 'slug' is the final directory name of a component folder, e.g.
``feature-planning`` from ``components/skills/productivity/feature-planning/``.

Resolution priority for candidate index files within a slug directory:
1. ``SKILL.md``
2. ``README.md``
3. First ``.md`` file found (alphabetical)

On ambiguous slug (same directory name under different parents), the
canonical path is the one with the fewest parts; a WARNING is emitted.
"""
from __future__ import annotations

import logging
from pathlib import Path

logger = logging.getLogger(__name__)

_PREFERRED = ("SKILL.md", "README.md")


def _pick_index_file(directory: Path) -> Path | None:
    """Return the preferred representative file for a slug directory."""
    for name in _PREFERRED:
        candidate = directory / name
        if candidate.exists():
            return candidate
    md_files = sorted(directory.glob("*.md"))
    return md_files[0] if md_files else None


class SlugRegistry:
    """Immutable map of slug → canonical Path, built from root directories."""

    def __init__(self, mapping: dict[str, Path]) -> None:
        """Initialise with a pre-built slug→path mapping."""
        self._map = mapping

    @classmethod
    def build(cls, roots: list[Path]) -> "SlugRegistry":
        """Scan *roots* recursively and build slug→path registry.

        Args:
            roots: List of root directories to scan (e.g. each source root).

        Returns:
            A new SlugRegistry instance.
        """
        candidates: dict[str, list[Path]] = {}
        for root in roots:
            for directory in root.rglob("*"):
                if not directory.is_dir():
                    continue
                index = _pick_index_file(directory)
                if index is None:
                    continue
                slug = directory.name
                candidates.setdefault(slug, []).append(index)

        mapping: dict[str, Path] = {}
        for slug, paths in candidates.items():
            if len(paths) > 1:
                logger.warning(
                    "ambiguous slug %r: %d candidates %s — using shortest path",
                    slug,
                    len(paths),
                    [str(p) for p in paths],
                )
                # Shortest path (fewest parts) wins as canonical
                paths = sorted(paths, key=lambda p: len(p.parts))
            mapping[slug] = paths[0]

        return cls(mapping)

    def resolve(self, slug: str) -> Path | None:
        """Return the canonical Path for *slug*, or None if unknown."""
        return self._map.get(slug)

    def __len__(self) -> int:
        """Return number of known slugs."""
        return len(self._map)
```

### Step 4: Run tests to verify they pass

```bash
uv run pytest tests/graph/test_registry.py -v
```
Expected: all 5 tests pass.

### Step 5: Commit

```bash
git add src/corpus_analyzer/graph/registry.py tests/graph/test_registry.py
git commit -m "feat(graph): add SlugRegistry for slug-to-path resolution"
```

---

## Task 3: `graph/store.py` — SQLite adjacency table

**Files:**
- Create: `src/corpus_analyzer/graph/store.py`
- Create: `tests/graph/test_store.py`

### Step 1: Write the failing tests

```python
# tests/graph/test_store.py
"""Tests for GraphStore SQLite adjacency table."""
from __future__ import annotations

from pathlib import Path

from corpus_analyzer.graph.store import GraphStore


def test_write_and_read_edge(tmp_path: Path) -> None:
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/skills/a/SKILL.md", [("/skills/b/SKILL.md", True, "related_skill")])
    edges = store.edges_from("/skills/a/SKILL.md")
    assert len(edges) == 1
    assert edges[0]["target_path"] == "/skills/b/SKILL.md"
    assert edges[0]["resolved"] is True


def test_edges_to_returns_upstream(tmp_path: Path) -> None:
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/a.md", [("/b.md", True, "related_skill")])
    upstream = store.edges_to("/b.md")
    assert len(upstream) == 1
    assert upstream[0]["source_path"] == "/a.md"


def test_write_edges_is_idempotent(tmp_path: Path) -> None:
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/a.md", [("/b.md", True, "related_skill")])
    store.write_edges("/a.md", [("/b.md", True, "related_skill")])
    assert len(store.edges_from("/a.md")) == 1


def test_clear_edges_for_source(tmp_path: Path) -> None:
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/a.md", [("/b.md", True, "related_skill"), ("/c.md", False, "related_skill")])
    store.clear_edges_for("/a.md")
    assert store.edges_from("/a.md") == []


def test_unresolved_edge_stores_slug(tmp_path: Path) -> None:
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/a.md", [("unknown-slug", False, "related_skill")])
    edges = store.edges_from("/a.md")
    assert edges[0]["target_path"] == "unknown-slug"
    assert edges[0]["resolved"] is False
```

### Step 2: Run tests to verify they fail

```bash
uv run pytest tests/graph/test_store.py -v
```
Expected: `ImportError`.

### Step 3: Implement `graph/store.py`

```python
# src/corpus_analyzer/graph/store.py
"""SQLite-backed directed adjacency store for the corpus relationship graph.

Schema (``relationships`` table):
    id           INTEGER PRIMARY KEY
    source_path  TEXT NOT NULL   -- absolute path of the source file
    target_path  TEXT NOT NULL   -- absolute path (resolved) or raw slug (unresolved)
    resolved     INTEGER NOT NULL -- 1=path resolved, 0=slug only
    relation_type TEXT NOT NULL  -- e.g. 'related_skill', 'related_file'
    indexed_at   TEXT NOT NULL   -- ISO 8601 UTC

Unique constraint: (source_path, target_path) — INSERT OR REPLACE handles
re-indexing without duplicating edges.
"""
from __future__ import annotations

import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from typing import Any


class GraphStore:
    """Thin wrapper around a SQLite file storing the relationship graph."""

    def __init__(self, db_path: Path) -> None:
        """Open (or create) the SQLite graph database at *db_path*."""
        self._path = db_path
        self._init_schema()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self._path)
        conn.row_factory = sqlite3.Row
        return conn

    def _init_schema(self) -> None:
        with self._connect() as conn:
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS relationships (
                    id           INTEGER PRIMARY KEY,
                    source_path  TEXT NOT NULL,
                    target_path  TEXT NOT NULL,
                    resolved     INTEGER NOT NULL DEFAULT 0,
                    relation_type TEXT NOT NULL DEFAULT 'related_skill',
                    indexed_at   TEXT NOT NULL,
                    UNIQUE(source_path, target_path)
                )
                """
            )
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_source ON relationships(source_path)"
            )
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_target ON relationships(target_path)"
            )

    def write_edges(
        self,
        source_path: str,
        edges: list[tuple[str, bool, str]],
    ) -> None:
        """Upsert edges for *source_path*.

        Args:
            source_path: Absolute path of the source file.
            edges: List of (target_path_or_slug, resolved, relation_type).
        """
        now = datetime.now(UTC).isoformat()
        with self._connect() as conn:
            conn.executemany(
                """
                INSERT INTO relationships (source_path, target_path, resolved, relation_type, indexed_at)
                VALUES (?, ?, ?, ?, ?)
                ON CONFLICT(source_path, target_path) DO UPDATE SET
                    resolved=excluded.resolved,
                    relation_type=excluded.relation_type,
                    indexed_at=excluded.indexed_at
                """,
                [
                    (source_path, target, int(resolved), rel_type, now)
                    for target, resolved, rel_type in edges
                ],
            )

    def clear_edges_for(self, source_path: str) -> None:
        """Delete all edges originating from *source_path* (used before re-index)."""
        with self._connect() as conn:
            conn.execute("DELETE FROM relationships WHERE source_path = ?", (source_path,))

    def edges_from(self, source_path: str) -> list[dict[str, Any]]:
        """Return all downstream edges from *source_path*."""
        with self._connect() as conn:
            rows = conn.execute(
                "SELECT * FROM relationships WHERE source_path = ? ORDER BY target_path",
                (source_path,),
            ).fetchall()
        return [dict(r) for r in rows]

    def edges_to(self, target_path: str) -> list[dict[str, Any]]:
        """Return all upstream edges pointing at *target_path*."""
        with self._connect() as conn:
            rows = conn.execute(
                "SELECT * FROM relationships WHERE target_path = ? ORDER BY source_path",
                (target_path,),
            ).fetchall()
        return [dict(r) for r in rows]
```

### Step 4: Run tests to verify they pass

```bash
uv run pytest tests/graph/test_store.py -v
```
Expected: all 5 tests pass.

### Step 5: Commit

```bash
git add src/corpus_analyzer/graph/store.py tests/graph/test_store.py
git commit -m "feat(graph): add GraphStore SQLite adjacency table"
```

---

## Task 4: Wire extraction into `ingest/indexer.py`

**Files:**
- Modify: `src/corpus_analyzer/ingest/indexer.py`
- Modify: `tests/ingest/test_indexer.py`

Context: `CorpusIndex.index_source()` in `indexer.py` already calls `classify_file()` and `generate_summary()` per file. We add `extract_related_slugs()` + registry resolution + `GraphStore.write_edges()` in the same per-file block.

### Step 1: Write the failing test

Find the test file for the indexer and add:

```python
# tests/ingest/test_indexer.py — add to existing file

def test_index_writes_relationship_edges(tmp_path: Path) -> None:
    """Indexer writes edges to GraphStore when ## Related Skills section present."""
    from corpus_analyzer.graph.store import GraphStore

    source_dir = tmp_path / "skills"
    source_dir.mkdir()
    (source_dir / "alpha.md").write_text(
        "# Alpha\n\n## Related Skills\n\n- `beta`\n"
    )
    (source_dir / "beta.md").write_text("# Beta\n\nContent.\n")

    graph_db = tmp_path / "graph.sqlite"
    # ... (use existing indexer test harness pattern from this file)
    # Assert: GraphStore(graph_db).edges_from(str(source_dir / "alpha.md")) has 1 entry
    # Assert: entry target_path == str(source_dir / "beta.md") and resolved == True
```

> **Note:** Look at the existing `test_index_adds_construct_type` test at line ~315 in `tests/ingest/test_indexer.py` for the exact fixture/harness pattern used. Mirror that pattern exactly for this test. The key assertion is that `GraphStore(graph_db).edges_from(alpha_path)` returns one edge pointing to `beta.md` with `resolved=True`.

### Step 2: Run test to verify it fails

```bash
uv run pytest tests/ingest/test_indexer.py::test_index_writes_relationship_edges -v
```
Expected: FAIL — `GraphStore` import works but no edges are written yet.

### Step 3: Modify `indexer.py`

Locate the `index_source` method (or equivalent per-file block). Add after the `classify_file` / `generate_summary` calls:

```python
# At top of indexer.py, add imports:
from corpus_analyzer.graph.extractor import extract_related_slugs
from corpus_analyzer.graph.registry import SlugRegistry
from corpus_analyzer.graph.store import GraphStore

# In CorpusIndex.__init__ or index_source, accept graph_store and registry params:
# graph_store: GraphStore | None = None
# registry: SlugRegistry | None = None

# In the per-file loop, after chunking:
if graph_store is not None and registry is not None:
    slugs = extract_related_slugs(file_text)
    if slugs:
        graph_store.clear_edges_for(str(file_path))
        edges = []
        for slug in slugs:
            resolved_path = registry.resolve(slug)
            if resolved_path is not None:
                edges.append((str(resolved_path), True, "related_skill"))
            else:
                edges.append((slug, False, "related_skill"))
        graph_store.write_edges(str(file_path), edges)
```

Wire `graph_store` and `registry` through `CorpusIndex.index_source()` signature. Pass `None` for both by default so existing callers are unaffected.

### Step 4: Run test to verify it passes

```bash
uv run pytest tests/ingest/test_indexer.py -v
```
Expected: all existing + new test pass.

### Step 5: Commit

```bash
git add src/corpus_analyzer/ingest/indexer.py tests/ingest/test_indexer.py
git commit -m "feat(graph): wire relation extraction into indexer pipeline"
```

---

## Task 5: Wire registry + GraphStore into `cli.py` `index` command

**Files:**
- Modify: `src/corpus_analyzer/cli.py`
- Modify: `tests/cli/test_index.py` (or nearest CLI test file for the `index` command)

### Step 1: Write the failing test

```python
# In the CLI test for `index` command — add:
def test_index_command_builds_graph_sqlite(tmp_path, ...) -> None:
    """Running `corpus index` creates graph.sqlite alongside the LanceDB index."""
    # invoke the index command with a source dir containing ## Related Skills
    # assert (data_dir / "graph.sqlite").exists()
```

> Mirror the existing `index` command test pattern. The assertion is simply that `graph.sqlite` is created in `DATA_DIR` after a successful `corpus index` run.

### Step 2: Run to verify it fails

```bash
uv run pytest tests/cli/ -k "index" -v
```

### Step 3: Modify `cli.py` `index_command`

```python
# In index_command(), after CorpusIndex.open():
from corpus_analyzer.graph.registry import SlugRegistry
from corpus_analyzer.graph.store import GraphStore

graph_store = GraphStore(DATA_DIR / "graph.sqlite")

# Build registry from all source roots
source_roots = [Path(s.path) for s in config.sources]
registry = SlugRegistry.build(source_roots)
if len(registry) == 0:
    console.print("[dim]Graph registry: no component directories found.[/]")

# Pass graph_store and registry into index_source() calls
```

### Step 4: Run to verify it passes

```bash
uv run pytest tests/cli/ -k "index" -v
```

### Step 5: Commit

```bash
git add src/corpus_analyzer/cli.py tests/cli/
git commit -m "feat(graph): wire GraphStore + SlugRegistry into corpus index command"
```

---

## Task 6: `corpus graph` CLI command

**Files:**
- Modify: `src/corpus_analyzer/cli.py`
- Create: `tests/cli/test_graph_command.py`

### Step 1: Write the failing tests

```python
# tests/cli/test_graph_command.py
"""CLI tests for the `corpus graph` command."""
from __future__ import annotations

from pathlib import Path
from unittest.mock import patch, MagicMock

from typer.testing import CliRunner
from corpus_analyzer.cli import app

runner = CliRunner()


def test_graph_help_available() -> None:
    result = runner.invoke(app, ["graph", "--help"])
    assert result.exit_code == 0
    assert "--depth" in result.stdout or "depth" in result.stdout.lower()


def test_graph_no_results_message(tmp_path: Path) -> None:
    graph_db = tmp_path / "graph.sqlite"
    from corpus_analyzer.graph.store import GraphStore
    GraphStore(graph_db)  # init empty db
    with patch("corpus_analyzer.cli.DATA_DIR", tmp_path):
        result = runner.invoke(app, ["graph", "no-such-skill"])
    assert result.exit_code == 0
    assert "no relationships" in result.stdout.lower() or "not found" in result.stdout.lower()


def test_graph_shows_downstream(tmp_path: Path) -> None:
    from corpus_analyzer.graph.store import GraphStore
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/skills/alpha/SKILL.md", [("/skills/beta/SKILL.md", True, "related_skill")])
    with patch("corpus_analyzer.cli.DATA_DIR", tmp_path):
        result = runner.invoke(app, ["graph", "alpha"])
    assert result.exit_code == 0
    assert "beta" in result.stdout


def test_graph_shows_upstream(tmp_path: Path) -> None:
    from corpus_analyzer.graph.store import GraphStore
    store = GraphStore(tmp_path / "graph.sqlite")
    store.write_edges("/skills/alpha/SKILL.md", [("/skills/beta/SKILL.md", True, "related_skill")])
    with patch("corpus_analyzer.cli.DATA_DIR", tmp_path):
        result = runner.invoke(app, ["graph", "beta"])
    assert result.exit_code == 0
    assert "alpha" in result.stdout
```

### Step 2: Run to verify they fail

```bash
uv run pytest tests/cli/test_graph_command.py -v
```
Expected: `FAIL` — command does not exist.

### Step 3: Add `corpus graph` to `cli.py`

```python
@app.command("graph")
def graph_command(
    slug: Annotated[str, typer.Argument(help="Component slug or path fragment to look up")],
    depth: Annotated[int, typer.Option("--depth", "-d", help="Traversal depth")] = 1,
) -> None:
    """Show upstream and downstream relationships for a component."""
    from corpus_analyzer.graph.store import GraphStore

    graph_db = DATA_DIR / "graph.sqlite"
    if not graph_db.exists():
        console.print("[yellow]No graph index found. Run 'corpus index' first.[/yellow]")
        raise typer.Exit(code=1)

    store = GraphStore(graph_db)

    # Resolve slug to paths by substring match
    all_edges: list[dict] = []
    # Get all edges and filter by slug appearing in source or target path
    with store._connect() as conn:
        all_rows = conn.execute("SELECT * FROM relationships").fetchall()
    matching_sources = {
        dict(r)["source_path"] for r in all_rows
        if slug in dict(r)["source_path"] or slug in dict(r)["target_path"]
    }

    if not matching_sources:
        console.print(f"[yellow]No relationships found for '[bold]{slug}[/bold]'.[/yellow]")
        return

    for source in sorted(matching_sources):
        downstream = store.edges_from(source)
        upstream = store.edges_to(source)
        console.print(f"\n[bold blue]{Path(source).parent.name}[/] ([dim]{source}[/])")
        if upstream:
            console.print("  [dim]Upstream (depends on this):[/]")
            for e in upstream:
                name = Path(e["source_path"]).parent.name
                flag = "" if e["resolved"] else " [dim](unresolved)[/]"
                console.print(f"    ← {name}{flag}")
        if downstream:
            console.print("  [dim]Downstream (this depends on):[/]")
            for e in downstream:
                name = Path(e["target_path"]).parent.name
                flag = "" if e["resolved"] else " [dim](unresolved)[/]"
                console.print(f"    → {name}{flag}")
```

### Step 4: Run to verify they pass

```bash
uv run pytest tests/cli/test_graph_command.py -v
```
Expected: all 4 tests pass.

### Step 5: Full suite

```bash
uv run pytest -v
```
Expected: all tests pass (previous 241 + new graph tests).

### Step 6: Commit

```bash
git add src/corpus_analyzer/cli.py tests/cli/test_graph_command.py
git commit -m "feat(graph): add corpus graph command for upstream/downstream queries"
```

---

## Done

Run the full suite one final time:

```bash
uv run pytest -v && uv run ruff check . && uv run mypy src/
```

All green = v1.2 Graph Linker complete.
