diff --git a/.codeboarding/static_analysis.pkl b/.codeboarding/static_analysis.pkl
deleted file mode 100644
index 62c36b1..0000000
Binary files a/.codeboarding/static_analysis.pkl and /dev/null differ
diff --git a/.github/workflows/example-usage.yml b/.github/workflows/example-usage.yml
deleted file mode 100644
index 1f75c83..0000000
--- a/.github/workflows/example-usage.yml
+++ /dev/null
@@ -1,126 +0,0 @@
-name: Example Usage of CodeBoarding Action
-
-on:
- workflow_dispatch:
- inputs:
- repository_url:
- description: 'Repository URL to test with'
- required: false
- default: 'https://github.com/microsoft/markitdown'
- type: string
- source_branch:
- description: 'Source branch for comparison'
- required: false
- default: 'main'
- type: string
- target_branch:
- description: 'Target branch for comparison'
- required: false
- default: 'develop'
- type: string
- output_format:
- description: 'Output format for documentation'
- required: false
- default: '.md'
- type: choice
- options:
- - '.md'
- - '.rst'
-
- pull_request:
- branches: [ main, master ]
- types: [opened, synchronize, reopened]
-
- schedule:
- # Run daily at 2 AM UTC
- - cron: '0 2 * * *'
-
-jobs:
- update-docs-action-usage:
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- fetch-depth: 0 # Required to access branch history
-
- # Determine branches based on context
- - name: Set branch variables
- id: set-branches
- run: |
- if [ "${{ github.event_name }}" = "pull_request" ]; then
- echo "source_branch=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- echo "target_branch=${{ github.base_ref }}" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.source_branch }}" != "" ] && [ "${{ github.event.inputs.target_branch }}" != "" ]; then
- echo "source_branch=${{ github.event.inputs.source_branch }}" >> $GITHUB_OUTPUT
- echo "target_branch=${{ github.event.inputs.target_branch }}" >> $GITHUB_OUTPUT
- else
- # Default to current branch and main
- echo "source_branch=${{ github.ref_name }}" >> $GITHUB_OUTPUT
- echo "target_branch=main" >> $GITHUB_OUTPUT
- fi
-
- - name: Fetch CodeBoarding Documentation
- id: codeboarding
- uses: ./
- with:
- repository_url: ${{ github.event.inputs.repository_url }}
- source_branch: ${{ steps.set-branches.outputs.source_branch }}
- target_branch: ${{ steps.set-branches.outputs.target_branch }}
- output_directory: 'docs'
- output_format: ${{ github.event.inputs.output_format || '.md' }}
-
- - name: Display Action Results
- run: |
- echo "Documentation files created: ${{ steps.codeboarding.outputs.markdown_files_created }}"
- echo "JSON files created: ${{ steps.codeboarding.outputs.json_files_created }}"
- echo "Documentation directory: ${{ steps.codeboarding.outputs.output_directory }}"
- echo "JSON directory: ${{ steps.codeboarding.outputs.json_directory }}"
- echo "Has changes: ${{ steps.codeboarding.outputs.has_changes }}"
-
- # Check if we have any changes to commit
- - name: Check for changes
- id: git-changes
- run: |
- if [ -n "$(git status --porcelain)" ]; then
- echo "has_git_changes=true" >> $GITHUB_OUTPUT
- else
- echo "has_git_changes=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Create Pull Request
- if: steps.git-changes.outputs.has_git_changes == 'true' && steps.codeboarding.outputs.has_changes == 'true'
- uses: peter-evans/create-pull-request@v5
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- commit-message: "docs: update codeboarding documentation"
- title: "๐ CodeBoarding Documentation Update"
- body: |
- ## ๐ Documentation Update
-
- This PR contains updated documentation files fetched from the CodeBoarding service.
-
- ### ๐ Summary
- - **Documentation files created/updated**: ${{ steps.codeboarding.outputs.markdown_files_created }}
- - **JSON files created/updated**: ${{ steps.codeboarding.outputs.json_files_created }}
- - **Documentation directory**: `${{ steps.codeboarding.outputs.output_directory }}/`
- - **JSON directory**: `${{ steps.codeboarding.outputs.json_directory }}/`
- - **Source branch**: `${{ steps.set-branches.outputs.source_branch }}`
- - **Target branch**: `${{ steps.set-branches.outputs.target_branch }}`
- - **Output format**: `${{ github.event.inputs.output_format || '.md' }}`
- - **Repository analyzed**: `${{ steps.codeboarding.outputs.repo_url }}`
-
- ### ๐ Changes
- Files have been updated with fresh documentation content based on code changes between branches.
-
- ---
-
- ๐ค This PR was automatically generated by the CodeBoarding documentation update workflow.
- branch: docs/codeboarding-update
- base: ${{ steps.set-branches.outputs.target_branch }}
- delete-branch: true
diff --git a/.github/workflows/release-major-tag.yml b/.github/workflows/release-major-tag.yml
new file mode 100644
index 0000000..4b3c08b
--- /dev/null
+++ b/.github/workflows/release-major-tag.yml
@@ -0,0 +1,41 @@
+name: Move major version tag
+
+# Marketplace actions are pinned by consumers as `@v1`, a *moving* major tag that
+# should always point at the newest v1.x.x release. This re-points it on every
+# published (non-pre) release, e.g. publishing v1.4.2 moves `v1` -> v1.4.2.
+#
+# First release is still manual (cut a `vX.Y.Z` release once); after that `vX`
+# is maintained here automatically.
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ major-tag:
+ if: github.event.release.prerelease == false
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.release.tag_name }}
+
+ - name: Re-point major tag at ${{ github.event.release.tag_name }}
+ env:
+ TAG: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+ ver="${TAG#v}"
+ if ! printf '%s' "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "::notice::Tag '$TAG' is not a clean vMAJOR.MINOR.PATCH release (prerelease/suffix); skipping major-tag move."
+ exit 0
+ fi
+ major="v${ver%%.*}"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -fa "$major" -m "Update ${major} to ${TAG}"
+ git push origin "refs/tags/${major}" --force
+ echo "::notice::${major} now points at ${TAG}"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ea9ddb1
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,40 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ unittest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - name: Run unit tests (stdlib only)
+ run: python -m unittest discover -s tests -v
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+ - name: Install shellcheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+ - name: Install actionlint
+ run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7
+ - name: Run actionlint
+ run: actionlint
+ - name: Run shellcheck
+ run: shellcheck scripts/run_local.sh
diff --git a/.gitignore b/.gitignore
index 865fddd..5fc1409 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,24 @@
test_response.json
test_codeboarding/
+# Local test harness output (scripts/run_local.sh)
+.cb-local/
+
+# Dependencies
+node_modules/
+
+# Python generated files
+__pycache__/
+*.py[cod]
+
+# CodeBoarding generated cache/log artifacts
+.codeboarding/static_analysis.pkl
+.codeboarding/static_analysis.sha
+.codeboarding/logs/
+.codeboarding/health/*
+!.codeboarding/health/
+!.codeboarding/health/health_report.json
+
# Environment files
.env
diff --git a/README.md b/README.md
index 043c743..e2cb55e 100644
--- a/README.md
+++ b/README.md
@@ -1,111 +1,193 @@
-

-
- # CodeBoarding [Diagram-First Documentation]
-
- [](https://github.com/marketplace/actions/codeboarding-diagram-first-documentation)
+

+
+ # CodeBoarding Visual Architecture Review
+
+ Visual system-design review for pull requests. CodeBoarding analyzes the architecture before and after a change, then comments on the PR with an inline Mermaid diagram showing what changed.
-Generates diagram-first visualizations of your codebase using static analysis and large language models.
+## What It Does
+
+- Builds or reuses a baseline architecture analysis for the PR base.
+- Runs incremental analysis on the PR head, then diffs components and relationships.
+- Posts a sticky PR comment with an inline Mermaid map โ ๐ฉ added ยท ๐จ modified ยท ๐ฅ deleted (dashed), for both nodes and edges.
+
+A PR comment looks like this:
+
+```mermaid
+graph LR
+ Gateway["API Gateway"]
+ Auth["Auth Service"]
+ Cache["Cache"]
+ Gateway -- "routes to" --> Auth
+ Auth -- "reads/writes" --> Cache
+ classDef added fill:#1f883d,stroke:#0b5d23,color:#fff;
+ classDef modified fill:#bf8700,stroke:#7d4e00,color:#fff;
+ classDef deleted fill:#cf222e,stroke:#82071e,color:#fff,stroke-dasharray:5 3;
+ class Cache added;
+ class Auth modified;
+ class Gateway deleted;
+ linkStyle 0 stroke:#cf222e,stroke-width:2px,stroke-dasharray:5 3;
+ linkStyle 1 stroke:#1f883d,stroke-width:2px;
+```
## Usage
+Create `.github/workflows/codeboarding.yml`:
+
```yaml
-name: Generate Documentation
+name: CodeBoarding review
+
on:
- push:
- branches: [ main ]
pull_request:
- branches: [ main ]
- types: [opened, synchronize, reopened]
+ # Generate ONCE, when the PR becomes reviewable โ not on every push, so you
+ # don't spend an LLM job per commit. Use [opened] for strictly creation-only,
+ # or add `synchronize` to re-run on each push. Refresh anytime with /codeboarding.
+ types: [opened, reopened, ready_for_review]
+ issue_comment:
+ types: [created]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }}
+ cancel-in-progress: true
jobs:
- documentation:
+ review:
runs-on: ubuntu-latest
+ timeout-minutes: 60
+ if: >
+ (github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request != null &&
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
steps:
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: CodeBoarding/CodeBoarding-action@v1
with:
- fetch-depth: 0 # Required to access branch history
-
- - name: Generate Documentation
- uses: codeboarding/codeboarding-ghaction@v1
+ llm_api_key: ${{ secrets.OPENROUTER_API_KEY }}
+```
+
+Add the API key as a repository secret (**Settings โ Secrets and variables โ Actions**):
+
+```text
+OPENROUTER_API_KEY = sk-or-...
+```
+
+That's the only required setup โ it's passed via `llm_api_key` above. (For local runs with `scripts/run_local.sh`, export `OPENROUTER_API_KEY` as an env var instead.)
+
+**Models are optional.** Omit `agent_model` / `parsing_model` to use the engine's default for your provider, or pin them โ inline or from a repository **variable** (a model name isn't a secret, so use `vars.`, not `secrets.`):
+
+```yaml
with:
- repository_url: ${{ github.server_url }}/${{ github.repository }}
- source_branch: ${{ github.head_ref || github.ref_name }}
- target_branch: ${{ github.base_ref || 'main' }}
- output_directory: 'docs'
- output_format: '.md'
-
- - name: Upload Documentation
- uses: actions/upload-artifact@v4
+ llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} # secret
+ agent_model: anthropic/claude-sonnet-4 # optional; or ${{ vars.AGENT_MODEL }}
+ parsing_model: google/gemini-3-flash-preview # optional
+```
+
+**Model format (OpenRouter):** a bare OpenRouter slug (e.g. `anthropic/claude-sonnet-4`) โ exactly one `/`, **no `openrouter/` prefix** (that's the LiteLLM form; the action rejects it early). Other providers use their own native model ids.
+
+## Bring your own LLM provider
+
+OpenRouter is the default, but you can use any provider the engine supports โ set `llm_provider` and pass that provider's key:
+
+```yaml
with:
- name: documentation
- path: |
- docs/
- .codeboarding/
+ llm_provider: anthropic # omit for OpenRouter (default)
+ llm_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```
+`llm_provider: ` hands your key to the engine as `_API_KEY`, and the engine auto-selects that provider. Set **exactly one** key per run.
+
+Supported providers
+
+| `llm_provider` | env var the engine reads |
+|---|---|
+| `openrouter` *(default)* | `OPENROUTER_API_KEY` |
+| `openai` | `OPENAI_API_KEY` |
+| `anthropic` | `ANTHROPIC_API_KEY` |
+| `google` | `GOOGLE_API_KEY` |
+| `vercel` | `VERCEL_API_KEY` |
+| `deepseek` | `DEEPSEEK_API_KEY` |
+| `cerebras` | `CEREBRAS_API_KEY` |
+| `glm` / `kimi` | `GLM_API_KEY` / `KIMI_API_KEY` |
+| `aws_bedrock` | `AWS_BEARER_TOKEN_BEDROCK` |
+| `ollama` | `OLLAMA_BASE_URL` |
+
+This table mirrors the engine and may lag it โ the source of truth is the engine's provider registry ([`agents/llm_config.py`](https://github.com/CodeBoarding/CodeBoarding/blob/main/agents/llm_config.py)). Any provider it adds that follows the `_API_KEY` convention works here with no action change.
+
+
+
+## When it runs
+
+- **PR opened / reopened / marked ready** โ generated once (per the `on:` triggers above). It does **not** re-run on every push, so you never spend an LLM job per commit; the comment reflects that point until refreshed.
+- **`/codeboarding` comment** โ a trusted collaborator (`OWNER`/`MEMBER`/`COLLABORATOR`) regenerates the diagram against the **current** PR head, even if one already exists. It re-runs and updates the same comment in place (the action reacts with ๐). Change the keyword via `trigger_command`.
+
+The command needs the `issue_comment` trigger and runs from your **default branch** (GitHub's rule), so it only works once the workflow is merged there. On-demand runs on fork PRs are refused, so fork code is never analyzed with your secrets.
+
## Inputs
-| Input | Description | Required | Default |
-|-------|-------------|----------|---------|
-| `repository_url` | Repository URL for which documentation will be generated | Yes | - |
-| `source_branch` | Source branch for comparison (typically the PR branch) | Yes | - |
-| `target_branch` | Target branch for comparison (typically the base branch) | Yes | - |
-| `output_directory` | Directory where documentation files will be saved | No | `docs` |
-| `output_format` | Format for documentation files (either `.md` or `.rst`) | No | `.md` |
+| Input | Default | Description |
+|---|---|---|
+| `llm_api_key` | required | Your LLM provider API key (see `llm_provider`). |
+| `llm_provider` | `openrouter` | Provider for the key โ mapped to `_API_KEY` (e.g. `anthropic`, `openai`, `google`). |
+| `github_token` | `${{ github.token }}` | Token used to post/update the PR comment. |
+| `engine_ref` | `v0.12.0` | CodeBoarding engine ref. Pin for reproducibility. |
+| `depth_level` | `1` | Analysis depth, 1 to 3. Higher is slower and richer. |
+| `render_depth` | `1` | Display depth for the PR diagram. Keep `1` for a clean top-level view. |
+| `diagram_direction` | `LR` | Mermaid direction: `LR`, `TD`, `TB`, `RL`, or `BT`. |
+| `changed_only` | `false` | Render only changed components and incident edges. |
+| `agent_model` | engine default | Analysis model. Bare OpenRouter slug (e.g. `anthropic/claude-sonnet-4`); empty = engine's per-provider default. |
+| `parsing_model` | engine default | Parsing model. Bare OpenRouter slug; empty = engine's per-provider default. |
+| `comment_header` | `Architecture review` | Heading for the PR comment. |
+| `trigger_command` | `/codeboarding` | Slash command for trusted on-demand runs. |
+| `cta_base_url` | empty | Optional click-proxy base URL for editor and extension links. |
## Outputs
| Output | Description |
-|--------|-------------|
-| `markdown_files_created` | Number of documentation files created |
-| `json_files_created` | Number of JSON files created |
-| `output_directory` | Directory where documentation files were saved |
-| `json_directory` | Directory where JSON files were saved (always `.codeboarding`) |
-| `has_changes` | Whether any files were created or changed |
+|---|---|
+| `diagram_md` | Path to the generated Mermaid markdown block on the runner. |
+| `n_changed` | Number of changed components, counted recursively. |
+| `truncated` | `true` when the graph was reduced to fit GitHub Mermaid limits. |
-## How It Works
+## Notes
-The action works by:
+- No checkout step is required in your workflow. This action checks out the target PR and the CodeBoarding engine internally.
+- GitHub withholds secrets from fork PRs on `pull_request`, so fork runs fail early if an LLM key is unavailable.
+- Do not use `pull_request_target` for this action. It can expose secrets to PR-head code.
+- GitHub renders Mermaid in strict mode, so node click-through links are not supported in the PR diagram.
-1. Analyzing the differences introduced in the source branch and putting the results in the target branch
-2. Generating documentation files based on the latest version of the source branch
-3. Outputting two types of files:
- - Documentation files (Markdown or RST) in the specified output directory
- - Metadata files in the `.codeboarding` directory
+## Local Testing
-## License
+Fast path, no LLM calls:
-MIT License - see [LICENSE](LICENSE) file for details.
+```bash
+scripts/run_local.sh --base-json /tmp/base.json --head-json /tmp/head.json
+```
-# CodeBoarding GitHub Action
+Full local pipeline:
-## Important: Timeout Configuration
+```bash
+export OPENROUTER_API_KEY=sk-or-...
+scripts/run_local.sh --repo /path/to/repo --base --head \
+ --engine /path/to/CodeBoarding
+```
-For large repositories, the analysis can take 15-45 minutes. Make sure to configure appropriate timeouts in your workflow:
+Useful flags:
-```yaml
-jobs:
- generate-docs:
- runs-on: ubuntu-latest
- timeout-minutes: 60 # Set to 60+ minutes for large repositories
- steps:
- - uses: actions/checkout@v4
- - uses: your-username/codeboarding-ghaction@v1
- with:
- # your inputs here
+```text
+--depth N
+--render-depth N
+--direction LR|TD|TB|RL|BT
+--changed-only
+--no-edge-labels
+--out DIR
+--no-open
```
-## Timeout Guidelines
-
-- **Small repositories** (<1k files): 10-15 minutes
-- **Medium repositories** (1k-5k files): 20-30 minutes
-- **Large repositories** (5k+ files): 30-60 minutes
-- **Very large repositories** (10k+ files): 45-90 minutes
+## License
-If your workflow consistently times out, consider:
-1. Increasing `timeout-minutes` to 90 or higher
-2. Running the action on a schedule during off-peak hours
-3. Analyzing specific branches with smaller diffs
+MIT. See [LICENSE](LICENSE).
diff --git a/action.yml b/action.yml
index 6f17d1d..24bcf5f 100644
--- a/action.yml
+++ b/action.yml
@@ -1,447 +1,613 @@
-name: 'CodeBoarding [Diagram-First Documentation]'
-description: 'Generates diagram-first visualizations of your codebase using static analysis and large language models.'
+name: 'CodeBoarding Architecture Diff (Mermaid)'
+description: 'Posts a PR comment with a Mermaid architecture diagram showing which components changed (green added / yellow modified / red deleted) โ nodes and arrows.'
author: 'CodeBoarding'
branding:
- icon: 'book-open' # or 'layers', 'git-branch', 'book-open', 'target'
+ icon: 'git-pull-request'
color: 'blue'
inputs:
- output_directory:
- description: 'Directory where documentation files will be saved'
- required: false
- default: 'docs'
- repository_url:
- description: 'Repository URL to fetch documentation for (defaults to current repository)'
- required: true
- source_branch:
- description: 'Source branch for comparison'
- required: true
- target_branch:
- description: 'Target branch for comparison'
+ llm_api_key:
+ description: 'Your LLM provider API key (see llm_provider). Required.'
required: true
- output_format:
- description: 'Output format for documentation files (.md, .mdx, .rst, or .html)'
+ llm_provider:
+ description: 'Provider for llm_api_key. The key is handed to the engine as that provider''s env var (anthropic -> ANTHROPIC_API_KEY, openai -> OPENAI_API_KEY, ...; aws_bedrock -> AWS_BEARER_TOKEN_BEDROCK, ollama -> OLLAMA_BASE_URL) and the engine auto-selects it. Default openrouter.'
+ required: false
+ default: 'openrouter'
+ github_token:
+ description: 'GITHUB_TOKEN used to post the PR comment. Defaults to the workflow token.'
+ required: false
+ default: ${{ github.token }}
+ engine_ref:
+ description: 'Git ref (tag/branch/SHA) of CodeBoarding/CodeBoarding used as the analysis engine. Pinned to a release for reproducibility; override to track a newer ref.'
+ required: false
+ default: 'v0.12.0'
+ depth_level:
+ description: 'Diagram depth (1-3). Higher is slower and more detailed.'
+ required: false
+ default: '1'
+ agent_model:
+ description: 'Analysis model (AGENT_MODEL env var). A bare OpenRouter slug, e.g. anthropic/claude-sonnet-4. Empty (default) uses the engine''s own per-provider default.'
+ required: false
+ default: ''
+ parsing_model:
+ description: 'Parsing model (PARSING_MODEL env var). A bare OpenRouter slug. Empty (default) uses the engine''s own per-provider default.'
+ required: false
+ default: ''
+ comment_header:
+ description: 'Header line used inside the sticky PR comment.'
+ required: false
+ default: 'Architecture review'
+ diagram_direction:
+ description: 'Mermaid layout direction: LR, TD, TB, RL, or BT.'
+ required: false
+ default: 'LR'
+ changed_only:
+ description: 'Render only changed components and their incident edges (also auto-applied when the full graph exceeds GitHub''s Mermaid limit).'
+ required: false
+ default: 'false'
+ render_depth:
+ description: 'Component levels to DRAW in the PR Mermaid (independent of depth_level): 1 = top-level flat (default), 2 = +one nesting level as subgraphs, etc. Lets you analyze deep (depth_level=2) but display a clean level-1 diagram.'
required: false
- default: '.md'
+ default: '1'
+ cta_base_url:
+ description: 'Base URL of the click proxy (e.g. https://go.codeboarding.org). When set, the comment adds "open in VS Code/Cursor" / "get the extension" links with owner/repo/pr appended. Empty disables the CTA.'
+ required: false
+ default: ''
+ trigger_command:
+ description: 'Slash-command that triggers the action from a PR comment (issue_comment event). A comment whose first word is this runs the diagram on-demand.'
+ required: false
+ default: '/codeboarding'
outputs:
- markdown_files_created:
- description: 'Number of Markdown files created'
- value: ${{ steps.process-docs.outputs.markdown_files_created }}
- json_files_created:
- description: 'Number of JSON files created'
- value: ${{ steps.process-docs.outputs.json_files_created }}
- output_directory:
- description: 'Directory where Markdown files were saved'
- value: ${{ steps.process-docs.outputs.output_directory }}
- json_directory:
- description: 'Directory where JSON files were saved (.codeboarding)'
- value: ${{ steps.process-docs.outputs.json_directory }}
- has_changes:
- description: 'Whether any files were created or changed'
- value: ${{ steps.process-docs.outputs.has_changes }}
- repo_url:
- description: 'Repository URL that was analyzed'
- value: ${{ steps.repo-url.outputs.repo_url }}
+ diagram_md:
+ description: 'Path to the rendered ```mermaid block (in the runner workspace).'
+ value: ${{ steps.diagram.outputs.diagram_md }}
+ n_changed:
+ description: 'Number of components added/modified/deleted, counted recursively.'
+ value: ${{ steps.diagram.outputs.n_changed }}
+ truncated:
+ description: 'True if the diagram was reduced to changed-only to fit GitHub''s Mermaid limit.'
+ value: ${{ steps.diagram.outputs.truncated }}
runs:
using: 'composite'
steps:
- - name: Determine repository URL
- id: repo-url
+ - name: Guard โ resolve the target PR
+ id: guard
shell: bash
+ env:
+ GH_TOKEN: ${{ inputs.github_token }}
+ # Read from env, NEVER interpolated into the script โ a comment body is
+ # untrusted input and must not reach the shell as code (injection).
+ COMMENT_BODY: ${{ github.event.comment.body }}
+ AUTHOR_ASSOC: ${{ github.event.comment.author_association }}
+ TRIGGER: ${{ inputs.trigger_command }}
+ EVENT: ${{ github.event_name }}
+ REPOSITORY: ${{ github.repository }}
+ PR_NUMBER_PULL: ${{ github.event.pull_request.number }}
+ PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ PULL_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PULL_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
+ PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }}
run: |
- # Use the provided repository URL if it's not empty
- if [ -n "${{ inputs.repository_url }}" ]; then
- REPO_URL="${{ inputs.repository_url }}"
- echo "Using provided repository URL: $REPO_URL"
- # Otherwise try to determine from git if we're in a git repository
- elif git config --get remote.origin.url > /dev/null 2>&1; then
- REPO_URL=$(git config --get remote.origin.url)
- # Convert SSH URL to HTTPS if needed
- if [[ $REPO_URL == git@* ]]; then
- REPO_URL=$(echo $REPO_URL | sed 's|git@github.com:|https://github.com/|')
- fi
- echo "Using git remote URL: $REPO_URL"
+ set -uo pipefail
+ skip() { echo "::notice::$1 Skipping."; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
+
+ if [ "$EVENT" = "pull_request" ]; then
+ PR_NUMBER="$PR_NUMBER_PULL"
+ BASE_SHA="$PULL_BASE_SHA"
+ HEAD_SHA="$PULL_HEAD_SHA"
+ BASE_REF="$PULL_BASE_REF"
+ BASE_REPO="$PULL_BASE_REPO"
+ HEAD_REPO="$PULL_HEAD_REPO"
+ elif [ "$EVENT" = "pull_request_target" ]; then
+ skip "pull_request_target is not supported because it can expose secrets to PR-head code; use pull_request or trusted issue_comment."
+ elif [ "$EVENT" = "issue_comment" ]; then
+ # On-demand "/codeboarding" command. Must be a PR comment whose first
+ # word is the trigger; the payload lacks SHAs so we query the API.
+ [ -n "$ISSUE_PR_URL" ] || skip "Comment is on a plain issue, not a PR."
+ FIRST_WORD="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR==1{print $1; exit}')"
+ [ "$FIRST_WORD" = "$TRIGGER" ] || skip "Comment does not start with '$TRIGGER'."
+ # SECURITY (pwn-request guard): issue_comment runs in the base repo WITH
+ # secrets for ANY commenter. Only a trusted collaborator may trigger an
+ # analysis that checks out + runs over PR-head code with the LLM key present.
+ case "$AUTHOR_ASSOC" in
+ OWNER|MEMBER|COLLABORATOR) : ;;
+ *) skip "Commenter is '$AUTHOR_ASSOC' (not OWNER/MEMBER/COLLABORATOR)." ;;
+ esac
+ PR_NUMBER="$ISSUE_NUMBER"
+ PR_JSON="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" 2>/dev/null)" || skip "Could not fetch PR #$PR_NUMBER from the API."
+ BASE_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["sha"])' 2>/dev/null)" || skip "Could not parse base SHA from the PR API."
+ HEAD_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["sha"])' 2>/dev/null)" || skip "Could not parse head SHA from the PR API."
+ BASE_REF="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["ref"])' 2>/dev/null)" || BASE_REF=""
+ BASE_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse base repo from the PR API."
+ HEAD_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse head repo from the PR API."
+ [ "$HEAD_REPO" = "$REPOSITORY" ] || skip "On-demand runs with secrets are disabled for fork PRs."
else
- REPO_URL="${{ github.server_url }}/${{ github.repository }}"
- echo "Using GitHub context URL: $REPO_URL"
+ skip "Unsupported event '$EVENT' (use pull_request or issue_comment)."
fi
- echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
- - name: Create and poll documentation job
- id: fetch-docs
+ { [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] && [ -n "$BASE_REPO" ] && [ -n "$HEAD_REPO" ]; } || skip "Could not resolve PR/base/head SHAs/repos."
+ {
+ echo "skip=false"
+ echo "pr_number=$PR_NUMBER"
+ echo "base_sha=$BASE_SHA"
+ echo "head_sha=$HEAD_SHA"
+ echo "base_ref=$BASE_REF"
+ echo "base_repo=$BASE_REPO"
+ echo "head_repo=$HEAD_REPO"
+ } >> "$GITHUB_OUTPUT"
+ echo "Resolved PR #$PR_NUMBER (base=$BASE_REPO@$BASE_SHA head=$HEAD_REPO@$HEAD_SHA) via $EVENT"
+
+ - name: Acknowledge command
+ if: steps.guard.outputs.skip != 'true' && github.event_name == 'issue_comment'
shell: bash
+ env:
+ GH_TOKEN: ${{ inputs.github_token }}
+ REPOSITORY: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
run: |
- CREATE_JOB_URL="https://server.codeboarding.org/github_action/jobs"
- REPO_URL="${{ steps.repo-url.outputs.repo_url }}"
- SOURCE_BRANCH="${{ inputs.source_branch }}"
- TARGET_BRANCH="${{ inputs.target_branch }}"
- OUTPUT_DIRECTORY="${{ inputs.output_directory }}"
- OUTPUT_FORMAT="${{ inputs.output_format }}"
-
- echo "๐ Creating CodeBoarding analysis job...$CREATE_JOB_URL"
- echo "๐ Repository: $REPO_URL"
- echo "๐ฟ Source branch: $SOURCE_BRANCH"
- echo "๐ฏ Target branch: $TARGET_BRANCH"
- echo "๐ Output format: $OUTPUT_FORMAT"
-
- # Create JSON payload
- JSON_PAYLOAD=$(jq -n \
- --arg url "$REPO_URL" \
- --arg source_branch "$SOURCE_BRANCH" \
- --arg target_branch "$TARGET_BRANCH" \
- --arg output_directory "$OUTPUT_DIRECTORY" \
- --arg extension "$OUTPUT_FORMAT" \
- '{
- url: $url,
- source_branch: $source_branch,
- target_branch: $target_branch,
- output_directory: $output_directory,
- extension: $extension
- }')
-
- echo "๐ Request payload:"
- echo "$JSON_PAYLOAD"
-
- # Create temporary file for response
- TEMP_FILE=$(mktemp)
-
- echo "๐ Making API request to create job..."
-
- # Make the API call to create job
- response=$(curl -s -w "%{http_code}" -o "$TEMP_FILE" \
- -X POST \
- -H "Content-Type: application/json" \
- -d "$JSON_PAYLOAD" \
- --max-time 60 \
- --connect-timeout 30 \
- "$CREATE_JOB_URL")
- curl_exit_code=$?
-
- http_code=${response: -3}
-
- echo "โ
Job creation request completed!"
- echo "๐ Response status code: $http_code"
- echo "๐ง Curl exit code: $curl_exit_code"
-
- # Handle curl errors
- if [ $curl_exit_code -ne 0 ]; then
- echo "โ Error: Curl failed with exit code $curl_exit_code"
- case $curl_exit_code in
- 6) echo "๐ Couldn't resolve host - check network connectivity" ;;
- 7) echo "๐ Failed to connect to host - server might be down" ;;
- 28) echo "โฐ Request timed out - server might be busy" ;;
- *) echo "โ Unknown curl error - check network and server status" ;;
- esac
- rm -f "$TEMP_FILE"
+ # ๐ react to the triggering comment so the user knows it was picked up.
+ gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content=eyes >/dev/null 2>&1 || true
+
+ - name: Checkout CodeBoarding engine
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: CodeBoarding/CodeBoarding
+ ref: ${{ inputs.engine_ref }}
+ path: codeboarding-engine
+ persist-credentials: false
+
+ - name: Checkout target repository (PR head)
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ steps.guard.outputs.head_repo }}
+ path: target-repo
+ fetch-depth: 0
+ ref: ${{ steps.guard.outputs.head_sha }}
+ persist-credentials: false
+
+ - name: Ensure PR base commit is fetched
+ if: steps.guard.outputs.skip != 'true'
+ shell: bash
+ working-directory: target-repo
+ env:
+ BASE_SHA: ${{ steps.guard.outputs.base_sha }}
+ BASE_REPO: ${{ steps.guard.outputs.base_repo }}
+ run: |
+ git fetch origin "$BASE_SHA" --depth=1 || true
+ if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then
+ git remote add base "https://github.com/${BASE_REPO}.git" 2>/dev/null || git remote set-url base "https://github.com/${BASE_REPO}.git"
+ git fetch base "$BASE_SHA" --depth=1 || true
+ fi
+ git cat-file -e "$BASE_SHA" && echo "Base commit reachable." || \
+ (echo "::error::Base commit $BASE_SHA is not reachable." && exit 1)
+
+ - name: Set up Python 3.13
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+
+ - name: Set up Node.js 20
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install uv
+ if: steps.guard.outputs.skip != 'true'
+ uses: astral-sh/setup-uv@v4
+ with:
+ enable-cache: true # cache ~/.cache/uv (wheels/builds) for fast cold installs
+
+ - name: Cache uv venv (engine)
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/cache@v4
+ with:
+ path: codeboarding-engine/.venv
+ # No restore-keys: a lockfile change must force a clean cold venv, not
+ # restore a venv built from a different lock (`uv pip install -e .` won't
+ # downgrade/sync already-installed transitive deps back to this uv.lock).
+ key: cb-uv-${{ runner.os }}-${{ hashFiles('codeboarding-engine/pyproject.toml', 'codeboarding-engine/uv.lock') }}
+
+ - name: Cache LSP servers
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/cache@v4
+ with:
+ path: |
+ codeboarding-engine/static_analyzer/servers/node_modules
+ codeboarding-engine/static_analyzer/servers/bin
+ key: cb-lsp-${{ runner.os }}-v1
+ restore-keys: |
+ cb-lsp-${{ runner.os }}-
+
+ - name: Install Python dependencies
+ if: steps.guard.outputs.skip != 'true'
+ shell: bash
+ working-directory: codeboarding-engine
+ run: |
+ test -d .venv || uv venv # reuse the cached venv instead of wiping it (--clear defeated the cache)
+ uv pip install -e .
+
+ - name: Install LSP servers
+ if: steps.guard.outputs.skip != 'true'
+ shell: bash
+ working-directory: codeboarding-engine
+ run: |
+ uv run python install.py --auto-install-npm
+
+ - name: Prepare & verify LLM key
+ if: steps.guard.outputs.skip != 'true'
+ shell: bash
+ env:
+ RAW_KEY: ${{ inputs.llm_api_key }}
+ RAW_PROVIDER: ${{ inputs.llm_provider }}
+ RAW_AGENT_MODEL: ${{ inputs.agent_model }}
+ RAW_PARSING_MODEL: ${{ inputs.parsing_model }}
+ run: |
+ AUTH_FILE="${RUNNER_TEMP}/openrouter-auth.json"
+ trap 'rm -f "$AUTH_FILE"' EXIT
+ if [ -z "$RAW_KEY" ]; then
+ echo "::error::llm_api_key is empty. On fork PRs, repo secrets are withheld by GitHub."
exit 1
fi
-
- if [ "$http_code" != "202" ]; then
- echo "โ Error: Job creation failed with status code $http_code"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
-
- # Try to parse as JSON for better error message
- if jq -e '.detail' "$TEMP_FILE" > /dev/null 2>&1; then
- echo "๐ Error details: $(jq -r '.detail' "$TEMP_FILE")"
+ # Resolve the provider -> the env var the engine reads. Convention is
+ # _API_KEY; two providers don't follow it. The engine is the source
+ # of truth: an unknown provider just yields an env var it won't recognize,
+ # and the engine errors with the list of valid keys.
+ PROVIDER="$(printf '%s' "$RAW_PROVIDER" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_')"
+ PROVIDER="${PROVIDER:-openrouter}"
+ case "$PROVIDER" in
+ aws_bedrock) PROVIDER_ENV="AWS_BEARER_TOKEN_BEDROCK" ;;
+ ollama) PROVIDER_ENV="OLLAMA_BASE_URL" ;;
+ *) PROVIDER_ENV="$(printf '%s' "$PROVIDER" | tr '[:lower:]' '[:upper:]')_API_KEY" ;;
+ esac
+
+ # Normalize a pasted key: strip whitespace/quotes and a leading `=`.
+ _strip() { printf '%s' "$1" | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'\$//"; }
+ KEY="$(_strip "$RAW_KEY")"
+ case "$KEY" in "${PROVIDER_ENV}="*) KEY="${KEY#${PROVIDER_ENV}=}";; esac
+ KEY="$(_strip "$KEY")"
+ AGENT_MODEL="$(_strip "$RAW_AGENT_MODEL")"
+ PARSING_MODEL="$(_strip "$RAW_PARSING_MODEL")"
+ echo "::add-mask::$KEY"
+ echo "Provider: $PROVIDER -> $PROVIDER_ENV; key length: ${#KEY}"
+
+ if [ "$PROVIDER" = "openrouter" ]; then
+ # OpenRouter-only checks. The litellm 'openrouter/...' model prefix 400s
+ # the engine's native OpenRouter call; other providers use native ids.
+ for M in "$AGENT_MODEL" "$PARSING_MODEL"; do
+ case "$M" in
+ openrouter/*)
+ echo "::error::Invalid model '$M': drop the 'openrouter/' prefix and use a bare OpenRouter slug, e.g. anthropic/claude-sonnet-4."
+ exit 1 ;;
+ esac
+ done
+ # Cheap preflight; other providers are validated by the engine at run time.
+ STATUS=$(curl -sS -o "$AUTH_FILE" -w "%{http_code}" \
+ -H "Authorization: Bearer $KEY" --max-time 10 \
+ https://openrouter.ai/api/v1/auth/key || echo "curl-fail")
+ echo "OpenRouter /auth/key response: HTTP $STATUS"
+ if [ "$STATUS" != "200" ]; then
+ # Surface the upstream error MESSAGE only โ never the whole auth body (avoid leaking).
+ MSG="$(AUTH_FILE="$AUTH_FILE" python3 -c 'import json,os;print(json.load(open(os.environ["AUTH_FILE"])).get("error",{}).get("message",""))' 2>/dev/null || true)"
+ echo "::error::OpenRouter rejected the API key (HTTP $STATUS). ${MSG:-Verify the OPENROUTER_API_KEY secret.}"
+ exit 1
fi
-
- rm -f "$TEMP_FILE"
- exit 1
fi
-
- # Check if response is valid JSON
- if ! jq empty "$TEMP_FILE" 2>/dev/null; then
- echo "โ Error: Invalid JSON response"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
- rm -f "$TEMP_FILE"
- exit 1
+
+ # Store key material in runner-temp files. Later shell steps read these
+ # explicitly; third-party post-comment actions do not inherit the LLM key.
+ umask 077
+ printf '%s' "$KEY" > "${RUNNER_TEMP}/cb-llm-key"
+ printf '%s' "$PROVIDER_ENV" > "${RUNNER_TEMP}/cb-provider-env"
+ printf '%s' "$AGENT_MODEL" > "${RUNNER_TEMP}/cb-agent-model"
+ printf '%s' "$PARSING_MODEL" > "${RUNNER_TEMP}/cb-parsing-model"
+
+ - name: Resolve base analysis (committed baseline)
+ if: steps.guard.outputs.skip != 'true'
+ id: base
+ shell: bash
+ working-directory: target-repo
+ env:
+ BASE_SHA: ${{ steps.guard.outputs.base_sha }}
+ run: |
+ BASE_DIR="${RUNNER_TEMP}/cb-base"
+ HEAD_DIR="${RUNNER_TEMP}/cb-head"
+ mkdir -p "$BASE_DIR" "$HEAD_DIR"
+ echo "base_dir=$BASE_DIR" >> $GITHUB_OUTPUT
+ echo "head_dir=$HEAD_DIR" >> $GITHUB_OUTPUT
+ if git show "${BASE_SHA}:.codeboarding/analysis.json" > "${BASE_DIR}/analysis.json" 2>/dev/null; then
+ echo "committed=true" >> $GITHUB_OUTPUT
+ echo "Using committed .codeboarding/analysis.json at ${BASE_SHA}."
+ else
+ rm -f "${BASE_DIR}/analysis.json"
+ echo "committed=false" >> $GITHUB_OUTPUT
+ echo "No committed baseline at ${BASE_SHA}; will generate one via a full analysis on the base commit."
fi
-
- # Extract job_id from response
- JOB_ID=$(jq -r '.job_id' "$TEMP_FILE")
-
- if [ "$JOB_ID" = "null" ] || [ -z "$JOB_ID" ]; then
- echo "โ Error: No job_id found in response"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
- rm -f "$TEMP_FILE"
+
+ - name: Restore base artifacts (keyed by base SHA)
+ if: steps.guard.outputs.skip != 'true'
+ id: basecache
+ uses: actions/cache/restore@v4
+ with:
+ path: ${{ runner.temp }}/cb-base
+ key: cb-base-${{ runner.os }}-${{ steps.guard.outputs.base_sha }}-d${{ inputs.depth_level }}-${{ inputs.engine_ref }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }}
+
+ - name: Generate base analysis (no committed baseline)
+ if: steps.guard.outputs.skip != 'true' && steps.base.outputs.committed == 'false' && steps.basecache.outputs.cache-hit != 'true'
+ shell: bash
+ working-directory: codeboarding-engine
+ env:
+ STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml
+ PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine
+ DIAGRAM_DEPTH_LEVEL: ${{ inputs.depth_level }}
+ CACHING_DOCUMENTATION: 'false'
+ ENABLE_MONITORING: 'false'
+ ACTION_PATH: ${{ github.action_path }}
+ TARGET: ${{ github.workspace }}/target-repo
+ BASE_DIR: ${{ steps.base.outputs.base_dir }}
+ REPO_NAME: ${{ github.event.repository.name }}
+ RUN_ID_BASE: ${{ github.run_id }}-${{ github.run_attempt }}-base
+ DEPTH: ${{ inputs.depth_level }}
+ BASE_SHA: ${{ steps.guard.outputs.base_sha }}
+ run: |
+ # Export the key under the selected provider's env var (only this one),
+ # so the engine auto-selects that provider.
+ PROVIDER_ENV="$(cat "${RUNNER_TEMP}/cb-provider-env")"
+ export "$PROVIDER_ENV"="$(cat "${RUNNER_TEMP}/cb-llm-key")"
+ # Export the model env only when the user set it; empty -> the engine uses
+ # its own valid per-provider default (no stale hardcoded model id to rot).
+ AGENT_MODEL="$(cat "${RUNNER_TEMP}/cb-agent-model")"
+ PARSING_MODEL="$(cat "${RUNNER_TEMP}/cb-parsing-model")"
+ if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi
+ if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi
+
+ BASE_SRC="${RUNNER_TEMP}/base-src"
+ # Clean up any stale registration before re-adding (rm -rf alone leaves a
+ # dangling worktree entry that makes a retry's `worktree add` fail).
+ git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true
+ git -C "$TARGET" worktree prune
+ rm -rf "$BASE_SRC"
+ git -C "$TARGET" worktree add --detach "$BASE_SRC" "$BASE_SHA"
+ uv run python "$ACTION_PATH/scripts/cb_engine.py" base \
+ --repo "$BASE_SRC" \
+ --out "$BASE_DIR" \
+ --name "$REPO_NAME" \
+ --run-id "$RUN_ID_BASE" \
+ --depth "$DEPTH" \
+ --source-sha "$BASE_SHA"
+ git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true
+ if [ ! -f "$BASE_DIR/analysis.json" ]; then
+ echo "::error::Base full analysis ran but analysis.json is missing."
exit 1
fi
-
- echo "โ
Job created successfully!"
- echo "๐ Job ID: $JOB_ID"
-
- # Start polling job status
- STATUS_URL="https://server.codeboarding.org/github_action/jobs/$JOB_ID"
-
- echo "๐ Starting job status polling..."
- echo "โฐ This may take 15-45 minutes for large repositories..."
- echo "๐ก If your workflow times out, increase 'timeout-minutes' in your job configuration"
-
- # Polling loop
- POLL_COUNT=0
- MAX_POLLS=90 # 90 minutes max (90 * 1 minute intervals)
-
- while [ $POLL_COUNT -lt $MAX_POLLS ]; do
- POLL_COUNT=$((POLL_COUNT + 1))
-
- echo "๐ Polling attempt $POLL_COUNT of $MAX_POLLS ($(date '+%H:%M:%S'))"
-
- # Make status check API call
- response=$(curl -s -w "%{http_code}" -o "$TEMP_FILE" \
- --max-time 30 \
- --connect-timeout 10 \
- "$STATUS_URL")
-
- curl_exit_code=$?
- http_code=${response: -3}
-
- # Handle curl errors
- if [ $curl_exit_code -ne 0 ]; then
- echo "โ ๏ธ Warning: Status check failed with curl exit code $curl_exit_code"
- echo "๐ Retrying in 30 seconds..."
- sleep 30
- continue
- fi
-
- if [ "$http_code" != "200" ]; then
- echo "โ ๏ธ Warning: Status check failed with HTTP code $http_code"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
- echo "๐ Retrying in 30 seconds..."
- sleep 30
- continue
- fi
-
- # Check if response is valid JSON
- if ! jq empty "$TEMP_FILE" 2>/dev/null; then
- echo "โ ๏ธ Warning: Invalid JSON response"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
- echo "๐ Retrying in 30 seconds..."
- sleep 30
- continue
- fi
-
- # Extract status from response
- STATUS=$(jq -r '.status' "$TEMP_FILE")
-
- echo "๐ Current job status: $STATUS"
-
- if [ "$STATUS" = "COMPLETED" ]; then
- echo "โ
Job completed successfully!"
-
- # Check if result field exists and contains files
- if jq -e '.result' "$TEMP_FILE" > /dev/null; then
- echo "๐ฆ Result field found, preparing output..."
-
- # Check if result is a JSON string or already a JSON object
- RESULT_TYPE=$(jq -r '.result | type' "$TEMP_FILE")
-
- if [ "$RESULT_TYPE" = "string" ]; then
- echo "๐ง Result is a JSON string, parsing it..."
- # Parse the JSON string in the result field
- jq -r '.result' "$TEMP_FILE" | jq '.' > "${TEMP_FILE}_result"
- else
- echo "๐ง Result is already a JSON object, extracting it..."
- # Extract the result object directly
- jq '.result' "$TEMP_FILE" > "${TEMP_FILE}_result"
- fi
-
- # Verify the extracted result
- if jq -e '.files' "${TEMP_FILE}_result" > /dev/null; then
- echo "โ
Files extracted successfully"
- mv "${TEMP_FILE}_result" "$TEMP_FILE"
- echo "response_file=$TEMP_FILE" >> $GITHUB_OUTPUT
- exit 0 # Successfully extracted files, exit with success
- else
- echo "โ Error: Extracted result is missing files structure"
- echo "๐ Extracted content:"
- cat "${TEMP_FILE}_result"
- rm -f "${TEMP_FILE}_result" "$TEMP_FILE"
- exit 1
- fi
- else
- echo "โ Error: Job completed but no result or result.files found in response"
- echo "๐ Response structure:"
- jq '.' "$TEMP_FILE"
-
- # If result exists, show what it contains
- if jq -e '.result' "$TEMP_FILE" > /dev/null; then
- echo "๐ Result field content:"
- RESULT_TYPE=$(jq -r '.result | type' "$TEMP_FILE")
- echo "Result type: $RESULT_TYPE"
-
- if [ "$RESULT_TYPE" = "string" ]; then
- echo "Result string content:"
- jq -r '.result' "$TEMP_FILE"
- else
- echo "Result object content:"
- jq '.result' "$TEMP_FILE"
- fi
- fi
-
- rm -f "$TEMP_FILE"
- exit 1
- fi
- elif [ "$STATUS" = "FAILED" ] || [ "$STATUS" = "ERROR" ]; then
- echo "โ Job failed with status: $STATUS"
- echo "๐ Response content:"
- cat "$TEMP_FILE"
- rm -f "$TEMP_FILE"
- exit 1
- else
- # Job still in progress
- echo "โณ Job in progress (status: $STATUS)..."
-
- # Show additional progress information if available
- if jq -e '.updated_at' "$TEMP_FILE" > /dev/null; then
- UPDATED_AT=$(jq -r '.updated_at' "$TEMP_FILE")
- echo "๐ Last updated: $UPDATED_AT"
- fi
-
- echo "๐ค Waiting 15 seconds before next check..."
- sleep 15
- fi
- done
-
- # Only reach here if we've exceeded max polls without completion
- echo "โ Error: Job polling timed out after $MAX_POLLS attempts"
- echo "๐๏ธ The repository analysis is taking longer than expected."
- echo "๐ This might be due to:"
- echo " โข Very large repository size (>10k files)"
- echo " โข Complex codebase requiring extensive analysis"
- echo " โข Server load or processing delays"
- echo ""
- echo "๐ก Suggestions:"
- echo " โข Try again later when server load might be lower"
- echo " โข Consider analyzing smaller branches or specific directories"
- echo " โข Increase your GitHub Actions job timeout-minutes to 120+"
- echo " โข Contact support if the issue persists"
-
- rm -f "$TEMP_FILE"
- exit 1
-
- - name: Process documentation files
- id: process-docs
+
+ - name: Save generated base artifacts
+ if: steps.guard.outputs.skip != 'true' && steps.base.outputs.committed == 'false' && steps.basecache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v4
+ with:
+ path: ${{ runner.temp }}/cb-base
+ key: cb-base-${{ runner.os }}-${{ steps.guard.outputs.base_sha }}-d${{ inputs.depth_level }}-${{ inputs.engine_ref }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }}
+
+ - name: Analyze PR head (incremental from base)
+ if: steps.guard.outputs.skip != 'true'
+ id: analyze
shell: bash
+ working-directory: codeboarding-engine
+ env:
+ STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml
+ PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine
+ DIAGRAM_DEPTH_LEVEL: ${{ inputs.depth_level }}
+ CACHING_DOCUMENTATION: 'false'
+ ENABLE_MONITORING: 'false'
+ ACTION_PATH: ${{ github.action_path }}
+ TARGET_REPO: ${{ github.workspace }}/target-repo
+ BASE_DIR: ${{ steps.base.outputs.base_dir }}
+ HEAD_DIR: ${{ steps.base.outputs.head_dir }}
+ REPO_NAME: ${{ github.event.repository.name }}
+ RUN_ID_HEAD: ${{ github.run_id }}-${{ github.run_attempt }}-head
+ DEPTH: ${{ inputs.depth_level }}
+ BASE_SHA: ${{ steps.guard.outputs.base_sha }}
+ HEAD_SHA: ${{ steps.guard.outputs.head_sha }}
run: |
- RESPONSE_FILE="${{ steps.fetch-docs.outputs.response_file }}"
- MD_OUTPUT_DIR="${{ inputs.output_directory }}"
- JSON_OUTPUT_DIR=".codeboarding"
- OUTPUT_FORMAT="${{ inputs.output_format }}"
-
- # Validate output format
- if [[ "$OUTPUT_FORMAT" != ".md" && "$OUTPUT_FORMAT" != ".mdx" && "$OUTPUT_FORMAT" != ".rst" && "$OUTPUT_FORMAT" != ".html" ]]; then
- echo "Error: Invalid output format '$OUTPUT_FORMAT'. Must be either '.md', '.mdx', '.rst', or '.html'"
+ # Export the key under the selected provider's env var (only this one),
+ # so the engine auto-selects that provider.
+ PROVIDER_ENV="$(cat "${RUNNER_TEMP}/cb-provider-env")"
+ export "$PROVIDER_ENV"="$(cat "${RUNNER_TEMP}/cb-llm-key")"
+ # Export the model env only when the user set it; empty -> the engine uses
+ # its own valid per-provider default (no stale hardcoded model id to rot).
+ AGENT_MODEL="$(cat "${RUNNER_TEMP}/cb-agent-model")"
+ PARSING_MODEL="$(cat "${RUNNER_TEMP}/cb-parsing-model")"
+ if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi
+ if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi
+
+ # Seed the head dir from the base analysis so incremental stitches
+ # component ids from the baseline (stable diff). Base dir is left
+ # untouched as the "before" snapshot for the diff.
+ cp -a "$BASE_DIR"/. "$HEAD_DIR"/ 2>/dev/null || true
+ rm -rf "$HEAD_DIR/health"
+ uv run python "$ACTION_PATH/scripts/cb_engine.py" head \
+ --repo "$TARGET_REPO" \
+ --out "$HEAD_DIR" \
+ --name "$REPO_NAME" \
+ --run-id "$RUN_ID_HEAD" \
+ --depth "$DEPTH" \
+ --base-ref "$BASE_SHA" \
+ --target-ref "$HEAD_SHA" \
+ --source-sha "$HEAD_SHA"
+ if [ ! -f "$HEAD_DIR/analysis.json" ]; then
+ echo "::error::Head analysis ran but analysis.json is missing."
exit 1
fi
-
- # Clean and create the output directories
- mkdir -p "$MD_OUTPUT_DIR"
-
- # Remove existing .codeboarding files before adding new ones
- if [ -d "$JSON_OUTPUT_DIR" ]; then
- echo "Cleaning existing JSON files from $JSON_OUTPUT_DIR"
- rm -rf "$JSON_OUTPUT_DIR"
- fi
- mkdir -p "$JSON_OUTPUT_DIR"
-
- # Initialize counters
- MARKDOWN_FILES_CREATED=0
- JSON_FILES_CREATED=0
-
- echo "=== Processing Documentation Files ==="
- echo "Response JSON structure:"
- jq . "$RESPONSE_FILE"
- echo "Using output format: $OUTPUT_FORMAT"
- # Parse JSON response and create files using keys as filenames
- if jq -e '.files' "$RESPONSE_FILE" > /dev/null; then
- echo "Files key found, proceeding to create files..."
-
- # Check if files object is empty
- FILES_COUNT=$(jq '.files | length' "$RESPONSE_FILE")
- if [ "$FILES_COUNT" -eq 0 ]; then
- echo "โน๏ธ No documentation files were generated for this repository/branch combination."
- echo "๐ This might be because:"
- echo " โข No changes were detected between the source and target branches"
- echo " โข The repository or branches don't exist or are not accessible"
- echo " โข No analyzable code files were found"
- echo " โข The branches are identical (no diff to analyze)"
+ echo "base_analysis=$BASE_DIR/analysis.json" >> $GITHUB_OUTPUT
+ echo "head_analysis=$HEAD_DIR/analysis.json" >> $GITHUB_OUTPUT
+
+ - name: Architecture health check (best-effort)
+ if: steps.guard.outputs.skip != 'true'
+ id: health
+ continue-on-error: true
+ shell: bash
+ working-directory: codeboarding-engine
+ env:
+ STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml
+ PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine
+ ACTION_PATH: ${{ github.action_path }}
+ ARTIFACT_DIR: ${{ steps.base.outputs.head_dir }}
+ TARGET_REPO: ${{ github.workspace }}/target-repo
+ REPO_NAME: ${{ github.event.repository.name }}
+ run: |
+ rm -f /tmp/cb-issues.txt
+ # cb_engine writes the WARNING/CRITICAL count (0 on any failure โ best-effort).
+ uv run python "$ACTION_PATH/scripts/cb_engine.py" health \
+ --artifact-dir "$ARTIFACT_DIR" \
+ --repo "$TARGET_REPO" \
+ --name "$REPO_NAME" \
+ --issues-out /tmp/cb-issues.txt || true
+ N=$(cat /tmp/cb-issues.txt 2>/dev/null || echo 0)
+ echo "issues=$N" >> $GITHUB_OUTPUT
+ echo "Architecture issues: $N"
+
+ - name: Drop LLM key material
+ if: always() && steps.guard.outputs.skip != 'true'
+ shell: bash
+ run: |
+ rm -f "${RUNNER_TEMP}/cb-llm-key" \
+ "${RUNNER_TEMP}/cb-provider-env" \
+ "${RUNNER_TEMP}/cb-agent-model" \
+ "${RUNNER_TEMP}/cb-parsing-model"
+
+ - name: Diff analyses โ Mermaid
+ if: steps.guard.outputs.skip != 'true'
+ id: diagram
+ shell: bash
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ BASE_ANALYSIS: ${{ steps.analyze.outputs.base_analysis }}
+ HEAD_ANALYSIS: ${{ steps.analyze.outputs.head_analysis }}
+ DIRECTION: ${{ inputs.diagram_direction }}
+ RENDER_DEPTH: ${{ inputs.render_depth }}
+ CHANGED_ONLY: ${{ inputs.changed_only }}
+ run: |
+ case "$CHANGED_ONLY" in
+ true|false) ;;
+ *) echo "::error::changed_only must be 'true' or 'false'."; exit 1 ;;
+ esac
+ case "$RENDER_DEPTH" in
+ ''|*[!0-9]*) echo "::error::render_depth must be a positive integer."; exit 1 ;;
+ esac
+
+ args=(
+ --base "$BASE_ANALYSIS"
+ --head "$HEAD_ANALYSIS"
+ --out "${RUNNER_TEMP}/diagram.md"
+ --direction "$DIRECTION"
+ --render-depth "$RENDER_DEPTH"
+ )
+ [ "$CHANGED_ONLY" = "true" ] && args+=(--changed-only)
+ META=$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" "${args[@]}")
+ echo "$META" > "${RUNNER_TEMP}/diagram_meta.json"
+ echo "diff meta: $META"
+ read N CHANGED RENDERED TRUNC < <(python3 -c "import json;d=json.load(open('${RUNNER_TEMP}/diagram_meta.json'));print(d['n_changed'], str(d.get('changed', d['n_changed']>0)).lower(), str(d['rendered']).lower(), str(d['truncated']).lower())")
+ echo "n_changed=$N" >> $GITHUB_OUTPUT
+ echo "changed=$CHANGED" >> $GITHUB_OUTPUT
+ echo "rendered=$RENDERED" >> $GITHUB_OUTPUT
+ echo "truncated=$TRUNC" >> $GITHUB_OUTPUT
+ echo "diagram_md=${RUNNER_TEMP}/diagram.md" >> $GITHUB_OUTPUT
+
+ - name: Build PR comment body
+ if: steps.guard.outputs.skip != 'true'
+ id: body
+ shell: bash
+ env:
+ # Pass event/input-derived strings as DATA (not interpolated into the script).
+ HEADER: ${{ inputs.comment_header }}
+ BASE_REF: ${{ steps.guard.outputs.base_ref }}
+ CTA_BASE: ${{ inputs.cta_base_url }}
+ OWNER_REPO: ${{ github.repository }}
+ ACTION_PATH: ${{ github.action_path }}
+ TARGET_REPO: ${{ github.workspace }}/target-repo
+ DIAGRAM_MD: ${{ steps.diagram.outputs.diagram_md }}
+ RUN_ID: ${{ github.run_id }}
+ N: ${{ steps.diagram.outputs.n_changed }}
+ CHANGED: ${{ steps.diagram.outputs.changed }}
+ RENDERED: ${{ steps.diagram.outputs.rendered }}
+ TRUNC: ${{ steps.diagram.outputs.truncated }}
+ PR: ${{ steps.guard.outputs.pr_number }}
+ ISSUES: ${{ steps.health.outputs.issues }}
+ run: |
+ BODY_FILE=$(mktemp)
+ OWNER="${OWNER_REPO%%/*}"; REPO="${OWNER_REPO##*/}"
+
+ headline() {
+ if [ "$CHANGED" != "true" ]; then echo "no architectural changes";
+ elif [ "$N" = "1" ]; then echo "1 component changed";
+ elif [ "$N" = "0" ]; then echo "architecture updated";
+ else echo "$N components changed"; fi
+ }
+
+ # CTA footer (editor + extension links via the click proxy, warning banner
+ # on real health findings). build_cta also emits the โ ๏ธ banner with no proxy.
+ cta() {
+ python3 "$ACTION_PATH/scripts/build_cta.py" \
+ --cta-base "$CTA_BASE" --owner "$OWNER" --repo "$REPO" --pr "$PR" \
+ --repo-path "$TARGET_REPO" --issues "${ISSUES:-0}"
+ }
+
+ {
+ echo "### ${HEADER} ยท $(headline)"
+ echo ""
+ if [ "$RENDERED" = "true" ]; then
+ cat "$DIAGRAM_MD"
+ echo ""
+ echo ""
+ echo "Colors indicate component changes compared to \`${BASE_REF}\`: ๐ฉ Added ยท ๐จ Modified ยท ๐ฅ Removed"
+ if [ "$TRUNC" = "true" ]; then
+ echo ""
+ echo "Showing changed components only โ the full graph exceeds GitHub's inline Mermaid limit."
+ fi
+ elif [ "$CHANGED" = "true" ]; then
+ echo "Architecture changed versus \`${BASE_REF}\`, but the diagram is too large to render inline (GitHub caps inline Mermaid at ~500 edges)."
else
- # Get each key from files object and create a file with that name
- while IFS= read -r filename; do
- echo "Processing file: $filename"
-
- # Get the content for this filename
- content=$(jq -r ".files[\"$filename\"]" "$RESPONSE_FILE")
-
- # Determine file type and destination
- if [[ "$filename" == *.json ]]; then
- # JSON file
- output_dir="$JSON_OUTPUT_DIR"
- output_filename="$filename"
- echo "$content" > "$output_dir/$output_filename"
- echo "Created JSON file: $output_dir/$output_filename"
- JSON_FILES_CREATED=$((JSON_FILES_CREATED + 1))
- else
- # Documentation file - add appropriate extension if not present
- output_dir="$MD_OUTPUT_DIR"
-
- # Check if filename has an extension
- if [[ "$filename" == *.* ]]; then
- # Extract basename without extension
- basename="${filename%.*}"
- else
- basename="$filename"
- fi
-
- # Add the selected output format extension
- output_filename="${basename}${OUTPUT_FORMAT}"
-
- echo "$content" > "$output_dir/$output_filename"
- echo "Created documentation file: $output_dir/$output_filename"
- MARKDOWN_FILES_CREATED=$((MARKDOWN_FILES_CREATED + 1))
- fi
- done < <(jq -r '.files | keys[]' "$RESPONSE_FILE")
+ echo "No architectural changes detected versus \`${BASE_REF}\`."
fi
- else
- echo "No 'files' key found in response JSON - checking if job completed with no results"
- fi
-
- # Clean up temporary file
- rm -f "$RESPONSE_FILE"
-
- # Check if any files were created
- TOTAL_FILES=$((MARKDOWN_FILES_CREATED + JSON_FILES_CREATED))
- if [ "$TOTAL_FILES" -gt 0 ]; then
- HAS_CHANGES="true"
- echo "Created $MARKDOWN_FILES_CREATED Markdown files in $MD_OUTPUT_DIR"
- echo "Created $JSON_FILES_CREATED JSON files in $JSON_OUTPUT_DIR"
-
- # List created files
- if [ "$MARKDOWN_FILES_CREATED" -gt 0 ]; then
- echo "Markdown files created:"
- ls -la "$MD_OUTPUT_DIR"
- fi
-
- if [ "$JSON_FILES_CREATED" -gt 0 ]; then
- echo "JSON files created:"
- ls -la "$JSON_OUTPUT_DIR"
- fi
- else
- HAS_CHANGES="false"
- echo "No files were created"
- fi
-
- # Set outputs
- echo "markdown_files_created=$MARKDOWN_FILES_CREATED" >> $GITHUB_OUTPUT
- echo "json_files_created=$JSON_FILES_CREATED" >> $GITHUB_OUTPUT
- echo "output_directory=$MD_OUTPUT_DIR" >> $GITHUB_OUTPUT
- echo "json_directory=$JSON_OUTPUT_DIR" >> $GITHUB_OUTPUT
- echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT
\ No newline at end of file
+ cta
+ echo ""
+ echo "codeboarding-action ยท run ${RUN_ID}"
+ } > "$BODY_FILE"
+
+ echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
+
+ - name: Post sticky PR comment
+ if: steps.guard.outputs.skip != 'true'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: codeboarding-architecture-diff
+ number: ${{ steps.guard.outputs.pr_number }}
+ path: ${{ steps.body.outputs.body_file }}
+ GITHUB_TOKEN: ${{ inputs.github_token }}
+
+ # If any analysis step failed, replace the sticky comment with a short failure
+ # note (same header) instead of leaving the PR with nothing / a stale diagram.
+ - name: Post failure comment
+ if: failure() && steps.guard.outputs.skip != 'true'
+ continue-on-error: true
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: codeboarding-architecture-diff
+ number: ${{ steps.guard.outputs.pr_number }}
+ message: |
+ ### ${{ inputs.comment_header }} ยท failed
+
+ The architecture diff couldn't be generated for this run. See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
+
+ codeboarding-action ยท run ${{ github.run_id }}
+ GITHUB_TOKEN: ${{ inputs.github_token }}
diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md
new file mode 100644
index 0000000..8acaea2
--- /dev/null
+++ b/docs/COMMIT_STRATEGY.md
@@ -0,0 +1,52 @@
+# Baseline & artifact commit strategy
+
+What CodeBoarding writes into a repo, what we commit vs. cache, where, and how
+today's choice keeps a future hosted-webview viewer possible without rework.
+
+## The artifacts
+
+The engine writes these under `.codeboarding/`:
+
+| File | Type | Size | Purpose |
+|---|---|---|---|
+| `analysis.json` | JSON (text) | KBโlow MB | The component graph โ **the diagram source** |
+| `health/health_report.json` | JSON (text) | KB | Health findings โ **the warnings** |
+| `static_analysis.pkl` | binary pickle | MB-scale | LSP/CFG cache โ **warm-start** (re-LSP only changed files) |
+| `static_analysis.sha` | text (1 line) | bytes | Tag recording the pkl's commit โ the warm-start gate |
+
+## Decision
+
+**Commit (text, small, display-critical):**
+- โ
`analysis.json` โ required for the extension (and later the webview) to **show the diagram instantly without regenerating** โ i.e. without spending the user's API key. It's text and diffs meaningfully.
+- โ
`health/health_report.json` โ required for warnings in the extension/webview. Small text.
+
+**Do NOT commit (binary, bloat):**
+- โ `static_analysis.pkl` โ binary, MB-scale, noisy diffs, repo bloat. It is a *rebuildable speed cache*, not display data. Keep it in **`actions/cache` keyed by the base SHA** (or a backend). A cache miss just falls back to a cold (full) LSP pass โ slower but correct, and the committed `analysis.json` still drives the diagram.
+- `static_analysis.sha` โ commit **only** if the pkl is kept reachable (cache/backend); on its own it's harmless but unused.
+
+> **Principle:** version-control the *source-of-truth display data* (text, small); *cache* the *rebuildable speed artifacts* (binary, large). This is exactly what keeps the repo clean โ the thing that bloats (`.pkl`) never enters git.
+
+## Where to commit โ two separate workflows
+
+1. **CI/CD on `main` (the baseline keeper).** On push to `main`, regenerate and commit `analysis.json` + `health/health_report.json` to `main`. Keeps the baseline current so PRs diff against an accurate, up-to-date snapshot and the extension shows a real diagram on the default branch.
+
+2. **The review action (PR).** **Comment-only by default** โ no commits to contributors' branches (no churn, and it still works on fork PRs where the token is read-only). The PR comment leads users to the extension.
+ - *Optional later:* commit the head `analysis.json` to the PR branch so opening the extension on that PR shows the exact head diagram. Deferred โ it pushes a bot commit to the contributor's branch and can't run on fork PRs.
+
+## Now vs. later
+
+- **Now โ extension-direct.** Committing `analysis.json` + `health_report.json` on `main` means a user who installs the extension and opens the repo sees the committed diagram + warnings **instantly, with no API key**. The PR comment's CTA points straight at the extension (install / open in editor).
+- **Later โ hosted webview.** The webview needs the **same** committed `analysis.json` (+ a diff + health). So committing now is **forward-compatible**: when the viewer is built, the data already exists at each commit โ no migration, just a host layer that reads it.
+
+## Warm-start tradeoff (the `.pkl`)
+
+The warm-start needs the pkl **and** its `.sha`. When the review action has to generate a base analysis, it saves that generated base artifact directory in `actions/cache` keyed by base SHA / depth / engine ref, then seeds the head analysis from that directory. When a committed `analysis.json` already exists but no matching cache exists, the PR still diffs correctly but may run a cold LSP pass. This keeps the repo clean; the cache improves speed but is not required for correctness.
+
+## Summary
+
+| Artifact | Commit? | Where | Why |
+|---|---|---|---|
+| `analysis.json` | โ
| `main` (CI/CD); PR branch optional/later | diagram source; powers extension now + webview later |
+| `health_report.json` | โ
| with `analysis.json` | warnings |
+| `static_analysis.pkl` | โ | `actions/cache` (or backend), key = base SHA | binary speed cache; never bloat git |
+| `static_analysis.sha` | โ ๏ธ optional | with the cached pkl | warm-start gate; useless without the pkl |
diff --git a/scripts/build_cta.py b/scripts/build_cta.py
new file mode 100644
index 0000000..553c06b
--- /dev/null
+++ b/scripts/build_cta.py
@@ -0,0 +1,100 @@
+"""Build the call-to-action footer appended to the architecture-diff PR comment.
+
+The footer links into CodeBoarding's click proxy (so owner/repo/pr are tracked)
+and currently drives straight to the VS Code/Cursor **extension**: an "open this
+architecture in your editor" link (editor-specific) plus an "install the
+extension" link, and a warning banner when real health findings exist. A
+no-install hosted-webview ("explore in browser") tier is intentionally deferred
+(see docs/COMMIT_STRATEGY.md) โ the committed analysis already supports it later.
+
+Editor coverage is deliberately limited to **VS Code and Cursor**. Per the 2025
+Stack Overflow Developer Survey (https://survey.stackoverflow.co/2025/technology/),
+editor usage is VS Code 75.9%, Cursor 17.9%, VSCodium 6.2%, Windsurf 4.9%,
+Trae 0.8% โ so VS Code + Cursor alone cover ~94% of developers. The long-tail
+forks each carry their own URL scheme and extension registry, and don't justify
+that upkeep for <7% reach apiece.
+
+Which editor link(s) appear is inferred from the analyzed repo's own signals:
+a ``.vscode`` directory -> VS Code, a ``.cursor`` directory -> Cursor, both ->
+both, neither -> VS Code (the safe majority default).
+
+Self-contained stdlib.
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+from urllib.parse import urlencode
+
+
+def detect_editors(repo_path: Path) -> list[str]:
+ """Return the editor link(s) to offer, from the repo's ``.vscode``/``.cursor`` dirs.
+
+ ``.vscode`` -> ['vscode'], ``.cursor`` -> ['cursor'], both -> both (VS Code
+ first), neither -> ['vscode']. Only VS Code and Cursor are considered (see
+ module docstring for the market-share rationale).
+ """
+ editors: list[str] = []
+ if (repo_path / ".vscode").is_dir():
+ editors.append("vscode")
+ if (repo_path / ".cursor").is_dir():
+ editors.append("cursor")
+ return editors or ["vscode"]
+
+
+_EDITOR_LABEL = {"vscode": "VS Code", "cursor": "Cursor"}
+
+
+def build_cta(cta_base: str, owner: str, repo: str, pr: str, repo_path: Path, issues: int = 0) -> str:
+ """Return the markdown CTA footer (the warning banner shows even without a proxy URL).
+
+ The โ ๏ธ health banner is informational and needs no proxy, so it renders
+ whenever ``issues > 0``; the editor/marketplace links require ``cta_base``.
+ Returns '' only when there's nothing to show.
+ """
+ parts: list[str] = []
+ if issues > 0:
+ noun = "issue" if issues == 1 else "issues"
+ parts.append(f"โ ๏ธ **{issues} architecture {noun} found** โ open CodeBoarding to explore them.")
+
+ if cta_base:
+ base = cta_base.rstrip("/")
+
+ def link(path: str, **extra: str) -> str:
+ return f"{base}/{path}?" + urlencode({"owner": owner, "repo": repo, "pr": pr, **extra})
+
+ editor_links = " ยท ".join(
+ f"[**Open in {_EDITOR_LABEL[e]} โ**]({link('open-in-editor', editor=e)})" for e in detect_editors(repo_path)
+ )
+ parts.append(f"See this architecture in your editor: {editor_links}")
+ parts.append(f"๐ก New to CodeBoarding? [**Get the extension โ**]({link('use-marketplace')})")
+
+ if not parts:
+ return ""
+ lines = ["", "---"]
+ for p in parts:
+ lines += ["", p]
+ return "\n".join(lines)
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(description="Build the architecture-diff PR-comment CTA footer.")
+ p.add_argument("--cta-base", required=True, help="Click-proxy base URL (empty -> no footer)")
+ p.add_argument("--owner", required=True)
+ p.add_argument("--repo", required=True)
+ p.add_argument("--pr", required=True)
+ p.add_argument("--repo-path", required=True, type=Path, help="Path to the analyzed repo checkout")
+ p.add_argument("--issues", default="0", help="Real architecture-issue count (0 -> no warning banner)")
+ args = p.parse_args()
+
+ try:
+ issues = int(args.issues or 0)
+ except ValueError:
+ issues = 0
+ print(build_cta(args.cta_base, args.owner, args.repo, args.pr, args.repo_path, issues))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/cb_engine.py b/scripts/cb_engine.py
new file mode 100644
index 0000000..842fb69
--- /dev/null
+++ b/scripts/cb_engine.py
@@ -0,0 +1,173 @@
+"""Engine orchestration for the action โ extracted from inline ``python -c`` blocks
+in action.yml so it is checked in, reviewable, and unit-testable.
+
+Subcommands (all paths/refs come in as argv, never interpolated into source):
+
+ base --repo P --out D --name N --run-id ID --depth K --source-sha SHA
+ head --repo P --out D --name N --run-id ID --depth K --base-ref B --target-ref T --source-sha SHA
+ health --artifact-dir D --repo P --name N --issues-out FILE
+
+``base`` runs a full analysis; ``head`` runs incremental, falling back to full on
+``IncrementalCacheMissingError``/``BaselineUnavailableError``; ``health`` writes the
+WARNING/CRITICAL finding count to ``--issues-out`` (and never fails the run).
+
+The engine (``codeboarding_workflows`` etc.) is imported lazily inside each
+function so this module imports without the engine venv present โ the tests stub
+those modules and assert we call the engine with the right arguments.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+
+def _log_path(out: str, name: str) -> str:
+ return str(Path(out) / name)
+
+
+def _clear_dir(path: Path) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+ for child in path.iterdir():
+ if child.is_dir() and not child.is_symlink():
+ shutil.rmtree(child)
+ else:
+ child.unlink()
+
+
+def run_base(repo: str, out: str, name: str, run_id: str, depth: int, source_sha: str) -> None:
+ from codeboarding_workflows.analysis import run_full
+
+ res = run_full(
+ repo_name=name,
+ repo_path=Path(repo),
+ output_dir=Path(out),
+ run_id=run_id,
+ log_path=_log_path(out, "cb-base.log"),
+ depth_level=depth,
+ source_sha=source_sha,
+ )
+ print(f"Base analysis written: {res}")
+
+
+def run_head(repo: str, out: str, name: str, run_id: str, depth: int, base_ref: str, target_ref: str, source_sha: str) -> None:
+ from codeboarding_workflows.analysis import BaselineUnavailableError, run_full, run_incremental
+ from diagram_analysis.exceptions import IncrementalCacheMissingError
+
+ try:
+ res = run_incremental(
+ repo_path=Path(repo),
+ output_dir=Path(out),
+ project_name=name,
+ run_id=run_id,
+ log_path=_log_path(out, "cb-head.log"),
+ base_ref=base_ref,
+ target_ref=target_ref,
+ source_sha=source_sha,
+ )
+ except (IncrementalCacheMissingError, BaselineUnavailableError) as exc:
+ print(f"Incremental unavailable ({exc}); running full analysis on head.")
+ _clear_dir(Path(out))
+ res = run_full(
+ repo_name=name,
+ repo_path=Path(repo),
+ output_dir=Path(out),
+ run_id=run_id,
+ log_path=_log_path(out, "cb-head.log"),
+ depth_level=depth,
+ source_sha=source_sha,
+ )
+ print(f"Head analysis written: {res}")
+
+
+def _count_report_issues(report: dict) -> int:
+ issues = 0
+ if not isinstance(report, dict):
+ raise ValueError("health report root is not an object")
+ for cs in report.get("check_summaries") or []:
+ if not isinstance(cs, dict):
+ continue
+ for fg in cs.get("finding_groups") or []:
+ if not isinstance(fg, dict):
+ continue
+ if fg.get("severity") in ("warning", "critical"):
+ entities = fg.get("entities") or []
+ issues += len(entities) if isinstance(entities, list) else 0
+ return issues
+
+
+def _count_health_report(artifact_dir: str) -> int | None:
+ report_path = Path(artifact_dir) / "health" / "health_report.json"
+ if not report_path.is_file():
+ return None
+ try:
+ return _count_report_issues(json.loads(report_path.read_text(encoding="utf-8")))
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
+ print(f"Health report unreadable ({exc}); falling back to health runner.")
+ return None
+
+
+def run_health(artifact_dir: str, repo: str, name: str) -> int:
+ """Return the WARNING/CRITICAL finding count; 0 on any failure (best-effort)."""
+ report_count = _count_health_report(artifact_dir)
+ if report_count is not None:
+ print(f"Architecture issues found in health report: {report_count}")
+ return report_count
+
+ try:
+ from health.models import Severity
+ from health.runner import run_health_checks
+ from static_analyzer.analysis_cache import StaticAnalysisCache
+ except Exception as exc: # engine without the health module
+ print(f"Health check skipped ({exc}).")
+ return 0
+ try:
+ cache = StaticAnalysisCache(artifact_dir=Path(artifact_dir), repo_root=Path(repo))
+ sa = cache.get()
+ issues = 0
+ if sa is not None:
+ report = run_health_checks(sa, repo_name=name, repo_path=Path(repo))
+ if report is not None:
+ for cs in report.check_summaries:
+ for fg in getattr(cs, "finding_groups", []):
+ if getattr(fg, "severity", None) in (Severity.WARNING, Severity.CRITICAL):
+ issues += len(fg.entities)
+ print(f"Architecture issues found: {issues}")
+ return issues
+ except Exception as exc:
+ print(f"Health check skipped ({exc}).")
+ return 0
+
+
+def main(argv=None) -> int:
+ p = argparse.ArgumentParser(description=__doc__)
+ sub = p.add_subparsers(dest="cmd", required=True)
+
+ b = sub.add_parser("base")
+ for a in ("--repo", "--out", "--name", "--run-id", "--source-sha"):
+ b.add_argument(a, required=True)
+ b.add_argument("--depth", required=True, type=int, choices=range(1, 4))
+
+ h = sub.add_parser("head")
+ for a in ("--repo", "--out", "--name", "--run-id", "--base-ref", "--target-ref", "--source-sha"):
+ h.add_argument(a, required=True)
+ h.add_argument("--depth", required=True, type=int, choices=range(1, 4))
+
+ hc = sub.add_parser("health")
+ for a in ("--artifact-dir", "--repo", "--name", "--issues-out"):
+ hc.add_argument(a, required=True)
+
+ args = p.parse_args(argv)
+ if args.cmd == "base":
+ run_base(args.repo, args.out, args.name, args.run_id, args.depth, args.source_sha)
+ elif args.cmd == "head":
+ run_head(args.repo, args.out, args.name, args.run_id, args.depth, args.base_ref, args.target_ref, args.source_sha)
+ elif args.cmd == "health":
+ Path(args.issues_out).write_text(str(run_health(args.artifact_dir, args.repo, args.name)))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/diff_to_mermaid.py b/scripts/diff_to_mermaid.py
new file mode 100644
index 0000000..0696d3f
--- /dev/null
+++ b/scripts/diff_to_mermaid.py
@@ -0,0 +1,554 @@
+"""Diff two CodeBoarding analysis.json files and render the delta as a colored Mermaid graph.
+
+Reads a *base* (before) and *head* (after) ``analysis.json`` โ both already
+materialized on disk by the engine โ computes a component/relation diff, and
+emits a GitHub-renderable ```mermaid block where:
+
+ * nodes are colored green=added / yellow=modified / red=deleted (deleted dashed)
+ * arrows are colored the same way (red dashed for deleted)
+
+GitHub renders ```mermaid fenced blocks natively inside PR/issue comments, so the
+output goes straight into the sticky comment โ no image, no Playwright.
+
+The diff set-arithmetic is a port of the action's ``compute_diff.py``, with two
+differences for this use case: both sides are read from plain file paths (not
+``git show``), and a relation whose ``(src, dst)`` is unchanged but whose label
+text changed is reported as ``modified`` (the original only did added/deleted).
+
+Self-contained stdlib.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections import defaultdict
+import json
+import re
+import sys
+from pathlib import Path
+
+# GitHub's mermaid config caps (config.schema.yaml defaults; NOT raisable on
+# GitHub). Exceeding either renders a red error box with no diagram, so we stay
+# comfortably under and degrade to a changed-only / text fallback instead.
+MAX_EDGES = 480 # hard cap 500
+MAX_TEXT = 45_000 # hard cap 50000 chars
+
+# Primer-ish fills that read on both light and dark GitHub backgrounds. White
+# label text is set explicitly so it survives dark mode.
+COLORS = {
+ "added": {"fill": "#1f883d", "stroke": "#0b5d23"},
+ "modified": {"fill": "#bf8700", "stroke": "#7d4e00"},
+ "deleted": {"fill": "#cf222e", "stroke": "#82071e"},
+}
+CHANGED = ("added", "modified", "deleted")
+_EDGE_LABEL_MAX = 48
+
+
+# --------------------------------------------------------------------------- #
+# load
+# --------------------------------------------------------------------------- #
+def load_analysis(path: Path) -> dict:
+ try:
+ return json.loads(path.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ sys.exit(f"::error::Could not read analysis JSON at {path}: {exc}")
+
+
+# --------------------------------------------------------------------------- #
+# diff (ported from compute_diff.py; relation diff extended with 'modified')
+# --------------------------------------------------------------------------- #
+def _comp_id(c: dict) -> str:
+ return c.get("component_id") or c.get("name", "")
+
+
+def _comp_name(c: dict) -> str:
+ return c.get("name", "")
+
+
+def _file_methods(c: dict) -> list:
+ return c.get("file_methods") or []
+
+
+def _methods_by_file(c: dict) -> dict:
+ by_file: dict = {}
+ for fm in _file_methods(c):
+ fp = fm.get("file_path") or ""
+ names = {m for m in (fm.get("methods") or []) if isinstance(m, str)}
+ if names:
+ by_file.setdefault(fp, set()).update(names)
+ return by_file
+
+
+def _has_structural_changes(base: dict, current: dict) -> bool:
+ base_files = {fm.get("file_path", "") for fm in _file_methods(base)}
+ current_files = {fm.get("file_path", "") for fm in _file_methods(current)}
+ return base_files != current_files or len(base.get("components") or []) != len(current.get("components") or [])
+
+
+def _has_method_changes(base: dict, current: dict) -> bool:
+ base_by_file = _methods_by_file(base)
+ current_by_file = _methods_by_file(current)
+ return any(
+ base_by_file.get(fp, set()) != current_by_file.get(fp, set())
+ for fp in set(base_by_file) | set(current_by_file)
+ )
+
+
+def _rel_key(r: dict) -> tuple:
+ # Name is the stable join across two independent analyses; component ids are
+ # positional and can be reshuffled on a full re-run, so prefer names.
+ return (r.get("src_name") or r.get("src_id") or "", r.get("dst_name") or r.get("dst_id") or "")
+
+
+def _diff_relations(base_rels: list, current_rels: list) -> list:
+ base_by_endpoint: dict = defaultdict(list)
+ current_by_endpoint: dict = defaultdict(list)
+ for rel in base_rels or []:
+ base_by_endpoint[_rel_key(rel)].append(rel)
+ for rel in current_rels or []:
+ current_by_endpoint[_rel_key(rel)].append(rel)
+
+ result: list = []
+ keys = list(current_by_endpoint)
+ keys.extend(k for k in base_by_endpoint if k not in current_by_endpoint)
+ for key in keys:
+ base_group = base_by_endpoint.get(key, [])
+ current_group = current_by_endpoint.get(key, [])
+ if not base_group:
+ result.extend({**rel, "diff_status": "added"} for rel in current_group)
+ continue
+ if not current_group:
+ result.extend({**rel, "diff_status": "deleted"} for rel in base_group)
+ continue
+
+ if len(base_group) == 1 and len(current_group) == 1:
+ status = "unchanged" if (base_group[0].get("relation") or "") == (current_group[0].get("relation") or "") else "modified"
+ result.append({**current_group[0], "diff_status": status})
+ continue
+
+ unmatched_base = list(base_group)
+ unmatched_current = []
+ for rel in current_group:
+ label = rel.get("relation") or ""
+ match_idx = next((i for i, b in enumerate(unmatched_base) if (b.get("relation") or "") == label), None)
+ if match_idx is None:
+ unmatched_current.append(rel)
+ else:
+ unmatched_base.pop(match_idx)
+ result.append({**rel, "diff_status": "unchanged"})
+
+ if len(unmatched_base) == 1 and len(unmatched_current) == 1:
+ result.append({**unmatched_current[0], "diff_status": "modified"})
+ else:
+ result.extend({**rel, "diff_status": "added"} for rel in unmatched_current)
+ result.extend({**rel, "diff_status": "deleted"} for rel in unmatched_base)
+ return result
+
+
+def _has_changes(components: list, relations: list) -> bool:
+ if any(r.get("diff_status") in CHANGED for r in relations or []):
+ return True
+ for comp in components or []:
+ if comp.get("diff_status") in CHANGED:
+ return True
+ if _has_changes(comp.get("components") or [], comp.get("components_relations") or []):
+ return True
+ return False
+
+
+def _diff_components(base_components: list, current_components: list) -> list:
+ base = base_components or []
+ current = current_components or []
+ base_by_name = {_comp_name(c): c for c in base} # name is the stable cross-analysis join
+ matched_names: set = set()
+ result: list = []
+
+ for comp in current:
+ base_match = base_by_name.get(_comp_name(comp))
+ if base_match is None:
+ result.append({**comp, "diff_status": "added"})
+ continue
+ matched_names.add(_comp_name(base_match))
+ structural = _has_structural_changes(base_match, comp)
+ diff_status = "modified" if (structural or _has_method_changes(base_match, comp)) else "unchanged"
+
+ annotated = {**comp, "diff_status": diff_status}
+
+ base_subs = base_match.get("components") or []
+ current_subs = comp.get("components") or []
+ if base_subs or current_subs:
+ annotated["components"] = _diff_components(base_subs, current_subs)
+
+ base_sub_rels = base_match.get("components_relations") or []
+ current_sub_rels = comp.get("components_relations") or []
+ if base_sub_rels or current_sub_rels:
+ annotated["components_relations"] = _diff_relations(base_sub_rels, current_sub_rels)
+
+ if diff_status == "unchanged" and _has_changes(
+ annotated.get("components") or [],
+ annotated.get("components_relations") or [],
+ ):
+ annotated["display_status"] = "modified"
+
+ result.append(annotated)
+
+ for comp in base:
+ if _comp_name(comp) not in matched_names:
+ # Keep the subtree: a deleted parent's children/relations render as a
+ # deleted subgraph (the renderer forces 'deleted' down), mirroring how
+ # an added parent renders its whole subtree.
+ ghost = {k: v for k, v in comp.items() if k != "can_expand"}
+ ghost["diff_status"] = "deleted"
+ result.append(ghost)
+
+ return result
+
+
+def build_diff(base: dict, head: dict) -> dict:
+ return {
+ "components": _diff_components(base.get("components") or [], head.get("components") or []),
+ "components_relations": _diff_relations(
+ base.get("components_relations") or [],
+ head.get("components_relations") or [],
+ ),
+ }
+
+
+# --------------------------------------------------------------------------- #
+# mermaid emit
+# --------------------------------------------------------------------------- #
+def _sanitize(name: str) -> str:
+ """Match the engine's node-id sanitization (utils.sanitize)."""
+ return re.sub(r"\W+", "_", name or "")
+
+
+# Mermaid label metacharacters โ numeric/named char-refs (the ``#NNN;`` form
+# GitHub's strict renderer accepts). A bare ``]`` / ``)`` / ``}`` terminates a
+# node label and breaks the whole diagram, so escape the shape chars too โ not
+# just ``#`` and ``"``.
+_ESC_MAP = {
+ "&": "#amp;", '"': "#quot;", "<": "#lt;", ">": "#gt;",
+ "[": "#91;", "]": "#93;", "(": "#40;", ")": "#41;",
+ "{": "#123;", "}": "#125;", "|": "#124;",
+}
+
+
+def _esc(text: str) -> str:
+ """Escape arbitrary text for a Mermaid label under GitHub's strict renderer."""
+ out = (text or "").replace("\n", " ").replace("\r", " ").strip()
+ out = out.replace("#", "#35;") # first: literal '#'; the entities below add their own '#'
+ for ch, ent in _ESC_MAP.items():
+ out = out.replace(ch, ent)
+ return out
+
+
+def _truncate(text: str, limit: int = _EDGE_LABEL_MAX) -> str:
+ text = (text or "").strip()
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "โฆ"
+
+
+def _display_status(comp: dict, force: str | None = None) -> str:
+ return force or comp.get("display_status") or comp.get("diff_status", "unchanged")
+
+
+class _Scope:
+ """Per-level name/id -> mermaid key resolver for one nesting level.
+
+ Deleted ghosts get a separate ``del_`` key namespace from present nodes so a
+ reused id/name can't merge an added node onto a deleted one. Keys are made
+ globally unique via the shared ``used`` set. Resolution is name-first (the
+ stable cross-analysis join); present edges resolve head-first, deleted edges
+ ghost-first. ``force`` overrides the per-component diff_status (used when a
+ wholly-added/deleted parent colors its whole subtree).
+ """
+
+ def __init__(self, components: list, used: set, force: str | None = None):
+ self.entries: list = [] # (key, label, status, component)
+ self.head_by_id: dict = {}
+ self.head_by_name: dict = {}
+ self.del_by_id: dict = {}
+ self.del_by_name: dict = {}
+ for comp in components:
+ status = _display_status(comp, force)
+ present = status != "deleted"
+ cid, cname = _comp_id(comp), _comp_name(comp)
+ base = ("n_" if present else "del_") + _sanitize(cname or cid or "node")
+ key, n = base, 1
+ while key in used:
+ n += 1
+ key = f"{base}_{n}"
+ used.add(key)
+ self.entries.append((key, cname or cid or "(unnamed)", status, comp))
+ by_id = self.head_by_id if present else self.del_by_id
+ by_name = self.head_by_name if present else self.del_by_name
+ if cname:
+ by_name[cname] = key
+ if cid:
+ by_id[cid] = key
+
+ def resolve(self, rid: str, rname: str, present: bool) -> str | None:
+ maps = [(self.head_by_id, self.head_by_name), (self.del_by_id, self.del_by_name)]
+ if not present:
+ maps.reverse()
+ for by_id, by_name in maps:
+ if rname and rname in by_name: # name-first: stable cross-analysis join
+ return by_name[rname]
+ if rid and rid in by_id:
+ return by_id[rid]
+ return None
+
+
+def _filter_changed(components: list, relations: list) -> tuple:
+ """Keep changed components, changed-edge endpoints, ancestors, and edges among the kept."""
+ changed_rels = [r for r in relations if r.get("diff_status") in CHANGED]
+ keep_ids: set = set()
+ keep_names: set = set()
+ filtered_children: dict[int, tuple] = {}
+ for c in components:
+ child_components, child_relations = _filter_changed(
+ c.get("components") or [],
+ c.get("components_relations") or [],
+ )
+ filtered_children[id(c)] = (child_components, child_relations)
+ if _display_status(c) in CHANGED or child_components or child_relations:
+ keep_ids.add(_comp_id(c))
+ keep_names.add(_comp_name(c))
+ for r in changed_rels: # so a changed edge between two unchanged nodes still draws its endpoints
+ keep_ids.update(x for x in (r.get("src_id", ""), r.get("dst_id", "")) if x)
+ keep_names.update(x for x in (r.get("src_name", ""), r.get("dst_name", "")) if x)
+ keep_ids.discard("")
+ keep_names.discard("")
+
+ kept = []
+ for c in components:
+ if not ((_comp_id(c) and _comp_id(c) in keep_ids) or (_comp_name(c) and _comp_name(c) in keep_names)):
+ continue
+ child_components, child_relations = filtered_children[id(c)]
+ status = _display_status(c)
+ if child_components or child_relations or status == "modified":
+ c = {**c, "components": child_components, "components_relations": child_relations}
+ kept.append(c)
+ kept_ids = {_comp_id(c) for c in kept if _comp_id(c)}
+ kept_names = {_comp_name(c) for c in kept if _comp_name(c)}
+
+ def touches(r: dict, side_id: str, side_name: str) -> bool:
+ rid, rname = r.get(side_id, ""), r.get(side_name, "")
+ return bool((rid and rid in kept_ids) or (rname and rname in kept_names))
+
+ rels = [
+ r
+ for r in relations
+ if r.get("diff_status") in CHANGED
+ or (touches(r, "src_id", "src_name") and touches(r, "dst_id", "dst_name"))
+ ]
+ return kept, rels
+
+
+def _init_directive(font_size, node_padding, node_spacing, rank_spacing) -> str | None:
+ """Build a Mermaid ``%%{init}%%`` directive to enlarge nodes / spacing.
+
+ Nodes auto-size to their label, so the effective levers are font size and
+ interior padding (bigger nodes) plus node/rank spacing (less cramped). These
+ config keys are honored by GitHub's strict renderer.
+ """
+ flowchart: dict = {}
+ if node_padding is not None:
+ flowchart["padding"] = node_padding
+ if node_spacing is not None:
+ flowchart["nodeSpacing"] = node_spacing
+ if rank_spacing is not None:
+ flowchart["rankSpacing"] = rank_spacing
+ cfg: dict = {}
+ if flowchart:
+ cfg["flowchart"] = flowchart
+ if font_size is not None:
+ cfg["themeVariables"] = {"fontSize": f"{font_size}px"}
+ return "%%{init: " + json.dumps(cfg) + "}%%" if cfg else None
+
+
+def _count_changed_components(components: list) -> int:
+ """Recursively count components whose diff_status is added/modified/deleted."""
+ n = 0
+ for c in components or []:
+ if c.get("diff_status") in CHANGED:
+ n += 1
+ n += _count_changed_components(c.get("components") or [])
+ return n
+
+
+def _has_changed_relations(components: list, relations: list) -> bool:
+ """Recursively: is any relation (at any nesting level) added/modified/deleted?"""
+ return _has_changes([], relations) or any(
+ _has_changed_relations(c.get("components") or [], c.get("components_relations") or [])
+ for c in components or []
+ )
+
+
+def render_mermaid(
+ diff: dict,
+ direction: str = "LR",
+ changed_only: bool = False,
+ edge_labels: bool = True,
+ render_depth: int = 1,
+ font_size: int | None = None,
+ node_padding: int | None = None,
+ node_spacing: int | None = None,
+ rank_spacing: int | None = None,
+) -> tuple:
+ """Return (mermaid_text, meta). ``mermaid_text`` is None when there's nothing to draw.
+
+ ``render_depth`` controls how many component levels are drawn, independent of
+ the engine's analysis depth: 1 = top-level flat (default), 2 = top-level plus
+ one level of sub-components as subgraphs, etc. ``meta`` reports ``n_changed``
+ (recursive changed-component count) and ``changed`` (any changed component OR
+ relation at any level) so the caller never mistakes a relation/nested change
+ for "no changes". On overflow of GitHub's Mermaid caps the full graph degrades
+ to a changed-only graph (and finally to None) rather than emitting an
+ unrenderable blob.
+ """
+ all_components = diff.get("components") or []
+ all_relations = diff.get("components_relations") or []
+ n_changed = _count_changed_components(all_components)
+ changed = n_changed > 0 or _has_changed_relations(all_components, all_relations)
+ directive = _init_directive(font_size, node_padding, node_spacing, rank_spacing)
+
+ def build(only_changed: bool):
+ components, relations = (
+ _filter_changed(all_components, all_relations) if only_changed else (all_components, all_relations)
+ )
+ used: set = set()
+ body: list = []
+ node_classes: dict = {"added": [], "modified": [], "deleted": []}
+ box_classes: dict = {"added": [], "modified": [], "deleted": []}
+ edge_styles: dict = {"added": [], "modified": [], "deleted": []}
+ counters = {"edges": 0, "nodes": 0}
+
+ def emit_edges(rels, scope, pad, force):
+ for rel in rels:
+ status = force or rel.get("diff_status", "unchanged")
+ present = status != "deleted"
+ src = scope.resolve(rel.get("src_id", ""), rel.get("src_name", ""), present)
+ dst = scope.resolve(rel.get("dst_id", ""), rel.get("dst_name", ""), present)
+ if src is None or dst is None:
+ continue # endpoint not drawn โ skip, don't consume an edge index
+ label = _esc(_truncate(rel.get("relation", ""))) if edge_labels else ""
+ body.append(f'{pad}{src} -- "{label}" --> {dst}' if label else f"{pad}{src} --> {dst}")
+ if status in edge_styles:
+ edge_styles[status].append(counters["edges"])
+ counters["edges"] += 1
+
+ def emit_level(comps, rels, indent, force, level):
+ pad = " " * indent
+ scope = _Scope(comps, used, force)
+ for key, label, status, comp in scope.entries:
+ children = comp.get("components") if level < render_depth else None # cap drawn nesting
+ if children:
+ body.append(f'{pad}subgraph {key}["{_esc(label)}"]')
+ if status in box_classes:
+ box_classes[status].append(key)
+ child_force = force or (status if status in ("added", "deleted") else None)
+ emit_level(children, comp.get("components_relations") or [], indent + 1, child_force, level + 1)
+ body.append(f"{pad}end")
+ else:
+ body.append(f'{pad}{key}["{_esc(label)}"]')
+ if status in node_classes:
+ node_classes[status].append(key)
+ counters["nodes"] += 1
+ emit_edges(rels, scope, pad, force)
+
+ emit_level(components, relations, 1, None, 1)
+ if counters["nodes"] == 0:
+ return None, 0, 0
+
+ style: list = [
+ f' classDef added fill:{COLORS["added"]["fill"]},stroke:{COLORS["added"]["stroke"]},color:#ffffff;',
+ f' classDef modified fill:{COLORS["modified"]["fill"]},stroke:{COLORS["modified"]["stroke"]},color:#ffffff;',
+ f' classDef deleted fill:{COLORS["deleted"]["fill"]},stroke:{COLORS["deleted"]["stroke"]},'
+ f"color:#ffffff,stroke-dasharray:5 3;",
+ ]
+ if any(box_classes.values()): # stroke-only containers so big parents aren't solid blocks
+ for st in CHANGED:
+ dash = ",stroke-dasharray:5 3" if st == "deleted" else ""
+ style.append(f' classDef {st}Box stroke:{COLORS[st]["stroke"]},stroke-width:2px,fill:none{dash};')
+ for status in CHANGED:
+ if node_classes[status]:
+ style.append(f' class {",".join(node_classes[status])} {status};')
+ if box_classes[status]:
+ style.append(f' class {",".join(box_classes[status])} {status}Box;')
+ for status in CHANGED:
+ idxs = edge_styles[status]
+ if not idxs:
+ continue
+ s = f'stroke:{COLORS[status]["stroke"]},stroke-width:2px'
+ if status == "deleted":
+ s += ",stroke-dasharray:5 3"
+ style.append(f' linkStyle {",".join(str(i) for i in idxs)} {s};')
+
+ head = ["```mermaid"] + ([directive] if directive else []) + [f"graph {direction}"]
+ return "\n".join(head + body + style + ["```"]), counters["nodes"], counters["edges"]
+
+ text, n_nodes, n_edges = build(changed_only)
+ rendered_changed_only = changed_only
+ truncated = False
+ # Degrade an oversized full graph to changed-only before giving up (GitHub caps).
+ if text is not None and (n_edges > MAX_EDGES or len(text) > MAX_TEXT) and not changed_only:
+ t2, nn2, ne2 = build(True)
+ if t2 is not None:
+ text, n_nodes, n_edges, truncated = t2, nn2, ne2, True
+ rendered_changed_only = True
+
+ meta = {
+ "n_changed": n_changed,
+ "changed": changed,
+ "n_nodes": n_nodes if text is not None else 0,
+ "n_edges": n_edges if text is not None else 0,
+ "truncated": bool(truncated or text is None),
+ "changed_only": bool(rendered_changed_only),
+ "requested_changed_only": bool(changed_only),
+ }
+ if text is None or n_edges > MAX_EDGES or len(text) > MAX_TEXT: # never trip GitHub's red error box
+ meta["truncated"] = True
+ return None, meta
+ return text, meta
+
+
+# --------------------------------------------------------------------------- #
+# cli
+# --------------------------------------------------------------------------- #
+def main() -> int:
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--base", required=True, type=Path, help="Path to the base (before) analysis.json")
+ p.add_argument("--head", required=True, type=Path, help="Path to the head (after) analysis.json")
+ p.add_argument("--out", required=True, type=Path, help="Where to write the ```mermaid block")
+ p.add_argument("--direction", default="LR", choices=["LR", "TD", "TB", "RL", "BT"])
+ p.add_argument("--changed-only", action="store_true", help="Render only changed components + incident edges")
+ p.add_argument("--no-edge-labels", dest="edge_labels", action="store_false", help="Draw arrows without relation labels")
+ p.add_argument("--render-depth", type=int, default=1, help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...")
+ p.add_argument("--font-size", type=int, default=None, help="Node label font size in px (bigger label โ bigger node)")
+ p.add_argument("--node-padding", type=int, default=None, help="Interior padding around each node label")
+ p.add_argument("--node-spacing", type=int, default=None, help="Space between nodes in the same rank")
+ p.add_argument("--rank-spacing", type=int, default=None, help="Space between ranks")
+ args = p.parse_args()
+
+ diff = build_diff(load_analysis(args.base), load_analysis(args.head))
+ mermaid, meta = render_mermaid(
+ diff,
+ direction=args.direction,
+ changed_only=args.changed_only,
+ edge_labels=args.edge_labels,
+ render_depth=args.render_depth,
+ font_size=args.font_size,
+ node_padding=args.node_padding,
+ node_spacing=args.node_spacing,
+ rank_spacing=args.rank_spacing,
+ )
+
+ args.out.write_text(mermaid if mermaid is not None else "", encoding="utf-8")
+ meta["rendered"] = mermaid is not None
+ # Machine-readable summary on stdout for the action to consume.
+ print(json.dumps(meta))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/run_local.sh b/scripts/run_local.sh
new file mode 100755
index 0000000..b575c02
--- /dev/null
+++ b/scripts/run_local.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/env bash
+#
+# Local test harness for the CodeBoarding Mermaid architecture-diff action.
+# Mirrors action.yml so you can iterate without waiting on a GitHub runner.
+#
+# Two modes:
+#
+# FAST (no LLM, instant) โ diff two existing analysis.json files and preview:
+# scripts/run_local.sh --base-json BASE.json --head-json HEAD.json
+#
+# FULL pipeline (needs OPENROUTER_API_KEY) โ run the engine on two refs of a
+# local repo, exactly like the action (committed-or-generated base, then
+# incremental head), then diff + preview:
+# export OPENROUTER_API_KEY=sk-or-...
+# scripts/run_local.sh --repo /path/to/repo --base [ --head ][
+#
+# Outputs (default ./.cb-local):
+# diagram.md the ```mermaid block (what the action posts)
+# preview.html opens in a browser and renders the colored diagram via mermaid.js
+#
+set -euo pipefail
+
+ACTION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ENGINE="${ENGINE:-$ACTION_DIR/../CodeBoarding}"
+OUT="$ACTION_DIR/.cb-local"
+DEPTH="1"
+DIRECTION="LR"
+CHANGED_ONLY=()
+NO_EDGE_LABELS=()
+RENDER_DEPTH=()
+EXTRA=()
+OPEN="auto"
+REPO="" BASE_REF="" HEAD_REF="" BASE_JSON="" HEAD_JSON=""
+# Empty by default: the engine then uses its own valid per-provider default.
+# Override with a bare OpenRouter slug, e.g. AGENT_MODEL=anthropic/claude-sonnet-4
+AGENT_MODEL="${AGENT_MODEL:-}"
+PARSING_MODEL="${PARSING_MODEL:-}"
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --repo) REPO="$2"; shift 2;;
+ --base) BASE_REF="$2"; shift 2;;
+ --head) HEAD_REF="$2"; shift 2;;
+ --base-json) BASE_JSON="$2"; shift 2;;
+ --head-json) HEAD_JSON="$2"; shift 2;;
+ --engine) ENGINE="$2"; shift 2;;
+ --out) OUT="$2"; shift 2;;
+ --depth) DEPTH="$2"; shift 2;;
+ --direction) DIRECTION="$2"; shift 2;;
+ --changed-only) CHANGED_ONLY=(--changed-only); shift;;
+ --no-edge-labels) NO_EDGE_LABELS=(--no-edge-labels); shift;;
+ --render-depth) RENDER_DEPTH=(--render-depth "$2"); shift 2;;
+ --extra) read -r -a EXTRA <<< "$2"; shift 2;; # raw args forwarded to diff_to_mermaid.py, e.g. --extra "--font-size 20 --node-padding 16"
+ --no-open) OPEN="no"; shift;;
+ -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0;;
+ *) echo "Unknown arg: $1" >&2; exit 2;;
+ esac
+done
+
+mkdir -p "$OUT"
+
+run_engine() {
+ ( cd "$ENGINE"
+ export STATIC_ANALYSIS_CONFIG="$ENGINE/static_analysis_config.yml" \
+ PROJECT_ROOT="$ENGINE" \
+ DIAGRAM_DEPTH_LEVEL="$DEPTH" \
+ CACHING_DOCUMENTATION="false" \
+ ENABLE_MONITORING="false" \
+ OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}"
+ # Pass the model only when set; empty -> engine's own valid per-provider default.
+ if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi
+ if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi
+ uv run python "$ACTION_DIR/scripts/cb_engine.py" "$@" )
+}
+
+if [ -n "$BASE_JSON" ] && [ -n "$HEAD_JSON" ]; then
+ echo "== Fast mode: diffing existing analyses (no engine run) =="
+ BASE_ANALYSIS="$BASE_JSON"
+ HEAD_ANALYSIS="$HEAD_JSON"
+else
+ [ -n "$REPO" ] && [ -n "$BASE_REF" ] && [ -n "$HEAD_REF" ] || {
+ echo "Need either --base-json/--head-json, or --repo/--base/--head." >&2; exit 2; }
+ [ -d "$ENGINE" ] || { echo "Engine not found at $ENGINE (set --engine or \$ENGINE)." >&2; exit 2; }
+ [ -n "${OPENROUTER_API_KEY:-}" ] || { echo "Export OPENROUTER_API_KEY for the full pipeline." >&2; exit 2; }
+ REPO="$(cd "$REPO" && pwd)"
+ BASE_DIR="$OUT/base"; HEAD_DIR="$OUT/head"
+ rm -rf "$BASE_DIR" "$HEAD_DIR"; mkdir -p "$BASE_DIR" "$HEAD_DIR"
+
+ echo "== Resolving base analysis at $BASE_REF =="
+ if git -C "$REPO" show "$BASE_REF:.codeboarding/analysis.json" > "$BASE_DIR/analysis.json" 2>/dev/null; then
+ echo " using committed baseline"
+ else
+ rm -f "$BASE_DIR/analysis.json"
+ echo " no committed baseline; running FULL analysis on base (LLM)..."
+ BASE_SRC="$OUT/base-src"
+ git -C "$REPO" worktree remove --force "$BASE_SRC" 2>/dev/null || true
+ git -C "$REPO" worktree prune
+ rm -rf "$BASE_SRC"
+ git -C "$REPO" worktree add --detach "$BASE_SRC" "$BASE_REF" >/dev/null
+ run_engine base \
+ --repo "$BASE_SRC" \
+ --out "$BASE_DIR" \
+ --name "$(basename "$REPO")" \
+ --run-id local-base \
+ --depth "$DEPTH" \
+ --source-sha "$BASE_REF"
+ git -C "$REPO" worktree remove --force "$BASE_SRC" >/dev/null 2>&1 || true
+ [ -f "$BASE_DIR/analysis.json" ] || { echo "Base full analysis ran but analysis.json is missing." >&2; exit 1; }
+ fi
+
+ echo "== Analyzing head at $HEAD_REF (incremental from base) =="
+ cp -a "$BASE_DIR"/. "$HEAD_DIR"/ 2>/dev/null || true
+ run_engine head \
+ --repo "$REPO" \
+ --out "$HEAD_DIR" \
+ --name "$(basename "$REPO")" \
+ --run-id local-head \
+ --depth "$DEPTH" \
+ --base-ref "$BASE_REF" \
+ --target-ref "$HEAD_REF" \
+ --source-sha "$HEAD_REF"
+ [ -f "$HEAD_DIR/analysis.json" ] || { echo "Head analysis ran but analysis.json is missing." >&2; exit 1; }
+ BASE_ANALYSIS="$BASE_DIR/analysis.json"
+ HEAD_ANALYSIS="$HEAD_DIR/analysis.json"
+fi
+
+echo "== Diff -> Mermaid =="
+META="$(python3 "$ACTION_DIR/scripts/diff_to_mermaid.py" \
+ --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" \
+ --out "$OUT/diagram.md" --direction "$DIRECTION" \
+ ${CHANGED_ONLY[@]+"${CHANGED_ONLY[@]}"} ${NO_EDGE_LABELS[@]+"${NO_EDGE_LABELS[@]}"} ${RENDER_DEPTH[@]+"${RENDER_DEPTH[@]}"} ${EXTRA[@]+"${EXTRA[@]}"})"
+echo " $META"
+
+# Browser preview: render the (fence-stripped) mermaid via mermaid.js, strict mode
+# to match GitHub. HTML-escape the body so labels with < > & stay valid.
+python3 - "$OUT/diagram.md" "$OUT/preview.html" <<'PY'
+import html, sys
+src, dst = sys.argv[1], sys.argv[2]
+body = open(src, encoding="utf-8").read().strip()
+lines = body.splitlines()
+if lines and lines[0].startswith("```"): lines = lines[1:]
+if lines and lines[-1].startswith("```"): lines = lines[:-1]
+graph = html.escape("\n".join(lines))
+open(dst, "w", encoding="utf-8").write(f"""
+CodeBoarding architecture diff
+
+]Architecture diff preview
+
+ ■ added
+ ■ modified
+ ■ deleted
+
+
+{graph}
+
+""")
+print(f" wrote {dst}")
+PY
+
+echo
+echo "diagram : $OUT/diagram.md"
+echo "preview : $OUT/preview.html"
+if [ "$OPEN" != "no" ]; then
+ if command -v open >/dev/null 2>&1; then open "$OUT/preview.html";
+ elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$OUT/preview.html";
+ else echo "(open $OUT/preview.html in your browser)"; fi
+fi
diff --git a/tests/test_build_cta.py b/tests/test_build_cta.py
new file mode 100644
index 0000000..acb5580
--- /dev/null
+++ b/tests/test_build_cta.py
@@ -0,0 +1,70 @@
+"""Unit tests for scripts/build_cta.py โ editor detection + CTA footer."""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+import build_cta as bc # noqa: E402
+
+
+def repo_with(*dirs):
+ d = Path(tempfile.mkdtemp())
+ for x in dirs:
+ (d / x).mkdir()
+ return d
+
+
+class TestDetectEditors(unittest.TestCase):
+ def test_neither_defaults_to_vscode(self):
+ self.assertEqual(bc.detect_editors(repo_with()), ["vscode"])
+
+ def test_vscode_only(self):
+ self.assertEqual(bc.detect_editors(repo_with(".vscode")), ["vscode"])
+
+ def test_cursor_only(self):
+ self.assertEqual(bc.detect_editors(repo_with(".cursor")), ["cursor"])
+
+ def test_both_vscode_first(self):
+ self.assertEqual(bc.detect_editors(repo_with(".vscode", ".cursor")), ["vscode", "cursor"])
+
+
+class TestBuildCta(unittest.TestCase):
+ def test_empty_base_yields_no_footer(self):
+ self.assertEqual(bc.build_cta("", "o", "r", "1", repo_with()), "")
+
+ def test_warning_shows_without_cta_base(self):
+ out = bc.build_cta("", "o", "r", "1", repo_with(), issues=3)
+ self.assertIn("3 architecture issues found", out)
+ self.assertNotIn("http", out) # no links without a proxy base
+
+ def test_links_banner_and_cursor_only(self):
+ out = bc.build_cta("https://x.dev/", "Org", "Repo", "9", repo_with(".cursor"), issues=2)
+ self.assertIn("2 architecture issues found", out)
+ self.assertNotIn("use-workspace", out) # webview/browser tier deferred โ extension-direct
+ self.assertIn("open-in-editor?owner=Org&repo=Repo&pr=9&editor=cursor", out)
+ self.assertIn("use-marketplace?owner=Org&repo=Repo&pr=9", out)
+ self.assertNotIn("Open in VS Code", out) # cursor-only repo
+
+ def test_no_banner_when_zero_issues_and_default_vscode(self):
+ out = bc.build_cta("https://x.dev", "o", "r", "1", repo_with(), issues=0)
+ self.assertNotIn("architecture issue", out)
+ self.assertIn("Open in VS Code", out)
+ self.assertNotIn("Open in Cursor", out)
+
+ def test_both_editors_singular_issue(self):
+ out = bc.build_cta("https://x.dev", "o", "r", "1", repo_with(".vscode", ".cursor"), issues=1)
+ self.assertIn("1 architecture issue found", out) # singular
+ self.assertIn("Open in VS Code", out)
+ self.assertIn("Open in Cursor", out)
+
+ def test_trailing_slash_in_base_is_normalized(self):
+ a = bc.build_cta("https://x.dev/", "o", "r", "1", repo_with())
+ b = bc.build_cta("https://x.dev", "o", "r", "1", repo_with())
+ self.assertNotIn("x.dev//", a)
+ self.assertEqual(a, b)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cb_engine.py b/tests/test_cb_engine.py
new file mode 100644
index 0000000..523db5d
--- /dev/null
+++ b/tests/test_cb_engine.py
@@ -0,0 +1,209 @@
+"""Smoke tests for scripts/cb_engine.py โ verify it calls the engine API correctly,
+using stub modules so no real engine venv is needed."""
+
+import sys
+import tempfile
+import types
+import unittest
+from contextlib import redirect_stderr
+from io import StringIO
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+import cb_engine # noqa: E402
+
+_STUBBED = [
+ "codeboarding_workflows", "codeboarding_workflows.analysis",
+ "diagram_analysis", "diagram_analysis.exceptions",
+ "health", "health.models", "health.runner",
+ "static_analyzer", "static_analyzer.analysis_cache",
+]
+
+
+class _Rec:
+ def __init__(self, ret="OUT", raises=None):
+ self.calls = []
+ self._ret, self._raises = ret, raises
+
+ def __call__(self, *a, **k):
+ self.calls.append(k)
+ if self._raises:
+ raise self._raises("boom")
+ return self._ret
+
+
+def _mod(name, **attrs):
+ m = types.ModuleType(name)
+ for k, v in attrs.items():
+ setattr(m, k, v)
+ sys.modules[name] = m
+ return m
+
+
+class _Base(unittest.TestCase):
+ def tearDown(self):
+ for n in _STUBBED:
+ sys.modules.pop(n, None)
+
+
+class TestAnalysis(_Base):
+ def _install(self, run_full=None, run_incremental=None):
+ class BaselineUnavailableError(Exception):
+ pass
+
+ class IncrementalCacheMissingError(Exception):
+ pass
+
+ analysis = _mod(
+ "codeboarding_workflows.analysis",
+ run_full=run_full or _Rec(),
+ run_incremental=run_incremental or _Rec(),
+ BaselineUnavailableError=BaselineUnavailableError,
+ )
+ pkg = _mod("codeboarding_workflows")
+ pkg.analysis = analysis
+ exc = _mod("diagram_analysis.exceptions", IncrementalCacheMissingError=IncrementalCacheMissingError)
+ da = _mod("diagram_analysis")
+ da.exceptions = exc
+ return analysis, IncrementalCacheMissingError, BaselineUnavailableError
+
+ def test_base_calls_run_full(self):
+ rf = _Rec()
+ self._install(run_full=rf)
+ cb_engine.run_base("/repo", "/out", "myrepo", "rid-base", 2, "abc123")
+ self.assertEqual(len(rf.calls), 1)
+ k = rf.calls[0]
+ self.assertEqual(k["repo_name"], "myrepo")
+ self.assertEqual(str(k["repo_path"]), "/repo")
+ self.assertEqual(k["depth_level"], 2)
+ self.assertEqual(k["source_sha"], "abc123")
+
+ def test_main_parses_depth_as_int(self):
+ rf = _Rec()
+ self._install(run_full=rf)
+ cb_engine.main([
+ "base",
+ "--repo", "/repo",
+ "--out", "/out",
+ "--name", "myrepo",
+ "--run-id", "rid-base",
+ "--depth", "2",
+ "--source-sha", "abc123",
+ ])
+ self.assertEqual(rf.calls[0]["depth_level"], 2)
+
+ def test_main_rejects_invalid_depth(self):
+ for depth in ("0", "4", "x"):
+ with self.subTest(depth=depth):
+ with redirect_stderr(StringIO()):
+ with self.assertRaises(SystemExit):
+ cb_engine.main([
+ "base",
+ "--repo", "/repo",
+ "--out", "/out",
+ "--name", "myrepo",
+ "--run-id", "rid-base",
+ "--depth", depth,
+ "--source-sha", "abc123",
+ ])
+
+ def test_head_uses_incremental(self):
+ ri, rf = _Rec(), _Rec()
+ self._install(run_full=rf, run_incremental=ri)
+ cb_engine.run_head("/repo", "/out", "r", "rid", 1, "base", "head", "head")
+ self.assertEqual(len(ri.calls), 1)
+ self.assertEqual(len(rf.calls), 0) # no fallback
+ self.assertEqual(ri.calls[0]["base_ref"], "base")
+ self.assertEqual(ri.calls[0]["target_ref"], "head")
+
+ def test_head_falls_back_to_full_on_cache_miss(self):
+ analysis, IncMiss, _ = self._install() # install once so the exception class identity matches
+ rf = _Rec()
+ analysis.run_full = rf
+ analysis.run_incremental = _Rec(raises=IncMiss)
+ out = tempfile.mkdtemp()
+ (Path(out) / "stale.json").write_text("{}") # must be wiped before the full run
+ (Path(out) / "health").mkdir()
+ (Path(out) / "health" / "stale.json").write_text("{}")
+ cb_engine.run_head("/repo", out, "r", "rid", 3, "base", "head", "head")
+ self.assertEqual(len(rf.calls), 1) # fell back to full
+ self.assertEqual(rf.calls[0]["depth_level"], 3)
+ self.assertFalse((Path(out) / "stale.json").exists()) # head dir wiped before full
+ self.assertFalse((Path(out) / "health").exists()) # nested stale artifacts wiped too
+
+ def test_head_falls_back_to_full_on_baseline_unavailable(self):
+ analysis, _, BaseUnavail = self._install() # the other warm-start failure must also fall back
+ rf = _Rec()
+ analysis.run_full = rf
+ analysis.run_incremental = _Rec(raises=BaseUnavail)
+ cb_engine.run_head("/repo", tempfile.mkdtemp(), "r", "rid", 1, "base", "head", "head")
+ self.assertEqual(len(rf.calls), 1) # BaselineUnavailableError also triggers the full re-run
+
+
+class TestHealth(_Base):
+ def _install_health(self, report):
+ class Severity:
+ WARNING, CRITICAL = "warning", "critical"
+
+ class _Cache:
+ def __init__(self, artifact_dir, repo_root):
+ pass
+
+ def get(self):
+ return object() # non-None static analysis
+
+ _mod("health.models", Severity=Severity)
+ _mod("health.runner", run_health_checks=lambda sa, repo_name, repo_path: report)
+ _mod("health", )
+ _mod("static_analyzer.analysis_cache", StaticAnalysisCache=_Cache)
+ _mod("static_analyzer", )
+ return Severity
+
+ def test_counts_warning_and_critical(self):
+ Sev = self._install_health(report=None)
+
+ class FG:
+ def __init__(self, sev, n):
+ self.severity, self.entities = sev, list(range(n))
+
+ class CS:
+ finding_groups = [FG(Sev.WARNING, 2), FG(Sev.CRITICAL, 1), FG("info", 5)]
+
+ report = types.SimpleNamespace(check_summaries=[CS()])
+ self._install_health(report=report)
+ self.assertEqual(cb_engine.run_health("/art", "/repo", "r"), 3) # 2 warnings + 1 critical, info ignored
+
+ def test_prefers_written_health_report(self):
+ artifact_dir = Path(tempfile.mkdtemp())
+ report_dir = artifact_dir / "health"
+ report_dir.mkdir()
+ (report_dir / "health_report.json").write_text(
+ """
+ {
+ "check_summaries": [
+ {"finding_groups": [
+ {"severity": "warning", "entities": [{}, {}]},
+ {"severity": "critical", "entities": [{}]},
+ {"severity": "info", "entities": [{}, {}, {}, {}, {}]}
+ ]}
+ ]
+ }
+ """,
+ encoding="utf-8",
+ )
+ self.assertEqual(cb_engine.run_health(str(artifact_dir), "/repo", "r"), 3)
+
+ def test_malformed_health_report_falls_back(self):
+ artifact_dir = Path(tempfile.mkdtemp())
+ report_dir = artifact_dir / "health"
+ report_dir.mkdir()
+ (report_dir / "health_report.json").write_text("[]", encoding="utf-8")
+ self.assertEqual(cb_engine.run_health(str(artifact_dir), "/repo", "r"), 0)
+
+ def test_missing_module_yields_zero(self):
+ # No health.* modules installed -> import fails -> 0, never raises.
+ self.assertEqual(cb_engine.run_health("/art", "/repo", "r"), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_diff_to_mermaid.py b/tests/test_diff_to_mermaid.py
new file mode 100644
index 0000000..1b14daf
--- /dev/null
+++ b/tests/test_diff_to_mermaid.py
@@ -0,0 +1,233 @@
+"""Unit tests for scripts/diff_to_mermaid.py โ diff logic + Mermaid rendering."""
+
+import re
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+import diff_to_mermaid as dm # noqa: E402
+
+
+def comp(name, files=None, cid=None, subs=None, subrels=None):
+ c = {
+ "name": name,
+ "component_id": cid or name,
+ "file_methods": [{"file_path": f, "methods": m} for f, m in (files or {}).items()],
+ }
+ if subs is not None:
+ c["components"] = subs
+ if subrels is not None:
+ c["components_relations"] = subrels
+ return c
+
+
+def rel(src, dst, label="calls"):
+ return {"src_name": src, "dst_name": dst, "src_id": src, "dst_id": dst, "relation": label}
+
+
+def linkstyle_indices_in_range(text):
+ n_edges = text.count("-->")
+ idxs = [int(x) for m in re.finditer(r"linkStyle ([\d,]+)", text) for x in m.group(1).split(",")]
+ return all(i < n_edges for i in idxs)
+
+
+class TestDiff(unittest.TestCase):
+ def test_added_modified_deleted_unchanged(self):
+ base = {"components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("D")], "components_relations": []}
+ head = {"components": [comp("A", {"a.py": ["f", "g"]}), comp("B"), comp("C")], "components_relations": []}
+ status = {c["name"]: c["diff_status"] for c in dm.build_diff(base, head)["components"]}
+ self.assertEqual(status["A"], "modified") # method added inside the component
+ self.assertEqual(status["B"], "unchanged")
+ self.assertEqual(status["C"], "added")
+ self.assertEqual(status["D"], "deleted")
+
+ def test_structural_change_is_modified(self):
+ base = {"components": [comp("A", {"a.py": ["f"]})], "components_relations": []}
+ head = {"components": [comp("A", {"a.py": ["f"], "b.py": ["h"]})], "components_relations": []}
+ self.assertEqual(dm.build_diff(base, head)["components"][0]["diff_status"], "modified")
+
+ def test_rename_is_add_plus_delete(self):
+ base = {"components": [comp("Old")], "components_relations": []}
+ head = {"components": [comp("New")], "components_relations": []}
+ status = {c["name"]: c["diff_status"] for c in dm.build_diff(base, head)["components"]}
+ self.assertEqual(status, {"New": "added", "Old": "deleted"})
+
+ def test_relation_modified_on_label_change(self):
+ base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]}
+ head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "calls")]}
+ self.assertEqual(dm.build_diff(base, head)["components_relations"][0]["diff_status"], "modified")
+
+ def test_relation_added_and_deleted(self):
+ base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B")]}
+ head = {"components": [comp("A"), comp("B")], "components_relations": [rel("B", "A")]}
+ statuses = sorted(r["diff_status"] for r in dm.build_diff(base, head)["components_relations"])
+ self.assertEqual(statuses, ["added", "deleted"])
+
+ def test_parallel_relation_deletion_is_not_label_modification(self):
+ base = {
+ "components": [comp("A"), comp("B")],
+ "components_relations": [rel("A", "B", "uses"), rel("A", "B", "publishes")],
+ }
+ head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]}
+ statuses = sorted(r["diff_status"] for r in dm.build_diff(base, head)["components_relations"])
+ self.assertEqual(statuses, ["deleted", "unchanged"])
+
+
+class TestRender(unittest.TestCase):
+ def _diff(self):
+ base = {"components": [comp("A"), comp("B"), comp("Gone")], "components_relations": [rel("A", "B"), rel("A", "Gone")]}
+ head = {"components": [comp("A", {"x.py": ["f"]}), comp("B"), comp("New")], "components_relations": [rel("A", "B"), rel("A", "New")]}
+ return dm.build_diff(base, head)
+
+ def test_flat_default_has_no_subgraphs(self):
+ text, _ = dm.render_mermaid(self._diff(), render_depth=1)
+ self.assertNotIn("subgraph", text)
+ for cls in ("added", "modified", "deleted"):
+ self.assertIn(f"classDef {cls}", text)
+ self.assertTrue(linkstyle_indices_in_range(text))
+
+ def test_nested_subgraphs_balanced_and_valid(self):
+ base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2")])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1"), comp("c3")], subrels=[rel("c1", "c3")])], "components_relations": []}
+ text, _ = dm.render_mermaid(dm.build_diff(base, head), render_depth=2)
+ sg = sum(1 for line in text.splitlines() if line.strip().startswith("subgraph "))
+ en = sum(1 for line in text.splitlines() if line.strip() == "end")
+ self.assertGreater(sg, 0)
+ self.assertEqual(sg, en)
+ self.assertTrue(linkstyle_indices_in_range(text))
+
+ def test_render_depth_caps_at_data_depth(self):
+ base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
+ diff = dm.build_diff(base, head)
+ deep = dm.render_mermaid(diff, render_depth=5)[0]
+ two = dm.render_mermaid(diff, render_depth=2)[0]
+ self.assertEqual(deep, two) # no level-3 data, so depth 5 == depth 2
+
+ def test_label_escaping(self):
+ head = {"components": [comp('A "q" #h'), comp("B")], "components_relations": []}
+ base = {"components": [comp("B")], "components_relations": []}
+ text, _ = dm.render_mermaid(dm.build_diff(base, head), render_depth=1)
+ self.assertIn("#quot;", text)
+ self.assertIn("#35;", text)
+
+ def test_label_escaping_brackets_break_chars(self):
+ # `]` / `(` / `&` would break GitHub's renderer if left raw.
+ self.assertEqual(dm._esc("Has]Bracket"), "Has#93;Bracket")
+ self.assertEqual(dm._esc("f(x)"), "f#40;x#41;")
+ self.assertEqual(dm._esc("A & B"), "A #amp; B")
+ head = {"components": [comp("Weird]Name(x)"), comp("B")], "components_relations": []}
+ base = {"components": [comp("B")], "components_relations": []}
+ text, _ = dm.render_mermaid(dm.build_diff(base, head))
+ self.assertNotIn("]Name", text) # no raw ] inside a label
+ self.assertIn("#93;", text)
+
+ def test_esc_strips_newlines(self):
+ # A raw newline/CR in a label breaks the whole Mermaid block.
+ self.assertNotIn("\n", dm._esc("line1\nline2"))
+ self.assertNotIn("\r", dm._esc("a\r\nb"))
+
+ def test_truncate_caps_long_edge_label_with_ellipsis(self):
+ out = dm._truncate("x" * 60)
+ self.assertLessEqual(len(out), dm._EDGE_LABEL_MAX)
+ self.assertTrue(out.endswith("โฆ"))
+ self.assertEqual(dm._truncate("short"), "short") # under the cap: unchanged
+
+ def test_changed_flag_relation_only(self):
+ # A label-only relation change leaves n_changed=0 but must report changed=True.
+ base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]}
+ head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "calls")]}
+ text, meta = dm.render_mermaid(dm.build_diff(base, head))
+ self.assertEqual(meta["n_changed"], 0)
+ self.assertTrue(meta["changed"])
+ self.assertIsNotNone(text)
+
+ def test_changed_flag_false_when_identical(self):
+ d = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B")]}
+ _, meta = dm.render_mermaid(dm.build_diff(d, d))
+ self.assertEqual(meta["n_changed"], 0)
+ self.assertFalse(meta["changed"])
+
+ def test_changed_flag_counts_nested(self):
+ base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]})], subrels=[])], "components_relations": []}
+ _, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2)
+ self.assertEqual(meta["n_changed"], 1) # the nested child counts
+ self.assertTrue(meta["changed"])
+
+ def test_nested_method_change_highlights_collapsed_parent(self):
+ base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]})], subrels=[])], "components_relations": []}
+ text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1)
+ self.assertEqual(meta["n_changed"], 1)
+ self.assertIn("class n_P modified;", text)
+
+ def test_nested_relation_change_highlights_collapsed_parent(self):
+ base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "uses")])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "calls")])], "components_relations": []}
+ text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1)
+ self.assertEqual(meta["n_changed"], 0)
+ self.assertTrue(meta["changed"])
+ self.assertIn("class n_P modified;", text)
+
+ def test_changed_only_keeps_nested_change(self):
+ base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
+ head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]}), comp("c2")], subrels=[])], "components_relations": []}
+ text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True)
+ self.assertIsNotNone(text)
+ self.assertTrue(meta["changed"])
+ self.assertFalse(meta["truncated"])
+ self.assertIn("subgraph n_P", text)
+ self.assertIn("class n_c1 modified;", text)
+ self.assertNotIn('n_c2["c2"]', text)
+
+ def test_changed_only_prunes_unchanged_children_of_modified_parent(self):
+ base = {"components": [comp("P", {"p.py": ["old"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
+ head = {"components": [comp("P", {"p.py": ["old", "new"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
+ text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True)
+ self.assertIsNotNone(text)
+ self.assertTrue(meta["changed"])
+ self.assertIn('n_P["P"]', text)
+ self.assertNotIn('n_c1["c1"]', text)
+ self.assertNotIn('n_c2["c2"]', text)
+
+ def test_changed_only_is_not_auto_truncated(self):
+ text, meta = dm.render_mermaid(self._diff(), render_depth=1, changed_only=True)
+ self.assertIsNotNone(text)
+ self.assertFalse(meta["truncated"])
+ self.assertTrue(meta["changed_only"])
+ self.assertTrue(meta["requested_changed_only"])
+
+ def test_auto_truncation_reports_rendered_changed_only(self):
+ base = {
+ "components": [comp("A"), comp("B"), comp("C")],
+ "components_relations": [rel("B", "C", "uses"), rel("C", "B", "uses")],
+ }
+ head = {
+ "components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("C")],
+ "components_relations": [rel("B", "C", "uses"), rel("C", "B", "uses")],
+ }
+ old = dm.MAX_EDGES
+ try:
+ dm.MAX_EDGES = 1
+ text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1)
+ finally:
+ dm.MAX_EDGES = old
+ self.assertIsNotNone(text)
+ self.assertTrue(meta["truncated"])
+ self.assertTrue(meta["changed_only"])
+ self.assertFalse(meta["requested_changed_only"])
+
+ def test_empty_returns_none(self):
+ text, meta = dm.render_mermaid({"components": [], "components_relations": []})
+ self.assertIsNone(text)
+ self.assertEqual(meta["n_nodes"], 0)
+
+ def test_no_edge_labels(self):
+ text, _ = dm.render_mermaid(self._diff(), render_depth=1, edge_labels=False)
+ self.assertNotIn(' -- "', text)
+
+
+if __name__ == "__main__":
+ unittest.main()