From 708e33450f6e5af44f8be48605d4afa2314304a4 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 14 Sep 2026 00:36:17 +0200 Subject: [PATCH] chore: seed the lode and enable the pre-PR gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable project memory now lives in lode/, so an agent starting cold reads the system as it is instead of re-deriving it from 52K of README and a CLAUDE.md whose Rails floor the gemspec never declares. What the lode holds: summary, terminology and practices as the baseline; lode-map.md as the index; seven area summaries — core (loader, engine, #render_page, Scope), config (the 36 knobs and the value objects), registry-and-versions (the page DSL, DocVersion, Snapshot), components (the 30 DocsUI classes and the one docs-nav controller), ai-surfaces (the Markdown twin, llms.txt, search, MCP, the OpenAPI bridge), install-path (the generator's 20 steps, SyncReport, the migrations, the CLI), and testing-and-ci (the four spec layers, the three CI jobs, release, deploy, the dogfood site). Every line range and count in them was computed with a script, not read off the screen. lode/workflow.md fills all ten headings from the code, CLAUDE.md, .claude/rules and the local commands it replaces, so the shared /lode: skills behave as if written for this repo: the SimpleCov floor that makes a single-file rspec run exit non-zero, the docs-site job's BUNDLE_FROZEN escape, the tracked docs/ lockfiles and how to resolve them, and the reviewer suggestions that are wrong here (add railties, use CurrentAttributes, name the reader #config, interpolate a Tailwind class, render json: the JSON-RPC envelope). Review rules: one, in lode/review/cli-and-templates.md — that docs-kit new resolves every DOCS_KIT_* value before the template runs, so the CLI default, its two help-text mentions and the template fallback must move together. It merges cubic learning ec3d720b with PR #70's accepted review thread, the only merged PR in the repo carrying review comments. The other six cubic learnings describe TAILWIND_SOURCES_COVER, ignore_generated_css_sources and SyncReport.tailwind_sources_rule, none of which exist on main — they live on open PR #72 — so writing them here would have asserted behaviour the shipping tree does not have. They are held with their safe failure directions for /lode:learn to import the day #72 merges. Retired in favour of the plugin: /lfg, /plan, /tdd, /github-review-pr, /github-review-failures and /github-review-comments. Their repo-specific content — the conflict runbook, the CI quirks, the constraint tables — moved into lode/workflow.md rather than being deleted. /architect, /security and /review-pr stay: they are docs-kit specialists the plugin does not cover. Claude-Session: https://claude.ai/code/session_01Hga3idYBp7fWdj7GmmfofF --- .claude/README.md | 22 +- .claude/commands/github-review-comments.md | 237 ------------------ .claude/commands/github-review-failures.md | 178 -------------- .claude/commands/github-review-pr.md | 162 ------------ .claude/commands/lfg.md | 272 --------------------- .claude/commands/plan.md | 95 ------- .claude/commands/tdd.md | 115 --------- .claude/settings.json | 13 + .gitignore | 2 + CLAUDE.md | 49 ++-- lode/ai-surfaces/summary.md | 53 ++++ lode/components/summary.md | 54 ++++ lode/config/summary.md | 51 ++++ lode/core/summary.md | 49 ++++ lode/install-path/summary.md | 69 ++++++ lode/lode-map.md | 43 ++++ lode/plans/README.md | 21 ++ lode/practices.md | 49 ++++ lode/registry-and-versions/summary.md | 51 ++++ lode/review/cli-and-templates.md | 11 + lode/summary.md | 13 + lode/terminology.md | 21 ++ lode/testing-and-ci/summary.md | 55 +++++ lode/workflow.md | 136 +++++++++++ 24 files changed, 739 insertions(+), 1082 deletions(-) delete mode 100644 .claude/commands/github-review-comments.md delete mode 100644 .claude/commands/github-review-failures.md delete mode 100644 .claude/commands/github-review-pr.md delete mode 100644 .claude/commands/lfg.md delete mode 100644 .claude/commands/plan.md delete mode 100644 .claude/commands/tdd.md create mode 100644 .claude/settings.json create mode 100644 lode/ai-surfaces/summary.md create mode 100644 lode/components/summary.md create mode 100644 lode/config/summary.md create mode 100644 lode/core/summary.md create mode 100644 lode/install-path/summary.md create mode 100644 lode/lode-map.md create mode 100644 lode/plans/README.md create mode 100644 lode/practices.md create mode 100644 lode/registry-and-versions/summary.md create mode 100644 lode/review/cli-and-templates.md create mode 100644 lode/summary.md create mode 100644 lode/terminology.md create mode 100644 lode/testing-and-ci/summary.md create mode 100644 lode/workflow.md diff --git a/.claude/README.md b/.claude/README.md index bdf416c..52b90f0 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -1,21 +1,24 @@ # `.claude/` — docs-kit engineering toolkit Slash commands and rules that drive autonomous and semi-autonomous work on -docs-kit. `/lfg` runs the full loop; the others are focused specialists. +docs-kit. The full loop is `/lode:lfg`, from the `lode@zoolutions` plugin enabled +in `settings.json`; the commands below are the repo-specific specialists that +remain here. The plugin reads `../lode/workflow.md` for everything specific to +this repository. ## Commands | Command | Tier | Purpose | |---------|------|---------| -| `/plan` | `fable` | Read-only planning → a self-contained GitHub issue or `docs/plans/` markdown that `/lfg` executes | -| `/lfg` | `opus` | Full workflow: branch → understand → explore → plan → TDD → verify → PR | -| `/tdd` | `sonnet` | Enforce RED → GREEN → REFACTOR | | `/architect` | `opus` | Coordinate a change across config → registry → components → client → generator → CSS | | `/security` | `opus` | Security audit (HTML escaping, config trust, render path, generated files, deploy secrets) | -| `/review-pr` | `opus` | Review a PR for pattern compliance | -| `/github-review-pr` | `opus` | Full PR pass: CI failures first, then review comments | -| `/github-review-failures` | `sonnet` | Fix failing CI checks until green | -| `/github-review-comments` | `sonnet` | Process unresolved PR review comments | +| `/review-pr` | `opus` | Review a PR for docs-kit pattern compliance | + +Retired in favour of the plugin: `/plan` → `/lode:plan`, `/lfg` → `/lode:lfg`, +`/tdd` → `/lode:tdd`, `/github-review-pr` → `/lode:review-pr`, +`/github-review-failures` and `/github-review-comments` → phases of +`/lode:review-pr`. Their repo-specific content — the conflict rules, the CI +quirks, the constraint tables — moved to `../lode/workflow.md`. ## Rules @@ -24,6 +27,7 @@ docs-kit. `/lfg` runs the full loop; the others are focused specialists. - `coding-style.md` — many small files, compose from `DocsUI::` components, read config, progressive enhancement - `git-workflow.md` — conventional commits, branch naming, PR flow, `rake release` - `testing.md` — the test layers (config / component render / generator), coverage bars +- `seo.md` — the SEO/OG meta-tag contract - `agents.md` — when to delegate, parallel exploration, cheaper models for mechanical subagents ## Model-tier convention @@ -42,7 +46,7 @@ argument-hint: "..." - `haiku` — mechanical/config work, diff pattern-scans - `sonnet` — layer specialists / pattern-following implementation (the default) - `opus` — orchestration, security, PR/production review -- `fable` — read-only planning that hands execution to cheaper models (`/plan`); otherwise pick it per-session with `/model` +- `fable` — read-only planning that hands execution to cheaper models; otherwise pick it per-session with `/model` Aliases track the latest model in each tier, so a pin never goes stale the way a literal `claude-opus-4-8` does. When you author a new command, pick the tier by diff --git a/.claude/commands/github-review-comments.md b/.claude/commands/github-review-comments.md deleted file mode 100644 index ca1f3f2..0000000 --- a/.claude/commands/github-review-comments.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -description: "Use when a PR has unresolved review comments that need responses -- evaluates each comment, implements valid fixes, pushes back on incorrect suggestions, and resolves all threads." -model: sonnet -argument-hint: "PR number (e.g., 123 or #123)" -allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(git log:*), Bash(git blame:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR Comments: $ARGUMENTS - -You are reviewing and responding to all unresolved review comments on a GitHub pull request. Apply technical rigour -- evaluate each comment against the actual codebase before accepting or rejecting it. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR123`, `PR 123`, `pr123` -> PR 123 -- `123` -> PR 123 -- `#123` -> PR 123 -- Empty/blank -> auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url -``` - ---- - -## Phase 1: Fetch All Unresolved Review Comments - -Retrieve all review comments and identify unresolved ones: - -```bash -# Get all review comments (not resolved) -gh api "repos/zoolutions/docs-kit/pulls//comments" --paginate - -# Get all review threads to check resolution status -gh api graphql -f query=' - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - id - isResolved - path - line - comments(first: 20) { - nodes { - id - databaseId - body - author { login } - createdAt - } - } - } - } - } - } - } -' -f owner=zoolutions -f repo=docs-kit -F pr= -``` - -For each unresolved thread, extract: -- Thread ID (for resolving) -- Comment body (the review feedback) -- File path and line number (if inline) -- Author (to understand context) - -Filter to only **unresolved** threads. Skip bot comments (CodeRabbit, dependabot), resolved threads, and PR description comments. - -If there are no unresolved review comments, report that and stop. - ---- - -## Phase 2: Read and Categorise Each Comment - -For each unresolved comment, read the full body and categorise it: - -| Category | Action | -|----------|--------| -| Valid fix needed | Implement the fix | -| Valid test gap | Add the missing test | -| Valid style/consistency issue | Fix it | -| Incorrect suggestion | Push back with technical reasoning | -| Suggestion conflicts with architecture | Push back, reference existing patterns | -| Over-engineering / YAGNI | Push back, explain why it's unnecessary | -| Unclear | Ask for clarification (do NOT implement) | - -**Before categorising**, always: -1. Read the actual file and line being commented on -2. Check if the suggestion is technically correct for THIS codebase -3. Check if it would break existing functionality (a consuming docs site) -4. Check if existing patterns/conventions contradict the suggestion -5. Check CLAUDE.md rules -- project conventions override reviewer preferences - ---- - -## Phase 3: Implement Accepted Fixes - -For all comments you've decided to accept: - -1. **Make the code changes** -- edit the relevant files -2. **Run affected tests** to verify nothing breaks: - ```bash - bundle exec rspec - ``` -3. **Run validators**: - ```bash - bundle exec rubocop - ``` -4. **Commit** all fixes together with a clear message: - ```bash - git commit -m "$(cat <<'EOF' - fix: address PR review feedback - - - Description of fix 1 - - Description of fix 2 - EOF - )" - ``` -5. **Push** to the remote branch: - ```bash - git push - ``` - ---- - -## Phase 4: Reply to Every Comment - -For **each** unresolved thread, reply: - -### For accepted fixes: - -Reply with what was fixed and the commit SHA: - -```bash -gh api "repos/zoolutions/docs-kit/pulls//comments//replies" \ - --method POST \ - -f 'body=Fixed in . .' -``` - -### For rejected suggestions: - -Reply with technical reasoning: - -```bash -gh api "repos/zoolutions/docs-kit/pulls//comments//replies" \ - --method POST \ - -f 'body=' -``` - -### Resolving threads (via GraphQL): - -After replying, resolve the thread: - -```bash -gh api graphql -f query=' - mutation($threadId: ID!) { - resolveReviewThread(input: {threadId: $threadId}) { - thread { isResolved } - } - } -' -f threadId= -``` - -### For general PR comments (not inline review threads): - -Reply directly: - -```bash -gh pr comment --body "" -``` - ---- - -## Phase 5: Verify Completion - -After processing all comments, verify no unresolved threads remain: - -```bash -gh api graphql -f query=' - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - totalCount - nodes { isResolved } - } - } - } - } -' -f owner=zoolutions -f repo=docs-kit -F pr= -``` - -Report the final tally: how many comments were accepted/fixed, how many were pushed back on, and confirm all threads are resolved. - ---- - -## Response Style - -When replying to comments: - -- **No performative agreement** -- never say "Great point!" or "You're absolutely right!" -- **No gratitude** -- never say "Thanks for catching that" -- **Be direct** -- state the fix or the reasoning, nothing more -- **Reference commits** -- always include the short SHA when a fix was made -- **Be specific** -- when pushing back, reference actual code, not abstract principles - -When pushing back: - -- Use technical reasoning grounded in the actual codebase -- Reference existing patterns if the suggestion contradicts them -- Reference CLAUDE.md rules when applicable -- Explain what would break or what edge case the reviewer missed -- If the suggestion is valid in principle but wrong for this context, say so - ---- - -## Important Notes - -- Always read the actual code before evaluating a comment -- reviewers sometimes misread diffs -- If a comment reveals a genuine bug you missed, fix it without defensiveness -- If multiple comments suggest the same change, implement it once and reference the fix in all replies -- Bot reviewers (CodeRabbit, etc.) sometimes suggest changes that conflict with project conventions -- verify against CLAUDE.md -- If a new round of review comments appears after your push (from re-review), report that to the user rather than entering an infinite loop - -Now begin by determining the PR number from `$ARGUMENTS` or the current branch. diff --git a/.claude/commands/github-review-failures.md b/.claude/commands/github-review-failures.md deleted file mode 100644 index bd4b68f..0000000 --- a/.claude/commands/github-review-failures.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -description: "Use when CI checks are failing on a PR — fetches failure logs, diagnoses root causes, implements fixes, and pushes until CI is green." -model: sonnet -argument-hint: "PR number (e.g., 41 or #41)" -allowed-tools: Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh pr diff:*), Bash(gh api:*), Bash(gh run view:*), Bash(git log:*), Bash(git diff:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Fix GitHub CI Failures: $ARGUMENTS - -You are diagnosing and fixing CI failures on a GitHub pull request. Work systematically: identify failures, read logs, diagnose root causes, fix locally, verify, push. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR41`, `PR 41`, `pr41` -> PR 41 -- `41` -> PR 41 -- `#41` -> PR 41 -- Empty/blank -> auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url,mergeable -``` - -**Pre-flight: merge conflicts (detection only).** If `mergeable` is `CONFLICTING`, STOP — do not diagnose CI on a conflicted branch (the merge itself may fix or cause the failures). Report the conflict and hand off to `/github-review-pr`, whose Phase A0 owns the resolution runbook — this command's toolset deliberately does not include the merge machinery. If `mergeable` is `UNKNOWN`, note it and proceed: the orchestrator resolves the ambiguity; a standalone run shouldn't block on GitHub's recompute. - ---- - -## Phase 1: Identify Failing Checks - -```bash -gh pr checks -``` - -Categorise each failing check: - -| Check Type | Examples | How to Get Logs | -|------------|----------|----------------| -| Lint (rubocop) + gem build | `Lint` | `gh run view --job= --log-failed` | -| Unit + component specs | `Ruby 3.x` | `gh run view --job= --log-failed` | -| Docs-site deploy (if triggered) | `Deploy docs` | `gh run view --job= --log-failed` | - -Extract the run ID and job IDs from the check URLs. The URL format is: -`https://github.com/zoolutions/docs-kit/actions/runs//job/` - -If all checks pass or are pending, report that and stop. - ---- - -## Phase 2: Fetch Failure Logs - -For each failing check, get the logs: - -```bash -# Get the failed job logs (condensed output) -gh run view --job= --log-failed -``` - -If `--log-failed` output is too large or unclear, try: - -```bash -# Full log for a specific job -gh run view --job= --log 2>&1 | tail -100 -``` - ---- - -## Phase 3: Diagnose Each Failure - -For each failure, determine the root cause: - -### Lint Failures - -Look for: -- RuboCop offenses: file path, line number, cop name, message - -**Key**: RuboCop failures can often be auto-fixed with `bundle exec rubocop -A `. - -### Spec Failures - -Look for: -- Test name and file path -- Error class and message -- Relevant backtrace lines (ignore framework noise) -- Whether it's a test environment issue vs actual code bug - -**Key patterns**: -- `NameError: uninitialized constant` -> missing require or renamed class -- `NoMethodError: undefined method` -> API change, missing method -- Component spec `expected HTML to include X` -> the render output changed, or a class the CSS relies on was dropped -- `expected: X, got: Y` -> logic bug or test needs updating - -### Build / Deploy Failures - -Look for: -- Gem build errors: missing files in gemspec, syntax errors -- Bundle install failures: dependency conflicts -- Deploy workflow: Docker build context, dash image/service name mismatch, missing `docs` environment secret - ---- - -## Phase 4: Fix Locally - -For each diagnosed failure: - -1. **Read the relevant file** to understand context before fixing -2. **Make the fix** -- edit the file -3. **Verify locally** before committing: - -```bash -# For rubocop failures -bundle exec rubocop - -# For spec failures -bundle exec rspec - -# For full validation -bundle exec rake -``` - -### Fix Priority Order - -1. **Lint/style fixes** first (fast, deterministic) -2. **Spec failures** second (may require understanding the code change) -3. **Build/deploy issues** third (usually gemspec, dependency, or a dash name mismatch) - ---- - -## Phase 5: Commit and Push - -```bash -git add -git commit -m "$(cat <<'EOF' -fix(ci): - -- Fix 1 description -- Fix 2 description -EOF -)" -git push -``` - ---- - -## Phase 6: Verify - -After pushing, check if CI has been re-triggered: - -```bash -gh pr checks -``` - -If there are still pending checks, report which checks are running and what was fixed. Do NOT poll in a loop -- report the status and let the user know. - -If you can identify that certain failures will persist for environmental reasons (e.g., a deploy job that needs a `docs` environment secret not present on a fork PR), flag that explicitly. - ---- - -## Important Notes - -- **Read before fixing** -- always read the actual failing code before attempting a fix -- **Fix the root cause** -- don't add `# rubocop:disable` to bypass lint; fix the actual issue (a targeted `# rubocop:disable` is acceptable only when RuboCop is demonstrably wrong) -- **Don't fix unrelated failures** -- if a spec was already failing on main, note it but don't fix it in this PR -- **Deploy vs. test failures** -- the `Deploy docs` workflow only fires on a release/dispatch; a normal PR usually runs lint + specs. A deploy failure is often a dash image/service name or missing-secret issue, not a code bug (see the README deploy section). -- **Flaky tests** -- if a test passes locally but fails in CI, note it as potentially flaky rather than adding workarounds. -- **Don't retry CI blindly** -- diagnose first, fix, then push. Each push triggers a full CI run. - -Now begin by determining the PR number and fetching the failing checks. diff --git a/.claude/commands/github-review-pr.md b/.claude/commands/github-review-pr.md deleted file mode 100644 index fd52467..0000000 --- a/.claude/commands/github-review-pr.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -description: "Use when a PR needs full review — resolves merge conflicts with the base first, then fixes CI failures, then addresses unresolved review comments. Conflicts first so CI diagnoses the post-merge reality; failures before comments because comment fixes trigger new CI runs that obscure the original failures." -model: opus -argument-hint: "PR number (e.g., 156 or #156)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh pr checkout:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh run view:*), Bash(git log:*), Bash(git blame:*), Bash(git diff:*), Bash(git status:*), Bash(git switch:*), Bash(git checkout --ours:*), Bash(git checkout --theirs:*), Bash(git fetch:*), Bash(git merge:*), Bash(git merge-tree:*), Bash(git rev-parse:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Bash(bundle install:*), Bash(bun:*), Bash(cd:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR (full pass): $ARGUMENTS - -You are running a full review pass on a pull request. The pass has three phases that MUST run in this order: - -1. **Phase A0: merge conflicts** — bring the branch up to date with its base and resolve any conflicts before anything else. -2. **Phase A: CI failures** — fix anything red before touching review comments. -3. **Phase B: review comments** — only after Phase A leaves CI green (or pending green after a push). - -## Why this order matters - -**Conflicts before failures**: CI results only matter for the code that will actually merge. On a conflicted (or stale) branch you'd diagnose failures against a base that no longer exists — and the conflict resolution itself changes code, invalidating the run you just fixed. Resolving conflicts first means Phase A reads CI for the post-merge reality, and you spend exactly one extra CI cycle instead of two. - -**Failures before comments**: if you fix review comments first, every commit pushes a new CI run. By the time the review-comment fixes finish, the original failure logs are buried under new pipeline runs. Symptoms: - -- The failing spec log you needed to read is now from a stale run; the latest run is still in progress on top of your unrelated comment fixes. -- A review-comment fix accidentally repairs the CI failure as a side effect, and you lose the chance to verify the failure was real. -- A review-comment fix accidentally INTRODUCES a CI failure, and you can't tell whether the new failure was pre-existing or your fault. - -Conflicts-first, then failures-first eliminates this confusion. CI is either green or red on a known commit against the current base; the review-comment fixes layer cleanly on top. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR156`, `PR 156`, `pr156` → PR 156 -- `156` → PR 156 -- `#156` → PR 156 -- Empty/blank → auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url -``` - ---- - -## Phase A0: Merge conflicts - -Check whether the branch merges cleanly into its base: - -```bash -gh pr view --json mergeable,mergeStateStatus,baseRefName -``` - -| `mergeable` | Action | -|-------------|--------| -| `MERGEABLE` | Skip to Phase A. | -| `UNKNOWN` | GitHub is recomputing (common right after pushes, and it can stay UNKNOWN for minutes). Don't poll it — verify **locally**, against the PR's actual head (NOT `HEAD`, which may be some other checked-out branch): `git fetch origin ` and `git fetch origin pull//head`, verify both refs resolve (`git rev-parse --verify origin/^{commit}` and `git rev-parse --verify FETCH_HEAD^{commit}` — a bad ref also exits 1 from merge-tree, so exit code alone can't be trusted), then `git merge-tree --write-tree --name-only origin/ FETCH_HEAD`. Clean exit → no conflicts, skip to Phase A. Exit 1 **with conflict output** → resolve below (the `--name-only` file list is your work list). | -| `CONFLICTING` | Resolve, below. | - -### Resolution procedure - -1. Check out the PR's branch (`gh pr checkout `) with a clean tree (`git status`). Stash nothing — if the tree is dirty, stop and ask the user. -2. `git fetch origin ` then **`git merge origin/`** — MERGE, never rebase. The branch is shared (it has a PR); a rebase would require a force-push, which is forbidden on shared branches. -3. Resolve every conflicted file **semantically** — read both sides and produce the version that preserves BOTH changes' intent. Never blanket `--ours`/`--theirs` a source file. Repo-specific rules: - - **`CHANGELOG.md` (Unreleased)**: union — keep BOTH sides' entries (main's landed bullets and this branch's), most recent first, without duplicating the `### Added`/`### Fixed` subheads. Losing either side is a real regression reviewers rarely catch. - - **`lib/docs_kit/version.rb`**: releases land DIRECTLY on `main` via `rake release[X.Y.Z]` (the task aborts unless it's on `main`, no PR), so an ordinary feature branch never edits this file — a conflict here means the BRANCH bumped it on purpose (a release-prep PR). Keep the branch's bump in that case; if the intent isn't obvious from the branch's own commits, stop and ask. Only take the base's version when the branch's edit was clearly accidental. - - **`docs/Gemfile.lock`** (tracked — the dogfood site is a real deployable app and commits its lock; the gem root's `Gemfile.lock` is gitignored and can never conflict): take the base's file — `git checkout --theirs docs/Gemfile.lock` (merging `origin/` into the branch makes the base "theirs") then `git add` it — and run `bundle install` in `docs/` so the branch's own dependency changes, if any, re-resolve on top. Never hand-edit a lockfile. - - **`docs/bun.lock`** (tracked — CI installs with `bun install --frozen-lockfile`): same treatment — `git checkout --theirs docs/bun.lock`, `git add` it, then run `bun install` in `docs/` to re-resolve. Never hand-edit it. - - **Vendored icon SVGs** (`docs/app/assets/svg/icons/**`): these are upstream Lucide copies, not authored files — never hand-merge SVG markup; take one side wholesale (`git checkout --theirs ` for the base's copy, `git checkout --ours ` for the branch's, then `git add`) or re-vendor from upstream. - - **Append-only registries** (`docs/app/models/doc.rb`'s `page` lines, `docs/config/routes.rb`): both sides usually appended — keep both lines, in base order first. -4. Run the verification gates BEFORE pushing the merge — scoped to what the conflict touched, at minimum: - ```bash - bundle exec rake # the gem suite + rubocop — exactly what CI's rake job runs - # docs/ files involved (the dogfood site has its own bundle + .rubocop.yml — - # the gem's rubocop deliberately excludes it; CI's docs-site job runs its rspec): - cd docs && bundle exec rspec && bundle exec rubocop - ``` -5. Commit the merge (keep git's standard merge-commit message; add a body line naming any non-obvious resolution choice) and `git push` — a merge commit never needs force. - -### Phase A0 exit criteria - -- The PR reports `MERGEABLE` (or the local `git merge-tree` check is clean), AND the merge commit (if one was needed) is pushed. -- If the merge produced changes, CI is now re-running — that's expected; Phase A reads the fresh run. -- If a conflict cannot be resolved with confidence (both sides rewrote the same logic and the correct combination isn't decidable from the code), **stop and ask the user** — a guessed resolution that compiles is worse than a question. - ---- - -## Phase A: Run `/github-review-failures` - -Invoke the existing `/github-review-failures` slash command with the same `$ARGUMENTS` value. Its purpose: fix every failing CI check, push, leave the branch in a state where CI is either green or running-pending-toward-green. - -Follow that command's full process — phases 1–6 of the failures runbook. The slash command is at `.claude/commands/github-review-failures.md`. Its workflow: - -1. Identify failing checks via `gh pr checks `. -2. Fetch failure logs. -3. Diagnose root cause for each. -4. Fix locally — lint first (fast, deterministic), then specs, then build issues. -5. Verify locally before commit (`bundle exec rspec `, `bundle exec rubocop`). -6. Commit + push + report which checks are now running. - -### Phase A exit criteria - -Before moving to Phase B, one of these must be true: - -- All CI checks are green on the latest pushed commit. OR -- All CI checks are pending (running) on the latest pushed commit, AND no checks failed in the most recent completed run on this commit. OR -- A persistent CI failure exists that is **not caused by changes on this branch** (e.g., a flaky test on `main`, a deploy job that fails for environmental reasons). Report this explicitly and proceed to Phase B with the caveat noted. - -If failures persist on this branch's changes, **do NOT proceed to Phase B**. Report what's still failing, what's been tried, and ask the user how to proceed. - ---- - -## Phase B: Run `/github-review-comments` - -Once Phase A's exit criteria are met, invoke `/github-review-comments` with the same `$ARGUMENTS`. Its purpose: address every unresolved review thread on the PR, push fixes, reply with commit SHAs, and resolve the threads. - -The slash command is at `.claude/commands/github-review-comments.md`. Its workflow: - -1. Fetch all unresolved review threads via the GitHub GraphQL API. -2. Read and categorise each comment (valid fix / invalid suggestion / unclear). -3. Implement accepted fixes; verify locally (specs, validators, rubocop). -4. Commit all fixes together with a clear message; push. -5. Reply to every thread with the commit SHA (for accepted fixes) or technical reasoning (for rejections). -6. Resolve each thread via the GraphQL `resolveReviewThread` mutation. -7. Verify no unresolved threads remain. - -### Phase B exit criteria - -- All unresolved review threads have been replied to and resolved (or the user has explicitly approved leaving a specific thread open). -- The branch has been pushed with all accepted fixes. - ---- - -## Phase C: Final report - -Before reporting, re-check mergeability once more (`gh pr view --json mergeable`, or the local `git merge-tree` check if UNKNOWN) — the base can move underneath a long pass. If a NEW conflict appeared, loop back to Phase A0. - -After all phases complete, report: - -1. **Phase A0 summary**: whether the branch was conflicted, which files conflicted, how each was resolved (and the merge commit SHA) — or "clean merge, no action". -2. **Phase A summary**: which CI failures were diagnosed and fixed. Note the commit SHAs for the fixes. -3. **Phase B summary**: which review comments were accepted (with commit SHAs), which were pushed back on (with reasoning), and the final unresolved-thread count (should be 0). -4. **End state**: final mergeability + CI status on the latest commit. -5. **Outstanding work**: anything that still needs attention — e.g., CI was pending at the end of Phase B and the user should verify the latest run after the comment fixes. - ---- - -## Important Notes - -- **Do not interleave the phases.** Don't fix a CI failure, then a review comment, then another CI failure. The whole point of this command is the strict ordering. -- **A new CI failure emerging during Phase B** (e.g., a comment fix breaks a spec) means looping back to Phase A — fix the new failure before continuing comment work. Likewise, **a new conflict appearing mid-pass** (the base moved) means looping back to Phase A0. These loop-backs are the only allowed reverse directions. -- **If the PR is already merged**, there is nothing to review — report that and stop. (A stale `$ARGUMENTS` or a just-merged PR shows up as `state: MERGED` in Phase 0's confirm step.) -- **If the PR merges cleanly, has no failures AND no unresolved comments**, report "PR is clean" and stop. -- **If `$ARGUMENTS` is the same as the current open PR**, the two child slash commands will see the same PR. They share state through the git branch and the GitHub API, not through any in-process variable. -- **Don't re-implement the child slash commands' logic**. Invoke them and let them do their work. This command is the orchestrator. diff --git a/.claude/commands/lfg.md b/.claude/commands/lfg.md deleted file mode 100644 index 9c92d51..0000000 --- a/.claude/commands/lfg.md +++ /dev/null @@ -1,272 +0,0 @@ ---- -description: "Executes full autonomous engineering workflow with verification. Use when implementing complete features, tackling GitHub issues, or running end-to-end development cycles." -model: opus -argument-hint: "GitHub issue number/URL or feature description" -allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr create:*), Bash(gh pr view:*), Bash(bundle exec:*), Bash(bun:*), Bash(git:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# LFG - Full Autonomous Workflow - -Execute a complete engineering workflow with verification at each phase. - -## Phase 0: Branch Setup - -**BEFORE any other work, prepare the git branch:** - -1. Check the current branch: `git branch --show-current` -2. If NOT on `main`, switch: `git checkout main` -3. Pull latest: `git pull origin main` -4. Create feature branch: `git checkout -b issue-{number}-{brief-description}` (or `feature/{description}` if no issue number) - ---- - -## Phase 1: Understand - -### Step 1: Gather Requirements - -If `$ARGUMENTS` is a GitHub issue number or URL: - -```bash -gh issue view --json title,body,labels,assignees,comments -``` - -If `$ARGUMENTS` is a description, use it directly. - -### Step 2: Define Acceptance Criteria - -**MANDATORY:** Write explicit acceptance criteria: - -- **GIVEN** [context/setup] -- **WHEN** [action taken] -- **THEN** [expected outcome] - -You MUST NOT proceed until you can articulate these clearly. - -### Step 3: Comprehension Gate - -Before proceeding, you must: - -1. State the problem/feature in one sentence -2. Explain WHY this is needed (the user-facing payoff — docs sites that look identical, maintained in one place) -3. List what changes from the consuming site's perspective (the config/component/generator API delta) -4. Identify edge cases not explicitly mentioned -5. Explain the flow: `DocsKit.configure` → the `DocsUI::` component reads config → Phlex renders through a real view context → the CSS build scans the Ruby → the browser gets the chrome. Which link changes? - -If you cannot complete ALL five items, investigate further. - -### Step 4: Create Task List - -Create a TaskCreate todo list with specific implementation steps. - ---- - -## Phase 2: Explore - -1. Find related files (Glob/Grep or Explore agent) -2. Read existing patterns in similar components -3. Understand integration points across the layers -4. Check existing test coverage in `spec/` -5. Review the component kit in `app/components/docs_ui/` (Shell, Sidebar, Code, Page, ThemeSwitcher, …) -6. Review the config surface in `lib/docs_kit/configuration.rb` (any new knob a site must set goes here) -7. Review the registry mixin + value object (`lib/docs_kit/registry.rb`, `lib/docs_kit/nav_item.rb`) -8. Review the engine (`lib/docs_kit/engine.rb`) — it auto-pins the `docs-nav` controller and mounts assets -9. Review the install generator (`lib/generators/docs_kit/install/`) — a new required setup step must land in the templates/`docs-kit new` template -10. Review the one client controller (`app/javascript/docs_kit/controllers/docs_nav_controller.js`) — collapse persistence + auto-TOC - ---- - -## Phase 3: Plan - -1. List files to modify with specific changes -2. List new files to create with purpose -3. Identify the config default vs. per-site override (a new behavior host apps tune goes on `Configuration`) -4. Plan test coverage across layers (TDD: tests FIRST) — config, component render, registry, generator -5. Update the task list -6. Consider backwards compatibility (existing consuming sites must keep working verbatim) -7. If the change adds classes the CSS must generate, plan the `@source` / `@source inline(...)` update (Tailwind scans Ruby, and Drawer classes are render-time) - ---- - -## Phase 4: Implement (TDD) - -### The deviation log (keep it from the first edit) - -The plan is the map; the codebase is the territory. The moment reality forces a choice the plan or issue didn't settle, log it in `implementation-notes.md` at the repo root — one line, at the moment it happens, not reconstructed later: - -- **Deviations** — the plan said X, you did Y, because Z -- **Discoveries** — facts about the codebase the plan didn't know -- **Judgment calls** — choices the user might have made differently (defaults, naming, scope cuts) - -Pick the conservative option and keep going. The log is how the user audits your judgment afterwards. Never commit the file: its contents move into the PR body, then the file is deleted. - -For each logical unit: - -### 4.1: Write Failing Test First - -```bash -bundle exec rspec -``` - -### 4.2: Implement Minimum Code - -Write the MINIMUM code to make the test pass. Follow project patterns: - -| Never Do | Always Do | -|----------|-----------| -| Hand-write raw daisyUI markup | Compose from `DocsUI::` Phlex components | -| Hardcode a site-specific value in a component | Read it from `DocsKit.configuration` | -| Require JS for the page to work | Server-render a working page; JS only *enhances* | -| Add a Stimulus controller per feature | The ONE `docs-nav` controller, auto-pinned | -| Offer a theme the CSS never built | Keep `config.themes` in sync with the Tailwind `@plugin` list | -| Fabricate a view context to render | Render through a real view context (CSRF, `dom_id`, url helpers must work) | -| Add a required setup step only to the README | Also wire it into the install generator / `docs-kit new` template | - -### 4.3: Refactor - -Once green, refactor while keeping tests passing. - -### 4.4: Validate - -```bash -bundle exec rubocop -``` - -### 4.5: Repeat - -Move to the next unit. Mark task items complete. - ---- - -## Phase 5: Deep Root Cause Analysis (Bug Fixes Only) - -**If this is a bug fix, investigate before implementing.** - -### Trace the lifecycle - -For the failing behavior: -- Did it originate in config, in a component's render, in the CSS build (a class not scanned), or in the `docs-nav` controller? -- Is it a server-render bug or a client-enhancement bug (does it reproduce with JS off)? -- What ASSUMPTIONS does the code make at the failure point? Which was violated, and WHY? - -### Use git history - -```bash -git log --oneline -20 -git blame -``` - -### Map all callers - -Use Grep to find every call site. Does the bug happen only for a certain config -(no version badge, empty nav group)? Only for a specific theme? Only when a -consuming site overrides a default? - -### Five Whys - -Keep asking WHY until you reach the real fix point. - -### Fix-location principle - -The best fix is usually NOT where the error surfaced: -- daisyUI class missing at runtime → the Tailwind `@source` scan, not an inline class hack -- Sidebar collapsed with JS off → server-render sections `open`, not a JS fallback -- Switcher offers a dead theme → sync `config.themes` with the CSS build, not filter in the view -- Active link wrong → the server-side path match, not a client patch - -### Unacceptable superficial fixes — DO NOT DO THESE - -- `rescue nil` / bare `rescue` to silence an error you don't understand -- `&.` to paper over a nil without finding why it's nil -- `return if x.nil?` to silently skip -- swallowing errors instead of logging + fixing the cause - -**These HIDE bugs. Find the EARLIEST point you could prevent the error and fix there.** - ---- - -## Phase 6: Verify - -**ALL of these must pass before committing:** - -```bash -bundle exec rubocop -bundle exec rspec -# CSS-affecting changes (new emitted classes): rebuild and eyeball the output -bun run build:css # if the change adds/renames classes the Tailwind build must scan -``` - -### Solution verification - -- "If I were the requester, is this fully resolved?" -- "Did I fix the ROOT CAUSE, not the symptom?" -- "Do the tests prove it?" -- "Does every existing consuming site still work verbatim (backwards compatible)?" -- "If the change needs setup, does the install generator / `docs-kit new` template do it?" - ---- - -## Phase 7: Commit & PR - -### Commit - -```bash -git add -git commit -m "$(cat <<'EOF' -feat(scope): brief description - -## Summary -[What changed and why] - -## Test Coverage -- spec 1: validates X -- spec 2: validates the config-driven default - -## Verification -- [x] bundle exec rubocop passes -- [x] bundle exec rspec passes -EOF -)" -``` - -### Push & PR - -```bash -git push -u origin $(git branch --show-current) - -gh pr create --title "feat(scope): brief description" --body-file /tmp/pr-body.md -``` - -Write the PR body to a temp file (`--body-file`) to avoid shell-interpolation of -backticks/tables. The body is copied verbatim — if you would not type a -backslash in a GitHub comment, do not type one in the heredoc. - -The PR body MUST end with a `## Deviations & judgment calls` section copied from -`implementation-notes.md` (then delete the file). If the plan held completely, -write "None — the plan held." This section is read FIRST in review — it is the -audit trail for every decision the plan didn't make. - ---- - -## Phase 8: Comprehension Close-Out - -The tests prove the CODE is right; this phase keeps the USER's mental model right. After the PR is up, end your final message with: - -1. **The decisions, not the diff** — the 3–5 non-obvious choices in this change someone must understand to maintain it. Lead with anything from the deviation log; the user has never seen those. -2. **Three merge-gate questions** the user should be able to answer before merging. If any answer isn't obvious to them, offer a walkthrough — an unanswerable question is comprehension debt, and merging anyway is how it compounds. - ---- - -## Verification Checklist - -- [ ] All acceptance criteria met -- [ ] Tests written BEFORE implementation -- [ ] `bundle exec rubocop` passes -- [ ] `bundle exec rspec` passes -- [ ] Backwards compatible — existing consuming sites unchanged -- [ ] New setup wired into the install generator / `docs-kit new` template -- [ ] CSS build scans any new emitted classes (`@source` updated if needed) -- [ ] PR created with summary + test plan -- [ ] PR body ends with `## Deviations & judgment calls` (from implementation-notes.md, since deleted) -- [ ] Comprehension close-out delivered (decisions + three merge-gate questions) - -Now, execute this workflow for the provided issue or feature. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md deleted file mode 100644 index 60f15d7..0000000 --- a/.claude/commands/plan.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -description: "Investigates the codebase, designs a solution, and produces a durable plan artifact — a GitHub issue or a plan markdown under docs/plans/. Read-only: never edits application code. Use before /lfg for anything non-trivial." -model: fable -argument-hint: "issue | md | " -allowed-tools: Bash(gh issue create:*), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh search:*), Bash(gh label list:*), Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(date:*), Read, Grep, Glob, Write, Agent, AskUserQuestion ---- - -# Plan — design expensive, execute cheap - -You are the planning specialist. This command runs on the most capable model deliberately: the thinking happens here, the execution happens later on cheaper models (`/lfg` on Opus, layer specialists on Sonnet). That split only works if the plan is **self-contained** — an executor with none of this session's context must be able to implement it without guessing. - -## Output mode from $ARGUMENTS - -| $ARGUMENTS starts with | Artifact | -|------------------------|----------| -| `issue` | GitHub issue (default — feeds directly into `/lfg `) | -| `md` or `file` | Markdown file at `docs/plans/YYYY-MM-DD-.md` (date from `date +%F`) | -| anything else | GitHub issue | - -## Hard constraints - -- **Read-only for source code.** Never edit application code, never commit, never create branches. The only file you may Write is a new plan markdown under `docs/plans/`. -- **Never reproduce secrets** (keys, tokens, credentials) in the plan, even redacted ones you encounter while reading config. -- **Dedupe before creating an issue**: `gh issue list --search ""` — if an existing issue covers this, extend it in your summary instead of duplicating. - -## Phase 1 — Investigate - -Protect this session's context: delegate mechanical exploration to cheaper subagents and keep the capable model for judgment. - -1. Fan out Explore agents (`model: haiku`) for file discovery and naming-convention sweeps; use `model: sonnet` agents when a subsystem needs to be read and summarized. Launch independent explorations in parallel. -2. Read the load-bearing files yourself — the ones the design decision actually hinges on. Don't design from subagent summaries alone. For docs-kit that usually means `lib/docs_kit/configuration.rb`, the relevant `app/components/docs_ui/*.rb`, `lib/docs_kit/engine.rb`, and the install generator under `lib/generators/docs_kit/install/`. -3. Read `CLAUDE.md` and the `.claude/rules/` files — the project invariants live there. -4. Check `git log` for recent related work; the design should extend it, not fight it. - -## Phase 2 — Surface the unknowns (blindspot pass + interview) - -Investigation tells you what the codebase says; this phase finds what the REQUEST doesn't say. Run it BEFORE designing — a wrong assumption caught here costs one question; caught in review it costs a rewrite. - -1. **Blindspot pass.** Write down the unknowns you are carrying into the design: - - decisions the request leaves open (defaults, naming, public API/config surface, rollout & upgrade story) - - edge cases the codebase makes possible that the request never mentions - - anything with no precedent in this repo — flag it explicitly as unknown-unknown territory -2. **Interview the user** with AskUserQuestion, one question at a time, prioritized by blast radius: architecture-changing answers first, then public API / config surface, then UX. Rules: - - Skip anything the codebase, CLAUDE.md, or an existing issue already answers. - - 2–5 questions is the sweet spot; zero is fine when the request is genuinely unambiguous — say so rather than inventing questions. - - Every question offers concrete options with a recommended default, never an open-ended essay prompt. -3. **Record the answers** in the plan's Decision section as `Settled in interview:` bullets — constraints the executor must not re-litigate. - -## Phase 3 — Design - -- Develop 2–3 candidate approaches with real tradeoffs. Pick one and say why; record why the others lost. -- The chosen design must respect docs-kit invariants: chrome is composed from `DocsUI::` Phlex components (never raw daisyUI markup); site-specific values come from `DocsKit.configuration` (with a default, for backwards compat); the page works with JavaScript off (progressive enhancement); there is exactly ONE Stimulus controller (`docs-nav`) — no per-feature JS; `config.themes` stays in sync with the Tailwind `@plugin` theme list; new emitted classes need a CSS `@source` scan; any required setup is wired into the install generator AND the `docs-kit new` template, not just the README; TDD (specs named before implementation steps). -- Decide the test strategy per the testing rules: config specs for a new knob + its default, component-render specs for output/semantics, registry specs for grouping/lookup, generator specs for install output. - -## Phase 4 — Emit the plan artifact - -Use this structure for the issue body or markdown file. Every section is load-bearing — an executor uses Context to avoid re-discovery, Steps to act, Gates to verify, Boundaries to stop. - -```markdown -# - -## Problem / Goal -<What's wrong or missing, who it affects (which consuming sites), what done looks like.> - -## Context (read these first) -<Bullet list: `path/to/file.rb` — why it matters to this change. Include the config surface, the components, the registry, the engine, the generator templates. Self-contained: no references to "as discussed" or this session.> - -## Decision -<Chosen approach and rationale. Then: alternatives considered and why each was rejected. -End with `Settled in interview:` bullets for every constraint the user confirmed in the interview phase — the executor must not re-litigate these.> - -## Implementation steps -<Ordered, small, each mapped to a layer where useful (config → registry → component → client → generator → CSS contract → docs). Specs come before the code they cover. Name exact files to create or change.> - -## Verification gates -<Exact commands + expected outcome:> -- `bundle exec rspec <paths>` — all green -- `bundle exec rubocop -A` — no offenses -- `bun run build:css` — the new classes are generated (if any classes were added/renamed) -- Backwards compat — an existing consuming site still renders (new config knob has a default) - -## Out of scope -<Explicit boundaries — the adjacent things an eager executor must NOT do.> - -## Execution -Execute with `/lfg <issue-number>` (or `/lfg docs/plans/<file>.md`). -``` - -For GitHub issues: create with `gh issue create --title "..." --body "$(cat <<'EOF' ... EOF)"` — single-quoted heredoc delimiter, backticks unescaped (see `.claude/rules/git-workflow.md`). Apply the `plan` label if it exists (`gh label list`); don't create labels. - -For markdown files: Write to `docs/plans/YYYY-MM-DD-<slug>.md`. Leave it uncommitted — committing is the user's call. - -## Phase 5 — Handoff - -Report back: link to the issue (or file path), the chosen approach in 2–3 sentences, and the exact execute command. Stop there — do not start implementing. diff --git a/.claude/commands/tdd.md b/.claude/commands/tdd.md deleted file mode 100644 index 3ac7114..0000000 --- a/.claude/commands/tdd.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -description: "Use when implementing any feature or fixing any bug — enforces RED-GREEN-REFACTOR: write failing test first, implement minimum code to pass, then refactor." -model: sonnet ---- - -# TDD Command - -Enforce test-driven development with RED → GREEN → REFACTOR. - -## The TDD Cycle - -```text -RED: Write a failing test (it MUST fail first) -GREEN: Write MINIMAL code to pass (nothing more) -REFACTOR: Improve code while keeping tests green -REPEAT: Next scenario -``` - -## When to Use - -- Implementing a new `DocsUI::` component or extending an existing one -- Adding a config knob to `DocsKit::Configuration` -- Changing the registry (`DocsKit::Registry` / `DocsKit::NavItem`) -- Extending the install generator -- Fixing a bug (write the reproducing test FIRST) - -## Workflow - -### Step 1: Write Failing Tests (RED) - -Pick the cheapest layer that proves the behavior: - -```ruby -# Config (no Rails): a new configuration knob -RSpec.describe DocsKit::Configuration do - it "defaults on_page_default to :panel" do - expect(described_class.new.on_page_default).to eq(:panel) - end -end - -# Component render: the produced markup given props -RSpec.describe DocsUI::ThemeSwitcher do - it "renders every configured theme" do - html = render(described_class.new(themes: %w[dark light])) - expect(html).to include("dark").and include("light") - end -end - -# Registry: grouping / lookup -RSpec.describe DocsKit::Registry do - it "groups docs by their declared group" do - # ... - end -end - -# Generator: install writes the expected files -RSpec.describe "docs_kit:install" do - it "creates the docs_kit initializer" do - # ... - end -end -``` - -### Step 2: Run — Verify FAIL - -```bash -bundle exec rspec <spec_file> -# FAIL — confirms the test runs, tests the right thing, and the code doesn't already exist -``` - -### Step 3: Implement Minimal Code (GREEN) - -### Step 4: Run — Verify PASS - -```bash -bundle exec rspec <spec_file> -# N examples, 0 failures -``` - -### Step 5: Refactor - -Improve while staying green: extract methods, improve names, reduce duplication. - -### Step 6: Run Full Suite + Lint - -```bash -bundle exec rspec -bundle exec rubocop -``` - -## Coverage Expectations - -| Code | Minimum | -|------|---------| -| All code | 80% | -| `DocsKit::Configuration` (every knob + its default + override) | 100% | -| `DocsKit::Registry` (grouping, lookup, ordering) | 100% | - -## Best Practices - -**DO:** test FIRST; verify RED; minimal GREEN; refactor green; assert on -component **semantics** (an active link, a present theme option, a config-driven -value) via a real render; render through a real view context when url helpers / -`dom_id` are involved. - -**DON'T:** implement before testing; assert brittle full-HTML snapshots; test -implementation details; hardcode a value a component should read from config. - -## Checklist - -- [ ] Tests written BEFORE implementation; RED verified -- [ ] Minimal GREEN; refactored green -- [ ] Coverage meets the bar (100% on config + registry) -- [ ] Edge cases covered (empty nav group, no version badge, JS-off render) -- [ ] `bundle exec rubocop` passes diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..90b8653 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "zoolutions": { + "source": { + "source": "github", + "repo": "zoolutions/claude-plugins" + } + } + }, + "enabledPlugins": { + "lode@zoolutions": true + } +} diff --git a/.gitignore b/.gitignore index d121cea..bf580eb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ Gemfile.lock .DS_Store # Claude Code agent scratch worktrees. /.claude/worktrees/ +# Lode scratch for a run in progress. +/lode/tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 6b6a017..e85ee31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,15 @@ Shared [Phlex](https://www.phlex.fun) chrome for documentation sites built on and page kit extracted into one gem so multiple docs sites look identical and are maintained in one place. +## Memory + +Durable project memory lives in `lode/` (index: `lode/lode-map.md`). Read it +before exploring the code. `lode/review/` holds accepted review findings as +rules about the system; `/lode:gate` enforces them before any push, and +`/lode:learn` adds to them. `lode/workflow.md` is the profile the shared +`/lode:` workflow skills read — commands, CI quirks, conflict rules, the +reviewer suggestions that are wrong here. + ## Tech Stack - **Ruby**: >= 3.2 | **Rails**: >= 7.1 (engine) @@ -52,15 +61,24 @@ bun run build:css # Rebuild the Tailwind/daisyUI CSS (in a consuming site | Command | Purpose | |---------|---------| -| `/plan` | Fable-powered planning → GitHub issue or `docs/plans/` markdown (read-only; execute with `/lfg`) | -| `/lfg` | Full autonomous workflow: branch → understand → explore → plan → TDD → verify → PR | -| `/tdd` | Enforce RED → GREEN → REFACTOR | +| `/lode:plan` | Read-only planning → a GitHub issue (default) or `lode/plans/` markdown; execute with `/lode:lfg` | +| `/lode:lfg` | Full autonomous workflow: branch → understand → explore → plan → TDD → verify → gate → PR | +| `/lode:tdd` | Enforce RED → GREEN → REFACTOR | +| `/lode:review-pr` | Full PR pass: merge conflicts, then CI failures, then unresolved review comments (in that order) | +| `/lode:finish-prs` | Drive a stack of open PRs to merge-ready, one at a time | +| `/lode:debug-flaky` | Root-cause an intermittent failure — evidence → repro → stress-proofed fix; never skip/retry | +| `/lode:gate` | The pre-PR gate: fresh-context review against the rules and `lode/review/`; the push hook requires it | +| `/lode:learn` | Write accepted review findings into `lode/review/` | +| `/lode:sync` | Keep `lode/` true to the code after a change | | `/architect` | Coordinate a change across config → registry → components → client → generator → CSS | | `/security` | Security audit (HTML escaping, config trust, the render path, generated files, deploy secrets) | -| `/review-pr` | Review a PR for pattern compliance | -| `/github-review-pr` | Full PR pass: fix CI failures, then resolve review comments (in that order) | -| `/github-review-failures` | Fix failing CI checks until green | -| `/github-review-comments` | Process unresolved PR review comments | +| `/review-pr` | Review a PR for docs-kit pattern compliance (the repo-specific checklist; `/lode:review-pr` is the full pass) | + +The `/lode:` commands come from the `lode@zoolutions` plugin, enabled in +`.claude/settings.json`. They replace the local `/lfg`, `/plan`, `/tdd`, +`/github-review-pr`, `/github-review-failures` and `/github-review-comments` +copies, which this repo no longer ships; `lode/workflow.md` carries the +repo-specific constraint tables those files used to hold. ## Architecture @@ -85,13 +103,13 @@ the "on this page" TOC) — there is no server round-trip. See `README.md`. ## Model tiers (for Claude Code commands & agents) -Commands and agents pin a model **tier** via frontmatter aliases, not a full -model ID — aliases track the latest model in each tier, so pins never go stale: +The `/lode:` commands pin their own tiers. The local commands and agents pin a +model **tier** via frontmatter aliases, not a full model ID — aliases track the latest model in each tier, so pins never go stale: - `haiku` — mechanical/config work, diff pattern-scans -- `sonnet` — layer specialists / pattern-following implementation (the default for `/tdd`, the review-comment/failure runbooks) -- `opus` — orchestration, security, production/PR review (`/lfg`, `/architect`, `/security`, `/review-pr`, `/github-review-pr`) -- `fable` — pinned only on `/plan` (read-only planning that hands execution to cheaper models); otherwise choose it per-session with `/model` for architecture and the hardest debugging +- `sonnet` — layer specialists / pattern-following implementation +- `opus` — orchestration, security, production/PR review (`/architect`, `/security`, `/review-pr`) +- `fable` — read-only planning that hands execution to cheaper models; otherwise choose it per-session with `/model` for architecture and the hardest debugging When spawning subagents for mechanical work (file finding, pattern scans), pass a cheaper model explicitly (`model: haiku`) rather than letting them inherit the @@ -117,6 +135,9 @@ the naming note. ## More Documentation -- `.claude/commands/` — slash command definitions -- `.claude/rules/` — coding style, git workflow, testing, agents +- `lode/` — durable project memory; start at `lode/lode-map.md` +- `lode/workflow.md` — the workflow profile the `/lode:` skills read +- `.claude/commands/` — the repo-specific slash commands that remain +- `.claude/rules/` — coding style, git workflow, testing, agents, seo +- `AGENTS.md` — cross-tool orientation and the page-authoring contract - `README.md` — the full install/configure/render/deploy guide diff --git a/lode/ai-surfaces/summary.md b/lode/ai-surfaces/summary.md new file mode 100644 index 0000000..0be8842 --- /dev/null +++ b/lode/ai-surfaces/summary.md @@ -0,0 +1,53 @@ +# AI surfaces — the Markdown twin, llms.txt, search, MCP + +Four consumer-facing surfaces, all derived from the pages themselves so none can drift from the docs. `DocsKit::LlmsText.pages` is the shared enumeration seam and `DocsKit::MarkdownExport` is the shared rendering seam; every surface below is a thin skin over those two. + +## `DocsKit::MarkdownExport` — the twin + +`markdown_export.rb` plus `blocks.rb`, `inline.rb`, `table.rb`. It derives GFM **from the page's own rendered HTML**, not from a second authored source — so Phlex markup, `md` islands and raw tags in `Prose` all convert identically, and the twin can never drift from what the page shows. + +The pipeline: render the view → `Nokogiri::HTML5.fragment` → `at_css("#docs-content")` → remove everything matching `[data-md-skip], script, style` → walk the subtree. Two render-time hints carry the cases HTML alone can't express: `data-md-lang` (stamped by `DocsUI::Code`) becomes a fenced block's language, and `data-md-callout` (stamped by `DocsUI::Callout`) becomes a `> **Tip:**` blockquote, labelled from `CALLOUT_LABELS` (note / tip / warning). + +`#to_md` returns `""` when there is no `#docs-content` region — a page that isn't the docs chrome exports nothing rather than exporting the wrong thing; the HTML route is untouched either way. + +`#render_html` picks one of three paths, in order (`markdown_export.rb:77-85`): a view whose `#call` accepts a `view_context:` keyword gets it; else a Phlex component with a view context is rendered through Rails (`@view_context.render(@view)`); else a bare `#call`. `#absolutize` rewrites relative hrefs against `base_url` and leaves absolute, protocol-relative, and `#`/`mailto:` URLs alone. + +## `DocsKit::LlmsText` and `LlmsController` — `/llms.txt`, `/llms-full.txt` + +`LlmsText` is a pure `module_function` text builder with no Rails. `.index` (`llms_text.rb:35-47`) emits `# brand`, an optional `> tagline` blockquote, one `## group` block per nav group as a tight bullet list of `.md` links, and — last, and only when `config.mcp_enabled?` — an `## MCP` block advertising the endpoint. `.full` joins `# title` + body pairs with a `---` rule. + +`.pages` (`llms_text.rb:68-73`) is **the** enumeration seam: resolve the version from the argument, else `Scope.version`, else `config.current_version`; an archived version enumerates its snapshot, anything else flattens `config.nav_registries` to entries with a resolvable `view_class`. `.renderable_for` (`87-89`) is the matching render seam, with a `respond_to?(:renderable)` fallback to `view_class.new` for a site's older custom registry class. + +`DocsKit::LlmsController` threads the Rails view context and nothing else. `#full` renders each page's twin through `MarkdownExport` and hands the pairs to `LlmsText.full`. Caching is `expires_in LLMS_MAX_AGE (300), public: true` plus `stale?(etag: [DocsKit::VERSION, body], public: true)` — the max-age matters because `public` alone is not storable under RFC 9111, so a shared cache would skip the response; the etag still revalidates inside the window. + +## `DocsKit::SearchIndex` and `SearchController` — `/docs/search` + +The index is built **per request** from the same Markdown twins `llms-full.txt` serves, so search cannot drift from the pages. There is no external service, no build step, no second registry. + +`SearchIndex.new(triples)` takes `[[page_title, page_href, markdown], …]` and splits each twin on level-2 ATX headings. `#split_sections` (`search_index.rb:104-120`) scans line by line and toggles an in-fence flag on ``` / ~~~ so a `## ` inside a code block stays body text — the rendered page never ids it, so a section entry there would carry a dead anchor. Text before the first heading becomes a page-intro entry; a page with neither still gets one entry so its title is searchable. + +Scoring is plain Ruby: tokens are whitespace-split and lowercased, **every** token must match somewhere (AND), and each token scores the heaviest field it hit — `TITLE_WEIGHT` 100 > `HEADING_WEIGHT` 10 > `BODY_WEIGHT` 1. The page title is a searchable field **only on the page-intro entry**, because a title token matches every section of a page equally and weighting each one would flood the results with near-identical rows. Results sort by `[-score, page_title, section_title]` and cap at `MAX_RESULTS` (20). No fuzzy matching. + +`SearchController#index` answers both formats off the same index: `html` renders `DocsUI::SearchResults` inside a `Shell` (the JS-off path the topbar form submits to), `json` serves the palette's debounced fetch. + +## `DocsKit::McpTools`, `McpServer`, `McpController` — `POST /mcp` + +`McpTools` is the pure core: `list_pages`, `get_page(slug:)`, `search_docs(query:)`, all over the same registry, twins and index. Zero `mcp`-gem dependency and zero JSON-RPC, so the whole consumption story is unit-testable without the SDK. + +`McpServer.build` wraps them into an `MCP::Server`, returning **nil** when the gem isn't loadable. `base_url` and `view_context` ride in the SDK's `server_context` so the tools render twins through Rails and absolutize URLs — the same seam `LlmsController#full` uses. + +`McpController#create` heads 404 unless `mcp_enabled?` and again unless the server builds, then delegates the entire protocol to `server.handle_json(request.body.read)`. That returns an already-serialised JSON string, so it is rendered as `body:` with an explicit content type — `render json:` would re-encode the string and corrupt the JSON-RPC envelope. GET and DELETE are 405: the endpoint is read-only and stateless, with no SSE stream and no session to terminate. + +## `DocsKit::OpenApi` — the spec bridge + +`open_api.rb` plus `document.rb`, `operation.rb`, `schema.rb`. Loads an OpenAPI 3.x spec (file path or parsed Hash; YAML via Psych, JSON via the stdlib — no parser dependency) into a narrow gem-owned model exposing only what the render targets consume: `#body_rows` for a `FieldTable`, `#error_rows` for an `ErrorTable`, `#success_example` for a `JsonResponse`. `OperationNotFound` lists the available operationIds so a typo is diagnosable at the call site. `DocsUI::OpenApiOperation` renders one operation through the kit. + +## Wiring + +All three gem controllers subclass `ActionController::Base` directly, include `DocsKit::Scoping`, name their config reader `#docs_config`, and are routed by the **host** — the engine draws no routes. `InstallGenerator#add_routes` draws the llms and search routes live and the MCP pair commented out, because MCP needs the optional gem. + +## Related + +- `../registry-and-versions/summary.md` — what `.pages` enumerates +- `../components/summary.md` — `#docs-content`, `data-md-skip`, `data-md-lang` +- `../install-path/summary.md` — the routes the generator draws diff --git a/lode/components/summary.md b/lode/components/summary.md new file mode 100644 index 0000000..cceb18d --- /dev/null +++ b/lode/components/summary.md @@ -0,0 +1,54 @@ +# Components — the `DocsUI::` chrome and the client controller + +`app/components/docs_ui/` holds 30 Phlex component classes plus `page_helpers.rb` (the `DocsUI::PageHelpers` module). `DocsUI` is a `Phlex::Kit`, so `DocsUI::Code(src)` and `render DocsUI::Code.new(src)` are the same call; the shipped RuboCop cop `DocsKit/RenderComponentPreferred` steers authored doc pages to the former. + +## The document: `Shell` + +`DocsUI::Shell` **is** the whole HTML document — `doctype`, `<html>`, `<head>`, `<body>` — which is why every controller renders it with `layout: false`. Structure (`shell.rb`): + +- `<html data-theme=…>` from `config.default_theme`. +- `#render_head` (`75-95`): `<title>` joining the page title and `config.title_suffix` with `" · "`, charset, viewport, `DocsUI::MetaTags`, `csrf_meta_tags`, `csp_meta_tag`, the two Turbo morph metas, one `stylesheet_link_tag` per `config.stylesheets`, the theme-restore script, `javascript_importmap_tags`. +- `#theme_restore_script` (`107-123`) applies the persisted theme **before first paint**, so there is no flash of the server default. It reads the same `docs-kit:<site>:theme` localStorage key `docs-nav` writes, re-applies on `turbo:load`, and carries the request's CSP nonce (nil off a request, which Phlex omits). +- `data-controller="docs-nav"` sits on `<body>`, not the sidebar — the shared ancestor of both the sidebar and the content column, because the `:panel`/`:toggle` TOC lives in the content. +- `#shell` (`127-151`) is the daisyUI `Drawer`: `lg:drawer-open` pins the sidebar on desktop and the hamburger toggles it on mobile. The content column is `div(id: "docs-content")` — the Markdown-export anchor — with the topbar, sidebar and TOC deliberately **outside** it so they never bleed into a `.md` twin. +- `#topbar` (`154-170`): hamburger (mobile only), brand anchor at `config.brand_href`, the opt-in app-home link, `SearchBox` when `config.search_enabled?`, `TopbarLinks`, `ThemeSwitcher`. +- `#topbar_brand_classes` (`176-179`) appends `lg:hidden` when `config.topbar_brand == :mobile_only`, deduplicating the mark at the breakpoint where the sidebar brand is already visible. `:always` keeps the pre-knob classes verbatim. +- `#brand_mark` renders `DocsUI::Logo` when `config.brand_logo` is set, else `plain config.brand`. `Sidebar` has the same method at a larger size (`h-7` vs `h-6`), and the text brand remains the mark's accessible-name fallback. + +## The nav: `Sidebar` + +Driven entirely by `config.nav_groups`, an ordered `{ heading => { subgroup => [NavItem] } }`. Groups whose value is nil or empty are rejected first (`sidebar.rb:37`); the heading label renders **only when more than one group survives** (`labeled: groups.size > 1`) — a single-registry site's masthead already labels the sidebar. + +A heading renders as a static `li.menu-title`, never a `<details>`, so subgroups stay at the menu's top level. A subgroup renders as `li > details[open]` — server-rendered open, which is the whole progressive-enhancement story: with JS off the sidebar is simply fully expanded. `MARKER_RESET` (`sidebar.rb:26`) suppresses the native disclosure triangle, written as a literal class string because Tailwind tree-shakes interpolated ones. A collapsible `<summary>` must **not** carry `.menu-title`: daisyUI's caret layout rule is `summary:not(.menu-title)`, and a `.menu-title` summary loses the grid and drops its chevron below-left. + +Active state is a strict `request.path == item.href` match (`#link_classes`, `111-114`), server-side, no JS. `#current_path` rescues to nil so an isolated render works. + +## The page: `Page`, `Section`, and the authoring helpers + +`DocsUI::Page` is the base class an authored page subclasses. Class-level `title`, `eyebrow`, `description`, `on_page`; instance `#lead` and `#content` (which raises `NotImplementedError` until written). `#view_template` renders `Shell` with `description: self.class.description || lead`, so a page gets a sensible SEO description for free. + +The masthead nav carries `data-md-skip` — it is chrome, so "← Docs home" and the "Markdown" action never reach the `.md` twin. `#home_href` is `config.brand_href`, never the host's `root_path`: on an app-embedded site the application root is not the docs landing. + +`DocsUI::Section` owns page structure and the TOC. It resolves its anchor id at render time and de-duplicates it across the page through Phlex's shared render `context` (`#unique_id`, `section.rb:86-92`): colliding bases get a `-1`, `-2` … suffix, and a title that slugifies to empty falls back to `"section"`. Its `description:` accepts three forms and the order of the branches is load-bearing — a Phlex component also responds to `#call`, so `when Phlex::SGML` must be matched before the callable branch or the component would be `instance_exec`'d instead of rendered (`section.rb:61-70`). + +`DocsUI::PageHelpers` supplies the lowercase `md` / `prose` / `example` helpers — the no-positional-argument path that avoids the parens-with-blocks gotcha. + +## Code, Markdown, MetaTags + +`DocsUI::Code` resolves its lexer in order: explicit `lexer:`, then a guess from `filename:` via Rouge's own filename globs, else ruby; an unresolvable language falls back to `config.code_lexer_fallback` ("plaintext") and never raises. It injects its own Rouge theme CSS inline (nonced), so no separate stylesheet asset is needed, and stamps `data-md-lang` with the **resolved** Rouge tag so the Markdown export emits a ` ```lang ` fence without re-resolving. + +`DocsUI::Markdown` parses GFM with commonmarker and walks the AST emitting Phlex nodes — it never `raw`s commonmarker's HTML. So author text is Phlex-escaped, `#{}` in prose renders literally, fenced blocks delegate to `DocsUI::Code`, and the wrapper reuses `Prose::CLASSES` verbatim. Raw HTML in the source is dropped (`html_block`/`html_inline` nodes are skipped) with no config to enable it. Markdown headings render as styled `h3`/`h4`; document structure and the TOC stay with `Section`. + +`DocsUI::MetaTags` emits description, Open Graph, Twitter Card, canonical, favicon, robots and theme-color from `config.seo` plus the page title/description. A relative `og_image` resolves through `image_url` to the digested `/assets` URL; an absolute URL passes through; nil emits no tag. Canonical and `og:url` come from `config.seo.site_url` else the request URL, and both are omitted off a request. The full contract is `../../.claude/rules/seo.md`. + +## The one client controller + +`app/javascript/docs_kit/controllers/docs_nav_controller.js` (619 lines) is the only Stimulus controller the gem ships, auto-pinned by the engine through `config/importmap.rb`'s `pin_all_from`. It does collapse persistence (localStorage, keyed by summary text, namespaced by `storageKey`), the auto-TOC in three placements, scroll-spy via `IntersectionObserver`, and the debounced search palette. Each behaviour degrades to a harmless no-op: no TOC on the page, no-op; JS off, the server-rendered page still works. + +`config/importmap.rb` and `REGISTER_LINE` in the install generator both insist on `eagerLoadControllersFrom`, not `lazyLoadControllersFrom`: the default `controllers/index.js` imports only the eager one, so a `lazyLoadControllersFrom` call with no import throws a `ReferenceError` that aborts the module and registers **zero** controllers. + +## Related + +- `../config/summary.md` — every knob these components read +- `../ai-surfaces/summary.md` — `#docs-content` and `data-md-skip` as the export contract +- `../../.claude/rules/seo.md` — the OG-image rules diff --git a/lode/config/summary.md b/lode/config/summary.md new file mode 100644 index 0000000..85cdec4 --- /dev/null +++ b/lode/config/summary.md @@ -0,0 +1,51 @@ +# Config — `DocsKit::Configuration` and the value objects + +`lib/docs_kit/configuration.rb` (656 lines) is the whole surface a site tunes. `DocsKit.configuration` memoizes one instance; `DocsKit.configure { |c| … }` yields it; `DocsKit.reset_configuration!` replaces it (the suite calls this in a `before` hook). + +## Shape + +36 top-level knobs. 33 are plain `attr_accessor`/`attr_writer`/`attr_reader`; three have hand-written writers because assignment does work: + +- `nav=` (`configuration.rb:60`) sets a `@nav_explicit` flag. The flag, not object identity, is what makes `#nav_groups` prefer an explicit lambda — so *any* assigned lambda wins, even one that resolves to `{}`. +- `brand_logo=` (`configuration.rb:205`) clears the memo and stores the raw value, because `#brand_logo` memoizes a built `BrandLogo` (a `file:` mark reads its SVG on build, and re-normalising per render would repeat that IO). +- `topbar_brand=` (`configuration.rb:219`) validates against `TOPBAR_BRAND_MODES` (`%i[always mobile_only]`) and raises `ArgumentError` on anything else. + +Two knobs are nested config objects, lazily built and memoized so a `c.seo.x = …` block mutates the instance the Shell later reads: `#seo` → `DocsKit::SeoConfig` (11 accessors: description, og_image, og_type, twitter_card, twitter_site, twitter_creator, locale, site_url, favicon, robots, theme_color) and `#landing` → `DocsKit::LandingConfig` (8: logo, eyebrow, title, lead, install, doc_index, ctas, features). + +Several writers pair with a normalising reader, and the reader is the one to call — never the ivar: `@topbar_links` → `#topbar_links` (coerces each entry through `TopbarLink.from`), `@versions` → `#versions` (`DocVersion.from`), `@app_link` → `#app_link`, `@search_shortcuts` → `#search_shortcuts` (`Shortcut.parse_list`, dropping anything unparseable), `@api_clients` → `#api_clients` (merged **over** `ApiClient::DEFAULTS`, so a reused token replaces that tab in place and a new token appends), `@snapshots_path` → `#snapshots_path`. + +## The derived readers + +- `#nav_groups` (`configuration.rb:592-599`) — the sidebar source. Three branches, in order: an **archived** version in `DocsKit::Scope` wins outright and the nav comes from that version's snapshot manifest (hrefs already version-prefixed, so an archived page never links into the live docs); else an explicit `#nav` lambda; else derived from `#nav_registries` via `#nav_groups_from_registries`, which drops a heading whose registry has no authored pages so no empty group renders. +- `#default_theme` → `@default_theme || themes.first`; `#title_suffix` → `@title_suffix || brand`; `#nav_storage_key` → a slug of the brand. Each is a "configured value or a derived default", never nil. +- `#search_enabled?` (`567-569`) and `#mcp_enabled?` (`560-562`) share a shape: the site toggle **and** a capability. Search needs a non-empty `@search_path` to submit to; MCP needs the optional `mcp` gem to be loadable (`#mcp_gem_present?`, memoized across both outcomes so a site without the gem doesn't pay a failed require per request). +- `#versioning_enabled?` (`398-400`) is `versions.size > 1` — one configured version is not worth a switcher, and an unconfigured site stays byte-identical. +- `#resolve_version(id)` (`391-393`) is `version(id) || current_version`: an unknown or missing id degrades to the current docs rather than 500ing. `DocsKit::Controller#render_page` and `DocsKit::Scoping` both call it, so the rule is stated once. +- `#code_theme_class` / `#code_theme_dark_class` resolve a String name through `#resolve_theme`, which rescues `NameError` to nil — a typo'd theme name must not crash every code block. The light reader then falls back to `DEFAULT_CODE_THEME`; the dark one returns nil, which `DocsUI::Code` reads as "emit no dark CSS". +- `#dark_themes_shipped` (`641-643`) is `themes & dark_themes`, in `themes` declaration order, so a dark theme the Tailwind build never generated emits no dead CSS. +- `#on_page_default` / `#normalize_on_page` coerce to `ON_PAGE_MODES` (`%i[panel toggle sidebar]`) or `false`; a bare `true` means `:panel`. Anything else raises `ArgumentError`. + +## Value objects + +All are `Data.define` with a keyword `initialize` supplying defaults, and most carry a `.from` that passes an existing instance through and coerces a Hash with `transform_keys(&:to_sym)` — so a YAML- or JSON-sourced config loads cleanly. + +| Object | File | Role | +|---|---|---| +| `NavItem` | `nav_item.rb` | one sidebar link: `href`, `label`, optional `icon` | +| `TopbarLink` | `topbar_link.rb` | a topbar external link; `icon` symbolised on build; `#external?` drives `target=_blank` + `rel=noopener` | +| `DocVersion` | `doc_version.rb` | one documentation version; see `../registry-and-versions/summary.md` | +| `SearchHit` | `search_hit.rb` | one ranked result, with `#label` and the `#as_json` shape the palette fetches | +| `ApiClient` | `api_client.rb` | one language tab for `DocsUI::RequestExample`: label, lexer, filename (String or proc), and a `(ApiRequest) -> String` template | +| `ApiRequest` | `api_request.rb` | one declared request, handed to every client template so a snippet is authored once | +| `Shortcut` | `shortcut.rb` | a parsed search-palette chord; a plain class, not `Data` | +| `BrandLogo` | `brand_logo.rb` | the normalised brand mark; a plain class | +| `SeoConfig`, `LandingConfig` | `seo_config.rb`, `landing_config.rb` | plain accessor objects, because each field is individually assignable in a `c.seo.x = …` block | + +`Shortcut` exists because three places must agree on one chord: the config surface, the server-rendered `<kbd>` hint (`#label`), and the `docs-nav` matcher (`#to_h`, serialised to JSON). `"mod"` stays abstract on the server — ⌘ on mac, Ctrl elsewhere — and the browser resolves it, so one config entry works on every platform. + +`BrandLogo` accepts exactly one of five form keys (`svg:`, `paths:`, `markup:`, `file:`, `src:`); giving none or several is ambiguous and raises. The `markup:`/`file:` forms are shape-checked as an `<svg>` element at config time, because `DocsUI::Logo` embeds them verbatim through `raw(safe(...))`. + +## Related + +- `../components/summary.md` — the components that read these knobs +- `../core/summary.md` — `Scope`, which `#nav_groups` consults diff --git a/lode/core/summary.md b/lode/core/summary.md new file mode 100644 index 0000000..648c345 --- /dev/null +++ b/lode/core/summary.md @@ -0,0 +1,49 @@ +# Core — loader, engine, controller glue, request scope + +The four files that decide what loads, what Rails hooks are installed, and what a request sees. + +## `lib/docs_kit.rb` — the loader + +Requires `phlex`, `rouge`, `zeitwerk`, then `docs_kit/version` and `docs_kit/configuration` eagerly. `DocsUI` is declared and `extend Phlex::Kit` **before** `loader.setup`, so zeitwerk autoloads its children into an already-extended module — reorder that and the kit helper methods stop being defined. + +Two `push_dir` calls: `lib/docs_kit/` → `DocsKit`, and `app/components/docs_ui/` → `DocsUI`. Nine `loader.ignore` calls follow, each for its own reason: + +| Ignored | Why | +|---|---| +| `version.rb`, `configuration.rb` | required eagerly above; zeitwerk would double-manage the constants | +| `seo_config.rb`, `landing_config.rb` | required eagerly by `configuration.rb` | +| `brand_logo.rb` | required eagerly by `landing_config.rb` (the `LandingConfig::Logo` alias resolves at require time) | +| `og_generator.rb` | loaded only by the host's `docs_kit:og` rake task, so its browser tooling never reaches a host that doesn't run it | +| `rubocop.rb` | defines `RuboCop::Cop::DocsKit::*`, not a `DocsKit::Rubocop` constant | +| `engine.rb` | required explicitly below, only under Rails | +| `templates/` | a Rails application template, not autoloadable Ruby | + +The last line is `require_relative "docs_kit/engine" if defined?(Rails::Engine)`. `lib/docs-kit.rb` exists only because Bundler auto-requires the file matching the gem name; it delegates to `docs_kit.rb`. + +## `lib/docs_kit/engine.rb` — glue only + +An asset/glue engine: no `isolate_namespace`, no routes, no models. It sets `config.autoload_paths = []`, `config.eager_load_paths = []` and `paths["app"].skip_eager_load!` so Rails does not also autoload `app/components` — the gem's own zeitwerk loader is the single owner of those constants. + +Four initializers: include `DocsKit::Controller` into `ActionController::Base`; register `text/markdown` as `:md` (guarded, so a host that already declared it is a no-op); append `app/javascript` to the asset paths; and, `before: "importmap"`, append `config/importmap.rb` to `importmap.paths` and the JS dir to `importmap.cache_sweepers`. That last one is what auto-pins `docs_kit/controllers/docs_nav_controller`; each of the three appends is guarded by a `respond_to?` so a host without importmap-rails or without the assets config still boots. + +The engine draws **no routes**. `LlmsController`, `SearchController` and `McpController` are gem controllers the host wires itself, which is what leaves a site in control of path, auth and omission — and why the install generator's `add_routes` exists. + +## `lib/docs_kit/controller.rb` — `#render_page` + +56 lines, three methods. `#render_page` (`controller.rb:29-35`) wraps the whole render in `DocsKit::Scope.with(version: config.resolve_version(params[:version]))`, then either returns the Markdown twin or `render view, layout: false`. `layout: false` is not optional: `DocsUI::Shell` emits `<html>`/`<head>`/`<body>` itself, so the Rails ERB layout would double-nest the document. phlex-rails still renders through a real view context, so CSRF, `dom_id`, url helpers and a reactive site's token signer work inside components. + +`#markdown_request?` (`controller.rb:42-44`) accepts `.md` **or** `.text` — `.text` is the alias for a host whose routes only permit the built-in format. `#render_markdown` builds a `DocsKit::MarkdownExport` with the controller's `view_context` and `request.base_url` and renders it as `text/markdown`. + +The block wrapper is sufficient because `render` runs synchronously inside the action — no `around_action`, no host code change. + +## `lib/docs_kit/scope.rb` and `scoping.rb` — the request axis + +`Scope` is a `module_function` module over `Thread.current[:docs_kit_scope]` (fiber-local in Ruby, which is what a fibered server wants) — deliberately **not** `ActiveSupport::CurrentAttributes`, so a bare Phlex component spec can set a scope without booting Rails. `Scope.with` restores the previous scope in an `ensure`, so a raising block cannot leak a version across requests sharing a thread. An empty scope reads as `version: nil` / `locale: nil`, which every consumer treats as "the current version" — today's behaviour on an unversioned site. `Scope.locale` is reserved for a future i18n axis and is nil in every path today. + +`Scoping` is a plain module with an `included` hook (not an `ActiveSupport::Concern` — it has no dependency chain and must stay loadable Rails-free) that installs `around_action :docs_scope`. The gem's three controllers include it. A host's own docs controller does **not**: it gets the same behaviour from `render_page`'s wrapper, because `around_action`-ing every host action is not the gem's call to make. + +## Related + +- `../config/summary.md` — what `resolve_version` reads from +- `../registry-and-versions/summary.md` — `DocVersion`, `Snapshot`, and what an archived scope swaps +- `../ai-surfaces/summary.md` — the three gem controllers that include `Scoping` diff --git a/lode/install-path/summary.md b/lode/install-path/summary.md new file mode 100644 index 0000000..239095f --- /dev/null +++ b/lode/install-path/summary.md @@ -0,0 +1,69 @@ +# Install path — generators, templates, the CLI + +Three entry points get a site running: `rails g docs_kit:install` (wire an app), `rails g docs_kit:page` (add a page), and `docs-kit new NAME` (scaffold a whole app). The rule underneath all three: a required setup step lands in the **install generator and the `docs-kit new` template**, never in the README alone. + +## `InstallGenerator` — `rails g docs_kit:install [--sync]` + +`lib/generators/docs_kit/install/install_generator.rb`, 622 lines, 20 public Thor steps that run in declaration order, then private helpers from line 379. 16 templates live beside it. + +Fully idempotent by design, which is what makes re-running it the sanctioned upgrade path. `--sync` narrows it to the additive wiring: `create_registry_and_pages` and `create_css_build` return early with a skip status (`install_generator.rb:130`, `186`), and `run_migrations` runs **only** under `--sync` (`332-340`). + +Three ownership categories decide what a step does: + +| Category | Behaviour | Examples | +|---|---|---| +| Site-owned | skip when present; print the template path for a manual diff | `config/initializers/docs_kit.rb` (`104-112`), `Dockerfile` (`211-219`), the skill file (`439-444`), the phlex and rails_icons initializers | +| Gem-owned wiring | refreshed on every run | `lib/tasks/docs_kit_og.rake` (`201-203`), `.dockerignore` (`225-227`, `force: true`) | +| Merged | read, merge, write back only if changed | `.rubocop.yml` (`450-464`), `AGENTS.md` (`429-435`) | + +Steps worth knowing: + +- **`add_routes`** (`139-159`). `route_once` guards on the endpoint, not on Thor's byte-identical skip. Thor's `route` **prepends**, so the search route is drawn *after* the `docs/:doc` route in order to land *above* it in the file — otherwise `docs/:doc` swallows `/docs/search`. The docs route carries `(.:format)` and deliberately no `defaults: { format: "html" }`, because that would pin html and defeat the `.md` twin. +- **`add_mcp_route`** (`172-180`). Drawn **commented out** — MCP needs the optional `mcp` gem. Guarded on `route_present?`, not Thor's skip: a site that opted in has live routes in its own style that never byte-match the commented template, so plain `route` would re-inject the scaffold on every `--sync` (found dogfooding 1.0.3 into pgbus and phlex-reactive). +- **`register_stimulus_controller`** (`287-310`). Skips when either loader already registers `docs_kit/controllers`, in any quote style. If there is no eager anchor to inject after, it appends **only** when the file already imports `eagerLoadControllersFrom`; a lazy-only index is valid, and appending an unimported eager call would throw a `ReferenceError` that registers zero controllers, so it warns instead. +- **`create_thrust_binstub`** (`235-246`) and **`gemfile_bundles_thruster?`** (`604-619`). The Dockerfile's exec-form `CMD ["./bin/thrust", …]` needs the file to exist in the image, and `bundle install` installs the gem, not app binstubs — without this the image builds green and the container crashes at boot. The Gemfile scan does line-level block tracking (`… do` pushes, `end` pops) and counts a `gem "thruster"` line only outside every `group` block and without an inline `group:` kwarg, because the Dockerfile sets `BUNDLE_WITHOUT="development:test"`. +- **`stamp_synced_version`** (`348-359`) writes the inert `# docs-kit synced: vX.Y.Z` comment at the top of the initializer — the one file every site has, and the one `create_initializer` never rewrites, which is why stamping is its own step. It updates a stale stamp in place and is a no-op when current. `#synced_version` (`385-390`) reads it **before** the restamp; an unstamped site reads `"0.0.0"`, so every migration applies. + +`#app_brand`, `#docker_service` and `#ruby_version_arg` are the template bindings (the app name humanized, the app dir basename, and the host's running Ruby). + +## `SyncReport` — drift detection + +`lib/generators/docs_kit/install/sync_report.rb`, 90 lines, three checks: `ApplicationController` defining its own `render_page` (`49-55`), a leftover `app/helpers/icon_helper.rb` (`59-64`), and a `Dockerfile` whose `# docs-kit Dockerfile vX.Y.Z` marker is older than `DocsKit::VERSION` (`70-80`). String-level and conservative: it reads, reports, and **never touches a byte**. A Dockerfile with no marker is one the site brought itself and is left alone — no stamp, no warning. `report_drift` runs on every invocation, not just `--sync`, and never fails the run. + +## `Migration` / `MigrationRegistry` — versioned upgrades + +`MigrationRegistry.default.migrate!(synced_version, root, generator)` selects migrations in the half-open range `(from_version, upto]` — above the site's stamp, no newer than the installed gem — and runs them ascending, collecting warn-only messages. The `upto` ceiling exists because `--sync` restamps to `DocsKit::VERSION` afterward: a migration targeting an unreleased version would exceed every future stamp and re-run forever. + +`MIGRATIONS` is `[].freeze`. The registry ships **empty**; the mechanism is the deliverable, and the first concrete transform is a one-line `Migration.new(to: …)` addition. A migration is warn-only-safe by contract: it does what it can idempotently and hands back strings for whatever needs a human. + +## `PageGenerator` — `rails g docs_kit:page TITLE --group=GROUP` + +Writes `app/views/docs/pages/<view>.rb` **and** injects the one-line `page` entry into the registry. `--slug`, `--view`, `--eyebrow`, `--registry` override every derivation; `#override_kwargs` (`page_generator.rb:91-96`) spells out `slug:`/`view:` only when they differ from the derived defaults. + +Two guards. A legacy `entries [...]` registry is detected (`#legacy_entries?`, `100-102`) and left alone with a by-hand instruction printed, rather than corrupted by a `page` line. And `#registry_anchor` (`113-124`) returns the last `page` line as a **String**, not a Regexp: Thor's `inject_into_file` replaces *every* match of a Regexp `after:`, so a "page line not followed by a page line" pattern fires once per group in the blank-line-separated layout the install generator produces, duplicating the entry. Falls back to `view_namespace`, then `path_prefix`, then the `extend DocsKit::Registry` line. + +## `exe/docs-kit` and `templates/new_site.rb` + +`docs-kit new NAME [--image OWNER/REPO] [--service NAME] [--gem-source SRC]` runs `rails new` with propshaft + importmap + turbo + stimulus (deliberately **not** `--minimal`, which strips JS entirely) and applies `lib/docs_kit/templates/new_site.rb`. + +The CLI resolves the environment **before** the template runs (`exe/docs-kit:59-63`): `DOCS_KIT_IMAGE` defaults to `zoolutions/#{name}` and `DOCS_KIT_SERVICE` to `name`. The template's `ENV.fetch` fallbacks (`new_site.rb:26-27`) are therefore only reachable when the template is applied directly, not through the CLI — so the CLI default, its two help-text mentions, and the template fallback must be changed together. + +The naming invariant: `image` must be the calling repo's `OWNER/REPO` so the auto-linked GHCR package lets `GITHUB_TOKEN` both push and pull it. A name that doesn't match becomes an unlinked user-scoped package `GITHUB_TOKEN` can't pull. `service` must equal `service:` in `config/deploy.yml` and is stamped as the image's `service` LABEL; the template corrects the Dockerfile's LABEL when `--service` differs from the app name. + +## The CSS contract + +`bin/build-css` (shipped as a template) resolves the `daisyui` and `docs-kit` gem paths with `bundle show` and writes `@source` globs into `app/assets/stylesheets/tailwind.sources.css`, which `application.tailwind.css` imports. It aborts when a gem can't be resolved — a silently missing `@source` ships an unstyled site. The generated file is per-environment; the sites that have it gitignore it (`docs/.gitignore:38`). + +## Shipped RuboCop cops + +`lib/rubocop/cop/docs_kit/` holds two cops, loaded by `require "docs_kit/rubocop"` (which requires rubocop lazily — it is a host dev-time dependency, never a docs-kit runtime one) and enabled by `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }`. Both default to `Include: app/views/docs/**/*`. + +- `DocsKit/RenderComponentPreferred` — prefer `DocsUI::Code(…)` over `render DocsUI::Code.new(…)`. It keeps the namespace prefix, because an unqualified helper may resolve to a different kit depending on inclusion order. +- `DocsKit/EscapedInterpolationInHeredoc` — use a single-quoted heredoc delimiter instead of escaping `\#{…}`. It treats all three Ruby interpolation sigils (`#{`, `#@`, `#$`) as the escape tax, and a live unescaped occurrence of any of them blocks autocorrection. + +`wire_rubocop_cops` (`275-285`) writes `RUBOCOP_STARTER` when the site has no `.rubocop.yml`, and otherwise merges — a `rails new` app ships an omakase `inherit_gem` that must not be dropped. + +## Related + +- `../components/summary.md` — why `eagerLoadControllersFrom` is the only safe register line +- `../testing-and-ci/summary.md` — the generator specs and the deploy workflows diff --git a/lode/lode-map.md b/lode/lode-map.md new file mode 100644 index 0000000..90059af --- /dev/null +++ b/lode/lode-map.md @@ -0,0 +1,43 @@ +# Lode map + +The index of this repository's durable memory. Read this first; then the file that +covers the area you are changing. Every file states the system as it is now — no +changelog prose, no "known gaps" (those live in the PR that closes them). + +## Baseline + +| File | What it holds | +|---|---| +| [summary.md](summary.md) | What docs-kit is, who runs it, and the three invariants every change is measured against | +| [terminology.md](terminology.md) | The repo's own words — chrome, the kit, registry, the `page` DSL, authored page, Markdown twin, scope, snapshot, drift, synced stamp — each with the file that defines it | +| [practices.md](practices.md) | The patterns `.claude/rules/` does not state: absent-knob defaults, degrade-on-render, mtime-backed config, `#docs_config`, runtime-detected optional gems, the one enumeration seam, semantic generator idempotence, site-owned vs gem-owned files, literal Tailwind classes, Rails-free renders, `raw(safe(…))` | +| [workflow.md](workflow.md) | The profile the shared `/lode:` workflow skills read: commands, branches, layers, shapes, constraints, docs, CI, flake sources, conflicts, verification | +| [plans/README.md](plans/README.md) | Where a plan artifact goes | + +## Areas + +| File | Subsystem | +|---|---| +| [core/summary.md](core/summary.md) | `lib/docs_kit.rb` (zeitwerk loader), `engine.rb` (four initializers, no routes), `controller.rb` (`#render_page`), `scope.rb` / `scoping.rb` (the request axis) | +| [config/summary.md](config/summary.md) | `DocsKit::Configuration` — the 36 top-level knobs, the derived readers, and the `Data.define` value objects | +| [registry-and-versions/summary.md](registry-and-versions/summary.md) | `Registry` (the `page` DSL and the legacy `entries` API), `Registry::Entry`, `DocVersion`, `Snapshot` | +| [components/summary.md](components/summary.md) | The 30 `DocsUI::` Phlex classes plus `PageHelpers`, and the one `docs-nav` Stimulus controller | +| [ai-surfaces/summary.md](ai-surfaces/summary.md) | `MarkdownExport` (the twin), `LlmsText` + `/llms.txt`, `SearchIndex` + `/docs/search`, `McpTools` / `McpServer` + `POST /mcp`, the `OpenApi` bridge | +| [install-path/summary.md](install-path/summary.md) | `InstallGenerator` and its 16 templates, `SyncReport`, `Migration` / `MigrationRegistry`, `PageGenerator`, `exe/docs-kit`, `templates/new_site.rb`, the CSS contract, the shipped RuboCop cops | +| [testing-and-ci/summary.md](testing-and-ci/summary.md) | The RSpec suite's four layers, `ci.yml`'s three jobs, `rake release`, the reusable `deploy.yml`, and the `docs/` dogfood site | + +## Review rules + +Accepted review findings, rewritten as rules about the system. `/lode:gate` +enforces them; `/lode:learn` adds to them. + +| File | Covers | +|---|---| +| [review/cli-and-templates.md](review/cli-and-templates.md) | `docs-kit new` and `lib/docs_kit/templates/new_site.rb` — where a default must be changed, and why a template fallback is dead on the CLI path | + +## Related, outside the lode + +- `../CLAUDE.md` — the project brief, the critical-rules lists, the layer map +- `../AGENTS.md` — the cross-tool orientation and the page-authoring contract +- `../.claude/rules/` — coding-style, git-workflow, testing, agents, seo +- `tmp/` — scratch for a run in progress; gitignored, never committed diff --git a/lode/plans/README.md b/lode/plans/README.md new file mode 100644 index 0000000..3cd7e62 --- /dev/null +++ b/lode/plans/README.md @@ -0,0 +1,21 @@ +# Plans + +Plan artifacts for docs-kit go to **GitHub issues on `zoolutions/docs-kit`** — +that is the default and the one that feeds execution (`/lode:lfg <issue-number>` +reads the issue body as its brief). Label and milestone the issue the way the +tracker already does; dedupe with `gh issue list --search "<keywords>"` before +opening a new one. + +A file-backed plan (`/lode:plan --file`) is written here, as +`lode/plans/YYYY-MM-DD-<slug>.md`. Use it only when the plan should not be public +or is a working draft that will become an issue. + +`docs/plans/` does **not** exist in this repository, despite what the retired +`/plan` command and `.claude/README.md` said. `docs/` is the dogfood Rails app; +nothing under it is a plan. + +A plan is self-contained: an executor with none of the planning session's context +must be able to implement it without guessing. Sections, in order — Context (what +was read and what it says), Decision (the chosen approach and why the others +lost), Steps (specs named before implementation), Gates (the commands that must +pass), Boundaries (what this change must not touch). diff --git a/lode/practices.md b/lode/practices.md new file mode 100644 index 0000000..923da26 --- /dev/null +++ b/lode/practices.md @@ -0,0 +1,49 @@ +# Practices + +Patterns this codebase follows that `../.claude/rules/` does not already state. The rules cover style, testing layers, git flow and the SEO/OG contract; these are the shapes a reviewer will expect and a new contributor will otherwise re-invent. + +## An opt-in knob reads as absent, never as broken + +Every feature added since 1.0 defaults to a value that renders nothing: `c.versions = []`, `c.topbar_links = []`, `c.app_link = nil`, `c.brand_logo = nil`, `c.openapi = nil`, `c.code_theme_dark = nil`, `c.seo.og_image = nil` (`configuration.rb:295-337`). The component then guards on the reader and returns early — `Shell#app_home_link` (`shell.rb:197-207`), `Configuration#compare_url` (`configuration.rb:414-418`). "Absent value, absent tag" beats "absent value, empty tag", because an empty `og:image` or a bare `href=""` is a bug a reader sees and the suite does not. + +## Degrade on the render path; raise at config time + +A render must not take the site down for a config mistake that has a sensible fallback: an unresolvable Rouge theme name returns nil and falls back to the default (`Configuration#resolve_theme`, `configuration.rb:650-654`), an unreadable snapshot manifest reads back as an empty snapshot (`Snapshot#read_manifest`, `snapshot.rb:140-149`), an unreadable snapshot file renders as `""` (`Snapshot::Entry#markdown`), an unknown `params[:version]` resolves to the current version rather than 404ing (`Configuration#resolve_version`, `configuration.rb:391-393`). + +The exceptions are deliberate and are all *configuration shape* errors, where there is nothing meaningful to degrade to: `c.topbar_brand` outside `TOPBAR_BRAND_MODES` raises `ArgumentError` at assignment (`configuration.rb:219`), `on_page` outside `ON_PAGE_MODES` raises in `coerce_on_page_mode` (`configuration.rb:535-543`), a malformed `c.brand_logo` raises on first read (`configuration.rb:352-356`), and `openapi_document` raises `DocsKit::Error` naming the knob when `c.openapi` is unset (`configuration.rb:443-451`). Loud at boot beats silently wrong in production. + +## File-backed config memoizes and reloads on mtime + +Three places read a file the developer edits while the server runs, and all three use the same shape — memoize, invalidate when the source's mtime changes: `Configuration#openapi_document` (`configuration.rb:443-451`), `Snapshot.for` (keyed on `[version id, root]`, `snapshot.rb:31-44`), and `BrandLogo`'s `file:` form. Copy that shape rather than a plain `||=`; a plain memo means a restart for every edit. + +## A gem controller never defines `#config` + +`ActionController::Base#config` is the Rails config object, and `RequestForgeryProtection` delegates `allow_forgery_protection` to it. Shadowing it breaks `csrf_meta_tags` the moment the controller renders a `<head>`. All three gem controllers name the reader `#docs_config` instead (`llms_controller.rb:63`, `search_controller.rb:50`, `mcp_controller.rb:62`). The same three declare their own forgery posture, because a bare `ActionController::Base` subclass does not inherit the host's `default_protect_from_forgery`: `:null_session` on the two GET-only text endpoints, `skip_forgery_protection` on the JSON-RPC MCP endpoint. + +## Optional gems are runtime-detected, never gemspec dependencies + +`mcp` is the live example: not in the gemspec, loaded in a memoized `require`/`rescue LoadError` (`Configuration#mcp_gem_present?`, `configuration.rb:502-512`), gated with the site toggle by `#mcp_enabled?` (`configuration.rb:560-562`), and `McpServer.build` returns nil when it is absent. A CI leg installs `--without mcp` and runs the suite to prove the feature no-ops. `rails_icons` gets a lighter version of the same treatment in `DocsUI::Icon`; the OG screenshot tooling (`shot-scraper`/chromium) is resolved at rake-task runtime and `og_generator.rb` is zeitwerk-ignored so it is never eager-loaded. + +## One enumeration seam + +`DocsKit::LlmsText.pages` (`llms_text.rb:68-73`) is the single place that answers "which pages does this request see?". `llms-full.txt`, the search index and the three MCP tools all call it, which is why making *it* version-aware made every AI surface version-aware at once. `LlmsText.renderable_for` (`llms_text.rb:87-89`) is the matching seam for "how do I render one" — it handles a live `Registry::Entry`, a `Snapshot::Entry`, and a site's older custom registry class that predates `#renderable`. Add a fourth consumer by calling these two, not by re-deriving the list. + +## Generator idempotence is semantic, not byte-equality + +Thor's `route` and `template` skip only a byte-identical line, which is useless against a site that wrote the same route in its own style. So the generator detects by meaning: `route_present?` matches the `controller#action` string in any quote style (`install_generator.rb:497-506`), `stimulus_registered?` matches either loader and either quote style (`install_generator.rb:558-560`), `merge_rubocop_config` round-trips through YAML and returns the original text when nothing changed (`install_generator.rb:450-464`), and `merge_agents_block` replaces only the text between the `<!-- BEGIN docs-kit -->` delimiters. When adding a step, make re-running it a genuine no-op and prove it with a spec that runs the generator twice. + +## Site-owned files are skipped, never clobbered + +`config/initializers/docs_kit.rb`, `Dockerfile`, `.claude/skills/write-docs-page/SKILL.md`, the doc registry, the sample pages and `application.tailwind.css` are the site's. The generator skips them when present and prints the template path for a manual diff (`create_initializer`, `install_generator.rb:104-112`; `create_dockerfile`, `211-219`). Gem-owned wiring goes the other way and is refreshed every run: `create_og_task` (`201-203`) and `create_dockerignore` (`225-227`, `force: true`). Decide which side a new file is on before writing the step. `SyncReport` warns about drift; it never edits. + +## Tailwind only sees literal class strings + +Tailwind scans the Ruby source, so an interpolated class name is tree-shaken out of the build. Class lists that must survive are written as literal strings — `Sidebar::MARKER_RESET` (`sidebar.rb:26`) says so explicitly. A render-time class that no source line spells literally needs an `@source inline(...)` entry in the site's `application.tailwind.css`. This is also why `bin/build-css` resolves the `daisyui` and `docs-kit` gem paths with `bundle show` and writes them into the generated `tailwind.sources.css`, and why it aborts when a gem can't be resolved: a silently missing `@source` ships an unstyled site. + +## A component may render with no Rails request + +Component specs render Phlex in isolation, and `DocsUI::MetaTags` may render in a static build, so anything reaching for `view_context` guards first: `Shell#csp_nonce` (`shell.rb:69`) returns nil when there is no view context and Phlex then omits the attribute, keeping the un-nonced markup unchanged; `Sidebar#current_path` rescues to nil (`sidebar.rb:116-120`); `ArchivedPage` deliberately omits the `Routes`/`Request` phlex-rails helpers because their bodies run `Rails.*` at class load. `Section#slugify` and `SearchIndex#slugify` each fall back to an ASCII slug when `String#parameterize` is unavailable. + +## `raw(safe(...))` is for gem-authored markup only + +Seven call sites bypass Phlex escaping, and every one of them emits markup the gem or a shape-checked site asset produced, never config free text: the Rouge-formatted code body and its static theme CSS (`code.rb:47`, `code.rb:123`), a shipped brand path (`brand_mark.rb:83`), a synced icon SVG (`icon.rb:27`), the theme-restore script whose only interpolation is `key.to_json` (`shell.rb:110`), the search snippet the index escaped itself, and `DocsUI::Logo`'s `markup:`/`file:` forms (`logo.rb:64`) — which `BrandLogo` shape-checks as an `<svg>` element at config time. Config free text (brand, description, labels) flows through `plain` or an ordinary attribute value and is escaped. diff --git a/lode/registry-and-versions/summary.md b/lode/registry-and-versions/summary.md new file mode 100644 index 0000000..e684a02 --- /dev/null +++ b/lode/registry-and-versions/summary.md @@ -0,0 +1,51 @@ +# Registry and versions + +Where a site's page list comes from, and how an archived version swaps it out underneath everything. + +## `DocsKit::Registry` — the mixin + +A site's registry class does `extend DocsKit::Registry`. Two authoring styles share one lookup API. + +**The `page` DSL (v2)** — the default. `page "Installation", group: "Guide"` (`registry.rb:65-75`) appends a frozen Hash in declaration order, which is sidebar order. Slug derives from `title.parameterize`, view from `title.parameterize(separator: "_").camelize`; both are overridable with `slug:`/`view:`, and `icon:` is optional. `path_prefix` (default `"/docs"`) and `view_namespace` are class-level declarations. + +**The hash `entries` API** — for a registry with a bespoke schema. The site declares `entries [...]` and writes its own `initialize`/readers/`view_class`. + +A registry uses one style. `page` raises `Registry::Error` when `@entries` is set, and `entries` raises when `@pages` is non-empty (`registry.rb:53-59`, `65-75`). + +The shared API: `.all` (`99-105`, built **fresh on every call** — instances are cheap and a site may resolve view classes that change under code reload in development), `.from_slug`, `.grouped` (grouped by `group_by_attribute`, default `:group`, preserving order within a group), and `.nav_items` (`120-126`) — `{ group => [NavItem] }` for authored pages only. + +`Registry::Entry` is the default instance for a v2 `page`: readers plus `#href` (`"#{path_prefix}/#{slug}"`) and `#view_class` (`145-149`), which `safe_constantize`s `"#{view_namespace}::#{view_name}"` and returns nil until the class is written. `#renderable` (`154-156`) is `view_class&.new`. + +**Authored means a resolvable `view_class`.** `nav_items` filters on it, and so does `LlmsText.pages`. That is the one mechanism preventing a dead link to a declared-but-unwritten page across the sidebar, `llms.txt`, search and MCP. Note the filter is `respond_to?(:view_class) && view_class` — an `entries`-style registry whose instances don't define `view_class` contributes no nav items at all. + +## `DocsKit::DocVersion` — one version + +A `Data.define(:id, :label, :ref, :current, :noindex)` (`doc_version.rb:22`). Defaults matter: `label` falls back to `id.to_s`; `current` defaults false; **`noindex` defaults to the inverse of `current`** — archived copies are noindex'd so search engines keep pointing at the live docs, overridable per version with `noindex: false`. + +`#path_prefix` (`doc_version.rb:55-57`) is `""` for the current version and `"/#{id}"` for an archived one, so existing sites' URLs and SEO are untouched. Named `DocVersion`, not `Version`, because `lib/docs_kit/version.rb` already owns that file slot for `DocsKit::VERSION`. + +Configuration reads: `#versions` normalises the list, `#current_version` is the entry marked `current: true` else the first entry else nil, `#version(id)` is a strict lookup, `#resolve_version(id)` is the degrade-to-current rule, `#versioning_enabled?` needs two or more. + +## `DocsKit::Snapshot` — an archived version's content + +A snapshot lives at `<config.snapshots_path>/<version id>/`: a `manifest.json` plus one `.md` file per page. `snapshots_path` defaults to `Rails.root/"docs_snapshots"` under Rails, nil outside it (the standalone suite passes explicit paths and points at `spec/fixtures/snapshots/`). + +`Snapshot` speaks the **same duck type as a registry** — `#all`, `#from_slug`, `#nav_items`, plus `#nav_groups` and `#markdown_for(slug)` — which is what lets an archived version render through today's chrome with only its content frozen. `Snapshot::Entry` mirrors `Registry::Entry` (`#slug`/`#title`/`#group`/`#icon`/`#href`/`#view_class`/`#renderable`); its `#view_class` returns the truthy `DocsUI::ArchivedPage` constant, so the `select(&:view_class)` authored-page filter passes unchanged, and `#renderable` returns `DocsUI::ArchivedPage.new(entry: self)`. + +`Snapshot.for` (`snapshot.rb:31-44`) memoizes per `[version id, root]` and invalidates on `manifest.json`'s mtime. `Snapshot.reset_cache!` clears it. + +Failure is silent by design, in both directions: a missing directory or unparseable manifest reads back as an empty snapshot (`#read_manifest`, `snapshot.rb:140-149`, rescuing `JSON::ParserError` and `SystemCallError`), and an unreadable page file renders as `""`. A version configured before its snapshot is written must not take the site down. `SCHEMA = 1` is stamped by the writer so a future format change is detectable rather than silently misread. + +## How a request picks a source + +`DocsKit::Scope.version` is set once per request by `Controller#render_page` or `Scoping`. Two readers branch on it and nothing else has to: + +- `Configuration#nav_groups` — an archived version's nav comes from the snapshot manifest. +- `LlmsText.pages` — an archived version enumerates the snapshot instead of the live registries. + +`DocsUI::ArchivedPage` renders an entry's frozen Markdown through `DocsUI::Shell` + `DocsUI::Markdown`. It deliberately does **not** include the `Routes`/`Request` phlex-rails helpers, whose bodies run `Rails.*` at class load and would make the constant — and therefore `Snapshot::Entry#view_class` — unloadable in a Rails-free render. Every kwarg defaults, so even a naive `entry.view_class.new` renders an empty page rather than raising. + +## Related + +- `../config/summary.md` — `nav_registries`, `versions`, `snapshots_path` +- `../ai-surfaces/summary.md` — `LlmsText.pages`, the shared enumeration seam diff --git a/lode/review/cli-and-templates.md b/lode/review/cli-and-templates.md new file mode 100644 index 0000000..5d90bc5 --- /dev/null +++ b/lode/review/cli-and-templates.md @@ -0,0 +1,11 @@ +# Review rules — the `docs-kit new` CLI and the site templates + +Accepted review findings about the scaffolding path, rewritten as rules about the system and verified against the code as it is. + +### The `docs-kit new` CLI resolves every `DOCS_KIT_*` value before the template runs, so a template default is dead on the CLI path + +- **Holds because:** `exe/docs-kit` builds the whole env hash — `DOCS_KIT_GEM_SOURCE`, `DOCS_KIT_IMAGE`, `DOCS_KIT_SERVICE` — and passes it to `system(env, *rails_new)` (`exe/docs-kit:59-63, 80`). `DOCS_KIT_IMAGE` is `opts[:image] || "zoolutions/#{name}"`, so it is always set. `lib/docs_kit/templates/new_site.rb:26` then reads `ENV.fetch("DOCS_KIT_IMAGE", "zoolutions/#{app_name}")` and the fallback never fires — it is reachable only when the template is applied directly (`rails new -m …` by hand). Changing the template's fallback alone therefore fixes nothing a user of the documented command sees, which is exactly what happened when the repo moved from `mhenrixon/` to `zoolutions/`. +- **Where:** `exe/docs-kit:59-63` (the env hash) and `:28` (the `--image` help text) and `:7` (the usage example); `lib/docs_kit/templates/new_site.rb:26` +- **Safe direction:** the three must move together. A CLI default that is wrong is the *unsafe* direction, because the value it scaffolds into `config/deploy.yml` and the caller workflow looks plausible and fails late — an image name that is not the calling repo's `OWNER/REPO` becomes an unlinked user-scoped GHCR package that `GITHUB_TOKEN` cannot pull, so the failure surfaces at deploy time in someone else's repository. Prefer a CLI that errors over one that scaffolds a plausible-but-unlinked name. +- **Proven by:** no test — `exe/docs-kit` has no spec (grepping `spec/` for `DOCS_KIT_IMAGE` or `docs-kit new` returns nothing), and the generator specs exercise `docs_kit:install`, not the CLI. The fallback in `new_site.rb` is likewise unexercised. Treat any edit to either default as untested and check both files plus the two help-text mentions by hand. +- **Origin:** cubic learning ec3d720b; PR #70 review thread (accepted, fixed in `d9adaa2`) diff --git a/lode/summary.md b/lode/summary.md new file mode 100644 index 0000000..298f085 --- /dev/null +++ b/lode/summary.md @@ -0,0 +1,13 @@ +# docs-kit + +A Rails engine gem (`docs-kit`, `DocsKit::VERSION` 1.1.1) that ships the shared Phlex/daisyUI chrome for documentation sites — the shell, sidebar, code blocks, theme switcher and page kit — plus the AI surfaces a docs site is expected to have (a Markdown twin of every page, `/llms.txt`, server-rendered search, an optional MCP endpoint) and the install path that wires a site up (`rails g docs_kit:install`, `rails g docs_kit:page`, the `docs-kit new` CLI, a reusable deploy workflow). Several sites — importmap-plus, phlex-reactive, glyphs, and this repo's own `docs/` dogfood app — run the same chrome and differ only in their `DocsKit.configure` block. + +Three invariants govern every change. + +**Chrome is configuration, not markup.** A `DocsUI::` component reads `DocsKit.configuration`; nothing site-specific is hardcoded in a component, and a site never hand-writes drawer or menu HTML. The 36 top-level knobs on `DocsKit::Configuration` (plus the nested `c.seo.*` and `c.landing.*` blocks) are the whole surface a site tunes. + +**The server renders a working page.** There is exactly one Stimulus controller, `docs-nav`, auto-pinned by the engine (`config/importmap.rb`), and it only *enhances* — collapse persistence, the auto-TOC, scroll-spy, the search palette. With JavaScript off the sidebar renders fully expanded and the search form still submits. + +**A new knob defaults backwards-compatibly.** Every opt-in feature reads as absent when unset, so a site that upgrades and changes nothing renders byte-identical markup. `c.versions = []`, `c.topbar_links = []`, `c.brand_logo = nil`, `c.openapi = nil`, `c.seo.og_image = nil` are all the same shape: absent value, absent tag. + +The gem is deliberately loose on Rails. The gemspec declares no `rails`/`railties` dependency; `lib/docs_kit.rb` requires the engine only `if defined?(Rails::Engine)`, and the suite renders the components with no Rails boot. The Rails-only bits — `Rails.root`, `Rails.env` — are each guarded by a `defined?(Rails)` check. diff --git a/lode/terminology.md b/lode/terminology.md new file mode 100644 index 0000000..d5afc5e --- /dev/null +++ b/lode/terminology.md @@ -0,0 +1,21 @@ +# Terminology + +The words this repository uses, with the file that defines each. + +- **chrome** — everything around a page's authored content: shell, topbar, sidebar, theme switcher, masthead. Shipped by the gem as `DocsUI::` Phlex components (`app/components/docs_ui/`, 30 component classes plus the `PageHelpers` module). +- **the kit / `DocsUI`** — the `DocsUI` module, extended with `Phlex::Kit` in `lib/docs_kit.rb`, so every component constant is also a bare callable: `DocsUI::Code(src)` == `render DocsUI::Code.new(src)`. Named `DocsUI`, not `Docs`, so it never collides with a host app's `Views::Docs` page namespace. +- **consuming site / docs site** — a Rails app that bundles the gem and calls `DocsKit.configure`. This repo's `docs/` directory is one (the dogfood site), depending on the gem via `path: ".."`. +- **registry** — an in-memory list of a site's pages. A site class `extend DocsKit::Registry` and declares pages; the mixin supplies `.all` / `.from_slug` / `.grouped` / `.nav_items` (`lib/docs_kit/registry.rb`). +- **the `page` DSL (v2)** — `page "Installation", group: "Guide"`, one line per page, slug and view class derived from the title (`Registry#page`, `registry.rb:65-75`). The older form is the hash `entries [...]` API; a registry uses one style, and mixing them raises `Registry::Error`. +- **authored page** — a registry entry whose `#view_class` resolves to a real constant (`Registry::Entry#view_class`, `registry.rb:145-149`). `nav_items`, `llms.txt`, search and MCP all filter to authored pages, so a declared-but-unwritten page is never linked. +- **Markdown twin** — the GFM rendering of a page derived from the page's own rendered HTML, not from a second source (`DocsKit::MarkdownExport`). `GET /docs/x.md` returns it; `/llms-full.txt` concatenates them; the search index is built from them. +- **`#docs-content`** — the `div` id the Shell stamps on its content column (`shell.rb:142`). It is the extraction anchor for the Markdown twin, which is why the topbar, sidebar and TOC render outside it. +- **`data-md-skip`** — the attribute that drops an element from the Markdown twin (`MarkdownExport::DROP_SELECTOR`). `DocsUI::Page` puts its "← Docs home" nav inside it. +- **scope** — the request-scoped content axis: which documentation version this render serves (`DocsKit::Scope`, backed by `Thread.current`, deliberately not `CurrentAttributes` so bare Phlex specs can set one without Rails). `Scope.locale` is reserved and always nil today. +- **snapshot** — a committed Markdown freeze of one archived documentation version, read back as the registry duck type (`DocsKit::Snapshot`), so an archived version renders through today's chrome. +- **current version / archived version** — the version serving unprefixed at `/docs` versus one serving at `/<id>/docs` (`DocVersion#path_prefix`, `doc_version.rb:55-57`). `versioning_enabled?` needs at least two configured versions. +- **`--sync`** — the install generator's upgrade mode: re-run the additive wiring, scaffold no site-owned content, report drift (`InstallGenerator`'s `:sync` class option). +- **drift** — a hand-written artefact in a site that the gem now provides, detected but never rewritten by `DocsKit::Generators::SyncReport`. +- **synced stamp** — the inert `# docs-kit synced: vX.Y.Z` comment the generator writes at the top of `config/initializers/docs_kit.rb`, so the next `--sync` knows which migrations to run (`SYNCED_STAMP_RE`, `install_generator.rb:56`). +- **`tailwind.sources.css`** — the generated file `bin/build-css` writes with `@source` globs pointing at the resolved `daisyui` and `docs-kit` gem paths, imported by `application.tailwind.css`. Generated per environment, so it is gitignored in the sites that have it. +- **dash** — the deploy tool the reusable workflow drives (`.github/workflows/deploy.yml`): build with buildx, push to GHCR, deploy with `--skip-push` behind a Cloudflare Tunnel and dash-proxy. diff --git a/lode/testing-and-ci/summary.md b/lode/testing-and-ci/summary.md new file mode 100644 index 0000000..6b39d92 --- /dev/null +++ b/lode/testing-and-ci/summary.md @@ -0,0 +1,55 @@ +# Testing, CI, deploy, and the dogfood site + +## The suite + +RSpec, 62 spec files, 953 examples (`bundle exec rspec --dry-run`). `.rspec` is `--require spec_helper --format documentation`. + +| Layer | Path | Files | Boots | +|---|---|---|---| +| Unit | `spec/docs_kit/**` | 27 | nothing — config, registry, scope, snapshot, the value objects, `LlmsText`, `SearchIndex`, `McpTools`, the OpenAPI model, the three gem controllers | +| Component | `spec/docs_ui/**` | 28 | a Phlex render, no Rails request | +| Generator | `spec/generators/**` | 4 | plain Thor against a tmp app skeleton — install, page, migration, migration_registry | +| Cops | `spec/rubocop/**` | 2 | RuboCop's cop harness | + +Plus `spec/docs_kit_spec.rb` for the module itself. Fixtures: `spec/fixtures/openapi.{json,yaml}` and `spec/fixtures/snapshots/1.0/` (a manifest plus two `.md` pages). + +`spec/spec_helper.rb` starts SimpleCov **before** requiring `docs_kit` (line 7) with `minimum_coverage 80` and branch coverage, so `rspec` and `rake` fail below the floor locally and in CI. It then loads just enough to render the chrome standalone: two ActiveSupport core-exts, `phlex/rails`, and `daisy_ui` — the gem never requires daisyui itself, the host does, so the constant would otherwise be undefined when a component renders. `mcp` is required in a `begin/rescue LoadError`. `config.order = :random`, monkey-patching disabled, and a `before` hook calling `DocsKit.reset_configuration!` so every example starts clean. + +The rules in `../../.claude/rules/testing.md`: assert on semantics (an active link, a present theme option, a config-driven value), not full-HTML snapshots; 100% aspired for `Configuration` and `Registry`. + +## CI — `.github/workflows/ci.yml` + +Triggers on push to `main` and every pull request, with `cancel-in-progress` concurrency per ref. Three jobs: + +1. **`rake`** — `bundle exec rake` (spec + rubocop) on Ruby 3.2, 3.3, 3.4, `fail-fast: false`. +2. **`docs-site`** — the dogfood app's own RSpec: request specs plus Playwright/chromium system specs. It sets `BUNDLE_FROZEN: "false"` because the app depends on the gem via `path: ".."` and the gemspec lists files with `git ls-files`, so **every commit changes the path-gem's digest** and bundler's frozen mode (forced by `bundler-cache`) fails with "the gemspecs for path gems changed". This job is the only coverage that a logical `og_image` resolves to a served, digested `/assets` URL — the exact gap that let the og:image 404 ship. +3. **`without-mcp`** — installs with `bundle config set --local without mcp` and runs the suite, proving the optional MCP feature no-ops when the gem is absent. + +`Rakefile`'s RuboCop task passes explicit patterns (`app lib spec Rakefile Gemfile docs-kit.gemspec`) so RuboCop never discovers `docs/.rubocop.yml`, whose `inherit_gem` can't resolve in the gem's bundle. `docs/` lints itself. + +## Release + +`rake release[X.Y.Z]` (`Rakefile:40-171`), never `gem push`. It aborts unless the branch is `main` and the tree is clean, bumps `lib/docs_kit/version.rb`, refreshes both lockfiles and runs `gem build --strict` as verification, commits **only** `version.rb`, pushes `main`, and creates the GitHub Release. The root `Gemfile.lock` is gitignored so its refresh is pure verification; `docs/Gemfile.lock` is **tracked**, so the release leaves it modified and uncommitted and someone lands the new pin in a follow-up commit (`97a696a`, "chore(docs): refresh docs lock for 1.1.0"). Between the two, the tracked pin lags `DocsKit::VERSION`. `release.yml` then publishes to RubyGems over OIDC trusted publishing. `rake release[pre]` re-releases the current version as a prerelease; a second `force` argument deletes the existing release and tag first. + +Because releases land directly on `main`, a feature branch never edits `version.rb` — a conflict there means the branch is a deliberate release-prep PR. + +## Deploy + +`deploy.yml` is the **reusable** workflow every docs-kit site calls, so the build+deploy is defined once: buildx build with registry cache, push to GHCR, then `dash` deploys with `--skip-push` so the image is never built twice. `deploy-docs.yml` is this repo's thin caller (`image: zoolutions/docs-kit`, `service: docs-kit`) firing on `release: published` and `workflow_dispatch`. + +Two things bite callers. The caller must **grant** `packages: write` — a reusable workflow can only narrow the permissions it is given, so with the repo default of read-only the build job's `packages: write` exceeds the grant and the run is a `startup_failure`. And the `secrets:` inputs are declared `required: false` on purpose: callers pass them with `secrets: inherit`, and GitHub cannot statically confirm an inherited secret satisfies `required: true`, which is itself a `startup_failure`. They are still effectively required — the deploy step fails fast on an empty `DEPLOY_HOST`/`DEPLOY_DOMAIN`. + +The build job checks out with `persist-credentials: false`: the image `COPY`s the repo root including `.git` (kept for the gemspec's `git ls-files`) and is pushed to a public GHCR package, so a persisted `GITHUB_TOKEN` in `.git/config` must not ride along into a published layer. + +## The dogfood site — `docs/` + +A real Rails app under `docs/`, depending on the gem via `path: ".."`, deployed to https://docs-kit.zoolutions.llc. It is a docs-kit site like any other: `docs/app/models/doc.rb` is the registry (14 `page` lines across five groups), `docs/app/views/docs/pages/` holds the page classes. + +Its own bundle, `.rubocop.yml`, `.rspec` and specs — run everything from inside `docs/`, never the repo root. `docs/bin/ci` (via `docs/config/ci.rb`) chains setup, RuboCop, bundler-audit, `bin/importmap audit`, Brakeman, and `bin/rspec`. `docs/Gemfile.lock` and `docs/bun.lock` are **tracked** (a deployable app commits them) while the gem root's `Gemfile.lock` is gitignored. + +Adding a page: `cd docs && bin/rails g docs_kit:page "Title" --group=…`. The authoring contract is `../../AGENTS.md` and the always-current worked example is `docs/app/views/docs/pages/authoring.rb`, rendered at `/docs/authoring`. + +## Related + +- `../install-path/summary.md` — what the generator specs exercise +- `../../.claude/rules/testing.md` — the layer table and coverage bars diff --git a/lode/workflow.md b/lode/workflow.md new file mode 100644 index 0000000..86d93d2 --- /dev/null +++ b/lode/workflow.md @@ -0,0 +1,136 @@ +# Workflow profile + +Everything the shared workflow skills (`/lode:lfg`, `/lode:review-pr`, +`/lode:finish-prs`, `/lode:debug-flaky`, `/lode:tdd`, `/lode:plan`) need to know +about docs-kit that is not already in `../CLAUDE.md`, `../AGENTS.md`, +`../.claude/rules/` or the rest of `lode/`. + +## Commands + +| Purpose | Command | Notes | +|---|---|---| +| fast loop (one file) | `bundle exec rspec spec/<path>_spec.rb` | the examples run and report, but the **process still exits non-zero**: SimpleCov's `minimum_coverage 80` (`spec/spec_helper.rb:15`) measures the whole of `lib/` and `app/` on every run, and one file never reaches 80%. Read the `N examples, M failures` line, not the exit code. There is no env escape hatch. | +| full suite | `bundle exec rake` (= `spec` + `rubocop`; `Rakefile:173`) | 62 spec files, 953 examples. No network, no services, no browser — the gem suite renders Phlex standalone. Safe to run in two worktrees at once. | +| lint | `bundle exec rubocop` (1.91.0) | `rake rubocop:autocorrect` for the safe cops. The task passes explicit patterns (`app lib spec Rakefile Gemfile docs-kit.gemspec`, `Rakefile:14`) so RuboCop never discovers `docs/.rubocop.yml`, whose `inherit_gem` cannot resolve in the gem's bundle. | +| one CI cell locally | `bundle exec rake` for the `rake` job; `cd docs && bundle exec rspec` for `docs-site` (needs a Playwright chromium: `cd docs && bunx --bun playwright install chromium`); `bundle config set --local without mcp && bundle install && bundle exec rspec` for `without-mcp` — **unset it afterwards** (`bundle config unset --local without`) or every later run hides the MCP specs | | +| docs build / check | `cd docs && bin/ci` (setup → rubocop → bundler-audit → `bin/importmap audit` → brakeman → rspec). `cd docs && bun run build:css` rebuilds the Tailwind/daisyUI CSS | always from inside `docs/` — it is a separate app with its own bundle, `.rubocop.yml`, `.rspec` and lockfiles. There is no `package.json` at the repo root. | +| run the app | `cd docs && bin/dev` (`bin/rails server`) | the dogfood site is the only runnable app in the repo | +| the scaffolder | `ruby exe/docs-kit --help` | `docs-kit new NAME [--image OWNER/REPO] [--service NAME] [--gem-source SRC]`; it shells out to `rails new`, so only run it for real in a throwaway directory | + +## Branches and PRs + +- Default branch: `main`. All work goes through a PR; never commit to `main` directly. +- Work branches: `feature/*`, `fix/*`, `refactor/*`, `ci/*`, `chore/*`, rooted off fresh `origin/main` (`../.claude/rules/git-workflow.md`). +- Commits: conventional, with a scope drawn from the architecture — `shell`, `sidebar`, `code`, `page`, `theme`, `registry`, `controller`, `generator`, `engine`, `deploy`, `docs`, `ci`. The body says **why**. +- A `gh pr`/`gh issue` body written through a single-quoted heredoc is copied verbatim: never escape backticks or pipes. +- PR body sections, in order: Summary, Test plan, Deviations & judgment calls, Gate. +- Merge policy: squash on `main` once CI is green and the PR is approved. Never rebase a branch that has a PR — merge `main` forward into it. +- Attribution: no `Co-Authored-By: Claude`, no "Generated with" line. End a commit body with `Claude-Session: <url>` and a PR body with the session URL. + +## Layers + +| Layer | Files | Edit rule | +|---|---|---| +| Client runtime | `app/javascript/docs_kit/controllers/docs_nav_controller.js` (the only one) | owned here — but it may only *enhance*. Never add a second controller; never make a page require it. | +| Components | `app/components/docs_ui/*.rb` (30 classes + `page_helpers.rb`) | owned here. New chrome is a `DocsUI::` component, never raw daisyUI markup, and it reads `DocsKit.configuration` rather than hardcoding. | +| AI surfaces | `lib/docs_kit/{markdown_export,blocks,inline,table,llms_text,search_index,search_hit,mcp_tools,mcp_server,open_api}.rb`, `app/controllers/docs_kit/*.rb` | owned here. A new consumer calls `LlmsText.pages` / `.renderable_for`; it does not re-derive the page list. | +| Registry + versions | `lib/docs_kit/{registry,doc_version,snapshot,nav_item}.rb` | owned here. `Registry`'s public API is what sites depend on — additive only. | +| Config | `lib/docs_kit/configuration.rb` plus the value objects | owned here. A new knob ships with a default that renders nothing. | +| Core | `lib/docs_kit.rb`, `engine.rb`, `controller.rb`, `scope.rb`, `scoping.rb` | owned here. The `DocsUI`/`Phlex::Kit` extend must stay before `loader.setup`; a new file needs the matching `loader.ignore` reasoning. | +| Install path | `lib/generators/docs_kit/**`, `lib/docs_kit/templates/new_site.rb`, `exe/docs-kit` | owned here. Every step must be a genuine no-op on re-run, proved by a spec that runs it twice. | +| Generator templates | `lib/generators/docs_kit/install/templates/**` (16 files) | owned here, but they become **site-owned** once written: the generator skips them, so a change only reaches existing sites through a `Migration`. | +| Shipped cops | `lib/rubocop/cop/docs_kit/*.rb`, `config/rubocop/docs_kit.yml` | owned here; rubocop is a host dev-time dependency, required lazily. | +| Dogfood site | `docs/**` | a consuming app, not gem source. Its own bundle and lint; the gem's `rake rubocop` deliberately excludes it. | +| Vendored icons | `docs/app/assets/svg/icons/**` | vendored Lucide copies — re-vendor, never hand-edit. | +| Lockfiles | `docs/Gemfile.lock`, `docs/bun.lock` (tracked); root `Gemfile.lock` (gitignored) | generated — regenerate, never hand-edit. | + +## Shapes + +Check a change against every one of these before calling it done. + +- **A site that sets nothing.** Every opt-in knob must render byte-identical markup when unset (`c.versions = []`, `c.topbar_links = []`, `c.brand_logo = nil`, `c.openapi = nil`, `c.seo.og_image = nil`). +- **A render with no Rails.** Component specs render Phlex standalone; `MetaTags` may render in a static build. Anything touching `view_context`, `request` or `Rails.*` guards or rescues first. +- **JavaScript off.** The server-rendered page is fully expanded, the search form submits, the theme is the configured default. +- **Both registry styles.** The `page` DSL and the legacy `entries [...]` API — and an `entries` registry whose instances define no `#view_class` contributes no nav items at all. +- **A declared-but-unwritten page.** `Registry::Entry#view_class` is nil until the class exists; `nav_items` and `LlmsText.pages` both filter on it. +- **An archived version in `DocsKit::Scope`.** `Configuration#nav_groups` and `LlmsText.pages` branch on it; a missing or unparseable snapshot must read back empty, not raise. +- **An unknown `params[:version]`.** `resolve_version` degrades to the current version; it never 404s. +- **The `mcp` gem absent** (a whole CI job), and `rails_icons` absent. +- **A site re-running the generator.** `--sync` and a full run; an unstamped site (`synced_version` reads `"0.0.0"`); a site that already wrote the route or the Stimulus register line in its own quote style; a `.rubocop.yml` that already has an omakase `inherit_gem`. +- **Ruby 3.2** — the gemspec floor and the oldest CI cell. +- **Request formats** `.md` and `.text`, not just `html`. +- **A relative and an absolute `og_image`**, and none at all. +- **A dark theme that is not in `config.themes`** — `dark_themes_shipped` must emit no dead CSS. +- **A Tailwind class that no source line spells literally** — it is tree-shaken out unless a site adds `@source inline(...)`. + +## Constraints + +Reviewer suggestions that are wrong in this repository. Push back on sight. + +| Suggestion | Why it is wrong here | +|---|---| +| "Add `rails`/`railties` to the gemspec" | Deliberate: `docs-kit.gemspec` declares none, `lib/docs_kit.rb` requires the engine only `if defined?(Rails::Engine)`, and the whole suite renders Rails-free. | +| "Make `mcp` a real dependency" | It is runtime-detected (`Configuration#mcp_gem_present?`) and the `without-mcp` CI job exists to prove the feature no-ops without it. | +| "Use `ActiveSupport::CurrentAttributes` instead of `Thread.current` in `Scope`" | A bare Phlex component spec must be able to set a scope without booting Rails. | +| "Name the controller's config reader `#config`" | `ActionController::Base#config` is Rails' config object and `RequestForgeryProtection` delegates to it; shadowing it breaks `csrf_meta_tags`. All three gem controllers use `#docs_config`. | +| "Extract these Tailwind classes into an interpolated helper" | Tailwind scans the Ruby source; an interpolated class name is tree-shaken out of the build. Class lists stay literal strings. | +| "Use `render json:` in `McpController`" | `server.handle_json` already returns a serialised JSON string; `render json:` re-encodes it and corrupts the JSON-RPC envelope. | +| "Let Thor's `route`/`template` skip handle idempotence" | Thor skips only a **byte-identical** line, which is useless against a site that wrote the same route in its own style. Detection is semantic (`route_present?`, `stimulus_registered?`). | +| "Use a Regexp `after:` for `inject_into_file`" | Thor replaces *every* Regexp match; `PageGenerator#registry_anchor` returns a String for exactly this reason. | +| "Use `lazyLoadControllersFrom`" | The default `controllers/index.js` imports only the eager loader, so a lazy call throws a `ReferenceError` that aborts the module and registers **zero** controllers. | +| "`raw`/`html_safe` this config value" | Only gem-authored or shape-checked markup may bypass Phlex escaping (seven call sites). Config free text flows through `plain` or an ordinary attribute. | +| "Bump `lib/docs_kit/version.rb` in this PR" | Releases land directly on `main` via `rake release[X.Y.Z]`, which aborts off `main`. A feature branch touching `version.rb` is either a deliberate release-prep PR or a mistake. | +| "Lint `docs/` from the root Rakefile" | `docs/.rubocop.yml` inherits gems absent from the gem's bundle; it lints itself. | +| "Rebase this branch onto `main`" | It has a PR, so it is shared — merge `main` forward instead. | +| "Document it in the README" (alone) | A required setup step lands in the install generator **and** the `docs-kit new` template, or new sites never get it. | + +## Docs + +- User-facing docs live in **two** places and both are user-facing: `README.md` (the full install/configure/render/deploy guide, 19 `##` sections) and the dogfood site's pages in `docs/app/views/docs/pages/` (14 pages, registered in `docs/app/models/doc.rb`). +- A change maps to a page by subject: a config knob → `configuration.rb`; a component → `components.rb`; authoring or Markdown → `authoring.rb` / `markdown.rb`; code highlighting → `languages.rb`; the AI surfaces → `ai.rb` and `search.rb`; the OpenAPI bridge → `open_api.rb` / `api.rb`; the install path → `installation.rb`; the CSS build → `styling.rb`; deploy → `deploy.rb`; the TOC → `on_this_page.rb`. +- Add a page with `cd docs && bin/rails g docs_kit:page "Title" --group=…`; the authoring contract is `../AGENTS.md` and the worked example is `docs/app/views/docs/pages/authoring.rb`. +- Changelog: `CHANGELOG.md`. New entries go under `## [Unreleased]`, in an `### Added` / `### Fixed` subhead (the two the changelog uses); a release renames the heading. Do not add a second subhead of the same name. +- Version-pinning files that drift after a release: **`docs/Gemfile.lock`** pins `docs-kit (X.Y.Z)` through `path: ".."`. `rake release` refreshes it but does not commit it, so it lags until someone lands `cd docs && bundle install` in a follow-up commit. `docs/bun.lock` drifts only when a JS dependency changes (`cd docs && bun install`). + +## CI + +- Workflows: `.github/workflows/ci.yml` (push to `main` + every PR, `cancel-in-progress` per ref); `release.yml` (RubyGems over OIDC trusted publishing, on a GitHub Release); `deploy.yml` (the **reusable** build+deploy every docs-kit site calls); `deploy-docs.yml` (this repo's thin caller, on `release: published` and `workflow_dispatch`). +- Three jobs, three different pictures of the same code: + - `rake (Ruby 3.2 | 3.3 | 3.4)` — `bundle exec rake`, `fail-fast: false`. SimpleCov's 80% floor fails this job too. + - `docs site (RSpec + Playwright)` — runs in `docs/` with `BUNDLE_FROZEN: "false"` (the path gem's digest changes on every commit, so frozen mode would fail), a real chromium, request + system specs. The only job that proves a logical `og_image` resolves to a served, digested `/assets` URL. + - `gate (no mcp gem)` — installs `--without mcp` and runs the suite. +- Cells that differ from local: the `docs-site` job needs Playwright chromium installed and `RAILS_ENV=test`; the `without-mcp` job's bundle config is local-only and will silently persist if you reproduce it by hand. +- Fetch a failure: `gh pr checks <N>`, then `gh run view <RUN_ID> --job=<JOB_ID> --log-failed`. +- "Green" means all three jobs, every matrix cell. +- Known not-this-branch failures: a `Deploy docs` run failing on a missing `docs` environment secret or a dash image/service name is a deploy-wiring problem, not a code bug. +- Shared or rate-limited services the checks hit: none. Nothing in CI talks to a CDN or a registry beyond rubygems/npm during install, so PRs can run concurrently. + +## Flake sources + +- **Playwright/chromium system specs in `docs/`** — the only real browser in the repo, and the only place timing, animation and headless-rendering differences can bite. Everything in the gem suite is a synchronous Phlex render. +- **`config.order = :random`** with a `before` hook calling `DocsKit.reset_configuration!`. A spec that sets class-level or memoized state and does not reset it (`Snapshot.reset_cache!`, a `bundle config` left set, a `Scope` not unwound) fails only under some seeds. Reproduce with the printed `--seed`. +- **mtime-keyed memoization** (`Configuration#openapi_document`, `Snapshot.for`, `BrandLogo`'s `file:` form). A test that rewrites a fixture inside the same second can read the stale memo. +- **SimpleCov's coverage floor** is a whole-suite property: running a subset makes the process exit non-zero with every example passing. That is not a flake. +- Not a flake source: network calls. There are none in the gem suite. + +## Conflicts + +| File | Rule | +|---|---| +| `docs/Gemfile.lock` | never hand-merge: `git checkout --theirs docs/Gemfile.lock` (merging `origin/main` in makes the base "theirs"), `git add`, then `cd docs && bundle install` so the branch's own dependency changes re-resolve on top | +| `docs/bun.lock` | same: take the base's, then `cd docs && bun install`. CI installs with `--frozen-lockfile`, so a hand-edit fails there | +| root `Gemfile.lock` | gitignored — it can never conflict | +| `CHANGELOG.md` | union under `## [Unreleased]`: keep both sides' bullets, most recent first, without duplicating the `### Added` / `### Fixed` subheads | +| `lib/docs_kit/version.rb` | a feature branch never edits this; a conflict means the branch is a deliberate release-prep PR — keep the branch's bump. If the intent is not obvious from the branch's own commits, stop and ask | +| `docs/app/models/doc.rb`, `docs/config/routes.rb` | append-only registries: keep both sides' lines, base order first | +| `docs/app/assets/svg/icons/**` | vendored Lucide copies: take one side wholesale or re-vendor; never hand-merge SVG markup | +| `spec/fixtures/**` | add a second fixture rather than merge two shapes into one | + +After resolving, run the gates the conflict touched — `bundle exec rake` at minimum, plus `cd docs && bundle exec rspec && bundle exec rubocop <changed docs files>` when `docs/` was involved — **before** pushing the merge commit. + +## Verification + +- The manual check a user of the change would do: `cd docs && bin/dev`, open `/docs`, and look at the page the change touches — with JavaScript disabled as well as enabled. For a Markdown-twin or AI-surface change, also fetch `/docs/<slug>.md`, `/llms.txt` and `/docs/search?q=…`. For a generator change, run `rails g docs_kit:install` twice against a throwaway app and diff — the second run must change nothing. +- A CSS-affecting change (a new emitted class) needs `cd docs && bun run build:css` and a look at the built output; a class no source line spells literally will be missing. +- Stress iterations for a flake proof: 50 runs of the suspect spec file with `--seed` varied (`for i in (seq 50); bundle exec rspec <file> --seed $i; end`); for an ordering-dependent failure, reproduce the exact `--seed` CI printed first. +- Where evidence goes: `lode/tmp/` (gitignored, never committed) unless the PR needs an auditable trail.